@firstpick/pi-package-webui 0.1.0 → 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/bin/pi-webui.mjs CHANGED
@@ -2,7 +2,8 @@
2
2
  import { spawn } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { createServer } from "node:http";
5
- import { access, readFile, stat } from "node:fs/promises";
5
+ import { access, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
6
+ import { homedir, networkInterfaces } from "node:os";
6
7
  import path from "node:path";
7
8
  import { StringDecoder } from "node:string_decoder";
8
9
  import { fileURLToPath } from "node:url";
@@ -16,14 +17,44 @@ const DEFAULT_HOST = "127.0.0.1";
16
17
  const DEFAULT_PORT = 31415;
17
18
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
18
19
  const BODY_LIMIT_BYTES = 1024 * 1024;
20
+ const EVENT_HISTORY_LIMIT = 200;
21
+ const STATUS_RPC_TIMEOUT_MS = 1_800;
22
+ const FAST_PICK_LIMIT = 30;
19
23
 
20
24
  const MIME_TYPES = new Map([
21
25
  [".html", "text/html; charset=utf-8"],
22
26
  [".js", "text/javascript; charset=utf-8"],
23
27
  [".css", "text/css; charset=utf-8"],
24
28
  [".svg", "image/svg+xml"],
29
+ [".png", "image/png"],
30
+ [".webmanifest", "application/manifest+json; charset=utf-8"],
25
31
  ]);
26
32
 
33
+ const NATIVE_SLASH_COMMANDS = [
34
+ { name: "settings", description: "Open settings menu" },
35
+ { name: "model", description: "Select model (opens selector UI)" },
36
+ { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" },
37
+ { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" },
38
+ { name: "import", description: "Import and resume a session from a JSONL file" },
39
+ { name: "share", description: "Share session as a secret GitHub gist" },
40
+ { name: "copy", description: "Copy last agent message to clipboard" },
41
+ { name: "name", description: "Set session display name" },
42
+ { name: "session", description: "Show session info and stats" },
43
+ { name: "changelog", description: "Show changelog entries" },
44
+ { name: "hotkeys", description: "Show all keyboard shortcuts" },
45
+ { name: "fork", description: "Create a new fork from a previous user message" },
46
+ { name: "clone", description: "Duplicate the current session at the current position" },
47
+ { name: "tree", description: "Navigate session tree (switch branches)" },
48
+ { name: "login", description: "Configure provider authentication" },
49
+ { name: "logout", description: "Remove provider authentication" },
50
+ { name: "new", description: "Start a new session" },
51
+ { name: "compact", description: "Manually compact the session context" },
52
+ { name: "resume", description: "Resume a different session" },
53
+ { name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes" },
54
+ { name: "quit", description: "Quit Pi" },
55
+ ].map((command) => ({ ...command, source: "native", location: "Pi" }));
56
+ const NATIVE_SLASH_COMMAND_NAMES = new Set(NATIVE_SLASH_COMMANDS.map((command) => command.name));
57
+
27
58
  function usage() {
28
59
  console.log(`pi-webui ${packageJson.version}
29
60
 
@@ -137,6 +168,14 @@ function isLocalHost(host) {
137
168
  return host === "localhost" || host === "::1" || host === "[::1]" || host.startsWith("127.");
138
169
  }
139
170
 
171
+ function formatUrlHost(host) {
172
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
173
+ }
174
+
175
+ function isLocalAddress(address = "") {
176
+ return address === "::1" || address.startsWith("127.") || address === "::ffff:127.0.0.1" || address.startsWith("::ffff:127.");
177
+ }
178
+
140
179
  function sanitizeError(error) {
141
180
  if (!error) return "Unknown error";
142
181
  if (typeof error === "string") return error;
@@ -315,6 +354,12 @@ function sendJson(res, statusCode, payload) {
315
354
  res.end(body);
316
355
  }
317
356
 
357
+ function makeHttpError(statusCode, message) {
358
+ const error = new Error(message);
359
+ error.statusCode = statusCode;
360
+ return error;
361
+ }
362
+
318
363
  function sendError(res, statusCode, error) {
319
364
  sendJson(res, statusCode, { ok: false, error: sanitizeError(error) });
320
365
  }
@@ -337,6 +382,51 @@ function sendSse(res, event) {
337
382
  res.write(`data: ${JSON.stringify(event)}\n\n`);
338
383
  }
339
384
 
385
+ function rpcSuccess(command, data = {}) {
386
+ return { type: "response", command, success: true, data };
387
+ }
388
+
389
+ function parseSlashCommand(message) {
390
+ const text = String(message || "").trim();
391
+ if (!text.startsWith("/") || text.includes("\n")) return undefined;
392
+ const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/);
393
+ if (!match) return undefined;
394
+ const name = match[1].toLowerCase();
395
+ if (!NATIVE_SLASH_COMMAND_NAMES.has(name)) return undefined;
396
+ return { name, args: (match[2] || "").trim(), text };
397
+ }
398
+
399
+ const eventHistory = [];
400
+
401
+ function truncateStatusText(value, maxLength = 240) {
402
+ const text = String(value || "").replace(/\s+/g, " ").trim();
403
+ return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
404
+ }
405
+
406
+ function statusEventSummary(event) {
407
+ const summary = {
408
+ timestamp: new Date().toISOString(),
409
+ type: String(event?.type || "event"),
410
+ };
411
+ for (const key of ["tabId", "tabTitle", "pid", "cwd", "code", "signal", "command", "queueLength", "pendingMessageCount"]) {
412
+ if (event?.[key] !== undefined) summary[key] = event[key];
413
+ }
414
+ if (event?.assistantMessageEvent?.type) summary.updateType = event.assistantMessageEvent.type;
415
+ if (event?.message?.role) summary.messageRole = event.message.role;
416
+ if (event?.error) summary.error = truncateStatusText(event.error);
417
+ if (event?.text && summary.type === "pi_stderr") summary.text = truncateStatusText(event.text);
418
+ return summary;
419
+ }
420
+
421
+ function recordEvent(event) {
422
+ eventHistory.push(statusEventSummary(event));
423
+ if (eventHistory.length > EVENT_HISTORY_LIMIT) eventHistory.splice(0, eventHistory.length - EVENT_HISTORY_LIMIT);
424
+ }
425
+
426
+ function latestEvents(limit = 40) {
427
+ return eventHistory.slice(-Math.max(0, Math.min(EVENT_HISTORY_LIMIT, limit)));
428
+ }
429
+
340
430
  function runCommand(command, args, { cwd, timeoutMs = 2000 } = {}) {
341
431
  return new Promise((resolve) => {
342
432
  const child = spawn(command, args, {
@@ -379,6 +469,215 @@ function displayPath(cwd) {
379
469
  return normalized;
380
470
  }
381
471
 
472
+ function expandUserPath(value) {
473
+ const input = String(value || "").trim();
474
+ if (input === "~") {
475
+ const home = process.env.HOME || process.env.USERPROFILE;
476
+ if (!home) throw makeHttpError(400, "Cannot expand ~ because no home directory is configured");
477
+ return home;
478
+ }
479
+ if (input.startsWith("~/") || input.startsWith("~\\")) {
480
+ const home = process.env.HOME || process.env.USERPROFILE;
481
+ if (!home) throw makeHttpError(400, "Cannot expand ~ because no home directory is configured");
482
+ return path.join(home, input.slice(2));
483
+ }
484
+ return input;
485
+ }
486
+
487
+ async function resolveCwd(value, baseCwd = options.cwd) {
488
+ const input = expandUserPath(value);
489
+ if (!input) throw makeHttpError(400, "cwd is required");
490
+ const cwd = path.resolve(baseCwd, input);
491
+ let info;
492
+ try {
493
+ info = await stat(cwd);
494
+ } catch {
495
+ throw makeHttpError(400, `cwd does not exist: ${cwd}`);
496
+ }
497
+ if (!info.isDirectory()) throw makeHttpError(400, `cwd is not a directory: ${cwd}`);
498
+ return cwd;
499
+ }
500
+
501
+ function uniquePathItems(items) {
502
+ const seen = new Set();
503
+ const result = [];
504
+ for (const item of items) {
505
+ if (!item?.cwd || seen.has(item.cwd)) continue;
506
+ seen.add(item.cwd);
507
+ result.push(item);
508
+ }
509
+ return result;
510
+ }
511
+
512
+ function normalizePathFastPicks(value) {
513
+ const items = Array.isArray(value) ? value : Array.isArray(value?.picks) ? value.picks : [];
514
+ const seen = new Set();
515
+ const picks = [];
516
+ for (const item of items) {
517
+ const rawCwd = typeof item === "string" ? item : item?.cwd;
518
+ if (!rawCwd) continue;
519
+ let cwd;
520
+ try {
521
+ cwd = path.resolve(options.cwd, expandUserPath(rawCwd));
522
+ } catch {
523
+ continue;
524
+ }
525
+ if (!cwd || seen.has(cwd)) continue;
526
+ seen.add(cwd);
527
+ const displayCwd = String(typeof item === "object" && item?.displayCwd ? item.displayCwd : displayPath(cwd)).slice(0, 4096);
528
+ picks.push({ cwd, displayCwd });
529
+ if (picks.length >= FAST_PICK_LIMIT) break;
530
+ }
531
+ return picks;
532
+ }
533
+
534
+ function fastPicksStorageFile() {
535
+ if (process.env.PI_WEBUI_FAST_PICKS_FILE) return path.resolve(expandUserPath(process.env.PI_WEBUI_FAST_PICKS_FILE));
536
+ const stateRoot = process.env.XDG_STATE_HOME || path.join(homedir(), ".local", "state");
537
+ return path.join(stateRoot, "pi-webui", "fast-picks.json");
538
+ }
539
+
540
+ let pathFastPicksCache = null;
541
+
542
+ async function readPathFastPicks() {
543
+ if (pathFastPicksCache) return pathFastPicksCache;
544
+ try {
545
+ const parsed = JSON.parse(await readFile(fastPicksStorageFile(), "utf8"));
546
+ pathFastPicksCache = normalizePathFastPicks(parsed);
547
+ } catch (error) {
548
+ if (error?.code !== "ENOENT") console.warn(`failed to read path fast picks: ${sanitizeError(error)}`);
549
+ pathFastPicksCache = [];
550
+ }
551
+ return pathFastPicksCache;
552
+ }
553
+
554
+ async function writePathFastPicks(picks) {
555
+ const normalized = normalizePathFastPicks(picks);
556
+ const storageFile = fastPicksStorageFile();
557
+ await mkdir(path.dirname(storageFile), { recursive: true });
558
+ const tmpFile = `${storageFile}.${process.pid}.${Date.now()}.tmp`;
559
+ await writeFile(tmpFile, `${JSON.stringify({ version: 1, picks: normalized }, null, 2)}\n`, { mode: 0o600 });
560
+ await rename(tmpFile, storageFile);
561
+ pathFastPicksCache = normalized;
562
+ return normalized;
563
+ }
564
+
565
+ function parseCliScopedModelPatterns() {
566
+ for (let index = 0; index < options.piArgs.length; index++) {
567
+ const arg = options.piArgs[index];
568
+ if (arg === "--models" && options.piArgs[index + 1]) return options.piArgs[index + 1].split(",").map((item) => item.trim()).filter(Boolean);
569
+ if (arg.startsWith("--models=")) return arg.slice("--models=".length).split(",").map((item) => item.trim()).filter(Boolean);
570
+ }
571
+ return undefined;
572
+ }
573
+
574
+ async function readJsonFileIfExists(filePath) {
575
+ try {
576
+ return JSON.parse(await readFile(filePath, "utf8"));
577
+ } catch (error) {
578
+ if (error?.code === "ENOENT") return undefined;
579
+ console.warn(`failed to read ${filePath}: ${sanitizeError(error)}`);
580
+ return undefined;
581
+ }
582
+ }
583
+
584
+ async function configuredScopedModelPatterns(cwd = options.cwd) {
585
+ const cliPatterns = parseCliScopedModelPatterns();
586
+ if (cliPatterns !== undefined) return { patterns: cliPatterns, source: "cli" };
587
+
588
+ const agentDir = process.env.PI_CODING_AGENT_DIR ? path.resolve(expandUserPath(process.env.PI_CODING_AGENT_DIR)) : path.join(homedir(), ".pi", "agent");
589
+ const [globalSettings, projectSettings] = await Promise.all([
590
+ readJsonFileIfExists(path.join(agentDir, "settings.json")),
591
+ readJsonFileIfExists(path.join(cwd, ".pi", "settings.json")),
592
+ ]);
593
+
594
+ if (Array.isArray(projectSettings?.enabledModels)) return { patterns: projectSettings.enabledModels, source: "project" };
595
+ if (Array.isArray(globalSettings?.enabledModels)) return { patterns: globalSettings.enabledModels, source: "global" };
596
+ return { patterns: [], source: "none" };
597
+ }
598
+
599
+ function stripThinkingSuffix(pattern) {
600
+ const text = String(pattern || "").trim();
601
+ const slashIndex = text.indexOf("/");
602
+ const colonIndex = text.lastIndexOf(":");
603
+ if (colonIndex > (slashIndex === -1 ? -1 : slashIndex)) return text.slice(0, colonIndex);
604
+ return text;
605
+ }
606
+
607
+ function globToRegExp(pattern) {
608
+ const escaped = pattern.replace(/[.+^${}()|\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
609
+ return new RegExp(`^${escaped}$`, "i");
610
+ }
611
+
612
+ function modelMatchesPattern(model, pattern) {
613
+ const clean = stripThinkingSuffix(pattern).toLowerCase();
614
+ if (!clean) return false;
615
+ const full = `${model.provider}/${model.id}`.toLowerCase();
616
+ const id = String(model.id || "").toLowerCase();
617
+ if (/[?*\[]/.test(clean)) return globToRegExp(clean).test(full) || globToRegExp(clean).test(id);
618
+ return full === clean || id === clean || full.includes(clean) || id.includes(clean);
619
+ }
620
+
621
+ function resolveScopedModelsFromPatterns(patterns, models) {
622
+ const scoped = [];
623
+ const seen = new Set();
624
+ for (const pattern of patterns || []) {
625
+ for (const model of models || []) {
626
+ const key = `${model.provider}/${model.id}`;
627
+ if (seen.has(key) || !modelMatchesPattern(model, pattern)) continue;
628
+ seen.add(key);
629
+ scoped.push(model);
630
+ }
631
+ }
632
+ return scoped;
633
+ }
634
+
635
+ async function getScopedModelData(tab) {
636
+ const { patterns, source } = await configuredScopedModelPatterns(tab.cwd);
637
+ if (!patterns.length) return { models: [], patterns, source };
638
+ const response = await tab.rpc.send({ type: "get_available_models" });
639
+ if (response.success === false) throw makeHttpError(400, response.error || "failed to load available models");
640
+ return { models: resolveScopedModelsFromPatterns(patterns, response.data?.models || []), patterns, source };
641
+ }
642
+
643
+ function pathPickerRoots(activeCwd, viewedCwd) {
644
+ const home = process.env.HOME || process.env.USERPROFILE;
645
+ return uniquePathItems([
646
+ { label: "Tab", cwd: activeCwd, displayCwd: displayPath(activeCwd) },
647
+ { label: "Default", cwd: options.cwd, displayCwd: displayPath(options.cwd) },
648
+ home ? { label: "Home", cwd: home, displayCwd: displayPath(home) } : undefined,
649
+ { label: "Root", cwd: path.parse(viewedCwd || activeCwd || options.cwd).root, displayCwd: path.parse(viewedCwd || activeCwd || options.cwd).root },
650
+ ]);
651
+ }
652
+
653
+ async function getDirectoryPickerData(viewPath, activeCwd) {
654
+ const cwd = await resolveCwd(viewPath || activeCwd, activeCwd);
655
+ let entries;
656
+ try {
657
+ entries = await readdir(cwd, { withFileTypes: true });
658
+ } catch (error) {
659
+ throw makeHttpError(error?.code === "EACCES" ? 403 : 400, `Cannot read directory ${cwd}: ${sanitizeError(error)}`);
660
+ }
661
+
662
+ const directoryEntries = entries
663
+ .filter((entry) => entry.isDirectory())
664
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
665
+ const directories = directoryEntries.slice(0, 500).map((entry) => {
666
+ const entryPath = path.join(cwd, entry.name);
667
+ return { name: entry.name, cwd: entryPath, displayCwd: displayPath(entryPath), hidden: entry.name.startsWith(".") };
668
+ });
669
+ const parent = path.dirname(cwd);
670
+
671
+ return {
672
+ cwd,
673
+ displayCwd: displayPath(cwd),
674
+ parent: parent === cwd ? null : parent,
675
+ roots: pathPickerRoots(activeCwd, cwd),
676
+ directories,
677
+ truncated: directoryEntries.length > directories.length,
678
+ };
679
+ }
680
+
382
681
  async function getWorkspaceInfo(cwd, startedAt) {
383
682
  const info = {
384
683
  cwd,
@@ -517,18 +816,18 @@ function gitWorkflowCommandPayload(result) {
517
816
  };
518
817
  }
519
818
 
520
- async function handleGitWorkflowRequest(pathname, body = {}) {
819
+ async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd) {
521
820
  try {
522
821
  switch (pathname) {
523
822
  case "/api/git-workflow/message":
524
- return { ok: true, data: await readGitWorkflowMessages(options.cwd) };
823
+ return { ok: true, data: await readGitWorkflowMessages(cwd) };
525
824
  case "/api/git-workflow/add":
526
- await getGitRoot(options.cwd);
527
- return gitWorkflowCommandPayload(await runGitWorkflowCommand(["add", "."], { cwd: options.cwd }));
825
+ await getGitRoot(cwd);
826
+ return gitWorkflowCommandPayload(await runGitWorkflowCommand(["add", "."], { cwd }));
528
827
  case "/api/git-workflow/commit": {
529
828
  const variant = String(body.variant || "").trim();
530
829
  if (!["short", "long"].includes(variant)) throw new Error("variant must be 'short' or 'long'");
531
- const messages = await readGitWorkflowMessages(options.cwd);
830
+ const messages = await readGitWorkflowMessages(cwd);
532
831
  if (variant === "short") {
533
832
  const message = messages.short.trim();
534
833
  if (!message) throw new Error(`${messages.shortPath} is empty`);
@@ -538,7 +837,7 @@ async function handleGitWorkflowRequest(pathname, body = {}) {
538
837
  return gitWorkflowCommandPayload(await runGitWorkflowCommand(["commit", "-F", messages.longPath], { cwd: messages.root, label: "git commit -F dev/COMMIT/staged-commit-long.txt" }));
539
838
  }
540
839
  case "/api/git-workflow/push": {
541
- const root = await getGitRoot(options.cwd);
840
+ const root = await getGitRoot(cwd);
542
841
  return gitWorkflowCommandPayload(await runGitWorkflowCommand(["push"], { cwd: root, timeoutMs: 15 * 60 * 1000 }));
543
842
  }
544
843
  case "/api/git-workflow/cancel": {
@@ -557,7 +856,7 @@ async function handleGitWorkflowRequest(pathname, body = {}) {
557
856
  function normalizeStaticPath(urlPath) {
558
857
  if (urlPath === "/") return "index.html";
559
858
  const name = urlPath.startsWith("/") ? urlPath.slice(1) : urlPath;
560
- if (!["index.html", "app.js", "styles.css"].includes(name)) return undefined;
859
+ 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;
561
860
  return name;
562
861
  }
563
862
 
@@ -660,12 +959,18 @@ if (options.version) {
660
959
  process.exit(0);
661
960
  }
662
961
 
663
- const piArgs = ["--mode", "rpc"];
664
- if (options.noSession) piArgs.push("--no-session");
665
- if (options.name) piArgs.push("--name", options.name);
666
- piArgs.push(...options.piArgs);
962
+ function buildPiArgsForTab(tabIndex, title) {
963
+ const args = ["--mode", "rpc"];
964
+ if (options.noSession) args.push("--no-session");
965
+
966
+ const sessionName = tabIndex === 1 ? options.name : title;
967
+ if (sessionName) args.push("--name", sessionName);
968
+
969
+ args.push(...options.piArgs);
970
+ return args;
971
+ }
667
972
 
668
- async function resolvePiCommand() {
973
+ async function resolvePiCommand(piArgs) {
669
974
  if (options.piBinExplicit) {
670
975
  return { command: options.piBin, args: piArgs, displayCommand: `${options.piBin} ${piArgs.join(" ")}` };
671
976
  }
@@ -683,19 +988,456 @@ async function resolvePiCommand() {
683
988
  }
684
989
  }
685
990
 
686
- const piCommand = await resolvePiCommand();
687
- const rpc = new PiRpcProcess({ ...piCommand, cwd: options.cwd });
688
- const sseClients = new Set();
689
- rpc.onEvent((event) => {
690
- for (const client of sseClients) sendSse(client, event);
691
- });
692
- rpc.start();
991
+ const tabs = new Map();
992
+ let nextTabIndex = 1;
993
+
994
+ function defaultTabTitle(tabIndex) {
995
+ if (options.name) return tabIndex === 1 ? options.name : `${options.name} ${tabIndex}`;
996
+ return `Terminal ${tabIndex}`;
997
+ }
998
+
999
+ function attachRpcToTab(tab, rpc) {
1000
+ tab.rpcUnsubscribe?.();
1001
+ tab.rpc = rpc;
1002
+ tab.rpcUnsubscribe = rpc.onEvent((event) => {
1003
+ const scopedEvent = { ...event, tabId: tab.id, tabTitle: tab.title };
1004
+ recordEvent(scopedEvent);
1005
+ for (const client of tab.sseClients) sendSse(client, scopedEvent);
1006
+ });
1007
+ }
1008
+
1009
+ async function createTab({ title, cwd } = {}) {
1010
+ const tabIndex = nextTabIndex++;
1011
+ const tabTitle = String(title || "").trim() || defaultTabTitle(tabIndex);
1012
+ const tabCwd = cwd ? await resolveCwd(cwd, options.cwd) : options.cwd;
1013
+ const id = randomUUID();
1014
+ const piArgs = buildPiArgsForTab(tabIndex, tabTitle);
1015
+ const piCommand = await resolvePiCommand(piArgs);
1016
+ const rpc = new PiRpcProcess({ ...piCommand, cwd: tabCwd });
1017
+ const tab = {
1018
+ id,
1019
+ index: tabIndex,
1020
+ title: tabTitle,
1021
+ cwd: tabCwd,
1022
+ createdAt: new Date().toISOString(),
1023
+ rpc: undefined,
1024
+ rpcUnsubscribe: undefined,
1025
+ sseClients: new Set(),
1026
+ };
1027
+
1028
+ attachRpcToTab(tab, rpc);
1029
+ tabs.set(id, tab);
1030
+ rpc.start();
1031
+ return tab;
1032
+ }
1033
+
1034
+ function firstTab() {
1035
+ return tabs.values().next().value;
1036
+ }
1037
+
1038
+ function tabMeta(tab) {
1039
+ return {
1040
+ id: tab.id,
1041
+ index: tab.index,
1042
+ title: tab.title,
1043
+ cwd: tab.cwd,
1044
+ createdAt: tab.createdAt,
1045
+ startedAt: tab.rpc.startedAt,
1046
+ pid: tab.rpc.child?.pid,
1047
+ running: !!tab.rpc.child && tab.rpc.child.exitCode === null,
1048
+ command: tab.rpc.displayCommand,
1049
+ clientCount: tab.sseClients.size,
1050
+ };
1051
+ }
1052
+
1053
+ function listTabs() {
1054
+ return [...tabs.values()].map(tabMeta);
1055
+ }
1056
+
1057
+ async function updateTabCwd(id, cwd) {
1058
+ const tab = tabs.get(id);
1059
+ if (!tab) throw makeHttpError(404, `Unknown Pi tab: ${id}`);
1060
+
1061
+ const nextCwd = await resolveCwd(cwd, tab.cwd);
1062
+ if (nextCwd === tab.cwd) return { tab, changed: false };
1063
+
1064
+ const piArgs = buildPiArgsForTab(tab.index, tab.title);
1065
+ const piCommand = await resolvePiCommand(piArgs);
1066
+ const restartingEvent = { type: "webui_tab_restarting", tabId: tab.id, tabTitle: tab.title, cwd: nextCwd };
1067
+ recordEvent(restartingEvent);
1068
+ for (const client of tab.sseClients) {
1069
+ sendSse(client, restartingEvent);
1070
+ }
1071
+
1072
+ const oldRpc = tab.rpc;
1073
+ tab.rpcUnsubscribe?.();
1074
+ tab.rpcUnsubscribe = undefined;
1075
+ oldRpc.stop();
1076
+
1077
+ tab.cwd = nextCwd;
1078
+ const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
1079
+ attachRpcToTab(tab, rpc);
1080
+ rpc.start();
1081
+
1082
+ const changedEvent = { type: "webui_cwd_changed", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, pid: tab.rpc.child?.pid };
1083
+ recordEvent(changedEvent);
1084
+ for (const client of tab.sseClients) {
1085
+ sendSse(client, changedEvent);
1086
+ }
1087
+ return { tab, changed: true };
1088
+ }
1089
+
1090
+ async function restartTabRpc(tab, reason = "reload") {
1091
+ const state = await tab.rpc.send({ type: "get_state" });
1092
+ if (state.success === false) throw makeHttpError(400, state.error || "Unable to read Pi state before reload");
1093
+ if (state.data?.isStreaming) throw makeHttpError(409, "Wait for the current response to finish before reloading.");
1094
+ if (state.data?.isCompacting) throw makeHttpError(409, "Wait for compaction to finish before reloading.");
1095
+
1096
+ const piArgs = buildPiArgsForTab(tab.index, tab.title);
1097
+ if (state.data?.sessionFile && !options.noSession) piArgs.push("--session", state.data.sessionFile);
1098
+ const piCommand = await resolvePiCommand(piArgs);
1099
+ const reloadingEvent = { type: "webui_tab_reloading", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, reason, sessionFile: state.data?.sessionFile };
1100
+ recordEvent(reloadingEvent);
1101
+ for (const client of tab.sseClients) sendSse(client, reloadingEvent);
1102
+
1103
+ const oldRpc = tab.rpc;
1104
+ tab.rpcUnsubscribe?.();
1105
+ tab.rpcUnsubscribe = undefined;
1106
+ oldRpc.stop();
1107
+
1108
+ const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
1109
+ attachRpcToTab(tab, rpc);
1110
+ rpc.start();
1111
+
1112
+ const reloadedEvent = { type: "webui_tab_reloaded", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, pid: tab.rpc.child?.pid, reason, sessionFile: state.data?.sessionFile };
1113
+ recordEvent(reloadedEvent);
1114
+ for (const client of tab.sseClients) sendSse(client, reloadedEvent);
1115
+ return tab;
1116
+ }
1117
+
1118
+ async function getCommandData(tab) {
1119
+ const response = await tab.rpc.send({ type: "get_commands" });
1120
+ if (response.success === false) throw makeHttpError(400, response.error || "failed to load commands");
1121
+ return { commands: [...NATIVE_SLASH_COMMANDS, ...(response.data?.commands || [])] };
1122
+ }
1123
+
1124
+ function formatSessionOutput(tab, state, stats) {
1125
+ return [
1126
+ `Session: ${state.sessionName || state.sessionId || "unknown"}`,
1127
+ `Tab: ${tab.title}`,
1128
+ `CWD: ${tab.cwd}`,
1129
+ `Model: ${state.model ? `${state.model.provider}/${state.model.id}` : "none"}`,
1130
+ `Thinking: ${state.thinkingLevel || "unknown"}`,
1131
+ `Status: ${state.isStreaming ? "running" : state.isCompacting ? "compacting" : "idle"}`,
1132
+ `Messages: ${state.messageCount ?? "?"}`,
1133
+ `Queue: ${state.pendingMessageCount ?? 0}`,
1134
+ `Session file: ${state.sessionFile || "none"}`,
1135
+ stats ? `Tokens: input ${stats.tokens?.input ?? 0}, output ${stats.tokens?.output ?? 0}, cache read ${stats.tokens?.cacheRead ?? 0}` : undefined,
1136
+ stats?.cost !== undefined ? `Cost: ${stats.cost}` : undefined,
1137
+ ].filter(Boolean).join("\n");
1138
+ }
1139
+
1140
+ function webuiHotkeysOutput() {
1141
+ return [
1142
+ "Web UI hotkeys:",
1143
+ "Enter: send on desktop; newline on mobile",
1144
+ "Ctrl/Cmd+Enter: send from textarea",
1145
+ "Tab: accept slash-command suggestion",
1146
+ "Arrow up/down: move through slash-command suggestions",
1147
+ "Escape: close actions, tabs, model picker, or mobile drawer",
1148
+ "Mobile: Send button submits; Return inserts a newline",
1149
+ ].join("\n");
1150
+ }
1151
+
1152
+ async function handleNativeSlashCommand(tab, body) {
1153
+ const parsed = parseSlashCommand(body.message);
1154
+ if (!parsed) return undefined;
1155
+
1156
+ switch (parsed.name) {
1157
+ case "reload": {
1158
+ const reloaded = await restartTabRpc(tab, "slash-command");
1159
+ return rpcSuccess("native_slash_command", { command: "reload", tab: tabMeta(reloaded), message: "Reloaded keybindings, extensions, skills, prompts, and themes." });
1160
+ }
1161
+ case "new": {
1162
+ const response = await tab.rpc.send({ type: "new_session" });
1163
+ return response.success === false ? response : rpcSuccess("native_slash_command", { command: "new", message: "Started a new session.", result: response.data });
1164
+ }
1165
+ case "compact": {
1166
+ const response = await tab.rpc.send(parsed.args ? { type: "compact", customInstructions: parsed.args } : { type: "compact" });
1167
+ return response.success === false ? response : rpcSuccess("native_slash_command", { command: "compact", message: "Compaction finished.", result: response.data });
1168
+ }
1169
+ case "name": {
1170
+ if (!parsed.args) throw makeHttpError(400, "Usage: /name <session name>");
1171
+ const response = await tab.rpc.send({ type: "set_session_name", name: parsed.args });
1172
+ return response.success === false ? response : rpcSuccess("native_slash_command", { command: "name", message: `Session name set to: ${parsed.args}` });
1173
+ }
1174
+ case "session": {
1175
+ const [state, stats] = await Promise.all([
1176
+ tab.rpc.send({ type: "get_state" }),
1177
+ tab.rpc.send({ type: "get_session_stats" }).catch((error) => ({ success: false, error: sanitizeError(error) })),
1178
+ ]);
1179
+ if (state.success === false) return state;
1180
+ return rpcSuccess("native_slash_command", { command: "session", message: formatSessionOutput(tab, state.data || {}, stats.success === false ? null : stats.data) });
1181
+ }
1182
+ case "copy": {
1183
+ const response = await tab.rpc.send({ type: "get_last_assistant_text" });
1184
+ if (response.success === false) return response;
1185
+ const text = String(response.data?.text || "");
1186
+ if (!text.trim()) throw makeHttpError(400, "No assistant message to copy.");
1187
+ return rpcSuccess("native_slash_command", { command: "copy", message: "Copied the last assistant message.", copyText: text });
1188
+ }
1189
+ case "hotkeys": {
1190
+ return rpcSuccess("native_slash_command", { command: "hotkeys", message: webuiHotkeysOutput() });
1191
+ }
1192
+ case "clone": {
1193
+ const response = await tab.rpc.send({ type: "clone" });
1194
+ return response.success === false ? response : rpcSuccess("native_slash_command", { command: "clone", message: "Cloned the current session.", result: response.data });
1195
+ }
1196
+ default:
1197
+ throw makeHttpError(400, `/${parsed.name} is a native Pi TUI command, but this Web UI cannot run that interactive command yet.`);
1198
+ }
1199
+ }
1200
+
1201
+ function closeTab(id) {
1202
+ const tab = tabs.get(id);
1203
+ if (!tab) throw makeHttpError(404, `Unknown Pi tab: ${id}`);
1204
+ if (tabs.size <= 1) throw makeHttpError(400, "Cannot close the last Pi tab");
1205
+
1206
+ const closingEvent = { type: "webui_tab_closing", tabId: tab.id, tabTitle: tab.title };
1207
+ recordEvent(closingEvent);
1208
+ for (const client of tab.sseClients) {
1209
+ sendSse(client, closingEvent);
1210
+ client.end();
1211
+ }
1212
+ tab.sseClients.clear();
1213
+ tab.rpcUnsubscribe?.();
1214
+ tab.rpc.stop();
1215
+ tabs.delete(id);
1216
+ return tab;
1217
+ }
1218
+
1219
+ function requestedTabId(req, url, body) {
1220
+ const header = req.headers["x-pi-webui-tab"];
1221
+ const headerValue = Array.isArray(header) ? header[0] : header;
1222
+ return String(url.searchParams.get("tab") || url.searchParams.get("tabId") || body?.tabId || body?.tab || headerValue || "").trim();
1223
+ }
1224
+
1225
+ function getRequestedTab(req, url, body = {}) {
1226
+ const id = requestedTabId(req, url, body);
1227
+ if (!id) {
1228
+ const tab = firstTab();
1229
+ if (!tab) throw makeHttpError(503, "No Pi tabs are available");
1230
+ return tab;
1231
+ }
1232
+ const tab = tabs.get(id);
1233
+ if (!tab) throw makeHttpError(404, `Unknown Pi tab: ${id}`);
1234
+ return tab;
1235
+ }
1236
+
1237
+ const serverStartedAt = new Date().toISOString();
1238
+ const initialTab = await createTab();
1239
+ let currentHost = options.host;
1240
+ let networkRebindInProgress = false;
1241
+
1242
+ function localNetworkAddresses() {
1243
+ const addresses = [];
1244
+ for (const entries of Object.values(networkInterfaces())) {
1245
+ for (const entry of entries || []) {
1246
+ if (entry.internal || entry.family !== "IPv4") continue;
1247
+ addresses.push(entry.address);
1248
+ }
1249
+ }
1250
+ return [...new Set(addresses)].sort();
1251
+ }
1252
+
1253
+ function networkStatus() {
1254
+ const open = !isLocalHost(currentHost);
1255
+ const networkUrls = open ? localNetworkAddresses().map((address) => `http://${address}:${options.port}/`) : [];
1256
+ return {
1257
+ open,
1258
+ opening: networkRebindInProgress,
1259
+ host: currentHost,
1260
+ port: options.port,
1261
+ localUrl: `http://127.0.0.1:${options.port}/`,
1262
+ networkUrls,
1263
+ };
1264
+ }
1265
+
1266
+ function closeSseClientsForRebind(nextHost) {
1267
+ for (const tab of tabs.values()) {
1268
+ const rebindEvent = { type: "webui_network_rebinding", tabId: tab.id, tabTitle: tab.title, host: nextHost, port: options.port };
1269
+ recordEvent(rebindEvent);
1270
+ for (const client of tab.sseClients) {
1271
+ sendSse(client, rebindEvent);
1272
+ client.end();
1273
+ }
1274
+ tab.sseClients.clear();
1275
+ }
1276
+ }
1277
+
1278
+ function closeServerListener() {
1279
+ return new Promise((resolve, reject) => {
1280
+ if (!server.listening) {
1281
+ resolve();
1282
+ return;
1283
+ }
1284
+ server.close((error) => {
1285
+ if (error) reject(error);
1286
+ else resolve();
1287
+ });
1288
+ });
1289
+ }
1290
+
1291
+ function listenOn(host) {
1292
+ return new Promise((resolve, reject) => {
1293
+ const cleanup = () => {
1294
+ server.off("error", onError);
1295
+ server.off("listening", onListening);
1296
+ };
1297
+ const onError = (error) => {
1298
+ cleanup();
1299
+ reject(error);
1300
+ };
1301
+ const onListening = () => {
1302
+ cleanup();
1303
+ resolve();
1304
+ };
1305
+ server.once("error", onError);
1306
+ server.once("listening", onListening);
1307
+ server.listen(options.port, host);
1308
+ });
1309
+ }
1310
+
1311
+ async function openToLocalNetwork() {
1312
+ const nextHost = "0.0.0.0";
1313
+ if (!isLocalHost(currentHost) || networkRebindInProgress) return networkStatus();
1314
+
1315
+ networkRebindInProgress = true;
1316
+ closeSseClientsForRebind(nextHost);
1317
+ const previousHost = currentHost;
1318
+ try {
1319
+ await closeServerListener();
1320
+ await listenOn(nextHost);
1321
+ currentHost = nextHost;
1322
+ console.warn("WARNING: Web UI is now reachable from the local network and has no authentication.");
1323
+ return networkStatus();
1324
+ } catch (error) {
1325
+ console.error("Failed to open Web UI to local network:", sanitizeError(error));
1326
+ if (!server.listening) {
1327
+ try {
1328
+ await listenOn(previousHost);
1329
+ } catch (restoreError) {
1330
+ console.error("Failed to restore Web UI listener:", sanitizeError(restoreError));
1331
+ }
1332
+ }
1333
+ throw error;
1334
+ } finally {
1335
+ networkRebindInProgress = false;
1336
+ }
1337
+ }
1338
+
1339
+ async function safeRpcData(tab, command, timeoutMs = STATUS_RPC_TIMEOUT_MS) {
1340
+ try {
1341
+ const response = await tab.rpc.send(command, timeoutMs);
1342
+ if (response?.success === false) return { ok: false, error: response.error || `${command.type} failed` };
1343
+ return { ok: true, data: response?.data ?? null };
1344
+ } catch (error) {
1345
+ return { ok: false, error: sanitizeError(error) };
1346
+ }
1347
+ }
1348
+
1349
+ function providerList(models) {
1350
+ const providers = new Set();
1351
+ for (const model of Array.isArray(models) ? models : []) {
1352
+ if (model?.provider) providers.add(String(model.provider));
1353
+ }
1354
+ return [...providers].sort();
1355
+ }
1356
+
1357
+ async function tabStatusDetails(tab) {
1358
+ const [stateResult, modelsResult, statsResult, workspaceResult] = await Promise.all([
1359
+ safeRpcData(tab, { type: "get_state" }),
1360
+ safeRpcData(tab, { type: "get_available_models" }),
1361
+ safeRpcData(tab, { type: "get_session_stats" }),
1362
+ getWorkspaceInfo(tab.cwd, tab.rpc.startedAt).then((data) => ({ ok: true, data })).catch((error) => ({ ok: false, error: sanitizeError(error) })),
1363
+ ]);
1364
+ const models = modelsResult.ok ? modelsResult.data?.models || [] : [];
1365
+ return {
1366
+ ...tabMeta(tab),
1367
+ state: stateResult.ok ? stateResult.data : null,
1368
+ stateError: stateResult.ok ? undefined : stateResult.error,
1369
+ stats: statsResult.ok ? statsResult.data : null,
1370
+ statsError: statsResult.ok ? undefined : statsResult.error,
1371
+ workspace: workspaceResult.ok ? workspaceResult.data : null,
1372
+ workspaceError: workspaceResult.ok ? undefined : workspaceResult.error,
1373
+ models: {
1374
+ count: models.length,
1375
+ providers: providerList(models),
1376
+ error: modelsResult.ok ? undefined : modelsResult.error,
1377
+ },
1378
+ };
1379
+ }
1380
+
1381
+ async function webuiStatus({ detailed = false, eventLimit = 40 } = {}) {
1382
+ const tab = firstTab();
1383
+ const network = networkStatus();
1384
+ const data = {
1385
+ online: true,
1386
+ webuiVersion: packageJson.version,
1387
+ webuiPid: process.pid,
1388
+ startedAt: serverStartedAt,
1389
+ cwd: options.cwd,
1390
+ boundHost: currentHost,
1391
+ port: options.port,
1392
+ pageUrl: network.localUrl,
1393
+ boundUrl: `http://${formatUrlHost(currentHost)}:${options.port}/`,
1394
+ network,
1395
+ piPid: tab?.rpc.child?.pid,
1396
+ piRunning: !!tab?.rpc.child && tab.rpc.child.exitCode === null,
1397
+ tabs: listTabs(),
1398
+ };
1399
+
1400
+ if (detailed) {
1401
+ data.tabs = await Promise.all([...tabs.values()].map((item) => tabStatusDetails(item)));
1402
+ data.events = latestEvents(eventLimit);
1403
+ }
1404
+
1405
+ return data;
1406
+ }
693
1407
 
694
1408
  const server = createServer(async (req, res) => {
695
1409
  try {
696
1410
  const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
697
1411
 
1412
+ if (url.pathname === "/api/tabs" && req.method === "GET") {
1413
+ sendJson(res, 200, { ok: true, data: { tabs: listTabs() } });
1414
+ return;
1415
+ }
1416
+
1417
+ if (url.pathname === "/api/tabs" && req.method === "POST") {
1418
+ const body = await readJsonBody(req);
1419
+ const tab = await createTab({ title: body.title, cwd: body.cwd });
1420
+ sendJson(res, 201, { ok: true, data: { tab: tabMeta(tab), tabs: listTabs() } });
1421
+ return;
1422
+ }
1423
+
1424
+ if (url.pathname.startsWith("/api/tabs/") && req.method === "PATCH") {
1425
+ const id = decodeURIComponent(url.pathname.slice("/api/tabs/".length));
1426
+ const body = await readJsonBody(req);
1427
+ const { tab, changed } = await updateTabCwd(id, body.cwd);
1428
+ sendJson(res, 200, { ok: true, data: { tab: tabMeta(tab), tabs: listTabs(), changed } });
1429
+ return;
1430
+ }
1431
+
1432
+ if (url.pathname.startsWith("/api/tabs/") && req.method === "DELETE") {
1433
+ const id = decodeURIComponent(url.pathname.slice("/api/tabs/".length));
1434
+ closeTab(id);
1435
+ sendJson(res, 200, { ok: true, data: { tabs: listTabs(), activeTabId: firstTab()?.id || null } });
1436
+ return;
1437
+ }
1438
+
698
1439
  if (url.pathname === "/api/events" && req.method === "GET") {
1440
+ const tab = getRequestedTab(req, url);
699
1441
  res.writeHead(200, {
700
1442
  "content-type": "text/event-stream; charset=utf-8",
701
1443
  "cache-control": "no-cache, no-transform",
@@ -703,44 +1445,129 @@ const server = createServer(async (req, res) => {
703
1445
  "x-content-type-options": "nosniff",
704
1446
  });
705
1447
  res.write(": connected\n\n");
706
- sseClients.add(res);
1448
+ tab.sseClients.add(res);
707
1449
  sendSse(res, {
708
1450
  type: "webui_connected",
709
1451
  version: packageJson.version,
710
- pid: rpc.child?.pid,
711
- cwd: options.cwd,
712
- startedAt: rpc.startedAt,
1452
+ tabId: tab.id,
1453
+ tabTitle: tab.title,
1454
+ pid: tab.rpc.child?.pid,
1455
+ cwd: tab.cwd,
1456
+ startedAt: tab.rpc.startedAt,
713
1457
  });
714
1458
  const keepAlive = setInterval(() => res.write(": keepalive\n\n"), 15000);
715
1459
  req.on("close", () => {
716
1460
  clearInterval(keepAlive);
717
- sseClients.delete(res);
1461
+ tab.sseClients.delete(res);
718
1462
  });
719
1463
  return;
720
1464
  }
721
1465
 
722
1466
  if (url.pathname === "/api/health" && req.method === "GET") {
1467
+ const status = await webuiStatus();
723
1468
  sendJson(res, 200, {
724
1469
  ok: true,
725
- webuiVersion: packageJson.version,
726
- piPid: rpc.child?.pid,
727
- piRunning: !!rpc.child && rpc.child.exitCode === null,
728
- cwd: options.cwd,
1470
+ webuiVersion: status.webuiVersion,
1471
+ webuiPid: status.webuiPid,
1472
+ piPid: status.piPid,
1473
+ piRunning: status.piRunning,
1474
+ cwd: status.cwd,
1475
+ network: status.network,
1476
+ tabs: status.tabs,
729
1477
  });
730
1478
  return;
731
1479
  }
732
1480
 
1481
+ if (url.pathname === "/api/webui-status" && req.method === "GET") {
1482
+ const detailed = ["1", "true", "yes", "detailed"].includes(String(url.searchParams.get("detailed") || "").toLowerCase());
1483
+ const parsedEventLimit = Number.parseInt(url.searchParams.get("events") || "40", 10);
1484
+ const eventLimit = Number.isFinite(parsedEventLimit) ? parsedEventLimit : 40;
1485
+ sendJson(res, 200, { ok: true, data: await webuiStatus({ detailed, eventLimit }) });
1486
+ return;
1487
+ }
1488
+
1489
+ if (url.pathname === "/api/network" && req.method === "GET") {
1490
+ sendJson(res, 200, { ok: true, data: networkStatus() });
1491
+ return;
1492
+ }
1493
+
1494
+ if (url.pathname === "/api/network/open" && req.method === "POST") {
1495
+ if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Opening to the network is only allowed from localhost");
1496
+ const before = networkStatus();
1497
+ sendJson(res, 202, { ok: true, data: { ...before, opening: true } });
1498
+ if (!before.open && !networkRebindInProgress) {
1499
+ setTimeout(() => openToLocalNetwork().catch((error) => console.error("network open failed:", sanitizeError(error))), 20).unref();
1500
+ }
1501
+ return;
1502
+ }
1503
+
1504
+ if (url.pathname === "/api/shutdown" && req.method === "POST") {
1505
+ if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Shutdown is only allowed from localhost");
1506
+ sendJson(res, 200, { ok: true, message: "Pi Web UI shutting down", webuiPid: process.pid });
1507
+ setTimeout(() => shutdown("api shutdown"), 20).unref();
1508
+ return;
1509
+ }
1510
+
733
1511
  if (url.pathname === "/api/workspace" && req.method === "GET") {
1512
+ const tab = getRequestedTab(req, url);
1513
+ sendJson(res, 200, {
1514
+ ok: true,
1515
+ data: await getWorkspaceInfo(tab.cwd, tab.rpc.startedAt),
1516
+ });
1517
+ return;
1518
+ }
1519
+
1520
+ if (url.pathname === "/api/directories" && req.method === "GET") {
1521
+ const tab = getRequestedTab(req, url);
734
1522
  sendJson(res, 200, {
735
1523
  ok: true,
736
- data: await getWorkspaceInfo(options.cwd, rpc.startedAt),
1524
+ data: await getDirectoryPickerData(url.searchParams.get("path"), tab.cwd),
737
1525
  });
738
1526
  return;
739
1527
  }
740
1528
 
1529
+ if (url.pathname === "/api/path-fast-picks" && req.method === "GET") {
1530
+ sendJson(res, 200, { ok: true, data: { picks: await readPathFastPicks() } });
1531
+ return;
1532
+ }
1533
+
1534
+ if (url.pathname === "/api/path-fast-picks" && req.method === "POST") {
1535
+ const body = await readJsonBody(req);
1536
+ const picks = await writePathFastPicks(body.picks ?? body);
1537
+ sendJson(res, 200, { ok: true, data: { picks } });
1538
+ return;
1539
+ }
1540
+
1541
+ if (url.pathname === "/api/scoped-models" && req.method === "GET") {
1542
+ const tab = getRequestedTab(req, url);
1543
+ sendJson(res, 200, { ok: true, data: await getScopedModelData(tab) });
1544
+ return;
1545
+ }
1546
+
1547
+ if (url.pathname === "/api/commands" && req.method === "GET") {
1548
+ const tab = getRequestedTab(req, url);
1549
+ sendJson(res, 200, { type: "response", command: "get_commands", success: true, data: await getCommandData(tab) });
1550
+ return;
1551
+ }
1552
+
1553
+ if (url.pathname === "/api/prompt" && req.method === "POST") {
1554
+ const body = await readJsonBody(req);
1555
+ const tab = getRequestedTab(req, url, body);
1556
+ const nativeResponse = await handleNativeSlashCommand(tab, body);
1557
+ if (nativeResponse) {
1558
+ sendJson(res, nativeResponse.success === false ? 400 : 200, nativeResponse);
1559
+ return;
1560
+ }
1561
+ const command = commandFromPost(url.pathname, body);
1562
+ const response = await tab.rpc.send(command);
1563
+ sendJson(res, response.success === false ? 400 : 200, response);
1564
+ return;
1565
+ }
1566
+
741
1567
  if (url.pathname.startsWith("/api/git-workflow/")) {
742
1568
  const body = req.method === "POST" ? await readJsonBody(req) : {};
743
- const response = await handleGitWorkflowRequest(url.pathname, body);
1569
+ const tab = getRequestedTab(req, url, body);
1570
+ const response = await handleGitWorkflowRequest(url.pathname, body, tab.cwd);
744
1571
  if (response) {
745
1572
  sendJson(res, 200, response);
746
1573
  return;
@@ -749,16 +1576,19 @@ const server = createServer(async (req, res) => {
749
1576
 
750
1577
  const getCommand = req.method === "GET" ? commandFromGet(url.pathname) : undefined;
751
1578
  if (getCommand) {
752
- const response = await rpc.send(getCommand);
1579
+ const tab = getRequestedTab(req, url);
1580
+ const response = await tab.rpc.send(getCommand);
753
1581
  sendJson(res, response.success === false ? 400 : 200, response);
754
1582
  return;
755
1583
  }
756
1584
 
757
1585
  if (req.method === "POST" && url.pathname === "/api/extension-ui-response") {
758
1586
  const body = await readJsonBody(req);
759
- if (body.type !== "extension_ui_response") body.type = "extension_ui_response";
760
- if (!body.id) throw new Error("id is required");
761
- await rpc.writeRaw(body);
1587
+ const tab = getRequestedTab(req, url, body);
1588
+ const { tabId, tab: _tab, ...payload } = body;
1589
+ if (payload.type !== "extension_ui_response") payload.type = "extension_ui_response";
1590
+ if (!payload.id) throw new Error("id is required");
1591
+ await tab.rpc.writeRaw(payload);
762
1592
  sendJson(res, 200, { ok: true });
763
1593
  return;
764
1594
  }
@@ -767,7 +1597,8 @@ const server = createServer(async (req, res) => {
767
1597
  const body = await readJsonBody(req);
768
1598
  const command = commandFromPost(url.pathname, body);
769
1599
  if (command) {
770
- const response = await rpc.send(command);
1600
+ const tab = getRequestedTab(req, url, body);
1601
+ const response = await tab.rpc.send(command);
771
1602
  sendJson(res, response.success === false ? 400 : 200, response);
772
1603
  return;
773
1604
  }
@@ -777,22 +1608,26 @@ const server = createServer(async (req, res) => {
777
1608
 
778
1609
  sendError(res, 404, "Not found");
779
1610
  } catch (error) {
780
- sendError(res, 500, error);
1611
+ sendError(res, error?.statusCode || 500, error);
781
1612
  }
782
1613
  });
783
1614
 
784
1615
  server.on("error", (error) => {
1616
+ if (networkRebindInProgress) {
1617
+ console.error("Web UI network rebind failed:", sanitizeError(error));
1618
+ return;
1619
+ }
785
1620
  console.error("Web UI server failed:", sanitizeError(error));
786
- rpc.stop();
1621
+ for (const tab of tabs.values()) tab.rpc.stop();
787
1622
  process.exit(1);
788
1623
  });
789
1624
 
790
- server.listen(options.port, options.host, () => {
791
- const urlHost = options.host.includes(":") && !options.host.startsWith("[") ? `[${options.host}]` : options.host;
1625
+ server.listen(options.port, currentHost, () => {
1626
+ const urlHost = formatUrlHost(currentHost);
792
1627
  console.log(`Pi Web UI: http://${urlHost}:${options.port}/`);
793
1628
  console.log(`Working directory: ${options.cwd}`);
794
- console.log(`Pi RPC: ${piCommand.displayCommand}`);
795
- if (!isLocalHost(options.host)) {
1629
+ console.log(`Pi RPC: ${initialTab.rpc.displayCommand}`);
1630
+ if (!isLocalHost(currentHost)) {
796
1631
  console.warn("WARNING: Web UI has no authentication. Only expose it on trusted networks.");
797
1632
  }
798
1633
  });
@@ -800,7 +1635,7 @@ server.listen(options.port, options.host, () => {
800
1635
  function shutdown(signal) {
801
1636
  console.log(`\n${signal}: shutting down Pi Web UI...`);
802
1637
  server.close(() => process.exit(0));
803
- rpc.stop();
1638
+ for (const tab of tabs.values()) tab.rpc.stop();
804
1639
  setTimeout(() => process.exit(0), 4000).unref();
805
1640
  }
806
1641