@firstpick/pi-package-webui 0.3.6 → 0.3.8
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 +2 -1
- package/WEBUI_TUI_NATIVE_PARITY.json +22 -22
- package/bin/pi-webui.mjs +452 -110
- package/images/WebUI_v0.3.7.png +0 -0
- package/index.ts +15 -4
- package/lib/auth-actions.mjs +81 -0
- package/lib/native-command-adapter.mjs +220 -0
- package/lib/session-actions.mjs +134 -0
- package/lib/temp-artifacts.mjs +34 -0
- package/lib/trust-boundaries.mjs +141 -0
- package/package.json +8 -4
- package/public/app.js +1278 -99
- package/public/index.html +22 -4
- package/public/service-worker.js +23 -9
- package/public/styles.css +454 -0
- package/start-webui.sh +6 -5
- package/tests/fixtures/fake-pi.mjs +73 -0
- package/tests/http-endpoints-harness.test.mjs +146 -0
- package/tests/mobile-static.test.mjs +66 -21
- package/tests/native-parity-harness.test.mjs +147 -0
- package/tests/native-parity.test.mjs +25 -6
- package/tests/run-all.mjs +19 -0
- package/tests/session-auth-harness.test.mjs +140 -0
- package/tests/temp-artifacts-harness.test.mjs +38 -0
package/bin/pi-webui.mjs
CHANGED
|
@@ -10,6 +10,29 @@ import path from "node:path";
|
|
|
10
10
|
import { StringDecoder } from "node:string_decoder";
|
|
11
11
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
12
12
|
import { AuthStorage, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { authProvidersPayload, createAuthContext, logoutStoredProvider } from "../lib/auth-actions.mjs";
|
|
14
|
+
import {
|
|
15
|
+
collectOpenSessionFiles,
|
|
16
|
+
deleteSessionFile,
|
|
17
|
+
isSessionPathAllowed,
|
|
18
|
+
renameSessionMetadata,
|
|
19
|
+
validateSessionDelete,
|
|
20
|
+
} from "../lib/session-actions.mjs";
|
|
21
|
+
import { sweepStaleTempEntries } from "../lib/temp-artifacts.mjs";
|
|
22
|
+
import {
|
|
23
|
+
evaluateDispatchTrustGuards,
|
|
24
|
+
guardsForNativeCommand,
|
|
25
|
+
isLocalRequest,
|
|
26
|
+
remoteShellTrustWarning,
|
|
27
|
+
requireLocalhostRoute,
|
|
28
|
+
} from "../lib/trust-boundaries.mjs";
|
|
29
|
+
import {
|
|
30
|
+
nativeCommandBlocked,
|
|
31
|
+
nativeCommandResponse,
|
|
32
|
+
nativeCommandUnavailable,
|
|
33
|
+
nativeSlashCommandEntries,
|
|
34
|
+
parseSlashCommand as parseNativeSlashCommand,
|
|
35
|
+
} from "../lib/native-command-adapter.mjs";
|
|
13
36
|
|
|
14
37
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
38
|
const require = createRequire(import.meta.url);
|
|
@@ -94,6 +117,11 @@ const TREE_SELECTOR_TEXT_LIMIT = 260;
|
|
|
94
117
|
const NETWORK_REBIND_DELAY_MS = 100;
|
|
95
118
|
const NETWORK_REBIND_FORCE_CLOSE_MS = 750;
|
|
96
119
|
const NATIVE_DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
120
|
+
const UPLOAD_TEMP_ROOT = path.join(tmpdir(), "pi-webui-uploads");
|
|
121
|
+
const NATIVE_EXPORT_TEMP_ROOT = path.join(tmpdir(), "pi-webui-native-exports");
|
|
122
|
+
const UPLOAD_TEMP_TTL_MS = 24 * 60 * 60 * 1000;
|
|
123
|
+
const NATIVE_EXPORT_TEMP_TTL_MS = 60 * 60 * 1000;
|
|
124
|
+
const TEMP_ARTIFACT_SWEEP_INTERVAL_MS = 60 * 60 * 1000;
|
|
97
125
|
const AUTO_TAB_TITLE_MAX_LENGTH = 44;
|
|
98
126
|
const AUTO_TAB_TITLE_WORD_LIMIT = 8;
|
|
99
127
|
const AUTO_TAB_TITLE_STOP_WORDS = new Set([
|
|
@@ -178,37 +206,19 @@ function isSourceCheckout(root) {
|
|
|
178
206
|
return normalized.includes("/npm-packages/") && !normalized.includes("/node_modules/");
|
|
179
207
|
}
|
|
180
208
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
}
|
|
209
|
+
const NATIVE_SLASH_COMMANDS = nativeSlashCommandEntries(nativeParityMatrix);
|
|
210
|
+
const NATIVE_SLASH_COMMAND_NAMES = new Set(NATIVE_SLASH_COMMANDS.map((command) => command.name));
|
|
211
|
+
const respondNative = (command, data = {}) => nativeCommandResponse(command, data, nativeParityMatrix);
|
|
212
|
+
const unavailableNative = (command, details = {}) => nativeCommandUnavailable(command, details, nativeParityMatrix);
|
|
184
213
|
|
|
185
|
-
function
|
|
186
|
-
return
|
|
187
|
-
.filter((surface) => surface?.kind === "slash-command")
|
|
188
|
-
.map((surface) => {
|
|
189
|
-
const name = String(surface.command?.name || surface.id || "").replace(/^\//, "").trim();
|
|
190
|
-
return {
|
|
191
|
-
name,
|
|
192
|
-
description: String(surface.command?.description || surface.title || `/${name}`),
|
|
193
|
-
source: "native",
|
|
194
|
-
location: "Pi",
|
|
195
|
-
nativeParity: {
|
|
196
|
-
status: surface.webStatus || "unsupported",
|
|
197
|
-
priority: surface.priority || "P2",
|
|
198
|
-
guards: Array.isArray(surface.guards) ? surface.guards : [],
|
|
199
|
-
sensitive: surface.sensitive === true,
|
|
200
|
-
},
|
|
201
|
-
};
|
|
202
|
-
})
|
|
203
|
-
.filter((command) => command.name);
|
|
214
|
+
function parseSlashCommand(message) {
|
|
215
|
+
return parseNativeSlashCommand(message, NATIVE_SLASH_COMMAND_NAMES);
|
|
204
216
|
}
|
|
205
|
-
|
|
206
|
-
const NATIVE_SLASH_COMMANDS = nativeSlashCommandEntries();
|
|
207
|
-
const NATIVE_SLASH_COMMAND_NAMES = new Set(NATIVE_SLASH_COMMANDS.map((command) => command.name));
|
|
208
217
|
const OPTIONAL_FEATURE_PACKAGES = new Map([
|
|
209
218
|
["gitWorkflow", "@firstpick/pi-prompts-git-pr"],
|
|
210
219
|
["releaseNpm", "@firstpick/pi-extension-release-npm"],
|
|
211
220
|
["releaseAur", "@firstpick/pi-extension-release-aur"],
|
|
221
|
+
["safetyGuard", "@firstpick/pi-extension-safety-guard"],
|
|
212
222
|
["tuiSkillsCommand", "@firstpick/pi-extension-setup-skills"],
|
|
213
223
|
["todoProgressWidget", "@firstpick/pi-extension-todo-progress"],
|
|
214
224
|
["tuiToolsCommand", "@firstpick/pi-extension-tools"],
|
|
@@ -358,10 +368,6 @@ function formatUrlHost(host) {
|
|
|
358
368
|
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
359
369
|
}
|
|
360
370
|
|
|
361
|
-
function isLocalAddress(address = "") {
|
|
362
|
-
return address === "::1" || address.startsWith("127.") || address === "::ffff:127.0.0.1" || address.startsWith("::ffff:127.");
|
|
363
|
-
}
|
|
364
|
-
|
|
365
371
|
function sanitizeError(error) {
|
|
366
372
|
if (!error) return "Unknown error";
|
|
367
373
|
if (typeof error === "string") return error;
|
|
@@ -784,16 +790,6 @@ async function handleActionFeedback(tab, body) {
|
|
|
784
790
|
return response;
|
|
785
791
|
}
|
|
786
792
|
|
|
787
|
-
function parseSlashCommand(message) {
|
|
788
|
-
const text = String(message || "").trim();
|
|
789
|
-
if (!text.startsWith("/") || text.includes("\n")) return undefined;
|
|
790
|
-
const match = text.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/);
|
|
791
|
-
if (!match) return undefined;
|
|
792
|
-
const name = match[1].toLowerCase();
|
|
793
|
-
if (!NATIVE_SLASH_COMMAND_NAMES.has(name)) return undefined;
|
|
794
|
-
return { name, args: (match[2] || "").trim(), text };
|
|
795
|
-
}
|
|
796
|
-
|
|
797
793
|
function truncateTabTitle(title, maxLength = AUTO_TAB_TITLE_MAX_LENGTH) {
|
|
798
794
|
const text = String(title || "").replace(/\s+/g, " ").trim();
|
|
799
795
|
if (!maxLength || text.length <= maxLength) return text;
|
|
@@ -2754,6 +2750,8 @@ async function getWorkspaceInfo(cwd, startedAt) {
|
|
|
2754
2750
|
}
|
|
2755
2751
|
|
|
2756
2752
|
let activeGitWorkflowProcess = null;
|
|
2753
|
+
const GIT_CHANGES_COMMAND_TIMEOUT_MS = 5000;
|
|
2754
|
+
const GIT_CHANGES_DIFF_MAX_OUTPUT = 500_000;
|
|
2757
2755
|
|
|
2758
2756
|
async function getGitRoot(cwd) {
|
|
2759
2757
|
const result = await runCommand("git", ["rev-parse", "--show-toplevel"], { cwd, timeoutMs: 2000 });
|
|
@@ -2763,6 +2761,120 @@ async function getGitRoot(cwd) {
|
|
|
2763
2761
|
return path.resolve(result.stdout.trim());
|
|
2764
2762
|
}
|
|
2765
2763
|
|
|
2764
|
+
async function runGitReadCommand(root, args, { timeoutMs = GIT_CHANGES_COMMAND_TIMEOUT_MS, maxOutputLength = GIT_CHANGES_DIFF_MAX_OUTPUT } = {}) {
|
|
2765
|
+
const result = await runCommand("git", args, { cwd: root, timeoutMs, maxOutputLength });
|
|
2766
|
+
if (result.exitCode === 0 && !result.timedOut && !result.error) return result.stdout;
|
|
2767
|
+
const command = formatGitCommand(args);
|
|
2768
|
+
const message = result.timedOut
|
|
2769
|
+
? `${command} timed out`
|
|
2770
|
+
: (result.stderr || result.stdout || result.error || `${command} failed with exit code ${result.exitCode ?? "unknown"}`);
|
|
2771
|
+
throw new Error(String(message).trim());
|
|
2772
|
+
}
|
|
2773
|
+
|
|
2774
|
+
function gitBranchFromStatus(statusText) {
|
|
2775
|
+
const branchLine = String(statusText || "").split(/\r?\n/).find((line) => line.startsWith("## ")) || "";
|
|
2776
|
+
return branchLine.slice(3).trim().replace(/\.\.\..*$/, "") || "detached";
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
function summarizeGitShortStatus(statusText) {
|
|
2780
|
+
const summary = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0 };
|
|
2781
|
+
for (const line of String(statusText || "").split(/\r?\n/)) {
|
|
2782
|
+
if (!line || line.startsWith("## ")) continue;
|
|
2783
|
+
const x = line[0] || " ";
|
|
2784
|
+
const y = line[1] || " ";
|
|
2785
|
+
if (x === "?" && y === "?") {
|
|
2786
|
+
summary.untracked += 1;
|
|
2787
|
+
continue;
|
|
2788
|
+
}
|
|
2789
|
+
if (x === "U" || y === "U" || (x === "A" && y === "A") || (x === "D" && y === "D")) {
|
|
2790
|
+
summary.conflicted += 1;
|
|
2791
|
+
continue;
|
|
2792
|
+
}
|
|
2793
|
+
if (x && x !== " ") summary.staged += 1;
|
|
2794
|
+
if (y && y !== " ") summary.unstaged += 1;
|
|
2795
|
+
}
|
|
2796
|
+
return summary;
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
function resolveGitRelativePath(root, relativePath) {
|
|
2800
|
+
const normalized = String(relativePath || "").trim();
|
|
2801
|
+
if (!normalized || normalized.includes("\0")) throw new Error("Invalid git path");
|
|
2802
|
+
const resolved = path.resolve(root, normalized);
|
|
2803
|
+
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) throw new Error(`Git path escapes repository: ${normalized}`);
|
|
2804
|
+
return resolved;
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
function isLikelyBinaryBuffer(buffer) {
|
|
2808
|
+
return Buffer.isBuffer(buffer) && buffer.includes(0);
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
function normalizeGitRelativePath(root, relativePath) {
|
|
2812
|
+
const resolved = resolveGitRelativePath(root, relativePath);
|
|
2813
|
+
return path.relative(root, resolved).split(path.sep).join("/");
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
async function readGitUntrackedEntry(root, file) {
|
|
2817
|
+
const normalized = normalizeGitRelativePath(root, file);
|
|
2818
|
+
const filePath = resolveGitRelativePath(root, normalized);
|
|
2819
|
+
const info = await stat(filePath);
|
|
2820
|
+
if (!info.isFile()) return { path: normalized, size: info.size, binary: false, content: "", error: "Not a regular file" };
|
|
2821
|
+
const buffer = await readFile(filePath);
|
|
2822
|
+
const binary = isLikelyBinaryBuffer(buffer);
|
|
2823
|
+
return {
|
|
2824
|
+
path: normalized,
|
|
2825
|
+
size: info.size,
|
|
2826
|
+
binary,
|
|
2827
|
+
content: binary ? "" : buffer.toString("utf8"),
|
|
2828
|
+
};
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
async function readGitUntrackedEntries(root, files) {
|
|
2832
|
+
const entries = [];
|
|
2833
|
+
for (const file of files) {
|
|
2834
|
+
try {
|
|
2835
|
+
entries.push(await readGitUntrackedEntry(root, file));
|
|
2836
|
+
} catch (error) {
|
|
2837
|
+
entries.push({ path: file, size: 0, binary: false, content: "", error: sanitizeError(error) });
|
|
2838
|
+
}
|
|
2839
|
+
}
|
|
2840
|
+
return entries;
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
async function readGitUntrackedFile(cwd, requestedPath) {
|
|
2844
|
+
const root = await getGitRoot(cwd);
|
|
2845
|
+
const normalized = normalizeGitRelativePath(root, requestedPath);
|
|
2846
|
+
const listed = await runGitReadCommand(root, ["ls-files", "--others", "--exclude-standard", "--", normalized], { maxOutputLength: 120_000 });
|
|
2847
|
+
const files = listed.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
2848
|
+
if (!files.includes(normalized)) throw new Error(`Not an untracked file: ${normalized}`);
|
|
2849
|
+
return readGitUntrackedEntry(root, normalized);
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
async function readGitChanges(cwd) {
|
|
2853
|
+
const root = await getGitRoot(cwd);
|
|
2854
|
+
const diffArgs = ["diff", "--no-ext-diff", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/"];
|
|
2855
|
+
const [statusText, unstagedDiff, stagedDiff, untrackedText] = await Promise.all([
|
|
2856
|
+
runGitReadCommand(root, ["status", "--short", "--branch", "--untracked-files=all"], { maxOutputLength: 120_000 }),
|
|
2857
|
+
runGitReadCommand(root, diffArgs),
|
|
2858
|
+
runGitReadCommand(root, ["diff", "--cached", "--no-ext-diff", "--no-color", "--find-renames", "--src-prefix=a/", "--dst-prefix=b/"]),
|
|
2859
|
+
runGitReadCommand(root, ["ls-files", "--others", "--exclude-standard"], { maxOutputLength: 120_000 }),
|
|
2860
|
+
]);
|
|
2861
|
+
const untrackedFiles = untrackedText.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
2862
|
+
const untracked = await readGitUntrackedEntries(root, untrackedFiles);
|
|
2863
|
+
return {
|
|
2864
|
+
cwd,
|
|
2865
|
+
root,
|
|
2866
|
+
branch: gitBranchFromStatus(statusText),
|
|
2867
|
+
generatedAt: new Date().toISOString(),
|
|
2868
|
+
summary: summarizeGitShortStatus(statusText),
|
|
2869
|
+
status: statusText.trimEnd(),
|
|
2870
|
+
sections: [
|
|
2871
|
+
{ key: "staged", label: "Staged", command: "git diff --cached", diff: stagedDiff.trimEnd() },
|
|
2872
|
+
{ key: "unstaged", label: "Unstaged", command: "git diff", diff: unstagedDiff.trimEnd() },
|
|
2873
|
+
],
|
|
2874
|
+
untracked,
|
|
2875
|
+
};
|
|
2876
|
+
}
|
|
2877
|
+
|
|
2766
2878
|
function commitMessagePaths(root) {
|
|
2767
2879
|
return {
|
|
2768
2880
|
shortPath: path.join(root, "dev", "COMMIT", "staged-commit-short.txt"),
|
|
@@ -2829,6 +2941,68 @@ async function currentGitBranch(root) {
|
|
|
2829
2941
|
return branch;
|
|
2830
2942
|
}
|
|
2831
2943
|
|
|
2944
|
+
async function currentGitBranchForPicker(root) {
|
|
2945
|
+
try {
|
|
2946
|
+
return (await runGitReadCommand(root, ["branch", "--show-current"], { timeoutMs: 5000, maxOutputLength: 10_000 })).trim();
|
|
2947
|
+
} catch {
|
|
2948
|
+
return "";
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
|
|
2952
|
+
function normalizeGitBranchList(branchText, current = "") {
|
|
2953
|
+
const seen = new Set();
|
|
2954
|
+
const branches = [];
|
|
2955
|
+
for (const line of String(branchText || "").split(/\r?\n/)) {
|
|
2956
|
+
const name = line.trim();
|
|
2957
|
+
if (!name || seen.has(name)) continue;
|
|
2958
|
+
seen.add(name);
|
|
2959
|
+
branches.push({ name, current: !!current && name === current });
|
|
2960
|
+
}
|
|
2961
|
+
return branches.sort((left, right) => {
|
|
2962
|
+
if (left.current !== right.current) return left.current ? -1 : 1;
|
|
2963
|
+
return left.name.localeCompare(right.name);
|
|
2964
|
+
});
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
async function readGitBranches(cwd) {
|
|
2968
|
+
const root = await getGitRoot(cwd);
|
|
2969
|
+
const [current, branchText] = await Promise.all([
|
|
2970
|
+
currentGitBranchForPicker(root),
|
|
2971
|
+
runGitReadCommand(root, ["branch", "--format=%(refname:short)"], { timeoutMs: 5000, maxOutputLength: 120_000 }),
|
|
2972
|
+
]);
|
|
2973
|
+
return {
|
|
2974
|
+
cwd,
|
|
2975
|
+
root,
|
|
2976
|
+
current,
|
|
2977
|
+
generatedAt: new Date().toISOString(),
|
|
2978
|
+
branches: normalizeGitBranchList(branchText, current),
|
|
2979
|
+
};
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
async function switchGitBranch(cwd, branch, { create = false } = {}) {
|
|
2983
|
+
const root = await getGitRoot(cwd);
|
|
2984
|
+
const targetBranch = cleanGitBranchName(branch);
|
|
2985
|
+
await validateGitBranchName(root, targetBranch);
|
|
2986
|
+
const branches = await readGitBranches(cwd);
|
|
2987
|
+
const branchExists = branches.branches.some((item) => item.name === targetBranch);
|
|
2988
|
+
if (create && branchExists) throw new Error(`Local git branch already exists: ${targetBranch}`);
|
|
2989
|
+
if (!create && !branchExists) throw new Error(`Unknown local git branch: ${targetBranch}`);
|
|
2990
|
+
if (!create && branches.current === targetBranch) {
|
|
2991
|
+
return { ok: true, data: { command: `git switch ${targetBranch}`, stdout: "", stderr: "", exitCode: 0, branch: targetBranch, root, switched: false, created: false } };
|
|
2992
|
+
}
|
|
2993
|
+
const args = create ? ["switch", "-c", targetBranch] : ["switch", targetBranch];
|
|
2994
|
+
const payload = gitWorkflowCommandPayload(await runGitWorkflowCommand(args, { cwd: root, timeoutMs: 10 * 60 * 1000 }));
|
|
2995
|
+
if (payload.ok) {
|
|
2996
|
+
payload.data.branch = targetBranch;
|
|
2997
|
+
payload.data.root = root;
|
|
2998
|
+
payload.data.switched = true;
|
|
2999
|
+
payload.data.created = create;
|
|
3000
|
+
} else {
|
|
3001
|
+
payload.error = (payload.data?.stderr || payload.data?.stdout || payload.error || `Failed to ${create ? "create and switch to" : "switch to"} ${targetBranch}`).trim();
|
|
3002
|
+
}
|
|
3003
|
+
return payload;
|
|
3004
|
+
}
|
|
3005
|
+
|
|
2832
3006
|
async function defaultGitRemote(root) {
|
|
2833
3007
|
const result = await runGitWorkflowCommand(["remote"], { cwd: root, timeoutMs: 5000 });
|
|
2834
3008
|
if (result.exitCode !== 0) throw new Error((result.stderr || result.stdout || "Cannot list git remotes").trim());
|
|
@@ -3171,7 +3345,7 @@ async function saveUploadedAttachments(body) {
|
|
|
3171
3345
|
});
|
|
3172
3346
|
}
|
|
3173
3347
|
|
|
3174
|
-
const uploadDir = path.join(
|
|
3348
|
+
const uploadDir = path.join(UPLOAD_TEMP_ROOT, randomUUID());
|
|
3175
3349
|
await mkdir(uploadDir, { recursive: true });
|
|
3176
3350
|
const saved = [];
|
|
3177
3351
|
for (const [index, file] of decoded.entries()) {
|
|
@@ -4056,7 +4230,7 @@ async function checkLatestNpmPackageStatus(packageName, currentVersion) {
|
|
|
4056
4230
|
function updateStatusForRequest(status, req) {
|
|
4057
4231
|
return {
|
|
4058
4232
|
...status,
|
|
4059
|
-
canRunUpdate:
|
|
4233
|
+
canRunUpdate: isLocalRequest(req),
|
|
4060
4234
|
updateInProgress: piUpdateInProgress,
|
|
4061
4235
|
};
|
|
4062
4236
|
}
|
|
@@ -4196,9 +4370,16 @@ async function updateTabCwd(id, cwd) {
|
|
|
4196
4370
|
const nextCwd = await resolveCwd(cwd, tab.cwd);
|
|
4197
4371
|
if (nextCwd === tab.cwd) return { tab, changed: false };
|
|
4198
4372
|
|
|
4373
|
+
// Capture the live session before stopping the old RPC so the conversation
|
|
4374
|
+
// survives the cwd restart, mirroring restartTabRpc. Best-effort: a dead RPC
|
|
4375
|
+
// falls back to the last remembered session file.
|
|
4376
|
+
if (tab.rpc?.isRunning()) await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
|
|
4377
|
+
const sessionFile = tabRestorableSessionFile(tab);
|
|
4378
|
+
|
|
4199
4379
|
const piArgs = buildPiArgsForTab(tab.index, tab.title);
|
|
4380
|
+
if (sessionFile && !options.noSession) piArgs.push("--session", sessionFile);
|
|
4200
4381
|
const piCommand = await resolvePiCommand(piArgs);
|
|
4201
|
-
const restartingEvent = { type: "webui_tab_restarting", tabId: tab.id, tabTitle: tab.title, cwd: nextCwd };
|
|
4382
|
+
const restartingEvent = { type: "webui_tab_restarting", tabId: tab.id, tabTitle: tab.title, cwd: nextCwd, sessionFile };
|
|
4202
4383
|
recordEvent(restartingEvent);
|
|
4203
4384
|
for (const client of tab.sseClients) {
|
|
4204
4385
|
sendSse(client, restartingEvent);
|
|
@@ -4212,15 +4393,16 @@ async function updateTabCwd(id, cwd) {
|
|
|
4212
4393
|
oldRpc.stop();
|
|
4213
4394
|
|
|
4214
4395
|
tab.cwd = nextCwd;
|
|
4215
|
-
forgetTabState(tab);
|
|
4216
4396
|
resetTabActivity(tab);
|
|
4217
4397
|
clearPendingExtensionUiRequests(tab);
|
|
4218
4398
|
clearExtensionStatuses(tab);
|
|
4219
4399
|
const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
|
|
4220
4400
|
attachRpcToTab(tab, rpc);
|
|
4221
4401
|
rpc.start();
|
|
4402
|
+
// Non-fatal: a failed start surfaces through pi_process_error/exit events.
|
|
4403
|
+
await primeTabRpc(tab).catch(() => {});
|
|
4222
4404
|
|
|
4223
|
-
const changedEvent = { type: "webui_cwd_changed", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, pid: tab.rpc.child?.pid, tabActivity: tabActivitySnapshot(tab) };
|
|
4405
|
+
const changedEvent = { type: "webui_cwd_changed", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, pid: tab.rpc.child?.pid, sessionFile, tabActivity: tabActivitySnapshot(tab) };
|
|
4224
4406
|
recordEvent(changedEvent);
|
|
4225
4407
|
for (const client of tab.sseClients) {
|
|
4226
4408
|
sendSse(client, changedEvent);
|
|
@@ -4869,6 +5051,18 @@ function configuredSessionDir() {
|
|
|
4869
5051
|
return undefined;
|
|
4870
5052
|
}
|
|
4871
5053
|
|
|
5054
|
+
/** Roots that session switch/rename/delete paths must stay inside. */
|
|
5055
|
+
function allowedSessionDirs() {
|
|
5056
|
+
const configured = configuredSessionDir();
|
|
5057
|
+
return configured ? [configured] : [path.join(agentDir, "sessions")];
|
|
5058
|
+
}
|
|
5059
|
+
|
|
5060
|
+
function requireAllowedSessionPath(targetPath) {
|
|
5061
|
+
if (!isSessionPathAllowed(targetPath, allowedSessionDirs())) {
|
|
5062
|
+
throw makeHttpError(403, "sessionPath must stay inside the Pi session directory");
|
|
5063
|
+
}
|
|
5064
|
+
}
|
|
5065
|
+
|
|
4872
5066
|
function requirePersistentSessions() {
|
|
4873
5067
|
if (options.noSession) throw makeHttpError(400, "Session selectors are unavailable when Web UI was started with --no-session.");
|
|
4874
5068
|
}
|
|
@@ -5037,6 +5231,7 @@ async function switchTabSession(tab, sessionPath) {
|
|
|
5037
5231
|
const targetPath = resolveTabPath(tab, sessionPath);
|
|
5038
5232
|
if (!targetPath) throw makeHttpError(400, "sessionPath is required");
|
|
5039
5233
|
if (!targetPath.endsWith(".jsonl")) throw makeHttpError(400, "sessionPath must point to a .jsonl session file");
|
|
5234
|
+
requireAllowedSessionPath(targetPath);
|
|
5040
5235
|
const targetStats = await stat(targetPath).catch(() => null);
|
|
5041
5236
|
if (!targetStats?.isFile()) throw makeHttpError(404, `Session file not found: ${targetPath}`);
|
|
5042
5237
|
const manager = SessionManager.open(targetPath, configuredSessionDir());
|
|
@@ -5054,6 +5249,51 @@ async function switchTabSession(tab, sessionPath) {
|
|
|
5054
5249
|
});
|
|
5055
5250
|
}
|
|
5056
5251
|
|
|
5252
|
+
let authContextCache;
|
|
5253
|
+
|
|
5254
|
+
function authContext() {
|
|
5255
|
+
if (!authContextCache) authContextCache = createAuthContext();
|
|
5256
|
+
return authContextCache;
|
|
5257
|
+
}
|
|
5258
|
+
|
|
5259
|
+
async function renameSessionData(tab, body) {
|
|
5260
|
+
requirePersistentSessions();
|
|
5261
|
+
const sessionPath = resolveTabPath(tab, body.sessionPath || body.path);
|
|
5262
|
+
const result = await renameSessionMetadata(sessionPath, body.name, configuredSessionDir(), { allowedDirs: allowedSessionDirs() });
|
|
5263
|
+
return {
|
|
5264
|
+
message: `Renamed session metadata to: ${result.name}`,
|
|
5265
|
+
...result,
|
|
5266
|
+
};
|
|
5267
|
+
}
|
|
5268
|
+
|
|
5269
|
+
async function deleteSessionData(tab, body) {
|
|
5270
|
+
requirePersistentSessions();
|
|
5271
|
+
const state = await currentSessionState(tab).catch(() => tab.lastState || {});
|
|
5272
|
+
const validation = validateSessionDelete(resolveTabPath(tab, body.sessionPath || body.path), {
|
|
5273
|
+
openSessionFiles: collectOpenSessionFiles([...tabs.values()]),
|
|
5274
|
+
currentSessionFile: state.sessionFile || tabRestorableSessionFile(tab),
|
|
5275
|
+
confirmed: body.confirmed === true,
|
|
5276
|
+
allowedDirs: allowedSessionDirs(),
|
|
5277
|
+
});
|
|
5278
|
+
if (!validation.allowed) {
|
|
5279
|
+
throw makeHttpError(validation.reason === "confirmation_required" ? 409 : validation.reason === "outside_session_dir" ? 403 : 400, validation.message);
|
|
5280
|
+
}
|
|
5281
|
+
const deleted = await deleteSessionFile(validation.sessionPath, { allowedDirs: allowedSessionDirs() });
|
|
5282
|
+
return {
|
|
5283
|
+
message: deleted.method === "trash" ? "Session moved to trash." : "Session deleted.",
|
|
5284
|
+
...deleted,
|
|
5285
|
+
};
|
|
5286
|
+
}
|
|
5287
|
+
|
|
5288
|
+
function getAuthProvidersData() {
|
|
5289
|
+
return authProvidersPayload(authContext().modelRegistry);
|
|
5290
|
+
}
|
|
5291
|
+
|
|
5292
|
+
function logoutAuthProviderData(body) {
|
|
5293
|
+
if (body.confirmed !== true) throw makeHttpError(409, "Logout requires explicit confirmation (confirmed: true).");
|
|
5294
|
+
return logoutStoredProvider(authContext().modelRegistry, body.provider || body.providerId);
|
|
5295
|
+
}
|
|
5296
|
+
|
|
5057
5297
|
async function navigateSessionTree(tab, body) {
|
|
5058
5298
|
requirePersistentSessions();
|
|
5059
5299
|
await requireIdleForSessionAction(tab, "navigating the session tree");
|
|
@@ -5100,9 +5340,8 @@ function nativeExportBaseName(tab, state = {}) {
|
|
|
5100
5340
|
}
|
|
5101
5341
|
|
|
5102
5342
|
async function nativeExportTempPath(tab, state = {}, ext = ".html") {
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
return path.join(dir, `${nativeExportBaseName(tab, state)}-${randomUUID()}${ext}`);
|
|
5343
|
+
await mkdir(NATIVE_EXPORT_TEMP_ROOT, { recursive: true });
|
|
5344
|
+
return path.join(NATIVE_EXPORT_TEMP_ROOT, `${nativeExportBaseName(tab, state)}-${randomUUID()}${ext}`);
|
|
5106
5345
|
}
|
|
5107
5346
|
|
|
5108
5347
|
function exportTargetExtension(targetPath) {
|
|
@@ -5128,22 +5367,24 @@ async function handleNativeExportCommand(tab, args, req) {
|
|
|
5128
5367
|
fileName: `${nativeExportBaseName(tab, state)}.html`,
|
|
5129
5368
|
contentType: MIME_TYPES.get(".html"),
|
|
5130
5369
|
});
|
|
5131
|
-
return
|
|
5370
|
+
return respondNative("export", {
|
|
5132
5371
|
status: "succeeded",
|
|
5133
5372
|
level: "info",
|
|
5134
5373
|
message: `Exported current session to HTML.\nDownload: ${download.fileName}\nLink expires: ${download.expiresAt}`,
|
|
5135
5374
|
download,
|
|
5136
5375
|
result: response.data,
|
|
5376
|
+
refresh: ["state"],
|
|
5137
5377
|
});
|
|
5138
5378
|
}
|
|
5139
5379
|
|
|
5140
|
-
if (!
|
|
5141
|
-
return
|
|
5142
|
-
status: "
|
|
5143
|
-
level: "
|
|
5380
|
+
if (!isLocalRequest(req)) {
|
|
5381
|
+
return respondNative("export", {
|
|
5382
|
+
status: "blocked",
|
|
5383
|
+
level: "error",
|
|
5144
5384
|
reason: "Server-side export paths are only allowed from localhost.",
|
|
5145
5385
|
safetyRestriction: "Explicit /export paths write files on the server and are blocked for non-local browser clients.",
|
|
5146
5386
|
message: "Explicit /export paths are only allowed from localhost. Run /export without a path for a browser download, or retry from the local machine.",
|
|
5387
|
+
refresh: [],
|
|
5147
5388
|
});
|
|
5148
5389
|
}
|
|
5149
5390
|
|
|
@@ -5151,12 +5392,13 @@ async function handleNativeExportCommand(tab, args, req) {
|
|
|
5151
5392
|
const ext = exportTargetExtension(targetPath);
|
|
5152
5393
|
if (![".html", ".jsonl"].includes(ext)) throw makeHttpError(400, "Usage: /export [path.html|path.jsonl]");
|
|
5153
5394
|
if (await exportTargetExists(targetPath)) {
|
|
5154
|
-
return
|
|
5395
|
+
return respondNative("export", {
|
|
5155
5396
|
status: "confirmation_required",
|
|
5156
5397
|
level: "warn",
|
|
5157
5398
|
reason: `Export target already exists: ${targetPath}`,
|
|
5158
5399
|
safetyRestriction: "Overwrites require an explicit confirmation flow, which is not available from plain slash-command text yet.",
|
|
5159
5400
|
message: `Export target already exists and was not overwritten:\n${targetPath}\n\nUse /export without a path for a browser download, or delete/rename the existing file first.`,
|
|
5401
|
+
refresh: [],
|
|
5160
5402
|
});
|
|
5161
5403
|
}
|
|
5162
5404
|
|
|
@@ -5165,12 +5407,13 @@ async function handleNativeExportCommand(tab, args, req) {
|
|
|
5165
5407
|
if (ext === ".html") {
|
|
5166
5408
|
const response = await tab.rpc.send({ type: "export_html", outputPath: targetPath });
|
|
5167
5409
|
if (response.success === false) return response;
|
|
5168
|
-
return
|
|
5410
|
+
return respondNative("export", {
|
|
5169
5411
|
status: "succeeded",
|
|
5170
5412
|
level: "info",
|
|
5171
5413
|
message: `Exported current session HTML to server path:\n${response.data?.path || targetPath}`,
|
|
5172
5414
|
serverPath: response.data?.path || targetPath,
|
|
5173
5415
|
result: response.data,
|
|
5416
|
+
refresh: ["state"],
|
|
5174
5417
|
});
|
|
5175
5418
|
}
|
|
5176
5419
|
|
|
@@ -5180,12 +5423,13 @@ async function handleNativeExportCommand(tab, args, req) {
|
|
|
5180
5423
|
const sourceStats = await stat(sessionFile).catch(() => null);
|
|
5181
5424
|
if (!sourceStats?.isFile()) throw makeHttpError(404, `Current session file not found: ${sessionFile}`);
|
|
5182
5425
|
await copyFile(sessionFile, targetPath);
|
|
5183
|
-
return
|
|
5426
|
+
return respondNative("export", {
|
|
5184
5427
|
status: "succeeded",
|
|
5185
5428
|
level: "info",
|
|
5186
5429
|
message: `Copied current session JSONL to server path:\n${targetPath}`,
|
|
5187
5430
|
serverPath: targetPath,
|
|
5188
5431
|
result: { path: targetPath, sourcePath: sessionFile },
|
|
5432
|
+
refresh: ["state"],
|
|
5189
5433
|
});
|
|
5190
5434
|
}
|
|
5191
5435
|
|
|
@@ -5201,70 +5445,68 @@ function webuiHotkeysOutput() {
|
|
|
5201
5445
|
].join("\n");
|
|
5202
5446
|
}
|
|
5203
5447
|
|
|
5204
|
-
function nativeParitySurfaceForCommand(name) {
|
|
5205
|
-
return nativeParitySurfaces().find((surface) => surface.kind === "slash-command" && surface.command?.name === name) || null;
|
|
5206
|
-
}
|
|
5207
|
-
|
|
5208
|
-
function nativeCommandResponse(command, data = {}) {
|
|
5209
|
-
const surface = nativeParitySurfaceForCommand(command);
|
|
5210
|
-
const status = data.status || (surface?.webStatus === "implemented" ? "succeeded" : surface?.webStatus === "degraded" ? "degraded" : "unavailable");
|
|
5211
|
-
const level = data.level || (status === "succeeded" ? "info" : "warn");
|
|
5212
|
-
return rpcSuccess("native_slash_command", {
|
|
5213
|
-
command,
|
|
5214
|
-
status,
|
|
5215
|
-
level,
|
|
5216
|
-
nativeParity: surface ? {
|
|
5217
|
-
webStatus: surface.webStatus,
|
|
5218
|
-
priority: surface.priority,
|
|
5219
|
-
sensitive: surface.sensitive === true,
|
|
5220
|
-
guards: Array.isArray(surface.guards) ? surface.guards : [],
|
|
5221
|
-
} : undefined,
|
|
5222
|
-
...data,
|
|
5223
|
-
});
|
|
5224
|
-
}
|
|
5225
|
-
|
|
5226
|
-
function nativeCommandUnavailable(command, details = {}) {
|
|
5227
|
-
const surface = nativeParitySurfaceForCommand(command);
|
|
5228
|
-
const guards = Array.isArray(surface?.guards) ? surface.guards.filter((guard) => guard !== "none") : [];
|
|
5229
|
-
const reason = details.reason || surface?.currentBehavior || "This native Pi TUI command is not implemented in the Web UI yet.";
|
|
5230
|
-
const nextActions = details.nextActions || [
|
|
5231
|
-
surface?.targetBehavior ? `Planned Web UI behavior: ${surface.targetBehavior}` : "Use the Pi TUI for this command until Web UI parity is implemented.",
|
|
5232
|
-
];
|
|
5233
|
-
return nativeCommandResponse(command, {
|
|
5234
|
-
status: "unavailable",
|
|
5235
|
-
level: "warn",
|
|
5236
|
-
reason,
|
|
5237
|
-
safetyRestriction: details.safetyRestriction || (guards.length ? `Guarded by: ${guards.join(", ")}.` : undefined),
|
|
5238
|
-
nextActions,
|
|
5239
|
-
message: details.message || [`/${command} is not available in the Web UI yet.`, reason, ...nextActions].filter(Boolean).join("\n"),
|
|
5240
|
-
});
|
|
5241
|
-
}
|
|
5242
|
-
|
|
5243
5448
|
async function handleNativeSlashCommand(tab, body, req) {
|
|
5244
5449
|
const parsed = parseSlashCommand(body.message);
|
|
5245
5450
|
if (!parsed) return undefined;
|
|
5246
5451
|
|
|
5452
|
+
// Dispatch guards come straight from the parity matrix guards array (not the
|
|
5453
|
+
// sensitive flag), so localhost/trusted-context entries cannot drift out of
|
|
5454
|
+
// enforcement; confirmation guards stay handler/browser-specific by design.
|
|
5455
|
+
const evaluation = evaluateDispatchTrustGuards(guardsForNativeCommand(parsed.name, nativeParityMatrix), {
|
|
5456
|
+
isLocal: isLocalRequest(req),
|
|
5457
|
+
confirmed: body.confirmed === true,
|
|
5458
|
+
networkOpen: networkStatus().open,
|
|
5459
|
+
});
|
|
5460
|
+
if (!evaluation.allowed) {
|
|
5461
|
+
return nativeCommandBlocked(parsed.name, req, nativeParityMatrix, {
|
|
5462
|
+
confirmed: body.confirmed === true,
|
|
5463
|
+
networkOpen: networkStatus().open,
|
|
5464
|
+
});
|
|
5465
|
+
}
|
|
5466
|
+
|
|
5247
5467
|
switch (parsed.name) {
|
|
5248
5468
|
case "reload": {
|
|
5249
5469
|
const reloaded = await restartTabRpc(tab, "slash-command");
|
|
5250
|
-
return
|
|
5470
|
+
return respondNative("reload", {
|
|
5471
|
+
status: "succeeded",
|
|
5472
|
+
message: "Reloaded keybindings, extensions, skills, prompts, and themes.",
|
|
5473
|
+
tab: tabMeta(reloaded),
|
|
5474
|
+
refresh: ["tabs", "state", "commands"],
|
|
5475
|
+
});
|
|
5251
5476
|
}
|
|
5252
5477
|
case "new": {
|
|
5253
5478
|
const response = await tab.rpc.send({ type: "new_session" });
|
|
5254
5479
|
if (response.success === false) return response;
|
|
5255
5480
|
tab.conversationStarted = false;
|
|
5256
|
-
return
|
|
5481
|
+
return respondNative("new", {
|
|
5482
|
+
status: "succeeded",
|
|
5483
|
+
message: "Started a new session.",
|
|
5484
|
+
tab: tabMeta(tab),
|
|
5485
|
+
result: response.data,
|
|
5486
|
+
refresh: ["tabs", "state"],
|
|
5487
|
+
});
|
|
5257
5488
|
}
|
|
5258
5489
|
case "compact": {
|
|
5259
5490
|
const response = await tab.rpc.send(parsed.args ? { type: "compact", customInstructions: parsed.args } : { type: "compact" });
|
|
5260
|
-
|
|
5491
|
+
if (response.success === false) return response;
|
|
5492
|
+
return respondNative("compact", {
|
|
5493
|
+
status: "succeeded",
|
|
5494
|
+
message: "Compaction finished.",
|
|
5495
|
+
result: response.data,
|
|
5496
|
+
refresh: ["state"],
|
|
5497
|
+
});
|
|
5261
5498
|
}
|
|
5262
5499
|
case "name": {
|
|
5263
5500
|
if (!parsed.args) throw makeHttpError(400, "Usage: /name <session name>");
|
|
5264
5501
|
const response = await tab.rpc.send({ type: "set_session_name", name: parsed.args });
|
|
5265
5502
|
if (response.success === false) return response;
|
|
5266
5503
|
renameTab(tab, parsed.args, { source: "explicit" });
|
|
5267
|
-
return
|
|
5504
|
+
return respondNative("name", {
|
|
5505
|
+
status: "succeeded",
|
|
5506
|
+
message: `Session and tab name set to: ${tab.title}`,
|
|
5507
|
+
tab: tabMeta(tab),
|
|
5508
|
+
refresh: ["tabs"],
|
|
5509
|
+
});
|
|
5268
5510
|
}
|
|
5269
5511
|
case "session": {
|
|
5270
5512
|
const [state, stats] = await Promise.all([
|
|
@@ -5272,7 +5514,11 @@ async function handleNativeSlashCommand(tab, body, req) {
|
|
|
5272
5514
|
tab.rpc.send({ type: "get_session_stats" }).catch((error) => ({ success: false, error: sanitizeError(error) })),
|
|
5273
5515
|
]);
|
|
5274
5516
|
if (state.success === false) return state;
|
|
5275
|
-
return
|
|
5517
|
+
return respondNative("session", {
|
|
5518
|
+
status: "succeeded",
|
|
5519
|
+
message: formatSessionOutput(tab, state.data || {}, stats.success === false ? null : stats.data),
|
|
5520
|
+
refresh: ["state"],
|
|
5521
|
+
});
|
|
5276
5522
|
}
|
|
5277
5523
|
case "export": {
|
|
5278
5524
|
return handleNativeExportCommand(tab, parsed.args, req);
|
|
@@ -5282,17 +5528,32 @@ async function handleNativeSlashCommand(tab, body, req) {
|
|
|
5282
5528
|
if (response.success === false) return response;
|
|
5283
5529
|
const text = String(response.data?.text || "");
|
|
5284
5530
|
if (!text.trim()) throw makeHttpError(400, "No assistant message to copy.");
|
|
5285
|
-
return
|
|
5531
|
+
return respondNative("copy", {
|
|
5532
|
+
status: "succeeded",
|
|
5533
|
+
message: "Copied the last assistant message.",
|
|
5534
|
+
copyText: text,
|
|
5535
|
+
refresh: [],
|
|
5536
|
+
});
|
|
5286
5537
|
}
|
|
5287
5538
|
case "hotkeys": {
|
|
5288
|
-
return
|
|
5539
|
+
return respondNative("hotkeys", {
|
|
5540
|
+
status: "degraded",
|
|
5541
|
+
message: webuiHotkeysOutput(),
|
|
5542
|
+
refresh: [],
|
|
5543
|
+
});
|
|
5289
5544
|
}
|
|
5290
5545
|
case "clone": {
|
|
5291
5546
|
const response = await runCloneCommand(tab);
|
|
5292
|
-
|
|
5547
|
+
if (response.success === false) return response;
|
|
5548
|
+
return respondNative("clone", {
|
|
5549
|
+
status: "succeeded",
|
|
5550
|
+
message: response.data?.message || "Cloned the current session.",
|
|
5551
|
+
result: response.data?.result,
|
|
5552
|
+
refresh: ["tabs", "state"],
|
|
5553
|
+
});
|
|
5293
5554
|
}
|
|
5294
5555
|
default:
|
|
5295
|
-
return
|
|
5556
|
+
return unavailableNative(parsed.name);
|
|
5296
5557
|
}
|
|
5297
5558
|
}
|
|
5298
5559
|
|
|
@@ -5782,7 +6043,7 @@ const server = createServer(async (req, res) => {
|
|
|
5782
6043
|
}
|
|
5783
6044
|
|
|
5784
6045
|
if (url.pathname === "/api/network/open" && req.method === "POST") {
|
|
5785
|
-
|
|
6046
|
+
requireLocalhostRoute(req, url.pathname);
|
|
5786
6047
|
const before = networkStatus();
|
|
5787
6048
|
const shouldOpen = !before.open && !networkRebindInProgress;
|
|
5788
6049
|
sendJson(res, 202, { ok: true, data: { ...before, opening: shouldOpen || before.opening, closing: before.closing } }, { connection: "close" });
|
|
@@ -5793,6 +6054,7 @@ const server = createServer(async (req, res) => {
|
|
|
5793
6054
|
}
|
|
5794
6055
|
|
|
5795
6056
|
if (url.pathname === "/api/network/close" && req.method === "POST") {
|
|
6057
|
+
requireLocalhostRoute(req, url.pathname);
|
|
5796
6058
|
const before = networkStatus();
|
|
5797
6059
|
const shouldClose = before.open && !networkRebindInProgress;
|
|
5798
6060
|
sendJson(res, 202, { ok: true, data: { ...before, opening: before.opening, closing: shouldClose || before.closing } }, { connection: "close" });
|
|
@@ -5803,7 +6065,7 @@ const server = createServer(async (req, res) => {
|
|
|
5803
6065
|
}
|
|
5804
6066
|
|
|
5805
6067
|
if (url.pathname === "/api/restart" && req.method === "POST") {
|
|
5806
|
-
|
|
6068
|
+
requireLocalhostRoute(req, url.pathname);
|
|
5807
6069
|
const restorableTabs = await restorableTabsForRestart();
|
|
5808
6070
|
const child = spawnRestartServer(restorableTabs);
|
|
5809
6071
|
sendJson(res, 200, { ok: true, message: "Pi Web UI restarting", webuiPid: process.pid, nextWebuiPid: child.pid, restorableTabCount: restorableTabs.length });
|
|
@@ -5812,7 +6074,7 @@ const server = createServer(async (req, res) => {
|
|
|
5812
6074
|
}
|
|
5813
6075
|
|
|
5814
6076
|
if (url.pathname === "/api/update" && req.method === "POST") {
|
|
5815
|
-
|
|
6077
|
+
requireLocalhostRoute(req, url.pathname);
|
|
5816
6078
|
const data = await runPiUpdateAndPrepareRestart();
|
|
5817
6079
|
sendJson(res, 200, { ok: true, data });
|
|
5818
6080
|
setTimeout(() => shutdown("api update"), 20).unref();
|
|
@@ -5820,7 +6082,7 @@ const server = createServer(async (req, res) => {
|
|
|
5820
6082
|
}
|
|
5821
6083
|
|
|
5822
6084
|
if (url.pathname === "/api/shutdown" && req.method === "POST") {
|
|
5823
|
-
|
|
6085
|
+
requireLocalhostRoute(req, url.pathname);
|
|
5824
6086
|
sendJson(res, 200, { ok: true, message: "Pi Web UI shutting down", webuiPid: process.pid });
|
|
5825
6087
|
setTimeout(() => shutdown("api shutdown"), 20).unref();
|
|
5826
6088
|
return;
|
|
@@ -5989,6 +6251,33 @@ const server = createServer(async (req, res) => {
|
|
|
5989
6251
|
return;
|
|
5990
6252
|
}
|
|
5991
6253
|
|
|
6254
|
+
if (url.pathname === "/api/session-rename" && req.method === "POST") {
|
|
6255
|
+
const body = await readJsonBody(req);
|
|
6256
|
+
const tab = getRequestedTab(req, url, body);
|
|
6257
|
+
sendJson(res, 200, { ok: true, data: await renameSessionData(tab, body), tab: tabMeta(tab) });
|
|
6258
|
+
return;
|
|
6259
|
+
}
|
|
6260
|
+
|
|
6261
|
+
if (url.pathname === "/api/session-delete" && req.method === "POST") {
|
|
6262
|
+
requireLocalhostRoute(req, url.pathname);
|
|
6263
|
+
const body = await readJsonBody(req);
|
|
6264
|
+
const tab = getRequestedTab(req, url, body);
|
|
6265
|
+
sendJson(res, 200, { ok: true, data: await deleteSessionData(tab, body), tab: tabMeta(tab) });
|
|
6266
|
+
return;
|
|
6267
|
+
}
|
|
6268
|
+
|
|
6269
|
+
if (url.pathname === "/api/auth-providers" && req.method === "GET") {
|
|
6270
|
+
sendJson(res, 200, { ok: true, data: getAuthProvidersData() });
|
|
6271
|
+
return;
|
|
6272
|
+
}
|
|
6273
|
+
|
|
6274
|
+
if (url.pathname === "/api/auth-logout" && req.method === "POST") {
|
|
6275
|
+
requireLocalhostRoute(req, url.pathname);
|
|
6276
|
+
const body = await readJsonBody(req);
|
|
6277
|
+
sendJson(res, 200, { ok: true, data: logoutAuthProviderData(body) });
|
|
6278
|
+
return;
|
|
6279
|
+
}
|
|
6280
|
+
|
|
5992
6281
|
if (url.pathname === "/api/tree-navigate" && req.method === "POST") {
|
|
5993
6282
|
const body = await readJsonBody(req);
|
|
5994
6283
|
const tab = getRequestedTab(req, url, body);
|
|
@@ -5998,7 +6287,7 @@ const server = createServer(async (req, res) => {
|
|
|
5998
6287
|
}
|
|
5999
6288
|
|
|
6000
6289
|
if (url.pathname === "/api/optional-feature-install" && req.method === "POST") {
|
|
6001
|
-
|
|
6290
|
+
requireLocalhostRoute(req, url.pathname);
|
|
6002
6291
|
const body = await readJsonBody(req);
|
|
6003
6292
|
const data = await installOptionalFeaturePackage(String(body.featureId || ""));
|
|
6004
6293
|
sendJson(res, 200, { ok: true, data });
|
|
@@ -6038,7 +6327,7 @@ const server = createServer(async (req, res) => {
|
|
|
6038
6327
|
}
|
|
6039
6328
|
|
|
6040
6329
|
if (url.pathname === "/api/skill-file" && req.method === "POST") {
|
|
6041
|
-
|
|
6330
|
+
requireLocalhostRoute(req, url.pathname);
|
|
6042
6331
|
const body = await readJsonBody(req, { limitBytes: SKILL_FILE_BODY_LIMIT_BYTES });
|
|
6043
6332
|
const tab = getRequestedTab(req, url, body);
|
|
6044
6333
|
sendJson(res, 200, { ok: true, data: await saveSkillFileData(tab, body) });
|
|
@@ -6097,6 +6386,47 @@ const server = createServer(async (req, res) => {
|
|
|
6097
6386
|
return;
|
|
6098
6387
|
}
|
|
6099
6388
|
|
|
6389
|
+
if (url.pathname === "/api/git-changes" && req.method === "GET") {
|
|
6390
|
+
const tab = getRequestedTab(req, url);
|
|
6391
|
+
try {
|
|
6392
|
+
sendJson(res, 200, { ok: true, data: await readGitChanges(tab.cwd) });
|
|
6393
|
+
} catch (error) {
|
|
6394
|
+
sendJson(res, 200, { ok: false, error: sanitizeError(error) });
|
|
6395
|
+
}
|
|
6396
|
+
return;
|
|
6397
|
+
}
|
|
6398
|
+
|
|
6399
|
+
if (url.pathname === "/api/git-changes/untracked-file" && req.method === "GET") {
|
|
6400
|
+
const tab = getRequestedTab(req, url);
|
|
6401
|
+
try {
|
|
6402
|
+
sendJson(res, 200, { ok: true, data: await readGitUntrackedFile(tab.cwd, url.searchParams.get("path") || "") });
|
|
6403
|
+
} catch (error) {
|
|
6404
|
+
sendJson(res, 200, { ok: false, error: sanitizeError(error) });
|
|
6405
|
+
}
|
|
6406
|
+
return;
|
|
6407
|
+
}
|
|
6408
|
+
|
|
6409
|
+
if (url.pathname === "/api/git-branches" && req.method === "GET") {
|
|
6410
|
+
const tab = getRequestedTab(req, url);
|
|
6411
|
+
try {
|
|
6412
|
+
sendJson(res, 200, { ok: true, data: await readGitBranches(tab.cwd) });
|
|
6413
|
+
} catch (error) {
|
|
6414
|
+
sendJson(res, 200, { ok: false, error: sanitizeError(error) });
|
|
6415
|
+
}
|
|
6416
|
+
return;
|
|
6417
|
+
}
|
|
6418
|
+
|
|
6419
|
+
if (url.pathname === "/api/git-branch" && req.method === "POST") {
|
|
6420
|
+
const body = await readJsonBody(req);
|
|
6421
|
+
const tab = getRequestedTab(req, url, body);
|
|
6422
|
+
try {
|
|
6423
|
+
sendJson(res, 200, await switchGitBranch(tab.cwd, body.branch, { create: body.create === true }));
|
|
6424
|
+
} catch (error) {
|
|
6425
|
+
sendJson(res, 200, { ok: false, error: sanitizeError(error) });
|
|
6426
|
+
}
|
|
6427
|
+
return;
|
|
6428
|
+
}
|
|
6429
|
+
|
|
6100
6430
|
if (url.pathname.startsWith("/api/git-workflow/")) {
|
|
6101
6431
|
const body = req.method === "POST" ? await readJsonBody(req) : {};
|
|
6102
6432
|
const tab = getRequestedTab(req, url, body);
|
|
@@ -6148,11 +6478,15 @@ const server = createServer(async (req, res) => {
|
|
|
6148
6478
|
maybeNameTabForConversation(tab, command);
|
|
6149
6479
|
markTabWorking(tab);
|
|
6150
6480
|
}
|
|
6151
|
-
|
|
6481
|
+
let response = command.type === "set_thinking_level"
|
|
6152
6482
|
? await setThinkingLevelForTab(tab, command.level)
|
|
6153
6483
|
: command.type === "bash"
|
|
6154
6484
|
? await sendQueuedBashCommand(tab, command)
|
|
6155
6485
|
: await tab.rpc.send(command);
|
|
6486
|
+
if (command.type === "bash" && response.success !== false) {
|
|
6487
|
+
const trustWarning = remoteShellTrustWarning(req, networkStatus().open);
|
|
6488
|
+
if (trustWarning) response = { ...response, warnings: [trustWarning] };
|
|
6489
|
+
}
|
|
6156
6490
|
if (response.success === false && startsVisibleWork) markTabIdle(tab);
|
|
6157
6491
|
if (response.success !== false && command.type === "new_session") {
|
|
6158
6492
|
tab.conversationStarted = false;
|
|
@@ -6187,6 +6521,14 @@ server.on("error", (error) => {
|
|
|
6187
6521
|
process.exit(1);
|
|
6188
6522
|
});
|
|
6189
6523
|
|
|
6524
|
+
function sweepWebuiTempArtifacts() {
|
|
6525
|
+
sweepStaleTempEntries(UPLOAD_TEMP_ROOT, { ttlMs: UPLOAD_TEMP_TTL_MS }).catch(() => {});
|
|
6526
|
+
sweepStaleTempEntries(NATIVE_EXPORT_TEMP_ROOT, { ttlMs: NATIVE_EXPORT_TEMP_TTL_MS }).catch(() => {});
|
|
6527
|
+
}
|
|
6528
|
+
|
|
6529
|
+
sweepWebuiTempArtifacts();
|
|
6530
|
+
setInterval(sweepWebuiTempArtifacts, TEMP_ARTIFACT_SWEEP_INTERVAL_MS).unref();
|
|
6531
|
+
|
|
6190
6532
|
server.listen(options.port, currentHost, () => {
|
|
6191
6533
|
const urlHost = formatUrlHost(currentHost);
|
|
6192
6534
|
console.log(`Pi Web UI: http://${urlHost}:${options.port}/`);
|