@firstpick/pi-package-webui 0.1.0 → 0.1.1

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, readFile, readdir, stat } from "node:fs/promises";
6
+ import { 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";
@@ -137,6 +138,14 @@ function isLocalHost(host) {
137
138
  return host === "localhost" || host === "::1" || host === "[::1]" || host.startsWith("127.");
138
139
  }
139
140
 
141
+ function formatUrlHost(host) {
142
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
143
+ }
144
+
145
+ function isLocalAddress(address = "") {
146
+ return address === "::1" || address.startsWith("127.") || address === "::ffff:127.0.0.1" || address.startsWith("::ffff:127.");
147
+ }
148
+
140
149
  function sanitizeError(error) {
141
150
  if (!error) return "Unknown error";
142
151
  if (typeof error === "string") return error;
@@ -315,6 +324,12 @@ function sendJson(res, statusCode, payload) {
315
324
  res.end(body);
316
325
  }
317
326
 
327
+ function makeHttpError(statusCode, message) {
328
+ const error = new Error(message);
329
+ error.statusCode = statusCode;
330
+ return error;
331
+ }
332
+
318
333
  function sendError(res, statusCode, error) {
319
334
  sendJson(res, statusCode, { ok: false, error: sanitizeError(error) });
320
335
  }
@@ -379,6 +394,84 @@ function displayPath(cwd) {
379
394
  return normalized;
380
395
  }
381
396
 
397
+ function expandUserPath(value) {
398
+ const input = String(value || "").trim();
399
+ if (input === "~") {
400
+ const home = process.env.HOME || process.env.USERPROFILE;
401
+ if (!home) throw makeHttpError(400, "Cannot expand ~ because no home directory is configured");
402
+ return home;
403
+ }
404
+ if (input.startsWith("~/") || input.startsWith("~\\")) {
405
+ const home = process.env.HOME || process.env.USERPROFILE;
406
+ if (!home) throw makeHttpError(400, "Cannot expand ~ because no home directory is configured");
407
+ return path.join(home, input.slice(2));
408
+ }
409
+ return input;
410
+ }
411
+
412
+ async function resolveCwd(value, baseCwd = options.cwd) {
413
+ const input = expandUserPath(value);
414
+ if (!input) throw makeHttpError(400, "cwd is required");
415
+ const cwd = path.resolve(baseCwd, input);
416
+ let info;
417
+ try {
418
+ info = await stat(cwd);
419
+ } catch {
420
+ throw makeHttpError(400, `cwd does not exist: ${cwd}`);
421
+ }
422
+ if (!info.isDirectory()) throw makeHttpError(400, `cwd is not a directory: ${cwd}`);
423
+ return cwd;
424
+ }
425
+
426
+ function uniquePathItems(items) {
427
+ const seen = new Set();
428
+ const result = [];
429
+ for (const item of items) {
430
+ if (!item?.cwd || seen.has(item.cwd)) continue;
431
+ seen.add(item.cwd);
432
+ result.push(item);
433
+ }
434
+ return result;
435
+ }
436
+
437
+ function pathPickerRoots(activeCwd, viewedCwd) {
438
+ const home = process.env.HOME || process.env.USERPROFILE;
439
+ return uniquePathItems([
440
+ { label: "Tab", cwd: activeCwd, displayCwd: displayPath(activeCwd) },
441
+ { label: "Default", cwd: options.cwd, displayCwd: displayPath(options.cwd) },
442
+ home ? { label: "Home", cwd: home, displayCwd: displayPath(home) } : undefined,
443
+ { label: "Root", cwd: path.parse(viewedCwd || activeCwd || options.cwd).root, displayCwd: path.parse(viewedCwd || activeCwd || options.cwd).root },
444
+ ]);
445
+ }
446
+
447
+ async function getDirectoryPickerData(viewPath, activeCwd) {
448
+ const cwd = await resolveCwd(viewPath || activeCwd, activeCwd);
449
+ let entries;
450
+ try {
451
+ entries = await readdir(cwd, { withFileTypes: true });
452
+ } catch (error) {
453
+ throw makeHttpError(error?.code === "EACCES" ? 403 : 400, `Cannot read directory ${cwd}: ${sanitizeError(error)}`);
454
+ }
455
+
456
+ const directoryEntries = entries
457
+ .filter((entry) => entry.isDirectory())
458
+ .sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
459
+ const directories = directoryEntries.slice(0, 500).map((entry) => {
460
+ const entryPath = path.join(cwd, entry.name);
461
+ return { name: entry.name, cwd: entryPath, displayCwd: displayPath(entryPath), hidden: entry.name.startsWith(".") };
462
+ });
463
+ const parent = path.dirname(cwd);
464
+
465
+ return {
466
+ cwd,
467
+ displayCwd: displayPath(cwd),
468
+ parent: parent === cwd ? null : parent,
469
+ roots: pathPickerRoots(activeCwd, cwd),
470
+ directories,
471
+ truncated: directoryEntries.length > directories.length,
472
+ };
473
+ }
474
+
382
475
  async function getWorkspaceInfo(cwd, startedAt) {
383
476
  const info = {
384
477
  cwd,
@@ -517,18 +610,18 @@ function gitWorkflowCommandPayload(result) {
517
610
  };
518
611
  }
519
612
 
520
- async function handleGitWorkflowRequest(pathname, body = {}) {
613
+ async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd) {
521
614
  try {
522
615
  switch (pathname) {
523
616
  case "/api/git-workflow/message":
524
- return { ok: true, data: await readGitWorkflowMessages(options.cwd) };
617
+ return { ok: true, data: await readGitWorkflowMessages(cwd) };
525
618
  case "/api/git-workflow/add":
526
- await getGitRoot(options.cwd);
527
- return gitWorkflowCommandPayload(await runGitWorkflowCommand(["add", "."], { cwd: options.cwd }));
619
+ await getGitRoot(cwd);
620
+ return gitWorkflowCommandPayload(await runGitWorkflowCommand(["add", "."], { cwd }));
528
621
  case "/api/git-workflow/commit": {
529
622
  const variant = String(body.variant || "").trim();
530
623
  if (!["short", "long"].includes(variant)) throw new Error("variant must be 'short' or 'long'");
531
- const messages = await readGitWorkflowMessages(options.cwd);
624
+ const messages = await readGitWorkflowMessages(cwd);
532
625
  if (variant === "short") {
533
626
  const message = messages.short.trim();
534
627
  if (!message) throw new Error(`${messages.shortPath} is empty`);
@@ -538,7 +631,7 @@ async function handleGitWorkflowRequest(pathname, body = {}) {
538
631
  return gitWorkflowCommandPayload(await runGitWorkflowCommand(["commit", "-F", messages.longPath], { cwd: messages.root, label: "git commit -F dev/COMMIT/staged-commit-long.txt" }));
539
632
  }
540
633
  case "/api/git-workflow/push": {
541
- const root = await getGitRoot(options.cwd);
634
+ const root = await getGitRoot(cwd);
542
635
  return gitWorkflowCommandPayload(await runGitWorkflowCommand(["push"], { cwd: root, timeoutMs: 15 * 60 * 1000 }));
543
636
  }
544
637
  case "/api/git-workflow/cancel": {
@@ -660,12 +753,18 @@ if (options.version) {
660
753
  process.exit(0);
661
754
  }
662
755
 
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);
756
+ function buildPiArgsForTab(tabIndex, title) {
757
+ const args = ["--mode", "rpc"];
758
+ if (options.noSession) args.push("--no-session");
667
759
 
668
- async function resolvePiCommand() {
760
+ const sessionName = tabIndex === 1 ? options.name : title;
761
+ if (sessionName) args.push("--name", sessionName);
762
+
763
+ args.push(...options.piArgs);
764
+ return args;
765
+ }
766
+
767
+ async function resolvePiCommand(piArgs) {
669
768
  if (options.piBinExplicit) {
670
769
  return { command: options.piBin, args: piArgs, displayCommand: `${options.piBin} ${piArgs.join(" ")}` };
671
770
  }
@@ -683,19 +782,266 @@ async function resolvePiCommand() {
683
782
  }
684
783
  }
685
784
 
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();
785
+ const tabs = new Map();
786
+ let nextTabIndex = 1;
787
+
788
+ function defaultTabTitle(tabIndex) {
789
+ if (options.name) return tabIndex === 1 ? options.name : `${options.name} ${tabIndex}`;
790
+ return `Terminal ${tabIndex}`;
791
+ }
792
+
793
+ function attachRpcToTab(tab, rpc) {
794
+ tab.rpcUnsubscribe?.();
795
+ tab.rpc = rpc;
796
+ tab.rpcUnsubscribe = rpc.onEvent((event) => {
797
+ const scopedEvent = { ...event, tabId: tab.id, tabTitle: tab.title };
798
+ for (const client of tab.sseClients) sendSse(client, scopedEvent);
799
+ });
800
+ }
801
+
802
+ async function createTab({ title, cwd } = {}) {
803
+ const tabIndex = nextTabIndex++;
804
+ const tabTitle = String(title || "").trim() || defaultTabTitle(tabIndex);
805
+ const tabCwd = cwd ? await resolveCwd(cwd, options.cwd) : options.cwd;
806
+ const id = randomUUID();
807
+ const piArgs = buildPiArgsForTab(tabIndex, tabTitle);
808
+ const piCommand = await resolvePiCommand(piArgs);
809
+ const rpc = new PiRpcProcess({ ...piCommand, cwd: tabCwd });
810
+ const tab = {
811
+ id,
812
+ index: tabIndex,
813
+ title: tabTitle,
814
+ cwd: tabCwd,
815
+ createdAt: new Date().toISOString(),
816
+ rpc: undefined,
817
+ rpcUnsubscribe: undefined,
818
+ sseClients: new Set(),
819
+ };
820
+
821
+ attachRpcToTab(tab, rpc);
822
+ tabs.set(id, tab);
823
+ rpc.start();
824
+ return tab;
825
+ }
826
+
827
+ function firstTab() {
828
+ return tabs.values().next().value;
829
+ }
830
+
831
+ function tabMeta(tab) {
832
+ return {
833
+ id: tab.id,
834
+ index: tab.index,
835
+ title: tab.title,
836
+ cwd: tab.cwd,
837
+ createdAt: tab.createdAt,
838
+ startedAt: tab.rpc.startedAt,
839
+ pid: tab.rpc.child?.pid,
840
+ running: !!tab.rpc.child && tab.rpc.child.exitCode === null,
841
+ command: tab.rpc.displayCommand,
842
+ clientCount: tab.sseClients.size,
843
+ };
844
+ }
845
+
846
+ function listTabs() {
847
+ return [...tabs.values()].map(tabMeta);
848
+ }
849
+
850
+ async function updateTabCwd(id, cwd) {
851
+ const tab = tabs.get(id);
852
+ if (!tab) throw makeHttpError(404, `Unknown Pi tab: ${id}`);
853
+
854
+ const nextCwd = await resolveCwd(cwd, tab.cwd);
855
+ if (nextCwd === tab.cwd) return { tab, changed: false };
856
+
857
+ const piArgs = buildPiArgsForTab(tab.index, tab.title);
858
+ const piCommand = await resolvePiCommand(piArgs);
859
+ for (const client of tab.sseClients) {
860
+ sendSse(client, { type: "webui_tab_restarting", tabId: tab.id, tabTitle: tab.title, cwd: nextCwd });
861
+ }
862
+
863
+ const oldRpc = tab.rpc;
864
+ tab.rpcUnsubscribe?.();
865
+ tab.rpcUnsubscribe = undefined;
866
+ oldRpc.stop();
867
+
868
+ tab.cwd = nextCwd;
869
+ const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
870
+ attachRpcToTab(tab, rpc);
871
+ rpc.start();
872
+
873
+ for (const client of tab.sseClients) {
874
+ sendSse(client, { type: "webui_cwd_changed", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, pid: tab.rpc.child?.pid });
875
+ }
876
+ return { tab, changed: true };
877
+ }
878
+
879
+ function closeTab(id) {
880
+ const tab = tabs.get(id);
881
+ if (!tab) throw makeHttpError(404, `Unknown Pi tab: ${id}`);
882
+ if (tabs.size <= 1) throw makeHttpError(400, "Cannot close the last Pi tab");
883
+
884
+ for (const client of tab.sseClients) {
885
+ sendSse(client, { type: "webui_tab_closing", tabId: tab.id, tabTitle: tab.title });
886
+ client.end();
887
+ }
888
+ tab.sseClients.clear();
889
+ tab.rpcUnsubscribe?.();
890
+ tab.rpc.stop();
891
+ tabs.delete(id);
892
+ return tab;
893
+ }
894
+
895
+ function requestedTabId(req, url, body) {
896
+ const header = req.headers["x-pi-webui-tab"];
897
+ const headerValue = Array.isArray(header) ? header[0] : header;
898
+ return String(url.searchParams.get("tab") || url.searchParams.get("tabId") || body?.tabId || body?.tab || headerValue || "").trim();
899
+ }
900
+
901
+ function getRequestedTab(req, url, body = {}) {
902
+ const id = requestedTabId(req, url, body);
903
+ if (!id) {
904
+ const tab = firstTab();
905
+ if (!tab) throw makeHttpError(503, "No Pi tabs are available");
906
+ return tab;
907
+ }
908
+ const tab = tabs.get(id);
909
+ if (!tab) throw makeHttpError(404, `Unknown Pi tab: ${id}`);
910
+ return tab;
911
+ }
912
+
913
+ const initialTab = await createTab();
914
+ let currentHost = options.host;
915
+ let networkRebindInProgress = false;
916
+
917
+ function localNetworkAddresses() {
918
+ const addresses = [];
919
+ for (const entries of Object.values(networkInterfaces())) {
920
+ for (const entry of entries || []) {
921
+ if (entry.internal || entry.family !== "IPv4") continue;
922
+ addresses.push(entry.address);
923
+ }
924
+ }
925
+ return [...new Set(addresses)].sort();
926
+ }
927
+
928
+ function networkStatus() {
929
+ const open = !isLocalHost(currentHost);
930
+ const networkUrls = open ? localNetworkAddresses().map((address) => `http://${address}:${options.port}/`) : [];
931
+ return {
932
+ open,
933
+ opening: networkRebindInProgress,
934
+ host: currentHost,
935
+ port: options.port,
936
+ localUrl: `http://127.0.0.1:${options.port}/`,
937
+ networkUrls,
938
+ };
939
+ }
940
+
941
+ function closeSseClientsForRebind(nextHost) {
942
+ for (const tab of tabs.values()) {
943
+ for (const client of tab.sseClients) {
944
+ sendSse(client, { type: "webui_network_rebinding", tabId: tab.id, tabTitle: tab.title, host: nextHost, port: options.port });
945
+ client.end();
946
+ }
947
+ tab.sseClients.clear();
948
+ }
949
+ }
950
+
951
+ function closeServerListener() {
952
+ return new Promise((resolve, reject) => {
953
+ if (!server.listening) {
954
+ resolve();
955
+ return;
956
+ }
957
+ server.close((error) => {
958
+ if (error) reject(error);
959
+ else resolve();
960
+ });
961
+ });
962
+ }
963
+
964
+ function listenOn(host) {
965
+ return new Promise((resolve, reject) => {
966
+ const cleanup = () => {
967
+ server.off("error", onError);
968
+ server.off("listening", onListening);
969
+ };
970
+ const onError = (error) => {
971
+ cleanup();
972
+ reject(error);
973
+ };
974
+ const onListening = () => {
975
+ cleanup();
976
+ resolve();
977
+ };
978
+ server.once("error", onError);
979
+ server.once("listening", onListening);
980
+ server.listen(options.port, host);
981
+ });
982
+ }
983
+
984
+ async function openToLocalNetwork() {
985
+ const nextHost = "0.0.0.0";
986
+ if (!isLocalHost(currentHost) || networkRebindInProgress) return networkStatus();
987
+
988
+ networkRebindInProgress = true;
989
+ closeSseClientsForRebind(nextHost);
990
+ const previousHost = currentHost;
991
+ try {
992
+ await closeServerListener();
993
+ await listenOn(nextHost);
994
+ currentHost = nextHost;
995
+ console.warn("WARNING: Web UI is now reachable from the local network and has no authentication.");
996
+ return networkStatus();
997
+ } catch (error) {
998
+ console.error("Failed to open Web UI to local network:", sanitizeError(error));
999
+ if (!server.listening) {
1000
+ try {
1001
+ await listenOn(previousHost);
1002
+ } catch (restoreError) {
1003
+ console.error("Failed to restore Web UI listener:", sanitizeError(restoreError));
1004
+ }
1005
+ }
1006
+ throw error;
1007
+ } finally {
1008
+ networkRebindInProgress = false;
1009
+ }
1010
+ }
693
1011
 
694
1012
  const server = createServer(async (req, res) => {
695
1013
  try {
696
1014
  const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
697
1015
 
1016
+ if (url.pathname === "/api/tabs" && req.method === "GET") {
1017
+ sendJson(res, 200, { ok: true, data: { tabs: listTabs() } });
1018
+ return;
1019
+ }
1020
+
1021
+ if (url.pathname === "/api/tabs" && req.method === "POST") {
1022
+ const body = await readJsonBody(req);
1023
+ const tab = await createTab({ title: body.title, cwd: body.cwd });
1024
+ sendJson(res, 201, { ok: true, data: { tab: tabMeta(tab), tabs: listTabs() } });
1025
+ return;
1026
+ }
1027
+
1028
+ if (url.pathname.startsWith("/api/tabs/") && req.method === "PATCH") {
1029
+ const id = decodeURIComponent(url.pathname.slice("/api/tabs/".length));
1030
+ const body = await readJsonBody(req);
1031
+ const { tab, changed } = await updateTabCwd(id, body.cwd);
1032
+ sendJson(res, 200, { ok: true, data: { tab: tabMeta(tab), tabs: listTabs(), changed } });
1033
+ return;
1034
+ }
1035
+
1036
+ if (url.pathname.startsWith("/api/tabs/") && req.method === "DELETE") {
1037
+ const id = decodeURIComponent(url.pathname.slice("/api/tabs/".length));
1038
+ closeTab(id);
1039
+ sendJson(res, 200, { ok: true, data: { tabs: listTabs(), activeTabId: firstTab()?.id || null } });
1040
+ return;
1041
+ }
1042
+
698
1043
  if (url.pathname === "/api/events" && req.method === "GET") {
1044
+ const tab = getRequestedTab(req, url);
699
1045
  res.writeHead(200, {
700
1046
  "content-type": "text/event-stream; charset=utf-8",
701
1047
  "cache-control": "no-cache, no-transform",
@@ -703,44 +1049,83 @@ const server = createServer(async (req, res) => {
703
1049
  "x-content-type-options": "nosniff",
704
1050
  });
705
1051
  res.write(": connected\n\n");
706
- sseClients.add(res);
1052
+ tab.sseClients.add(res);
707
1053
  sendSse(res, {
708
1054
  type: "webui_connected",
709
1055
  version: packageJson.version,
710
- pid: rpc.child?.pid,
711
- cwd: options.cwd,
712
- startedAt: rpc.startedAt,
1056
+ tabId: tab.id,
1057
+ tabTitle: tab.title,
1058
+ pid: tab.rpc.child?.pid,
1059
+ cwd: tab.cwd,
1060
+ startedAt: tab.rpc.startedAt,
713
1061
  });
714
1062
  const keepAlive = setInterval(() => res.write(": keepalive\n\n"), 15000);
715
1063
  req.on("close", () => {
716
1064
  clearInterval(keepAlive);
717
- sseClients.delete(res);
1065
+ tab.sseClients.delete(res);
718
1066
  });
719
1067
  return;
720
1068
  }
721
1069
 
722
1070
  if (url.pathname === "/api/health" && req.method === "GET") {
1071
+ const tab = firstTab();
723
1072
  sendJson(res, 200, {
724
1073
  ok: true,
725
1074
  webuiVersion: packageJson.version,
726
- piPid: rpc.child?.pid,
727
- piRunning: !!rpc.child && rpc.child.exitCode === null,
1075
+ webuiPid: process.pid,
1076
+ piPid: tab?.rpc.child?.pid,
1077
+ piRunning: !!tab?.rpc.child && tab.rpc.child.exitCode === null,
728
1078
  cwd: options.cwd,
1079
+ network: networkStatus(),
1080
+ tabs: listTabs(),
729
1081
  });
730
1082
  return;
731
1083
  }
732
1084
 
1085
+ if (url.pathname === "/api/network" && req.method === "GET") {
1086
+ sendJson(res, 200, { ok: true, data: networkStatus() });
1087
+ return;
1088
+ }
1089
+
1090
+ if (url.pathname === "/api/network/open" && req.method === "POST") {
1091
+ if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Opening to the network is only allowed from localhost");
1092
+ const before = networkStatus();
1093
+ sendJson(res, 202, { ok: true, data: { ...before, opening: true } });
1094
+ if (!before.open && !networkRebindInProgress) {
1095
+ setTimeout(() => openToLocalNetwork().catch((error) => console.error("network open failed:", sanitizeError(error))), 20).unref();
1096
+ }
1097
+ return;
1098
+ }
1099
+
1100
+ if (url.pathname === "/api/shutdown" && req.method === "POST") {
1101
+ if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Shutdown is only allowed from localhost");
1102
+ sendJson(res, 200, { ok: true, message: "Pi Web UI shutting down", webuiPid: process.pid });
1103
+ setTimeout(() => shutdown("api shutdown"), 20).unref();
1104
+ return;
1105
+ }
1106
+
733
1107
  if (url.pathname === "/api/workspace" && req.method === "GET") {
1108
+ const tab = getRequestedTab(req, url);
1109
+ sendJson(res, 200, {
1110
+ ok: true,
1111
+ data: await getWorkspaceInfo(tab.cwd, tab.rpc.startedAt),
1112
+ });
1113
+ return;
1114
+ }
1115
+
1116
+ if (url.pathname === "/api/directories" && req.method === "GET") {
1117
+ const tab = getRequestedTab(req, url);
734
1118
  sendJson(res, 200, {
735
1119
  ok: true,
736
- data: await getWorkspaceInfo(options.cwd, rpc.startedAt),
1120
+ data: await getDirectoryPickerData(url.searchParams.get("path"), tab.cwd),
737
1121
  });
738
1122
  return;
739
1123
  }
740
1124
 
741
1125
  if (url.pathname.startsWith("/api/git-workflow/")) {
742
1126
  const body = req.method === "POST" ? await readJsonBody(req) : {};
743
- const response = await handleGitWorkflowRequest(url.pathname, body);
1127
+ const tab = getRequestedTab(req, url, body);
1128
+ const response = await handleGitWorkflowRequest(url.pathname, body, tab.cwd);
744
1129
  if (response) {
745
1130
  sendJson(res, 200, response);
746
1131
  return;
@@ -749,16 +1134,19 @@ const server = createServer(async (req, res) => {
749
1134
 
750
1135
  const getCommand = req.method === "GET" ? commandFromGet(url.pathname) : undefined;
751
1136
  if (getCommand) {
752
- const response = await rpc.send(getCommand);
1137
+ const tab = getRequestedTab(req, url);
1138
+ const response = await tab.rpc.send(getCommand);
753
1139
  sendJson(res, response.success === false ? 400 : 200, response);
754
1140
  return;
755
1141
  }
756
1142
 
757
1143
  if (req.method === "POST" && url.pathname === "/api/extension-ui-response") {
758
1144
  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);
1145
+ const tab = getRequestedTab(req, url, body);
1146
+ const { tabId, tab: _tab, ...payload } = body;
1147
+ if (payload.type !== "extension_ui_response") payload.type = "extension_ui_response";
1148
+ if (!payload.id) throw new Error("id is required");
1149
+ await tab.rpc.writeRaw(payload);
762
1150
  sendJson(res, 200, { ok: true });
763
1151
  return;
764
1152
  }
@@ -767,7 +1155,8 @@ const server = createServer(async (req, res) => {
767
1155
  const body = await readJsonBody(req);
768
1156
  const command = commandFromPost(url.pathname, body);
769
1157
  if (command) {
770
- const response = await rpc.send(command);
1158
+ const tab = getRequestedTab(req, url, body);
1159
+ const response = await tab.rpc.send(command);
771
1160
  sendJson(res, response.success === false ? 400 : 200, response);
772
1161
  return;
773
1162
  }
@@ -777,22 +1166,26 @@ const server = createServer(async (req, res) => {
777
1166
 
778
1167
  sendError(res, 404, "Not found");
779
1168
  } catch (error) {
780
- sendError(res, 500, error);
1169
+ sendError(res, error?.statusCode || 500, error);
781
1170
  }
782
1171
  });
783
1172
 
784
1173
  server.on("error", (error) => {
1174
+ if (networkRebindInProgress) {
1175
+ console.error("Web UI network rebind failed:", sanitizeError(error));
1176
+ return;
1177
+ }
785
1178
  console.error("Web UI server failed:", sanitizeError(error));
786
- rpc.stop();
1179
+ for (const tab of tabs.values()) tab.rpc.stop();
787
1180
  process.exit(1);
788
1181
  });
789
1182
 
790
- server.listen(options.port, options.host, () => {
791
- const urlHost = options.host.includes(":") && !options.host.startsWith("[") ? `[${options.host}]` : options.host;
1183
+ server.listen(options.port, currentHost, () => {
1184
+ const urlHost = formatUrlHost(currentHost);
792
1185
  console.log(`Pi Web UI: http://${urlHost}:${options.port}/`);
793
1186
  console.log(`Working directory: ${options.cwd}`);
794
- console.log(`Pi RPC: ${piCommand.displayCommand}`);
795
- if (!isLocalHost(options.host)) {
1187
+ console.log(`Pi RPC: ${initialTab.rpc.displayCommand}`);
1188
+ if (!isLocalHost(currentHost)) {
796
1189
  console.warn("WARNING: Web UI has no authentication. Only expose it on trusted networks.");
797
1190
  }
798
1191
  });
@@ -800,7 +1193,7 @@ server.listen(options.port, options.host, () => {
800
1193
  function shutdown(signal) {
801
1194
  console.log(`\n${signal}: shutting down Pi Web UI...`);
802
1195
  server.close(() => process.exit(0));
803
- rpc.stop();
1196
+ for (const tab of tabs.values()) tab.rpc.stop();
804
1197
  setTimeout(() => process.exit(0), 4000).unref();
805
1198
  }
806
1199