@firstpick/pi-package-webui 0.3.9 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -11
- package/bin/pi-webui.mjs +841 -39
- package/package.json +11 -22
- package/public/app.js +2629 -193
- package/public/index.html +78 -4
- package/public/service-worker.js +1 -1
- package/public/styles.css +931 -4
- package/tests/fixtures/fake-pi.mjs +10 -1
- package/tests/http-endpoints-harness.test.mjs +140 -2
- package/tests/mobile-static.test.mjs +96 -31
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 {
|
|
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}
|
|
@@ -926,6 +933,10 @@ async function installRootDeclaresPackage(root, packageName) {
|
|
|
926
933
|
return declaredDependencySpec(pkg, packageName) !== undefined;
|
|
927
934
|
}
|
|
928
935
|
|
|
936
|
+
async function installRootContainsPackage(root, packageName) {
|
|
937
|
+
return directoryExists(packageNodeModulesPath(path.join(root, "node_modules"), packageName));
|
|
938
|
+
}
|
|
939
|
+
|
|
929
940
|
function configuredAgentNpmRoot() {
|
|
930
941
|
const root = process.env.PI_CODING_AGENT_DIR ? path.resolve(expandUserPath(process.env.PI_CODING_AGENT_DIR)) : agentDir;
|
|
931
942
|
return path.join(root, "npm");
|
|
@@ -936,10 +947,10 @@ async function optionalDependencyInstallRoot() {
|
|
|
936
947
|
if (configuredRoot) return path.resolve(expandUserPath(configuredRoot));
|
|
937
948
|
|
|
938
949
|
const installRoot = nodeModulesParentForPackageRoot(packageRoot);
|
|
939
|
-
if (await installRootDeclaresPackage(installRoot, "@firstpick/pi-package-webui")) return installRoot;
|
|
950
|
+
if (await installRootDeclaresPackage(installRoot, "@firstpick/pi-package-webui") || await installRootContainsPackage(installRoot, "@firstpick/pi-package-webui")) return installRoot;
|
|
940
951
|
|
|
941
952
|
const agentNpmRoot = configuredAgentNpmRoot();
|
|
942
|
-
if (installRoot !== agentNpmRoot && await installRootDeclaresPackage(agentNpmRoot, "@firstpick/pi-package-webui")) return agentNpmRoot;
|
|
953
|
+
if (installRoot !== agentNpmRoot && (await installRootDeclaresPackage(agentNpmRoot, "@firstpick/pi-package-webui") || await installRootContainsPackage(agentNpmRoot, "@firstpick/pi-package-webui"))) return agentNpmRoot;
|
|
943
954
|
|
|
944
955
|
if (webuiDevServer) return installRoot;
|
|
945
956
|
|
|
@@ -949,13 +960,102 @@ async function optionalDependencyInstallRoot() {
|
|
|
949
960
|
);
|
|
950
961
|
}
|
|
951
962
|
|
|
963
|
+
function minimumPackageVersionFromSpec(spec) {
|
|
964
|
+
const match = String(spec || "").match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/);
|
|
965
|
+
return match?.[0] || "";
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function packageVersionBelowSpec(currentVersion, spec) {
|
|
969
|
+
const minimum = minimumPackageVersionFromSpec(spec);
|
|
970
|
+
return !!(currentVersion && minimum && isNewerPackageVersion(minimum, currentVersion));
|
|
971
|
+
}
|
|
972
|
+
|
|
952
973
|
function formatCommandForDisplay(command, args) {
|
|
953
974
|
return [command, ...args].map((part) => (/\s/.test(part) ? JSON.stringify(part) : part)).join(" ");
|
|
954
975
|
}
|
|
955
976
|
|
|
956
|
-
|
|
977
|
+
let optionalPackageNodeModulesRootsCache = null;
|
|
978
|
+
async function optionalPackageNodeModulesRoots() {
|
|
979
|
+
if (optionalPackageNodeModulesRootsCache) return optionalPackageNodeModulesRootsCache;
|
|
980
|
+
const roots = [];
|
|
981
|
+
const seen = new Set();
|
|
982
|
+
const add = (root) => {
|
|
983
|
+
if (!root) return;
|
|
984
|
+
const normalized = path.resolve(root);
|
|
985
|
+
if (seen.has(normalized)) return;
|
|
986
|
+
seen.add(normalized);
|
|
987
|
+
roots.push(normalized);
|
|
988
|
+
};
|
|
989
|
+
const configuredRoot = process.env[OPTIONAL_FEATURE_INSTALL_ROOT_ENV];
|
|
990
|
+
if (configuredRoot) add(path.join(path.resolve(expandUserPath(configuredRoot)), "node_modules"));
|
|
991
|
+
add(path.join(packageRoot, "node_modules"));
|
|
992
|
+
add(path.join(nodeModulesParentForPackageRoot(packageRoot), "node_modules"));
|
|
993
|
+
add(path.join(configuredAgentNpmRoot(), "node_modules"));
|
|
994
|
+
const npmGlobalRoot = await npmGlobalNodeModulesRoot();
|
|
995
|
+
if (npmGlobalRoot) add(npmGlobalRoot);
|
|
996
|
+
for (const bunRoot of await bunGlobalNodeModulesRoots()) add(bunRoot);
|
|
997
|
+
optionalPackageNodeModulesRootsCache = roots;
|
|
998
|
+
return roots;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
async function optionalPackageCandidateRoots(packageName) {
|
|
1002
|
+
return (await optionalPackageNodeModulesRoots()).map((root) => packageNodeModulesPath(root, packageName));
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
async function resolveInstalledPackageRoot(packageName) {
|
|
1006
|
+
const workspaceRoot = await workspacePackageRootForName(packageName);
|
|
1007
|
+
if (workspaceRoot) return workspaceRoot;
|
|
1008
|
+
for (const candidate of await optionalPackageCandidateRoots(packageName)) {
|
|
1009
|
+
if (await directoryExists(candidate)) return candidate;
|
|
1010
|
+
}
|
|
1011
|
+
return null;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
async function resolveInstalledPackageSubpath(packageName, subpath = "") {
|
|
1015
|
+
const root = await resolveInstalledPackageRoot(packageName);
|
|
1016
|
+
if (!root) return null;
|
|
1017
|
+
const candidate = path.join(root, subpath || "");
|
|
1018
|
+
try {
|
|
1019
|
+
await access(candidate);
|
|
1020
|
+
return candidate;
|
|
1021
|
+
} catch {
|
|
1022
|
+
return null;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function optionalFeatureDeclaredSpec(packageName) {
|
|
1027
|
+
return declaredDependencySpec(packageJson, packageName) || "";
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
async function optionalFeaturePackageStatus(featureId) {
|
|
957
1031
|
const packageName = OPTIONAL_FEATURE_PACKAGES.get(featureId);
|
|
958
1032
|
if (!packageName) throw makeHttpError(400, `Unknown optional feature: ${featureId}`);
|
|
1033
|
+
const declaredSpec = optionalFeatureDeclaredSpec(packageName);
|
|
1034
|
+
const installedRoot = await resolveInstalledPackageRoot(packageName);
|
|
1035
|
+
const manifest = installedRoot ? await readJsonFileIfExists(path.join(installedRoot, "package.json")) : null;
|
|
1036
|
+
const installedVersion = typeof manifest?.version === "string" ? manifest.version : "";
|
|
1037
|
+
const updateAvailable = !!(installedVersion && packageVersionBelowSpec(installedVersion, declaredSpec));
|
|
1038
|
+
return {
|
|
1039
|
+
featureId,
|
|
1040
|
+
packageName,
|
|
1041
|
+
declaredSpec,
|
|
1042
|
+
installed: !!installedRoot,
|
|
1043
|
+
installedVersion,
|
|
1044
|
+
installedRoot,
|
|
1045
|
+
updateAvailable,
|
|
1046
|
+
updateReason: updateAvailable ? `installed ${installedVersion} is older than Web UI expects (${declaredSpec})` : "",
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
async function optionalFeaturePackageStatuses() {
|
|
1051
|
+
const features = [];
|
|
1052
|
+
for (const featureId of OPTIONAL_FEATURE_PACKAGES.keys()) features.push(await optionalFeaturePackageStatus(featureId));
|
|
1053
|
+
return { features };
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
async function installOptionalFeaturePackage(featureId) {
|
|
1057
|
+
const beforeStatus = await optionalFeaturePackageStatus(featureId);
|
|
1058
|
+
const packageName = beforeStatus.packageName;
|
|
959
1059
|
|
|
960
1060
|
const installRoot = await optionalDependencyInstallRoot();
|
|
961
1061
|
const npmCommand = process.env.PI_WEBUI_NPM_BIN || "npm";
|
|
@@ -971,6 +1071,8 @@ async function installOptionalFeaturePackage(featureId) {
|
|
|
971
1071
|
const details = [result.error, result.timedOut ? "timed out" : undefined, result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join("\n");
|
|
972
1072
|
throw makeHttpError(500, `Optional feature install failed: ${command}${details ? `\n${details}` : ""}`);
|
|
973
1073
|
}
|
|
1074
|
+
const afterStatus = await optionalFeaturePackageStatus(featureId);
|
|
1075
|
+
const operation = beforeStatus.installed ? "Updated" : "Installed";
|
|
974
1076
|
return {
|
|
975
1077
|
featureId,
|
|
976
1078
|
packageName,
|
|
@@ -978,7 +1080,8 @@ async function installOptionalFeaturePackage(featureId) {
|
|
|
978
1080
|
command,
|
|
979
1081
|
stdout: result.stdout,
|
|
980
1082
|
stderr: result.stderr,
|
|
981
|
-
|
|
1083
|
+
status: afterStatus,
|
|
1084
|
+
message: `${operation} optional feature package ${packageName}${afterStatus.installedVersion ? ` to ${afterStatus.installedVersion}` : ""}. Reload the active Pi tab to load new resources.`,
|
|
982
1085
|
};
|
|
983
1086
|
}
|
|
984
1087
|
|
|
@@ -2940,6 +3043,219 @@ function cleanGitBranchName(value) {
|
|
|
2940
3043
|
return branch;
|
|
2941
3044
|
}
|
|
2942
3045
|
|
|
3046
|
+
function cleanGitCommitMessageInput(value) {
|
|
3047
|
+
const message = String(value || "").replace(/\r\n?/g, "\n").trim();
|
|
3048
|
+
if (!message) throw new Error("commit message is required");
|
|
3049
|
+
if (message.includes("\0")) throw new Error("commit message contains a NUL byte");
|
|
3050
|
+
if (message.length > 10000) throw new Error("commit message is too long");
|
|
3051
|
+
return message;
|
|
3052
|
+
}
|
|
3053
|
+
|
|
3054
|
+
function parseGitPorcelainZEntries(text) {
|
|
3055
|
+
const fields = String(text || "").split("\0").filter(Boolean);
|
|
3056
|
+
const entries = [];
|
|
3057
|
+
for (let index = 0; index < fields.length; index++) {
|
|
3058
|
+
const field = fields[index];
|
|
3059
|
+
if (field.length < 4) {
|
|
3060
|
+
entries.push({ x: "", y: "", path: field, unsupported: true });
|
|
3061
|
+
continue;
|
|
3062
|
+
}
|
|
3063
|
+
const x = field[0] || " ";
|
|
3064
|
+
const y = field[1] || " ";
|
|
3065
|
+
const filePath = field.slice(3);
|
|
3066
|
+
const entry = { x, y, path: filePath };
|
|
3067
|
+
if ((x === "R" || x === "C") && index + 1 < fields.length) entry.oldPath = fields[++index];
|
|
3068
|
+
entries.push(entry);
|
|
3069
|
+
}
|
|
3070
|
+
return entries;
|
|
3071
|
+
}
|
|
3072
|
+
|
|
3073
|
+
function gitWorkflowDefaultCommitAction(entry) {
|
|
3074
|
+
if (!entry || entry.y !== " ") return "";
|
|
3075
|
+
if (entry.x === "A") return "created";
|
|
3076
|
+
if (entry.x === "M" || entry.x === "T") return "updated";
|
|
3077
|
+
if (entry.x === "D") return "deleted";
|
|
3078
|
+
return "";
|
|
3079
|
+
}
|
|
3080
|
+
|
|
3081
|
+
function formatGitWorkflowDefaultCommitPath(filePath) {
|
|
3082
|
+
return String(filePath || "").replace(/[\0\r\n\t]+/g, " ").replace(/\s+/g, " ").trim().slice(0, 4000);
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
async function readGitWorkflowDefaultCommitMessage(cwd) {
|
|
3086
|
+
const root = await getGitRoot(cwd);
|
|
3087
|
+
const statusText = await runGitReadCommand(root, ["status", "--porcelain=v1", "-z", "--untracked-files=all"], { maxOutputLength: 120_000 });
|
|
3088
|
+
const entries = parseGitPorcelainZEntries(statusText);
|
|
3089
|
+
const empty = (reason, extra = {}) => ({ root, message: "", reason, ...extra });
|
|
3090
|
+
if (entries.length === 0) return empty("No changed files are ready for a default commit message.");
|
|
3091
|
+
if (entries.length !== 1) return empty(`Expected exactly one changed file for a default commit message; found ${entries.length}.`);
|
|
3092
|
+
const [entry] = entries;
|
|
3093
|
+
const action = gitWorkflowDefaultCommitAction(entry);
|
|
3094
|
+
const displayPath = formatGitWorkflowDefaultCommitPath(entry.path);
|
|
3095
|
+
if (!action || !displayPath) {
|
|
3096
|
+
return empty("The only changed file is not a staged created, updated, or deleted file.", { path: entry.path || "" });
|
|
3097
|
+
}
|
|
3098
|
+
return {
|
|
3099
|
+
root,
|
|
3100
|
+
message: `${action} ${displayPath}`,
|
|
3101
|
+
action,
|
|
3102
|
+
path: entry.path,
|
|
3103
|
+
};
|
|
3104
|
+
}
|
|
3105
|
+
|
|
3106
|
+
function cleanGitHubUsername(value) {
|
|
3107
|
+
const username = String(value || "").trim().replace(/^@+/, "");
|
|
3108
|
+
if (!username) throw new Error("GitHub username is required");
|
|
3109
|
+
if (username.length > 39 || !/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(username) || username.includes("--")) {
|
|
3110
|
+
throw new Error("Invalid GitHub username");
|
|
3111
|
+
}
|
|
3112
|
+
return username;
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
function cleanGitHubRepoName(value) {
|
|
3116
|
+
let repoName = String(value || "").trim();
|
|
3117
|
+
const githubUrlMatch = repoName.match(/github\.com[:/][^/\s]+\/([^/\s]+?)(?:\.git)?\/?$/i);
|
|
3118
|
+
if (githubUrlMatch) repoName = githubUrlMatch[1];
|
|
3119
|
+
if (repoName.includes("/")) repoName = repoName.split("/").filter(Boolean).pop() || "";
|
|
3120
|
+
repoName = repoName.replace(/\.git$/i, "");
|
|
3121
|
+
if (!repoName) throw new Error("GitHub repository name is required");
|
|
3122
|
+
if (repoName.length > 100 || repoName === "." || repoName === ".." || !/^[A-Za-z0-9._-]+$/.test(repoName)) {
|
|
3123
|
+
throw new Error("Invalid GitHub repository name");
|
|
3124
|
+
}
|
|
3125
|
+
return repoName;
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
function gitHubOriginUrl(username, repoName) {
|
|
3129
|
+
return `https://github.com/${cleanGitHubUsername(username)}/${cleanGitHubRepoName(repoName)}.git`;
|
|
3130
|
+
}
|
|
3131
|
+
|
|
3132
|
+
function defaultGitRepoNameFromRoot(root) {
|
|
3133
|
+
try {
|
|
3134
|
+
return cleanGitHubRepoName(path.basename(root));
|
|
3135
|
+
} catch {
|
|
3136
|
+
return "new-repo";
|
|
3137
|
+
}
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
async function ensureOutsideGitRepository(cwd) {
|
|
3141
|
+
const result = await runCommand("git", ["rev-parse", "--show-toplevel"], { cwd, timeoutMs: 2000 });
|
|
3142
|
+
if (result.exitCode === 0 && result.stdout.trim()) throw new Error(`Already inside a git repository: ${path.resolve(result.stdout.trim())}`);
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
async function regularFileExists(filePath) {
|
|
3146
|
+
try {
|
|
3147
|
+
return (await stat(filePath)).isFile();
|
|
3148
|
+
} catch (error) {
|
|
3149
|
+
if (error?.code === "ENOENT") return false;
|
|
3150
|
+
throw error;
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
async function detectRepositoryStack(root) {
|
|
3155
|
+
let names = new Set();
|
|
3156
|
+
try {
|
|
3157
|
+
names = new Set(await readdir(root));
|
|
3158
|
+
} catch {
|
|
3159
|
+
return "";
|
|
3160
|
+
}
|
|
3161
|
+
const detected = [];
|
|
3162
|
+
if (names.has("package.json")) {
|
|
3163
|
+
try {
|
|
3164
|
+
const manifest = JSON.parse(await readFile(path.join(root, "package.json"), "utf8"));
|
|
3165
|
+
const deps = { ...(manifest.dependencies || {}), ...(manifest.devDependencies || {}) };
|
|
3166
|
+
if (deps.next) detected.push("Next.js");
|
|
3167
|
+
else if (deps.react || deps.vite) detected.push("React / Vite");
|
|
3168
|
+
else detected.push("Node.js / TypeScript");
|
|
3169
|
+
if (deps.typescript || names.has("tsconfig.json")) detected.push("TypeScript");
|
|
3170
|
+
} catch {
|
|
3171
|
+
detected.push("Node.js");
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
if (names.has("pyproject.toml") || names.has("requirements.txt") || names.has("setup.py")) detected.push("Python");
|
|
3175
|
+
if (names.has("manage.py")) detected.push("Django");
|
|
3176
|
+
if (names.has("Cargo.toml")) detected.push("Rust");
|
|
3177
|
+
if (names.has("go.mod")) detected.push("Go");
|
|
3178
|
+
if (names.has("pom.xml") || names.has("build.gradle") || names.has("build.gradle.kts")) detected.push("Java / Gradle");
|
|
3179
|
+
if (names.has("Dockerfile") || names.has("docker-compose.yml") || names.has("compose.yml")) detected.push("Docker");
|
|
3180
|
+
return [...new Set(detected)].join(", ");
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
async function initialRepositoryFilesStatus(cwd) {
|
|
3184
|
+
const root = await getGitRoot(cwd);
|
|
3185
|
+
const readmePath = path.join(root, "README.md");
|
|
3186
|
+
const gitignorePath = path.join(root, ".gitignore");
|
|
3187
|
+
const [readmeExists, gitignoreExists, detectedStack] = await Promise.all([
|
|
3188
|
+
regularFileExists(readmePath),
|
|
3189
|
+
regularFileExists(gitignorePath),
|
|
3190
|
+
detectRepositoryStack(root),
|
|
3191
|
+
]);
|
|
3192
|
+
return { root, readmePath, gitignorePath, readmeExists, gitignoreExists, detectedStack };
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
function gitignoreLinesForStack(stackInput, detectedStack = "") {
|
|
3196
|
+
const stack = `${stackInput || ""} ${detectedStack || ""}`.toLowerCase();
|
|
3197
|
+
const sections = [
|
|
3198
|
+
["# OS / editors", ".DS_Store", "Thumbs.db", ".idea/", ".vscode/", "*.swp", "*.swo"],
|
|
3199
|
+
["# Local env / secrets", ".env", ".env.*", "!.env.example", "*.local"],
|
|
3200
|
+
["# Logs / temp", "*.log", "logs/", "tmp/", "temp/", ".cache/"],
|
|
3201
|
+
];
|
|
3202
|
+
if (/node|npm|pnpm|yarn|bun|typescript|javascript|react|vite|next/.test(stack)) {
|
|
3203
|
+
sections.push(["# Node / frontend", "node_modules/", "dist/", "build/", ".next/", "out/", "coverage/", ".turbo/", ".vite/", "*.tsbuildinfo"]);
|
|
3204
|
+
}
|
|
3205
|
+
if (/python|django|fastapi|flask/.test(stack)) {
|
|
3206
|
+
sections.push(["# Python", "__pycache__/", "*.py[cod]", ".pytest_cache/", ".ruff_cache/", ".mypy_cache/", ".venv/", "venv/", "htmlcov/", "*.egg-info/"]);
|
|
3207
|
+
}
|
|
3208
|
+
if (/rust|cargo/.test(stack)) sections.push(["# Rust", "target/"]);
|
|
3209
|
+
if (/\bgo\b|golang/.test(stack)) sections.push(["# Go", "bin/", "*.test", "coverage.out"]);
|
|
3210
|
+
if (/java|gradle|maven|kotlin/.test(stack)) sections.push(["# Java / JVM", "target/", "build/", ".gradle/", "*.class"]);
|
|
3211
|
+
if (/docker|container/.test(stack)) sections.push(["# Docker", ".docker/", "docker-compose.override.yml"]);
|
|
3212
|
+
if (sections.length === 3) {
|
|
3213
|
+
sections.push(["# Common dependency / build outputs", "node_modules/", ".venv/", "venv/", "target/", "dist/", "build/", "coverage/", "vendor/", "*.tmp"]);
|
|
3214
|
+
}
|
|
3215
|
+
const seen = new Set();
|
|
3216
|
+
const lines = [];
|
|
3217
|
+
for (const section of sections) {
|
|
3218
|
+
lines.push(section[0]);
|
|
3219
|
+
for (const pattern of section.slice(1)) {
|
|
3220
|
+
if (seen.has(pattern)) continue;
|
|
3221
|
+
seen.add(pattern);
|
|
3222
|
+
lines.push(pattern);
|
|
3223
|
+
}
|
|
3224
|
+
lines.push("");
|
|
3225
|
+
}
|
|
3226
|
+
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
async function prepareInitialRepositoryFiles(cwd, { repoName: repoNameInput, stack = "" } = {}) {
|
|
3230
|
+
const before = await initialRepositoryFilesStatus(cwd);
|
|
3231
|
+
const repoName = repoNameInput ? cleanGitHubRepoName(repoNameInput) : defaultGitRepoNameFromRoot(before.root);
|
|
3232
|
+
if (await regularFileExists(before.readmePath)) {
|
|
3233
|
+
// Explicit pre-add check: keep existing README.md unchanged.
|
|
3234
|
+
} else {
|
|
3235
|
+
await writeFile(before.readmePath, `# ${repoName}\n\nInitialized by Pi Web UI.\n`, "utf8");
|
|
3236
|
+
}
|
|
3237
|
+
let gitignoreCreated = false;
|
|
3238
|
+
let gitignoreSource = before.gitignoreExists ? "existing" : "fallback";
|
|
3239
|
+
if (!(await regularFileExists(before.gitignorePath))) {
|
|
3240
|
+
await writeFile(before.gitignorePath, gitignoreLinesForStack(stack, before.detectedStack), "utf8");
|
|
3241
|
+
gitignoreCreated = true;
|
|
3242
|
+
}
|
|
3243
|
+
const payload = gitWorkflowCommandPayload(await runGitWorkflowCommand(["add", "--", "README.md", ".gitignore"], { cwd: before.root, label: "git add README.md .gitignore" }));
|
|
3244
|
+
if (payload.data) {
|
|
3245
|
+
payload.data.root = before.root;
|
|
3246
|
+
payload.data.readme = { path: before.readmePath, exists: true, created: !before.readmeExists };
|
|
3247
|
+
payload.data.gitignore = { path: before.gitignorePath, exists: true, created: gitignoreCreated, source: gitignoreSource };
|
|
3248
|
+
payload.data.detectedStack = before.detectedStack;
|
|
3249
|
+
payload.data.stack = stack;
|
|
3250
|
+
payload.data.stdout = [
|
|
3251
|
+
before.readmeExists ? `Using existing ${before.readmePath}` : `Created ${before.readmePath}`,
|
|
3252
|
+
before.gitignoreExists ? `Using existing ${before.gitignorePath}` : `Created ${before.gitignorePath} (${gitignoreSource})`,
|
|
3253
|
+
payload.data.stdout?.trimEnd(),
|
|
3254
|
+
].filter(Boolean).join("\n");
|
|
3255
|
+
}
|
|
3256
|
+
return payload;
|
|
3257
|
+
}
|
|
3258
|
+
|
|
2943
3259
|
async function validateGitBranchName(root, branch) {
|
|
2944
3260
|
const result = await runGitWorkflowCommand(["check-ref-format", "--branch", branch], { cwd: root, timeoutMs: 5000 });
|
|
2945
3261
|
if (result.exitCode !== 0 || result.timedOut || result.cancelled || result.error) {
|
|
@@ -3132,10 +3448,46 @@ async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd)
|
|
|
3132
3448
|
switch (pathname) {
|
|
3133
3449
|
case "/api/git-workflow/message":
|
|
3134
3450
|
return { ok: true, data: await readGitWorkflowMessages(cwd) };
|
|
3451
|
+
case "/api/git-workflow/default-commit-message":
|
|
3452
|
+
return { ok: true, data: await readGitWorkflowDefaultCommitMessage(cwd) };
|
|
3135
3453
|
case "/api/git-workflow/branch-name":
|
|
3136
3454
|
return { ok: true, data: await readGitWorkflowBranchName(cwd) };
|
|
3137
3455
|
case "/api/git-workflow/pr-description":
|
|
3138
3456
|
return { ok: true, data: await readGitWorkflowPrDescription(cwd) };
|
|
3457
|
+
case "/api/git-workflow/init":
|
|
3458
|
+
await ensureOutsideGitRepository(cwd);
|
|
3459
|
+
return gitWorkflowCommandPayload(await runGitWorkflowCommand(["init"], { cwd }));
|
|
3460
|
+
case "/api/git-workflow/init-files-status":
|
|
3461
|
+
return { ok: true, data: await initialRepositoryFilesStatus(cwd) };
|
|
3462
|
+
case "/api/git-workflow/readme":
|
|
3463
|
+
return prepareInitialRepositoryFiles(cwd, { repoName: body.repoName, stack: body.stack });
|
|
3464
|
+
case "/api/git-workflow/initial-commit": {
|
|
3465
|
+
const root = await getGitRoot(cwd);
|
|
3466
|
+
return gitWorkflowCommandPayload(await runGitWorkflowCommand(["commit", "-m", "Initial commit"], { cwd: root, label: "git commit -m \"Initial commit\"" }));
|
|
3467
|
+
}
|
|
3468
|
+
case "/api/git-workflow/main-branch": {
|
|
3469
|
+
const root = await getGitRoot(cwd);
|
|
3470
|
+
return gitWorkflowCommandPayload(await runGitWorkflowCommand(["branch", "-M", "main"], { cwd: root }));
|
|
3471
|
+
}
|
|
3472
|
+
case "/api/git-workflow/remote": {
|
|
3473
|
+
const root = await getGitRoot(cwd);
|
|
3474
|
+
const username = cleanGitHubUsername(body.username);
|
|
3475
|
+
const repoName = cleanGitHubRepoName(body.repoName);
|
|
3476
|
+
const remoteUrl = gitHubOriginUrl(username, repoName);
|
|
3477
|
+
const payload = gitWorkflowCommandPayload(await runGitWorkflowCommand(["remote", "add", "origin", remoteUrl], { cwd: root }));
|
|
3478
|
+
if (payload.data) {
|
|
3479
|
+
payload.data.root = root;
|
|
3480
|
+
payload.data.remote = "origin";
|
|
3481
|
+
payload.data.remoteUrl = remoteUrl;
|
|
3482
|
+
payload.data.repoName = repoName;
|
|
3483
|
+
payload.data.username = username;
|
|
3484
|
+
}
|
|
3485
|
+
return payload;
|
|
3486
|
+
}
|
|
3487
|
+
case "/api/git-workflow/init-push": {
|
|
3488
|
+
const root = await getGitRoot(cwd);
|
|
3489
|
+
return gitWorkflowCommandPayload(await runGitWorkflowCommand(["push", "-u", "origin", "main"], { cwd: root, timeoutMs: 15 * 60 * 1000 }));
|
|
3490
|
+
}
|
|
3139
3491
|
case "/api/git-workflow/add":
|
|
3140
3492
|
await getGitRoot(cwd);
|
|
3141
3493
|
return gitWorkflowCommandPayload(await runGitWorkflowCommand(["add", "."], { cwd }));
|
|
@@ -3149,7 +3501,12 @@ async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd)
|
|
|
3149
3501
|
}
|
|
3150
3502
|
case "/api/git-workflow/commit": {
|
|
3151
3503
|
const variant = String(body.variant || "").trim();
|
|
3152
|
-
if (!["short", "long"].includes(variant)) throw new Error("variant must be 'short' or '
|
|
3504
|
+
if (!["short", "long", "input"].includes(variant)) throw new Error("variant must be 'short', 'long', or 'input'");
|
|
3505
|
+
if (variant === "input") {
|
|
3506
|
+
const root = await getGitRoot(cwd);
|
|
3507
|
+
const message = cleanGitCommitMessageInput(body.message);
|
|
3508
|
+
return gitWorkflowCommandPayload(await runGitWorkflowCommand(["commit", "-m", message], { cwd: root, label: "git commit -m <input message>" }));
|
|
3509
|
+
}
|
|
3153
3510
|
const messages = await readGitWorkflowMessages(cwd);
|
|
3154
3511
|
if (variant === "short") {
|
|
3155
3512
|
const message = messages.short.trim();
|
|
@@ -3288,6 +3645,43 @@ function normalizeStaticPath(urlPath) {
|
|
|
3288
3645
|
return name;
|
|
3289
3646
|
}
|
|
3290
3647
|
|
|
3648
|
+
const compressWithBrotli = promisify(brotliCompress);
|
|
3649
|
+
const compressWithGzip = promisify(gzip);
|
|
3650
|
+
const STATIC_COMPRESSIBLE_EXTENSIONS = new Set([".html", ".css", ".js", ".mjs", ".svg", ".json", ".webmanifest"]);
|
|
3651
|
+
const STATIC_COMPRESSION_MIN_BYTES = 1024;
|
|
3652
|
+
// filePath -> { mtimeMs, size, etag, raw, br, gz }; invalidated by mtime/size change.
|
|
3653
|
+
const staticAssetCache = new Map();
|
|
3654
|
+
|
|
3655
|
+
async function loadStaticAsset(filePath) {
|
|
3656
|
+
const stats = await stat(filePath);
|
|
3657
|
+
const cached = staticAssetCache.get(filePath);
|
|
3658
|
+
if (cached && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) return cached;
|
|
3659
|
+
const raw = await readFile(filePath);
|
|
3660
|
+
const entry = {
|
|
3661
|
+
mtimeMs: stats.mtimeMs,
|
|
3662
|
+
size: stats.size,
|
|
3663
|
+
etag: `"${createHash("sha1").update(raw).digest("base64url")}"`,
|
|
3664
|
+
raw,
|
|
3665
|
+
br: null,
|
|
3666
|
+
gz: null,
|
|
3667
|
+
};
|
|
3668
|
+
staticAssetCache.set(filePath, entry);
|
|
3669
|
+
return entry;
|
|
3670
|
+
}
|
|
3671
|
+
|
|
3672
|
+
function acceptedStaticEncoding(req) {
|
|
3673
|
+
const header = String(req.headers["accept-encoding"] || "");
|
|
3674
|
+
if (/\bbr\b/i.test(header)) return "br";
|
|
3675
|
+
if (/\bgzip\b/i.test(header)) return "gzip";
|
|
3676
|
+
return "";
|
|
3677
|
+
}
|
|
3678
|
+
|
|
3679
|
+
function requestEtagMatches(req, etag) {
|
|
3680
|
+
const header = String(req.headers["if-none-match"] || "");
|
|
3681
|
+
if (!header) return false;
|
|
3682
|
+
return header.split(",").some((candidate) => candidate.trim() === etag);
|
|
3683
|
+
}
|
|
3684
|
+
|
|
3291
3685
|
async function serveStatic(req, res, url) {
|
|
3292
3686
|
if (req.method !== "GET") return false;
|
|
3293
3687
|
const staticName = normalizeStaticPath(url.pathname);
|
|
@@ -3295,13 +3689,42 @@ async function serveStatic(req, res, url) {
|
|
|
3295
3689
|
|
|
3296
3690
|
const filePath = path.join(publicDir, staticName);
|
|
3297
3691
|
const ext = path.extname(filePath);
|
|
3298
|
-
const
|
|
3299
|
-
|
|
3692
|
+
const asset = await loadStaticAsset(filePath);
|
|
3693
|
+
const headers = {
|
|
3300
3694
|
"content-type": MIME_TYPES.get(ext) || "application/octet-stream",
|
|
3301
|
-
|
|
3695
|
+
// no-cache (unlike no-store) allows conditional revalidation via ETag/304
|
|
3696
|
+
// while still guaranteeing fresh content after deploys.
|
|
3697
|
+
"cache-control": "no-cache",
|
|
3698
|
+
etag: asset.etag,
|
|
3699
|
+
vary: "Accept-Encoding",
|
|
3302
3700
|
"x-content-type-options": "nosniff",
|
|
3303
|
-
}
|
|
3304
|
-
|
|
3701
|
+
};
|
|
3702
|
+
if (requestEtagMatches(req, asset.etag)) {
|
|
3703
|
+
res.writeHead(304, headers);
|
|
3704
|
+
res.end();
|
|
3705
|
+
return true;
|
|
3706
|
+
}
|
|
3707
|
+
let body = asset.raw;
|
|
3708
|
+
if (STATIC_COMPRESSIBLE_EXTENSIONS.has(ext) && asset.raw.length >= STATIC_COMPRESSION_MIN_BYTES) {
|
|
3709
|
+
const encoding = acceptedStaticEncoding(req);
|
|
3710
|
+
if (encoding === "br") {
|
|
3711
|
+
asset.br ||= await compressWithBrotli(asset.raw, {
|
|
3712
|
+
params: {
|
|
3713
|
+
[zlibConstants.BROTLI_PARAM_QUALITY]: 6,
|
|
3714
|
+
[zlibConstants.BROTLI_PARAM_SIZE_HINT]: asset.raw.length,
|
|
3715
|
+
},
|
|
3716
|
+
});
|
|
3717
|
+
body = asset.br;
|
|
3718
|
+
headers["content-encoding"] = "br";
|
|
3719
|
+
} else if (encoding === "gzip") {
|
|
3720
|
+
asset.gz ||= await compressWithGzip(asset.raw, { level: 7 });
|
|
3721
|
+
body = asset.gz;
|
|
3722
|
+
headers["content-encoding"] = "gzip";
|
|
3723
|
+
}
|
|
3724
|
+
}
|
|
3725
|
+
headers["content-length"] = body.length;
|
|
3726
|
+
res.writeHead(200, headers);
|
|
3727
|
+
res.end(body);
|
|
3305
3728
|
return true;
|
|
3306
3729
|
}
|
|
3307
3730
|
|
|
@@ -3462,6 +3885,23 @@ function commandFromPost(pathname, body) {
|
|
|
3462
3885
|
}
|
|
3463
3886
|
}
|
|
3464
3887
|
|
|
3888
|
+
/**
|
|
3889
|
+
* Delta transcript support (P1-1): /api/messages?since=N serializes only the
|
|
3890
|
+
* tail starting at message index N plus { totalCount, since } so clients can
|
|
3891
|
+
* merge appended messages instead of re-downloading the whole transcript on
|
|
3892
|
+
* every agent event. Without ?since= the legacy full payload is returned.
|
|
3893
|
+
*/
|
|
3894
|
+
function applyMessagesSinceParam(response, url) {
|
|
3895
|
+
const sinceRaw = url.searchParams.get("since");
|
|
3896
|
+
if (sinceRaw === null) return;
|
|
3897
|
+
const messages = response?.data?.messages;
|
|
3898
|
+
if (!Array.isArray(messages)) return;
|
|
3899
|
+
const parsed = Number.parseInt(sinceRaw, 10);
|
|
3900
|
+
const total = messages.length;
|
|
3901
|
+
const since = Number.isInteger(parsed) ? Math.min(Math.max(parsed, 0), total) : 0;
|
|
3902
|
+
response.data = { ...response.data, messages: messages.slice(since), totalCount: total, since };
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3465
3905
|
function commandFromGet(pathname) {
|
|
3466
3906
|
switch (pathname) {
|
|
3467
3907
|
case "/api/state":
|
|
@@ -3556,10 +3996,153 @@ function readRestoreTabsFromEnv() {
|
|
|
3556
3996
|
}
|
|
3557
3997
|
}
|
|
3558
3998
|
|
|
3559
|
-
function
|
|
3560
|
-
|
|
3999
|
+
async function packageNameForResourcePath(resourcePath) {
|
|
4000
|
+
let current = path.dirname(resourcePath);
|
|
4001
|
+
while (current && current !== path.dirname(current)) {
|
|
4002
|
+
if (PACKAGE_NAME_CACHE.has(current)) return PACKAGE_NAME_CACHE.get(current) || undefined;
|
|
4003
|
+
try {
|
|
4004
|
+
const pkg = JSON.parse(await readFile(path.join(current, "package.json"), "utf8"));
|
|
4005
|
+
const name = typeof pkg.name === "string" ? pkg.name : "";
|
|
4006
|
+
PACKAGE_NAME_CACHE.set(current, name);
|
|
4007
|
+
return name || undefined;
|
|
4008
|
+
} catch {
|
|
4009
|
+
current = path.dirname(current);
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
4012
|
+
return undefined;
|
|
4013
|
+
}
|
|
4014
|
+
|
|
4015
|
+
async function packageRootRealpath() {
|
|
4016
|
+
try {
|
|
4017
|
+
return await realpath(packageRoot);
|
|
4018
|
+
} catch {
|
|
4019
|
+
return packageRoot;
|
|
4020
|
+
}
|
|
4021
|
+
}
|
|
4022
|
+
|
|
4023
|
+
async function devWorkspaceRoot() {
|
|
4024
|
+
if (!webuiDevServer) return null;
|
|
4025
|
+
const envRoot = process.env.PI_NPM_PACKAGES_ROOT ? path.resolve(expandUserPath(process.env.PI_NPM_PACKAGES_ROOT)) : "";
|
|
4026
|
+
const candidates = [envRoot, path.dirname(packageRoot), path.dirname(await packageRootRealpath())].filter(Boolean);
|
|
4027
|
+
for (const candidate of candidates) {
|
|
4028
|
+
try {
|
|
4029
|
+
const entries = await readdir(candidate, { withFileTypes: true });
|
|
4030
|
+
if (entries.some((entry) => entry.isDirectory() && entry.name === "pi-package-webui")) return candidate;
|
|
4031
|
+
} catch {
|
|
4032
|
+
// Try the next candidate.
|
|
4033
|
+
}
|
|
4034
|
+
}
|
|
4035
|
+
return null;
|
|
4036
|
+
}
|
|
4037
|
+
|
|
4038
|
+
async function workspacePackageRootForName(packageName) {
|
|
4039
|
+
const root = await devWorkspaceRoot();
|
|
4040
|
+
if (!root) return null;
|
|
4041
|
+
let entries;
|
|
4042
|
+
try {
|
|
4043
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
4044
|
+
} catch {
|
|
4045
|
+
return null;
|
|
4046
|
+
}
|
|
4047
|
+
for (const entry of entries) {
|
|
4048
|
+
if (!entry.isDirectory() || !entry.name.startsWith("pi-")) continue;
|
|
4049
|
+
const candidate = path.join(root, entry.name);
|
|
4050
|
+
if ((await packageNameForResourcePath(path.join(candidate, "index.ts"))) === packageName) return candidate;
|
|
4051
|
+
}
|
|
4052
|
+
return null;
|
|
4053
|
+
}
|
|
4054
|
+
|
|
4055
|
+
function parseNodeModulesPackageRef(manifestEntry) {
|
|
4056
|
+
const normalized = String(manifestEntry || "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
4057
|
+
if (!normalized.startsWith("node_modules/")) return null;
|
|
4058
|
+
const parts = normalized.slice("node_modules/".length).split("/").filter(Boolean);
|
|
4059
|
+
if (!parts.length) return null;
|
|
4060
|
+
const scoped = parts[0].startsWith("@");
|
|
4061
|
+
const packageName = scoped ? `${parts[0]}/${parts[1] || ""}` : parts[0];
|
|
4062
|
+
if (!packageName || packageName.endsWith("/")) return null;
|
|
4063
|
+
const subpath = parts.slice(scoped ? 2 : 1).join("/");
|
|
4064
|
+
return { packageName, subpath };
|
|
4065
|
+
}
|
|
4066
|
+
|
|
4067
|
+
async function resolveStartedWebuiManifestResource(manifestEntry) {
|
|
4068
|
+
const nodeModulesRef = parseNodeModulesPackageRef(manifestEntry);
|
|
4069
|
+
if (nodeModulesRef && WEBUI_CONTROLLED_PACKAGES.has(nodeModulesRef.packageName)) {
|
|
4070
|
+
const installedCandidate = await resolveInstalledPackageSubpath(nodeModulesRef.packageName, nodeModulesRef.subpath);
|
|
4071
|
+
if (installedCandidate) return installedCandidate;
|
|
4072
|
+
}
|
|
4073
|
+
|
|
4074
|
+
const candidate = path.resolve(packageRoot, manifestEntry);
|
|
4075
|
+
try {
|
|
4076
|
+
await access(candidate);
|
|
4077
|
+
return candidate;
|
|
4078
|
+
} catch {
|
|
4079
|
+
return null;
|
|
4080
|
+
}
|
|
4081
|
+
}
|
|
4082
|
+
|
|
4083
|
+
async function startedWebuiResourcePaths(resourceType) {
|
|
4084
|
+
const entries = Array.isArray(packageJson.pi?.[resourceType]) ? packageJson.pi[resourceType] : [];
|
|
4085
|
+
const resolved = [];
|
|
4086
|
+
for (const entry of entries) {
|
|
4087
|
+
if (typeof entry !== "string") continue;
|
|
4088
|
+
const resourcePath = await resolveStartedWebuiManifestResource(entry);
|
|
4089
|
+
if (resourcePath) resolved.push(resourcePath);
|
|
4090
|
+
}
|
|
4091
|
+
return resolved;
|
|
4092
|
+
}
|
|
4093
|
+
|
|
4094
|
+
function piArgsDisableResourceDiscovery(resourceType) {
|
|
4095
|
+
const flags = {
|
|
4096
|
+
extensions: new Set(["--no-extensions", "-ne"]),
|
|
4097
|
+
skills: new Set(["--no-skills", "-ns"]),
|
|
4098
|
+
prompts: new Set(["--no-prompt-templates", "-np"]),
|
|
4099
|
+
themes: new Set(["--no-themes"]),
|
|
4100
|
+
}[resourceType];
|
|
4101
|
+
return !!flags && options.piArgs.some((arg) => flags.has(arg));
|
|
4102
|
+
}
|
|
4103
|
+
|
|
4104
|
+
async function resolvedNormalPiResourcesForTab(cwd) {
|
|
4105
|
+
try {
|
|
4106
|
+
const settingsManager = SettingsManager.create(cwd, agentDir);
|
|
4107
|
+
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
|
4108
|
+
return await packageManager.resolve(async () => "skip");
|
|
4109
|
+
} catch (error) {
|
|
4110
|
+
console.warn(`failed to resolve configured Pi resources for Web UI tab: ${sanitizeError(error)}`);
|
|
4111
|
+
return { extensions: [], skills: [], prompts: [], themes: [] };
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
4114
|
+
|
|
4115
|
+
async function normalPiResourcePathsForTab(resolved, resourceType) {
|
|
4116
|
+
if (piArgsDisableResourceDiscovery(resourceType)) return [];
|
|
4117
|
+
const resourcePaths = [];
|
|
4118
|
+
for (const resource of resolved[resourceType] || []) {
|
|
4119
|
+
if (!resource.enabled) continue;
|
|
4120
|
+
const packageName = await packageNameForResourcePath(resource.path);
|
|
4121
|
+
if (packageName && WEBUI_CONTROLLED_PACKAGES.has(packageName)) continue;
|
|
4122
|
+
resourcePaths.push(resource.path);
|
|
4123
|
+
}
|
|
4124
|
+
return resourcePaths;
|
|
4125
|
+
}
|
|
4126
|
+
|
|
4127
|
+
function appendResourceArgs(args, flag, resourcePaths) {
|
|
4128
|
+
for (const resourcePath of resourcePaths) args.push(flag, resourcePath);
|
|
4129
|
+
}
|
|
4130
|
+
|
|
4131
|
+
async function appendCuratedResourceArgs(args, normalResources, resourceType, flag) {
|
|
4132
|
+
appendResourceArgs(args, flag, await normalPiResourcePathsForTab(normalResources, resourceType));
|
|
4133
|
+
appendResourceArgs(args, flag, await startedWebuiResourcePaths(resourceType));
|
|
4134
|
+
}
|
|
4135
|
+
|
|
4136
|
+
async function buildPiArgsForTab(tabIndex, title, tabCwd = options.cwd) {
|
|
4137
|
+
const args = ["--mode", "rpc", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-themes"];
|
|
3561
4138
|
if (options.noSession) args.push("--no-session");
|
|
3562
4139
|
|
|
4140
|
+
const normalResources = await resolvedNormalPiResourcesForTab(tabCwd);
|
|
4141
|
+
await appendCuratedResourceArgs(args, normalResources, "extensions", "--extension");
|
|
4142
|
+
await appendCuratedResourceArgs(args, normalResources, "skills", "--skill");
|
|
4143
|
+
await appendCuratedResourceArgs(args, normalResources, "prompts", "--prompt-template");
|
|
4144
|
+
await appendCuratedResourceArgs(args, normalResources, "themes", "--theme");
|
|
4145
|
+
|
|
3563
4146
|
// Load a browser-safe RPC helper into every Web UI tab. It exposes hidden
|
|
3564
4147
|
// extension commands for Web UI-native /tools and /skills selectors without
|
|
3565
4148
|
// depending on TUI-only extension UIs.
|
|
@@ -4028,7 +4611,7 @@ async function createTab({ id: requestedId, index, title, titleSource, conversat
|
|
|
4028
4611
|
const resolvedTitleSource = ["explicit", "auto", "default"].includes(titleSource) ? titleSource : titleIsExplicit ? "explicit" : "default";
|
|
4029
4612
|
const tabCwd = cwd ? await resolveCwd(cwd, options.cwd) : options.cwd;
|
|
4030
4613
|
const id = requestedId && !tabs.has(requestedId) ? requestedId : randomUUID();
|
|
4031
|
-
const piArgs = buildPiArgsForTab(tabIndex, tabTitle);
|
|
4614
|
+
const piArgs = await buildPiArgsForTab(tabIndex, tabTitle, tabCwd);
|
|
4032
4615
|
if (sessionFile && !options.noSession) piArgs.push("--session", sessionFile);
|
|
4033
4616
|
const piCommand = await resolvePiCommand(piArgs);
|
|
4034
4617
|
const rpc = new PiRpcProcess({ ...piCommand, cwd: tabCwd });
|
|
@@ -4271,13 +4854,13 @@ async function getUpdateStatus({ force = false } = {}) {
|
|
|
4271
4854
|
checkedAt: new Date(now).toISOString(),
|
|
4272
4855
|
updateAvailable,
|
|
4273
4856
|
restartRequired: true,
|
|
4274
|
-
command: "pi update",
|
|
4857
|
+
command: "pi update + Web UI/Pi package-manager updates",
|
|
4275
4858
|
webuiDev: webuiDevServer,
|
|
4276
4859
|
pi: piStatus,
|
|
4277
4860
|
webui: webuiStatus,
|
|
4278
4861
|
packages: {
|
|
4279
4862
|
checked: false,
|
|
4280
|
-
note: "pi update
|
|
4863
|
+
note: "Update runs pi update plus all detected local, Pi-agent, npm-global, and Bun-global Web UI/Pi package roots."
|
|
4281
4864
|
},
|
|
4282
4865
|
};
|
|
4283
4866
|
updateStatusCacheAt = now;
|
|
@@ -4286,15 +4869,235 @@ async function getUpdateStatus({ force = false } = {}) {
|
|
|
4286
4869
|
|
|
4287
4870
|
async function resolvePiUpdateCommand() {
|
|
4288
4871
|
if (options.piBinExplicit) {
|
|
4289
|
-
return { command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
|
|
4872
|
+
return { label: "Pi CLI and configured packages", command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
|
|
4290
4873
|
}
|
|
4291
4874
|
|
|
4292
4875
|
const pathPi = await runCommand(options.piBin, ["--version"], { timeoutMs: 3000, maxOutputLength: 4000 });
|
|
4293
4876
|
if (pathPi.exitCode === 0 && !pathPi.timedOut && !pathPi.error) {
|
|
4294
|
-
return { command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
|
|
4877
|
+
return { label: "Pi CLI and configured packages", command: options.piBin, args: ["update"], displayCommand: formatCommandForDisplay(options.piBin, ["update"]) };
|
|
4878
|
+
}
|
|
4879
|
+
|
|
4880
|
+
const fallback = await resolvePiCommand(["update"]);
|
|
4881
|
+
return { ...fallback, label: "bundled Pi CLI and configured packages" };
|
|
4882
|
+
}
|
|
4883
|
+
|
|
4884
|
+
function packageNodeModulesPath(nodeModulesRoot, packageName) {
|
|
4885
|
+
return path.join(nodeModulesRoot, ...String(packageName || "").split("/").filter(Boolean));
|
|
4886
|
+
}
|
|
4887
|
+
|
|
4888
|
+
function isWebuiOrPiPackageName(packageName) {
|
|
4889
|
+
const name = String(packageName || "").trim();
|
|
4890
|
+
return UPDATE_PACKAGE_NAMES.includes(name)
|
|
4891
|
+
|| /^@firstpick\/pi(?:-|$)/.test(name)
|
|
4892
|
+
|| /^@earendil-works\/pi(?:-|$)/.test(name)
|
|
4893
|
+
|| /^@firstpick\/.*webui/i.test(name);
|
|
4894
|
+
}
|
|
4895
|
+
|
|
4896
|
+
function declaredWebuiPiPackageNames(manifest) {
|
|
4897
|
+
const names = new Set();
|
|
4898
|
+
for (const section of [manifest?.dependencies, manifest?.optionalDependencies, manifest?.devDependencies]) {
|
|
4899
|
+
for (const packageName of Object.keys(section || {})) {
|
|
4900
|
+
if (isWebuiOrPiPackageName(packageName)) names.add(packageName);
|
|
4901
|
+
}
|
|
4902
|
+
}
|
|
4903
|
+
return [...names].sort();
|
|
4904
|
+
}
|
|
4905
|
+
|
|
4906
|
+
async function packagesPresentInNodeModulesRoot(nodeModulesRoot, packageNames = UPDATE_PACKAGE_NAMES) {
|
|
4907
|
+
const found = new Set();
|
|
4908
|
+
if (!nodeModulesRoot || !await directoryExists(nodeModulesRoot)) return [];
|
|
4909
|
+
for (const packageName of packageNames) {
|
|
4910
|
+
if (await directoryExists(packageNodeModulesPath(nodeModulesRoot, packageName))) found.add(packageName);
|
|
4911
|
+
}
|
|
4912
|
+
|
|
4913
|
+
let entries = [];
|
|
4914
|
+
try {
|
|
4915
|
+
entries = await readdir(nodeModulesRoot, { withFileTypes: true });
|
|
4916
|
+
} catch {
|
|
4917
|
+
return [...found].sort();
|
|
4295
4918
|
}
|
|
4296
4919
|
|
|
4297
|
-
|
|
4920
|
+
for (const entry of entries) {
|
|
4921
|
+
if (!entry.isDirectory()) continue;
|
|
4922
|
+
if (entry.name.startsWith("@")) {
|
|
4923
|
+
let scopedEntries = [];
|
|
4924
|
+
try {
|
|
4925
|
+
scopedEntries = await readdir(path.join(nodeModulesRoot, entry.name), { withFileTypes: true });
|
|
4926
|
+
} catch {
|
|
4927
|
+
continue;
|
|
4928
|
+
}
|
|
4929
|
+
for (const scopedEntry of scopedEntries) {
|
|
4930
|
+
if (!scopedEntry.isDirectory()) continue;
|
|
4931
|
+
const packageName = `${entry.name}/${scopedEntry.name}`;
|
|
4932
|
+
if (isWebuiOrPiPackageName(packageName)) found.add(packageName);
|
|
4933
|
+
}
|
|
4934
|
+
continue;
|
|
4935
|
+
}
|
|
4936
|
+
if (isWebuiOrPiPackageName(entry.name)) found.add(entry.name);
|
|
4937
|
+
}
|
|
4938
|
+
return [...found].sort();
|
|
4939
|
+
}
|
|
4940
|
+
|
|
4941
|
+
async function packagesPresentInInstallPrefix(installRoot, packageNames = UPDATE_PACKAGE_NAMES) {
|
|
4942
|
+
const found = new Set();
|
|
4943
|
+
if (!installRoot || !await directoryExists(installRoot)) return [];
|
|
4944
|
+
const manifest = await readJsonFileIfExists(path.join(installRoot, "package.json"));
|
|
4945
|
+
for (const packageName of packageNames) {
|
|
4946
|
+
if (declaredDependencySpec(manifest, packageName) !== undefined) found.add(packageName);
|
|
4947
|
+
}
|
|
4948
|
+
for (const packageName of declaredWebuiPiPackageNames(manifest)) found.add(packageName);
|
|
4949
|
+
for (const packageName of await packagesPresentInNodeModulesRoot(path.join(installRoot, "node_modules"), packageNames)) {
|
|
4950
|
+
found.add(packageName);
|
|
4951
|
+
}
|
|
4952
|
+
return [...found].sort();
|
|
4953
|
+
}
|
|
4954
|
+
|
|
4955
|
+
function packageInstallSpecs(packageNames) {
|
|
4956
|
+
return packageNames.map((packageName) => `${packageName}@latest`);
|
|
4957
|
+
}
|
|
4958
|
+
|
|
4959
|
+
function npmCommandName() {
|
|
4960
|
+
return process.env.PI_WEBUI_NPM_BIN || "npm";
|
|
4961
|
+
}
|
|
4962
|
+
|
|
4963
|
+
function npmPrefixUpdateTask(label, installRoot, packageNames) {
|
|
4964
|
+
if (!packageNames.length) return null;
|
|
4965
|
+
const npmCommand = npmCommandName();
|
|
4966
|
+
return {
|
|
4967
|
+
label,
|
|
4968
|
+
command: npmCommand,
|
|
4969
|
+
args: ["install", "--prefix", installRoot, "--ignore-scripts", "--min-release-age=0", ...packageInstallSpecs(packageNames)],
|
|
4970
|
+
cwd: installRoot,
|
|
4971
|
+
};
|
|
4972
|
+
}
|
|
4973
|
+
|
|
4974
|
+
async function currentWebuiPackageUpdateTask() {
|
|
4975
|
+
const sourceCheckout = webuiDevServer || !String(packageRoot).split(path.sep).includes("node_modules");
|
|
4976
|
+
if (sourceCheckout) {
|
|
4977
|
+
const manifest = await readJsonFileIfExists(path.join(packageRoot, "package.json"));
|
|
4978
|
+
const packages = Object.keys(manifest?.dependencies || {}).filter(isWebuiOrPiPackageName).sort();
|
|
4979
|
+
return npmPrefixUpdateTask("current Web UI checkout package dependencies", packageRoot, packages);
|
|
4980
|
+
}
|
|
4981
|
+
|
|
4982
|
+
const installRoot = nodeModulesParentForPackageRoot(packageRoot);
|
|
4983
|
+
const packages = await packagesPresentInInstallPrefix(installRoot);
|
|
4984
|
+
return npmPrefixUpdateTask("current Web UI install root", installRoot, packages);
|
|
4985
|
+
}
|
|
4986
|
+
|
|
4987
|
+
async function agentPackageRootUpdateTask() {
|
|
4988
|
+
const installRoot = configuredAgentNpmRoot();
|
|
4989
|
+
const packages = await packagesPresentInInstallPrefix(installRoot);
|
|
4990
|
+
return npmPrefixUpdateTask("Pi agent npm package root", installRoot, packages);
|
|
4991
|
+
}
|
|
4992
|
+
|
|
4993
|
+
async function npmGlobalNodeModulesRoot() {
|
|
4994
|
+
const npmCommand = npmCommandName();
|
|
4995
|
+
const result = await runCommand(npmCommand, ["root", "-g"], { timeoutMs: 5000, maxOutputLength: 8000 });
|
|
4996
|
+
if (result.exitCode !== 0 || result.timedOut || result.error) return null;
|
|
4997
|
+
return result.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1) || null;
|
|
4998
|
+
}
|
|
4999
|
+
|
|
5000
|
+
async function npmGlobalPackageRootUpdateTask() {
|
|
5001
|
+
const nodeModulesRoot = await npmGlobalNodeModulesRoot();
|
|
5002
|
+
const packages = await packagesPresentInNodeModulesRoot(nodeModulesRoot);
|
|
5003
|
+
if (!packages.length) return null;
|
|
5004
|
+
const npmCommand = npmCommandName();
|
|
5005
|
+
return {
|
|
5006
|
+
label: "global npm package root",
|
|
5007
|
+
command: npmCommand,
|
|
5008
|
+
args: ["install", "-g", "--ignore-scripts", "--min-release-age=0", ...packageInstallSpecs(packages)],
|
|
5009
|
+
cwd: nodeModulesRoot ? path.dirname(nodeModulesRoot) : process.cwd(),
|
|
5010
|
+
};
|
|
5011
|
+
}
|
|
5012
|
+
|
|
5013
|
+
async function bunGlobalNodeModulesRoots() {
|
|
5014
|
+
const available = await runCommand("bun", ["--version"], { timeoutMs: 3000, maxOutputLength: 2000 });
|
|
5015
|
+
if (available.exitCode !== 0 || available.timedOut || available.error) return [];
|
|
5016
|
+
|
|
5017
|
+
const roots = new Set([path.join(homedir(), ".bun", "install", "global", "node_modules")]);
|
|
5018
|
+
const binResult = await runCommand("bun", ["pm", "bin", "-g"], { timeoutMs: 3000, maxOutputLength: 8000 });
|
|
5019
|
+
if (binResult.exitCode === 0 && !binResult.timedOut && !binResult.error) {
|
|
5020
|
+
const binDir = binResult.stdout.trim().split(/\r?\n/).filter(Boolean).at(-1);
|
|
5021
|
+
if (binDir) roots.add(path.join(path.dirname(binDir), "install", "global", "node_modules"));
|
|
5022
|
+
}
|
|
5023
|
+
return [...roots];
|
|
5024
|
+
}
|
|
5025
|
+
|
|
5026
|
+
async function bunGlobalPackageRootUpdateTask() {
|
|
5027
|
+
const packages = new Set();
|
|
5028
|
+
for (const nodeModulesRoot of await bunGlobalNodeModulesRoots()) {
|
|
5029
|
+
for (const packageName of await packagesPresentInNodeModulesRoot(nodeModulesRoot)) packages.add(packageName);
|
|
5030
|
+
}
|
|
5031
|
+
if (!packages.size) return null;
|
|
5032
|
+
return {
|
|
5033
|
+
label: "global Bun package root",
|
|
5034
|
+
command: "bun",
|
|
5035
|
+
args: ["install", "-g", "--ignore-scripts", "--minimum-release-age=0", ...packageInstallSpecs([...packages])],
|
|
5036
|
+
cwd: homedir(),
|
|
5037
|
+
};
|
|
5038
|
+
}
|
|
5039
|
+
|
|
5040
|
+
function updateTaskDisplay(task) {
|
|
5041
|
+
return task.displayCommand || formatCommandForDisplay(task.command, task.args || []);
|
|
5042
|
+
}
|
|
5043
|
+
|
|
5044
|
+
function uniqueUpdateTasks(tasks) {
|
|
5045
|
+
const unique = [];
|
|
5046
|
+
const seen = new Set();
|
|
5047
|
+
for (const task of tasks.filter(Boolean)) {
|
|
5048
|
+
const key = [task.command, JSON.stringify(task.args || []), task.cwd || ""].join("\u0000");
|
|
5049
|
+
if (seen.has(key)) continue;
|
|
5050
|
+
seen.add(key);
|
|
5051
|
+
unique.push(task);
|
|
5052
|
+
}
|
|
5053
|
+
return unique;
|
|
5054
|
+
}
|
|
5055
|
+
|
|
5056
|
+
async function resolveUpdateTasks() {
|
|
5057
|
+
return uniqueUpdateTasks([
|
|
5058
|
+
await resolvePiUpdateCommand(),
|
|
5059
|
+
await currentWebuiPackageUpdateTask(),
|
|
5060
|
+
await agentPackageRootUpdateTask(),
|
|
5061
|
+
await npmGlobalPackageRootUpdateTask(),
|
|
5062
|
+
await bunGlobalPackageRootUpdateTask(),
|
|
5063
|
+
]);
|
|
5064
|
+
}
|
|
5065
|
+
|
|
5066
|
+
function updateFailureDetails(result) {
|
|
5067
|
+
return [result.error, result.timedOut ? "timed out" : undefined, result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join("\n");
|
|
5068
|
+
}
|
|
5069
|
+
|
|
5070
|
+
async function runUpdateTask(task) {
|
|
5071
|
+
const command = updateTaskDisplay(task);
|
|
5072
|
+
recordEvent({ type: "webui_update_step_started", command });
|
|
5073
|
+
const result = await runCommand(task.command, task.args || [], {
|
|
5074
|
+
cwd: task.cwd || process.cwd(),
|
|
5075
|
+
timeoutMs: task.timeoutMs || PACKAGE_UPDATE_TIMEOUT_MS,
|
|
5076
|
+
maxOutputLength: task.maxOutputLength || PACKAGE_UPDATE_OUTPUT_MAX_CHARS,
|
|
5077
|
+
});
|
|
5078
|
+
const ok = result.exitCode === 0 && !result.timedOut && !result.error;
|
|
5079
|
+
if (!ok) {
|
|
5080
|
+
const details = updateFailureDetails(result);
|
|
5081
|
+
recordEvent({ type: "webui_update_step_failed", command, error: truncateStatusText(details || `exit code ${result.exitCode ?? "unknown"}`) });
|
|
5082
|
+
throw makeHttpError(500, truncateLongText(`Update step failed (${task.label || "package update"}): ${command}${details ? `\n${details}` : ""}`));
|
|
5083
|
+
}
|
|
5084
|
+
recordEvent({ type: "webui_update_step_completed", command });
|
|
5085
|
+
return {
|
|
5086
|
+
label: task.label || "package update",
|
|
5087
|
+
command,
|
|
5088
|
+
stdout: result.stdout,
|
|
5089
|
+
stderr: result.stderr,
|
|
5090
|
+
};
|
|
5091
|
+
}
|
|
5092
|
+
|
|
5093
|
+
function combinedUpdateOutput(results, field) {
|
|
5094
|
+
return results
|
|
5095
|
+
.map((result) => {
|
|
5096
|
+
const output = String(result?.[field] || "").trim();
|
|
5097
|
+
return output ? `# ${result.label}\n${output}` : "";
|
|
5098
|
+
})
|
|
5099
|
+
.filter(Boolean)
|
|
5100
|
+
.join("\n\n");
|
|
4298
5101
|
}
|
|
4299
5102
|
|
|
4300
5103
|
async function runPiUpdateAndPrepareRestart() {
|
|
@@ -4303,20 +5106,12 @@ async function runPiUpdateAndPrepareRestart() {
|
|
|
4303
5106
|
let restartPrepared = false;
|
|
4304
5107
|
try {
|
|
4305
5108
|
const restorableTabs = await restorableTabsForRestart();
|
|
4306
|
-
const
|
|
4307
|
-
|
|
5109
|
+
const updateTasks = await resolveUpdateTasks();
|
|
5110
|
+
if (!updateTasks.length) throw makeHttpError(500, "No Pi/Web UI update commands could be resolved.");
|
|
5111
|
+
const command = updateTasks.map(updateTaskDisplay).join(" && ");
|
|
4308
5112
|
recordEvent({ type: "webui_update_started", command, restorableTabCount: restorableTabs.length });
|
|
4309
|
-
const
|
|
4310
|
-
|
|
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
|
-
}
|
|
5113
|
+
const results = [];
|
|
5114
|
+
for (const task of updateTasks) results.push(await runUpdateTask(task));
|
|
4320
5115
|
|
|
4321
5116
|
updateStatusCache = null;
|
|
4322
5117
|
updateStatusCacheAt = 0;
|
|
@@ -4324,10 +5119,11 @@ async function runPiUpdateAndPrepareRestart() {
|
|
|
4324
5119
|
restartPrepared = true;
|
|
4325
5120
|
recordEvent({ type: "webui_update_restarting", command, nextWebuiPid: child.pid, restorableTabCount: restorableTabs.length });
|
|
4326
5121
|
return {
|
|
4327
|
-
message: "Pi
|
|
5122
|
+
message: "Pi/Web UI package updates completed. Pi Web UI is restarting.",
|
|
4328
5123
|
command,
|
|
4329
|
-
|
|
4330
|
-
|
|
5124
|
+
commands: results.map((result) => ({ label: result.label, command: result.command })),
|
|
5125
|
+
stdout: combinedUpdateOutput(results, "stdout"),
|
|
5126
|
+
stderr: combinedUpdateOutput(results, "stderr"),
|
|
4331
5127
|
webuiPid: process.pid,
|
|
4332
5128
|
nextWebuiPid: child.pid,
|
|
4333
5129
|
restorableTabCount: restorableTabs.length,
|
|
@@ -4400,7 +5196,7 @@ async function updateTabCwd(id, cwd) {
|
|
|
4400
5196
|
if (tab.rpc?.isRunning()) await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
|
|
4401
5197
|
const sessionFile = tabRestorableSessionFile(tab);
|
|
4402
5198
|
|
|
4403
|
-
const piArgs = buildPiArgsForTab(tab.index, tab.title);
|
|
5199
|
+
const piArgs = await buildPiArgsForTab(tab.index, tab.title, nextCwd);
|
|
4404
5200
|
if (sessionFile && !options.noSession) piArgs.push("--session", sessionFile);
|
|
4405
5201
|
const piCommand = await resolvePiCommand(piArgs);
|
|
4406
5202
|
const restartingEvent = { type: "webui_tab_restarting", tabId: tab.id, tabTitle: tab.title, cwd: nextCwd, sessionFile };
|
|
@@ -4441,7 +5237,7 @@ async function restartTabRpc(tab, reason = "reload") {
|
|
|
4441
5237
|
if (state.data?.isStreaming) throw makeHttpError(409, "Wait for the current response to finish before reloading.");
|
|
4442
5238
|
if (state.data?.isCompacting) throw makeHttpError(409, "Wait for compaction to finish before reloading.");
|
|
4443
5239
|
|
|
4444
|
-
const piArgs = buildPiArgsForTab(tab.index, tab.title);
|
|
5240
|
+
const piArgs = await buildPiArgsForTab(tab.index, tab.title, tab.cwd);
|
|
4445
5241
|
if (state.data?.sessionFile && !options.noSession) piArgs.push("--session", state.data.sessionFile);
|
|
4446
5242
|
const piCommand = await resolvePiCommand(piArgs);
|
|
4447
5243
|
const reloadingEvent = { type: "webui_tab_reloading", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, reason, sessionFile: state.data?.sessionFile };
|
|
@@ -6310,6 +7106,11 @@ const server = createServer(async (req, res) => {
|
|
|
6310
7106
|
return;
|
|
6311
7107
|
}
|
|
6312
7108
|
|
|
7109
|
+
if (url.pathname === "/api/optional-features" && req.method === "GET") {
|
|
7110
|
+
sendJson(res, 200, { ok: true, data: await optionalFeaturePackageStatuses() });
|
|
7111
|
+
return;
|
|
7112
|
+
}
|
|
7113
|
+
|
|
6313
7114
|
if (url.pathname === "/api/optional-feature-install" && req.method === "POST") {
|
|
6314
7115
|
requireLocalhostRoute(req, url.pathname);
|
|
6315
7116
|
const body = await readJsonBody(req);
|
|
@@ -6465,6 +7266,7 @@ const server = createServer(async (req, res) => {
|
|
|
6465
7266
|
if (getCommand) {
|
|
6466
7267
|
const tab = getRequestedTab(req, url);
|
|
6467
7268
|
const response = await safeRpcResponse(tab, getCommand);
|
|
7269
|
+
if (url.pathname === "/api/messages") applyMessagesSinceParam(response, url);
|
|
6468
7270
|
sendJson(res, response.success === false ? 400 : 200, response);
|
|
6469
7271
|
return;
|
|
6470
7272
|
}
|