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/5] 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/5] 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: () => [], From a8669c069cdfcadd33975f7fe71dcf791d90872e Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:18:57 +0800 Subject: [PATCH 3/5] fix(web): bound terminal Session discovery --- tests/web/pi-adapter.test.ts | 45 +++++++++++++++++++++--------------- web/adapter/pi-adapter.ts | 20 ++++++++++++---- 2 files changed, 42 insertions(+), 23 deletions(-) diff --git a/tests/web/pi-adapter.test.ts b/tests/web/pi-adapter.test.ts index fc708def..78ffa83c 100644 --- a/tests/web/pi-adapter.test.ts +++ b/tests/web/pi-adapter.test.ts @@ -163,25 +163,32 @@ test("discovers default Pi sessions as bounded read-only projections", async () 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); + const listAll = SessionManager.listAll; + SessionManager.listAll = async () => { + throw new Error("unrelated Session discovery must not be used"); + }; + try { + 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); + } finally { + SessionManager.listAll = listAll; + } assert.equal((await SessionManager.listAll(sessionDirectory)).length, 0); } finally { if (previousAgentDirectory === undefined) { diff --git a/web/adapter/pi-adapter.ts b/web/adapter/pi-adapter.ts index bec6313d..a62cd64a 100644 --- a/web/adapter/pi-adapter.ts +++ b/web/adapter/pi-adapter.ts @@ -234,6 +234,18 @@ export class PiWebAdapter { return canonical; } + private async requireSelectedWorkspace() { + await this.ensureWorkspaceStateLoaded(); + const workspace = resolve(this.runtime.cwd); + if ( + this.runtime.workspaceSelected !== true || + this.hiddenWorkspaces.has(workspace) + ) { + throw new Error("Workspace is not available"); + } + return workspace; + } + async requireSession(path: string) { await this.ensureWorkspaceStateLoaded(); const sessions = await this.listSessions(path); @@ -458,11 +470,11 @@ export class PiWebAdapter { async listReadOnlyTerminalSessions( options: { query?: string; cursor?: number; limit?: number } = {}, ) { - const workspace = await this.requireWorkspace(this.runtime.cwd); + const workspace = await this.requireSelectedWorkspace(); const query = options.query?.trim().toLocaleLowerCase() ?? ""; const cursor = options.cursor ?? 0; const limit = options.limit ?? 50; - const sessions = (await SessionManager.listAll()) + const sessions = (await SessionManager.list(workspace)) .filter((session) => resolve(session.cwd) === workspace) .filter((session) => { if (!query) return true; @@ -500,9 +512,9 @@ export class PiWebAdapter { } async getReadOnlyTerminalSession(path: string) { - const workspace = await this.requireWorkspace(this.runtime.cwd); + const workspace = await this.requireSelectedWorkspace(); const canonical = resolve(path); - const session = (await SessionManager.listAll()).find( + const session = (await SessionManager.list(workspace)).find( (candidate) => resolve(candidate.path) === canonical && resolve(candidate.cwd) === workspace, From 9fd5be9155ff72b0b5476f38b25f9eaa3facf56d Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:04:07 +0800 Subject: [PATCH 4/5] fix(web): bound terminal session metadata discovery --- tests/web/pi-adapter.test.ts | 21 ++++ web/adapter/pi-adapter.ts | 211 +++++++++++++++++++++++++++++++++-- 2 files changed, 222 insertions(+), 10 deletions(-) diff --git a/tests/web/pi-adapter.test.ts b/tests/web/pi-adapter.test.ts index 78ffa83c..ea6a097b 100644 --- a/tests/web/pi-adapter.test.ts +++ b/tests/web/pi-adapter.test.ts @@ -160,6 +160,12 @@ test("discovers default Pi sessions as bounded read-only projections", async () persistSession(terminal, "terminal history", 2); const terminalPath = terminal.getSessionFile(); assert.ok(terminalPath); + const unrelatedWorkspace = join(root, "unrelated-workspace"); + await mkdir(unrelatedWorkspace); + const unrelated = SessionManager.create(unrelatedWorkspace); + persistSession(unrelated, "unrelated-only-token", 3); + const unrelatedPath = unrelated.getSessionFile(); + assert.ok(unrelatedPath); const adapter = new PiWebAdapter( runtimeFor(root, sessionDirectory, current), ); @@ -170,6 +176,7 @@ test("discovers default Pi sessions as bounded read-only projections", async () try { const listed = await adapter.listReadOnlyTerminalSessions({ limit: 1 }); assert.equal(listed.total, 1); + assert.equal("allMessagesText" in listed.sessions[0]!, false); assert.deepEqual(listed.sessions[0], { id: terminal.getSessionId(), path: terminalPath, @@ -186,6 +193,20 @@ test("discovers default Pi sessions as bounded read-only projections", async () assert.equal(inspected.readOnly, true); assert.equal(inspected.source, "pi-default"); assert.equal(inspected.preview.messages.length, 2); + assert.equal( + ( + await adapter.listReadOnlyTerminalSessions({ + query: "unrelated-only-token", + }) + ).total, + 0, + ); + await assert.rejects( + adapter.getReadOnlyTerminalSession(unrelatedPath), + (error: unknown) => + error instanceof Error && + (error as { code?: string }).code === "SESSION_NOT_FOUND", + ); } finally { SessionManager.listAll = listAll; } diff --git a/web/adapter/pi-adapter.ts b/web/adapter/pi-adapter.ts index a62cd64a..08f80644 100644 --- a/web/adapter/pi-adapter.ts +++ b/web/adapter/pi-adapter.ts @@ -1,6 +1,19 @@ -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 { + lstat, + open, + readFile, + readdir, + realpath, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { basename, isAbsolute, join, relative, resolve } from "node:path"; +import { + getAgentDir, + 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 { @@ -38,6 +51,175 @@ type WorkspaceStateSnapshot = { restoreInitialWorkspace: boolean; }; +const TERMINAL_DISCOVERY_MAX_BYTES = 256 * 1024; +const TERMINAL_DISCOVERY_MAX_FILES = WEB_MAX_SESSIONS; + +type ReadOnlyTerminalSessionInfo = { + id: string; + path: string; + cwd: string; + name?: string; + modified: Date; + created: Date; + messageCount: number; + firstMessage: string; +}; + +function defaultTerminalSessionDirectory(cwd: string) { + const resolvedCwd = resolve(cwd); + const encoded = `--${resolvedCwd.replace(/^[/\\]/u, "").replace(/[/\\:]/gu, "-")}--`; + return join(getAgentDir(), "sessions", encoded); +} + +function containedPath(parent: string, candidate: string) { + const child = relative(parent, candidate); + return ( + child.length > 0 && + child !== ".." && + !child.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && + !isAbsolute(child) + ); +} + +function terminalTextContent(message: Record) { + const content = message.content; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .filter( + (part): part is { type: "text"; text: string } => + !!part && + typeof part === "object" && + (part as Record).type === "text" && + typeof (part as Record).text === "string", + ) + .map((part) => part.text) + .join(" "); +} + +async function readTerminalSessionInfo( + filePath: string, + modified: Date, +): Promise { + let fileStat; + try { + fileStat = await lstat(filePath); + if (!fileStat.isFile() || fileStat.isSymbolicLink()) return undefined; + } catch { + return undefined; + } + + let handle; + try { + handle = await open(filePath, "r"); + } catch { + return undefined; + } + try { + const length = Math.min(fileStat.size, TERMINAL_DISCOVERY_MAX_BYTES); + const bytes = Buffer.allocUnsafe(length); + const { bytesRead } = await handle.read(bytes, 0, length, 0); + const text = bytes.toString("utf8", 0, bytesRead); + const lines = text.split(/\r?\n/u); + if (bytesRead < fileStat.size) lines.pop(); + + let header: Record | undefined; + let name: string | undefined; + let firstMessage = ""; + let messageCount = 0; + for (const line of lines) { + if (!line.trim()) continue; + let value: unknown; + try { + value = JSON.parse(line); + } catch { + continue; + } + if (!value || typeof value !== "object" || Array.isArray(value)) { + continue; + } + const entry = value as Record; + if (!header) { + if (entry.type !== "session" || typeof entry.id !== "string") { + return undefined; + } + header = entry; + continue; + } + if (entry.type === "session_info") { + const entryName = entry.name; + name = typeof entryName === "string" ? entryName.trim() || undefined : name; + continue; + } + if (entry.type !== "message") continue; + messageCount++; + const message = entry.message; + if ( + !firstMessage && + message && + typeof message === "object" && + !Array.isArray(message) && + (message as Record).role === "user" + ) { + firstMessage = terminalTextContent(message as Record); + } + } + if (!header || typeof header.cwd !== "string") return undefined; + const created = + typeof header.timestamp === "string" && !Number.isNaN(Date.parse(header.timestamp)) + ? new Date(header.timestamp) + : modified; + return { + id: header.id as string, + path: filePath, + cwd: resolve(header.cwd), + ...(name ? { name } : {}), + modified, + created, + messageCount, + firstMessage: firstMessage || "(no messages)", + }; + } finally { + await handle.close(); + } +} + +async function listTerminalSessionInfo(workspace: string) { + const directory = defaultTerminalSessionDirectory(workspace); + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch { + return []; + } + const candidates = await Promise.all( + entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) + .map(async (entry) => { + const path = join(directory, entry.name); + try { + const fileStat = await stat(path); + return { path, modified: fileStat.mtime }; + } catch { + return undefined; + } + }), + ); + const infos = await Promise.all( + candidates + .filter((candidate): candidate is { path: string; modified: Date } => !!candidate) + .sort((left, right) => right.modified.getTime() - left.modified.getTime()) + .slice(0, TERMINAL_DISCOVERY_MAX_FILES) + .map((candidate) => readTerminalSessionInfo(candidate.path, candidate.modified)), + ); + return infos + .filter( + (session): session is ReadOnlyTerminalSessionInfo => + !!session && session.cwd === workspace, + ) + .sort((left, right) => right.modified.getTime() - left.modified.getTime()); +} + export class PiWebAdapter { private readonly runtime: WebRuntimeController; private readonly importedWorkspaces = new Set(); @@ -474,8 +656,7 @@ export class PiWebAdapter { const query = options.query?.trim().toLocaleLowerCase() ?? ""; const cursor = options.cursor ?? 0; const limit = options.limit ?? 50; - const sessions = (await SessionManager.list(workspace)) - .filter((session) => resolve(session.cwd) === workspace) + const sessions = (await listTerminalSessionInfo(workspace)) .filter((session) => { if (!query) return true; return [session.name, session.cwd, session.firstMessage].some((value) => @@ -514,14 +695,24 @@ export class PiWebAdapter { async getReadOnlyTerminalSession(path: string) { const workspace = await this.requireSelectedWorkspace(); const canonical = resolve(path); - const session = (await SessionManager.list(workspace)).find( - (candidate) => - resolve(candidate.path) === canonical && - resolve(candidate.cwd) === workspace, - ); + const directory = defaultTerminalSessionDirectory(workspace); + let session: ReadOnlyTerminalSessionInfo | undefined; + if (containedPath(directory, canonical)) { + try { + session = await readTerminalSessionInfo( + canonical, + (await stat(canonical)).mtime, + ); + } catch { + session = undefined; + } + } if (!session) { throw new WebReadOnlySessionError("Terminal Session is not available"); } + if (session.cwd !== workspace) { + throw new WebReadOnlySessionError("Terminal Session is not available"); + } const preview = await loadSessionPreviewData(session.path); return { id: session.id, From 11348eef966dfd71b850b689cff95fc584f26890 Mon Sep 17 00:00:00 2001 From: testikun <320479488+testikun@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:04:46 +0800 Subject: [PATCH 5/5] fix(web): cancel terminal history reads with their request --- tests/web/pi-adapter.test.ts | 133 ++++++++++++++++++++++++++++++++++- tests/web/web-host.test.ts | 20 ++++++ web/adapter/pi-adapter.ts | 24 +++++-- web/host/web-host.ts | 11 ++- 4 files changed, 179 insertions(+), 9 deletions(-) diff --git a/tests/web/pi-adapter.test.ts b/tests/web/pi-adapter.test.ts index ea6a097b..5bec3304 100644 --- a/tests/web/pi-adapter.test.ts +++ b/tests/web/pi-adapter.test.ts @@ -1,4 +1,6 @@ import assert from "node:assert/strict"; +import fsPromises from "node:fs/promises"; +import { syncBuiltinESMExports } from "node:module"; import { mkdir, mkdtemp, @@ -147,7 +149,7 @@ test("snapshot pins current and selected sessions while bounding the projection" } }); -test("discovers default Pi sessions as bounded read-only projections", async () => { +test("discovers default Pi sessions as bounded read-only projections", async (t) => { const root = await mkdtemp(join(tmpdir(), "openpi-web-terminal-history-")); const sessionDirectory = join(root, "web-sessions"); const agentDirectory = join(root, "pi-agent"); @@ -163,9 +165,31 @@ test("discovers default Pi sessions as bounded read-only projections", async () const unrelatedWorkspace = join(root, "unrelated-workspace"); await mkdir(unrelatedWorkspace); const unrelated = SessionManager.create(unrelatedWorkspace); - persistSession(unrelated, "unrelated-only-token", 3); + persistSession(unrelated, "unrelated first message", 3); + unrelated.appendMessage({ + role: "user", + content: "unrelated-only-token", + timestamp: 4, + }); const unrelatedPath = unrelated.getSessionFile(); assert.ok(unrelatedPath); + const fileBefore = await readFile(terminalPath); + const originalOpen = fsPromises.open; + const openedPaths: string[] = []; + t.mock.method( + fsPromises, + "open", + (...args: Parameters) => { + openedPaths.push(String(args[0])); + assert.notEqual(String(args[0]), unrelatedPath); + return originalOpen(...args); + }, + ); + syncBuiltinESMExports(); + t.after(() => { + t.mock.restoreAll(); + syncBuiltinESMExports(); + }); const adapter = new PiWebAdapter( runtimeFor(root, sessionDirectory, current), ); @@ -193,6 +217,8 @@ test("discovers default Pi sessions as bounded read-only projections", async () assert.equal(inspected.readOnly, true); assert.equal(inspected.source, "pi-default"); assert.equal(inspected.preview.messages.length, 2); + assert.equal("allMessagesText" in inspected, false); + assert.ok(openedPaths.includes(terminalPath)); assert.equal( ( await adapter.listReadOnlyTerminalSessions({ @@ -207,10 +233,113 @@ test("discovers default Pi sessions as bounded read-only projections", async () error instanceof Error && (error as { code?: string }).code === "SESSION_NOT_FOUND", ); + const cancelled = AbortSignal.abort(); + const opensBefore = openedPaths.length; + await assert.rejects( + adapter.getReadOnlyTerminalSession(terminalPath, { signal: cancelled }), + { name: "AbortError" }, + ); + await assert.rejects( + adapter.listReadOnlyTerminalSessions({ signal: cancelled }), + { name: "AbortError" }, + ); + assert.equal(openedPaths.length, opensBefore); + + const controller = new AbortController(); + let targetOpens = 0; + t.mock.method( + fsPromises, + "open", + async (...args: Parameters) => { + const handle = await originalOpen(...args); + if (String(args[0]) === terminalPath && ++targetOpens === 2) { + const read = handle.read.bind(handle); + t.mock.method( + handle, + "read", + async (...readArgs: Parameters) => { + const result = await read(...readArgs); + controller.abort(); + return result; + }, + ); + } + return handle; + }, + ); + syncBuiltinESMExports(); + await assert.rejects( + adapter.getReadOnlyTerminalSession(terminalPath, { + signal: controller.signal, + }), + { name: "AbortError" }, + ); + assert.equal( + targetOpens, + 2, + "cancellation occurs during the preview, after metadata admission", + ); } finally { SessionManager.listAll = listAll; } assert.equal((await SessionManager.listAll(sessionDirectory)).length, 0); + assert.deepEqual(await readFile(terminalPath), fileBefore); + assert.equal( + (await adapter.listSessions()).some( + (session) => session.path === terminalPath, + ), + false, + ); + assert.equal( + (await adapter.getSnapshot()).currentSessionId, + current.getSessionId(), + ); + const hidden = new PiWebAdapter( + runtimeFor(root, sessionDirectory, current), + ); + await hidden.removeWorkspace(root); + const unboundRuntime = { + ...runtimeFor(root, sessionDirectory, current), + workspaceSelected: false, + }; + const unbound = new PiWebAdapter(unboundRuntime); + const originalReaddir = fsPromises.readdir; + t.mock.method( + fsPromises, + "readdir", + (...args: Parameters) => { + assert.ok( + !String(args[0]).startsWith(agentDirectory), + "unavailable workspace must not walk the default store", + ); + return originalReaddir(...args); + }, + ); + syncBuiltinESMExports(); + await assert.rejects(hidden.listReadOnlyTerminalSessions()); + await assert.rejects(unbound.listReadOnlyTerminalSessions()); + + const firstKept = terminal.appendMessage({ + role: "user", + content: "kept after compaction", + timestamp: 5, + }); + terminal.appendCompaction("summary before kept window", firstKept, 100); + const compacted = await adapter.getReadOnlyTerminalSession(terminalPath); + assert.ok( + JSON.stringify(compacted.preview.messages).includes( + "kept after compaction", + ), + ); + assert.ok( + !JSON.stringify(compacted.preview.messages).includes("terminal history"), + ); + assert.ok(compacted.preview.messages.length <= 80); + assert.ok(compacted.preview.retainedBytes <= 1024 * 1024); + await assert.rejects( + readFile(join(sessionDirectory, "archived-sessions.json")), + { code: "ENOENT" }, + ); } finally { if (previousAgentDirectory === undefined) { delete process.env.PI_CODING_AGENT_DIR; diff --git a/tests/web/web-host.test.ts b/tests/web/web-host.test.ts index e822c513..3a8dc3e1 100644 --- a/tests/web/web-host.test.ts +++ b/tests/web/web-host.test.ts @@ -912,6 +912,7 @@ test("serves terminal Sessions through a read-only bounded endpoint", async () = assert.equal(page.sessions[0]?.source, "pi-default"); assert.equal(page.sessions[0]?.origin, "terminal"); assert.equal(page.sessions[0]?.readOnly, true); + assert.equal(JSON.stringify(page).includes("allMessagesText"), false); assert.equal( ( await fetch( @@ -958,6 +959,25 @@ test("serves terminal Sessions through a read-only bounded endpoint", async () = assert.equal(details.readOnly, true); assert.equal(details.preview.messages.length, 2); assert.ok(details.preview.retainedBytes > 0); + assert.equal(JSON.stringify(details).includes("allMessagesText"), false); + for (const method of ["POST", "PATCH", "DELETE"]) { + const rejected = await fetch(`${launched.origin}/api/terminal-sessions`, { + method, + headers, + }); + assert.equal(rejected.status, 405); + } + const capabilities = await fetch(`${launched.origin}/api/capabilities`, { + headers, + }); + assert.equal( + (await capabilities.json()).sessionId, + sessionManager.getSessionId(), + ); + const webSessions = await fetch(`${launched.origin}/api/sessions`, { + headers, + }); + assert.ok(!JSON.stringify(await webSessions.json()).includes("pi-default")); } finally { await host.stop(); if (previousAgentDirectory === undefined) { diff --git a/web/adapter/pi-adapter.ts b/web/adapter/pi-adapter.ts index 08f80644..5c8d695e 100644 --- a/web/adapter/pi-adapter.ts +++ b/web/adapter/pi-adapter.ts @@ -100,7 +100,9 @@ function terminalTextContent(message: Record) { async function readTerminalSessionInfo( filePath: string, modified: Date, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); let fileStat; try { fileStat = await lstat(filePath); @@ -116,9 +118,11 @@ async function readTerminalSessionInfo( return undefined; } try { + signal?.throwIfAborted(); const length = Math.min(fileStat.size, TERMINAL_DISCOVERY_MAX_BYTES); const bytes = Buffer.allocUnsafe(length); const { bytesRead } = await handle.read(bytes, 0, length, 0); + signal?.throwIfAborted(); const text = bytes.toString("utf8", 0, bytesRead); const lines = text.split(/\r?\n/u); if (bytesRead < fileStat.size) lines.pop(); @@ -184,7 +188,8 @@ async function readTerminalSessionInfo( } } -async function listTerminalSessionInfo(workspace: string) { +async function listTerminalSessionInfo(workspace: string, signal?: AbortSignal) { + signal?.throwIfAborted(); const directory = defaultTerminalSessionDirectory(workspace); let entries; try { @@ -196,6 +201,7 @@ async function listTerminalSessionInfo(workspace: string) { entries .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) .map(async (entry) => { + signal?.throwIfAborted(); const path = join(directory, entry.name); try { const fileStat = await stat(path); @@ -210,7 +216,7 @@ async function listTerminalSessionInfo(workspace: string) { .filter((candidate): candidate is { path: string; modified: Date } => !!candidate) .sort((left, right) => right.modified.getTime() - left.modified.getTime()) .slice(0, TERMINAL_DISCOVERY_MAX_FILES) - .map((candidate) => readTerminalSessionInfo(candidate.path, candidate.modified)), + .map((candidate) => readTerminalSessionInfo(candidate.path, candidate.modified, signal)), ); return infos .filter( @@ -650,19 +656,21 @@ export class PiWebAdapter { } async listReadOnlyTerminalSessions( - options: { query?: string; cursor?: number; limit?: number } = {}, + options: { query?: string; cursor?: number; limit?: number; signal?: AbortSignal } = {}, ) { + options.signal?.throwIfAborted(); const workspace = await this.requireSelectedWorkspace(); const query = options.query?.trim().toLocaleLowerCase() ?? ""; const cursor = options.cursor ?? 0; const limit = options.limit ?? 50; - const sessions = (await listTerminalSessionInfo(workspace)) + const sessions = (await listTerminalSessionInfo(workspace, options.signal)) .filter((session) => { if (!query) return true; return [session.name, session.cwd, session.firstMessage].some((value) => value?.toLocaleLowerCase().includes(query), ); }); + options.signal?.throwIfAborted(); const page = sessions.slice(cursor, cursor + limit); return { sessions: page.map((session) => ({ @@ -692,7 +700,8 @@ export class PiWebAdapter { }; } - async getReadOnlyTerminalSession(path: string) { + async getReadOnlyTerminalSession(path: string, options: { signal?: AbortSignal } = {}) { + options.signal?.throwIfAborted(); const workspace = await this.requireSelectedWorkspace(); const canonical = resolve(path); const directory = defaultTerminalSessionDirectory(workspace); @@ -702,8 +711,10 @@ export class PiWebAdapter { session = await readTerminalSessionInfo( canonical, (await stat(canonical)).mtime, + options.signal, ); } catch { + options.signal?.throwIfAborted(); session = undefined; } } @@ -713,7 +724,8 @@ export class PiWebAdapter { if (session.cwd !== workspace) { throw new WebReadOnlySessionError("Terminal Session is not available"); } - const preview = await loadSessionPreviewData(session.path); + const preview = await loadSessionPreviewData(session.path, options); + options.signal?.throwIfAborted(); return { id: session.id, path: session.path, diff --git a/web/host/web-host.ts b/web/host/web-host.ts index 41aa5fea..72bfb5c4 100644 --- a/web/host/web-host.ts +++ b/web/host/web-host.ts @@ -749,13 +749,18 @@ export class WebHost { error: "limit must be an integer from 1 to 100", }); } + const controller = new AbortController(); + const abort = () => controller.abort(); + request.once("aborted", abort); + response.once("close", abort); + const signal = AbortSignal.any([controller.signal, this.chooserAbort.signal]); try { const path = url.searchParams.get("path"); if (path) { return this.json( response, 200, - await this.adapter.getReadOnlyTerminalSession(path), + await this.adapter.getReadOnlyTerminalSession(path, { signal }), ); } return this.json( @@ -765,6 +770,7 @@ export class WebHost { query, cursor: cursor.value, limit, + signal, }), ); } catch (error) { @@ -775,6 +781,9 @@ export class WebHost { }); } throw error; + } finally { + request.off("aborted", abort); + response.off("close", abort); } } if (url.pathname === "/api/models")