@ashwin-pc/pi-web 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.ts ADDED
@@ -0,0 +1,1789 @@
1
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
2
+ import { createReadStream, existsSync, readFileSync } from "node:fs";
3
+ import { mkdir, readdir, stat, writeFile } from "node:fs/promises";
4
+ import { randomUUID } from "node:crypto";
5
+ import { execFile } from "node:child_process";
6
+ import { promisify } from "node:util";
7
+ import { extname, isAbsolute, join, relative, resolve } from "node:path";
8
+ import { createServer as createViteServer, type ViteDevServer } from "vite";
9
+ import { fileURLToPath } from "node:url";
10
+ import { WebSocketServer, type WebSocket } from "ws";
11
+ import {
12
+ AuthStorage,
13
+ createAgentSession,
14
+ DefaultResourceLoader,
15
+ getAgentDir,
16
+ ModelRegistry,
17
+ SessionManager,
18
+ type ExtensionUIDialogOptions,
19
+ type ExtensionUIContext,
20
+ type SessionStartEvent,
21
+ type SlashCommandInfo,
22
+ } from "@mariozechner/pi-coding-agent";
23
+ import { createMockHarness } from "./server/mock.js";
24
+ import { resolveBundledExtensionPaths } from "./server/extensions.js";
25
+ import { createSettingsStore } from "./server/settings.js";
26
+ import type { PiWebSession } from "./server/types.js";
27
+
28
+ const appDir = resolve(fileURLToPath(new URL(".", import.meta.url)));
29
+ const distDir = join(appDir, "dist");
30
+ const staticDir = distDir;
31
+
32
+ const isDev = process.env.PI_WEB_DEV === "1" || process.env.NODE_ENV === "development";
33
+ const host = process.env.HOST || "127.0.0.1";
34
+ const port = Number(process.env.PORT || 8787);
35
+ const token = process.env.PI_WEB_TOKEN || "";
36
+ let piCwd = resolve(process.env.PI_WEB_CWD || process.cwd());
37
+ let artifactDir = join(piCwd, ".pi", "web", "artifacts");
38
+ let legacyArtifactDir = join(piCwd, ".pi-web-uploads", "artifacts");
39
+ const knownCwds = new Set<string>([piCwd]);
40
+
41
+ const webUiContextFile = join(appDir, "contexts", "web-ui.md");
42
+ const bundledExtensionsDir = join(appDir, ".pi", "extensions");
43
+ const noSession = process.env.PI_WEB_NO_SESSION === "1";
44
+ const mockMode = process.env.PI_WEB_MOCK === "1";
45
+ const execFileAsync = promisify(execFile);
46
+
47
+ type WebSlashCommandInfo = Omit<SlashCommandInfo, "source"> & { source: SlashCommandInfo["source"] | "web" };
48
+
49
+ const webSlashCommands: WebSlashCommandInfo[] = [
50
+ { name: "help", description: "Show slash command help", source: "web", sourceInfo: { path: "<pi-web>", source: "pi-web", scope: "temporary", origin: "top-level" } },
51
+ { name: "commands", description: "List available web, extension, prompt, and skill commands", source: "web", sourceInfo: { path: "<pi-web>", source: "pi-web", scope: "temporary", origin: "top-level" } },
52
+ { name: "reload", description: "Reload pi resources, extensions, skills, prompts, and models", source: "web", sourceInfo: { path: "<pi-web>", source: "pi-web", scope: "temporary", origin: "top-level" } },
53
+ { name: "model", description: "List models or switch with /model <provider/model-id>", source: "web", sourceInfo: { path: "<pi-web>", source: "pi-web", scope: "temporary", origin: "top-level" } },
54
+ { name: "models", description: "List available models", source: "web", sourceInfo: { path: "<pi-web>", source: "pi-web", scope: "temporary", origin: "top-level" } },
55
+ { name: "thinking", description: "Show or set reasoning level", source: "web", sourceInfo: { path: "<pi-web>", source: "pi-web", scope: "temporary", origin: "top-level" } },
56
+ { name: "new", description: "Start a new session", source: "web", sourceInfo: { path: "<pi-web>", source: "pi-web", scope: "temporary", origin: "top-level" } },
57
+ { name: "compact", description: "Compact conversation context; optional instructions after the command", source: "web", sourceInfo: { path: "<pi-web>", source: "pi-web", scope: "temporary", origin: "top-level" } },
58
+ { name: "abort", description: "Stop the current response", source: "web", sourceInfo: { path: "<pi-web>", source: "pi-web", scope: "temporary", origin: "top-level" } },
59
+ { name: "stop", description: "Stop the current response", source: "web", sourceInfo: { path: "<pi-web>", source: "pi-web", scope: "temporary", origin: "top-level" } },
60
+ { name: "logout", description: "Clear the web UI token in this browser", source: "web", sourceInfo: { path: "<pi-web>", source: "pi-web", scope: "temporary", origin: "top-level" } },
61
+ ];
62
+
63
+ const contentTypes: Record<string, string> = {
64
+ ".html": "text/html; charset=utf-8",
65
+ ".css": "text/css; charset=utf-8",
66
+ ".js": "text/javascript; charset=utf-8",
67
+ ".json": "application/json; charset=utf-8",
68
+ ".md": "text/markdown; charset=utf-8",
69
+ ".markdown": "text/markdown; charset=utf-8",
70
+ ".svg": "image/svg+xml",
71
+ ".png": "image/png",
72
+ ".jpg": "image/jpeg",
73
+ ".jpeg": "image/jpeg",
74
+ ".gif": "image/gif",
75
+ ".webp": "image/webp",
76
+ };
77
+
78
+ function sendJson(res: ServerResponse, status: number, value: unknown) {
79
+ const body = JSON.stringify(value);
80
+ res.writeHead(status, {
81
+ "content-type": "application/json; charset=utf-8",
82
+ "content-length": Buffer.byteLength(body),
83
+ });
84
+ res.end(body);
85
+ }
86
+
87
+ function unauthorized(res: ServerResponse) {
88
+ sendJson(res, 401, { ok: false, error: "Unauthorized" });
89
+ }
90
+
91
+ function requestToken(req: IncomingMessage): string {
92
+ const auth = req.headers.authorization || "";
93
+ if (auth.startsWith("Bearer ")) return auth.slice("Bearer ".length);
94
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
95
+ return url.searchParams.get("token") || "";
96
+ }
97
+
98
+ function isAuthorized(req: IncomingMessage): boolean {
99
+ return !token || requestToken(req) === token;
100
+ }
101
+
102
+ async function readBody(req: IncomingMessage): Promise<unknown> {
103
+ const chunks: Buffer[] = [];
104
+ for await (const chunk of req) chunks.push(Buffer.from(chunk));
105
+ const text = Buffer.concat(chunks).toString("utf-8");
106
+ return text ? JSON.parse(text) : {};
107
+ }
108
+
109
+ function safeArtifactName(name: string) {
110
+ return name.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^\.+/, "").slice(0, 160);
111
+ }
112
+
113
+ function serveArtifact(req: IncomingMessage, res: ServerResponse) {
114
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
115
+ const rawName = decodeURIComponent(url.pathname.slice("/api/artifacts/".length));
116
+ const name = safeArtifactName(rawName);
117
+ if (!name || rawName.includes("..") || rawName.includes("/") || name !== rawName) return sendJson(res, 400, { ok: false, error: "Invalid artifact name" });
118
+
119
+ const file = resolve(artifactDir, name);
120
+ const legacyFile = resolve(legacyArtifactDir, name);
121
+ const resolvedFile = file.startsWith(artifactDir) && existsSync(file)
122
+ ? file
123
+ : legacyFile.startsWith(legacyArtifactDir) && existsSync(legacyFile)
124
+ ? legacyFile
125
+ : "";
126
+ if (!resolvedFile) return sendJson(res, 404, { ok: false, error: "Artifact not found" });
127
+
128
+ res.writeHead(200, {
129
+ "content-type": contentTypes[extname(resolvedFile).toLowerCase()] || "application/octet-stream",
130
+ "cache-control": "no-store",
131
+ });
132
+ createReadStream(resolvedFile).pipe(res);
133
+ }
134
+
135
+ function serveStatic(req: IncomingMessage, res: ServerResponse) {
136
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
137
+ const pathname = decodeURIComponent(url.pathname);
138
+ const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
139
+ const file = resolve(staticDir, relative);
140
+
141
+ if (!file.startsWith(staticDir) || !existsSync(file)) {
142
+ sendJson(res, 404, { ok: false, error: "Not found" });
143
+ return;
144
+ }
145
+
146
+ res.writeHead(200, { "content-type": contentTypes[extname(file)] || "application/octet-stream" });
147
+ createReadStream(file).pipe(res);
148
+ }
149
+
150
+ function textFromContent(content: unknown): string {
151
+ if (typeof content === "string") return content;
152
+ if (!Array.isArray(content)) return "";
153
+ return content.map((part) => {
154
+ if (!part || typeof part !== "object") return "";
155
+ const p = part as Record<string, unknown>;
156
+ if (p.type === "text" && typeof p.text === "string") return p.text;
157
+ if (p.type === "image") return "[image]";
158
+ // toolCall parts are rendered as tool cards in the UI — omit from text
159
+ return "";
160
+ }).filter(Boolean).join("\n");
161
+ }
162
+
163
+ function simplifyModel(model: any) {
164
+ if (!model) return undefined;
165
+ return {
166
+ provider: model.provider,
167
+ id: model.id,
168
+ name: model.name || model.id,
169
+ reasoning: Boolean(model.reasoning),
170
+ contextWindow: model.contextWindow,
171
+ maxTokens: model.maxTokens,
172
+ };
173
+ }
174
+
175
+ async function git(args: string[], timeout = 15_000, cwd = piCwd) {
176
+ return execFileAsync("git", args, { cwd, timeout, maxBuffer: 10 * 1024 * 1024 });
177
+ }
178
+
179
+ async function gitBuffer(args: string[], timeout = 15_000, cwd = piCwd) {
180
+ return new Promise<Buffer>((resolvePromise, reject) => {
181
+ execFile("git", args, { cwd, timeout, maxBuffer: 50 * 1024 * 1024, encoding: "buffer" }, (error, stdout) => {
182
+ if (error) {
183
+ (error as any).stdout = stdout;
184
+ reject(error);
185
+ return;
186
+ }
187
+ resolvePromise(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout));
188
+ });
189
+ });
190
+ }
191
+
192
+ async function isGitRepo(cwd = piCwd) {
193
+ try { await git(["rev-parse", "--is-inside-work-tree"], 15_000, cwd); return true; } catch { return false; }
194
+ }
195
+
196
+ async function assertDirectory(path: string) {
197
+ const resolved = resolve(path || piCwd);
198
+ const info = await stat(resolved);
199
+ if (!info.isDirectory()) throw new Error("Path is not a directory");
200
+ return resolved;
201
+ }
202
+
203
+ async function listDirectories(path: string) {
204
+ const resolved = await assertDirectory(path);
205
+ const entries = await readdir(resolved, { withFileTypes: true });
206
+ const dirs = entries
207
+ .filter((entry) => entry.isDirectory())
208
+ .map((entry) => ({ name: entry.name, path: join(resolved, entry.name) }))
209
+ .sort((a, b) => a.name.localeCompare(b.name));
210
+ return { ok: true, path: resolved, parent: resolve(resolved, ".."), dirs };
211
+ }
212
+
213
+ function hasUserMessages(value: PiWebSession) {
214
+ return value.messages.some((message: any) => message?.role === "user");
215
+ }
216
+
217
+ async function ensurePiWebStorage(cwd = piCwd) {
218
+ const webDir = join(cwd, ".pi", "web");
219
+ await mkdir(webDir, { recursive: true });
220
+ const ignoreFile = join(webDir, ".gitignore");
221
+ if (!existsSync(ignoreFile)) await writeFile(ignoreFile, "*\n");
222
+ }
223
+
224
+ async function setPiCwd(path: string) {
225
+ piCwd = await assertDirectory(path);
226
+ knownCwds.add(piCwd);
227
+ artifactDir = join(piCwd, ".pi", "web", "artifacts");
228
+ legacyArtifactDir = join(piCwd, ".pi-web-uploads", "artifacts");
229
+ await ensurePiWebStorage(piCwd);
230
+ }
231
+
232
+ function gitLabel(indexStatus: string, worktreeStatus: string) {
233
+ if (indexStatus === "?" && worktreeStatus === "?") return "untracked";
234
+ if (indexStatus === "U" || worktreeStatus === "U" || indexStatus === "A" && worktreeStatus === "A" || indexStatus === "D" && worktreeStatus === "D") return "conflicted";
235
+ if (indexStatus === "R" || worktreeStatus === "R") return "renamed";
236
+ if (indexStatus === "A" || worktreeStatus === "A") return "added";
237
+ if (indexStatus === "D" || worktreeStatus === "D") return "deleted";
238
+ if (indexStatus !== " " && indexStatus !== "?") return "staged";
239
+ return "modified";
240
+ }
241
+
242
+ function parseStatusLine(line: string) {
243
+ const indexStatus = line[0] || " ";
244
+ const worktreeStatus = line[1] || " ";
245
+ const rawPath = line.slice(3);
246
+ const renamed = rawPath.includes(" -> ");
247
+ const [oldPath, path] = renamed ? rawPath.split(" -> ") : [undefined, rawPath];
248
+ return { path: path || rawPath, oldPath, indexStatus, worktreeStatus, label: gitLabel(indexStatus, worktreeStatus), staged: indexStatus !== " " && indexStatus !== "?" };
249
+ }
250
+
251
+ async function gitStatus(cwd = piCwd, fetchRemote = false) {
252
+ if (!await isGitRepo(cwd)) return { ok: true, isRepo: false, ahead: 0, behind: 0, files: [] };
253
+ if (fetchRemote) await git(["fetch", "--prune"], 60_000, cwd).catch(() => undefined);
254
+ const [{ stdout: root }, { stdout: branchOut }, { stdout: porcelain }, upstreamResult, defaultResult] = await Promise.all([
255
+ git(["rev-parse", "--show-toplevel"], 15_000, cwd),
256
+ git(["branch", "--show-current"], 15_000, cwd).catch(() => ({ stdout: "" })),
257
+ git(["status", "--porcelain=v1", "-b"], 15_000, cwd),
258
+ git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], 15_000, cwd).catch(() => ({ stdout: "" })),
259
+ git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], 15_000, cwd).catch(() => ({ stdout: "" })),
260
+ ]);
261
+ const lines = porcelain.trimEnd().split("\n").filter(Boolean);
262
+ const header = lines[0] || "";
263
+ const ahead = Number(header.match(/ahead (\d+)/)?.[1] || 0);
264
+ const behind = Number(header.match(/behind (\d+)/)?.[1] || 0);
265
+ const trackedFiles = lines.slice(1).map(parseStatusLine).filter((file) => file.label !== "untracked");
266
+ const { stdout: untrackedOut } = await git(["ls-files", "--others", "--exclude-standard"], 15_000, cwd).catch(() => ({ stdout: "" }));
267
+ const untrackedFiles = untrackedOut.split("\n").map((path) => path.trim()).filter(Boolean).map((path) => ({
268
+ path,
269
+ indexStatus: "?",
270
+ worktreeStatus: "?",
271
+ label: "untracked",
272
+ staged: false,
273
+ }));
274
+ return {
275
+ ok: true,
276
+ isRepo: true,
277
+ root: root.trim(),
278
+ branch: branchOut.trim(),
279
+ upstream: upstreamResult.stdout.trim(),
280
+ defaultRemoteBranch: defaultResult.stdout.trim(),
281
+ ahead,
282
+ behind,
283
+ files: [...trackedFiles, ...untrackedFiles],
284
+ };
285
+ }
286
+
287
+ function safeGitPath(path: string) {
288
+ if (!path || path.startsWith("/") || path.includes("..") || path.includes("\0")) throw new Error("Invalid path");
289
+ return path;
290
+ }
291
+
292
+ function isImageGitPath(path: string) {
293
+ return [".png", ".jpg", ".jpeg", ".gif", ".webp"].includes(extname(path).toLowerCase());
294
+ }
295
+
296
+ async function sendGitImage(res: ServerResponse, options: { cwd: string; path: string; oldPath?: string; version: string; staged: boolean }) {
297
+ const filePath = safeGitPath(options.path);
298
+ const oldPath = options.oldPath ? safeGitPath(options.oldPath) : undefined;
299
+ const displayPath = options.version === "before" ? oldPath || filePath : filePath;
300
+ if (!isImageGitPath(displayPath)) return sendJson(res, 415, { ok: false, error: "Not an image file" });
301
+
302
+ const contentType = contentTypes[extname(displayPath).toLowerCase()] || "application/octet-stream";
303
+ if (options.version === "before") {
304
+ const data = await gitBuffer(["show", `HEAD:${oldPath || filePath}`], 15_000, options.cwd);
305
+ res.writeHead(200, { "content-type": contentType, "cache-control": "no-store" });
306
+ res.end(data);
307
+ return;
308
+ }
309
+
310
+ if (options.version !== "after") throw new Error("Invalid image version");
311
+ if (options.staged) {
312
+ const data = await gitBuffer(["show", `:${filePath}`], 15_000, options.cwd);
313
+ res.writeHead(200, { "content-type": contentType, "cache-control": "no-store" });
314
+ res.end(data);
315
+ return;
316
+ }
317
+
318
+ const resolved = resolve(options.cwd, filePath);
319
+ const rel = relative(options.cwd, resolved);
320
+ if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Image path is outside the repository");
321
+ const info = await stat(resolved);
322
+ if (!info.isFile()) throw new Error("Image not found");
323
+ res.writeHead(200, { "content-type": contentType, "cache-control": "no-store" });
324
+ createReadStream(resolved).pipe(res);
325
+ }
326
+
327
+ async function gitCwdFromRepoParam(repo: string | null) {
328
+ if (!repo || repo === ".") return piCwd;
329
+ if (repo.includes("\0") || isAbsolute(repo)) throw new Error("Invalid repository path");
330
+ const resolved = resolve(piCwd, repo);
331
+ const rel = relative(piCwd, resolved);
332
+ if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("Repository path is outside the workspace");
333
+ const info = await stat(resolved);
334
+ if (!info.isDirectory()) throw new Error("Repository path is not a directory");
335
+ return resolved;
336
+ }
337
+
338
+ const ignoredGitRepoDirs = new Set([".git", ".pi", ".pi-web-uploads", "node_modules", "dist", "build", ".cache", ".next", "target", "vendor"]);
339
+
340
+ async function gitRepoSummary(path: string, cwd: string) {
341
+ const status = await gitStatus(cwd) as any;
342
+ return {
343
+ path,
344
+ root: status.root || cwd,
345
+ branch: status.branch || "",
346
+ upstream: status.upstream || "",
347
+ ahead: status.ahead || 0,
348
+ behind: status.behind || 0,
349
+ dirtyCount: status.files?.length || 0,
350
+ isCurrent: path === ".",
351
+ };
352
+ }
353
+
354
+ async function listGitRepos() {
355
+ const repos: Array<Awaited<ReturnType<typeof gitRepoSummary>>> = [];
356
+ const seenRoots = new Set<string>();
357
+ async function addRepo(path: string, cwd: string) {
358
+ if (!await isGitRepo(cwd)) return;
359
+ const { stdout } = await git(["rev-parse", "--show-toplevel"], 15_000, cwd);
360
+ const root = resolve(stdout.trim());
361
+ if (seenRoots.has(root)) return;
362
+ seenRoots.add(root);
363
+ repos.push(await gitRepoSummary(path, cwd));
364
+ }
365
+
366
+ await addRepo(".", piCwd);
367
+ const entries = await readdir(piCwd, { withFileTypes: true });
368
+ for (const entry of entries) {
369
+ if (!entry.isDirectory() || ignoredGitRepoDirs.has(entry.name)) continue;
370
+ const cwd = join(piCwd, entry.name);
371
+ if (!existsSync(join(cwd, ".git"))) continue;
372
+ await addRepo(entry.name, cwd);
373
+ }
374
+ return { ok: true, cwd: piCwd, depth: 1, repos };
375
+ }
376
+
377
+ function parseCommit(entry: string) {
378
+ const [hash = "", shortHash = "", parents = "", author = "", date = "", refs = "", subject = ""] = entry.split("\x1f");
379
+ return { hash, shortHash, parents: parents ? parents.split(" ").filter(Boolean) : [], author, date, refs: refs ? refs.split(", ").filter(Boolean) : [], subject };
380
+ }
381
+
382
+ async function gitLog(cwd = piCwd) {
383
+ if (!await isGitRepo(cwd)) return { ok: true, isRepo: false, commits: [] };
384
+ 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);
385
+ const commits = stdout.split("\x1e").map((entry) => entry.trim()).filter(Boolean).map(parseCommit);
386
+ return { ok: true, isRepo: true, commits };
387
+ }
388
+
389
+ async function gitCommitDetails(hash: string, cwd = piCwd) {
390
+ if (!await isGitRepo(cwd)) throw new Error("Not a Git repository");
391
+ if (!/^[a-f0-9]{7,40}$/i.test(hash)) throw new Error("Invalid commit hash");
392
+ const [{ stdout: commitOut }, { stdout: nameOut }, { stdout: numstatOut }, { stdout: diff }] = await Promise.all([
393
+ 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),
394
+ git(["show", "--name-status", "--format=", hash], 15_000, cwd),
395
+ git(["show", "--numstat", "--format=", hash], 15_000, cwd),
396
+ git(["show", "--format=", "--patch", "--find-renames", hash], 15_000, cwd),
397
+ ]);
398
+ const stats = new Map<string, { additions?: number; deletions?: number }>();
399
+ for (const line of numstatOut.split("\n").filter(Boolean)) {
400
+ const [add, del, ...pathParts] = line.split("\t");
401
+ const path = pathParts.join("\t");
402
+ stats.set(path, { additions: Number(add) || 0, deletions: Number(del) || 0 });
403
+ }
404
+ const files = nameOut.split("\n").filter(Boolean).map((line) => {
405
+ const [status, ...parts] = line.split("\t");
406
+ const path = parts.at(-1) || "";
407
+ return { path, status, ...(stats.get(path) || {}) };
408
+ });
409
+ return { ok: true, commit: parseCommit(commitOut.trim()), files, diff };
410
+ }
411
+
412
+ // Models confirmed broken with this Copilot integration — tracked at runtime.
413
+ const blockedModelIds = new Set<string>();
414
+
415
+ // Parse allowed model IDs from Copilot's model_not_available_for_integrator error.
416
+ // Returns null if no such error has been seen yet.
417
+ function copilotAllowedIdsFromSession(): Set<string> | null {
418
+ const entries = session.messages;
419
+ for (let i = entries.length - 1; i >= 0; i--) {
420
+ const msg = entries[i] as any;
421
+ const err: string = msg?.errorMessage || msg?.message?.errorMessage || "";
422
+ if (!err.includes("model_not_available_for_integrator")) continue;
423
+ const match = err.match(/Available models: \[([^\]]+)\]/);
424
+ if (!match) continue;
425
+ return new Set(match[1].split(/\s+/).map((s: string) => s.trim()).filter(Boolean));
426
+ }
427
+ return null;
428
+ }
429
+
430
+ function getAvailableModels() {
431
+ const all = session.modelRegistry.getAvailable();
432
+ const allowed = copilotAllowedIdsFromSession();
433
+ return all.filter((m: any) => {
434
+ if (blockedModelIds.has(m.id)) return false;
435
+ if (allowed && !allowed.has(m.id)) return false;
436
+ return true;
437
+ });
438
+ }
439
+
440
+ const imageExtensions: Record<string, string> = {
441
+ "image/gif": ".gif",
442
+ "image/jpeg": ".jpg",
443
+ "image/png": ".png",
444
+ "image/webp": ".webp",
445
+ };
446
+
447
+ async function persistPromptImages(images: Array<{ data: string; mimeType: string; name?: string }>) {
448
+ if (!images.length) return "";
449
+ await ensurePiWebStorage();
450
+ const uploadDir = join(piCwd, ".pi", "web", "uploads");
451
+ await mkdir(uploadDir, { recursive: true });
452
+
453
+ const lines: string[] = [];
454
+ for (const image of images) {
455
+ const extension = imageExtensions[image.mimeType] || ".img";
456
+ const safeName = String(image.name || "image").replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 80);
457
+ const fileName = `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID()}-${safeName}${safeName.endsWith(extension) ? "" : extension}`;
458
+ const filePath = join(uploadDir, fileName);
459
+ const data = Buffer.from(image.data, "base64");
460
+ await writeFile(filePath, data);
461
+ lines.push(`- ${filePath}`);
462
+ }
463
+
464
+ return `\n\nAttached image file${images.length === 1 ? "" : "s"}:\n${lines.join("\n")}`;
465
+ }
466
+
467
+ function simplifyMessage(message: unknown, toolCallArgs?: Map<string, Record<string, unknown>>) {
468
+ if (!message || typeof message !== "object") return message;
469
+ const m = message as Record<string, unknown>;
470
+ if (m.role === "toolResult") {
471
+ const args = toolCallArgs?.get(m.toolCallId as string);
472
+ return {
473
+ role: "toolResult",
474
+ toolCallId: m.toolCallId,
475
+ toolName: m.toolName,
476
+ toolArgs: args,
477
+ isError: Boolean(m.isError),
478
+ text: textFromContent(m.content),
479
+ timestamp: m.timestamp,
480
+ raw: m,
481
+ };
482
+ }
483
+ const text = textFromContent(m.content);
484
+ const errorText = m.role === "assistant" && !text && m.errorMessage ? assistantErrorPreview(m) : "";
485
+ const toolCalls = m.role === "assistant" && Array.isArray(m.content)
486
+ ? m.content.filter((part: any) => part?.type === "toolCall").map((part: any) => ({
487
+ id: part.id,
488
+ toolName: part.toolName || part.name || "tool",
489
+ args: part.arguments || part.args || {},
490
+ }))
491
+ : undefined;
492
+ return {
493
+ role: m.role,
494
+ text: errorText || text,
495
+ toolCalls,
496
+ isError: Boolean(m.errorMessage || m.stopReason === "error"),
497
+ timestamp: m.timestamp,
498
+ raw: m,
499
+ };
500
+ }
501
+
502
+ function truncatePreview(value: string, max = 220) {
503
+ const text = value.replace(/\s+/g, " ").trim();
504
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
505
+ }
506
+
507
+ function entryMessage(entry: any) {
508
+ if (entry?.type === "message") return entry.message;
509
+ if (entry?.type === "custom_message") return { role: "custom", content: entry.content, timestamp: entry.timestamp };
510
+ return undefined;
511
+ }
512
+
513
+ function messageToolCalls(message: any) {
514
+ return Array.isArray(message?.content)
515
+ ? message.content.filter((part: any) => part?.type === "toolCall")
516
+ : [];
517
+ }
518
+
519
+ function toolCallName(part: any) {
520
+ return String(part?.toolName || part?.name || "tool");
521
+ }
522
+
523
+ function toolCallArgs(part: any) {
524
+ const args = part?.arguments || part?.args;
525
+ return args && typeof args === "object" ? args as Record<string, unknown> : {};
526
+ }
527
+
528
+ function shortArg(value: unknown, max = 90) {
529
+ const text = typeof value === "string" ? value : JSON.stringify(value ?? "");
530
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
531
+ }
532
+
533
+ function toolCallPreview(part: any) {
534
+ const name = toolCallName(part);
535
+ const args = toolCallArgs(part);
536
+ if (name === "bash" && typeof args.command === "string") return `Tool call: bash ${shortArg(args.command, 120)}`;
537
+ if (typeof args.path === "string") return `Tool call: ${name} ${shortArg(args.path, 120)}`;
538
+ if (typeof args.query === "string") return `Tool call: ${name} ${shortArg(args.query, 120)}`;
539
+ if (typeof args.pattern === "string") return `Tool call: ${name} ${shortArg(args.pattern, 120)}`;
540
+ const first = Object.entries(args).find(([, value]) => typeof value === "string" || typeof value === "number" || typeof value === "boolean");
541
+ return first ? `Tool call: ${name} ${first[0]}=${shortArg(first[1], 90)}` : `Tool call: ${name}`;
542
+ }
543
+
544
+ function toolCallsPreview(message: any) {
545
+ const calls = messageToolCalls(message);
546
+ if (calls.length === 0) return "";
547
+ const [first] = calls;
548
+ const suffix = calls.length > 1 ? ` + ${calls.length - 1} more` : "";
549
+ return `${toolCallPreview(first)}${suffix}`;
550
+ }
551
+
552
+ function messageTextPreview(message: any) {
553
+ return textFromContent(message?.content || "");
554
+ }
555
+
556
+ function assistantErrorPreview(message: any) {
557
+ const raw = String(message?.errorMessage || "").trim();
558
+ if (!raw) return "";
559
+ const jsonText = raw.replace(/^Codex error:\s*/, "");
560
+ try {
561
+ const parsed = JSON.parse(jsonText);
562
+ const detail = parsed?.error?.message || parsed?.message || raw;
563
+ return `Error: ${detail}`;
564
+ } catch {
565
+ return raw.length > 180 ? `${raw.slice(0, 179)}…` : raw;
566
+ }
567
+ }
568
+
569
+ function entryRole(entry: any) {
570
+ const message = entryMessage(entry);
571
+ if (message?.role === "assistant" && !messageTextPreview(message).trim()) {
572
+ if (messageToolCalls(message).length > 0) return "toolCall";
573
+ if (message.errorMessage) return "error";
574
+ }
575
+ if (message?.role) return String(message.role);
576
+ switch (entry?.type) {
577
+ case "branch_summary": return "branchSummary";
578
+ case "compaction": return "compaction";
579
+ case "model_change": return "model";
580
+ case "thinking_level_change": return "thinking";
581
+ case "session_info": return "session";
582
+ case "label": return "label";
583
+ case "custom": return "custom";
584
+ default: return String(entry?.type || "entry");
585
+ }
586
+ }
587
+
588
+ function entryPreview(entry: any) {
589
+ const message = entryMessage(entry);
590
+ if (message) {
591
+ if (message.role === "toolResult") {
592
+ const text = textFromContent(message.content);
593
+ return `Tool result: ${message.toolName || "tool"}${text ? ` — ${text}` : ""}`;
594
+ }
595
+ const text = messageTextPreview(message);
596
+ if (text.trim()) return text;
597
+ const calls = toolCallsPreview(message);
598
+ if (calls) return calls;
599
+ const error = assistantErrorPreview(message);
600
+ if (error) return error;
601
+ return message.role === "assistant" ? "Empty assistant message" : `${message.role || "Message"} message`;
602
+ }
603
+ switch (entry?.type) {
604
+ case "branch_summary": return entry.summary || "Branch summary";
605
+ case "compaction": return entry.summary || "Compaction summary";
606
+ case "model_change": return `Model changed to ${entry.provider || "provider"}/${entry.modelId || "model"}`;
607
+ case "thinking_level_change": return `Thinking level changed to ${entry.thinkingLevel || "unknown"}`;
608
+ case "session_info": return entry.name ? `Session named ${entry.name}` : "Session name cleared";
609
+ case "label": return entry.label ? `Label ${entry.targetId || "entry"} as ${entry.label}` : `Clear label on ${entry.targetId || "entry"}`;
610
+ case "custom": return `Custom entry${entry.customType ? `: ${entry.customType}` : ""}`;
611
+ default: return String(entry?.type || "Entry");
612
+ }
613
+ }
614
+
615
+ function countTreeNodes(nodes: any[]): number {
616
+ return nodes.reduce((sum, node) => sum + 1 + countTreeNodes(Array.isArray(node.children) ? node.children : []), 0);
617
+ }
618
+
619
+ function countBranchPoints(nodes: any[]): number {
620
+ return nodes.reduce((sum, node) => {
621
+ const children = Array.isArray(node.children) ? node.children : [];
622
+ return sum + (children.length > 1 ? 1 : 0) + countBranchPoints(children);
623
+ }, 0);
624
+ }
625
+
626
+ function simplifyTreeNode(node: any, activePathIds: Set<string>, leafId: string | null): any {
627
+ const entry = node?.entry || node;
628
+ const children = Array.isArray(node?.children) ? node.children.map((child: any) => simplifyTreeNode(child, activePathIds, leafId)) : [];
629
+ const id = String(entry?.id || "");
630
+ return {
631
+ id,
632
+ parentId: typeof entry?.parentId === "string" ? entry.parentId : null,
633
+ type: String(entry?.type || "entry"),
634
+ role: entryRole(entry),
635
+ preview: truncatePreview(entryPreview(entry)),
636
+ timestamp: String(entry?.timestamp || ""),
637
+ label: typeof node?.label === "string" ? node.label : undefined,
638
+ labelTimestamp: typeof node?.labelTimestamp === "string" ? node.labelTimestamp : undefined,
639
+ childCount: children.length,
640
+ isOnActivePath: activePathIds.has(id),
641
+ isCurrentLeaf: Boolean(leafId && id === leafId),
642
+ children,
643
+ };
644
+ }
645
+
646
+ function conversationTreeForSession(targetSession: PiWebSession) {
647
+ const manager = targetSession.sessionManager;
648
+ if (typeof manager.getTree !== "function") throw new Error("Session tree is not available");
649
+ const leafId = typeof manager.getLeafId === "function" ? manager.getLeafId() : null;
650
+ const activePath = typeof manager.getBranch === "function" ? manager.getBranch() : [];
651
+ const activePathIds = new Set(activePath.map((entry: any) => String(entry?.id || "")).filter(Boolean));
652
+ const roots = manager.getTree();
653
+ const nodes = roots.map((node: any) => simplifyTreeNode(node, activePathIds, leafId));
654
+ return {
655
+ ok: true,
656
+ sessionId: targetSession.sessionId,
657
+ leafId,
658
+ activePathIds: Array.from(activePathIds),
659
+ entryCount: countTreeNodes(nodes),
660
+ branchPointCount: countBranchPoints(nodes),
661
+ nodes,
662
+ };
663
+ }
664
+
665
+ function runtimeForPath(path: string) {
666
+ const live = liveSessions.get(path)?.session;
667
+ const isStreaming = Boolean(live?.isStreaming);
668
+ const isCompacting = Boolean(live?.isCompacting);
669
+ return {
670
+ loaded: Boolean(live),
671
+ isRunning: isStreaming || isCompacting,
672
+ isStreaming,
673
+ isCompacting,
674
+ pendingMessageCount: Number(live?.pendingMessageCount || 0),
675
+ model: simplifyModel(live?.model),
676
+ };
677
+ }
678
+
679
+ function simplifySessionInfo(info: Awaited<ReturnType<typeof SessionManager.list>>[number], cwd = piCwd) {
680
+ return {
681
+ id: info.id,
682
+ name: info.name,
683
+ firstMessage: info.firstMessage,
684
+ created: info.created.toISOString(),
685
+ modified: info.modified.toISOString(),
686
+ messageCount: info.messageCount,
687
+ cwd,
688
+ isCurrent: cwd === piCwd && info.id === session.sessionId,
689
+ runtime: runtimeForPath(info.path),
690
+ };
691
+ }
692
+
693
+ async function listSessionInfos(extraCwds: string[] = []) {
694
+ if (noSession) return [];
695
+ if (mockMode) return mockSessions.map((info) => simplifySessionInfo(info as any, info.cwd || piCwd));
696
+ const cwds = new Set<string>(knownCwds);
697
+ for (const cwd of extraCwds) {
698
+ if (typeof cwd !== "string" || !cwd.trim()) continue;
699
+ cwds.add(resolve(cwd));
700
+ }
701
+ const groups = await Promise.all(Array.from(cwds).map(async (cwd) => {
702
+ try {
703
+ const sessions = await SessionManager.list(cwd);
704
+ return sessions.map((info) => simplifySessionInfo(info, cwd));
705
+ } catch {
706
+ return [];
707
+ }
708
+ }));
709
+ return groups.flat().sort((a, b) => Date.parse(b.modified) - Date.parse(a.modified));
710
+ }
711
+
712
+ function finiteNumber(value: unknown) {
713
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
714
+ }
715
+
716
+ function sessionDisplayName(targetSession: PiWebSession) {
717
+ return targetSession.getSessionName?.()?.trim()
718
+ || targetSession.sessionName?.trim()
719
+ || targetSession.sessionManager.getSessionName?.()?.trim()
720
+ || undefined;
721
+ }
722
+
723
+ function liveSessionTitle(targetSession: PiWebSession) {
724
+ const name = sessionDisplayName(targetSession);
725
+ if (name) return name;
726
+
727
+ for (const message of targetSession.messages as any[]) {
728
+ const text = textFromContent(message?.content).trim();
729
+ if (message?.role === "user" && text) return truncatePreview(text, 80);
730
+ }
731
+ return "New session";
732
+ }
733
+
734
+ function sessionStats(targetSession: PiWebSession) {
735
+ let input = 0;
736
+ let output = 0;
737
+ let cacheRead = 0;
738
+ let cacheWrite = 0;
739
+ let cost = 0;
740
+ let userMessages = 0;
741
+ let assistantMessages = 0;
742
+ let toolResults = 0;
743
+
744
+ const branch = targetSession.sessionManager.getBranch?.();
745
+ const entries = Array.isArray(branch) && branch.length > 0
746
+ ? branch.map((entry: any) => entry?.message ?? entry)
747
+ : targetSession.messages;
748
+
749
+ for (const message of entries as any[]) {
750
+ if (!message || typeof message !== "object") continue;
751
+ if (message.role === "user") userMessages++;
752
+ if (message.role === "toolResult") toolResults++;
753
+ if (message.role !== "assistant") continue;
754
+ assistantMessages++;
755
+ const usage = message.usage || {};
756
+ input += finiteNumber(usage.input);
757
+ output += finiteNumber(usage.output);
758
+ cacheRead += finiteNumber(usage.cacheRead);
759
+ cacheWrite += finiteNumber(usage.cacheWrite);
760
+ const usageCost = usage.cost || {};
761
+ const totalCost = finiteNumber(usageCost.total);
762
+ cost += totalCost || finiteNumber(usageCost.input) + finiteNumber(usageCost.output) + finiteNumber(usageCost.cacheRead) + finiteNumber(usageCost.cacheWrite);
763
+ }
764
+
765
+ const contextUsage = targetSession.getContextUsage?.() || undefined;
766
+ return {
767
+ userMessages,
768
+ assistantMessages,
769
+ toolResults,
770
+ totalMessages: entries.length,
771
+ tokens: {
772
+ input,
773
+ output,
774
+ cacheRead,
775
+ cacheWrite,
776
+ total: input + output + cacheRead + cacheWrite,
777
+ },
778
+ cost,
779
+ contextUsage,
780
+ };
781
+ }
782
+
783
+ function currentState() {
784
+ return {
785
+ cwd: piCwd,
786
+ sessionFile: session.sessionFile,
787
+ sessionId: session.sessionId,
788
+ sessionName: sessionDisplayName(session),
789
+ sessionTitle: liveSessionTitle(session),
790
+ isStreaming: session.isStreaming,
791
+ model: simplifyModel(session.model),
792
+ thinkingLevel: session.thinkingLevel,
793
+ stats: sessionStats(session),
794
+ };
795
+ }
796
+
797
+ function currentStateWithThinkingLevels() {
798
+ return {
799
+ ...currentState(),
800
+ thinkingLevels: session.getAvailableThinkingLevels(),
801
+ };
802
+ }
803
+
804
+ function getSessionSlashCommands(value: any): WebSlashCommandInfo[] {
805
+ const commands: WebSlashCommandInfo[] = [];
806
+
807
+ for (const command of value.extensionRunner?.getRegisteredCommands?.() || []) {
808
+ commands.push({
809
+ name: command.invocationName || command.name,
810
+ description: command.description,
811
+ source: "extension",
812
+ sourceInfo: command.sourceInfo,
813
+ });
814
+ }
815
+
816
+ for (const template of value.promptTemplates || value.resourceLoader?.getPrompts?.().prompts || []) {
817
+ commands.push({
818
+ name: template.name,
819
+ description: template.description,
820
+ source: "prompt",
821
+ sourceInfo: template.sourceInfo,
822
+ });
823
+ }
824
+
825
+ for (const skill of value.resourceLoader?.getSkills?.().skills || []) {
826
+ commands.push({
827
+ name: `skill:${skill.name}`,
828
+ description: skill.description,
829
+ source: "skill",
830
+ sourceInfo: skill.sourceInfo,
831
+ });
832
+ }
833
+
834
+ return commands.filter((command) => typeof command.name === "string" && command.name.length > 0);
835
+ }
836
+
837
+ function getSlashCommands(value: any = session): WebSlashCommandInfo[] {
838
+ return [...webSlashCommands, ...getSessionSlashCommands(value)];
839
+ }
840
+
841
+ function formatSlashCommandList(commands: WebSlashCommandInfo[]) {
842
+ const groups: Array<[string, string]> = [["web", "Web"], ["extension", "Extensions"], ["prompt", "Prompts"], ["skill", "Skills"]];
843
+ const lines: string[] = ["Available slash commands:"];
844
+ for (const [source, label] of groups) {
845
+ const matching = commands.filter((command) => command.source === source);
846
+ if (!matching.length) continue;
847
+ lines.push("", `${label}:`);
848
+ for (const command of matching) {
849
+ lines.push(`/${command.name}${command.description ? ` - ${command.description}` : ""}`);
850
+ }
851
+ }
852
+ return lines.join("\n");
853
+ }
854
+
855
+ function slashHelp() {
856
+ return [
857
+ "Type / in the composer to browse available commands.",
858
+ "",
859
+ "Web commands run in pi-web; extension, prompt, and skill commands are discovered from pi's extension/resource system.",
860
+ "",
861
+ formatSlashCommandList(getSlashCommands()),
862
+ ].join("\n");
863
+ }
864
+
865
+ function formatModelList() {
866
+ return getAvailableModels()
867
+ .map((model: any) => `${model.provider}/${model.id}${model.name && model.name !== model.id ? ` (${model.name})` : ""}`)
868
+ .join("\n");
869
+ }
870
+
871
+ async function executeSlashCommand(input: string) {
872
+ const trimmed = input.trim();
873
+ const [rawName = "", ...rest] = trimmed.replace(/^\/+/, "").split(/\s+/);
874
+ const name = rawName.toLowerCase();
875
+ const args = rest.join(" ").trim();
876
+
877
+ switch (name) {
878
+ case "help":
879
+ case "?":
880
+ return { message: slashHelp(), state: currentStateWithThinkingLevels() };
881
+
882
+ case "commands":
883
+ return { message: formatSlashCommandList(getSlashCommands()), state: currentStateWithThinkingLevels() };
884
+
885
+ case "reload": {
886
+ if (session.isStreaming) throw new Error("Wait for the current response to finish before reloading.");
887
+ if (session.isCompacting) throw new Error("Wait for compaction to finish before reloading.");
888
+ if (typeof session.reload !== "function") throw new Error("Reload is not available in this session.");
889
+ await session.reload();
890
+ return { message: "Reloaded pi resources, extensions, and models.", state: currentStateWithThinkingLevels() };
891
+ }
892
+
893
+ case "model": {
894
+ if (!args) {
895
+ return { message: formatModelList() || "No models available.", state: currentStateWithThinkingLevels() };
896
+ }
897
+ const slashIndex = args.indexOf("/");
898
+ if (slashIndex <= 0) throw new Error("Usage: /model <provider/model-id>");
899
+ const provider = args.slice(0, slashIndex);
900
+ const id = args.slice(slashIndex + 1);
901
+ const model = session.modelRegistry.find(provider, id);
902
+ if (!model) throw new Error(`Model not found: ${args}`);
903
+ await session.setModel(model);
904
+ return { message: `Model set to ${provider}/${id}.`, state: currentStateWithThinkingLevels() };
905
+ }
906
+
907
+ case "models":
908
+ return { message: formatModelList() || "No models available.", state: currentStateWithThinkingLevels() };
909
+
910
+ case "thinking": {
911
+ if (!args) {
912
+ return { message: `Thinking level: ${session.thinkingLevel}\nAvailable: ${session.getAvailableThinkingLevels().join(", ")}`, state: currentStateWithThinkingLevels() };
913
+ }
914
+ const levels = session.getAvailableThinkingLevels();
915
+ if (!levels.includes(args as any)) throw new Error(`Unknown thinking level: ${args}. Available: ${levels.join(", ")}`);
916
+ session.setThinkingLevel(args as any);
917
+ return { message: `Thinking level set to ${session.thinkingLevel}.`, state: currentStateWithThinkingLevels() };
918
+ }
919
+
920
+ case "new": {
921
+ session = await createNewLiveSession();
922
+ return { message: "New session.", state: currentStateWithThinkingLevels() };
923
+ }
924
+
925
+ case "compact": {
926
+ if (session.isStreaming) throw new Error("Wait for the current response to finish before compacting.");
927
+ if (session.isCompacting) throw new Error("Compaction is already running.");
928
+ if (typeof session.compact !== "function") throw new Error("Compaction is not available in this session.");
929
+ void session.compact(args || undefined).catch((error: unknown) => broadcast({
930
+ type: "server_error",
931
+ sessionId: session.sessionId,
932
+ sessionFile: session.sessionFile,
933
+ error: error instanceof Error ? error.message : String(error),
934
+ }));
935
+ return { message: "Compaction started.", state: currentStateWithThinkingLevels() };
936
+ }
937
+
938
+ case "abort":
939
+ case "stop":
940
+ await session.abort();
941
+ return { message: "Aborted.", state: currentStateWithThinkingLevels() };
942
+
943
+ default:
944
+ throw new Error(`Unknown slash command: /${name}. Try /help.`);
945
+ }
946
+ }
947
+
948
+ async function findSessionInfoById(id: string, cwd = piCwd) {
949
+ if (!id) return undefined;
950
+ const sessions = noSession ? [] : mockMode ? mockSessions : await SessionManager.list(cwd);
951
+ return sessions.find((info) => info.id === id);
952
+ }
953
+
954
+ async function getOrCreateLiveSessionById(id: string) {
955
+ if (id === session.sessionId) return session;
956
+ for (const entry of liveSessions.values()) {
957
+ if (entry.session.sessionId === id) return entry.session;
958
+ }
959
+ const info = await findSessionInfoById(id);
960
+ return info ? getOrCreateLiveSession(info.path) : undefined;
961
+ }
962
+
963
+ async function switchToSessionId(id: string) {
964
+ const target = await getOrCreateLiveSessionById(id);
965
+ if (!target) throw new Error("Session not found");
966
+ session = target;
967
+ }
968
+
969
+ const authStorage = AuthStorage.create();
970
+ const modelRegistry = ModelRegistry.create(authStorage);
971
+ const settingsStore = createSettingsStore(process.env.PI_WEB_SETTINGS_FILE || join(getAgentDir(), "pi-web-settings.json"));
972
+ const liveSessions = new Map<string, { session: any; unsubscribe?: () => void }>();
973
+ let session: PiWebSession;
974
+ let modelFallbackMessage: string | undefined;
975
+
976
+ const clients = new Set<WebSocket>();
977
+ type RealtimeEnvelope = Record<string, unknown> & { seq: number };
978
+ const realtimeEventLog: RealtimeEnvelope[] = [];
979
+ const maxRealtimeEventLogSize = 1000;
980
+ let nextRealtimeSeq = 1;
981
+
982
+ function recordRealtimeMessage(value: unknown): RealtimeEnvelope {
983
+ const envelope = { ...(typeof value === "object" && value !== null ? value as Record<string, unknown> : { value }), seq: nextRealtimeSeq++ };
984
+ realtimeEventLog.push(envelope);
985
+ if (realtimeEventLog.length > maxRealtimeEventLogSize) realtimeEventLog.splice(0, realtimeEventLog.length - maxRealtimeEventLogSize);
986
+ return envelope;
987
+ }
988
+
989
+ function broadcast(value: unknown) {
990
+ const envelope = recordRealtimeMessage(value);
991
+ const data = JSON.stringify(envelope);
992
+ for (const client of clients) {
993
+ if (client.readyState === client.OPEN) client.send(data);
994
+ }
995
+ }
996
+
997
+ const plainExtensionTheme = {
998
+ fg: (_color: string, text: string) => text,
999
+ bg: (_color: string, text: string) => text,
1000
+ bold: (text: string) => text,
1001
+ italic: (text: string) => text,
1002
+ underline: (text: string) => text,
1003
+ inverse: (text: string) => text,
1004
+ strikethrough: (text: string) => text,
1005
+ getFgAnsi: () => "",
1006
+ getBgAnsi: () => "",
1007
+ getColorMode: () => "truecolor",
1008
+ getThinkingBorderColor: () => (text: string) => text,
1009
+ getBashModeBorderColor: () => (text: string) => text,
1010
+ };
1011
+
1012
+ type PendingExtensionUiRequest = {
1013
+ resolve: (response: Record<string, unknown>) => void;
1014
+ cleanup: () => void;
1015
+ };
1016
+ const pendingExtensionUiRequests = new Map<string, PendingExtensionUiRequest>();
1017
+
1018
+ function broadcastExtensionUiRequest(value: any, method: string, payload: Record<string, unknown>) {
1019
+ const id = randomUUID();
1020
+ broadcast({
1021
+ type: "extension_ui_request",
1022
+ id,
1023
+ method,
1024
+ sessionId: value.sessionId,
1025
+ sessionFile: value.sessionFile,
1026
+ ...payload,
1027
+ });
1028
+ return id;
1029
+ }
1030
+
1031
+ function requestExtensionUi<T>(
1032
+ value: any,
1033
+ method: string,
1034
+ payload: Record<string, unknown>,
1035
+ opts: ExtensionUIDialogOptions | undefined,
1036
+ defaultValue: T,
1037
+ parse: (response: Record<string, unknown>) => T,
1038
+ ): Promise<T> {
1039
+ if (opts?.signal?.aborted || clients.size === 0) return Promise.resolve(defaultValue);
1040
+
1041
+ return new Promise<T>((resolvePromise) => {
1042
+ const id = randomUUID();
1043
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
1044
+
1045
+ const cleanup = () => {
1046
+ if (timeoutId) clearTimeout(timeoutId);
1047
+ opts?.signal?.removeEventListener("abort", onAbort);
1048
+ pendingExtensionUiRequests.delete(id);
1049
+ };
1050
+ const finish = (result: T) => {
1051
+ cleanup();
1052
+ resolvePromise(result);
1053
+ };
1054
+ const onAbort = () => finish(defaultValue);
1055
+
1056
+ opts?.signal?.addEventListener("abort", onAbort, { once: true });
1057
+ if (opts?.timeout) timeoutId = setTimeout(() => finish(defaultValue), opts.timeout);
1058
+
1059
+ pendingExtensionUiRequests.set(id, {
1060
+ cleanup,
1061
+ resolve: (response) => finish(parse(response)),
1062
+ });
1063
+
1064
+ broadcast({
1065
+ type: "extension_ui_request",
1066
+ id,
1067
+ method,
1068
+ sessionId: value.sessionId,
1069
+ sessionFile: value.sessionFile,
1070
+ timeout: opts?.timeout,
1071
+ ...payload,
1072
+ });
1073
+ });
1074
+ }
1075
+
1076
+ function createWebExtensionUiContext(value: any): ExtensionUIContext {
1077
+ return {
1078
+ select: (title, options, opts) => requestExtensionUi(
1079
+ value,
1080
+ "select",
1081
+ { title, options },
1082
+ opts,
1083
+ undefined,
1084
+ (response) => response.cancelled ? undefined : typeof response.value === "string" ? response.value : undefined,
1085
+ ),
1086
+ confirm: (title, message, opts) => requestExtensionUi(
1087
+ value,
1088
+ "confirm",
1089
+ { title, message },
1090
+ opts,
1091
+ false,
1092
+ (response) => response.cancelled ? false : Boolean(response.confirmed),
1093
+ ),
1094
+ input: (title, placeholder, opts) => requestExtensionUi(
1095
+ value,
1096
+ "input",
1097
+ { title, placeholder },
1098
+ opts,
1099
+ undefined,
1100
+ (response) => response.cancelled ? undefined : typeof response.value === "string" ? response.value : undefined,
1101
+ ),
1102
+ notify(message, type = "info") {
1103
+ broadcastExtensionUiRequest(value, "notify", { message, notifyType: type });
1104
+ },
1105
+ onTerminalInput: () => () => undefined,
1106
+ setStatus(key, text) {
1107
+ broadcastExtensionUiRequest(value, "setStatus", { statusKey: key, statusText: text });
1108
+ },
1109
+ setWorkingMessage: () => undefined,
1110
+ setWorkingVisible: () => undefined,
1111
+ setWorkingIndicator: () => undefined,
1112
+ setHiddenThinkingLabel: () => undefined,
1113
+ setWidget(key, content, options) {
1114
+ if (content === undefined || Array.isArray(content)) {
1115
+ broadcastExtensionUiRequest(value, "setWidget", { widgetKey: key, widgetLines: content, widgetPlacement: options?.placement });
1116
+ }
1117
+ },
1118
+ setFooter: () => undefined,
1119
+ setHeader: () => undefined,
1120
+ setTitle(title) {
1121
+ broadcastExtensionUiRequest(value, "setTitle", { title });
1122
+ },
1123
+ async custom() {
1124
+ return undefined as never;
1125
+ },
1126
+ pasteToEditor(text) {
1127
+ this.setEditorText(text);
1128
+ },
1129
+ setEditorText(text) {
1130
+ broadcastExtensionUiRequest(value, "set_editor_text", { text });
1131
+ },
1132
+ getEditorText: () => "",
1133
+ editor: (title, prefill) => requestExtensionUi(
1134
+ value,
1135
+ "editor",
1136
+ { title, prefill },
1137
+ undefined,
1138
+ undefined,
1139
+ (response) => response.cancelled ? undefined : typeof response.value === "string" ? response.value : undefined,
1140
+ ),
1141
+ addAutocompleteProvider: () => undefined,
1142
+ setEditorComponent: () => undefined,
1143
+ getEditorComponent: () => undefined,
1144
+ theme: plainExtensionTheme as any,
1145
+ getAllThemes: () => [],
1146
+ getTheme: () => undefined,
1147
+ setTheme: () => ({ success: false, error: "Theme switching is not supported in pi-web yet" }),
1148
+ getToolsExpanded: () => false,
1149
+ setToolsExpanded: () => undefined,
1150
+ };
1151
+ }
1152
+
1153
+ async function bindWebExtensions(value: any) {
1154
+ if (typeof value.bindExtensions !== "function") return;
1155
+ await value.bindExtensions({
1156
+ uiContext: createWebExtensionUiContext(value),
1157
+ commandContextActions: {
1158
+ waitForIdle: () => value.agent.waitForIdle(),
1159
+ newSession: async () => {
1160
+ session = await createNewLiveSession();
1161
+ const state = currentStateWithThinkingLevels();
1162
+ broadcast({ type: "state_changed", ...state });
1163
+ return { cancelled: false };
1164
+ },
1165
+ fork: async () => {
1166
+ throw new Error("Extension-initiated fork is not supported in pi-web yet.");
1167
+ },
1168
+ navigateTree: async (targetId: string, options: any) => {
1169
+ const result = await value.navigateTree(targetId, options);
1170
+ return { cancelled: Boolean(result?.cancelled) };
1171
+ },
1172
+ switchSession: async () => {
1173
+ throw new Error("Extension-initiated session switching is not supported in pi-web yet.");
1174
+ },
1175
+ reload: async () => {
1176
+ await value.reload?.();
1177
+ },
1178
+ },
1179
+ shutdownHandler: () => {
1180
+ broadcast({ type: "server_error", sessionId: value.sessionId, sessionFile: value.sessionFile, error: "An extension requested shutdown; pi-web ignored the request." });
1181
+ },
1182
+ onError: (error: any) => {
1183
+ broadcast({ type: "server_error", sessionId: value.sessionId, sessionFile: value.sessionFile, error: `Extension error (${error.extensionPath}): ${error.error}` });
1184
+ },
1185
+ });
1186
+ }
1187
+
1188
+ const mockHarness = createMockHarness({
1189
+ piCwd,
1190
+ broadcast,
1191
+ isCurrentSession: (value: PiWebSession) => value === session,
1192
+ currentState,
1193
+ });
1194
+ const { mockSessions, createMockSession, resetMockSessions } = mockHarness;
1195
+
1196
+ function sessionPathKey(value: any) {
1197
+ return String(value.sessionFile || value.sessionId || "");
1198
+ }
1199
+
1200
+ function registerLiveSession(value: any) {
1201
+ const key = sessionPathKey(value);
1202
+ if (!key || liveSessions.get(key)?.session === value) return value;
1203
+
1204
+ const unsubscribe = value.subscribe?.((event: unknown) => {
1205
+ const eventSessionFile = value.sessionFile;
1206
+ const eventSessionId = value.sessionId;
1207
+ broadcast({ type: "pi_event", sessionId: eventSessionId, sessionFile: eventSessionFile, event });
1208
+ broadcast({
1209
+ type: "session_runtime_changed",
1210
+ sessionId: eventSessionId,
1211
+ sessionFile: eventSessionFile,
1212
+ runtime: runtimeForPath(eventSessionFile),
1213
+ });
1214
+
1215
+ // Track models that fail with model_not_supported and remove them from the list.
1216
+ const e = event as any;
1217
+
1218
+ // Broadcast state update when session name changes
1219
+ if (e?.type === "session_info_changed") {
1220
+ broadcast({ type: "state_changed", ...currentState() });
1221
+ }
1222
+
1223
+ if (e?.type === "message_end" || e?.type === "agent_end" || e?.type === "compaction_end") {
1224
+ broadcast({ type: "session_stats_changed", sessionId: eventSessionId, sessionFile: eventSessionFile, stats: sessionStats(value) });
1225
+ }
1226
+
1227
+ if (e?.type === "message_end" || e?.type === "turn_end") {
1228
+ const msg = e?.message ?? e?.toolResults?.[0];
1229
+ const err: string = msg?.errorMessage || msg?.message?.errorMessage || "";
1230
+ const modelId: string = msg?.model || msg?.message?.model || "";
1231
+ if (modelId && (err.includes("model_not_supported") || err.includes("model_not_available"))) {
1232
+ if (!blockedModelIds.has(modelId)) {
1233
+ blockedModelIds.add(modelId);
1234
+ broadcast({ type: "models_updated", models: getAvailableModels().map(simplifyModel) });
1235
+ }
1236
+ }
1237
+ }
1238
+ });
1239
+ liveSessions.set(key, { session: value, unsubscribe });
1240
+ return value;
1241
+ }
1242
+
1243
+ function bundledExtensionPaths() {
1244
+ return resolveBundledExtensionPaths({ piCwd, appDir, bundledExtensionsDir });
1245
+ }
1246
+
1247
+ async function makeAgentSession(path?: string, sessionStartEvent?: SessionStartEvent) {
1248
+ if (mockMode) return { session: createMockSession(path), modelFallbackMessage: undefined };
1249
+
1250
+ const sessionManager = noSession ? SessionManager.inMemory() : SessionManager.create(piCwd);
1251
+ if (path && !noSession) sessionManager.setSessionFile(path);
1252
+ if (!path && !noSession && sessionStartEvent?.reason === "new") sessionManager.newSession();
1253
+
1254
+ const webUiContext = existsSync(webUiContextFile) ? readFileSync(webUiContextFile, "utf-8") : "";
1255
+
1256
+ const loader = new DefaultResourceLoader({
1257
+ cwd: piCwd,
1258
+ agentDir: getAgentDir(),
1259
+ additionalExtensionPaths: bundledExtensionPaths(),
1260
+ appendSystemPromptOverride: (base) => [
1261
+ ...base,
1262
+ webUiContext,
1263
+ ].filter(Boolean),
1264
+ });
1265
+ await loader.reload();
1266
+
1267
+ const result = await createAgentSession({
1268
+ cwd: piCwd,
1269
+ sessionManager,
1270
+ authStorage,
1271
+ modelRegistry,
1272
+ resourceLoader: loader,
1273
+ sessionStartEvent,
1274
+ });
1275
+ await bindWebExtensions(result.session);
1276
+ return result;
1277
+ }
1278
+
1279
+ async function getOrCreateLiveSession(path: string) {
1280
+ const existing = liveSessions.get(path)?.session;
1281
+ if (existing) return existing;
1282
+ const created = await makeAgentSession(path);
1283
+ if (created.modelFallbackMessage) console.warn(created.modelFallbackMessage);
1284
+ return registerLiveSession(created.session);
1285
+ }
1286
+
1287
+ async function applyDefaultSessionSettings(value: any) {
1288
+ const settings = await settingsStore.read();
1289
+ const modelSetting = settings.defaults.model;
1290
+ if (modelSetting) {
1291
+ const model = value.modelRegistry.find(modelSetting.provider, modelSetting.id);
1292
+ if (model) await value.setModel(model);
1293
+ }
1294
+ const thinkingLevel = settings.defaults.thinkingLevel;
1295
+ if (thinkingLevel && value.getAvailableThinkingLevels().includes(thinkingLevel as any)) {
1296
+ value.setThinkingLevel(thinkingLevel as any);
1297
+ }
1298
+ }
1299
+
1300
+ async function createNewLiveSession(cwd?: string) {
1301
+ if (cwd) await setPiCwd(cwd);
1302
+ const previousSessionFile = session?.sessionFile;
1303
+ const created = await makeAgentSession(undefined, { type: "session_start", reason: "new", previousSessionFile });
1304
+ if (created.modelFallbackMessage) console.warn(created.modelFallbackMessage);
1305
+ const value = created.session;
1306
+ if (mockMode) {
1307
+ value.sessionManager.newSession();
1308
+ value.agent.state.messages = value.sessionManager.buildSessionContext().messages;
1309
+ }
1310
+ await applyDefaultSessionSettings(value);
1311
+ return registerLiveSession(value);
1312
+ }
1313
+
1314
+ async function switchEmptySessionCwd(cwd: string) {
1315
+ if (session.isStreaming) throw new Error("Wait for the current response to finish before changing the working directory.");
1316
+ if (session.isCompacting) throw new Error("Wait for compaction to finish before changing the working directory.");
1317
+ if (hasUserMessages(session)) throw new Error("Working directory can only be changed before the first message.");
1318
+ session = await createNewLiveSession(cwd);
1319
+ return currentStateWithThinkingLevels();
1320
+ }
1321
+
1322
+ await ensurePiWebStorage();
1323
+
1324
+ const createdSession = await makeAgentSession();
1325
+ session = registerLiveSession(createdSession.session);
1326
+ modelFallbackMessage = createdSession.modelFallbackMessage;
1327
+
1328
+ if (modelFallbackMessage) {
1329
+ console.warn(modelFallbackMessage);
1330
+ }
1331
+
1332
+ let viteDevServer: ViteDevServer | undefined;
1333
+
1334
+ const server = createServer(async (req, res) => {
1335
+ try {
1336
+ const method = req.method || "GET";
1337
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
1338
+
1339
+ if (url.pathname.startsWith("/api/")) {
1340
+ if (method === "GET" && url.pathname.startsWith("/api/artifacts/")) {
1341
+ return serveArtifact(req, res);
1342
+ }
1343
+
1344
+ if (!isAuthorized(req)) return unauthorized(res);
1345
+
1346
+ if (mockMode && method === "POST" && url.pathname === "/api/mock/reset") {
1347
+ for (const entry of liveSessions.values()) entry.unsubscribe?.();
1348
+ liveSessions.clear();
1349
+ resetMockSessions();
1350
+ session = registerLiveSession(createMockSession());
1351
+ broadcast({ type: "state_changed", ...currentState() });
1352
+ return sendJson(res, 200, { ok: true });
1353
+ }
1354
+
1355
+ if (method === "GET" && url.pathname === "/api/fs/dirs") {
1356
+ try {
1357
+ return sendJson(res, 200, await listDirectories(url.searchParams.get("path") || piCwd));
1358
+ } catch (error) {
1359
+ return sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
1360
+ }
1361
+ }
1362
+
1363
+ if (method === "GET" && url.pathname === "/api/git/repos") {
1364
+ return sendJson(res, 200, await listGitRepos());
1365
+ }
1366
+
1367
+ if (method === "GET" && url.pathname === "/api/git/status") {
1368
+ try {
1369
+ return sendJson(res, 200, await gitStatus(await gitCwdFromRepoParam(url.searchParams.get("repo")), url.searchParams.get("fetch") === "1"));
1370
+ } catch (error) {
1371
+ return sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
1372
+ }
1373
+ }
1374
+
1375
+ if (method === "GET" && url.pathname === "/api/git/log") {
1376
+ try {
1377
+ return sendJson(res, 200, await gitLog(await gitCwdFromRepoParam(url.searchParams.get("repo"))));
1378
+ } catch (error) {
1379
+ return sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
1380
+ }
1381
+ }
1382
+
1383
+ if (method === "GET" && url.pathname === "/api/git/commit") {
1384
+ try {
1385
+ return sendJson(res, 200, await gitCommitDetails(url.searchParams.get("hash") || "", await gitCwdFromRepoParam(url.searchParams.get("repo"))));
1386
+ } catch (error) {
1387
+ return sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
1388
+ }
1389
+ }
1390
+
1391
+ if (method === "GET" && url.pathname === "/api/git/diff") {
1392
+ try {
1393
+ const cwd = await gitCwdFromRepoParam(url.searchParams.get("repo"));
1394
+ if (!await isGitRepo(cwd)) return sendJson(res, 404, { ok: false, error: "Not a Git repository" });
1395
+ const filePath = safeGitPath(url.searchParams.get("path") || "");
1396
+ const staged = url.searchParams.get("staged") === "1";
1397
+ const args = staged ? ["diff", "--cached", "--", filePath] : ["diff", "--", filePath];
1398
+ let { stdout } = await git(args, 15_000, cwd);
1399
+ if (!stdout) {
1400
+ const status = await gitStatus(cwd) as any;
1401
+ const file = status.files?.find((f: any) => f.path === filePath);
1402
+ if (file?.label === "untracked") stdout = (await git(["diff", "--no-index", "--", "/dev/null", filePath], 15_000, cwd).catch((error: any) => ({ stdout: error.stdout || "" }))).stdout;
1403
+ }
1404
+ return sendJson(res, 200, { ok: true, path: filePath, staged, diff: stdout });
1405
+ } catch (error) {
1406
+ return sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
1407
+ }
1408
+ }
1409
+
1410
+ if (method === "GET" && url.pathname === "/api/git/image") {
1411
+ try {
1412
+ const cwd = await gitCwdFromRepoParam(url.searchParams.get("repo"));
1413
+ if (!await isGitRepo(cwd)) return sendJson(res, 404, { ok: false, error: "Not a Git repository" });
1414
+ await sendGitImage(res, {
1415
+ cwd,
1416
+ path: url.searchParams.get("path") || "",
1417
+ oldPath: url.searchParams.get("oldPath") || undefined,
1418
+ version: url.searchParams.get("version") || "",
1419
+ staged: url.searchParams.get("staged") === "1",
1420
+ });
1421
+ return;
1422
+ } catch (error) {
1423
+ return sendJson(res, 404, { ok: false, error: error instanceof Error ? error.message : String(error) });
1424
+ }
1425
+ }
1426
+
1427
+ if (method === "POST" && url.pathname === "/api/git/sync") {
1428
+ try {
1429
+ const cwd = await gitCwdFromRepoParam(url.searchParams.get("repo"));
1430
+ if (!await isGitRepo(cwd)) return sendJson(res, 404, { ok: false, error: "Not a Git repository" });
1431
+ const status = await gitStatus(cwd) as any;
1432
+ const branch = status.branch;
1433
+ if (!branch) return sendJson(res, 400, { ok: false, error: "Cannot sync detached HEAD" });
1434
+ const fetchResult = await git(["fetch", "--prune", "origin"], 60_000, cwd);
1435
+ const pullResult = await git(["pull", "--rebase", "--autostash", "origin", branch], 120_000, cwd);
1436
+ return sendJson(res, 200, { ok: true, output: `${fetchResult.stdout}${fetchResult.stderr}${pullResult.stdout}${pullResult.stderr}`, status: await gitStatus(cwd) });
1437
+ } catch (error) {
1438
+ return sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
1439
+ }
1440
+ }
1441
+
1442
+ if (method === "GET" && url.pathname === "/api/state") {
1443
+ return sendJson(res, 200, {
1444
+ ok: true,
1445
+ ...currentStateWithThinkingLevels(),
1446
+ tokenRequired: Boolean(token),
1447
+ });
1448
+ }
1449
+
1450
+ if (method === "GET" && url.pathname === "/api/session/stats") {
1451
+ const requestedSessionId = url.searchParams.get("sessionId") || session.sessionId;
1452
+ const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId);
1453
+ if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" });
1454
+ return sendJson(res, 200, { ok: true, sessionId: targetSession.sessionId, stats: sessionStats(targetSession) });
1455
+ }
1456
+
1457
+ if (method === "GET" && url.pathname === "/api/session/tree") {
1458
+ const requestedSessionId = url.searchParams.get("sessionId") || session.sessionId;
1459
+ const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId);
1460
+ if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" });
1461
+ try {
1462
+ return sendJson(res, 200, conversationTreeForSession(targetSession));
1463
+ } catch (error) {
1464
+ return sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
1465
+ }
1466
+ }
1467
+
1468
+ if (method === "POST" && url.pathname === "/api/session/tree/navigate") {
1469
+ const body = await readBody(req) as { sessionId?: unknown; targetId?: unknown; summarize?: unknown; customInstructions?: unknown; replaceInstructions?: unknown; label?: unknown };
1470
+ const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId;
1471
+ const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId);
1472
+ if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" });
1473
+ if (targetSession !== session) return sendJson(res, 400, { ok: false, error: "Open the session before navigating its tree" });
1474
+ if (targetSession.isStreaming) return sendJson(res, 409, { ok: false, error: "Wait for the current response to finish before navigating the tree" });
1475
+ if (targetSession.isCompacting) return sendJson(res, 409, { ok: false, error: "Wait for the current compaction to finish before navigating the tree" });
1476
+ if (typeof targetSession.navigateTree !== "function") return sendJson(res, 400, { ok: false, error: "Tree navigation is not available" });
1477
+
1478
+ const targetId = String(body.targetId || "").trim();
1479
+ if (!targetId) return sendJson(res, 400, { ok: false, error: "targetId is required" });
1480
+
1481
+ try {
1482
+ const navigation = targetSession.navigateTree(targetId, {
1483
+ summarize: Boolean(body.summarize),
1484
+ customInstructions: typeof body.customInstructions === "string" && body.customInstructions.trim() ? body.customInstructions.trim() : undefined,
1485
+ replaceInstructions: Boolean(body.replaceInstructions),
1486
+ label: typeof body.label === "string" && body.label.trim() ? body.label.trim() : undefined,
1487
+ });
1488
+ broadcast({ type: "session_runtime_changed", sessionId: targetSession.sessionId, sessionFile: targetSession.sessionFile, runtime: runtimeForPath(targetSession.sessionFile) });
1489
+ const result = await navigation;
1490
+ const state = currentStateWithThinkingLevels();
1491
+ broadcast({ type: "state_changed", ...state });
1492
+ return sendJson(res, 200, { ok: true, ...result, leafId: targetSession.sessionManager.getLeafId?.() || null, state });
1493
+ } catch (error) {
1494
+ return sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
1495
+ } finally {
1496
+ broadcast({ type: "session_runtime_changed", sessionId: targetSession.sessionId, sessionFile: targetSession.sessionFile, runtime: runtimeForPath(targetSession.sessionFile) });
1497
+ }
1498
+ }
1499
+
1500
+ if (method === "POST" && url.pathname === "/api/session/tree/abort-summary") {
1501
+ const body = await readBody(req) as { sessionId?: unknown };
1502
+ const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId;
1503
+ const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId);
1504
+ if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" });
1505
+ targetSession.abortBranchSummary?.();
1506
+ return sendJson(res, 202, { ok: true, sessionId: targetSession.sessionId });
1507
+ }
1508
+
1509
+ if (method === "GET" && url.pathname === "/api/messages") {
1510
+ const requestedSessionId = url.searchParams.get("sessionId") || session.sessionId;
1511
+ const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId);
1512
+ if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" });
1513
+ const msgs = targetSession.messages;
1514
+ // Build toolCallId -> args map from assistant messages
1515
+ const toolCallArgs = new Map<string, Record<string, unknown>>();
1516
+ for (const m of msgs) {
1517
+ const msg = m as any;
1518
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
1519
+ for (const part of msg.content) {
1520
+ if (part?.type === "toolCall" && part.id) {
1521
+ toolCallArgs.set(part.id, part.arguments || {});
1522
+ }
1523
+ }
1524
+ }
1525
+ }
1526
+ return sendJson(res, 200, { ok: true, messages: msgs.map((m: unknown) => simplifyMessage(m, toolCallArgs)) });
1527
+ }
1528
+
1529
+ if (method === "GET" && url.pathname === "/api/sessions") {
1530
+ const extraCwds = url.searchParams.getAll("cwd");
1531
+ return sendJson(res, 200, { ok: true, sessions: await listSessionInfos(extraCwds) });
1532
+ }
1533
+
1534
+ if (method === "GET" && url.pathname === "/api/settings") {
1535
+ return sendJson(res, 200, { ok: true, settings: await settingsStore.read() });
1536
+ }
1537
+
1538
+ if (method === "PATCH" && url.pathname === "/api/settings") {
1539
+ const settings = await settingsStore.patch(await readBody(req));
1540
+ broadcast({ type: "settings_updated", settings });
1541
+ return sendJson(res, 200, { ok: true, settings });
1542
+ }
1543
+
1544
+ if (method === "GET" && url.pathname === "/api/commands") {
1545
+ const requestedSessionId = url.searchParams.get("sessionId") || session.sessionId;
1546
+ const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId);
1547
+ if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" });
1548
+ return sendJson(res, 200, { ok: true, commands: getSlashCommands(targetSession) });
1549
+ }
1550
+
1551
+ if (method === "GET" && url.pathname === "/api/models") {
1552
+ return sendJson(res, 200, {
1553
+ ok: true,
1554
+ cwd: piCwd,
1555
+ current: simplifyModel(session.model),
1556
+ thinkingLevel: session.thinkingLevel,
1557
+ thinkingLevels: session.getAvailableThinkingLevels(),
1558
+ models: getAvailableModels().map(simplifyModel),
1559
+ });
1560
+ }
1561
+
1562
+ if (method === "POST" && url.pathname === "/api/model") {
1563
+ const body = await readBody(req) as { provider?: unknown; id?: unknown; thinkingLevel?: unknown };
1564
+ const provider = String(body.provider || "").trim();
1565
+ const id = String(body.id || "").trim();
1566
+ if (!provider || !id) return sendJson(res, 400, { ok: false, error: "provider and id are required" });
1567
+
1568
+ const model = session.modelRegistry.find(provider, id);
1569
+ if (!model) return sendJson(res, 404, { ok: false, error: "Model not found" });
1570
+
1571
+ await session.setModel(model);
1572
+ if (typeof body.thinkingLevel === "string") session.setThinkingLevel(body.thinkingLevel as any);
1573
+
1574
+ const state = {
1575
+ cwd: piCwd,
1576
+ sessionFile: session.sessionFile,
1577
+ sessionId: session.sessionId,
1578
+ model: simplifyModel(session.model),
1579
+ thinkingLevel: session.thinkingLevel,
1580
+ thinkingLevels: session.getAvailableThinkingLevels(),
1581
+ };
1582
+ broadcast({ type: "state_changed", ...state });
1583
+ return sendJson(res, 200, { ok: true, ...state });
1584
+ }
1585
+
1586
+ if (method === "POST" && url.pathname === "/api/command") {
1587
+ const body = await readBody(req) as { command?: unknown };
1588
+ const command = String(body.command || "").trim();
1589
+ if (!command.startsWith("/")) return sendJson(res, 400, { ok: false, error: "Slash command is required" });
1590
+
1591
+ const result = await executeSlashCommand(command);
1592
+ return sendJson(res, 200, { ok: true, ...result });
1593
+ }
1594
+
1595
+ if (method === "POST" && url.pathname === "/api/extension-ui/respond") {
1596
+ const body = await readBody(req) as { id?: unknown } & Record<string, unknown>;
1597
+ const id = String(body.id || "").trim();
1598
+ if (!id) return sendJson(res, 400, { ok: false, error: "id is required" });
1599
+ const pending = pendingExtensionUiRequests.get(id);
1600
+ if (!pending) return sendJson(res, 404, { ok: false, error: "Extension UI request not found" });
1601
+ pending.resolve(body);
1602
+ return sendJson(res, 200, { ok: true });
1603
+ }
1604
+
1605
+ if (method === "POST" && url.pathname === "/api/prompt") {
1606
+ const body = await readBody(req) as { sessionId?: unknown; message?: unknown; mode?: unknown; images?: unknown };
1607
+ const message = String(body.message || "").trim();
1608
+ const images = Array.isArray(body.images)
1609
+ ? body.images.filter((image): image is { type: "image"; data: string; mimeType: string; name?: string } => {
1610
+ if (!image || typeof image !== "object") return false;
1611
+ const value = image as Record<string, unknown>;
1612
+ return value.type === "image"
1613
+ && typeof value.data === "string"
1614
+ && typeof value.mimeType === "string"
1615
+ && value.mimeType.startsWith("image/");
1616
+ })
1617
+ : [];
1618
+ if (!message && images.length === 0) return sendJson(res, 400, { ok: false, error: "message or image is required" });
1619
+
1620
+ const imageFileNote = await persistPromptImages(images);
1621
+ const promptText = `${message || "Please review the attached image."}${imageFileNote}`;
1622
+ const mode = body.mode === "followUp" ? "followUp" : "steer";
1623
+ const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId;
1624
+ const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId);
1625
+ if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" });
1626
+ void targetSession.prompt(promptText, {
1627
+ ...(targetSession.isStreaming ? { streamingBehavior: mode } : {}),
1628
+ ...(images.length ? { images: images.map(({ type, data, mimeType }) => ({ type, data, mimeType })) } : {}),
1629
+ })
1630
+ .catch((error: unknown) => broadcast({
1631
+ type: "server_error",
1632
+ sessionId: targetSession.sessionId,
1633
+ sessionFile: targetSession.sessionFile,
1634
+ error: error instanceof Error ? error.message : String(error),
1635
+ }));
1636
+
1637
+ return sendJson(res, 202, { ok: true, sessionId: targetSession.sessionId });
1638
+ }
1639
+
1640
+ if (method === "POST" && url.pathname === "/api/abort") {
1641
+ const body = await readBody(req) as { sessionId?: unknown };
1642
+ const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId;
1643
+ const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId);
1644
+ if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" });
1645
+ void targetSession.abort().catch((error: unknown) => broadcast({
1646
+ type: "server_error",
1647
+ sessionId: targetSession.sessionId,
1648
+ sessionFile: targetSession.sessionFile,
1649
+ error: error instanceof Error ? error.message : String(error),
1650
+ }));
1651
+ return sendJson(res, 202, { ok: true, sessionId: targetSession.sessionId });
1652
+ }
1653
+
1654
+ if (method === "POST" && url.pathname === "/api/compaction/abort") {
1655
+ const body = await readBody(req) as { sessionId?: unknown };
1656
+ const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId;
1657
+ const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId);
1658
+ if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" });
1659
+ if (typeof targetSession.abortCompaction !== "function") return sendJson(res, 400, { ok: false, error: "Compaction cancellation is not available" });
1660
+ targetSession.abortCompaction();
1661
+ return sendJson(res, 202, { ok: true, sessionId: targetSession.sessionId });
1662
+ }
1663
+
1664
+ if (method === "POST" && url.pathname === "/api/session/name") {
1665
+ const body = await readBody(req) as { sessionId?: unknown; name?: unknown };
1666
+ const requestedSessionId = typeof body.sessionId === "string" ? body.sessionId : session.sessionId;
1667
+ const targetSession = requestedSessionId === session.sessionId ? session : await getOrCreateLiveSessionById(requestedSessionId);
1668
+ if (!targetSession) return sendJson(res, 404, { ok: false, error: "Session not found" });
1669
+ if (typeof targetSession.setSessionName !== "function") return sendJson(res, 400, { ok: false, error: "Renaming sessions is not available" });
1670
+
1671
+ const name = String(body.name || "").trim();
1672
+ targetSession.setSessionName(name);
1673
+ const state = targetSession === session ? currentStateWithThinkingLevels() : { sessionId: targetSession.sessionId, sessionName: sessionDisplayName(targetSession) };
1674
+ return sendJson(res, 200, { ok: true, ...state });
1675
+ }
1676
+
1677
+ if (method === "POST" && (url.pathname === "/api/new-chat" || url.pathname === "/api/sessions/new")) {
1678
+ const body = await readBody(req) as { cwd?: unknown };
1679
+ session = await createNewLiveSession(typeof body.cwd === "string" ? body.cwd : undefined);
1680
+ const state = currentStateWithThinkingLevels();
1681
+ broadcast({ type: "state_changed", ...state });
1682
+ return sendJson(res, 200, { ok: true, ...state });
1683
+ }
1684
+
1685
+ if (method === "POST" && url.pathname === "/api/session/cwd") {
1686
+ const body = await readBody(req) as { cwd?: unknown };
1687
+ const cwd = String(body.cwd || "").trim();
1688
+ if (!cwd) return sendJson(res, 400, { ok: false, error: "cwd is required" });
1689
+ try {
1690
+ const state = await switchEmptySessionCwd(cwd);
1691
+ broadcast({ type: "state_changed", ...state });
1692
+ return sendJson(res, 200, { ok: true, ...state });
1693
+ } catch (error) {
1694
+ return sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
1695
+ }
1696
+ }
1697
+
1698
+ if (method === "POST" && url.pathname === "/api/sessions/open") {
1699
+ const body = await readBody(req) as { id?: unknown; sessionId?: unknown; cwd?: unknown };
1700
+ const requestedId = typeof body.sessionId === "string" ? body.sessionId : typeof body.id === "string" ? body.id : "";
1701
+ if (!requestedId) return sendJson(res, 400, { ok: false, error: "sessionId is required" });
1702
+
1703
+ try {
1704
+ if (typeof body.cwd === "string" && body.cwd.trim()) await setPiCwd(body.cwd);
1705
+ await switchToSessionId(requestedId);
1706
+ } catch {
1707
+ return sendJson(res, 404, { ok: false, error: "Session not found" });
1708
+ }
1709
+ const state = currentStateWithThinkingLevels();
1710
+ broadcast({ type: "state_changed", ...state });
1711
+ return sendJson(res, 200, { ok: true, ...state });
1712
+ }
1713
+
1714
+ return sendJson(res, 404, { ok: false, error: "Unknown API route" });
1715
+ }
1716
+
1717
+ if (viteDevServer) {
1718
+ viteDevServer.middlewares(req, res, () => {
1719
+ if (!res.writableEnded) sendJson(res, 404, { ok: false, error: "Not found" });
1720
+ });
1721
+ return;
1722
+ }
1723
+
1724
+ serveStatic(req, res);
1725
+ } catch (error) {
1726
+ sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
1727
+ }
1728
+ });
1729
+
1730
+ const wss = new WebSocketServer({ noServer: true });
1731
+ server.on("upgrade", (req, socket, head) => {
1732
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
1733
+ if (url.pathname !== "/ws") return;
1734
+
1735
+ if (!isAuthorized(req)) {
1736
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
1737
+ socket.destroy();
1738
+ return;
1739
+ }
1740
+
1741
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
1742
+ });
1743
+
1744
+ wss.on("connection", (ws, req) => {
1745
+ clients.add(ws);
1746
+ const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
1747
+ const lastSeq = Number(url.searchParams.get("lastSeq") || 0);
1748
+ const latestSeq = nextRealtimeSeq - 1;
1749
+ const oldestSeq = realtimeEventLog[0]?.seq || nextRealtimeSeq;
1750
+
1751
+ if (Number.isFinite(lastSeq) && lastSeq > 0) {
1752
+ if (lastSeq > latestSeq || lastSeq < oldestSeq - 1) {
1753
+ ws.send(JSON.stringify({ type: "sync_required", latestSeq }));
1754
+ } else {
1755
+ for (const event of realtimeEventLog) {
1756
+ if (event.seq > lastSeq) ws.send(JSON.stringify({ ...event, replay: true }));
1757
+ }
1758
+ }
1759
+ }
1760
+
1761
+ ws.send(JSON.stringify({
1762
+ type: "hello",
1763
+ seq: latestSeq,
1764
+ cwd: piCwd,
1765
+ sessionFile: session.sessionFile,
1766
+ sessionId: session.sessionId,
1767
+ model: simplifyModel(session.model),
1768
+ thinkingLevel: session.thinkingLevel,
1769
+ isStreaming: session.isStreaming,
1770
+ }));
1771
+ ws.on("close", () => clients.delete(ws));
1772
+ });
1773
+
1774
+ if (isDev) {
1775
+ viteDevServer = await createViteServer({
1776
+ appType: "spa",
1777
+ server: {
1778
+ middlewareMode: true,
1779
+ hmr: { server },
1780
+ },
1781
+ });
1782
+ }
1783
+
1784
+ server.listen(port, host, () => {
1785
+ console.log(`pi-web listening on http://${host}:${port}`);
1786
+ console.log(`Pi cwd: ${piCwd}`);
1787
+ console.log(isDev ? "Mode: development (Vite HMR enabled)" : "Mode: production");
1788
+ console.log(token ? "Auth: bearer token required" : "Auth: disabled (set PI_WEB_TOKEN to enable)");
1789
+ });