@firstpick/pi-package-webui 0.3.9 → 0.4.0

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
@@ -1,15 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
- import { randomUUID } from "node:crypto";
3
+ import { createHash, randomUUID } from "node:crypto";
4
4
  import { createReadStream } from "node:fs";
5
5
  import { createServer } from "node:http";
6
6
  import { createRequire } from "node:module";
7
- import { access, copyFile, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
7
+ import { access, copyFile, mkdir, readFile, readdir, realpath, rename, stat, writeFile } from "node:fs/promises";
8
8
  import { homedir, networkInterfaces, tmpdir } from "node:os";
9
9
  import path from "node:path";
10
10
  import { StringDecoder } from "node:string_decoder";
11
11
  import { fileURLToPath, pathToFileURL } from "node:url";
12
- import { AuthStorage, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
12
+ import { promisify } from "node:util";
13
+ import { brotliCompress, constants as zlibConstants, gzip } from "node:zlib";
14
+ import { AuthStorage, SessionManager, SettingsManager, DefaultPackageManager } from "@earendil-works/pi-coding-agent";
13
15
  import { authProvidersPayload, createAuthContext, logoutStoredProvider } from "../lib/auth-actions.mjs";
14
16
  import {
15
17
  collectOpenSessionFiles,
@@ -66,6 +68,9 @@ const UPDATE_STATUS_CACHE_MS = 10 * 60 * 1000;
66
68
  const UPDATE_STATUS_TIMEOUT_MS = 10 * 1000;
67
69
  const PI_UPDATE_TIMEOUT_MS = 15 * 60 * 1000;
68
70
  const PI_UPDATE_OUTPUT_MAX_CHARS = 120_000;
71
+ const UPDATE_PACKAGE_NAMES = [PI_CODING_AGENT_PACKAGE, WEBUI_PACKAGE];
72
+ const PACKAGE_UPDATE_TIMEOUT_MS = 15 * 60 * 1000;
73
+ const PACKAGE_UPDATE_OUTPUT_MAX_CHARS = 120_000;
69
74
  const CODEX_USAGE_TIMEOUT_MS = 15 * 1000;
70
75
  const CODEX_TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000;
71
76
  const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
@@ -226,6 +231,8 @@ const OPTIONAL_FEATURE_PACKAGES = new Map([
226
231
  ["statsCommand", "@firstpick/pi-extension-stats"],
227
232
  ["themeBundle", "@firstpick/pi-themes-bundle"],
228
233
  ]);
234
+ const WEBUI_CONTROLLED_PACKAGES = new Set([WEBUI_PACKAGE, ...OPTIONAL_FEATURE_PACKAGES.values()]);
235
+ const PACKAGE_NAME_CACHE = new Map();
229
236
 
230
237
  function usage() {
231
238
  console.log(`pi-webui ${packageJson.version}
@@ -2940,6 +2947,167 @@ function cleanGitBranchName(value) {
2940
2947
  return branch;
2941
2948
  }
2942
2949
 
2950
+ function cleanGitCommitMessageInput(value) {
2951
+ const message = String(value || "").replace(/\r\n?/g, "\n").trim();
2952
+ if (!message) throw new Error("commit message is required");
2953
+ if (message.includes("\0")) throw new Error("commit message contains a NUL byte");
2954
+ if (message.length > 10000) throw new Error("commit message is too long");
2955
+ return message;
2956
+ }
2957
+
2958
+ function cleanGitHubUsername(value) {
2959
+ const username = String(value || "").trim().replace(/^@+/, "");
2960
+ if (!username) throw new Error("GitHub username is required");
2961
+ if (username.length > 39 || !/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(username) || username.includes("--")) {
2962
+ throw new Error("Invalid GitHub username");
2963
+ }
2964
+ return username;
2965
+ }
2966
+
2967
+ function cleanGitHubRepoName(value) {
2968
+ let repoName = String(value || "").trim();
2969
+ const githubUrlMatch = repoName.match(/github\.com[:/][^/\s]+\/([^/\s]+?)(?:\.git)?\/?$/i);
2970
+ if (githubUrlMatch) repoName = githubUrlMatch[1];
2971
+ if (repoName.includes("/")) repoName = repoName.split("/").filter(Boolean).pop() || "";
2972
+ repoName = repoName.replace(/\.git$/i, "");
2973
+ if (!repoName) throw new Error("GitHub repository name is required");
2974
+ if (repoName.length > 100 || repoName === "." || repoName === ".." || !/^[A-Za-z0-9._-]+$/.test(repoName)) {
2975
+ throw new Error("Invalid GitHub repository name");
2976
+ }
2977
+ return repoName;
2978
+ }
2979
+
2980
+ function gitHubOriginUrl(username, repoName) {
2981
+ return `https://github.com/${cleanGitHubUsername(username)}/${cleanGitHubRepoName(repoName)}.git`;
2982
+ }
2983
+
2984
+ function defaultGitRepoNameFromRoot(root) {
2985
+ try {
2986
+ return cleanGitHubRepoName(path.basename(root));
2987
+ } catch {
2988
+ return "new-repo";
2989
+ }
2990
+ }
2991
+
2992
+ async function ensureOutsideGitRepository(cwd) {
2993
+ const result = await runCommand("git", ["rev-parse", "--show-toplevel"], { cwd, timeoutMs: 2000 });
2994
+ if (result.exitCode === 0 && result.stdout.trim()) throw new Error(`Already inside a git repository: ${path.resolve(result.stdout.trim())}`);
2995
+ }
2996
+
2997
+ async function regularFileExists(filePath) {
2998
+ try {
2999
+ return (await stat(filePath)).isFile();
3000
+ } catch (error) {
3001
+ if (error?.code === "ENOENT") return false;
3002
+ throw error;
3003
+ }
3004
+ }
3005
+
3006
+ async function detectRepositoryStack(root) {
3007
+ let names = new Set();
3008
+ try {
3009
+ names = new Set(await readdir(root));
3010
+ } catch {
3011
+ return "";
3012
+ }
3013
+ const detected = [];
3014
+ if (names.has("package.json")) {
3015
+ try {
3016
+ const manifest = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
3017
+ const deps = { ...(manifest.dependencies || {}), ...(manifest.devDependencies || {}) };
3018
+ if (deps.next) detected.push("Next.js");
3019
+ else if (deps.react || deps.vite) detected.push("React / Vite");
3020
+ else detected.push("Node.js / TypeScript");
3021
+ if (deps.typescript || names.has("tsconfig.json")) detected.push("TypeScript");
3022
+ } catch {
3023
+ detected.push("Node.js");
3024
+ }
3025
+ }
3026
+ if (names.has("pyproject.toml") || names.has("requirements.txt") || names.has("setup.py")) detected.push("Python");
3027
+ if (names.has("manage.py")) detected.push("Django");
3028
+ if (names.has("Cargo.toml")) detected.push("Rust");
3029
+ if (names.has("go.mod")) detected.push("Go");
3030
+ if (names.has("pom.xml") || names.has("build.gradle") || names.has("build.gradle.kts")) detected.push("Java / Gradle");
3031
+ if (names.has("Dockerfile") || names.has("docker-compose.yml") || names.has("compose.yml")) detected.push("Docker");
3032
+ return [...new Set(detected)].join(", ");
3033
+ }
3034
+
3035
+ async function initialRepositoryFilesStatus(cwd) {
3036
+ const root = await getGitRoot(cwd);
3037
+ const readmePath = path.join(root, "README.md");
3038
+ const gitignorePath = path.join(root, ".gitignore");
3039
+ const [readmeExists, gitignoreExists, detectedStack] = await Promise.all([
3040
+ regularFileExists(readmePath),
3041
+ regularFileExists(gitignorePath),
3042
+ detectRepositoryStack(root),
3043
+ ]);
3044
+ return { root, readmePath, gitignorePath, readmeExists, gitignoreExists, detectedStack };
3045
+ }
3046
+
3047
+ function gitignoreLinesForStack(stackInput, detectedStack = "") {
3048
+ const stack = `${stackInput || ""} ${detectedStack || ""}`.toLowerCase();
3049
+ const sections = [
3050
+ ["# OS / editors", ".DS_Store", "Thumbs.db", ".idea/", ".vscode/", "*.swp", "*.swo"],
3051
+ ["# Local env / secrets", ".env", ".env.*", "!.env.example", "*.local"],
3052
+ ["# Logs / temp", "*.log", "logs/", "tmp/", "temp/", ".cache/"],
3053
+ ];
3054
+ if (/node|npm|pnpm|yarn|bun|typescript|javascript|react|vite|next/.test(stack)) {
3055
+ sections.push(["# Node / frontend", "node_modules/", "dist/", "build/", ".next/", "out/", "coverage/", ".turbo/", ".vite/", "*.tsbuildinfo"]);
3056
+ }
3057
+ if (/python|django|fastapi|flask/.test(stack)) {
3058
+ sections.push(["# Python", "__pycache__/", "*.py[cod]", ".pytest_cache/", ".ruff_cache/", ".mypy_cache/", ".venv/", "venv/", "htmlcov/", "*.egg-info/"]);
3059
+ }
3060
+ if (/rust|cargo/.test(stack)) sections.push(["# Rust", "target/"]);
3061
+ if (/\bgo\b|golang/.test(stack)) sections.push(["# Go", "bin/", "*.test", "coverage.out"]);
3062
+ if (/java|gradle|maven|kotlin/.test(stack)) sections.push(["# Java / JVM", "target/", "build/", ".gradle/", "*.class"]);
3063
+ if (/docker|container/.test(stack)) sections.push(["# Docker", ".docker/", "docker-compose.override.yml"]);
3064
+ if (sections.length === 3) {
3065
+ sections.push(["# Common dependency / build outputs", "node_modules/", ".venv/", "venv/", "target/", "dist/", "build/", "coverage/", "vendor/", "*.tmp"]);
3066
+ }
3067
+ const seen = new Set();
3068
+ const lines = [];
3069
+ for (const section of sections) {
3070
+ lines.push(section[0]);
3071
+ for (const pattern of section.slice(1)) {
3072
+ if (seen.has(pattern)) continue;
3073
+ seen.add(pattern);
3074
+ lines.push(pattern);
3075
+ }
3076
+ lines.push("");
3077
+ }
3078
+ return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
3079
+ }
3080
+
3081
+ async function prepareInitialRepositoryFiles(cwd, { repoName: repoNameInput, stack = "" } = {}) {
3082
+ const before = await initialRepositoryFilesStatus(cwd);
3083
+ const repoName = repoNameInput ? cleanGitHubRepoName(repoNameInput) : defaultGitRepoNameFromRoot(before.root);
3084
+ if (await regularFileExists(before.readmePath)) {
3085
+ // Explicit pre-add check: keep existing README.md unchanged.
3086
+ } else {
3087
+ await writeFile(before.readmePath, `# ${repoName}\n\nInitialized by Pi Web UI.\n`, "utf8");
3088
+ }
3089
+ let gitignoreCreated = false;
3090
+ let gitignoreSource = before.gitignoreExists ? "existing" : "fallback";
3091
+ if (!(await regularFileExists(before.gitignorePath))) {
3092
+ await writeFile(before.gitignorePath, gitignoreLinesForStack(stack, before.detectedStack), "utf8");
3093
+ gitignoreCreated = true;
3094
+ }
3095
+ const payload = gitWorkflowCommandPayload(await runGitWorkflowCommand(["add", "--", "README.md", ".gitignore"], { cwd: before.root, label: "git add README.md .gitignore" }));
3096
+ if (payload.data) {
3097
+ payload.data.root = before.root;
3098
+ payload.data.readme = { path: before.readmePath, exists: true, created: !before.readmeExists };
3099
+ payload.data.gitignore = { path: before.gitignorePath, exists: true, created: gitignoreCreated, source: gitignoreSource };
3100
+ payload.data.detectedStack = before.detectedStack;
3101
+ payload.data.stack = stack;
3102
+ payload.data.stdout = [
3103
+ before.readmeExists ? `Using existing ${before.readmePath}` : `Created ${before.readmePath}`,
3104
+ before.gitignoreExists ? `Using existing ${before.gitignorePath}` : `Created ${before.gitignorePath} (${gitignoreSource})`,
3105
+ payload.data.stdout?.trimEnd(),
3106
+ ].filter(Boolean).join("\n");
3107
+ }
3108
+ return payload;
3109
+ }
3110
+
2943
3111
  async function validateGitBranchName(root, branch) {
2944
3112
  const result = await runGitWorkflowCommand(["check-ref-format", "--branch", branch], { cwd: root, timeoutMs: 5000 });
2945
3113
  if (result.exitCode !== 0 || result.timedOut || result.cancelled || result.error) {
@@ -3136,6 +3304,40 @@ async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd)
3136
3304
  return { ok: true, data: await readGitWorkflowBranchName(cwd) };
3137
3305
  case "/api/git-workflow/pr-description":
3138
3306
  return { ok: true, data: await readGitWorkflowPrDescription(cwd) };
3307
+ case "/api/git-workflow/init":
3308
+ await ensureOutsideGitRepository(cwd);
3309
+ return gitWorkflowCommandPayload(await runGitWorkflowCommand(["init"], { cwd }));
3310
+ case "/api/git-workflow/init-files-status":
3311
+ return { ok: true, data: await initialRepositoryFilesStatus(cwd) };
3312
+ case "/api/git-workflow/readme":
3313
+ return prepareInitialRepositoryFiles(cwd, { repoName: body.repoName, stack: body.stack });
3314
+ case "/api/git-workflow/initial-commit": {
3315
+ const root = await getGitRoot(cwd);
3316
+ return gitWorkflowCommandPayload(await runGitWorkflowCommand(["commit", "-m", "Initial commit"], { cwd: root, label: "git commit -m \"Initial commit\"" }));
3317
+ }
3318
+ case "/api/git-workflow/main-branch": {
3319
+ const root = await getGitRoot(cwd);
3320
+ return gitWorkflowCommandPayload(await runGitWorkflowCommand(["branch", "-M", "main"], { cwd: root }));
3321
+ }
3322
+ case "/api/git-workflow/remote": {
3323
+ const root = await getGitRoot(cwd);
3324
+ const username = cleanGitHubUsername(body.username);
3325
+ const repoName = cleanGitHubRepoName(body.repoName);
3326
+ const remoteUrl = gitHubOriginUrl(username, repoName);
3327
+ const payload = gitWorkflowCommandPayload(await runGitWorkflowCommand(["remote", "add", "origin", remoteUrl], { cwd: root }));
3328
+ if (payload.data) {
3329
+ payload.data.root = root;
3330
+ payload.data.remote = "origin";
3331
+ payload.data.remoteUrl = remoteUrl;
3332
+ payload.data.repoName = repoName;
3333
+ payload.data.username = username;
3334
+ }
3335
+ return payload;
3336
+ }
3337
+ case "/api/git-workflow/init-push": {
3338
+ const root = await getGitRoot(cwd);
3339
+ return gitWorkflowCommandPayload(await runGitWorkflowCommand(["push", "-u", "origin", "main"], { cwd: root, timeoutMs: 15 * 60 * 1000 }));
3340
+ }
3139
3341
  case "/api/git-workflow/add":
3140
3342
  await getGitRoot(cwd);
3141
3343
  return gitWorkflowCommandPayload(await runGitWorkflowCommand(["add", "."], { cwd }));
@@ -3149,7 +3351,12 @@ async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd)
3149
3351
  }
3150
3352
  case "/api/git-workflow/commit": {
3151
3353
  const variant = String(body.variant || "").trim();
3152
- if (!["short", "long"].includes(variant)) throw new Error("variant must be 'short' or 'long'");
3354
+ if (!["short", "long", "input"].includes(variant)) throw new Error("variant must be 'short', 'long', or 'input'");
3355
+ if (variant === "input") {
3356
+ const root = await getGitRoot(cwd);
3357
+ const message = cleanGitCommitMessageInput(body.message);
3358
+ return gitWorkflowCommandPayload(await runGitWorkflowCommand(["commit", "-m", message], { cwd: root, label: "git commit -m <input message>" }));
3359
+ }
3153
3360
  const messages = await readGitWorkflowMessages(cwd);
3154
3361
  if (variant === "short") {
3155
3362
  const message = messages.short.trim();
@@ -3288,6 +3495,43 @@ function normalizeStaticPath(urlPath) {
3288
3495
  return name;
3289
3496
  }
3290
3497
 
3498
+ const compressWithBrotli = promisify(brotliCompress);
3499
+ const compressWithGzip = promisify(gzip);
3500
+ const STATIC_COMPRESSIBLE_EXTENSIONS = new Set([".html", ".css", ".js", ".mjs", ".svg", ".json", ".webmanifest"]);
3501
+ const STATIC_COMPRESSION_MIN_BYTES = 1024;
3502
+ // filePath -> { mtimeMs, size, etag, raw, br, gz }; invalidated by mtime/size change.
3503
+ const staticAssetCache = new Map();
3504
+
3505
+ async function loadStaticAsset(filePath) {
3506
+ const stats = await stat(filePath);
3507
+ const cached = staticAssetCache.get(filePath);
3508
+ if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) return cached;
3509
+ const raw = await readFile(filePath);
3510
+ const entry = {
3511
+ mtimeMs: stats.mtimeMs,
3512
+ size: stats.size,
3513
+ etag: `"${createHash("sha1").update(raw).digest("base64url")}"`,
3514
+ raw,
3515
+ br: null,
3516
+ gz: null,
3517
+ };
3518
+ staticAssetCache.set(filePath, entry);
3519
+ return entry;
3520
+ }
3521
+
3522
+ function acceptedStaticEncoding(req) {
3523
+ const header = String(req.headers["accept-encoding"] || "");
3524
+ if (/\bbr\b/i.test(header)) return "br";
3525
+ if (/\bgzip\b/i.test(header)) return "gzip";
3526
+ return "";
3527
+ }
3528
+
3529
+ function requestEtagMatches(req, etag) {
3530
+ const header = String(req.headers["if-none-match"] || "");
3531
+ if (!header) return false;
3532
+ return header.split(",").some((candidate) => candidate.trim() === etag);
3533
+ }
3534
+
3291
3535
  async function serveStatic(req, res, url) {
3292
3536
  if (req.method !== "GET") return false;
3293
3537
  const staticName = normalizeStaticPath(url.pathname);
@@ -3295,13 +3539,42 @@ async function serveStatic(req, res, url) {
3295
3539
 
3296
3540
  const filePath = path.join(publicDir, staticName);
3297
3541
  const ext = path.extname(filePath);
3298
- const content = await readFile(filePath);
3299
- res.writeHead(200, {
3542
+ const asset = await loadStaticAsset(filePath);
3543
+ const headers = {
3300
3544
  "content-type": MIME_TYPES.get(ext) || "application/octet-stream",
3301
- "cache-control": "no-store",
3545
+ // no-cache (unlike no-store) allows conditional revalidation via ETag/304
3546
+ // while still guaranteeing fresh content after deploys.
3547
+ "cache-control": "no-cache",
3548
+ etag: asset.etag,
3549
+ vary: "Accept-Encoding",
3302
3550
  "x-content-type-options": "nosniff",
3303
- });
3304
- res.end(content);
3551
+ };
3552
+ if (requestEtagMatches(req, asset.etag)) {
3553
+ res.writeHead(304, headers);
3554
+ res.end();
3555
+ return true;
3556
+ }
3557
+ let body = asset.raw;
3558
+ if (STATIC_COMPRESSIBLE_EXTENSIONS.has(ext) && asset.raw.length >= STATIC_COMPRESSION_MIN_BYTES) {
3559
+ const encoding = acceptedStaticEncoding(req);
3560
+ if (encoding === "br") {
3561
+ asset.br ||= await compressWithBrotli(asset.raw, {
3562
+ params: {
3563
+ [zlibConstants.BROTLI_PARAM_QUALITY]: 6,
3564
+ [zlibConstants.BROTLI_PARAM_SIZE_HINT]: asset.raw.length,
3565
+ },
3566
+ });
3567
+ body = asset.br;
3568
+ headers["content-encoding"] = "br";
3569
+ } else if (encoding === "gzip") {
3570
+ asset.gz ||= await compressWithGzip(asset.raw, { level: 7 });
3571
+ body = asset.gz;
3572
+ headers["content-encoding"] = "gzip";
3573
+ }
3574
+ }
3575
+ headers["content-length"] = body.length;
3576
+ res.writeHead(200, headers);
3577
+ res.end(body);
3305
3578
  return true;
3306
3579
  }
3307
3580
 
@@ -3462,6 +3735,23 @@ function commandFromPost(pathname, body) {
3462
3735
  }
3463
3736
  }
3464
3737
 
3738
+ /**
3739
+ * Delta transcript support (P1-1): /api/messages?since=N serializes only the
3740
+ * tail starting at message index N plus { totalCount, since } so clients can
3741
+ * merge appended messages instead of re-downloading the whole transcript on
3742
+ * every agent event. Without ?since= the legacy full payload is returned.
3743
+ */
3744
+ function applyMessagesSinceParam(response, url) {
3745
+ const sinceRaw = url.searchParams.get("since");
3746
+ if (sinceRaw === null) return;
3747
+ const messages = response?.data?.messages;
3748
+ if (!Array.isArray(messages)) return;
3749
+ const parsed = Number.parseInt(sinceRaw, 10);
3750
+ const total = messages.length;
3751
+ const since = Number.isInteger(parsed) ? Math.min(Math.max(parsed, 0), total) : 0;
3752
+ response.data = { ...response.data, messages: messages.slice(since), totalCount: total, since };
3753
+ }
3754
+
3465
3755
  function commandFromGet(pathname) {
3466
3756
  switch (pathname) {
3467
3757
  case "/api/state":
@@ -3556,10 +3846,161 @@ function readRestoreTabsFromEnv() {
3556
3846
  }
3557
3847
  }
3558
3848
 
3559
- function buildPiArgsForTab(tabIndex, title) {
3560
- const args = ["--mode", "rpc"];
3849
+ async function packageNameForResourcePath(resourcePath) {
3850
+ let current = path.dirname(resourcePath);
3851
+ while (current && current !== path.dirname(current)) {
3852
+ if (PACKAGE_NAME_CACHE.has(current)) return PACKAGE_NAME_CACHE.get(current) || undefined;
3853
+ try {
3854
+ const pkg = JSON.parse(await readFile(path.join(current, "package.json"), "utf8"));
3855
+ const name = typeof pkg.name === "string" ? pkg.name : "";
3856
+ PACKAGE_NAME_CACHE.set(current, name);
3857
+ return name || undefined;
3858
+ } catch {
3859
+ current = path.dirname(current);
3860
+ }
3861
+ }
3862
+ return undefined;
3863
+ }
3864
+
3865
+ async function packageRootRealpath() {
3866
+ try {
3867
+ return await realpath(packageRoot);
3868
+ } catch {
3869
+ return packageRoot;
3870
+ }
3871
+ }
3872
+
3873
+ async function devWorkspaceRoot() {
3874
+ if (!webuiDevServer) return null;
3875
+ const envRoot = process.env.PI_NPM_PACKAGES_ROOT ? path.resolve(expandUserPath(process.env.PI_NPM_PACKAGES_ROOT)) : "";
3876
+ const candidates = [envRoot, path.dirname(packageRoot), path.dirname(await packageRootRealpath())].filter(Boolean);
3877
+ for (const candidate of candidates) {
3878
+ try {
3879
+ const entries = await readdir(candidate, { withFileTypes: true });
3880
+ if (entries.some((entry) => entry.isDirectory() && entry.name === "pi-package-webui")) return candidate;
3881
+ } catch {
3882
+ // Try the next candidate.
3883
+ }
3884
+ }
3885
+ return null;
3886
+ }
3887
+
3888
+ async function workspacePackageRootForName(packageName) {
3889
+ const root = await devWorkspaceRoot();
3890
+ if (!root) return null;
3891
+ let entries;
3892
+ try {
3893
+ entries = await readdir(root, { withFileTypes: true });
3894
+ } catch {
3895
+ return null;
3896
+ }
3897
+ for (const entry of entries) {
3898
+ if (!entry.isDirectory() || !entry.name.startsWith("pi-")) continue;
3899
+ const candidate = path.join(root, entry.name);
3900
+ if ((await packageNameForResourcePath(path.join(candidate, "index.ts"))) === packageName) return candidate;
3901
+ }
3902
+ return null;
3903
+ }
3904
+
3905
+ function parseNodeModulesPackageRef(manifestEntry) {
3906
+ const normalized = String(manifestEntry || "").replace(/\\/g, "/").replace(/^\.\//, "");
3907
+ if (!normalized.startsWith("node_modules/")) return null;
3908
+ const parts = normalized.slice("node_modules/".length).split("/").filter(Boolean);
3909
+ if (!parts.length) return null;
3910
+ const scoped = parts[0].startsWith("@");
3911
+ const packageName = scoped ? `${parts[0]}/${parts[1] || ""}` : parts[0];
3912
+ if (!packageName || packageName.endsWith("/")) return null;
3913
+ const subpath = parts.slice(scoped ? 2 : 1).join("/");
3914
+ return { packageName, subpath };
3915
+ }
3916
+
3917
+ async function resolveStartedWebuiManifestResource(manifestEntry) {
3918
+ const nodeModulesRef = parseNodeModulesPackageRef(manifestEntry);
3919
+ if (nodeModulesRef && WEBUI_CONTROLLED_PACKAGES.has(nodeModulesRef.packageName)) {
3920
+ const workspaceRoot = await workspacePackageRootForName(nodeModulesRef.packageName);
3921
+ if (workspaceRoot) {
3922
+ const devCandidate = path.join(workspaceRoot, nodeModulesRef.subpath);
3923
+ try {
3924
+ await access(devCandidate);
3925
+ return devCandidate;
3926
+ } catch {
3927
+ // Fall back to the started package's node_modules copy below.
3928
+ }
3929
+ }
3930
+ }
3931
+
3932
+ const candidate = path.resolve(packageRoot, manifestEntry);
3933
+ try {
3934
+ await access(candidate);
3935
+ return candidate;
3936
+ } catch {
3937
+ return null;
3938
+ }
3939
+ }
3940
+
3941
+ async function startedWebuiResourcePaths(resourceType) {
3942
+ const entries = Array.isArray(packageJson.pi?.[resourceType]) ? packageJson.pi[resourceType] : [];
3943
+ const resolved = [];
3944
+ for (const entry of entries) {
3945
+ if (typeof entry !== "string") continue;
3946
+ const resourcePath = await resolveStartedWebuiManifestResource(entry);
3947
+ if (resourcePath) resolved.push(resourcePath);
3948
+ }
3949
+ return resolved;
3950
+ }
3951
+
3952
+ function piArgsDisableResourceDiscovery(resourceType) {
3953
+ const flags = {
3954
+ extensions: new Set(["--no-extensions", "-ne"]),
3955
+ skills: new Set(["--no-skills", "-ns"]),
3956
+ prompts: new Set(["--no-prompt-templates", "-np"]),
3957
+ themes: new Set(["--no-themes"]),
3958
+ }[resourceType];
3959
+ return !!flags && options.piArgs.some((arg) => flags.has(arg));
3960
+ }
3961
+
3962
+ async function resolvedNormalPiResourcesForTab(cwd) {
3963
+ try {
3964
+ const settingsManager = SettingsManager.create(cwd, agentDir);
3965
+ const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
3966
+ return await packageManager.resolve(async () => "skip");
3967
+ } catch (error) {
3968
+ console.warn(`failed to resolve configured Pi resources for Web UI tab: ${sanitizeError(error)}`);
3969
+ return { extensions: [], skills: [], prompts: [], themes: [] };
3970
+ }
3971
+ }
3972
+
3973
+ async function normalPiResourcePathsForTab(resolved, resourceType) {
3974
+ if (piArgsDisableResourceDiscovery(resourceType)) return [];
3975
+ const resourcePaths = [];
3976
+ for (const resource of resolved[resourceType] || []) {
3977
+ if (!resource.enabled) continue;
3978
+ const packageName = await packageNameForResourcePath(resource.path);
3979
+ if (packageName && WEBUI_CONTROLLED_PACKAGES.has(packageName)) continue;
3980
+ resourcePaths.push(resource.path);
3981
+ }
3982
+ return resourcePaths;
3983
+ }
3984
+
3985
+ function appendResourceArgs(args, flag, resourcePaths) {
3986
+ for (const resourcePath of resourcePaths) args.push(flag, resourcePath);
3987
+ }
3988
+
3989
+ async function appendCuratedResourceArgs(args, normalResources, resourceType, flag) {
3990
+ appendResourceArgs(args, flag, await normalPiResourcePathsForTab(normalResources, resourceType));
3991
+ appendResourceArgs(args, flag, await startedWebuiResourcePaths(resourceType));
3992
+ }
3993
+
3994
+ async function buildPiArgsForTab(tabIndex, title, tabCwd = options.cwd) {
3995
+ const args = ["--mode", "rpc", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes"];
3561
3996
  if (options.noSession) args.push("--no-session");
3562
3997
 
3998
+ const normalResources = await resolvedNormalPiResourcesForTab(tabCwd);
3999
+ await appendCuratedResourceArgs(args, normalResources, "extensions", "--extension");
4000
+ await appendCuratedResourceArgs(args, normalResources, "skills", "--skill");
4001
+ await appendCuratedResourceArgs(args, normalResources, "prompts", "--prompt-template");
4002
+ await appendCuratedResourceArgs(args, normalResources, "themes", "--theme");
4003
+
3563
4004
  // Load a browser-safe RPC helper into every Web UI tab. It exposes hidden
3564
4005
  // extension commands for Web UI-native /tools and /skills selectors without
3565
4006
  // depending on TUI-only extension UIs.
@@ -4028,7 +4469,7 @@ async function createTab({ id: requestedId, index, title, titleSource, conversat
4028
4469
  const resolvedTitleSource = ["explicit", "auto", "default"].includes(titleSource) ? titleSource : titleIsExplicit ? "explicit" : "default";
4029
4470
  const tabCwd = cwd ? await resolveCwd(cwd, options.cwd) : options.cwd;
4030
4471
  const id = requestedId && !tabs.has(requestedId) ? requestedId : randomUUID();
4031
- const piArgs = buildPiArgsForTab(tabIndex, tabTitle);
4472
+ const piArgs = await buildPiArgsForTab(tabIndex, tabTitle, tabCwd);
4032
4473
  if (sessionFile && !options.noSession) piArgs.push("--session", sessionFile);
4033
4474
  const piCommand = await resolvePiCommand(piArgs);
4034
4475
  const rpc = new PiRpcProcess({ ...piCommand, cwd: tabCwd });
@@ -4271,13 +4712,13 @@ async function getUpdateStatus({ force = false } = {}) {
4271
4712
  checkedAt: new Date(now).toISOString(),
4272
4713
  updateAvailable,
4273
4714
  restartRequired: true,
4274
- command: "pi update",
4715
+ command: "pi update + Web UI/Pi package-manager updates",
4275
4716
  webuiDev: webuiDevServer,
4276
4717
  pi: piStatus,
4277
4718
  webui: webuiStatus,
4278
4719
  packages: {
4279
4720
  checked: false,
4280
- note: "pi update will also update configured unpinned Pi packages.",
4721
+ note: "Update runs pi update plus all detected local, Pi-agent, npm-global, and Bun-global Web UI/Pi package roots."
4281
4722
  },
4282
4723
  };
4283
4724
  updateStatusCacheAt = now;
@@ -4286,15 +4727,235 @@ async function getUpdateStatus({ force = false } = {}) {
4286
4727
 
4287
4728
  async function resolvePiUpdateCommand() {
4288
4729
  if (options.piBinExplicit) {
4289
- return { command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
4730
+ return { label: "Pi CLI and configured packages", command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
4290
4731
  }
4291
4732
 
4292
4733
  const pathPi = await runCommand(options.piBin, ["--version"], { timeoutMs: 3000, maxOutputLength: 4000 });
4293
4734
  if (pathPi.exitCode === 0 && !pathPi.timedOut && !pathPi.error) {
4294
- return { command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
4735
+ return { label: "Pi CLI and configured packages", command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
4295
4736
  }
4296
4737
 
4297
- return resolvePiCommand(["update"]);
4738
+ const fallback = await resolvePiCommand(["update"]);
4739
+ return { ...fallback, label: "bundled Pi CLI and configured packages" };
4740
+ }
4741
+
4742
+ function packageNodeModulesPath(nodeModulesRoot, packageName) {
4743
+ return path.join(nodeModulesRoot, ...String(packageName || "").split("/").filter(Boolean));
4744
+ }
4745
+
4746
+ function isWebuiOrPiPackageName(packageName) {
4747
+ const name = String(packageName || "").trim();
4748
+ return UPDATE_PACKAGE_NAMES.includes(name)
4749
+ || /^@firstpick\/pi(?:-|$)/.test(name)
4750
+ || /^@earendil-works\/pi(?:-|$)/.test(name)
4751
+ || /^@firstpick\/.*webui/i.test(name);
4752
+ }
4753
+
4754
+ function declaredWebuiPiPackageNames(manifest) {
4755
+ const names = new Set();
4756
+ for (const section of [manifest?.dependencies, manifest?.optionalDependencies, manifest?.devDependencies]) {
4757
+ for (const packageName of Object.keys(section || {})) {
4758
+ if (isWebuiOrPiPackageName(packageName)) names.add(packageName);
4759
+ }
4760
+ }
4761
+ return [...names].sort();
4762
+ }
4763
+
4764
+ async function packagesPresentInNodeModulesRoot(nodeModulesRoot, packageNames = UPDATE_PACKAGE_NAMES) {
4765
+ const found = new Set();
4766
+ if (!nodeModulesRoot || !await directoryExists(nodeModulesRoot)) return [];
4767
+ for (const packageName of packageNames) {
4768
+ if (await directoryExists(packageNodeModulesPath(nodeModulesRoot, packageName))) found.add(packageName);
4769
+ }
4770
+
4771
+ let entries = [];
4772
+ try {
4773
+ entries = await readdir(nodeModulesRoot, { withFileTypes: true });
4774
+ } catch {
4775
+ return [...found].sort();
4776
+ }
4777
+
4778
+ for (const entry of entries) {
4779
+ if (!entry.isDirectory()) continue;
4780
+ if (entry.name.startsWith("@")) {
4781
+ let scopedEntries = [];
4782
+ try {
4783
+ scopedEntries = await readdir(path.join(nodeModulesRoot, entry.name), { withFileTypes: true });
4784
+ } catch {
4785
+ continue;
4786
+ }
4787
+ for (const scopedEntry of scopedEntries) {
4788
+ if (!scopedEntry.isDirectory()) continue;
4789
+ const packageName = `${entry.name}/${scopedEntry.name}`;
4790
+ if (isWebuiOrPiPackageName(packageName)) found.add(packageName);
4791
+ }
4792
+ continue;
4793
+ }
4794
+ if (isWebuiOrPiPackageName(entry.name)) found.add(entry.name);
4795
+ }
4796
+ return [...found].sort();
4797
+ }
4798
+
4799
+ async function packagesPresentInInstallPrefix(installRoot, packageNames = UPDATE_PACKAGE_NAMES) {
4800
+ const found = new Set();
4801
+ if (!installRoot || !await directoryExists(installRoot)) return [];
4802
+ const manifest = await readJsonFileIfExists(path.join(installRoot, "package.json"));
4803
+ for (const packageName of packageNames) {
4804
+ if (declaredDependencySpec(manifest, packageName) !== undefined) found.add(packageName);
4805
+ }
4806
+ for (const packageName of declaredWebuiPiPackageNames(manifest)) found.add(packageName);
4807
+ for (const packageName of await packagesPresentInNodeModulesRoot(path.join(installRoot, "node_modules"), packageNames)) {
4808
+ found.add(packageName);
4809
+ }
4810
+ return [...found].sort();
4811
+ }
4812
+
4813
+ function packageInstallSpecs(packageNames) {
4814
+ return packageNames.map((packageName) => `${packageName}@latest`);
4815
+ }
4816
+
4817
+ function npmCommandName() {
4818
+ return process.env.PI_WEBUI_NPM_BIN || "npm";
4819
+ }
4820
+
4821
+ function npmPrefixUpdateTask(label, installRoot, packageNames) {
4822
+ if (!packageNames.length) return null;
4823
+ const npmCommand = npmCommandName();
4824
+ return {
4825
+ label,
4826
+ command: npmCommand,
4827
+ args: ["install", "--prefix", installRoot, "--ignore-scripts", "--min-release-age=0", ...packageInstallSpecs(packageNames)],
4828
+ cwd: installRoot,
4829
+ };
4830
+ }
4831
+
4832
+ async function currentWebuiPackageUpdateTask() {
4833
+ const sourceCheckout = webuiDevServer || !String(packageRoot).split(path.sep).includes("node_modules");
4834
+ if (sourceCheckout) {
4835
+ const manifest = await readJsonFileIfExists(path.join(packageRoot, "package.json"));
4836
+ const packages = Object.keys(manifest?.dependencies || {}).filter(isWebuiOrPiPackageName).sort();
4837
+ return npmPrefixUpdateTask("current Web UI checkout package dependencies", packageRoot, packages);
4838
+ }
4839
+
4840
+ const installRoot = nodeModulesParentForPackageRoot(packageRoot);
4841
+ const packages = await packagesPresentInInstallPrefix(installRoot);
4842
+ return npmPrefixUpdateTask("current Web UI install root", installRoot, packages);
4843
+ }
4844
+
4845
+ async function agentPackageRootUpdateTask() {
4846
+ const installRoot = configuredAgentNpmRoot();
4847
+ const packages = await packagesPresentInInstallPrefix(installRoot);
4848
+ return npmPrefixUpdateTask("Pi agent npm package root", installRoot, packages);
4849
+ }
4850
+
4851
+ async function npmGlobalNodeModulesRoot() {
4852
+ const npmCommand = npmCommandName();
4853
+ const result = await runCommand(npmCommand, ["root", "-g"], { timeoutMs: 5000, maxOutputLength: 8000 });
4854
+ if (result.exitCode !== 0 || result.timedOut || result.error) return null;
4855
+ return result.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1) || null;
4856
+ }
4857
+
4858
+ async function npmGlobalPackageRootUpdateTask() {
4859
+ const nodeModulesRoot = await npmGlobalNodeModulesRoot();
4860
+ const packages = await packagesPresentInNodeModulesRoot(nodeModulesRoot);
4861
+ if (!packages.length) return null;
4862
+ const npmCommand = npmCommandName();
4863
+ return {
4864
+ label: "global npm package root",
4865
+ command: npmCommand,
4866
+ args: ["install", "-g", "--ignore-scripts", "--min-release-age=0", ...packageInstallSpecs(packages)],
4867
+ cwd: nodeModulesRoot ? path.dirname(nodeModulesRoot) : process.cwd(),
4868
+ };
4869
+ }
4870
+
4871
+ async function bunGlobalNodeModulesRoots() {
4872
+ const available = await runCommand("bun", ["--version"], { timeoutMs: 3000, maxOutputLength: 2000 });
4873
+ if (available.exitCode !== 0 || available.timedOut || available.error) return [];
4874
+
4875
+ const roots = new Set([path.join(homedir(), ".bun", "install", "global", "node_modules")]);
4876
+ const binResult = await runCommand("bun", ["pm", "bin", "-g"], { timeoutMs: 3000, maxOutputLength: 8000 });
4877
+ if (binResult.exitCode === 0 && !binResult.timedOut && !binResult.error) {
4878
+ const binDir = binResult.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
4879
+ if (binDir) roots.add(path.join(path.dirname(binDir), "install", "global", "node_modules"));
4880
+ }
4881
+ return [...roots];
4882
+ }
4883
+
4884
+ async function bunGlobalPackageRootUpdateTask() {
4885
+ const packages = new Set();
4886
+ for (const nodeModulesRoot of await bunGlobalNodeModulesRoots()) {
4887
+ for (const packageName of await packagesPresentInNodeModulesRoot(nodeModulesRoot)) packages.add(packageName);
4888
+ }
4889
+ if (!packages.size) return null;
4890
+ return {
4891
+ label: "global Bun package root",
4892
+ command: "bun",
4893
+ args: ["install", "-g", "--ignore-scripts", "--minimum-release-age=0", ...packageInstallSpecs([...packages])],
4894
+ cwd: homedir(),
4895
+ };
4896
+ }
4897
+
4898
+ function updateTaskDisplay(task) {
4899
+ return task.displayCommand || formatCommandForDisplay(task.command, task.args || []);
4900
+ }
4901
+
4902
+ function uniqueUpdateTasks(tasks) {
4903
+ const unique = [];
4904
+ const seen = new Set();
4905
+ for (const task of tasks.filter(Boolean)) {
4906
+ const key = [task.command, JSON.stringify(task.args || []), task.cwd || ""].join("\u0000");
4907
+ if (seen.has(key)) continue;
4908
+ seen.add(key);
4909
+ unique.push(task);
4910
+ }
4911
+ return unique;
4912
+ }
4913
+
4914
+ async function resolveUpdateTasks() {
4915
+ return uniqueUpdateTasks([
4916
+ await resolvePiUpdateCommand(),
4917
+ await currentWebuiPackageUpdateTask(),
4918
+ await agentPackageRootUpdateTask(),
4919
+ await npmGlobalPackageRootUpdateTask(),
4920
+ await bunGlobalPackageRootUpdateTask(),
4921
+ ]);
4922
+ }
4923
+
4924
+ function updateFailureDetails(result) {
4925
+ return [result.error, result.timedOut ? "timed out" : undefined, result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join("\n");
4926
+ }
4927
+
4928
+ async function runUpdateTask(task) {
4929
+ const command = updateTaskDisplay(task);
4930
+ recordEvent({ type: "webui_update_step_started", command });
4931
+ const result = await runCommand(task.command, task.args || [], {
4932
+ cwd: task.cwd || process.cwd(),
4933
+ timeoutMs: task.timeoutMs || PACKAGE_UPDATE_TIMEOUT_MS,
4934
+ maxOutputLength: task.maxOutputLength || PACKAGE_UPDATE_OUTPUT_MAX_CHARS,
4935
+ });
4936
+ const ok = result.exitCode === 0 && !result.timedOut && !result.error;
4937
+ if (!ok) {
4938
+ const details = updateFailureDetails(result);
4939
+ recordEvent({ type: "webui_update_step_failed", command, error: truncateStatusText(details || `exit code ${result.exitCode ?? "unknown"}`) });
4940
+ throw makeHttpError(500, truncateLongText(`Update step failed (${task.label || "package update"}): ${command}${details ? `\n${details}` : ""}`));
4941
+ }
4942
+ recordEvent({ type: "webui_update_step_completed", command });
4943
+ return {
4944
+ label: task.label || "package update",
4945
+ command,
4946
+ stdout: result.stdout,
4947
+ stderr: result.stderr,
4948
+ };
4949
+ }
4950
+
4951
+ function combinedUpdateOutput(results, field) {
4952
+ return results
4953
+ .map((result) => {
4954
+ const output = String(result?.[field] || "").trim();
4955
+ return output ? `# ${result.label}\n${output}` : "";
4956
+ })
4957
+ .filter(Boolean)
4958
+ .join("\n\n");
4298
4959
  }
4299
4960
 
4300
4961
  async function runPiUpdateAndPrepareRestart() {
@@ -4303,20 +4964,12 @@ async function runPiUpdateAndPrepareRestart() {
4303
4964
  let restartPrepared = false;
4304
4965
  try {
4305
4966
  const restorableTabs = await restorableTabsForRestart();
4306
- const piCommand = await resolvePiUpdateCommand();
4307
- const command = piCommand.displayCommand || formatCommandForDisplay(piCommand.command, piCommand.args || []);
4967
+ const updateTasks = await resolveUpdateTasks();
4968
+ if (!updateTasks.length) throw makeHttpError(500, "No Pi/Web UI update commands could be resolved.");
4969
+ const command = updateTasks.map(updateTaskDisplay).join(" && ");
4308
4970
  recordEvent({ type: "webui_update_started", command, restorableTabCount: restorableTabs.length });
4309
- const result = await runCommand(piCommand.command, piCommand.args || [], {
4310
- cwd: process.cwd(),
4311
- timeoutMs: PI_UPDATE_TIMEOUT_MS,
4312
- maxOutputLength: PI_UPDATE_OUTPUT_MAX_CHARS,
4313
- });
4314
- const ok = result.exitCode === 0 && !result.timedOut && !result.error;
4315
- if (!ok) {
4316
- const details = [result.error, result.timedOut ? "timed out" : undefined, result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join("\n");
4317
- recordEvent({ type: "webui_update_failed", command, error: truncateStatusText(details || `exit code ${result.exitCode ?? "unknown"}`) });
4318
- throw makeHttpError(500, truncateLongText(`Pi update failed: ${command}${details ? `\n${details}` : ""}`));
4319
- }
4971
+ const results = [];
4972
+ for (const task of updateTasks) results.push(await runUpdateTask(task));
4320
4973
 
4321
4974
  updateStatusCache = null;
4322
4975
  updateStatusCacheAt = 0;
@@ -4324,10 +4977,11 @@ async function runPiUpdateAndPrepareRestart() {
4324
4977
  restartPrepared = true;
4325
4978
  recordEvent({ type: "webui_update_restarting", command, nextWebuiPid: child.pid, restorableTabCount: restorableTabs.length });
4326
4979
  return {
4327
- message: "Pi update completed. Pi Web UI is restarting.",
4980
+ message: "Pi/Web UI package updates completed. Pi Web UI is restarting.",
4328
4981
  command,
4329
- stdout: result.stdout,
4330
- stderr: result.stderr,
4982
+ commands: results.map((result) => ({ label: result.label, command: result.command })),
4983
+ stdout: combinedUpdateOutput(results, "stdout"),
4984
+ stderr: combinedUpdateOutput(results, "stderr"),
4331
4985
  webuiPid: process.pid,
4332
4986
  nextWebuiPid: child.pid,
4333
4987
  restorableTabCount: restorableTabs.length,
@@ -4400,7 +5054,7 @@ async function updateTabCwd(id, cwd) {
4400
5054
  if (tab.rpc?.isRunning()) await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
4401
5055
  const sessionFile = tabRestorableSessionFile(tab);
4402
5056
 
4403
- const piArgs = buildPiArgsForTab(tab.index, tab.title);
5057
+ const piArgs = await buildPiArgsForTab(tab.index, tab.title, nextCwd);
4404
5058
  if (sessionFile && !options.noSession) piArgs.push("--session", sessionFile);
4405
5059
  const piCommand = await resolvePiCommand(piArgs);
4406
5060
  const restartingEvent = { type: "webui_tab_restarting", tabId: tab.id, tabTitle: tab.title, cwd: nextCwd, sessionFile };
@@ -4441,7 +5095,7 @@ async function restartTabRpc(tab, reason = "reload") {
4441
5095
  if (state.data?.isStreaming) throw makeHttpError(409, "Wait for the current response to finish before reloading.");
4442
5096
  if (state.data?.isCompacting) throw makeHttpError(409, "Wait for compaction to finish before reloading.");
4443
5097
 
4444
- const piArgs = buildPiArgsForTab(tab.index, tab.title);
5098
+ const piArgs = await buildPiArgsForTab(tab.index, tab.title, tab.cwd);
4445
5099
  if (state.data?.sessionFile && !options.noSession) piArgs.push("--session", state.data.sessionFile);
4446
5100
  const piCommand = await resolvePiCommand(piArgs);
4447
5101
  const reloadingEvent = { type: "webui_tab_reloading", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, reason, sessionFile: state.data?.sessionFile };
@@ -6465,6 +7119,7 @@ const server = createServer(async (req, res) => {
6465
7119
  if (getCommand) {
6466
7120
  const tab = getRequestedTab(req, url);
6467
7121
  const response = await safeRpcResponse(tab, getCommand);
7122
+ if (url.pathname === "/api/messages") applyMessagesSinceParam(response, url);
6468
7123
  sendJson(res, response.success === false ? 400 : 200, response);
6469
7124
  return;
6470
7125
  }