diff --git a/.gitignore b/.gitignore index 23fca14..6e03b37 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ pnpm-debug.log* test-results/ playwright-report/ .pi/playwright-session-ui-state-*.json +.pi/pi-web-runtime-bindings-*.json +.pi/pi-web-runtimes-*.json # OS / editor .DS_Store diff --git a/.pi/extensions/pi-web-session.ts b/.pi/extensions/pi-web-session.ts new file mode 100644 index 0000000..1cf68f2 --- /dev/null +++ b/.pi/extensions/pi-web-session.ts @@ -0,0 +1,354 @@ +/** + * pi-web bundled session orchestration tool. + * + * Gives an agent a safe, normalized way to inspect and nudge other pi-web + * sessions without exposing the pi-web bearer token in the conversation. + */ + +import { StringEnum } from "@earendil-works/pi-ai"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; + +const ACTIONS = ["list", "state", "messages", "prompt", "wait", "abort", "open", "new"] as const; +const PROMPT_MODES = ["followUp", "steer"] as const; + +type Action = typeof ACTIONS[number]; +type PromptMode = typeof PROMPT_MODES[number]; + +type PiWebSessionToolParams = { + action: Action; + sessionId?: string; + cwd?: string; + message?: string; + tail?: number; + limit?: number; + maxTextChars?: number; + timeoutMs?: number; + intervalMs?: number; + mode?: PromptMode; +}; + +type ApiOptions = { + signal?: AbortSignal; +}; + +type NormalizedState = { + ok: true; + sessionId?: string; + cwd?: string; + sessionFile?: string; + sessionTitle?: string; + isRunning: boolean; + isStreaming: boolean; + isCompacting: boolean; + runtime?: unknown; + runtimeRef?: unknown; + model?: unknown; + thinkingLevel?: unknown; + stats?: unknown; +}; + +function piWebBaseUrl() { + const configured = process.env.PI_WEB_INTERNAL_BASE_URL || process.env.PI_WEB_BASE_URL; + if (configured) return configured.replace(/\/+$/, ""); + return `http://127.0.0.1:${process.env.PORT || "8787"}`; +} + +function piWebHeaders(hasBody: boolean) { + const headers: Record = { accept: "application/json" }; + if (hasBody) headers["content-type"] = "application/json"; + if (process.env.PI_WEB_TOKEN) headers.authorization = `Bearer ${process.env.PI_WEB_TOKEN}`; + return headers; +} + +async function piWebApi(method: string, path: string, body?: unknown, options: ApiOptions = {}): Promise { + const response = await fetch(`${piWebBaseUrl()}${path}`, { + method, + headers: piWebHeaders(body !== undefined), + body: body === undefined ? undefined : JSON.stringify(body), + signal: options.signal, + }); + + const text = await response.text(); + let value: any = {}; + try { + value = text ? JSON.parse(text) : {}; + } catch { + value = { ok: false, error: text || response.statusText }; + } + + if (!response.ok || value?.ok === false) { + throw new Error(`${method} ${path} failed (${response.status}): ${value?.error || response.statusText}`); + } + + return value as T; +} + +function numberInRange(value: unknown, fallback: number, min: number, max: number) { + const parsed = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(min, Math.min(max, Math.floor(parsed))); +} + +function cleanString(value: unknown) { + return typeof value === "string" ? value.trim() : ""; +} + +function requiredSessionId(params: PiWebSessionToolParams) { + const sessionId = cleanString(params.sessionId); + if (!sessionId) throw new Error(`sessionId is required for action "${params.action}"`); + return sessionId; +} + +function truncate(text: string, maxChars: number) { + if (text.length <= maxChars) return text; + const omitted = text.length - maxChars; + return `${text.slice(0, Math.max(0, maxChars))}\n…[truncated ${omitted} chars]`; +} + +function safeJson(value: unknown, maxChars: number) { + try { + return truncate(JSON.stringify(value, null, 2), maxChars); + } catch { + return String(value); + } +} + +function messageText(message: any) { + if (typeof message?.text === "string") return message.text; + const content = message?.content; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content.map((part: any) => { + if (!part || typeof part !== "object") return ""; + if (part.type === "text" && typeof part.text === "string") return part.text; + if (part.type === "toolResult" && typeof part.content === "string") return part.content; + if (part.type === "toolCall") return `[tool:${part.name || part.toolName || "call"}]`; + if (part.type === "image") return "[image]"; + return ""; + }).filter(Boolean).join("\n"); +} + +function normalizeToolCalls(toolCalls: unknown, maxTextChars: number) { + if (!Array.isArray(toolCalls)) return undefined; + return toolCalls.map((toolCall: any) => ({ + id: typeof toolCall?.id === "string" ? toolCall.id : undefined, + toolName: typeof toolCall?.toolName === "string" ? toolCall.toolName : typeof toolCall?.name === "string" ? toolCall.name : undefined, + args: toolCall?.args === undefined && toolCall?.arguments === undefined + ? undefined + : safeJson(toolCall.args ?? toolCall.arguments, Math.min(maxTextChars, 2000)), + })); +} + +function normalizeMessage(message: any, maxTextChars: number) { + return { + entryId: typeof message?.entryId === "string" ? message.entryId : undefined, + role: typeof message?.role === "string" ? message.role : "unknown", + isError: Boolean(message?.isError), + timestamp: typeof message?.timestamp === "number" ? message.timestamp : undefined, + text: truncate(messageText(message), maxTextChars), + toolName: typeof message?.toolName === "string" ? message.toolName : undefined, + toolArgs: message?.toolArgs === undefined ? undefined : safeJson(message.toolArgs, Math.min(maxTextChars, 2000)), + toolCalls: normalizeToolCalls(message?.toolCalls, maxTextChars), + }; +} + +export function normalizePiWebMessages(result: any, options: { tail?: number; maxTextChars?: number } = {}) { + const allMessages = Array.isArray(result?.messages) ? result.messages : []; + const tail = numberInRange(options.tail, 20, 1, 100); + const maxTextChars = numberInRange(options.maxTextChars, 4000, 200, 20_000); + const messages = allMessages.slice(-tail).map((message: any) => normalizeMessage(message, maxTextChars)); + return { + ok: true, + count: allMessages.length, + returned: messages.length, + messages, + }; +} + +function isRunning(value: any) { + return Boolean( + value?.isStreaming + || value?.isCompacting + || value?.runtime?.isRunning + || value?.runtime?.isStreaming + || value?.runtime?.isCompacting, + ); +} + +export function normalizePiWebSessionState(value: any): NormalizedState { + return { + ok: true, + sessionId: typeof value?.sessionId === "string" ? value.sessionId : undefined, + cwd: typeof value?.cwd === "string" ? value.cwd : undefined, + sessionFile: typeof value?.sessionFile === "string" ? value.sessionFile : undefined, + sessionTitle: typeof value?.sessionTitle === "string" ? value.sessionTitle : undefined, + isRunning: isRunning(value), + isStreaming: Boolean(value?.isStreaming || value?.runtime?.isStreaming), + isCompacting: Boolean(value?.isCompacting || value?.runtime?.isCompacting), + runtime: value?.runtime ? { + loaded: Boolean(value.runtime.loaded), + isRunning: Boolean(value.runtime.isRunning), + isStreaming: Boolean(value.runtime.isStreaming), + isCompacting: Boolean(value.runtime.isCompacting), + pendingMessageCount: typeof value.runtime.pendingMessageCount === "number" ? value.runtime.pendingMessageCount : undefined, + startedAt: typeof value.runtime.startedAt === "string" ? value.runtime.startedAt : undefined, + lastActivityAt: typeof value.runtime.lastActivityAt === "string" ? value.runtime.lastActivityAt : undefined, + } : undefined, + runtimeRef: value?.runtimeRef, + model: value?.model || value?.runtime?.model, + thinkingLevel: value?.thinkingLevel, + stats: value?.stats, + }; +} + +function normalizeSessionList(value: any, limit: number, maxTextChars: number) { + const sessions = Array.isArray(value?.sessions) ? value.sessions : []; + return { + ok: true, + count: sessions.length, + returned: Math.min(sessions.length, limit), + sessions: sessions.slice(0, limit).map((session: any) => ({ + id: session?.id, + name: session?.name, + firstMessage: typeof session?.firstMessage === "string" ? truncate(session.firstMessage, maxTextChars) : undefined, + created: session?.created, + modified: session?.modified, + messageCount: session?.messageCount, + cwd: session?.cwd, + isCurrent: Boolean(session?.isCurrent), + unread: Boolean(session?.unread), + runtime: session?.runtime, + })), + }; +} + +function encodeSessionQuery(sessionId?: string) { + const clean = cleanString(sessionId); + return clean ? `?sessionId=${encodeURIComponent(clean)}` : ""; +} + +function sleep(ms: number, signal?: AbortSignal) { + if (signal?.aborted) return Promise.reject(new Error("Cancelled")); + return new Promise((resolve, reject) => { + let settled = false; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + fn(); + }; + const timer = setTimeout(() => finish(resolve), ms); + const onAbort = () => finish(() => reject(new Error("Cancelled"))); + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +function toolResult(result: unknown) { + return { + content: [{ type: "text" as const, text: safeJson(result, 30_000) }], + details: result, + }; +} + +export function createPiWebSessionTool() { + return { + name: "pi_web_session", + label: "pi-web Session", + description: "Manage and monitor pi-web sessions through the local pi-web API. Use this to inspect, wait on, nudge, open, create, or abort another pi-web session by id.", + promptSnippet: "Orchestrate other pi-web sessions: inspect state/messages, wait until idle, nudge with a prompt, open/create, or abort by session id.", + promptGuidelines: [ + "Use pi_web_session when the user asks you to monitor, orchestrate, critique, or nudge another pi-web session by id.", + "Before using pi_web_session with action=prompt, inspect the target with action=state and action=messages so the nudge is specific and non-disruptive.", + "For pi_web_session action=prompt, prefer mode=followUp unless the user explicitly wants to interrupt the running response.", + "Use normal bash/read/grep tools for verification, git diffs, Docker smoke tests, and file inspection; pi_web_session is only for session orchestration.", + ], + parameters: Type.Object({ + action: StringEnum(ACTIONS), + sessionId: Type.Optional(Type.String({ description: "Target pi-web session id. Required for prompt, wait, abort, and open. Optional for state/messages to use pi-web's current session." })), + cwd: Type.Optional(Type.String({ description: "Workspace directory for list filtering, opening, or creating sessions." })), + message: Type.Optional(Type.String({ description: "Message to send for action=prompt." })), + tail: Type.Optional(Type.Number({ description: "Number of most recent messages to return for action=messages (1-100).", minimum: 1, maximum: 100 })), + limit: Type.Optional(Type.Number({ description: "Maximum sessions to return for action=list (1-200).", minimum: 1, maximum: 200 })), + maxTextChars: Type.Optional(Type.Number({ description: "Maximum text characters per returned message/session field.", minimum: 200, maximum: 20000 })), + timeoutMs: Type.Optional(Type.Number({ description: "Timeout for action=wait in milliseconds.", minimum: 1000, maximum: 3600000 })), + intervalMs: Type.Optional(Type.Number({ description: "Polling interval for action=wait in milliseconds.", minimum: 250, maximum: 60000 })), + mode: Type.Optional(StringEnum(PROMPT_MODES)), + }), + async execute(_toolCallId: string, rawParams: PiWebSessionToolParams, signal?: AbortSignal) { + const params = rawParams || { action: "state" as const }; + const maxTextChars = numberInRange(params.maxTextChars, 4000, 200, 20_000); + + switch (params.action) { + case "list": { + const query = new URLSearchParams(); + const cwd = cleanString(params.cwd); + if (cwd) query.append("cwd", cwd); + const result = await piWebApi("GET", `/api/sessions${query.size ? `?${query}` : ""}`, undefined, { signal }); + return toolResult(normalizeSessionList(result, numberInRange(params.limit, 50, 1, 200), maxTextChars)); + } + + case "state": { + const result = await piWebApi("GET", `/api/state${encodeSessionQuery(params.sessionId)}`, undefined, { signal }); + return toolResult(normalizePiWebSessionState(result)); + } + + case "messages": { + const result = await piWebApi("GET", `/api/messages${encodeSessionQuery(params.sessionId)}`, undefined, { signal }); + return toolResult({ sessionId: cleanString(params.sessionId) || undefined, ...normalizePiWebMessages(result, { tail: params.tail, maxTextChars }) }); + } + + case "prompt": { + const sessionId = requiredSessionId(params); + const message = cleanString(params.message); + if (!message) throw new Error("message is required for action \"prompt\""); + const mode = params.mode === "steer" ? "steer" : "followUp"; + const result = await piWebApi>("POST", "/api/prompt", { sessionId, message, mode }, { signal }); + return toolResult({ ok: true, action: "prompt", mode, ...result }); + } + + case "wait": { + const sessionId = requiredSessionId(params); + const timeoutMs = numberInRange(params.timeoutMs, 10 * 60_000, 1000, 60 * 60_000); + const intervalMs = numberInRange(params.intervalMs, 2000, 250, 60_000); + const startedAt = Date.now(); + let latest: NormalizedState | undefined; + while (true) { + const result = await piWebApi("GET", `/api/state?sessionId=${encodeURIComponent(sessionId)}`, undefined, { signal }); + latest = normalizePiWebSessionState(result); + if (!latest.isRunning) return toolResult({ ok: true, action: "wait", elapsedMs: Date.now() - startedAt, state: latest }); + if (Date.now() - startedAt >= timeoutMs) { + throw new Error(`Timed out after ${timeoutMs}ms waiting for session ${sessionId}`); + } + await sleep(intervalMs, signal); + } + } + + case "abort": { + const sessionId = requiredSessionId(params); + const result = await piWebApi>("POST", "/api/abort", { sessionId }, { signal }); + return toolResult({ ok: true, action: "abort", ...result }); + } + + case "open": { + const sessionId = requiredSessionId(params); + const cwd = cleanString(params.cwd); + const result = await piWebApi("POST", "/api/sessions/open", { sessionId, ...(cwd ? { cwd } : {}) }, { signal }); + return toolResult({ action: "open", state: normalizePiWebSessionState(result) }); + } + + case "new": { + const cwd = cleanString(params.cwd); + const result = await piWebApi("POST", "/api/sessions/new", cwd ? { cwd } : {}, { signal }); + return toolResult({ action: "new", state: normalizePiWebSessionState(result) }); + } + } + }, + }; +} + +export default function piWebSessionExtension(pi: ExtensionAPI) { + if (process.env.PI_WEB_SESSION_TOOL === "0") return; + pi.registerTool(createPiWebSessionTool()); +} diff --git a/README.md b/README.md index 14efbba..9f3559f 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,18 @@ Then open: http://:8787 ``` +## Bundled session orchestration tool + +pi-web includes a bundled `pi_web_session` tool for orchestrator/verifier agents. It can list sessions, read state/messages, wait for a target session to become idle, send a follow-up nudge, open/create sessions, or abort a runaway response. Use normal `bash`, `read`, and `grep` tools for verification, diffs, tests, and Docker smoke checks. + +## Runtime workbenches + +pi-web can connect once to an existing Apple container, Docker/Podman container, SSH host, or advanced command-backed runner. Each browser tab has one explicit VS Code-style workbench runtime: its tabs, session drawer, folders, model selection, git, artifacts, composer, and tools switch together. Existing sessions remain bound to their authoritative runtime; cross-runtime navigation requires an explicit workbench switch and never falls back to local. + +Guided Docker/Podman runtimes must have inspected `--network none`; guided Apple runtimes must use an inspected `hostOnly` network with DNS disabled (host services remain reachable). Every connection requires an explicit model-access choice: typed host broker, where provider credentials never enter the runtime, or credentials/models owned by the runtime. SSH networking remains owned by the remote machine and can use either model mode explicitly. + +See [docs/runtime-binding-design.md](docs/runtime-binding-design.md) for the ownership/routing and model-broker design, and [docs/docker-runtime.md](docs/docker-runtime.md) for durable Docker setup. + ## Environment variables - `HOST` - bind host, default `127.0.0.1` @@ -213,6 +225,15 @@ http://:8787 - `PI_WEB_CHILD_HOST` - supervised child bind host, default `127.0.0.1` - `PI_WEB_CHILD_PORT` - supervised child port, default `PORT + 1` (for example `8788` when `PORT=8787`) - `PI_WEB_RESTART_GRACE_MS` - delay between child stop/start, default `250` +- `PI_WEB_INTERNAL_BASE_URL` / `PI_WEB_BASE_URL` - optional base URL used by the bundled `pi_web_session` tool when it needs to call pi-web from inside a non-default runtime +- `PI_WEB_SESSION_TOOL=0` - disable the bundled `pi_web_session` tool +- `PI_WEB_LOCAL_RUNNER=1` - expose the development-only local stdio runner +- `PI_WEB_ALLOW_CUSTOM_RUNTIMES=1` - permit persistent advanced command JSON runtimes; guided adapters remain available without this opt-in +- `PI_WEB_DOCKER_WORKSPACE_HOST` - expose one built-in Docker workspace runtime +- `PI_WEB_DOCKER_WORKSPACE_CONTAINER` - runtime workspace path, default `/workspace` +- `PI_WEB_DOCKER_SESSION_VOLUME` - persistent Docker volume mounted at `/root/.pi/agent` +- `PI_RUNNER_MAX_ARTIFACT_BYTES` - maximum artifact size returned as base64, default 20 MiB +- `PI_RUNNER_MAX_LIVE_SESSIONS` - runner live-session LRU cap, default 50 ## Development architecture diff --git a/docs/docker-runtime.md b/docs/docker-runtime.md new file mode 100644 index 0000000..3bf83e0 --- /dev/null +++ b/docs/docker-runtime.md @@ -0,0 +1,51 @@ +# Docker workspace runtime + +pi-web can expose one configured Docker runtime in addition to the normal local runtime. The runtime starts `server/runner.ts` in a container, mounts exactly one host workspace at a fixed container path, and stores pi sessions in a persistent Docker volume. Connecting is a one-time setup; selecting it switches the complete browser-tab workbench—tabs, sessions, folders, model selection, git, artifacts, composer, and tools. + +## Network and authentication boundary + +Managed Docker runtimes run with `--network none`. They do not need provider network access: + +1. The runner sends a typed model request over its existing stdio connection to pi-web. +2. The host validates the provider/model against its authoritative `ModelRegistry`. +3. The host resolves authentication and streams the model response back over stdio. + +The broker cannot request arbitrary URLs. Runner-supplied URLs, API keys, headers, environment, signals, and callbacks are not accepted. Model endpoint and authentication are always selected by the host registry. Provider credentials and the pi-web bearer token are never copied or mounted into the container. + +Tools inside the runtime have no DNS or internet path, so clearing proxy variables or opening raw sockets cannot bypass the policy. If the broker or stdio connection is unavailable, model calls fail closed. + +## Secure expectations + +- Set `PI_WEB_TOKEN`; browser/API access remains protected by the normal bearer-token flow. +- Set `PI_WEB_DOCKER_WORKSPACE_HOST` to the only host folder Docker may mount. pi-web does not accept arbitrary host mount paths from API requests. +- The mounted folder appears inside the container as `PI_WEB_DOCKER_WORKSPACE_CONTAINER` (default `/workspace`). +- The pi-web source tree is mounted read-only at `/app` so the container can run the same `server/runner.ts`. The target workspace is a separate mount. +- Set `PI_WEB_DOCKER_READONLY=1` to mount the workspace read-only. +- Runtime-owned session history outlives the disposable `--rm` container in a named volume at `/root/.pi/agent`. Override its name with `PI_WEB_DOCKER_SESSION_VOLUME`. Removing that volume permanently removes the runtime's sessions and runtime configuration. +- `PI_WEB_DOCKER_ENV_ALLOWLIST` has no credential defaults. Avoid passing secrets into a brokered runtime. + +## Run + +```sh +cd /Users/ashwin/projects/pi-web +PI_WEB_TOKEN="$(openssl rand -hex 24)" \ +PI_WEB_DOCKER_WORKSPACE_HOST=/absolute/path/to/repo \ +PI_WEB_DOCKER_WORKSPACE_CONTAINER=/workspace \ +PI_WEB_DOCKER_IMAGE=node:22-bookworm-slim \ +npm run dev +``` + +Open pi-web, enter the token, connect **Docker workspace** once, and select it from the session drawer's workbench switcher. The complete tab is then scoped to the container and paths resolve under `/workspace`. A different-runtime session requires an explicit workbench switch or another browser tab. + +Guided connections to existing Docker/Podman containers are accepted only when inspection confirms `--network none`. An internal bridge is not sufficient because its embedded DNS resolver may still become an egress channel. Apple containers require both a `hostOnly` internal network and `--no-dns`; this is reported honestly as `host-only` because services on the host gateway remain reachable. Container identity is pinned, engine socket mounts are rejected, and isolation is rechecked before each runner spawn. Advanced custom command runtimes are marked as unverified because pi-web cannot prove their network posture. + +Every connection requires an explicit model-access decision with no default: **host credentials** uses the typed model broker, while **runtime credentials/models** never grants host model access. The choice is persisted, displayed on the runtime, and can be changed with an explicit reconnect. + +## Current limitations + +- The Docker runner process and `--rm` container lifecycle are tied to the server process, but session data is durable in the named volume. +- **Remove from list** removes only an offline locator cached by pi-web. **Delete session data** requires the runtime to be connected and deletes the authoritative runtime session file. +- Git status/diff and prompt/state/messages are routed to runner sessions; host-only routes return an explicit unsupported-runtime error instead of falling back to the host. +- The default image runs `npm exec tsx server/runner.ts` with the pi-web source mounted at `/app`; custom images must be able to run that command. +- SSH runtimes use network policy configured on the remote machine. Model access is explicitly chosen at connection time: remote credentials/models or the host broker. +- Persistent custom command runtimes are authenticated host command execution. They are disabled in production unless `PI_WEB_ALLOW_CUSTOM_RUNTIMES=1` is set. diff --git a/docs/runtime-binding-design.md b/docs/runtime-binding-design.md index c8d5525..093bfb4 100644 --- a/docs/runtime-binding-design.md +++ b/docs/runtime-binding-design.md @@ -1,259 +1,188 @@ -# Per-session runtime binding design +# Runtime-authoritative session and workbench design -Issue context: GitHub issue #3 proposes that pi-web should support both normal host sessions and sandboxed sessions at the same time. The runtime choice is made per session, not globally. +Issue context: pi-web must support local and sandbox/remote sessions without silently crossing trust boundaries. Connecting a runtime is a one-time operation; choosing a workbench runtime establishes the complete execution scope for one browser tab. -## Goals +## Product model -- Keep current host behavior unchanged by default. -- Let a user choose a trust boundary when creating/opening a session. -- Support managed sandboxes created by pi-web and external sandboxes created by the user. -- Do not require one worktree per session. Multiple sessions may attach to the same runtime/cwd, with warnings only. -- Make existing API routes runtime-aware without duplicating UI behavior. +- **Runtime manager:** connect, verify, reconnect, and forget runtime configurations. +- **Workbench runtime:** an explicit per-tab choice, similar to a VS Code window's remote authority. Session tabs, drawer, folders, models/auth, git, artifacts, composer, and tools all share it. +- **Session runtime:** an immutable routing attribute of an existing session. A different-runtime session must switch this workbench explicitly or open in another browser tab. +- **cwd:** always interpreted inside the selected workbench runtime. +- **No fallback:** an unavailable runtime produces recovery UI, never a local session with the same id/cwd. -## Non-goals for the first implementation - -- Perfect concurrent edit isolation. -- A full container orchestration product. -- Replacing the current local `createAgentSession` path. -- Rewriting all pi-web session history storage in one change. - -## Runtime model - -```ts -export type RuntimeRef = - | { kind: "host"; cwd: string } - | { kind: "sandbox"; sandboxId: string; cwd: string }; - -export type SandboxRuntime = { - id: string; - name: string; - mode: "managed" | "external"; - endpoint: string; - status: "unknown" | "starting" | "ready" | "stopped" | "error"; - network: "unknown" | "on" | "off"; - description?: string; -}; -``` - -Persist a binding: +Connecting and selecting are separate: ```text -sessionId -> RuntimeRef +Connect SSH devbox once + -> select SSH devbox as this tab's workbench runtime + -> browse ~/workspace on devbox + -> create multiple sessions there ``` -The binding should live in server-owned web metadata, not in the core pi session file format initially. A simple first store can be `.pi/web/runtime-bindings.json` keyed by session id. +The workbench switcher lives in the session drawer footer beside Settings. It remains the entry point for connecting a first runtime, while the empty-session screen only changes folders. -## Server architecture +## Identity and routing -Introduce a small adapter layer around host and sandbox execution. +`sessionId` remains the globally unique identity. Runtime ids are routing attributes, not part of identity: ```ts -interface RuntimeAdapter { - ref: RuntimeRef; - getState(sessionId: string): Promise; - getMessages(sessionId: string): Promise; - prompt(input: PromptRequest): Promise<{ sessionId: string }>; - abort(sessionId: string): Promise; - listGitRepos(sessionId: string): Promise; - gitStatus(request: GitStatusRequest): Promise; - gitDiff(request: GitDiffRequest): Promise; - gitSync(request: GitSyncRequest): Promise; - listDirs(path: string): Promise; - createDir(parent: string, name: string): Promise; - artifact(name: string): Promise; -} +type SessionLocator = { + sessionId: string; + runtimeId: string; + cwd: string; + sessionFile?: string; + updatedAt: string; // host-observed activity/reconciliation time +}; ``` -### Host adapter +Reasons: -The host adapter wraps existing server functions: +- pi session ids are UUIDv7 and already globally unique; +- pins, unread state, and markers can stay keyed by session id without migration; +- the same durable session store exposed through two runtime configs remains one session; +- API calls, deep links, and realtime envelopes carry `runtimeId` explicitly for routing. -- `makeAgentSession` -- `getOrCreateLiveSessionById` -- `createNewLiveSession` -- `liveSessions` -- `sessionCwd` -- local git/fs/artifact helpers +Session URLs use `sessionId` plus `runtimeId` for recovery routing. Current-session API calls also send `x-pi-web-runtime-id`. The runtime id is a stable, user-declared configuration id—not a container instance id. -This should be mostly extraction/refactoring. Behavior should remain byte-for-byte compatible where possible. +## Ownership -### Sandbox adapter +### Runtime is authoritative -The sandbox adapter proxies to a worker running inside a Docker container, VM, or user-managed environment. +A runtime owns: -Expected worker surface: +- pi session files and transcripts; +- cwd, title, message count, model/thinking selection state; +- session tree and runtime artifacts; +- runtime-side git and tool execution. -```text -GET /health -POST /sessions/new -POST /sessions/:id/prompt -POST /sessions/:id/abort -GET /sessions/:id/state -GET /sessions/:id/messages -GET /git/repos?sessionId=... -GET /git/status?sessionId=...&repo=... -GET /git/diff?sessionId=...&repo=...&path=... -POST /git/sync -GET /fs/dirs?path=... -POST /fs/dirs -GET /artifacts/:name -WS /events -``` +Model access is an explicit runtime connection property with no inferred default. A runtime either uses host credentials through the typed broker described below, or uses only credentials/models owned by that runtime. SSH and other-machine networking remains controlled by the remote machine even when host-brokered model access is selected. -The worker should emit the same logical event types pi-web already broadcasts, so the UI can stay mostly unchanged. +The runner exposes capped/paginated `sessions.list`; pi-web reconciles its locator cache from that list. -## Request routing +### Host stores connection configuration and locator cache -Add a resolver: +The host stores: -```ts -async function runtimeForSessionId(sessionId?: string): Promise; -``` +- declarative runtime connection configurations; +- last-known session locator and display metadata; +- host-observed activity ordering; +- normal web UI state keyed by session id. -Rules: +The locator cache supports immediate drawer rendering, deep links, and unavailable-runtime recovery. It is not authoritative session history. A locator's runtime ownership is immutable: reconciliation may update metadata but must never reclassify the session to a runtime that also happens to list the same id. Forgetting a runtime intentionally removes all of its cached locators. -1. If no `sessionId`, use the current active session binding. -2. If the session has no binding, default to `{ kind: "host", cwd: sessionCwd(...) }` and persist it lazily. -3. For new sessions, use the runtime selected in the request body. -4. For opening historical sessions, use existing binding; if missing, assume host. +## Durable runtime storage -Routes to move behind adapters first: +Disposable infrastructure cannot be authoritative for durable data. -- `/api/prompt` -- `/api/abort` -- `/api/state` -- `/api/messages` -- `/api/sessions/new` -- `/api/sessions/open` -- `/api/git/repos` -- `/api/git/status` -- `/api/git/diff` -- `/api/git/sync` -- `/api/artifacts/*` -- `/api/fs/dirs` -- `/api/session/cwd` +The built-in Docker provider uses an `--rm` container but mounts a stable named volume at `/root/.pi/agent`. The container/runner can be recreated while session data survives. The volume name derives from the declarative runtime id and can be overridden with `PI_WEB_DOCKER_SESSION_VOLUME`. -## API additions +User-managed Apple containers, Docker/Podman exec targets, and SSH hosts are responsible for durable runtime storage. The UI should state whether storage is a pi-web volume, runtime-managed, or explicitly disposable. -```text -GET /api/runtimes -POST /api/runtimes/managed -POST /api/runtimes/external -GET /api/sessions/:id/runtime -PATCH /api/sessions/:id/runtime -``` +## Session host architecture -Example new-session body: +Routes resolve a session once to a capability-based host: -```json -{ - "cwd": "/workspace/pi-web", - "runtime": { "kind": "sandbox", "sandboxId": "sensitive-work", "cwd": "/workspace/pi-web" } +```ts +interface SessionHost { + kind: "local" | "runner" | "unavailable"; + sessionId: string; + runtimeId: string; + state(): Promise; + messages(): Promise; + prompt?(message: string, images: ImagePart[], mode: QueueMode): Promise; + abort?(): Promise; + listModels?(): Promise; + setModel?(...): Promise; + gitStatus?(): Promise; + // Optional method means explicit unsupported capability. } ``` -## UX design +- Local host wraps the in-process pi session. +- Runner host wraps the shared stdio runner protocol. +- Unavailable host returns last-known metadata and recovery state. -### New session flow +The runner forwards every pi event through the same host enrichment and browser realtime pipeline used by local sessions. -```text -Start session in: - ○ Host machine - ○ Existing sandbox - sensitive-work · sandbox · network off - private-client · sandbox · network on - disposable · sandbox - ○ Create sandbox... - ○ Connect external sandbox... -``` +### Typed capability contract -### Session drawer metadata +Runtime summaries and session state carry a typed capability map. The current runner advertises message branching but explicitly reports session rename, slash commands, shell commands, full session statistics, the full Git panel (including history/image previews/sync), extension UI, and compaction cancellation as unavailable. The browser disables or explains those controls instead of attempting a local fallback or discovering the gap through a failed request. Missing capability metadata remains backward-compatible for Local and older servers; explicit `false` is authoritative. -```text -Fix reload issue -pi-web · host +## Host model broker -Review sensitive client repo -sensitive-work · sandbox · network off -``` +Runtimes explicitly configured for host model access use a typed, bidirectional model protocol over the existing stdio transport. Guided Docker/Podman containers additionally have no network route. Guided Apple containers are limited to a DNS-disabled `hostOnly` network, which still permits access to host services and is reported separately from `none`. -### Warnings +- On startup the runner requests the host's available model catalog. The catalog contains model metadata only—never URLs, headers, or credentials. +- The runtime keeps authoritative model/thinking selection in its session but registers a broker stream implementation for inference. +- A model request carries only an approved provider/model id, conversation context, and safe stream options. +- The host looks up the authoritative model, resolves host auth, invokes pi-ai, and forwards typed stream events. +- Runner-supplied API keys, headers, environment, URLs, callbacks, and abort signals are discarded. Abort is a separate typed operation tied to one stream id. +- A runner may not issue arbitrary host HTTP requests. Unknown host methods and unavailable models are rejected. +- Closing the runner transport aborts active host model streams so reconnects cannot leave billable requests running. -Show non-blocking warnings when: +The runtime process scrubs credential-shaped environment variables in broker mode and exposes only broker-registered models; built-in models backed by ambient AWS profiles, Google ADC files, or other runtime auth cannot bypass the broker registry. Host auth never enters runtime storage. This supersedes copying `auth.json` into containers. -- Multiple live sessions attach to the same sandbox and cwd. -- Sandbox health is unknown or disconnected. -- The selected cwd is outside the sandbox-mounted workspace. -- A sandbox has network enabled for a session marked sensitive. +For Docker/Podman, tools have no network route and cannot bypass the broker with curl, DNS, raw sockets, or cleared proxy settings. Apple `hostOnly` prevents internet routing and DNS but does not claim host-service isolation. Broker failure is fail-closed. SSH may use the broker only after the user explicitly grants host model access; otherwise it depends entirely on authentication configured remotely. -## Managed sandbox MVP +## Listing and ordering -Docker is the simplest first managed runtime. +`GET /api/sessions?runtimeId=` lists only the requested runtime and includes `runtimeRef` on every row. -Suggested shape: +For remote runtimes, the browser first requests cached locators and renders them immediately, then requests authoritative runner data in the background. `sessions.list` is capped and cursor-paginated. -```text -pi-web server - creates container - mounts selected host repo to /workspace/ - starts pi-web sandbox worker inside container - stores sandbox metadata - proxies session operations to worker -``` +Drawer ordering uses host-observed activity/reconciliation time. Runtime timestamps remain metadata but do not control cross-host ordering, avoiding SSH clock-skew bugs. -Initial policy knobs: +Remembered cwd history is keyed by runtime id so paths such as `/workspace` are never suggested for local sessions. -- mount path -- read/write vs read-only mount -- network on/off -- environment/secrets allowlist -- image name/tag +## Delete versus remove -## External sandbox MVP +These are distinct operations: -For user-created sandboxes, pi-web only stores: +- **Remove from list:** deletes only the host locator/UI metadata. It works while the runtime is offline and does not claim runtime data was deleted. If an online runtime later lists the session again, it correctly reappears. +- **Delete session data:** requires the authoritative runtime to be connected, releases the live session, and deletes its session file. Only then is the locator removed. -- name -- endpoint URL -- optional auth token -- display metadata: network/cwd/description +Unavailable sessions show **Remove from list**, not a misleading successful **Delete** action. **Forget runtime** removes the saved connection and all host-side locators for that runtime, but never claims to delete runtime-owned session data. -Health check verifies `/health` and protocol version. +## Connection and recovery -## Event forwarding +Command-backed providers keep desired session subscriptions and automatically reconnect with exponential backoff. After reconnect they resubscribe to remembered sessions. -Each sandbox adapter maintains a worker WebSocket connection. Incoming sandbox events are normalized and rebroadcast through the existing pi-web WebSocket stream: +On transport/runner death the host: -```text -sandbox worker event -> sandbox adapter -> pi-web broadcast(...) -> browser -``` +- broadcasts runtime connection state; +- clears running/tool/activity enrichment maps for sessions routed there; +- broadcasts terminal unavailable state so rows do not remain “running” forever; +- never falls back to local. + +Deep links and WebSocket hello for an unavailable explicit runtime return a locator/recovery state rather than a local state. Requests without an explicit runtime are local for compatibility and never infer routing from locator metadata. + +## Runtime manager UX -Events must include `sessionId` and runtime metadata so the browser can reconcile active sessions. +Normal users use guided forms for: -## Rollout plan +- Apple container exec; +- Docker exec; +- Podman exec; +- SSH host aliases. -1. Add `RuntimeRef` types and runtime binding store. -2. Persist host bindings for current and newly created sessions. -3. Extract current host behavior into `HostRuntimeAdapter` with no UX changes. -4. Route `/api/prompt`, `/api/state`, `/api/messages`, and `/api/abort` through the adapter resolver. -5. Add `/api/runtimes` and UI display of runtime badges. -6. Add external sandbox adapter and worker protocol. -7. Add runtime selection to new-session/open-session flow. -8. Add Docker managed sandbox creation. -9. Move git/fs/artifact routes fully behind adapters. -10. Add tests for host compatibility and sandbox proxy behavior. +The server generates constrained commands for guided adapters and verifies runner health/protocol before registration. For Apple container, Docker, and Podman it first inspects the network and rejects an unproven policy. Apple must use a `hostOnly` network with `--no-dns`; Docker/Podman must use `--network none`. Internal Docker/Podman bridges are rejected because embedded DNS can remain an egress channel. The inspected container identity is pinned, engine socket mounts are rejected, and isolation is checked again before every runner spawn. Advanced command runtimes cannot be proven by pi-web and are labeled `unverified`. -## Test strategy +The connection UI requires an explicit host-broker versus runtime-owned-model choice for every adapter and custom command. The saved choice is visible and can be changed only through an explicit reconnect. -- Unit test binding store migration/defaulting. -- API tests proving current host behavior is unchanged. -- Mock sandbox worker tests for prompt/state/messages/abort. -- Git/fs/artifact route tests for both host and sandbox adapters. -- E2E tests for new-session runtime selection and session drawer badges. +Raw command JSON remains under an Advanced disclosure and requires `PI_WEB_ALLOW_CUSTOM_RUNTIMES=1` outside development/mock mode because it is persistent authenticated host command execution. -## Open questions +## Validation requirements -- Should artifacts remain globally addressable by filename, or include session/runtime in the URL for sandbox artifacts? -- How should sandbox worker authentication be configured for external sandboxes? -- Should managed sandbox lifecycle follow session lifecycle, or be explicitly user-controlled? -- How much of model/auth configuration should be copied into managed sandboxes vs proxied from host? +- Local behavior remains unchanged with no extra runtime. +- Runner-owned session list and pagination. +- Cache-first runtime-scoped drawer reconciliation. +- Per-tab workbench persistence with runtime-scoped tabs, session drawer, folders, models/auth, git, artifacts, composer, and tools. +- Cross-runtime session navigation requires an explicit workbench switch or another browser tab. +- Explicit runtime routing for state/messages/prompt/model/git/artifacts/open/delete and WebSocket hello. +- Durable Docker session-volume command construction. +- Typed host model broker with host-authoritative endpoint/auth resolution and cancellation. +- Zero-egress managed containers and guided network-inspection rejection tests. +- Proof that runtime-supplied credentials/headers cannot cross the broker contract. +- Offline remove versus online delete semantics. +- Reconnect/resubscribe and stale-running cleanup. +- Full typecheck, unit/API/runtime tests, E2E suite, and production build. diff --git a/index.html b/index.html index 02601d8..2c3cb10 100644 --- a/index.html +++ b/index.html @@ -41,6 +41,10 @@

Scan to connect to pi web

+ @@ -58,6 +62,9 @@

Scan to connect to pi web

+ @@ -80,10 +87,18 @@

Git

@@ -97,6 +112,79 @@

Sessions

+ + +