From 27a3b2b5edad8c41e5a00ea82b9902a9df895239 Mon Sep 17 00:00:00 2001 From: marcus Date: Sun, 14 Jun 2026 11:21:50 +0200 Subject: [PATCH 01/24] =?UTF-8?q?feat:=20Plugin=20API=20Completeness=20?= =?UTF-8?q?=E2=80=94=20file=20mutations,=20prompt=20editor,=20and=20attach?= =?UTF-8?q?ment=20APIs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorkspaceFiles: writeFile, deleteFile, moveFile with path safety - writeFile: text/binary, auto-create dirs, overwrite option - deleteFile: idempotent, uses lstat (removes symlinks not targets) - moveFile: unix mv semantics, overwrite defaults to false - All mutations auto-refreshFiles() in File Explorer - Symlink escape prevention via realpath(dirname) check - PluginPromptEditor: insertText, getText, getSelection, onPaste, onKeyDown, focus - Uses CM6 EditorView.domEventHandlers() via Compartment (not raw DOM) - Handlers registered before mount are preserved and applied on mount - First-to-consume-wins ordering for multi-plugin scenarios - insertText replaces selection (not inserts after) - PluginAttachments: insertFileReference, getAttachedFiles, removeFileReference - insertFileReference validates file exists before inserting @path - Does not auto-focus editor (unlike prompt.insertText) - @file regex requires file extension to avoid matching emails - Server endpoints: PUT /file, DELETE /file, POST /file/move - All work for local and federated machines - Tests: 31 unit tests, 9 integration tests, 5 client tests - Docs: 3 new sections in plugins.md --- .changeset/plugin-api-completeness.md | 5 + .gitignore | 4 + docs/plugins.md | 175 +++++++++++- package.json | 1 + scripts/postinstall.mjs | 35 +++ src/client/src/api.ts | 2 +- src/client/src/api/clients.test.ts | 63 ++++ src/client/src/api/clients.ts | 31 +- src/client/src/api/http.ts | 2 +- src/client/src/api/parsers.ts | 30 +- src/client/src/components/PiWebApp.ts | 103 ++++++- src/client/src/components/PromptEditor.ts | 61 ++++ src/client/src/plugins/registry.test.ts | 46 ++- src/client/src/plugins/types.ts | 22 +- src/plugin-api.ts | 55 +++- src/server/app.test.ts | 268 +++++++++++++++++- src/server/piWebPluginService.test.ts | 14 +- src/server/workspaceExplorerRoutes.ts | 43 ++- .../workspaces/fileContentService.test.ts | 248 +++++++++++++++- src/server/workspaces/fileContentService.ts | 105 ++++++- src/server/workspaces/pathSafety.ts | 4 +- src/shared/apiTypes.ts | 29 ++ 22 files changed, 1314 insertions(+), 32 deletions(-) create mode 100644 .changeset/plugin-api-completeness.md create mode 100644 scripts/postinstall.mjs diff --git a/.changeset/plugin-api-completeness.md b/.changeset/plugin-api-completeness.md new file mode 100644 index 0000000..0bff30c --- /dev/null +++ b/.changeset/plugin-api-completeness.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Add file mutation, prompt editor, and attachment APIs to the plugin system, completing the stable workspace interaction surface. diff --git a/.gitignore b/.gitignore index 052ea5e..c74fccc 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ dist/ # Local runtime attachment uploads (created by the chat composer "save to folder" mode). .pi-web/ + +# Local paste upload directory and temporary working docs. +.pi-paste/ +docs/tmp/ diff --git a/docs/plugins.md b/docs/plugins.md index 0743670..6706641 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,74 @@ 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` 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`. | +| `onPaste(handler)` | Register a paste handler scoped to the prompt editor. Returns an unsubscribe function. Handler returns `true` to consume the event. | +| `onKeyDown(handler)` | Register a keydown handler scoped to the prompt editor. Returns an unsubscribe function. Handler returns `true` to consume the event. | +| `focus()` | Focus the prompt editor. | + +Usage: + +```js +// Insert text at cursor +context.prompt.insertText("@file.txt"); + +// Intercept paste events +const unsub = context.prompt.onPaste((event) => { + const items = event.clipboardData?.items; + if (items?.[0]?.type.startsWith("image/")) { + // Handle image paste + return true; // consume the event + } + return false; +}); +// Later, when the plugin no longer needs the handler: +unsub(); +``` + +Handlers registered via `onPaste` and `onKeyDown` are scoped to the prompt editor using CodeMirror's extension system. They run in registration order: if a handler returns `true` to consume the event, subsequent plugin handlers for the same event will not run (first-to-consume-wins). Register handlers early if your plugin needs to intercept events before others. Handlers are automatically cleaned up when the editor is destroyed. Call the returned unsubscribe function when your plugin no longer needs the handler. Do not use `document.addEventListener` for prompt interactions — raw DOM listeners are not scoped to the editor, can leak memory, and may break across PI WEB upgrades. + +`focusPrompt()` on `PluginRuntimeContext` is kept for backward compatibility. `prompt.focus()` is the preferred path. + +### Attachment API + +The `attachments` helper on `PluginRuntimeContext` manages file references in the chat prompt: + +| Method | Description | +| --- | --- | +| `insertFileReference(path)` | Validate a workspace file exists and insert `@path` at the cursor. Returns the reference string. Throws if no workspace is selected or the file does not exist. | +| `getAttachedFiles()` | Returns an array of file paths currently referenced in the prompt (without the `@` prefix). | +| `removeFileReference(path)` | Remove the first occurrence of `@path` from the prompt. | + +Usage: + +```js +// Save a file, then attach it +const result = await context.files.writeFile(".pi-paste/screenshot.png", imageBytes); +const ref = await context.attachments.insertFileReference(result.path); +// ref is "@.pi-paste/screenshot.png" + +// Check what's attached +const files = context.attachments.getAttachedFiles(); +// files is [".pi-paste/screenshot.png"] + +// Remove it +context.attachments.removeFileReference(".pi-paste/screenshot.png"); +``` + +`insertFileReference` validates the file exists using `files.readFile()` before inserting the `@path` reference. Use `files.writeFile()` to create the file first, then `attachments.insertFileReference()` to attach it. + +`getAttachedFiles()` uses a pattern that matches `@path/to/file.ext` — it requires a file extension (`.something`) to avoid matching email addresses like `user@example.com`. Paths are returned without the `@` prefix. + +`removeFileReference(path)` removes the first occurrence of `@path` in the prompt text. If the path is not found, it does nothing. + #### 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,6 +590,9 @@ 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; }; terminal: { open(options?: { terminalId?: string }): void; @@ -539,7 +611,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`, `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). 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 +679,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 +689,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 +828,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 +966,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`, `prompt`, and `attachments`. 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. @@ -811,13 +976,13 @@ If you are an AI agent building or editing a PI WEB plugin, follow this checklis Check discovery: ```bash -curl http://127.0.0.1:8504/pi-web-plugins/manifest.json +curl http://localhost:8504/pi-web-plugins/manifest.json ``` Check a plugin module: ```bash -curl http://127.0.0.1:8504/pi-web-plugins/my-plugin/pi-web-plugin.js +curl http://localhost:8504/pi-web-plugins/my-plugin/pi-web-plugin.js ``` Common issues: diff --git a/package.json b/package.json index 11e045c..2783bc6 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "prepublishOnly": "npm run verify", "publish:npm": "npm publish --access public", "prepare": "node scripts/install-git-hooks.mjs", + "postinstall": "node scripts/postinstall.mjs", "changeset": "changeset", "release:version": "changeset version", "changelog:status": "changeset status" diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs new file mode 100644 index 0000000..66b023e --- /dev/null +++ b/scripts/postinstall.mjs @@ -0,0 +1,35 @@ +import { chmodSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +/** + * node-pty 1.1.0 ships macOS prebuilds with `spawn-helper` files at 644 + * instead of 755, causing `posix_spawnp failed` at runtime. This script + * fixes permissions after install on Darwin platforms. + */ +function fixNodePtyPermissions() { + if (process.platform === "win32") return; + const prebuildsDir = join("node_modules", "node-pty", "prebuilds"); + let entries; + try { + entries = readdirSync(prebuildsDir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const helper = join(prebuildsDir, entry.name, "spawn-helper"); + let stats; + try { + stats = statSync(helper); + } catch { + continue; + } + if (!stats.isFile()) continue; + // 0o100 = regular file, 0o111 = owner/group/other execute + if ((stats.mode & 0o111) === 0) { + chmodSync(helper, 0o755); + } + } +} + +fixNodePtyPermissions(); diff --git a/src/client/src/api.ts b/src/client/src/api.ts index 63d4243..c6cbeb7 100644 --- a/src/client/src/api.ts +++ b/src/client/src/api.ts @@ -1,3 +1,3 @@ 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 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, Project, PromptAttachment, QueuedSessionMessage, RealtimeEvent, RunTerminalCommandInput, SavedPromptAttachment, SessionActivity, SessionInfo, SessionModel, SessionRef, SessionStatus, SlashCommand, SessionUiEvent, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace, WorkspaceActivity, WorkspaceActivityResponse, WorkspaceActivityUiEvent } from "../../shared/apiTypes"; diff --git a/src/client/src/api/clients.test.ts b/src/client/src/api/clients.test.ts index 90b865a..659ba47 100644 --- a/src/client/src/api/clients.test.ts +++ b/src/client/src/api/clients.test.ts @@ -147,6 +147,69 @@ describe("machine-scoped terminal command-run API", () => { }); }); +describe("workspace file write API", () => { + it("sends text content with Content-Type text/plain", async () => { + const fetchMock = stubJsonFetch({ path: "hello.txt", size: 11, modifiedAt: "2026-06-10T00:00:00.000Z", created: true }); + + await workspacesApi.writeWorkspaceFile("p 1", "w/1", "hello.txt", "hello world"); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchCall(fetchMock, 0); + expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=hello.txt"); + expect(init?.method).toBe("PUT"); + expect(new Headers(init?.headers).get("content-type")).toBe("text/plain"); + }); + + it("sends binary content with Content-Type application/octet-stream", async () => { + const fetchMock = stubJsonFetch({ path: "image.png", size: 4, modifiedAt: "2026-06-10T00:00:00.000Z", created: true }); + const binary = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + + await workspacesApi.writeWorkspaceFile("p 1", "w/1", "image.png", binary); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchCall(fetchMock, 0); + expect(url).toBe("/api/machines/local/projects/p%201/workspaces/w%2F1/file?path=image.png"); + expect(init?.method).toBe("PUT"); + expect(new Headers(init?.headers).get("content-type")).toBe("application/octet-stream"); + }); + + it("sends createDirs and overwrite query parameters", async () => { + const fetchMock = stubJsonFetch({ path: "config/new.json", size: 10, modifiedAt: "2026-06-10T00:00:00.000Z", created: true }); + + await workspacesApi.writeWorkspaceFile("p 1", "w/1", "config/new.json", "{\"a\":1}", { createDirs: false, overwrite: false }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url] = fetchCall(fetchMock, 0); + expect(url).toContain("createDirs=false"); + expect(url).toContain("overwrite=false"); + }); + + it("parses WriteWorkspaceFileResponse correctly", async () => { + const fetchMock = stubJsonFetch({ path: "output/result.txt", size: 42, modifiedAt: "2026-06-10T12:00:00.000Z", created: true }); + + const result = await workspacesApi.writeWorkspaceFile("p 1", "w/1", "output/result.txt", "content"); + + expect(fetchMock).toHaveBeenCalledOnce(); + + expect(result).toEqual({ + path: "output/result.txt", + size: 42, + modifiedAt: "2026-06-10T12:00:00.000Z", + created: true, + }); + }); + + it("routes through machine prefix for remote machines", async () => { + const fetchMock = stubJsonFetch({ path: "file.txt", size: 5, modifiedAt: "2026-06-10T00:00:00.000Z", created: false }); + + await workspacesApi.writeWorkspaceFile("p 1", "w/1", "file.txt", "data", undefined, "remote a"); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url] = fetchCall(fetchMock, 0); + expect(url).toContain("/api/machines/remote%20a/"); + }); +}); + type FetchLike = (url: string | URL | Request, init?: RequestInit) => Promise; type FetchMock = ReturnType>; diff --git a/src/client/src/api/clients.ts b/src/client/src/api/clients.ts index 628cdd5..bd36da7 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/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.ts b/src/client/src/api/parsers.ts index 23250cd..37b4a00 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 { @@ -336,6 +336,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"); diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 43dfa04..95493d3 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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, PluginAttachments, 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"; @@ -1198,6 +1198,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; + }, }; } @@ -1369,9 +1384,95 @@ 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 } }); + }, + 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) }; + }, + onPaste: (handler) => { + if (!this.promptEditor) { + console.warn("[pi-web] prompt.onPaste() called but prompt editor is not available. Handler will not be registered."); + return () => undefined; + } + const id = this.promptEditor.addPluginHandler("paste", handler); + return () => { this.promptEditor?.removePluginHandler(id); }; + }, + onKeyDown: (handler) => { + if (!this.promptEditor) { + console.warn("[pi-web] prompt.onKeyDown() called but prompt editor is not available. Handler will not be registered."); + return () => undefined; + } + const id = this.promptEditor.addPluginHandler("keydown", handler); + return () => { this.promptEditor?.removePluginHandler(id); }; + }, + focus: () => { + this.promptEditor?.focusInput(); + }, + }; + } + + private createPluginAttachments(): PluginAttachments { + const workspace = this.state.selectedWorkspace; + const machineId = selectedMachineId(this.state); + return { + insertFileReference: async (path: string) => { + if (!workspace) throw new Error("No workspace selected"); + try { + await workspacesApi.workspaceFile(workspace.projectId, workspace.id, path, machineId); + } catch { + throw new Error(`File not found in workspace: ${path}`); + } + const reference = `@${path}`; + const editor = this.promptEditor?.view; + if (editor) { + const sel = editor.state.selection.main; + editor.dispatch({ changes: { from: sel.from, to: sel.to, insert: reference } }); + } + return reference; + }, + getAttachedFiles: () => { + const text = this.promptEditor?.view?.state.doc.toString() ?? ""; + const matches: string[] = []; + const atFilePattern = /@([\w./\-\u00C0-\u024F]+(?:\.[\w]+))/g; + let m: RegExpExecArray | null; + while ((m = atFilePattern.exec(text)) !== null) { + if (m[1] !== undefined) matches.push(m[1]); + } + return matches; + }, + removeFileReference: (path: string) => { + const editor = this.promptEditor?.view; + if (!editor) return; + const text = editor.state.doc.toString(); + const reference = `@${path}`; + const index = text.indexOf(reference); + if (index === -1) return; + editor.dispatch({ + changes: { from: index, to: index + reference.length, insert: "" }, + }); + }, + }; + } + private createPluginRuntimeContext(): PluginRuntimeContext { const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({ state: this.state, + prompt: this.createPromptEditor(), + attachments: this.createPluginAttachments(), 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 3d8eb40..ff0d7ee 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -27,6 +27,9 @@ interface PendingAttachment { size: number; } +type PluginPasteHandler = (event: ClipboardEvent) => boolean; +type PluginKeydownHandler = (event: KeyboardEvent) => boolean; + @customElement("prompt-editor") export class PromptEditor extends LitElement { @property({ type: Boolean }) disabled = false; @@ -56,6 +59,10 @@ export class PromptEditor extends LitElement { private editor: EditorView | undefined; private readonly editableCompartment = new Compartment(); private readonly readOnlyCompartment = new Compartment(); + private readonly pluginHandlersCompartment = new Compartment(); + private nextHandlerId = 0; + private readonly pasteHandlers = new Map(); + private readonly keydownHandlers = new Map(); protected override willUpdate(changed: PropertyValues) { if (!changed.has("sessionId") && !changed.has("machineId")) return; @@ -79,6 +86,8 @@ export class PromptEditor extends LitElement { } override disconnectedCallback(): void { + this.pasteHandlers.clear(); + this.keydownHandlers.clear(); this.editor?.destroy(); this.editor = undefined; super.disconnectedCallback(); @@ -114,6 +123,57 @@ export class PromptEditor extends LitElement { this.editor?.focus(); } + /** Get the underlying CM6 EditorView, or undefined if not yet mounted. */ + get view(): EditorView | undefined { + return this.editor; + } + + /** Register a plugin event handler. Returns a numeric ID for later removal. */ + addPluginHandler(...args: ["paste", PluginPasteHandler] | ["keydown", PluginKeydownHandler]): number { + const [type, handler] = args; + const id = this.nextHandlerId++; + if (type === "paste") { + this.pasteHandlers.set(id, handler); + } else { + this.keydownHandlers.set(id, handler); + } + this.reconfigurePluginHandlers(); + return id; + } + + /** Remove a previously registered plugin event handler by ID. */ + removePluginHandler(id: number): void { + this.pasteHandlers.delete(id); + this.keydownHandlers.delete(id); + this.reconfigurePluginHandlers(); + } + + private reconfigurePluginHandlers(): void { + const extension = this.buildPluginHandlersExtension(); + this.editor?.dispatch({ + effects: this.pluginHandlersCompartment.reconfigure(extension), + }); + } + + private buildPluginHandlersExtension() { + const pasteHandlers = [...this.pasteHandlers.values()]; + const keydownHandlers = [...this.keydownHandlers.values()]; + return EditorView.domEventHandlers({ + paste(event) { + for (const handler of pasteHandlers) { + if (handler(event)) return true; + } + return false; + }, + keydown(event) { + for (const handler of keydownHandlers) { + if (handler(event)) return true; + } + return false; + }, + }); + } + private renderCompactStatus() { const status = this.status; if (status === undefined) return null; @@ -223,6 +283,7 @@ export class PromptEditor extends LitElement { placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"), this.editableCompartment.of(EditorView.editable.of(!this.disabled)), this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)), + this.pluginHandlersCompartment.of(this.buildPluginHandlersExtension()), EditorView.updateListener.of((update) => { if (update.docChanged) this.updateDraft(update.state.doc.toString()); }), diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 7be8258..0c0d538 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -1,6 +1,6 @@ import { html } from "lit"; import { describe, expect, it, vi } from "vitest"; -import type { FileContentResponse, SessionInfo, SessionStatus, Workspace } from "../api"; +import type { DeleteWorkspaceFileResponse, FileContentResponse, MoveWorkspaceFileResponse, SessionInfo, SessionStatus, WriteWorkspaceFileResponse, Workspace } from "../api"; import { initialAppState, type AppState } from "../appState"; import { markCachedNewSessionInfo } from "../cachedNewSessions"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; @@ -14,6 +14,19 @@ function createContext(statePatch: Partial = {}) { const calls: string[] = []; const context: PluginRuntimeContext = { state: { ...initialAppState(), ...statePatch }, + prompt: { + insertText: vi.fn(), + getText: vi.fn(() => ""), + getSelection: vi.fn(() => null), + onPaste: vi.fn(() => vi.fn()), + onKeyDown: vi.fn(() => vi.fn()), + focus: vi.fn(() => { calls.push("prompt.focus"); }), + }, + attachments: { + insertFileReference: vi.fn(), + getAttachedFiles: vi.fn(() => []), + removeFileReference: vi.fn(), + }, piWebUnstable: { terminalCommandRuns: { runCommand: vi.fn(), @@ -335,7 +348,7 @@ describe("PluginRegistry", () => { context.host.requestRender(); return [{ type: "text", text: context.machine.id }]; }); - const context = createWorkspaceLabelContext("remote-1", workspace, { files: { readFile }, host: { requestRender } }); + const context = createWorkspaceLabelContext("remote-1", workspace, { files: { readFile, writeFile: vi.fn(() => Promise.resolve(testWriteFileResponse())), deleteFile: vi.fn(() => Promise.resolve(testDeleteFileResponse())), moveFile: vi.fn(() => Promise.resolve(testMoveFileResponse())) }, host: { requestRender } }); registry.register({ id: "example", @@ -545,7 +558,7 @@ function testWorkspace(patch: Partial = {}): Workspace { } function createWorkspaceLabelContext(machineId: string, workspace = testWorkspace(), helpers: Partial> = {}): WorkspaceLabelContext { - const files: WorkspaceFiles = helpers.files ?? { readFile: vi.fn(() => Promise.resolve(testFileContent())) }; + const files: WorkspaceFiles = helpers.files ?? { readFile: vi.fn(() => Promise.resolve(testFileContent())), writeFile: vi.fn(() => Promise.resolve(testWriteFileResponse())), deleteFile: vi.fn(() => Promise.resolve(testDeleteFileResponse())), moveFile: vi.fn(() => Promise.resolve(testMoveFileResponse())) }; const host: WorkspaceHost = helpers.host ?? { requestRender: vi.fn() }; return { machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" }, @@ -562,7 +575,7 @@ function createWorkspacePanelContext(machineId: string): WorkspacePanelContext { machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" }, workspace, state: { ...initialAppState(), selectedMachine: testMachine(machineId) }, - files: { readFile: vi.fn() }, + files: { readFile: vi.fn(), writeFile: vi.fn(), deleteFile: vi.fn(), moveFile: vi.fn() }, terminal: { open: vi.fn(), runCommand: vi.fn() }, host: { requestRender: vi.fn() }, fileTree: [], @@ -613,6 +626,31 @@ function testStatus(patch: Partial = {}): SessionStatus { }; } +function testWriteFileResponse(path = "README.md"): WriteWorkspaceFileResponse { + return { + path, + size: 0, + modifiedAt: "2026-05-20T00:00:00.000Z", + created: true, + }; +} + +function testDeleteFileResponse(path = "README.md"): DeleteWorkspaceFileResponse { + return { + path, + existed: true, + }; +} + +function testMoveFileResponse(fromPath = "old.txt", toPath = "new.txt"): MoveWorkspaceFileResponse { + return { + fromPath, + toPath, + size: 0, + modifiedAt: "2026-05-20T00:00:00.000Z", + }; +} + function testMachine(id: string) { return { id, name: id, kind: id === "local" ? "local" as const : "remote" as const, createdAt: "2026-05-20T00:00:00.000Z", updatedAt: "2026-05-20T00:00:00.000Z" }; } diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index 2b44063..d7cf32e 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -1,6 +1,6 @@ import type { TemplateResult } from "lit"; import type { AppAction } from "../actions"; -import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, Workspace } from "../api"; +import type { DeleteWorkspaceFileResponse, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Machine, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, RunTerminalCommandInput, TerminalCommandRun, TerminalCommandRunFilter, TerminalCommandRunHandle, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, Workspace } from "../api"; import type { AppState } from "../appState"; import type { SettingsSection } from "../settingsRoute"; import type { LocalContributionId, PluginId, QualifiedContributionId } from "./ids"; @@ -50,6 +50,9 @@ export interface PluginMachine { export interface WorkspaceFiles { 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; } export interface WorkspaceHost { @@ -83,8 +86,25 @@ export interface TerminalCommandRunsInternalRuntime { open(options?: { terminalId?: string | undefined }): void; } +export interface PluginPromptEditor { + insertText(text: string): void; + getText(): string; + getSelection(): { start: number; end: number; text: string } | null; + onPaste(handler: (event: ClipboardEvent) => boolean): () => void; + onKeyDown(handler: (event: KeyboardEvent) => boolean): () => void; + focus(): void; +} + +export interface PluginAttachments { + insertFileReference(path: string): Promise; + getAttachedFiles(): string[]; + removeFileReference(path: string): void; +} + export interface PluginRuntimeContext { state: AppState; + prompt: PluginPromptEditor; + attachments: PluginAttachments; piWebUnstable?: PiWebUnstableRuntimeContext; openActionPalette: () => void; focusPrompt: () => void; diff --git a/src/plugin-api.ts b/src/plugin-api.ts index 971e46c..3c195bb 100644 --- a/src/plugin-api.ts +++ b/src/plugin-api.ts @@ -1,5 +1,5 @@ import type { TemplateResult } from "lit"; -import type { FileContentResponse, MachineKind, PiWebStatusResponse, TerminalCommandRunHandle } from "./shared/apiTypes.js"; +import type { FileContentResponse, MachineKind, PiWebStatusResponse, TerminalCommandRunHandle, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse, DeleteWorkspaceFileResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse } from "./shared/apiTypes.js"; export type { FileContentMediaType, @@ -20,6 +20,11 @@ export type { TerminalCommandRunFilter, TerminalCommandRunHandle, TerminalCommandRunStatus, + WriteWorkspaceFileOptions, + WriteWorkspaceFileResponse, + DeleteWorkspaceFileResponse, + MoveWorkspaceFileOptions, + MoveWorkspaceFileResponse, } from "./shared/apiTypes.js"; export type PluginId = string; @@ -67,8 +72,46 @@ export interface PluginRuntimeState { piWebStatus?: PiWebStatusResponse; } +export interface PluginPromptEditor { + /** Insert text at the current cursor position. Replaces any selection. + * If the editor is not focused, focuses it first. + * No-op if the editor is not mounted. */ + insertText(text: string): void; + /** Get the current prompt text content. Returns "" if the editor is not mounted. */ + getText(): string; + /** Get the current selection range, or null if no selection or editor not mounted. */ + getSelection(): { start: number; end: number; text: string } | null; + /** Register a paste event handler scoped to the prompt editor. + * Handlers run in registration order; first handler returning true consumes the event. + * Returns an unsubscribe function. No-op if the editor is not mounted. */ + onPaste(handler: (event: ClipboardEvent) => boolean): () => void; + /** Register a keydown handler scoped to the prompt editor. + * Handlers run in registration order; first handler returning true consumes the event. + * Returns unsubscribe. No-op if the editor is not mounted. */ + onKeyDown(handler: (event: KeyboardEvent) => boolean): () => void; + /** Focus the prompt editor. No-op if not mounted. */ + focus(): void; +} + +export interface PluginAttachments { + /** Insert a file reference at the current cursor position in the chat prompt. + * Validates that the file exists in the workspace before insertion. + * Does not auto-focus the editor (unlike prompt.insertText). Use prompt.focus() first if needed. + * @throws Error if no workspace is selected or the file doesn't exist + * Returns the canonical @file reference string (e.g., "@path/to/file.png"). */ + insertFileReference(path: string): Promise; + /** List currently attached file paths in the prompt. Returns paths without the @ prefix. + * Best-effort heuristic: matches @path/to/file.ext patterns. May match email-like patterns; + * use insertFileReference() for guaranteed-accurate insertion. */ + getAttachedFiles(): string[]; + /** Remove a file reference from the prompt by path. Removes the first occurrence of @path. */ + removeFileReference(path: string): void; +} + export interface PluginRuntimeContext { state: PluginRuntimeState; + prompt: PluginPromptEditor; + attachments: PluginAttachments; openActionPalette: () => void; focusPrompt: () => void; addProject: () => void | Promise; @@ -109,7 +152,17 @@ export interface Workspace { } export interface WorkspaceFiles { + /** Read a file from the workspace. Works for local and federated machines. */ readFile(path: string): Promise; + /** Write content to a workspace file. Creates intermediate directories by default. + * Works for local and federated machines. Auto-refreshes the file explorer after success. */ + writeFile(path: string, content: string | Uint8Array, options?: WriteWorkspaceFileOptions): Promise; + /** Delete a file from the workspace. Idempotent — returns { existed: false } if file doesn't exist. + * Deletes the entry itself (for symlinks, removes the symlink not the target). */ + deleteFile(path: string): Promise; + /** Move or rename a file within the workspace. Unix mv semantics. + * Default overwrite: false (safer than writeFile). Auto-refreshes the file explorer after success. */ + moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise; } export type WorkspacePanelFiles = WorkspaceFiles; diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 5eda1a5..c7c5923 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, realpath, rm, truncate, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, realpath, rm, truncate, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { Readable } from "node:stream"; @@ -478,6 +478,272 @@ describe("buildApp", () => { expect(tooLargeResponse.statusCode).toBe(400); expect(tooLargeResponse.json()).toEqual({ error: "Image is too large to preview (limit 10 MB)" }); }); + + it("writes workspace files through the HTTP contract", async () => { + const addResponse = await app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "WriteTest", path: projectDir, create: true }, + }); + const project = addResponse.json(); + const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); + const workspace = workspacesResponse.json()[0]; + if (workspace === undefined) throw new Error("Expected workspace"); + + // Write a text file + const writeTextResponse = await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`, + payload: "hello world", + headers: { "content-type": "text/plain" }, + }); + expect(writeTextResponse.statusCode).toBe(200); + expect(writeTextResponse.json()).toMatchObject({ path: "hello.txt", created: true }); + const writeBody = writeTextResponse.json>(); + expect(typeof writeBody['size']).toBe("number"); + + // Read it back + const readResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}` }); + const readBody = readResponse.json>(); + expect(readBody['content']).toBe("hello world"); + + // Write binary content + const writeBinaryResponse = await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("image.png")}`, + payload: Buffer.from([0x89, 0x50, 0x4e, 0x47]), + headers: { "content-type": "application/octet-stream" }, + }); + expect(writeBinaryResponse.statusCode).toBe(200); + expect(writeBinaryResponse.json()).toMatchObject({ path: "image.png", created: true }); + + // Create intermediate directories (default) + const writeDeepResponse = await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}`, + payload: "deep content", + headers: { "content-type": "text/plain" }, + }); + expect(writeDeepResponse.statusCode).toBe(200); + + // Verify the nested file was written + const readDeepResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("deep/nested/dir/file.txt")}` }); + const readDeepBody = readDeepResponse.json>(); + expect(readDeepBody['content']).toBe("deep content"); + + // Overwrite an existing file (default) + const overwriteResponse = await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}`, + payload: "updated", + headers: { "content-type": "text/plain" }, + }); + expect(overwriteResponse.json()).toMatchObject({ path: "hello.txt", created: false }); + + // Reject overwrite=false when file exists + const noOverwriteResponse = await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("hello.txt")}&overwrite=false`, + payload: "should fail", + headers: { "content-type": "text/plain" }, + }); + expect(noOverwriteResponse.statusCode).toBe(400); + const noOverwriteBody = noOverwriteResponse.json>(); + expect(noOverwriteBody['error']).toContain("File already exists"); + + // Reject path traversal + const traversalResponse = await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`, + payload: "evil", + headers: { "content-type": "text/plain" }, + }); + expect(traversalResponse.statusCode).toBe(400); + const traversalBody = traversalResponse.json>(); + expect(traversalBody['error']).toContain("Path traversal"); + + // Reject missing path + const noPathResponse = await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`, + payload: "no path", + headers: { "content-type": "text/plain" }, + }); + expect(noPathResponse.statusCode).toBe(400); + const noPathBody = noPathResponse.json>(); + expect(noPathBody['error']).toContain("path query parameter is required"); + + // Fail when createDirs=false and parent directory does not exist + const noDirsResponse = await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("nonexistent/parent/file.txt")}&createDirs=false`, + payload: "should fail", + headers: { "content-type": "text/plain" }, + }); + expect(noDirsResponse.statusCode).toBe(400); + + // Reject writing to a directory path + await mkdir(join(projectDir, "subdir"), { recursive: true }); + const dirWriteResponse = await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("subdir")}`, + payload: "should fail", + headers: { "content-type": "text/plain" }, + }); + expect(dirWriteResponse.statusCode).toBe(400); + }); + + it("deletes workspace files through the HTTP contract", async () => { + const addResponse = await app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "DeleteTest", path: projectDir, create: true }, + }); + const project = addResponse.json(); + const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); + const workspace = workspacesResponse.json()[0]; + if (workspace === undefined) throw new Error("Expected workspace"); + + // Write a file first so we can delete it + await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`, + payload: "delete me", + headers: { "content-type": "text/plain" }, + }); + + // Delete existing file + const deleteResponse = await app.inject({ + method: "DELETE", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("to-delete.txt")}`, + }); + expect(deleteResponse.statusCode).toBe(200); + expect(deleteResponse.json()).toMatchObject({ path: "to-delete.txt", existed: true }); + + // Delete non-existent file (idempotent) + const deleteMissingResponse = await app.inject({ + method: "DELETE", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("missing.txt")}`, + }); + expect(deleteMissingResponse.statusCode).toBe(200); + expect(deleteMissingResponse.json()).toMatchObject({ path: "missing.txt", existed: false }); + + // Reject path traversal + const traversalResponse = await app.inject({ + method: "DELETE", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("../../etc/passwd")}`, + }); + expect(traversalResponse.statusCode).toBe(400); + const deleteTraversalBody = traversalResponse.json>(); + expect(deleteTraversalBody['error']).toContain("Path traversal"); + + // Reject missing path + const noPathResponse = await app.inject({ + method: "DELETE", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file`, + }); + expect(noPathResponse.statusCode).toBe(400); + const deleteNoPathBody = noPathResponse.json>(); + expect(deleteNoPathBody['error']).toContain("path query parameter is required"); + }); + + it("moves workspace files through the HTTP contract", async () => { + const addResponse = await app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "MoveTest", path: projectDir, create: true }, + }); + const project = addResponse.json(); + const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); + const workspace = workspacesResponse.json()[0]; + if (workspace === undefined) throw new Error("Expected workspace"); + + // Write a file first so we can move it + await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}`, + payload: "move me", + headers: { "content-type": "text/plain" }, + }); + + // Move a file to a new path + const moveResponse = await app.inject({ + method: "POST", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("original.txt")}&toPath=${encodeURIComponent("moved.txt")}`, + }); + expect(moveResponse.statusCode).toBe(200); + expect(moveResponse.json()).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" }); + const moveBody = moveResponse.json>(); + expect(typeof moveBody['size']).toBe("number"); + + // Verify source is gone + const readSourceResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("original.txt")}` }); + expect(readSourceResponse.statusCode).toBe(400); + + // Verify target exists + const readTargetResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("moved.txt")}` }); + expect(readTargetResponse.statusCode).toBe(200); + const targetBody = readTargetResponse.json>(); + expect(targetBody['content']).toBe("move me"); + + // Write another file for overwrite test + await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source2.txt")}`, + payload: "source", + headers: { "content-type": "text/plain" }, + }); + await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("target2.txt")}`, + payload: "target", + headers: { "content-type": "text/plain" }, + }); + + // Move with overwrite=true succeeds + const overwriteResponse = await app.inject({ + method: "POST", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source2.txt")}&toPath=${encodeURIComponent("target2.txt")}&overwrite=true`, + }); + expect(overwriteResponse.statusCode).toBe(200); + + // Move with overwrite=false (default) fails when target exists + await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("source3.txt")}`, + payload: "s", + headers: { "content-type": "text/plain" }, + }); + await app.inject({ + method: "PUT", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file?path=${encodeURIComponent("target3.txt")}`, + payload: "t", + headers: { "content-type": "text/plain" }, + }); + const noOverwriteResponse = await app.inject({ + method: "POST", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("source3.txt")}&toPath=${encodeURIComponent("target3.txt")}`, + }); + expect(noOverwriteResponse.statusCode).toBe(400); + const moveNoOverwriteBody = noOverwriteResponse.json>(); + expect(moveNoOverwriteBody['error']).toContain("File already exists"); + + // Reject path traversal in fromPath + const traversalFromResponse = await app.inject({ + method: "POST", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move?fromPath=${encodeURIComponent("../../etc/passwd")}&toPath=${encodeURIComponent("safe.txt")}`, + }); + expect(traversalFromResponse.statusCode).toBe(400); + + // Reject missing params + const noParamsResponse = await app.inject({ + method: "POST", + url: `/api/projects/${project.id}/workspaces/${workspace.id}/file/move`, + }); + expect(noParamsResponse.statusCode).toBe(400); + const noParamsBody = noParamsResponse.json>(); + expect(noParamsBody['error']).toContain("fromPath query parameter is required"); + }); }); interface CapturedSessionDaemonRequest { diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index 7b4ec66..1e11c73 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -22,7 +22,7 @@ describe("PiWebPluginService", () => { files: { "pi-web-plugin.js": "export default { apiVersion: 1, name: 'Info', activate: () => ({ contributions: {} }) };" }, }); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); await expect(service.manifest()).resolves.toEqual({ plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local", machineSpecific: false })], @@ -41,7 +41,7 @@ describe("PiWebPluginService", () => { files: { "pi-web-plugin.js": "export default {};" }, }); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true }] }); await expect(service.plugins()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true, enabled: true }] }); @@ -74,7 +74,7 @@ describe("PiWebPluginService", () => { files: { "dist/pi-web-plugin.js": "export default { apiVersion: 1, name: 'Source Dev', activate: () => ({ contributions: {} }) };" }, }); - const service = new PiWebPluginService({ cwd: tempDir, packageProvider: false }); + const service = new PiWebPluginService({ cwd: tempDir, packageProvider: false, configProvider: () => ({ plugins: {} }) }); const manifest = await service.manifest(); expect(manifest.plugins).toEqual(expect.arrayContaining([ @@ -92,7 +92,7 @@ describe("PiWebPluginService", () => { await mkdir(join(tempDir, "plugins"), { recursive: true }); await symlink(pluginDir, join(tempDir, "plugins", "dev"), process.platform === "win32" ? "junction" : "dir"); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); const manifest = await service.manifest(); expect(manifest.plugins).toHaveLength(1); @@ -135,7 +135,7 @@ describe("PiWebPluginService", () => { files: { "pi-web-plugin.js": "export default {};" }, }); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); const manifest = await service.manifest(); expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["duplicate"]); @@ -167,7 +167,7 @@ describe("PiWebPluginService", () => { files: { "pi-web-plugin.js": "export default {};" }, }); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); const manifest = await service.manifest(); expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["valid"]); @@ -181,7 +181,7 @@ describe("PiWebPluginService", () => { }); await writeFile(join(tempDir, "plugins", "escape.js"), "nope"); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); const manifest = await service.manifest(); expect(manifest.plugins).toHaveLength(1); diff --git a/src/server/workspaceExplorerRoutes.ts b/src/server/workspaceExplorerRoutes.ts index 1c76e50..0e47449 100644 --- a/src/server/workspaceExplorerRoutes.ts +++ b/src/server/workspaceExplorerRoutes.ts @@ -1,12 +1,19 @@ import type { FastifyInstance } from "fastify"; import type { ProjectService } from "./projects/projectService.js"; import type { WorkspaceService } from "./workspaces/workspaceService.js"; +import type { WriteWorkspaceFileOptions } from "../shared/apiTypes.js"; import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js"; import { listWorkspaceTree } from "./workspaces/fileTreeService.js"; -import { readWorkspaceFile } from "./workspaces/fileContentService.js"; +import { deleteWorkspaceFile, moveWorkspaceFile, readWorkspaceFile, writeWorkspaceFile } from "./workspaces/fileContentService.js"; import { readWorkspaceImagePreview } from "./workspaces/imagePreviewService.js"; export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix = "/api"): void { + // Register content type parsers for workspace file writes. + // Fastify's default parser only handles application/json. + // Guard against re-registration since this function may be called multiple times. + try { app.addContentTypeParser("text/plain", { parseAs: "string" }, (_req, body, done) => { done(null, Buffer.from(body)); }); } catch { /* already registered */ } + try { app.addContentTypeParser("application/octet-stream", { parseAs: "buffer" }, (_req, body, done) => { done(null, body); }); } catch { /* already registered */ } + try { app.addContentTypeParser(/^([a-z]+\/[a-z0-9.+-]+)$/, { parseAs: "buffer" }, (_req, body, done) => { done(null, body); }); } catch { /* already registered */ } app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/tree`, async (request, reply) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); @@ -25,6 +32,40 @@ export function registerWorkspaceExplorerRoutes(app: FastifyInstance, projects: } }); + app.put<{ Params: { projectId: string; workspaceId: string }; Body: Buffer; Querystring: { path?: string; createDirs?: string; overwrite?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => { + try { + const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); + const options: WriteWorkspaceFileOptions = { + createDirs: request.query.createDirs !== "false", + overwrite: request.query.overwrite !== "false", + }; + return await writeWorkspaceFile(context.root, request.query.path, request.body, options); + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.delete<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file`, async (request, reply) => { + try { + const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); + return await deleteWorkspaceFile(context.root, request.query.path); + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + + app.post<{ Params: { projectId: string; workspaceId: string }; Querystring: { fromPath?: string; toPath?: string; createDirs?: string; overwrite?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file/move`, async (request, reply) => { + try { + const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); + return await moveWorkspaceFile(context.root, request.query.fromPath, request.query.toPath, { + createDirs: request.query.createDirs !== "false", + overwrite: request.query.overwrite === "true", + }); + } catch (error) { + return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); + } + }); + app.get<{ Params: { projectId: string; workspaceId: string }; Querystring: { path?: string } }>(`${prefix}/projects/:projectId/workspaces/:workspaceId/file/preview`, async (request, reply) => { try { const context = await resolveWorkspaceContext(projects, workspaces, request.params.projectId, request.params.workspaceId); diff --git a/src/server/workspaces/fileContentService.test.ts b/src/server/workspaces/fileContentService.test.ts index 22705da..232978b 100644 --- a/src/server/workspaces/fileContentService.test.ts +++ b/src/server/workspaces/fileContentService.test.ts @@ -1,9 +1,10 @@ -import { mkdtemp, mkdir, rm, truncate, writeFile } from "node:fs/promises"; +import { mkdtemp, mkdir, readFile, rm, symlink, truncate, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { MAX_IMAGE_PREVIEW_BYTES } from "../../shared/workspaceFiles.js"; -import { readWorkspaceFile } from "./fileContentService.js"; +import { readWorkspaceFile, writeWorkspaceFile } from "./fileContentService.js"; +import { deleteWorkspaceFile, moveWorkspaceFile } from "./fileContentService.js"; import { readWorkspaceImagePreview } from "./imagePreviewService.js"; const roots: string[] = []; @@ -96,3 +97,246 @@ describe("readWorkspaceFile", () => { expect(file.binary).toBe(false); }); }); + +describe("writeWorkspaceFile", () => { + it("writes text content to a new file with normalized paths", async () => { + const root = await tempWorkspace(); + + const result = await writeWorkspaceFile(root, "./src//hello.ts", Buffer.from("const greeting = 'hello';\n")); + + expect(result).toMatchObject({ path: "src/hello.ts", created: true }); + expect(result.size).toBe(26); + expect(Date.parse(result.modifiedAt)).not.toBeNaN(); + + // Verify the file was actually written + const content = await readFile(join(root, "src", "hello.ts"), "utf8"); + expect(content).toBe("const greeting = 'hello';\n"); + }); + + it("writes binary content", async () => { + const root = await tempWorkspace(); + const binaryData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); + + const result = await writeWorkspaceFile(root, "image.png", binaryData); + + expect(result).toMatchObject({ path: "image.png", created: true, size: 6 }); + }); + + it("overwrites existing files by default", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "notes.txt"), "old content"); + + const result = await writeWorkspaceFile(root, "notes.txt", Buffer.from("new content")); + + expect(result).toMatchObject({ path: "notes.txt", created: false, size: 11 }); + const content = await readFile(join(root, "notes.txt"), "utf8"); + expect(content).toBe("new content"); + }); + + it("throws when overwrite is false and file exists", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "existing.txt"), "data"); + + await expect(writeWorkspaceFile(root, "existing.txt", Buffer.from("new"), { overwrite: false })).rejects.toThrow("File already exists"); + }); + + it("creates intermediate directories by default", async () => { + const root = await tempWorkspace(); + + await writeWorkspaceFile(root, "deep/nested/dir/file.txt", Buffer.from("deep content")); + + const content = await readFile(join(root, "deep", "nested", "dir", "file.txt"), "utf8"); + expect(content).toBe("deep content"); + }); + + it("fails when createDirs is false and parent directory does not exist", async () => { + const root = await tempWorkspace(); + + await expect(writeWorkspaceFile(root, "missing/dir/file.txt", Buffer.from("x"), { createDirs: false })).rejects.toThrow(); + }); + + it("rejects missing paths, traversal, and absolute paths", async () => { + const root = await tempWorkspace(); + + await expect(writeWorkspaceFile(root, undefined, Buffer.from("x"))).rejects.toThrow("path query parameter is required"); + await expect(writeWorkspaceFile(root, "../secret.txt", Buffer.from("x"))).rejects.toThrow("Path traversal is not allowed"); + await expect(writeWorkspaceFile(root, "/etc/passwd", Buffer.from("x"))).rejects.toThrow("Absolute paths are not allowed"); + }); + + it("rejects writing to a directory path", async () => { + const root = await tempWorkspace(); + await mkdir(join(root, "mydir"), { recursive: true }); + + await expect(writeWorkspaceFile(root, "mydir", Buffer.from("data"))).rejects.toThrow("Path is not a file"); + }); + + it("prevents writing through symlinks that escape the workspace", async () => { + const root = await tempWorkspace(); + await mkdir(join(root, "subdir"), { recursive: true }); + // Create a symlink inside the workspace that points outside + const { symlink } = await import("node:fs/promises"); + const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-")); + roots.push(outsideDir); + await symlink(outsideDir, join(root, "subdir", "escape"), "junction"); + + // Attempting to write through the symlink should be blocked + await expect(writeWorkspaceFile(root, "subdir/escape/evil.txt", Buffer.from("evil"))).rejects.toThrow(); + }); +}); + +describe("deleteWorkspaceFile", () => { + it("deletes an existing file and returns existed: true", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "notes.txt"), "hello"); + + const result = await deleteWorkspaceFile(root, "notes.txt"); + + expect(result).toMatchObject({ path: "notes.txt", existed: true }); + await expect(readWorkspaceFile(root, "notes.txt")).rejects.toThrow("Path does not exist"); + }); + + it("returns existed: false when deleting a non-existent file", async () => { + const root = await tempWorkspace(); + + const result = await deleteWorkspaceFile(root, "missing.txt"); + + expect(result).toMatchObject({ path: "missing.txt", existed: false }); + }); + + it("rejects deleting a directory", async () => { + const root = await tempWorkspace(); + await mkdir(join(root, "mydir"), { recursive: true }); + + await expect(deleteWorkspaceFile(root, "mydir")).rejects.toThrow("Path is a directory"); + }); + + it("rejects path traversal", async () => { + const root = await tempWorkspace(); + + await expect(deleteWorkspaceFile(root, "../secret.txt")).rejects.toThrow("Path traversal is not allowed"); + await expect(deleteWorkspaceFile(root, "/etc/passwd")).rejects.toThrow("Absolute paths are not allowed"); + }); + + it("rejects missing path", async () => { + const root = await tempWorkspace(); + + await expect(deleteWorkspaceFile(root, undefined)).rejects.toThrow("path query parameter is required"); + await expect(deleteWorkspaceFile(root, "")).rejects.toThrow("path query parameter is required"); + }); + + it("deletes a symlink itself, not its target", async () => { + const root = await tempWorkspace(); + const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-delete-")); + roots.push(outsideDir); + await writeFile(join(outsideDir, "real.txt"), "real content"); + // Create a symlink inside the workspace pointing outside + await symlink(join(outsideDir, "real.txt"), join(root, "link.txt")); + + const result = await deleteWorkspaceFile(root, "link.txt"); + + expect(result).toMatchObject({ path: "link.txt", existed: true }); + // The symlink should be gone, but the target file should still exist + await expect(readWorkspaceFile(root, "link.txt")).rejects.toThrow(); + const realContent = await readFile(join(outsideDir, "real.txt"), "utf8"); + expect(realContent).toBe("real content"); + }); +}); + +describe("moveWorkspaceFile", () => { + it("moves a file to a new path", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "original.txt"), "content"); + + const result = await moveWorkspaceFile(root, "original.txt", "moved.txt"); + + expect(result).toMatchObject({ fromPath: "original.txt", toPath: "moved.txt" }); + expect(result.size).toBe(7); + expect(Date.parse(result.modifiedAt)).not.toBeNaN(); + // Source should no longer exist + await expect(readWorkspaceFile(root, "original.txt")).rejects.toThrow("Path does not exist"); + // Target should exist + const target = await readWorkspaceFile(root, "moved.txt"); + expect(target.content).toBe("content"); + }); + + it("creates intermediate directories by default", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "file.txt"), "data"); + + await moveWorkspaceFile(root, "file.txt", "deep/nested/dir/file.txt"); + + const target = await readWorkspaceFile(root, "deep/nested/dir/file.txt"); + expect(target.content).toBe("data"); + }); + + it("fails when createDirs is false and parent directory does not exist", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "file.txt"), "data"); + + await expect(moveWorkspaceFile(root, "file.txt", "missing/dir/file.txt", { createDirs: false })).rejects.toThrow(); + }); + + it("overwrites target when overwrite is true", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "source.txt"), "source content"); + await writeFile(join(root, "target.txt"), "target content"); + + const result = await moveWorkspaceFile(root, "source.txt", "target.txt", { overwrite: true }); + + expect(result.toPath).toBe("target.txt"); + const target = await readWorkspaceFile(root, "target.txt"); + expect(target.content).toBe("source content"); + }); + + it("throws when target exists and overwrite is false (default)", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "source.txt"), "source"); + await writeFile(join(root, "target.txt"), "target"); + + await expect(moveWorkspaceFile(root, "source.txt", "target.txt")).rejects.toThrow("File already exists"); + // Source should still exist + const source = await readWorkspaceFile(root, "source.txt"); + expect(source.content).toBe("source"); + }); + + it("rejects source path traversal", async () => { + const root = await tempWorkspace(); + + await expect(moveWorkspaceFile(root, "../secret.txt", "target.txt")).rejects.toThrow("Path traversal is not allowed"); + }); + + it("rejects target path traversal", async () => { + const root = await tempWorkspace(); + await writeFile(join(root, "source.txt"), "data"); + + await expect(moveWorkspaceFile(root, "source.txt", "../secret.txt")).rejects.toThrow(); + }); + + it("rejects moving a directory", async () => { + const root = await tempWorkspace(); + await mkdir(join(root, "mydir"), { recursive: true }); + + await expect(moveWorkspaceFile(root, "mydir", "newdir")).rejects.toThrow("Source path is not a file"); + }); + + it("rejects missing fromPath or toPath", async () => { + const root = await tempWorkspace(); + + await expect(moveWorkspaceFile(root, undefined, "target.txt")).rejects.toThrow("fromPath query parameter is required"); + await expect(moveWorkspaceFile(root, "source.txt", undefined)).rejects.toThrow("toPath query parameter is required"); + await expect(moveWorkspaceFile(root, "", "target.txt")).rejects.toThrow("fromPath query parameter is required"); + await expect(moveWorkspaceFile(root, "source.txt", "")).rejects.toThrow("toPath query parameter is required"); + }); + + it("prevents moving through symlinks that escape the workspace", async () => { + const root = await tempWorkspace(); + await mkdir(join(root, "subdir"), { recursive: true }); + await writeFile(join(root, "subdir", "file.txt"), "data"); + // Create a symlink inside the workspace that points outside + const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-move-outside-")); + roots.push(outsideDir); + await symlink(outsideDir, join(root, "subdir", "escape"), "junction"); + + await expect(moveWorkspaceFile(root, "subdir/file.txt", "subdir/escape/evil.txt")).rejects.toThrow(); + }); +}); diff --git a/src/server/workspaces/fileContentService.ts b/src/server/workspaces/fileContentService.ts index 1718141..58de7a2 100644 --- a/src/server/workspaces/fileContentService.ts +++ b/src/server/workspaces/fileContentService.ts @@ -1,7 +1,8 @@ -import { open, stat } from "node:fs/promises"; -import type { FileContentResponse } from "../../shared/apiTypes.js"; +import { lstat, mkdir, open, realpath, rename, stat, unlink, writeFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; +import type { DeleteWorkspaceFileResponse, FileContentResponse, MoveWorkspaceFileOptions, MoveWorkspaceFileResponse, WriteWorkspaceFileOptions, WriteWorkspaceFileResponse } from "../../shared/apiTypes.js"; import { imageMimeTypeForPath } from "./imagePreviewService.js"; -import { resolveInsideWorkspace } from "./pathSafety.js"; +import { ensureInside, isNodeErrorWithCode, resolveInsideWorkspace, resolveParentInsideWorkspace } from "./pathSafety.js"; const MAX_BYTES = 512 * 1024; @@ -39,6 +40,104 @@ async function readFilePrefix(target: string, bytesToRead: number): Promise { + if (path === undefined || path === "") throw new Error("path query parameter is required"); + + const createDirs = options.createDirs ?? true; + const overwrite = options.overwrite ?? true; + + let exists = false; + try { + const { target, relativePath } = await resolveInsideWorkspace(rootPath, path); + const s = await stat(target); + if (!s.isFile()) throw new Error("Path is not a file"); + if (!overwrite) throw new Error(`File already exists: ${relativePath}`); + exists = true; + } catch (error: unknown) { + if (error instanceof Error && error.message.startsWith("File already exists")) throw error; + if (isNodeErrorWithCode(error, "ENOENT")) { /* expected for creation — continue */ } + else if (error instanceof Error && error.message === "Path does not exist") { /* expected for creation — continue */ } + else throw error; // re-throw permission errors, "not a file", traversal errors, etc. + } + + // Use resolveParentInsideWorkspace for the actual write since the target may not exist yet + const { root, target, relativePath } = await resolveParentInsideWorkspace(rootPath, path); + + if (createDirs) await mkdir(dirname(target), { recursive: true }); + + // Resolve symlinks in the parent path to prevent escape via symlink + const realParent = await realpath(dirname(target)); + const realTarget = join(realParent, basename(target)); + ensureInside(root, realTarget); + await writeFile(realTarget, content); + + const s = await stat(realTarget); + return { + path: relativePath, + size: s.size, + modifiedAt: s.mtime.toISOString(), + created: !exists, + }; +} + +export async function deleteWorkspaceFile(rootPath: string, path: string | undefined): Promise { + if (path === undefined || path === "") throw new Error("path query parameter is required"); + // Use resolveParentInsideWorkspace + lstat so that deleting a symlink + // deletes the symlink itself, not the target it points to. + // resolveInsideWorkspace would call realpath on the target, following + // symlinks and resolving the symlink's destination instead. + const { target, relativePath } = await resolveParentInsideWorkspace(rootPath, path); + try { + const s = await lstat(target); + // Allow deleting regular files and symlinks, but not directories + if (s.isDirectory()) throw new Error("Path is a directory, use directory deletion instead"); + await unlink(target); + return { path: relativePath, existed: true }; + } catch (error: unknown) { + if (isNodeErrorWithCode(error, "ENOENT")) return { path: relativePath, existed: false }; + if (error instanceof Error && error.message === "Path does not exist") return { path: relativePath, existed: false }; + throw error; + } +} + +export async function moveWorkspaceFile(rootPath: string, fromPath: string | undefined, toPath: string | undefined, options: MoveWorkspaceFileOptions = {}): Promise { + if (fromPath === undefined || fromPath === "") throw new Error("fromPath query parameter is required"); + if (toPath === undefined || toPath === "") throw new Error("toPath query parameter is required"); + + const createDirs = options.createDirs ?? true; + const overwrite = options.overwrite ?? false; + + // Source: must exist and be a file (uses realpath via resolveInsideWorkspace) + const { target: source, relativePath: fromRelative } = await resolveInsideWorkspace(rootPath, fromPath); + const sourceStat = await stat(source); + if (!sourceStat.isFile()) throw new Error("Source path is not a file"); + + // Target: uses resolveParentInsideWorkspace + realpath(dirname) pattern (same as writeFile) + const { root, target: dest, relativePath: destRelative } = await resolveParentInsideWorkspace(rootPath, toPath); + + if (createDirs) await mkdir(dirname(dest), { recursive: true }); + + // Resolve symlinks in the parent path to prevent escape via symlink + const realParent = await realpath(dirname(dest)); + const realDest = join(realParent, basename(dest)); + ensureInside(root, realDest); + + if (!overwrite) { + try { + const destStat = await stat(realDest); + if (destStat.isFile()) throw new Error(`File already exists: ${destRelative}`); + } catch (error: unknown) { + if (isNodeErrorWithCode(error, "ENOENT")) { /* expected — target doesn't exist */ } + else if (error instanceof Error && error.message.startsWith("File already exists")) throw error; + else throw error; + } + } + + await rename(source, realDest); + const finalStat = await stat(realDest); + return { fromPath: fromRelative, toPath: destRelative, size: finalStat.size, modifiedAt: finalStat.mtime.toISOString() }; +} + function isProbablyBinary(buffer: Buffer): boolean { const sample = buffer.subarray(0, Math.min(buffer.length, 8192)); return sample.includes(0); diff --git a/src/server/workspaces/pathSafety.ts b/src/server/workspaces/pathSafety.ts index 36d2cbd..02a8921 100644 --- a/src/server/workspaces/pathSafety.ts +++ b/src/server/workspaces/pathSafety.ts @@ -30,11 +30,11 @@ export function normalizeRelativePath(input: string | undefined): string { return parts.join("/"); } -function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException { +export function isNodeErrorWithCode(error: unknown, code: string): error is NodeJS.ErrnoException { return typeof error === "object" && error !== null && "code" in error && error.code === code; } -function ensureInside(root: string, target: string): void { +export function ensureInside(root: string, target: string): void { const rel = relative(root, target); if (rel === "") return; if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Path escapes workspace"); diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 6ce5db7..06bb685 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -297,6 +297,35 @@ export interface FileContentResponse { binary: boolean; } +export interface WriteWorkspaceFileOptions { + createDirs?: boolean; // default: true — mkdir -p equivalent + overwrite?: boolean; // default: true — throw if false and file exists +} + +export interface WriteWorkspaceFileResponse { + path: string; + size: number; + modifiedAt: string; + created: boolean; // true if file was created, false if overwritten +} + +export interface DeleteWorkspaceFileResponse { + path: string; + existed: boolean; // true if file existed and was deleted, false if file did not exist +} + +export interface MoveWorkspaceFileOptions { + createDirs?: boolean; // default: true — mkdir -p equivalent for target parent directory + overwrite?: boolean; // default: false — throw if target exists (safer default than writeFile) +} + +export interface MoveWorkspaceFileResponse { + fromPath: string; + toPath: string; + size: number; + modifiedAt: string; +} + export type GitFileState = "unmodified" | "modified" | "added" | "deleted" | "renamed" | "copied" | "untracked" | "ignored" | "conflicted"; export interface GitStatusFile { From fd6c01067be31e3a4d0aae5457bccba65f6976cc Mon Sep 17 00:00:00 2001 From: marcus Date: Sun, 14 Jun 2026 13:08:02 +0200 Subject: [PATCH 02/24] fix(plugin-api): position cursor after insertions and removals in prompt/attachments API Ensure prompt.insertText, attachments.insertFileReference, and attachments.removeFileReference move the cursor to the correct position after modifying the prompt editor. Previously the cursor stayed at the start of inserted text, which broke the natural flow for plugin-driven file attachments like screenshot-paste. --- src/client/src/components/PiWebApp.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 95493d3..0250799 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1391,7 +1391,10 @@ export class PiWebApp extends LitElement { 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 } }); + 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() ?? ""; @@ -1440,7 +1443,10 @@ export class PiWebApp extends LitElement { const editor = this.promptEditor?.view; if (editor) { const sel = editor.state.selection.main; - editor.dispatch({ changes: { from: sel.from, to: sel.to, insert: reference } }); + editor.dispatch({ + changes: { from: sel.from, to: sel.to, insert: reference }, + selection: { anchor: sel.from + reference.length }, + }); } return reference; }, @@ -1463,6 +1469,7 @@ export class PiWebApp extends LitElement { if (index === -1) return; editor.dispatch({ changes: { from: index, to: index + reference.length, insert: "" }, + selection: { anchor: index }, }); }, }; From dde8675454def36d04901b94d1f9aeee86acd3b0 Mon Sep 17 00:00:00 2001 From: marcus Date: Sun, 14 Jun 2026 14:33:53 +0200 Subject: [PATCH 03/24] fix(plugin-api): federate workspace file mutations and trim PR scope --- .gitignore | 4 --- docs/plugins.md | 4 +-- package.json | 1 - scripts/postinstall.mjs | 35 ------------------- .../src/api/federatedRouteContract.test.ts | 3 ++ src/server/piWebPluginService.test.ts | 14 ++++---- src/shared/federatedRoutes.ts | 5 ++- 7 files changed, 16 insertions(+), 50 deletions(-) delete mode 100644 scripts/postinstall.mjs diff --git a/.gitignore b/.gitignore index c74fccc..052ea5e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,3 @@ dist/ # Local runtime attachment uploads (created by the chat composer "save to folder" mode). .pi-web/ - -# Local paste upload directory and temporary working docs. -.pi-paste/ -docs/tmp/ diff --git a/docs/plugins.md b/docs/plugins.md index 6706641..387ef6c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -976,13 +976,13 @@ If you are an AI agent building or editing a PI WEB plugin, follow this checklis Check discovery: ```bash -curl http://localhost:8504/pi-web-plugins/manifest.json +curl http://127.0.0.1:8504/pi-web-plugins/manifest.json ``` Check a plugin module: ```bash -curl http://localhost:8504/pi-web-plugins/my-plugin/pi-web-plugin.js +curl http://127.0.0.1:8504/pi-web-plugins/my-plugin/pi-web-plugin.js ``` Common issues: diff --git a/package.json b/package.json index 2783bc6..11e045c 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,6 @@ "prepublishOnly": "npm run verify", "publish:npm": "npm publish --access public", "prepare": "node scripts/install-git-hooks.mjs", - "postinstall": "node scripts/postinstall.mjs", "changeset": "changeset", "release:version": "changeset version", "changelog:status": "changeset status" diff --git a/scripts/postinstall.mjs b/scripts/postinstall.mjs deleted file mode 100644 index 66b023e..0000000 --- a/scripts/postinstall.mjs +++ /dev/null @@ -1,35 +0,0 @@ -import { chmodSync, readdirSync, statSync } from "node:fs"; -import { join } from "node:path"; - -/** - * node-pty 1.1.0 ships macOS prebuilds with `spawn-helper` files at 644 - * instead of 755, causing `posix_spawnp failed` at runtime. This script - * fixes permissions after install on Darwin platforms. - */ -function fixNodePtyPermissions() { - if (process.platform === "win32") return; - const prebuildsDir = join("node_modules", "node-pty", "prebuilds"); - let entries; - try { - entries = readdirSync(prebuildsDir, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const helper = join(prebuildsDir, entry.name, "spawn-helper"); - let stats; - try { - stats = statSync(helper); - } catch { - continue; - } - if (!stats.isFile()) continue; - // 0o100 = regular file, 0o111 = owner/group/other execute - if ((stats.mode & 0o111) === 0) { - chmodSync(helper, 0o755); - } - } -} - -fixNodePtyPermissions(); diff --git a/src/client/src/api/federatedRouteContract.test.ts b/src/client/src/api/federatedRouteContract.test.ts index 059212e..faafbaf 100644 --- a/src/client/src/api/federatedRouteContract.test.ts +++ b/src/client/src/api/federatedRouteContract.test.ts @@ -37,6 +37,9 @@ 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(gitApi.gitStatus("p 1", "w 1", machineId)), ignoreParseFailure(gitApi.gitDiff("p 1", "w 1", { path: "README.md", staged: true }, machineId)), diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts index 1e11c73..7b4ec66 100644 --- a/src/server/piWebPluginService.test.ts +++ b/src/server/piWebPluginService.test.ts @@ -22,7 +22,7 @@ describe("PiWebPluginService", () => { files: { "pi-web-plugin.js": "export default { apiVersion: 1, name: 'Info', activate: () => ({ contributions: {} }) };" }, }); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); await expect(service.manifest()).resolves.toEqual({ plugins: [expect.objectContaining({ id: "info", source: "test", scope: "local", machineSpecific: false })], @@ -41,7 +41,7 @@ describe("PiWebPluginService", () => { files: { "pi-web-plugin.js": "export default {};" }, }); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); await expect(service.manifest()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true }] }); await expect(service.plugins()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true, enabled: true }] }); @@ -74,7 +74,7 @@ describe("PiWebPluginService", () => { files: { "dist/pi-web-plugin.js": "export default { apiVersion: 1, name: 'Source Dev', activate: () => ({ contributions: {} }) };" }, }); - const service = new PiWebPluginService({ cwd: tempDir, packageProvider: false, configProvider: () => ({ plugins: {} }) }); + const service = new PiWebPluginService({ cwd: tempDir, packageProvider: false }); const manifest = await service.manifest(); expect(manifest.plugins).toEqual(expect.arrayContaining([ @@ -92,7 +92,7 @@ describe("PiWebPluginService", () => { await mkdir(join(tempDir, "plugins"), { recursive: true }); await symlink(pluginDir, join(tempDir, "plugins", "dev"), process.platform === "win32" ? "junction" : "dir"); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); const manifest = await service.manifest(); expect(manifest.plugins).toHaveLength(1); @@ -135,7 +135,7 @@ describe("PiWebPluginService", () => { files: { "pi-web-plugin.js": "export default {};" }, }); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); const manifest = await service.manifest(); expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["duplicate"]); @@ -167,7 +167,7 @@ describe("PiWebPluginService", () => { files: { "pi-web-plugin.js": "export default {};" }, }); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); const manifest = await service.manifest(); expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["valid"]); @@ -181,7 +181,7 @@ describe("PiWebPluginService", () => { }); await writeFile(join(tempDir, "plugins", "escape.js"), "nope"); - const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false, configProvider: () => ({ plugins: {} }) }); + const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false }); const manifest = await service.manifest(); expect(manifest.plugins).toHaveLength(1); diff --git a/src/shared/federatedRoutes.ts b/src/shared/federatedRoutes.ts index 3493d50..eab03cf 100644 --- a/src/shared/federatedRoutes.ts +++ b/src/shared/federatedRoutes.ts @@ -1,4 +1,4 @@ -export type FederatedHttpMethod = "GET" | "POST" | "DELETE"; +export type FederatedHttpMethod = "GET" | "POST" | "PUT" | "DELETE"; export interface FederatedHttpRouteSpec { method: FederatedHttpMethod; @@ -15,6 +15,9 @@ export const FEDERATED_HTTP_ROUTES = [ { method: "DELETE", path: "/projects/:projectId/workspaces/:workspaceId" }, { method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/tree" }, { method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file" }, + { method: "PUT", path: "/projects/:projectId/workspaces/:workspaceId/file" }, + { method: "DELETE", path: "/projects/:projectId/workspaces/:workspaceId/file" }, + { method: "POST", path: "/projects/:projectId/workspaces/:workspaceId/file/move" }, { method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/file/preview" }, { method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/status" }, { method: "GET", path: "/projects/:projectId/workspaces/:workspaceId/git/diff" }, From 3742bcc9620d60d38f04601daa2ee6074dcecbff Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sun, 14 Jun 2026 23:16:37 +0200 Subject: [PATCH 04/24] refactor(plugin-api): trim plugin API scope to grounded capabilities Builds on marcus's plugin-api-completeness work. Narrows the new plugin surface to capabilities that expose real, otherwise-unreachable pi-web functionality, and drops invented/duplicative surfaces: Kept: - files.writeFile / deleteFile / moveFile (genuine workspace mutation, federated, path-safe) - prompt.insertText / getText / getSelection (editor state access) Dropped: - attachments.* (insertFileReference/getAttachedFiles/removeFileReference): getAttachedFiles invented a structured-attachment notion pi-web does not have and duplicated prompt.getText() + a regex with a false email-safety claim; insert/removeFileReference were thin sugar over readFile + insertText that plugins can compose themselves. - prompt.onPaste / onKeyDown: an incomplete two-event hook system shaped around a single use case, overlapping the editor's native image-paste handling. Deferred until a real editor event/hook surface is designed. - prompt.focus: redundant and buggier duplicate of the existing focusPrompt() (silently no-ops when not on the chat view). Focus stays as focusPrompt(). Security fix: - deleteWorkspaceFile now resolves the parent via realpath + ensureInside before lstat/unlink, closing a symlinked-parent-directory escape that allowed deleting files outside the workspace (write/move already did this). Final path component is still not resolved, so deleting a symlink removes the link, not its target. Adds a regression test. Docs and the registry test mock updated to match the trimmed surface. --- .changeset/plugin-api-completeness.md | 2 +- docs/plugins.md | 57 ++------------- src/client/src/components/PiWebApp.ts | 69 +------------------ src/client/src/components/PromptEditor.ts | 56 --------------- src/client/src/plugins/registry.test.ts | 8 --- src/client/src/plugins/types.ts | 10 --- src/plugin-api.ts | 26 ------- .../workspaces/fileContentService.test.ts | 16 +++++ src/server/workspaces/fileContentService.ts | 13 +++- 9 files changed, 34 insertions(+), 223 deletions(-) diff --git a/.changeset/plugin-api-completeness.md b/.changeset/plugin-api-completeness.md index 0bff30c..5a3b3f3 100644 --- a/.changeset/plugin-api-completeness.md +++ b/.changeset/plugin-api-completeness.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Add file mutation, prompt editor, and attachment APIs to the plugin system, completing the stable workspace interaction surface. +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/docs/plugins.md b/docs/plugins.md index 387ef6c..d2a1814 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -474,64 +474,19 @@ The `prompt` helper on `PluginRuntimeContext` provides stable access to the chat | `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`. | -| `onPaste(handler)` | Register a paste handler scoped to the prompt editor. Returns an unsubscribe function. Handler returns `true` to consume the event. | -| `onKeyDown(handler)` | Register a keydown handler scoped to the prompt editor. Returns an unsubscribe function. Handler returns `true` to consume the event. | -| `focus()` | Focus the prompt editor. | Usage: ```js -// Insert text at cursor +// Insert text at the cursor (e.g. a file mention) context.prompt.insertText("@file.txt"); -// Intercept paste events -const unsub = context.prompt.onPaste((event) => { - const items = event.clipboardData?.items; - if (items?.[0]?.type.startsWith("image/")) { - // Handle image paste - return true; // consume the event - } - return false; -}); -// Later, when the plugin no longer needs the handler: -unsub(); +// Read the current prompt and selection +const text = context.prompt.getText(); +const selection = context.prompt.getSelection(); // { start, end, text } | null ``` -Handlers registered via `onPaste` and `onKeyDown` are scoped to the prompt editor using CodeMirror's extension system. They run in registration order: if a handler returns `true` to consume the event, subsequent plugin handlers for the same event will not run (first-to-consume-wins). Register handlers early if your plugin needs to intercept events before others. Handlers are automatically cleaned up when the editor is destroyed. Call the returned unsubscribe function when your plugin no longer needs the handler. Do not use `document.addEventListener` for prompt interactions — raw DOM listeners are not scoped to the editor, can leak memory, and may break across PI WEB upgrades. - -`focusPrompt()` on `PluginRuntimeContext` is kept for backward compatibility. `prompt.focus()` is the preferred path. - -### Attachment API - -The `attachments` helper on `PluginRuntimeContext` manages file references in the chat prompt: - -| Method | Description | -| --- | --- | -| `insertFileReference(path)` | Validate a workspace file exists and insert `@path` at the cursor. Returns the reference string. Throws if no workspace is selected or the file does not exist. | -| `getAttachedFiles()` | Returns an array of file paths currently referenced in the prompt (without the `@` prefix). | -| `removeFileReference(path)` | Remove the first occurrence of `@path` from the prompt. | - -Usage: - -```js -// Save a file, then attach it -const result = await context.files.writeFile(".pi-paste/screenshot.png", imageBytes); -const ref = await context.attachments.insertFileReference(result.path); -// ref is "@.pi-paste/screenshot.png" - -// Check what's attached -const files = context.attachments.getAttachedFiles(); -// files is [".pi-paste/screenshot.png"] - -// Remove it -context.attachments.removeFileReference(".pi-paste/screenshot.png"); -``` - -`insertFileReference` validates the file exists using `files.readFile()` before inserting the `@path` reference. Use `files.writeFile()` to create the file first, then `attachments.insertFileReference()` to attach it. - -`getAttachedFiles()` uses a pattern that matches `@path/to/file.ext` — it requires a file extension (`.something`) to avoid matching email addresses like `user@example.com`. Paths are returned without the `@` prefix. - -`removeFileReference(path)` removes the first occurrence of `@path` in the prompt text. If the path is not found, it does nothing. +Use `focusPrompt()` on `PluginRuntimeContext` to move focus to the prompt editor. #### Keyboard shortcuts @@ -966,7 +921,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`, `state.piWebStatus`, `prompt`, and `attachments`. +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/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 0250799..a7e678e 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -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, PluginAttachments, PluginPromptEditor, 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"; @@ -1406,72 +1406,6 @@ export class PiWebApp extends LitElement { if (sel.empty) return null; return { start: sel.from, end: sel.to, text: editor.state.sliceDoc(sel.from, sel.to) }; }, - onPaste: (handler) => { - if (!this.promptEditor) { - console.warn("[pi-web] prompt.onPaste() called but prompt editor is not available. Handler will not be registered."); - return () => undefined; - } - const id = this.promptEditor.addPluginHandler("paste", handler); - return () => { this.promptEditor?.removePluginHandler(id); }; - }, - onKeyDown: (handler) => { - if (!this.promptEditor) { - console.warn("[pi-web] prompt.onKeyDown() called but prompt editor is not available. Handler will not be registered."); - return () => undefined; - } - const id = this.promptEditor.addPluginHandler("keydown", handler); - return () => { this.promptEditor?.removePluginHandler(id); }; - }, - focus: () => { - this.promptEditor?.focusInput(); - }, - }; - } - - private createPluginAttachments(): PluginAttachments { - const workspace = this.state.selectedWorkspace; - const machineId = selectedMachineId(this.state); - return { - insertFileReference: async (path: string) => { - if (!workspace) throw new Error("No workspace selected"); - try { - await workspacesApi.workspaceFile(workspace.projectId, workspace.id, path, machineId); - } catch { - throw new Error(`File not found in workspace: ${path}`); - } - const reference = `@${path}`; - const editor = this.promptEditor?.view; - if (editor) { - const sel = editor.state.selection.main; - editor.dispatch({ - changes: { from: sel.from, to: sel.to, insert: reference }, - selection: { anchor: sel.from + reference.length }, - }); - } - return reference; - }, - getAttachedFiles: () => { - const text = this.promptEditor?.view?.state.doc.toString() ?? ""; - const matches: string[] = []; - const atFilePattern = /@([\w./\-\u00C0-\u024F]+(?:\.[\w]+))/g; - let m: RegExpExecArray | null; - while ((m = atFilePattern.exec(text)) !== null) { - if (m[1] !== undefined) matches.push(m[1]); - } - return matches; - }, - removeFileReference: (path: string) => { - const editor = this.promptEditor?.view; - if (!editor) return; - const text = editor.state.doc.toString(); - const reference = `@${path}`; - const index = text.indexOf(reference); - if (index === -1) return; - editor.dispatch({ - changes: { from: index, to: index + reference.length, insert: "" }, - selection: { anchor: index }, - }); - }, }; } @@ -1479,7 +1413,6 @@ export class PiWebApp extends LitElement { const createContext = (origin: string): PluginRuntimeContext => installPluginRuntimeScope({ state: this.state, prompt: this.createPromptEditor(), - attachments: this.createPluginAttachments(), 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 ff0d7ee..0f8e24d 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -27,9 +27,6 @@ interface PendingAttachment { size: number; } -type PluginPasteHandler = (event: ClipboardEvent) => boolean; -type PluginKeydownHandler = (event: KeyboardEvent) => boolean; - @customElement("prompt-editor") export class PromptEditor extends LitElement { @property({ type: Boolean }) disabled = false; @@ -59,10 +56,6 @@ export class PromptEditor extends LitElement { private editor: EditorView | undefined; private readonly editableCompartment = new Compartment(); private readonly readOnlyCompartment = new Compartment(); - private readonly pluginHandlersCompartment = new Compartment(); - private nextHandlerId = 0; - private readonly pasteHandlers = new Map(); - private readonly keydownHandlers = new Map(); protected override willUpdate(changed: PropertyValues) { if (!changed.has("sessionId") && !changed.has("machineId")) return; @@ -86,8 +79,6 @@ export class PromptEditor extends LitElement { } override disconnectedCallback(): void { - this.pasteHandlers.clear(); - this.keydownHandlers.clear(); this.editor?.destroy(); this.editor = undefined; super.disconnectedCallback(); @@ -128,52 +119,6 @@ export class PromptEditor extends LitElement { return this.editor; } - /** Register a plugin event handler. Returns a numeric ID for later removal. */ - addPluginHandler(...args: ["paste", PluginPasteHandler] | ["keydown", PluginKeydownHandler]): number { - const [type, handler] = args; - const id = this.nextHandlerId++; - if (type === "paste") { - this.pasteHandlers.set(id, handler); - } else { - this.keydownHandlers.set(id, handler); - } - this.reconfigurePluginHandlers(); - return id; - } - - /** Remove a previously registered plugin event handler by ID. */ - removePluginHandler(id: number): void { - this.pasteHandlers.delete(id); - this.keydownHandlers.delete(id); - this.reconfigurePluginHandlers(); - } - - private reconfigurePluginHandlers(): void { - const extension = this.buildPluginHandlersExtension(); - this.editor?.dispatch({ - effects: this.pluginHandlersCompartment.reconfigure(extension), - }); - } - - private buildPluginHandlersExtension() { - const pasteHandlers = [...this.pasteHandlers.values()]; - const keydownHandlers = [...this.keydownHandlers.values()]; - return EditorView.domEventHandlers({ - paste(event) { - for (const handler of pasteHandlers) { - if (handler(event)) return true; - } - return false; - }, - keydown(event) { - for (const handler of keydownHandlers) { - if (handler(event)) return true; - } - return false; - }, - }); - } - private renderCompactStatus() { const status = this.status; if (status === undefined) return null; @@ -283,7 +228,6 @@ export class PromptEditor extends LitElement { placeholder("Message pi... Use / for commands, @ for tracked files, @ space for all files"), this.editableCompartment.of(EditorView.editable.of(!this.disabled)), this.readOnlyCompartment.of(EditorState.readOnly.of(this.disabled)), - this.pluginHandlersCompartment.of(this.buildPluginHandlersExtension()), EditorView.updateListener.of((update) => { if (update.docChanged) this.updateDraft(update.state.doc.toString()); }), diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 0c0d538..82edc7a 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -18,14 +18,6 @@ function createContext(statePatch: Partial = {}) { insertText: vi.fn(), getText: vi.fn(() => ""), getSelection: vi.fn(() => null), - onPaste: vi.fn(() => vi.fn()), - onKeyDown: vi.fn(() => vi.fn()), - focus: vi.fn(() => { calls.push("prompt.focus"); }), - }, - attachments: { - insertFileReference: vi.fn(), - getAttachedFiles: vi.fn(() => []), - removeFileReference: vi.fn(), }, piWebUnstable: { terminalCommandRuns: { diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index d7cf32e..355b955 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -90,21 +90,11 @@ export interface PluginPromptEditor { insertText(text: string): void; getText(): string; getSelection(): { start: number; end: number; text: string } | null; - onPaste(handler: (event: ClipboardEvent) => boolean): () => void; - onKeyDown(handler: (event: KeyboardEvent) => boolean): () => void; - focus(): void; -} - -export interface PluginAttachments { - insertFileReference(path: string): Promise; - getAttachedFiles(): string[]; - removeFileReference(path: string): void; } export interface PluginRuntimeContext { state: AppState; prompt: PluginPromptEditor; - attachments: PluginAttachments; piWebUnstable?: PiWebUnstableRuntimeContext; openActionPalette: () => void; focusPrompt: () => void; diff --git a/src/plugin-api.ts b/src/plugin-api.ts index 3c195bb..aa83cdc 100644 --- a/src/plugin-api.ts +++ b/src/plugin-api.ts @@ -81,37 +81,11 @@ export interface PluginPromptEditor { getText(): string; /** Get the current selection range, or null if no selection or editor not mounted. */ getSelection(): { start: number; end: number; text: string } | null; - /** Register a paste event handler scoped to the prompt editor. - * Handlers run in registration order; first handler returning true consumes the event. - * Returns an unsubscribe function. No-op if the editor is not mounted. */ - onPaste(handler: (event: ClipboardEvent) => boolean): () => void; - /** Register a keydown handler scoped to the prompt editor. - * Handlers run in registration order; first handler returning true consumes the event. - * Returns unsubscribe. No-op if the editor is not mounted. */ - onKeyDown(handler: (event: KeyboardEvent) => boolean): () => void; - /** Focus the prompt editor. No-op if not mounted. */ - focus(): void; -} - -export interface PluginAttachments { - /** Insert a file reference at the current cursor position in the chat prompt. - * Validates that the file exists in the workspace before insertion. - * Does not auto-focus the editor (unlike prompt.insertText). Use prompt.focus() first if needed. - * @throws Error if no workspace is selected or the file doesn't exist - * Returns the canonical @file reference string (e.g., "@path/to/file.png"). */ - insertFileReference(path: string): Promise; - /** List currently attached file paths in the prompt. Returns paths without the @ prefix. - * Best-effort heuristic: matches @path/to/file.ext patterns. May match email-like patterns; - * use insertFileReference() for guaranteed-accurate insertion. */ - getAttachedFiles(): string[]; - /** Remove a file reference from the prompt by path. Removes the first occurrence of @path. */ - removeFileReference(path: string): void; } export interface PluginRuntimeContext { state: PluginRuntimeState; prompt: PluginPromptEditor; - attachments: PluginAttachments; openActionPalette: () => void; focusPrompt: () => void; addProject: () => void | Promise; diff --git a/src/server/workspaces/fileContentService.test.ts b/src/server/workspaces/fileContentService.test.ts index 232978b..8067933 100644 --- a/src/server/workspaces/fileContentService.test.ts +++ b/src/server/workspaces/fileContentService.test.ts @@ -240,6 +240,22 @@ describe("deleteWorkspaceFile", () => { const realContent = await readFile(join(outsideDir, "real.txt"), "utf8"); expect(realContent).toBe("real content"); }); + + it("prevents deleting through a symlinked parent directory that escapes the workspace", async () => { + const root = await tempWorkspace(); + await mkdir(join(root, "subdir"), { recursive: true }); + // A real file living outside the workspace that must not be deletable. + const outsideDir = await mkdtemp(join(tmpdir(), "pi-web-outside-delete-parent-")); + roots.push(outsideDir); + await writeFile(join(outsideDir, "victim.txt"), "important"); + // A symlinked parent directory inside the workspace pointing outside. + await symlink(outsideDir, join(root, "subdir", "escape"), "junction"); + + await expect(deleteWorkspaceFile(root, "subdir/escape/victim.txt")).rejects.toThrow("Path escapes workspace"); + // The outside file must survive. + const realContent = await readFile(join(outsideDir, "victim.txt"), "utf8"); + expect(realContent).toBe("important"); + }); }); describe("moveWorkspaceFile", () => { diff --git a/src/server/workspaces/fileContentService.ts b/src/server/workspaces/fileContentService.ts index 58de7a2..f866e4e 100644 --- a/src/server/workspaces/fileContentService.ts +++ b/src/server/workspaces/fileContentService.ts @@ -86,12 +86,19 @@ export async function deleteWorkspaceFile(rootPath: string, path: string | undef // deletes the symlink itself, not the target it points to. // resolveInsideWorkspace would call realpath on the target, following // symlinks and resolving the symlink's destination instead. - const { target, relativePath } = await resolveParentInsideWorkspace(rootPath, path); + const { root, target, relativePath } = await resolveParentInsideWorkspace(rootPath, path); try { - const s = await lstat(target); + // Resolve symlinks in the parent path to prevent escape via a symlinked + // parent directory. The final path component is intentionally NOT resolved + // so that lstat/unlink act on the entry itself (deleting a symlink rather + // than the file it points to). + const realParent = await realpath(dirname(target)); + const realTarget = join(realParent, basename(target)); + ensureInside(root, realTarget); + const s = await lstat(realTarget); // Allow deleting regular files and symlinks, but not directories if (s.isDirectory()) throw new Error("Path is a directory, use directory deletion instead"); - await unlink(target); + await unlink(realTarget); return { path: relativePath, existed: true }; } catch (error: unknown) { if (isNodeErrorWithCode(error, "ENOENT")) return { path: relativePath, existed: false }; From c479a0d54d45301843ef5a7ed6e8da979fef9c20 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 24 Jun 2026 09:15:32 +0200 Subject: [PATCH 05/24] fix: support pi-ai provider compat entrypoint --- .changeset/sessiond-pi-ai-compat.md | 5 ++ src/server/sessions/authProviderOptions.ts | 5 +- src/server/sessions/sessionNameGenerator.ts | 54 ++++++++++++++++++++- 3 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 .changeset/sessiond-pi-ai-compat.md diff --git a/.changeset/sessiond-pi-ai-compat.md b/.changeset/sessiond-pi-ai-compat.md new file mode 100644 index 0000000..43470e1 --- /dev/null +++ b/.changeset/sessiond-pi-ai-compat.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +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. diff --git a/src/server/sessions/authProviderOptions.ts b/src/server/sessions/authProviderOptions.ts index 61940db..58211d8 100644 --- a/src/server/sessions/authProviderOptions.ts +++ b/src/server/sessions/authProviderOptions.ts @@ -1,8 +1,6 @@ -import { getProviders } from "@earendil-works/pi-ai"; import type { AuthProviderOption, AuthProviderStatus, AuthType } from "../../shared/apiTypes.js"; const OAUTH_ONLY_PROVIDERS = new Set(["github-copilot", "openai-codex"]); -const BUILT_IN_MODEL_PROVIDERS = new Set(getProviders()); export interface AuthProviderModelRegistry { authStorage: { @@ -54,11 +52,10 @@ export function getLogoutProviderOptions(modelRegistry: AuthProviderModelRegistr return filterAndSort(options); } -export function isApiKeyLoginProvider(providerId: string, oauthProviderIds: ReadonlySet, builtInProviderIds: ReadonlySet = BUILT_IN_MODEL_PROVIDERS): boolean { +export function isApiKeyLoginProvider(providerId: string, oauthProviderIds: ReadonlySet): boolean { if (OAUTH_ONLY_PROVIDERS.has(providerId)) return false; if (providerId === "anthropic") return true; if (oauthProviderIds.has(providerId)) return false; - if (builtInProviderIds.has(providerId)) return true; return true; } diff --git a/src/server/sessions/sessionNameGenerator.ts b/src/server/sessions/sessionNameGenerator.ts index d214ea3..77c7c15 100644 --- a/src/server/sessions/sessionNameGenerator.ts +++ b/src/server/sessions/sessionNameGenerator.ts @@ -1,13 +1,27 @@ -import { getApiProvider, type Api, type AssistantMessage, type Model } from "@earendil-works/pi-ai"; +import type { Api, AssistantMessage, AssistantMessageEventStream, Context, Model, SimpleStreamOptions } from "@earendil-works/pi-ai"; import type { ModelRegistry } from "@earendil-works/pi-coding-agent"; const SESSION_NAME_TIMEOUT_MS = 10_000; const SESSION_NAME_MAX_INPUT_CHARS = 4_000; const SESSION_NAME_MAX_LENGTH = 60; const FALLBACK_SESSION_NAME_MAX_WORDS = 6; +const PI_AI_COMPAT_MODULE = ["@earendil-works/pi-ai", "compat"].join("/"); + +interface SessionNameApiProvider { + streamSimple(model: Model, context: Context, options?: SimpleStreamOptions): AssistantMessageEventStream; +} + +interface PiAiProviderRegistryModule { + getApiProvider?: (api: Api) => SessionNameApiProvider | undefined; +} + +type ModuleImporter = (specifier: string) => Promise; + +let piAiProviderRegistryModulePromise: Promise | undefined; export async function generateShortSessionName(modelRegistry: ModelRegistry, model: Model, firstMessage: string): Promise { - const provider = getApiProvider(model.api); + const providerRegistry = await getPiAiProviderRegistryModule(); + const provider = providerRegistry.getApiProvider?.(model.api); if (provider === undefined) return undefined; const auth = await modelRegistry.getApiKeyAndHeaders(model); @@ -67,6 +81,42 @@ export function cleanSessionName(value: string): string | undefined { return title === "" ? undefined : title; } +async function getPiAiProviderRegistryModule(importer: ModuleImporter = (specifier) => import(specifier)): Promise { + piAiProviderRegistryModulePromise ??= loadPiAiProviderRegistryModule(importer); + return piAiProviderRegistryModulePromise; +} + +async function loadPiAiProviderRegistryModule(importer: ModuleImporter): Promise { + const compatModule = await importOptionalPiAiModule(PI_AI_COMPAT_MODULE, importer); + if (hasGetApiProvider(compatModule)) return compatModule; + + const rootModule = await importer("@earendil-works/pi-ai"); + if (hasGetApiProvider(rootModule)) return rootModule; + return {}; +} + +async function importOptionalPiAiModule(specifier: string, importer: ModuleImporter): Promise { + try { + return await importer(specifier); + } catch (error) { + if (isModuleUnavailableError(error)) return undefined; + throw error; + } +} + +function hasGetApiProvider(moduleValue: unknown): moduleValue is PiAiProviderRegistryModule { + return typeof moduleValue === "object" + && moduleValue !== null + && "getApiProvider" in moduleValue + && typeof moduleValue.getApiProvider === "function"; +} + +function isModuleUnavailableError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const code = "code" in error ? error.code : undefined; + return code === "ERR_MODULE_NOT_FOUND" || code === "ERR_PACKAGE_PATH_NOT_EXPORTED"; +} + function textFromAssistant(message: AssistantMessage): string { return message.content .filter((part) => part.type === "text") From 3134421652909c4272d565fb736f1c46520c4074 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 24 Jun 2026 09:17:26 +0200 Subject: [PATCH 06/24] chore(release): v1.202606.6 --- .changeset/sessiond-pi-ai-compat.md | 5 ----- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 9 insertions(+), 8 deletions(-) delete mode 100644 .changeset/sessiond-pi-ai-compat.md diff --git a/.changeset/sessiond-pi-ai-compat.md b/.changeset/sessiond-pi-ai-compat.md deleted file mode 100644 index 43470e1..0000000 --- a/.changeset/sessiond-pi-ai-compat.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -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. 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/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", From 9980027cee2951367abba63be851035819828028 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 24 Jun 2026 23:59:39 +0200 Subject: [PATCH 07/24] feat: expose prompt helper to workspace panels --- .changeset/plugin-panel-prompt-context.md | 5 ++++ docs/plugins.md | 7 +++-- src/client/src/components/PiWebApp.ts | 1 + src/client/src/plugins/registry.test.ts | 34 ++++++++++++++++++++++- src/client/src/plugins/types.ts | 1 + src/plugin-api.ts | 1 + 6 files changed, 45 insertions(+), 4 deletions(-) create mode 100644 .changeset/plugin-panel-prompt-context.md 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/docs/plugins.md b/docs/plugins.md index 5a36291..e328513 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -467,7 +467,7 @@ Notes: ### Prompt editor API -The `prompt` helper on `PluginRuntimeContext` provides stable access to the chat prompt editor: +The `prompt` helper on `PluginRuntimeContext` and `WorkspacePanelContext` provides stable access to the chat prompt editor: | Method | Description | | --- | --- | @@ -486,7 +486,7 @@ 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. +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 @@ -549,6 +549,7 @@ interface WorkspacePanelContext { deleteFile(path: string): Promise; moveFile(fromPath: string, toPath: string, options?: MoveWorkspaceFileOptions): Promise; }; + prompt: PluginPromptEditor; terminal: { open(options?: { terminalId?: string }): void; runCommand(input: { @@ -566,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. The `files` helper supports `readFile`, `writeFile`, `deleteFile`, and `moveFile` — see [Reading workspace files](#reading-workspace-files) and [Writing workspace files](#writing-workspace-files). 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()`. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 38c8318..f520115 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1250,6 +1250,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 }), diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 82edc7a..5356926 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -89,6 +89,37 @@ describe("PluginRegistry", () => { expect(registry.getWorkspacePanels()[0]?.icon).toBeDefined(); }); + it("exposes the prompt helper to workspace panel callbacks", () => { + const registry = new PluginRegistry(); + registry.register({ + id: "example", + plugin: { + apiVersion: 1, + name: "Example", + activate: () => ({ + contributions: { + workspacePanels: [ + { + id: "workspace.prompt", + title: "Prompt", + render: (context) => { + context.prompt.insertText("@docs/example.md"); + return html`

Prompt

`; + }, + }, + ], + }, + }), + }, + }); + const insertText = vi.fn(); + const context = createWorkspacePanelContext("local", { insertText, getText: vi.fn(() => ""), getSelection: vi.fn(() => null) }); + + registry.getWorkspacePanels()[0]?.render(context); + + expect(insertText).toHaveBeenCalledWith("@docs/example.md"); + }); + it("rejects duplicate ids within the same namespace", () => { const registry = new PluginRegistry(); @@ -561,13 +592,14 @@ function createWorkspaceLabelContext(machineId: string, workspace = testWorkspac }; } -function createWorkspacePanelContext(machineId: string): WorkspacePanelContext { +function createWorkspacePanelContext(machineId: string, prompt: WorkspacePanelContext["prompt"] = { insertText: vi.fn(), getText: vi.fn(() => ""), getSelection: vi.fn(() => null) }): WorkspacePanelContext { const workspace = testWorkspace(); return { machine: { id: machineId, name: machineId, kind: machineId === "local" ? "local" : "remote" }, workspace, state: { ...initialAppState(), selectedMachine: testMachine(machineId) }, files: { readFile: vi.fn(), writeFile: vi.fn(), deleteFile: vi.fn(), moveFile: vi.fn() }, + prompt, terminal: { open: vi.fn(), runCommand: vi.fn() }, host: { requestRender: vi.fn() }, fileTree: [], diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index 355b955..083db9a 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -138,6 +138,7 @@ export interface QualifiedPluginAction extends AppAction { } export interface WorkspacePanelContext extends WorkspaceContext { + prompt: PluginPromptEditor; terminal: WorkspacePanelTerminal; /** * @deprecated Runtime-only compatibility alias for pre-v2 plugins. Use `terminal.open()` instead. diff --git a/src/plugin-api.ts b/src/plugin-api.ts index aa83cdc..1b8443a 100644 --- a/src/plugin-api.ts +++ b/src/plugin-api.ts @@ -168,6 +168,7 @@ export interface WorkspacePanelTerminal { } export interface WorkspacePanelContext extends WorkspaceContext { + prompt: PluginPromptEditor; terminal: WorkspacePanelTerminal; } From a99696bd09e437fa7d475ebcb80ad9a0364fbb3a Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 25 Jun 2026 00:20:19 +0200 Subject: [PATCH 08/24] fix: persist tracked subsession links --- .changeset/persist-subsession-links.md | 5 + src/server/sessions/piSessionService.test.ts | 371 ++++++++++++++++++- src/server/sessions/piSessionService.ts | 325 +++++++++++++++- 3 files changed, 692 insertions(+), 9 deletions(-) create mode 100644 .changeset/persist-subsession-links.md 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/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index 9de755f..543105a 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -1,3 +1,6 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import { describe, expect, it, vi } from "vitest"; import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js"; @@ -32,11 +35,12 @@ interface TestSession extends PiAgentSession { getFollowUpMessages: () => readonly string[]; } -function fakeSessionManager(cwd = "/workspace"): PiSessionManager { +function fakeSessionManager(cwd = "/workspace", patch: Partial = {}): PiSessionManager { return { getCwd: () => cwd, getBranch: () => [], getLeafId: () => "leaf-1", + ...patch, }; } @@ -141,6 +145,16 @@ function sessionGateway(records: ReturnType[]): SessionGat }; } +function emptyArchiveStore(): NonNullable { + return { + list: () => Promise.resolve([]), + get: () => Promise.resolve(undefined), + archive: () => Promise.reject(new Error("archive should not be called")), + restore: () => Promise.resolve(), + isArchived: () => Promise.resolve(false), + }; +} + describe("PiSessionService", () => { it("starts sessions through an injected runtime creator", async () => { const hub = new CapturingSessionEventHub(); @@ -887,6 +901,361 @@ describe("PiSessionService", () => { await service.dispose(); }); + it("persists tracked child links in the parent and child sessions", async () => { + const parentPersisted: { customType: string; data?: unknown }[] = []; + const childPersisted: { customType: string; data?: unknown }[] = []; + const parent = fakeRuntime("parent-1", { + sessionFile: "/tmp/parent-1.jsonl", + sessionManager: fakeSessionManager("/workspace", { + appendCustomEntry: (customType, data) => { + parentPersisted.push({ customType, data }); + return "parent-entry-1"; + }, + }), + }); + const child = fakeRuntime("child-1", { + sessionFile: "/tmp/child-1.jsonl", + sessionManager: fakeSessionManager("/workspace-feature", { + appendCustomEntry: (customType, data) => { + childPersisted.push({ customType, data }); + return "child-entry-1"; + }, + }), + }); + const runtimes = [parent.runtime, child.runtime]; + let index = 0; + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? child.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: sessionGateway([]), + archiveStore: emptyArchiveStore(), + spawnTargets: { resolveSpawnTarget: () => Promise.resolve({ allowed: true, cwd: "/workspace-feature" }) }, + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "do the slice", cwd: "/workspace-feature" }); + + expect(parentPersisted).toEqual([ + { + customType: "pi-web.subsession.link", + data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/tmp/child-1.jsonl", cwd: "/workspace-feature" }, + }, + ]); + expect(childPersisted).toEqual([ + { + customType: "pi-web.subsession.spawned", + data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" }, + }, + ]); + await service.dispose(); + }); + + it("hydrates persisted child links after a service restart so the parent can inspect them", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-")); + const parentFile = join(tempDir, "parent.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getBranch: () => [{ type: "message", message: { role: "assistant", content: "finished" } }], + }); + const parent = fakeRuntime("parent-1", { + sessionFile: parentFile, + sessionManager: fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], + }), + }); + const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); + const runtimes = [parent.runtime, child.runtime]; + let index = 0; + const open = vi.fn(() => childManager); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? child.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + + await expect(service.checkSubsession("parent-1", "child-1")).resolves.toEqual({ + sessionId: "child-1", + cwd: "/workspace-feature", + status: "idle", + finalText: "finished", + messageCount: 1, + }); + expect(open).toHaveBeenCalledWith(childFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("ignores stale persisted child links when the child no longer records the parent", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-stale-")); + const parentFile = join(tempDir, "parent.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature" })}\n`, "utf8"); + + try { + const parent = fakeRuntime("parent-1", { + sessionFile: parentFile, + sessionManager: fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], + }), + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(parent.runtime), + sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + + await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("hydrates persisted links to archived children without scanning unrelated child headers", async () => { + const parentFile = "/sessions/parent-1.jsonl"; + const parent = fakeRuntime("parent-1", { + sessionFile: parentFile, + sessionManager: fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/sessions/child-1.jsonl", cwd: "/workspace-feature" } }], + }), + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(parent.runtime), + sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, + archiveStore: { + ...emptyArchiveStore(), + list: () => Promise.resolve([]), + get: (sessionId) => Promise.resolve(sessionId === "child-1" ? { sessionId: "child-1", cwd: "/workspace-feature", archivedAt: "2026-01-01T00:00:00.000Z", parentSessionPath: parentFile } : undefined), + isArchived: (sessionId) => Promise.resolve(sessionId === "child-1"), + }, + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + + await expect(service.listSubsessions("parent-1")).resolves.toEqual([ + { sessionId: "child-1", cwd: "/workspace-feature", status: "archived" }, + ]); + await service.dispose(); + }); + + it("does not hydrate parent links without a child file or exact archived child validation", async () => { + const parentFile = "/sessions/parent-1.jsonl"; + const parent = fakeRuntime("parent-1", { + sessionFile: parentFile, + sessionManager: fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child", cwd: "/workspace-feature" } }], + }), + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(parent.runtime), + sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, + archiveStore: { + ...emptyArchiveStore(), + get: (sessionId) => Promise.resolve(sessionId === "child" ? { sessionId: "child-fork", cwd: "/workspace-feature", archivedAt: "2026-01-01T00:00:00.000Z", parentSessionPath: parentFile } : undefined), + }, + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + + await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); + await service.dispose(); + }); + + it("does not invent subsession links from existing child session headers", async () => { + const parentFile = "/sessions/parent-1.jsonl"; + const childRecord = { ...sessionRecord("child-1", "/workspace-feature"), path: "/sessions/child-1.jsonl", parentSessionPath: parentFile }; + const parent = fakeRuntime("parent-1", { + sessionFile: parentFile, + sessionManager: fakeSessionManager("/workspace", { getEntries: () => [] }), + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(parent.runtime), + sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([childRecord]), open: () => fakeSessionManager() }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + + await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); + await service.dispose(); + }); + + it("does not hydrate copied parent links when the opened parent has a different id", async () => { + const forkedParent = fakeRuntime("parent-fork-1", { + sessionFile: "/sessions/parent-fork-1.jsonl", + sessionManager: fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: "/sessions/child-1.jsonl", cwd: "/workspace-feature" } }], + }), + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(forkedParent.runtime), + sessionManager: { create: () => forkedParent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.start("/workspace"); + + await expect(service.listSubsessions("parent-fork-1")).resolves.toEqual([]); + await service.dispose(); + }); + + it("relinks a spawned child when the child session is opened after restart", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-open-child-")); + const parentFile = join(tempDir, "parent.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getHeader: () => ({ parentSession: parentFile }), + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + }); + const parentManager = fakeSessionManager("/workspace"); + const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); + const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); + const runtimes = [child.runtime, parent.runtime]; + let index = 0; + const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? parent.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: { + create: () => childManager, + list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-1", "/workspace-feature")); + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(parent.calls.sendCustomMessage).toHaveLength(1); + expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); + expect(open).toHaveBeenCalledWith(parentFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("does not relink a child marker when the child header points at a different parent id", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-wrong-parent-")); + const mismatchedParentFile = join(tempDir, "other-parent.jsonl"); + const actualParentFile = join(tempDir, "parent.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(mismatchedParentFile, `${JSON.stringify({ type: "session", version: 3, id: "other-parent", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: mismatchedParentFile })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getHeader: () => ({ parentSession: mismatchedParentFile }), + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + }); + const parent = fakeRuntime("parent-1", { sessionFile: actualParentFile, sessionManager: fakeSessionManager("/workspace") }); + const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); + const runtimes = [child.runtime, parent.runtime]; + let index = 0; + const open = vi.fn((path: string) => path === actualParentFile ? parent.session.sessionManager : childManager); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? parent.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: { + create: () => childManager, + list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: mismatchedParentFile }]), + listAll: () => Promise.resolve([{ ...sessionRecord("parent-1", "/workspace"), path: actualParentFile }]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-1", "/workspace-feature")); + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(parent.calls.sendCustomMessage).toHaveLength(0); + expect(open).not.toHaveBeenCalledWith(actualParentFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("does not relink copied child markers when the opened child has a different id", async () => { + const parentFile = "/sessions/parent-1.jsonl"; + const childFile = "/sessions/child-fork-1.jsonl"; + const childManager = fakeSessionManager("/workspace-feature", { + getHeader: () => ({ parentSession: parentFile }), + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + }); + const child = fakeRuntime("child-fork-1", { sessionFile: childFile, sessionManager: childManager }); + const open = vi.fn(() => childManager); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(child.runtime), + sessionManager: { + create: () => childManager, + list: () => Promise.resolve([{ ...sessionRecord("child-fork-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-fork-1", "/workspace-feature")); + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(open).not.toHaveBeenCalledWith(parentFile); + await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); + await service.dispose(); + }); + it("notifies the parent once when the tracked child stops working", async () => { const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); await service.start("/workspace"); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 3562925..b414534 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -1,4 +1,4 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { open, readFile, writeFile } from "node:fs/promises"; import type { Api, ImageContent, Model } from "@earendil-works/pi-ai"; import { AuthStorage, @@ -81,6 +81,26 @@ interface QueuedPrompt { echoUserMessage?: boolean; } +interface TrackedSubsessionLink { + parentSessionId: string; + childSessionId: string; + childSessionFile?: string; + parentSessionFile?: string; + cwd?: string; +} + +interface PersistedParentSubsessionLink { + spawnedBySessionId: string; + spawnedSessionId: string; + spawnedSessionFile?: string; + cwd?: string; +} + +interface PersistedChildSubsessionLink { + spawnedBySessionId: string; + spawnedSessionId: string; +} + function requirePromptText(value: unknown): string { if (typeof value !== "string") throw new Error("Prompt text is required"); return value; @@ -123,8 +143,10 @@ type ModelRegistryInstance = ReturnType; export interface PiSessionManager { getCwd(): string; getBranch(): unknown[]; + getEntries?(): readonly unknown[]; getLeafId(): string | null; getHeader?(): { parentSession?: string } | null | undefined; + appendCustomEntry?(customType: string, data?: unknown): string; } export interface PiSessionManagerGateway { @@ -290,6 +312,10 @@ export class PiSessionService { private readonly subsessionParents = new Map(); /** Parent session id -> the set of tracked subsession ids it spawned. */ private readonly subsessionChildren = new Map>(); + /** Tracked subsession id -> persisted recovery details for the child. */ + private readonly subsessionLinks = new Map(); + /** Parent session ids whose persisted links have already been loaded. */ + private readonly subsessionHydratedParents = new Set(); /** * Tracked subsession id -> whether a completion notification is armed. * Armed when the child starts working; firing on completion disarms it so a @@ -362,6 +388,8 @@ export class PiSessionService { this.authLossWarnings.clear(); this.subsessionParents.clear(); this.subsessionChildren.clear(); + this.subsessionLinks.clear(); + this.subsessionHydratedParents.clear(); this.subsessionNotifyArmed.clear(); await Promise.all(activeSessions.map(async (active) => { active.unsubscribe(); @@ -439,7 +467,16 @@ export class PiSessionService { const decision = await this.spawnTargets.resolveSpawnTarget(input.spawningCwd, input.cwd); if (!decision.allowed) throw spawnTargetError(decision); const created = await this.start(decision.cwd, input.parentSessionFile); - this.registerSubsession(input.parentSessionId, created.id); + const parentSessionFile = nonEmptyString(input.parentSessionFile); + const link = { + childSessionId: created.id, + ...(created.path === "" ? {} : { childSessionFile: created.path }), + ...(parentSessionFile === undefined ? {} : { parentSessionFile }), + cwd: decision.cwd, + }; + this.registerSubsession(input.parentSessionId, link); + this.persistSubsessionLink(input.parentSessionId, link); + this.persistSubsessionChildMarker(input.parentSessionId, created.id); await this.prompt(created.id, input.prompt); this.logger.info( { parentSessionId: input.parentSessionId, sessionId: created.id, cwd: decision.cwd, promptLength: input.prompt.length }, @@ -450,6 +487,7 @@ export class PiSessionService { /** Summaries of the tracked subsessions spawned by `parentSessionId`. */ async listSubsessions(parentSessionId: string): Promise { + await this.hydrateSubsessionsForParent(parentSessionId); const childIds = this.subsessionChildren.get(parentSessionId); if (childIds === undefined) return []; return Promise.all([...childIds].map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) }))); @@ -482,18 +520,144 @@ export class PiSessionService { /** Open a session after verifying it is one of the caller's tracked children. */ private async openSubsession(parentSessionId: string, sessionId: string): Promise { + await this.hydrateSubsessionsForParent(parentSessionId); if (this.subsessionParents.get(sessionId) !== parentSessionId) { throw new Error(`Session ${sessionId} is not one of your subsessions`); } - return this.getOrOpen(sessionId); + return this.getOrOpenTrackedSubsession(sessionId); } - private registerSubsession(parentSessionId: string, childSessionId: string): void { + private registerSubsession(parentSessionId: string, link: Omit): void { + const childSessionId = link.childSessionId; + const previousParentId = this.subsessionParents.get(childSessionId); + if (previousParentId !== undefined && previousParentId !== parentSessionId) { + const previousChildren = this.subsessionChildren.get(previousParentId); + previousChildren?.delete(childSessionId); + if (previousChildren?.size === 0) this.subsessionChildren.delete(previousParentId); + } + this.subsessionParents.set(childSessionId, parentSessionId); const children = this.subsessionChildren.get(parentSessionId) ?? new Set(); children.add(childSessionId); this.subsessionChildren.set(parentSessionId, children); - this.subsessionNotifyArmed.set(childSessionId, false); + + const previous = this.subsessionLinks.get(childSessionId); + this.subsessionLinks.set(childSessionId, mergeSubsessionLink(previous, { ...link, parentSessionId })); + if (!this.subsessionNotifyArmed.has(childSessionId)) this.subsessionNotifyArmed.set(childSessionId, false); + } + + private unregisterSubsession(childSessionId: string): void { + const parentSessionId = this.subsessionParents.get(childSessionId); + this.subsessionParents.delete(childSessionId); + this.subsessionLinks.delete(childSessionId); + this.subsessionNotifyArmed.delete(childSessionId); + if (parentSessionId === undefined) return; + const children = this.subsessionChildren.get(parentSessionId); + children?.delete(childSessionId); + if (children?.size === 0) this.subsessionChildren.delete(parentSessionId); + } + + private persistSubsessionLink(parentSessionId: string, link: Omit): void { + const parent = this.active.get(parentSessionId)?.runtime.session; + if (parent === undefined) return; + if (parent.sessionManager.appendCustomEntry === undefined) return; + try { + parent.sessionManager.appendCustomEntry(SUBSESSION_LINK_CUSTOM_TYPE, persistedParentSubsessionLinkData(parentSessionId, link)); + } catch (error: unknown) { + this.logger.info( + { parentSessionId, sessionId: link.childSessionId, error: error instanceof Error ? error.message : String(error) }, + "failed to persist subsession link", + ); + } + } + + private persistSubsessionChildMarker(parentSessionId: string, childSessionId: string): void { + const child = this.active.get(childSessionId)?.runtime.session; + if (child === undefined) return; + if (child.sessionManager.appendCustomEntry === undefined) return; + try { + child.sessionManager.appendCustomEntry(SUBSESSION_CHILD_LINK_CUSTOM_TYPE, persistedChildSubsessionLinkData(parentSessionId, childSessionId)); + } catch (error: unknown) { + this.logger.info( + { parentSessionId, sessionId: childSessionId, error: error instanceof Error ? error.message : String(error) }, + "failed to persist subsession child marker", + ); + } + } + + private async hydrateSubsessionsForParent(parentSessionId: string): Promise { + if (this.subsessionHydratedParents.has(parentSessionId)) return; + const parent = this.active.get(parentSessionId)?.runtime.session; + if (parent === undefined) return; + + const parentSessionFile = nonEmptyString(parent.sessionFile); + await this.registerPersistedSubsessionLinks(parentSessionId, parent, parentSessionFile); + this.subsessionHydratedParents.add(parentSessionId); + } + + private async registerPersistedSubsessionLinks(parentSessionId: string, parent: PiAgentSession, parentSessionFile: string | undefined): Promise { + const entries = parent.sessionManager.getEntries?.() ?? parent.sessionManager.getBranch(); + for (const entry of entries) { + const link = parsePersistedParentSubsessionLink(entry); + if (link === undefined) continue; + if (link.spawnedBySessionId !== parentSessionId) continue; + if (!await this.persistedSubsessionLinkMatchesParent(parentSessionFile, link)) continue; + this.registerSubsession(parentSessionId, trackedSubsessionLinkFromParentLink(link, parentSessionFile)); + } + } + + private async persistedSubsessionLinkMatchesParent(parentSessionFile: string | undefined, link: PersistedParentSubsessionLink): Promise { + if (parentSessionFile === undefined) return false; + if (link.spawnedSessionFile !== undefined) { + const header = await readSessionHeaderSummary(link.spawnedSessionFile); + if (header?.id === link.spawnedSessionId) { + return header.parentSession !== undefined && sessionPathsEqual(header.parentSession, parentSessionFile); + } + } + + const archived = await this.getArchivedExact(link.spawnedSessionId); + return archived?.parentSessionPath !== undefined && sessionPathsEqual(archived.parentSessionPath, parentSessionFile); + } + + private async recoverSubsessionTrackingForOpenedSession(session: PiAgentSession): Promise { + const entries = session.sessionManager.getEntries?.() ?? session.sessionManager.getBranch(); + let marker: PersistedChildSubsessionLink | undefined; + for (const entry of entries) { + const parsed = parsePersistedChildSubsessionLink(entry); + if (parsed?.spawnedSessionId === session.sessionId) marker = parsed; + } + if (marker === undefined) return; + + const parentSessionFile = await parentSessionFileForSession(session); + if (parentSessionFile === undefined) return; + const parentHeader = await readSessionHeaderSummary(parentSessionFile); + if (parentHeader?.id !== marker.spawnedBySessionId) return; + const childSessionFile = nonEmptyString(session.sessionFile); + this.registerSubsession(marker.spawnedBySessionId, { + childSessionId: session.sessionId, + ...(childSessionFile === undefined ? {} : { childSessionFile }), + parentSessionFile, + cwd: session.sessionManager.getCwd(), + }); + } + + private async getOrOpenTrackedSubsession(sessionId: string): Promise { + const active = this.active.get(sessionId); + if (active !== undefined) return active.runtime.session; + + const archived = await this.getArchivedExact(sessionId); + if (archived?.archivePath !== undefined) return (await this.create(this.sessionManager.open(archived.archivePath), archived.cwd)).runtime.session; + + const link = this.subsessionLinks.get(sessionId); + if (link?.childSessionFile !== undefined) { + const header = await readSessionHeaderSummary(link.childSessionFile); + if (header?.id === sessionId) { + const sessionManager = this.sessionManager.open(link.childSessionFile); + return (await this.create(sessionManager, link.cwd ?? sessionManager.getCwd())).runtime.session; + } + } + + return this.getOrOpen(sessionId); } private async subsessionSummaryFields(childSessionId: string): Promise<{ cwd: string; status: SubsessionStatus }> { @@ -501,13 +665,18 @@ export class PiSessionService { if (active !== undefined) { return { cwd: active.runtime.cwd, status: await this.subsessionStatus(active.runtime.session) }; } - const archived = await this.archiveStore.get(childSessionId); + const archived = await this.getArchivedExact(childSessionId); if (archived !== undefined) return { cwd: archived.cwd, status: "archived" }; + const link = this.subsessionLinks.get(childSessionId); + if (link?.childSessionFile !== undefined && (await readSessionHeaderSummary(link.childSessionFile))?.id === childSessionId) { + return { cwd: link.cwd ?? "", status: "idle" }; + } + if (link?.cwd !== undefined) return { cwd: link.cwd, status: "unknown" }; return { cwd: "", status: "unknown" }; } private async subsessionStatus(session: PiAgentSession): Promise { - if (await this.archiveStore.isArchived(session.sessionId)) return "archived"; + if (await this.getArchivedExact(session.sessionId) !== undefined) return "archived"; if (this.hasActiveWork(session)) return "working"; if (this.activities.get(session.sessionId)?.phase === "error") return "error"; return "idle"; @@ -536,6 +705,19 @@ export class PiSessionService { void this.notifyParentOfSubsession(parentId, childId, text); } + private async getOrOpenParentForSubsession(parentSessionId: string, childSessionId: string): Promise { + const active = this.activeForLookup(parentSessionId); + if (active !== undefined) return active.runtime.session; + + const parentSessionFile = this.subsessionLinks.get(childSessionId)?.parentSessionFile; + if (parentSessionFile !== undefined && (await readSessionHeaderSummary(parentSessionFile))?.id === parentSessionId) { + const sessionManager = this.sessionManager.open(parentSessionFile); + return (await this.create(sessionManager, sessionManager.getCwd())).runtime.session; + } + + return this.getOrOpen(parentSessionId); + } + /** * Deliver a subsession-completion notice to the parent as a system-authored * custom message rather than a user message, so it is not attributed to the @@ -545,7 +727,7 @@ export class PiSessionService { */ private async notifyParentOfSubsession(parentId: string, childId: string, text: string): Promise { try { - const session = await this.getOrOpen(parentId); + const session = await this.getOrOpenParentForSubsession(parentId, childId); await session.sendCustomMessage( { customType: SUBSESSION_NOTIFICATION_CUSTOM_TYPE, content: text, display: true, details: { sessionId: childId } }, { triggerTurn: true, deliverAs: "followUp" }, @@ -809,6 +991,8 @@ export class PiSessionService { const sessionFile = session.sessionFile; if (sessionFile === undefined || sessionFile === "") throw new Error("Session is not persisted"); await clearParentSession(sessionFile); + clearParentSessionHeader(session.sessionManager); + this.unregisterSubsession(session.sessionId); } async abort(ref: PiSessionLookup): Promise { @@ -961,6 +1145,11 @@ export class PiSessionService { return archived; } + private async getArchivedExact(sessionId: string): Promise { + const archived = await this.archiveStore.get(sessionId); + return archived?.sessionId === sessionId ? archived : undefined; + } + private activeForLookup(ref: PiSessionLookup): ActiveSession | undefined { const sessionId = sessionIdFromLookup(ref); const exact = this.active.get(sessionId); @@ -979,8 +1168,10 @@ export class PiSessionService { runtime.setRebindSession(async (session) => { await this.bindSessionExtensions(session); this.bindRuntime(active); + await this.recoverSubsessionTrackingForOpenedSession(session); }); this.active.set(runtime.session.sessionId, active); + await this.recoverSubsessionTrackingForOpenedSession(runtime.session); this.publishStatus(runtime.session); return active; } @@ -1411,6 +1602,113 @@ function isDefined(value: T | undefined): value is T { return value !== undefined; } +function mergeSubsessionLink(previous: TrackedSubsessionLink | undefined, next: TrackedSubsessionLink): TrackedSubsessionLink { + return { + parentSessionId: next.parentSessionId, + childSessionId: next.childSessionId, + ...(previous?.childSessionFile === undefined ? {} : { childSessionFile: previous.childSessionFile }), + ...(previous?.parentSessionFile === undefined ? {} : { parentSessionFile: previous.parentSessionFile }), + ...(previous?.cwd === undefined ? {} : { cwd: previous.cwd }), + ...(next.childSessionFile === undefined ? {} : { childSessionFile: next.childSessionFile }), + ...(next.parentSessionFile === undefined ? {} : { parentSessionFile: next.parentSessionFile }), + ...(next.cwd === undefined ? {} : { cwd: next.cwd }), + }; +} + +function trackedSubsessionLinkFromParentLink(link: PersistedParentSubsessionLink, parentSessionFile: string | undefined): Omit { + return { + childSessionId: link.spawnedSessionId, + ...(link.spawnedSessionFile === undefined ? {} : { childSessionFile: link.spawnedSessionFile }), + ...(parentSessionFile === undefined ? {} : { parentSessionFile }), + ...(link.cwd === undefined ? {} : { cwd: link.cwd }), + }; +} + +function persistedParentSubsessionLinkData(parentSessionId: string, link: Omit): Record { + return { + version: 1, + spawnedBySessionId: parentSessionId, + spawnedSessionId: link.childSessionId, + ...(link.childSessionFile === undefined ? {} : { spawnedSessionFile: link.childSessionFile }), + ...(link.cwd === undefined ? {} : { cwd: link.cwd }), + }; +} + +function persistedChildSubsessionLinkData(parentSessionId: string, childSessionId: string): Record { + return { + version: 1, + spawnedBySessionId: parentSessionId, + spawnedSessionId: childSessionId, + }; +} + +function parsePersistedParentSubsessionLink(entry: unknown): PersistedParentSubsessionLink | undefined { + if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_LINK_CUSTOM_TYPE) return undefined; + const data = entry["data"]; + if (!isRecord(data)) return undefined; + const spawnedBySessionId = getString(data, "spawnedBySessionId"); + const spawnedSessionId = getString(data, "spawnedSessionId"); + if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "") return undefined; + const spawnedSessionFile = getString(data, "spawnedSessionFile"); + const cwd = getString(data, "cwd"); + return { + spawnedBySessionId, + spawnedSessionId, + ...(spawnedSessionFile === undefined || spawnedSessionFile === "" ? {} : { spawnedSessionFile }), + ...(cwd === undefined || cwd === "" ? {} : { cwd }), + }; +} + +function parsePersistedChildSubsessionLink(entry: unknown): PersistedChildSubsessionLink | undefined { + if (!isRecord(entry) || entry["type"] !== "custom" || entry["customType"] !== SUBSESSION_CHILD_LINK_CUSTOM_TYPE) return undefined; + const data = entry["data"]; + if (!isRecord(data)) return undefined; + const spawnedBySessionId = getString(data, "spawnedBySessionId"); + const spawnedSessionId = getString(data, "spawnedSessionId"); + if (spawnedBySessionId === undefined || spawnedBySessionId === "" || spawnedSessionId === undefined || spawnedSessionId === "") return undefined; + return { spawnedBySessionId, spawnedSessionId }; +} + +function nonEmptyString(value: string | undefined): string | undefined { + return value === undefined || value === "" ? undefined : value; +} + +function sessionPathsEqual(a: string, b: string): boolean { + return cwdPathsEqual(a, b); +} + +interface SessionHeaderSummary { + id: string; + parentSession?: string; +} + +async function readSessionHeaderSummary(sessionFile: string): Promise { + let file: Awaited> | undefined; + try { + file = await open(sessionFile, "r"); + const buffer = Buffer.alloc(4096); + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); + const firstLine = buffer.toString("utf8", 0, bytesRead).split("\n", 1)[0]; + if (firstLine === undefined || firstLine === "") return undefined; + const header: unknown = JSON.parse(firstLine); + if (!isRecord(header) || header["type"] !== "session" || typeof header["id"] !== "string") return undefined; + const parentSession = getString(header, "parentSession"); + return { id: header["id"], ...(parentSession === undefined ? {} : { parentSession }) }; + } catch { + return undefined; + } finally { + await file?.close().catch(() => undefined); + } +} + +async function parentSessionFileForSession(session: PiAgentSession): Promise { + const headerParentSession = nonEmptyString(session.sessionManager.getHeader?.()?.parentSession); + if (headerParentSession !== undefined) return headerParentSession; + const sessionFile = nonEmptyString(session.sessionFile); + if (sessionFile === undefined) return undefined; + return (await readSessionHeaderSummary(sessionFile))?.parentSession; +} + async function clearParentSession(sessionFile: string): Promise { const content = await readFile(sessionFile, "utf8"); const newlineIndex = content.indexOf("\n"); @@ -1423,6 +1721,11 @@ async function clearParentSession(sessionFile: string): Promise { await writeFile(sessionFile, `${JSON.stringify(header)}${rest}`, "utf8"); } +function clearParentSessionHeader(sessionManager: PiSessionManager): void { + const header = sessionManager.getHeader?.(); + if (header !== undefined && header !== null) delete header.parentSession; +} + function clearSessionQueue(session: PiAgentSession): void { session.clearQueue(); } @@ -1475,6 +1778,12 @@ function historyMessages(session: PiAgentSession): unknown[] { return messages; } +/** custom entry type used to persist parent -> child subsession links outside LLM context. */ +const SUBSESSION_LINK_CUSTOM_TYPE = "pi-web.subsession.link"; + +/** custom entry type used to mark a child as created by spawn_subsession. */ +const SUBSESSION_CHILD_LINK_CUSTOM_TYPE = "pi-web.subsession.spawned"; + /** customType marking a parent-facing subsession-completion notice. */ const SUBSESSION_NOTIFICATION_CUSTOM_TYPE = "subsession.completion"; From 417b04a23ff5488c2945e9e5d830ede5127d572e Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 25 Jun 2026 00:30:16 +0200 Subject: [PATCH 09/24] fix: harden subsession recovery validation --- src/server/sessions/piSessionService.test.ts | 119 ++++++++++++++++++- src/server/sessions/piSessionService.ts | 41 +++++-- 2 files changed, 151 insertions(+), 9 deletions(-) diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index 543105a..cb72342 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -1136,7 +1136,9 @@ describe("PiSessionService", () => { getHeader: () => ({ parentSession: parentFile }), getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], }); - const parentManager = fakeSessionManager("/workspace"); + const parentManager = fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], + }); const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); const runtimes = [child.runtime, parent.runtime]; @@ -1174,6 +1176,121 @@ describe("PiSessionService", () => { } }); + it("notifies the validated parent file instead of an active prefix-matched parent id", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-prefix-parent-")); + const parentFile = join(tempDir, "parent.jsonl"); + const forkParentFile = join(tempDir, "parent-fork.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(forkParentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1-fork", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getHeader: () => ({ parentSession: parentFile }), + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + }); + const parentManager = fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], + }); + const forkManager = fakeSessionManager("/workspace"); + const fork = fakeRuntime("parent-1-fork", { sessionFile: forkParentFile, sessionManager: forkManager }); + const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); + const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); + const runtimes = [fork.runtime, child.runtime, parent.runtime]; + let index = 0; + const open = vi.fn((path: string) => { + if (path === parentFile) return parentManager; + if (path === forkParentFile) return forkManager; + return childManager; + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? parent.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: { + create: () => forkManager, + list: (cwd: string) => Promise.resolve(cwd === "/workspace" + ? [{ ...sessionRecord("parent-1-fork", "/workspace"), path: forkParentFile }] + : [{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("parent-1-fork", "/workspace")); + await service.status(sessionRef("child-1", "/workspace-feature")); + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(fork.calls.sendCustomMessage).toHaveLength(0); + expect(parent.calls.sendCustomMessage).toHaveLength(1); + expect(open).toHaveBeenCalledWith(parentFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("does not relink a copied child with the original session id unless the parent link names the current child file", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-copied-child-")); + const parentFile = join(tempDir, "parent.jsonl"); + const originalChildFile = join(tempDir, "original-child.jsonl"); + const copiedChildFile = join(tempDir, "copied-child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(originalChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + await writeFile(copiedChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getHeader: () => ({ parentSession: parentFile }), + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + }); + const parentManager = fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: originalChildFile, cwd: "/workspace-feature" } }], + }); + const child = fakeRuntime("child-1", { sessionFile: copiedChildFile, sessionManager: childManager }); + const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); + const runtimes = [child.runtime, parent.runtime]; + let index = 0; + const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? parent.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: { + create: () => childManager, + list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: copiedChildFile, parentSessionPath: parentFile }]), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-1", "/workspace-feature")); + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(parent.calls.sendCustomMessage).toHaveLength(0); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + it("does not relink a child marker when the child header points at a different parent id", async () => { const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-wrong-parent-")); const mismatchedParentFile = join(tempDir, "other-parent.jsonl"); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index b414534..e2bc43f 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -633,14 +633,35 @@ export class PiSessionService { const parentHeader = await readSessionHeaderSummary(parentSessionFile); if (parentHeader?.id !== marker.spawnedBySessionId) return; const childSessionFile = nonEmptyString(session.sessionFile); + if (childSessionFile === undefined) return; + const hasReciprocalLink = await this.parentHasReciprocalSubsessionLink(parentSessionFile, marker.spawnedBySessionId, session.sessionId, childSessionFile); + if (!hasReciprocalLink) return; this.registerSubsession(marker.spawnedBySessionId, { childSessionId: session.sessionId, - ...(childSessionFile === undefined ? {} : { childSessionFile }), + childSessionFile, parentSessionFile, cwd: session.sessionManager.getCwd(), }); } + private async parentHasReciprocalSubsessionLink(parentSessionFile: string, parentSessionId: string, childSessionId: string, childSessionFile: string): Promise { + let parentManager: PiSessionManager; + try { + parentManager = this.sessionManager.open(parentSessionFile); + } catch { + return false; + } + const entries = parentManager.getEntries?.() ?? parentManager.getBranch(); + for (const entry of entries) { + const link = parsePersistedParentSubsessionLink(entry); + if (link === undefined) continue; + if (link.spawnedBySessionId !== parentSessionId || link.spawnedSessionId !== childSessionId) continue; + if (link.spawnedSessionFile === undefined || !sessionPathsEqual(link.spawnedSessionFile, childSessionFile)) continue; + if (await this.persistedSubsessionLinkMatchesParent(parentSessionFile, link)) return true; + } + return false; + } + private async getOrOpenTrackedSubsession(sessionId: string): Promise { const active = this.active.get(sessionId); if (active !== undefined) return active.runtime.session; @@ -657,7 +678,11 @@ export class PiSessionService { } } - return this.getOrOpen(sessionId); + const listed = link?.cwd === undefined + ? (await this.sessionManager.listAll?.() ?? []).find((session) => session.id === sessionId) + : (await this.sessionManager.list(link.cwd)).find((session) => session.id === sessionId); + if (listed === undefined) throw new Error("Session not found"); + return (await this.create(this.sessionManager.open(listed.path), listed.cwd)).runtime.session; } private async subsessionSummaryFields(childSessionId: string): Promise<{ cwd: string; status: SubsessionStatus }> { @@ -706,16 +731,16 @@ export class PiSessionService { } private async getOrOpenParentForSubsession(parentSessionId: string, childSessionId: string): Promise { - const active = this.activeForLookup(parentSessionId); + const active = this.active.get(parentSessionId); if (active !== undefined) return active.runtime.session; const parentSessionFile = this.subsessionLinks.get(childSessionId)?.parentSessionFile; - if (parentSessionFile !== undefined && (await readSessionHeaderSummary(parentSessionFile))?.id === parentSessionId) { - const sessionManager = this.sessionManager.open(parentSessionFile); - return (await this.create(sessionManager, sessionManager.getCwd())).runtime.session; + if (parentSessionFile === undefined) throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`); + if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) { + throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`); } - - return this.getOrOpen(parentSessionId); + const sessionManager = this.sessionManager.open(parentSessionFile); + return (await this.create(sessionManager, sessionManager.getCwd())).runtime.session; } /** From 47c9b668193d2bcada5fd2fdbd58e419dcc30845 Mon Sep 17 00:00:00 2001 From: Gilbert Date: Thu, 25 Jun 2026 10:54:49 +0800 Subject: [PATCH 10/24] fix: run doctor version checks under fish The doctor "can find npm/pi" checks wrap the version command in a POSIX subshell, `(cmd --version 2>&1 || true)`. Fish treats `( ... )` as command substitution syntax and forbids it in command position, so every fish user saw false-negative failures: fish: command substitutions not allowed in command position command -v npm && (npm --version 2>&1 || true) Branch on the detected service shell and emit fish's `begin; ...; end` grouping for fish, mirroring the existing fish-aware quoting in serviceShellQuote. Bash and zsh keep the POSIX subshell. Also guard the `main()` invocation with an ESM main-module check so the CLI helpers can be imported by tests without side effects, and add a regression test covering the bash, zsh, and fish command shapes. --- .changeset/fix-fish-doctor-version-check.md | 9 ++++++ src/cli.test.ts | 31 +++++++++++++++++++++ src/cli.ts | 18 ++++++++---- 3 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 .changeset/fix-fish-doctor-version-check.md create mode 100644 src/cli.test.ts 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/src/cli.test.ts b/src/cli.test.ts new file mode 100644 index 0000000..cb3849d --- /dev/null +++ b/src/cli.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { commandWithVersionCheck } 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("("); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 75d2c64..5e5f663 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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,9 @@ 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); -}); +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + }); +} From 5550c609504674ed2e82475241e03abe0562a9e9 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 25 Jun 2026 09:27:10 +0200 Subject: [PATCH 11/24] fix: enforce exact subsession recovery links --- src/server/sessions/piSessionService.test.ts | 54 +++++++ src/server/sessions/piSessionService.ts | 140 ++++++++++--------- 2 files changed, 127 insertions(+), 67 deletions(-) diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index cb72342..717c650 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -1291,6 +1291,60 @@ describe("PiSessionService", () => { } }); + it("does not relink a child marker when the current child file header no longer records the parent", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-stale-child-header-")); + const parentFile = join(tempDir, "parent.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature" })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getHeader: () => ({ parentSession: parentFile }), + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + }); + const parentManager = fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], + }); + const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); + const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); + const runtimes = [child.runtime, parent.runtime]; + let index = 0; + const open = vi.fn((path: string) => path === parentFile ? parentManager : childManager); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: () => { + const runtime = runtimes[index] ?? parent.runtime; + index += 1; + return Promise.resolve(runtime); + }, + sessionManager: { + create: () => childManager, + list: () => Promise.resolve([{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: { + ...emptyArchiveStore(), + get: (sessionId) => Promise.resolve(sessionId === "child-1" ? { sessionId: "child-1", cwd: "/workspace-feature", archivedAt: "2026-01-01T00:00:00.000Z", parentSessionPath: parentFile } : undefined), + }, + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-1", "/workspace-feature")); + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(parent.calls.sendCustomMessage).toHaveLength(0); + expect(open).not.toHaveBeenCalledWith(parentFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + it("does not relink a child marker when the child header points at a different parent id", async () => { const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-wrong-parent-")); const mismatchedParentFile = join(tempDir, "other-parent.jsonl"); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index e2bc43f..78e5a6b 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -468,14 +468,15 @@ export class PiSessionService { if (!decision.allowed) throw spawnTargetError(decision); const created = await this.start(decision.cwd, input.parentSessionFile); const parentSessionFile = nonEmptyString(input.parentSessionFile); - const link = { + const link: TrackedSubsessionLink = { + parentSessionId: input.parentSessionId, childSessionId: created.id, ...(created.path === "" ? {} : { childSessionFile: created.path }), ...(parentSessionFile === undefined ? {} : { parentSessionFile }), cwd: decision.cwd, }; - this.registerSubsession(input.parentSessionId, link); - this.persistSubsessionLink(input.parentSessionId, link); + this.registerVerifiedSubsession(link); + this.persistSubsessionLink(link); this.persistSubsessionChildMarker(input.parentSessionId, created.id); await this.prompt(created.id, input.prompt); this.logger.info( @@ -527,8 +528,8 @@ export class PiSessionService { return this.getOrOpenTrackedSubsession(sessionId); } - private registerSubsession(parentSessionId: string, link: Omit): void { - const childSessionId = link.childSessionId; + private registerVerifiedSubsession(link: TrackedSubsessionLink): void { + const { childSessionId, parentSessionId } = link; const previousParentId = this.subsessionParents.get(childSessionId); if (previousParentId !== undefined && previousParentId !== parentSessionId) { const previousChildren = this.subsessionChildren.get(previousParentId); @@ -541,8 +542,7 @@ export class PiSessionService { children.add(childSessionId); this.subsessionChildren.set(parentSessionId, children); - const previous = this.subsessionLinks.get(childSessionId); - this.subsessionLinks.set(childSessionId, mergeSubsessionLink(previous, { ...link, parentSessionId })); + this.subsessionLinks.set(childSessionId, link); if (!this.subsessionNotifyArmed.has(childSessionId)) this.subsessionNotifyArmed.set(childSessionId, false); } @@ -557,15 +557,15 @@ export class PiSessionService { if (children?.size === 0) this.subsessionChildren.delete(parentSessionId); } - private persistSubsessionLink(parentSessionId: string, link: Omit): void { - const parent = this.active.get(parentSessionId)?.runtime.session; + private persistSubsessionLink(link: TrackedSubsessionLink): void { + const parent = this.active.get(link.parentSessionId)?.runtime.session; if (parent === undefined) return; if (parent.sessionManager.appendCustomEntry === undefined) return; try { - parent.sessionManager.appendCustomEntry(SUBSESSION_LINK_CUSTOM_TYPE, persistedParentSubsessionLinkData(parentSessionId, link)); + parent.sessionManager.appendCustomEntry(SUBSESSION_LINK_CUSTOM_TYPE, persistedParentSubsessionLinkData(link)); } catch (error: unknown) { this.logger.info( - { parentSessionId, sessionId: link.childSessionId, error: error instanceof Error ? error.message : String(error) }, + { parentSessionId: link.parentSessionId, sessionId: link.childSessionId, error: error instanceof Error ? error.message : String(error) }, "failed to persist subsession link", ); } @@ -596,60 +596,81 @@ export class PiSessionService { } private async registerPersistedSubsessionLinks(parentSessionId: string, parent: PiAgentSession, parentSessionFile: string | undefined): Promise { + // Parent custom links are the authoritative recovery record: verify the + // exact live child file/header or an exact archived child before tracking. const entries = parent.sessionManager.getEntries?.() ?? parent.sessionManager.getBranch(); for (const entry of entries) { const link = parsePersistedParentSubsessionLink(entry); if (link === undefined) continue; - if (link.spawnedBySessionId !== parentSessionId) continue; - if (!await this.persistedSubsessionLinkMatchesParent(parentSessionFile, link)) continue; - this.registerSubsession(parentSessionId, trackedSubsessionLinkFromParentLink(link, parentSessionFile)); + const verified = await this.verifiedSubsessionLinkFromParentLink(parentSessionId, parentSessionFile, link); + if (verified === undefined) continue; + this.registerVerifiedSubsession(verified); } } - private async persistedSubsessionLinkMatchesParent(parentSessionFile: string | undefined, link: PersistedParentSubsessionLink): Promise { - if (parentSessionFile === undefined) return false; - if (link.spawnedSessionFile !== undefined) { - const header = await readSessionHeaderSummary(link.spawnedSessionFile); - if (header?.id === link.spawnedSessionId) { - return header.parentSession !== undefined && sessionPathsEqual(header.parentSession, parentSessionFile); - } - } + private async verifiedSubsessionLinkFromParentLink(parentSessionId: string, parentSessionFile: string | undefined, link: PersistedParentSubsessionLink): Promise { + if (parentSessionFile === undefined) return undefined; + if (link.spawnedBySessionId !== parentSessionId) return undefined; + if (!(await this.parentLinkHasValidChildTarget(parentSessionFile, link))) return undefined; + return trackedSubsessionLinkFromParentLink(parentSessionId, link, parentSessionFile); + } + private async parentLinkHasValidChildTarget(parentSessionFile: string, link: PersistedParentSubsessionLink): Promise { + if (link.spawnedSessionFile !== undefined && (await sessionFileHeaderMatches(link.spawnedSessionFile, { sessionId: link.spawnedSessionId, parentSessionFile }))) return true; + return this.archivedSubsessionLinkMatchesParent(parentSessionFile, link); + } + + private async archivedSubsessionLinkMatchesParent(parentSessionFile: string, link: PersistedParentSubsessionLink): Promise { const archived = await this.getArchivedExact(link.spawnedSessionId); - return archived?.parentSessionPath !== undefined && sessionPathsEqual(archived.parentSessionPath, parentSessionFile); + if (archived?.parentSessionPath === undefined) return false; + if (!sessionPathsEqual(archived.parentSessionPath, parentSessionFile)) return false; + if (archived.originalPath !== undefined && link.spawnedSessionFile !== undefined && !sessionPathsEqual(archived.originalPath, link.spawnedSessionFile)) return false; + return true; } private async recoverSubsessionTrackingForOpenedSession(session: PiAgentSession): Promise { + const link = await this.verifiedSubsessionLinkFromOpenedChild(session); + if (link === undefined) return; + this.registerVerifiedSubsession(link); + } + + private async verifiedSubsessionLinkFromOpenedChild(session: PiAgentSession): Promise { + // Child markers are only hints; the current child header and reciprocal + // parent custom link must agree on the exact ids and files before relinking. const entries = session.sessionManager.getEntries?.() ?? session.sessionManager.getBranch(); let marker: PersistedChildSubsessionLink | undefined; for (const entry of entries) { const parsed = parsePersistedChildSubsessionLink(entry); if (parsed?.spawnedSessionId === session.sessionId) marker = parsed; } - if (marker === undefined) return; + if (marker === undefined) return undefined; - const parentSessionFile = await parentSessionFileForSession(session); - if (parentSessionFile === undefined) return; - const parentHeader = await readSessionHeaderSummary(parentSessionFile); - if (parentHeader?.id !== marker.spawnedBySessionId) return; const childSessionFile = nonEmptyString(session.sessionFile); - if (childSessionFile === undefined) return; - const hasReciprocalLink = await this.parentHasReciprocalSubsessionLink(parentSessionFile, marker.spawnedBySessionId, session.sessionId, childSessionFile); - if (!hasReciprocalLink) return; - this.registerSubsession(marker.spawnedBySessionId, { + if (childSessionFile === undefined) return undefined; + const childHeader = await readSessionHeaderSummary(childSessionFile); + if (childHeader?.id !== session.sessionId) return undefined; + const parentSessionFile = nonEmptyString(childHeader.parentSession); + if (parentSessionFile === undefined) return undefined; + const parentHeader = await readSessionHeaderSummary(parentSessionFile); + if (parentHeader?.id !== marker.spawnedBySessionId) return undefined; + + const parentLink = this.findReciprocalParentSubsessionLink(parentSessionFile, marker.spawnedBySessionId, session.sessionId, childSessionFile); + if (parentLink === undefined) return undefined; + return { + parentSessionId: marker.spawnedBySessionId, childSessionId: session.sessionId, childSessionFile, parentSessionFile, - cwd: session.sessionManager.getCwd(), - }); + cwd: parentLink.cwd ?? session.sessionManager.getCwd(), + }; } - private async parentHasReciprocalSubsessionLink(parentSessionFile: string, parentSessionId: string, childSessionId: string, childSessionFile: string): Promise { + private findReciprocalParentSubsessionLink(parentSessionFile: string, parentSessionId: string, childSessionId: string, childSessionFile: string): PersistedParentSubsessionLink | undefined { let parentManager: PiSessionManager; try { parentManager = this.sessionManager.open(parentSessionFile); } catch { - return false; + return undefined; } const entries = parentManager.getEntries?.() ?? parentManager.getBranch(); for (const entry of entries) { @@ -657,9 +678,9 @@ export class PiSessionService { if (link === undefined) continue; if (link.spawnedBySessionId !== parentSessionId || link.spawnedSessionId !== childSessionId) continue; if (link.spawnedSessionFile === undefined || !sessionPathsEqual(link.spawnedSessionFile, childSessionFile)) continue; - if (await this.persistedSubsessionLinkMatchesParent(parentSessionFile, link)) return true; + return link; } - return false; + return undefined; } private async getOrOpenTrackedSubsession(sessionId: string): Promise { @@ -671,11 +692,9 @@ export class PiSessionService { const link = this.subsessionLinks.get(sessionId); if (link?.childSessionFile !== undefined) { - const header = await readSessionHeaderSummary(link.childSessionFile); - if (header?.id === sessionId) { - const sessionManager = this.sessionManager.open(link.childSessionFile); - return (await this.create(sessionManager, link.cwd ?? sessionManager.getCwd())).runtime.session; - } + if (!(await sessionFileHeaderMatches(link.childSessionFile, { sessionId, parentSessionFile: link.parentSessionFile }))) throw new Error("Session not found"); + const sessionManager = this.sessionManager.open(link.childSessionFile); + return (await this.create(sessionManager, link.cwd ?? sessionManager.getCwd())).runtime.session; } const listed = link?.cwd === undefined @@ -693,7 +712,7 @@ export class PiSessionService { const archived = await this.getArchivedExact(childSessionId); if (archived !== undefined) return { cwd: archived.cwd, status: "archived" }; const link = this.subsessionLinks.get(childSessionId); - if (link?.childSessionFile !== undefined && (await readSessionHeaderSummary(link.childSessionFile))?.id === childSessionId) { + if (link?.childSessionFile !== undefined && (await sessionFileHeaderMatches(link.childSessionFile, { sessionId: childSessionId, parentSessionFile: link.parentSessionFile }))) { return { cwd: link.cwd ?? "", status: "idle" }; } if (link?.cwd !== undefined) return { cwd: link.cwd, status: "unknown" }; @@ -1627,32 +1646,20 @@ function isDefined(value: T | undefined): value is T { return value !== undefined; } -function mergeSubsessionLink(previous: TrackedSubsessionLink | undefined, next: TrackedSubsessionLink): TrackedSubsessionLink { - return { - parentSessionId: next.parentSessionId, - childSessionId: next.childSessionId, - ...(previous?.childSessionFile === undefined ? {} : { childSessionFile: previous.childSessionFile }), - ...(previous?.parentSessionFile === undefined ? {} : { parentSessionFile: previous.parentSessionFile }), - ...(previous?.cwd === undefined ? {} : { cwd: previous.cwd }), - ...(next.childSessionFile === undefined ? {} : { childSessionFile: next.childSessionFile }), - ...(next.parentSessionFile === undefined ? {} : { parentSessionFile: next.parentSessionFile }), - ...(next.cwd === undefined ? {} : { cwd: next.cwd }), - }; -} - -function trackedSubsessionLinkFromParentLink(link: PersistedParentSubsessionLink, parentSessionFile: string | undefined): Omit { +function trackedSubsessionLinkFromParentLink(parentSessionId: string, link: PersistedParentSubsessionLink, parentSessionFile: string): TrackedSubsessionLink { return { + parentSessionId, childSessionId: link.spawnedSessionId, ...(link.spawnedSessionFile === undefined ? {} : { childSessionFile: link.spawnedSessionFile }), - ...(parentSessionFile === undefined ? {} : { parentSessionFile }), + parentSessionFile, ...(link.cwd === undefined ? {} : { cwd: link.cwd }), }; } -function persistedParentSubsessionLinkData(parentSessionId: string, link: Omit): Record { +function persistedParentSubsessionLinkData(link: TrackedSubsessionLink): Record { return { version: 1, - spawnedBySessionId: parentSessionId, + spawnedBySessionId: link.parentSessionId, spawnedSessionId: link.childSessionId, ...(link.childSessionFile === undefined ? {} : { spawnedSessionFile: link.childSessionFile }), ...(link.cwd === undefined ? {} : { cwd: link.cwd }), @@ -1726,12 +1733,11 @@ async function readSessionHeaderSummary(sessionFile: string): Promise { - const headerParentSession = nonEmptyString(session.sessionManager.getHeader?.()?.parentSession); - if (headerParentSession !== undefined) return headerParentSession; - const sessionFile = nonEmptyString(session.sessionFile); - if (sessionFile === undefined) return undefined; - return (await readSessionHeaderSummary(sessionFile))?.parentSession; +async function sessionFileHeaderMatches(sessionFile: string, expected: { sessionId: string; parentSessionFile?: string | undefined }): Promise { + const header = await readSessionHeaderSummary(sessionFile); + if (header?.id !== expected.sessionId) return false; + if (expected.parentSessionFile === undefined) return true; + return header.parentSession !== undefined && sessionPathsEqual(header.parentSession, expected.parentSessionFile); } async function clearParentSession(sessionFile: string): Promise { From b0b497d49368ce16469cc5dd4b0fcc29f3951c23 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 25 Jun 2026 09:47:41 +0200 Subject: [PATCH 12/24] fix: require exact active subsession files --- src/server/sessions/piSessionService.test.ts | 146 +++++++++++++++++ src/server/sessions/piSessionService.ts | 148 +++++++++++++----- .../sessions/spawnSubsessionTool.test.ts | 12 +- src/server/sessions/spawnSubsessionTool.ts | 15 +- 4 files changed, 268 insertions(+), 53 deletions(-) diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index 717c650..381e971 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -1291,6 +1291,152 @@ describe("PiSessionService", () => { } }); + it("uses the verified child file instead of an active copied child with the same id", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-active-copy-child-")); + const parentFile = join(tempDir, "parent.jsonl"); + const originalChildFile = join(tempDir, "original-child.jsonl"); + const copiedChildFile = join(tempDir, "copied-child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(originalChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + await writeFile(copiedChildFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + + try { + const copiedManager = fakeSessionManager("/workspace-feature", { + getBranch: () => [{ type: "message", message: { role: "assistant", content: "copied child result" } }], + }); + const originalManager = fakeSessionManager("/workspace-feature", { + getBranch: () => [{ type: "message", message: { role: "assistant", content: "original child result" } }], + }); + const parentManager = fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: originalChildFile, cwd: "/workspace-feature" } }], + }); + const copiedChild = fakeRuntime("child-1", { sessionFile: copiedChildFile, sessionManager: copiedManager, isStreaming: true }); + const originalChild = fakeRuntime("child-1", { sessionFile: originalChildFile, sessionManager: originalManager }); + const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); + const createAgentRuntime: RuntimeCreator = (_createRuntime, options) => { + if (options.sessionManager === copiedManager) return Promise.resolve(copiedChild.runtime); + if (options.sessionManager === originalManager) return Promise.resolve(originalChild.runtime); + if (options.sessionManager === parentManager) return Promise.resolve(parent.runtime); + throw new Error("unexpected session manager"); + }; + const open = vi.fn((path: string) => { + if (path === copiedChildFile) return copiedManager; + if (path === originalChildFile) return originalManager; + if (path === parentFile) return parentManager; + throw new Error(`unexpected open path ${path}`); + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime, + sessionManager: { + create: () => parentManager, + list: (cwd: string) => Promise.resolve(cwd === "/workspace-feature" ? [{ ...sessionRecord("child-1", "/workspace-feature"), path: copiedChildFile, parentSessionPath: parentFile }] : []), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-1", "/workspace-feature")); + await service.start("/workspace"); + + await expect(service.listSubsessions("parent-1", parentFile)).resolves.toEqual([ + { sessionId: "child-1", cwd: "/workspace-feature", status: "idle" }, + ]); + + copiedChild.session.isStreaming = true; + copiedChild.emit({ type: "agent_start" }); + copiedChild.session.isStreaming = false; + copiedChild.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(parent.calls.sendCustomMessage).toHaveLength(0); + + await expect(service.checkSubsession("parent-1", "child-1", parentFile)).resolves.toMatchObject({ + sessionId: "child-1", + cwd: "/workspace-feature", + status: "idle", + finalText: "original child result", + messageCount: 1, + }); + const read = await service.readSubsession("parent-1", "child-1", { roles: ["assistant"] }, parentFile); + expect(read.entries[0]?.parts[0]).toMatchObject({ kind: "text", text: "original child result" }); + expect(open).toHaveBeenCalledWith(originalChildFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it("uses the verified parent file instead of an active copied parent with the same id", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-active-copy-parent-")); + const parentFile = join(tempDir, "parent.jsonl"); + const copiedParentFile = join(tempDir, "copied-parent.jsonl"); + const childFile = join(tempDir, "child.jsonl"); + await writeFile(parentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(copiedParentFile, `${JSON.stringify({ type: "session", version: 3, id: "parent-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace" })}\n`, "utf8"); + await writeFile(childFile, `${JSON.stringify({ type: "session", version: 3, id: "child-1", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/workspace-feature", parentSession: parentFile })}\n`, "utf8"); + + try { + const childManager = fakeSessionManager("/workspace-feature", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.spawned", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1" } }], + getBranch: () => [{ type: "message", message: { role: "assistant", content: "child result" } }], + }); + const parentManager = fakeSessionManager("/workspace", { + getEntries: () => [{ type: "custom", customType: "pi-web.subsession.link", data: { version: 1, spawnedBySessionId: "parent-1", spawnedSessionId: "child-1", spawnedSessionFile: childFile, cwd: "/workspace-feature" } }], + }); + const copiedParentManager = fakeSessionManager("/workspace", { getEntries: () => [] }); + const child = fakeRuntime("child-1", { sessionFile: childFile, sessionManager: childManager }); + const parent = fakeRuntime("parent-1", { sessionFile: parentFile, sessionManager: parentManager }); + const copiedParent = fakeRuntime("parent-1", { sessionFile: copiedParentFile, sessionManager: copiedParentManager }); + const createAgentRuntime: RuntimeCreator = (_createRuntime, options) => { + if (options.sessionManager === childManager) return Promise.resolve(child.runtime); + if (options.sessionManager === parentManager) return Promise.resolve(parent.runtime); + if (options.sessionManager === copiedParentManager) return Promise.resolve(copiedParent.runtime); + throw new Error("unexpected session manager"); + }; + const open = vi.fn((path: string) => { + if (path === childFile) return childManager; + if (path === parentFile) return parentManager; + if (path === copiedParentFile) return copiedParentManager; + throw new Error(`unexpected open path ${path}`); + }); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime, + sessionManager: { + create: () => copiedParentManager, + list: (cwd: string) => Promise.resolve(cwd === "/workspace" + ? [{ ...sessionRecord("parent-1", "/workspace"), path: copiedParentFile }] + : [{ ...sessionRecord("child-1", "/workspace-feature"), path: childFile, parentSessionPath: parentFile }]), + listAll: () => Promise.resolve([]), + open, + }, + archiveStore: emptyArchiveStore(), + heartbeatIntervalMs: 60_000, + }); + + await service.status(sessionRef("child-1", "/workspace-feature")); + await service.status(sessionRef("parent-1", "/workspace")); + + await expect(service.listSubsessions("parent-1", copiedParentFile)).resolves.toEqual([]); + await expect(service.checkSubsession("parent-1", "child-1", copiedParentFile)).rejects.toThrow("not one of your subsessions"); + await expect(service.readSubsession("parent-1", "child-1", {}, copiedParentFile)).rejects.toThrow("not one of your subsessions"); + + child.session.isStreaming = true; + child.emit({ type: "agent_start" }); + child.session.isStreaming = false; + child.emit({ type: "agent_end" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(copiedParent.calls.sendCustomMessage).toHaveLength(0); + expect(parent.calls.sendCustomMessage).toHaveLength(1); + expect(parent.calls.sendCustomMessage[0]?.message.content).toContain("Subsession child-1 stopped working"); + expect(open).toHaveBeenCalledWith(parentFile); + await service.dispose(); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + it("does not relink a child marker when the current child file header no longer records the parent", async () => { const tempDir = await mkdtemp(join(tmpdir(), "pi-web-subsession-stale-child-header-")); const parentFile = join(tempDir, "parent.jsonl"); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 78e5a6b..552f9b6 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -314,7 +314,7 @@ export class PiSessionService { private readonly subsessionChildren = new Map>(); /** Tracked subsession id -> persisted recovery details for the child. */ private readonly subsessionLinks = new Map(); - /** Parent session ids whose persisted links have already been loaded. */ + /** Parent id/file identities whose persisted links have already been loaded. */ private readonly subsessionHydratedParents = new Set(); /** * Tracked subsession id -> whether a completion notification is armed. @@ -348,9 +348,9 @@ export class PiSessionService { this.spawnTargets === undefined ? undefined : (input) => this.spawnSession(input), !subsessionsActive ? undefined : { spawn: (input) => this.spawnSubsession(input), - list: (parentSessionId) => this.listSubsessions(parentSessionId), - check: (parentSessionId, sessionId) => this.checkSubsession(parentSessionId, sessionId), - read: (parentSessionId, sessionId, query) => this.readSubsession(parentSessionId, sessionId, query), + list: (parentSessionId, parentSessionFile) => this.listSubsessions(parentSessionId, parentSessionFile), + check: (parentSessionId, sessionId, parentSessionFile) => this.checkSubsession(parentSessionId, sessionId, parentSessionFile), + read: (parentSessionId, sessionId, query, parentSessionFile) => this.readSubsession(parentSessionId, sessionId, query, parentSessionFile), }, ); this.createAgentRuntime = deps.createAgentRuntime ?? defaultCreateAgentRuntime; @@ -487,16 +487,18 @@ export class PiSessionService { } /** Summaries of the tracked subsessions spawned by `parentSessionId`. */ - async listSubsessions(parentSessionId: string): Promise { - await this.hydrateSubsessionsForParent(parentSessionId); + async listSubsessions(parentSessionId: string, parentSessionFile?: string): Promise { + const parentFile = nonEmptyString(parentSessionFile); + await this.hydrateSubsessionsForParent(parentSessionId, parentFile); const childIds = this.subsessionChildren.get(parentSessionId); if (childIds === undefined) return []; - return Promise.all([...childIds].map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) }))); + const authorizedChildIds = [...childIds].filter((childId) => this.subsessionLinkBelongsToParent(parentSessionId, parentFile, childId)); + return Promise.all(authorizedChildIds.map(async (childId) => ({ sessionId: childId, ...(await this.subsessionSummaryFields(childId)) }))); } /** Status and final result of a subsession, scoped to the caller's children. */ - async checkSubsession(parentSessionId: string, sessionId: string): Promise { - const session = await this.openSubsession(parentSessionId, sessionId); + async checkSubsession(parentSessionId: string, sessionId: string, parentSessionFile?: string): Promise { + const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile); const messages = historyMessages(session); return { sessionId, @@ -508,8 +510,8 @@ export class PiSessionService { } /** Filtered, paginated transcript of a subsession, scoped to the caller's children. */ - async readSubsession(parentSessionId: string, sessionId: string, query: SubsessionReadQuery): Promise { - const session = await this.openSubsession(parentSessionId, sessionId); + async readSubsession(parentSessionId: string, sessionId: string, query: SubsessionReadQuery, parentSessionFile?: string): Promise { + const session = await this.openSubsession(parentSessionId, sessionId, parentSessionFile); const view = buildTranscriptView(historyMessages(session), query); return { sessionId, @@ -520,14 +522,41 @@ export class PiSessionService { } /** Open a session after verifying it is one of the caller's tracked children. */ - private async openSubsession(parentSessionId: string, sessionId: string): Promise { - await this.hydrateSubsessionsForParent(parentSessionId); - if (this.subsessionParents.get(sessionId) !== parentSessionId) { + private async openSubsession(parentSessionId: string, sessionId: string, parentSessionFile?: string): Promise { + const parentFile = nonEmptyString(parentSessionFile); + await this.hydrateSubsessionsForParent(parentSessionId, parentFile); + if (this.subsessionParents.get(sessionId) !== parentSessionId || !this.subsessionLinkBelongsToParent(parentSessionId, parentFile, sessionId)) { throw new Error(`Session ${sessionId} is not one of your subsessions`); } return this.getOrOpenTrackedSubsession(sessionId); } + private subsessionLinkBelongsToParent(parentSessionId: string, parentSessionFile: string | undefined, childSessionId: string): boolean { + const link = this.subsessionLinks.get(childSessionId); + if (link?.parentSessionId !== parentSessionId) return false; + return parentSessionFile === undefined || trackedLinkParentFileMatches(link, parentSessionFile); + } + + private activeChildForSubsessionLink(link: TrackedSubsessionLink): ActiveSession | undefined { + const active = this.active.get(link.childSessionId); + if (active === undefined) return undefined; + return activeSessionFileMatches(active, link.childSessionFile) ? active : undefined; + } + + private activeParentForSubsessionLink(link: TrackedSubsessionLink): ActiveSession | undefined { + const active = this.active.get(link.parentSessionId); + if (active === undefined) return undefined; + return activeSessionFileMatches(active, link.parentSessionFile) ? active : undefined; + } + + private subsessionLinkForActiveChild(session: PiAgentSession): TrackedSubsessionLink | undefined { + const childId = session.sessionId; + const parentId = this.subsessionParents.get(childId); + const link = this.subsessionLinks.get(childId); + if (parentId === undefined || link?.parentSessionId !== parentId) return undefined; + return sessionFileMatches(session, link.childSessionFile) ? link : undefined; + } + private registerVerifiedSubsession(link: TrackedSubsessionLink): void { const { childSessionId, parentSessionId } = link; const previousParentId = this.subsessionParents.get(childSessionId); @@ -558,7 +587,7 @@ export class PiSessionService { } private persistSubsessionLink(link: TrackedSubsessionLink): void { - const parent = this.active.get(link.parentSessionId)?.runtime.session; + const parent = this.activeParentForSubsessionLink(link)?.runtime.session; if (parent === undefined) return; if (parent.sessionManager.appendCustomEntry === undefined) return; try { @@ -585,20 +614,39 @@ export class PiSessionService { } } - private async hydrateSubsessionsForParent(parentSessionId: string): Promise { - if (this.subsessionHydratedParents.has(parentSessionId)) return; - const parent = this.active.get(parentSessionId)?.runtime.session; - if (parent === undefined) return; + private async hydrateSubsessionsForParent(parentSessionId: string, parentSessionFile?: string): Promise { + const hydrationKey = subsessionHydratedParentKey(parentSessionId, parentSessionFile); + if (this.subsessionHydratedParents.has(hydrationKey)) return; - const parentSessionFile = nonEmptyString(parent.sessionFile); - await this.registerPersistedSubsessionLinks(parentSessionId, parent, parentSessionFile); - this.subsessionHydratedParents.add(parentSessionId); + const activeParent = this.active.get(parentSessionId); + if (activeParent !== undefined && (parentSessionFile === undefined || activeSessionFileMatches(activeParent, parentSessionFile))) { + const activeParentFile = nonEmptyString(activeParent.runtime.session.sessionFile); + await this.registerPersistedSubsessionLinks(parentSessionId, activeParent.runtime.session.sessionManager, activeParentFile); + this.subsessionHydratedParents.add(hydrationKey); + return; + } + + if (parentSessionFile === undefined) return; + if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) { + this.subsessionHydratedParents.add(hydrationKey); + return; + } + + let parentManager: PiSessionManager; + try { + parentManager = this.sessionManager.open(parentSessionFile); + } catch { + this.subsessionHydratedParents.add(hydrationKey); + return; + } + await this.registerPersistedSubsessionLinks(parentSessionId, parentManager, parentSessionFile); + this.subsessionHydratedParents.add(hydrationKey); } - private async registerPersistedSubsessionLinks(parentSessionId: string, parent: PiAgentSession, parentSessionFile: string | undefined): Promise { + private async registerPersistedSubsessionLinks(parentSessionId: string, parentManager: PiSessionManager, parentSessionFile: string | undefined): Promise { // Parent custom links are the authoritative recovery record: verify the // exact live child file/header or an exact archived child before tracking. - const entries = parent.sessionManager.getEntries?.() ?? parent.sessionManager.getBranch(); + const entries = parentManager.getEntries?.() ?? parentManager.getBranch(); for (const entry of entries) { const link = parsePersistedParentSubsessionLink(entry); if (link === undefined) continue; @@ -684,34 +732,32 @@ export class PiSessionService { } private async getOrOpenTrackedSubsession(sessionId: string): Promise { - const active = this.active.get(sessionId); + const link = this.subsessionLinks.get(sessionId); + if (link === undefined) throw new Error("Session not found"); + + const active = this.activeChildForSubsessionLink(link); if (active !== undefined) return active.runtime.session; const archived = await this.getArchivedExact(sessionId); if (archived?.archivePath !== undefined) return (await this.create(this.sessionManager.open(archived.archivePath), archived.cwd)).runtime.session; - const link = this.subsessionLinks.get(sessionId); - if (link?.childSessionFile !== undefined) { + if (link.childSessionFile !== undefined) { if (!(await sessionFileHeaderMatches(link.childSessionFile, { sessionId, parentSessionFile: link.parentSessionFile }))) throw new Error("Session not found"); const sessionManager = this.sessionManager.open(link.childSessionFile); return (await this.create(sessionManager, link.cwd ?? sessionManager.getCwd())).runtime.session; } - const listed = link?.cwd === undefined - ? (await this.sessionManager.listAll?.() ?? []).find((session) => session.id === sessionId) - : (await this.sessionManager.list(link.cwd)).find((session) => session.id === sessionId); - if (listed === undefined) throw new Error("Session not found"); - return (await this.create(this.sessionManager.open(listed.path), listed.cwd)).runtime.session; + throw new Error("Session not found"); } private async subsessionSummaryFields(childSessionId: string): Promise<{ cwd: string; status: SubsessionStatus }> { - const active = this.active.get(childSessionId); + const link = this.subsessionLinks.get(childSessionId); + const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link); if (active !== undefined) { return { cwd: active.runtime.cwd, status: await this.subsessionStatus(active.runtime.session) }; } const archived = await this.getArchivedExact(childSessionId); if (archived !== undefined) return { cwd: archived.cwd, status: "archived" }; - const link = this.subsessionLinks.get(childSessionId); if (link?.childSessionFile !== undefined && (await sessionFileHeaderMatches(link.childSessionFile, { sessionId: childSessionId, parentSessionFile: link.parentSessionFile }))) { return { cwd: link.cwd ?? "", status: "idle" }; } @@ -733,9 +779,9 @@ export class PiSessionService { * parent is busy and delivers immediately when it is idle). */ private updateSubsessionTracking(session: PiAgentSession): void { - const childId = session.sessionId; - const parentId = this.subsessionParents.get(childId); - if (parentId === undefined) return; + const link = this.subsessionLinkForActiveChild(session); + if (link === undefined) return; + const childId = link.childSessionId; if (this.hasActiveWork(session)) { this.subsessionNotifyArmed.set(childId, true); return; @@ -746,14 +792,17 @@ export class PiSessionService { const finalText = finalAssistantText(historyMessages(session)); const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText); const text = `Subsession ${childId} stopped working (status: ${status}). Latest output:\n\n${preview}\n\nUse check_subsession with sessionId "${childId}" for its status and latest output, or read_subsession to look through its full transcript.`; - void this.notifyParentOfSubsession(parentId, childId, text); + void this.notifyParentOfSubsession(link.parentSessionId, childId, text); } private async getOrOpenParentForSubsession(parentSessionId: string, childSessionId: string): Promise { - const active = this.active.get(parentSessionId); + const link = this.subsessionLinks.get(childSessionId); + if (link?.parentSessionId !== parentSessionId) throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`); + + const active = this.activeParentForSubsessionLink(link); if (active !== undefined) return active.runtime.session; - const parentSessionFile = this.subsessionLinks.get(childSessionId)?.parentSessionFile; + const parentSessionFile = link.parentSessionFile; if (parentSessionFile === undefined) throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`); if ((await readSessionHeaderSummary(parentSessionFile))?.id !== parentSessionId) { throw new Error(`Parent session ${parentSessionId} is not available for subsession notification`); @@ -1150,7 +1199,7 @@ export class PiSessionService { // Disarm subsession notification before teardown so the abort below cannot // emit a "stopped working" event that notifies the parent (e.g. on archive). // The parent/children link is kept so the parent can still see the child. - this.subsessionNotifyArmed.delete(sessionId); + if (this.subsessionLinkForActiveChild(active.runtime.session) !== undefined) this.subsessionNotifyArmed.delete(sessionId); clearSessionQueue(active.runtime.session); active.unsubscribe(); try { @@ -1705,10 +1754,27 @@ function nonEmptyString(value: string | undefined): string | undefined { return value === undefined || value === "" ? undefined : value; } +function subsessionHydratedParentKey(parentSessionId: string, parentSessionFile: string | undefined): string { + return `${parentSessionId}\0${parentSessionFile ?? ""}`; +} + function sessionPathsEqual(a: string, b: string): boolean { return cwdPathsEqual(a, b); } +function sessionFileMatches(session: PiAgentSession, expectedSessionFile: string | undefined): boolean { + const sessionFile = nonEmptyString(session.sessionFile); + return sessionFile !== undefined && expectedSessionFile !== undefined && sessionPathsEqual(sessionFile, expectedSessionFile); +} + +function activeSessionFileMatches(active: ActiveSession, expectedSessionFile: string | undefined): boolean { + return sessionFileMatches(active.runtime.session, expectedSessionFile); +} + +function trackedLinkParentFileMatches(link: TrackedSubsessionLink, parentSessionFile: string): boolean { + return link.parentSessionFile !== undefined && sessionPathsEqual(link.parentSessionFile, parentSessionFile); +} + interface SessionHeaderSummary { id: string; parentSession?: string; diff --git a/src/server/sessions/spawnSubsessionTool.test.ts b/src/server/sessions/spawnSubsessionTool.test.ts index 35b4311..3bfa036 100644 --- a/src/server/sessions/spawnSubsessionTool.test.ts +++ b/src/server/sessions/spawnSubsessionTool.test.ts @@ -56,9 +56,9 @@ describe("createSubsessionToolDefinitions", () => { ])); const { list: listTool } = tools({ list }); - const result = await listTool.execute("call-2", {}, undefined, undefined, ctxFor("parent-1", undefined)); + const result = await listTool.execute("call-2", {}, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl")); - expect(list).toHaveBeenCalledWith("parent-1"); + expect(list).toHaveBeenCalledWith("parent-1", "/sessions/parent-1.jsonl"); expect(result.details).toEqual({ subsessions: [ { sessionId: "child-1", cwd: "/repos/a", status: "working" }, { sessionId: "child-2", cwd: "/repos/a", status: "idle" }, @@ -76,9 +76,9 @@ describe("createSubsessionToolDefinitions", () => { const check = vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a", status: "idle" as const, finalText: "all done", messageCount: 4 })); const { check: checkTool } = tools({ check }); - const result = await checkTool.execute("call-4", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", undefined)); + const result = await checkTool.execute("call-4", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl")); - expect(check).toHaveBeenCalledWith("parent-1", "child-1"); + expect(check).toHaveBeenCalledWith("parent-1", "child-1", "/sessions/parent-1.jsonl"); expect(result.details).toMatchObject({ sessionId: "child-1", status: "idle", finalText: "all done" }); expect(firstText(result.content)).toContain("all done"); }); @@ -99,9 +99,9 @@ describe("createSubsessionToolDefinitions", () => { })); const { read: readTool } = tools({ read }); - const result = await readTool.execute("call-6", { sessionId: "child-1", roles: ["assistant"], maxChars: 200 }, undefined, undefined, ctxFor("parent-1", undefined)); + const result = await readTool.execute("call-6", { sessionId: "child-1", roles: ["assistant"], maxChars: 200 }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl")); - expect(read).toHaveBeenCalledWith("parent-1", "child-1", { roles: ["assistant"], maxChars: 200 }); + expect(read).toHaveBeenCalledWith("parent-1", "child-1", { roles: ["assistant"], maxChars: 200 }, "/sessions/parent-1.jsonl"); expect(result.details).toMatchObject({ sessionId: "child-1", matched: 1 }); expect(firstText(result.content)).toContain("the answer"); }); diff --git a/src/server/sessions/spawnSubsessionTool.ts b/src/server/sessions/spawnSubsessionTool.ts index 5ce2405..5a47665 100644 --- a/src/server/sessions/spawnSubsessionTool.ts +++ b/src/server/sessions/spawnSubsessionTool.ts @@ -56,9 +56,9 @@ export interface SubsessionReadQuery { export interface SubsessionToolDeps { spawn(input: SpawnSubsessionInvocation): Promise; - list(parentSessionId: string): Promise; - check(parentSessionId: string, sessionId: string): Promise; - read(parentSessionId: string, sessionId: string, query: SubsessionReadQuery): Promise; + list(parentSessionId: string, parentSessionFile?: string): Promise; + check(parentSessionId: string, sessionId: string, parentSessionFile?: string): Promise; + read(parentSessionId: string, sessionId: string, query: SubsessionReadQuery, parentSessionFile?: string): Promise; } const SpawnSubsessionParams = Type.Object({ @@ -196,7 +196,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse parameters: ListSubsessionsParams, async execute(_toolCallId, _params, _signal, _onUpdate, ctx) { const parentSessionId = ctx.sessionManager.getSessionId(); - const subsessions = await deps.list(parentSessionId); + const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined; + const subsessions = await deps.list(parentSessionId, parentSessionFile); const text = subsessions.length === 0 ? "You have not spawned any subsessions." : `Your subsessions:\n${subsessions.map(statusLine).join("\n")}`; @@ -212,7 +213,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse parameters: CheckSubsessionParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const parentSessionId = ctx.sessionManager.getSessionId(); - const result = await deps.check(parentSessionId, params.sessionId); + const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined; + const result = await deps.check(parentSessionId, params.sessionId, parentSessionFile); const body = result.finalText === "" ? "(no output yet)" : result.finalText; return { content: [{ type: "text", text: `Subsession ${result.sessionId} [${result.status}]:\n\n${body}` }], @@ -229,8 +231,9 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse parameters: ReadSubsessionParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const parentSessionId = ctx.sessionManager.getSessionId(); + const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined; const { sessionId, ...query } = params; - const result = await deps.read(parentSessionId, sessionId, query); + const result = await deps.read(parentSessionId, sessionId, query, parentSessionFile); return { content: [{ type: "text", text: renderTranscript(result) }], details: result, From b14205e523af57ce19e966c358635093b3f9bbda Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 25 Jun 2026 10:56:53 +0200 Subject: [PATCH 13/24] feat: add inline Git diff highlights --- .changeset/git-inline-diff-highlights.md | 5 + .../src/components/UnifiedDiffViewer.ts | 59 +++++ src/client/src/components/shared.ts | 2 +- src/client/src/diff/unifiedDiff.test.ts | 117 +++++++++ src/client/src/diff/unifiedDiff.ts | 224 ++++++++++++++++++ src/client/src/plugins/core/panels.ts | 8 +- 6 files changed, 412 insertions(+), 3 deletions(-) create mode 100644 .changeset/git-inline-diff-highlights.md create mode 100644 src/client/src/components/UnifiedDiffViewer.ts create mode 100644 src/client/src/diff/unifiedDiff.test.ts create mode 100644 src/client/src/diff/unifiedDiff.ts 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/src/client/src/components/UnifiedDiffViewer.ts b/src/client/src/components/UnifiedDiffViewer.ts new file mode 100644 index 0000000..3844f52 --- /dev/null +++ b/src/client/src/components/UnifiedDiffViewer.ts @@ -0,0 +1,59 @@ +import { LitElement, css, html, type TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import { parseUnifiedDiff, type UnifiedDiffLine, type UnifiedDiffTextSpan } from "../diff/unifiedDiff"; + +@customElement("unified-diff-viewer") +export class UnifiedDiffViewer extends LitElement { + @property() diff = ""; + + override render(): TemplateResult { + const lines = parseUnifiedDiff(this.diff); + if (lines.length === 0) return html`

No diff.

`; + return html` +
+
+ ${lines.map((line) => this.renderLine(line))} +
+
+ `; + } + + private renderLine(line: UnifiedDiffLine): TemplateResult { + const kindClass = line.kind; + return html` +
+ ${formatLineNumber(line.oldLineNumber)} + ${formatLineNumber(line.newLineNumber)} + ${line.prefix} + ${renderSpans(line.spans)} +
+ `; + } + + static override styles = css` + :host { display: block; min-height: 0; height: 100%; color: var(--pi-text); background: var(--pi-bg); } + .empty { box-sizing: border-box; margin: 0; padding: 10px; color: var(--pi-muted); } + .scroller { height: 100%; min-height: 0; overflow: auto; background: var(--pi-bg); } + .diff-grid { display: grid; grid-template-columns: max-content max-content 2ch max-content; width: max-content; min-width: 100%; padding: 6px 0; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; } + .line { display: contents; } + .cell { min-height: 1.45em; white-space: pre; } + .line-number { min-width: 4ch; padding: 0 8px; border-right: 1px solid var(--pi-border-muted); color: var(--pi-dim); text-align: right; user-select: none; } + .prefix { padding: 0 4px; color: var(--pi-dim); text-align: center; user-select: none; } + .content { padding: 0 12px 0 4px; } + .meta { color: var(--pi-dim); } + .hunk { background: color-mix(in srgb, var(--pi-accent) 9%, transparent); color: var(--pi-accent); } + .add { background: color-mix(in srgb, var(--pi-success) 12%, transparent); } + .remove { background: color-mix(in srgb, var(--pi-danger) 12%, transparent); } + .marker { color: var(--pi-dim); } + .content.add .inline-change { border-radius: 2px; background: color-mix(in srgb, var(--pi-success) 36%, transparent); color: var(--pi-text); } + .content.remove .inline-change { border-radius: 2px; background: color-mix(in srgb, var(--pi-danger) 36%, transparent); color: var(--pi-text); } + `; +} + +function renderSpans(spans: UnifiedDiffTextSpan[]): TemplateResult[] { + return spans.map((span) => html`${span.text}`); +} + +function formatLineNumber(lineNumber: number | undefined): string { + return lineNumber === undefined ? "" : String(lineNumber); +} diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 91165e5..fde76ff 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -200,7 +200,7 @@ export const workspacePanelStyles = css` .diff-section:last-child { border-bottom: 0; } .viewer-header { position: sticky; top: 0; display: flex; justify-content: space-between; gap: 8px; padding: 8px; border-bottom: 1px solid var(--pi-border-muted); background: var(--pi-bg); } .viewer-header strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - code-viewer { flex: 1 1 auto; min-height: 0; } + code-viewer, unified-diff-viewer { flex: 1 1 auto; min-height: 0; } .image-preview { flex: 1 1 auto; min-height: 0; box-sizing: border-box; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 16px; } .image-preview img { display: block; max-width: 100%; max-height: 100%; object-fit: contain; border: 1px solid var(--pi-border-muted); border-radius: 8px; background-color: var(--pi-surface); background-image: linear-gradient(45deg, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 25%, transparent 25%), linear-gradient(-45deg, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 25%, transparent 25%), linear-gradient(45deg, transparent 75%, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 75%), linear-gradient(-45deg, transparent 75%, color-mix(in srgb, var(--pi-border-muted) 45%, transparent) 75%); background-position: 0 0, 0 8px, 8px -8px, -8px 0; background-size: 16px 16px; box-shadow: 0 8px 24px var(--pi-shadow-soft); } pre { margin: 0; padding: 10px; overflow: auto; font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; } diff --git a/src/client/src/diff/unifiedDiff.test.ts b/src/client/src/diff/unifiedDiff.test.ts new file mode 100644 index 0000000..bdebc9b --- /dev/null +++ b/src/client/src/diff/unifiedDiff.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vitest"; +import { parseUnifiedDiff, type UnifiedDiffLine, type UnifiedDiffLineKind } from "./unifiedDiff"; + +describe("parseUnifiedDiff", () => { + it("computes inline spans for paired removed and added lines", () => { + const diff = [ + "diff --git a/src/app.ts b/src/app.ts", + "index 1111111..2222222 100644", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -10,2 +10,2 @@ export function demo() {", + "- const name = \"fooBar\";", + "+ const name = \"fooBaz\";", + " return name;", + ].join("\n"); + + const lines = parseUnifiedDiff(diff); + const removed = firstLineOfKind(lines, "remove"); + const added = firstLineOfKind(lines, "add"); + const context = firstLineOfKind(lines, "context"); + + expect(removed.oldLineNumber).toBe(10); + expect(removed.newLineNumber).toBeUndefined(); + expect(changedText(removed)).toEqual(["r"]); + expect(added.oldLineNumber).toBeUndefined(); + expect(added.newLineNumber).toBe(10); + expect(changedText(added)).toEqual(["z"]); + expect(context.oldLineNumber).toBe(11); + expect(context.newLineNumber).toBe(11); + }); + + it("keeps file headers as metadata before a hunk starts", () => { + const diff = [ + "diff --git a/README.md b/README.md", + "index 1111111..2222222 100644", + "--- a/README.md", + "+++ b/README.md", + "@@ -1 +1 @@", + "-old", + "+new", + ].join("\n"); + + expect(parseUnifiedDiff(diff).slice(0, 5).map((line) => line.kind)).toEqual(["meta", "meta", "meta", "meta", "hunk"]); + }); + + it("parses changed content that starts with file header markers inside hunks", () => { + const diff = [ + "diff --git a/README.md b/README.md", + "--- a/README.md", + "+++ b/README.md", + "@@ -1 +1 @@", + "---- removed heading", + "++++ added heading", + ].join("\n"); + + const removed = firstLineOfKind(parseUnifiedDiff(diff), "remove"); + const added = firstLineOfKind(parseUnifiedDiff(diff), "add"); + + expect(removed.text).toBe("--- removed heading"); + expect(added.text).toBe("+++ added heading"); + }); + + it("pairs a single removed line with the closest added line in uneven blocks", () => { + const diff = [ + "diff --git a/src/app.ts b/src/app.ts", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1 +1,2 @@", + "-const label = \"old\";", + "+const label = \"new\";", + "+const extra = true;", + ].join("\n"); + + const addedLines = linesOfKind(parseUnifiedDiff(diff), "add"); + const firstAdded = lineAt(addedLines, 0); + const secondAdded = lineAt(addedLines, 1); + + expect(changedText(firstAdded)).toEqual(["new"]); + expect(secondAdded.spans.every((span) => !span.changed)).toBe(true); + }); + + it("leaves pure additions without inline change spans", () => { + const diff = [ + "diff --git a/new.txt b/new.txt", + "new file mode 100644", + "--- /dev/null", + "+++ b/new.txt", + "@@ -0,0 +1 @@", + "+brand new", + ].join("\n"); + + const added = firstLineOfKind(parseUnifiedDiff(diff), "add"); + + expect(added.newLineNumber).toBe(1); + expect(added.spans).toEqual([{ text: "brand new", changed: false }]); + }); +}); + +function firstLineOfKind(lines: UnifiedDiffLine[], kind: UnifiedDiffLineKind): UnifiedDiffLine { + const found = lines.find((line) => line.kind === kind); + if (found === undefined) throw new Error(`Missing ${kind} line`); + return found; +} + +function linesOfKind(lines: UnifiedDiffLine[], kind: UnifiedDiffLineKind): UnifiedDiffLine[] { + return lines.filter((line) => line.kind === kind); +} + +function lineAt(lines: UnifiedDiffLine[], index: number): UnifiedDiffLine { + const line = lines[index]; + if (line === undefined) throw new Error(`Missing line at ${String(index)}`); + return line; +} + +function changedText(line: UnifiedDiffLine): string[] { + return line.spans.filter((span) => span.changed).map((span) => span.text); +} diff --git a/src/client/src/diff/unifiedDiff.ts b/src/client/src/diff/unifiedDiff.ts new file mode 100644 index 0000000..7a5a3c8 --- /dev/null +++ b/src/client/src/diff/unifiedDiff.ts @@ -0,0 +1,224 @@ +import { diffChars } from "diff"; + +export type UnifiedDiffLineKind = "meta" | "hunk" | "context" | "add" | "remove" | "marker"; + +export interface UnifiedDiffTextSpan { + text: string; + changed: boolean; +} + +export interface UnifiedDiffLine { + kind: UnifiedDiffLineKind; + prefix: string; + text: string; + spans: UnifiedDiffTextSpan[]; + oldLineNumber?: number; + newLineNumber?: number; +} + +interface InlineDiffResult { + removed: UnifiedDiffTextSpan[]; + added: UnifiedDiffTextSpan[]; +} + +interface DiffLinePair { + removed: UnifiedDiffLine; + added: UnifiedDiffLine; +} + +const hunkHeaderPattern = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/; +const maxInlineLineLength = 5_000; +const maxInlineBlockLines = 20; +const minInlineSimilarity = 0.20; +const minPairSimilarity = 0.25; + +export function parseUnifiedDiff(diff: string): UnifiedDiffLine[] { + const parsedLines = parseUnifiedDiffLines(diff); + applyInlineDiffs(parsedLines); + return parsedLines; +} + +function parseUnifiedDiffLines(diff: string): UnifiedDiffLine[] { + const lines = splitDiffLines(diff); + const parsedLines: UnifiedDiffLine[] = []; + let oldLineNumber: number | undefined; + let newLineNumber: number | undefined; + + for (const rawLine of lines) { + const hunkMatch = hunkHeaderPattern.exec(rawLine); + if (hunkMatch !== null) { + oldLineNumber = Number(hunkMatch[1]); + newLineNumber = Number(hunkMatch[2]); + parsedLines.push(line("hunk", "", rawLine)); + continue; + } + + if (oldLineNumber !== undefined && newLineNumber !== undefined) { + if (rawLine.startsWith("+")) { + parsedLines.push(line("add", "+", rawLine.slice(1), { newLineNumber })); + newLineNumber++; + continue; + } + if (rawLine.startsWith("-")) { + parsedLines.push(line("remove", "-", rawLine.slice(1), { oldLineNumber })); + oldLineNumber++; + continue; + } + if (rawLine.startsWith(" ")) { + parsedLines.push(line("context", " ", rawLine.slice(1), { oldLineNumber, newLineNumber })); + oldLineNumber++; + newLineNumber++; + continue; + } + if (rawLine.startsWith("\\")) { + parsedLines.push(line("marker", "", rawLine)); + continue; + } + } + + oldLineNumber = undefined; + newLineNumber = undefined; + parsedLines.push(line("meta", "", rawLine)); + } + + return parsedLines; +} + +function splitDiffLines(diff: string): string[] { + if (diff === "") return []; + const lines = diff.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); + if (lines.at(-1) === "") lines.pop(); + return lines; +} + +function line(kind: UnifiedDiffLineKind, prefix: string, text: string, numbers: { oldLineNumber?: number; newLineNumber?: number } = {}): UnifiedDiffLine { + return { + kind, + prefix, + text, + spans: text === "" ? [] : [{ text, changed: false }], + ...numbers, + }; +} + +function applyInlineDiffs(lines: UnifiedDiffLine[]): void { + let index = 0; + while (index < lines.length) { + const current = lines[index]; + if (current?.kind !== "remove") { + index++; + continue; + } + + const removedStart = index; + while (lines[index]?.kind === "remove") index++; + const addedStart = index; + while (lines[index]?.kind === "add") index++; + + if (addedStart === index) continue; + const removedLines = lines.slice(removedStart, addedStart); + const addedLines = lines.slice(addedStart, index); + applyInlineDiffBlock(removedLines, addedLines); + } +} + +function applyInlineDiffBlock(removedLines: UnifiedDiffLine[], addedLines: UnifiedDiffLine[]): void { + if (removedLines.length + addedLines.length > maxInlineBlockLines) return; + for (const pair of pairChangedLines(removedLines, addedLines)) { + const inlineDiff = computeInlineDiff(pair.removed.text, pair.added.text); + if (inlineDiff === undefined) continue; + pair.removed.spans = inlineDiff.removed; + pair.added.spans = inlineDiff.added; + } +} + +function pairChangedLines(removedLines: UnifiedDiffLine[], addedLines: UnifiedDiffLine[]): DiffLinePair[] { + if (removedLines.length === addedLines.length) return removedLines.map((removed, index) => ({ removed, added: addedLines[index] })).filter(isCompletePair); + if (removedLines.length === 1) return bestPairsForSingleRemovedLine(removedLines[0], addedLines); + if (addedLines.length === 1) return bestPairsForSingleAddedLine(removedLines, addedLines[0]); + + const pairs: DiffLinePair[] = []; + const pairCount = Math.min(removedLines.length, addedLines.length); + for (let index = 0; index < pairCount; index++) { + const removed = removedLines[index]; + const added = addedLines[index]; + if (removed === undefined || added === undefined) continue; + if (lineSimilarity(removed.text, added.text) >= minPairSimilarity) pairs.push({ removed, added }); + } + return pairs; +} + +function isCompletePair(pair: { removed: UnifiedDiffLine; added: UnifiedDiffLine | undefined }): pair is DiffLinePair { + return pair.added !== undefined; +} + +function bestPairsForSingleRemovedLine(removed: UnifiedDiffLine | undefined, addedLines: UnifiedDiffLine[]): DiffLinePair[] { + if (removed === undefined) return []; + const added = bestMatchingLine(removed.text, addedLines); + return added === undefined ? [] : [{ removed, added }]; +} + +function bestPairsForSingleAddedLine(removedLines: UnifiedDiffLine[], added: UnifiedDiffLine | undefined): DiffLinePair[] { + if (added === undefined) return []; + const removed = bestMatchingLine(added.text, removedLines); + return removed === undefined ? [] : [{ removed, added }]; +} + +function bestMatchingLine(text: string, candidates: UnifiedDiffLine[]): UnifiedDiffLine | undefined { + let bestCandidate: UnifiedDiffLine | undefined; + let bestScore = minPairSimilarity; + for (const candidate of candidates) { + const score = lineSimilarity(text, candidate.text); + if (score <= bestScore) continue; + bestCandidate = candidate; + bestScore = score; + } + return bestCandidate; +} + +function computeInlineDiff(oldText: string, newText: string): InlineDiffResult | undefined { + if (oldText === newText) return undefined; + if (oldText.length > maxInlineLineLength || newText.length > maxInlineLineLength) return undefined; + + const changes = diffChars(oldText, newText); + const similarity = similarityFromChanges(changes, oldText, newText); + if (Math.max(oldText.length, newText.length) >= 20 && similarity < minInlineSimilarity) return undefined; + + const removed: UnifiedDiffTextSpan[] = []; + const added: UnifiedDiffTextSpan[] = []; + for (const change of changes) { + if (change.value === "") continue; + if (change.added) added.push({ text: change.value, changed: true }); + else if (change.removed) removed.push({ text: change.value, changed: true }); + else { + removed.push({ text: change.value, changed: false }); + added.push({ text: change.value, changed: false }); + } + } + + if (!removed.some((span) => span.changed) && !added.some((span) => span.changed)) return undefined; + return { removed: mergeAdjacentSpans(removed), added: mergeAdjacentSpans(added) }; +} + +function lineSimilarity(oldText: string, newText: string): number { + if (oldText === newText) return 1; + if (oldText.length > maxInlineLineLength || newText.length > maxInlineLineLength) return 0; + return similarityFromChanges(diffChars(oldText, newText), oldText, newText); +} + +function similarityFromChanges(changes: ReturnType, oldText: string, newText: string): number { + const maxLength = Math.max(oldText.length, newText.length); + if (maxLength === 0) return 1; + const unchangedLength = changes.reduce((total, change) => change.added || change.removed ? total : total + change.value.length, 0); + return unchangedLength / maxLength; +} + +function mergeAdjacentSpans(spans: UnifiedDiffTextSpan[]): UnifiedDiffTextSpan[] { + const merged: UnifiedDiffTextSpan[] = []; + for (const span of spans) { + const previous = merged[merged.length - 1]; + if (previous?.changed === span.changed) previous.text += span.text; + else merged.push({ ...span }); + } + return merged; +} diff --git a/src/client/src/plugins/core/panels.ts b/src/client/src/plugins/core/panels.ts index 8ba9ed8..0f0d2a3 100644 --- a/src/client/src/plugins/core/panels.ts +++ b/src/client/src/plugins/core/panels.ts @@ -146,11 +146,11 @@ function renderDiffViewer(context: WorkspacePanelContext): TemplateResult { } function renderDiffSection(diff: GitDiffResponse): TemplateResult { - loadCodeViewer(); + loadUnifiedDiffViewer(); return html`
${diff.path ?? "diff"}${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}
- +
`; } @@ -159,6 +159,10 @@ function loadCodeViewer(): void { void import("../../components/CodeViewer"); } +function loadUnifiedDiffViewer(): void { + void import("../../components/UnifiedDiffViewer"); +} + function loadTerminalPanel(): void { void import("../../components/TerminalPanel"); } From 56c1c1714e77cb9e935f132cf87c903678b46978 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 25 Jun 2026 11:08:26 +0200 Subject: [PATCH 14/24] fix: treat missing subsession files as unavailable --- src/server/sessions/piSessionService.test.ts | 24 +++++--------- src/server/sessions/piSessionService.ts | 33 +++++--------------- src/server/sessions/spawnSubsessionTool.ts | 2 +- 3 files changed, 15 insertions(+), 44 deletions(-) diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index 381e971..4c54fde 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -1032,7 +1032,7 @@ describe("PiSessionService", () => { } }); - it("hydrates persisted links to archived children without scanning unrelated child headers", async () => { + it("does not hydrate persisted links when the exact child file is unavailable", async () => { const parentFile = "/sessions/parent-1.jsonl"; const parent = fakeRuntime("parent-1", { sessionFile: parentFile, @@ -1043,24 +1043,17 @@ describe("PiSessionService", () => { const service = new PiSessionService(new CapturingSessionEventHub(), { createAgentRuntime: runtimeCreator(parent.runtime), sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, - archiveStore: { - ...emptyArchiveStore(), - list: () => Promise.resolve([]), - get: (sessionId) => Promise.resolve(sessionId === "child-1" ? { sessionId: "child-1", cwd: "/workspace-feature", archivedAt: "2026-01-01T00:00:00.000Z", parentSessionPath: parentFile } : undefined), - isArchived: (sessionId) => Promise.resolve(sessionId === "child-1"), - }, + archiveStore: emptyArchiveStore(), heartbeatIntervalMs: 60_000, }); await service.start("/workspace"); - await expect(service.listSubsessions("parent-1")).resolves.toEqual([ - { sessionId: "child-1", cwd: "/workspace-feature", status: "archived" }, - ]); + await expect(service.listSubsessions("parent-1")).resolves.toEqual([]); await service.dispose(); }); - it("does not hydrate parent links without a child file or exact archived child validation", async () => { + it("does not hydrate parent links without a child file", async () => { const parentFile = "/sessions/parent-1.jsonl"; const parent = fakeRuntime("parent-1", { sessionFile: parentFile, @@ -1071,10 +1064,7 @@ describe("PiSessionService", () => { const service = new PiSessionService(new CapturingSessionEventHub(), { createAgentRuntime: runtimeCreator(parent.runtime), sessionManager: { create: () => parent.session.sessionManager, list: () => Promise.resolve([]), listAll: () => Promise.resolve([]), open: () => fakeSessionManager() }, - archiveStore: { - ...emptyArchiveStore(), - get: (sessionId) => Promise.resolve(sessionId === "child" ? { sessionId: "child-fork", cwd: "/workspace-feature", archivedAt: "2026-01-01T00:00:00.000Z", parentSessionPath: parentFile } : undefined), - }, + archiveStore: emptyArchiveStore(), heartbeatIntervalMs: 60_000, }); @@ -1633,7 +1623,7 @@ describe("PiSessionService", () => { await service.dispose(); }); - it("reports an archived child's status in the subsession list", async () => { + it("reports a missing tracked child file as unknown in the subsession list", async () => { const { service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }); await service.start("/workspace"); await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "go", cwd: "/workspace-feature" }); @@ -1641,7 +1631,7 @@ describe("PiSessionService", () => { await service.archive("child-1"); await expect(service.listSubsessions("parent-1")).resolves.toEqual([ - { sessionId: "child-1", cwd: "/workspace-feature", status: "archived" }, + { sessionId: "child-1", cwd: "/workspace-feature", status: "unknown" }, ]); await service.dispose(); }); diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 552f9b6..35047b9 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -503,7 +503,7 @@ export class PiSessionService { return { sessionId, cwd: session.sessionManager.getCwd(), - status: await this.subsessionStatus(session), + status: this.subsessionStatus(session), finalText: finalAssistantText(messages), messageCount: messages.length, }; @@ -516,7 +516,7 @@ export class PiSessionService { return { sessionId, cwd: session.sessionManager.getCwd(), - status: await this.subsessionStatus(session), + status: this.subsessionStatus(session), ...view, }; } @@ -645,7 +645,7 @@ export class PiSessionService { private async registerPersistedSubsessionLinks(parentSessionId: string, parentManager: PiSessionManager, parentSessionFile: string | undefined): Promise { // Parent custom links are the authoritative recovery record: verify the - // exact live child file/header or an exact archived child before tracking. + // exact live child file/header before tracking. const entries = parentManager.getEntries?.() ?? parentManager.getBranch(); for (const entry of entries) { const link = parsePersistedParentSubsessionLink(entry); @@ -664,16 +664,8 @@ export class PiSessionService { } private async parentLinkHasValidChildTarget(parentSessionFile: string, link: PersistedParentSubsessionLink): Promise { - if (link.spawnedSessionFile !== undefined && (await sessionFileHeaderMatches(link.spawnedSessionFile, { sessionId: link.spawnedSessionId, parentSessionFile }))) return true; - return this.archivedSubsessionLinkMatchesParent(parentSessionFile, link); - } - - private async archivedSubsessionLinkMatchesParent(parentSessionFile: string, link: PersistedParentSubsessionLink): Promise { - const archived = await this.getArchivedExact(link.spawnedSessionId); - if (archived?.parentSessionPath === undefined) return false; - if (!sessionPathsEqual(archived.parentSessionPath, parentSessionFile)) return false; - if (archived.originalPath !== undefined && link.spawnedSessionFile !== undefined && !sessionPathsEqual(archived.originalPath, link.spawnedSessionFile)) return false; - return true; + return link.spawnedSessionFile !== undefined + && await sessionFileHeaderMatches(link.spawnedSessionFile, { sessionId: link.spawnedSessionId, parentSessionFile }); } private async recoverSubsessionTrackingForOpenedSession(session: PiAgentSession): Promise { @@ -738,9 +730,6 @@ export class PiSessionService { const active = this.activeChildForSubsessionLink(link); if (active !== undefined) return active.runtime.session; - const archived = await this.getArchivedExact(sessionId); - if (archived?.archivePath !== undefined) return (await this.create(this.sessionManager.open(archived.archivePath), archived.cwd)).runtime.session; - if (link.childSessionFile !== undefined) { if (!(await sessionFileHeaderMatches(link.childSessionFile, { sessionId, parentSessionFile: link.parentSessionFile }))) throw new Error("Session not found"); const sessionManager = this.sessionManager.open(link.childSessionFile); @@ -754,10 +743,8 @@ export class PiSessionService { const link = this.subsessionLinks.get(childSessionId); const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link); if (active !== undefined) { - return { cwd: active.runtime.cwd, status: await this.subsessionStatus(active.runtime.session) }; + return { cwd: active.runtime.cwd, status: this.subsessionStatus(active.runtime.session) }; } - const archived = await this.getArchivedExact(childSessionId); - if (archived !== undefined) return { cwd: archived.cwd, status: "archived" }; if (link?.childSessionFile !== undefined && (await sessionFileHeaderMatches(link.childSessionFile, { sessionId: childSessionId, parentSessionFile: link.parentSessionFile }))) { return { cwd: link.cwd ?? "", status: "idle" }; } @@ -765,8 +752,7 @@ export class PiSessionService { return { cwd: "", status: "unknown" }; } - private async subsessionStatus(session: PiAgentSession): Promise { - if (await this.getArchivedExact(session.sessionId) !== undefined) return "archived"; + private subsessionStatus(session: PiAgentSession): SubsessionStatus { if (this.hasActiveWork(session)) return "working"; if (this.activities.get(session.sessionId)?.phase === "error") return "error"; return "idle"; @@ -1238,11 +1224,6 @@ export class PiSessionService { return archived; } - private async getArchivedExact(sessionId: string): Promise { - const archived = await this.archiveStore.get(sessionId); - return archived?.sessionId === sessionId ? archived : undefined; - } - private activeForLookup(ref: PiSessionLookup): ActiveSession | undefined { const sessionId = sessionIdFromLookup(ref); const exact = this.active.get(sessionId); diff --git a/src/server/sessions/spawnSubsessionTool.ts b/src/server/sessions/spawnSubsessionTool.ts index 5a47665..9297396 100644 --- a/src/server/sessions/spawnSubsessionTool.ts +++ b/src/server/sessions/spawnSubsessionTool.ts @@ -3,7 +3,7 @@ import { defineTool } from "@earendil-works/pi-coding-agent"; import type { TranscriptContentKind, TranscriptEntry, TranscriptRole, TranscriptView } from "./subsessionTranscript.js"; /** Lifecycle phase of a tracked subsession as seen by its parent. */ -export type SubsessionStatus = "working" | "idle" | "error" | "archived" | "unknown"; +export type SubsessionStatus = "working" | "idle" | "error" | "unknown"; export interface SpawnSubsessionResult { sessionId: string; From fb9d444743ae195a2080d53cae17a9d517a18b46 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 25 Jun 2026 11:30:49 +0200 Subject: [PATCH 15/24] docs(relay): add context-contained status baton --- skills/relay/SKILL.md | 84 +++++++++--- skills/relay/evals/evals.json | 56 +++++--- skills/relay/evals/live-behavior-testing.md | 134 ++++++++++++++++++++ 3 files changed, 238 insertions(+), 36 deletions(-) create mode 100644 skills/relay/evals/live-behavior-testing.md 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. From 5269a7a7d7d3900afe518d007439ef79b5df613f Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 25 Jun 2026 14:41:58 +0200 Subject: [PATCH 16/24] fix: support symlinked CLI entrypoint guard --- src/cli.test.ts | 31 ++++++++++++++++++++++++++++++- src/cli.ts | 14 ++++++++++++-- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/cli.test.ts b/src/cli.test.ts index cb3849d..72da8f8 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1,5 +1,8 @@ +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 } from "./cli.js"; +import { commandWithVersionCheck, isCliEntrypoint } from "./cli.js"; const originalShell = process.env["SHELL"]; @@ -29,3 +32,29 @@ describe("commandWithVersionCheck", () => { 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 5e5f663..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"; @@ -1092,7 +1092,17 @@ async function main(): Promise { else throw new Error(`Unknown command: ${command}`); } -if (process.argv[1] === fileURLToPath(import.meta.url)) { +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); From e46d9ecbf8c084f1bea9dcded13ea9e633e1b9a4 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 25 Jun 2026 15:16:07 +0200 Subject: [PATCH 17/24] feat: add safe manual workspace uploads --- .changeset/manual-workspace-uploads.md | 5 + README.md | 2 + docs/config.md | 43 +- src/client/src/api.ts | 4 +- src/client/src/api/parsers.test.ts | 54 +- src/client/src/api/parsers.ts | 18 + src/client/src/api/urls.ts | 8 + src/client/src/api/workspaceUploads.test.ts | 219 ++++++++ src/client/src/api/workspaceUploads.ts | 355 +++++++++++++ src/client/src/appState.ts | 4 + src/client/src/components/PiWebApp.ts | 10 +- src/client/src/components/SettingsDialog.ts | 2 +- .../components/WorkspaceFilesPanel.test.ts | 102 ++++ .../src/components/WorkspaceFilesPanel.ts | 492 ++++++++++++++++++ .../settings/settingsConfigDraft.test.ts | 3 +- .../settings/settingsConfigDraft.ts | 1 + .../fileExplorerController.test.ts | 305 +++++++++++ .../src/controllers/fileExplorerController.ts | 195 ++++++- src/client/src/plugins/core/panels.ts | 87 +--- src/client/src/plugins/registry.test.ts | 4 + src/client/src/plugins/types.ts | 4 + src/client/src/workspaceUploadState.ts | 179 +++++++ src/config.test.ts | 22 +- src/config.ts | 34 +- src/server/app.test.ts | 91 ++++ src/server/app.ts | 27 +- src/server/configRoutes.test.ts | 16 +- src/server/configRoutes.ts | 4 +- src/server/machines/machineClient.test.ts | 47 ++ src/server/machines/machineClient.ts | 38 +- src/server/machines/machineProxyRoutes.ts | 25 +- .../workspaces/projectPiWebConfig.test.ts | 22 +- src/server/workspaces/projectPiWebConfig.ts | 11 +- src/shared/apiTypes.ts | 12 + 34 files changed, 2314 insertions(+), 131 deletions(-) create mode 100644 .changeset/manual-workspace-uploads.md create mode 100644 src/client/src/api/workspaceUploads.test.ts create mode 100644 src/client/src/api/workspaceUploads.ts create mode 100644 src/client/src/components/WorkspaceFilesPanel.test.ts create mode 100644 src/client/src/components/WorkspaceFilesPanel.ts create mode 100644 src/client/src/controllers/fileExplorerController.test.ts create mode 100644 src/client/src/workspaceUploadState.ts create mode 100644 src/server/machines/machineClient.test.ts 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/README.md b/README.md index 801f4b5..2afd031 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ This maps naturally to real development work: - 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. +- Upload files from the Files panel with direct drag/drop, configurable default destinations, and per-file progress. - Add and list server-side projects. - Discover git worktrees automatically with `git worktree list --porcelain`. - Support non-git folders as single-workspace projects. @@ -280,6 +281,7 @@ 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. +- `uploads.defaultFolder` — workspace-relative default destination for manual Files-panel uploads. Set it globally or in `/.pi-web/config.json`; the project-local value wins for that project's workspaces. Defaults to `.pi-web/uploads`. - `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`. 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/src/client/src/api.ts b/src/client/src/api.ts index c6cbeb7..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, 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, 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"; +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/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 891bad3..dd8dae2 100644 --- a/src/client/src/api/parsers.ts +++ b/src/client/src/api/parsers.ts @@ -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"])), }; } @@ -474,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")), @@ -502,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 f520115..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"; @@ -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 { @@ -1271,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); }, diff --git a/src/client/src/components/SettingsDialog.ts b/src/client/src/components/SettingsDialog.ts index 65076ad..86eaa7d 100644 --- a/src/client/src/components/SettingsDialog.ts +++ b/src/client/src/components/SettingsDialog.ts @@ -166,7 +166,7 @@ export class SettingsDialog extends LitElement { try { const response = await configApi.saveConfig(config); this.configResponse = response; - this.onConfigSaved?.(response.config); + this.onConfigSaved?.(response.effectiveConfig); this.showSavedMessage(); } catch (error) { this.error = `Failed to save config: ${errorMessage(error)}`; diff --git a/src/client/src/components/WorkspaceFilesPanel.test.ts b/src/client/src/components/WorkspaceFilesPanel.test.ts new file mode 100644 index 0000000..923fd83 --- /dev/null +++ b/src/client/src/components/WorkspaceFilesPanel.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from "vitest"; +import type { WorkspaceUploadBatchState } from "../workspaceUploadState"; +import { startDirectWorkspaceUpload, uploadBatchProgressValue, uploadBatchStatusLabel, workspaceUploadBatchesForScope, workspaceUploadReviewDefaults, workspaceUploadReviewError } from "./WorkspaceFilesPanel"; + +describe("workspaceUploadBatchesForScope", () => { + it("filters upload batches to the selected project, workspace, and machine", () => { + const matchingOlder = uploadBatch({ id: "older", startedAt: "2026-06-25T00:00:00.000Z" }); + const matchingNewer = uploadBatch({ id: "newer", startedAt: "2026-06-25T00:01:00.000Z" }); + const batches = { + older: matchingOlder, + otherProject: uploadBatch({ id: "otherProject", projectId: "project-2" }), + otherWorkspace: uploadBatch({ id: "otherWorkspace", workspaceId: "workspace-2" }), + otherMachine: uploadBatch({ id: "otherMachine", machineId: "remote-1" }), + newer: matchingNewer, + }; + + expect(workspaceUploadBatchesForScope(batches, { projectId: "project-1", workspaceId: "workspace-1", machineId: "local" })).toEqual([matchingNewer, matchingOlder]); + }); +}); + +describe("workspace upload terminal display", () => { + it("uses terminal labels and full progress for failed batches instead of stale partial percentages", () => { + const failed = uploadBatch({ status: "error", percent: 0.31 }); + + expect(uploadBatchStatusLabel(failed)).toBe("Failed"); + expect(uploadBatchProgressValue(failed)).toBe(1); + }); + + it("keeps live percentages while a batch is uploading", () => { + const uploading = uploadBatch({ status: "uploading", percent: 0.31 }); + + expect(uploadBatchStatusLabel(uploading)).toBe("31%"); + expect(uploadBatchProgressValue(uploading)).toBe(0.31); + }); +}); + +describe("workspace upload defaults", () => { + it("uses safe defaults for the review dialog", () => { + expect(workspaceUploadReviewDefaults("project/uploads")).toEqual({ + destinationFolder: "project/uploads", + createDirs: true, + overwrite: false, + }); + }); + + it("starts drag/drop uploads directly with safe defaults", () => { + const files = [new File(["a"], "a.txt")]; + const onStartWorkspaceUpload = vi.fn(() => ({ batchId: "batch-1", done: Promise.resolve() })); + + const run = startDirectWorkspaceUpload({ workspaceUploadDefaultFolder: "project/uploads", onStartWorkspaceUpload }, files); + + expect(run?.batchId).toBe("batch-1"); + expect(onStartWorkspaceUpload).toHaveBeenCalledWith(files, { + destinationFolder: "project/uploads", + createDirs: true, + overwrite: false, + selectUploadedFile: true, + }); + }); + + it("ignores empty drag/drop uploads", () => { + const onStartWorkspaceUpload = vi.fn(() => ({ batchId: "batch-1", done: Promise.resolve() })); + + expect(startDirectWorkspaceUpload({ workspaceUploadDefaultFolder: "project/uploads", onStartWorkspaceUpload }, [])).toBeUndefined(); + expect(onStartWorkspaceUpload).not.toHaveBeenCalled(); + }); +}); + +describe("workspaceUploadReviewError", () => { + it("accepts one or more files with a workspace-relative destination", () => { + expect(workspaceUploadReviewError([ + new File(["a"], "a.txt"), + new File(["b"], "b.txt"), + ], ".pi-web/uploads")).toBeUndefined(); + }); + + it("rejects empty selections and unsafe destinations before starting an upload", () => { + expect(workspaceUploadReviewError([], ".pi-web/uploads")).toBe("Choose at least one file to upload."); + expect(workspaceUploadReviewError([new File(["a"], "a.txt")], "../outside")).toContain("path traversal"); + }); +}); + +function uploadBatch(patch: Partial = {}): WorkspaceUploadBatchState { + return { + id: patch.id ?? "batch-1", + projectId: patch.projectId ?? "project-1", + workspaceId: patch.workspaceId ?? "workspace-1", + machineId: patch.machineId ?? "local", + destinationFolder: patch.destinationFolder ?? ".pi-web/uploads", + overwrite: patch.overwrite ?? true, + createDirs: patch.createDirs ?? true, + files: patch.files ?? [], + currentFileIndex: patch.currentFileIndex ?? -1, + loaded: patch.loaded ?? 0, + total: patch.total ?? 0, + percent: patch.percent ?? 0, + status: patch.status ?? "uploading", + startedAt: patch.startedAt ?? "2026-06-25T00:00:00.000Z", + ...(patch.completedAt === undefined ? {} : { completedAt: patch.completedAt }), + ...(patch.error === undefined ? {} : { error: patch.error }), + }; +} diff --git a/src/client/src/components/WorkspaceFilesPanel.ts b/src/client/src/components/WorkspaceFilesPanel.ts new file mode 100644 index 0000000..29f080e --- /dev/null +++ b/src/client/src/components/WorkspaceFilesPanel.ts @@ -0,0 +1,492 @@ +import { css, html, LitElement, type PropertyValues, type TemplateResult } from "lit"; +import { customElement, property, query, state } from "lit/decorators.js"; +import type { FileContentResponse, FileTreeEntry } from "../api"; +import { workspaceImagePreviewUrl } from "../api/urls"; +import { workspaceUploadPath } from "../api/workspaceUploads"; +import type { WorkspaceUploadBatchState, WorkspaceUploadFileState } from "../workspaceUploadState"; +import { MAX_IMAGE_PREVIEW_BYTES, MAX_IMAGE_PREVIEW_LABEL } from "../../../shared/workspaceFiles"; +import type { WorkspacePanelContext } from "../plugins/types"; +import { workspacePanelStyles } from "./shared"; + +interface PendingWorkspaceUploadReview { + files: File[]; +} + +export interface WorkspaceUploadScope { + projectId: string; + workspaceId: string; + machineId: string; +} + +@customElement("workspace-files-panel") +export class WorkspaceFilesPanel extends LitElement { + @property({ attribute: false }) context: WorkspacePanelContext | undefined; + @query("#workspace-upload-input") private uploadInput?: HTMLInputElement; + @state() private pendingUpload: PendingWorkspaceUploadReview | undefined; + @state() private destinationFolder = ""; + @state() private overwrite = false; + @state() private createDirs = true; + @state() private formError = ""; + @state() private dragActive = false; + private dragDepth = 0; + + protected override willUpdate(changedProperties: PropertyValues): void { + if (!changedProperties.has("context")) return; + const previous = changedProperties.get("context"); + if (previous !== undefined && this.context !== undefined && workspaceContextKey(previous) !== workspaceContextKey(this.context)) this.resetPendingUpload(); + } + + override render(): TemplateResult { + const context = this.context; + if (context === undefined) return html`

Files unavailable.

`; + return html` +
+
+ Files + ${context.fileTreeStale ? html`stale` : null} +
+ + +
+ +
+ ${this.renderUploadProgress(context)} +
+
+ ${context.fileTree.length === 0 ? html`

No files loaded.

` : context.fileTree.map((entry) => this.renderTreeEntry(context, entry, 0))} +
+
+ ${this.renderFileViewer(context)} +
+
+
+
+ Drop files to upload + Uploads immediately to the default folder. +
+
+ ${this.pendingUpload === undefined ? null : this.renderUploadDialog(context, this.pendingUpload)} +
+ `; + } + + private renderTreeEntry(context: WorkspacePanelContext, entry: FileTreeEntry, depth: number): TemplateResult { + const children = context.expandedDirs[entry.path]; + const hasChildren = children !== undefined; + const selected = entry.type !== "directory" && context.selectedFilePath === entry.path; + return html` + + ${hasChildren ? children.map((child) => this.renderTreeEntry(context, child, depth + 1)) : null} + `; + } + + private selectTreeEntry(context: WorkspacePanelContext, entry: FileTreeEntry): void { + if (entry.type === "directory") context.onExpandDir(entry.path); + else context.onSelectFile(entry.path); + } + + private renderFileViewer(context: WorkspacePanelContext): TemplateResult { + const file = context.selectedFileContent; + if (context.selectedFilePath === undefined || context.selectedFilePath === "") return html`

Select a file.

`; + if (file === undefined) return html`

Loading ${context.selectedFilePath}…

`; + if (file.mediaType === "image") return this.renderImageViewer(context, file); + if (file.binary) return html`

Binary file: ${file.path} · ${formatFileSize(file.size)}

`; + loadCodeViewer(); + return html` +
${file.path}${file.language ?? "text"}${file.truncated ? " · truncated" : ""}
+ + `; + } + + private renderImageViewer(context: WorkspacePanelContext, file: FileContentResponse): TemplateResult { + const metadata = `${file.mimeType ?? "image"} · ${formatFileSize(file.size)}`; + if (file.size > MAX_IMAGE_PREVIEW_BYTES) { + return html` +
${file.path}${metadata}
+

Image too large to preview: ${formatFileSize(file.size)} · limit ${MAX_IMAGE_PREVIEW_LABEL}

+ `; + } + const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt, machineId: context.machine.id }); + return html` +
${file.path}${metadata}
+
+ ${file.path} +
+ `; + } + + private renderUploadProgress(context: WorkspacePanelContext): TemplateResult | null { + const batches = workspaceUploadBatchesForScope(context.state.workspaceUploadBatches, { + projectId: context.workspace.projectId, + workspaceId: context.workspace.id, + machineId: context.machine.id, + }); + if (batches.length === 0) return null; + return html` +
+
+ Uploads + ${uploadSummaryLabel(batches)} +
+ ${batches.map((batch) => this.renderUploadBatch(context, batch))} +
+ `; + } + + private renderUploadBatch(context: WorkspacePanelContext, batch: WorkspaceUploadBatchState): TemplateResult { + return html` +
+
+
+ ${uploadBatchTitle(batch)} + ${batch.destinationFolder === "" ? "workspace root" : batch.destinationFolder} +
+ ${uploadBatchStatusLabel(batch)} +
+ +
+ ${batch.files.map((file) => this.renderUploadFile(file))} +
+
+ ${batch.status === "uploading" ? html`` : html``} +
+
+ `; + } + + private renderUploadFile(file: WorkspaceUploadFileState): TemplateResult { + const detail = uploadFileDetail(file); + return html` +
+
+ ${file.name} + ${detail} +
+ ${uploadFileStatusLabel(file)} +
+ `; + } + + private renderUploadDialog(context: WorkspacePanelContext, review: PendingWorkspaceUploadReview): TemplateResult { + const fileCount = review.files.length; + return html` +
{ this.closeUploadDialog(); }}> + +
+ `; + } + + private readonly openFilePicker = (): void => { + this.uploadInput?.click(); + }; + + private readonly handleFileInputChange = (event: Event): void => { + const input = event.currentTarget instanceof HTMLInputElement ? event.currentTarget : undefined; + const files = fileListToArray(input?.files); + if (input !== undefined) input.value = ""; + if (files.length > 0) this.openUploadReview(files); + }; + + private readonly handleDragEnter = (event: DragEvent): void => { + if (!isFileDrag(event)) return; + event.preventDefault(); + this.dragDepth += 1; + this.dragActive = true; + }; + + private readonly handleDragOver = (event: DragEvent): void => { + if (!isFileDrag(event)) return; + event.preventDefault(); + if (event.dataTransfer !== null) event.dataTransfer.dropEffect = "copy"; + this.dragActive = true; + }; + + private readonly handleDragLeave = (event: DragEvent): void => { + if (!isFileDrag(event)) return; + event.preventDefault(); + this.dragDepth = Math.max(0, this.dragDepth - 1); + if (this.dragDepth === 0) this.dragActive = false; + }; + + private readonly handleDrop = (event: DragEvent): void => { + if (!isFileDrag(event)) return; + event.preventDefault(); + this.dragDepth = 0; + this.dragActive = false; + const files = fileListToArray(event.dataTransfer?.files); + const context = this.context; + if (files.length > 0 && context !== undefined) startDirectWorkspaceUpload(context, files); + }; + + private readonly handleDestinationInput = (event: Event): void => { + const input = event.currentTarget instanceof HTMLInputElement ? event.currentTarget : undefined; + this.destinationFolder = input?.value ?? ""; + this.formError = ""; + }; + + private readonly handleCreateDirsChange = (event: Event): void => { + const input = event.currentTarget instanceof HTMLInputElement ? event.currentTarget : undefined; + this.createDirs = input?.checked ?? true; + }; + + private readonly handleOverwriteChange = (event: Event): void => { + const input = event.currentTarget instanceof HTMLInputElement ? event.currentTarget : undefined; + this.overwrite = input?.checked ?? false; + }; + + private readonly handleDialogKeyDown = (event: KeyboardEvent): void => { + if (event.key !== "Escape") return; + event.preventDefault(); + this.closeUploadDialog(); + }; + + private openUploadReview(files: File[]): void { + const context = this.context; + const defaults = workspaceUploadReviewDefaults(context?.workspaceUploadDefaultFolder ?? ""); + this.pendingUpload = { files }; + this.destinationFolder = defaults.destinationFolder; + this.overwrite = defaults.overwrite; + this.createDirs = defaults.createDirs; + this.formError = ""; + } + + private submitUploadReview(event: SubmitEvent, context: WorkspacePanelContext, review: PendingWorkspaceUploadReview): void { + event.preventDefault(); + const validationError = workspaceUploadReviewError(review.files, this.destinationFolder); + if (validationError !== undefined) { + this.formError = validationError; + return; + } + const run = context.onStartWorkspaceUpload(review.files, { + destinationFolder: this.destinationFolder, + createDirs: this.createDirs, + overwrite: this.overwrite, + selectUploadedFile: true, + }); + if (run !== undefined) this.closeUploadDialog(); + } + + private closeUploadDialog(): void { + this.pendingUpload = undefined; + this.formError = ""; + } + + private resetPendingUpload(): void { + this.closeUploadDialog(); + this.dragDepth = 0; + this.dragActive = false; + } + + static override styles = [ + workspacePanelStyles, + css` + :host { flex: 1 1 auto; } + .files-panel { position: relative; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } + .toolbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; } + .toolbar .toolbar-actions button { margin-left: 0; } + .visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; } + .drop-overlay { position: absolute; inset: 52px 10px 10px; z-index: 15; display: grid; place-items: center; border: 2px dashed var(--pi-accent); border-radius: 12px; background: color-mix(in srgb, var(--pi-bg-overlay) 90%, var(--pi-accent) 10%); color: var(--pi-text); opacity: 0; pointer-events: none; transition: opacity .12s ease; } + .files-panel.dragging .drop-overlay { opacity: 1; } + .drop-overlay div { display: grid; gap: 4px; justify-items: center; padding: 18px; border-radius: 10px; background: var(--pi-bg-overlay); box-shadow: 0 8px 24px var(--pi-shadow); } + .drop-overlay span { color: var(--pi-muted); } + .upload-progress { flex: 0 0 auto; display: grid; gap: 8px; padding: 8px; border-bottom: 1px solid var(--pi-border-muted); background: color-mix(in srgb, var(--pi-surface) 55%, transparent); } + .upload-progress-header, .upload-batch-heading, .upload-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; } + .upload-batch { display: grid; gap: 6px; border: 1px solid var(--pi-border-muted); border-radius: 8px; background: var(--pi-bg); padding: 8px; } + .upload-batch.error { border-color: var(--pi-danger); } + .upload-batch.cancelled { border-color: var(--pi-warning-border); } + .upload-batch.completed { border-color: var(--pi-success-border); } + .upload-batch-heading > div { min-width: 0; display: grid; gap: 2px; } + .upload-batch-heading strong, .upload-batch-heading small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + progress { width: 100%; accent-color: var(--pi-accent); } + .upload-file-list { display: grid; gap: 4px; max-height: 180px; overflow: auto; padding-right: 2px; } + .upload-file { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 8px; color: var(--pi-muted); } + .upload-file.completed .upload-file-status { color: var(--pi-success); } + .upload-file.error { color: var(--pi-danger); } + .upload-file.cancelled .upload-file-status { color: var(--pi-warning); } + .upload-file-main { min-width: 0; display: grid; gap: 1px; } + .upload-file-main span, .upload-file-main small { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .upload-file-status { font-size: 12px; white-space: nowrap; } + .upload-actions { justify-content: end; } + .dialog-backdrop { position: fixed; inset: 0; z-index: 100; box-sizing: border-box; display: grid; place-items: center; padding: max(20px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) max(20px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); background: var(--pi-overlay); } + .upload-dialog { box-sizing: border-box; width: min(560px, 100%); max-height: min(720px, 100%); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--pi-border); border-radius: 14px; background: var(--pi-bg); box-shadow: 0 18px 70px var(--pi-shadow-strong); } + .upload-dialog header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--pi-border-muted); } + .upload-dialog h2 { margin: 2px 0 0; font-size: 18px; line-height: 1.2; } + .eyebrow { color: var(--pi-muted); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; } + .close-button { font-size: 20px; line-height: 1; padding: 4px 9px; } + form { min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: auto; padding: 16px; } + form > label { display: grid; gap: 6px; } + form > label > span, .review-files > strong { font-weight: 600; } + input[type="text"], form > label > input:not([type]) { box-sizing: border-box; width: 100%; border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 8px 9px; font: inherit; } + input:focus-visible { outline: 2px solid var(--pi-accent); outline-offset: 1px; } + .dialog-options { display: grid; gap: 8px; } + .dialog-options label { display: flex; align-items: center; gap: 8px; color: var(--pi-text); } + .review-files { display: grid; gap: 6px; min-height: 0; max-height: 180px; overflow: auto; border: 1px solid var(--pi-border-muted); border-radius: 8px; padding: 8px; } + .review-file { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: baseline; } + .review-file span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .dialog-error { border: 1px solid var(--pi-danger); border-radius: 8px; background: color-mix(in srgb, var(--pi-danger) 10%, transparent); color: var(--pi-danger); padding: 9px; line-height: 1.35; overflow-wrap: anywhere; } + footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 4px; } + `, + ]; +} + +export function workspaceUploadBatchesForScope(batches: Record, scope: WorkspaceUploadScope): WorkspaceUploadBatchState[] { + return Object.values(batches) + .filter((batch) => batch.projectId === scope.projectId && batch.workspaceId === scope.workspaceId && batch.machineId === scope.machineId) + .sort((left, right) => right.startedAt.localeCompare(left.startedAt)); +} + +export function workspaceUploadReviewError(files: readonly File[], destinationFolder: string): string | undefined { + if (files.length === 0) return "Choose at least one file to upload."; + for (const file of files) { + try { + workspaceUploadPath(destinationFolder, file.name); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + } + return undefined; +} + +export function workspaceUploadReviewDefaults(destinationFolder: string): { destinationFolder: string; createDirs: boolean; overwrite: boolean } { + return { destinationFolder, createDirs: true, overwrite: false }; +} + +export function startDirectWorkspaceUpload( + context: Pick, + files: readonly File[], +): ReturnType { + if (files.length === 0) return undefined; + return context.onStartWorkspaceUpload(files, { + destinationFolder: context.workspaceUploadDefaultFolder, + createDirs: true, + overwrite: false, + selectUploadedFile: true, + }); +} + +function workspaceContextKey(context: WorkspacePanelContext): string { + return `${context.machine.id}:${context.workspace.projectId}:${context.workspace.id}`; +} + +function fileListToArray(files: FileList | null | undefined): File[] { + return files === null || files === undefined ? [] : Array.from(files); +} + +function isFileDrag(event: DragEvent): boolean { + return Array.from(event.dataTransfer?.types ?? []).includes("Files"); +} + +function uploadSummaryLabel(batches: readonly WorkspaceUploadBatchState[]): string { + const uploading = batches.filter((batch) => batch.status === "uploading").length; + return uploading === 0 ? `${String(batches.length)} recent` : `${String(uploading)} uploading`; +} + +function uploadBatchTitle(batch: WorkspaceUploadBatchState): string { + const count = batch.files.length; + const files = count === 1 ? "file" : "files"; + switch (batch.status) { + case "completed": return `Uploaded ${String(count)} ${files}`; + case "error": return `Upload failed for ${String(count)} ${files}`; + case "cancelled": return `Upload cancelled for ${String(count)} ${files}`; + case "uploading": return `Uploading ${String(count)} ${files}`; + } +} + +export function uploadBatchStatusLabel(batch: WorkspaceUploadBatchState): string { + switch (batch.status) { + case "completed": return "Done"; + case "error": return "Failed"; + case "cancelled": return "Cancelled"; + case "uploading": return formatPercent(batch.percent); + } +} + +export function uploadBatchProgressValue(batch: WorkspaceUploadBatchState): number { + return batch.status === "uploading" ? batch.percent : 1; +} + +function uploadFileStatusLabel(file: WorkspaceUploadFileState): string { + switch (file.status) { + case "pending": return "Pending"; + case "uploading": return formatPercent(file.percent); + case "completed": return "Done"; + case "error": return "Error"; + case "cancelled": return "Cancelled"; + } +} + +function uploadFileDetail(file: WorkspaceUploadFileState): string { + if (file.error !== undefined) return file.error; + if (file.response !== undefined) return `Wrote ${file.response.path}`; + return `${file.path} · ${formatFileSize(file.loaded)} / ${formatFileSize(file.total)}`; +} + +function formatPercent(value: number): string { + return `${String(Math.round(Math.max(0, Math.min(1, value)) * 100))}%`; +} + +function loadCodeViewer(): void { + void import("./CodeViewer"); +} + +function formatFileSize(size: number): string { + if (!Number.isFinite(size) || size < 0) return "0 B"; + if (size < 1024) return `${String(size)} B`; + const kib = size / 1024; + if (kib < 1024) return `${formatScaledFileSize(kib)} KB`; + const mib = kib / 1024; + if (mib < 1024) return `${formatScaledFileSize(mib)} MB`; + return `${formatScaledFileSize(mib / 1024)} GB`; +} + +function formatScaledFileSize(value: number): string { + return value >= 10 ? String(Math.round(value)) : value.toFixed(1); +} diff --git a/src/client/src/components/settings/settingsConfigDraft.test.ts b/src/client/src/components/settings/settingsConfigDraft.test.ts index 2ff81c2..69ebb53 100644 --- a/src/client/src/components/settings/settingsConfigDraft.test.ts +++ b/src/client/src/components/settings/settingsConfigDraft.test.ts @@ -20,13 +20,14 @@ describe("settings config drafts", () => { allowedHostsMode: "list", allowedHostsText: "example.local, 192.168.1.20\n", allowedPathsText: "/tmp\n~/SDKs\n", - }, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, maxUploadBytes: 1234 })).toEqual({ + }, { shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234 })).toEqual({ host: "127.0.0.1", port: 9000, allowedHosts: ["example.local", "192.168.1.20"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, + uploads: { defaultFolder: "manual/uploads" }, maxUploadBytes: 1234, }); }); diff --git a/src/client/src/components/settings/settingsConfigDraft.ts b/src/client/src/components/settings/settingsConfigDraft.ts index 2df0e7c..414ef16 100644 --- a/src/client/src/components/settings/settingsConfigDraft.ts +++ b/src/client/src/components/settings/settingsConfigDraft.ts @@ -26,6 +26,7 @@ export function configFromDraft(draft: ConfigDraft, baseConfig: PiWebConfigValue const config: PiWebConfigValues = { ...(baseConfig.shortcuts === undefined ? {} : { shortcuts: baseConfig.shortcuts }), ...(baseConfig.plugins === undefined ? {} : { plugins: baseConfig.plugins }), + ...(baseConfig.uploads === undefined ? {} : { uploads: baseConfig.uploads }), ...(baseConfig.maxUploadBytes === undefined ? {} : { maxUploadBytes: baseConfig.maxUploadBytes }), ...(baseConfig.spawnSessions === undefined ? {} : { spawnSessions: baseConfig.spawnSessions }), ...(baseConfig.subsessions === undefined ? {} : { subsessions: baseConfig.subsessions }), diff --git a/src/client/src/controllers/fileExplorerController.test.ts b/src/client/src/controllers/fileExplorerController.test.ts new file mode 100644 index 0000000..4cf87e5 --- /dev/null +++ b/src/client/src/controllers/fileExplorerController.test.ts @@ -0,0 +1,305 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { initialAppState, type AppState } from "../appState"; +import { + WorkspaceUploadBatchError, + WorkspaceUploadCancelledError, + type FileContentResponse, + type FileTreeResponse, + type Machine, + type Project, + type Workspace, + type WorkspaceUploadBatchProgress, + type WriteWorkspaceFileResponse, +} from "../api"; +import { FileExplorerController, type FileExplorerControllerDependencies } from "./fileExplorerController"; + +type UploadWorkspaceFiles = NonNullable; +type UploadWorkspaceFilesOptions = NonNullable[3]>; + +const originalWindow = globalThis.window; + +afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(globalThis, "window", { value: originalWindow, configurable: true }); +}); + +const machine: Machine = { + id: "remote-1", + name: "Remote", + kind: "remote", + createdAt: "2026-06-25T00:00:00.000Z", + updatedAt: "2026-06-25T00:00:00.000Z", +}; + +const project: Project = { + id: "project-1", + name: "Project", + path: "/repo", + createdAt: "2026-06-25T00:00:00.000Z", +}; + +const workspace: Workspace = { + id: "workspace-1", + projectId: project.id, + path: "/repo", + label: "repo", + isMain: true, + isGitRepo: true, + isGitWorktree: false, +}; + +describe("FileExplorerController workspace uploads", () => { + it("tracks upload progress, completes from final responses, refreshes files, and selects the first uploaded file", async () => { + const upload = controllableUpload(); + const harness = createHarness({ uploadWorkspaceFiles: upload.fn, now: sequenceNow("start", "complete") }); + const files = [new File(["aa"], "a.txt", { type: "text/plain" }), new File(["bbb"], "b.txt")]; + + const run = harness.controller.startWorkspaceUpload(files, { destinationFolder: "uploads/manual", overwrite: false }); + + expect(run?.batchId).toBe("batch-1"); + expect(upload.fn).toHaveBeenCalledWith("project-1", "workspace-1", files, expect.objectContaining({ + destinationFolder: "uploads/manual", + machineId: "remote-1", + overwrite: false, + createDirs: true, + })); + expect(harness.state.workspaceUploadBatches["batch-1"]).toMatchObject({ + destinationFolder: "uploads/manual", + overwrite: false, + createDirs: true, + status: "uploading", + startedAt: "start", + total: 5, + files: [ + { name: "a.txt", path: "uploads/manual/a.txt", status: "uploading", total: 2 }, + { name: "b.txt", path: "uploads/manual/b.txt", status: "pending", total: 3 }, + ], + }); + + upload.emitProgress({ + currentFileIndex: 0, + files: [ + { index: 0, name: "a.txt", path: "uploads/manual/a.txt", loaded: 1, total: 2, percent: 0.5, lengthComputable: true, done: false }, + { index: 1, name: "b.txt", path: "uploads/manual/b.txt", loaded: 0, total: 3, percent: 0, lengthComputable: true, done: false }, + ], + loaded: 1, + total: 5, + percent: 0.2, + done: false, + }); + + expect(harness.state.workspaceUploadBatches["batch-1"]).toMatchObject({ + loaded: 1, + percent: 0.2, + files: [ + { path: "uploads/manual/a.txt", loaded: 1, percent: 0.5, status: "uploading" }, + { path: "uploads/manual/b.txt", loaded: 0, status: "pending" }, + ], + }); + + upload.resolve([ + writeResponse("uploads/manual/a.txt", 2), + writeResponse("uploads/manual/b.txt", 3), + ]); + await run?.done; + + expect(harness.api.workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "", "remote-1"); + expect(harness.api.workspaceFile).toHaveBeenCalledWith("project-1", "workspace-1", "uploads/manual/a.txt", "remote-1"); + expect(harness.updateUrl).toHaveBeenCalledWith({ replace: true }); + expect(harness.state.selectedFilePath).toBe("uploads/manual/a.txt"); + expect(harness.state.workspaceUploadBatches["batch-1"]).toMatchObject({ + status: "completed", + completedAt: "complete", + loaded: 5, + percent: 1, + files: [ + { status: "completed", response: { path: "uploads/manual/a.txt", size: 2 } }, + { status: "completed", response: { path: "uploads/manual/b.txt", size: 3 } }, + ], + }); + }); + + it("defaults uploads to create parent folders without overwriting existing files", () => { + const upload = controllableUpload(); + const harness = createHarness({ uploadWorkspaceFiles: upload.fn }); + const files = [new File(["aa"], "a.txt")]; + + const run = harness.controller.startWorkspaceUpload(files, { destinationFolder: "uploads" }); + + expect(run?.batchId).toBe("batch-1"); + expect(upload.fn).toHaveBeenCalledWith("project-1", "workspace-1", files, expect.objectContaining({ + destinationFolder: "uploads", + machineId: "remote-1", + overwrite: false, + createDirs: true, + })); + expect(harness.state.workspaceUploadBatches["batch-1"]).toMatchObject({ + destinationFolder: "uploads", + overwrite: false, + createDirs: true, + }); + }); + + it("cancels an in-flight upload without setting the global error", async () => { + const upload = controllableUpload({ rejectOnCancel: true }); + const harness = createHarness({ uploadWorkspaceFiles: upload.fn, now: sequenceNow("start", "cancel") }); + const run = harness.controller.startWorkspaceUpload([new File(["aa"], "a.txt")], { destinationFolder: "uploads" }); + + harness.controller.cancelWorkspaceUpload(run?.batchId ?? "missing"); + await run?.done; + + expect(upload.cancel).toHaveBeenCalledTimes(1); + expect(harness.state.error).toBe(""); + expect(harness.state.workspaceUploadBatches["batch-1"]).toMatchObject({ + status: "cancelled", + completedAt: "cancel", + error: "Upload cancelled", + files: [{ status: "cancelled", error: "Upload cancelled" }], + }); + }); + + it("keeps per-file errors accurate and refreshes after partial batch success", async () => { + const upload = controllableUpload(); + const harness = createHarness({ uploadWorkspaceFiles: upload.fn, now: sequenceNow("start", "fail") }); + const run = harness.controller.startWorkspaceUpload([new File(["aa"], "a.txt"), new File(["bbbb"], "b.txt")], { destinationFolder: "uploads" }); + + upload.emitProgress({ + currentFileIndex: 1, + files: [ + { index: 0, name: "a.txt", path: "uploads/a.txt", loaded: 2, total: 2, percent: 1, lengthComputable: true, done: true, error: "File already exists: uploads/a.txt" }, + { index: 1, name: "b.txt", path: "uploads/b.txt", loaded: 4, total: 4, percent: 1, lengthComputable: true, done: true }, + ], + loaded: 6, + total: 6, + percent: 1, + done: true, + }); + upload.reject(new WorkspaceUploadBatchError( + [{ index: 0, name: "a.txt", path: "uploads/a.txt", error: "File already exists: uploads/a.txt" }], + [writeResponse("uploads/b.txt", 4)], + )); + await run?.done; + + expect(harness.api.workspaceTree).toHaveBeenCalledWith("project-1", "workspace-1", "", "remote-1"); + expect(harness.api.workspaceFile).toHaveBeenCalledWith("project-1", "workspace-1", "uploads/b.txt", "remote-1"); + expect(harness.state.error).toBe(""); + expect(harness.state.selectedFilePath).toBe("uploads/b.txt"); + expect(harness.state.workspaceUploadBatches["batch-1"]).toMatchObject({ + status: "error", + completedAt: "fail", + error: "File already exists: uploads/a.txt", + loaded: 6, + total: 6, + percent: 1, + files: [ + { path: "uploads/a.txt", status: "error", error: "File already exists: uploads/a.txt" }, + { path: "uploads/b.txt", status: "completed" }, + ], + }); + }); + + it("rejects unsafe upload destinations before starting a batch", () => { + const upload = controllableUpload(); + const harness = createHarness({ uploadWorkspaceFiles: upload.fn }); + + const run = harness.controller.startWorkspaceUpload([new File(["aa"], "a.txt")], { destinationFolder: "../outside" }); + + expect(run).toBeUndefined(); + expect(upload.fn).not.toHaveBeenCalled(); + expect(harness.state.workspaceUploadBatches).toEqual({}); + expect(harness.state.error).toContain("upload destination must not contain path traversal"); + }); +}); + +function createHarness(deps: FileExplorerControllerDependencies = {}) { + installWindow("http://localhost/app"); + let state: AppState = { + ...initialAppState(), + selectedMachine: machine, + selectedProject: project, + selectedWorkspace: workspace, + }; + const api: NonNullable = deps.api ?? { + workspaceTree: vi.fn["workspaceTree"]>((_projectId, _workspaceId, path = "") => Promise.resolve(treeResponse(path))), + workspaceFile: vi.fn["workspaceFile"]>((_projectId, _workspaceId, path) => Promise.resolve(fileResponse(path))), + }; + const updateUrl = vi.fn(); + let batchSequence = 0; + const controller = new FileExplorerController( + () => state, + (patch) => { state = { ...state, ...patch }; }, + updateUrl, + { + ...deps, + api, + createUploadBatchId: deps.createUploadBatchId ?? (() => { + batchSequence += 1; + return `batch-${String(batchSequence)}`; + }), + }, + ); + return { + controller, + api, + updateUrl, + get state(): AppState { return state; }, + }; +} + +function installWindow(href: string): void { + const url = new URL(href); + const fakeWindow = { + location: { + href: url.href, + pathname: url.pathname, + search: url.search, + hash: url.hash, + }, + history: { + pushState: vi.fn(), + replaceState: vi.fn(), + }, + }; + Object.defineProperty(globalThis, "window", { value: fakeWindow, configurable: true }); +} + +function controllableUpload(options: { rejectOnCancel?: boolean } = {}) { + let resolveUpload: ((responses: WriteWorkspaceFileResponse[]) => void) | undefined; + let rejectUpload: ((error: unknown) => void) | undefined; + let uploadOptions: UploadWorkspaceFilesOptions | undefined; + const cancel = vi.fn(() => { + if (options.rejectOnCancel === true) rejectUpload?.(new WorkspaceUploadCancelledError()); + }); + const fn = vi.fn((_projectId, _workspaceId, _files, sentOptions = {}) => { + uploadOptions = sentOptions; + const promise = new Promise((resolve, reject) => { + resolveUpload = resolve; + rejectUpload = reject; + }); + return { promise, cancel }; + }); + return { + fn, + cancel, + emitProgress: (progress: WorkspaceUploadBatchProgress) => { uploadOptions?.onProgress?.(progress); }, + resolve: (responses: WriteWorkspaceFileResponse[]) => { resolveUpload?.(responses); }, + reject: (error: unknown) => { rejectUpload?.(error); }, + }; +} + +function sequenceNow(...values: string[]): () => string { + let index = 0; + return () => values[index++] ?? values.at(-1) ?? "now"; +} + +function treeResponse(path: string): FileTreeResponse { + return { path, entries: [], scannedAt: "2026-06-25T00:00:00.000Z", truncated: false }; +} + +function fileResponse(path: string): FileContentResponse { + return { path, encoding: "utf8", size: 2, modifiedAt: "2026-06-25T00:00:00.000Z", content: "aa", truncated: false, binary: false }; +} + +function writeResponse(path: string, size: number): WriteWorkspaceFileResponse { + return { path, size, modifiedAt: "2026-06-25T00:00:00.000Z", created: true }; +} diff --git a/src/client/src/controllers/fileExplorerController.ts b/src/client/src/controllers/fileExplorerController.ts index e9dd073..12d6fa9 100644 --- a/src/client/src/controllers/fileExplorerController.ts +++ b/src/client/src/controllers/fileExplorerController.ts @@ -1,11 +1,69 @@ -import { api } from "../api"; +import { + api as defaultApi, + uploadWorkspaceFiles as defaultUploadWorkspaceFiles, + WorkspaceUploadBatchError, + WorkspaceUploadCancelledError, + type WorkspaceUploadBatchProgress, + type WorkspaceUploadTask, + type WriteWorkspaceFileResponse, +} from "../api"; import { queryNamespace, setNamespacedQueryKey } from "../namespacedQueryArgs"; +import { + cancelWorkspaceUploadBatch, + completeWorkspaceUploadBatch, + createWorkspaceUploadBatchState, + failWorkspaceUploadBatch, + updateWorkspaceUploadBatchProgress, + type WorkspaceUploadBatchState, +} from "../workspaceUploadState"; import { selectedMachineId, type GetState, type SetState, type UpdateUrl } from "./types"; const FILES_ROUTE_NAMESPACE = queryNamespace("core:workspace.files"); +type FileExplorerApi = Pick; +type UploadWorkspaceFiles = typeof defaultUploadWorkspaceFiles; + +export interface FileExplorerControllerDependencies { + api?: FileExplorerApi; + uploadWorkspaceFiles?: UploadWorkspaceFiles; + createUploadBatchId?: () => string; + now?: () => string; +} + +export interface StartWorkspaceUploadOptions { + destinationFolder: string; + createDirs?: boolean; + overwrite?: boolean; + selectUploadedFile?: boolean; +} + +export interface WorkspaceUploadRun { + batchId: string; + done: Promise; +} + export class FileExplorerController { - constructor(private readonly getState: GetState, private readonly setState: SetState, private readonly updateUrl: UpdateUrl) {} + private readonly api: FileExplorerApi; + private readonly uploadWorkspaceFiles: UploadWorkspaceFiles; + private readonly createUploadBatchId: () => string; + private readonly now: () => string; + private readonly uploadTasks = new Map>(); + private uploadBatchSequence = 0; + + constructor( + private readonly getState: GetState, + private readonly setState: SetState, + private readonly updateUrl: UpdateUrl, + deps: FileExplorerControllerDependencies = {}, + ) { + this.api = deps.api ?? defaultApi; + this.uploadWorkspaceFiles = deps.uploadWorkspaceFiles ?? defaultUploadWorkspaceFiles; + this.createUploadBatchId = deps.createUploadBatchId ?? (() => { + this.uploadBatchSequence += 1; + return `workspace-upload-${String(this.uploadBatchSequence)}`; + }); + this.now = deps.now ?? (() => new Date().toISOString()); + } async refreshFiles(): Promise { const project = this.getState().selectedProject; @@ -13,9 +71,9 @@ export class FileExplorerController { if (project === undefined || workspace === undefined) return; try { const machineId = selectedMachineId(this.getState()); - const root = await api.workspaceTree(project.id, workspace.id, "", machineId); + const root = await this.api.workspaceTree(project.id, workspace.id, "", machineId); const expanded = { ...this.getState().expandedDirs }; - await Promise.all(Object.keys(expanded).map(async (path) => { expanded[path] = (await api.workspaceTree(project.id, workspace.id, path, machineId)).entries; })); + await Promise.all(Object.keys(expanded).map(async (path) => { expanded[path] = (await this.api.workspaceTree(project.id, workspace.id, path, machineId)).entries; })); this.setState({ fileTree: root.entries, expandedDirs: expanded, fileTreeStale: false, error: "" }); } catch (error) { this.setState({ error: String(error) }); @@ -31,7 +89,7 @@ export class FileExplorerController { return; } try { - const response = await api.workspaceTree(project.id, workspace.id, path, selectedMachineId(this.getState())); + const response = await this.api.workspaceTree(project.id, workspace.id, path, selectedMachineId(this.getState())); this.setState({ expandedDirs: { ...this.getState().expandedDirs, [path]: response.entries }, error: "" }); } catch (error) { this.setState({ error: String(error) }); @@ -51,7 +109,7 @@ export class FileExplorerController { if (project === undefined || workspace === undefined) return; this.setState({ selectedFilePath: path, selectedFileContent: undefined }); try { - const content = await api.workspaceFile(project.id, workspace.id, path, selectedMachineId(this.getState())); + const content = await this.api.workspaceFile(project.id, workspace.id, path, selectedMachineId(this.getState())); if (this.getState().selectedFilePath === path) this.setState({ selectedFileContent: content, error: "" }); } catch (error) { if (this.getState().selectedFilePath !== path) return; @@ -64,6 +122,123 @@ export class FileExplorerController { this.setState({ error: String(error) }); } } + + startWorkspaceUpload(files: readonly File[], options: StartWorkspaceUploadOptions): WorkspaceUploadRun | undefined { + const project = this.getState().selectedProject; + const workspace = this.getState().selectedWorkspace; + if (project === undefined || workspace === undefined) { + this.setState({ error: "Select a workspace before uploading files." }); + return undefined; + } + if (files.length === 0) return undefined; + + const machineId = selectedMachineId(this.getState()); + const overwrite = options.overwrite ?? false; + const createDirs = options.createDirs ?? true; + let batch: WorkspaceUploadBatchState; + try { + batch = createWorkspaceUploadBatchState({ + id: this.createUploadBatchId(), + projectId: project.id, + workspaceId: workspace.id, + machineId, + destinationFolder: options.destinationFolder, + overwrite, + createDirs, + files, + startedAt: this.now(), + }); + } catch (error) { + this.setState({ error: String(error) }); + return undefined; + } + + this.setUploadBatch(batch); + let task: WorkspaceUploadTask; + try { + task = this.uploadWorkspaceFiles(project.id, workspace.id, files, { + destinationFolder: options.destinationFolder, + machineId, + overwrite, + createDirs, + onProgress: (progress) => { this.updateUploadProgress(batch.id, progress); }, + }); + } catch (error) { + this.failUploadBatch(batch.id, error); + return { batchId: batch.id, done: Promise.resolve() }; + } + + this.uploadTasks.set(batch.id, task); + const done = task.promise + .then(async (responses) => { await this.completeUploadBatch(batch.id, responses, options); }) + .catch(async (error: unknown) => { await this.handleUploadFailure(batch.id, error, options); }) + .finally(() => { this.uploadTasks.delete(batch.id); }); + return { batchId: batch.id, done }; + } + + cancelWorkspaceUpload(batchId: string): void { + const batch = this.getUploadBatch(batchId); + if (batch?.status !== "uploading") return; + this.setUploadBatch(cancelWorkspaceUploadBatch(batch, this.now())); + this.uploadTasks.get(batchId)?.cancel(); + } + + clearWorkspaceUpload(batchId: string): void { + this.uploadTasks.get(batchId)?.cancel(); + this.uploadTasks.delete(batchId); + this.setState({ workspaceUploadBatches: omitKey(this.getState().workspaceUploadBatches, batchId) }); + } + + private updateUploadProgress(batchId: string, progress: WorkspaceUploadBatchProgress): void { + const batch = this.getUploadBatch(batchId); + if (batch?.status !== "uploading") return; + this.setUploadBatch(updateWorkspaceUploadBatchProgress(batch, progress)); + } + + private async completeUploadBatch(batchId: string, responses: WriteWorkspaceFileResponse[], options: StartWorkspaceUploadOptions): Promise { + const batch = this.getUploadBatch(batchId); + if (batch?.status !== "uploading") return; + this.setUploadBatch(completeWorkspaceUploadBatch(batch, responses, this.now()), { error: "" }); + if (!this.isCurrentWorkspaceBatch(batch)) return; + await this.refreshFiles(); + const uploadedPath = responses[0]?.path; + if (options.selectUploadedFile !== false && uploadedPath !== undefined && this.isCurrentWorkspaceBatch(batch)) await this.selectFile(uploadedPath); + } + + private async handleUploadFailure(batchId: string, error: unknown, options: StartWorkspaceUploadOptions): Promise { + const batch = this.failUploadBatch(batchId, error); + if (!(error instanceof WorkspaceUploadBatchError) || error.responses.length === 0 || batch === undefined || !this.isCurrentWorkspaceBatch(batch)) return; + await this.refreshFiles(); + const uploadedPath = error.responses[0]?.path; + if (options.selectUploadedFile !== false && uploadedPath !== undefined && this.isCurrentWorkspaceBatch(batch)) await this.selectFile(uploadedPath); + } + + private failUploadBatch(batchId: string, error: unknown): WorkspaceUploadBatchState | undefined { + const batch = this.getUploadBatch(batchId); + if (batch?.status !== "uploading") return undefined; + if (isWorkspaceUploadCancelled(error)) { + const cancelled = cancelWorkspaceUploadBatch(batch, this.now()); + this.setUploadBatch(cancelled); + return cancelled; + } + const message = errorMessage(error); + const failed = failWorkspaceUploadBatch(batch, message, this.now()); + this.setUploadBatch(failed, { error: message }); + return failed; + } + + private getUploadBatch(batchId: string): WorkspaceUploadBatchState | undefined { + return this.getState().workspaceUploadBatches[batchId]; + } + + private setUploadBatch(batch: WorkspaceUploadBatchState, patch: { error?: string } = {}): void { + this.setState({ workspaceUploadBatches: { ...this.getState().workspaceUploadBatches, [batch.id]: batch }, ...patch }); + } + + private isCurrentWorkspaceBatch(batch: WorkspaceUploadBatchState): boolean { + const state = this.getState(); + return state.selectedProject?.id === batch.projectId && state.selectedWorkspace?.id === batch.workspaceId && selectedMachineId(state) === batch.machineId; + } } function isUnavailableFileError(error: unknown): boolean { @@ -71,6 +246,14 @@ function isUnavailableFileError(error: unknown): boolean { return message.includes("Path does not exist") || message.includes("ENOENT") || message.includes("no such file or directory"); } +function isWorkspaceUploadCancelled(error: unknown): boolean { + return error instanceof WorkspaceUploadCancelledError; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + function omitKey(record: Record, keyToOmit: string): Record { return Object.fromEntries(Object.entries(record).filter(([key]) => key !== keyToOmit)); } diff --git a/src/client/src/plugins/core/panels.ts b/src/client/src/plugins/core/panels.ts index 0f0d2a3..4c2dca6 100644 --- a/src/client/src/plugins/core/panels.ts +++ b/src/client/src/plugins/core/panels.ts @@ -1,8 +1,7 @@ import { html, type TemplateResult } from "lit"; -import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse } from "../../api"; -import { workspaceImagePreviewUrl } from "../../api/urls"; -import { MAX_IMAGE_PREVIEW_BYTES, MAX_IMAGE_PREVIEW_LABEL } from "../../../../shared/workspaceFiles"; +import type { GitDiffResponse, GitStatusResponse } from "../../api"; import { renderBuiltinTabIcon } from "../../components/tabIcons"; +import "../../components/WorkspaceFilesPanel"; import type { WorkspacePanelContribution, WorkspacePanelContext } from "../types"; export function createCoreWorkspacePanels(): WorkspacePanelContribution[] { @@ -34,69 +33,7 @@ export function createCoreWorkspacePanels(): WorkspacePanelContribution[] { } function renderFiles(context: WorkspacePanelContext): TemplateResult { - return html` -
- Files - ${context.fileTreeStale ? html`stale` : null} - -
-
-
- ${context.fileTree.length === 0 ? html`

No files loaded.

` : context.fileTree.map((entry) => renderTreeEntry(context, entry, 0))} -
-
- ${renderFileViewer(context)} -
-
- `; -} - -function renderTreeEntry(context: WorkspacePanelContext, entry: FileTreeEntry, depth: number): TemplateResult { - const children = context.expandedDirs[entry.path]; - const hasChildren = children !== undefined; - const selected = entry.type !== "directory" && context.selectedFilePath === entry.path; - return html` - - ${hasChildren ? children.map((child) => renderTreeEntry(context, child, depth + 1)) : null} - `; -} - -function selectTreeEntry(context: WorkspacePanelContext, entry: FileTreeEntry): void { - if (entry.type === "directory") context.onExpandDir(entry.path); - else context.onSelectFile(entry.path); -} - -function renderFileViewer(context: WorkspacePanelContext): TemplateResult { - const file = context.selectedFileContent; - if (context.selectedFilePath === undefined || context.selectedFilePath === "") return html`

Select a file.

`; - if (file === undefined) return html`

Loading ${context.selectedFilePath}…

`; - if (file.mediaType === "image") return renderImageViewer(context, file); - if (file.binary) return html`

Binary file: ${file.path} · ${formatFileSize(file.size)}

`; - loadCodeViewer(); - return html` -
${file.path}${file.language ?? "text"}${file.truncated ? " · truncated" : ""}
- - `; -} - -function renderImageViewer(context: WorkspacePanelContext, file: FileContentResponse): TemplateResult { - const metadata = `${file.mimeType ?? "image"} · ${formatFileSize(file.size)}`; - if (file.size > MAX_IMAGE_PREVIEW_BYTES) { - return html` -
${file.path}${metadata}
-

Image too large to preview: ${formatFileSize(file.size)} · limit ${MAX_IMAGE_PREVIEW_LABEL}

- `; - } - const src = workspaceImagePreviewUrl(context.workspace.projectId, context.workspace.id, file.path, { modifiedAt: file.modifiedAt, machineId: context.machine.id }); - return html` -
${file.path}${metadata}
-
- ${file.path} -
- `; + return html``; } function renderTerminal(context: WorkspacePanelContext): TemplateResult { @@ -155,10 +92,6 @@ function renderDiffSection(diff: GitDiffResponse): TemplateResult { `; } -function loadCodeViewer(): void { - void import("../../components/CodeViewer"); -} - function loadUnifiedDiffViewer(): void { void import("../../components/UnifiedDiffViewer"); } @@ -178,17 +111,3 @@ function stateLabel(index: string, workingTree: string): string { const label = workingTree !== "unmodified" ? workingTree : index; return label.slice(0, 1).toUpperCase(); } - -function formatFileSize(size: number): string { - if (!Number.isFinite(size) || size < 0) return "0 B"; - if (size < 1024) return `${String(size)} B`; - const kib = size / 1024; - if (kib < 1024) return `${formatScaledFileSize(kib)} KB`; - const mib = kib / 1024; - if (mib < 1024) return `${formatScaledFileSize(mib)} MB`; - return `${formatScaledFileSize(mib / 1024)} GB`; -} - -function formatScaledFileSize(value: number): string { - return value >= 10 ? String(Math.round(value)) : value.toFixed(1); -} diff --git a/src/client/src/plugins/registry.test.ts b/src/client/src/plugins/registry.test.ts index 5356926..a5b535f 100644 --- a/src/client/src/plugins/registry.test.ts +++ b/src/client/src/plugins/registry.test.ts @@ -615,9 +615,13 @@ function createWorkspacePanelContext(machineId: string, prompt: WorkspacePanelCo activeTerminalCount: 0, selectedTerminalId: undefined, terminalAutoStart: false, + workspaceUploadDefaultFolder: ".pi-web/uploads", onRefreshFiles: vi.fn(), onExpandDir: vi.fn(), onSelectFile: vi.fn(), + onStartWorkspaceUpload: vi.fn(), + onCancelWorkspaceUpload: vi.fn(), + onClearWorkspaceUpload: vi.fn(), onRefreshGit: vi.fn(), onSelectDiff: vi.fn(), onSelectTerminal: vi.fn(), diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index 083db9a..13915a4 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -159,9 +159,13 @@ export interface WorkspacePanelContext extends WorkspaceContext { activeTerminalCount: number; selectedTerminalId: string | undefined; terminalAutoStart: boolean; + workspaceUploadDefaultFolder: string; onRefreshFiles: () => void; onExpandDir: (path: string) => void; onSelectFile: (path: string) => void; + onStartWorkspaceUpload: (files: readonly File[], options: { destinationFolder: string; createDirs?: boolean; overwrite?: boolean; selectUploadedFile?: boolean }) => { batchId: string; done: Promise } | undefined; + onCancelWorkspaceUpload: (batchId: string) => void; + onClearWorkspaceUpload: (batchId: string) => void; onRefreshGit: () => void; onSelectDiff: (path: string) => void; onSelectTerminal: (terminalId: string | undefined, options?: { replace?: boolean | undefined }) => void; diff --git a/src/client/src/workspaceUploadState.ts b/src/client/src/workspaceUploadState.ts new file mode 100644 index 0000000..7a837fa --- /dev/null +++ b/src/client/src/workspaceUploadState.ts @@ -0,0 +1,179 @@ +import type { WriteWorkspaceFileResponse } from "../../shared/apiTypes"; +import { workspaceUploadPath, type WorkspaceUploadBatchProgress } from "./api/workspaceUploads"; + +export type WorkspaceUploadFileStatus = "pending" | "uploading" | "completed" | "error" | "cancelled"; +export type WorkspaceUploadBatchStatus = "uploading" | "completed" | "error" | "cancelled"; + +export interface WorkspaceUploadFileState { + index: number; + name: string; + path: string; + size: number; + loaded: number; + total: number; + percent: number; + lengthComputable: boolean; + status: WorkspaceUploadFileStatus; + error?: string; + response?: WriteWorkspaceFileResponse; +} + +export interface WorkspaceUploadBatchState { + id: string; + projectId: string; + workspaceId: string; + machineId: string; + destinationFolder: string; + overwrite: boolean; + createDirs: boolean; + files: WorkspaceUploadFileState[]; + currentFileIndex: number; + loaded: number; + total: number; + percent: number; + status: WorkspaceUploadBatchStatus; + startedAt: string; + completedAt?: string; + error?: string; +} + +export interface WorkspaceUploadFileLike { + name: string; + size: number; +} + +export interface CreateWorkspaceUploadBatchStateInput { + id: string; + projectId: string; + workspaceId: string; + machineId: string; + destinationFolder: string; + overwrite: boolean; + createDirs: boolean; + files: readonly WorkspaceUploadFileLike[]; + startedAt: string; +} + +export function createWorkspaceUploadBatchState(input: CreateWorkspaceUploadBatchStateInput): WorkspaceUploadBatchState { + const files = input.files.map((file, index): WorkspaceUploadFileState => { + const total = file.size; + return { + index, + name: file.name, + path: workspaceUploadPath(input.destinationFolder, file.name), + size: file.size, + loaded: 0, + total, + percent: percentFor(0, total), + lengthComputable: true, + status: index === 0 ? "uploading" : "pending", + }; + }); + const total = files.reduce((sum, file) => sum + file.total, 0); + return { + id: input.id, + projectId: input.projectId, + workspaceId: input.workspaceId, + machineId: input.machineId, + destinationFolder: input.destinationFolder, + overwrite: input.overwrite, + createDirs: input.createDirs, + files, + currentFileIndex: files.length === 0 ? -1 : 0, + loaded: 0, + total, + percent: percentFor(0, total), + status: "uploading", + startedAt: input.startedAt, + }; +} + +export function updateWorkspaceUploadBatchProgress(batch: WorkspaceUploadBatchState, progress: WorkspaceUploadBatchProgress): WorkspaceUploadBatchState { + const progressByIndex = new Map(progress.files.map((file) => [file.index, file])); + const files = batch.files.map((file): WorkspaceUploadFileState => { + const progressFile = progressByIndex.get(file.index); + if (progressFile === undefined) return file; + const next: WorkspaceUploadFileState = { + ...file, + path: progressFile.path, + loaded: progressFile.loaded, + total: progressFile.total, + percent: progressFile.percent, + lengthComputable: progressFile.lengthComputable, + status: progressFile.error !== undefined ? "error" : progressFile.done ? "completed" : progress.currentFileIndex === file.index ? "uploading" : file.status, + }; + if (progressFile.error === undefined) delete next.error; + else next.error = progressFile.error; + return next; + }); + return { + ...batch, + files, + currentFileIndex: progress.currentFileIndex, + loaded: progress.loaded, + total: progress.total, + percent: progress.percent, + }; +} + +export function completeWorkspaceUploadBatch(batch: WorkspaceUploadBatchState, responses: readonly WriteWorkspaceFileResponse[], completedAt: string): WorkspaceUploadBatchState { + const files = batch.files.map((file, index): WorkspaceUploadFileState => { + const response = responses[index]; + return { + ...file, + ...(response === undefined ? {} : { path: response.path, response }), + loaded: file.total, + percent: 1, + lengthComputable: true, + status: "completed", + }; + }); + const progress = terminalBatchProgress(files); + return { + ...batch, + files, + currentFileIndex: files.length === 0 ? -1 : files.length - 1, + ...progress, + status: "completed", + completedAt, + }; +} + +export function failWorkspaceUploadBatch(batch: WorkspaceUploadBatchState, error: string, completedAt: string): WorkspaceUploadBatchState { + const files = batch.files.map((file): WorkspaceUploadFileState => { + if (file.status === "completed" || file.status === "error") return file; + if (file.status === "uploading" || file.index === batch.currentFileIndex) return { ...file, status: "error", error }; + return { ...file, status: "cancelled", error: "Not uploaded because an earlier file failed." }; + }); + return { + ...batch, + files, + ...terminalBatchProgress(files), + status: "error", + error, + completedAt, + }; +} + +export function cancelWorkspaceUploadBatch(batch: WorkspaceUploadBatchState, completedAt: string): WorkspaceUploadBatchState { + const error = "Upload cancelled"; + const files = batch.files.map((file): WorkspaceUploadFileState => file.status === "completed" || file.status === "error" ? file : { ...file, status: "cancelled", error }); + return { + ...batch, + files, + ...terminalBatchProgress(files), + status: "cancelled", + error, + completedAt, + }; +} + +function terminalBatchProgress(files: readonly WorkspaceUploadFileState[]): Pick { + const total = files.reduce((sum, file) => sum + file.total, 0); + return { loaded: total, total, percent: files.length === 0 ? 0 : 1 }; +} + +function percentFor(loaded: number, total: number): number { + if (total <= 0) return loaded <= 0 ? 0 : 1; + return Math.max(0, Math.min(1, loaded / total)); +} diff --git a/src/config.test.ts b/src/config.test.ts index 88abfc3..c23c0c2 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { DEFAULT_MAX_UPLOAD_BYTES, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; +import { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_UPLOADS_FOLDER, effectivePiWebConfig, loadPiWebConfig, maxUploadBytes, savePiWebConfig, spawnSessionsEnabled, subsessionsEnabled } from "./config.js"; let tempDir: string; let configPath: string; @@ -18,18 +18,18 @@ afterEach(async () => { describe("PI WEB config persistence", () => { it("writes and reads the configured PI WEB config path", () => { - const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } }, testOptions()); + const saved = savePiWebConfig({ host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual\\incoming" } }, testOptions()); - expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } } }); + expect(saved).toEqual({ path: configPath, exists: true, config: { host: "0.0.0.0", port: 9000, allowedHosts: ["example.local"], shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { "workspace-tasks": { enabled: false, settings: { configPath: ".pi-web/tasks.json" } } }, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual/incoming" } } }); expect(loadPiWebConfig(testOptions())).toEqual(saved); }); it("preserves unrelated config keys while replacing managed keys", async () => { - await writeFile(configPath, `${JSON.stringify({ host: "old", port: 8504, allowedHosts: true, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, future: { enabled: true } }, null, 2)}\n`, "utf8"); + await writeFile(configPath, `${JSON.stringify({ host: "old", port: 8504, allowedHosts: true, plugins: { info: { enabled: false } }, pathAccess: { allowedPaths: ["/old"] }, uploads: { defaultFolder: "old" }, future: { enabled: true } }, null, 2)}\n`, "utf8"); - savePiWebConfig({ port: 9000, allowedHosts: [], pathAccess: { allowedPaths: ["/new"] } }, testOptions()); + savePiWebConfig({ port: 9000, allowedHosts: [], pathAccess: { allowedPaths: ["/new"] }, uploads: { defaultFolder: "new" } }, testOptions()); - expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ future: { enabled: true }, port: 9000, allowedHosts: [], pathAccess: { allowedPaths: ["/new"] } }); + expect(JSON.parse(await readFile(configPath, "utf8"))).toEqual({ future: { enabled: true }, port: 9000, allowedHosts: [], pathAccess: { allowedPaths: ["/new"] }, uploads: { defaultFolder: "new" } }); }); it("rejects invalid plugin config", async () => { @@ -48,6 +48,16 @@ describe("PI WEB config persistence", () => { savePiWebConfig({ maxUploadBytes: 1234 }, testOptions()); expect(loadPiWebConfig(testOptions()).config.maxUploadBytes).toBe(1234); }); + + it("exposes the default upload folder in the effective config", () => { + expect(effectivePiWebConfig(testOptions()).config.uploads).toEqual({ defaultFolder: DEFAULT_UPLOADS_FOLDER }); + }); + + it("rejects upload defaults that are not workspace-relative", async () => { + await writeFile(configPath, `${JSON.stringify({ uploads: { defaultFolder: "../outside" } }, null, 2)}\n`, "utf8"); + + expect(() => loadPiWebConfig(testOptions())).toThrow("PI WEB config uploads.defaultFolder must not contain path traversal"); + }); }); describe("maxUploadBytes", () => { diff --git a/src/config.ts b/src/config.ts index 02555ff..b770fb1 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, isAbsolute, join, resolve } from "node:path"; import type { PiWebConfigValues } from "./shared/apiTypes.js"; import { isPiWebPluginId, piWebPluginIdPattern } from "./shared/pluginIds.js"; @@ -33,6 +33,12 @@ export function defaultPiWebDataDir(): string { */ export const DEFAULT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024; +export const DEFAULT_UPLOADS_FOLDER = ".pi-web/uploads"; + +export function effectiveUploadsConfig(config: Pick = {}): NonNullable { + return { defaultFolder: config.uploads?.defaultFolder ?? DEFAULT_UPLOADS_FOLDER }; +} + export function maxUploadBytes(env: NodeJS.ProcessEnv = process.env, config: PiWebConfig = {}): number { const fromEnv = env["PI_WEB_MAX_UPLOAD_BYTES"]; if (fromEnv !== undefined && fromEnv !== "") { @@ -82,6 +88,7 @@ export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConf ...(port !== undefined && port !== "" ? { port: parsePort(port, "PI_WEB_PORT") } : {}), ...(allowedHosts !== undefined && allowedHosts !== "" ? { allowedHosts: parseAllowedHostsEnv(allowedHosts) } : {}), ...(maxUpload !== undefined && maxUpload !== "" ? { maxUploadBytes: parseMaxUploadBytes(maxUpload, "PI_WEB_MAX_UPLOAD_BYTES") } : {}), + uploads: effectiveUploadsConfig(loaded.config), // Always resolved (on by default) so the effective config is the single // source of truth for the runtime state and the settings UI toggle. spawnSessions: spawnSessionsEnabled(env, loaded.config), @@ -102,6 +109,7 @@ export function savePiWebConfig(config: PiWebConfig, options: LoadOptions = {}): delete existing["shortcuts"]; delete existing["plugins"]; delete existing["pathAccess"]; + delete existing["uploads"]; delete existing["maxUploadBytes"]; delete existing["spawnSessions"]; delete existing["subsessions"]; @@ -126,6 +134,7 @@ function piWebConfigRecord(config: PiWebConfig): Record { ...(config.shortcuts !== undefined ? { shortcuts: config.shortcuts } : {}), ...(config.plugins !== undefined ? { plugins: config.plugins } : {}), ...(config.pathAccess !== undefined ? { pathAccess: config.pathAccess } : {}), + ...(config.uploads !== undefined ? { uploads: config.uploads } : {}), ...(config.maxUploadBytes !== undefined ? { maxUploadBytes: config.maxUploadBytes } : {}), ...(config.spawnSessions !== undefined ? { spawnSessions: config.spawnSessions } : {}), ...(config.subsessions !== undefined ? { subsessions: config.subsessions } : {}), @@ -140,6 +149,7 @@ function parsePiWebConfig(value: Record, path: string): PiWebCo ...(value["shortcuts"] !== undefined ? { shortcuts: parseShortcuts(value["shortcuts"], path) } : {}), ...(value["plugins"] !== undefined ? { plugins: parsePlugins(value["plugins"], path) } : {}), ...(value["pathAccess"] !== undefined ? { pathAccess: parsePathAccessConfig(value["pathAccess"], path) } : {}), + ...(value["uploads"] !== undefined ? { uploads: parseUploadsConfig(value["uploads"], path) } : {}), ...(value["maxUploadBytes"] !== undefined ? { maxUploadBytes: parseMaxUploadBytes(value["maxUploadBytes"], "maxUploadBytes", path) } : {}), ...(value["spawnSessions"] !== undefined ? { spawnSessions: parseSpawnSessions(value["spawnSessions"], path) } : {}), ...(value["subsessions"] !== undefined ? { subsessions: parseSubsessions(value["subsessions"], path) } : {}), @@ -225,6 +235,28 @@ function parseAllowedPaths(value: unknown, path: string): string[] { return value; } +export function parseUploadsConfig(value: unknown, path: string): NonNullable { + if (!isRecord(value)) throw new Error(`PI WEB config uploads must be an object: ${path}`); + const defaultFolder = value["defaultFolder"]; + return { + ...(defaultFolder !== undefined ? { defaultFolder: parseWorkspaceRelativeFolder(defaultFolder, "uploads.defaultFolder", path) } : {}), + }; +} + +function parseWorkspaceRelativeFolder(value: unknown, key: string, path: string): string { + if (typeof value !== "string" || value.trim() === "") throw new Error(`PI WEB config ${key} must be a non-empty workspace-relative path: ${path}`); + if (isAbsoluteLike(value)) throw new Error(`PI WEB config ${key} must be workspace-relative: ${path}`); + const parts = value.split(/[\\/]+/).filter((part) => part !== "" && part !== "."); + if (parts.length === 0) throw new Error(`PI WEB config ${key} must be a non-empty workspace-relative path: ${path}`); + if (parts.some((part) => part === "..")) throw new Error(`PI WEB config ${key} must not contain path traversal: ${path}`); + return parts.join("/"); +} + +function isAbsoluteLike(value: string): boolean { + const withForwardSlashes = value.replace(/\\/g, "/"); + return isAbsolute(value) || withForwardSlashes.startsWith("/") || /^[A-Za-z]:\//.test(withForwardSlashes); +} + function parseShortcuts(value: unknown, path: string): Record { if (!isRecord(value)) throw new Error(`PI WEB config shortcuts must be an object: ${path}`); return Object.fromEntries(Object.entries(value).map(([actionId, shortcut]) => { diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 7241884..955c5f5 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -155,6 +155,33 @@ describe("buildApp", () => { expect(request).toHaveBeenCalledWith("GET", "/api/projects?active=true", undefined); }); + it("proxies remote workspace effective upload config through the existing federated workspace route", async () => { + const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const remoteWorkspaces = [{ + id: "w1", + projectId: "p1", + path: "/repo", + label: "main", + isMain: true, + isGitRepo: false, + isGitWorktree: false, + effectiveConfig: { uploads: { defaultFolder: "remote-project-uploads" } }, + }]; + const request = vi.fn(() => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: Readable.from([JSON.stringify(remoteWorkspaces)]), + })); + remoteClient = fakeRemoteClient({ request }); + + const response = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/projects/p1/workspaces` }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual(remoteWorkspaces); + expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces", undefined); + }); + it("preserves remote file preview security headers while proxying safe response metadata", async () => { const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); const remote = addResponse.json<{ id: string }>(); @@ -181,6 +208,29 @@ describe("buildApp", () => { expect(request).toHaveBeenCalledWith("GET", "/api/projects/p1/workspaces/w1/file/preview?path=diagram.svg", undefined); }); + it("proxies remote workspace file writes as raw request bodies", async () => { + const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const payload = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + const request = vi.fn(() => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: Readable.from([JSON.stringify({ path: "image.png", size: payload.length, modifiedAt: "now", created: true })]), + })); + remoteClient = fakeRemoteClient({ request }); + + const response = await app.inject({ + method: "PUT", + url: `/api/machines/${remote.id}/projects/p1/workspaces/w1/file?path=${encodeURIComponent("image.png")}`, + payload, + headers: { "content-type": "application/octet-stream" }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ path: "image.png", size: payload.length, modifiedAt: "now", created: true }); + expect(request).toHaveBeenCalledWith("PUT", "/api/projects/p1/workspaces/w1/file?path=image.png", payload, { contentType: "application/octet-stream" }); + }); + it("proxies remote terminal command-run and continue routes", async () => { const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); const remote = addResponse.json<{ id: string }>(); @@ -465,6 +515,47 @@ describe("buildApp", () => { ]); }); + it("exposes the default upload config on workspace responses", async () => { + const addResponse = await app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "Upload Defaults", path: projectDir, create: true }, + }); + const project = addResponse.json(); + + const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); + + expect(workspacesResponse.statusCode).toBe(200); + expect(workspacesResponse.json()).toEqual([ + expect.objectContaining({ + projectId: project.id, + effectiveConfig: { uploads: { defaultFolder: ".pi-web/uploads" } }, + }), + ]); + }); + + it("lets project-local upload config override global upload config on workspace responses", async () => { + piWebConfig = { uploads: { defaultFolder: "global-uploads" } }; + const addResponse = await app.inject({ + method: "POST", + url: "/api/projects", + payload: { name: "Project Upload Defaults", path: projectDir, create: true }, + }); + const project = addResponse.json(); + await mkdir(join(projectDir, ".pi-web"), { recursive: true }); + await writeFile(join(projectDir, ".pi-web", "config.json"), `${JSON.stringify({ version: 1, uploads: { defaultFolder: "project-uploads" } }, null, 2)}\n`); + + const workspacesResponse = await app.inject({ method: "GET", url: `/api/projects/${project.id}/workspaces` }); + + expect(workspacesResponse.statusCode).toBe(200); + expect(workspacesResponse.json()).toEqual([ + expect.objectContaining({ + projectId: project.id, + effectiveConfig: { uploads: { defaultFolder: "project-uploads" } }, + }), + ]); + }); + it("serves supported workspace images as previews", async () => { const addResponse = await app.inject({ method: "POST", diff --git a/src/server/app.ts b/src/server/app.ts index cff2a3c..c0bcf5d 100644 --- a/src/server/app.ts +++ b/src/server/app.ts @@ -9,6 +9,7 @@ import { ProjectService } from "./projects/projectService.js"; import { WorkspaceService } from "./workspaces/workspaceService.js"; import { isAbsoluteishFileSuggestionQuery, listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js"; import { pathAccessForCwd } from "./workspaces/effectivePathAccess.js"; +import { loadEffectiveProjectUploadsConfig } from "./workspaces/projectPiWebConfig.js"; import { normalizeRequestCwd } from "./workingDirectory.js"; import { listDirectorySuggestions } from "./projects/directorySuggestions.js"; import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js"; @@ -25,6 +26,7 @@ import { MachineService } from "./machines/machineService.js"; import { registerMachineRoutes } from "./machines/machineRoutes.js"; import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js"; import { proxyMachinePluginAsset, registerMachinePluginProxyRoutes } from "./machines/machinePluginProxyRoutes.js"; +import type { Project, Workspace } from "./types.js"; export interface AppDependencies { projects?: ProjectService; @@ -39,7 +41,11 @@ export interface AppDependencies { bodyLimit?: number; } -function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string): void { +interface LocalProjectRouteOptions { + config?: Pick; +} + +function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectService, workspaces: WorkspaceService, prefix: string, options: LocalProjectRouteOptions = {}): void { app.get(`${prefix}/projects`, async () => projects.list()); app.post<{ Body: { name?: string; path: string; create?: boolean } }>(`${prefix}/projects`, async (request, reply) => { @@ -70,13 +76,26 @@ function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectServi app.get<{ Params: { projectId: string } }>(`${prefix}/projects/:projectId/workspaces`, async (request, reply) => { try { const project = await projects.requireProject(request.params.projectId); - return await workspaces.list(project); + return await listWorkspacesWithEffectiveConfig(project, workspaces, options.config); } catch (error) { return reply.code(404).send({ error: error instanceof Error ? error.message : String(error) }); } }); } +async function listWorkspacesWithEffectiveConfig(project: Project, workspaces: WorkspaceService, config?: Pick): Promise { + const [workspaceList, effectiveConfig] = await Promise.all([ + workspaces.list(project), + workspaceEffectiveConfig(project.path, config), + ]); + return workspaceList.map((workspace) => ({ ...workspace, effectiveConfig })); +} + +async function workspaceEffectiveConfig(projectPath: string, config?: Pick): Promise> { + const globalConfig = config === undefined ? {} : (await config.read()).effectiveConfig; + return { uploads: await loadEffectiveProjectUploadsConfig(projectPath, globalConfig) }; +} + interface LocalFileSuggestionRouteOptions { config?: Pick; } @@ -131,8 +150,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise { const response = await app.inject({ method: "PUT", url: "/api/config", - payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 } }, + payload: { config: { host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads\\manual" }, maxUploadBytes: 1234 } }, }); expect(response.statusCode).toBe(200); - expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, maxUploadBytes: 1234 }); + expect(savedConfig).toEqual({ host: "0.0.0.0", port: 9000, allowedHosts: true, spawnSessions: true, subsessions: true, shortcuts: { "core:view.chat": "mod+1", "core:session.stop": null }, plugins: { info: { enabled: false, settings: { note: "hidden" } } }, pathAccess: { allowedPaths: ["/tmp"] }, uploads: { defaultFolder: "uploads/manual" }, maxUploadBytes: 1234 }); expect(response.json().config).toEqual(savedConfig); }); @@ -80,6 +80,18 @@ describe("config routes", () => { expect(response.json()).toHaveProperty("error"); expect(service.write).not.toHaveBeenCalled(); }); + + it("rejects invalid upload defaults before writing", async () => { + const response = await app.inject({ + method: "PUT", + url: "/api/config", + payload: { config: { uploads: { defaultFolder: "/tmp" } } }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toHaveProperty("error"); + expect(service.write).not.toHaveBeenCalled(); + }); }); function responseFor(config: PiWebConfigValues, exists: boolean): PiWebConfigResponse { diff --git a/src/server/configRoutes.ts b/src/server/configRoutes.ts index eec326e..1585efa 100644 --- a/src/server/configRoutes.ts +++ b/src/server/configRoutes.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from "fastify"; -import { effectivePiWebConfig, loadPiWebConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js"; +import { effectivePiWebConfig, loadPiWebConfig, parseUploadsConfig, savePiWebConfig, type LoadOptions, type PiWebConfig } from "../config.js"; import type { PiWebConfigEnvOverrides, PiWebConfigResponse, PiWebConfigValues } from "../shared/apiTypes.js"; import { isPiWebPluginId } from "../shared/pluginIds.js"; @@ -59,6 +59,7 @@ function parseConfigRequest(value: unknown): PiWebConfig { const shortcuts = value["shortcuts"]; const plugins = value["plugins"]; const pathAccess = value["pathAccess"]; + const uploads = value["uploads"]; const maxUploadBytes = value["maxUploadBytes"]; const spawnSessions = value["spawnSessions"]; const subsessions = value["subsessions"]; @@ -74,6 +75,7 @@ function parseConfigRequest(value: unknown): PiWebConfig { if (shortcuts !== undefined) config.shortcuts = parseShortcutsRequest(shortcuts); if (plugins !== undefined) config.plugins = parsePluginsRequest(plugins); if (pathAccess !== undefined) config.pathAccess = parsePathAccessRequest(pathAccess); + if (uploads !== undefined) config.uploads = parseUploadsConfig(uploads, "request"); if (maxUploadBytes !== undefined) config.maxUploadBytes = parseMaxUploadBytesRequest(maxUploadBytes); if (spawnSessions !== undefined) { if (typeof spawnSessions !== "boolean") throw new Error("PI WEB config spawnSessions must be a boolean"); diff --git a/src/server/machines/machineClient.test.ts b/src/server/machines/machineClient.test.ts new file mode 100644 index 0000000..ed15e92 --- /dev/null +++ b/src/server/machines/machineClient.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; +import { RemoteMachineClient } from "./machineClient.js"; + +describe("RemoteMachineClient", () => { + it("forwards raw binary request bodies with the provided content type", async () => { + const fetchImpl = vi.fn(() => Promise.resolve(new Response("ok", { status: 200 }))); + const client = new RemoteMachineClient({ baseUrl: "https://remote.example.test/" }, fetchImpl); + const payload = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + + await client.request("PUT", "/api/projects/p1/workspaces/w1/file?path=image.png", payload, { contentType: "image/png" }); + + const { input, init } = onlyFetchCall(fetchImpl); + expect(fetchInputUrl(input)).toBe("https://remote.example.test/api/projects/p1/workspaces/w1/file?path=image.png"); + expect(init.method).toBe("PUT"); + expect(new Headers(init.headers).get("content-type")).toBe("image/png"); + if (!(init.body instanceof ArrayBuffer)) throw new Error("Expected binary request body"); + expect(Array.from(new Uint8Array(init.body))).toEqual([0x89, 0x50, 0x4e, 0x47]); + }); + + it("serializes structured request bodies as JSON by default", async () => { + const fetchImpl = vi.fn(() => Promise.resolve(new Response("ok", { status: 200 }))); + const client = new RemoteMachineClient({ baseUrl: "https://remote.example.test/base/", token: "secret" }, fetchImpl); + + await client.request("POST", "/api/sessions", { cwd: "/repo" }); + + const { input, init } = onlyFetchCall(fetchImpl); + expect(fetchInputUrl(input)).toBe("https://remote.example.test/base/api/sessions"); + expect(new Headers(init.headers).get("authorization")).toBe("Bearer secret"); + expect(new Headers(init.headers).get("content-type")).toBe("application/json"); + expect(init.body).toBe(JSON.stringify({ cwd: "/repo" })); + }); +}); + +function fetchInputUrl(input: RequestInfo | URL): string { + if (typeof input === "string") return input; + if (input instanceof URL) return input.href; + return input.url; +} + +function onlyFetchCall(fetchImpl: ReturnType>): { input: RequestInfo | URL; init: RequestInit } { + expect(fetchImpl).toHaveBeenCalledTimes(1); + const call = fetchImpl.mock.calls[0]; + if (call === undefined) throw new Error("Expected fetch call"); + const [input, init] = call; + if (init === undefined) throw new Error("Expected fetch init"); + return { input, init }; +} diff --git a/src/server/machines/machineClient.ts b/src/server/machines/machineClient.ts index d1658ff..57b2b96 100644 --- a/src/server/machines/machineClient.ts +++ b/src/server/machines/machineClient.ts @@ -16,6 +16,7 @@ export interface MachineJsonResponse { export interface MachineRequestOptions { timeoutMs?: number; + contentType?: string; } export interface MachineClient { @@ -82,13 +83,14 @@ export class RemoteMachineClient implements MachineClient { const controller = new AbortController(); const timeout = setTimeout(() => { controller.abort(); }, options.timeoutMs ?? DEFAULT_REMOTE_REQUEST_TIMEOUT_MS); try { + const requestBody = serializeRequestBody(method, body); const init: RequestInit = { method, - headers: this.requestHeaders(body), + headers: this.requestHeaders(body, options), signal: controller.signal, redirect: "manual", }; - if (body !== undefined && method !== "GET" && method !== "HEAD") init.body = JSON.stringify(body); + if (requestBody !== undefined) init.body = requestBody; return await this.fetchImpl(this.remoteUrl(path), init); } catch (error) { if (isAbortError(error)) throw new RemoteMachineRequestError("Remote machine request timed out", 504); @@ -98,11 +100,11 @@ export class RemoteMachineClient implements MachineClient { } } - private requestHeaders(body: unknown): HeadersInit { + private requestHeaders(body: unknown, options: MachineRequestOptions): HeadersInit { return { ...this.remoteHeaders(), accept: "*/*", - ...(body === undefined ? {} : { "content-type": "application/json" }), + ...(body === undefined ? {} : { "content-type": options.contentType ?? defaultContentTypeForBody(body) }), }; } @@ -147,6 +149,34 @@ function headersToRecord(headers: Headers): Record { return Object.fromEntries(headers.entries()); } +function serializeRequestBody(method: string, body: unknown): NonNullable | undefined { + if (body === undefined || method === "GET" || method === "HEAD") return undefined; + if (isRawRequestBody(body)) return body; + if (ArrayBuffer.isView(body)) return copyArrayBufferView(body); + const serialized: string = JSON.stringify(body); + return serialized; +} + +function defaultContentTypeForBody(body: unknown): string { + return isRawRequestBody(body) || ArrayBuffer.isView(body) ? "application/octet-stream" : "application/json"; +} + +function isRawRequestBody(body: unknown): body is NonNullable { + return typeof body === "string" + || body instanceof URLSearchParams + || body instanceof Blob + || body instanceof FormData + || body instanceof ReadableStream + || body instanceof ArrayBuffer; +} + +function copyArrayBufferView(view: ArrayBufferView): ArrayBuffer { + const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} + function readableFromWebResponseBody(body: Response["body"]): NodeJS.ReadableStream { if (body === null) throw new Error("Response body is not readable"); // eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- Node fetch returns a web stream that is runtime-compatible with Readable.fromWeb, but DOM and node:stream/web types are not structurally identical in this TS config. diff --git a/src/server/machines/machineProxyRoutes.ts b/src/server/machines/machineProxyRoutes.ts index bdd710f..14a99d5 100644 --- a/src/server/machines/machineProxyRoutes.ts +++ b/src/server/machines/machineProxyRoutes.ts @@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyReply } from "fastify"; import type { WebSocket } from "ws"; import { FEDERATED_HTTP_ROUTES, FEDERATED_WEBSOCKET_ROUTES } from "../../shared/federatedRoutes.js"; import { bridgeSockets } from "../webSocketBridge.js"; -import { RemoteMachineRequestError } from "./machineClient.js"; +import { RemoteMachineRequestError, type MachineRequestOptions } from "./machineClient.js"; import { MachineService } from "./machineService.js"; export const REMOTE_HTTP_ROUTES = FEDERATED_HTTP_ROUTES; @@ -23,7 +23,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new app.route<{ Params: { machineId: string }; Body: unknown }>({ method: spec.method, url: `/api/machines/:machineId${spec.path}`, - handler: (request, reply) => proxyHttpRequest(machines, request.params.machineId, request.method, request.url, request.body, reply), + handler: (request, reply) => proxyHttpRequest(machines, request.params.machineId, request.method, request.url, request.body, request.headers["content-type"], reply), }); } @@ -34,7 +34,7 @@ export function registerMachineProxyRoutes(app: FastifyInstance, machines = new } } -async function proxyHttpRequest(machines: MachineService, machineId: string, method: string, requestUrl: string, body: unknown, reply: FastifyReply): Promise { +async function proxyHttpRequest(machines: MachineService, machineId: string, method: string, requestUrl: string, body: unknown, contentType: string | string[] | undefined, reply: FastifyReply): Promise { if (machineId === "local") { return reply.code(501).send({ error: "Local machine route is not registered for this endpoint" }); } @@ -45,7 +45,10 @@ async function proxyHttpRequest(machines: MachineService, machineId: string, met } try { - const upstream = await client.request(method, remoteApiPath(machineId, requestUrl), body); + const requestOptions = proxyRequestOptions(body, contentType); + const upstream = requestOptions === undefined + ? await client.request(method, remoteApiPath(machineId, requestUrl), body) + : await client.request(method, remoteApiPath(machineId, requestUrl), body, requestOptions); reply.code(upstream.statusCode); applySafeHeaders(reply, upstream.headers); if (upstream.body === undefined) return await reply.send(); @@ -81,6 +84,20 @@ function remoteApiPath(machineId: string, requestUrl: string): string { return `/api${compatPath}`; } +function proxyRequestOptions(body: unknown, contentType: string | string[] | undefined): MachineRequestOptions | undefined { + if (!isRawProxyBody(body)) return undefined; + const value = firstHeaderValue(contentType); + return value === undefined || value === "" ? undefined : { contentType: value }; +} + +function isRawProxyBody(body: unknown): boolean { + return typeof body === "string" || body instanceof ArrayBuffer || ArrayBuffer.isView(body); +} + +function firstHeaderValue(value: string | string[] | undefined): string | undefined { + return Array.isArray(value) ? value[0] : value; +} + function applySafeHeaders(reply: FastifyReply, headers: Record): void { for (const [name, value] of Object.entries(headers)) { if (value === undefined) continue; diff --git a/src/server/workspaces/projectPiWebConfig.test.ts b/src/server/workspaces/projectPiWebConfig.test.ts index c160c6f..51f6aa6 100644 --- a/src/server/workspaces/projectPiWebConfig.test.ts +++ b/src/server/workspaces/projectPiWebConfig.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { loadEffectiveProjectPathAccess, loadProjectPiWebConfig, mergePathAccessConfigs, PROJECT_PI_WEB_CONFIG_PATH } from "./projectPiWebConfig.js"; +import { loadEffectiveProjectPathAccess, loadEffectiveProjectUploadsConfig, loadProjectPiWebConfig, mergePathAccessConfigs, PROJECT_PI_WEB_CONFIG_PATH } from "./projectPiWebConfig.js"; let tempDir: string; let projectPath: string; @@ -26,13 +26,13 @@ describe("project PI WEB config", () => { }); }); - it("loads project-local path access config", async () => { - await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } }); + it("loads project-local path access and upload config", async () => { + await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual\\incoming" } }); await expect(loadProjectPiWebConfig(projectPath)).resolves.toEqual({ path: join(projectPath, PROJECT_PI_WEB_CONFIG_PATH), exists: true, - config: { version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] } }, + config: { version: 1, pathAccess: { allowedPaths: ["/tmp", "~/SDKs"] }, uploads: { defaultFolder: "manual/incoming" } }, }); }); @@ -48,6 +48,12 @@ describe("project PI WEB config", () => { await expect(loadProjectPiWebConfig(projectPath)).rejects.toThrow("PI WEB config pathAccess.allowedPaths must be an array of non-empty strings"); }); + it("reuses PI WEB upload schema validation", async () => { + await writeProjectConfig({ version: 1, uploads: { defaultFolder: "../outside" } }); + + await expect(loadProjectPiWebConfig(projectPath)).rejects.toThrow("PI WEB config uploads.defaultFolder must not contain path traversal"); + }); + it("merges global and project path access in order", async () => { await writeProjectConfig({ version: 1, pathAccess: { allowedPaths: ["/project-sdk", "/shared"] } }); @@ -55,6 +61,14 @@ describe("project PI WEB config", () => { allowedPaths: ["/global-sdk", "/shared", "/project-sdk"], }); }); + + it("lets project upload defaults override global upload defaults", async () => { + await writeProjectConfig({ version: 1, uploads: { defaultFolder: "project-uploads" } }); + + await expect(loadEffectiveProjectUploadsConfig(projectPath, { uploads: { defaultFolder: "global-uploads" } })).resolves.toEqual({ + defaultFolder: "project-uploads", + }); + }); }); describe("mergePathAccessConfigs", () => { diff --git a/src/server/workspaces/projectPiWebConfig.ts b/src/server/workspaces/projectPiWebConfig.ts index 7684d4e..a1213d5 100644 --- a/src/server/workspaces/projectPiWebConfig.ts +++ b/src/server/workspaces/projectPiWebConfig.ts @@ -1,13 +1,14 @@ import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import { parsePathAccessConfig, type PiWebConfig } from "../../config.js"; -import type { PiWebPathAccessConfig } from "../../shared/apiTypes.js"; +import { effectiveUploadsConfig, parsePathAccessConfig, parseUploadsConfig, type PiWebConfig } from "../../config.js"; +import type { PiWebPathAccessConfig, PiWebUploadsConfig } from "../../shared/apiTypes.js"; export const PROJECT_PI_WEB_CONFIG_PATH = ".pi-web/config.json"; export interface ProjectPiWebConfig { version?: 1; pathAccess?: PiWebPathAccessConfig; + uploads?: PiWebUploadsConfig; } export interface LoadedProjectPiWebConfig { @@ -33,6 +34,11 @@ export async function loadEffectiveProjectPathAccess(projectPath: string, global return mergePathAccessConfigs(globalConfig.pathAccess, projectConfig.config.pathAccess); } +export async function loadEffectiveProjectUploadsConfig(projectPath: string, globalConfig: PiWebConfig): Promise { + const projectConfig = await loadProjectPiWebConfig(projectPath); + return effectiveUploadsConfig({ uploads: { ...(globalConfig.uploads ?? {}), ...(projectConfig.config.uploads ?? {}) } }); +} + export function mergePathAccessConfigs(...configs: (PiWebPathAccessConfig | undefined)[]): PiWebPathAccessConfig | undefined { const allowedPaths = dedupe(configs.flatMap((config) => config?.allowedPaths ?? [])); return allowedPaths.length === 0 ? undefined : { allowedPaths }; @@ -43,6 +49,7 @@ function parseProjectPiWebConfig(value: Record, path: string): return { ...(version !== undefined ? { version: parseProjectConfigVersion(version, path) } : {}), ...(value["pathAccess"] !== undefined ? { pathAccess: parsePathAccessConfig(value["pathAccess"], path) } : {}), + ...(value["uploads"] !== undefined ? { uploads: parseUploadsConfig(value["uploads"], path) } : {}), }; } diff --git a/src/shared/apiTypes.ts b/src/shared/apiTypes.ts index 9a5dcb4..6d7847e 100644 --- a/src/shared/apiTypes.ts +++ b/src/shared/apiTypes.ts @@ -56,6 +56,10 @@ export interface PiWebPathAccessConfig { allowedPaths?: string[]; } +export interface PiWebUploadsConfig { + defaultFolder?: string; +} + export interface PiWebConfigValues { host?: string; port?: number; @@ -64,6 +68,8 @@ export interface PiWebConfigValues { plugins?: PiWebPluginConfigMap; /** External filesystem roots PI WEB may expose outside a workspace. */ pathAccess?: PiWebPathAccessConfig; + /** Workspace-relative defaults for manual file uploads. */ + uploads?: PiWebUploadsConfig; /** Maximum accepted HTTP request body size in bytes (uploads/attachments). */ maxUploadBytes?: number; /** When true, LLMs can start new sessions via the spawn_session tool. */ @@ -115,6 +121,10 @@ export interface Project { createdAt: string; } +export interface WorkspaceEffectiveConfig { + uploads?: PiWebUploadsConfig; +} + export interface Workspace { id: string; projectId: string; @@ -124,6 +134,8 @@ export interface Workspace { isMain: boolean; isGitRepo: boolean; isGitWorktree: boolean; + /** Workspace-effective project/global settings needed by workspace UI features. */ + effectiveConfig?: WorkspaceEffectiveConfig; } export interface SessionRef { From cd5843e8e54d74517d525ddc2a338c7941e8ca33 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 25 Jun 2026 15:20:42 +0200 Subject: [PATCH 18/24] chore: update package lock --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index ea3cc33..de4644b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1575,7 +1575,7 @@ "typebox": "1.1.38" }, "bin": { - "pi-ai": "dist/cli.js" + "pi-ai": "./dist/cli.js" }, "engines": { "node": ">=22.19.0" From a4ad23545a7531c19168215082c2d9e6bd4523a6 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 25 Jun 2026 15:39:44 +0200 Subject: [PATCH 19/24] docs: refresh README overview --- README.md | 414 +++++++++++++----------------------------------------- 1 file changed, 100 insertions(+), 314 deletions(-) diff --git a/README.md b/README.md index 2afd031..86c68c6 100644 --- a/README.md +++ b/README.md @@ -1,171 +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. -- Upload files from the Files panel with direct drag/drop, configurable default destinations, and per-file progress. -- 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 @@ -177,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 @@ -230,160 +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. -- `uploads.defaultFolder` — workspace-relative default destination for manual Files-panel uploads. Set it globally or in `/.pi-web/config.json`; the project-local value wins for that project's workspaces. Defaults to `.pi-web/uploads`. -- `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 From 790b36f0eae65f7ebb8daab1676c1adc6f1c35f3 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 25 Jun 2026 15:50:45 +0200 Subject: [PATCH 20/24] docs: improve screenshot gallery --- docs/index.html | 59 +++++++--- docs/site.js | 161 +++++++++++++++++++++++++++ docs/styles.css | 283 ++++++++++++++++++++++++++++++++++++++++++++---- 3 files changed, 469 insertions(+), 34 deletions(-) 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/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; From 32ea809adcae7d0225ccdbecb8eb05abcd285174 Mon Sep 17 00:00:00 2001 From: Andrey Romantsev Date: Thu, 25 Jun 2026 18:11:41 +0200 Subject: [PATCH 21/24] fix: keep Enter as newline in mobile chat composer --- .changeset/mobile-enter-newline.md | 5 +++++ src/client/src/components/PromptEditor.ts | 9 +++++++-- src/client/src/promptEnterBehavior.test.ts | 17 +++++++++++++++++ src/client/src/promptEnterBehavior.ts | 11 +++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 .changeset/mobile-enter-newline.md create mode 100644 src/client/src/promptEnterBehavior.test.ts create mode 100644 src/client/src/promptEnterBehavior.ts diff --git a/.changeset/mobile-enter-newline.md b/.changeset/mobile-enter-newline.md new file mode 100644 index 0000000..fd2296b --- /dev/null +++ b/.changeset/mobile-enter-newline.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Keep Enter/Return in the mobile chat composer for new lines, and send messages there only from the send button. diff --git a/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index a5dc404..944c3a9 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -13,6 +13,7 @@ import { machineSessionKey } from "../machineKeys"; import { detectPromptCompletionTrigger, fileCompletionInsertText, type PromptCompletionTrigger } from "../promptCompletions"; import { clearDraft, loadDraft, saveDraft } from "../promptDraftStorage"; import { loadAttachmentDelivery, saveAttachmentDelivery } from "../attachmentPreferences"; +import { createMobilePromptEnterMedia, shouldSendPromptOnEnter } from "../promptEnterBehavior"; import { promptEditorStyles, type CompletionItem } from "./shared"; import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, renderStopIcon, renderThinkingGauge } from "./promptEditorIcons"; import { thinkingGauge, thinkingLevelLabel } from "../../../shared/thinkingLevels"; @@ -59,6 +60,7 @@ export class PromptEditor extends LitElement { private editor: EditorView | undefined; private readonly editableCompartment = new Compartment(); private readonly readOnlyCompartment = new Compartment(); + private readonly mobilePromptEnterMedia = createMobilePromptEnterMedia(); protected override willUpdate(changed: PropertyValues) { if (!changed.has("sessionId") && !changed.has("machineId")) return; @@ -238,7 +240,7 @@ export class PromptEditor extends LitElement { { key: "ArrowDown", run: () => this.moveCompletion(1) }, { key: "ArrowUp", run: () => this.moveCompletion(-1) }, { key: "Escape", run: () => this.closeCompletions() }, - { key: "Enter", run: () => this.handleEditorEnter() }, + { key: "Enter", run: (view) => this.handleEditorEnter(view) }, { key: "Shift-Enter", run: (view) => insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view) }, { key: "Tab", run: (view) => this.handleEditorTab(view) }, { key: "Shift-Tab", run: (view) => indentWithTab.shift?.(view) ?? false }, @@ -335,12 +337,15 @@ export class PromptEditor extends LitElement { return true; } - private handleEditorEnter(): boolean { + private handleEditorEnter(view: EditorView): boolean { if (this.completions.length) { const completion = this.completions[this.selectedIndex]; if (completion !== undefined) this.pick(completion); return true; } + if (!shouldSendPromptOnEnter(this.mobilePromptEnterMedia)) { + return insertNewlineContinueMarkup(view) || insertNewlineAndIndent(view); + } this.send(this.canSteer || this.isCompacting ? "followUp" : undefined); return true; } diff --git a/src/client/src/promptEnterBehavior.test.ts b/src/client/src/promptEnterBehavior.test.ts new file mode 100644 index 0000000..bb5eabe --- /dev/null +++ b/src/client/src/promptEnterBehavior.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { MOBILE_PROMPT_ENTER_MEDIA_QUERY, shouldSendPromptOnEnter, type PromptEnterMedia } from "./promptEnterBehavior"; + +describe("promptEnterBehavior", () => { + it("uses the expected mobile media query", () => { + expect(MOBILE_PROMPT_ENTER_MEDIA_QUERY).toBe("(pointer: coarse), (max-width: 760px)"); + }); + + it("sends on Enter outside the mobile environment", () => { + expect(shouldSendPromptOnEnter({ matches: false } satisfies PromptEnterMedia)).toBe(true); + expect(shouldSendPromptOnEnter(undefined)).toBe(true); + }); + + it("keeps Enter as a newline in the mobile environment", () => { + expect(shouldSendPromptOnEnter({ matches: true } satisfies PromptEnterMedia)).toBe(false); + }); +}); diff --git a/src/client/src/promptEnterBehavior.ts b/src/client/src/promptEnterBehavior.ts new file mode 100644 index 0000000..faef8ac --- /dev/null +++ b/src/client/src/promptEnterBehavior.ts @@ -0,0 +1,11 @@ +export const MOBILE_PROMPT_ENTER_MEDIA_QUERY = "(pointer: coarse), (max-width: 760px)"; + +export type PromptEnterMedia = Pick; + +export function createMobilePromptEnterMedia(): PromptEnterMedia | undefined { + return typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia(MOBILE_PROMPT_ENTER_MEDIA_QUERY) : undefined; +} + +export function shouldSendPromptOnEnter(media = createMobilePromptEnterMedia()): boolean { + return media?.matches !== true; +} From 7e812aa7f54c45f71f983fbb2a375b15e86b8e93 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Thu, 25 Jun 2026 21:32:07 +0200 Subject: [PATCH 22/24] feat: support general chat file attachments --- .changeset/chat-file-uploads.md | 5 + src/client/src/components/PromptEditor.ts | 91 ++++++++++++------- src/client/src/components/shared.ts | 3 + .../src/promptAttachmentCapture.test.ts | 66 ++++++++++---- src/client/src/promptAttachmentCapture.ts | 73 +++++++++++---- src/server/sessions/attachmentService.test.ts | 75 ++++++++++++++- src/server/sessions/attachmentService.ts | 84 ++++++++++++++--- src/server/sessions/piSessionService.ts | 2 +- src/shared/apiTypes.ts | 24 +++-- src/shared/promptAttachments.test.ts | 25 +++++ src/shared/promptAttachments.ts | 45 +++++++-- 11 files changed, 397 insertions(+), 96 deletions(-) create mode 100644 .changeset/chat-file-uploads.md 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/src/client/src/components/PromptEditor.ts b/src/client/src/components/PromptEditor.ts index a5dc404..6367c42 100644 --- a/src/client/src/components/PromptEditor.ts +++ b/src/client/src/components/PromptEditor.ts @@ -7,7 +7,7 @@ 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"; @@ -18,14 +18,7 @@ import { renderAttachIcon, renderSendIcon, renderQueueIcon, renderSteerIcon, ren 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 { @@ -96,8 +89,8 @@ export class PromptEditor extends LitElement {