From 82ba2e0490b709e65c1c63a86a416b8c940a5d8a Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 5 Jun 2026 10:59:01 +0200 Subject: [PATCH 01/28] fix: validate prompt API payloads Fixes #11 --- .changeset/guard-missing-prompt-text.md | 5 ++ src/server/sessions/piSessionService.test.ts | 14 +++++ src/server/sessions/piSessionService.ts | 25 ++++++-- .../sessions/sessionNameGenerator.test.ts | 4 ++ src/server/sessions/sessionNameGenerator.ts | 4 +- src/server/sessions/sessionRoutes.test.ts | 58 +++++++++++++++++++ src/server/sessions/sessionRoutes.ts | 9 ++- 7 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 .changeset/guard-missing-prompt-text.md create mode 100644 src/server/sessions/sessionRoutes.test.ts diff --git a/.changeset/guard-missing-prompt-text.md b/.changeset/guard-missing-prompt-text.md new file mode 100644 index 0000000..c094f79 --- /dev/null +++ b/.changeset/guard-missing-prompt-text.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Prevent malformed session prompt API calls from crashing the session daemon. diff --git a/src/server/sessions/piSessionService.test.ts b/src/server/sessions/piSessionService.test.ts index eb23fc1..f04535e 100644 --- a/src/server/sessions/piSessionService.test.ts +++ b/src/server/sessions/piSessionService.test.ts @@ -366,6 +366,20 @@ describe("PiSessionService", () => { await service.dispose(); }); + it("rejects malformed prompt text before opening the runtime", async () => { + const fake = fakeRuntime("prompt-session"); + const service = new PiSessionService(new CapturingSessionEventHub(), { + createAgentRuntime: runtimeCreator(fake.runtime), + sessionManager: sessionGateway([sessionRecord("prompt-session")]), + heartbeatIntervalMs: 60_000, + }); + + await expect(service.prompt("prompt-session", undefined)).rejects.toThrow("Prompt text is required"); + + expect(fake.calls.prompt).toEqual([]); + await service.dispose(); + }); + it("includes queued message details in session status", async () => { const fake = fakeRuntime("status-session", { messages: [{ role: "user", content: "hello" }, { role: "assistant", content: "hi" }], diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index cdb29ed..26b6df8 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -41,6 +41,17 @@ interface QueuedPrompt { text: string; } +function requirePromptText(value: unknown): string { + if (typeof value !== "string") throw new Error("Prompt text is required"); + return value; +} + +function parsePromptStreamingBehavior(value: unknown): QueuedPromptKind | undefined { + if (value === undefined) return undefined; + if (value === "steer" || value === "followUp") return value; + throw new Error('Prompt streamingBehavior must be "steer" or "followUp"'); +} + type SessionArchiveRepository = Pick; interface PiSessionListEntry { id: string; @@ -360,22 +371,24 @@ export class PiSessionService { return commands.sort((a, b) => a.name.localeCompare(b.name)); } - async prompt(sessionId: string, text: string, streamingBehavior?: "steer" | "followUp"): Promise { + async prompt(sessionId: string, text: unknown, streamingBehavior?: unknown): Promise { + const promptText = requirePromptText(text); + const requestedBehavior = parsePromptStreamingBehavior(streamingBehavior); await this.assertWritable(sessionId); const session = await this.getOrOpen(sessionId); - this.maybeGenerateSessionName(session, text); + this.maybeGenerateSessionName(session, promptText); const isQueued = session.isStreaming || session.isCompacting; - const behavior = isQueued ? streamingBehavior ?? "followUp" : undefined; - if (isQueued && this.hasQueuedMessageText(session, text)) { + const behavior = isQueued ? requestedBehavior ?? "followUp" : undefined; + if (isQueued && this.hasQueuedMessageText(session, promptText)) { this.publishActivity(session, "duplicate queued message ignored", "active"); this.publishStatus(session); return; } if (session.isCompacting) { - this.enqueuePromptDuringCompaction(session, text, behavior ?? "followUp"); + this.enqueuePromptDuringCompaction(session, promptText, behavior ?? "followUp"); return; } - void this.submitPrompt(session, text, behavior); + void this.submitPrompt(session, promptText, behavior); } private submitPrompt(session: PiAgentSession, text: string, behavior: QueuedPromptKind | undefined): Promise { diff --git a/src/server/sessions/sessionNameGenerator.test.ts b/src/server/sessions/sessionNameGenerator.test.ts index be26d68..5a93d50 100644 --- a/src/server/sessions/sessionNameGenerator.test.ts +++ b/src/server/sessions/sessionNameGenerator.test.ts @@ -15,4 +15,8 @@ describe("sessionNameGenerator", () => { expect(fallbackSessionName('\nDo x\n\n\nCheck the UI now')) .toBe("Check the UI now"); }); + + it("skips fallback names when the first request is missing", () => { + expect(fallbackSessionName(undefined)).toBeUndefined(); + }); }); diff --git a/src/server/sessions/sessionNameGenerator.ts b/src/server/sessions/sessionNameGenerator.ts index 0c5ed07..d214ea3 100644 --- a/src/server/sessions/sessionNameGenerator.ts +++ b/src/server/sessions/sessionNameGenerator.ts @@ -43,7 +43,9 @@ export async function generateShortSessionName(modelRegistry: return cleanSessionName(finalMessage === undefined ? streamedText : textFromAssistant(finalMessage)); } -export function fallbackSessionName(firstMessage: string): string | undefined { +export function fallbackSessionName(firstMessage: unknown): string | undefined { + if (typeof firstMessage !== "string") return undefined; + return cleanSessionName(firstMessage .replace(/[\s\S]*?<\/skill>/g, "") .replace(/```[\s\S]*?```/g, " ") diff --git a/src/server/sessions/sessionRoutes.test.ts b/src/server/sessions/sessionRoutes.test.ts new file mode 100644 index 0000000..4e9c438 --- /dev/null +++ b/src/server/sessions/sessionRoutes.test.ts @@ -0,0 +1,58 @@ +import Fastify, { type FastifyInstance } from "fastify"; +import fastifyWebsocket from "@fastify/websocket"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { SessionEventHub } from "../realtime/sessionEventHub.js"; +import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js"; +import { registerSessionRoutes } from "./sessionRoutes.js"; + +let app: FastifyInstance; +let service: PiSessionService; +let sessionManager: RejectingSessionManager; + +beforeEach(async () => { + app = Fastify({ logger: false }); + await app.register(fastifyWebsocket); + sessionManager = new RejectingSessionManager(); + const eventHub = new SessionEventHub(); + service = new PiSessionService(eventHub, { sessionManager, heartbeatIntervalMs: 60_000 }); + registerSessionRoutes(app, service, eventHub); +}); + +afterEach(async () => { + await service.dispose(); + await app.close(); +}); + +describe("session routes", () => { + it("rejects prompt payloads that omit text without opening a session", async () => { + const response = await app.inject({ method: "POST", url: "/sessions/session-1/prompt", payload: { body: "Build the thing" } }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "Prompt text is required" }); + expect(sessionManager.calls).toEqual({ create: 0, list: 0, listAll: 0, open: 0 }); + }); +}); + +class RejectingSessionManager implements PiSessionManagerGateway { + readonly calls = { create: 0, list: 0, listAll: 0, open: 0 }; + + list() { + this.calls.list += 1; + return Promise.resolve([]); + } + + create(): never { + this.calls.create += 1; + throw new Error("Session manager should not create sessions for invalid prompt payloads"); + } + + listAll() { + this.calls.listAll += 1; + return Promise.resolve([]); + } + + open(): never { + this.calls.open += 1; + throw new Error("Session manager should not open sessions for invalid prompt payloads"); + } +} diff --git a/src/server/sessions/sessionRoutes.ts b/src/server/sessions/sessionRoutes.ts index 4de5b55..5fa9aab 100644 --- a/src/server/sessions/sessionRoutes.ts +++ b/src/server/sessions/sessionRoutes.ts @@ -2,6 +2,11 @@ import type { FastifyInstance } from "fastify"; import type { SessionEventHub } from "../realtime/sessionEventHub.js"; import type { PiSessionService } from "./piSessionService.js"; +interface PromptRequestBody { + text?: unknown; + streamingBehavior?: unknown; +} + export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionService, eventHub: SessionEventHub, prefix = ""): void { app.get<{ Querystring: { cwd?: string } }>(`${prefix}/sessions`, async (request, reply) => { if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" }); @@ -89,9 +94,9 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS } }); - app.post<{ Params: { sessionId: string }; Body: { text: string; streamingBehavior?: "steer" | "followUp" } }>(`${prefix}/sessions/:sessionId/prompt`, async (request, reply) => { + app.post<{ Params: { sessionId: string }; Body: PromptRequestBody | undefined }>(`${prefix}/sessions/:sessionId/prompt`, async (request, reply) => { try { - await sessions.prompt(request.params.sessionId, request.body.text, request.body.streamingBehavior); + await sessions.prompt(request.params.sessionId, request.body?.text, request.body?.streamingBehavior); return { accepted: true }; } catch (error) { return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) }); From 0d9d1242373da9a7362f23d8964a7f7433fa56f4 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 5 Jun 2026 14:26:35 +0200 Subject: [PATCH 02/28] fix(plugins): preserve deprecated panel terminal alias --- .changeset/remove-panel-open-terminal.md | 2 +- docs/plugins.md | 8 +++++--- src/client/src/components/PiWebApp.ts | 1 + src/client/src/plugins/types.ts | 5 +++++ src/plugin-api.ts | 1 - 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.changeset/remove-panel-open-terminal.md b/.changeset/remove-panel-open-terminal.md index f989483..00a8f80 100644 --- a/.changeset/remove-panel-open-terminal.md +++ b/.changeset/remove-panel-open-terminal.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Clean up the workspace panel plugin context by removing the legacy `openTerminal` alias and moving render invalidation to `context.host.requestRender()`. +Clean up the workspace panel plugin context by moving render invalidation to `context.host.requestRender()` and deprecating the legacy runtime-only `openTerminal` alias in favor of `context.terminal.open()`. diff --git a/docs/plugins.md b/docs/plugins.md index 66570fd..2e8ab53 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -182,7 +182,7 @@ Built-in plugins can be managed from **Settings → Plugins** or with the top-le ### Updates -**Plugin id:** `updates` +**Plugin id:** `updates` **What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance. Updates is enabled by default. To hide it, disable `updates` in **Settings → Plugins** or set: @@ -197,8 +197,8 @@ Updates is enabled by default. To hide it, disable `updates` in **Settings → P ### Workspace Tasks -**Plugin id:** `workspace-tasks` -**Config file:** `.pi-web/tasks.json` +**Plugin id:** `workspace-tasks` +**Config file:** `.pi-web/tasks.json` **What it does:** adds a **Tasks** workspace tab for running configured shell commands in dedicated PI WEB terminals. Workspace Tasks is enabled by default. To hide it, disable `workspace-tasks` in **Settings → Plugins** or set: @@ -534,6 +534,8 @@ interface WorkspacePanelContext { `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`. +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()`. + Useful workspace and machine shapes: ```ts diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 5e6c925..4bc238c 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -943,6 +943,7 @@ export class PiWebApp extends LitElement { open: (options) => { void this.openRuntimeTerminal(machineId, workspace, options); }, runCommand: (input) => terminalCommandRuns.runCommand({ ...input, workspace }), }, + openTerminal: (options) => { void this.openRuntimeTerminal(machineId, workspace, options); }, host: this.createWorkspaceHost(), piWebUnstable: { terminalCommandRuns }, fileTree: this.state.fileTree, diff --git a/src/client/src/plugins/types.ts b/src/client/src/plugins/types.ts index 4290865..23cde24 100644 --- a/src/client/src/plugins/types.ts +++ b/src/client/src/plugins/types.ts @@ -131,6 +131,11 @@ export interface QualifiedPluginAction extends AppAction { export interface WorkspacePanelContext extends WorkspaceContext { terminal: WorkspacePanelTerminal; + /** + * @deprecated Runtime-only compatibility alias for pre-v2 plugins. Use `terminal.open()` instead. + * This is intentionally not part of the public `@jmfederico/pi-web/plugin-api` declarations. + */ + openTerminal?: (options?: { terminalId?: string | undefined }) => void; piWebUnstable?: Pick; fileTree: FileTreeEntry[]; expandedDirs: Record; diff --git a/src/plugin-api.ts b/src/plugin-api.ts index d7285a5..971e46c 100644 --- a/src/plugin-api.ts +++ b/src/plugin-api.ts @@ -212,4 +212,3 @@ export interface ThemePairContribution { light: LocalContributionId; dark: LocalContributionId; } - From f2d211da9c215f3f45ff415d4ff5dd93720ba3b3 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 5 Jun 2026 14:32:41 +0200 Subject: [PATCH 03/28] fix(plugins): harden remote plugin asset proxy --- .changeset/harden-remote-plugin-assets.md | 5 ++ src/server/app.test.ts | 39 +++++++++++ .../machines/machinePluginProxyRoutes.ts | 64 ++++++++++++++----- 3 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 .changeset/harden-remote-plugin-assets.md diff --git a/.changeset/harden-remote-plugin-assets.md b/.changeset/harden-remote-plugin-assets.md new file mode 100644 index 0000000..62797c2 --- /dev/null +++ b/.changeset/harden-remote-plugin-assets.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Harden remote machine plugin asset proxying so plugin asset URLs cannot escape the remote plugin directory. diff --git a/src/server/app.test.ts b/src/server/app.test.ts index 9113b5a..aebed93 100644 --- a/src/server/app.test.ts +++ b/src/server/app.test.ts @@ -343,6 +343,45 @@ describe("buildApp", () => { expect(request).toHaveBeenCalledWith("GET", "/pi-web-plugins/remote-tools/pi-web-plugin.js?v=123"); }); + it("drops unsafe remote machine plugin manifest modules", 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 }>(); + remoteClient = fakeRemoteClient({ + requestJson: vi.fn(() => Promise.resolve({ + statusCode: 200, + headers: { "content-type": "application/json" }, + body: { + plugins: [ + { id: "safe-tools", module: "nested/pi-web-plugin.js?v=1", source: "local", scope: "local" }, + { id: "traversal-tools", module: "..%2F..%2Fapi%2Fconfig", source: "local", scope: "local" }, + { id: "wrong-root", module: "/pi-web-plugins/other/pi-web-plugin.js", source: "local", scope: "local" }, + ], + }, + })), + }); + + const manifestResponse = await app.inject({ method: "GET", url: `/api/machines/${remote.id}/pi-web-plugins/manifest.json` }); + + expect(manifestResponse.statusCode).toBe(200); + expect(manifestResponse.json()).toEqual({ + plugins: [{ id: "safe-tools", module: `/pi-web-plugins/${machineScopedPluginId(remote.id, "safe-tools")}/nested/pi-web-plugin.js?v=1`, source: "local", scope: "local" }], + }); + }); + + it("rejects remote machine plugin asset traversal before proxying", async () => { + const addResponse = await app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } }); + const remote = addResponse.json<{ id: string }>(); + const request = vi.fn(() => Promise.resolve({ statusCode: 200, headers: {}, body: Readable.from([]) })); + remoteClient = fakeRemoteClient({ request }); + const scopedPluginId = machineScopedPluginId(remote.id, "remote-tools"); + + const response = await app.inject({ method: "GET", url: `/pi-web-plugins/${scopedPluginId}/..%2F..%2Fapi%2Fconfig` }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ error: "Invalid remote PI WEB plugin asset path" }); + expect(request).not.toHaveBeenCalled(); + }); + it("returns stable errors for invalid project requests", async () => { const addResponse = await app.inject({ method: "POST", diff --git a/src/server/machines/machinePluginProxyRoutes.ts b/src/server/machines/machinePluginProxyRoutes.ts index 20b37ee..65856b2 100644 --- a/src/server/machines/machinePluginProxyRoutes.ts +++ b/src/server/machines/machinePluginProxyRoutes.ts @@ -59,8 +59,14 @@ export async function proxyMachinePluginAsset(machines: MachinePluginProxyMachin return true; } + const requestPath = remotePluginAssetRequestPath(remotePlugin, assetPath, requestUrl); + if (requestPath === undefined) { + await reply.code(400).send({ error: "Invalid remote PI WEB plugin asset path" }); + return true; + } + try { - const upstream = await client.request("GET", remotePluginAssetRequestPath(remotePlugin, assetPath, requestUrl)); + const upstream = await client.request("GET", requestPath); reply.code(upstream.statusCode); applySafeHeaders(reply, upstream.headers); if (upstream.body === undefined) await reply.send(); @@ -87,29 +93,57 @@ function rewriteRemotePluginManifest(machineId: string, manifest: RemotePluginMa function remotePluginModulePath(pluginId: string, module: string): { path: string; query: string } | undefined { if (!isPiWebPluginId(pluginId)) return undefined; + const prefix = `/pi-web-plugins/${encodeURIComponent(pluginId)}/`; + const base = new URL(prefix, "http://pi-web.local"); try { - const url = new URL(module, "http://pi-web.local"); - const prefix = `/pi-web-plugins/${encodeURIComponent(pluginId)}/`; - if (url.pathname.startsWith(prefix)) { - return { path: url.pathname.slice(prefix.length), query: url.search }; - } - if (!module.startsWith("/") && !/^https?:\/\//iu.test(module)) { - const [path, query = ""] = module.split("?", 2); - if (path !== undefined && path !== "") return { path, query: query === "" ? "" : `?${query}` }; - } + const url = new URL(module, base); + if (url.origin !== base.origin || !url.pathname.startsWith(prefix)) return undefined; + const path = safeRemotePluginAssetPath(url.pathname.slice(prefix.length)); + return path === undefined ? undefined : { path, query: url.search }; } catch { return undefined; } - return undefined; } -function remotePluginAssetRequestPath(remotePlugin: MachineScopedPluginIdParts, assetPath: string, requestUrl: string): string { +function remotePluginAssetRequestPath(remotePlugin: MachineScopedPluginIdParts, assetPath: string, requestUrl: string): string | undefined { + const path = safeRemotePluginAssetPath(assetPath); + if (path === undefined) return undefined; const query = requestUrl.includes("?") ? requestUrl.slice(requestUrl.indexOf("?")) : ""; - return `/pi-web-plugins/${encodeURIComponent(remotePlugin.pluginId)}/${encodePathSegments(assetPath)}${query}`; + return `/pi-web-plugins/${encodeURIComponent(remotePlugin.pluginId)}/${path}${query}`; } -function encodePathSegments(path: string): string { - return path.split("/").map((segment) => encodeURIComponent(segment)).join("/"); +function safeRemotePluginAssetPath(path: string): string | undefined { + const segments: string[] = []; + for (const rawSegment of path.split("/")) { + const segment = safeRemotePluginAssetPathSegment(rawSegment); + if (segment === undefined) return undefined; + if (segment === "") continue; + segments.push(segment); + } + if (segments.length === 0) return undefined; + return segments.map((segment) => encodeURIComponent(segment)).join("/"); +} + +function safeRemotePluginAssetPathSegment(rawSegment: string): string | undefined { + if (rawSegment === "" || rawSegment === ".") return ""; + if (/%(?:2f|5c)/iu.test(rawSegment)) return undefined; + let segment: string; + try { + segment = decodeURIComponent(rawSegment); + } catch { + return undefined; + } + if (segment === "" || segment === ".") return ""; + if (segment === ".." || segment.includes("/") || segment.includes("\\") || hasControlCharacter(segment)) return undefined; + return segment; +} + +function hasControlCharacter(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; } function parseRemoteManifest(value: unknown): RemotePluginManifest { From 73218be5166ec21ee932c6ec0734a9bb44b08318 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 5 Jun 2026 15:41:53 +0200 Subject: [PATCH 04/28] chore(release): v1.202606.1 --- .changeset/add-machine-dialog.md | 5 --- .changeset/built-in-plugin-docs.md | 5 --- ...close-workspace-terminals-before-delete.md | 5 --- .changeset/fix-workspace-api-reference.md | 5 --- .changeset/guard-missing-prompt-text.md | 5 --- .changeset/harden-remote-plugin-assets.md | 5 --- .changeset/hide-single-machine-navigation.md | 5 --- .changeset/machine-activity-indicators.md | 5 --- .changeset/machine-scoped-local-apis.md | 5 --- .changeset/mobile-actions-button.md | 5 --- .changeset/mobile-tab-icons.md | 5 --- .changeset/offline-remote-fallback.md | 5 --- .changeset/permanent-tab-icons.md | 5 --- .changeset/persist-tab-navigation-memory.md | 5 --- .changeset/plugin-disable-config.md | 5 --- .changeset/plugin-public-context-helpers.md | 5 --- .changeset/pwa-refresh-menu.md | 5 --- .changeset/remember-machine-navigation.md | 5 --- .changeset/remote-machine-federation.md | 5 --- .changeset/remote-machine-plugins.md | 5 --- .changeset/remove-panel-open-terminal.md | 5 --- .changeset/settings-config-ui.md | 5 --- .changeset/shortcut-config-foundation.md | 5 --- .changeset/synthesized-local-machines.md | 5 --- .changeset/updates-plugin-rename.md | 5 --- .changeset/workspace-label-file-context.md | 5 --- .changeset/workspace-tasks-click-feedback.md | 5 --- .changeset/workspace-tasks-rename.md | 5 --- CHANGELOG.md | 32 +++++++++++++++++++ package-lock.json | 4 +-- package.json | 2 +- 31 files changed, 35 insertions(+), 143 deletions(-) delete mode 100644 .changeset/add-machine-dialog.md delete mode 100644 .changeset/built-in-plugin-docs.md delete mode 100644 .changeset/close-workspace-terminals-before-delete.md delete mode 100644 .changeset/fix-workspace-api-reference.md delete mode 100644 .changeset/guard-missing-prompt-text.md delete mode 100644 .changeset/harden-remote-plugin-assets.md delete mode 100644 .changeset/hide-single-machine-navigation.md delete mode 100644 .changeset/machine-activity-indicators.md delete mode 100644 .changeset/machine-scoped-local-apis.md delete mode 100644 .changeset/mobile-actions-button.md delete mode 100644 .changeset/mobile-tab-icons.md delete mode 100644 .changeset/offline-remote-fallback.md delete mode 100644 .changeset/permanent-tab-icons.md delete mode 100644 .changeset/persist-tab-navigation-memory.md delete mode 100644 .changeset/plugin-disable-config.md delete mode 100644 .changeset/plugin-public-context-helpers.md delete mode 100644 .changeset/pwa-refresh-menu.md delete mode 100644 .changeset/remember-machine-navigation.md delete mode 100644 .changeset/remote-machine-federation.md delete mode 100644 .changeset/remote-machine-plugins.md delete mode 100644 .changeset/remove-panel-open-terminal.md delete mode 100644 .changeset/settings-config-ui.md delete mode 100644 .changeset/shortcut-config-foundation.md delete mode 100644 .changeset/synthesized-local-machines.md delete mode 100644 .changeset/updates-plugin-rename.md delete mode 100644 .changeset/workspace-label-file-context.md delete mode 100644 .changeset/workspace-tasks-click-feedback.md delete mode 100644 .changeset/workspace-tasks-rename.md diff --git a/.changeset/add-machine-dialog.md b/.changeset/add-machine-dialog.md deleted file mode 100644 index 30c4559..0000000 --- a/.changeset/add-machine-dialog.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Replace add-machine browser prompts with a PI WEB form that asks for the remote URL first, suggests a machine name, and supports an optional bearer token. diff --git a/.changeset/built-in-plugin-docs.md b/.changeset/built-in-plugin-docs.md deleted file mode 100644 index 309e0cb..0000000 --- a/.changeset/built-in-plugin-docs.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Document built-in PI WEB plugins, including configuration guidance for Workspace Tasks. diff --git a/.changeset/close-workspace-terminals-before-delete.md b/.changeset/close-workspace-terminals-before-delete.md deleted file mode 100644 index ed12d70..0000000 --- a/.changeset/close-workspace-terminals-before-delete.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Delete workspaces through a server-side operation that closes target workspace terminals before running the worktree removal command, preventing stale machine activity indicators. diff --git a/.changeset/fix-workspace-api-reference.md b/.changeset/fix-workspace-api-reference.md deleted file mode 100644 index 60272d3..0000000 --- a/.changeset/fix-workspace-api-reference.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Fix workspace selection in the web UI so local machine project and session loading no longer fails with `api is not defined`. diff --git a/.changeset/guard-missing-prompt-text.md b/.changeset/guard-missing-prompt-text.md deleted file mode 100644 index c094f79..0000000 --- a/.changeset/guard-missing-prompt-text.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Prevent malformed session prompt API calls from crashing the session daemon. diff --git a/.changeset/harden-remote-plugin-assets.md b/.changeset/harden-remote-plugin-assets.md deleted file mode 100644 index 62797c2..0000000 --- a/.changeset/harden-remote-plugin-assets.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Harden remote machine plugin asset proxying so plugin asset URLs cannot escape the remote plugin directory. diff --git a/.changeset/hide-single-machine-navigation.md b/.changeset/hide-single-machine-navigation.md deleted file mode 100644 index 68d9a2e..0000000 --- a/.changeset/hide-single-machine-navigation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Hide the Machines navigation section when only one machine is configured, align Machines list spacing with the other navigation sections, and add a remove action to remote machine rows. diff --git a/.changeset/machine-activity-indicators.md b/.changeset/machine-activity-indicators.md deleted file mode 100644 index 9a319b0..0000000 --- a/.changeset/machine-activity-indicators.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Show machine activity indicators when sessions or terminals are active on any workspace for that machine. diff --git a/.changeset/machine-scoped-local-apis.md b/.changeset/machine-scoped-local-apis.md deleted file mode 100644 index ef8f8bb..0000000 --- a/.changeset/machine-scoped-local-apis.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add machine-scoped local project, workspace, file, and git API aliases as the next step toward machine federation. diff --git a/.changeset/mobile-actions-button.md b/.changeset/mobile-actions-button.md deleted file mode 100644 index 88308c7..0000000 --- a/.changeset/mobile-actions-button.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Make the mobile Actions entry available from the top context controls and remove the redundant PI WEB navigation header on mobile. diff --git a/.changeset/mobile-tab-icons.md b/.changeset/mobile-tab-icons.md deleted file mode 100644 index ad794bc..0000000 --- a/.changeset/mobile-tab-icons.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Use compact icons, initials, and inline badges for the mobile main tab bar so tabs are easier to fit without losing horizontal scrolling; let workspace panel plugins provide custom SVG tab icons; and add icons for bundled Info, Updates, and Tasks plugin panels. diff --git a/.changeset/offline-remote-fallback.md b/.changeset/offline-remote-fallback.md deleted file mode 100644 index eab366f..0000000 --- a/.changeset/offline-remote-fallback.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Fall back to the local machine when a bookmarked or restored remote machine is offline, and clear stale remote workspace route state. diff --git a/.changeset/permanent-tab-icons.md b/.changeset/permanent-tab-icons.md deleted file mode 100644 index 7c7f5b3..0000000 --- a/.changeset/permanent-tab-icons.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Keep workspace tool tab icons visible in the desktop workspace panel and collapse tab names only in compact panel widths. diff --git a/.changeset/persist-tab-navigation-memory.md b/.changeset/persist-tab-navigation-memory.md deleted file mode 100644 index 68e55f0..0000000 --- a/.changeset/persist-tab-navigation-memory.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Preserve machine, workspace, session, and terminal navigation memory across reloads within each browser tab. diff --git a/.changeset/plugin-disable-config.md b/.changeset/plugin-disable-config.md deleted file mode 100644 index 2aacf91..0000000 --- a/.changeset/plugin-disable-config.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add plugin enablement settings so discovered PI WEB plugins can be disabled before the browser imports them. diff --git a/.changeset/plugin-public-context-helpers.md b/.changeset/plugin-public-context-helpers.md deleted file mode 100644 index ddaeeb4..0000000 --- a/.changeset/plugin-public-context-helpers.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add documented plugin context helpers for machine-scoped workspace files and terminal commands, generate plugin API declarations from source, and move bundled plugins away from direct PI WEB API calls. diff --git a/.changeset/pwa-refresh-menu.md b/.changeset/pwa-refresh-menu.md deleted file mode 100644 index 7bb77b6..0000000 --- a/.changeset/pwa-refresh-menu.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Keep the PWA refresh control menu visible above mobile tab navigation and workspace tab content. diff --git a/.changeset/remember-machine-navigation.md b/.changeset/remember-machine-navigation.md deleted file mode 100644 index 2702826..0000000 --- a/.changeset/remember-machine-navigation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Remember each machine's last selected project, workspace, session, and workspace tool when switching machines in the web UI. diff --git a/.changeset/remote-machine-federation.md b/.changeset/remote-machine-federation.md deleted file mode 100644 index f2c3c62..0000000 --- a/.changeset/remote-machine-federation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add remote machine federation so PI WEB can register trusted remote runtimes and proxy their projects, workspaces, sessions, files, git state, activity, and terminals through the current web server. diff --git a/.changeset/remote-machine-plugins.md b/.changeset/remote-machine-plugins.md deleted file mode 100644 index 07b7121..0000000 --- a/.changeset/remote-machine-plugins.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Load trusted PI WEB plugins from selected federated machines with machine-scoped actions, workspace panels, labels, proxied plugin assets, and gateway-preferred duplicate handling. diff --git a/.changeset/remove-panel-open-terminal.md b/.changeset/remove-panel-open-terminal.md deleted file mode 100644 index 00a8f80..0000000 --- a/.changeset/remove-panel-open-terminal.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Clean up the workspace panel plugin context by moving render invalidation to `context.host.requestRender()` and deprecating the legacy runtime-only `openTerminal` alias in favor of `context.terminal.open()`. diff --git a/.changeset/settings-config-ui.md b/.changeset/settings-config-ui.md deleted file mode 100644 index 38c42d2..0000000 --- a/.changeset/settings-config-ui.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add a deep-linked Settings UI for editing the active PI WEB config file and viewing registered keyboard shortcuts. diff --git a/.changeset/shortcut-config-foundation.md b/.changeset/shortcut-config-foundation.md deleted file mode 100644 index 467023f..0000000 --- a/.changeset/shortcut-config-foundation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add shortcut preferences to the PI WEB config schema so keyboard shortcuts can be overridden or disabled by action id. diff --git a/.changeset/synthesized-local-machines.md b/.changeset/synthesized-local-machines.md deleted file mode 100644 index 22ac957..0000000 --- a/.changeset/synthesized-local-machines.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add the first machine registry API and show the synthesized Local machine in the web UI as the foundation for machine federation. diff --git a/.changeset/updates-plugin-rename.md b/.changeset/updates-plugin-rename.md deleted file mode 100644 index 1ddfd22..0000000 --- a/.changeset/updates-plugin-rename.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Rename the built-in Updates plugin id from `pi-web` to `updates` for clearer plugin configuration. diff --git a/.changeset/workspace-label-file-context.md b/.changeset/workspace-label-file-context.md deleted file mode 100644 index fc950fe..0000000 --- a/.changeset/workspace-label-file-context.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Add workspace file and render helpers to plugin workspace label callbacks so labels can load workspace-scoped metadata without hidden panels. diff --git a/.changeset/workspace-tasks-click-feedback.md b/.changeset/workspace-tasks-click-feedback.md deleted file mode 100644 index 731b4e2..0000000 --- a/.changeset/workspace-tasks-click-feedback.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Prevent redundant Workspace Tasks panel re-renders from resetting mobile scroll position or replacing task buttons mid-click, and show feedback for stale, cancelled, or already-starting tasks. diff --git a/.changeset/workspace-tasks-rename.md b/.changeset/workspace-tasks-rename.md deleted file mode 100644 index d195d8b..0000000 --- a/.changeset/workspace-tasks-rename.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@jmfederico/pi-web": patch ---- - -Bundle Workspace Tasks with PI WEB as a built-in plugin for running `.pi-web/tasks.json` commands in workspace terminals. diff --git a/CHANGELOG.md b/CHANGELOG.md index 8de60eb..5312898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # @jmfederico/pi-web +## 1.202606.1 + +### Patch Changes + +- 93b50e6: Replace add-machine browser prompts with a PI WEB form that asks for the remote URL first, suggests a machine name, and supports an optional bearer token. +- 08f69d0: Document built-in PI WEB plugins, including configuration guidance for Workspace Tasks. +- 9c3dafc: Delete workspaces through a server-side operation that closes target workspace terminals before running the worktree removal command, preventing stale machine activity indicators. +- 159f533: Fix workspace selection in the web UI so local machine project and session loading no longer fails with `api is not defined`. +- 82ba2e0: Prevent malformed session prompt API calls from crashing the session daemon. +- f2d211d: Harden remote machine plugin asset proxying so plugin asset URLs cannot escape the remote plugin directory. +- ccd4a76: Hide the Machines navigation section when only one machine is configured, align Machines list spacing with the other navigation sections, and add a remove action to remote machine rows. +- 193c9d0: Show machine activity indicators when sessions or terminals are active on any workspace for that machine. +- b5f8810: Add machine-scoped local project, workspace, file, and git API aliases as the next step toward machine federation. +- 4495a26: Make the mobile Actions entry available from the top context controls and remove the redundant PI WEB navigation header on mobile. +- 4548e5c: Use compact icons, initials, and inline badges for the mobile main tab bar so tabs are easier to fit without losing horizontal scrolling; let workspace panel plugins provide custom SVG tab icons; and add icons for bundled Info, Updates, and Tasks plugin panels. +- e352dce: Fall back to the local machine when a bookmarked or restored remote machine is offline, and clear stale remote workspace route state. +- bd8d1f1: Keep workspace tool tab icons visible in the desktop workspace panel and collapse tab names only in compact panel widths. +- 30fb960: Preserve machine, workspace, session, and terminal navigation memory across reloads within each browser tab. +- 08f69d0: Add plugin enablement settings so discovered PI WEB plugins can be disabled before the browser imports them. +- e3533eb: Add documented plugin context helpers for machine-scoped workspace files and terminal commands, generate plugin API declarations from source, and move bundled plugins away from direct PI WEB API calls. +- 8cd2bba: Keep the PWA refresh control menu visible above mobile tab navigation and workspace tab content. +- b3bb732: Remember each machine's last selected project, workspace, session, and workspace tool when switching machines in the web UI. +- a142f5e: Add remote machine federation so PI WEB can register trusted remote runtimes and proxy their projects, workspaces, sessions, files, git state, activity, and terminals through the current web server. +- b9be7de: Load trusted PI WEB plugins from selected federated machines with machine-scoped actions, workspace panels, labels, proxied plugin assets, and gateway-preferred duplicate handling. +- f1c8f1f: Clean up the workspace panel plugin context by moving render invalidation to `context.host.requestRender()` and deprecating the legacy runtime-only `openTerminal` alias in favor of `context.terminal.open()`. +- 4495a26: Add a deep-linked Settings UI for editing the active PI WEB config file and viewing registered keyboard shortcuts. +- a58c211: Add shortcut preferences to the PI WEB config schema so keyboard shortcuts can be overridden or disabled by action id. +- 0405b38: Add the first machine registry API and show the synthesized Local machine in the web UI as the foundation for machine federation. +- 4bc0010: Add workspace file and render helpers to plugin workspace label callbacks so labels can load workspace-scoped metadata without hidden panels. +- 08f69d0: Prevent redundant Workspace Tasks panel re-renders from resetting mobile scroll position or replacing task buttons mid-click, and show feedback for stale, cancelled, or already-starting tasks. +- 08f69d0: Bundle Workspace Tasks with PI WEB as a built-in plugin for running `.pi-web/tasks.json` commands in workspace terminals. + ## 1.202606.0 ### Patch Changes diff --git a/package-lock.json b/package-lock.json index d9b2bf3..a8c805d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@jmfederico/pi-web", - "version": "1.202606.0", + "version": "1.202606.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@jmfederico/pi-web", - "version": "1.202606.0", + "version": "1.202606.1", "license": "MIT", "dependencies": { "@codemirror/lang-css": "^6.3.1", diff --git a/package.json b/package.json index 93a8752..b75ff9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@jmfederico/pi-web", - "version": "1.202606.0", + "version": "1.202606.1", "description": "Remote web UI and browser control plane for persistent Pi Coding Agent sessions.", "license": "MIT", "author": "Federico Jaramillo Martinez", From b35ce1d3e039663d02e2d668b802649a49443339 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 5 Jun 2026 20:26:12 +0200 Subject: [PATCH 05/28] fix: reduce repeated workspace context --- .changeset/lean-workspace-context.md | 5 +++++ src/client/src/components/PiWebApp.ts | 4 +--- src/client/src/components/StatusBar.ts | 7 +------ src/client/src/components/WorkspacePanel.ts | 19 ++++++++----------- src/client/src/components/shared.ts | 10 +--------- 5 files changed, 16 insertions(+), 29 deletions(-) create mode 100644 .changeset/lean-workspace-context.md diff --git a/.changeset/lean-workspace-context.md b/.changeset/lean-workspace-context.md new file mode 100644 index 0000000..4542b43 --- /dev/null +++ b/.changeset/lean-workspace-context.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Reduce repeated workspace details in the chat status bar and workspace tool header so workspace context stays in the workspace chip. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 4bc238c..d8c0efc 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -726,7 +726,6 @@ export class PiWebApp extends LitElement { private renderWorkspacePanel() { const workspace = this.state.selectedWorkspace; const panelContext = workspace === undefined ? undefined : this.createWorkspacePanelContext(workspace); - const workspaceLabelItems = workspace === undefined ? [] : this.workspaceLabelItems(workspace); const emptyState = workspace === undefined ? this.workspacePanelEmptyState() : undefined; return html` { this.openWorkspaceTool(tool); }} > `; @@ -1384,7 +1382,7 @@ export class PiWebApp extends LitElement { ${state.selectedSession ? html` 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}> - + ${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}>` : null} ${state.modelDialog !== undefined ? html` { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}>` : null} ${state.thinkingDialog !== undefined ? html` { void this.pickThinking(value); }} .onCancel=${() => { this.setState({ thinkingDialog: undefined }); }}>` : null} diff --git a/src/client/src/components/StatusBar.ts b/src/client/src/components/StatusBar.ts index 72666e1..3fa605b 100644 --- a/src/client/src/components/StatusBar.ts +++ b/src/client/src/components/StatusBar.ts @@ -1,17 +1,13 @@ import { LitElement, html } from "lit"; import { customElement, property } from "lit/decorators.js"; -import type { Machine, SessionStatus, Workspace } from "../api"; -import type { WorkspaceLabelItem } from "../plugins/types"; +import type { Machine, SessionStatus } from "../api"; import { formatCost, formatTokenCount } from "../utils/format"; import { statusBarStyles } from "./shared"; -import { renderWorkspaceLabel } from "./workspaceLabel"; @customElement("status-bar") export class StatusBar extends LitElement { @property({ attribute: false }) status?: SessionStatus; @property({ attribute: false }) machine?: Machine; - @property({ attribute: false }) workspace?: Workspace; - @property({ attribute: false }) workspaceLabelItems: WorkspaceLabelItem[] = []; override render() { const status = this.status; @@ -26,7 +22,6 @@ export class StatusBar extends LitElement { return html`
${this.machine?.name ?? "Local"} - ${renderWorkspaceLabel(this.workspace?.label ?? "workspace", this.workspaceLabelItems, this.workspace?.path)} ↑${formatTokenCount(tokens.input)} ↓${formatTokenCount(tokens.output)} ${contextText} diff --git a/src/client/src/components/WorkspacePanel.ts b/src/client/src/components/WorkspacePanel.ts index ced47fb..43f3029 100644 --- a/src/client/src/components/WorkspacePanel.ts +++ b/src/client/src/components/WorkspacePanel.ts @@ -1,9 +1,8 @@ import { LitElement, html, type TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import type { Workspace } from "../api"; -import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types"; +import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, WorkspacePanelContext } from "../plugins/types"; import { workspacePanelStyles } from "./shared"; -import { renderWorkspaceLabel } from "./workspaceLabel"; export interface WorkspacePanelEmptyState { title: string; @@ -19,7 +18,6 @@ export class WorkspacePanel extends LitElement { @property({ attribute: false }) emptyState: WorkspacePanelEmptyState | undefined; @property() tool: QualifiedContributionId = "core:workspace.files"; @property({ attribute: false }) panels: QualifiedWorkspacePanelContribution[] = []; - @property({ attribute: false }) workspaceLabelItems: WorkspaceLabelItem[] = []; @property({ type: Boolean }) hideToolTabs = false; @property({ attribute: false }) onSelectTool: (tool: QualifiedContributionId) => void = () => undefined; @query(".workspace-header-strip") private workspaceHeaderStrip?: HTMLElement | null; @@ -63,10 +61,10 @@ export class WorkspacePanel extends LitElement { const visiblePanels = this.panels; const selectedPanel = visiblePanels.find((panel) => panel.id === this.tool) ?? visiblePanels[0]; return html` -
-
-
- ${this.hideToolTabs ? null : html` + ${this.hideToolTabs ? null : html` +
+
+
${visiblePanels.map((panel) => { const selected = selectedPanel?.id === panel.id; @@ -79,11 +77,10 @@ export class WorkspacePanel extends LitElement { `; })}
- `} - ${renderWorkspaceLabel(workspace.label, this.workspaceLabelItems, workspace.path)} +
-
-
+ + `} ${selectedPanel === undefined ? this.renderEmptyState({ title: "No workspace tools available", body: "No tools are available for this workspace.", diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index e4a4ee1..8aeca75 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -186,10 +186,7 @@ export const workspacePanelStyles = css` .empty-state h2 { margin: 0; color: var(--pi-text); font-size: 15px; line-height: 1.3; } .empty-state p { margin: 0; line-height: 1.45; } small, .muted { color: var(--pi-muted); } - header small { flex: 0 0 auto; min-width: max-content; overflow: visible; text-overflow: clip; white-space: nowrap; } - header .workspace-label { width: max-content; max-width: none; overflow: visible; } - header .workspace-label-base, header .workspace-label-item, header .workspace-label-render { overflow: visible; text-overflow: clip; } - @media (max-width: 1180px) { .tabs { display: none; } } + @media (max-width: 1180px) { header { display: none; } } .workspace-label { min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; max-width: 100%; overflow: hidden; white-space: nowrap; } .workspace-label-base, .workspace-label-item, .workspace-label-render { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .workspace-label-item, .workspace-label-render, .workspace-label-separator { color: var(--pi-muted); } @@ -395,11 +392,6 @@ export const statusBarStyles = css` :host { display: block; color: var(--pi-muted); font: 12px system-ui, sans-serif; } .bar { display: flex; gap: 12px; align-items: center; min-width: 0; padding: 7px 12px; border-top: 1px solid var(--pi-border); background: var(--pi-bg); white-space: nowrap; overflow: hidden; } span { overflow: hidden; text-overflow: ellipsis; } - .workspace-label { min-width: 0; display: inline-flex; align-items: baseline; gap: 5px; max-width: 100%; overflow: hidden; white-space: nowrap; } - .workspace-label-base, .workspace-label-item, .workspace-label-render { min-width: 0; overflow: hidden; text-overflow: ellipsis; } - .workspace-label-item, .workspace-label-render, .workspace-label-separator { color: var(--pi-muted); } - .workspace-label-link { color: var(--pi-accent); text-decoration: none; } - .workspace-label-link:hover, .workspace-label-link:focus { text-decoration: underline; } .bar > span:first-child { flex: 1 1 auto; min-width: 80px; } .activity { display: inline-flex; align-items: center; gap: 6px; color: var(--pi-muted); } .activity.active { color: var(--pi-success); } From 98cdc29208cd413c48172659df8bd6ded391070a Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 5 Jun 2026 20:33:58 +0200 Subject: [PATCH 06/28] fix: streamline status bar context --- .changeset/lean-workspace-context.md | 2 +- src/client/src/components/PiWebApp.ts | 2 +- src/client/src/components/StatusBar.ts | 6 ++---- src/client/src/components/shared.ts | 4 ++-- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/.changeset/lean-workspace-context.md b/.changeset/lean-workspace-context.md index 4542b43..43e4a76 100644 --- a/.changeset/lean-workspace-context.md +++ b/.changeset/lean-workspace-context.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Reduce repeated workspace details in the chat status bar and workspace tool header so workspace context stays in the workspace chip. +Reduce repeated machine and workspace details in the chat status bar and workspace tool header so context stays in the location chips. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index d8c0efc..99d22ee 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -1382,7 +1382,7 @@ export class PiWebApp extends LitElement { ${state.selectedSession ? html` 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}> 0} .status=${state.status} .onSend=${(text: string, streamingBehavior?: "steer" | "followUp") => { this.sendPrompt(text, streamingBehavior); }} .onStop=${() => this.sessions.stopActiveWork()} .onSelectModel=${() => { void this.openModelDialog(); }} .onSelectThinking=${() => { void this.openThinkingDialog(); }}> - + ${state.commandDialog !== undefined ? html` this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}>` : null} ${state.modelDialog !== undefined ? html` { void this.pickModel(value); }} .onCancel=${() => { this.setState({ modelDialog: undefined }); }}>` : null} ${state.thinkingDialog !== undefined ? html` { void this.pickThinking(value); }} .onCancel=${() => { this.setState({ thinkingDialog: undefined }); }}>` : null} diff --git a/src/client/src/components/StatusBar.ts b/src/client/src/components/StatusBar.ts index 3fa605b..2fc2afb 100644 --- a/src/client/src/components/StatusBar.ts +++ b/src/client/src/components/StatusBar.ts @@ -1,13 +1,12 @@ import { LitElement, html } from "lit"; import { customElement, property } from "lit/decorators.js"; -import type { Machine, SessionStatus } from "../api"; +import type { SessionStatus } from "../api"; import { formatCost, formatTokenCount } from "../utils/format"; import { statusBarStyles } from "./shared"; @customElement("status-bar") export class StatusBar extends LitElement { @property({ attribute: false }) status?: SessionStatus; - @property({ attribute: false }) machine?: Machine; override render() { const status = this.status; @@ -21,10 +20,9 @@ export class StatusBar extends LitElement { const tokens = status.tokens; return html`
- ${this.machine?.name ?? "Local"} ↑${formatTokenCount(tokens.input)} ↓${formatTokenCount(tokens.output)} - ${contextText} + ${contextText} ${formatCost(status.cost)} ${status.pendingMessageCount > 0 ? html`${String(status.pendingMessageCount)} queued` : null}
diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 8aeca75..1202696 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -391,8 +391,8 @@ export const formattedTextStyles = css` export const statusBarStyles = css` :host { display: block; color: var(--pi-muted); font: 12px system-ui, sans-serif; } .bar { display: flex; gap: 12px; align-items: center; min-width: 0; padding: 7px 12px; border-top: 1px solid var(--pi-border); background: var(--pi-bg); white-space: nowrap; overflow: hidden; } - span { overflow: hidden; text-overflow: ellipsis; } - .bar > span:first-child { flex: 1 1 auto; min-width: 80px; } + span { flex: 0 0 auto; overflow: hidden; text-overflow: ellipsis; } + .context { flex: 1 1 auto; min-width: 0; } .activity { display: inline-flex; align-items: center; gap: 6px; color: var(--pi-muted); } .activity.active { color: var(--pi-success); } .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; opacity: .45; flex: 0 0 auto; } From 66c5d0faca229acb443a992cbd17ed97dee36d46 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 5 Jun 2026 20:35:18 +0200 Subject: [PATCH 07/28] fix: right align status bar metrics --- .changeset/lean-workspace-context.md | 2 +- src/client/src/components/shared.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.changeset/lean-workspace-context.md b/.changeset/lean-workspace-context.md index 43e4a76..d44258a 100644 --- a/.changeset/lean-workspace-context.md +++ b/.changeset/lean-workspace-context.md @@ -2,4 +2,4 @@ "@jmfederico/pi-web": patch --- -Reduce repeated machine and workspace details in the chat status bar and workspace tool header so context stays in the location chips. +Reduce repeated machine and workspace details in the chat status bar and workspace tool header, keeping compact session metrics right-aligned. diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 1202696..c4a78f6 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -390,9 +390,8 @@ export const formattedTextStyles = css` export const statusBarStyles = css` :host { display: block; color: var(--pi-muted); font: 12px system-ui, sans-serif; } - .bar { display: flex; gap: 12px; align-items: center; min-width: 0; padding: 7px 12px; border-top: 1px solid var(--pi-border); background: var(--pi-bg); white-space: nowrap; overflow: hidden; } - span { flex: 0 0 auto; overflow: hidden; text-overflow: ellipsis; } - .context { flex: 1 1 auto; min-width: 0; } + .bar { display: flex; justify-content: flex-end; gap: 12px; align-items: center; min-width: 0; padding: 7px 12px; border-top: 1px solid var(--pi-border); background: var(--pi-bg); white-space: nowrap; overflow: hidden; } + span { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; } .activity { display: inline-flex; align-items: center; gap: 6px; color: var(--pi-muted); } .activity.active { color: var(--pi-success); } .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; opacity: .45; flex: 0 0 auto; } From ad963a239106b2f4f28d307f14864db931aa3eaf Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 5 Jun 2026 20:43:45 +0200 Subject: [PATCH 08/28] fix: simplify mobile breadcrumb machine context --- .../hide-single-machine-mobile-crumb.md | 5 +++ src/client/src/components/PiWebApp.ts | 11 +------ .../components/appShell/AppContextBar.test.ts | 24 ++++++++++++++ .../src/components/appShell/AppContextBar.ts | 31 +++++++++---------- 4 files changed, 44 insertions(+), 27 deletions(-) create mode 100644 .changeset/hide-single-machine-mobile-crumb.md create mode 100644 src/client/src/components/appShell/AppContextBar.test.ts diff --git a/.changeset/hide-single-machine-mobile-crumb.md b/.changeset/hide-single-machine-mobile-crumb.md new file mode 100644 index 0000000..1d97f32 --- /dev/null +++ b/.changeset/hide-single-machine-mobile-crumb.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Simplify the mobile location breadcrumb by hiding the machine crumb when there is only one configured machine and removing activity indicators from breadcrumb items. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 99d22ee..230e423 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -34,7 +34,6 @@ import { readSettingsSection, writeSettingsSection, type SettingsSection } from import { applyShortcutPreferences } from "../shortcutPreferences"; import { createTerminalCommandRunsRuntime } from "../runtime/terminalRuntime"; import { isWorkspaceDeletionPending, isWorkspaceDeletionRunPending, latestWorkspaceDeletionRuns, pendingWorkspaceDeletionIds, targetWorkspaceIdForRun, workspaceDeletionRunFilter } from "../workspaceDeletion"; -import { machineActivityIndicator } from "../workspaceActivity"; import "./MachineList"; import "./ProjectList"; import "./WorkspaceList"; @@ -1326,8 +1325,8 @@ export class PiWebApp extends LitElement { if (!this.appShell.isMobileNavigationLayout) return null; return html` ): boolean { return Object.entries(patch).some(([key, value]) => Reflect.get(state, key) !== value); } diff --git a/src/client/src/components/appShell/AppContextBar.test.ts b/src/client/src/components/appShell/AppContextBar.test.ts new file mode 100644 index 0000000..29badcc --- /dev/null +++ b/src/client/src/components/appShell/AppContextBar.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import type { Machine } from "../../api"; +import { shouldShowMachineContext } from "./AppContextBar"; + +describe("shouldShowMachineContext", () => { + it("hides the machine crumb when there is no machine choice", () => { + expect(shouldShowMachineContext([])).toBe(false); + expect(shouldShowMachineContext([machine("local")])).toBe(false); + }); + + it("shows the machine crumb when multiple machines exist", () => { + expect(shouldShowMachineContext([machine("local"), machine("remote-a")])).toBe(true); + }); +}); + +function machine(id: string): Machine { + return { + id, + name: id, + kind: id === "local" ? "local" : "remote", + createdAt: "2026-06-04T00:00:00.000Z", + updatedAt: "2026-06-04T00:00:00.000Z", + }; +} diff --git a/src/client/src/components/appShell/AppContextBar.ts b/src/client/src/components/appShell/AppContextBar.ts index 6a014f7..0b48445 100644 --- a/src/client/src/components/appShell/AppContextBar.ts +++ b/src/client/src/components/appShell/AppContextBar.ts @@ -2,12 +2,11 @@ import { LitElement, css, html } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import type { Machine, Project, SessionInfo, Workspace } from "../../api"; import type { NavigationSection } from "../../appShell/navigationState"; -import { renderActivityIndicator, type ActivityIndicatorKind } from "../activityBadge"; @customElement("app-context-bar") export class AppContextBar extends LitElement { + @property({ attribute: false }) machines: Machine[] = []; @property({ attribute: false }) machine?: Machine; - @property({ attribute: false }) machineActivityKind?: ActivityIndicatorKind; @property({ attribute: false }) project?: Project; @property({ attribute: false }) workspace?: Workspace; @property({ attribute: false }) session?: SessionInfo; @@ -38,6 +37,7 @@ export class AppContextBar extends LitElement { } override render() { + const showMachineContext = shouldShowMachineContext(this.machines); const machineLabel = machineContextLabel(this.machine); const projectLabel = projectContextLabel(this.project); const workspaceLabel = workspaceContextLabel(this.workspace); @@ -46,13 +46,14 @@ export class AppContextBar extends LitElement {
+ + + +
+
+
+

PI WEB fleet

+

Connect trusted PI WEB runtimes when one machine is not enough.

+

+ Most PI WEB setups only need one runtime. When you do have more than one, machine federation lets the PI WEB + instance you opened act as a gateway to other trusted runtimes while each machine keeps its own repositories, + credentials, sessions, and plugins. +

+
+
+ +
+
+ + +
+
+

What machine federation is

+

+ A machine is a PI WEB runtime endpoint. The local machine is synthesized automatically. Remote machines + are opt-in PI WEB runtimes that you register with a base URL and, optionally, a bearer token. +

+

+ After registration, the browser keeps talking to the current PI WEB origin. The gateway contacts the + selected remote PI WEB server and routes that machine's projects, workspaces, sessions, files, git state, + activity, and terminals to the browser UI. +

+
+
+ Federated shape + +
+
Browser
+  ↓
+PI WEB gateway you opened
+  ↓ gateway proxy
+Remote PI WEB runtime
+  ↓
+Remote projects, workspaces, sessions, terminals, plugins
+
+
+ +
+

When to use federation

+

+ Federation is useful when you have more than one place where agents should work, but you want one stable + browser entrypoint. +

+
+
+
+

Many dev boxes

+

Register a workstation, home server, cloud VM, or client-specific host without moving repositories.

+
+
+
+

One gateway

+

Open one trusted PI WEB URL and switch machines instead of juggling browser tabs and tunnels.

+
+
+
+

Local ownership

+

Each target machine keeps its own Pi auth, sessions, worktrees, terminal state, and plugins.

+
+
+
+ +
+

Prepare the target machines

+

+ Install and run PI WEB on every machine you want to register. The remote URL must be reachable from the + gateway server, not just from your browser. +

+
+
+ Install on each target + +
+
$ npm install -g @jmfederico/pi-web
+$ pi-web install
+$ pi-web doctor
+
+

+ Prefer a private path such as NetBird, Tailscale, WireGuard, private LAN, SSH tunnel, or an authenticated reverse + proxy. If the remote is behind a path prefix, include that prefix in the machine URL, for example + https://devbox.example.test/pi-web. +

+
+ Do not expose PI WEB directly to the public internet. Register machines only over trusted network paths + and only when you trust the endpoint. +
+
+ +
+

Add a remote machine

+
    +
  1. Open the PI WEB instance you want to use as the gateway.
  2. +
  3. Open Actions → Add Machine.
  4. +
  5. Enter the remote PI WEB base URL, including http:// or https://.
  6. +
  7. Accept the suggested name or enter a friendlier sidebar label.
  8. +
  9. Paste an optional bearer token if the remote endpoint requires one.
  10. +
+

+ The Machines section appears when there is more than one machine. Select a machine, then add projects, + workspaces, and sessions on that selected machine. +

+
+ Removing a remote machine only removes it from this gateway's registry. It does not stop the remote PI WEB + service or delete projects, workspaces, sessions, or credentials on the target machine. +
+
+ +
+

What is proxied

+

+ After you select a machine, the rest of the app works in that machine's scope. The gateway routes the + selected-machine work to the target PI WEB runtime. +

+
    +
  • Projects and workspaces.
  • +
  • Files, previews, git status, and diffs.
  • +
  • Pi sessions, transcripts, prompts, model controls, and commands.
  • +
  • Activity indicators and realtime updates.
  • +
  • Terminals and terminal command runs.
  • +
  • Remote plugins from the selected machine.
  • +
+
+ +
+

Auth and credentials stay on the target machine

+

+ Model-provider credentials, Pi configuration, OAuth state, repositories, and active session runtimes stay + on the selected target machine. The gateway does not copy them into its own Pi configuration. +

+
    +
  • API-key provider configuration can be proxied through the gateway.
  • +
  • OAuth login should be completed by opening the remote PI WEB directly.
  • +
  • The optional machine bearer token is stored by the gateway and sent to the remote while proxying requests.
  • +
+

+ Use Actions → Open Selected Machine PI WEB when you need to authenticate directly on a + remote machine or inspect it outside the gateway. +

+
+ +
+

Remote plugins are machine-scoped

+

+ When you select a remote machine, PI WEB tries to load that machine's discovered plugins through the + gateway. Remote plugin actions, workspace panels, and workspace labels only appear while that machine is + selected, and documented file and terminal helpers run against that machine. +

+

+ Remote plugins are still trusted browser-side code. Only federate machines whose PI WEB plugins you are + comfortable loading in the browser. +

+

Read the remote machine plugin notes →

+
+ +
+

Trust model

+

+ Machine federation is for trusted users, trusted PI WEB servers, and trusted network paths. Adding a + machine gives the gateway permission to contact that URL and forward user-initiated PI WEB traffic to it. +

+
    +
  • Use private networking, an SSH tunnel, or an authenticated reverse proxy.
  • +
  • Do not include credentials in the machine URL itself.
  • +
  • Use bearer tokens or proxy authentication when a remote endpoint needs an extra gate.
  • +
+
+ +
+

Storage and troubleshooting

+

+ Remote machine records are stored on the gateway in ~/.pi-web/machines.json. If + PI_WEB_DATA_DIR is set, they live under $PI_WEB_DATA_DIR/machines.json. +

+
    +
  • Use Actions → Refresh Selected Machine to re-check health.
  • +
  • Make sure the remote URL is reachable from the gateway server.
  • +
  • Open the remote PI WEB directly to verify it is running and to complete provider login flows.
  • +
  • Check gateway logs with pi-web logs for proxy timeouts or upstream errors.
  • +
+ +
+
+
+
+
+ + + + + diff --git a/docs/plugins.html b/docs/plugins.html index 7963d6b..ea29827 100644 --- a/docs/plugins.html +++ b/docs/plugins.html @@ -28,6 +28,7 @@ PI WEB