@firstpick/pi-package-webui 0.1.1 → 0.1.3

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,13 +2,15 @@
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, readdir, stat } from "node:fs/promises";
6
- import { networkInterfaces } from "node:os";
5
+ import { createRequire } from "node:module";
6
+ import { access, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
7
+ import { homedir, networkInterfaces } from "node:os";
7
8
  import path from "node:path";
8
9
  import { StringDecoder } from "node:string_decoder";
9
10
  import { fileURLToPath } from "node:url";
10
11
 
11
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const require = createRequire(import.meta.url);
12
14
  const packageRoot = path.resolve(__dirname, "..");
13
15
  const publicDir = path.join(packageRoot, "public");
14
16
  const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
@@ -17,14 +19,92 @@ const DEFAULT_HOST = "127.0.0.1";
17
19
  const DEFAULT_PORT = 31415;
18
20
  const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
19
21
  const BODY_LIMIT_BYTES = 1024 * 1024;
22
+ const EVENT_HISTORY_LIMIT = 200;
23
+ const EXTENSION_UI_BLOCKING_METHODS = new Set(["select", "confirm", "input", "editor"]);
24
+ const STATUS_RPC_TIMEOUT_MS = 1_800;
25
+ const FAST_PICK_LIMIT = 30;
26
+ const PATH_SUGGESTION_LIMIT = 20;
27
+ const PATH_SUGGESTION_QUERY_LIMIT = 512;
28
+ const PATH_SUGGESTION_SCAN_LIMIT = 5000;
29
+ const PATH_SUGGESTION_MAX_OUTPUT_LENGTH = 300000;
30
+ const PATH_SUGGESTION_EXCLUDED_DIRS = new Set([".git", "node_modules"]);
31
+ const RESTORE_TAB_LIMIT = 30;
32
+ const NETWORK_REBIND_DELAY_MS = 100;
33
+ const NETWORK_REBIND_FORCE_CLOSE_MS = 750;
34
+ const AUTO_TAB_TITLE_MAX_LENGTH = 44;
35
+ const AUTO_TAB_TITLE_WORD_LIMIT = 8;
36
+ const AUTO_TAB_TITLE_STOP_WORDS = new Set([
37
+ "a",
38
+ "an",
39
+ "and",
40
+ "are",
41
+ "as",
42
+ "at",
43
+ "be",
44
+ "best",
45
+ "by",
46
+ "for",
47
+ "from",
48
+ "how",
49
+ "in",
50
+ "is",
51
+ "it",
52
+ "its",
53
+ "me",
54
+ "my",
55
+ "of",
56
+ "on",
57
+ "or",
58
+ "please",
59
+ "s",
60
+ "should",
61
+ "that",
62
+ "the",
63
+ "this",
64
+ "to",
65
+ "way",
66
+ "what",
67
+ "whats",
68
+ "when",
69
+ "with",
70
+ "you",
71
+ "your",
72
+ ]);
20
73
 
21
74
  const MIME_TYPES = new Map([
22
75
  [".html", "text/html; charset=utf-8"],
23
76
  [".js", "text/javascript; charset=utf-8"],
24
77
  [".css", "text/css; charset=utf-8"],
25
78
  [".svg", "image/svg+xml"],
79
+ [".png", "image/png"],
80
+ [".webmanifest", "application/manifest+json; charset=utf-8"],
26
81
  ]);
27
82
 
83
+ const NATIVE_SLASH_COMMANDS = [
84
+ { name: "settings", description: "Open settings menu" },
85
+ { name: "model", description: "Select model (opens selector UI)" },
86
+ { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" },
87
+ { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" },
88
+ { name: "import", description: "Import and resume a session from a JSONL file" },
89
+ { name: "share", description: "Share session as a secret GitHub gist" },
90
+ { name: "copy", description: "Copy last agent message to clipboard" },
91
+ { name: "name", description: "Set session display name" },
92
+ { name: "session", description: "Show session info and stats" },
93
+ { name: "changelog", description: "Show changelog entries" },
94
+ { name: "hotkeys", description: "Show all keyboard shortcuts" },
95
+ { name: "fork", description: "Create a new fork from a previous user message" },
96
+ { name: "clone", description: "Duplicate the current session at the current position" },
97
+ { name: "tree", description: "Navigate session tree (switch branches)" },
98
+ { name: "login", description: "Configure provider authentication" },
99
+ { name: "logout", description: "Remove provider authentication" },
100
+ { name: "new", description: "Start a new session" },
101
+ { name: "compact", description: "Manually compact the session context" },
102
+ { name: "resume", description: "Resume a different session" },
103
+ { name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes" },
104
+ { name: "quit", description: "Quit Pi" },
105
+ ].map((command) => ({ ...command, source: "native", location: "Pi" }));
106
+ const NATIVE_SLASH_COMMAND_NAMES = new Set(NATIVE_SLASH_COMMANDS.map((command) => command.name));
107
+
28
108
  function usage() {
29
109
  console.log(`pi-webui ${packageJson.version}
30
110
 
@@ -314,12 +394,13 @@ class PiRpcProcess {
314
394
  }
315
395
  }
316
396
 
317
- function sendJson(res, statusCode, payload) {
397
+ function sendJson(res, statusCode, payload, headers = {}) {
318
398
  const body = JSON.stringify(payload, null, 2);
319
399
  res.writeHead(statusCode, {
320
400
  "content-type": "application/json; charset=utf-8",
321
401
  "cache-control": "no-store",
322
402
  "x-content-type-options": "nosniff",
403
+ ...headers,
323
404
  });
324
405
  res.end(body);
325
406
  }
@@ -352,7 +433,175 @@ function sendSse(res, event) {
352
433
  res.write(`data: ${JSON.stringify(event)}\n\n`);
353
434
  }
354
435
 
355
- function runCommand(command, args, { cwd, timeoutMs = 2000 } = {}) {
436
+ function rpcSuccess(command, data = {}) {
437
+ return { type: "response", command, success: true, data };
438
+ }
439
+
440
+ const ACTION_FEEDBACK_REACTIONS = new Set(["up", "down", "question"]);
441
+
442
+ function trimFeedbackField(value, maxLength) {
443
+ const text = String(value || "").trim();
444
+ return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
445
+ }
446
+
447
+ function normalizeActionFeedbackItems(body) {
448
+ const rawItems = Array.isArray(body?.feedback) ? body.feedback : Array.isArray(body?.items) ? body.items : [];
449
+ if (rawItems.length === 0) throw new Error("feedback is required");
450
+ if (rawItems.length > 20) throw new Error("feedback is limited to 20 reactions per submission");
451
+ return rawItems.map((item, index) => {
452
+ const reaction = String(item?.reaction || "").trim();
453
+ if (!ACTION_FEEDBACK_REACTIONS.has(reaction)) throw new Error(`Invalid feedback reaction at item ${index + 1}`);
454
+ return {
455
+ reaction,
456
+ comment: trimFeedbackField(item?.comment, 800),
457
+ kind: trimFeedbackField(item?.kind || "action", 80),
458
+ title: trimFeedbackField(item?.title || `item ${index + 1}`, 240),
459
+ snippet: trimFeedbackField(item?.snippet, 2000),
460
+ messageIndex: Number.isFinite(Number(item?.messageIndex)) ? Number(item.messageIndex) : index,
461
+ createdAt: trimFeedbackField(item?.createdAt, 80),
462
+ };
463
+ });
464
+ }
465
+
466
+ function actionFeedbackReactionLabel(reaction) {
467
+ if (reaction === "up") return "👍 thumbs up — Good job; repeat this pattern when appropriate.";
468
+ if (reaction === "down") return "👎 thumbs down — avoid or reconsider this target/pattern; prioritize the user comment.";
469
+ return "? question mark — explain this target in detail in the final output.";
470
+ }
471
+
472
+ function formatActionFeedbackLearningPrompt(items) {
473
+ const lines = [
474
+ "The user submitted direct feedback on specific Web UI action or final-output cards from your last run.",
475
+ "Use it to steer future behavior and create or update a concise LEARNING note from this feedback.",
476
+ "Reaction semantics:",
477
+ "- 👍 thumbs up: treat as 'Good job!' and reinforce the action/pattern.",
478
+ "- 👎 thumbs down: avoid or reconsider this target/pattern; include any user comment.",
479
+ "- ? question mark: explain the target in detail in your final output.",
480
+ "",
481
+ "Feedback items:",
482
+ ];
483
+
484
+ items.forEach((item, index) => {
485
+ lines.push(
486
+ `${index + 1}. ${actionFeedbackReactionLabel(item.reaction)}`,
487
+ ` Target (${item.kind}): ${item.title}`,
488
+ item.comment ? ` User comment: ${item.comment}` : undefined,
489
+ item.snippet ? ` Action excerpt:\n${item.snippet.split(/\r?\n/).map((line) => ` ${line}`).join("\n")}` : undefined,
490
+ );
491
+ });
492
+
493
+ lines.push(
494
+ "",
495
+ "After processing this feedback, report which LEARNING was created or updated. If any item used '?', include the requested detailed explanation in the final response.",
496
+ );
497
+ return lines.filter((line) => line !== undefined).join("\n");
498
+ }
499
+
500
+ async function handleActionFeedback(tab, body) {
501
+ const feedbackItems = normalizeActionFeedbackItems(body);
502
+ const state = await tab.rpc.send({ type: "get_state" });
503
+ if (state.success === false) return state;
504
+ if (state.data?.isStreaming || state.data?.isCompacting) {
505
+ throw makeHttpError(409, "Wait for the current agent run or compaction to finish before sending feedback.");
506
+ }
507
+
508
+ const command = { type: "prompt", message: formatActionFeedbackLearningPrompt(feedbackItems) };
509
+ markTabWorking(tab);
510
+ const response = await tab.rpc.send(command);
511
+ if (response.success === false) markTabIdle(tab);
512
+ return response;
513
+ }
514
+
515
+ function parseSlashCommand(message) {
516
+ const text = String(message || "").trim();
517
+ if (!text.startsWith("/") || text.includes("\n")) return undefined;
518
+ const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/);
519
+ if (!match) return undefined;
520
+ const name = match[1].toLowerCase();
521
+ if (!NATIVE_SLASH_COMMAND_NAMES.has(name)) return undefined;
522
+ return { name, args: (match[2] || "").trim(), text };
523
+ }
524
+
525
+ function truncateTabTitle(title, maxLength = AUTO_TAB_TITLE_MAX_LENGTH) {
526
+ const text = String(title || "").replace(/\s+/g, " ").trim();
527
+ if (!maxLength || text.length <= maxLength) return text;
528
+ return `${text.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
529
+ }
530
+
531
+ function titleCaseTabTitle(title) {
532
+ return title ? `${title.charAt(0).toUpperCase()}${title.slice(1)}` : "";
533
+ }
534
+
535
+ function generatedTabTitleFromPrompt(message) {
536
+ const line = String(message || "")
537
+ .split(/\r?\n/)
538
+ .map((item) => item.trim())
539
+ .find((item) => item && !item.startsWith("```"));
540
+ if (!line) return "";
541
+
542
+ const cleaned = line
543
+ .replace(/https?:\/\/\S+/gi, "link")
544
+ .replace(/^\/+/, "")
545
+ .replace(/[-_]+/g, " ")
546
+ .replace(/[`*_~#>{}\[\]()<>'"“”‘’,;:!?]+/g, " ")
547
+ .replace(/\s+/g, " ")
548
+ .trim()
549
+ .replace(/^(?:please\s+)?(?:can|could|would)\s+you\s+/i, "")
550
+ .replace(/^(?:please\s+)?(?:help\s+me\s+|i\s+(?:need|want)\s+(?:you\s+to\s+)?)/i, "")
551
+ .replace(/^(?:for|in|on)\s+the\s+/i, "");
552
+ if (!cleaned) return "";
553
+
554
+ const words = cleaned.split(/\s+/).map((word) => word.replace(/^[^\w]+|[^\w]+$/g, "")).filter(Boolean);
555
+ const meaningfulWords = words.filter((word) => !AUTO_TAB_TITLE_STOP_WORDS.has(word.toLowerCase()));
556
+ const selectedWords = (meaningfulWords.length >= 3 ? meaningfulWords : words).slice(0, AUTO_TAB_TITLE_WORD_LIMIT);
557
+ return truncateTabTitle(titleCaseTabTitle(selectedWords.join(" ")));
558
+ }
559
+
560
+ function uniqueTabTitle(title, currentTab, maxLength = AUTO_TAB_TITLE_MAX_LENGTH) {
561
+ const base = truncateTabTitle(title, maxLength);
562
+ if (!base) return "";
563
+ const existing = new Set([...tabs.values()].filter((tab) => tab.id !== currentTab?.id).map((tab) => tab.title));
564
+ if (!existing.has(base)) return base;
565
+ for (let suffix = 2; suffix < 100; suffix++) {
566
+ const suffixText = ` ${suffix}`;
567
+ const candidate = `${truncateTabTitle(base, Math.max(1, maxLength - suffixText.length))}${suffixText}`;
568
+ if (!existing.has(candidate)) return candidate;
569
+ }
570
+ return `${truncateTabTitle(base, Math.max(1, maxLength - 4))} ${currentTab?.index || 1}`;
571
+ }
572
+
573
+ const eventHistory = [];
574
+
575
+ function truncateStatusText(value, maxLength = 240) {
576
+ const text = String(value || "").replace(/\s+/g, " ").trim();
577
+ return text.length > maxLength ? `${text.slice(0, maxLength - 1)}…` : text;
578
+ }
579
+
580
+ function statusEventSummary(event) {
581
+ const summary = {
582
+ timestamp: new Date().toISOString(),
583
+ type: String(event?.type || "event"),
584
+ };
585
+ for (const key of ["id", "tabId", "tabTitle", "previousTabTitle", "titleSource", "pid", "cwd", "code", "signal", "command", "method", "replayed", "queueLength", "pendingMessageCount", "pendingExtensionUiRequestCount"]) {
586
+ if (event?.[key] !== undefined) summary[key] = event[key];
587
+ }
588
+ if (event?.assistantMessageEvent?.type) summary.updateType = event.assistantMessageEvent.type;
589
+ if (event?.message?.role) summary.messageRole = event.message.role;
590
+ if (event?.error) summary.error = truncateStatusText(event.error);
591
+ if (event?.text && summary.type === "pi_stderr") summary.text = truncateStatusText(event.text);
592
+ return summary;
593
+ }
594
+
595
+ function recordEvent(event) {
596
+ eventHistory.push(statusEventSummary(event));
597
+ if (eventHistory.length > EVENT_HISTORY_LIMIT) eventHistory.splice(0, eventHistory.length - EVENT_HISTORY_LIMIT);
598
+ }
599
+
600
+ function latestEvents(limit = 40) {
601
+ return eventHistory.slice(-Math.max(0, Math.min(EVENT_HISTORY_LIMIT, limit)));
602
+ }
603
+
604
+ function runCommand(command, args, { cwd, timeoutMs = 2000, maxOutputLength = 20000 } = {}) {
356
605
  return new Promise((resolve) => {
357
606
  const child = spawn(command, args, {
358
607
  cwd,
@@ -374,11 +623,11 @@ function runCommand(command, args, { cwd, timeoutMs = 2000 } = {}) {
374
623
  }, timeoutMs);
375
624
  child.stdout.on("data", (chunk) => {
376
625
  stdout += String(chunk);
377
- if (stdout.length > 20000) stdout = stdout.slice(-20000);
626
+ if (stdout.length > maxOutputLength) stdout = stdout.slice(-maxOutputLength);
378
627
  });
379
628
  child.stderr.on("data", (chunk) => {
380
629
  stderr += String(chunk);
381
- if (stderr.length > 20000) stderr = stderr.slice(-20000);
630
+ if (stderr.length > maxOutputLength) stderr = stderr.slice(-maxOutputLength);
382
631
  });
383
632
  child.on("error", (error) => finish({ exitCode: undefined, stdout, stderr: sanitizeError(error), error: sanitizeError(error) }));
384
633
  child.on("exit", (exitCode) => finish({ exitCode, stdout, stderr, timedOut: false }));
@@ -434,6 +683,137 @@ function uniquePathItems(items) {
434
683
  return result;
435
684
  }
436
685
 
686
+ function normalizePathFastPicks(value) {
687
+ const items = Array.isArray(value) ? value : Array.isArray(value?.picks) ? value.picks : [];
688
+ const seen = new Set();
689
+ const picks = [];
690
+ for (const item of items) {
691
+ const rawCwd = typeof item === "string" ? item : item?.cwd;
692
+ if (!rawCwd) continue;
693
+ let cwd;
694
+ try {
695
+ cwd = path.resolve(options.cwd, expandUserPath(rawCwd));
696
+ } catch {
697
+ continue;
698
+ }
699
+ if (!cwd || seen.has(cwd)) continue;
700
+ seen.add(cwd);
701
+ const displayCwd = String(typeof item === "object" && item?.displayCwd ? item.displayCwd : displayPath(cwd)).slice(0, 4096);
702
+ picks.push({ cwd, displayCwd });
703
+ if (picks.length >= FAST_PICK_LIMIT) break;
704
+ }
705
+ return picks;
706
+ }
707
+
708
+ function fastPicksStorageFile() {
709
+ if (process.env.PI_WEBUI_FAST_PICKS_FILE) return path.resolve(expandUserPath(process.env.PI_WEBUI_FAST_PICKS_FILE));
710
+ const stateRoot = process.env.XDG_STATE_HOME || path.join(homedir(), ".local", "state");
711
+ return path.join(stateRoot, "pi-webui", "fast-picks.json");
712
+ }
713
+
714
+ let pathFastPicksCache = null;
715
+
716
+ async function readPathFastPicks() {
717
+ if (pathFastPicksCache) return pathFastPicksCache;
718
+ try {
719
+ const parsed = JSON.parse(await readFile(fastPicksStorageFile(), "utf8"));
720
+ pathFastPicksCache = normalizePathFastPicks(parsed);
721
+ } catch (error) {
722
+ if (error?.code !== "ENOENT") console.warn(`failed to read path fast picks: ${sanitizeError(error)}`);
723
+ pathFastPicksCache = [];
724
+ }
725
+ return pathFastPicksCache;
726
+ }
727
+
728
+ async function writePathFastPicks(picks) {
729
+ const normalized = normalizePathFastPicks(picks);
730
+ const storageFile = fastPicksStorageFile();
731
+ await mkdir(path.dirname(storageFile), { recursive: true });
732
+ const tmpFile = `${storageFile}.${process.pid}.${Date.now()}.tmp`;
733
+ await writeFile(tmpFile, `${JSON.stringify({ version: 1, picks: normalized }, null, 2)}\n`, { mode: 0o600 });
734
+ await rename(tmpFile, storageFile);
735
+ pathFastPicksCache = normalized;
736
+ return normalized;
737
+ }
738
+
739
+ function parseCliScopedModelPatterns() {
740
+ for (let index = 0; index < options.piArgs.length; index++) {
741
+ const arg = options.piArgs[index];
742
+ if (arg === "--models" && options.piArgs[index + 1]) return options.piArgs[index + 1].split(",").map((item) => item.trim()).filter(Boolean);
743
+ if (arg.startsWith("--models=")) return arg.slice("--models=".length).split(",").map((item) => item.trim()).filter(Boolean);
744
+ }
745
+ return undefined;
746
+ }
747
+
748
+ async function readJsonFileIfExists(filePath) {
749
+ try {
750
+ return JSON.parse(await readFile(filePath, "utf8"));
751
+ } catch (error) {
752
+ if (error?.code === "ENOENT") return undefined;
753
+ console.warn(`failed to read ${filePath}: ${sanitizeError(error)}`);
754
+ return undefined;
755
+ }
756
+ }
757
+
758
+ async function configuredScopedModelPatterns(cwd = options.cwd) {
759
+ const cliPatterns = parseCliScopedModelPatterns();
760
+ if (cliPatterns !== undefined) return { patterns: cliPatterns, source: "cli" };
761
+
762
+ const agentDir = process.env.PI_CODING_AGENT_DIR ? path.resolve(expandUserPath(process.env.PI_CODING_AGENT_DIR)) : path.join(homedir(), ".pi", "agent");
763
+ const [globalSettings, projectSettings] = await Promise.all([
764
+ readJsonFileIfExists(path.join(agentDir, "settings.json")),
765
+ readJsonFileIfExists(path.join(cwd, ".pi", "settings.json")),
766
+ ]);
767
+
768
+ if (Array.isArray(projectSettings?.enabledModels)) return { patterns: projectSettings.enabledModels, source: "project" };
769
+ if (Array.isArray(globalSettings?.enabledModels)) return { patterns: globalSettings.enabledModels, source: "global" };
770
+ return { patterns: [], source: "none" };
771
+ }
772
+
773
+ function stripThinkingSuffix(pattern) {
774
+ const text = String(pattern || "").trim();
775
+ const slashIndex = text.indexOf("/");
776
+ const colonIndex = text.lastIndexOf(":");
777
+ if (colonIndex > (slashIndex === -1 ? -1 : slashIndex)) return text.slice(0, colonIndex);
778
+ return text;
779
+ }
780
+
781
+ function globToRegExp(pattern) {
782
+ const escaped = pattern.replace(/[.+^${}()|\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
783
+ return new RegExp(`^${escaped}$`, "i");
784
+ }
785
+
786
+ function modelMatchesPattern(model, pattern) {
787
+ const clean = stripThinkingSuffix(pattern).toLowerCase();
788
+ if (!clean) return false;
789
+ const full = `${model.provider}/${model.id}`.toLowerCase();
790
+ const id = String(model.id || "").toLowerCase();
791
+ if (/[?*\[]/.test(clean)) return globToRegExp(clean).test(full) || globToRegExp(clean).test(id);
792
+ return full === clean || id === clean || full.includes(clean) || id.includes(clean);
793
+ }
794
+
795
+ function resolveScopedModelsFromPatterns(patterns, models) {
796
+ const scoped = [];
797
+ const seen = new Set();
798
+ for (const pattern of patterns || []) {
799
+ for (const model of models || []) {
800
+ const key = `${model.provider}/${model.id}`;
801
+ if (seen.has(key) || !modelMatchesPattern(model, pattern)) continue;
802
+ seen.add(key);
803
+ scoped.push(model);
804
+ }
805
+ }
806
+ return scoped;
807
+ }
808
+
809
+ async function getScopedModelData(tab) {
810
+ const { patterns, source } = await configuredScopedModelPatterns(tab.cwd);
811
+ if (!patterns.length) return { models: [], patterns, source };
812
+ const response = await tab.rpc.send({ type: "get_available_models" });
813
+ if (response.success === false) throw makeHttpError(400, response.error || "failed to load available models");
814
+ return { models: resolveScopedModelsFromPatterns(patterns, response.data?.models || []), patterns, source };
815
+ }
816
+
437
817
  function pathPickerRoots(activeCwd, viewedCwd) {
438
818
  const home = process.env.HOME || process.env.USERPROFILE;
439
819
  return uniquePathItems([
@@ -472,6 +852,199 @@ async function getDirectoryPickerData(viewPath, activeCwd) {
472
852
  };
473
853
  }
474
854
 
855
+ function normalizeSuggestionPath(value) {
856
+ return String(value || "").replace(/\\/g, "/");
857
+ }
858
+
859
+ function cleanPathSuggestionQuery(value) {
860
+ return normalizeSuggestionPath(value).replace(/\0/g, "").slice(0, PATH_SUGGESTION_QUERY_LIMIT);
861
+ }
862
+
863
+ function splitSuggestionPathQuery(query) {
864
+ const normalized = normalizeSuggestionPath(query);
865
+ if (normalized === "~") return { displayBase: "~", prefix: "" };
866
+ if (!normalized || normalized.endsWith("/")) return { displayBase: normalized, prefix: "" };
867
+ const slashIndex = normalized.lastIndexOf("/");
868
+ if (slashIndex === -1) return { displayBase: "", prefix: normalized };
869
+ return { displayBase: normalized.slice(0, slashIndex + 1), prefix: normalized.slice(slashIndex + 1) };
870
+ }
871
+
872
+ function resolveSuggestionBase(displayBase, cwd) {
873
+ const base = displayBase || ".";
874
+ if (base === "~" || base.startsWith("~/")) return path.resolve(expandUserPath(base));
875
+ if (base.startsWith("/")) return path.resolve(base);
876
+ return path.resolve(cwd, base);
877
+ }
878
+
879
+ function joinSuggestionDisplayPath(displayBase, name) {
880
+ const base = normalizeSuggestionPath(displayBase);
881
+ if (!base || base === ".") return name;
882
+ if (base === "/") return `/${name}`;
883
+ return `${base.replace(/\/+$/, "")}/${name}`;
884
+ }
885
+
886
+ function pathSuggestionLabel(pathText) {
887
+ const normalized = normalizeSuggestionPath(pathText).replace(/\/+$/, "");
888
+ const name = normalized ? path.posix.basename(normalized) : pathText;
889
+ return `${name || pathText}${pathText.endsWith("/") ? "/" : ""}`;
890
+ }
891
+
892
+ function sortPathSuggestions(items) {
893
+ return items.sort((a, b) => {
894
+ if (a.type !== b.type) return a.type === "directory" ? -1 : 1;
895
+ return a.path.localeCompare(b.path, undefined, { numeric: true, sensitivity: "base" });
896
+ });
897
+ }
898
+
899
+ async function getDirectPathSuggestions(query, cwd) {
900
+ const { displayBase, prefix } = splitSuggestionPathQuery(query);
901
+ const searchDir = resolveSuggestionBase(displayBase, cwd);
902
+ let entries;
903
+ try {
904
+ entries = await readdir(searchDir, { withFileTypes: true });
905
+ } catch {
906
+ return [];
907
+ }
908
+
909
+ const normalizedPrefix = prefix.toLowerCase();
910
+ const suggestions = [];
911
+ for (const entry of entries) {
912
+ if (entry.name === ".git" || (!normalizedPrefix && PATH_SUGGESTION_EXCLUDED_DIRS.has(entry.name))) continue;
913
+ if (normalizedPrefix && !entry.name.toLowerCase().startsWith(normalizedPrefix)) continue;
914
+ let isDirectory = entry.isDirectory();
915
+ if (!isDirectory && entry.isSymbolicLink()) {
916
+ try {
917
+ isDirectory = (await stat(path.join(searchDir, entry.name))).isDirectory();
918
+ } catch {
919
+ isDirectory = false;
920
+ }
921
+ }
922
+ const pathText = normalizeSuggestionPath(`${joinSuggestionDisplayPath(displayBase, entry.name)}${isDirectory ? "/" : ""}`);
923
+ suggestions.push({
924
+ path: pathText,
925
+ label: `${entry.name}${isDirectory ? "/" : ""}`,
926
+ type: isDirectory ? "directory" : "file",
927
+ description: pathText,
928
+ });
929
+ }
930
+ return sortPathSuggestions(suggestions).slice(0, PATH_SUGGESTION_LIMIT);
931
+ }
932
+
933
+ function addSuggestionEntry(entries, pathText, isDirectory) {
934
+ const normalized = normalizeSuggestionPath(pathText).replace(/^\.\//, "");
935
+ if (!normalized || normalized === ".git" || normalized.startsWith(".git/")) return;
936
+ const value = isDirectory && !normalized.endsWith("/") ? `${normalized}/` : normalized;
937
+ if (!entries.has(value)) entries.set(value, { path: value, isDirectory });
938
+ }
939
+
940
+ function addSuggestionPathWithParents(entries, pathText) {
941
+ const normalized = normalizeSuggestionPath(pathText).replace(/^\.\//, "");
942
+ if (!normalized || normalized.startsWith(".git/")) return;
943
+ const parts = normalized.split("/").filter(Boolean);
944
+ let parent = "";
945
+ for (let index = 0; index < parts.length - 1; index++) {
946
+ parent = parent ? `${parent}/${parts[index]}` : parts[index];
947
+ addSuggestionEntry(entries, `${parent}/`, true);
948
+ }
949
+ addSuggestionEntry(entries, normalized, false);
950
+ }
951
+
952
+ async function getGitPathSuggestionEntries(cwd) {
953
+ const result = await runCommand("git", ["-C", cwd, "ls-files", "-co", "--exclude-standard"], {
954
+ timeoutMs: 1200,
955
+ maxOutputLength: PATH_SUGGESTION_MAX_OUTPUT_LENGTH,
956
+ });
957
+ if (result.exitCode !== 0 || !result.stdout.trim()) return null;
958
+ const entries = new Map();
959
+ for (const line of result.stdout.split("\n")) addSuggestionPathWithParents(entries, line.trim());
960
+ return [...entries.values()];
961
+ }
962
+
963
+ async function getFilesystemPathSuggestionEntries(cwd) {
964
+ const entries = new Map();
965
+ async function walk(dir, relativeDir = "", depth = 0) {
966
+ if (entries.size >= PATH_SUGGESTION_SCAN_LIMIT || depth > 6) return;
967
+ let dirEntries;
968
+ try {
969
+ dirEntries = await readdir(dir, { withFileTypes: true });
970
+ } catch {
971
+ return;
972
+ }
973
+ dirEntries.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
974
+ for (const entry of dirEntries) {
975
+ if (entries.size >= PATH_SUGGESTION_SCAN_LIMIT) return;
976
+ const relativePath = normalizeSuggestionPath(relativeDir ? `${relativeDir}/${entry.name}` : entry.name);
977
+ let isDirectory = entry.isDirectory();
978
+ if (!isDirectory && entry.isSymbolicLink()) {
979
+ try {
980
+ isDirectory = (await stat(path.join(dir, entry.name))).isDirectory();
981
+ } catch {
982
+ isDirectory = false;
983
+ }
984
+ }
985
+ if (isDirectory) {
986
+ addSuggestionEntry(entries, `${relativePath}/`, true);
987
+ if (!PATH_SUGGESTION_EXCLUDED_DIRS.has(entry.name)) await walk(path.join(dir, entry.name), relativePath, depth + 1);
988
+ } else {
989
+ addSuggestionEntry(entries, relativePath, false);
990
+ }
991
+ }
992
+ }
993
+ await walk(cwd);
994
+ return [...entries.values()];
995
+ }
996
+
997
+ function isSubsequence(needle, haystack) {
998
+ let index = 0;
999
+ for (const char of haystack) {
1000
+ if (char === needle[index]) index++;
1001
+ if (index >= needle.length) return true;
1002
+ }
1003
+ return needle.length === 0;
1004
+ }
1005
+
1006
+ function scorePathSuggestion(entry, query) {
1007
+ const q = normalizeSuggestionPath(query).replace(/^\.\//, "").replace(/\/+$/, "").toLowerCase();
1008
+ if (!q) return entry.isDirectory ? 2 : 1;
1009
+ const entryPath = entry.path.replace(/\/+$/, "").toLowerCase();
1010
+ const name = path.posix.basename(entryPath);
1011
+ let score = 0;
1012
+ if (name === q) score = 100;
1013
+ else if (name.startsWith(q)) score = 90;
1014
+ else if (entryPath.startsWith(q)) score = 80;
1015
+ else if (name.includes(q)) score = 70;
1016
+ else if (entryPath.includes(q)) score = 55;
1017
+ else if (isSubsequence(q, name)) score = 40;
1018
+ else if (isSubsequence(q, entryPath)) score = 25;
1019
+ if (entry.isDirectory && score > 0) score += 5;
1020
+ return score;
1021
+ }
1022
+
1023
+ function formatRankedPathSuggestions(entries, query) {
1024
+ return entries
1025
+ .map((entry) => ({ ...entry, score: scorePathSuggestion(entry, query) }))
1026
+ .filter((entry) => entry.score > 0)
1027
+ .sort((a, b) => b.score - a.score || a.path.length - b.path.length || a.path.localeCompare(b.path))
1028
+ .slice(0, PATH_SUGGESTION_LIMIT)
1029
+ .map((entry) => ({
1030
+ path: entry.path,
1031
+ label: pathSuggestionLabel(entry.path),
1032
+ type: entry.isDirectory ? "directory" : "file",
1033
+ description: entry.path,
1034
+ }));
1035
+ }
1036
+
1037
+ async function getPathSuggestionData(tab, rawQuery) {
1038
+ const query = cleanPathSuggestionQuery(rawQuery);
1039
+ const shouldUseDirect = !query || query.includes("/") || query.startsWith(".") || query.startsWith("~");
1040
+ let suggestions = shouldUseDirect ? await getDirectPathSuggestions(query, tab.cwd) : [];
1041
+ if (suggestions.length === 0 && query) {
1042
+ const entries = (await getGitPathSuggestionEntries(tab.cwd)) ?? (await getFilesystemPathSuggestionEntries(tab.cwd));
1043
+ suggestions = formatRankedPathSuggestions(entries, query);
1044
+ }
1045
+ return { cwd: tab.cwd, displayCwd: displayPath(tab.cwd), query, suggestions };
1046
+ }
1047
+
475
1048
  async function getWorkspaceInfo(cwd, startedAt) {
476
1049
  const info = {
477
1050
  cwd,
@@ -647,10 +1220,86 @@ async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd)
647
1220
  }
648
1221
  }
649
1222
 
1223
+ function themeLabel(name) {
1224
+ return String(name || "")
1225
+ .split(/[-_]+/)
1226
+ .filter(Boolean)
1227
+ .map((part) => part.length <= 3 ? part.toUpperCase() : `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
1228
+ .join(" ");
1229
+ }
1230
+
1231
+ function stringRecord(value) {
1232
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
1233
+ const record = {};
1234
+ for (const [key, item] of Object.entries(value)) {
1235
+ if (typeof item === "string" || typeof item === "number" || typeof item === "boolean") record[key] = String(item);
1236
+ }
1237
+ return record;
1238
+ }
1239
+
1240
+ async function directoryExists(dir) {
1241
+ try {
1242
+ const info = await stat(dir);
1243
+ return info.isDirectory();
1244
+ } catch {
1245
+ return false;
1246
+ }
1247
+ }
1248
+
1249
+ async function resolveBundledThemesDir() {
1250
+ const candidates = [];
1251
+ try {
1252
+ const manifestPath = require.resolve("@firstpick/pi-themes-bundle/package.json");
1253
+ const root = path.dirname(manifestPath);
1254
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
1255
+ const declaredThemes = Array.isArray(manifest.pi?.themes) ? manifest.pi.themes : ["./themes"];
1256
+ for (const entry of declaredThemes) {
1257
+ if (typeof entry === "string" && entry.trim()) candidates.push(path.resolve(root, entry));
1258
+ }
1259
+ } catch {
1260
+ // In repo development the bundle may be a sibling package rather than an installed dependency.
1261
+ }
1262
+ candidates.push(path.resolve(packageRoot, "..", "pi-package-themes-bundle", "themes"));
1263
+
1264
+ for (const candidate of candidates) {
1265
+ if (await directoryExists(candidate)) return candidate;
1266
+ }
1267
+ return null;
1268
+ }
1269
+
1270
+ function sanitizeBundledTheme(theme, fileName) {
1271
+ const name = typeof theme?.name === "string" && theme.name.trim() ? theme.name.trim() : path.basename(fileName, ".json");
1272
+ return {
1273
+ name,
1274
+ label: themeLabel(name),
1275
+ vars: stringRecord(theme?.vars),
1276
+ colors: stringRecord(theme?.colors),
1277
+ export: stringRecord(theme?.export),
1278
+ };
1279
+ }
1280
+
1281
+ async function readBundledThemes() {
1282
+ const dir = await resolveBundledThemesDir();
1283
+ if (!dir) return { source: "@firstpick/pi-themes-bundle", themes: [] };
1284
+
1285
+ const files = (await readdir(dir)).filter((file) => file.endsWith(".json")).sort((a, b) => a.localeCompare(b));
1286
+ const themes = [];
1287
+ for (const file of files) {
1288
+ try {
1289
+ const raw = await readFile(path.join(dir, file), "utf8");
1290
+ themes.push(sanitizeBundledTheme(JSON.parse(raw), file));
1291
+ } catch (error) {
1292
+ console.error(`Skipping invalid theme ${file}: ${sanitizeError(error)}`);
1293
+ }
1294
+ }
1295
+ themes.sort((a, b) => a.label.localeCompare(b.label));
1296
+ return { source: "@firstpick/pi-themes-bundle", themes };
1297
+ }
1298
+
650
1299
  function normalizeStaticPath(urlPath) {
651
1300
  if (urlPath === "/") return "index.html";
652
1301
  const name = urlPath.startsWith("/") ? urlPath.slice(1) : urlPath;
653
- if (!["index.html", "app.js", "styles.css"].includes(name)) return undefined;
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;
654
1303
  return name;
655
1304
  }
656
1305
 
@@ -753,6 +1402,49 @@ if (options.version) {
753
1402
  process.exit(0);
754
1403
  }
755
1404
 
1405
+ const restoreTabs = readRestoreTabsFromEnv();
1406
+
1407
+ function normalizedRestoreString(value, maxLength) {
1408
+ const text = typeof value === "string" ? value.trim() : "";
1409
+ return text ? text.slice(0, maxLength) : undefined;
1410
+ }
1411
+
1412
+ function normalizeRestoreTabDescriptor(item, seenIds) {
1413
+ if (!item || typeof item !== "object") return null;
1414
+ const state = item.state && typeof item.state === "object" ? item.state : {};
1415
+ const rawId = normalizedRestoreString(item.id, 128);
1416
+ const id = rawId && /^[A-Za-z0-9._:-]+$/.test(rawId) && !seenIds.has(rawId) ? rawId : undefined;
1417
+ if (id) seenIds.add(id);
1418
+
1419
+ const descriptor = {
1420
+ id,
1421
+ title: normalizedRestoreString(item.title, 160),
1422
+ titleSource: ["explicit", "auto", "default"].includes(item.titleSource) ? item.titleSource : undefined,
1423
+ cwd: normalizedRestoreString(item.cwd || item.workspace?.cwd, 4096),
1424
+ conversationStarted: item.conversationStarted === true,
1425
+ sessionFile: normalizedRestoreString(item.sessionFile || state.sessionFile, 4096),
1426
+ };
1427
+
1428
+ if (Number.isInteger(item.index) && item.index > 0) descriptor.index = item.index;
1429
+ return descriptor;
1430
+ }
1431
+
1432
+ function readRestoreTabsFromEnv() {
1433
+ const raw = process.env.PI_WEBUI_RESTORE_TABS;
1434
+ delete process.env.PI_WEBUI_RESTORE_TABS;
1435
+ if (!raw) return [];
1436
+
1437
+ try {
1438
+ const parsed = JSON.parse(raw);
1439
+ const items = Array.isArray(parsed) ? parsed : [];
1440
+ const seenIds = new Set();
1441
+ return items.map((item) => normalizeRestoreTabDescriptor(item, seenIds)).filter(Boolean).slice(0, RESTORE_TAB_LIMIT);
1442
+ } catch (error) {
1443
+ console.warn(`failed to parse PI_WEBUI_RESTORE_TABS: ${sanitizeError(error)}`);
1444
+ return [];
1445
+ }
1446
+ }
1447
+
756
1448
  function buildPiArgsForTab(tabIndex, title) {
757
1449
  const args = ["--mode", "rpc"];
758
1450
  if (options.noSession) args.push("--no-session");
@@ -783,7 +1475,254 @@ async function resolvePiCommand(piArgs) {
783
1475
  }
784
1476
 
785
1477
  const tabs = new Map();
1478
+ const closedRestorableTabs = [];
786
1479
  let nextTabIndex = 1;
1480
+ const TAB_ACTIVITY_IDLE_RECONCILE_GRACE_MS = 1200;
1481
+ const TAB_ACTIVITY_STATE_RECONCILE_INTERVAL_MS = 2500;
1482
+ const TAB_ACTIVITY_STATE_RECONCILE_TIMEOUT_MS = 1200;
1483
+
1484
+ function sessionFileFromState(state) {
1485
+ return state && typeof state === "object" ? normalizedRestoreString(state.sessionFile, 4096) : undefined;
1486
+ }
1487
+
1488
+ function rememberTabState(tab, state) {
1489
+ if (!tab || !state || typeof state !== "object") return;
1490
+ tab.lastState = state;
1491
+ if (!options.noSession && Object.prototype.hasOwnProperty.call(state, "sessionFile")) tab.sessionFile = sessionFileFromState(state);
1492
+ }
1493
+
1494
+ function forgetTabState(tab) {
1495
+ if (!tab) return;
1496
+ tab.lastState = null;
1497
+ tab.sessionFile = undefined;
1498
+ }
1499
+
1500
+ function tabRestorableSessionFile(tab) {
1501
+ if (options.noSession) return undefined;
1502
+ return normalizedRestoreString(tab?.sessionFile || tab?.lastState?.sessionFile, 4096);
1503
+ }
1504
+
1505
+ function createTabActivity(now = new Date().toISOString()) {
1506
+ return {
1507
+ status: "idle",
1508
+ isWorking: false,
1509
+ completionSerial: 0,
1510
+ lastChangedAt: now,
1511
+ lastStartedAt: null,
1512
+ lastCompletedAt: null,
1513
+ };
1514
+ }
1515
+
1516
+ function resetTabActivity(tab) {
1517
+ tab.activity = createTabActivity();
1518
+ }
1519
+
1520
+ function tabActivitySnapshot(tab) {
1521
+ return { ...(tab.activity || createTabActivity(tab.createdAt)) };
1522
+ }
1523
+
1524
+ function pendingExtensionUiMap(tab) {
1525
+ if (!tab.pendingExtensionUiRequests) tab.pendingExtensionUiRequests = new Map();
1526
+ return tab.pendingExtensionUiRequests;
1527
+ }
1528
+
1529
+ function isPendingExtensionUiRequest(event) {
1530
+ return event?.type === "extension_ui_request" && EXTENSION_UI_BLOCKING_METHODS.has(event.method) && event.id;
1531
+ }
1532
+
1533
+ function pruneExpiredPendingExtensionUiRequests(tab, nowMs = Date.now()) {
1534
+ const pending = tab?.pendingExtensionUiRequests;
1535
+ if (!pending) return;
1536
+ for (const [id, request] of pending) {
1537
+ const expiresAtMs = Date.parse(request.expiresAt || "");
1538
+ if (Number.isFinite(expiresAtMs) && expiresAtMs <= nowMs) pending.delete(id);
1539
+ }
1540
+ }
1541
+
1542
+ function pendingExtensionUiRequests(tab) {
1543
+ pruneExpiredPendingExtensionUiRequests(tab);
1544
+ return [...(tab?.pendingExtensionUiRequests?.values() || [])];
1545
+ }
1546
+
1547
+ function pendingExtensionUiRequestSummaries(tab) {
1548
+ return pendingExtensionUiRequests(tab).map((request) => ({
1549
+ id: request.id,
1550
+ method: request.method,
1551
+ title: truncateStatusText(request.title || request.placeholder || "", 120),
1552
+ message: request.message ? truncateStatusText(request.message, 180) : undefined,
1553
+ receivedAt: request.receivedAt,
1554
+ expiresAt: request.expiresAt,
1555
+ }));
1556
+ }
1557
+
1558
+ function trackPendingExtensionUiRequest(tab, event) {
1559
+ if (!isPendingExtensionUiRequest(event)) return;
1560
+ const receivedAt = new Date().toISOString();
1561
+ const timeoutMs = Number(event.timeout);
1562
+ const expiresAt = Number.isFinite(timeoutMs) && timeoutMs > 0 ? new Date(Date.parse(receivedAt) + timeoutMs + 1000).toISOString() : undefined;
1563
+ pendingExtensionUiMap(tab).set(String(event.id), { ...event, receivedAt, expiresAt });
1564
+ if (!tab.activity?.isWorking) markTabWorking(tab, receivedAt);
1565
+ }
1566
+
1567
+ function resolvePendingExtensionUiRequest(tab, id) {
1568
+ if (!id) return false;
1569
+ return !!tab?.pendingExtensionUiRequests?.delete(String(id));
1570
+ }
1571
+
1572
+ function clearPendingExtensionUiRequests(tab) {
1573
+ tab?.pendingExtensionUiRequests?.clear();
1574
+ }
1575
+
1576
+ function replayPendingExtensionUiRequests(tab, res) {
1577
+ const pending = pendingExtensionUiRequests(tab);
1578
+ for (const request of pending) {
1579
+ sendSse(res, {
1580
+ ...request,
1581
+ type: "extension_ui_request",
1582
+ replayed: true,
1583
+ tabId: tab.id,
1584
+ tabTitle: tab.title,
1585
+ pendingExtensionUiRequestCount: pending.length,
1586
+ tabActivity: tabActivitySnapshot(tab),
1587
+ });
1588
+ }
1589
+ }
1590
+
1591
+ async function cancelPendingExtensionUiRequests(tab) {
1592
+ const pending = pendingExtensionUiRequests(tab);
1593
+ if (!pending.length) return 0;
1594
+ const ids = [];
1595
+ for (const request of pending) {
1596
+ ids.push(String(request.id));
1597
+ try {
1598
+ await tab.rpc.writeRaw({ type: "extension_ui_response", id: request.id, cancelled: true });
1599
+ } catch {
1600
+ // Abort should remain best-effort even if the RPC process already exited.
1601
+ }
1602
+ resolvePendingExtensionUiRequest(tab, request.id);
1603
+ }
1604
+ broadcastTabEvent(tab, {
1605
+ type: "webui_extension_ui_cancelled",
1606
+ tabId: tab.id,
1607
+ tabTitle: tab.title,
1608
+ ids,
1609
+ pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length,
1610
+ tabActivity: tabActivitySnapshot(tab),
1611
+ });
1612
+ return ids.length;
1613
+ }
1614
+
1615
+ function markTabWorking(tab, timestamp = new Date().toISOString()) {
1616
+ const activity = tab.activity || createTabActivity(timestamp);
1617
+ activity.status = "working";
1618
+ activity.isWorking = true;
1619
+ activity.lastStartedAt = timestamp;
1620
+ activity.lastChangedAt = timestamp;
1621
+ tab.activity = activity;
1622
+ }
1623
+
1624
+ function markTabDone(tab, timestamp = new Date().toISOString()) {
1625
+ const activity = tab.activity || createTabActivity(timestamp);
1626
+ activity.status = "done";
1627
+ activity.isWorking = false;
1628
+ activity.completionSerial = (Number(activity.completionSerial) || 0) + 1;
1629
+ activity.lastCompletedAt = timestamp;
1630
+ activity.lastChangedAt = timestamp;
1631
+ tab.activity = activity;
1632
+ }
1633
+
1634
+ function markTabIdle(tab, timestamp = new Date().toISOString()) {
1635
+ const activity = tab.activity || createTabActivity(timestamp);
1636
+ activity.status = "idle";
1637
+ activity.isWorking = false;
1638
+ activity.lastChangedAt = timestamp;
1639
+ tab.activity = activity;
1640
+ }
1641
+
1642
+ function commandStartsVisibleWork(command) {
1643
+ return command?.type === "compact" || (command?.type === "prompt" && !command.streamingBehavior);
1644
+ }
1645
+
1646
+ function commandStartsConversation(command) {
1647
+ return command?.type === "prompt" && !command.streamingBehavior;
1648
+ }
1649
+
1650
+ function stateHasVisibleWork(state) {
1651
+ return !!state?.isStreaming || !!state?.isCompacting || Number(state?.pendingMessageCount || 0) > 0;
1652
+ }
1653
+
1654
+ function activityRecentlyStarted(activity, nowMs = Date.now()) {
1655
+ const startedMs = Date.parse(activity?.lastStartedAt || activity?.lastChangedAt || "");
1656
+ return Number.isFinite(startedMs) && nowMs - startedMs < TAB_ACTIVITY_IDLE_RECONCILE_GRACE_MS;
1657
+ }
1658
+
1659
+ function reconcileTabActivityFromState(tab, state, timestamp = new Date().toISOString()) {
1660
+ if (!tab) return createTabActivity(timestamp);
1661
+ if (!state || typeof state !== "object") return tabActivitySnapshot(tab);
1662
+ if (pendingExtensionUiRequests(tab).length > 0) {
1663
+ if (!tab.activity?.isWorking) markTabWorking(tab, timestamp);
1664
+ return tabActivitySnapshot(tab);
1665
+ }
1666
+ if (stateHasVisibleWork(state)) {
1667
+ if (!tab.activity?.isWorking) markTabWorking(tab, timestamp);
1668
+ return tabActivitySnapshot(tab);
1669
+ }
1670
+ if (tab.activity?.isWorking && !activityRecentlyStarted(tab.activity)) {
1671
+ markTabDone(tab, timestamp);
1672
+ }
1673
+ return tabActivitySnapshot(tab);
1674
+ }
1675
+
1676
+ async function reconcileWorkingTabActivity(tab) {
1677
+ if (!tab?.activity?.isWorking) return;
1678
+ if (activityRecentlyStarted(tab.activity)) return;
1679
+ const now = Date.now();
1680
+ if (now - (tab.activityStateReconcileAt || 0) < TAB_ACTIVITY_STATE_RECONCILE_INTERVAL_MS) return;
1681
+ tab.activityStateReconcileAt = now;
1682
+ try {
1683
+ const response = await tab.rpc.send({ type: "get_state" }, TAB_ACTIVITY_STATE_RECONCILE_TIMEOUT_MS);
1684
+ if (response?.success !== false) {
1685
+ rememberTabState(tab, response.data);
1686
+ reconcileTabActivityFromState(tab, response.data);
1687
+ }
1688
+ } catch {
1689
+ // Ignore reconciliation failures; normal RPC events will still update activity.
1690
+ }
1691
+ }
1692
+
1693
+ async function listTabsWithReconciledActivity() {
1694
+ await Promise.all([...tabs.values()].map(reconcileWorkingTabActivity));
1695
+ return listTabs();
1696
+ }
1697
+
1698
+ function updateTabActivityFromEvent(tab, event) {
1699
+ const timestamp = new Date().toISOString();
1700
+ switch (event?.type) {
1701
+ case "agent_start":
1702
+ case "compaction_start":
1703
+ markTabWorking(tab, timestamp);
1704
+ break;
1705
+ case "agent_end":
1706
+ case "compaction_end":
1707
+ markTabDone(tab, timestamp);
1708
+ break;
1709
+ case "pi_process_exit":
1710
+ case "pi_process_error":
1711
+ if (tab.activity?.isWorking) markTabDone(tab, timestamp);
1712
+ else markTabIdle(tab, timestamp);
1713
+ break;
1714
+ case "response":
1715
+ if (event.command === "get_state" && event.success !== false) {
1716
+ rememberTabState(tab, event.data);
1717
+ reconcileTabActivityFromState(tab, event.data, timestamp);
1718
+ } else if (!tab.activity) tab.activity = createTabActivity(timestamp);
1719
+ break;
1720
+ default:
1721
+ if (!tab.activity) tab.activity = createTabActivity(timestamp);
1722
+ break;
1723
+ }
1724
+ return tabActivitySnapshot(tab);
1725
+ }
787
1726
 
788
1727
  function defaultTabTitle(tabIndex) {
789
1728
  if (options.name) return tabIndex === 1 ? options.name : `${options.name} ${tabIndex}`;
@@ -794,25 +1733,42 @@ function attachRpcToTab(tab, rpc) {
794
1733
  tab.rpcUnsubscribe?.();
795
1734
  tab.rpc = rpc;
796
1735
  tab.rpcUnsubscribe = rpc.onEvent((event) => {
797
- const scopedEvent = { ...event, tabId: tab.id, tabTitle: tab.title };
1736
+ updateTabActivityFromEvent(tab, event);
1737
+ let scopedEvent = { ...event, tabId: tab.id, tabTitle: tab.title, tabActivity: tabActivitySnapshot(tab) };
1738
+ if (event?.type === "pi_process_exit" || event?.type === "pi_process_error") clearPendingExtensionUiRequests(tab);
1739
+ else trackPendingExtensionUiRequest(tab, scopedEvent);
1740
+ scopedEvent = { ...scopedEvent, tabActivity: tabActivitySnapshot(tab), pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length };
1741
+ recordEvent(scopedEvent);
798
1742
  for (const client of tab.sseClients) sendSse(client, scopedEvent);
799
1743
  });
800
1744
  }
801
1745
 
802
- async function createTab({ title, cwd } = {}) {
803
- const tabIndex = nextTabIndex++;
804
- const tabTitle = String(title || "").trim() || defaultTabTitle(tabIndex);
1746
+ async function createTab({ id: requestedId, index, title, titleSource, conversationStarted, cwd, sessionFile } = {}) {
1747
+ const tabIndex = Number.isInteger(index) && index > 0 ? index : nextTabIndex;
1748
+ nextTabIndex = Math.max(nextTabIndex, tabIndex + 1);
1749
+ const explicitTitle = String(title || "").trim();
1750
+ const tabTitle = explicitTitle || defaultTabTitle(tabIndex);
1751
+ const titleIsExplicit = Boolean(explicitTitle || (options.name && tabIndex === 1));
1752
+ const resolvedTitleSource = ["explicit", "auto", "default"].includes(titleSource) ? titleSource : titleIsExplicit ? "explicit" : "default";
805
1753
  const tabCwd = cwd ? await resolveCwd(cwd, options.cwd) : options.cwd;
806
- const id = randomUUID();
1754
+ const id = requestedId && !tabs.has(requestedId) ? requestedId : randomUUID();
807
1755
  const piArgs = buildPiArgsForTab(tabIndex, tabTitle);
1756
+ if (sessionFile && !options.noSession) piArgs.push("--session", sessionFile);
808
1757
  const piCommand = await resolvePiCommand(piArgs);
809
1758
  const rpc = new PiRpcProcess({ ...piCommand, cwd: tabCwd });
1759
+ const createdAt = new Date().toISOString();
810
1760
  const tab = {
811
1761
  id,
812
1762
  index: tabIndex,
813
1763
  title: tabTitle,
1764
+ titleSource: resolvedTitleSource,
1765
+ conversationStarted: conversationStarted === true,
814
1766
  cwd: tabCwd,
815
- createdAt: new Date().toISOString(),
1767
+ createdAt,
1768
+ sessionFile: options.noSession ? undefined : normalizedRestoreString(sessionFile, 4096),
1769
+ lastState: null,
1770
+ activity: createTabActivity(createdAt),
1771
+ pendingExtensionUiRequests: new Map(),
816
1772
  rpc: undefined,
817
1773
  rpcUnsubscribe: undefined,
818
1774
  sseClients: new Set(),
@@ -821,6 +1777,9 @@ async function createTab({ title, cwd } = {}) {
821
1777
  attachRpcToTab(tab, rpc);
822
1778
  tabs.set(id, tab);
823
1779
  rpc.start();
1780
+ if (sessionFile && !options.noSession) {
1781
+ recordEvent({ type: "webui_tab_restored", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd });
1782
+ }
824
1783
  return tab;
825
1784
  }
826
1785
 
@@ -833,13 +1792,18 @@ function tabMeta(tab) {
833
1792
  id: tab.id,
834
1793
  index: tab.index,
835
1794
  title: tab.title,
1795
+ titleSource: tab.titleSource || "default",
1796
+ conversationStarted: !!tab.conversationStarted,
836
1797
  cwd: tab.cwd,
1798
+ sessionFile: tabRestorableSessionFile(tab),
837
1799
  createdAt: tab.createdAt,
838
1800
  startedAt: tab.rpc.startedAt,
839
1801
  pid: tab.rpc.child?.pid,
840
1802
  running: !!tab.rpc.child && tab.rpc.child.exitCode === null,
841
1803
  command: tab.rpc.displayCommand,
842
1804
  clientCount: tab.sseClients.size,
1805
+ pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length,
1806
+ activity: tabActivitySnapshot(tab),
843
1807
  };
844
1808
  }
845
1809
 
@@ -847,6 +1811,96 @@ function listTabs() {
847
1811
  return [...tabs.values()].map(tabMeta);
848
1812
  }
849
1813
 
1814
+ function restorableTabDescriptor(tab, state = null) {
1815
+ return normalizeRestoreTabDescriptor({
1816
+ id: tab.id,
1817
+ index: tab.index,
1818
+ title: tab.title,
1819
+ titleSource: tab.titleSource,
1820
+ conversationStarted: tab.conversationStarted,
1821
+ cwd: tab.cwd,
1822
+ sessionFile: sessionFileFromState(state) || tabRestorableSessionFile(tab),
1823
+ }, new Set());
1824
+ }
1825
+
1826
+ function restorableTabKey(tab) {
1827
+ if (tab.id) return `id:${tab.id}`;
1828
+ if (tab.sessionFile) return `session:${tab.sessionFile}`;
1829
+ return `tab:${tab.index || "?"}:${tab.title || ""}:${tab.cwd || ""}`;
1830
+ }
1831
+
1832
+ function restorableTabSortIndex(tab) {
1833
+ return Number.isInteger(tab.index) && tab.index > 0 ? tab.index : Number.MAX_SAFE_INTEGER;
1834
+ }
1835
+
1836
+ function mergeRestorableTabDescriptors(...sources) {
1837
+ const merged = [];
1838
+ const seen = new Set();
1839
+ for (const source of sources) {
1840
+ for (const item of Array.isArray(source) ? source : []) {
1841
+ const descriptor = normalizeRestoreTabDescriptor(item, new Set());
1842
+ if (!descriptor) continue;
1843
+ const key = restorableTabKey(descriptor);
1844
+ if (seen.has(key)) continue;
1845
+ seen.add(key);
1846
+ merged.push(descriptor);
1847
+ }
1848
+ }
1849
+ return merged
1850
+ .sort((a, b) => restorableTabSortIndex(a) - restorableTabSortIndex(b) || String(a.title || "").localeCompare(String(b.title || "")))
1851
+ .slice(0, RESTORE_TAB_LIMIT);
1852
+ }
1853
+
1854
+ function rememberClosedRestorableTab(tab, state = null) {
1855
+ const descriptor = restorableTabDescriptor(tab, state);
1856
+ if (!descriptor) return;
1857
+ const key = restorableTabKey(descriptor);
1858
+ const existingIndex = closedRestorableTabs.findIndex((item) => restorableTabKey(item) === key);
1859
+ if (existingIndex !== -1) closedRestorableTabs.splice(existingIndex, 1);
1860
+ closedRestorableTabs.push(descriptor);
1861
+ while (closedRestorableTabs.length > RESTORE_TAB_LIMIT) closedRestorableTabs.shift();
1862
+ }
1863
+
1864
+ function broadcastTabEvent(tab, event) {
1865
+ recordEvent(event);
1866
+ for (const client of tab.sseClients) sendSse(client, event);
1867
+ }
1868
+
1869
+ function renameTab(tab, title, { source = "explicit", maxLength, unique = source === "auto" } = {}) {
1870
+ if (!tab) return false;
1871
+ const rawTitle = maxLength ? truncateTabTitle(title, maxLength) : String(title || "").replace(/\s+/g, " ").trim();
1872
+ const nextTitle = unique ? uniqueTabTitle(rawTitle, tab, maxLength || AUTO_TAB_TITLE_MAX_LENGTH) : rawTitle;
1873
+ if (!nextTitle) return false;
1874
+
1875
+ const previousTitle = tab.title;
1876
+ tab.title = nextTitle;
1877
+ tab.titleSource = source;
1878
+ if (previousTitle === nextTitle) return false;
1879
+
1880
+ broadcastTabEvent(tab, {
1881
+ type: "webui_tab_renamed",
1882
+ tabId: tab.id,
1883
+ tabTitle: tab.title,
1884
+ previousTabTitle: previousTitle,
1885
+ titleSource: source,
1886
+ tab: tabMeta(tab),
1887
+ tabActivity: tabActivitySnapshot(tab),
1888
+ });
1889
+ return true;
1890
+ }
1891
+
1892
+ function maybeNameTabForConversation(tab, command) {
1893
+ if (!tab || !commandStartsConversation(command) || tab.conversationStarted || tab.titleSource === "explicit") return false;
1894
+ tab.conversationStarted = true;
1895
+ const title = generatedTabTitleFromPrompt(command.message) || `Conversation ${tab.index}`;
1896
+ return renameTab(tab, title, { source: "auto", maxLength: AUTO_TAB_TITLE_MAX_LENGTH });
1897
+ }
1898
+
1899
+ function responseWithTab(response, tab) {
1900
+ if (!response || typeof response !== "object") return response;
1901
+ return { ...response, tab: tabMeta(tab) };
1902
+ }
1903
+
850
1904
  async function updateTabCwd(id, cwd) {
851
1905
  const tab = tabs.get(id);
852
1906
  if (!tab) throw makeHttpError(404, `Unknown Pi tab: ${id}`);
@@ -856,8 +1910,10 @@ async function updateTabCwd(id, cwd) {
856
1910
 
857
1911
  const piArgs = buildPiArgsForTab(tab.index, tab.title);
858
1912
  const piCommand = await resolvePiCommand(piArgs);
1913
+ const restartingEvent = { type: "webui_tab_restarting", tabId: tab.id, tabTitle: tab.title, cwd: nextCwd };
1914
+ recordEvent(restartingEvent);
859
1915
  for (const client of tab.sseClients) {
860
- sendSse(client, { type: "webui_tab_restarting", tabId: tab.id, tabTitle: tab.title, cwd: nextCwd });
1916
+ sendSse(client, restartingEvent);
861
1917
  }
862
1918
 
863
1919
  const oldRpc = tab.rpc;
@@ -866,23 +1922,155 @@ async function updateTabCwd(id, cwd) {
866
1922
  oldRpc.stop();
867
1923
 
868
1924
  tab.cwd = nextCwd;
1925
+ forgetTabState(tab);
1926
+ resetTabActivity(tab);
1927
+ clearPendingExtensionUiRequests(tab);
869
1928
  const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
870
1929
  attachRpcToTab(tab, rpc);
871
1930
  rpc.start();
872
1931
 
1932
+ const changedEvent = { type: "webui_cwd_changed", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, pid: tab.rpc.child?.pid, tabActivity: tabActivitySnapshot(tab) };
1933
+ recordEvent(changedEvent);
873
1934
  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 });
1935
+ sendSse(client, changedEvent);
875
1936
  }
876
1937
  return { tab, changed: true };
877
1938
  }
878
1939
 
879
- function closeTab(id) {
1940
+ async function restartTabRpc(tab, reason = "reload") {
1941
+ const state = await tab.rpc.send({ type: "get_state" });
1942
+ if (state.success === false) throw makeHttpError(400, state.error || "Unable to read Pi state before reload");
1943
+ rememberTabState(tab, state.data);
1944
+ if (state.data?.isStreaming) throw makeHttpError(409, "Wait for the current response to finish before reloading.");
1945
+ if (state.data?.isCompacting) throw makeHttpError(409, "Wait for compaction to finish before reloading.");
1946
+
1947
+ const piArgs = buildPiArgsForTab(tab.index, tab.title);
1948
+ if (state.data?.sessionFile && !options.noSession) piArgs.push("--session", state.data.sessionFile);
1949
+ const piCommand = await resolvePiCommand(piArgs);
1950
+ const reloadingEvent = { type: "webui_tab_reloading", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, reason, sessionFile: state.data?.sessionFile };
1951
+ recordEvent(reloadingEvent);
1952
+ for (const client of tab.sseClients) sendSse(client, reloadingEvent);
1953
+
1954
+ const oldRpc = tab.rpc;
1955
+ tab.rpcUnsubscribe?.();
1956
+ tab.rpcUnsubscribe = undefined;
1957
+ oldRpc.stop();
1958
+
1959
+ resetTabActivity(tab);
1960
+ clearPendingExtensionUiRequests(tab);
1961
+ const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
1962
+ attachRpcToTab(tab, rpc);
1963
+ rpc.start();
1964
+
1965
+ 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, tabActivity: tabActivitySnapshot(tab) };
1966
+ recordEvent(reloadedEvent);
1967
+ for (const client of tab.sseClients) sendSse(client, reloadedEvent);
1968
+ return tab;
1969
+ }
1970
+
1971
+ 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 || [])] };
1975
+ }
1976
+
1977
+ function formatSessionOutput(tab, state, stats) {
1978
+ return [
1979
+ `Session: ${state.sessionName || state.sessionId || "unknown"}`,
1980
+ `Tab: ${tab.title}`,
1981
+ `CWD: ${tab.cwd}`,
1982
+ `Model: ${state.model ? `${state.model.provider}/${state.model.id}` : "none"}`,
1983
+ `Thinking: ${state.thinkingLevel || "unknown"}`,
1984
+ `Status: ${state.isStreaming ? "running" : state.isCompacting ? "compacting" : "idle"}`,
1985
+ `Messages: ${state.messageCount ?? "?"}`,
1986
+ `Queue: ${state.pendingMessageCount ?? 0}`,
1987
+ `Session file: ${state.sessionFile || "none"}`,
1988
+ stats ? `Tokens: input ${stats.tokens?.input ?? 0}, output ${stats.tokens?.output ?? 0}, cache read ${stats.tokens?.cacheRead ?? 0}` : undefined,
1989
+ stats?.cost !== undefined ? `Cost: ${stats.cost}` : undefined,
1990
+ ].filter(Boolean).join("\n");
1991
+ }
1992
+
1993
+ function webuiHotkeysOutput() {
1994
+ return [
1995
+ "Web UI hotkeys:",
1996
+ "Enter: send on desktop; newline on mobile",
1997
+ "Ctrl/Cmd+Enter: send from textarea",
1998
+ "Tab: accept slash-command or @path suggestion",
1999
+ "Arrow up/down: move through slash-command or @path suggestions",
2000
+ "Escape: close actions, tabs, model picker, or mobile drawer",
2001
+ "Mobile: Send button submits; Return inserts a newline",
2002
+ ].join("\n");
2003
+ }
2004
+
2005
+ async function handleNativeSlashCommand(tab, body) {
2006
+ const parsed = parseSlashCommand(body.message);
2007
+ if (!parsed) return undefined;
2008
+
2009
+ switch (parsed.name) {
2010
+ case "reload": {
2011
+ const reloaded = await restartTabRpc(tab, "slash-command");
2012
+ return rpcSuccess("native_slash_command", { command: "reload", tab: tabMeta(reloaded), message: "Reloaded keybindings, extensions, skills, prompts, and themes." });
2013
+ }
2014
+ case "new": {
2015
+ const response = await tab.rpc.send({ type: "new_session" });
2016
+ if (response.success === false) return response;
2017
+ tab.conversationStarted = false;
2018
+ return rpcSuccess("native_slash_command", { command: "new", tab: tabMeta(tab), message: "Started a new session.", result: response.data });
2019
+ }
2020
+ case "compact": {
2021
+ const response = await tab.rpc.send(parsed.args ? { type: "compact", customInstructions: parsed.args } : { type: "compact" });
2022
+ return response.success === false ? response : rpcSuccess("native_slash_command", { command: "compact", message: "Compaction finished.", result: response.data });
2023
+ }
2024
+ case "name": {
2025
+ if (!parsed.args) throw makeHttpError(400, "Usage: /name <session name>");
2026
+ const response = await tab.rpc.send({ type: "set_session_name", name: parsed.args });
2027
+ if (response.success === false) return response;
2028
+ renameTab(tab, parsed.args, { source: "explicit" });
2029
+ return rpcSuccess("native_slash_command", { command: "name", tab: tabMeta(tab), message: `Session and tab name set to: ${tab.title}` });
2030
+ }
2031
+ case "session": {
2032
+ const [state, stats] = await Promise.all([
2033
+ tab.rpc.send({ type: "get_state" }),
2034
+ tab.rpc.send({ type: "get_session_stats" }).catch((error) => ({ success: false, error: sanitizeError(error) })),
2035
+ ]);
2036
+ if (state.success === false) return state;
2037
+ return rpcSuccess("native_slash_command", { command: "session", message: formatSessionOutput(tab, state.data || {}, stats.success === false ? null : stats.data) });
2038
+ }
2039
+ case "copy": {
2040
+ const response = await tab.rpc.send({ type: "get_last_assistant_text" });
2041
+ if (response.success === false) return response;
2042
+ const text = String(response.data?.text || "");
2043
+ if (!text.trim()) throw makeHttpError(400, "No assistant message to copy.");
2044
+ return rpcSuccess("native_slash_command", { command: "copy", message: "Copied the last assistant message.", copyText: text });
2045
+ }
2046
+ case "hotkeys": {
2047
+ return rpcSuccess("native_slash_command", { command: "hotkeys", message: webuiHotkeysOutput() });
2048
+ }
2049
+ 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 });
2052
+ }
2053
+ default:
2054
+ throw makeHttpError(400, `/${parsed.name} is a native Pi TUI command, but this Web UI cannot run that interactive command yet.`);
2055
+ }
2056
+ }
2057
+
2058
+ async function closeTab(id) {
880
2059
  const tab = tabs.get(id);
881
2060
  if (!tab) throw makeHttpError(404, `Unknown Pi tab: ${id}`);
882
2061
  if (tabs.size <= 1) throw makeHttpError(400, "Cannot close the last Pi tab");
883
2062
 
2063
+ let restorableState = null;
2064
+ if (!options.noSession) {
2065
+ const stateResult = await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
2066
+ if (stateResult.ok) restorableState = stateResult.data;
2067
+ }
2068
+ rememberClosedRestorableTab(tab, restorableState);
2069
+
2070
+ const closingEvent = { type: "webui_tab_closing", tabId: tab.id, tabTitle: tab.title };
2071
+ recordEvent(closingEvent);
884
2072
  for (const client of tab.sseClients) {
885
- sendSse(client, { type: "webui_tab_closing", tabId: tab.id, tabTitle: tab.title });
2073
+ sendSse(client, closingEvent);
886
2074
  client.end();
887
2075
  }
888
2076
  tab.sseClients.clear();
@@ -910,9 +2098,27 @@ function getRequestedTab(req, url, body = {}) {
910
2098
  return tab;
911
2099
  }
912
2100
 
913
- const initialTab = await createTab();
2101
+ async function createInitialTabs() {
2102
+ if (!restoreTabs.length) return [await createTab()];
2103
+
2104
+ const created = [];
2105
+ for (const descriptor of restoreTabs) {
2106
+ try {
2107
+ created.push(await createTab(descriptor));
2108
+ } catch (error) {
2109
+ console.warn(`failed to restore Web UI tab ${descriptor.title || descriptor.id || "unknown"}: ${sanitizeError(error)}`);
2110
+ }
2111
+ }
2112
+
2113
+ return created.length ? created : [await createTab()];
2114
+ }
2115
+
2116
+ const serverStartedAt = new Date().toISOString();
2117
+ const initialTabs = await createInitialTabs();
2118
+ const initialTab = initialTabs[0];
914
2119
  let currentHost = options.host;
915
2120
  let networkRebindInProgress = false;
2121
+ let networkRebindTargetHost = null;
916
2122
 
917
2123
  function localNetworkAddresses() {
918
2124
  const addresses = [];
@@ -927,10 +2133,14 @@ function localNetworkAddresses() {
927
2133
 
928
2134
  function networkStatus() {
929
2135
  const open = !isLocalHost(currentHost);
2136
+ const targetHost = networkRebindTargetHost || currentHost;
2137
+ const opening = networkRebindInProgress && !isLocalHost(targetHost);
2138
+ const closing = networkRebindInProgress && isLocalHost(targetHost);
930
2139
  const networkUrls = open ? localNetworkAddresses().map((address) => `http://${address}:${options.port}/`) : [];
931
2140
  return {
932
2141
  open,
933
- opening: networkRebindInProgress,
2142
+ opening,
2143
+ closing,
934
2144
  host: currentHost,
935
2145
  port: options.port,
936
2146
  localUrl: `http://127.0.0.1:${options.port}/`,
@@ -940,8 +2150,18 @@ function networkStatus() {
940
2150
 
941
2151
  function closeSseClientsForRebind(nextHost) {
942
2152
  for (const tab of tabs.values()) {
2153
+ const rebindEvent = {
2154
+ type: "webui_network_rebinding",
2155
+ tabId: tab.id,
2156
+ tabTitle: tab.title,
2157
+ host: nextHost,
2158
+ port: options.port,
2159
+ opening: !isLocalHost(nextHost),
2160
+ closing: isLocalHost(nextHost),
2161
+ };
2162
+ recordEvent(rebindEvent);
943
2163
  for (const client of tab.sseClients) {
944
- sendSse(client, { type: "webui_network_rebinding", tabId: tab.id, tabTitle: tab.title, host: nextHost, port: options.port });
2164
+ sendSse(client, rebindEvent);
945
2165
  client.end();
946
2166
  }
947
2167
  tab.sseClients.clear();
@@ -954,10 +2174,18 @@ function closeServerListener() {
954
2174
  resolve();
955
2175
  return;
956
2176
  }
2177
+ const forceCloseTimer = setTimeout(() => {
2178
+ // Rebinding is intentionally disruptive. Long-poll/SSE/keep-alive clients can
2179
+ // otherwise keep server.close() pending and leave currentHost stuck on 0.0.0.0.
2180
+ server.closeAllConnections?.();
2181
+ }, NETWORK_REBIND_FORCE_CLOSE_MS);
2182
+ forceCloseTimer.unref?.();
957
2183
  server.close((error) => {
2184
+ clearTimeout(forceCloseTimer);
958
2185
  if (error) reject(error);
959
2186
  else resolve();
960
2187
  });
2188
+ server.closeIdleConnections?.();
961
2189
  });
962
2190
  }
963
2191
 
@@ -986,6 +2214,7 @@ async function openToLocalNetwork() {
986
2214
  if (!isLocalHost(currentHost) || networkRebindInProgress) return networkStatus();
987
2215
 
988
2216
  networkRebindInProgress = true;
2217
+ networkRebindTargetHost = nextHost;
989
2218
  closeSseClientsForRebind(nextHost);
990
2219
  const previousHost = currentHost;
991
2220
  try {
@@ -1006,7 +2235,115 @@ async function openToLocalNetwork() {
1006
2235
  throw error;
1007
2236
  } finally {
1008
2237
  networkRebindInProgress = false;
2238
+ networkRebindTargetHost = null;
2239
+ }
2240
+ }
2241
+
2242
+ async function closeNetworkAccess() {
2243
+ const nextHost = "127.0.0.1";
2244
+ if (isLocalHost(currentHost) || networkRebindInProgress) return networkStatus();
2245
+
2246
+ networkRebindInProgress = true;
2247
+ networkRebindTargetHost = nextHost;
2248
+ closeSseClientsForRebind(nextHost);
2249
+ const previousHost = currentHost;
2250
+ try {
2251
+ await closeServerListener();
2252
+ await listenOn(nextHost);
2253
+ currentHost = nextHost;
2254
+ console.warn("Web UI network access closed; listening on localhost only.");
2255
+ return networkStatus();
2256
+ } catch (error) {
2257
+ console.error("Failed to close Web UI network access:", sanitizeError(error));
2258
+ if (!server.listening) {
2259
+ try {
2260
+ await listenOn(previousHost);
2261
+ } catch (restoreError) {
2262
+ console.error("Failed to restore Web UI listener:", sanitizeError(restoreError));
2263
+ }
2264
+ }
2265
+ throw error;
2266
+ } finally {
2267
+ networkRebindInProgress = false;
2268
+ networkRebindTargetHost = null;
2269
+ }
2270
+ }
2271
+
2272
+ async function safeRpcData(tab, command, timeoutMs = STATUS_RPC_TIMEOUT_MS) {
2273
+ try {
2274
+ const response = await tab.rpc.send(command, timeoutMs);
2275
+ if (response?.success === false) return { ok: false, error: response.error || `${command.type} failed` };
2276
+ if (command?.type === "get_state") rememberTabState(tab, response?.data);
2277
+ return { ok: true, data: response?.data ?? null };
2278
+ } catch (error) {
2279
+ return { ok: false, error: sanitizeError(error) };
2280
+ }
2281
+ }
2282
+
2283
+ function providerList(models) {
2284
+ const providers = new Set();
2285
+ for (const model of Array.isArray(models) ? models : []) {
2286
+ if (model?.provider) providers.add(String(model.provider));
2287
+ }
2288
+ return [...providers].sort();
2289
+ }
2290
+
2291
+ async function tabStatusDetails(tab) {
2292
+ const [stateResult, modelsResult, statsResult, workspaceResult] = await Promise.all([
2293
+ safeRpcData(tab, { type: "get_state" }),
2294
+ safeRpcData(tab, { type: "get_available_models" }),
2295
+ safeRpcData(tab, { type: "get_session_stats" }),
2296
+ getWorkspaceInfo(tab.cwd, tab.rpc.startedAt).then((data) => ({ ok: true, data })).catch((error) => ({ ok: false, error: sanitizeError(error) })),
2297
+ ]);
2298
+ const models = modelsResult.ok ? modelsResult.data?.models || [] : [];
2299
+ const stateData = stateResult.ok ? stateResult.data : tab.lastState || null;
2300
+ return {
2301
+ ...tabMeta(tab),
2302
+ state: stateData,
2303
+ stateError: stateResult.ok ? undefined : stateResult.error,
2304
+ stats: statsResult.ok ? statsResult.data : null,
2305
+ statsError: statsResult.ok ? undefined : statsResult.error,
2306
+ workspace: workspaceResult.ok ? workspaceResult.data : null,
2307
+ workspaceError: workspaceResult.ok ? undefined : workspaceResult.error,
2308
+ pendingExtensionUiRequests: pendingExtensionUiRequestSummaries(tab),
2309
+ models: {
2310
+ count: models.length,
2311
+ providers: providerList(models),
2312
+ error: modelsResult.ok ? undefined : modelsResult.error,
2313
+ },
2314
+ };
2315
+ }
2316
+
2317
+ async function webuiStatus({ detailed = false, eventLimit = 40 } = {}) {
2318
+ const tab = firstTab();
2319
+ const network = networkStatus();
2320
+ const statusTabs = listTabs();
2321
+ const data = {
2322
+ online: true,
2323
+ webuiVersion: packageJson.version,
2324
+ webuiPid: process.pid,
2325
+ startedAt: serverStartedAt,
2326
+ cwd: options.cwd,
2327
+ boundHost: currentHost,
2328
+ port: options.port,
2329
+ pageUrl: network.localUrl,
2330
+ boundUrl: `http://${formatUrlHost(currentHost)}:${options.port}/`,
2331
+ network,
2332
+ piPid: tab?.rpc.child?.pid,
2333
+ piRunning: !!tab?.rpc.child && tab.rpc.child.exitCode === null,
2334
+ tabs: statusTabs,
2335
+ restorableTabs: mergeRestorableTabDescriptors(statusTabs, closedRestorableTabs),
2336
+ };
2337
+
2338
+ if (detailed) {
2339
+ const detailedTabs = await Promise.all([...tabs.values()].map((item) => tabStatusDetails(item)));
2340
+ data.tabs = detailedTabs;
2341
+ data.restorableTabs = mergeRestorableTabDescriptors(detailedTabs, closedRestorableTabs);
2342
+ data.closedTabs = closedRestorableTabs.slice();
2343
+ data.events = latestEvents(eventLimit);
1009
2344
  }
2345
+
2346
+ return data;
1010
2347
  }
1011
2348
 
1012
2349
  const server = createServer(async (req, res) => {
@@ -1014,7 +2351,7 @@ const server = createServer(async (req, res) => {
1014
2351
  const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
1015
2352
 
1016
2353
  if (url.pathname === "/api/tabs" && req.method === "GET") {
1017
- sendJson(res, 200, { ok: true, data: { tabs: listTabs() } });
2354
+ sendJson(res, 200, { ok: true, data: { tabs: await listTabsWithReconciledActivity() } });
1018
2355
  return;
1019
2356
  }
1020
2357
 
@@ -1035,7 +2372,7 @@ const server = createServer(async (req, res) => {
1035
2372
 
1036
2373
  if (url.pathname.startsWith("/api/tabs/") && req.method === "DELETE") {
1037
2374
  const id = decodeURIComponent(url.pathname.slice("/api/tabs/".length));
1038
- closeTab(id);
2375
+ await closeTab(id);
1039
2376
  sendJson(res, 200, { ok: true, data: { tabs: listTabs(), activeTabId: firstTab()?.id || null } });
1040
2377
  return;
1041
2378
  }
@@ -1058,7 +2395,10 @@ const server = createServer(async (req, res) => {
1058
2395
  pid: tab.rpc.child?.pid,
1059
2396
  cwd: tab.cwd,
1060
2397
  startedAt: tab.rpc.startedAt,
2398
+ tabActivity: tabActivitySnapshot(tab),
2399
+ pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length,
1061
2400
  });
2401
+ replayPendingExtensionUiRequests(tab, res);
1062
2402
  const keepAlive = setInterval(() => res.write(": keepalive\n\n"), 15000);
1063
2403
  req.on("close", () => {
1064
2404
  clearInterval(keepAlive);
@@ -1068,20 +2408,34 @@ const server = createServer(async (req, res) => {
1068
2408
  }
1069
2409
 
1070
2410
  if (url.pathname === "/api/health" && req.method === "GET") {
1071
- const tab = firstTab();
2411
+ const status = await webuiStatus();
1072
2412
  sendJson(res, 200, {
1073
2413
  ok: true,
1074
- webuiVersion: packageJson.version,
1075
- webuiPid: process.pid,
1076
- piPid: tab?.rpc.child?.pid,
1077
- piRunning: !!tab?.rpc.child && tab.rpc.child.exitCode === null,
1078
- cwd: options.cwd,
1079
- network: networkStatus(),
1080
- tabs: listTabs(),
2414
+ webuiVersion: status.webuiVersion,
2415
+ webuiPid: status.webuiPid,
2416
+ piPid: status.piPid,
2417
+ piRunning: status.piRunning,
2418
+ cwd: status.cwd,
2419
+ network: status.network,
2420
+ tabs: status.tabs,
2421
+ restorableTabs: status.restorableTabs,
1081
2422
  });
1082
2423
  return;
1083
2424
  }
1084
2425
 
2426
+ if (url.pathname === "/api/webui-status" && req.method === "GET") {
2427
+ const detailed = ["1", "true", "yes", "detailed"].includes(String(url.searchParams.get("detailed") || "").toLowerCase());
2428
+ const parsedEventLimit = Number.parseInt(url.searchParams.get("events") || "40", 10);
2429
+ const eventLimit = Number.isFinite(parsedEventLimit) ? parsedEventLimit : 40;
2430
+ sendJson(res, 200, { ok: true, data: await webuiStatus({ detailed, eventLimit }) });
2431
+ return;
2432
+ }
2433
+
2434
+ if (url.pathname === "/api/themes" && req.method === "GET") {
2435
+ sendJson(res, 200, { ok: true, data: await readBundledThemes() });
2436
+ return;
2437
+ }
2438
+
1085
2439
  if (url.pathname === "/api/network" && req.method === "GET") {
1086
2440
  sendJson(res, 200, { ok: true, data: networkStatus() });
1087
2441
  return;
@@ -1090,9 +2444,20 @@ const server = createServer(async (req, res) => {
1090
2444
  if (url.pathname === "/api/network/open" && req.method === "POST") {
1091
2445
  if (!isLocalAddress(req.socket.remoteAddress)) throw makeHttpError(403, "Opening to the network is only allowed from localhost");
1092
2446
  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();
2447
+ const shouldOpen = !before.open && !networkRebindInProgress;
2448
+ sendJson(res, 202, { ok: true, data: { ...before, opening: shouldOpen || before.opening, closing: before.closing } }, { connection: "close" });
2449
+ if (shouldOpen) {
2450
+ setTimeout(() => openToLocalNetwork().catch((error) => console.error("network open failed:", sanitizeError(error))), NETWORK_REBIND_DELAY_MS).unref();
2451
+ }
2452
+ return;
2453
+ }
2454
+
2455
+ if (url.pathname === "/api/network/close" && req.method === "POST") {
2456
+ const before = networkStatus();
2457
+ const shouldClose = before.open && !networkRebindInProgress;
2458
+ sendJson(res, 202, { ok: true, data: { ...before, opening: before.opening, closing: shouldClose || before.closing } }, { connection: "close" });
2459
+ if (shouldClose) {
2460
+ setTimeout(() => closeNetworkAccess().catch((error) => console.error("network close failed:", sanitizeError(error))), NETWORK_REBIND_DELAY_MS).unref();
1096
2461
  }
1097
2462
  return;
1098
2463
  }
@@ -1122,6 +2487,64 @@ const server = createServer(async (req, res) => {
1122
2487
  return;
1123
2488
  }
1124
2489
 
2490
+ if (url.pathname === "/api/path-suggestions" && req.method === "GET") {
2491
+ const tab = getRequestedTab(req, url);
2492
+ sendJson(res, 200, { ok: true, data: await getPathSuggestionData(tab, url.searchParams.get("query")) });
2493
+ return;
2494
+ }
2495
+
2496
+ if (url.pathname === "/api/path-fast-picks" && req.method === "GET") {
2497
+ sendJson(res, 200, { ok: true, data: { picks: await readPathFastPicks() } });
2498
+ return;
2499
+ }
2500
+
2501
+ if (url.pathname === "/api/path-fast-picks" && req.method === "POST") {
2502
+ const body = await readJsonBody(req);
2503
+ const picks = await writePathFastPicks(body.picks ?? body);
2504
+ sendJson(res, 200, { ok: true, data: { picks } });
2505
+ return;
2506
+ }
2507
+
2508
+ if (url.pathname === "/api/scoped-models" && req.method === "GET") {
2509
+ const tab = getRequestedTab(req, url);
2510
+ sendJson(res, 200, { ok: true, data: await getScopedModelData(tab) });
2511
+ return;
2512
+ }
2513
+
2514
+ if (url.pathname === "/api/commands" && req.method === "GET") {
2515
+ const tab = getRequestedTab(req, url);
2516
+ sendJson(res, 200, { type: "response", command: "get_commands", success: true, data: await getCommandData(tab) });
2517
+ return;
2518
+ }
2519
+
2520
+ if (url.pathname === "/api/action-feedback" && req.method === "POST") {
2521
+ const body = await readJsonBody(req);
2522
+ const tab = getRequestedTab(req, url, body);
2523
+ const response = await handleActionFeedback(tab, body);
2524
+ sendJson(res, response.success === false ? 400 : 200, response);
2525
+ return;
2526
+ }
2527
+
2528
+ if (url.pathname === "/api/prompt" && req.method === "POST") {
2529
+ const body = await readJsonBody(req);
2530
+ const tab = getRequestedTab(req, url, body);
2531
+ const nativeResponse = await handleNativeSlashCommand(tab, body);
2532
+ if (nativeResponse) {
2533
+ sendJson(res, nativeResponse.success === false ? 400 : 200, responseWithTab(nativeResponse, tab));
2534
+ return;
2535
+ }
2536
+ const command = commandFromPost(url.pathname, body);
2537
+ const startsVisibleWork = commandStartsVisibleWork(command);
2538
+ if (startsVisibleWork) {
2539
+ maybeNameTabForConversation(tab, command);
2540
+ markTabWorking(tab);
2541
+ }
2542
+ const response = await tab.rpc.send(command);
2543
+ if (response.success === false && startsVisibleWork) markTabIdle(tab);
2544
+ sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
2545
+ return;
2546
+ }
2547
+
1125
2548
  if (url.pathname.startsWith("/api/git-workflow/")) {
1126
2549
  const body = req.method === "POST" ? await readJsonBody(req) : {};
1127
2550
  const tab = getRequestedTab(req, url, body);
@@ -1147,7 +2570,18 @@ const server = createServer(async (req, res) => {
1147
2570
  if (payload.type !== "extension_ui_response") payload.type = "extension_ui_response";
1148
2571
  if (!payload.id) throw new Error("id is required");
1149
2572
  await tab.rpc.writeRaw(payload);
1150
- sendJson(res, 200, { ok: true });
2573
+ const resolved = resolvePendingExtensionUiRequest(tab, payload.id);
2574
+ if (resolved) {
2575
+ broadcastTabEvent(tab, {
2576
+ type: "webui_extension_ui_resolved",
2577
+ tabId: tab.id,
2578
+ tabTitle: tab.title,
2579
+ id: String(payload.id),
2580
+ pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length,
2581
+ tabActivity: tabActivitySnapshot(tab),
2582
+ });
2583
+ }
2584
+ sendJson(res, 200, { ok: true, tab: tabMeta(tab) });
1151
2585
  return;
1152
2586
  }
1153
2587
 
@@ -1156,8 +2590,21 @@ const server = createServer(async (req, res) => {
1156
2590
  const command = commandFromPost(url.pathname, body);
1157
2591
  if (command) {
1158
2592
  const tab = getRequestedTab(req, url, body);
2593
+ if (command.type === "abort") await cancelPendingExtensionUiRequests(tab);
2594
+ const startsVisibleWork = commandStartsVisibleWork(command);
2595
+ if (startsVisibleWork) {
2596
+ maybeNameTabForConversation(tab, command);
2597
+ markTabWorking(tab);
2598
+ }
1159
2599
  const response = await tab.rpc.send(command);
1160
- sendJson(res, response.success === false ? 400 : 200, response);
2600
+ if (response.success === false && startsVisibleWork) markTabIdle(tab);
2601
+ if (response.success !== false && command.type === "new_session") {
2602
+ tab.conversationStarted = false;
2603
+ forgetTabState(tab);
2604
+ rememberTabState(tab, response.data);
2605
+ clearPendingExtensionUiRequests(tab);
2606
+ }
2607
+ sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
1161
2608
  return;
1162
2609
  }
1163
2610
  }
@@ -1185,6 +2632,7 @@ server.listen(options.port, currentHost, () => {
1185
2632
  console.log(`Pi Web UI: http://${urlHost}:${options.port}/`);
1186
2633
  console.log(`Working directory: ${options.cwd}`);
1187
2634
  console.log(`Pi RPC: ${initialTab.rpc.displayCommand}`);
2635
+ if (restoreTabs.length) console.log(`Restored Web UI tabs: ${initialTabs.length}`);
1188
2636
  if (!isLocalHost(currentHost)) {
1189
2637
  console.warn("WARNING: Web UI has no authentication. Only expose it on trusted networks.");
1190
2638
  }