From dcebb07c788101f9e2eb634b1e6482602b634740 Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Sat, 5 Sep 2026 10:09:26 +0800 Subject: [PATCH 1/2] feat(web): expose read-only terminal session history --- tests/web/pi-adapter.test.ts | 46 ++++++++++++ tests/web/web-host.test.ts | 135 +++++++++++++++++++++++++++++++++++ web/adapter/pi-adapter.ts | 94 ++++++++++++++++++++++++ web/host/web-host.ts | 56 ++++++++++++++- 4 files changed, 330 insertions(+), 1 deletion(-) diff --git a/tests/web/pi-adapter.test.ts b/tests/web/pi-adapter.test.ts index af2f7476..f13b1ac8 100644 --- a/tests/web/pi-adapter.test.ts +++ b/tests/web/pi-adapter.test.ts @@ -121,6 +121,52 @@ test("snapshot pins current and selected sessions while bounding the projection" } }); +test("discovers default Pi sessions as bounded read-only projections", async () => { + const root = await mkdtemp(join(tmpdir(), "openpi-web-terminal-history-")); + const sessionDirectory = join(root, "web-sessions"); + const agentDirectory = join(root, "pi-agent"); + const previousAgentDirectory = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = agentDirectory; + try { + await mkdir(sessionDirectory, { recursive: true }); + const current = SessionManager.inMemory(root); + const terminal = SessionManager.create(root); + persistSession(terminal, "terminal history", 2); + const terminalPath = terminal.getSessionFile(); + assert.ok(terminalPath); + const adapter = new PiWebAdapter( + runtimeFor(root, sessionDirectory, current), + ); + + const listed = await adapter.listReadOnlyTerminalSessions({ limit: 1 }); + assert.equal(listed.total, 1); + assert.deepEqual(listed.sessions[0], { + id: terminal.getSessionId(), + path: terminalPath, + cwd: root, + modified: listed.sessions[0]?.modified, + created: listed.sessions[0]?.created, + messageCount: 2, + firstMessage: "terminal history", + source: "pi-default", + origin: "terminal", + readOnly: true, + }); + const inspected = await adapter.getReadOnlyTerminalSession(terminalPath); + assert.equal(inspected.readOnly, true); + assert.equal(inspected.source, "pi-default"); + assert.equal(inspected.preview.messages.length, 2); + assert.equal((await SessionManager.listAll(sessionDirectory)).length, 0); + } finally { + if (previousAgentDirectory === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = previousAgentDirectory; + } + await rm(root, { recursive: true, force: true }); + } +}); + test("an unbound Web runtime never projects its bootstrap cwd as a workspace or Session", async () => { const root = await mkdtemp(join(tmpdir(), "openpi-web-unbound-")); const bootstrap = join(root, ".bootstrap-workspace"); diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index d80df4fa..56ae6698 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -514,6 +514,141 @@ test("serves workspaces through a runtime isolated from terminal sessions", asyn } }); +test("serves terminal Sessions through a read-only bounded endpoint", async () => { + const root = await mkdtemp(join(tmpdir(), "openpi-web-terminal-host-")); + const previousAgentDirectory = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = join(root, "pi-agent"); + const sessionManager = SessionManager.inMemory(root); + const terminal = SessionManager.create(root); + terminal.appendMessage({ + role: "user", + content: "terminal endpoint", + timestamp: 1, + }); + terminal.appendMessage({ + role: "assistant", + content: [], + api: "openai-responses", + provider: "fixture", + model: "fixture", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, + }, + stopReason: "stop", + timestamp: 1, + }); + const runtime: WebRuntimeController = { + cwd: root, + workspaceSelected: true, + sessionDirectory: join(root, "web-sessions"), + sessionManager, + isIdle: () => true, + sendPrompt: async () => {}, + newSession: async () => ({ cancelled: false }), + switchSession: async () => ({ cancelled: false }), + listModels: () => [], + setModel: async () => { + throw new Error("not available"); + }, + subscribe: () => () => {}, + dispose: async () => {}, + }; + const host = new WebHost({ runtime }); + try { + await host.start(); + const launched = new URL(host.url); + const token = new URLSearchParams(launched.hash.slice(1)).get("token"); + assert.ok(token); + const headers = { Authorization: `Bearer ${token}` }; + const listed = await fetch( + `${launched.origin}/api/terminal-sessions?limit=1`, + { + headers, + }, + ); + assert.equal(listed.status, 200); + const page = (await listed.json()) as { + sessions: Array<{ + path: string; + source: string; + origin: string; + readOnly: boolean; + }>; + total: number; + }; + assert.equal(page.total, 1); + assert.equal(page.sessions[0]?.path, terminal.getSessionFile()); + assert.equal(page.sessions[0]?.source, "pi-default"); + assert.equal(page.sessions[0]?.origin, "terminal"); + assert.equal(page.sessions[0]?.readOnly, true); + assert.equal( + ( + await fetch( + `${launched.origin}/api/terminal-sessions?query=${"x".repeat(201)}`, + { headers }, + ) + ).status, + 400, + ); + assert.equal( + ( + await fetch(`${launched.origin}/api/terminal-sessions?cursor=nope`, { + headers, + }) + ).status, + 400, + ); + assert.equal( + ( + await fetch(`${launched.origin}/api/terminal-sessions?limit=101`, { + headers, + }) + ).status, + 400, + ); + const missing = await fetch( + `${launched.origin}/api/terminal-sessions?path=${encodeURIComponent(join(root, "missing.jsonl"))}`, + { headers }, + ); + assert.equal(missing.status, 404); + assert.deepEqual(await missing.json(), { + code: "SESSION_NOT_FOUND", + error: "Terminal Session is not available", + }); + const inspected = await fetch( + `${launched.origin}/api/terminal-sessions?path=${encodeURIComponent(terminal.getSessionFile()!)}`, + { headers }, + ); + assert.equal(inspected.status, 200); + const details = (await inspected.json()) as { + readOnly: boolean; + preview: { messages: unknown[]; retainedBytes: number }; + }; + assert.equal(details.readOnly, true); + assert.equal(details.preview.messages.length, 2); + assert.ok(details.preview.retainedBytes > 0); + } finally { + await host.stop(); + if (previousAgentDirectory === undefined) { + delete process.env.PI_CODING_AGENT_DIR; + } else { + process.env.PI_CODING_AGENT_DIR = previousAgentDirectory; + } + await rm(root, { recursive: true, force: true }); + } +}); + test("an unbound Host exposes no bootstrap Session and rejects prompt bypasses", async () => { const root = await mkdtemp(join(tmpdir(), "openpi-web-unbound-host-")); const bootstrap = join(root, ".bootstrap-workspace"); diff --git a/web/adapter/pi-adapter.ts b/web/adapter/pi-adapter.ts index fb4c0bc9..fb98673f 100644 --- a/web/adapter/pi-adapter.ts +++ b/web/adapter/pi-adapter.ts @@ -1,6 +1,7 @@ import { readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; import { basename, join, resolve } from "node:path"; import { SessionManager } from "@earendil-works/pi-coding-agent"; +import { loadSessionPreviewData } from "../../extensions/sessions/preview-loader.ts"; import { webCapabilitySnapshot } from "../../extensions/shared/web-observer-registry.ts"; import { boundedText, @@ -19,6 +20,16 @@ import { } from "../protocol/types.ts"; import type { WebRuntimeController } from "../runtime/types.ts"; +export class WebReadOnlySessionError extends Error { + readonly code = "SESSION_NOT_FOUND" as const; + readonly statusCode = 404 as const; + + constructor(message: string) { + super(message); + this.name = "WebReadOnlySessionError"; + } +} + type WorkspaceStateSnapshot = { importedWorkspaces: Set; hiddenWorkspaces: Set; @@ -427,6 +438,89 @@ export class PiWebAdapter { return (await this.listSessionProjection(pinnedPath)).sessions; } + async listReadOnlyTerminalSessions( + options: { query?: string; cursor?: number; limit?: number } = {}, + ) { + const workspace = await this.requireWorkspace(this.runtime.cwd); + const query = options.query?.trim().toLocaleLowerCase() ?? ""; + const cursor = options.cursor ?? 0; + const limit = options.limit ?? 50; + const sessions = (await SessionManager.listAll()) + .filter((session) => resolve(session.cwd) === workspace) + .filter((session) => { + if (!query) return true; + return [session.name, session.cwd, session.firstMessage].some((value) => + value?.toLocaleLowerCase().includes(query), + ); + }); + const page = sessions.slice(cursor, cursor + limit); + return { + sessions: page.map((session) => ({ + id: session.id, + path: session.path, + cwd: resolve(session.cwd), + ...(session.name + ? { name: boundedText(session.name, WEB_MAX_SESSION_PREVIEW) } + : {}), + modified: session.modified.toISOString(), + created: session.created.toISOString(), + messageCount: session.messageCount, + firstMessage: boundedText( + session.firstMessage, + WEB_MAX_SESSION_PREVIEW, + ), + source: "pi-default" as const, + origin: "terminal" as const, + readOnly: true as const, + })), + cursor, + nextCursor: + cursor + page.length < sessions.length + ? cursor + page.length + : undefined, + total: sessions.length, + }; + } + + async getReadOnlyTerminalSession(path: string) { + const workspace = await this.requireWorkspace(this.runtime.cwd); + const canonical = resolve(path); + const session = (await SessionManager.listAll()).find( + (candidate) => + resolve(candidate.path) === canonical && + resolve(candidate.cwd) === workspace, + ); + if (!session) { + throw new WebReadOnlySessionError("Terminal Session is not available"); + } + const preview = await loadSessionPreviewData(session.path); + return { + id: session.id, + path: session.path, + cwd: resolve(session.cwd), + ...(session.name + ? { name: boundedText(session.name, WEB_MAX_SESSION_PREVIEW) } + : {}), + modified: session.modified.toISOString(), + created: session.created.toISOString(), + messageCount: session.messageCount, + firstMessage: boundedText( + session.firstMessage, + WEB_MAX_SESSION_PREVIEW, + ), + source: "pi-default" as const, + origin: "terminal" as const, + readOnly: true as const, + preview: { + messages: preview.messages, + totalMessages: preview.totalMessages, + bytesRead: preview.bytesRead, + retainedBytes: preview.retainedBytes, + truncatedBytes: preview.truncatedBytes, + }, + }; + } + async getSnapshot(selectedPath?: string) { await this.ensureWorkspaceStateLoaded(); const sessionProjection = await this.listSessionProjection(selectedPath); diff --git a/web/host/web-host.ts b/web/host/web-host.ts index 6203e647..121f005d 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -10,7 +10,10 @@ import { import { URL } from "node:url"; import { promisify } from "node:util"; import { subscribeWebCapabilities } from "../../extensions/shared/web-observer-registry.ts"; -import { PiWebAdapter } from "../adapter/pi-adapter.ts"; +import { + PiWebAdapter, + WebReadOnlySessionError, +} from "../adapter/pi-adapter.ts"; import { jsonByteLength, WEB_MAX_EVENT_BYTES, @@ -555,6 +558,57 @@ export class WebHost { }, }); } + if (url.pathname === "/api/terminal-sessions") { + const query = url.searchParams.get("query") ?? ""; + if (query.length > 200) { + return this.json(response, 400, { + code: "QUERY_TOO_LONG", + error: "query must be at most 200 characters", + }); + } + const cursor = this.parseCursor(url.searchParams.get("cursor")); + if (cursor.invalid) { + return this.json(response, 400, { + code: "INVALID_CURSOR", + error: "cursor must be a non-negative integer", + }); + } + const rawLimit = url.searchParams.get("limit"); + const limit = rawLimit === null ? 50 : Number(rawLimit); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) { + return this.json(response, 400, { + code: "INVALID_LIMIT", + error: "limit must be an integer from 1 to 100", + }); + } + try { + const path = url.searchParams.get("path"); + if (path) { + return this.json( + response, + 200, + await this.adapter.getReadOnlyTerminalSession(path), + ); + } + return this.json( + response, + 200, + await this.adapter.listReadOnlyTerminalSessions({ + query, + cursor: cursor.value, + limit, + }), + ); + } catch (error) { + if (error instanceof WebReadOnlySessionError) { + return this.json(response, error.statusCode, { + code: error.code, + error: error.message, + }); + } + throw error; + } + } if (url.pathname === "/api/models") return this.json(response, 200, { models: this.runtime.listModels() }); if (url.pathname === "/api/snapshot") { From 75b1f4170a2c72d5c5f1fd3d87bbc8eee3b19d81 Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Sun, 6 Sep 2026 15:00:29 +0800 Subject: [PATCH 2/2] test(web): align terminal history fixture with runtime contract --- tests/web/web-host.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index ba6c6f5e..75dba80c 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -648,7 +648,9 @@ test("serves terminal Sessions through a read-only bounded endpoint", async () = sessionDirectory: join(root, "web-sessions"), sessionManager, isIdle: () => true, - sendPrompt: async () => {}, + getActiveTurn: () => undefined, + cancelTurn: async (options) => ({ ...options, state: "stale-turn" }), + sendPrompt: async () => ({ pendingFollowUps: 0 }), newSession: async () => ({ cancelled: false }), switchSession: async () => ({ cancelled: false }), listModels: () => [],