From 157207038f8ea6b83c6ca960a6b5e6f358048f2e Mon Sep 17 00:00:00 2001 From: Ashwin Pc Date: Sat, 18 Jul 2026 14:42:07 -0700 Subject: [PATCH 01/10] Extract shared filesystem and Git services --- server.ts | 309 +++------------------------- server/shared/artifacts.ts | 53 +++++ server/shared/fsList.ts | 40 ++++ server/shared/git.ts | 236 +++++++++++++++++++++ tests/shared-server-modules.test.ts | 149 ++++++++++++++ 5 files changed, 505 insertions(+), 282 deletions(-) create mode 100644 server/shared/artifacts.ts create mode 100644 server/shared/fsList.ts create mode 100644 server/shared/git.ts create mode 100644 tests/shared-server-modules.test.ts diff --git a/server.ts b/server.ts index 5bdbc96..497b1fd 100644 --- a/server.ts +++ b/server.ts @@ -1,10 +1,10 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { createReadStream, existsSync, readFileSync } from "node:fs"; -import { mkdir, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { mkdir, rm, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import { extname, isAbsolute, join, relative, resolve } from "node:path"; +import { extname, join, resolve } from "node:path"; import { createServer as createViteServer, type ViteDevServer } from "vite"; import { fileURLToPath } from "node:url"; import { WebSocketServer, type WebSocket } from "ws"; @@ -24,6 +24,9 @@ import { createMockHarness } from "./server/mock.js"; import { resolveBundledExtensionPaths, resolvePiWebExtensionPaths } from "./server/extensions.js"; import { createSessionUiStateStore, defaultSessionUiState } from "./server/sessionUiState.js"; import { createSettingsStore } from "./server/settings.js"; +import { findArtifactFile, isValidArtifactName, safeArtifactName } from "./server/shared/artifacts.js"; +import { assertDirectory, createDirectory, listDirectories } from "./server/shared/fsList.js"; +import { gitCommitDetails, gitCwdFromRepoParam, gitDiff, gitLog, gitStatus, gitSync, isGitRepo, listGitRepos, readGitImage } from "./server/shared/git.js"; import type { PiWebFooter, PiWebGitTab, PiWebHeaderAction, PiWebUi } from "./src/extensions.js"; import type { PiWebSession } from "./server/types.js"; @@ -36,8 +39,6 @@ const host = process.env.HOST || "127.0.0.1"; const port = Number(process.env.PORT || 8787); const token = process.env.PI_WEB_TOKEN || ""; let piCwd = resolve(process.env.PI_WEB_CWD || process.cwd()); -let artifactDir = join(piCwd, ".pi", "web", "artifacts"); -let legacyArtifactDir = join(piCwd, ".pi-web-uploads", "artifacts"); const knownCwds = new Set([piCwd]); const webUiContextFile = join(appDir, "contexts", "web-ui.md"); @@ -115,32 +116,14 @@ async function readBody(req: IncomingMessage): Promise { return text ? JSON.parse(text) : {}; } -function safeArtifactName(name: string) { - return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^\.+/, "").slice(0, 160); -} - function serveArtifact(req: IncomingMessage, res: ServerResponse) { const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); const rawName = decodeURIComponent(url.pathname.slice("/api/artifacts/".length)); const name = safeArtifactName(rawName); - if (!name || rawName.includes("..") || rawName.includes("/") || name !== rawName) return sendJson(res, 400, { ok: false, error: "Invalid artifact name" }); - - let resolvedFile = ""; - const artifactRoots = Array.from(new Set([piCwd, ...knownCwds])); - for (const cwd of artifactRoots) { - const currentArtifactDir = join(cwd, ".pi", "web", "artifacts"); - const currentLegacyArtifactDir = join(cwd, ".pi-web-uploads", "artifacts"); - const file = resolve(currentArtifactDir, name); - const legacyFile = resolve(currentLegacyArtifactDir, name); - if (file.startsWith(currentArtifactDir) && existsSync(file)) { - resolvedFile = file; - break; - } - if (legacyFile.startsWith(currentLegacyArtifactDir) && existsSync(legacyFile)) { - resolvedFile = legacyFile; - break; - } - } + if (!isValidArtifactName(rawName) || name !== rawName) return sendJson(res, 400, { ok: false, error: "Invalid artifact name" }); + + const artifactRoots = new Set([piCwd, ...knownCwds]); + const resolvedFile = findArtifactFile(artifactRoots, name); if (!resolvedFile) return sendJson(res, 404, { ok: false, error: "Artifact not found" }); res.writeHead(200, { @@ -190,58 +173,6 @@ function simplifyModel(model: any) { }; } -async function git(args: string[], timeout = 15_000, cwd = piCwd) { - return execFileAsync("git", args, { cwd, timeout, maxBuffer: 10 * 1024 * 1024 }); -} - -async function gitBuffer(args: string[], timeout = 15_000, cwd = piCwd) { - return new Promise((resolvePromise, reject) => { - execFile("git", args, { cwd, timeout, maxBuffer: 50 * 1024 * 1024, encoding: "buffer" }, (error, stdout) => { - if (error) { - (error as any).stdout = stdout; - reject(error); - return; - } - resolvePromise(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout)); - }); - }); -} - -async function isGitRepo(cwd = piCwd) { - try { await git(["rev-parse", "--is-inside-work-tree"], 15_000, cwd); return true; } catch { return false; } -} - -async function assertDirectory(path: string) { - const resolved = resolve(path || piCwd); - const info = await stat(resolved); - if (!info.isDirectory()) throw new Error("Path is not a directory"); - return resolved; -} - -async function listDirectories(path: string) { - const resolved = await assertDirectory(path); - const entries = await readdir(resolved, { withFileTypes: true }); - const dirs = entries - .filter((entry) => entry.isDirectory()) - .map((entry) => ({ name: entry.name, path: join(resolved, entry.name) })) - .sort((a, b) => a.name.localeCompare(b.name)); - return { ok: true, path: resolved, parent: resolve(resolved, ".."), dirs }; -} - -async function createDirectory(parent: string, name: string) { - const trimmedName = name.trim(); - if (!trimmedName) throw new Error("Folder name is required"); - if (isAbsolute(trimmedName) || trimmedName === "." || trimmedName === ".." || trimmedName.includes("/") || trimmedName.includes("\\")) { - throw new Error("Folder name must be a single directory name"); - } - const parentDir = await assertDirectory(parent); - const target = resolve(parentDir, trimmedName); - const rel = relative(parentDir, target); - if (!rel || rel.startsWith("..") || isAbsolute(rel)) throw new Error("Folder name must stay inside the selected directory"); - await mkdir(target); - return listDirectories(target); -} - function hasUserMessages(value: PiWebSession) { return value.messages.some((message: any) => message?.role === "user"); } @@ -254,163 +185,11 @@ async function ensurePiWebStorage(cwd = piCwd) { } async function setPiCwd(path: string) { - piCwd = await assertDirectory(path); + piCwd = await assertDirectory(path, piCwd); knownCwds.add(piCwd); - artifactDir = join(piCwd, ".pi", "web", "artifacts"); - legacyArtifactDir = join(piCwd, ".pi-web-uploads", "artifacts"); await ensurePiWebStorage(piCwd); } -function gitLabel(indexStatus: string, worktreeStatus: string) { - if (indexStatus === "?" && worktreeStatus === "?") return "untracked"; - if (indexStatus === "U" || worktreeStatus === "U" || indexStatus === "A" && worktreeStatus === "A" || indexStatus === "D" && worktreeStatus === "D") return "conflicted"; - if (indexStatus === "R" || worktreeStatus === "R") return "renamed"; - if (indexStatus === "A" || worktreeStatus === "A") return "added"; - if (indexStatus === "D" || worktreeStatus === "D") return "deleted"; - if (indexStatus !== " " && indexStatus !== "?") return "staged"; - return "modified"; -} - -function parseStatusLine(line: string) { - const indexStatus = line[0] || " "; - const worktreeStatus = line[1] || " "; - const rawPath = line.slice(3); - const renamed = rawPath.includes(" -> "); - const [oldPath, path] = renamed ? rawPath.split(" -> ") : [undefined, rawPath]; - return { path: path || rawPath, oldPath, indexStatus, worktreeStatus, label: gitLabel(indexStatus, worktreeStatus), staged: indexStatus !== " " && indexStatus !== "?" }; -} - -async function gitStatus(cwd = piCwd, fetchRemote = false) { - if (!await isGitRepo(cwd)) return { ok: true, isRepo: false, ahead: 0, behind: 0, files: [] }; - if (fetchRemote) await git(["fetch", "--prune"], 60_000, cwd).catch(() => undefined); - const [{ stdout: root }, { stdout: branchOut }, { stdout: porcelain }, upstreamResult, defaultResult] = await Promise.all([ - git(["rev-parse", "--show-toplevel"], 15_000, cwd), - git(["branch", "--show-current"], 15_000, cwd).catch(() => ({ stdout: "" })), - git(["status", "--porcelain=v1", "-b"], 15_000, cwd), - git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], 15_000, cwd).catch(() => ({ stdout: "" })), - git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], 15_000, cwd).catch(() => ({ stdout: "" })), - ]); - const lines = porcelain.trimEnd().split("\n").filter(Boolean); - const header = lines[0] || ""; - const ahead = Number(header.match(/ahead (\d+)/)?.[1] || 0); - const behind = Number(header.match(/behind (\d+)/)?.[1] || 0); - const trackedFiles = lines.slice(1).map(parseStatusLine).filter((file) => file.label !== "untracked"); - const { stdout: untrackedOut } = await git(["ls-files", "--others", "--exclude-standard"], 15_000, cwd).catch(() => ({ stdout: "" })); - const untrackedFiles = untrackedOut.split("\n").map((path) => path.trim()).filter(Boolean).map((path) => ({ - path, - indexStatus: "?", - worktreeStatus: "?", - label: "untracked", - staged: false, - })); - return { - ok: true, - isRepo: true, - root: root.trim(), - branch: branchOut.trim(), - upstream: upstreamResult.stdout.trim(), - defaultRemoteBranch: defaultResult.stdout.trim(), - ahead, - behind, - files: [...trackedFiles, ...untrackedFiles], - }; -} - -function safeGitPath(path: string) { - if (!path || path.startsWith("/") || path.includes("..") || path.includes("\0")) throw new Error("Invalid path"); - return path; -} - -function isImageGitPath(path: string) { - return [".png", ".jpg", ".jpeg", ".gif", ".webp"].includes(extname(path).toLowerCase()); -} - -async function sendGitImage(res: ServerResponse, options: { cwd: string; path: string; oldPath?: string; version: string; staged: boolean }) { - const filePath = safeGitPath(options.path); - const oldPath = options.oldPath ? safeGitPath(options.oldPath) : undefined; - const displayPath = options.version === "before" ? oldPath || filePath : filePath; - if (!isImageGitPath(displayPath)) return sendJson(res, 415, { ok: false, error: "Not an image file" }); - - const contentType = contentTypes[extname(displayPath).toLowerCase()] || "application/octet-stream"; - if (options.version === "before") { - const data = await gitBuffer(["show", `HEAD:${oldPath || filePath}`], 15_000, options.cwd); - res.writeHead(200, { "content-type": contentType, "cache-control": "no-store" }); - res.end(data); - return; - } - - if (options.version !== "after") throw new Error("Invalid image version"); - if (options.staged) { - const data = await gitBuffer(["show", `:${filePath}`], 15_000, options.cwd); - res.writeHead(200, { "content-type": contentType, "cache-control": "no-store" }); - res.end(data); - return; - } - - const resolved = resolve(options.cwd, filePath); - const rel = relative(options.cwd, resolved); - if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Image path is outside the repository"); - const info = await stat(resolved); - if (!info.isFile()) throw new Error("Image not found"); - res.writeHead(200, { "content-type": contentType, "cache-control": "no-store" }); - pipeReadStream(res, resolved); -} - -async function gitCwdFromRepoParam(repo: string | null, baseCwd = piCwd) { - if (!repo || repo === ".") return baseCwd; - if (repo.includes("\0") || isAbsolute(repo)) throw new Error("Invalid repository path"); - const resolved = resolve(baseCwd, repo); - const rel = relative(baseCwd, resolved); - if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Repository path is outside the workspace"); - const info = await stat(resolved); - if (!info.isDirectory()) throw new Error("Repository path is not a directory"); - return resolved; -} - -const ignoredGitRepoDirs = new Set([".git", ".pi", ".pi-web-uploads", "node_modules", "dist", "build", ".cache", ".next", "target", "vendor"]); - -async function gitRepoSummary(path: string, cwd: string) { - const status = await gitStatus(cwd) as any; - return { - path, - root: status.root || cwd, - branch: status.branch || "", - upstream: status.upstream || "", - ahead: status.ahead || 0, - behind: status.behind || 0, - dirtyCount: status.files?.length || 0, - isCurrent: path === ".", - }; -} - -async function listGitRepos(cwd = piCwd) { - const repos: Array>> = []; - const seenRoots = new Set(); - async function addRepo(path: string, cwd: string) { - if (!await isGitRepo(cwd)) return; - const { stdout } = await git(["rev-parse", "--show-toplevel"], 15_000, cwd); - const root = resolve(stdout.trim()); - if (seenRoots.has(root)) return; - seenRoots.add(root); - repos.push(await gitRepoSummary(path, cwd)); - } - - await addRepo(".", cwd); - const entries = await readdir(cwd, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isDirectory() || ignoredGitRepoDirs.has(entry.name)) continue; - const repoCwd = join(cwd, entry.name); - if (!existsSync(join(repoCwd, ".git"))) continue; - await addRepo(entry.name, repoCwd); - } - return { ok: true, cwd, depth: 1, repos }; -} - -function parseCommit(entry: string) { - const [hash = "", shortHash = "", parents = "", author = "", date = "", refs = "", subject = ""] = entry.split("\x1f"); - return { hash, shortHash, parents: parents ? parents.split(" ").filter(Boolean) : [], author, date, refs: refs ? refs.split(", ").filter(Boolean) : [], subject }; -} - async function requestCwdFromSessionId(sessionId: string | null) { if (!sessionId) return piCwd; if (sessionId === session.sessionId) return sessionCwd(session); @@ -422,36 +201,6 @@ async function requestCwdFromSessionId(sessionId: string | null) { return info.cwd || piCwd; } -async function gitLog(cwd = piCwd) { - if (!await isGitRepo(cwd)) return { ok: true, isRepo: false, commits: [] }; - const { stdout } = await git(["log", "--all", "-n", "200", "--date=iso-strict", "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ad%x1f%D%x1f%s%x1e"], 15_000, cwd); - const commits = stdout.split("\x1e").map((entry) => entry.trim()).filter(Boolean).map(parseCommit); - return { ok: true, isRepo: true, commits }; -} - -async function gitCommitDetails(hash: string, cwd = piCwd) { - if (!await isGitRepo(cwd)) throw new Error("Not a Git repository"); - if (!/^[a-f0-9]{7,40}$/i.test(hash)) throw new Error("Invalid commit hash"); - const [{ stdout: commitOut }, { stdout: nameOut }, { stdout: numstatOut }, { stdout: diff }] = await Promise.all([ - git(["show", "-s", "--date=iso-strict", "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ad%x1f%D%x1f%s", hash], 15_000, cwd), - git(["show", "--name-status", "--format=", hash], 15_000, cwd), - git(["show", "--numstat", "--format=", hash], 15_000, cwd), - git(["show", "--format=", "--patch", "--find-renames", hash], 15_000, cwd), - ]); - const stats = new Map(); - for (const line of numstatOut.split("\n").filter(Boolean)) { - const [add, del, ...pathParts] = line.split("\t"); - const path = pathParts.join("\t"); - stats.set(path, { additions: Number(add) || 0, deletions: Number(del) || 0 }); - } - const files = nameOut.split("\n").filter(Boolean).map((line) => { - const [status, ...parts] = line.split("\t"); - const path = parts.at(-1) || ""; - return { path, status, ...(stats.get(path) || {}) }; - }); - return { ok: true, commit: parseCommit(commitOut.trim()), files, diff }; -} - // Models confirmed broken with this Copilot integration — tracked at runtime. const blockedModelIds = new Set(); @@ -2326,7 +2075,7 @@ function additionalExtensionPaths(cwd = piCwd) { async function makeAgentSession(path?: string, sessionStartEvent?: SessionStartEvent, cwd = piCwd) { if (mockMode) return { session: createMockSession(path), modelFallbackMessage: undefined }; - const targetCwd = await assertDirectory(cwd); + const targetCwd = await assertDirectory(cwd, piCwd); const sessionManager = noSession ? SessionManager.inMemory(targetCwd) : path @@ -2401,7 +2150,7 @@ async function applyDefaultSessionBucket(sessionId: string) { } async function createNewLiveSession(cwd?: string, previousSessionFile?: string) { - const targetCwd = cwd ? await assertDirectory(cwd) : piCwd; + const targetCwd = cwd ? await assertDirectory(cwd, piCwd) : piCwd; knownCwds.add(targetCwd); await ensurePiWebStorage(targetCwd); const created = await makeAgentSession(undefined, { type: "session_start", reason: "new", previousSessionFile }, targetCwd); @@ -2507,7 +2256,7 @@ const server = createServer(async (req, res) => { if (method === "GET" && url.pathname === "/api/fs/dirs") { try { - return sendJson(res, 200, await listDirectories(url.searchParams.get("path") || piCwd)); + return sendJson(res, 200, await listDirectories(url.searchParams.get("path") || piCwd, piCwd)); } catch (error) { return sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) }); } @@ -2516,7 +2265,7 @@ const server = createServer(async (req, res) => { if (method === "POST" && url.pathname === "/api/fs/dirs") { const body = await readBody(req) as { parent?: unknown; name?: unknown }; try { - return sendJson(res, 201, await createDirectory(String(body.parent || piCwd), String(body.name || ""))); + return sendJson(res, 201, await createDirectory(String(body.parent || piCwd), String(body.name || ""), piCwd)); } catch (error) { return sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) }); } @@ -2558,16 +2307,11 @@ const server = createServer(async (req, res) => { const baseCwd = await requestCwdFromSessionId(url.searchParams.get("sessionId")); const cwd = await gitCwdFromRepoParam(url.searchParams.get("repo"), baseCwd); if (!await isGitRepo(cwd)) return sendJson(res, 404, { ok: false, error: "Not a Git repository" }); - const filePath = safeGitPath(url.searchParams.get("path") || ""); - const staged = url.searchParams.get("staged") === "1"; - const args = staged ? ["diff", "--cached", "--", filePath] : ["diff", "--", filePath]; - let { stdout } = await git(args, 15_000, cwd); - if (!stdout) { - const status = await gitStatus(cwd) as any; - const file = status.files?.find((f: any) => f.path === filePath); - if (file?.label === "untracked") stdout = (await git(["diff", "--no-index", "--", "/dev/null", filePath], 15_000, cwd).catch((error: any) => ({ stdout: error.stdout || "" }))).stdout; - } - return sendJson(res, 200, { ok: true, path: filePath, staged, diff: stdout }); + return sendJson(res, 200, await gitDiff({ + cwd, + path: url.searchParams.get("path") || "", + staged: url.searchParams.get("staged") === "1", + })); } catch (error) { return sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) }); } @@ -2578,13 +2322,19 @@ const server = createServer(async (req, res) => { const baseCwd = await requestCwdFromSessionId(url.searchParams.get("sessionId")); const cwd = await gitCwdFromRepoParam(url.searchParams.get("repo"), baseCwd); if (!await isGitRepo(cwd)) return sendJson(res, 404, { ok: false, error: "Not a Git repository" }); - await sendGitImage(res, { + const image = await readGitImage({ cwd, path: url.searchParams.get("path") || "", oldPath: url.searchParams.get("oldPath") || undefined, version: url.searchParams.get("version") || "", staged: url.searchParams.get("staged") === "1", }); + if (!image) return sendJson(res, 415, { ok: false, error: "Not an image file" }); + res.writeHead(200, { + "content-type": contentTypes[extname(image.displayPath).toLowerCase()] || "application/octet-stream", + "cache-control": "no-store", + }); + res.end(image.data); return; } catch (error) { return sendJson(res, 404, { ok: false, error: error instanceof Error ? error.message : String(error) }); @@ -2596,12 +2346,7 @@ const server = createServer(async (req, res) => { const baseCwd = await requestCwdFromSessionId(url.searchParams.get("sessionId")); const cwd = await gitCwdFromRepoParam(url.searchParams.get("repo"), baseCwd); if (!await isGitRepo(cwd)) return sendJson(res, 404, { ok: false, error: "Not a Git repository" }); - const status = await gitStatus(cwd) as any; - const branch = status.branch; - if (!branch) return sendJson(res, 400, { ok: false, error: "Cannot sync detached HEAD" }); - const fetchResult = await git(["fetch", "--prune", "origin"], 60_000, cwd); - const pullResult = await git(["pull", "--rebase", "--autostash", "origin", branch], 120_000, cwd); - return sendJson(res, 200, { ok: true, output: `${fetchResult.stdout}${fetchResult.stderr}${pullResult.stdout}${pullResult.stderr}`, status: await gitStatus(cwd) }); + return sendJson(res, 200, await gitSync(cwd)); } catch (error) { return sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) }); } diff --git a/server/shared/artifacts.ts b/server/shared/artifacts.ts new file mode 100644 index 0000000..17618ce --- /dev/null +++ b/server/shared/artifacts.ts @@ -0,0 +1,53 @@ +import { existsSync } from "node:fs"; +import { readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; + +export const artifactPathParts = [".pi", "web", "artifacts"] as const; +export const legacyArtifactPathParts = [".pi-web-uploads", "artifacts"] as const; + +export function safeArtifactName(name: unknown): string { + return String(name || "").replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^\.+/, "").slice(0, 160); +} + +export function isValidArtifactName(name: string): boolean { + return Boolean(name) && !name.includes("..") && !name.includes("/") && safeArtifactName(name) === name; +} + +export function artifactDirForCwd(cwd: string): string { + return join(cwd, ...artifactPathParts); +} + +export function legacyArtifactDirForCwd(cwd: string): string { + return join(cwd, ...legacyArtifactPathParts); +} + +export function artifactFileForCwd(cwd: string, name: string): string { + return join(artifactDirForCwd(cwd), name); +} + +export function legacyArtifactFileForCwd(cwd: string, name: string): string { + return join(legacyArtifactDirForCwd(cwd), name); +} + +export function findArtifactFile(cwds: Iterable, name: string): string | undefined { + if (!isValidArtifactName(name)) return undefined; + for (const cwd of cwds) { + const file = artifactFileForCwd(cwd, name); + if (existsSync(file)) return file; + const legacyFile = legacyArtifactFileForCwd(cwd, name); + if (existsSync(legacyFile)) return legacyFile; + } + return undefined; +} + +export async function readArtifactBase64(cwd: string, nameValue: unknown, maxBytes?: number) { + const name = String(nameValue || ""); + if (!isValidArtifactName(name)) throw new Error("Invalid artifact name"); + const file = artifactFileForCwd(cwd, name); + const info = await stat(file); + if (Number.isFinite(maxBytes) && Number(maxBytes) > 0 && info.size > Number(maxBytes)) { + throw new Error(`Artifact is too large (${info.size} bytes > ${maxBytes} bytes)`); + } + const bytes = await readFile(file); + return { ok: true as const, name, base64: bytes.toString("base64") }; +} diff --git a/server/shared/fsList.ts b/server/shared/fsList.ts new file mode 100644 index 0000000..e8fd95b --- /dev/null +++ b/server/shared/fsList.ts @@ -0,0 +1,40 @@ +import { mkdir, readdir, stat } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; + +export interface DirectoryListing { + ok: true; + path: string; + parent: string; + dirs: Array<{ name: string; path: string }>; +} + +export async function assertDirectory(pathValue: string, fallback = process.cwd()): Promise { + const resolved = resolve(pathValue || fallback); + const info = await stat(resolved); + if (!info.isDirectory()) throw new Error("Path is not a directory"); + return resolved; +} + +export async function listDirectories(pathValue: string, fallback = process.cwd()): Promise { + const resolved = await assertDirectory(pathValue, fallback); + const entries = await readdir(resolved, { withFileTypes: true }); + const dirs = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ name: entry.name, path: join(resolved, entry.name) })) + .sort((a, b) => a.name.localeCompare(b.name)); + return { ok: true, path: resolved, parent: dirname(resolved), dirs }; +} + +export async function createDirectory(parent: string, name: string, fallback = process.cwd()): Promise { + const trimmedName = name.trim(); + if (!trimmedName) throw new Error("Folder name is required"); + if (isAbsolute(trimmedName) || trimmedName === "." || trimmedName === ".." || trimmedName.includes("/") || trimmedName.includes("\\")) { + throw new Error("Folder name must be a single directory name"); + } + const parentDir = await assertDirectory(parent, fallback); + const target = resolve(parentDir, trimmedName); + const rel = relative(parentDir, target); + if (!rel || rel.startsWith("..") || isAbsolute(rel)) throw new Error("Folder name must stay inside the selected directory"); + await mkdir(target); + return listDirectories(target, fallback); +} diff --git a/server/shared/git.ts b/server/shared/git.ts new file mode 100644 index 0000000..60770f5 --- /dev/null +++ b/server/shared/git.ts @@ -0,0 +1,236 @@ +import { execFile } from "node:child_process"; +import { existsSync } from "node:fs"; +import { readFile, readdir, stat } from "node:fs/promises"; +import { extname, isAbsolute, join, relative, resolve } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +export interface GitFileStatus { + path: string; + oldPath?: string; + indexStatus: string; + worktreeStatus: string; + label: string; + staged: boolean; +} + +export async function git(args: string[], timeout = 15_000, cwd = process.cwd()) { + return execFileAsync("git", args, { cwd, timeout, maxBuffer: 10 * 1024 * 1024 }); +} + +export async function gitBuffer(args: string[], timeout = 15_000, cwd = process.cwd()) { + return new Promise((resolvePromise, reject) => { + execFile("git", args, { cwd, timeout, maxBuffer: 50 * 1024 * 1024, encoding: "buffer" }, (error, stdout) => { + if (error) { + (error as Error & { stdout?: Buffer }).stdout = Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout); + reject(error); + return; + } + resolvePromise(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout)); + }); + }); +} + +export async function isGitRepo(cwd = process.cwd()) { + try { + await git(["rev-parse", "--is-inside-work-tree"], 15_000, cwd); + return true; + } catch { + return false; + } +} + +export function gitLabel(indexStatus: string, worktreeStatus: string) { + if (indexStatus === "?" && worktreeStatus === "?") return "untracked"; + if (indexStatus === "U" || worktreeStatus === "U" || indexStatus === "A" && worktreeStatus === "A" || indexStatus === "D" && worktreeStatus === "D") return "conflicted"; + if (indexStatus === "R" || worktreeStatus === "R") return "renamed"; + if (indexStatus === "A" || worktreeStatus === "A") return "added"; + if (indexStatus === "D" || worktreeStatus === "D") return "deleted"; + if (indexStatus !== " " && indexStatus !== "?") return "staged"; + return "modified"; +} + +export function parseStatusLine(line: string): GitFileStatus { + const indexStatus = line[0] || " "; + const worktreeStatus = line[1] || " "; + const rawPath = line.slice(3); + const renamed = rawPath.includes(" -> "); + const [oldPath, path] = renamed ? rawPath.split(" -> ") : [undefined, rawPath]; + return { path: path || rawPath, oldPath, indexStatus, worktreeStatus, label: gitLabel(indexStatus, worktreeStatus), staged: indexStatus !== " " && indexStatus !== "?" }; +} + +export async function gitStatus(cwd = process.cwd(), fetchRemote = false) { + if (!await isGitRepo(cwd)) return { ok: true as const, isRepo: false as const, ahead: 0, behind: 0, files: [] as GitFileStatus[] }; + if (fetchRemote) await git(["fetch", "--prune"], 60_000, cwd).catch(() => undefined); + const [{ stdout: root }, { stdout: branchOut }, { stdout: porcelain }, upstreamResult, defaultResult] = await Promise.all([ + git(["rev-parse", "--show-toplevel"], 15_000, cwd), + git(["branch", "--show-current"], 15_000, cwd).catch(() => ({ stdout: "" })), + git(["status", "--porcelain=v1", "-b"], 15_000, cwd), + git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], 15_000, cwd).catch(() => ({ stdout: "" })), + git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], 15_000, cwd).catch(() => ({ stdout: "" })), + ]); + const lines = porcelain.trimEnd().split("\n").filter(Boolean); + const header = lines[0] || ""; + const ahead = Number(header.match(/ahead (\d+)/)?.[1] || 0); + const behind = Number(header.match(/behind (\d+)/)?.[1] || 0); + const trackedFiles = lines.slice(1).map(parseStatusLine).filter((file) => file.label !== "untracked"); + const { stdout: untrackedOut } = await git(["ls-files", "--others", "--exclude-standard"], 15_000, cwd).catch(() => ({ stdout: "" })); + const untrackedFiles: GitFileStatus[] = untrackedOut.split("\n").map((path) => path.trim()).filter(Boolean).map((path) => ({ + path, + indexStatus: "?", + worktreeStatus: "?", + label: "untracked", + staged: false, + })); + return { + ok: true as const, + isRepo: true as const, + root: root.trim(), + branch: branchOut.trim(), + upstream: upstreamResult.stdout.trim(), + defaultRemoteBranch: defaultResult.stdout.trim(), + ahead, + behind, + files: [...trackedFiles, ...untrackedFiles], + }; +} + +export function safeGitPath(path: string) { + if (!path || path.startsWith("/") || path.includes("..") || path.includes("\0")) throw new Error("Invalid path"); + return path; +} + +export function isImageGitPath(path: string) { + return [".png", ".jpg", ".jpeg", ".gif", ".webp"].includes(extname(path).toLowerCase()); +} + +export async function readGitImage(options: { cwd: string; path: string; oldPath?: string; version: string; staged: boolean }) { + const filePath = safeGitPath(options.path); + const oldPath = options.oldPath ? safeGitPath(options.oldPath) : undefined; + const displayPath = options.version === "before" ? oldPath || filePath : filePath; + if (!isImageGitPath(displayPath)) return undefined; + + if (options.version === "before") { + return { data: await gitBuffer(["show", `HEAD:${oldPath || filePath}`], 15_000, options.cwd), displayPath }; + } + if (options.version !== "after") throw new Error("Invalid image version"); + if (options.staged) { + return { data: await gitBuffer(["show", `:${filePath}`], 15_000, options.cwd), displayPath }; + } + + const resolved = resolve(options.cwd, filePath); + const rel = relative(options.cwd, resolved); + if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Image path is outside the repository"); + const info = await stat(resolved); + if (!info.isFile()) throw new Error("Image not found"); + return { data: await readFile(resolved), displayPath }; +} + +export async function gitCwdFromRepoParam(repo: string | null, baseCwd: string) { + if (!repo || repo === ".") return baseCwd; + if (repo.includes("\0") || isAbsolute(repo)) throw new Error("Invalid repository path"); + const resolved = resolve(baseCwd, repo); + const rel = relative(baseCwd, resolved); + if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Repository path is outside the workspace"); + const info = await stat(resolved); + if (!info.isDirectory()) throw new Error("Repository path is not a directory"); + return resolved; +} + +const ignoredGitRepoDirs = new Set([".git", ".pi", ".pi-web-uploads", "node_modules", "dist", "build", ".cache", ".next", "target", "vendor"]); + +async function gitRepoSummary(path: string, cwd: string) { + const status = await gitStatus(cwd); + return { + path, + root: status.isRepo ? status.root : cwd, + branch: status.isRepo ? status.branch : "", + upstream: status.isRepo ? status.upstream : "", + ahead: status.ahead, + behind: status.behind, + dirtyCount: status.files.length, + isCurrent: path === ".", + }; +} + +export async function listGitRepos(cwd = process.cwd()) { + const repos: Array>> = []; + const seenRoots = new Set(); + async function addRepo(path: string, repoCwd: string) { + if (!await isGitRepo(repoCwd)) return; + const { stdout } = await git(["rev-parse", "--show-toplevel"], 15_000, repoCwd); + const root = resolve(stdout.trim()); + if (seenRoots.has(root)) return; + seenRoots.add(root); + repos.push(await gitRepoSummary(path, repoCwd)); + } + + await addRepo(".", cwd); + const entries = await readdir(cwd, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || ignoredGitRepoDirs.has(entry.name)) continue; + const repoCwd = join(cwd, entry.name); + if (!existsSync(join(repoCwd, ".git"))) continue; + await addRepo(entry.name, repoCwd); + } + return { ok: true as const, cwd, depth: 1, repos }; +} + +function parseCommit(entry: string) { + const [hash = "", shortHash = "", parents = "", author = "", date = "", refs = "", subject = ""] = entry.split("\x1f"); + return { hash, shortHash, parents: parents ? parents.split(" ").filter(Boolean) : [], author, date, refs: refs ? refs.split(", ").filter(Boolean) : [], subject }; +} + +export async function gitLog(cwd = process.cwd()) { + if (!await isGitRepo(cwd)) return { ok: true as const, isRepo: false as const, commits: [] }; + const { stdout } = await git(["log", "--all", "-n", "200", "--date=iso-strict", "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ad%x1f%D%x1f%s%x1e"], 15_000, cwd); + const commits = stdout.split("\x1e").map((entry) => entry.trim()).filter(Boolean).map(parseCommit); + return { ok: true as const, isRepo: true as const, commits }; +} + +export async function gitCommitDetails(hash: string, cwd = process.cwd()) { + if (!await isGitRepo(cwd)) throw new Error("Not a Git repository"); + if (!/^[a-f0-9]{7,40}$/i.test(hash)) throw new Error("Invalid commit hash"); + const [{ stdout: commitOut }, { stdout: nameOut }, { stdout: numstatOut }, { stdout: diff }] = await Promise.all([ + git(["show", "-s", "--date=iso-strict", "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ad%x1f%D%x1f%s", hash], 15_000, cwd), + git(["show", "--name-status", "--format=", hash], 15_000, cwd), + git(["show", "--numstat", "--format=", hash], 15_000, cwd), + git(["show", "--format=", "--patch", "--find-renames", hash], 15_000, cwd), + ]); + const stats = new Map(); + for (const line of numstatOut.split("\n").filter(Boolean)) { + const [add, del, ...pathParts] = line.split("\t"); + const path = pathParts.join("\t"); + stats.set(path, { additions: Number(add) || 0, deletions: Number(del) || 0 }); + } + const files = nameOut.split("\n").filter(Boolean).map((line) => { + const [status, ...parts] = line.split("\t"); + const path = parts.at(-1) || ""; + return { path, status, ...(stats.get(path) || {}) }; + }); + return { ok: true as const, commit: parseCommit(commitOut.trim()), files, diff }; +} + +export async function gitDiff(options: { cwd: string; path: string; staged: boolean }) { + const filePath = safeGitPath(options.path); + const args = options.staged ? ["diff", "--cached", "--", filePath] : ["diff", "--", filePath]; + let { stdout } = await git(args, 15_000, options.cwd); + if (!stdout) { + const status = await gitStatus(options.cwd); + const file = status.files.find((entry) => entry.path === filePath); + if (file?.label === "untracked") { + stdout = (await git(["diff", "--no-index", "--", "/dev/null", filePath], 15_000, options.cwd).catch((error: Error & { stdout?: string }) => ({ stdout: error.stdout || "" }))).stdout; + } + } + return { ok: true as const, path: filePath, staged: options.staged, diff: stdout }; +} + +export async function gitSync(cwd: string) { + const status = await gitStatus(cwd); + const branch = status.isRepo ? status.branch : ""; + if (!branch) throw new Error("Cannot sync detached HEAD"); + const fetchResult = await git(["fetch", "--prune", "origin"], 60_000, cwd); + const pullResult = await git(["pull", "--rebase", "--autostash", "origin", branch], 120_000, cwd); + return { ok: true as const, output: `${fetchResult.stdout}${fetchResult.stderr}${pullResult.stdout}${pullResult.stderr}`, status: await gitStatus(cwd) }; +} diff --git a/tests/shared-server-modules.test.ts b/tests/shared-server-modules.test.ts new file mode 100644 index 0000000..9489e2a --- /dev/null +++ b/tests/shared-server-modules.test.ts @@ -0,0 +1,149 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + artifactDirForCwd, + findArtifactFile, + isValidArtifactName, + legacyArtifactDirForCwd, + readArtifactBase64, + safeArtifactName, +} from "../server/shared/artifacts.js"; +import { assertDirectory, createDirectory, listDirectories } from "../server/shared/fsList.js"; +import { + gitCommitDetails, + gitCwdFromRepoParam, + gitDiff, + gitLog, + gitStatus, + gitSync, + listGitRepos, + readGitImage, +} from "../server/shared/git.js"; + +const tempDirs: string[] = []; + +async function tempDir(prefix: string) { + const path = await mkdtemp(join(tmpdir(), prefix)); + tempDirs.push(path); + return path; +} + +function runGit(cwd: string, ...args: string[]) { + return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); +} + +async function makeRepo() { + const cwd = await tempDir("pi-web-shared-git-"); + runGit(cwd, "init", "-b", "main"); + runGit(cwd, "config", "user.name", "Pi Web Tests"); + runGit(cwd, "config", "user.email", "pi-web@example.test"); + await writeFile(join(cwd, "tracked.txt"), "first\n"); + await writeFile(join(cwd, "image.png"), Buffer.from([1, 2, 3])); + runGit(cwd, "add", "."); + runGit(cwd, "commit", "-m", "initial"); + return cwd; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +describe("shared filesystem helpers", () => { + it("lists and creates only direct child directories", async () => { + const root = await tempDir("pi-web-shared-fs-"); + await mkdir(join(root, "zeta")); + await mkdir(join(root, "alpha")); + await writeFile(join(root, "file.txt"), "not a directory"); + + const listing = await listDirectories(root); + expect(listing).toEqual({ + ok: true, + path: root, + parent: join(root, ".."), + dirs: [ + { name: "alpha", path: join(root, "alpha") }, + { name: "zeta", path: join(root, "zeta") }, + ], + }); + + const created = await createDirectory(root, "new-folder"); + expect(created.path).toBe(join(root, "new-folder")); + await expect(createDirectory(root, "../escape")).rejects.toThrow("single directory name"); + await expect(assertDirectory(join(root, "file.txt"))).rejects.toThrow("not a directory"); + }); +}); + +describe("shared artifact helpers", () => { + it("validates names and resolves current storage before the legacy fallback", async () => { + const root = await tempDir("pi-web-shared-artifacts-"); + const currentDir = artifactDirForCwd(root); + const legacyDir = legacyArtifactDirForCwd(root); + await mkdir(currentDir, { recursive: true }); + await mkdir(legacyDir, { recursive: true }); + await writeFile(join(currentDir, "report.txt"), "current"); + await writeFile(join(legacyDir, "report.txt"), "legacy"); + + expect(safeArtifactName("../report.txt")).toBe("_report.txt"); + expect(isValidArtifactName("report.txt")).toBe(true); + expect(isValidArtifactName("../report.txt")).toBe(false); + expect(findArtifactFile([root], "report.txt")).toBe(join(currentDir, "report.txt")); + expect(await readArtifactBase64(root, "report.txt")).toEqual({ + ok: true, + name: "report.txt", + base64: Buffer.from("current").toString("base64"), + }); + await expect(readArtifactBase64(root, "report.txt", 2)).rejects.toThrow("too large"); + }); +}); + +describe("shared Git helpers", () => { + it("covers status, diff, history, images, repository discovery, and sync", async () => { + const cwd = await makeRepo(); + const initialHash = runGit(cwd, "rev-parse", "HEAD"); + await writeFile(join(cwd, "tracked.txt"), "second\n"); + await writeFile(join(cwd, "untracked.txt"), "new\n"); + await writeFile(join(cwd, "image.png"), Buffer.from([4, 5, 6])); + + const status = await gitStatus(cwd); + expect(status).toMatchObject({ ok: true, isRepo: true, branch: "main", ahead: 0, behind: 0 }); + expect(status.files).toEqual(expect.arrayContaining([ + expect.objectContaining({ path: "tracked.txt", label: "modified" }), + expect.objectContaining({ path: "untracked.txt", label: "untracked" }), + ])); + + expect(await gitDiff({ cwd, path: "tracked.txt", staged: false })).toMatchObject({ ok: true, path: "tracked.txt", diff: expect.stringContaining("+second") }); + expect(await gitDiff({ cwd, path: "untracked.txt", staged: false })).toMatchObject({ diff: expect.stringContaining("+new") }); + expect(await gitLog(cwd)).toMatchObject({ ok: true, isRepo: true, commits: [expect.objectContaining({ hash: initialHash, subject: "initial" })] }); + expect(await gitCommitDetails(initialHash, cwd)).toMatchObject({ ok: true, commit: { hash: initialHash, subject: "initial" } }); + + const beforeImage = await readGitImage({ cwd, path: "image.png", version: "before", staged: false }); + const afterImage = await readGitImage({ cwd, path: "image.png", version: "after", staged: false }); + expect(beforeImage?.data).toEqual(Buffer.from([1, 2, 3])); + expect(afterImage?.data).toEqual(Buffer.from([4, 5, 6])); + expect(await readGitImage({ cwd, path: "tracked.txt", version: "after", staged: false })).toBeUndefined(); + + const child = join(cwd, "child"); + await mkdir(child); + runGit(child, "init", "-b", "main"); + runGit(child, "config", "user.name", "Pi Web Tests"); + runGit(child, "config", "user.email", "pi-web@example.test"); + await writeFile(join(child, "README.md"), "child\n"); + runGit(child, "add", "."); + runGit(child, "commit", "-m", "child initial"); + const repos = await listGitRepos(cwd); + expect(repos.repos.map((repo) => repo.path)).toEqual([".", "child"]); + expect(await gitCwdFromRepoParam("child", cwd)).toBe(child); + await expect(gitCwdFromRepoParam("../outside", cwd)).rejects.toThrow("outside the workspace"); + + const remote = await tempDir("pi-web-shared-git-remote-"); + runGit(remote, "init", "--bare"); + runGit(cwd, "remote", "add", "origin", remote); + runGit(cwd, "add", "tracked.txt", "untracked.txt", "image.png"); + runGit(cwd, "commit", "-m", "update"); + runGit(cwd, "push", "-u", "origin", "main"); + await expect(gitSync(cwd)).resolves.toMatchObject({ ok: true, status: { isRepo: true, branch: "main" } }); + }); +}); From 12b621bafb1dcb4ee6000a8a85a710b54b911b2e Mon Sep 17 00:00:00 2001 From: Ashwin Pc Date: Sat, 18 Jul 2026 15:06:48 -0700 Subject: [PATCH 02/10] Extract typed session projections --- server.ts | 543 ++----------------------------- server/session/dto.ts | 165 ++++++++++ server/session/projection.ts | 533 ++++++++++++++++++++++++++++++ tests/session-projection.test.ts | 91 ++++++ 4 files changed, 811 insertions(+), 521 deletions(-) create mode 100644 server/session/dto.ts create mode 100644 server/session/projection.ts create mode 100644 tests/session-projection.test.ts diff --git a/server.ts b/server.ts index 497b1fd..5b5984e 100644 --- a/server.ts +++ b/server.ts @@ -18,7 +18,6 @@ import { type ExtensionUIDialogOptions, type ExtensionUIContext, type SessionStartEvent, - type SlashCommandInfo, } from "@earendil-works/pi-coding-agent"; import { createMockHarness } from "./server/mock.js"; import { resolveBundledExtensionPaths, resolvePiWebExtensionPaths } from "./server/extensions.js"; @@ -29,6 +28,21 @@ import { assertDirectory, createDirectory, listDirectories } from "./server/shar import { gitCommitDetails, gitCwdFromRepoParam, gitDiff, gitLog, gitStatus, gitSync, isGitRepo, listGitRepos, readGitImage } from "./server/shared/git.js"; import type { PiWebFooter, PiWebGitTab, PiWebHeaderAction, PiWebUi } from "./src/extensions.js"; import type { PiWebSession } from "./server/types.js"; +import type { SlashCommandDto } from "./server/session/dto.js"; +import { + conversationTreeForSession, + getSessionSlashCommands, + isAssistantAbortedMessage, + isAssistantFailureMessage, + isIncompleteToolResultMessage, + messageEntryRefs, + projectSessionState, + sessionIsRetrying, + sessionStats, + simplifyMessage, + simplifyModel, + textFromContent, +} from "./server/session/projection.js"; const appDir = resolve(fileURLToPath(new URL(".", import.meta.url))); const distDir = join(appDir, "dist"); @@ -47,7 +61,7 @@ const noSession = process.env.PI_WEB_NO_SESSION === "1"; const mockMode = process.env.PI_WEB_MOCK === "1"; const execFileAsync = promisify(execFile); -type WebSlashCommandInfo = Omit & { source: SlashCommandInfo["source"] | "web" }; +type WebSlashCommandInfo = SlashCommandDto; const webSlashCommands: WebSlashCommandInfo[] = [ { name: "help", description: "Show slash command help", source: "web", sourceInfo: { path: "", source: "pi-web", scope: "temporary", origin: "top-level" } }, @@ -148,31 +162,6 @@ function serveStatic(req: IncomingMessage, res: ServerResponse) { pipeReadStream(res, file); } -function textFromContent(content: unknown): string { - if (typeof content === "string") return content; - if (!Array.isArray(content)) return ""; - return content.map((part) => { - if (!part || typeof part !== "object") return ""; - const p = part as Record; - if (p.type === "text" && typeof p.text === "string") return p.text; - if (p.type === "image") return "[image]"; - // toolCall parts are rendered as tool cards in the UI — omit from text - return ""; - }).filter(Boolean).join("\n"); -} - -function simplifyModel(model: any) { - if (!model) return undefined; - return { - provider: model.provider, - id: model.id, - name: model.name || model.id, - reasoning: Boolean(model.reasoning), - contextWindow: model.contextWindow, - maxTokens: model.maxTokens, - }; -} - function hasUserMessages(value: PiWebSession) { return value.messages.some((message: any) => message?.role === "user"); } @@ -279,348 +268,6 @@ function contentWithToolStartedAts(content: unknown, sessionFile?: string) { }); } -function appendMessageEntryRef(refs: Array<{ entryId?: string }>, entry: any) { - if (!entry || typeof entry !== "object") return; - if (entry.type === "message" || entry.type === "custom_message" || entry.type === "branch_summary" && entry.summary) { - const entryId = typeof entry.id === "string" && entry.id.trim() ? entry.id : undefined; - refs.push({ entryId }); - } -} - -function messageEntryRefs(targetSession: PiWebSession): Array<{ entryId?: string }> { - const getBranch = targetSession.sessionManager?.getBranch; - if (typeof getBranch !== "function") return []; - - let branch: any[]; - try { - branch = getBranch.call(targetSession.sessionManager); - } catch { - return []; - } - if (!Array.isArray(branch)) return []; - - const refs: Array<{ entryId?: string }> = []; - let compaction: any | undefined; - for (const entry of branch) { - if (entry?.type === "compaction") compaction = entry; - } - - if (!compaction) { - for (const entry of branch) appendMessageEntryRef(refs, entry); - return refs; - } - - const compactionId = typeof compaction.id === "string" && compaction.id.trim() ? compaction.id : undefined; - refs.push({ entryId: compactionId }); - const compactionIndex = branch.findIndex((entry) => entry?.type === "compaction" && entry?.id === compaction.id); - let foundFirstKept = false; - for (let index = 0; index < compactionIndex; index += 1) { - const entry = branch[index]; - if (entry?.id === compaction.firstKeptEntryId) foundFirstKept = true; - if (foundFirstKept) appendMessageEntryRef(refs, entry); - } - for (let index = compactionIndex + 1; index < branch.length; index += 1) appendMessageEntryRef(refs, branch[index]); - return refs; -} - -function simplifyMessage(message: unknown, toolCallArgs?: Map>, sessionFile?: string, entryId?: string) { - if (!message || typeof message !== "object") return message; - const m = message as Record; - const content = contentWithToolStartedAts(m.content, sessionFile); - const entry = entryId ? { entryId } : {}; - if (m.role === "bashExecution") { - return { - ...entry, - role: "bashExecution", - command: m.command, - output: m.output, - exitCode: m.exitCode, - cancelled: Boolean(m.cancelled), - truncated: Boolean(m.truncated), - fullOutputPath: m.fullOutputPath, - excludeFromContext: Boolean(m.excludeFromContext), - timestamp: m.timestamp, - raw: m, - }; - } - if (m.role === "toolResult") { - const args = toolCallArgs?.get(m.toolCallId as string); - return { - ...entry, - role: "toolResult", - toolCallId: m.toolCallId, - toolName: m.toolName, - toolArgs: args, - isError: Boolean(m.isError), - text: textFromContent(m.content), - timestamp: m.timestamp, - raw: m, - }; - } - const text = textFromContent(content); - const errorText = m.role === "assistant" && m.errorMessage ? assistantErrorPreview(m) : ""; - const stopReasonText = m.role === "assistant" && !errorText ? assistantStopReasonPreview(m) : ""; - const displayText = errorText || (text && stopReasonText ? `${text}\n\n${stopReasonText}` : stopReasonText || text); - const toolCalls = m.role === "assistant" && Array.isArray(content) - ? content.filter((part: any) => part?.type === "toolCall").map((part: any) => ({ - id: part.id, - toolName: part.toolName || part.name || "tool", - args: part.arguments || part.args || {}, - startedAt: part.startedAt, - })) - : undefined; - return { - ...entry, - role: m.role, - text: displayText, - toolCalls, - isError: Boolean(m.errorMessage || m.stopReason === "error" || stopReasonText), - timestamp: m.timestamp, - raw: content === m.content ? m : { ...m, content }, - }; -} - -function truncatePreview(value: string, max = 220) { - const text = value.replace(/\s+/g, " ").trim(); - return text.length > max ? `${text.slice(0, max - 1)}…` : text; -} - -function entryMessage(entry: any) { - if (entry?.type === "message") return entry.message; - if (entry?.type === "custom_message") return { role: "custom", content: entry.content, timestamp: entry.timestamp }; - return undefined; -} - -function messageToolCalls(message: any) { - return Array.isArray(message?.content) - ? message.content.filter((part: any) => part?.type === "toolCall") - : []; -} - -function toolCallName(part: any) { - return String(part?.toolName || part?.name || "tool"); -} - -function toolCallArgs(part: any) { - const args = part?.arguments || part?.args; - return args && typeof args === "object" ? args as Record : {}; -} - -function shortArg(value: unknown, max = 90) { - const text = typeof value === "string" ? value : JSON.stringify(value ?? ""); - return text.length > max ? `${text.slice(0, max - 1)}…` : text; -} - -function toolCallPreview(part: any) { - const name = toolCallName(part); - const args = toolCallArgs(part); - if (name === "bash" && typeof args.command === "string") return `Tool call: bash ${shortArg(args.command, 120)}`; - if (typeof args.path === "string") return `Tool call: ${name} ${shortArg(args.path, 120)}`; - if (typeof args.query === "string") return `Tool call: ${name} ${shortArg(args.query, 120)}`; - if (typeof args.pattern === "string") return `Tool call: ${name} ${shortArg(args.pattern, 120)}`; - const first = Object.entries(args).find(([, value]) => typeof value === "string" || typeof value === "number" || typeof value === "boolean"); - return first ? `Tool call: ${name} ${first[0]}=${shortArg(first[1], 90)}` : `Tool call: ${name}`; -} - -function toolCallsPreview(message: any) { - const calls = messageToolCalls(message); - if (calls.length === 0) return ""; - const [first] = calls; - const suffix = calls.length > 1 ? ` + ${calls.length - 1} more` : ""; - return `${toolCallPreview(first)}${suffix}`; -} - -function messageTextPreview(message: any) { - return textFromContent(message?.content || ""); -} - -const assistantHttpErrorLabels: Record = { - "429": "Throttling error", - "500": "Server error", - "502": "Bad gateway", - "503": "Service unavailable", - "504": "Gateway timeout", - "529": "Overloaded", -}; - -function isAssistantHttpErrorStatus(code: string) { - return code in assistantHttpErrorLabels || /^[45]\d\d$/.test(code); -} - -function assistantStatusLabel(label: string | undefined, code: string) { - const clean = (label || "").replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim(); - if (!clean || /^(?:http|status|error|request failed|model request failed)$/i.test(clean)) return assistantHttpErrorLabels[code] || `HTTP ${code}`; - return clean; -} - -function assistantStatusErrorPreview(text: string) { - const labelled = text.match(/^([A-Za-z][A-Za-z0-9 _/-]*?):\s*(\d{3})(?=$|[\s:,-])/); - if (labelled && isAssistantHttpErrorStatus(labelled[2])) return `${assistantStatusLabel(labelled[1], labelled[2])} (${labelled[2]})`; - const leading = text.match(/^(?:HTTP\s*)?(\d{3})(?=$|[\s:,-])/i); - if (leading && isAssistantHttpErrorStatus(leading[1])) return `${assistantStatusLabel(undefined, leading[1])} (${leading[1]})`; - const generic = text.match(/^(Error|Request failed|Model request failed)\s*:?\s*(\d{3})(?=$|[\s:,-])/i); - if (generic && isAssistantHttpErrorStatus(generic[2])) return `${assistantStatusLabel(generic[1], generic[2])} (${generic[2]})`; - return ""; -} - -function assistantParsedErrorDetail(parsed: any) { - if (typeof parsed === "string") return parsed.trim(); - if (!parsed || typeof parsed !== "object") return ""; - if (parsed.error && typeof parsed.error === "object") return parsed.error.message || parsed.error.type || ""; - return parsed.message || parsed.detail || parsed.error_description || ""; -} - -function assistantJsonErrorPreview(text: string) { - const trimmed = text.trim(); - if (!((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]")))) return ""; - try { - const detail = assistantParsedErrorDetail(JSON.parse(trimmed)); - return detail ? `Error: ${detail}` : ""; - } catch { - return ""; - } -} - -function assistantErrorPreview(message: any) { - const raw = String(message?.errorMessage || "").trim(); - if (!raw) return ""; - const jsonText = raw.replace(/^Codex error:\s*/i, "").trim(); - return assistantJsonErrorPreview(jsonText) - || assistantStatusErrorPreview(jsonText) - || assistantStatusErrorPreview(raw) - || (raw.length > 180 ? `${raw.slice(0, 179)}…` : raw); -} - -function assistantStopReasonPreview(message: any) { - const reason = String(message?.stopReason || "").trim(); - if (!reason || reason === "stop" || reason === "toolUse") return ""; - if (reason === "length") return "Response stopped because the model hit its output length limit."; - if (reason === "aborted") return "Response was aborted."; - return `Response stopped unexpectedly: ${reason}`; -} - -function entryRole(entry: any) { - const message = entryMessage(entry); - if (message?.role === "assistant" && !messageTextPreview(message).trim()) { - if (messageToolCalls(message).length > 0) return "toolCall"; - if (message.errorMessage || assistantStopReasonPreview(message)) return "error"; - } - if (message?.role) return String(message.role); - switch (entry?.type) { - case "branch_summary": return "branchSummary"; - case "compaction": return "compaction"; - case "model_change": return "model"; - case "thinking_level_change": return "thinking"; - case "session_info": return "session"; - case "label": return "label"; - case "custom": return "custom"; - default: return String(entry?.type || "entry"); - } -} - -function entryPreview(entry: any) { - const message = entryMessage(entry); - if (message) { - if (message.role === "toolResult") { - const text = textFromContent(message.content); - return `Tool result: ${message.toolName || "tool"}${text ? ` — ${text}` : ""}`; - } - const text = messageTextPreview(message); - if (text.trim()) return text; - const calls = toolCallsPreview(message); - if (calls) return calls; - const error = assistantErrorPreview(message); - if (error) return error; - const stopReason = assistantStopReasonPreview(message); - if (stopReason) return stopReason; - return message.role === "assistant" ? "Empty assistant message" : `${message.role || "Message"} message`; - } - switch (entry?.type) { - case "branch_summary": return entry.summary || "Branch summary"; - case "compaction": return entry.summary || "Compaction summary"; - case "model_change": return `Model changed to ${entry.provider || "provider"}/${entry.modelId || "model"}`; - case "thinking_level_change": return `Thinking level changed to ${entry.thinkingLevel || "unknown"}`; - case "session_info": return entry.name ? `Session named ${entry.name}` : "Session name cleared"; - case "label": return entry.label ? `Label ${entry.targetId || "entry"} as ${entry.label}` : `Clear label on ${entry.targetId || "entry"}`; - case "custom": return `Custom entry${entry.customType ? `: ${entry.customType}` : ""}`; - default: return String(entry?.type || "Entry"); - } -} - -function countTreeNodes(nodes: any[]): number { - let count = 0; - const stack = [...nodes]; - while (stack.length > 0) { - const node = stack.pop(); - count += 1; - const children = Array.isArray(node?.children) ? node.children : []; - for (const child of children) stack.push(child); - } - return count; -} - -function countBranchPoints(nodes: any[]): number { - let count = 0; - const stack = [...nodes]; - while (stack.length > 0) { - const node = stack.pop(); - const children = Array.isArray(node?.children) ? node.children : []; - if (children.length > 1) count += 1; - for (const child of children) stack.push(child); - } - return count; -} - -function simpleTreeNode(node: any, activePathIds: Set, leafId: string | null, childCount: number): any { - const entry = node?.entry || node; - const id = String(entry?.id || ""); - return { - id, - parentId: typeof entry?.parentId === "string" ? entry.parentId : null, - type: String(entry?.type || "entry"), - role: entryRole(entry), - preview: truncatePreview(entryPreview(entry)), - timestamp: String(entry?.timestamp || ""), - label: typeof node?.label === "string" ? node.label : undefined, - labelTimestamp: typeof node?.labelTimestamp === "string" ? node.labelTimestamp : undefined, - childCount, - isOnActivePath: activePathIds.has(id), - isCurrentLeaf: Boolean(leafId && id === leafId), - children: [], - }; -} - -function simplifyTreeNodesFlat(roots: any[], activePathIds: Set, leafId: string | null): any[] { - const nodes: any[] = []; - const stack = [...roots].reverse(); - while (stack.length > 0) { - const node = stack.pop(); - const children = Array.isArray(node?.children) ? node.children : []; - nodes.push(simpleTreeNode(node, activePathIds, leafId, children.length)); - for (let index = children.length - 1; index >= 0; index -= 1) stack.push(children[index]); - } - return nodes; -} - -function conversationTreeForSession(targetSession: PiWebSession) { - const manager = targetSession.sessionManager; - if (typeof manager.getTree !== "function") throw new Error("Session tree is not available"); - const leafId = typeof manager.getLeafId === "function" ? manager.getLeafId() : null; - const activePath = typeof manager.getBranch === "function" ? manager.getBranch() : []; - const activePathIds = new Set(activePath.map((entry: any) => String(entry?.id || "")).filter(Boolean)); - const roots = manager.getTree(); - const nodes = simplifyTreeNodesFlat(roots, activePathIds, leafId); - return { - ok: true, - sessionId: targetSession.sessionId, - leafId, - activePathIds: Array.from(activePathIds), - entryCount: nodes.length, - branchPointCount: nodes.filter((node: any) => node.childCount > 1).length, - nodes, - }; -} - function sessionCwd(targetSession: PiWebSession | any = session) { return String(targetSession?.sessionManager?.getCwd?.() || targetSession?.cwd || piCwd); } @@ -671,34 +318,6 @@ function clearRuntimeStartedAt(targetSession: any, sessionFile = sessionPathKey( } } -function messageRole(message: any) { - return String(message?.role || message?.raw?.role || ""); -} - -function messageStopReason(message: any) { - return String(message?.stopReason || message?.raw?.stopReason || ""); -} - -function messageErrorText(message: any) { - return typeof message?.errorMessage === "string" - ? message.errorMessage - : typeof message?.raw?.errorMessage === "string" - ? message.raw.errorMessage - : ""; -} - -function isAssistantFailureMessage(message: any) { - return messageRole(message) === "assistant" && (messageStopReason(message) === "error" || Boolean(messageErrorText(message).trim())); -} - -function isAssistantAbortedMessage(message: any) { - return messageRole(message) === "assistant" && messageStopReason(message) === "aborted"; -} - -function isIncompleteToolResultMessage(message: any) { - return messageRole(message) === "toolResult"; -} - type RetrySessionTarget = | { kind: "failure"; messages: any[]; index: number; message: any } | { kind: "aborted"; messages: any[]; index: number; message: any } @@ -800,10 +419,6 @@ async function retrySessionFromFailure(targetSession: PiWebSession) { } } -function sessionIsRetrying(live: PiWebSession | undefined) { - return Boolean((live as any)?.isRetrying); -} - function runtimeForPath(path: string, overrides: { isRetrying?: boolean } = {}) { const live = liveSessions.get(path)?.session; const isStreaming = Boolean(live?.isStreaming); @@ -915,100 +530,19 @@ async function listSessionInfos(extraCwds: string[] = []) { return groups.flat().sort((a, b) => Date.parse(b.modified) - Date.parse(a.modified)); } -function finiteNumber(value: unknown) { - return typeof value === "number" && Number.isFinite(value) ? value : 0; -} - -function sessionDisplayName(targetSession: PiWebSession) { - return targetSession.getSessionName?.()?.trim() - || targetSession.sessionName?.trim() - || targetSession.sessionManager.getSessionName?.()?.trim() - || undefined; -} - -function liveSessionTitle(targetSession: PiWebSession) { - const name = sessionDisplayName(targetSession); - if (name) return name; - - for (const message of targetSession.messages as any[]) { - const text = textFromContent(message?.content).trim(); - if (message?.role === "user" && text) return truncatePreview(text, 80); - } - return "New session"; -} - -function sessionStats(targetSession: PiWebSession) { - let input = 0; - let output = 0; - let cacheRead = 0; - let cacheWrite = 0; - let cost = 0; - let userMessages = 0; - let assistantMessages = 0; - let toolResults = 0; - - const branch = targetSession.sessionManager.getBranch?.(); - const entries = Array.isArray(branch) && branch.length > 0 - ? branch.map((entry: any) => entry?.message ?? entry) - : targetSession.messages; - - for (const message of entries as any[]) { - if (!message || typeof message !== "object") continue; - if (message.role === "user") userMessages++; - if (message.role === "toolResult") toolResults++; - if (message.role !== "assistant") continue; - assistantMessages++; - const usage = message.usage || {}; - input += finiteNumber(usage.input); - output += finiteNumber(usage.output); - cacheRead += finiteNumber(usage.cacheRead); - cacheWrite += finiteNumber(usage.cacheWrite); - const usageCost = usage.cost || {}; - const totalCost = finiteNumber(usageCost.total); - cost += totalCost || finiteNumber(usageCost.input) + finiteNumber(usageCost.output) + finiteNumber(usageCost.cacheRead) + finiteNumber(usageCost.cacheWrite); - } - - const contextUsage = targetSession.getContextUsage?.() || undefined; - return { - userMessages, - assistantMessages, - toolResults, - totalMessages: entries.length, - tokens: { - input, - output, - cacheRead, - cacheWrite, - total: input + output + cacheRead + cacheWrite, - }, - cost, - contextUsage, - }; -} - function currentState(targetSession: PiWebSession = session) { - const isRetrying = sessionIsRetrying(targetSession); - const isRunning = Boolean(targetSession.isStreaming || isRetrying || targetSession.isCompacting); - const runtime = runtimeForPath(targetSession.sessionFile); + const projected = projectSessionState(targetSession, sessionCwd(targetSession)); + const { thinkingLevels: _thinkingLevels, ...base } = projected; + const isRunning = Boolean(projected.isStreaming || projected.isRetrying || projected.isCompacting); return { - cwd: sessionCwd(targetSession), - sessionFile: targetSession.sessionFile, - sessionId: targetSession.sessionId, - sessionName: sessionDisplayName(targetSession), - sessionTitle: liveSessionTitle(targetSession), - isStreaming: targetSession.isStreaming, - isRetrying, - isCompacting: Boolean(targetSession.isCompacting), + ...base, runtimeStartedAt: typeof (targetSession as any).runtimeStartedAt === "string" ? (targetSession as any).runtimeStartedAt : runtimeStartedAtForPath(targetSession.sessionFile, isRunning), runtimeLastActivityAt: typeof (targetSession as any).runtimeLastActivityAt === "string" ? (targetSession as any).runtimeLastActivityAt : runtimeLastActivityAtForPath(targetSession.sessionFile, isRunning), - runtime, - model: simplifyModel(targetSession.model), - thinkingLevel: targetSession.thinkingLevel, - stats: sessionStats(targetSession), + runtime: runtimeForPath(targetSession.sessionFile), webFooters: webFooterEntries(targetSession), webHeaderActions: webHeaderActionEntries(targetSession), webGitTabs: webGitTabEntries(targetSession), @@ -1022,39 +556,6 @@ function currentStateWithThinkingLevels(targetSession: PiWebSession = session) { }; } -function getSessionSlashCommands(value: any): WebSlashCommandInfo[] { - const commands: WebSlashCommandInfo[] = []; - - for (const command of value.extensionRunner?.getRegisteredCommands?.() || []) { - commands.push({ - name: command.invocationName || command.name, - description: command.description, - source: "extension", - sourceInfo: command.sourceInfo, - }); - } - - for (const template of value.promptTemplates || value.resourceLoader?.getPrompts?.().prompts || []) { - commands.push({ - name: template.name, - description: template.description, - source: "prompt", - sourceInfo: template.sourceInfo, - }); - } - - for (const skill of value.resourceLoader?.getSkills?.().skills || []) { - commands.push({ - name: `skill:${skill.name}`, - description: skill.description, - source: "skill", - sourceInfo: skill.sourceInfo, - }); - } - - return commands.filter((command) => typeof command.name === "string" && command.name.length > 0); -} - function getSlashCommands(value: any = session): WebSlashCommandInfo[] { return [...webSlashCommands, ...getSessionSlashCommands(value)]; } @@ -2490,7 +1991,7 @@ const server = createServer(async (req, res) => { } } const refs = messageEntryRefs(targetSession); - return sendJson(res, 200, { ok: true, messages: msgs.map((m: unknown, index: number) => simplifyMessage(m, toolCallArgs, targetSession.sessionFile, refs[index]?.entryId)) }); + return sendJson(res, 200, { ok: true, messages: msgs.map((m: unknown, index: number) => simplifyMessage(m, { toolCallArgs, decorateContent: (content) => contentWithToolStartedAts(content, targetSession.sessionFile), entryId: refs[index]?.entryId })) }); } if (method === "GET" && url.pathname === "/api/sessions") { diff --git a/server/session/dto.ts b/server/session/dto.ts new file mode 100644 index 0000000..2432dfd --- /dev/null +++ b/server/session/dto.ts @@ -0,0 +1,165 @@ +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +export interface ModelDto { + provider: string; + id: string; + name: string; + reasoning: boolean; + contextWindow?: number; + maxTokens?: number; +} + +export interface SessionStatsDto { + userMessages: number; + assistantMessages: number; + toolResults: number; + totalMessages: number; + tokens: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number }; + cost: number; + contextUsage?: { tokens: number | null; contextWindow: number; percent: number | null }; +} + +export interface BaseSessionStateDto { + cwd: string; + sessionFile: string; + sessionId: string; + sessionName?: string; + sessionTitle: string; + isStreaming: boolean; + isRetrying: boolean; + isCompacting: boolean; + model?: ModelDto; + thinkingLevel: string; + thinkingLevels: string[]; + stats: SessionStatsDto; +} + +export interface MessageDto { + entryId?: string; + role?: string; + text?: string; + toolCalls?: Array<{ id?: string; toolName: string; args: JsonValue; startedAt?: string }>; + toolCallId?: string; + toolName?: string; + toolArgs?: JsonValue; + isError?: boolean; + timestamp?: string; + raw?: JsonValue; + [key: string]: JsonValue | undefined; +} + +export interface TreeNodeDto { + id: string; + parentId: string | null; + type: string; + role: string; + preview: string; + timestamp: string; + label?: string; + labelTimestamp?: string; + childCount: number; + isOnActivePath: boolean; + isCurrentLeaf: boolean; + children: never[]; +} + +export interface ConversationTreeDto { + ok: true; + sessionId: string; + leafId: string | null; + activePathIds: string[]; + entryCount: number; + branchPointCount: number; + nodes: TreeNodeDto[]; +} + +export interface SlashCommandDto { + name: string; + description?: string; + source: "web" | "extension" | "prompt" | "skill"; + sourceInfo?: JsonValue; +} + +export interface SessionInfoDto { + id: string; + name?: string; + firstMessage?: string; + created: string; + modified: string; + messageCount: number; + cwd: string; +} + +export interface SessionRefDto { sessionId: string; sessionFile: string; cwd: string } +export interface CreateSessionResultDto extends SessionRefDto { state: BaseSessionStateDto; previousSessionFile?: string } +export interface DeleteSessionResultDto { id: string; disposition: "trashed" | "deleted" } +export interface NavigateTreeResultDto { cancelled: boolean; aborted?: boolean; editorText?: string; summaryEntry?: JsonValue; leafId: string | null; state: BaseSessionStateDto } +export interface ShellResultDto { output: string; exitCode?: number; cancelled: boolean; truncated: boolean; fullOutputPath?: string } +export interface ArtifactDto { name: string; base64: string } +export interface GitImageDto { path: string; base64: string } +export interface DirectoryListingDto { path: string; parent: string; dirs: Array<{ name: string; path: string }> } + +export type SessionServiceEvent = + | { type: "pi"; sessionId: string; sessionFile: string; event: JsonValue } + | { type: "state"; state: BaseSessionStateDto } + | { type: "stats"; sessionId: string; sessionFile: string; stats: SessionStatsDto } + | { type: "models"; sessionId: string; models: ModelDto[] } + | { type: "error"; sessionId?: string; sessionFile?: string; error: string } + | { type: "shutdown"; sessionId: string; sessionFile: string } + | { type: "extension-ui"; request: JsonValue } + | { type: "footers"; sessionId: string; sessionFile: string; footers: JsonValue[] } + | { type: "header-actions"; sessionId: string; sessionFile: string; actions: JsonValue[] } + | { type: "git-tabs"; sessionId: string; sessionFile: string; tabs: JsonValue[] }; + +export interface SessionService { + create(cwd?: string, previousSessionFile?: string): Promise; + open(sessionId: string, cwd?: string): Promise; + delete(sessionId: string, cwd?: string): Promise; + list(extraCwds?: string[]): Promise; + switchCwd(sessionId: string, cwd: string): Promise; + state(sessionId: string): Promise; + messages(sessionId: string): Promise; + stats(sessionId: string): Promise; + tree(sessionId: string): Promise; + commands(sessionId: string): Promise; + models(sessionId: string): Promise<{ cwd: string; current?: ModelDto; thinkingLevel: string; thinkingLevels: string[]; models: ModelDto[] }>; + prompt(sessionId: string, text: string, images: Array<{ data: string; mimeType: string; name?: string }>, mode: "followUp" | "steer"): Promise; + abort(sessionId: string): Promise; + abortCompaction(sessionId: string): Promise; + retry(sessionId: string): Promise; + rename(sessionId: string, name: string): Promise; + setModel(sessionId: string, provider: string, id: string, thinkingLevel?: string): Promise; + executeCommand(sessionId: string, command: string): Promise<{ message?: string; state: BaseSessionStateDto }>; + executeShell(sessionId: string, command: string, excludeFromContext: boolean): Promise; + navigateTree(sessionId: string, targetId: string, options: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }): Promise; + abortBranchSummary(sessionId: string): Promise; + respondExtensionUi(id: string, response: JsonValue): void; + invokeHeaderAction(sessionId: string, key: string): Promise<{ label: string; markdown: string }>; + invokeGitTab(sessionId: string, key: string, action: string, payload?: JsonValue): Promise; + acquireViewer(sessionId: string, clientId: string): void; + releaseViewer(clientId: string): void; + fs: { + list(path: string): Promise; + mkdir(parent: string, name: string): Promise; + }; + git: { + repos(cwd: string): Promise; + status(cwd: string, fetchRemote?: boolean): Promise; + log(cwd: string): Promise; + commit(cwd: string, hash: string): Promise; + diff(cwd: string, path: string, staged: boolean): Promise; + imageBase64(cwd: string, path: string, oldPath: string | undefined, version: string, staged: boolean): Promise; + sync(cwd: string): Promise; + }; + artifacts: { + read(cwd: string, name: string): Promise; + readBase64(cwd: string, name: string, maxBytes?: number): Promise; + write(cwd: string, name: string, base64: string): Promise; + }; + subscribe(listener: (event: SessionServiceEvent) => void): () => void; +} + +export function jsonRoundTrip(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} diff --git a/server/session/projection.ts b/server/session/projection.ts new file mode 100644 index 0000000..115f64f --- /dev/null +++ b/server/session/projection.ts @@ -0,0 +1,533 @@ +import type { PiWebSession } from "../types.js"; +import type { BaseSessionStateDto, ConversationTreeDto, ModelDto, SessionStatsDto, SlashCommandDto } from "./dto.js"; + +export type ContentDecorator = (content: unknown) => unknown; + +export function textFromContent(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content.map((part) => { + if (!part || typeof part !== "object") return ""; + const p = part as Record; + if (p.type === "text" && typeof p.text === "string") return p.text; + if (p.type === "image") return "[image]"; + // toolCall parts are rendered as tool cards in the UI — omit from text + return ""; + }).filter(Boolean).join("\n"); +} + +export function simplifyModel(model: any): ModelDto | undefined { + if (!model) return undefined; + return { + provider: model.provider, + id: model.id, + name: model.name || model.id, + reasoning: Boolean(model.reasoning), + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + }; +} + + +export function appendMessageEntryRef(refs: Array<{ entryId?: string }>, entry: any) { + if (!entry || typeof entry !== "object") return; + if (entry.type === "message" || entry.type === "custom_message" || entry.type === "branch_summary" && entry.summary) { + const entryId = typeof entry.id === "string" && entry.id.trim() ? entry.id : undefined; + refs.push({ entryId }); + } +} + +export function messageEntryRefs(targetSession: PiWebSession): Array<{ entryId?: string }> { + const getBranch = targetSession.sessionManager?.getBranch; + if (typeof getBranch !== "function") return []; + + let branch: any[]; + try { + branch = getBranch.call(targetSession.sessionManager); + } catch { + return []; + } + if (!Array.isArray(branch)) return []; + + const refs: Array<{ entryId?: string }> = []; + let compaction: any | undefined; + for (const entry of branch) { + if (entry?.type === "compaction") compaction = entry; + } + + if (!compaction) { + for (const entry of branch) appendMessageEntryRef(refs, entry); + return refs; + } + + const compactionId = typeof compaction.id === "string" && compaction.id.trim() ? compaction.id : undefined; + refs.push({ entryId: compactionId }); + const compactionIndex = branch.findIndex((entry) => entry?.type === "compaction" && entry?.id === compaction.id); + let foundFirstKept = false; + for (let index = 0; index < compactionIndex; index += 1) { + const entry = branch[index]; + if (entry?.id === compaction.firstKeptEntryId) foundFirstKept = true; + if (foundFirstKept) appendMessageEntryRef(refs, entry); + } + for (let index = compactionIndex + 1; index < branch.length; index += 1) appendMessageEntryRef(refs, branch[index]); + return refs; +} + +export function simplifyMessage( + message: unknown, + options: { toolCallArgs?: Map>; decorateContent?: ContentDecorator; entryId?: string } = {}, +) { + if (!message || typeof message !== "object") return message; + const m = message as Record; + const content = options.decorateContent ? options.decorateContent(m.content) : m.content; + const entry = options.entryId ? { entryId: options.entryId } : {}; + const toolCallArgs = options.toolCallArgs; + if (m.role === "bashExecution") { + return { + ...entry, + role: "bashExecution", + command: m.command, + output: m.output, + exitCode: m.exitCode, + cancelled: Boolean(m.cancelled), + truncated: Boolean(m.truncated), + fullOutputPath: m.fullOutputPath, + excludeFromContext: Boolean(m.excludeFromContext), + timestamp: m.timestamp, + raw: m, + }; + } + if (m.role === "toolResult") { + const args = toolCallArgs?.get(m.toolCallId as string); + return { + ...entry, + role: "toolResult", + toolCallId: m.toolCallId, + toolName: m.toolName, + toolArgs: args, + isError: Boolean(m.isError), + text: textFromContent(m.content), + timestamp: m.timestamp, + raw: m, + }; + } + const text = textFromContent(content); + const errorText = m.role === "assistant" && m.errorMessage ? assistantErrorPreview(m) : ""; + const stopReasonText = m.role === "assistant" && !errorText ? assistantStopReasonPreview(m) : ""; + const displayText = errorText || (text && stopReasonText ? `${text}\n\n${stopReasonText}` : stopReasonText || text); + const toolCalls = m.role === "assistant" && Array.isArray(content) + ? content.filter((part: any) => part?.type === "toolCall").map((part: any) => ({ + id: part.id, + toolName: part.toolName || part.name || "tool", + args: part.arguments || part.args || {}, + startedAt: part.startedAt, + })) + : undefined; + return { + ...entry, + role: m.role, + text: displayText, + toolCalls, + isError: Boolean(m.errorMessage || m.stopReason === "error" || stopReasonText), + timestamp: m.timestamp, + raw: content === m.content ? m : { ...m, content }, + }; +} + +export function truncatePreview(value: string, max = 220) { + const text = value.replace(/\s+/g, " ").trim(); + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +} + +export function entryMessage(entry: any) { + if (entry?.type === "message") return entry.message; + if (entry?.type === "custom_message") return { role: "custom", content: entry.content, timestamp: entry.timestamp }; + return undefined; +} + +export function messageToolCalls(message: any) { + return Array.isArray(message?.content) + ? message.content.filter((part: any) => part?.type === "toolCall") + : []; +} + +export function toolCallName(part: any) { + return String(part?.toolName || part?.name || "tool"); +} + +export function toolCallArgs(part: any) { + const args = part?.arguments || part?.args; + return args && typeof args === "object" ? args as Record : {}; +} + +export function shortArg(value: unknown, max = 90) { + const text = typeof value === "string" ? value : JSON.stringify(value ?? ""); + return text.length > max ? `${text.slice(0, max - 1)}…` : text; +} + +export function toolCallPreview(part: any) { + const name = toolCallName(part); + const args = toolCallArgs(part); + if (name === "bash" && typeof args.command === "string") return `Tool call: bash ${shortArg(args.command, 120)}`; + if (typeof args.path === "string") return `Tool call: ${name} ${shortArg(args.path, 120)}`; + if (typeof args.query === "string") return `Tool call: ${name} ${shortArg(args.query, 120)}`; + if (typeof args.pattern === "string") return `Tool call: ${name} ${shortArg(args.pattern, 120)}`; + const first = Object.entries(args).find(([, value]) => typeof value === "string" || typeof value === "number" || typeof value === "boolean"); + return first ? `Tool call: ${name} ${first[0]}=${shortArg(first[1], 90)}` : `Tool call: ${name}`; +} + +export function toolCallsPreview(message: any) { + const calls = messageToolCalls(message); + if (calls.length === 0) return ""; + const [first] = calls; + const suffix = calls.length > 1 ? ` + ${calls.length - 1} more` : ""; + return `${toolCallPreview(first)}${suffix}`; +} + +export function messageTextPreview(message: any) { + return textFromContent(message?.content || ""); +} + +const assistantHttpErrorLabels: Record = { + "429": "Throttling error", + "500": "Server error", + "502": "Bad gateway", + "503": "Service unavailable", + "504": "Gateway timeout", + "529": "Overloaded", +}; + +export function isAssistantHttpErrorStatus(code: string) { + return code in assistantHttpErrorLabels || /^[45]\d\d$/.test(code); +} + +export function assistantStatusLabel(label: string | undefined, code: string) { + const clean = (label || "").replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim(); + if (!clean || /^(?:http|status|error|request failed|model request failed)$/i.test(clean)) return assistantHttpErrorLabels[code] || `HTTP ${code}`; + return clean; +} + +export function assistantStatusErrorPreview(text: string) { + const labelled = text.match(/^([A-Za-z][A-Za-z0-9 _/-]*?):\s*(\d{3})(?=$|[\s:,-])/); + if (labelled && isAssistantHttpErrorStatus(labelled[2])) return `${assistantStatusLabel(labelled[1], labelled[2])} (${labelled[2]})`; + const leading = text.match(/^(?:HTTP\s*)?(\d{3})(?=$|[\s:,-])/i); + if (leading && isAssistantHttpErrorStatus(leading[1])) return `${assistantStatusLabel(undefined, leading[1])} (${leading[1]})`; + const generic = text.match(/^(Error|Request failed|Model request failed)\s*:?\s*(\d{3})(?=$|[\s:,-])/i); + if (generic && isAssistantHttpErrorStatus(generic[2])) return `${assistantStatusLabel(generic[1], generic[2])} (${generic[2]})`; + return ""; +} + +export function assistantParsedErrorDetail(parsed: any) { + if (typeof parsed === "string") return parsed.trim(); + if (!parsed || typeof parsed !== "object") return ""; + if (parsed.error && typeof parsed.error === "object") return parsed.error.message || parsed.error.type || ""; + return parsed.message || parsed.detail || parsed.error_description || ""; +} + +export function assistantJsonErrorPreview(text: string) { + const trimmed = text.trim(); + if (!((trimmed.startsWith("{") && trimmed.endsWith("}")) || (trimmed.startsWith("[") && trimmed.endsWith("]")))) return ""; + try { + const detail = assistantParsedErrorDetail(JSON.parse(trimmed)); + return detail ? `Error: ${detail}` : ""; + } catch { + return ""; + } +} + +export function assistantErrorPreview(message: any) { + const raw = String(message?.errorMessage || "").trim(); + if (!raw) return ""; + const jsonText = raw.replace(/^Codex error:\s*/i, "").trim(); + return assistantJsonErrorPreview(jsonText) + || assistantStatusErrorPreview(jsonText) + || assistantStatusErrorPreview(raw) + || (raw.length > 180 ? `${raw.slice(0, 179)}…` : raw); +} + +export function assistantStopReasonPreview(message: any) { + const reason = String(message?.stopReason || "").trim(); + if (!reason || reason === "stop" || reason === "toolUse") return ""; + if (reason === "length") return "Response stopped because the model hit its output length limit."; + if (reason === "aborted") return "Response was aborted."; + return `Response stopped unexpectedly: ${reason}`; +} + +export function entryRole(entry: any) { + const message = entryMessage(entry); + if (message?.role === "assistant" && !messageTextPreview(message).trim()) { + if (messageToolCalls(message).length > 0) return "toolCall"; + if (message.errorMessage || assistantStopReasonPreview(message)) return "error"; + } + if (message?.role) return String(message.role); + switch (entry?.type) { + case "branch_summary": return "branchSummary"; + case "compaction": return "compaction"; + case "model_change": return "model"; + case "thinking_level_change": return "thinking"; + case "session_info": return "session"; + case "label": return "label"; + case "custom": return "custom"; + default: return String(entry?.type || "entry"); + } +} + +export function entryPreview(entry: any) { + const message = entryMessage(entry); + if (message) { + if (message.role === "toolResult") { + const text = textFromContent(message.content); + return `Tool result: ${message.toolName || "tool"}${text ? ` — ${text}` : ""}`; + } + const text = messageTextPreview(message); + if (text.trim()) return text; + const calls = toolCallsPreview(message); + if (calls) return calls; + const error = assistantErrorPreview(message); + if (error) return error; + const stopReason = assistantStopReasonPreview(message); + if (stopReason) return stopReason; + return message.role === "assistant" ? "Empty assistant message" : `${message.role || "Message"} message`; + } + switch (entry?.type) { + case "branch_summary": return entry.summary || "Branch summary"; + case "compaction": return entry.summary || "Compaction summary"; + case "model_change": return `Model changed to ${entry.provider || "provider"}/${entry.modelId || "model"}`; + case "thinking_level_change": return `Thinking level changed to ${entry.thinkingLevel || "unknown"}`; + case "session_info": return entry.name ? `Session named ${entry.name}` : "Session name cleared"; + case "label": return entry.label ? `Label ${entry.targetId || "entry"} as ${entry.label}` : `Clear label on ${entry.targetId || "entry"}`; + case "custom": return `Custom entry${entry.customType ? `: ${entry.customType}` : ""}`; + default: return String(entry?.type || "Entry"); + } +} + +export function countTreeNodes(nodes: any[]): number { + let count = 0; + const stack = [...nodes]; + while (stack.length > 0) { + const node = stack.pop(); + count += 1; + const children = Array.isArray(node?.children) ? node.children : []; + for (const child of children) stack.push(child); + } + return count; +} + +export function countBranchPoints(nodes: any[]): number { + let count = 0; + const stack = [...nodes]; + while (stack.length > 0) { + const node = stack.pop(); + const children = Array.isArray(node?.children) ? node.children : []; + if (children.length > 1) count += 1; + for (const child of children) stack.push(child); + } + return count; +} + +export function simpleTreeNode(node: any, activePathIds: Set, leafId: string | null, childCount: number): any { + const entry = node?.entry || node; + const id = String(entry?.id || ""); + return { + id, + parentId: typeof entry?.parentId === "string" ? entry.parentId : null, + type: String(entry?.type || "entry"), + role: entryRole(entry), + preview: truncatePreview(entryPreview(entry)), + timestamp: String(entry?.timestamp || ""), + ...(typeof node?.label === "string" ? { label: node.label } : {}), + ...(typeof node?.labelTimestamp === "string" ? { labelTimestamp: node.labelTimestamp } : {}), + childCount, + isOnActivePath: activePathIds.has(id), + isCurrentLeaf: Boolean(leafId && id === leafId), + children: [], + }; +} + +export function simplifyTreeNodesFlat(roots: any[], activePathIds: Set, leafId: string | null): any[] { + const nodes: any[] = []; + const stack = [...roots].reverse(); + while (stack.length > 0) { + const node = stack.pop(); + const children = Array.isArray(node?.children) ? node.children : []; + nodes.push(simpleTreeNode(node, activePathIds, leafId, children.length)); + for (let index = children.length - 1; index >= 0; index -= 1) stack.push(children[index]); + } + return nodes; +} + +export function conversationTreeForSession(targetSession: PiWebSession): ConversationTreeDto { + const manager = targetSession.sessionManager; + if (typeof manager.getTree !== "function") throw new Error("Session tree is not available"); + const leafId = typeof manager.getLeafId === "function" ? manager.getLeafId() : null; + const activePath = typeof manager.getBranch === "function" ? manager.getBranch() : []; + const activePathIds = new Set(activePath.map((entry: any) => String(entry?.id || "")).filter(Boolean)); + const roots = manager.getTree(); + const nodes = simplifyTreeNodesFlat(roots, activePathIds, leafId); + return { + ok: true, + sessionId: targetSession.sessionId, + leafId, + activePathIds: Array.from(activePathIds), + entryCount: nodes.length, + branchPointCount: nodes.filter((node: any) => node.childCount > 1).length, + nodes, + }; +} + + +export function messageRole(message: any) { + return String(message?.role || message?.raw?.role || ""); +} + +export function messageStopReason(message: any) { + return String(message?.stopReason || message?.raw?.stopReason || ""); +} + +export function messageErrorText(message: any) { + return typeof message?.errorMessage === "string" + ? message.errorMessage + : typeof message?.raw?.errorMessage === "string" + ? message.raw.errorMessage + : ""; +} + +export function isAssistantFailureMessage(message: any) { + return messageRole(message) === "assistant" && (messageStopReason(message) === "error" || Boolean(messageErrorText(message).trim())); +} + +export function isAssistantAbortedMessage(message: any) { + return messageRole(message) === "assistant" && messageStopReason(message) === "aborted"; +} + +export function isIncompleteToolResultMessage(message: any) { + return messageRole(message) === "toolResult"; +} + + +export function finiteNumber(value: unknown) { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +export function sessionDisplayName(targetSession: PiWebSession) { + return targetSession.getSessionName?.()?.trim() + || targetSession.sessionName?.trim() + || targetSession.sessionManager.getSessionName?.()?.trim() + || undefined; +} + +export function liveSessionTitle(targetSession: PiWebSession) { + const name = sessionDisplayName(targetSession); + if (name) return name; + + for (const message of targetSession.messages as any[]) { + const text = textFromContent(message?.content).trim(); + if (message?.role === "user" && text) return truncatePreview(text, 80); + } + return "New session"; +} + +export function sessionStats(targetSession: PiWebSession): SessionStatsDto { + let input = 0; + let output = 0; + let cacheRead = 0; + let cacheWrite = 0; + let cost = 0; + let userMessages = 0; + let assistantMessages = 0; + let toolResults = 0; + + const branch = targetSession.sessionManager.getBranch?.(); + const entries = Array.isArray(branch) && branch.length > 0 + ? branch.map((entry: any) => entry?.message ?? entry) + : targetSession.messages; + + for (const message of entries as any[]) { + if (!message || typeof message !== "object") continue; + if (message.role === "user") userMessages++; + if (message.role === "toolResult") toolResults++; + if (message.role !== "assistant") continue; + assistantMessages++; + const usage = message.usage || {}; + input += finiteNumber(usage.input); + output += finiteNumber(usage.output); + cacheRead += finiteNumber(usage.cacheRead); + cacheWrite += finiteNumber(usage.cacheWrite); + const usageCost = usage.cost || {}; + const totalCost = finiteNumber(usageCost.total); + cost += totalCost || finiteNumber(usageCost.input) + finiteNumber(usageCost.output) + finiteNumber(usageCost.cacheRead) + finiteNumber(usageCost.cacheWrite); + } + + const contextUsage = targetSession.getContextUsage?.() || undefined; + return { + userMessages, + assistantMessages, + toolResults, + totalMessages: entries.length, + tokens: { + input, + output, + cacheRead, + cacheWrite, + total: input + output + cacheRead + cacheWrite, + }, + cost, + contextUsage, + }; +} + + +export function sessionIsRetrying(targetSession: PiWebSession | undefined): boolean { + return Boolean(targetSession?.isRetrying); +} + +export function projectSessionState(targetSession: PiWebSession, cwd: string): BaseSessionStateDto { + return { + cwd, + sessionFile: targetSession.sessionFile, + sessionId: targetSession.sessionId, + sessionName: sessionDisplayName(targetSession), + sessionTitle: liveSessionTitle(targetSession), + isStreaming: targetSession.isStreaming, + isRetrying: sessionIsRetrying(targetSession), + isCompacting: Boolean(targetSession.isCompacting), + model: simplifyModel(targetSession.model), + thinkingLevel: targetSession.thinkingLevel, + thinkingLevels: targetSession.getAvailableThinkingLevels(), + stats: sessionStats(targetSession), + }; +} + +export function getSessionSlashCommands(value: PiWebSession): SlashCommandDto[] { + const commands: SlashCommandDto[] = []; + + for (const command of value.extensionRunner?.getRegisteredCommands?.() as any[] || []) { + commands.push({ + name: command.invocationName || command.name, + description: command.description, + source: "extension", + sourceInfo: command.sourceInfo, + }); + } + + for (const template of value.promptTemplates as any[] || value.resourceLoader?.getPrompts?.().prompts as any[] || []) { + commands.push({ + name: template.name, + description: template.description, + source: "prompt", + sourceInfo: template.sourceInfo, + }); + } + + for (const skill of value.resourceLoader?.getSkills?.().skills as any[] || []) { + commands.push({ + name: `skill:${skill.name}`, + description: skill.description, + source: "skill", + sourceInfo: skill.sourceInfo, + }); + } + + return commands.filter((command) => typeof command.name === "string" && command.name.length > 0); +} + diff --git a/tests/session-projection.test.ts b/tests/session-projection.test.ts new file mode 100644 index 0000000..f2b60bc --- /dev/null +++ b/tests/session-projection.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import type { PiWebSession } from "../server/types.js"; +import { + conversationTreeForSession, + getSessionSlashCommands, + messageEntryRefs, + projectSessionState, + sessionStats, + simplifyMessage, + simplifyModel, + textFromContent, +} from "../server/session/projection.js"; + +function roundTrip(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +function fixtureSession(): PiWebSession { + const branch = [ + { id: "user-1", parentId: null, type: "message", timestamp: "2026-01-01T00:00:00Z", message: { role: "user", content: "Hello" } }, + { id: "assistant-1", parentId: "user-1", type: "message", timestamp: "2026-01-01T00:00:01Z", message: { role: "assistant", content: [{ type: "text", text: "Hi" }], usage: { input: 2, output: 3, cost: { total: 0.01 } } } }, + ]; + const tree = [{ entry: branch[0], children: [{ entry: branch[1], children: [] }] }]; + return { + sessionId: "session-1", + sessionFile: "/tmp/session-1.jsonl", + sessionName: "Projection fixture", + isStreaming: false, + isCompacting: false, + model: { provider: "test", id: "model", name: "Test Model", reasoning: true, contextWindow: 1000, maxTokens: 100 }, + thinkingLevel: "medium", + messages: branch.map((entry) => entry.message), + agent: { state: { messages: branch.map((entry) => entry.message) } }, + sessionManager: { + newSession() {}, + buildSessionContext: () => ({ messages: branch.map((entry) => entry.message) }), + getSessionName: () => "Projection fixture", + getBranch: () => branch, + getLeafId: () => "assistant-1", + getTree: () => tree, + }, + modelRegistry: { getAvailable: () => [], find: () => undefined }, + extensionRunner: { getRegisteredCommands: () => [{ invocationName: "ext", description: "Extension command", sourceInfo: { path: "/tmp/ext.ts", source: "extension", scope: "user", origin: "top-level" } }] }, + promptTemplates: [{ name: "prompt", description: "Prompt command", sourceInfo: { path: "/tmp/prompt.md", source: "prompt", scope: "user", origin: "top-level" } }], + resourceLoader: { getSkills: () => ({ skills: [{ name: "demo", description: "Demo skill", sourceInfo: { path: "/tmp/SKILL.md", source: "skill", scope: "user", origin: "top-level" } }] }) }, + getAvailableThinkingLevels: () => ["low", "medium", "high"], + getSessionName: () => "Projection fixture", + getContextUsage: () => ({ tokens: 5, contextWindow: 1000, percent: 0.5 }), + async setModel() {}, + setThinkingLevel() {}, + async prompt() {}, + async abort() {}, + }; +} + +describe("pure session projections", () => { + it("projects content and models without session globals", () => { + expect(textFromContent([{ type: "text", text: "hello" }, { type: "image" }])).toBe("hello\n[image]"); + expect(simplifyModel(fixtureSession().model)).toEqual({ provider: "test", id: "model", name: "Test Model", reasoning: true, contextWindow: 1000, maxTokens: 100 }); + }); + + it("keeps compaction-aware active-branch entry ids", () => { + const session = fixtureSession(); + session.sessionManager.getBranch = () => [ + { id: "old", type: "message", message: { role: "user", content: "old" } }, + { id: "kept", type: "message", message: { role: "user", content: "kept" } }, + { id: "compact", type: "compaction", firstKeptEntryId: "kept", summary: "summary" }, + { id: "new", type: "message", message: { role: "assistant", content: "new" } }, + ]; + expect(messageEntryRefs(session)).toEqual([{ entryId: "compact" }, { entryId: "kept" }, { entryId: "new" }]); + }); + + it("accepts host decoration as explicit message projection input", () => { + const projected = simplifyMessage({ role: "assistant", content: [{ type: "toolCall", id: "tool-1", toolName: "read", arguments: { path: "README.md" } }], timestamp: "now" }, { + entryId: "entry-1", + decorateContent: (content) => (content as Array>).map((part) => ({ ...part, startedAt: "then" })), + }); + expect(projected).toMatchObject({ entryId: "entry-1", role: "assistant", toolCalls: [{ id: "tool-1", toolName: "read", startedAt: "then" }] }); + }); + + it("returns wire-stable state, stats, tree, and command DTOs", () => { + const session = fixtureSession(); + const results = [ + projectSessionState(session, "/tmp"), + sessionStats(session), + conversationTreeForSession(session), + getSessionSlashCommands(session), + ]; + for (const result of results) expect(roundTrip(result)).toStrictEqual(result); + }); +}); From 37d0a70e34034ac5153ab1a0085ad97da26d82ea Mon Sep 17 00:00:00 2001 From: Ashwin Pc Date: Sat, 18 Jul 2026 15:10:03 -0700 Subject: [PATCH 03/10] Separate realtime and session activity --- server.ts | 391 ++++--------------------------------- server/realtime.ts | 137 +++++++++++++ server/session/activity.ts | 212 ++++++++++++++++++++ 3 files changed, 387 insertions(+), 353 deletions(-) create mode 100644 server/realtime.ts create mode 100644 server/session/activity.ts diff --git a/server.ts b/server.ts index 5b5984e..1ecf4e6 100644 --- a/server.ts +++ b/server.ts @@ -29,6 +29,8 @@ import { gitCommitDetails, gitCwdFromRepoParam, gitDiff, gitLog, gitStatus, gitS import type { PiWebFooter, PiWebGitTab, PiWebHeaderAction, PiWebUi } from "./src/extensions.js"; import type { PiWebSession } from "./server/types.js"; import type { SlashCommandDto } from "./server/session/dto.js"; +import { SessionActivity } from "./server/session/activity.js"; +import { RealtimeHub, SessionUnreadTracker } from "./server/realtime.js"; import { conversationTreeForSession, getSessionSlashCommands, @@ -245,79 +247,10 @@ async function persistPromptImages(images: Array<{ data: string; mimeType: strin return `\n\nAttached image file${images.length === 1 ? "" : "s"}:\n${lines.join("\n")}`; } -function toolRuntimeKey(toolCallId: unknown, toolName: unknown) { - const id = typeof toolCallId === "string" ? toolCallId.trim() : ""; - if (id) return id; - return typeof toolName === "string" && toolName.trim() ? toolName.trim() : ""; -} - -function toolStartedAtFor(sessionFile: string | undefined, toolCallId: unknown, toolName: unknown) { - const key = toolRuntimeKey(toolCallId, toolName); - return sessionFile && key ? toolStartedAts.get(sessionFile)?.get(key) : undefined; -} - -function contentWithToolStartedAts(content: unknown, sessionFile?: string) { - if (!sessionFile || !Array.isArray(content)) return content; - return content.map((part) => { - if (!part || typeof part !== "object") return part; - const value = part as Record; - if (value.type !== "toolCall") return part; - const toolName = value.toolName || value.name; - const startedAt = toolStartedAtFor(sessionFile, value.id, toolName); - return startedAt && !value.startedAt ? { ...value, startedAt } : part; - }); -} - function sessionCwd(targetSession: PiWebSession | any = session) { return String(targetSession?.sessionManager?.getCwd?.() || targetSession?.cwd || piCwd); } -function runtimeStartedAtForPath(path: string, isRunning: boolean) { - if (!isRunning) return undefined; - const liveStartedAt = liveSessions.get(path)?.session?.runtimeStartedAt; - return typeof liveStartedAt === "string" && liveStartedAt.trim() ? liveStartedAt : runtimeStartedAts.get(path); -} - -function runtimeLastActivityAtForPath(path: string, isRunning: boolean) { - if (!isRunning) return undefined; - const liveLastActivityAt = liveSessions.get(path)?.session?.runtimeLastActivityAt; - return typeof liveLastActivityAt === "string" && liveLastActivityAt.trim() - ? liveLastActivityAt - : runtimeLastActivityAts.get(path) || runtimeStartedAtForPath(path, isRunning); -} - -function ensureRuntimeStartedAt(targetSession: any, startedAt = new Date().toISOString()) { - const key = sessionPathKey(targetSession); - const existing = key ? runtimeStartedAts.get(key) : undefined; - const value = typeof targetSession?.runtimeStartedAt === "string" ? targetSession.runtimeStartedAt : existing || startedAt; - if (key) { - runtimeStartedAts.set(key, value); - if (!runtimeLastActivityAts.has(key)) runtimeLastActivityAts.set(key, value); - } - if (targetSession && typeof targetSession === "object") { - targetSession.runtimeStartedAt = value; - if (typeof targetSession.runtimeLastActivityAt !== "string") targetSession.runtimeLastActivityAt = value; - } - return value; -} - -function markRuntimeActivity(targetSession: any, activityAt = new Date().toISOString(), sessionFile = sessionPathKey(targetSession)) { - if (sessionFile) runtimeLastActivityAts.set(sessionFile, activityAt); - if (targetSession && typeof targetSession === "object") targetSession.runtimeLastActivityAt = activityAt; - return activityAt; -} - -function clearRuntimeStartedAt(targetSession: any, sessionFile = sessionPathKey(targetSession)) { - if (sessionFile) { - runtimeStartedAts.delete(sessionFile); - runtimeLastActivityAts.delete(sessionFile); - } - if (targetSession && typeof targetSession === "object") { - delete targetSession.runtimeStartedAt; - delete targetSession.runtimeLastActivityAt; - } -} - type RetrySessionTarget = | { kind: "failure"; messages: any[]; index: number; message: any } | { kind: "aborted"; messages: any[]; index: number; message: any } @@ -419,76 +352,6 @@ async function retrySessionFromFailure(targetSession: PiWebSession) { } } -function runtimeForPath(path: string, overrides: { isRetrying?: boolean } = {}) { - const live = liveSessions.get(path)?.session; - const isStreaming = Boolean(live?.isStreaming); - const isRetrying = overrides.isRetrying ?? sessionIsRetrying(live); - const isCompacting = Boolean(live?.isCompacting); - const isRunning = isStreaming || isRetrying || isCompacting; - const startedAt = runtimeStartedAtForPath(path, isRunning); - const lastActivityAt = runtimeLastActivityAtForPath(path, isRunning); - return { - loaded: Boolean(live), - isRunning, - isStreaming, - isRetrying, - isCompacting, - startedAt, - lastActivityAt, - pendingMessageCount: Number(live?.pendingMessageCount || 0), - model: simplifyModel(live?.model), - }; -} - -function stoppedRuntimeForPath(path: string) { - const live = liveSessions.get(path)?.session; - return { - loaded: Boolean(live), - isRunning: false, - isStreaming: false, - isRetrying: false, - isCompacting: false, - startedAt: undefined, - lastActivityAt: undefined, - pendingMessageCount: Number(live?.pendingMessageCount || 0), - model: simplifyModel(live?.model), - }; -} - -function runtimeForEvent(path: string, event: any) { - if ((event?.type === "agent_end" || event?.type === "compaction_end") && event?.willRetry) { - return runtimeForPath(path, { isRetrying: true }); - } - return event?.type === "agent_end" || event?.type === "compaction_end" - ? stoppedRuntimeForPath(path) - : runtimeForPath(path); -} - -function isRuntimeActivityEvent(event: any) { - switch (event?.type) { - case "agent_start": - case "compaction_start": - case "message_update": - case "message_end": - case "turn_end": - case "tool_execution_start": - case "tool_execution_update": - case "tool_execution_end": - case "auto_retry_start": - case "auto_retry_end": - return true; - default: - return false; - } -} - -function runtimeActivityTimestamp(event: any, fallback = new Date().toISOString()) { - for (const value of [event?.lastActivityAt, event?.timestamp, event?.startedAt]) { - if (typeof value === "string" && value.trim()) return value.trim(); - } - return fallback; -} - function simplifySessionInfo(info: Awaited>[number], cwd = piCwd) { return { id: info.id, @@ -499,7 +362,7 @@ function simplifySessionInfo(info: Awaited { - clearRuntimeStartedAt(targetSession); + sessionActivity.clearStarted(targetSession); broadcast({ type: "server_error", sessionId: targetSession.sessionId, @@ -790,144 +653,24 @@ const websocketHeartbeatMs = envMs("PI_WEB_WS_HEARTBEAT_MS", 30_000); const websocketMaxMissedHeartbeats = Math.max(1, Math.floor(envMs("PI_WEB_WS_MAX_MISSED_HEARTBEATS", 3))); const liveSessions = new Map(); const viewerLeases = new Map(); -const runtimeStartedAts = new Map(); -const runtimeLastActivityAts = new Map(); -const toolStartedAts = new Map>(); +const sessionActivity = new SessionActivity((path) => liveSessions.get(path)?.session); let session: PiWebSession; let modelFallbackMessage: string | undefined; -type RealtimeSocket = WebSocket & { missedPongs?: number }; -const clients = new Set(); -type RealtimeEnvelope = Record & { seq: number }; -const realtimeEventLog: RealtimeEnvelope[] = []; -const maxRealtimeEventLogSize = 1000; -let nextRealtimeSeq = 1; - -function recordRealtimeMessage(value: unknown): RealtimeEnvelope { - const envelope = { ...(typeof value === "object" && value !== null ? value as Record : { value }), seq: nextRealtimeSeq++ }; - realtimeEventLog.push(envelope); - if (realtimeEventLog.length > maxRealtimeEventLogSize) realtimeEventLog.splice(0, realtimeEventLog.length - maxRealtimeEventLogSize); - return envelope; -} +let realtimeHub: RealtimeHub; +const unreadTracker = new SessionUnreadTracker(sessionUiStateStore, sessionActivity, (value) => realtimeHub.broadcast(value)); +realtimeHub = new RealtimeHub(websocketHeartbeatMs, websocketMaxMissedHeartbeats, (value) => unreadTracker.handle(value)); function broadcast(value: unknown) { - const envelope = recordRealtimeMessage(value); - const data = JSON.stringify(envelope); - for (const client of clients) { - if (client.readyState === client.OPEN) client.send(data); - } - queueUnreadStateFromBroadcast(value); -} - -function checkRealtimeHeartbeats() { - for (const client of clients) { - if (client.readyState === client.CLOSED || client.readyState === client.CLOSING) { - clients.delete(client); - continue; - } - if (client.readyState !== client.OPEN) continue; - const missedPongs = client.missedPongs || 0; - if (missedPongs >= websocketMaxMissedHeartbeats) { - client.terminate(); - continue; - } - client.missedPongs = missedPongs + 1; - try { - client.ping(); - } catch { - client.terminate(); - } - } -} - -if (websocketHeartbeatMs > 0) { - const realtimeHeartbeat = setInterval(checkRealtimeHeartbeats, websocketHeartbeatMs); - realtimeHeartbeat.unref?.(); -} - -function shouldClearSessionUnreadEvent(event: any) { - switch (event?.type) { - case "agent_start": - case "compaction_start": - return true; - default: - return false; - } -} - -function noteRuntimeEventForUnreadRecovery(data: Record) { - const sessionFile = typeof data.sessionFile === "string" ? data.sessionFile.trim() : ""; - if (!sessionFile) return; - const event = data.event; - switch (event?.type) { - case "agent_start": - case "compaction_start": { - const startedAt = typeof event.startedAt === "string" && event.startedAt.trim() ? event.startedAt.trim() : new Date().toISOString(); - runtimeStartedAts.set(sessionFile, startedAt); - runtimeLastActivityAts.set(sessionFile, runtimeActivityTimestamp(event, startedAt)); - return; - } - case "agent_end": - case "compaction_end": - if (!event.willRetry) { - runtimeStartedAts.delete(sessionFile); - runtimeLastActivityAts.delete(sessionFile); - } - return; - default: - if (isRuntimeActivityEvent(event)) runtimeLastActivityAts.set(sessionFile, runtimeActivityTimestamp(event)); - return; - } -} - -function broadcastSessionUiStateUpdate(operation: Promise, warning: string) { - void operation - .then((sessionUiState) => broadcast({ type: "session_ui_state_changed", sessionUiState })) - .catch((error) => console.warn(warning, error)); + realtimeHub.broadcast(value); } function markSessionUnreadCompleted(sessionId: string, unreadAt = new Date().toISOString()) { - broadcastSessionUiStateUpdate(sessionUiStateStore.markUnread(sessionId, unreadAt), "Could not mark session unread:"); + unreadTracker.markCompleted(sessionId, unreadAt); } function clearSessionUnread(sessionId: string) { - broadcastSessionUiStateUpdate(sessionUiStateStore.markRead(sessionId), "Could not clear session unread state:"); -} - -function shouldMarkSessionUnreadEvent(event: any) { - // Unread means a background session completed and may need attention. - // Do not mark on message_end: pi can emit it for the user's submitted - // message before the assistant response has finished. - if (!event || event.aborted || event.willRetry) return false; - switch (event.type) { - case "agent_end": - case "compaction_end": - return true; - default: - return false; - } -} - -function unreadTimestampForEvent(event: any) { - for (const value of [event?.timestamp, event?.endedAt, event?.startedAt]) { - if (typeof value === "string" && value.trim()) return value.trim(); - } - return new Date().toISOString(); -} - -function queueUnreadStateFromBroadcast(value: unknown) { - if (!value || typeof value !== "object") return; - const data = value as Record; - if (data.type !== "pi_event") return; - noteRuntimeEventForUnreadRecovery(data); - const sessionId = typeof data.sessionId === "string" ? data.sessionId.trim() : ""; - if (!sessionId) return; - if (shouldClearSessionUnreadEvent(data.event)) { - clearSessionUnread(sessionId); - return; - } - if (!shouldMarkSessionUnreadEvent(data.event)) return; - markSessionUnreadCompleted(sessionId, unreadTimestampForEvent(data.event)); + unreadTracker.clear(sessionId); } const plainExtensionTheme = { @@ -1147,7 +890,7 @@ function requestExtensionUi( defaultValue: T, parse: (response: Record) => T, ): Promise { - if (opts?.signal?.aborted || clients.size === 0) return Promise.resolve(defaultValue); + if (opts?.signal?.aborted || realtimeHub.clientCount === 0) return Promise.resolve(defaultValue); return new Promise((resolvePromise) => { const id = randomUUID(); @@ -1359,18 +1102,6 @@ async function emitSessionShutdown(value: any) { await runner.emit({ type: "session_shutdown", reason: "quit" }); } -function clearSessionRuntimeMaps(key: string, value: any) { - runtimeStartedAts.delete(key); - runtimeLastActivityAts.delete(key); - toolStartedAts.delete(key); - const file = typeof value?.sessionFile === "string" ? value.sessionFile : ""; - if (file && file !== key) { - runtimeStartedAts.delete(file); - runtimeLastActivityAts.delete(file); - toolStartedAts.delete(file); - } -} - async function disposeLiveSession(key: string, reason: "idle" | "delete" | "reset" = "idle", force = false) { const entry = liveSessions.get(key); if (!entry || entry.disposing) return; @@ -1407,10 +1138,10 @@ async function disposeLiveSession(key: string, reason: "idle" | "delete" | "rese } liveSessions.delete(key); - clearSessionRuntimeMaps(key, value); + sessionActivity.clearSession(key, value); if (sessionId) { - broadcast({ type: "session_runtime_changed", sessionId, sessionFile, runtime: runtimeForPath(sessionFile) }); + broadcast({ type: "session_runtime_changed", sessionId, sessionFile, runtime: sessionActivity.runtimeForPath(sessionFile) }); } } @@ -1495,49 +1226,20 @@ function registerLiveSession(value: any) { if (!key || liveSessions.get(key)?.session === value) return value; const unsubscribe = value.subscribe?.((event: unknown) => { - const eventSessionFile = value.sessionFile; - const eventSessionId = value.sessionId; - - // Track models that fail with model_not_supported and remove them from the list. const e = event as any; - let eventForClient = e; - if (e?.type === "agent_start" || e?.type === "compaction_start") { - const startedAt = ensureRuntimeStartedAt(value, typeof e.startedAt === "string" ? e.startedAt : undefined); - eventForClient = { ...e, startedAt }; - } else if (e?.type === "agent_end" || e?.type === "compaction_end") { - if (!e.willRetry) clearRuntimeStartedAt(value, eventSessionFile); - } - - if (e?.type === "tool_execution_start") { - const toolKey = toolRuntimeKey(e.toolCallId, e.toolName); - const startedAt = typeof e.startedAt === "string" ? e.startedAt : new Date().toISOString(); - if (toolKey) { - let sessionToolStarts = toolStartedAts.get(eventSessionFile); - if (!sessionToolStarts) { - sessionToolStarts = new Map(); - toolStartedAts.set(eventSessionFile, sessionToolStarts); - } - sessionToolStarts.set(toolKey, startedAt); - } - eventForClient = { ...e, startedAt }; - } else if (e?.type === "tool_execution_update" || e?.type === "tool_execution_end") { - const toolKey = toolRuntimeKey(e.toolCallId, e.toolName); - const startedAt = toolKey ? toolStartedAts.get(eventSessionFile)?.get(toolKey) : undefined; - if (startedAt) eventForClient = { ...e, startedAt }; - if (e?.type === "tool_execution_end" && toolKey) toolStartedAts.get(eventSessionFile)?.delete(toolKey); - } + const enriched = sessionActivity.enrichEvent(value, event); + const eventSessionFile = enriched.sessionFile; + const eventSessionId = enriched.sessionId; + const eventForClient = enriched.event; - if (isRuntimeActivityEvent(e)) { - const lastActivityAt = markRuntimeActivity(value, runtimeActivityTimestamp(eventForClient), eventSessionFile); - eventForClient = { ...eventForClient, lastActivityAt }; - } + // Track models that fail with model_not_supported and remove them from the list. broadcast({ type: "pi_event", sessionId: eventSessionId, sessionFile: eventSessionFile, event: eventForClient }); broadcast({ type: "session_runtime_changed", sessionId: eventSessionId, sessionFile: eventSessionFile, - runtime: runtimeForEvent(eventSessionFile, e), + runtime: sessionActivity.runtimeForEvent(eventSessionFile, e), }); // Broadcast state update when session name changes @@ -1951,7 +1653,7 @@ const server = createServer(async (req, res) => { replaceInstructions: Boolean(body.replaceInstructions), label: typeof body.label === "string" && body.label.trim() ? body.label.trim() : undefined, }); - broadcast({ type: "session_runtime_changed", sessionId: targetSession.sessionId, sessionFile: targetSession.sessionFile, runtime: runtimeForPath(targetSession.sessionFile) }); + broadcast({ type: "session_runtime_changed", sessionId: targetSession.sessionId, sessionFile: targetSession.sessionFile, runtime: sessionActivity.runtimeForPath(targetSession.sessionFile) }); const result = await navigation; const state = currentStateWithThinkingLevels(targetSession); broadcast({ type: "state_changed", ...state }); @@ -1960,7 +1662,7 @@ const server = createServer(async (req, res) => { return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }); } finally { releaseWorkLease(); - broadcast({ type: "session_runtime_changed", sessionId: targetSession.sessionId, sessionFile: targetSession.sessionFile, runtime: runtimeForPath(targetSession.sessionFile) }); + broadcast({ type: "session_runtime_changed", sessionId: targetSession.sessionId, sessionFile: targetSession.sessionFile, runtime: sessionActivity.runtimeForPath(targetSession.sessionFile) }); } } @@ -1991,7 +1693,7 @@ const server = createServer(async (req, res) => { } } const refs = messageEntryRefs(targetSession); - return sendJson(res, 200, { ok: true, messages: msgs.map((m: unknown, index: number) => simplifyMessage(m, { toolCallArgs, decorateContent: (content) => contentWithToolStartedAts(content, targetSession.sessionFile), entryId: refs[index]?.entryId })) }); + return sendJson(res, 200, { ok: true, messages: msgs.map((m: unknown, index: number) => simplifyMessage(m, { toolCallArgs, decorateContent: (content) => sessionActivity.decorateMessageContent(content, targetSession.sessionFile), entryId: refs[index]?.entryId })) }); } if (method === "GET" && url.pathname === "/api/sessions") { @@ -2147,7 +1849,7 @@ const server = createServer(async (req, res) => { const imageFileNote = await persistPromptImages(images, sessionCwd(targetSession)); const promptText = `${message || "Please review the attached image."}${imageFileNote}`; const wasAlreadyRunning = Boolean(targetSession.isStreaming || targetSession.isCompacting); - if (!wasAlreadyRunning) ensureRuntimeStartedAt(targetSession); + if (!wasAlreadyRunning) sessionActivity.ensureStarted(targetSession); const promptSessionFile = targetSession.sessionFile; const releaseWorkLease = acquireWorkLease(targetSession); void targetSession.prompt(promptText, { @@ -2164,16 +1866,16 @@ const server = createServer(async (req, res) => { }) .finally(() => { const isRunning = Boolean(targetSession.isStreaming || targetSession.isCompacting); - const missedTerminalEvent = Boolean(promptSessionFile && runtimeStartedAts.has(promptSessionFile) && !isRunning); + const missedTerminalEvent = Boolean(promptSessionFile && sessionActivity.hasStarted(promptSessionFile) && !isRunning); if (missedTerminalEvent) { - clearRuntimeStartedAt(targetSession, promptSessionFile); + sessionActivity.clearStarted(targetSession, promptSessionFile); markSessionUnreadCompleted(targetSession.sessionId); } broadcast({ type: "session_runtime_changed", sessionId: targetSession.sessionId, sessionFile: targetSession.sessionFile, - runtime: runtimeForPath(targetSession.sessionFile), + runtime: sessionActivity.runtimeForPath(targetSession.sessionFile), }); releaseWorkLease(); }); @@ -2192,12 +1894,12 @@ const server = createServer(async (req, res) => { return sendJson(res, 409, { ok: false, error: error instanceof Error ? error.message : String(error) }); } - ensureRuntimeStartedAt(targetSession); + sessionActivity.ensureStarted(targetSession); const retrySessionFile = targetSession.sessionFile; const releaseWorkLease = acquireWorkLease(targetSession); void retrySessionFromFailure(targetSession) .catch((error: unknown) => { - clearRuntimeStartedAt(targetSession, retrySessionFile); + sessionActivity.clearStarted(targetSession, retrySessionFile); broadcast({ type: "server_error", sessionId: targetSession.sessionId, @@ -2207,16 +1909,16 @@ const server = createServer(async (req, res) => { }) .finally(() => { const isRunning = Boolean(targetSession.isStreaming || targetSession.isCompacting); - const missedTerminalEvent = Boolean(retrySessionFile && runtimeStartedAts.has(retrySessionFile) && !isRunning); + const missedTerminalEvent = Boolean(retrySessionFile && sessionActivity.hasStarted(retrySessionFile) && !isRunning); if (missedTerminalEvent) { - clearRuntimeStartedAt(targetSession, retrySessionFile); + sessionActivity.clearStarted(targetSession, retrySessionFile); markSessionUnreadCompleted(targetSession.sessionId); } broadcast({ type: "session_runtime_changed", sessionId: targetSession.sessionId, sessionFile: targetSession.sessionFile, - runtime: runtimeForPath(targetSession.sessionFile), + runtime: sessionActivity.runtimeForPath(targetSession.sessionFile), }); releaseWorkLease(); }); @@ -2337,26 +2039,10 @@ server.on("upgrade", (req, socket, head) => { }); wss.on("connection", async (ws, req) => { - const realtimeWs = ws as RealtimeSocket; - realtimeWs.missedPongs = 0; - realtimeWs.on("pong", () => { - realtimeWs.missedPongs = 0; - }); - clients.add(realtimeWs); + const realtimeWs = ws; const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`); const lastSeq = Number(url.searchParams.get("lastSeq") || 0); - const latestSeq = nextRealtimeSeq - 1; - const oldestSeq = realtimeEventLog[0]?.seq || nextRealtimeSeq; - - if (Number.isFinite(lastSeq) && lastSeq > 0) { - if (lastSeq > latestSeq || lastSeq < oldestSeq - 1) { - ws.send(JSON.stringify({ type: "sync_required", latestSeq })); - } else { - for (const event of realtimeEventLog) { - if (event.seq > lastSeq) ws.send(JSON.stringify({ ...event, replay: true })); - } - } - } + const latestSeq = realtimeHub.attach(realtimeWs, lastSeq); const requestedSessionId = url.searchParams.get("sessionId") || session.sessionId; const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); @@ -2371,7 +2057,6 @@ wss.on("connection", async (ws, req) => { seq: latestSeq, ...helloState, })); - realtimeWs.on("close", () => clients.delete(realtimeWs)); }); if (isDev) { diff --git a/server/realtime.ts b/server/realtime.ts new file mode 100644 index 0000000..22957ea --- /dev/null +++ b/server/realtime.ts @@ -0,0 +1,137 @@ +import type { WebSocket } from "ws"; +import type { SessionActivity } from "./session/activity.js"; + +type RealtimeSocket = WebSocket & { missedPongs?: number }; +export type RealtimeEnvelope = Record & { seq: number }; + +export class RealtimeHub { + private readonly clients = new Set(); + private readonly eventLog: RealtimeEnvelope[] = []; + private nextSeq = 1; + + constructor( + heartbeatMs: number, + private readonly maxMissedHeartbeats: number, + private readonly onBroadcast: (value: unknown) => void, + private readonly maxEventLogSize = 1000, + ) { + if (heartbeatMs > 0) { + const timer = setInterval(() => this.checkHeartbeats(), heartbeatMs); + timer.unref?.(); + } + } + + get clientCount(): number { + return this.clients.size; + } + + get latestSeq(): number { + return this.nextSeq - 1; + } + + private record(value: unknown): RealtimeEnvelope { + const envelope = { ...(typeof value === "object" && value !== null ? value as Record : { value }), seq: this.nextSeq++ }; + this.eventLog.push(envelope); + if (this.eventLog.length > this.maxEventLogSize) this.eventLog.splice(0, this.eventLog.length - this.maxEventLogSize); + return envelope; + } + + broadcast(value: unknown): void { + const data = JSON.stringify(this.record(value)); + for (const client of this.clients) { + if (client.readyState === client.OPEN) client.send(data); + } + this.onBroadcast(value); + } + + attach(ws: WebSocket, lastSeq: number): number { + const client = ws as RealtimeSocket; + client.missedPongs = 0; + client.on("pong", () => { client.missedPongs = 0; }); + this.clients.add(client); + + const latestSeq = this.latestSeq; + const oldestSeq = this.eventLog[0]?.seq || this.nextSeq; + if (Number.isFinite(lastSeq) && lastSeq > 0) { + if (lastSeq > latestSeq || lastSeq < oldestSeq - 1) { + client.send(JSON.stringify({ type: "sync_required", latestSeq })); + } else { + for (const event of this.eventLog) { + if (event.seq > lastSeq) client.send(JSON.stringify({ ...event, replay: true })); + } + } + } + client.on("close", () => this.clients.delete(client)); + return latestSeq; + } + + private checkHeartbeats(): void { + for (const client of this.clients) { + if (client.readyState === client.CLOSED || client.readyState === client.CLOSING) { + this.clients.delete(client); + continue; + } + if (client.readyState !== client.OPEN) continue; + const missedPongs = client.missedPongs || 0; + if (missedPongs >= this.maxMissedHeartbeats) { + client.terminate(); + continue; + } + client.missedPongs = missedPongs + 1; + try { client.ping(); } catch { client.terminate(); } + } + } +} + +interface SessionUnreadStateStore { + markUnread(sessionId: string, unreadAt: string): Promise; + markRead(sessionId: string): Promise; +} + +export class SessionUnreadTracker { + constructor( + private readonly store: SessionUnreadStateStore, + private readonly activity: SessionActivity, + private readonly emit: (value: unknown) => void, + ) {} + + handle(value: unknown): void { + if (!value || typeof value !== "object") return; + const data = value as Record; + if (data.type !== "pi_event") return; + const sessionFile = typeof data.sessionFile === "string" ? data.sessionFile.trim() : ""; + if (sessionFile) this.activity.noteEvent(sessionFile, data.event); + const sessionId = typeof data.sessionId === "string" ? data.sessionId.trim() : ""; + if (!sessionId) return; + if (data.event?.type === "agent_start" || data.event?.type === "compaction_start") { + this.update(this.store.markRead(sessionId), "Could not clear session unread state:"); + return; + } + if (!this.shouldMark(data.event)) return; + this.update(this.store.markUnread(sessionId, this.timestamp(data.event)), "Could not mark session unread:"); + } + + markCompleted(sessionId: string, unreadAt = new Date().toISOString()): void { + this.update(this.store.markUnread(sessionId, unreadAt), "Could not mark session unread:"); + } + + clear(sessionId: string): void { + this.update(this.store.markRead(sessionId), "Could not clear session unread state:"); + } + + private update(operation: Promise, warning: string): void { + void operation.then((sessionUiState) => this.emit({ type: "session_ui_state_changed", sessionUiState })).catch((error) => console.warn(warning, error)); + } + + private shouldMark(event: any): boolean { + if (!event || event.aborted || event.willRetry) return false; + return event.type === "agent_end" || event.type === "compaction_end"; + } + + private timestamp(event: any): string { + for (const value of [event?.timestamp, event?.endedAt, event?.startedAt]) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + return new Date().toISOString(); + } +} diff --git a/server/session/activity.ts b/server/session/activity.ts new file mode 100644 index 0000000..ccce021 --- /dev/null +++ b/server/session/activity.ts @@ -0,0 +1,212 @@ +import type { PiWebSession } from "../types.js"; +import { sessionIsRetrying, simplifyModel } from "./projection.js"; + +export interface EnrichedSessionEvent { + event: any; + sessionId: string; + sessionFile: string; +} + +export class SessionActivity { + private readonly runtimeStartedAts = new Map(); + private readonly runtimeLastActivityAts = new Map(); + private readonly toolStartedAts = new Map>(); + + constructor(private readonly liveSessionForPath: (path: string) => PiWebSession | undefined) {} + + sessionPathKey(value: any): string { + return String(value?.sessionFile || value?.sessionId || ""); + } + + toolRuntimeKey(toolCallId: unknown, toolName: unknown): string { + const id = typeof toolCallId === "string" ? toolCallId.trim() : ""; + if (id) return id; + return typeof toolName === "string" && toolName.trim() ? toolName.trim() : ""; + } + + toolStartedAtFor(sessionFile: string | undefined, toolCallId: unknown, toolName: unknown): string | undefined { + const key = this.toolRuntimeKey(toolCallId, toolName); + return sessionFile && key ? this.toolStartedAts.get(sessionFile)?.get(key) : undefined; + } + + decorateMessageContent(content: unknown, sessionFile?: string): unknown { + if (!sessionFile || !Array.isArray(content)) return content; + return content.map((part) => { + if (!part || typeof part !== "object") return part; + const value = part as Record; + if (value.type !== "toolCall") return part; + const startedAt = this.toolStartedAtFor(sessionFile, value.id, value.toolName || value.name); + return startedAt && !value.startedAt ? { ...value, startedAt } : part; + }); + } + + hasStarted(path: string): boolean { + return this.runtimeStartedAts.has(path); + } + + startedAtForPath(path: string, isRunning: boolean): string | undefined { + if (!isRunning) return undefined; + const liveStartedAt = (this.liveSessionForPath(path) as any)?.runtimeStartedAt; + return typeof liveStartedAt === "string" && liveStartedAt.trim() ? liveStartedAt : this.runtimeStartedAts.get(path); + } + + lastActivityAtForPath(path: string, isRunning: boolean): string | undefined { + if (!isRunning) return undefined; + const liveLastActivityAt = (this.liveSessionForPath(path) as any)?.runtimeLastActivityAt; + return typeof liveLastActivityAt === "string" && liveLastActivityAt.trim() + ? liveLastActivityAt + : this.runtimeLastActivityAts.get(path) || this.startedAtForPath(path, isRunning); + } + + ensureStarted(targetSession: any, startedAt = new Date().toISOString()): string { + const key = this.sessionPathKey(targetSession); + const existing = key ? this.runtimeStartedAts.get(key) : undefined; + const value = typeof targetSession?.runtimeStartedAt === "string" ? targetSession.runtimeStartedAt : existing || startedAt; + if (key) { + this.runtimeStartedAts.set(key, value); + if (!this.runtimeLastActivityAts.has(key)) this.runtimeLastActivityAts.set(key, value); + } + if (targetSession && typeof targetSession === "object") { + targetSession.runtimeStartedAt = value; + if (typeof targetSession.runtimeLastActivityAt !== "string") targetSession.runtimeLastActivityAt = value; + } + return value; + } + + mark(targetSession: any, activityAt = new Date().toISOString(), sessionFile = this.sessionPathKey(targetSession)): string { + if (sessionFile) this.runtimeLastActivityAts.set(sessionFile, activityAt); + if (targetSession && typeof targetSession === "object") targetSession.runtimeLastActivityAt = activityAt; + return activityAt; + } + + clearStarted(targetSession: any, sessionFile = this.sessionPathKey(targetSession)): void { + if (sessionFile) { + this.runtimeStartedAts.delete(sessionFile); + this.runtimeLastActivityAts.delete(sessionFile); + } + if (targetSession && typeof targetSession === "object") { + delete targetSession.runtimeStartedAt; + delete targetSession.runtimeLastActivityAt; + } + } + + clearSession(key: string, value: any): void { + this.runtimeStartedAts.delete(key); + this.runtimeLastActivityAts.delete(key); + this.toolStartedAts.delete(key); + const file = typeof value?.sessionFile === "string" ? value.sessionFile : ""; + if (file && file !== key) { + this.runtimeStartedAts.delete(file); + this.runtimeLastActivityAts.delete(file); + this.toolStartedAts.delete(file); + } + } + + runtimeForPath(path: string, overrides: { isRetrying?: boolean } = {}) { + const live = this.liveSessionForPath(path); + const isStreaming = Boolean(live?.isStreaming); + const isRetrying = overrides.isRetrying ?? sessionIsRetrying(live); + const isCompacting = Boolean(live?.isCompacting); + const isRunning = isStreaming || isRetrying || isCompacting; + return { + loaded: Boolean(live), + isRunning, + isStreaming, + isRetrying, + isCompacting, + startedAt: this.startedAtForPath(path, isRunning), + lastActivityAt: this.lastActivityAtForPath(path, isRunning), + pendingMessageCount: Number(live?.pendingMessageCount || 0), + model: simplifyModel(live?.model), + }; + } + + stoppedRuntimeForPath(path: string) { + const live = this.liveSessionForPath(path); + return { + loaded: Boolean(live), + isRunning: false, + isStreaming: false, + isRetrying: false, + isCompacting: false, + startedAt: undefined, + lastActivityAt: undefined, + pendingMessageCount: Number(live?.pendingMessageCount || 0), + model: simplifyModel(live?.model), + }; + } + + runtimeForEvent(path: string, event: any) { + if ((event?.type === "agent_end" || event?.type === "compaction_end") && event?.willRetry) { + return this.runtimeForPath(path, { isRetrying: true }); + } + return event?.type === "agent_end" || event?.type === "compaction_end" + ? this.stoppedRuntimeForPath(path) + : this.runtimeForPath(path); + } + + isActivityEvent(event: any): boolean { + return ["agent_start", "compaction_start", "message_update", "message_end", "turn_end", "tool_execution_start", "tool_execution_update", "tool_execution_end", "auto_retry_start", "auto_retry_end"].includes(event?.type); + } + + activityTimestamp(event: any, fallback = new Date().toISOString()): string { + for (const value of [event?.lastActivityAt, event?.timestamp, event?.startedAt]) { + if (typeof value === "string" && value.trim()) return value.trim(); + } + return fallback; + } + + noteEvent(sessionFile: string, event: any): void { + if (!sessionFile) return; + switch (event?.type) { + case "agent_start": + case "compaction_start": { + const startedAt = typeof event.startedAt === "string" && event.startedAt.trim() ? event.startedAt.trim() : new Date().toISOString(); + this.runtimeStartedAts.set(sessionFile, startedAt); + this.runtimeLastActivityAts.set(sessionFile, this.activityTimestamp(event, startedAt)); + return; + } + case "agent_end": + case "compaction_end": + if (!event.willRetry) { + this.runtimeStartedAts.delete(sessionFile); + this.runtimeLastActivityAts.delete(sessionFile); + } + return; + default: + if (this.isActivityEvent(event)) this.runtimeLastActivityAts.set(sessionFile, this.activityTimestamp(event)); + } + } + + enrichEvent(targetSession: PiWebSession, event: unknown): EnrichedSessionEvent { + const e = event as any; + const sessionFile = targetSession.sessionFile; + let eventForClient = e; + if (e?.type === "agent_start" || e?.type === "compaction_start") { + eventForClient = { ...e, startedAt: this.ensureStarted(targetSession, typeof e.startedAt === "string" ? e.startedAt : undefined) }; + } else if ((e?.type === "agent_end" || e?.type === "compaction_end") && !e.willRetry) { + this.clearStarted(targetSession, sessionFile); + } + + if (e?.type === "tool_execution_start") { + const toolKey = this.toolRuntimeKey(e.toolCallId, e.toolName); + const startedAt = typeof e.startedAt === "string" ? e.startedAt : new Date().toISOString(); + if (toolKey) { + let starts = this.toolStartedAts.get(sessionFile); + if (!starts) this.toolStartedAts.set(sessionFile, starts = new Map()); + starts.set(toolKey, startedAt); + } + eventForClient = { ...eventForClient, startedAt }; + } else if (e?.type === "tool_execution_update" || e?.type === "tool_execution_end") { + const toolKey = this.toolRuntimeKey(e.toolCallId, e.toolName); + const startedAt = toolKey ? this.toolStartedAts.get(sessionFile)?.get(toolKey) : undefined; + if (startedAt) eventForClient = { ...eventForClient, startedAt }; + if (e?.type === "tool_execution_end" && toolKey) this.toolStartedAts.get(sessionFile)?.delete(toolKey); + } + + if (this.isActivityEvent(e)) { + eventForClient = { ...eventForClient, lastActivityAt: this.mark(targetSession, this.activityTimestamp(eventForClient), sessionFile) }; + } + return { event: eventForClient, sessionId: targetSession.sessionId, sessionFile }; + } +} From 4dc9e9420a1afdfba957a89f102af441712dc9c5 Mon Sep 17 00:00:00 2001 From: Ashwin Pc Date: Sat, 18 Jul 2026 15:11:43 -0700 Subject: [PATCH 04/10] Invert extension web UI through events --- server.ts | 427 ++---------------------------------- server/extensions/webUi.ts | 430 +++++++++++++++++++++++++++++++++++++ 2 files changed, 451 insertions(+), 406 deletions(-) create mode 100644 server/extensions/webUi.ts diff --git a/server.ts b/server.ts index 1ecf4e6..0d6c699 100644 --- a/server.ts +++ b/server.ts @@ -15,8 +15,6 @@ import { getAgentDir, ModelRegistry, SessionManager, - type ExtensionUIDialogOptions, - type ExtensionUIContext, type SessionStartEvent, } from "@earendil-works/pi-coding-agent"; import { createMockHarness } from "./server/mock.js"; @@ -26,11 +24,11 @@ import { createSettingsStore } from "./server/settings.js"; import { findArtifactFile, isValidArtifactName, safeArtifactName } from "./server/shared/artifacts.js"; import { assertDirectory, createDirectory, listDirectories } from "./server/shared/fsList.js"; import { gitCommitDetails, gitCwdFromRepoParam, gitDiff, gitLog, gitStatus, gitSync, isGitRepo, listGitRepos, readGitImage } from "./server/shared/git.js"; -import type { PiWebFooter, PiWebGitTab, PiWebHeaderAction, PiWebUi } from "./src/extensions.js"; import type { PiWebSession } from "./server/types.js"; import type { SlashCommandDto } from "./server/session/dto.js"; import { SessionActivity } from "./server/session/activity.js"; import { RealtimeHub, SessionUnreadTracker } from "./server/realtime.js"; +import { createWebUiBridge } from "./server/extensions/webUi.js"; import { conversationTreeForSession, getSessionSlashCommands, @@ -406,9 +404,7 @@ function currentState(targetSession: PiWebSession = session) { ? (targetSession as any).runtimeLastActivityAt : sessionActivity.lastActivityAtForPath(targetSession.sessionFile, isRunning), runtime: sessionActivity.runtimeForPath(targetSession.sessionFile), - webFooters: webFooterEntries(targetSession), - webHeaderActions: webHeaderActionEntries(targetSession), - webGitTabs: webGitTabEntries(targetSession), + ...webUiBridge.entries(targetSession), }; } @@ -673,375 +669,6 @@ function clearSessionUnread(sessionId: string) { unreadTracker.clear(sessionId); } -const plainExtensionTheme = { - fg: (_color: string, text: string) => text, - bg: (_color: string, text: string) => text, - bold: (text: string) => text, - italic: (text: string) => text, - underline: (text: string) => text, - inverse: (text: string) => text, - strikethrough: (text: string) => text, - getFgAnsi: () => "", - getBgAnsi: () => "", - getColorMode: () => "truecolor", - getThinkingBorderColor: () => (text: string) => text, - getBashModeBorderColor: () => (text: string) => text, -}; - -type PendingExtensionUiRequest = { - resolve: (response: Record) => void; - cleanup: () => void; -}; -const pendingExtensionUiRequests = new Map(); - -type WebFooterState = { - footers: Map; -}; - -type WebHeaderActionState = { - actions: Map; -}; - -type WebGitTabState = { - tabs: Map; -}; - -const webFooterStates = new WeakMap(); -const webHeaderActionStates = new WeakMap(); -const webGitTabStates = new WeakMap(); - -function getWebFooterState(value: any): WebFooterState { - const key = value as object; - let state = webFooterStates.get(key); - if (!state) { - state = { footers: new Map() }; - webFooterStates.set(key, state); - } - return state; -} - -function cleanFooterKey(value: unknown) { - if (typeof value !== "string") return undefined; - const cleaned = value.trim().slice(0, 80).replace(/[^a-zA-Z0-9_.:-]/g, "-"); - return cleaned || undefined; -} - -const cleanHeaderActionKey = cleanFooterKey; -const cleanGitTabKey = cleanFooterKey; - -function cleanHeaderActionText(value: unknown, maxLength = 200) { - if (typeof value !== "string") return undefined; - const cleaned = value.replace(/[\u0000-\u001F\u007F]/g, "").trim(); - return cleaned ? cleaned.slice(0, maxLength) : undefined; -} - -function getWebHeaderActionState(value: any): WebHeaderActionState { - const key = value as object; - let state = webHeaderActionStates.get(key); - if (!state) { - state = { actions: new Map() }; - webHeaderActionStates.set(key, state); - } - return state; -} - -function getWebGitTabState(value: any): WebGitTabState { - const key = value as object; - let state = webGitTabStates.get(key); - if (!state) { - state = { tabs: new Map() }; - webGitTabStates.set(key, state); - } - return state; -} - -function cleanFooterText(value: unknown, maxLength = 2_000) { - if (typeof value !== "string") return undefined; - const cleaned = value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "").trimEnd(); - return cleaned ? cleaned.slice(0, maxLength) : undefined; -} - -function normalizeTextLines(value: unknown) { - const rawLines = Array.isArray(value) ? value : typeof value === "string" ? [value] : []; - const lines = rawLines.slice(0, 8).map((line) => cleanFooterText(line)).filter((line): line is string => Boolean(line)); - return lines.length ? { kind: "text" as const, lines } : undefined; -} - -function normalizePiWebFooter(value: unknown): PiWebFooter | undefined { - if (typeof value === "string" || Array.isArray(value)) return normalizeTextLines(value); - if (!value || typeof value !== "object") return undefined; - const footer = value as Record; - if (footer.kind === "text") return normalizeTextLines(footer.lines); - if (footer.kind === "html") { - const html = cleanFooterText(footer.html, 20_000); - return html ? { kind: "html", html } : undefined; - } - return undefined; -} - -function webFooterEntries(value: any) { - return Array.from(getWebFooterState(value).footers.entries()).map(([key, footer]) => ({ key, footer })); -} - -function broadcastWebFooters(value: any) { - const webFooters = webFooterEntries(value); - broadcast({ - type: "web_footer_changed", - sessionId: value.sessionId, - sessionFile: value.sessionFile, - webFooters, - }); - return webFooters; -} - -function webHeaderActionEntries(value: any) { - return Array.from(getWebHeaderActionState(value).actions.entries()).map(([key, action]) => ({ - key, - icon: cleanHeaderActionText(action.icon, 80), - title: cleanHeaderActionText(action.title) || key, - label: cleanHeaderActionText(action.label), - })); -} - -function broadcastWebHeaderActions(value: any) { - const webHeaderActions = webHeaderActionEntries(value); - broadcast({ - type: "web_header_actions_changed", - sessionId: value.sessionId, - sessionFile: value.sessionFile, - webHeaderActions, - }); - return webHeaderActions; -} - -function webGitTabEntries(value: any) { - return Array.from(getWebGitTabState(value).tabs.entries()).map(([key, tab]) => ({ - key, - title: cleanHeaderActionText(tab.title) || key, - label: cleanHeaderActionText(tab.label, 80), - })); -} - -function broadcastWebGitTabs(value: any) { - const webGitTabs = webGitTabEntries(value); - broadcast({ - type: "web_git_tabs_changed", - sessionId: value.sessionId, - sessionFile: value.sessionFile, - webGitTabs, - }); - return webGitTabs; -} - -function createPiWebUi(value: any): PiWebUi { - return { - setFooter(key, footer) { - const footerKey = cleanFooterKey(key); - if (!footerKey) return; - const footerState = getWebFooterState(value); - const normalized = normalizePiWebFooter(footer); - if (normalized) footerState.footers.set(footerKey, normalized); - else footerState.footers.delete(footerKey); - broadcastWebFooters(value); - }, - setHeaderAction(key, action) { - const actionKey = cleanHeaderActionKey(key); - if (!actionKey) return; - const actionState = getWebHeaderActionState(value); - if (action && typeof action === "object" && typeof action.invoke === "function") { - actionState.actions.set(actionKey, action); - } else { - actionState.actions.delete(actionKey); - } - broadcastWebHeaderActions(value); - }, - setGitTab(key, tab) { - const tabKey = cleanGitTabKey(key); - if (!tabKey) return; - const tabState = getWebGitTabState(value); - if (tab && typeof tab === "object" && typeof tab.render === "function") { - tabState.tabs.set(tabKey, tab); - } else { - tabState.tabs.delete(tabKey); - } - broadcastWebGitTabs(value); - }, - }; -} - -function broadcastExtensionUiRequest(value: any, method: string, payload: Record) { - const id = randomUUID(); - broadcast({ - type: "extension_ui_request", - id, - method, - sessionId: value.sessionId, - sessionFile: value.sessionFile, - ...payload, - }); - return id; -} - -function requestExtensionUi( - value: any, - method: string, - payload: Record, - opts: ExtensionUIDialogOptions | undefined, - defaultValue: T, - parse: (response: Record) => T, -): Promise { - if (opts?.signal?.aborted || realtimeHub.clientCount === 0) return Promise.resolve(defaultValue); - - return new Promise((resolvePromise) => { - const id = randomUUID(); - const releaseWorkLease = acquireWorkLease(value); - let timeoutId: ReturnType | undefined; - - const cleanup = () => { - if (timeoutId) clearTimeout(timeoutId); - opts?.signal?.removeEventListener("abort", onAbort); - pendingExtensionUiRequests.delete(id); - releaseWorkLease(); - }; - const finish = (result: T) => { - cleanup(); - resolvePromise(result); - }; - const onAbort = () => finish(defaultValue); - - opts?.signal?.addEventListener("abort", onAbort, { once: true }); - if (opts?.timeout) timeoutId = setTimeout(() => finish(defaultValue), opts.timeout); - - pendingExtensionUiRequests.set(id, { - cleanup, - resolve: (response) => finish(parse(response)), - }); - - broadcast({ - type: "extension_ui_request", - id, - method, - sessionId: value.sessionId, - sessionFile: value.sessionFile, - timeout: opts?.timeout, - ...payload, - }); - }); -} - -function createWebExtensionUiContext(value: any): ExtensionUIContext & { web: PiWebUi } { - return { - web: createPiWebUi(value), - select: (title, options, opts) => requestExtensionUi( - value, - "select", - { title, options }, - opts, - undefined, - (response) => response.cancelled ? undefined : typeof response.value === "string" ? response.value : undefined, - ), - confirm: (title, message, opts) => requestExtensionUi( - value, - "confirm", - { title, message }, - opts, - false, - (response) => response.cancelled ? false : Boolean(response.confirmed), - ), - input: (title, placeholder, opts) => requestExtensionUi( - value, - "input", - { title, placeholder }, - opts, - undefined, - (response) => response.cancelled ? undefined : typeof response.value === "string" ? response.value : undefined, - ), - notify(message, type = "info") { - broadcastExtensionUiRequest(value, "notify", { message, notifyType: type }); - }, - onTerminalInput: () => () => undefined, - setStatus(key, text) { - broadcastExtensionUiRequest(value, "setStatus", { statusKey: key, statusText: text }); - }, - setWorkingMessage: () => undefined, - setWorkingVisible: () => undefined, - setWorkingIndicator: () => undefined, - setHiddenThinkingLabel: () => undefined, - setWidget(key, content, options) { - if (content === undefined || Array.isArray(content)) { - broadcastExtensionUiRequest(value, "setWidget", { widgetKey: key, widgetLines: content, widgetPlacement: options?.placement }); - } - }, - setFooter: () => undefined, - setHeader: () => undefined, - setTitle(title) { - broadcastExtensionUiRequest(value, "setTitle", { title }); - }, - async custom() { - return undefined as never; - }, - pasteToEditor(text) { - this.setEditorText(text); - }, - setEditorText(text) { - broadcastExtensionUiRequest(value, "set_editor_text", { text }); - }, - getEditorText: () => "", - editor: (title, prefill) => requestExtensionUi( - value, - "editor", - { title, prefill }, - undefined, - undefined, - (response) => response.cancelled ? undefined : typeof response.value === "string" ? response.value : undefined, - ), - addAutocompleteProvider: () => undefined, - setEditorComponent: () => undefined, - getEditorComponent: () => undefined, - theme: plainExtensionTheme as any, - getAllThemes: () => [], - getTheme: () => undefined, - setTheme: () => ({ success: false, error: "Theme switching is not supported in pi-web yet" }), - getToolsExpanded: () => false, - setToolsExpanded: () => undefined, - }; -} - -async function bindWebExtensions(value: any) { - if (typeof value.bindExtensions !== "function") return; - await value.bindExtensions({ - uiContext: createWebExtensionUiContext(value), - commandContextActions: { - waitForIdle: () => value.agent.waitForIdle(), - newSession: async () => { - const newSession = await createNewLiveSession(sessionCwd(value), value.sessionFile); - const state = currentStateWithThinkingLevels(newSession); - broadcast({ type: "state_changed", ...state }); - return { cancelled: false }; - }, - fork: async () => { - throw new Error("Extension-initiated fork is not supported in pi-web yet."); - }, - navigateTree: async (targetId: string, options: any) => { - const result = await value.navigateTree(targetId, options); - return { cancelled: Boolean(result?.cancelled) }; - }, - switchSession: async () => { - throw new Error("Extension-initiated session switching is not supported in pi-web yet."); - }, - reload: async () => { - await value.reload?.(); - }, - }, - shutdownHandler: () => { - broadcast({ type: "server_error", sessionId: value.sessionId, sessionFile: value.sessionFile, error: "An extension requested shutdown; pi-web ignored the request." }); - }, - onError: (error: any) => { - broadcast({ type: "server_error", sessionId: value.sessionId, sessionFile: value.sessionFile, error: `Extension error (${error.extensionPath}): ${error.error}` }); - }, - }); -} - const mockHarness = createMockHarness({ piCwd, broadcast, @@ -1311,7 +938,7 @@ async function makeAgentSession(path?: string, sessionStartEvent?: SessionStartE resourceLoader: loader, sessionStartEvent, }); - await bindWebExtensions(result.session); + await webUiBridge.bind(result.session); return result; } @@ -1401,6 +1028,15 @@ async function switchEmptySessionCwd(targetSession: PiWebSession, cwd: string) { return currentStateWithThinkingLevels(newSession); } +const webUiBridge = createWebUiBridge({ + emit: broadcast, + clientCount: () => realtimeHub.clientCount, + acquireWorkLease, + createNewSession: createNewLiveSession, + sessionCwd: (value) => sessionCwd(value), + state: (value) => currentStateWithThinkingLevels(value), +}); + await ensurePiWebStorage(); const createdSession = await makeAgentSession(); @@ -1573,17 +1209,12 @@ const server = createServer(async (req, res) => { const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId; const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); - const actionKey = cleanHeaderActionKey(body.key); - if (!actionKey) return sendJson(res, 400, { ok: false, error: "key is required" }); - const action = getWebHeaderActionState(targetSession).actions.get(actionKey); - if (!action) return sendJson(res, 404, { ok: false, error: "Header action not found" }); try { - const result = await action.invoke(); - const markdown = cleanFooterText(result?.markdown, 200_000); - if (!markdown) return sendJson(res, 400, { ok: false, error: "Header action returned no markdown" }); - return sendJson(res, 200, { ok: true, label: cleanHeaderActionText(action.label) || cleanHeaderActionText(action.title) || actionKey, markdown }); + return sendJson(res, 200, { ok: true, ...await webUiBridge.invokeHeaderAction(targetSession, body.key) }); } catch (error) { - return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }); + const message = error instanceof Error ? error.message : String(error); + const status = message === "key is required" || message === "Header action returned no markdown" ? 400 : message === "Header action not found" ? 404 : 500; + return sendJson(res, status, { ok: false, error: message }); } } @@ -1592,26 +1223,12 @@ const server = createServer(async (req, res) => { const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId; const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); - const tabKey = cleanGitTabKey(body.key); - if (!tabKey) return sendJson(res, 400, { ok: false, error: "key is required" }); - const tab = getWebGitTabState(targetSession).tabs.get(tabKey); - if (!tab) return sendJson(res, 404, { ok: false, error: "Git tab not found" }); try { - const repo = body.repo && typeof body.repo === "object" ? body.repo as Record : undefined; - const result = await tab.render({ - action: typeof body.action === "string" ? body.action : undefined, - payload: body.payload, - repo: repo ? { - path: typeof repo.path === "string" ? repo.path : undefined, - root: typeof repo.root === "string" ? repo.root : undefined, - branch: typeof repo.branch === "string" ? repo.branch : undefined, - } : undefined, - }); - const html = cleanFooterText(result?.html, 500_000); - if (!html) return sendJson(res, 400, { ok: false, error: "Git tab returned no HTML" }); - return sendJson(res, 200, { ok: true, title: cleanHeaderActionText(result?.title) || cleanHeaderActionText(tab.title) || tabKey, html }); + return sendJson(res, 200, { ok: true, ...await webUiBridge.invokeGitTab(targetSession, body) }); } catch (error) { - return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }); + const message = error instanceof Error ? error.message : String(error); + const status = message === "key is required" || message === "Git tab returned no HTML" ? 400 : message === "Git tab not found" ? 404 : 500; + return sendJson(res, status, { ok: false, error: message }); } } @@ -1821,9 +1438,7 @@ const server = createServer(async (req, res) => { const body = await readBody(req) as { id?: unknown } & Record; const id = String(body.id || "").trim(); if (!id) return sendJson(res, 400, { ok: false, error: "id is required" }); - const pending = pendingExtensionUiRequests.get(id); - if (!pending) return sendJson(res, 404, { ok: false, error: "Extension UI request not found" }); - pending.resolve(body); + if (!webUiBridge.respond(id, body)) return sendJson(res, 404, { ok: false, error: "Extension UI request not found" }); return sendJson(res, 200, { ok: true }); } diff --git a/server/extensions/webUi.ts b/server/extensions/webUi.ts new file mode 100644 index 0000000..4449ddf --- /dev/null +++ b/server/extensions/webUi.ts @@ -0,0 +1,430 @@ +import { randomUUID } from "node:crypto"; +import type { ExtensionUIDialogOptions, ExtensionUIContext } from "@earendil-works/pi-coding-agent"; +import type { PiWebFooter, PiWebGitTab, PiWebHeaderAction, PiWebUi } from "../../src/extensions.js"; + +export interface WebUiBridgeDependencies { + emit(value: unknown): void; + clientCount(): number; + acquireWorkLease(session: any): () => void; + createNewSession(cwd: string, previousSessionFile?: string): Promise; + sessionCwd(session: any): string; + state(session: any): Record; +} + +export function createWebUiBridge(deps: WebUiBridgeDependencies) { +const plainExtensionTheme = { + fg: (_color: string, text: string) => text, + bg: (_color: string, text: string) => text, + bold: (text: string) => text, + italic: (text: string) => text, + underline: (text: string) => text, + inverse: (text: string) => text, + strikethrough: (text: string) => text, + getFgAnsi: () => "", + getBgAnsi: () => "", + getColorMode: () => "truecolor", + getThinkingBorderColor: () => (text: string) => text, + getBashModeBorderColor: () => (text: string) => text, +}; + +type PendingExtensionUiRequest = { + resolve: (response: Record) => void; + cleanup: () => void; +}; +const pendingExtensionUiRequests = new Map(); + +type WebFooterState = { + footers: Map; +}; + +type WebHeaderActionState = { + actions: Map; +}; + +type WebGitTabState = { + tabs: Map; +}; + +const webFooterStates = new WeakMap(); +const webHeaderActionStates = new WeakMap(); +const webGitTabStates = new WeakMap(); + +function getWebFooterState(value: any): WebFooterState { + const key = value as object; + let state = webFooterStates.get(key); + if (!state) { + state = { footers: new Map() }; + webFooterStates.set(key, state); + } + return state; +} + +function cleanFooterKey(value: unknown) { + if (typeof value !== "string") return undefined; + const cleaned = value.trim().slice(0, 80).replace(/[^a-zA-Z0-9_.:-]/g, "-"); + return cleaned || undefined; +} + +const cleanHeaderActionKey = cleanFooterKey; +const cleanGitTabKey = cleanFooterKey; + +function cleanHeaderActionText(value: unknown, maxLength = 200) { + if (typeof value !== "string") return undefined; + const cleaned = value.replace(/[\u0000-\u001F\u007F]/g, "").trim(); + return cleaned ? cleaned.slice(0, maxLength) : undefined; +} + +function getWebHeaderActionState(value: any): WebHeaderActionState { + const key = value as object; + let state = webHeaderActionStates.get(key); + if (!state) { + state = { actions: new Map() }; + webHeaderActionStates.set(key, state); + } + return state; +} + +function getWebGitTabState(value: any): WebGitTabState { + const key = value as object; + let state = webGitTabStates.get(key); + if (!state) { + state = { tabs: new Map() }; + webGitTabStates.set(key, state); + } + return state; +} + +function cleanFooterText(value: unknown, maxLength = 2_000) { + if (typeof value !== "string") return undefined; + const cleaned = value.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "").trimEnd(); + return cleaned ? cleaned.slice(0, maxLength) : undefined; +} + +function normalizeTextLines(value: unknown) { + const rawLines = Array.isArray(value) ? value : typeof value === "string" ? [value] : []; + const lines = rawLines.slice(0, 8).map((line) => cleanFooterText(line)).filter((line): line is string => Boolean(line)); + return lines.length ? { kind: "text" as const, lines } : undefined; +} + +function normalizePiWebFooter(value: unknown): PiWebFooter | undefined { + if (typeof value === "string" || Array.isArray(value)) return normalizeTextLines(value); + if (!value || typeof value !== "object") return undefined; + const footer = value as Record; + if (footer.kind === "text") return normalizeTextLines(footer.lines); + if (footer.kind === "html") { + const html = cleanFooterText(footer.html, 20_000); + return html ? { kind: "html", html } : undefined; + } + return undefined; +} + +function webFooterEntries(value: any) { + return Array.from(getWebFooterState(value).footers.entries()).map(([key, footer]) => ({ key, footer })); +} + +function broadcastWebFooters(value: any) { + const webFooters = webFooterEntries(value); + deps.emit({ + type: "web_footer_changed", + sessionId: value.sessionId, + sessionFile: value.sessionFile, + webFooters, + }); + return webFooters; +} + +function webHeaderActionEntries(value: any) { + return Array.from(getWebHeaderActionState(value).actions.entries()).map(([key, action]) => ({ + key, + icon: cleanHeaderActionText(action.icon, 80), + title: cleanHeaderActionText(action.title) || key, + label: cleanHeaderActionText(action.label), + })); +} + +function broadcastWebHeaderActions(value: any) { + const webHeaderActions = webHeaderActionEntries(value); + deps.emit({ + type: "web_header_actions_changed", + sessionId: value.sessionId, + sessionFile: value.sessionFile, + webHeaderActions, + }); + return webHeaderActions; +} + +function webGitTabEntries(value: any) { + return Array.from(getWebGitTabState(value).tabs.entries()).map(([key, tab]) => ({ + key, + title: cleanHeaderActionText(tab.title) || key, + label: cleanHeaderActionText(tab.label, 80), + })); +} + +function broadcastWebGitTabs(value: any) { + const webGitTabs = webGitTabEntries(value); + deps.emit({ + type: "web_git_tabs_changed", + sessionId: value.sessionId, + sessionFile: value.sessionFile, + webGitTabs, + }); + return webGitTabs; +} + +function createPiWebUi(value: any): PiWebUi { + return { + setFooter(key, footer) { + const footerKey = cleanFooterKey(key); + if (!footerKey) return; + const footerState = getWebFooterState(value); + const normalized = normalizePiWebFooter(footer); + if (normalized) footerState.footers.set(footerKey, normalized); + else footerState.footers.delete(footerKey); + broadcastWebFooters(value); + }, + setHeaderAction(key, action) { + const actionKey = cleanHeaderActionKey(key); + if (!actionKey) return; + const actionState = getWebHeaderActionState(value); + if (action && typeof action === "object" && typeof action.invoke === "function") { + actionState.actions.set(actionKey, action); + } else { + actionState.actions.delete(actionKey); + } + broadcastWebHeaderActions(value); + }, + setGitTab(key, tab) { + const tabKey = cleanGitTabKey(key); + if (!tabKey) return; + const tabState = getWebGitTabState(value); + if (tab && typeof tab === "object" && typeof tab.render === "function") { + tabState.tabs.set(tabKey, tab); + } else { + tabState.tabs.delete(tabKey); + } + broadcastWebGitTabs(value); + }, + }; +} + +function broadcastExtensionUiRequest(value: any, method: string, payload: Record) { + const id = randomUUID(); + deps.emit({ + type: "extension_ui_request", + id, + method, + sessionId: value.sessionId, + sessionFile: value.sessionFile, + ...payload, + }); + return id; +} + +function requestExtensionUi( + value: any, + method: string, + payload: Record, + opts: ExtensionUIDialogOptions | undefined, + defaultValue: T, + parse: (response: Record) => T, +): Promise { + if (opts?.signal?.aborted || deps.clientCount() === 0) return Promise.resolve(defaultValue); + + return new Promise((resolvePromise) => { + const id = randomUUID(); + const releaseWorkLease = deps.acquireWorkLease(value); + let timeoutId: ReturnType | undefined; + + const cleanup = () => { + if (timeoutId) clearTimeout(timeoutId); + opts?.signal?.removeEventListener("abort", onAbort); + pendingExtensionUiRequests.delete(id); + releaseWorkLease(); + }; + const finish = (result: T) => { + cleanup(); + resolvePromise(result); + }; + const onAbort = () => finish(defaultValue); + + opts?.signal?.addEventListener("abort", onAbort, { once: true }); + if (opts?.timeout) timeoutId = setTimeout(() => finish(defaultValue), opts.timeout); + + pendingExtensionUiRequests.set(id, { + cleanup, + resolve: (response) => finish(parse(response)), + }); + + deps.emit({ + type: "extension_ui_request", + id, + method, + sessionId: value.sessionId, + sessionFile: value.sessionFile, + timeout: opts?.timeout, + ...payload, + }); + }); +} + +function createWebExtensionUiContext(value: any): ExtensionUIContext & { web: PiWebUi } { + return { + web: createPiWebUi(value), + select: (title, options, opts) => requestExtensionUi( + value, + "select", + { title, options }, + opts, + undefined, + (response) => response.cancelled ? undefined : typeof response.value === "string" ? response.value : undefined, + ), + confirm: (title, message, opts) => requestExtensionUi( + value, + "confirm", + { title, message }, + opts, + false, + (response) => response.cancelled ? false : Boolean(response.confirmed), + ), + input: (title, placeholder, opts) => requestExtensionUi( + value, + "input", + { title, placeholder }, + opts, + undefined, + (response) => response.cancelled ? undefined : typeof response.value === "string" ? response.value : undefined, + ), + notify(message, type = "info") { + broadcastExtensionUiRequest(value, "notify", { message, notifyType: type }); + }, + onTerminalInput: () => () => undefined, + setStatus(key, text) { + broadcastExtensionUiRequest(value, "setStatus", { statusKey: key, statusText: text }); + }, + setWorkingMessage: () => undefined, + setWorkingVisible: () => undefined, + setWorkingIndicator: () => undefined, + setHiddenThinkingLabel: () => undefined, + setWidget(key, content, options) { + if (content === undefined || Array.isArray(content)) { + broadcastExtensionUiRequest(value, "setWidget", { widgetKey: key, widgetLines: content, widgetPlacement: options?.placement }); + } + }, + setFooter: () => undefined, + setHeader: () => undefined, + setTitle(title) { + broadcastExtensionUiRequest(value, "setTitle", { title }); + }, + async custom() { + return undefined as never; + }, + pasteToEditor(text) { + this.setEditorText(text); + }, + setEditorText(text) { + broadcastExtensionUiRequest(value, "set_editor_text", { text }); + }, + getEditorText: () => "", + editor: (title, prefill) => requestExtensionUi( + value, + "editor", + { title, prefill }, + undefined, + undefined, + (response) => response.cancelled ? undefined : typeof response.value === "string" ? response.value : undefined, + ), + addAutocompleteProvider: () => undefined, + setEditorComponent: () => undefined, + getEditorComponent: () => undefined, + theme: plainExtensionTheme as any, + getAllThemes: () => [], + getTheme: () => undefined, + setTheme: () => ({ success: false, error: "Theme switching is not supported in pi-web yet" }), + getToolsExpanded: () => false, + setToolsExpanded: () => undefined, + }; +} + +async function bindWebExtensions(value: any) { + if (typeof value.bindExtensions !== "function") return; + await value.bindExtensions({ + uiContext: createWebExtensionUiContext(value), + commandContextActions: { + waitForIdle: () => value.agent.waitForIdle(), + newSession: async () => { + const newSession = await deps.createNewSession(deps.sessionCwd(value), value.sessionFile); + const state = deps.state(newSession); + deps.emit({ type: "state_changed", ...state }); + return { cancelled: false }; + }, + fork: async () => { + throw new Error("Extension-initiated fork is not supported in pi-web yet."); + }, + navigateTree: async (targetId: string, options: any) => { + const result = await value.navigateTree(targetId, options); + return { cancelled: Boolean(result?.cancelled) }; + }, + switchSession: async () => { + throw new Error("Extension-initiated session switching is not supported in pi-web yet."); + }, + reload: async () => { + await value.reload?.(); + }, + }, + shutdownHandler: () => { + deps.emit({ type: "server_error", sessionId: value.sessionId, sessionFile: value.sessionFile, error: "An extension requested shutdown; pi-web ignored the request." }); + }, + onError: (error: any) => { + deps.emit({ type: "server_error", sessionId: value.sessionId, sessionFile: value.sessionFile, error: `Extension error (${error.extensionPath}): ${error.error}` }); + }, + }); +} + + + async function invokeHeaderAction(value: any, keyValue: unknown) { + const key = cleanHeaderActionKey(keyValue); + if (!key) throw new Error("key is required"); + const action = getWebHeaderActionState(value).actions.get(key); + if (!action) throw new Error("Header action not found"); + const result = await action.invoke(); + const markdown = cleanFooterText(result?.markdown, 200_000); + if (!markdown) throw new Error("Header action returned no markdown"); + return { label: cleanHeaderActionText(action.label) || cleanHeaderActionText(action.title) || key, markdown }; + } + + async function invokeGitTab(value: any, input: { key?: unknown; action?: unknown; payload?: unknown; repo?: unknown }) { + const key = cleanGitTabKey(input.key); + if (!key) throw new Error("key is required"); + const tab = getWebGitTabState(value).tabs.get(key); + if (!tab) throw new Error("Git tab not found"); + const repo = input.repo && typeof input.repo === "object" ? input.repo as Record : undefined; + const result = await tab.render({ + action: typeof input.action === "string" ? input.action : undefined, + payload: input.payload, + repo: repo ? { + path: typeof repo.path === "string" ? repo.path : undefined, + root: typeof repo.root === "string" ? repo.root : undefined, + branch: typeof repo.branch === "string" ? repo.branch : undefined, + } : undefined, + }); + const html = cleanFooterText(result?.html, 500_000); + if (!html) throw new Error("Git tab returned no HTML"); + return { title: cleanHeaderActionText(result?.title) || cleanHeaderActionText(tab.title) || key, html }; + } + + function respond(id: string, response: Record): boolean { + const pending = pendingExtensionUiRequests.get(id); + if (!pending) return false; + pending.resolve(response); + return true; + } + + return { + bind: bindWebExtensions, + entries: (value: any) => ({ webFooters: webFooterEntries(value), webHeaderActions: webHeaderActionEntries(value), webGitTabs: webGitTabEntries(value) }), + invokeHeaderAction, + invokeGitTab, + respond, + }; +} From 9f446c5b4dd6e4188d1558d1504ca729421d2d06 Mon Sep 17 00:00:00 2001 From: Ashwin Pc Date: Sat, 18 Jul 2026 15:17:46 -0700 Subject: [PATCH 05/10] Route session operations through a service --- server.ts | 374 +++++++++++++------------------------- server/session/service.ts | 173 ++++++++++++++++++ 2 files changed, 301 insertions(+), 246 deletions(-) create mode 100644 server/session/service.ts diff --git a/server.ts b/server.ts index 0d6c699..34658c7 100644 --- a/server.ts +++ b/server.ts @@ -29,6 +29,7 @@ import type { SlashCommandDto } from "./server/session/dto.js"; import { SessionActivity } from "./server/session/activity.js"; import { RealtimeHub, SessionUnreadTracker } from "./server/realtime.js"; import { createWebUiBridge } from "./server/extensions/webUi.js"; +import { LocalSessionService, SessionServiceError } from "./server/session/service.js"; import { conversationTreeForSession, getSessionSlashCommands, @@ -1028,6 +1029,64 @@ async function switchEmptySessionCwd(targetSession: PiWebSession, cwd: string) { return currentStateWithThinkingLevels(newSession); } +async function navigateSession(targetSession: PiWebSession, targetId: string, options: Record) { + if (targetSession.isStreaming) throw new SessionServiceError("Wait for the current response to finish before navigating the tree", 409); + if (targetSession.isCompacting) throw new SessionServiceError("Wait for the current compaction to finish before navigating the tree", 409); + if (!targetSession.navigateTree) throw new SessionServiceError("Tree navigation is not available"); + const releaseWorkLease = acquireWorkLease(targetSession); + try { + const navigation = targetSession.navigateTree(targetId, options as any); + broadcast({ type: "session_runtime_changed", sessionId: targetSession.sessionId, sessionFile: targetSession.sessionFile, runtime: sessionActivity.runtimeForPath(targetSession.sessionFile) }); + const result = await navigation; + const state = currentStateWithThinkingLevels(targetSession); + broadcast({ type: "state_changed", ...state }); + return { ...result, leafId: targetSession.sessionManager.getLeafId?.() || null, state }; + } finally { + releaseWorkLease(); + broadcast({ type: "session_runtime_changed", sessionId: targetSession.sessionId, sessionFile: targetSession.sessionFile, runtime: sessionActivity.runtimeForPath(targetSession.sessionFile) }); + } +} + +async function startSessionPrompt(targetSession: PiWebSession, input: { message: string; mode: string; images: Array<{ data: string; mimeType: string; name?: string }> }) { + const imageFileNote = await persistPromptImages(input.images, sessionCwd(targetSession)); + const promptText = `${input.message || "Please review the attached image."}${imageFileNote}`; + if (!targetSession.isStreaming && !targetSession.isCompacting) sessionActivity.ensureStarted(targetSession); + const promptSessionFile = targetSession.sessionFile; + const releaseWorkLease = acquireWorkLease(targetSession); + void targetSession.prompt(promptText, { + ...(targetSession.isStreaming ? { streamingBehavior: input.mode } : {}), + ...(input.images.length ? { images: input.images.map(({ data, mimeType }) => ({ type: "image", data, mimeType })) } : {}), + }).catch((error: unknown) => broadcast({ type: "server_error", sessionId: targetSession.sessionId, sessionFile: targetSession.sessionFile, error: error instanceof Error ? error.message : String(error) })) + .finally(() => { + const isRunning = Boolean(targetSession.isStreaming || targetSession.isCompacting); + if (promptSessionFile && sessionActivity.hasStarted(promptSessionFile) && !isRunning) { + sessionActivity.clearStarted(targetSession, promptSessionFile); + markSessionUnreadCompleted(targetSession.sessionId); + } + broadcast({ type: "session_runtime_changed", sessionId: targetSession.sessionId, sessionFile: targetSession.sessionFile, runtime: sessionActivity.runtimeForPath(targetSession.sessionFile) }); + releaseWorkLease(); + }); +} + +async function startSessionRetry(targetSession: PiWebSession) { + try { assertCanRetryFromFailure(targetSession); } catch (error) { throw new SessionServiceError(error instanceof Error ? error.message : String(error), 409); } + sessionActivity.ensureStarted(targetSession); + const retrySessionFile = targetSession.sessionFile; + const releaseWorkLease = acquireWorkLease(targetSession); + void retrySessionFromFailure(targetSession).catch((error: unknown) => { + sessionActivity.clearStarted(targetSession, retrySessionFile); + broadcast({ type: "server_error", sessionId: targetSession.sessionId, sessionFile: targetSession.sessionFile, error: error instanceof Error ? error.message : String(error) }); + }).finally(() => { + const isRunning = Boolean(targetSession.isStreaming || targetSession.isCompacting); + if (retrySessionFile && sessionActivity.hasStarted(retrySessionFile) && !isRunning) { + sessionActivity.clearStarted(targetSession, retrySessionFile); + markSessionUnreadCompleted(targetSession.sessionId); + } + broadcast({ type: "session_runtime_changed", sessionId: targetSession.sessionId, sessionFile: targetSession.sessionFile, runtime: sessionActivity.runtimeForPath(targetSession.sessionFile) }); + releaseWorkLease(); + }); +} + const webUiBridge = createWebUiBridge({ emit: broadcast, clientCount: () => realtimeHub.clientCount, @@ -1037,6 +1096,28 @@ const webUiBridge = createWebUiBridge({ state: (value) => currentStateWithThinkingLevels(value), }); +const sessionService = new LocalSessionService({ + currentSessionId: () => session.sessionId, + resolve: (id) => getOrCreateLiveSessionById(id), + cwd: (value) => sessionCwd(value), + decorateState: (value) => currentStateWithThinkingLevels(value), + decorateMessageContent: (content, sessionFile) => sessionActivity.decorateMessageContent(content, sessionFile), + availableModels: (value) => getAvailableModels(value), + webCommands: webSlashCommands, + list: listSessionInfos, + create: createNewLiveSession, + open: switchToSessionId, + delete: deleteSessionById, + switchCwd: switchEmptySessionCwd, + executeCommand: executeSlashCommand, + prompt: startSessionPrompt, + retry: startSessionRetry, + navigate: navigateSession, + invokeHeaderAction: (value, key) => webUiBridge.invokeHeaderAction(value, key), + invokeGitTab: (value, input) => webUiBridge.invokeGitTab(value, input), + reportError: (value, error) => broadcast({ type: "server_error", sessionId: value.sessionId, sessionFile: value.sessionFile, error: error instanceof Error ? error.message : String(error) }), +}); + await ensurePiWebStorage(); const createdSession = await makeAgentSession(); @@ -1192,13 +1273,11 @@ const server = createServer(async (req, res) => { } if (method === "GET" && url.pathname === "/api/state") { - const requestedSessionId = url.searchParams.get("sessionId") || session.sessionId; - const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); - if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); - noteViewerLeaseFromRequest(req, targetSession, url.searchParams.get("clientId")); + const requestedSessionId = url.searchParams.get("sessionId") || undefined; + noteViewerLeaseFromRequest(req, await sessionService.require(requestedSessionId), url.searchParams.get("clientId")); return sendJson(res, 200, { ok: true, - ...currentStateWithThinkingLevels(targetSession), + ...await sessionService.state(requestedSessionId), sessionUiState: await sessionUiStateStore.read(), tokenRequired: Boolean(token), }); @@ -1206,11 +1285,8 @@ const server = createServer(async (req, res) => { if (method === "POST" && url.pathname === "/api/web-header-action/invoke") { const body = await readBody(req) as { sessionId?: unknown; key?: unknown }; - const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId; - const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); - if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); try { - return sendJson(res, 200, { ok: true, ...await webUiBridge.invokeHeaderAction(targetSession, body.key) }); + return sendJson(res, 200, { ok: true, ...await sessionService.invokeHeaderAction(typeof body.sessionId === "string" ? body.sessionId : undefined, body.key) }); } catch (error) { const message = error instanceof Error ? error.message : String(error); const status = message === "key is required" || message === "Header action returned no markdown" ? 400 : message === "Header action not found" ? 404 : 500; @@ -1220,11 +1296,8 @@ const server = createServer(async (req, res) => { if (method === "POST" && url.pathname === "/api/web-git-tab/invoke") { const body = await readBody(req) as { sessionId?: unknown; key?: unknown; action?: unknown; payload?: unknown; repo?: unknown }; - const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId; - const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); - if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); try { - return sendJson(res, 200, { ok: true, ...await webUiBridge.invokeGitTab(targetSession, body) }); + return sendJson(res, 200, { ok: true, ...await sessionService.invokeGitTab(typeof body.sessionId === "string" ? body.sessionId : undefined, body) }); } catch (error) { const message = error instanceof Error ? error.message : String(error); const status = message === "key is required" || message === "Git tab returned no HTML" ? 400 : message === "Git tab not found" ? 404 : 500; @@ -1233,90 +1306,39 @@ const server = createServer(async (req, res) => { } if (method === "GET" && url.pathname === "/api/session/stats") { - const requestedSessionId = url.searchParams.get("sessionId") || session.sessionId; - const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); - if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); - return sendJson(res, 200, { ok: true, sessionId: targetSession.sessionId, stats: sessionStats(targetSession) }); + return sendJson(res, 200, { ok: true, ...await sessionService.stats(url.searchParams.get("sessionId") || undefined) }); } if (method === "GET" && url.pathname === "/api/session/tree") { - const requestedSessionId = url.searchParams.get("sessionId") || session.sessionId; - const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); - if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); - try { - return sendJson(res, 200, conversationTreeForSession(targetSession)); - } catch (error) { - return sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) }); - } + return sendJson(res, 200, await sessionService.tree(url.searchParams.get("sessionId") || undefined)); } if (method === "POST" && url.pathname === "/api/session/tree/navigate") { const body = await readBody(req) as { sessionId?: unknown; targetId?: unknown; summarize?: unknown; customInstructions?: unknown; replaceInstructions?: unknown; label?: unknown }; - const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId; - const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); - if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); - if (targetSession.isStreaming) return sendJson(res, 409, { ok: false, error: "Wait for the current response to finish before navigating the tree" }); - if (targetSession.isCompacting) return sendJson(res, 409, { ok: false, error: "Wait for the current compaction to finish before navigating the tree" }); - if (typeof targetSession.navigateTree !== "function") return sendJson(res, 400, { ok: false, error: "Tree navigation is not available" }); - const targetId = String(body.targetId || "").trim(); if (!targetId) return sendJson(res, 400, { ok: false, error: "targetId is required" }); - - const releaseWorkLease = acquireWorkLease(targetSession); - try { - const navigation = targetSession.navigateTree(targetId, { - summarize: Boolean(body.summarize), - customInstructions: typeof body.customInstructions === "string" && body.customInstructions.trim() ? body.customInstructions.trim() : undefined, - replaceInstructions: Boolean(body.replaceInstructions), - label: typeof body.label === "string" && body.label.trim() ? body.label.trim() : undefined, - }); - broadcast({ type: "session_runtime_changed", sessionId: targetSession.sessionId, sessionFile: targetSession.sessionFile, runtime: sessionActivity.runtimeForPath(targetSession.sessionFile) }); - const result = await navigation; - const state = currentStateWithThinkingLevels(targetSession); - broadcast({ type: "state_changed", ...state }); - return sendJson(res, 200, { ok: true, ...result, leafId: targetSession.sessionManager.getLeafId?.() || null, state }); - } catch (error) { - return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }); - } finally { - releaseWorkLease(); - broadcast({ type: "session_runtime_changed", sessionId: targetSession.sessionId, sessionFile: targetSession.sessionFile, runtime: sessionActivity.runtimeForPath(targetSession.sessionFile) }); - } + const result = await sessionService.navigate(typeof body.sessionId === "string" ? body.sessionId : undefined, targetId, { + summarize: Boolean(body.summarize), + customInstructions: typeof body.customInstructions === "string" && body.customInstructions.trim() ? body.customInstructions.trim() : undefined, + replaceInstructions: Boolean(body.replaceInstructions), + label: typeof body.label === "string" && body.label.trim() ? body.label.trim() : undefined, + }); + return sendJson(res, 200, { ok: true, ...result }); } if (method === "POST" && url.pathname === "/api/session/tree/abort-summary") { const body = await readBody(req) as { sessionId?: unknown }; - const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId; - const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); - if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); - targetSession.abortBranchSummary?.(); - return sendJson(res, 202, { ok: true, sessionId: targetSession.sessionId }); + return sendJson(res, 202, { ok: true, ...await sessionService.abortBranchSummary(typeof body.sessionId === "string" ? body.sessionId : undefined) }); } if (method === "GET" && url.pathname === "/api/messages") { - const requestedSessionId = url.searchParams.get("sessionId") || session.sessionId; - const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); - if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); - const msgs = targetSession.messages; - // Build toolCallId -> args map from assistant messages - const toolCallArgs = new Map>(); - for (const m of msgs) { - const msg = m as any; - if (msg.role === "assistant" && Array.isArray(msg.content)) { - for (const part of msg.content) { - if (part?.type === "toolCall" && part.id) { - toolCallArgs.set(part.id, part.arguments || {}); - } - } - } - } - const refs = messageEntryRefs(targetSession); - return sendJson(res, 200, { ok: true, messages: msgs.map((m: unknown, index: number) => simplifyMessage(m, { toolCallArgs, decorateContent: (content) => sessionActivity.decorateMessageContent(content, targetSession.sessionFile), entryId: refs[index]?.entryId })) }); + return sendJson(res, 200, { ok: true, messages: await sessionService.messages(url.searchParams.get("sessionId") || undefined) }); } if (method === "GET" && url.pathname === "/api/sessions") { const extraCwds = url.searchParams.getAll("cwd"); const sessionUiState = await sessionUiStateStore.read(); - return sendJson(res, 200, { ok: true, sessions: applySessionUnreadState(await listSessionInfos(extraCwds), sessionUiState) }); + return sendJson(res, 200, { ok: true, sessions: applySessionUnreadState(await sessionService.list(extraCwds), sessionUiState) }); } if (method === "GET" && url.pathname === "/api/session-ui-state") { @@ -1344,7 +1366,7 @@ const server = createServer(async (req, res) => { if (!requestedId) return sendJson(res, 400, { ok: false, error: "sessionId is required" }); if (activeSessionId && activeSessionId === requestedId) return sendJson(res, 409, { ok: false, error: "Switch to another session before deleting the current session." }); try { - const result = await deleteSessionById(requestedId, typeof body.cwd === "string" && body.cwd.trim() ? body.cwd : undefined); + const result = await sessionService.delete(requestedId, typeof body.cwd === "string" && body.cwd.trim() ? body.cwd : undefined) as { id: string; disposition: "trashed" | "deleted" }; const sessionUiState = await sessionUiStateStore.removeSession(result.id); broadcast({ type: "session_deleted", sessionId: result.id, disposition: result.disposition }); broadcast({ type: "session_ui_state_changed", sessionUiState }); @@ -1366,72 +1388,40 @@ const server = createServer(async (req, res) => { } if (method === "GET" && url.pathname === "/api/commands") { - const requestedSessionId = url.searchParams.get("sessionId") || session.sessionId; - const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); - if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); - return sendJson(res, 200, { ok: true, commands: getSlashCommands(targetSession) }); + return sendJson(res, 200, { ok: true, commands: await sessionService.commands(url.searchParams.get("sessionId") || undefined) }); } if (method === "GET" && url.pathname === "/api/models") { - const requestedSessionId = url.searchParams.get("sessionId") || session.sessionId; - const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); - if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); - return sendJson(res, 200, { - ok: true, - cwd: sessionCwd(targetSession), - current: simplifyModel(targetSession.model), - thinkingLevel: targetSession.thinkingLevel, - thinkingLevels: targetSession.getAvailableThinkingLevels(), - models: getAvailableModels(targetSession).map(simplifyModel), - }); + return sendJson(res, 200, { ok: true, ...await sessionService.models(url.searchParams.get("sessionId") || undefined) }); } if (method === "POST" && url.pathname === "/api/model") { const body = await readBody(req) as { sessionId?: unknown; provider?: unknown; id?: unknown; thinkingLevel?: unknown }; - const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId; - const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); - if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); const provider = String(body.provider || "").trim(); const id = String(body.id || "").trim(); if (!provider || !id) return sendJson(res, 400, { ok: false, error: "provider and id are required" }); - - const model = targetSession.modelRegistry.find(provider, id); - if (!model) return sendJson(res, 404, { ok: false, error: "Model not found" }); - - await targetSession.setModel(model); - if (typeof body.thinkingLevel === "string") targetSession.setThinkingLevel(body.thinkingLevel as any); - - const state = currentStateWithThinkingLevels(targetSession); + const state = await sessionService.setModel(typeof body.sessionId === "string" ? body.sessionId : undefined, provider, id, typeof body.thinkingLevel === "string" ? body.thinkingLevel : undefined); broadcast({ type: "state_changed", ...state }); return sendJson(res, 200, { ok: true, ...state }); } if (method === "POST" && url.pathname === "/api/command") { const body = await readBody(req) as { sessionId?: unknown; command?: unknown }; - const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId; - const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); - if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); const command = String(body.command || "").trim(); if (!command.startsWith("/")) return sendJson(res, 400, { ok: false, error: "Slash command is required" }); - const result = await executeSlashCommand(command, targetSession); + const result = await sessionService.executeCommand(typeof body.sessionId === "string" ? body.sessionId : undefined, command); const stateSessionId = (result as any)?.state?.sessionId; const stateSession = typeof stateSessionId === "string" ? await getOrCreateLiveSessionById(stateSessionId) : undefined; - noteViewerLeaseFromRequest(req, stateSession || targetSession); + noteViewerLeaseFromRequest(req, stateSession || await sessionService.require(typeof body.sessionId === "string" ? body.sessionId : undefined)); return sendJson(res, 200, { ok: true, ...result }); } if (method === "POST" && url.pathname === "/api/shell") { const body = await readBody(req) as { sessionId?: unknown; command?: unknown; excludeFromContext?: unknown }; - const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId; - const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); - if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); const command = String(body.command || "").trim(); if (!command) return sendJson(res, 400, { ok: false, error: "command is required" }); - if (typeof targetSession.executeBash !== "function") return sendJson(res, 400, { ok: false, error: "Bash execution is not available in this session." }); - const excludeFromContext = Boolean(body.excludeFromContext); - const result = await targetSession.executeBash(command, undefined, { excludeFromContext }); - return sendJson(res, 200, { ok: true, command, cwd: sessionCwd(targetSession), ...result, excludeFromContext }); + return sendJson(res, 200, { ok: true, ...await sessionService.executeShell(typeof body.sessionId === "string" ? body.sessionId : undefined, command, Boolean(body.excludeFromContext)) }); } if (method === "POST" && url.pathname === "/api/extension-ui/respond") { @@ -1445,146 +1435,46 @@ const server = createServer(async (req, res) => { if (method === "POST" && url.pathname === "/api/prompt") { const body = await readBody(req) as { sessionId?: unknown; message?: unknown; mode?: unknown; images?: unknown }; const message = String(body.message || "").trim(); - const images = Array.isArray(body.images) - ? body.images.filter((image): image is { type: "image"; data: string; mimeType: string; name?: string } => { - if (!image || typeof image !== "object") return false; - const value = image as Record; - return value.type === "image" - && typeof value.data === "string" - && typeof value.mimeType === "string" - && value.mimeType.startsWith("image/"); - }) - : []; + const images = Array.isArray(body.images) ? body.images.flatMap((image) => { + if (!image || typeof image !== "object") return []; + const value = image as Record; + return value.type === "image" && typeof value.data === "string" && typeof value.mimeType === "string" && value.mimeType.startsWith("image/") + ? [{ data: value.data, mimeType: value.mimeType, ...(typeof value.name === "string" ? { name: value.name } : {}) }] + : []; + }) : []; if (!message && images.length === 0) return sendJson(res, 400, { ok: false, error: "message or image is required" }); - - const mode = body.mode === "followUp" ? "followUp" : "steer"; - const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId; - const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); - if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); - const imageFileNote = await persistPromptImages(images, sessionCwd(targetSession)); - const promptText = `${message || "Please review the attached image."}${imageFileNote}`; - const wasAlreadyRunning = Boolean(targetSession.isStreaming || targetSession.isCompacting); - if (!wasAlreadyRunning) sessionActivity.ensureStarted(targetSession); - const promptSessionFile = targetSession.sessionFile; - const releaseWorkLease = acquireWorkLease(targetSession); - void targetSession.prompt(promptText, { - ...(targetSession.isStreaming ? { streamingBehavior: mode } : {}), - ...(images.length ? { images: images.map(({ type, data, mimeType }) => ({ type, data, mimeType })) } : {}), - }) - .catch((error: unknown) => { - broadcast({ - type: "server_error", - sessionId: targetSession.sessionId, - sessionFile: targetSession.sessionFile, - error: error instanceof Error ? error.message : String(error), - }); - }) - .finally(() => { - const isRunning = Boolean(targetSession.isStreaming || targetSession.isCompacting); - const missedTerminalEvent = Boolean(promptSessionFile && sessionActivity.hasStarted(promptSessionFile) && !isRunning); - if (missedTerminalEvent) { - sessionActivity.clearStarted(targetSession, promptSessionFile); - markSessionUnreadCompleted(targetSession.sessionId); - } - broadcast({ - type: "session_runtime_changed", - sessionId: targetSession.sessionId, - sessionFile: targetSession.sessionFile, - runtime: sessionActivity.runtimeForPath(targetSession.sessionFile), - }); - releaseWorkLease(); - }); - - return sendJson(res, 202, { ok: true, sessionId: targetSession.sessionId }); + const result = await sessionService.prompt(typeof body.sessionId === "string" ? body.sessionId : undefined, { message, mode: body.mode === "followUp" ? "followUp" : "steer", images }); + return sendJson(res, 202, { ok: true, ...result }); } if (method === "POST" && url.pathname === "/api/session/retry") { const body = await readBody(req) as { sessionId?: unknown }; - const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId; - const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); - if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); - try { - assertCanRetryFromFailure(targetSession); - } catch (error) { - return sendJson(res, 409, { ok: false, error: error instanceof Error ? error.message : String(error) }); - } - - sessionActivity.ensureStarted(targetSession); - const retrySessionFile = targetSession.sessionFile; - const releaseWorkLease = acquireWorkLease(targetSession); - void retrySessionFromFailure(targetSession) - .catch((error: unknown) => { - sessionActivity.clearStarted(targetSession, retrySessionFile); - broadcast({ - type: "server_error", - sessionId: targetSession.sessionId, - sessionFile: targetSession.sessionFile, - error: error instanceof Error ? error.message : String(error), - }); - }) - .finally(() => { - const isRunning = Boolean(targetSession.isStreaming || targetSession.isCompacting); - const missedTerminalEvent = Boolean(retrySessionFile && sessionActivity.hasStarted(retrySessionFile) && !isRunning); - if (missedTerminalEvent) { - sessionActivity.clearStarted(targetSession, retrySessionFile); - markSessionUnreadCompleted(targetSession.sessionId); - } - broadcast({ - type: "session_runtime_changed", - sessionId: targetSession.sessionId, - sessionFile: targetSession.sessionFile, - runtime: sessionActivity.runtimeForPath(targetSession.sessionFile), - }); - releaseWorkLease(); - }); - - return sendJson(res, 202, { ok: true, sessionId: targetSession.sessionId }); + return sendJson(res, 202, { ok: true, ...await sessionService.retry(typeof body.sessionId === "string" ? body.sessionId : undefined) }); } if (method === "POST" && url.pathname === "/api/abort") { const body = await readBody(req) as { sessionId?: unknown }; - const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId; - const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); - if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); - void targetSession.abort().catch((error: unknown) => broadcast({ - type: "server_error", - sessionId: targetSession.sessionId, - sessionFile: targetSession.sessionFile, - error: error instanceof Error ? error.message : String(error), - })); - return sendJson(res, 202, { ok: true, sessionId: targetSession.sessionId }); + return sendJson(res, 202, { ok: true, ...await sessionService.abort(typeof body.sessionId === "string" ? body.sessionId : undefined) }); } if (method === "POST" && url.pathname === "/api/compaction/abort") { const body = await readBody(req) as { sessionId?: unknown }; - const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId; - const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); - if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); - if (typeof targetSession.abortCompaction !== "function") return sendJson(res, 400, { ok: false, error: "Compaction cancellation is not available" }); - targetSession.abortCompaction(); - return sendJson(res, 202, { ok: true, sessionId: targetSession.sessionId }); + return sendJson(res, 202, { ok: true, ...await sessionService.abortCompaction(typeof body.sessionId === "string" ? body.sessionId : undefined) }); } if (method === "POST" && url.pathname === "/api/session/name") { const body = await readBody(req) as { sessionId?: unknown; name?: unknown }; - const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId; - const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); - if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); - if (typeof targetSession.setSessionName !== "function") return sendJson(res, 400, { ok: false, error: "Renaming sessions is not available" }); - const name = String(body.name || "").trim(); - targetSession.setSessionName(name); - const state = currentStateWithThinkingLevels(targetSession); + if (!name) return sendJson(res, 400, { ok: false, error: "name is required" }); + const state = await sessionService.rename(typeof body.sessionId === "string" ? body.sessionId : undefined, name); + broadcast({ type: "state_changed", ...state }); return sendJson(res, 200, { ok: true, ...state }); } if (method === "POST" && (url.pathname === "/api/new-chat" || url.pathname === "/api/sessions/new")) { const body = await readBody(req) as { cwd?: unknown; sessionId?: unknown }; - const previousSession = typeof body.sessionId === "string" ? await getOrCreateLiveSessionById(body.sessionId) : session; - const targetCwd = typeof body.cwd === "string" ? body.cwd : previousSession ? sessionCwd(previousSession) : undefined; - const newSession = await createNewLiveSession(targetCwd, previousSession?.sessionFile); - noteViewerLeaseFromRequest(req, newSession); - const state = currentStateWithThinkingLevels(newSession); + const state = await sessionService.create(typeof body.sessionId === "string" ? body.sessionId : undefined, typeof body.cwd === "string" ? body.cwd : undefined); + noteViewerLeaseFromRequest(req, await sessionService.require(String(state.sessionId))); broadcast({ type: "state_changed", ...state }); return sendJson(res, 200, { ok: true, ...state }); } @@ -1593,12 +1483,9 @@ const server = createServer(async (req, res) => { const body = await readBody(req) as { sessionId?: unknown; cwd?: unknown }; const cwd = String(body.cwd || "").trim(); if (!cwd) return sendJson(res, 400, { ok: false, error: "cwd is required" }); - const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId; - const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId); - if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" }); try { - const state = await switchEmptySessionCwd(targetSession, cwd); - const stateSession = state.sessionId ? await getOrCreateLiveSessionById(state.sessionId) : undefined; + const state = await sessionService.switchCwd(typeof body.sessionId === "string" ? body.sessionId : undefined, cwd); + const stateSession = state.sessionId ? await getOrCreateLiveSessionById(String(state.sessionId)) : undefined; if (stateSession) noteViewerLeaseFromRequest(req, stateSession); broadcast({ type: "state_changed", ...state }); return sendJson(res, 200, { ok: true, ...state }); @@ -1612,14 +1499,8 @@ const server = createServer(async (req, res) => { const requestedId = typeof body.sessionId === "string" ? body.sessionId : typeof body.id === "string" ? body.id : ""; if (!requestedId) return sendJson(res, 400, { ok: false, error: "sessionId is required" }); - let targetSession: PiWebSession | undefined; - try { - targetSession = await switchToSessionId(requestedId, typeof body.cwd === "string" && body.cwd.trim() ? body.cwd : undefined); - } catch { - return sendJson(res, 404, { ok: false, error: "Session not found" }); - } - noteViewerLeaseFromRequest(req, targetSession, body.clientId); - const state = currentStateWithThinkingLevels(targetSession); + const state = await sessionService.open(requestedId, typeof body.cwd === "string" && body.cwd.trim() ? body.cwd : undefined); + noteViewerLeaseFromRequest(req, await sessionService.require(requestedId), body.clientId); return sendJson(res, 200, { ok: true, ...state }); } @@ -1635,7 +1516,8 @@ const server = createServer(async (req, res) => { serveStatic(req, res); } catch (error) { - sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }); + const status = error instanceof SessionServiceError ? error.status : 500; + sendJson(res, status, { ok: false, error: error instanceof Error ? error.message : String(error) }); } }); diff --git a/server/session/service.ts b/server/session/service.ts new file mode 100644 index 0000000..94cf6b7 --- /dev/null +++ b/server/session/service.ts @@ -0,0 +1,173 @@ +import type { PiWebSession } from "../types.js"; +import type { SlashCommandDto } from "./dto.js"; +import { conversationTreeForSession, getSessionSlashCommands, messageEntryRefs, sessionStats, simplifyMessage, simplifyModel } from "./projection.js"; + +export class SessionServiceError extends Error { + constructor(message: string, readonly status = 400) { super(message); } +} + +export interface LocalSessionServiceDependencies { + currentSessionId(): string; + resolve(sessionId: string): Promise; + cwd(session: PiWebSession): string; + decorateState(session: PiWebSession): Record; + decorateMessageContent(content: unknown, sessionFile: string): unknown; + availableModels(session: PiWebSession): unknown[]; + webCommands: SlashCommandDto[]; + list(extraCwds: string[]): Promise>>; + create(cwd?: string, previousSessionFile?: string): Promise; + open(sessionId: string, cwd?: string): Promise; + delete(sessionId: string, cwd?: string): Promise; + switchCwd(session: PiWebSession, cwd: string): Promise>; + executeCommand(command: string, session: PiWebSession): Promise<{ message: string; state: Record }>; + prompt(session: PiWebSession, input: { message: string; mode: string; images: Array<{ data: string; mimeType: string; name?: string }> }): Promise; + retry(session: PiWebSession): Promise; + navigate(session: PiWebSession, targetId: string, options: Record): Promise>; + invokeHeaderAction(session: PiWebSession, key: unknown): Promise>; + invokeGitTab(session: PiWebSession, input: Record): Promise>; + reportError(session: PiWebSession, error: unknown): void; +} + +export class LocalSessionService { + constructor(private readonly deps: LocalSessionServiceDependencies) {} + + defaultSessionId(): string { return this.deps.currentSessionId(); } + + async require(sessionId?: string): Promise { + const id = sessionId || this.defaultSessionId(); + const session = await this.deps.resolve(id); + if (!session) throw new SessionServiceError("Session not found", 404); + return session; + } + + async state(sessionId?: string) { + return this.deps.decorateState(await this.require(sessionId)); + } + + async stats(sessionId?: string) { + const session = await this.require(sessionId); + return { sessionId: session.sessionId, stats: sessionStats(session) }; + } + + async tree(sessionId?: string) { + return conversationTreeForSession(await this.require(sessionId)); + } + + async messages(sessionId?: string) { + const session = await this.require(sessionId); + const messages = session.messages; + const toolCallArgs = new Map>(); + for (const message of messages as any[]) { + if (message?.role !== "assistant" || !Array.isArray(message.content)) continue; + for (const part of message.content) { + if (part?.type === "toolCall" && part.id) toolCallArgs.set(part.id, part.arguments || part.args || {}); + } + } + const refs = messageEntryRefs(session); + return messages.map((message, index) => simplifyMessage(message, { + toolCallArgs, + decorateContent: (content) => this.deps.decorateMessageContent(content, session.sessionFile), + entryId: refs[index]?.entryId, + })); + } + + async commands(sessionId?: string) { + const session = await this.require(sessionId); + return [...this.deps.webCommands, ...getSessionSlashCommands(session)]; + } + + async models(sessionId?: string) { + const session = await this.require(sessionId); + return { + cwd: this.deps.cwd(session), + current: simplifyModel(session.model), + thinkingLevel: session.thinkingLevel, + thinkingLevels: session.getAvailableThinkingLevels(), + models: this.deps.availableModels(session).map(simplifyModel), + }; + } + + async setModel(sessionId: string | undefined, provider: string, id: string, thinkingLevel?: string) { + const session = await this.require(sessionId); + const model = session.modelRegistry.find(provider, id); + if (!model) throw new SessionServiceError("Model not found", 404); + await session.setModel(model); + if (thinkingLevel) session.setThinkingLevel(thinkingLevel); + return this.deps.decorateState(session); + } + + async executeShell(sessionId: string | undefined, command: string, excludeFromContext: boolean) { + const session = await this.require(sessionId); + if (!session.executeBash) throw new SessionServiceError("Bash execution is not available in this session."); + return { command, cwd: this.deps.cwd(session), ...await session.executeBash(command, undefined, { excludeFromContext }), excludeFromContext }; + } + + async executeCommand(sessionId: string | undefined, command: string) { + return this.deps.executeCommand(command, await this.require(sessionId)); + } + + async prompt(sessionId: string | undefined, input: { message: string; mode: string; images: Array<{ data: string; mimeType: string; name?: string }> }) { + const session = await this.require(sessionId); + await this.deps.prompt(session, input); + return { sessionId: session.sessionId }; + } + + async retry(sessionId?: string) { + const session = await this.require(sessionId); + await this.deps.retry(session); + return { sessionId: session.sessionId }; + } + + async abort(sessionId?: string) { + const session = await this.require(sessionId); + void session.abort().catch((error) => this.deps.reportError(session, error)); + return { sessionId: session.sessionId }; + } + + async abortCompaction(sessionId?: string) { + const session = await this.require(sessionId); + if (!session.abortCompaction) throw new SessionServiceError("Compaction cancellation is not available"); + session.abortCompaction(); + return { sessionId: session.sessionId }; + } + + async abortBranchSummary(sessionId?: string) { + const session = await this.require(sessionId); + session.abortBranchSummary?.(); + return { sessionId: session.sessionId }; + } + + async rename(sessionId: string | undefined, name: string) { + const session = await this.require(sessionId); + if (!session.setSessionName) throw new SessionServiceError("Renaming sessions is not available"); + session.setSessionName(name); + return this.deps.decorateState(session); + } + + async navigate(sessionId: string | undefined, targetId: string, options: Record) { + return this.deps.navigate(await this.require(sessionId), targetId, options); + } + + async invokeHeaderAction(sessionId: string | undefined, key: unknown) { + return this.deps.invokeHeaderAction(await this.require(sessionId), key); + } + + async invokeGitTab(sessionId: string | undefined, input: Record) { + return this.deps.invokeGitTab(await this.require(sessionId), input); + } + + list(extraCwds: string[] = []) { return this.deps.list(extraCwds); } + + async create(sessionId?: string, cwd?: string) { + const previous = await this.require(sessionId); + const created = await this.deps.create(cwd || this.deps.cwd(previous), previous.sessionFile); + return this.deps.decorateState(created); + } + + async open(sessionId: string, cwd?: string) { + return this.deps.decorateState(await this.deps.open(sessionId, cwd)); + } + + delete(sessionId: string, cwd?: string) { return this.deps.delete(sessionId, cwd); } + async switchCwd(sessionId: string | undefined, cwd: string) { return this.deps.switchCwd(await this.require(sessionId), cwd); } +} From 012c44757c4063cbe67f6f5a499f4ad981efac09 Mon Sep 17 00:00:00 2001 From: Ashwin Pc Date: Mon, 20 Jul 2026 08:45:54 -0700 Subject: [PATCH 06/10] Restore route parity and enforce service contract --- server.ts | 17 +++--- server/session/dto.ts | 83 +++++++++-------------------- server/session/projection.ts | 1 - server/session/service.ts | 20 ++++--- server/shared/git.ts | 4 +- tests/session-projection.test.ts | 7 +-- tests/shared-server-modules.test.ts | 2 +- 7 files changed, 52 insertions(+), 82 deletions(-) diff --git a/server.ts b/server.ts index 34658c7..da80aa7 100644 --- a/server.ts +++ b/server.ts @@ -1098,6 +1098,7 @@ const webUiBridge = createWebUiBridge({ const sessionService = new LocalSessionService({ currentSessionId: () => session.sessionId, + globalCwd: () => piCwd, resolve: (id) => getOrCreateLiveSessionById(id), cwd: (value) => sessionCwd(value), decorateState: (value) => currentStateWithThinkingLevels(value), @@ -1254,7 +1255,8 @@ const server = createServer(async (req, res) => { "content-type": contentTypes[extname(image.displayPath).toLowerCase()] || "application/octet-stream", "cache-control": "no-store", }); - res.end(image.data); + if ("file" in image && typeof image.file === "string") pipeReadStream(res, image.file); + else res.end(image.data); return; } catch (error) { return sendJson(res, 404, { ok: false, error: error instanceof Error ? error.message : String(error) }); @@ -1289,7 +1291,7 @@ const server = createServer(async (req, res) => { return sendJson(res, 200, { ok: true, ...await sessionService.invokeHeaderAction(typeof body.sessionId === "string" ? body.sessionId : undefined, body.key) }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - const status = message === "key is required" || message === "Header action returned no markdown" ? 400 : message === "Header action not found" ? 404 : 500; + const status = error instanceof SessionServiceError ? error.status : message === "key is required" || message === "Header action returned no markdown" ? 400 : message === "Header action not found" ? 404 : 500; return sendJson(res, status, { ok: false, error: message }); } } @@ -1300,7 +1302,7 @@ const server = createServer(async (req, res) => { return sendJson(res, 200, { ok: true, ...await sessionService.invokeGitTab(typeof body.sessionId === "string" ? body.sessionId : undefined, body) }); } catch (error) { const message = error instanceof Error ? error.message : String(error); - const status = message === "key is required" || message === "Git tab returned no HTML" ? 400 : message === "Git tab not found" ? 404 : 500; + const status = error instanceof SessionServiceError ? error.status : message === "key is required" || message === "Git tab returned no HTML" ? 400 : message === "Git tab not found" ? 404 : 500; return sendJson(res, status, { ok: false, error: message }); } } @@ -1315,9 +1317,11 @@ const server = createServer(async (req, res) => { if (method === "POST" && url.pathname === "/api/session/tree/navigate") { const body = await readBody(req) as { sessionId?: unknown; targetId?: unknown; summarize?: unknown; customInstructions?: unknown; replaceInstructions?: unknown; label?: unknown }; + const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : undefined; + await sessionService.require(requestedSessionId); const targetId = String(body.targetId || "").trim(); if (!targetId) return sendJson(res, 400, { ok: false, error: "targetId is required" }); - const result = await sessionService.navigate(typeof body.sessionId === "string" ? body.sessionId : undefined, targetId, { + const result = await sessionService.navigate(requestedSessionId, targetId, { summarize: Boolean(body.summarize), customInstructions: typeof body.customInstructions === "string" && body.customInstructions.trim() ? body.customInstructions.trim() : undefined, replaceInstructions: Boolean(body.replaceInstructions), @@ -1465,9 +1469,7 @@ const server = createServer(async (req, res) => { if (method === "POST" && url.pathname === "/api/session/name") { const body = await readBody(req) as { sessionId?: unknown; name?: unknown }; const name = String(body.name || "").trim(); - if (!name) return sendJson(res, 400, { ok: false, error: "name is required" }); const state = await sessionService.rename(typeof body.sessionId === "string" ? body.sessionId : undefined, name); - broadcast({ type: "state_changed", ...state }); return sendJson(res, 200, { ok: true, ...state }); } @@ -1490,7 +1492,8 @@ const server = createServer(async (req, res) => { broadcast({ type: "state_changed", ...state }); return sendJson(res, 200, { ok: true, ...state }); } catch (error) { - return sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) }); + const status = error instanceof SessionServiceError ? error.status : 400; + return sendJson(res, status, { ok: false, error: error instanceof Error ? error.message : String(error) }); } } diff --git a/server/session/dto.ts b/server/session/dto.ts index 2432dfd..80d98f6 100644 --- a/server/session/dto.ts +++ b/server/session/dto.ts @@ -100,66 +100,33 @@ export interface ArtifactDto { name: string; base64: string } export interface GitImageDto { path: string; base64: string } export interface DirectoryListingDto { path: string; parent: string; dirs: Array<{ name: string; path: string }> } -export type SessionServiceEvent = - | { type: "pi"; sessionId: string; sessionFile: string; event: JsonValue } - | { type: "state"; state: BaseSessionStateDto } - | { type: "stats"; sessionId: string; sessionFile: string; stats: SessionStatsDto } - | { type: "models"; sessionId: string; models: ModelDto[] } - | { type: "error"; sessionId?: string; sessionFile?: string; error: string } - | { type: "shutdown"; sessionId: string; sessionFile: string } - | { type: "extension-ui"; request: JsonValue } - | { type: "footers"; sessionId: string; sessionFile: string; footers: JsonValue[] } - | { type: "header-actions"; sessionId: string; sessionFile: string; actions: JsonValue[] } - | { type: "git-tabs"; sessionId: string; sessionFile: string; tabs: JsonValue[] }; - export interface SessionService { - create(cwd?: string, previousSessionFile?: string): Promise; - open(sessionId: string, cwd?: string): Promise; - delete(sessionId: string, cwd?: string): Promise; - list(extraCwds?: string[]): Promise; - switchCwd(sessionId: string, cwd: string): Promise; - state(sessionId: string): Promise; - messages(sessionId: string): Promise; - stats(sessionId: string): Promise; - tree(sessionId: string): Promise; - commands(sessionId: string): Promise; - models(sessionId: string): Promise<{ cwd: string; current?: ModelDto; thinkingLevel: string; thinkingLevels: string[]; models: ModelDto[] }>; - prompt(sessionId: string, text: string, images: Array<{ data: string; mimeType: string; name?: string }>, mode: "followUp" | "steer"): Promise; - abort(sessionId: string): Promise; - abortCompaction(sessionId: string): Promise; - retry(sessionId: string): Promise; - rename(sessionId: string, name: string): Promise; - setModel(sessionId: string, provider: string, id: string, thinkingLevel?: string): Promise; - executeCommand(sessionId: string, command: string): Promise<{ message?: string; state: BaseSessionStateDto }>; - executeShell(sessionId: string, command: string, excludeFromContext: boolean): Promise; - navigateTree(sessionId: string, targetId: string, options: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }): Promise; - abortBranchSummary(sessionId: string): Promise; - respondExtensionUi(id: string, response: JsonValue): void; - invokeHeaderAction(sessionId: string, key: string): Promise<{ label: string; markdown: string }>; - invokeGitTab(sessionId: string, key: string, action: string, payload?: JsonValue): Promise; - acquireViewer(sessionId: string, clientId: string): void; - releaseViewer(clientId: string): void; - fs: { - list(path: string): Promise; - mkdir(parent: string, name: string): Promise; - }; - git: { - repos(cwd: string): Promise; - status(cwd: string, fetchRemote?: boolean): Promise; - log(cwd: string): Promise; - commit(cwd: string, hash: string): Promise; - diff(cwd: string, path: string, staged: boolean): Promise; - imageBase64(cwd: string, path: string, oldPath: string | undefined, version: string, staged: boolean): Promise; - sync(cwd: string): Promise; - }; - artifacts: { - read(cwd: string, name: string): Promise; - readBase64(cwd: string, name: string, maxBytes?: number): Promise; - write(cwd: string, name: string, base64: string): Promise; - }; - subscribe(listener: (event: SessionServiceEvent) => void): () => void; + defaultSessionId(): string; + state(sessionId?: string): Promise>; + stats(sessionId?: string): Promise<{ sessionId: string; stats: SessionStatsDto }>; + tree(sessionId?: string): Promise; + messages(sessionId?: string): Promise; + commands(sessionId?: string): Promise; + models(sessionId?: string): Promise>; + setModel(sessionId: string | undefined, provider: string, id: string, thinkingLevel?: string): Promise>; + executeShell(sessionId: string | undefined, command: string, excludeFromContext: boolean): Promise>; + executeCommand(sessionId: string | undefined, command: string): Promise>; + prompt(sessionId: string | undefined, input: { message: string; mode: string; images: Array<{ data: string; mimeType: string; name?: string }> }): Promise<{ sessionId: string }>; + retry(sessionId?: string): Promise<{ sessionId: string }>; + abort(sessionId?: string): Promise<{ sessionId: string }>; + abortCompaction(sessionId?: string): Promise<{ sessionId: string }>; + abortBranchSummary(sessionId?: string): Promise<{ sessionId: string }>; + rename(sessionId: string | undefined, name: string): Promise>; + navigate(sessionId: string | undefined, targetId: string, options: Record): Promise>; + invokeHeaderAction(sessionId: string | undefined, key: unknown): Promise>; + invokeGitTab(sessionId: string | undefined, input: Record): Promise>; + list(extraCwds?: string[]): Promise>>; + create(sessionId?: string, cwd?: string): Promise>; + open(sessionId: string, cwd?: string): Promise>; + delete(sessionId: string, cwd?: string): Promise; + switchCwd(sessionId: string | undefined, cwd: string): Promise>; } -export function jsonRoundTrip(value: T): T { +export function jsonRoundTrip(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } diff --git a/server/session/projection.ts b/server/session/projection.ts index 115f64f..3bd2e2b 100644 --- a/server/session/projection.ts +++ b/server/session/projection.ts @@ -530,4 +530,3 @@ export function getSessionSlashCommands(value: PiWebSession): SlashCommandDto[] return commands.filter((command) => typeof command.name === "string" && command.name.length > 0); } - diff --git a/server/session/service.ts b/server/session/service.ts index 94cf6b7..94558c8 100644 --- a/server/session/service.ts +++ b/server/session/service.ts @@ -1,5 +1,5 @@ import type { PiWebSession } from "../types.js"; -import type { SlashCommandDto } from "./dto.js"; +import type { SessionService, SlashCommandDto } from "./dto.js"; import { conversationTreeForSession, getSessionSlashCommands, messageEntryRefs, sessionStats, simplifyMessage, simplifyModel } from "./projection.js"; export class SessionServiceError extends Error { @@ -8,6 +8,7 @@ export class SessionServiceError extends Error { export interface LocalSessionServiceDependencies { currentSessionId(): string; + globalCwd(): string; resolve(sessionId: string): Promise; cwd(session: PiWebSession): string; decorateState(session: PiWebSession): Record; @@ -28,7 +29,7 @@ export interface LocalSessionServiceDependencies { reportError(session: PiWebSession, error: unknown): void; } -export class LocalSessionService { +export class LocalSessionService implements SessionService { constructor(private readonly deps: LocalSessionServiceDependencies) {} defaultSessionId(): string { return this.deps.currentSessionId(); } @@ -50,7 +51,9 @@ export class LocalSessionService { } async tree(sessionId?: string) { - return conversationTreeForSession(await this.require(sessionId)); + const session = await this.require(sessionId); + try { return conversationTreeForSession(session); } + catch (error) { throw new SessionServiceError(error instanceof Error ? error.message : String(error), 400); } } async messages(sessionId?: string) { @@ -60,7 +63,7 @@ export class LocalSessionService { for (const message of messages as any[]) { if (message?.role !== "assistant" || !Array.isArray(message.content)) continue; for (const part of message.content) { - if (part?.type === "toolCall" && part.id) toolCallArgs.set(part.id, part.arguments || part.args || {}); + if (part?.type === "toolCall" && part.id) toolCallArgs.set(part.id, part.arguments || {}); } } const refs = messageEntryRefs(session); @@ -92,7 +95,7 @@ export class LocalSessionService { const model = session.modelRegistry.find(provider, id); if (!model) throw new SessionServiceError("Model not found", 404); await session.setModel(model); - if (thinkingLevel) session.setThinkingLevel(thinkingLevel); + if (thinkingLevel !== undefined) session.setThinkingLevel(thinkingLevel); return this.deps.decorateState(session); } @@ -159,13 +162,14 @@ export class LocalSessionService { list(extraCwds: string[] = []) { return this.deps.list(extraCwds); } async create(sessionId?: string, cwd?: string) { - const previous = await this.require(sessionId); - const created = await this.deps.create(cwd || this.deps.cwd(previous), previous.sessionFile); + const previous = sessionId ? await this.deps.resolve(sessionId) : undefined; + const created = await this.deps.create(cwd || (previous ? this.deps.cwd(previous) : this.deps.globalCwd()), previous?.sessionFile); return this.deps.decorateState(created); } async open(sessionId: string, cwd?: string) { - return this.deps.decorateState(await this.deps.open(sessionId, cwd)); + try { return this.deps.decorateState(await this.deps.open(sessionId, cwd)); } + catch (error) { throw new SessionServiceError(error instanceof Error ? error.message : String(error), 404); } } delete(sessionId: string, cwd?: string) { return this.deps.delete(sessionId, cwd); } diff --git a/server/shared/git.ts b/server/shared/git.ts index 60770f5..8e36bf3 100644 --- a/server/shared/git.ts +++ b/server/shared/git.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; -import { readFile, readdir, stat } from "node:fs/promises"; +import { readdir, stat } from "node:fs/promises"; import { extname, isAbsolute, join, relative, resolve } from "node:path"; import { promisify } from "node:util"; @@ -124,7 +124,7 @@ export async function readGitImage(options: { cwd: string; path: string; oldPath if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Image path is outside the repository"); const info = await stat(resolved); if (!info.isFile()) throw new Error("Image not found"); - return { data: await readFile(resolved), displayPath }; + return { file: resolved, displayPath }; } export async function gitCwdFromRepoParam(repo: string | null, baseCwd: string) { diff --git a/tests/session-projection.test.ts b/tests/session-projection.test.ts index f2b60bc..9abba8f 100644 --- a/tests/session-projection.test.ts +++ b/tests/session-projection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { PiWebSession } from "../server/types.js"; +import { jsonRoundTrip } from "../server/session/dto.js"; import { conversationTreeForSession, getSessionSlashCommands, @@ -11,10 +12,6 @@ import { textFromContent, } from "../server/session/projection.js"; -function roundTrip(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - function fixtureSession(): PiWebSession { const branch = [ { id: "user-1", parentId: null, type: "message", timestamp: "2026-01-01T00:00:00Z", message: { role: "user", content: "Hello" } }, @@ -86,6 +83,6 @@ describe("pure session projections", () => { conversationTreeForSession(session), getSessionSlashCommands(session), ]; - for (const result of results) expect(roundTrip(result)).toStrictEqual(result); + for (const result of results) expect(jsonRoundTrip(result)).toStrictEqual(result); }); }); diff --git a/tests/shared-server-modules.test.ts b/tests/shared-server-modules.test.ts index 9489e2a..31ab668 100644 --- a/tests/shared-server-modules.test.ts +++ b/tests/shared-server-modules.test.ts @@ -122,7 +122,7 @@ describe("shared Git helpers", () => { const beforeImage = await readGitImage({ cwd, path: "image.png", version: "before", staged: false }); const afterImage = await readGitImage({ cwd, path: "image.png", version: "after", staged: false }); expect(beforeImage?.data).toEqual(Buffer.from([1, 2, 3])); - expect(afterImage?.data).toEqual(Buffer.from([4, 5, 6])); + expect(afterImage).toMatchObject({ file: join(cwd, "image.png"), displayPath: "image.png" }); expect(await readGitImage({ cwd, path: "tracked.txt", version: "after", staged: false })).toBeUndefined(); const child = join(cwd, "child"); From 320cdb2ca5b76643fe3af8dd972e7eed9b49df14 Mon Sep 17 00:00:00 2001 From: Ashwin Pc Date: Mon, 20 Jul 2026 16:24:39 -0700 Subject: [PATCH 07/10] Restore current-session new chat fallback --- server/session/dto.ts | 33 --------------------------------- server/session/service.ts | 2 +- tests/api.test.ts | 13 ++++++++++++- 3 files changed, 13 insertions(+), 35 deletions(-) diff --git a/server/session/dto.ts b/server/session/dto.ts index 80d98f6..6ba6a6e 100644 --- a/server/session/dto.ts +++ b/server/session/dto.ts @@ -35,20 +35,6 @@ export interface BaseSessionStateDto { stats: SessionStatsDto; } -export interface MessageDto { - entryId?: string; - role?: string; - text?: string; - toolCalls?: Array<{ id?: string; toolName: string; args: JsonValue; startedAt?: string }>; - toolCallId?: string; - toolName?: string; - toolArgs?: JsonValue; - isError?: boolean; - timestamp?: string; - raw?: JsonValue; - [key: string]: JsonValue | undefined; -} - export interface TreeNodeDto { id: string; parentId: string | null; @@ -81,25 +67,6 @@ export interface SlashCommandDto { sourceInfo?: JsonValue; } -export interface SessionInfoDto { - id: string; - name?: string; - firstMessage?: string; - created: string; - modified: string; - messageCount: number; - cwd: string; -} - -export interface SessionRefDto { sessionId: string; sessionFile: string; cwd: string } -export interface CreateSessionResultDto extends SessionRefDto { state: BaseSessionStateDto; previousSessionFile?: string } -export interface DeleteSessionResultDto { id: string; disposition: "trashed" | "deleted" } -export interface NavigateTreeResultDto { cancelled: boolean; aborted?: boolean; editorText?: string; summaryEntry?: JsonValue; leafId: string | null; state: BaseSessionStateDto } -export interface ShellResultDto { output: string; exitCode?: number; cancelled: boolean; truncated: boolean; fullOutputPath?: string } -export interface ArtifactDto { name: string; base64: string } -export interface GitImageDto { path: string; base64: string } -export interface DirectoryListingDto { path: string; parent: string; dirs: Array<{ name: string; path: string }> } - export interface SessionService { defaultSessionId(): string; state(sessionId?: string): Promise>; diff --git a/server/session/service.ts b/server/session/service.ts index 94558c8..1e71f6a 100644 --- a/server/session/service.ts +++ b/server/session/service.ts @@ -162,7 +162,7 @@ export class LocalSessionService implements SessionService { list(extraCwds: string[] = []) { return this.deps.list(extraCwds); } async create(sessionId?: string, cwd?: string) { - const previous = sessionId ? await this.deps.resolve(sessionId) : undefined; + const previous = await this.deps.resolve(sessionId || this.deps.currentSessionId()); const created = await this.deps.create(cwd || (previous ? this.deps.cwd(previous) : this.deps.globalCwd()), previous?.sessionFile); return this.deps.decorateState(created); } diff --git a/tests/api.test.ts b/tests/api.test.ts index ce9eff2..a3d4178 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -446,9 +446,20 @@ describe("pi-web mock API", () => { }); it("creates and opens sessions through validated session APIs", async () => { + const currentState = await (await fetch(`${baseUrl}/api/state`)).json(); const newRes = await fetch(`${baseUrl}/api/sessions/new`, { method: "POST" }); expect(newRes.status).toBe(200); - expect((await newRes.json()).sessionId).toMatch(/^mock-/); + const created = await newRes.json(); + expect(created.sessionId).toMatch(/^mock-/); + expect(created.cwd).toBe(currentState.cwd); + + const unknownSourceRes = await fetch(`${baseUrl}/api/sessions/new`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sessionId: "missing-source" }), + }); + expect(unknownSourceRes.status).toBe(200); + expect((await unknownSourceRes.json()).cwd).toBe(currentState.cwd); const openRes = await fetch(`${baseUrl}/api/sessions/open`, { method: "POST", From d2b81036957708cde05dd8c8a1feed2360fa74ad Mon Sep 17 00:00:00 2001 From: Ashwin Pc Date: Mon, 20 Jul 2026 19:19:53 -0700 Subject: [PATCH 08/10] Lock down session route parity --- server.ts | 11 ++++- tests/api.test.ts | 69 ++++++++++++++++++++++++++----- tests/session-service.test.ts | 77 +++++++++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+), 12 deletions(-) create mode 100644 tests/session-service.test.ts diff --git a/server.ts b/server.ts index da80aa7..26108fd 100644 --- a/server.ts +++ b/server.ts @@ -1034,16 +1034,23 @@ async function navigateSession(targetSession: PiWebSession, targetId: string, op if (targetSession.isCompacting) throw new SessionServiceError("Wait for the current compaction to finish before navigating the tree", 409); if (!targetSession.navigateTree) throw new SessionServiceError("Tree navigation is not available"); const releaseWorkLease = acquireWorkLease(targetSession); + let finishAfterResponse = false; + const finish = () => { + releaseWorkLease(); + broadcast({ type: "session_runtime_changed", sessionId: targetSession.sessionId, sessionFile: targetSession.sessionFile, runtime: sessionActivity.runtimeForPath(targetSession.sessionFile) }); + }; try { const navigation = targetSession.navigateTree(targetId, options as any); broadcast({ type: "session_runtime_changed", sessionId: targetSession.sessionId, sessionFile: targetSession.sessionFile, runtime: sessionActivity.runtimeForPath(targetSession.sessionFile) }); const result = await navigation; const state = currentStateWithThinkingLevels(targetSession); broadcast({ type: "state_changed", ...state }); + finishAfterResponse = true; + // Defer the terminal runtime event so the HTTP route can write the navigation response first. + setTimeout(finish, 10); return { ...result, leafId: targetSession.sessionManager.getLeafId?.() || null, state }; } finally { - releaseWorkLease(); - broadcast({ type: "session_runtime_changed", sessionId: targetSession.sessionId, sessionFile: targetSession.sessionFile, runtime: sessionActivity.runtimeForPath(targetSession.sessionFile) }); + if (!finishAfterResponse) finish(); } } diff --git a/tests/api.test.ts b/tests/api.test.ts index a3d4178..d182fe1 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -164,6 +164,30 @@ describe("pi-web mock API", () => { await fetch(`${baseUrl}/api/mock/reset`, { method: "POST" }); }); + it("writes the tree navigation response before its terminal runtime event", async () => { + await fetch(`${baseUrl}/api/mock/reset`, { method: "POST" }); + const ws = new WebSocket(`ws://127.0.0.1:${new URL(baseUrl).port}/ws?sessionId=mock-current`); + await once(ws, "open"); + const order: string[] = []; + ws.on("message", (data) => { + const event = JSON.parse(String(data)); + if (event.type === "session_runtime_changed" && event.runtime?.isRunning === false) order.push("terminal"); + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + order.length = 0; + const response = await fetch(`${baseUrl}/api/session/tree/navigate`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ sessionId: "mock-current", targetId: "mock-u1" }), + }); + order.push("response"); + expect(response.status).toBe(200); + await waitForCondition(() => order.filter((item) => item === "terminal").length >= 2); + expect(order.indexOf("response")).toBeLessThan(order.lastIndexOf("terminal")); + ws.close(); + await fetch(`${baseUrl}/api/mock/reset`, { method: "POST" }); + }); + it("returns a very deep conversation tree without overflowing the stack", async () => { const port = await freePort(); const deepChild = spawn(process.execPath, ["--import", "tsx", "server.ts"], { @@ -305,21 +329,29 @@ describe("pi-web mock API", () => { } }); - it("renames the current session", async () => { - const res = await fetch(`${baseUrl}/api/session/name`, { + it("renames and clears the current session without duplicate state broadcasts", async () => { + const ws = new WebSocket(`ws://127.0.0.1:${new URL(baseUrl).port}/ws?sessionId=mock-current`); + await once(ws, "open"); + const events: any[] = []; + ws.on("message", (data) => events.push(JSON.parse(String(data)))); + + const rename = async (name: string) => fetch(`${baseUrl}/api/session/name`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ sessionId: "mock-current", name: "Renamed mock session" }), + body: JSON.stringify({ sessionId: "mock-current", name }), }); - expect(res.status).toBe(200); - const data = await res.json(); - expect(data.sessionName).toBe("Renamed mock session"); + expect((await rename("Renamed mock session")).status).toBe(200); + await waitForCondition(() => events.some((event) => event.type === "state_changed")); + events.length = 0; + const clearRes = await rename(""); + expect(clearRes.status).toBe(200); + expect((await clearRes.json()).sessionName).toBeUndefined(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(events.filter((event) => event.type === "state_changed")).toHaveLength(1); + ws.close(); const state = await (await fetch(`${baseUrl}/api/state`)).json(); - expect(state.sessionName).toBe("Renamed mock session"); - - const sessions = await (await fetch(`${baseUrl}/api/sessions`)).json(); - expect(sessions.sessions.find((item: any) => item.id === "mock-current").name).toBe("Renamed mock session"); + expect(state.sessionName).toBeUndefined(); }); it("rejects empty prompts", async () => { @@ -445,6 +477,23 @@ describe("pi-web mock API", () => { expect(older.isCurrent).toBe(false); }); + it("preserves missing-session status codes across session routes", async () => { + const missing = "does-not-exist"; + const cases: Array<[string, RequestInit, number]> = [ + [`/api/state?sessionId=${missing}`, {}, 404], + [`/api/messages?sessionId=${missing}`, {}, 404], + ["/api/sessions/open", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: missing }) }, 404], + ["/api/session/cwd", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId: missing, cwd: "/tmp" }) }, 404], + ["/api/web-header-action/invoke", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId: missing, key: "recap" }) }, 404], + ["/api/web-git-tab/invoke", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId: missing, key: "status" }) }, 404], + [`/api/session/tree?sessionId=${missing}`, {}, 404], + ["/api/session/tree/navigate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId: missing, targetId: "" }) }, 404], + ["/api/session/tree/navigate", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId: "mock-current", targetId: "" }) }, 400], + ["/api/session/name", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId: missing, name: "" }) }, 404], + ]; + for (const [path, init, status] of cases) expect((await fetch(`${baseUrl}${path}`, init)).status, path).toBe(status); + }); + it("creates and opens sessions through validated session APIs", async () => { const currentState = await (await fetch(`${baseUrl}/api/state`)).json(); const newRes = await fetch(`${baseUrl}/api/sessions/new`, { method: "POST" }); diff --git a/tests/session-service.test.ts b/tests/session-service.test.ts new file mode 100644 index 0000000..cf2eba1 --- /dev/null +++ b/tests/session-service.test.ts @@ -0,0 +1,77 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it, vi } from "vitest"; +import { jsonRoundTrip } from "../server/session/dto.js"; +import { LocalSessionService, type LocalSessionServiceDependencies } from "../server/session/service.js"; +import type { PiWebSession } from "../server/types.js"; + +function fixtureSession(): PiWebSession { + const entries = [ + { id: "call", parentId: null, type: "message", timestamp: "2026-01-01T00:00:00Z", message: { role: "assistant", content: [{ type: "toolCall", id: "t1", toolName: "read", args: { path: "secret" }, startedAt: "2026-01-01T00:00:00Z" }], timestamp: "2026-01-01T00:00:00Z" } }, + { id: "result", parentId: "call", type: "message", timestamp: "2026-01-01T00:00:01Z", message: { role: "toolResult", toolCallId: "t1", toolName: "read", content: [{ type: "text", text: "ok" }], isError: false, timestamp: "2026-01-01T00:00:01Z" } }, + ]; + const model = { provider: "test", id: "model", name: "Model", reasoning: true, contextWindow: 1000, maxTokens: 100 }; + return { + sessionId: "current", sessionFile: "/tmp/current.jsonl", isStreaming: false, isCompacting: false, + model, thinkingLevel: "medium", messages: entries.map((entry) => entry.message), agent: { state: { messages: entries.map((entry) => entry.message) } }, + sessionManager: { newSession() {}, getBranch: () => entries, getLeafId: () => "result", getTree: () => [{ entry: entries[0], children: [{ entry: entries[1], children: [] }] }] }, + modelRegistry: { getAvailable: () => [model], find: () => model }, + extensionRunner: { getRegisteredCommands: () => [] }, promptTemplates: [], resourceLoader: { getSkills: () => ({ skills: [] }) }, + getAvailableThinkingLevels: () => ["off", "medium"], getSessionName: () => "Fixture", getContextUsage: () => ({ tokens: 1, contextWindow: 1000, percent: 0.1 }), + setModel: vi.fn(async () => undefined), setThinkingLevel: vi.fn(), prompt: async () => undefined, abort: async () => undefined, + }; +} + +function fixtureService() { + const session = fixtureSession(); + const creates: Array<{ cwd?: string; previous?: string }> = []; + const deps: LocalSessionServiceDependencies = { + currentSessionId: () => "current", globalCwd: () => "/global", resolve: async (id) => id === "current" ? session : undefined, + cwd: () => "/current", decorateState: () => ({ sessionId: "current", cwd: "/current", model: { provider: "test", id: "model" } }), + decorateMessageContent: (content) => content, availableModels: () => [session.model], webCommands: [{ name: "web", source: "web" }], + list: async () => [], create: async (cwd, previous) => { creates.push({ cwd, previous }); return session; }, open: async () => session, + delete: async () => ({}), switchCwd: async () => ({}), executeCommand: async () => ({ message: "ok", state: {} }), prompt: async () => undefined, + retry: async () => undefined, navigate: async () => ({}), invokeHeaderAction: async () => ({}), invokeGitTab: async () => ({}), reportError: () => undefined, + }; + return { service: new LocalSessionService(deps), session, creates }; +} + +describe("LocalSessionService contract", () => { + it("returns JSON-round-trip-stable projection results", async () => { + const { service } = fixtureService(); + for (const result of await Promise.all([service.state(), service.stats(), service.tree(), service.messages(), service.models(), service.commands()])) { + expect(jsonRoundTrip(result)).toStrictEqual(result); + } + }); + + it("maps unavailable conversation trees to the legacy 400 status", async () => { + const { service, session } = fixtureService(); + session.sessionManager.getTree = undefined; + await expect(service.tree()).rejects.toMatchObject({ status: 400 }); + }); + + it("preserves message args and empty thinking-level behavior", async () => { + const { service, session } = fixtureService(); + const messages = await service.messages() as Array>; + expect(messages[1].toolArgs).toEqual({}); + await service.setModel(undefined, "test", "model", ""); + expect(session.setThinkingLevel).toHaveBeenCalledWith(""); + }); + + it("inherits the current session but lets unknown sources fall back globally", async () => { + const { service, creates } = fixtureService(); + await service.create(); + await service.create("missing"); + expect(creates).toEqual([ + { cwd: "/current", previous: "/tmp/current.jsonl" }, + { cwd: "/global", previous: undefined }, + ]); + }); +}); + +describe("session route boundary", () => { + it("does not access PiWebSession members directly in HTTP route bodies", async () => { + const source = await readFile(new URL("../server.ts", import.meta.url), "utf8"); + const routes = source.slice(source.indexOf("const server = createServer")); + expect(routes).not.toContain("targetSession."); + }); +}); From 219a41c3f88c00107ba6b610c7a3a7545417a1cc Mon Sep 17 00:00:00 2001 From: Ashwin Pc Date: Mon, 20 Jul 2026 19:57:35 -0700 Subject: [PATCH 09/10] Finalize tree navigation after response --- server.ts | 22 +++++++++++++--------- server/session/dto.ts | 4 +++- server/session/service.ts | 4 ++-- tests/api.test.ts | 13 ++++++------- tests/session-service.test.ts | 11 ++++++++++- 5 files changed, 34 insertions(+), 20 deletions(-) diff --git a/server.ts b/server.ts index 26108fd..d57cce0 100644 --- a/server.ts +++ b/server.ts @@ -1034,8 +1034,10 @@ async function navigateSession(targetSession: PiWebSession, targetId: string, op if (targetSession.isCompacting) throw new SessionServiceError("Wait for the current compaction to finish before navigating the tree", 409); if (!targetSession.navigateTree) throw new SessionServiceError("Tree navigation is not available"); const releaseWorkLease = acquireWorkLease(targetSession); - let finishAfterResponse = false; + let finished = false; const finish = () => { + if (finished) return; + finished = true; releaseWorkLease(); broadcast({ type: "session_runtime_changed", sessionId: targetSession.sessionId, sessionFile: targetSession.sessionFile, runtime: sessionActivity.runtimeForPath(targetSession.sessionFile) }); }; @@ -1045,12 +1047,10 @@ async function navigateSession(targetSession: PiWebSession, targetId: string, op const result = await navigation; const state = currentStateWithThinkingLevels(targetSession); broadcast({ type: "state_changed", ...state }); - finishAfterResponse = true; - // Defer the terminal runtime event so the HTTP route can write the navigation response first. - setTimeout(finish, 10); - return { ...result, leafId: targetSession.sessionManager.getLeafId?.() || null, state }; - } finally { - if (!finishAfterResponse) finish(); + return { ...result, leafId: targetSession.sessionManager.getLeafId?.() || null, state, finish }; + } catch (error) { + finish(); + throw error; } } @@ -1328,13 +1328,17 @@ const server = createServer(async (req, res) => { await sessionService.require(requestedSessionId); const targetId = String(body.targetId || "").trim(); if (!targetId) return sendJson(res, 400, { ok: false, error: "targetId is required" }); - const result = await sessionService.navigate(requestedSessionId, targetId, { + const { finish, ...result } = await sessionService.navigate(requestedSessionId, targetId, { summarize: Boolean(body.summarize), customInstructions: typeof body.customInstructions === "string" && body.customInstructions.trim() ? body.customInstructions.trim() : undefined, replaceInstructions: Boolean(body.replaceInstructions), label: typeof body.label === "string" && body.label.trim() ? body.label.trim() : undefined, }); - return sendJson(res, 200, { ok: true, ...result }); + try { + return sendJson(res, 200, { ok: true, ...result }); + } finally { + finish(); + } } if (method === "POST" && url.pathname === "/api/session/tree/abort-summary") { diff --git a/server/session/dto.ts b/server/session/dto.ts index 6ba6a6e..a2013e7 100644 --- a/server/session/dto.ts +++ b/server/session/dto.ts @@ -67,6 +67,8 @@ export interface SlashCommandDto { sourceInfo?: JsonValue; } +export type NavigationResult = Record & { finish(): void }; + export interface SessionService { defaultSessionId(): string; state(sessionId?: string): Promise>; @@ -84,7 +86,7 @@ export interface SessionService { abortCompaction(sessionId?: string): Promise<{ sessionId: string }>; abortBranchSummary(sessionId?: string): Promise<{ sessionId: string }>; rename(sessionId: string | undefined, name: string): Promise>; - navigate(sessionId: string | undefined, targetId: string, options: Record): Promise>; + navigate(sessionId: string | undefined, targetId: string, options: Record): Promise; invokeHeaderAction(sessionId: string | undefined, key: unknown): Promise>; invokeGitTab(sessionId: string | undefined, input: Record): Promise>; list(extraCwds?: string[]): Promise>>; diff --git a/server/session/service.ts b/server/session/service.ts index 1e71f6a..b7c6992 100644 --- a/server/session/service.ts +++ b/server/session/service.ts @@ -1,5 +1,5 @@ import type { PiWebSession } from "../types.js"; -import type { SessionService, SlashCommandDto } from "./dto.js"; +import type { NavigationResult, SessionService, SlashCommandDto } from "./dto.js"; import { conversationTreeForSession, getSessionSlashCommands, messageEntryRefs, sessionStats, simplifyMessage, simplifyModel } from "./projection.js"; export class SessionServiceError extends Error { @@ -23,7 +23,7 @@ export interface LocalSessionServiceDependencies { executeCommand(command: string, session: PiWebSession): Promise<{ message: string; state: Record }>; prompt(session: PiWebSession, input: { message: string; mode: string; images: Array<{ data: string; mimeType: string; name?: string }> }): Promise; retry(session: PiWebSession): Promise; - navigate(session: PiWebSession, targetId: string, options: Record): Promise>; + navigate(session: PiWebSession, targetId: string, options: Record): Promise; invokeHeaderAction(session: PiWebSession, key: unknown): Promise>; invokeGitTab(session: PiWebSession, input: Record): Promise>; reportError(session: PiWebSession, error: unknown): void; diff --git a/tests/api.test.ts b/tests/api.test.ts index d182fe1..0ee8af1 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -164,26 +164,25 @@ describe("pi-web mock API", () => { await fetch(`${baseUrl}/api/mock/reset`, { method: "POST" }); }); - it("writes the tree navigation response before its terminal runtime event", async () => { + it("returns the tree navigation response and emits its terminal runtime event", async () => { await fetch(`${baseUrl}/api/mock/reset`, { method: "POST" }); const ws = new WebSocket(`ws://127.0.0.1:${new URL(baseUrl).port}/ws?sessionId=mock-current`); await once(ws, "open"); - const order: string[] = []; + const runtimeEvents: any[] = []; ws.on("message", (data) => { const event = JSON.parse(String(data)); - if (event.type === "session_runtime_changed" && event.runtime?.isRunning === false) order.push("terminal"); + if (event.type === "session_runtime_changed") runtimeEvents.push(event); }); await new Promise((resolve) => setTimeout(resolve, 50)); - order.length = 0; + runtimeEvents.length = 0; const response = await fetch(`${baseUrl}/api/session/tree/navigate`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ sessionId: "mock-current", targetId: "mock-u1" }), }); - order.push("response"); expect(response.status).toBe(200); - await waitForCondition(() => order.filter((item) => item === "terminal").length >= 2); - expect(order.indexOf("response")).toBeLessThan(order.lastIndexOf("terminal")); + expect((await response.json()).leafId).toBeNull(); + await waitForCondition(() => runtimeEvents.length >= 2); ws.close(); await fetch(`${baseUrl}/api/mock/reset`, { method: "POST" }); }); diff --git a/tests/session-service.test.ts b/tests/session-service.test.ts index cf2eba1..2516fdb 100644 --- a/tests/session-service.test.ts +++ b/tests/session-service.test.ts @@ -30,7 +30,7 @@ function fixtureService() { decorateMessageContent: (content) => content, availableModels: () => [session.model], webCommands: [{ name: "web", source: "web" }], list: async () => [], create: async (cwd, previous) => { creates.push({ cwd, previous }); return session; }, open: async () => session, delete: async () => ({}), switchCwd: async () => ({}), executeCommand: async () => ({ message: "ok", state: {} }), prompt: async () => undefined, - retry: async () => undefined, navigate: async () => ({}), invokeHeaderAction: async () => ({}), invokeGitTab: async () => ({}), reportError: () => undefined, + retry: async () => undefined, navigate: async () => ({ finish() {} }), invokeHeaderAction: async () => ({}), invokeGitTab: async () => ({}), reportError: () => undefined, }; return { service: new LocalSessionService(deps), session, creates }; } @@ -74,4 +74,13 @@ describe("session route boundary", () => { const routes = source.slice(source.indexOf("const server = createServer")); expect(routes).not.toContain("targetSession."); }); + + it("writes a navigation response before calling its finalizer", async () => { + const source = await readFile(new URL("../server.ts", import.meta.url), "utf8"); + const start = source.indexOf('url.pathname === "/api/session/tree/navigate"'); + const route = source.slice(start, source.indexOf('url.pathname === "/api/session/tree/abort-summary"', start)); + expect(route.indexOf("sendJson(res, 200")).toBeGreaterThan(-1); + expect(route.indexOf("sendJson(res, 200")).toBeLessThan(route.indexOf("finish();")); + expect(route).not.toContain("setTimeout"); + }); }); From 04d8da5ce9890151fb8c7986ea2ac45f20e4dda6 Mon Sep 17 00:00:00 2001 From: Ashwin Pc Date: Mon, 20 Jul 2026 20:39:10 -0700 Subject: [PATCH 10/10] Restore footer state for recreated sessions --- examples/pi-web-extensions/git-footer.ts | 7 +++- tests/extensions.test.ts | 46 +++++++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/examples/pi-web-extensions/git-footer.ts b/examples/pi-web-extensions/git-footer.ts index f64b0b3..de95ad0 100644 --- a/examples/pi-web-extensions/git-footer.ts +++ b/examples/pi-web-extensions/git-footer.ts @@ -15,6 +15,7 @@ type GitSnapshot = { type SessionState = { ctx: PiWebExtensionContext; + sessionManager: PiWebExtensionContext["sessionManager"]; interval: ReturnType; lastHtml?: string; }; @@ -114,13 +115,17 @@ function startRefreshing(ctx: PiWebExtensionContext) { const key = sessionKey(ctx); const existing = sessions.get(key); if (existing) { + const runtimeChanged = existing.sessionManager !== ctx.sessionManager; existing.ctx = ctx; + existing.sessionManager = ctx.sessionManager; + if (runtimeChanged) existing.lastHtml = undefined; refresh(ctx); return; } const state: SessionState = { ctx, + sessionManager: ctx.sessionManager, interval: setInterval(() => refresh(state.ctx), REFRESH_MS), }; sessions.set(key, state); @@ -130,7 +135,7 @@ function startRefreshing(ctx: PiWebExtensionContext) { function stopRefreshing(ctx: PiWebExtensionContext) { const key = sessionKey(ctx); const state = sessions.get(key); - if (!state) return; + if (!state || state.sessionManager !== ctx.sessionManager) return; clearInterval(state.interval); sessions.delete(key); ctx.ui.web.setFooter(FOOTER_KEY, undefined); diff --git a/tests/extensions.test.ts b/tests/extensions.test.ts index ac7df8b..f28782d 100644 --- a/tests/extensions.test.ts +++ b/tests/extensions.test.ts @@ -1,8 +1,9 @@ import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { discoverExtensionEntryPaths, resolveBundledExtensionPaths } from "../server/extensions.js"; +import gitFooterExtension from "../examples/pi-web-extensions/git-footer.js"; const tempDirs: string[] = []; @@ -61,4 +62,47 @@ describe("bundled extension path discovery", () => { expect(resolveBundledExtensionPaths({ piCwd: appDir, appDir, bundledExtensionsDir })).toEqual([]); }); + + it("re-emits a footer when the same session id gets a new runtime", async () => { + vi.useFakeTimers(); + try { + const handlers = new Map unknown>>(); + gitFooterExtension({ + on(event: string, handler: (event: unknown, context: any) => unknown) { + const list = handlers.get(event) || []; + list.push(handler); + handlers.set(event, list); + }, + } as any); + + const makeContext = () => { + const calls: Array<[string, unknown]> = []; + const sessionManager = { getSessionId: () => "same-session", getCwd: () => process.cwd() }; + return { + calls, + context: { + cwd: process.cwd(), + sessionManager, + ui: { web: { setFooter: (key: string, footer: unknown) => calls.push([key, footer]) } }, + }, + }; + }; + const first = makeContext(); + const replacement = makeContext(); + const start = handlers.get("session_start")![0]; + const shutdown = handlers.get("session_shutdown")![0]; + + await start({}, first.context); + await start({}, replacement.context); + expect(first.calls.at(-1)?.[1]).toMatchObject({ kind: "html" }); + expect(replacement.calls.at(-1)?.[1]).toMatchObject({ kind: "html" }); + + await shutdown({}, first.context); + expect(replacement.calls.at(-1)?.[1]).toMatchObject({ kind: "html" }); + await shutdown({}, replacement.context); + expect(replacement.calls.at(-1)).toEqual(["local-git-footer", undefined]); + } finally { + vi.useRealTimers(); + } + }); });