@firstpick/pi-package-webui 0.1.4 → 0.1.6

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/bin/pi-webui.mjs CHANGED
@@ -4,10 +4,11 @@ import { randomUUID } from "node:crypto";
4
4
  import { createServer } from "node:http";
5
5
  import { createRequire } from "node:module";
6
6
  import { access, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
7
- import { homedir, networkInterfaces } from "node:os";
7
+ import { homedir, networkInterfaces, tmpdir } from "node:os";
8
8
  import path from "node:path";
9
9
  import { StringDecoder } from "node:string_decoder";
10
10
  import { fileURLToPath } from "node:url";
11
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
11
12
 
12
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
14
  const require = createRequire(import.meta.url);
@@ -19,6 +20,14 @@ const DEFAULT_HOST = "127.0.0.1";
19
20
  const DEFAULT_PORT = 31415;
20
21
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
21
22
  const BODY_LIMIT_BYTES = 1024 * 1024;
23
+ const PROMPT_BODY_LIMIT_BYTES = 24 * 1024 * 1024;
24
+ const UPLOAD_BODY_LIMIT_BYTES = 96 * 1024 * 1024;
25
+ const ATTACHMENT_UPLOAD_MAX_FILES = 12;
26
+ const ATTACHMENT_UPLOAD_MAX_FILE_BYTES = 64 * 1024 * 1024;
27
+ const ATTACHMENT_UPLOAD_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
28
+ const INLINE_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
29
+ const INLINE_IMAGE_TOTAL_MAX_BYTES = 16 * 1024 * 1024;
30
+ const RPC_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
22
31
  const EVENT_HISTORY_LIMIT = 200;
23
32
  const EXTENSION_UI_BLOCKING_METHODS = new Set(["select", "confirm", "input", "editor"]);
24
33
  const STATUS_RPC_TIMEOUT_MS = 1_800;
@@ -29,6 +38,8 @@ const PATH_SUGGESTION_SCAN_LIMIT = 5000;
29
38
  const PATH_SUGGESTION_MAX_OUTPUT_LENGTH = 300000;
30
39
  const PATH_SUGGESTION_EXCLUDED_DIRS = new Set([".git", "node_modules"]);
31
40
  const RESTORE_TAB_LIMIT = 30;
41
+ const SESSION_SELECTOR_LIMIT = 200;
42
+ const TREE_SELECTOR_TEXT_LIMIT = 260;
32
43
  const NETWORK_REBIND_DELAY_MS = 100;
33
44
  const NETWORK_REBIND_FORCE_CLOSE_MS = 750;
34
45
  const AUTO_TAB_TITLE_MAX_LENGTH = 44;
@@ -77,6 +88,7 @@ const MIME_TYPES = new Map([
77
88
  [".css", "text/css; charset=utf-8"],
78
89
  [".svg", "image/svg+xml"],
79
90
  [".png", "image/png"],
91
+ [".webp", "image/webp"],
80
92
  [".webmanifest", "application/manifest+json; charset=utf-8"],
81
93
  ]);
82
94
 
@@ -104,6 +116,15 @@ const NATIVE_SLASH_COMMANDS = [
104
116
  { name: "quit", description: "Quit Pi" },
105
117
  ].map((command) => ({ ...command, source: "native", location: "Pi" }));
106
118
  const NATIVE_SLASH_COMMAND_NAMES = new Set(NATIVE_SLASH_COMMANDS.map((command) => command.name));
119
+ const OPTIONAL_FEATURE_PACKAGES = new Map([
120
+ ["gitWorkflow", "@firstpick/pi-prompts-git-pr"],
121
+ ["releaseNpm", "@firstpick/pi-extension-release-npm"],
122
+ ["releaseAur", "@firstpick/pi-extension-release-aur"],
123
+ ["todoProgressWidget", "@firstpick/pi-extension-todo-progress"],
124
+ ["gitFooterStatus", "@firstpick/pi-extension-git-footer-status"],
125
+ ["statsCommand", "@firstpick/pi-extension-stats"],
126
+ ["themeBundle", "@firstpick/pi-themes-bundle"],
127
+ ]);
107
128
 
108
129
  function usage() {
109
130
  console.log(`pi-webui ${packageJson.version}
@@ -119,7 +140,7 @@ Options:
119
140
  --cwd <path> Working directory for the Pi session (default: current dir)
120
141
  --pi <command> Pi executable to spawn (default: bundled dependency, then "pi")
121
142
  --no-session Start Pi RPC with --no-session
122
- --name <name> Initial Pi session name
143
+ --name <name> Initial Web UI tab display name
123
144
  -h, --help Show this help
124
145
  -v, --version Print version
125
146
 
@@ -232,6 +253,10 @@ function sanitizeError(error) {
232
253
  return error.stack || error.message || String(error);
233
254
  }
234
255
 
256
+ function delay(ms) {
257
+ return new Promise((resolve) => setTimeout(resolve, ms));
258
+ }
259
+
235
260
  class PiRpcProcess {
236
261
  constructor({ command, args, displayCommand, cwd }) {
237
262
  this.command = command;
@@ -274,6 +299,10 @@ class PiRpcProcess {
274
299
  this.emit({ type: "pi_process_start", pid: this.child.pid, cwd: this.cwd, command: this.displayCommand, args: this.args });
275
300
  }
276
301
 
302
+ isRunning() {
303
+ return !!this.child && this.child.exitCode === null && !this.child.killed;
304
+ }
305
+
277
306
  onEvent(listener) {
278
307
  this.listeners.add(listener);
279
308
  return () => this.listeners.delete(listener);
@@ -344,7 +373,7 @@ class PiRpcProcess {
344
373
  }
345
374
 
346
375
  send(command, timeoutMs = REQUEST_TIMEOUT_MS) {
347
- if (!this.child || !this.child.stdin || this.child.exitCode !== null) {
376
+ if (!this.isRunning() || !this.child?.stdin) {
348
377
  return Promise.reject(new Error("Pi RPC process is not running"));
349
378
  }
350
379
 
@@ -367,7 +396,7 @@ class PiRpcProcess {
367
396
  }
368
397
 
369
398
  async writeRaw(command) {
370
- if (!this.child || !this.child.stdin || this.child.exitCode !== null) {
399
+ if (!this.isRunning() || !this.child?.stdin) {
371
400
  throw new Error("Pi RPC process is not running");
372
401
  }
373
402
 
@@ -415,12 +444,24 @@ function sendError(res, statusCode, error) {
415
444
  sendJson(res, statusCode, { ok: false, error: sanitizeError(error) });
416
445
  }
417
446
 
418
- async function readJsonBody(req) {
447
+ function formatBytes(bytes) {
448
+ const value = Number(bytes) || 0;
449
+ if (value < 1024) return `${value} B`;
450
+ const units = ["KB", "MB", "GB"];
451
+ let scaled = value / 1024;
452
+ for (const unit of units) {
453
+ if (scaled < 1024 || unit === units[units.length - 1]) return `${scaled.toFixed(scaled >= 10 ? 1 : 2)} ${unit}`;
454
+ scaled /= 1024;
455
+ }
456
+ return `${value} B`;
457
+ }
458
+
459
+ async function readJsonBody(req, { limitBytes = BODY_LIMIT_BYTES } = {}) {
419
460
  const chunks = [];
420
461
  let size = 0;
421
462
  for await (const chunk of req) {
422
463
  size += chunk.length;
423
- if (size > BODY_LIMIT_BYTES) throw new Error("Request body too large");
464
+ if (size > limitBytes) throw makeHttpError(413, `Request body too large (limit ${formatBytes(limitBytes)})`);
424
465
  chunks.push(chunk);
425
466
  }
426
467
  if (chunks.length === 0) return {};
@@ -634,6 +675,49 @@ function runCommand(command, args, { cwd, timeoutMs = 2000, maxOutputLength = 20
634
675
  });
635
676
  }
636
677
 
678
+ function optionalDependencyInstallRoot() {
679
+ const parts = packageRoot.split(path.sep);
680
+ const nodeModulesIndex = parts.lastIndexOf("node_modules");
681
+ if (nodeModulesIndex >= 0) {
682
+ const root = parts.slice(0, nodeModulesIndex).join(path.sep);
683
+ return root || path.parse(packageRoot).root;
684
+ }
685
+ return packageRoot;
686
+ }
687
+
688
+ function formatCommandForDisplay(command, args) {
689
+ return [command, ...args].map((part) => (/\s/.test(part) ? JSON.stringify(part) : part)).join(" ");
690
+ }
691
+
692
+ async function installOptionalFeaturePackage(featureId) {
693
+ const packageName = OPTIONAL_FEATURE_PACKAGES.get(featureId);
694
+ if (!packageName) throw makeHttpError(400, `Unknown optional feature: ${featureId}`);
695
+
696
+ const installRoot = optionalDependencyInstallRoot();
697
+ const npmCommand = process.env.PI_WEBUI_NPM_BIN || "npm";
698
+ const args = ["install", "--prefix", installRoot, packageName];
699
+ const result = await runCommand(npmCommand, args, {
700
+ cwd: installRoot,
701
+ timeoutMs: 5 * 60 * 1000,
702
+ maxOutputLength: 80000,
703
+ });
704
+ const command = formatCommandForDisplay(npmCommand, args);
705
+ const ok = result.exitCode === 0 && !result.timedOut && !result.error;
706
+ if (!ok) {
707
+ const details = [result.error, result.timedOut ? "timed out" : undefined, result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join("\n");
708
+ throw makeHttpError(500, `Optional feature install failed: ${command}${details ? `\n${details}` : ""}`);
709
+ }
710
+ return {
711
+ featureId,
712
+ packageName,
713
+ installRoot,
714
+ command,
715
+ stdout: result.stdout,
716
+ stderr: result.stderr,
717
+ message: `Installed optional feature package ${packageName}. Reload the active Pi tab to load new resources.`,
718
+ };
719
+ }
720
+
637
721
  function displayPath(cwd) {
638
722
  const normalized = cwd.replace(/\\/g, "/");
639
723
  const home = (process.env.USERPROFILE || process.env.HOME || "").replace(/\\/g, "/");
@@ -809,9 +893,9 @@ function resolveScopedModelsFromPatterns(patterns, models) {
809
893
  async function getScopedModelData(tab) {
810
894
  const { patterns, source } = await configuredScopedModelPatterns(tab.cwd);
811
895
  if (!patterns.length) return { models: [], patterns, source };
812
- const response = await tab.rpc.send({ type: "get_available_models" });
896
+ const response = await safeRpcResponse(tab, { type: "get_available_models" });
813
897
  if (response.success === false) throw makeHttpError(400, response.error || "failed to load available models");
814
- return { models: resolveScopedModelsFromPatterns(patterns, response.data?.models || []), patterns, source };
898
+ return { models: resolveScopedModelsFromPatterns(patterns, response.data?.models || []), patterns, source, rpcRunning: response.rpcRunning !== false };
815
899
  }
816
900
 
817
901
  function pathPickerRoots(activeCwd, viewedCwd) {
@@ -1299,7 +1383,7 @@ async function readBundledThemes() {
1299
1383
  function normalizeStaticPath(urlPath) {
1300
1384
  if (urlPath === "/") return "index.html";
1301
1385
  const name = urlPath.startsWith("/") ? urlPath.slice(1) : urlPath;
1302
- if (!["index.html", "app.js", "styles.css", "favicon.svg", "apple-touch-icon.png", "icon-192.png", "icon-512.png", "manifest.webmanifest", "service-worker.js"].includes(name)) return undefined;
1386
+ if (!["index.html", "app.js", "styles.css", "favicon.svg", "apple-touch-icon.png", "icon-192.png", "icon-512.png", "catppuccin-mocha-background.png", "matrix-background.webp", "manifest.webmanifest", "service-worker.js"].includes(name)) return undefined;
1303
1387
  return name;
1304
1388
  }
1305
1389
 
@@ -1320,6 +1404,97 @@ async function serveStatic(req, res, url) {
1320
1404
  return true;
1321
1405
  }
1322
1406
 
1407
+ function requestBodyLimitForPath(pathname) {
1408
+ if (pathname === "/api/attachments") return UPLOAD_BODY_LIMIT_BYTES;
1409
+ if (["/api/prompt", "/api/steer", "/api/follow-up"].includes(pathname)) return PROMPT_BODY_LIMIT_BYTES;
1410
+ return BODY_LIMIT_BYTES;
1411
+ }
1412
+
1413
+ function sanitizeUploadFileName(name) {
1414
+ const base = path.basename(String(name || "attachment").replace(/\0/g, ""));
1415
+ const safe = base.replace(/[^A-Za-z0-9._ -]+/g, "_").replace(/\s+/g, " ").trim().slice(0, 180);
1416
+ return safe && safe !== "." && safe !== ".." ? safe : "attachment";
1417
+ }
1418
+
1419
+ function normalizeMimeType(value) {
1420
+ const mimeType = String(value || "application/octet-stream").split(";", 1)[0].trim().toLowerCase();
1421
+ return mimeType || "application/octet-stream";
1422
+ }
1423
+
1424
+ function stripDataUrlPrefix(data) {
1425
+ const text = String(data || "").trim();
1426
+ if (!text.toLowerCase().startsWith("data:")) return text;
1427
+ const comma = text.indexOf(",");
1428
+ return comma === -1 ? text : text.slice(comma + 1);
1429
+ }
1430
+
1431
+ function decodeAttachmentData(data) {
1432
+ const base64 = stripDataUrlPrefix(data).replace(/\s+/g, "");
1433
+ if (!base64) throw new Error("attachment data is required");
1434
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(base64)) throw new Error("attachment data must be base64 encoded");
1435
+ return Buffer.from(base64, "base64");
1436
+ }
1437
+
1438
+ async function saveUploadedAttachments(body) {
1439
+ const rawFiles = Array.isArray(body?.files) ? body.files : [];
1440
+ if (rawFiles.length === 0) throw new Error("files are required");
1441
+ if (rawFiles.length > ATTACHMENT_UPLOAD_MAX_FILES) throw new Error(`attachments are limited to ${ATTACHMENT_UPLOAD_MAX_FILES} files`);
1442
+
1443
+ const decoded = [];
1444
+ let totalBytes = 0;
1445
+ for (const [index, file] of rawFiles.entries()) {
1446
+ const buffer = decodeAttachmentData(file?.data);
1447
+ if (buffer.length === 0) throw new Error(`attachment ${index + 1} is empty`);
1448
+ if (buffer.length > ATTACHMENT_UPLOAD_MAX_FILE_BYTES) throw new Error(`attachment ${index + 1} exceeds ${formatBytes(ATTACHMENT_UPLOAD_MAX_FILE_BYTES)}`);
1449
+ totalBytes += buffer.length;
1450
+ if (totalBytes > ATTACHMENT_UPLOAD_MAX_TOTAL_BYTES) throw new Error(`attachments exceed ${formatBytes(ATTACHMENT_UPLOAD_MAX_TOTAL_BYTES)} total`);
1451
+ decoded.push({
1452
+ id: String(file?.id || `attachment-${index + 1}`).slice(0, 120),
1453
+ name: sanitizeUploadFileName(file?.name),
1454
+ mimeType: normalizeMimeType(file?.mimeType || file?.type),
1455
+ size: buffer.length,
1456
+ buffer,
1457
+ });
1458
+ }
1459
+
1460
+ const uploadDir = path.join(tmpdir(), "pi-webui-uploads", randomUUID());
1461
+ await mkdir(uploadDir, { recursive: true });
1462
+ const saved = [];
1463
+ for (const [index, file] of decoded.entries()) {
1464
+ const fileName = `${String(index + 1).padStart(2, "0")}-${file.name}`;
1465
+ const filePath = path.join(uploadDir, fileName);
1466
+ await writeFile(filePath, file.buffer);
1467
+ saved.push({ id: file.id, name: file.name, mimeType: file.mimeType, size: file.size, path: filePath });
1468
+ }
1469
+ return { files: saved, uploadDir };
1470
+ }
1471
+
1472
+ function normalizeRpcImages(value) {
1473
+ if (!Array.isArray(value) || value.length === 0) return undefined;
1474
+ if (value.length > ATTACHMENT_UPLOAD_MAX_FILES) throw new Error(`images are limited to ${ATTACHMENT_UPLOAD_MAX_FILES} files`);
1475
+ const images = [];
1476
+ let totalBytes = 0;
1477
+ for (const [index, image] of value.entries()) {
1478
+ const mimeType = normalizeMimeType(image?.mimeType);
1479
+ if (!RPC_IMAGE_MIME_TYPES.has(mimeType)) throw new Error(`image ${index + 1} has unsupported MIME type ${mimeType}`);
1480
+ const data = stripDataUrlPrefix(image?.data).replace(/\s+/g, "");
1481
+ if (!data) throw new Error(`image ${index + 1} data is required`);
1482
+ if (!/^[A-Za-z0-9+/]*={0,2}$/.test(data)) throw new Error(`image ${index + 1} data must be base64 encoded`);
1483
+ const approxBytes = Math.floor((data.length * 3) / 4);
1484
+ if (approxBytes > INLINE_IMAGE_MAX_BYTES) throw new Error(`image ${index + 1} exceeds ${formatBytes(INLINE_IMAGE_MAX_BYTES)} inline limit`);
1485
+ totalBytes += approxBytes;
1486
+ if (totalBytes > INLINE_IMAGE_TOTAL_MAX_BYTES) throw new Error(`inline images exceed ${formatBytes(INLINE_IMAGE_TOTAL_MAX_BYTES)} total`);
1487
+ images.push({ type: "image", data, mimeType });
1488
+ }
1489
+ return images.length ? images : undefined;
1490
+ }
1491
+
1492
+ function attachImages(command, body) {
1493
+ const images = normalizeRpcImages(body?.images);
1494
+ if (images) command.images = images;
1495
+ return command;
1496
+ }
1497
+
1323
1498
  function commandFromPost(pathname, body) {
1324
1499
  switch (pathname) {
1325
1500
  case "/api/prompt": {
@@ -1329,17 +1504,17 @@ function commandFromPost(pathname, body) {
1329
1504
  if (body.streamingBehavior === "steer" || body.streamingBehavior === "followUp") {
1330
1505
  command.streamingBehavior = body.streamingBehavior;
1331
1506
  }
1332
- return command;
1507
+ return attachImages(command, body);
1333
1508
  }
1334
1509
  case "/api/steer": {
1335
1510
  const message = String(body.message || "").trim();
1336
1511
  if (!message) throw new Error("message is required");
1337
- return { type: "steer", message };
1512
+ return attachImages({ type: "steer", message }, body);
1338
1513
  }
1339
1514
  case "/api/follow-up": {
1340
1515
  const message = String(body.message || "").trim();
1341
1516
  if (!message) throw new Error("message is required");
1342
- return { type: "follow_up", message };
1517
+ return attachImages({ type: "follow_up", message }, body);
1343
1518
  }
1344
1519
  case "/api/abort":
1345
1520
  return { type: "abort" };
@@ -1358,6 +1533,18 @@ function commandFromPost(pathname, body) {
1358
1533
  }
1359
1534
  return { type: "set_thinking_level", level };
1360
1535
  }
1536
+ case "/api/steering-mode": {
1537
+ const mode = String(body.mode || "").trim();
1538
+ if (!["all", "one-at-a-time"].includes(mode)) throw new Error("Invalid steering mode");
1539
+ return { type: "set_steering_mode", mode };
1540
+ }
1541
+ case "/api/follow-up-mode": {
1542
+ const mode = String(body.mode || "").trim();
1543
+ if (!["all", "one-at-a-time"].includes(mode)) throw new Error("Invalid follow-up mode");
1544
+ return { type: "set_follow_up_mode", mode };
1545
+ }
1546
+ case "/api/auto-compaction":
1547
+ return { type: "set_auto_compaction", enabled: body.enabled === true };
1361
1548
  case "/api/compact":
1362
1549
  return body.customInstructions ? { type: "compact", customInstructions: String(body.customInstructions) } : { type: "compact" };
1363
1550
  default:
@@ -1449,9 +1636,9 @@ function buildPiArgsForTab(tabIndex, title) {
1449
1636
  const args = ["--mode", "rpc"];
1450
1637
  if (options.noSession) args.push("--no-session");
1451
1638
 
1452
- const sessionName = tabIndex === 1 ? options.name : title;
1453
- if (sessionName) args.push("--name", sessionName);
1454
-
1639
+ // Keep tab naming inside Web UI metadata. Some bundled Pi CLI versions do not
1640
+ // support --name, and passing Web UI-generated tab titles through to child
1641
+ // RPC processes makes every tab after the first exit immediately.
1455
1642
  args.push(...options.piArgs);
1456
1643
  return args;
1457
1644
  }
@@ -1729,6 +1916,18 @@ function defaultTabTitle(tabIndex) {
1729
1916
  return `Terminal ${tabIndex}`;
1730
1917
  }
1731
1918
 
1919
+ async function primeTabRpc(tab) {
1920
+ try {
1921
+ const response = await tab.rpc.send({ type: "get_state" }, 1500);
1922
+ if (response.success !== false) {
1923
+ rememberTabState(tab, response.data);
1924
+ reconcileTabActivityFromState(tab, response.data);
1925
+ }
1926
+ } catch (error) {
1927
+ if (!/Timed out waiting for RPC response/i.test(sanitizeError(error))) throw error;
1928
+ }
1929
+ }
1930
+
1732
1931
  function attachRpcToTab(tab, rpc) {
1733
1932
  tab.rpcUnsubscribe?.();
1734
1933
  tab.rpc = rpc;
@@ -1777,6 +1976,15 @@ async function createTab({ id: requestedId, index, title, titleSource, conversat
1777
1976
  attachRpcToTab(tab, rpc);
1778
1977
  tabs.set(id, tab);
1779
1978
  rpc.start();
1979
+ try {
1980
+ await primeTabRpc(tab);
1981
+ } catch (error) {
1982
+ if (!tab.rpc.isRunning()) {
1983
+ tab.rpcUnsubscribe?.();
1984
+ tabs.delete(id);
1985
+ throw new Error(`Pi RPC process failed while starting ${tabTitle}: ${sanitizeError(error)}`);
1986
+ }
1987
+ }
1780
1988
  if (sessionFile && !options.noSession) {
1781
1989
  recordEvent({ type: "webui_tab_restored", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd });
1782
1990
  }
@@ -1799,7 +2007,7 @@ function tabMeta(tab) {
1799
2007
  createdAt: tab.createdAt,
1800
2008
  startedAt: tab.rpc.startedAt,
1801
2009
  pid: tab.rpc.child?.pid,
1802
- running: !!tab.rpc.child && tab.rpc.child.exitCode === null,
2010
+ running: tab.rpc.isRunning(),
1803
2011
  command: tab.rpc.displayCommand,
1804
2012
  clientCount: tab.sseClients.size,
1805
2013
  pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length,
@@ -1968,10 +2176,296 @@ async function restartTabRpc(tab, reason = "reload") {
1968
2176
  return tab;
1969
2177
  }
1970
2178
 
2179
+ function rpcUnavailableMessage(tab) {
2180
+ return `Pi RPC process for ${tab?.title || "terminal"} is not running`;
2181
+ }
2182
+
2183
+ function fallbackRpcResponse(tab, command, error) {
2184
+ const message = sanitizeError(error) || rpcUnavailableMessage(tab);
2185
+ const base = { type: "response", command: command.type, success: true, rpcRunning: false, error: message };
2186
+ switch (command.type) {
2187
+ case "get_state":
2188
+ return {
2189
+ ...base,
2190
+ data: {
2191
+ model: null,
2192
+ thinkingLevel: "off",
2193
+ isStreaming: false,
2194
+ isCompacting: false,
2195
+ steeringMode: "one-at-a-time",
2196
+ followUpMode: "one-at-a-time",
2197
+ sessionFile: tab?.sessionFile,
2198
+ sessionId: tab?.id,
2199
+ sessionName: tab?.title,
2200
+ autoCompactionEnabled: false,
2201
+ messageCount: 0,
2202
+ pendingMessageCount: 0,
2203
+ rpcRunning: false,
2204
+ rpcError: message,
2205
+ },
2206
+ };
2207
+ case "get_messages":
2208
+ return { ...base, data: { messages: [] } };
2209
+ case "get_available_models":
2210
+ return { ...base, data: { models: [] } };
2211
+ case "get_session_stats":
2212
+ return { ...base, data: null };
2213
+ case "get_last_assistant_text":
2214
+ return { ...base, data: { text: "" } };
2215
+ default:
2216
+ return { ...base, success: false, error: message };
2217
+ }
2218
+ }
2219
+
2220
+ async function safeRpcResponse(tab, command, timeoutMs = REQUEST_TIMEOUT_MS) {
2221
+ try {
2222
+ return await tab.rpc.send(command, timeoutMs);
2223
+ } catch (error) {
2224
+ const message = sanitizeError(error);
2225
+ if (/Pi RPC process is not running/i.test(message)) return fallbackRpcResponse(tab, command, error);
2226
+ throw error;
2227
+ }
2228
+ }
2229
+
1971
2230
  async function getCommandData(tab) {
1972
- const response = await tab.rpc.send({ type: "get_commands" });
1973
- if (response.success === false) throw makeHttpError(400, response.error || "failed to load commands");
1974
- return { commands: [...NATIVE_SLASH_COMMANDS, ...(response.data?.commands || [])] };
2231
+ try {
2232
+ const response = await tab.rpc.send({ type: "get_commands" });
2233
+ if (response.success === false) throw makeHttpError(400, response.error || "failed to load commands");
2234
+ return { commands: [...NATIVE_SLASH_COMMANDS, ...(response.data?.commands || [])], rpcRunning: true };
2235
+ } catch (error) {
2236
+ const message = sanitizeError(error);
2237
+ if (!/Pi RPC process is not running/i.test(message)) throw error;
2238
+ return { commands: [...NATIVE_SLASH_COMMANDS], rpcRunning: false, error: message };
2239
+ }
2240
+ }
2241
+
2242
+ function resolveCliPath(value) {
2243
+ const text = String(value || "").trim();
2244
+ if (!text) return "";
2245
+ return path.isAbsolute(text) ? text : path.resolve(options.cwd, text);
2246
+ }
2247
+
2248
+ function resolveTabPath(tab, value) {
2249
+ const text = String(value || "").trim();
2250
+ if (!text) return "";
2251
+ return path.isAbsolute(text) ? text : path.resolve(tab?.cwd || options.cwd, text);
2252
+ }
2253
+
2254
+ function configuredSessionDir() {
2255
+ for (let index = 0; index < options.piArgs.length; index++) {
2256
+ const arg = options.piArgs[index];
2257
+ if (arg === "--session-dir" && options.piArgs[index + 1]) return resolveCliPath(options.piArgs[index + 1]);
2258
+ if (arg.startsWith("--session-dir=")) return resolveCliPath(arg.slice("--session-dir=".length));
2259
+ }
2260
+ return undefined;
2261
+ }
2262
+
2263
+ function requirePersistentSessions() {
2264
+ if (options.noSession) throw makeHttpError(400, "Session selectors are unavailable when Web UI was started with --no-session.");
2265
+ }
2266
+
2267
+ function isoDate(value) {
2268
+ const date = value instanceof Date ? value : new Date(value || 0);
2269
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
2270
+ }
2271
+
2272
+ function normalizeSessionInfo(info, currentSessionFile) {
2273
+ const sessionPath = String(info.path || "");
2274
+ return {
2275
+ path: sessionPath,
2276
+ id: String(info.id || ""),
2277
+ name: info.name || undefined,
2278
+ cwd: String(info.cwd || ""),
2279
+ created: isoDate(info.created),
2280
+ modified: isoDate(info.modified),
2281
+ messageCount: Number.isFinite(info.messageCount) ? info.messageCount : 0,
2282
+ firstMessage: truncateStatusText(info.firstMessage || "(no messages)", 220),
2283
+ parentSessionPath: info.parentSessionPath || undefined,
2284
+ current: !!currentSessionFile && path.resolve(sessionPath) === path.resolve(currentSessionFile),
2285
+ };
2286
+ }
2287
+
2288
+ async function currentSessionState(tab) {
2289
+ const response = await safeRpcResponse(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
2290
+ if (response.success === false) throw makeHttpError(400, response.error || "failed to load current session state");
2291
+ rememberTabState(tab, response.data);
2292
+ return response.data || {};
2293
+ }
2294
+
2295
+ async function getSessionSelectorData(tab, scope = "current") {
2296
+ requirePersistentSessions();
2297
+ const state = await currentSessionState(tab).catch(() => tab.lastState || {});
2298
+ const sessionDir = configuredSessionDir();
2299
+ const listAll = String(scope || "current").toLowerCase() === "all";
2300
+ const sessions = listAll ? await SessionManager.listAll(sessionDir) : await SessionManager.list(tab.cwd, sessionDir);
2301
+ return {
2302
+ scope: listAll ? "all" : "current",
2303
+ sessionDir: sessionDir || undefined,
2304
+ currentSessionFile: state.sessionFile || tabRestorableSessionFile(tab),
2305
+ sessions: sessions.slice(0, SESSION_SELECTOR_LIMIT).map((info) => normalizeSessionInfo(info, state.sessionFile || tabRestorableSessionFile(tab))),
2306
+ limited: sessions.length > SESSION_SELECTOR_LIMIT,
2307
+ };
2308
+ }
2309
+
2310
+ function extractSessionTextContent(content) {
2311
+ if (typeof content === "string") return content;
2312
+ if (!Array.isArray(content)) return "";
2313
+ return content
2314
+ .map((part) => {
2315
+ if (typeof part === "string") return part;
2316
+ if (part?.type === "text" && typeof part.text === "string") return part.text;
2317
+ if (part?.type === "toolCall") return `[tool call: ${part.toolName || "tool"}]`;
2318
+ if (part?.type === "thinking") return "[thinking]";
2319
+ if (part?.type === "image") return "[image]";
2320
+ return "";
2321
+ })
2322
+ .filter(Boolean)
2323
+ .join(" ");
2324
+ }
2325
+
2326
+ function sessionTreeEntryLabel(entry) {
2327
+ if (!entry || typeof entry !== "object") return "entry";
2328
+ if (entry.type === "message") return entry.message?.role || "message";
2329
+ if (entry.type === "branch_summary") return "branch summary";
2330
+ if (entry.type === "compaction") return "compaction";
2331
+ if (entry.type === "model_change") return "model";
2332
+ if (entry.type === "thinking_level_change") return "thinking";
2333
+ if (entry.type === "custom_message") return entry.customType || "custom";
2334
+ return entry.type || "entry";
2335
+ }
2336
+
2337
+ function sessionTreeEntryText(entry) {
2338
+ if (!entry || typeof entry !== "object") return "";
2339
+ if (entry.type === "message") return extractSessionTextContent(entry.message?.content);
2340
+ if (entry.type === "custom_message") return extractSessionTextContent(entry.content);
2341
+ if (entry.type === "branch_summary") return entry.summary || "branch summary";
2342
+ if (entry.type === "compaction") return entry.summary || "compaction summary";
2343
+ if (entry.type === "model_change") return [entry.provider, entry.modelId].filter(Boolean).join("/");
2344
+ if (entry.type === "thinking_level_change") return entry.thinkingLevel || "";
2345
+ return "";
2346
+ }
2347
+
2348
+ function flattenSessionTree(nodes, { depth = 0, leafId, result = [] } = {}) {
2349
+ for (const node of nodes || []) {
2350
+ const entry = node.entry || {};
2351
+ result.push({
2352
+ id: entry.id,
2353
+ parentId: entry.parentId ?? null,
2354
+ depth,
2355
+ type: entry.type || "entry",
2356
+ role: entry.message?.role || undefined,
2357
+ label: node.label || undefined,
2358
+ timestamp: entry.timestamp || undefined,
2359
+ title: sessionTreeEntryLabel(entry),
2360
+ text: truncateStatusText(sessionTreeEntryText(entry), TREE_SELECTOR_TEXT_LIMIT),
2361
+ childCount: Array.isArray(node.children) ? node.children.length : 0,
2362
+ currentLeaf: !!leafId && entry.id === leafId,
2363
+ });
2364
+ flattenSessionTree(node.children || [], { depth: depth + 1, leafId, result });
2365
+ }
2366
+ return result;
2367
+ }
2368
+
2369
+ async function getSessionTreeData(tab) {
2370
+ requirePersistentSessions();
2371
+ const state = await currentSessionState(tab).catch(() => tab.lastState || {});
2372
+ const sessionFile = state.sessionFile || tabRestorableSessionFile(tab);
2373
+ if (!sessionFile) throw makeHttpError(400, "No persisted session file is available for /tree.");
2374
+ const manager = SessionManager.open(sessionFile, configuredSessionDir(), tab.cwd);
2375
+ const leafId = manager.getLeafId();
2376
+ return {
2377
+ sessionFile: manager.getSessionFile(),
2378
+ sessionId: manager.getSessionId(),
2379
+ cwd: manager.getCwd(),
2380
+ leafId,
2381
+ nodes: flattenSessionTree(manager.getTree(), { leafId }),
2382
+ };
2383
+ }
2384
+
2385
+ async function getForkMessagesData(tab) {
2386
+ const response = await safeRpcResponse(tab, { type: "get_fork_messages" });
2387
+ if (response.success === false) throw makeHttpError(400, response.error || "failed to load fork points");
2388
+ return { messages: Array.isArray(response.data?.messages) ? response.data.messages : [] };
2389
+ }
2390
+
2391
+ async function requireIdleForSessionAction(tab, actionLabel) {
2392
+ const state = await currentSessionState(tab);
2393
+ if (state.isStreaming || state.isCompacting) throw makeHttpError(409, `Wait for the current agent run or compaction to finish before ${actionLabel}.`);
2394
+ }
2395
+
2396
+ async function runForkCommand(tab, entryId) {
2397
+ await requireIdleForSessionAction(tab, "forking the session");
2398
+ const targetEntryId = String(entryId || "").trim();
2399
+ if (!targetEntryId) throw makeHttpError(400, "entryId is required");
2400
+ const response = await tab.rpc.send({ type: "fork", entryId: targetEntryId });
2401
+ if (response.success === false) return response;
2402
+ const state = await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
2403
+ if (state.ok) rememberTabState(tab, state.data);
2404
+ return rpcSuccess("fork", {
2405
+ message: response.data?.cancelled ? "Fork cancelled." : "Forked the current session.",
2406
+ text: response.data?.text || "",
2407
+ result: response.data,
2408
+ tab: tabMeta(tab),
2409
+ });
2410
+ }
2411
+
2412
+ async function runCloneCommand(tab) {
2413
+ await requireIdleForSessionAction(tab, "cloning the session");
2414
+ const response = await tab.rpc.send({ type: "clone" });
2415
+ if (response.success === false) return response;
2416
+ const state = await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
2417
+ if (state.ok) rememberTabState(tab, state.data);
2418
+ return rpcSuccess("clone", {
2419
+ message: response.data?.cancelled ? "Clone cancelled." : "Cloned the current session.",
2420
+ result: response.data,
2421
+ tab: tabMeta(tab),
2422
+ });
2423
+ }
2424
+
2425
+ async function switchTabSession(tab, sessionPath) {
2426
+ requirePersistentSessions();
2427
+ await requireIdleForSessionAction(tab, "switching sessions");
2428
+ const targetPath = resolveTabPath(tab, sessionPath);
2429
+ if (!targetPath) throw makeHttpError(400, "sessionPath is required");
2430
+ if (!targetPath.endsWith(".jsonl")) throw makeHttpError(400, "sessionPath must point to a .jsonl session file");
2431
+ const targetStats = await stat(targetPath).catch(() => null);
2432
+ if (!targetStats?.isFile()) throw makeHttpError(404, `Session file not found: ${targetPath}`);
2433
+ const manager = SessionManager.open(targetPath, configuredSessionDir());
2434
+ const response = await tab.rpc.send({ type: "switch_session", sessionPath: manager.getSessionFile() });
2435
+ if (response.success === false) return response;
2436
+ if (!response.data?.cancelled) {
2437
+ tab.cwd = manager.getCwd();
2438
+ const state = await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
2439
+ if (state.ok) rememberTabState(tab, state.data);
2440
+ }
2441
+ return rpcSuccess("switch_session", {
2442
+ message: response.data?.cancelled ? "Resume cancelled." : "Resumed selected session.",
2443
+ result: response.data,
2444
+ tab: tabMeta(tab),
2445
+ });
2446
+ }
2447
+
2448
+ async function navigateSessionTree(tab, body) {
2449
+ requirePersistentSessions();
2450
+ await requireIdleForSessionAction(tab, "navigating the session tree");
2451
+ const entryId = String(body.entryId || body.targetId || "").trim();
2452
+ if (!entryId) throw makeHttpError(400, "entryId is required");
2453
+ const payload = {
2454
+ entryId,
2455
+ summarize: body.summarize === true,
2456
+ customInstructions: typeof body.customInstructions === "string" ? body.customInstructions : undefined,
2457
+ replaceInstructions: body.replaceInstructions === true,
2458
+ label: typeof body.label === "string" ? body.label : undefined,
2459
+ };
2460
+ const response = await tab.rpc.send({ type: "prompt", message: `/webui-tree-navigate ${JSON.stringify(payload)}` });
2461
+ if (response.success === false) return response;
2462
+ const state = await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
2463
+ if (state.ok) rememberTabState(tab, state.data);
2464
+ return rpcSuccess("tree", {
2465
+ message: "Navigated the session tree.",
2466
+ result: response.data,
2467
+ tab: tabMeta(tab),
2468
+ });
1975
2469
  }
1976
2470
 
1977
2471
  function formatSessionOutput(tab, state, stats) {
@@ -2047,8 +2541,8 @@ async function handleNativeSlashCommand(tab, body) {
2047
2541
  return rpcSuccess("native_slash_command", { command: "hotkeys", message: webuiHotkeysOutput() });
2048
2542
  }
2049
2543
  case "clone": {
2050
- const response = await tab.rpc.send({ type: "clone" });
2051
- return response.success === false ? response : rpcSuccess("native_slash_command", { command: "clone", message: "Cloned the current session.", result: response.data });
2544
+ const response = await runCloneCommand(tab);
2545
+ return response.success === false ? response : rpcSuccess("native_slash_command", { command: "clone", message: response.data?.message || "Cloned the current session.", result: response.data?.result });
2052
2546
  }
2053
2547
  default:
2054
2548
  throw makeHttpError(400, `/${parsed.name} is a native Pi TUI command, but this Web UI cannot run that interactive command yet.`);
@@ -2080,6 +2574,23 @@ async function closeTab(id) {
2080
2574
  return tab;
2081
2575
  }
2082
2576
 
2577
+ async function closeTabs(ids) {
2578
+ const uniqueIds = [...new Set((Array.isArray(ids) ? ids : []).map((id) => String(id || "").trim()).filter(Boolean))];
2579
+ const targetTabs = uniqueIds.map((id) => tabs.get(id)).filter(Boolean);
2580
+ if (!targetTabs.length) return [];
2581
+
2582
+ if (targetTabs.length >= tabs.size) {
2583
+ await createTab({ cwd: targetTabs[0]?.cwd || options.cwd });
2584
+ }
2585
+
2586
+ const closed = [];
2587
+ for (const tab of targetTabs) {
2588
+ if (!tabs.has(tab.id)) continue;
2589
+ closed.push(await closeTab(tab.id));
2590
+ }
2591
+ return closed;
2592
+ }
2593
+
2083
2594
  function requestedTabId(req, url, body) {
2084
2595
  const header = req.headers["x-pi-webui-tab"];
2085
2596
  const headerValue = Array.isArray(header) ? header[0] : header;
@@ -2332,13 +2843,13 @@ async function webuiStatus({ detailed = false, eventLimit = 40 } = {}) {
2332
2843
  piPid: tab?.rpc.child?.pid,
2333
2844
  piRunning: !!tab?.rpc.child && tab.rpc.child.exitCode === null,
2334
2845
  tabs: statusTabs,
2335
- restorableTabs: mergeRestorableTabDescriptors(statusTabs, closedRestorableTabs),
2846
+ restorableTabs: mergeRestorableTabDescriptors(statusTabs),
2336
2847
  };
2337
2848
 
2338
2849
  if (detailed) {
2339
2850
  const detailedTabs = await Promise.all([...tabs.values()].map((item) => tabStatusDetails(item)));
2340
2851
  data.tabs = detailedTabs;
2341
- data.restorableTabs = mergeRestorableTabDescriptors(detailedTabs, closedRestorableTabs);
2852
+ data.restorableTabs = mergeRestorableTabDescriptors(detailedTabs);
2342
2853
  data.closedTabs = closedRestorableTabs.slice();
2343
2854
  data.events = latestEvents(eventLimit);
2344
2855
  }
@@ -2362,6 +2873,13 @@ const server = createServer(async (req, res) => {
2362
2873
  return;
2363
2874
  }
2364
2875
 
2876
+ if (url.pathname === "/api/tabs/close" && req.method === "POST") {
2877
+ const body = await readJsonBody(req);
2878
+ const closed = await closeTabs(body.ids || body.tabIds || []);
2879
+ sendJson(res, 200, { ok: true, data: { closedIds: closed.map((tab) => tab.id), tabs: listTabs(), activeTabId: firstTab()?.id || null } });
2880
+ return;
2881
+ }
2882
+
2365
2883
  if (url.pathname.startsWith("/api/tabs/") && req.method === "PATCH") {
2366
2884
  const id = decodeURIComponent(url.pathname.slice("/api/tabs/".length));
2367
2885
  const body = await readJsonBody(req);
@@ -2505,12 +3023,76 @@ const server = createServer(async (req, res) => {
2505
3023
  return;
2506
3024
  }
2507
3025
 
3026
+ if (url.pathname === "/api/attachments" && req.method === "POST") {
3027
+ const body = await readJsonBody(req, { limitBytes: requestBodyLimitForPath(url.pathname) });
3028
+ sendJson(res, 201, { ok: true, data: await saveUploadedAttachments(body) });
3029
+ return;
3030
+ }
3031
+
2508
3032
  if (url.pathname === "/api/scoped-models" && req.method === "GET") {
2509
3033
  const tab = getRequestedTab(req, url);
2510
3034
  sendJson(res, 200, { ok: true, data: await getScopedModelData(tab) });
2511
3035
  return;
2512
3036
  }
2513
3037
 
3038
+ if (url.pathname === "/api/fork-messages" && req.method === "GET") {
3039
+ const tab = getRequestedTab(req, url);
3040
+ sendJson(res, 200, { ok: true, data: await getForkMessagesData(tab) });
3041
+ return;
3042
+ }
3043
+
3044
+ if (url.pathname === "/api/sessions" && req.method === "GET") {
3045
+ const tab = getRequestedTab(req, url);
3046
+ sendJson(res, 200, { ok: true, data: await getSessionSelectorData(tab, url.searchParams.get("scope") || "current") });
3047
+ return;
3048
+ }
3049
+
3050
+ if (url.pathname === "/api/session-tree" && req.method === "GET") {
3051
+ const tab = getRequestedTab(req, url);
3052
+ sendJson(res, 200, { ok: true, data: await getSessionTreeData(tab) });
3053
+ return;
3054
+ }
3055
+
3056
+ if (url.pathname === "/api/fork" && req.method === "POST") {
3057
+ const body = await readJsonBody(req);
3058
+ const tab = getRequestedTab(req, url, body);
3059
+ const response = await runForkCommand(tab, body.entryId);
3060
+ sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
3061
+ return;
3062
+ }
3063
+
3064
+ if (url.pathname === "/api/clone" && req.method === "POST") {
3065
+ const body = await readJsonBody(req);
3066
+ const tab = getRequestedTab(req, url, body);
3067
+ const response = await runCloneCommand(tab);
3068
+ sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
3069
+ return;
3070
+ }
3071
+
3072
+ if (url.pathname === "/api/switch-session" && req.method === "POST") {
3073
+ const body = await readJsonBody(req);
3074
+ const tab = getRequestedTab(req, url, body);
3075
+ const response = await switchTabSession(tab, body.sessionPath || body.path);
3076
+ sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
3077
+ return;
3078
+ }
3079
+
3080
+ if (url.pathname === "/api/tree-navigate" && req.method === "POST") {
3081
+ const body = await readJsonBody(req);
3082
+ const tab = getRequestedTab(req, url, body);
3083
+ const response = await navigateSessionTree(tab, body);
3084
+ sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
3085
+ return;
3086
+ }
3087
+
3088
+ if (url.pathname === "/api/optional-feature-install" && req.method === "POST") {
3089
+ if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Installing optional Web UI features is only allowed from localhost");
3090
+ const body = await readJsonBody(req);
3091
+ const data = await installOptionalFeaturePackage(String(body.featureId || ""));
3092
+ sendJson(res, 200, { ok: true, data });
3093
+ return;
3094
+ }
3095
+
2514
3096
  if (url.pathname === "/api/commands" && req.method === "GET") {
2515
3097
  const tab = getRequestedTab(req, url);
2516
3098
  sendJson(res, 200, { type: "response", command: "get_commands", success: true, data: await getCommandData(tab) });
@@ -2526,7 +3108,7 @@ const server = createServer(async (req, res) => {
2526
3108
  }
2527
3109
 
2528
3110
  if (url.pathname === "/api/prompt" && req.method === "POST") {
2529
- const body = await readJsonBody(req);
3111
+ const body = await readJsonBody(req, { limitBytes: requestBodyLimitForPath(url.pathname) });
2530
3112
  const tab = getRequestedTab(req, url, body);
2531
3113
  const nativeResponse = await handleNativeSlashCommand(tab, body);
2532
3114
  if (nativeResponse) {
@@ -2558,7 +3140,7 @@ const server = createServer(async (req, res) => {
2558
3140
  const getCommand = req.method === "GET" ? commandFromGet(url.pathname) : undefined;
2559
3141
  if (getCommand) {
2560
3142
  const tab = getRequestedTab(req, url);
2561
- const response = await tab.rpc.send(getCommand);
3143
+ const response = await safeRpcResponse(tab, getCommand);
2562
3144
  sendJson(res, response.success === false ? 400 : 200, response);
2563
3145
  return;
2564
3146
  }
@@ -2586,7 +3168,7 @@ const server = createServer(async (req, res) => {
2586
3168
  }
2587
3169
 
2588
3170
  if (req.method === "POST") {
2589
- const body = await readJsonBody(req);
3171
+ const body = await readJsonBody(req, { limitBytes: requestBodyLimitForPath(url.pathname) });
2590
3172
  const command = commandFromPost(url.pathname, body);
2591
3173
  if (command) {
2592
3174
  const tab = getRequestedTab(req, url, body);