@firstpick/pi-package-webui 0.3.7 → 0.3.9
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 +259 -112
- 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 +554 -94
- package/public/index.html +2 -2
- package/public/service-worker.js +23 -9
- package/public/styles.css +111 -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 +45 -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;
|
|
@@ -2780,10 +2776,23 @@ function gitBranchFromStatus(statusText) {
|
|
|
2780
2776
|
return branchLine.slice(3).trim().replace(/\.\.\..*$/, "") || "detached";
|
|
2781
2777
|
}
|
|
2782
2778
|
|
|
2779
|
+
function gitDivergenceFromBranchStatus(line) {
|
|
2780
|
+
const details = String(line || "").match(/\[(.+)\]\s*$/)?.[1] || "";
|
|
2781
|
+
const ahead = Number.parseInt(details.match(/ahead\s+(\d+)/i)?.[1] || "0", 10) || 0;
|
|
2782
|
+
const behind = Number.parseInt(details.match(/behind\s+(\d+)/i)?.[1] || "0", 10) || 0;
|
|
2783
|
+
return { ahead, behind };
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2783
2786
|
function summarizeGitShortStatus(statusText) {
|
|
2784
|
-
const summary = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0 };
|
|
2787
|
+
const summary = { staged: 0, unstaged: 0, untracked: 0, conflicted: 0, ahead: 0, behind: 0 };
|
|
2785
2788
|
for (const line of String(statusText || "").split(/\r?\n/)) {
|
|
2786
|
-
if (!line
|
|
2789
|
+
if (!line) continue;
|
|
2790
|
+
if (line.startsWith("## ")) {
|
|
2791
|
+
const divergence = gitDivergenceFromBranchStatus(line);
|
|
2792
|
+
summary.ahead = divergence.ahead;
|
|
2793
|
+
summary.behind = divergence.behind;
|
|
2794
|
+
continue;
|
|
2795
|
+
}
|
|
2787
2796
|
const x = line[0] || " ";
|
|
2788
2797
|
const y = line[1] || " ";
|
|
2789
2798
|
if (x === "?" && y === "?") {
|
|
@@ -3349,7 +3358,7 @@ async function saveUploadedAttachments(body) {
|
|
|
3349
3358
|
});
|
|
3350
3359
|
}
|
|
3351
3360
|
|
|
3352
|
-
const uploadDir = path.join(
|
|
3361
|
+
const uploadDir = path.join(UPLOAD_TEMP_ROOT, randomUUID());
|
|
3353
3362
|
await mkdir(uploadDir, { recursive: true });
|
|
3354
3363
|
const saved = [];
|
|
3355
3364
|
for (const [index, file] of decoded.entries()) {
|
|
@@ -3563,8 +3572,19 @@ function buildPiArgsForTab(tabIndex, title) {
|
|
|
3563
3572
|
return args;
|
|
3564
3573
|
}
|
|
3565
3574
|
|
|
3575
|
+
function isNodeScriptCommand(command) {
|
|
3576
|
+
return [".cjs", ".js", ".mjs"].includes(path.extname(String(command || "")).toLowerCase());
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3566
3579
|
async function resolvePiCommand(piArgs) {
|
|
3567
3580
|
if (options.piBinExplicit) {
|
|
3581
|
+
if (isNodeScriptCommand(options.piBin)) {
|
|
3582
|
+
return {
|
|
3583
|
+
command: process.execPath,
|
|
3584
|
+
args: [options.piBin, ...piArgs],
|
|
3585
|
+
displayCommand: `${process.execPath} ${options.piBin} ${piArgs.join(" ")}`,
|
|
3586
|
+
};
|
|
3587
|
+
}
|
|
3568
3588
|
return { command: options.piBin, args: piArgs, displayCommand: `${options.piBin} ${piArgs.join(" ")}` };
|
|
3569
3589
|
}
|
|
3570
3590
|
|
|
@@ -4234,7 +4254,7 @@ async function checkLatestNpmPackageStatus(packageName, currentVersion) {
|
|
|
4234
4254
|
function updateStatusForRequest(status, req) {
|
|
4235
4255
|
return {
|
|
4236
4256
|
...status,
|
|
4237
|
-
canRunUpdate:
|
|
4257
|
+
canRunUpdate: isLocalRequest(req),
|
|
4238
4258
|
updateInProgress: piUpdateInProgress,
|
|
4239
4259
|
};
|
|
4240
4260
|
}
|
|
@@ -4374,9 +4394,16 @@ async function updateTabCwd(id, cwd) {
|
|
|
4374
4394
|
const nextCwd = await resolveCwd(cwd, tab.cwd);
|
|
4375
4395
|
if (nextCwd === tab.cwd) return { tab, changed: false };
|
|
4376
4396
|
|
|
4397
|
+
// Capture the live session before stopping the old RPC so the conversation
|
|
4398
|
+
// survives the cwd restart, mirroring restartTabRpc. Best-effort: a dead RPC
|
|
4399
|
+
// falls back to the last remembered session file.
|
|
4400
|
+
if (tab.rpc?.isRunning()) await safeRpcData(tab, { type: "get_state" }, STATUS_RPC_TIMEOUT_MS);
|
|
4401
|
+
const sessionFile = tabRestorableSessionFile(tab);
|
|
4402
|
+
|
|
4377
4403
|
const piArgs = buildPiArgsForTab(tab.index, tab.title);
|
|
4404
|
+
if (sessionFile && !options.noSession) piArgs.push("--session", sessionFile);
|
|
4378
4405
|
const piCommand = await resolvePiCommand(piArgs);
|
|
4379
|
-
const restartingEvent = { type: "webui_tab_restarting", tabId: tab.id, tabTitle: tab.title, cwd: nextCwd };
|
|
4406
|
+
const restartingEvent = { type: "webui_tab_restarting", tabId: tab.id, tabTitle: tab.title, cwd: nextCwd, sessionFile };
|
|
4380
4407
|
recordEvent(restartingEvent);
|
|
4381
4408
|
for (const client of tab.sseClients) {
|
|
4382
4409
|
sendSse(client, restartingEvent);
|
|
@@ -4390,15 +4417,16 @@ async function updateTabCwd(id, cwd) {
|
|
|
4390
4417
|
oldRpc.stop();
|
|
4391
4418
|
|
|
4392
4419
|
tab.cwd = nextCwd;
|
|
4393
|
-
forgetTabState(tab);
|
|
4394
4420
|
resetTabActivity(tab);
|
|
4395
4421
|
clearPendingExtensionUiRequests(tab);
|
|
4396
4422
|
clearExtensionStatuses(tab);
|
|
4397
4423
|
const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
|
|
4398
4424
|
attachRpcToTab(tab, rpc);
|
|
4399
4425
|
rpc.start();
|
|
4426
|
+
// Non-fatal: a failed start surfaces through pi_process_error/exit events.
|
|
4427
|
+
await primeTabRpc(tab).catch(() => {});
|
|
4400
4428
|
|
|
4401
|
-
const changedEvent = { type: "webui_cwd_changed", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, pid: tab.rpc.child?.pid, tabActivity: tabActivitySnapshot(tab) };
|
|
4429
|
+
const changedEvent = { type: "webui_cwd_changed", tabId: tab.id, tabTitle: tab.title, cwd: tab.cwd, pid: tab.rpc.child?.pid, sessionFile, tabActivity: tabActivitySnapshot(tab) };
|
|
4402
4430
|
recordEvent(changedEvent);
|
|
4403
4431
|
for (const client of tab.sseClients) {
|
|
4404
4432
|
sendSse(client, changedEvent);
|
|
@@ -5047,6 +5075,18 @@ function configuredSessionDir() {
|
|
|
5047
5075
|
return undefined;
|
|
5048
5076
|
}
|
|
5049
5077
|
|
|
5078
|
+
/** Roots that session switch/rename/delete paths must stay inside. */
|
|
5079
|
+
function allowedSessionDirs() {
|
|
5080
|
+
const configured = configuredSessionDir();
|
|
5081
|
+
return configured ? [configured] : [path.join(agentDir, "sessions")];
|
|
5082
|
+
}
|
|
5083
|
+
|
|
5084
|
+
function requireAllowedSessionPath(targetPath) {
|
|
5085
|
+
if (!isSessionPathAllowed(targetPath, allowedSessionDirs())) {
|
|
5086
|
+
throw makeHttpError(403, "sessionPath must stay inside the Pi session directory");
|
|
5087
|
+
}
|
|
5088
|
+
}
|
|
5089
|
+
|
|
5050
5090
|
function requirePersistentSessions() {
|
|
5051
5091
|
if (options.noSession) throw makeHttpError(400, "Session selectors are unavailable when Web UI was started with --no-session.");
|
|
5052
5092
|
}
|
|
@@ -5215,6 +5255,7 @@ async function switchTabSession(tab, sessionPath) {
|
|
|
5215
5255
|
const targetPath = resolveTabPath(tab, sessionPath);
|
|
5216
5256
|
if (!targetPath) throw makeHttpError(400, "sessionPath is required");
|
|
5217
5257
|
if (!targetPath.endsWith(".jsonl")) throw makeHttpError(400, "sessionPath must point to a .jsonl session file");
|
|
5258
|
+
requireAllowedSessionPath(targetPath);
|
|
5218
5259
|
const targetStats = await stat(targetPath).catch(() => null);
|
|
5219
5260
|
if (!targetStats?.isFile()) throw makeHttpError(404, `Session file not found: ${targetPath}`);
|
|
5220
5261
|
const manager = SessionManager.open(targetPath, configuredSessionDir());
|
|
@@ -5232,6 +5273,51 @@ async function switchTabSession(tab, sessionPath) {
|
|
|
5232
5273
|
});
|
|
5233
5274
|
}
|
|
5234
5275
|
|
|
5276
|
+
let authContextCache;
|
|
5277
|
+
|
|
5278
|
+
function authContext() {
|
|
5279
|
+
if (!authContextCache) authContextCache = createAuthContext();
|
|
5280
|
+
return authContextCache;
|
|
5281
|
+
}
|
|
5282
|
+
|
|
5283
|
+
async function renameSessionData(tab, body) {
|
|
5284
|
+
requirePersistentSessions();
|
|
5285
|
+
const sessionPath = resolveTabPath(tab, body.sessionPath || body.path);
|
|
5286
|
+
const result = await renameSessionMetadata(sessionPath, body.name, configuredSessionDir(), { allowedDirs: allowedSessionDirs() });
|
|
5287
|
+
return {
|
|
5288
|
+
message: `Renamed session metadata to: ${result.name}`,
|
|
5289
|
+
...result,
|
|
5290
|
+
};
|
|
5291
|
+
}
|
|
5292
|
+
|
|
5293
|
+
async function deleteSessionData(tab, body) {
|
|
5294
|
+
requirePersistentSessions();
|
|
5295
|
+
const state = await currentSessionState(tab).catch(() => tab.lastState || {});
|
|
5296
|
+
const validation = validateSessionDelete(resolveTabPath(tab, body.sessionPath || body.path), {
|
|
5297
|
+
openSessionFiles: collectOpenSessionFiles([...tabs.values()]),
|
|
5298
|
+
currentSessionFile: state.sessionFile || tabRestorableSessionFile(tab),
|
|
5299
|
+
confirmed: body.confirmed === true,
|
|
5300
|
+
allowedDirs: allowedSessionDirs(),
|
|
5301
|
+
});
|
|
5302
|
+
if (!validation.allowed) {
|
|
5303
|
+
throw makeHttpError(validation.reason === "confirmation_required" ? 409 : validation.reason === "outside_session_dir" ? 403 : 400, validation.message);
|
|
5304
|
+
}
|
|
5305
|
+
const deleted = await deleteSessionFile(validation.sessionPath, { allowedDirs: allowedSessionDirs() });
|
|
5306
|
+
return {
|
|
5307
|
+
message: deleted.method === "trash" ? "Session moved to trash." : "Session deleted.",
|
|
5308
|
+
...deleted,
|
|
5309
|
+
};
|
|
5310
|
+
}
|
|
5311
|
+
|
|
5312
|
+
function getAuthProvidersData() {
|
|
5313
|
+
return authProvidersPayload(authContext().modelRegistry);
|
|
5314
|
+
}
|
|
5315
|
+
|
|
5316
|
+
function logoutAuthProviderData(body) {
|
|
5317
|
+
if (body.confirmed !== true) throw makeHttpError(409, "Logout requires explicit confirmation (confirmed: true).");
|
|
5318
|
+
return logoutStoredProvider(authContext().modelRegistry, body.provider || body.providerId);
|
|
5319
|
+
}
|
|
5320
|
+
|
|
5235
5321
|
async function navigateSessionTree(tab, body) {
|
|
5236
5322
|
requirePersistentSessions();
|
|
5237
5323
|
await requireIdleForSessionAction(tab, "navigating the session tree");
|
|
@@ -5278,9 +5364,8 @@ function nativeExportBaseName(tab, state = {}) {
|
|
|
5278
5364
|
}
|
|
5279
5365
|
|
|
5280
5366
|
async function nativeExportTempPath(tab, state = {}, ext = ".html") {
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
return path.join(dir, `${nativeExportBaseName(tab, state)}-${randomUUID()}${ext}`);
|
|
5367
|
+
await mkdir(NATIVE_EXPORT_TEMP_ROOT, { recursive: true });
|
|
5368
|
+
return path.join(NATIVE_EXPORT_TEMP_ROOT, `${nativeExportBaseName(tab, state)}-${randomUUID()}${ext}`);
|
|
5284
5369
|
}
|
|
5285
5370
|
|
|
5286
5371
|
function exportTargetExtension(targetPath) {
|
|
@@ -5306,22 +5391,24 @@ async function handleNativeExportCommand(tab, args, req) {
|
|
|
5306
5391
|
fileName: `${nativeExportBaseName(tab, state)}.html`,
|
|
5307
5392
|
contentType: MIME_TYPES.get(".html"),
|
|
5308
5393
|
});
|
|
5309
|
-
return
|
|
5394
|
+
return respondNative("export", {
|
|
5310
5395
|
status: "succeeded",
|
|
5311
5396
|
level: "info",
|
|
5312
5397
|
message: `Exported current session to HTML.\nDownload: ${download.fileName}\nLink expires: ${download.expiresAt}`,
|
|
5313
5398
|
download,
|
|
5314
5399
|
result: response.data,
|
|
5400
|
+
refresh: ["state"],
|
|
5315
5401
|
});
|
|
5316
5402
|
}
|
|
5317
5403
|
|
|
5318
|
-
if (!
|
|
5319
|
-
return
|
|
5320
|
-
status: "
|
|
5321
|
-
level: "
|
|
5404
|
+
if (!isLocalRequest(req)) {
|
|
5405
|
+
return respondNative("export", {
|
|
5406
|
+
status: "blocked",
|
|
5407
|
+
level: "error",
|
|
5322
5408
|
reason: "Server-side export paths are only allowed from localhost.",
|
|
5323
5409
|
safetyRestriction: "Explicit /export paths write files on the server and are blocked for non-local browser clients.",
|
|
5324
5410
|
message: "Explicit /export paths are only allowed from localhost. Run /export without a path for a browser download, or retry from the local machine.",
|
|
5411
|
+
refresh: [],
|
|
5325
5412
|
});
|
|
5326
5413
|
}
|
|
5327
5414
|
|
|
@@ -5329,12 +5416,13 @@ async function handleNativeExportCommand(tab, args, req) {
|
|
|
5329
5416
|
const ext = exportTargetExtension(targetPath);
|
|
5330
5417
|
if (![".html", ".jsonl"].includes(ext)) throw makeHttpError(400, "Usage: /export [path.html|path.jsonl]");
|
|
5331
5418
|
if (await exportTargetExists(targetPath)) {
|
|
5332
|
-
return
|
|
5419
|
+
return respondNative("export", {
|
|
5333
5420
|
status: "confirmation_required",
|
|
5334
5421
|
level: "warn",
|
|
5335
5422
|
reason: `Export target already exists: ${targetPath}`,
|
|
5336
5423
|
safetyRestriction: "Overwrites require an explicit confirmation flow, which is not available from plain slash-command text yet.",
|
|
5337
5424
|
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.`,
|
|
5425
|
+
refresh: [],
|
|
5338
5426
|
});
|
|
5339
5427
|
}
|
|
5340
5428
|
|
|
@@ -5343,12 +5431,13 @@ async function handleNativeExportCommand(tab, args, req) {
|
|
|
5343
5431
|
if (ext === ".html") {
|
|
5344
5432
|
const response = await tab.rpc.send({ type: "export_html", outputPath: targetPath });
|
|
5345
5433
|
if (response.success === false) return response;
|
|
5346
|
-
return
|
|
5434
|
+
return respondNative("export", {
|
|
5347
5435
|
status: "succeeded",
|
|
5348
5436
|
level: "info",
|
|
5349
5437
|
message: `Exported current session HTML to server path:\n${response.data?.path || targetPath}`,
|
|
5350
5438
|
serverPath: response.data?.path || targetPath,
|
|
5351
5439
|
result: response.data,
|
|
5440
|
+
refresh: ["state"],
|
|
5352
5441
|
});
|
|
5353
5442
|
}
|
|
5354
5443
|
|
|
@@ -5358,12 +5447,13 @@ async function handleNativeExportCommand(tab, args, req) {
|
|
|
5358
5447
|
const sourceStats = await stat(sessionFile).catch(() => null);
|
|
5359
5448
|
if (!sourceStats?.isFile()) throw makeHttpError(404, `Current session file not found: ${sessionFile}`);
|
|
5360
5449
|
await copyFile(sessionFile, targetPath);
|
|
5361
|
-
return
|
|
5450
|
+
return respondNative("export", {
|
|
5362
5451
|
status: "succeeded",
|
|
5363
5452
|
level: "info",
|
|
5364
5453
|
message: `Copied current session JSONL to server path:\n${targetPath}`,
|
|
5365
5454
|
serverPath: targetPath,
|
|
5366
5455
|
result: { path: targetPath, sourcePath: sessionFile },
|
|
5456
|
+
refresh: ["state"],
|
|
5367
5457
|
});
|
|
5368
5458
|
}
|
|
5369
5459
|
|
|
@@ -5379,70 +5469,68 @@ function webuiHotkeysOutput() {
|
|
|
5379
5469
|
].join("\n");
|
|
5380
5470
|
}
|
|
5381
5471
|
|
|
5382
|
-
function nativeParitySurfaceForCommand(name) {
|
|
5383
|
-
return nativeParitySurfaces().find((surface) => surface.kind === "slash-command" && surface.command?.name === name) || null;
|
|
5384
|
-
}
|
|
5385
|
-
|
|
5386
|
-
function nativeCommandResponse(command, data = {}) {
|
|
5387
|
-
const surface = nativeParitySurfaceForCommand(command);
|
|
5388
|
-
const status = data.status || (surface?.webStatus === "implemented" ? "succeeded" : surface?.webStatus === "degraded" ? "degraded" : "unavailable");
|
|
5389
|
-
const level = data.level || (status === "succeeded" ? "info" : "warn");
|
|
5390
|
-
return rpcSuccess("native_slash_command", {
|
|
5391
|
-
command,
|
|
5392
|
-
status,
|
|
5393
|
-
level,
|
|
5394
|
-
nativeParity: surface ? {
|
|
5395
|
-
webStatus: surface.webStatus,
|
|
5396
|
-
priority: surface.priority,
|
|
5397
|
-
sensitive: surface.sensitive === true,
|
|
5398
|
-
guards: Array.isArray(surface.guards) ? surface.guards : [],
|
|
5399
|
-
} : undefined,
|
|
5400
|
-
...data,
|
|
5401
|
-
});
|
|
5402
|
-
}
|
|
5403
|
-
|
|
5404
|
-
function nativeCommandUnavailable(command, details = {}) {
|
|
5405
|
-
const surface = nativeParitySurfaceForCommand(command);
|
|
5406
|
-
const guards = Array.isArray(surface?.guards) ? surface.guards.filter((guard) => guard !== "none") : [];
|
|
5407
|
-
const reason = details.reason || surface?.currentBehavior || "This native Pi TUI command is not implemented in the Web UI yet.";
|
|
5408
|
-
const nextActions = details.nextActions || [
|
|
5409
|
-
surface?.targetBehavior ? `Planned Web UI behavior: ${surface.targetBehavior}` : "Use the Pi TUI for this command until Web UI parity is implemented.",
|
|
5410
|
-
];
|
|
5411
|
-
return nativeCommandResponse(command, {
|
|
5412
|
-
status: "unavailable",
|
|
5413
|
-
level: "warn",
|
|
5414
|
-
reason,
|
|
5415
|
-
safetyRestriction: details.safetyRestriction || (guards.length ? `Guarded by: ${guards.join(", ")}.` : undefined),
|
|
5416
|
-
nextActions,
|
|
5417
|
-
message: details.message || [`/${command} is not available in the Web UI yet.`, reason, ...nextActions].filter(Boolean).join("\n"),
|
|
5418
|
-
});
|
|
5419
|
-
}
|
|
5420
|
-
|
|
5421
5472
|
async function handleNativeSlashCommand(tab, body, req) {
|
|
5422
5473
|
const parsed = parseSlashCommand(body.message);
|
|
5423
5474
|
if (!parsed) return undefined;
|
|
5424
5475
|
|
|
5476
|
+
// Dispatch guards come straight from the parity matrix guards array (not the
|
|
5477
|
+
// sensitive flag), so localhost/trusted-context entries cannot drift out of
|
|
5478
|
+
// enforcement; confirmation guards stay handler/browser-specific by design.
|
|
5479
|
+
const evaluation = evaluateDispatchTrustGuards(guardsForNativeCommand(parsed.name, nativeParityMatrix), {
|
|
5480
|
+
isLocal: isLocalRequest(req),
|
|
5481
|
+
confirmed: body.confirmed === true,
|
|
5482
|
+
networkOpen: networkStatus().open,
|
|
5483
|
+
});
|
|
5484
|
+
if (!evaluation.allowed) {
|
|
5485
|
+
return nativeCommandBlocked(parsed.name, req, nativeParityMatrix, {
|
|
5486
|
+
confirmed: body.confirmed === true,
|
|
5487
|
+
networkOpen: networkStatus().open,
|
|
5488
|
+
});
|
|
5489
|
+
}
|
|
5490
|
+
|
|
5425
5491
|
switch (parsed.name) {
|
|
5426
5492
|
case "reload": {
|
|
5427
5493
|
const reloaded = await restartTabRpc(tab, "slash-command");
|
|
5428
|
-
return
|
|
5494
|
+
return respondNative("reload", {
|
|
5495
|
+
status: "succeeded",
|
|
5496
|
+
message: "Reloaded keybindings, extensions, skills, prompts, and themes.",
|
|
5497
|
+
tab: tabMeta(reloaded),
|
|
5498
|
+
refresh: ["tabs", "state", "commands"],
|
|
5499
|
+
});
|
|
5429
5500
|
}
|
|
5430
5501
|
case "new": {
|
|
5431
5502
|
const response = await tab.rpc.send({ type: "new_session" });
|
|
5432
5503
|
if (response.success === false) return response;
|
|
5433
5504
|
tab.conversationStarted = false;
|
|
5434
|
-
return
|
|
5505
|
+
return respondNative("new", {
|
|
5506
|
+
status: "succeeded",
|
|
5507
|
+
message: "Started a new session.",
|
|
5508
|
+
tab: tabMeta(tab),
|
|
5509
|
+
result: response.data,
|
|
5510
|
+
refresh: ["tabs", "state"],
|
|
5511
|
+
});
|
|
5435
5512
|
}
|
|
5436
5513
|
case "compact": {
|
|
5437
5514
|
const response = await tab.rpc.send(parsed.args ? { type: "compact", customInstructions: parsed.args } : { type: "compact" });
|
|
5438
|
-
|
|
5515
|
+
if (response.success === false) return response;
|
|
5516
|
+
return respondNative("compact", {
|
|
5517
|
+
status: "succeeded",
|
|
5518
|
+
message: "Compaction finished.",
|
|
5519
|
+
result: response.data,
|
|
5520
|
+
refresh: ["state"],
|
|
5521
|
+
});
|
|
5439
5522
|
}
|
|
5440
5523
|
case "name": {
|
|
5441
5524
|
if (!parsed.args) throw makeHttpError(400, "Usage: /name <session name>");
|
|
5442
5525
|
const response = await tab.rpc.send({ type: "set_session_name", name: parsed.args });
|
|
5443
5526
|
if (response.success === false) return response;
|
|
5444
5527
|
renameTab(tab, parsed.args, { source: "explicit" });
|
|
5445
|
-
return
|
|
5528
|
+
return respondNative("name", {
|
|
5529
|
+
status: "succeeded",
|
|
5530
|
+
message: `Session and tab name set to: ${tab.title}`,
|
|
5531
|
+
tab: tabMeta(tab),
|
|
5532
|
+
refresh: ["tabs"],
|
|
5533
|
+
});
|
|
5446
5534
|
}
|
|
5447
5535
|
case "session": {
|
|
5448
5536
|
const [state, stats] = await Promise.all([
|
|
@@ -5450,7 +5538,11 @@ async function handleNativeSlashCommand(tab, body, req) {
|
|
|
5450
5538
|
tab.rpc.send({ type: "get_session_stats" }).catch((error) => ({ success: false, error: sanitizeError(error) })),
|
|
5451
5539
|
]);
|
|
5452
5540
|
if (state.success === false) return state;
|
|
5453
|
-
return
|
|
5541
|
+
return respondNative("session", {
|
|
5542
|
+
status: "succeeded",
|
|
5543
|
+
message: formatSessionOutput(tab, state.data || {}, stats.success === false ? null : stats.data),
|
|
5544
|
+
refresh: ["state"],
|
|
5545
|
+
});
|
|
5454
5546
|
}
|
|
5455
5547
|
case "export": {
|
|
5456
5548
|
return handleNativeExportCommand(tab, parsed.args, req);
|
|
@@ -5460,17 +5552,32 @@ async function handleNativeSlashCommand(tab, body, req) {
|
|
|
5460
5552
|
if (response.success === false) return response;
|
|
5461
5553
|
const text = String(response.data?.text || "");
|
|
5462
5554
|
if (!text.trim()) throw makeHttpError(400, "No assistant message to copy.");
|
|
5463
|
-
return
|
|
5555
|
+
return respondNative("copy", {
|
|
5556
|
+
status: "succeeded",
|
|
5557
|
+
message: "Copied the last assistant message.",
|
|
5558
|
+
copyText: text,
|
|
5559
|
+
refresh: [],
|
|
5560
|
+
});
|
|
5464
5561
|
}
|
|
5465
5562
|
case "hotkeys": {
|
|
5466
|
-
return
|
|
5563
|
+
return respondNative("hotkeys", {
|
|
5564
|
+
status: "degraded",
|
|
5565
|
+
message: webuiHotkeysOutput(),
|
|
5566
|
+
refresh: [],
|
|
5567
|
+
});
|
|
5467
5568
|
}
|
|
5468
5569
|
case "clone": {
|
|
5469
5570
|
const response = await runCloneCommand(tab);
|
|
5470
|
-
|
|
5571
|
+
if (response.success === false) return response;
|
|
5572
|
+
return respondNative("clone", {
|
|
5573
|
+
status: "succeeded",
|
|
5574
|
+
message: response.data?.message || "Cloned the current session.",
|
|
5575
|
+
result: response.data?.result,
|
|
5576
|
+
refresh: ["tabs", "state"],
|
|
5577
|
+
});
|
|
5471
5578
|
}
|
|
5472
5579
|
default:
|
|
5473
|
-
return
|
|
5580
|
+
return unavailableNative(parsed.name);
|
|
5474
5581
|
}
|
|
5475
5582
|
}
|
|
5476
5583
|
|
|
@@ -5960,7 +6067,7 @@ const server = createServer(async (req, res) => {
|
|
|
5960
6067
|
}
|
|
5961
6068
|
|
|
5962
6069
|
if (url.pathname === "/api/network/open" && req.method === "POST") {
|
|
5963
|
-
|
|
6070
|
+
requireLocalhostRoute(req, url.pathname);
|
|
5964
6071
|
const before = networkStatus();
|
|
5965
6072
|
const shouldOpen = !before.open && !networkRebindInProgress;
|
|
5966
6073
|
sendJson(res, 202, { ok: true, data: { ...before, opening: shouldOpen || before.opening, closing: before.closing } }, { connection: "close" });
|
|
@@ -5971,6 +6078,7 @@ const server = createServer(async (req, res) => {
|
|
|
5971
6078
|
}
|
|
5972
6079
|
|
|
5973
6080
|
if (url.pathname === "/api/network/close" && req.method === "POST") {
|
|
6081
|
+
requireLocalhostRoute(req, url.pathname);
|
|
5974
6082
|
const before = networkStatus();
|
|
5975
6083
|
const shouldClose = before.open && !networkRebindInProgress;
|
|
5976
6084
|
sendJson(res, 202, { ok: true, data: { ...before, opening: before.opening, closing: shouldClose || before.closing } }, { connection: "close" });
|
|
@@ -5981,7 +6089,7 @@ const server = createServer(async (req, res) => {
|
|
|
5981
6089
|
}
|
|
5982
6090
|
|
|
5983
6091
|
if (url.pathname === "/api/restart" && req.method === "POST") {
|
|
5984
|
-
|
|
6092
|
+
requireLocalhostRoute(req, url.pathname);
|
|
5985
6093
|
const restorableTabs = await restorableTabsForRestart();
|
|
5986
6094
|
const child = spawnRestartServer(restorableTabs);
|
|
5987
6095
|
sendJson(res, 200, { ok: true, message: "Pi Web UI restarting", webuiPid: process.pid, nextWebuiPid: child.pid, restorableTabCount: restorableTabs.length });
|
|
@@ -5990,7 +6098,7 @@ const server = createServer(async (req, res) => {
|
|
|
5990
6098
|
}
|
|
5991
6099
|
|
|
5992
6100
|
if (url.pathname === "/api/update" && req.method === "POST") {
|
|
5993
|
-
|
|
6101
|
+
requireLocalhostRoute(req, url.pathname);
|
|
5994
6102
|
const data = await runPiUpdateAndPrepareRestart();
|
|
5995
6103
|
sendJson(res, 200, { ok: true, data });
|
|
5996
6104
|
setTimeout(() => shutdown("api update"), 20).unref();
|
|
@@ -5998,7 +6106,7 @@ const server = createServer(async (req, res) => {
|
|
|
5998
6106
|
}
|
|
5999
6107
|
|
|
6000
6108
|
if (url.pathname === "/api/shutdown" && req.method === "POST") {
|
|
6001
|
-
|
|
6109
|
+
requireLocalhostRoute(req, url.pathname);
|
|
6002
6110
|
sendJson(res, 200, { ok: true, message: "Pi Web UI shutting down", webuiPid: process.pid });
|
|
6003
6111
|
setTimeout(() => shutdown("api shutdown"), 20).unref();
|
|
6004
6112
|
return;
|
|
@@ -6167,6 +6275,33 @@ const server = createServer(async (req, res) => {
|
|
|
6167
6275
|
return;
|
|
6168
6276
|
}
|
|
6169
6277
|
|
|
6278
|
+
if (url.pathname === "/api/session-rename" && req.method === "POST") {
|
|
6279
|
+
const body = await readJsonBody(req);
|
|
6280
|
+
const tab = getRequestedTab(req, url, body);
|
|
6281
|
+
sendJson(res, 200, { ok: true, data: await renameSessionData(tab, body), tab: tabMeta(tab) });
|
|
6282
|
+
return;
|
|
6283
|
+
}
|
|
6284
|
+
|
|
6285
|
+
if (url.pathname === "/api/session-delete" && req.method === "POST") {
|
|
6286
|
+
requireLocalhostRoute(req, url.pathname);
|
|
6287
|
+
const body = await readJsonBody(req);
|
|
6288
|
+
const tab = getRequestedTab(req, url, body);
|
|
6289
|
+
sendJson(res, 200, { ok: true, data: await deleteSessionData(tab, body), tab: tabMeta(tab) });
|
|
6290
|
+
return;
|
|
6291
|
+
}
|
|
6292
|
+
|
|
6293
|
+
if (url.pathname === "/api/auth-providers" && req.method === "GET") {
|
|
6294
|
+
sendJson(res, 200, { ok: true, data: getAuthProvidersData() });
|
|
6295
|
+
return;
|
|
6296
|
+
}
|
|
6297
|
+
|
|
6298
|
+
if (url.pathname === "/api/auth-logout" && req.method === "POST") {
|
|
6299
|
+
requireLocalhostRoute(req, url.pathname);
|
|
6300
|
+
const body = await readJsonBody(req);
|
|
6301
|
+
sendJson(res, 200, { ok: true, data: logoutAuthProviderData(body) });
|
|
6302
|
+
return;
|
|
6303
|
+
}
|
|
6304
|
+
|
|
6170
6305
|
if (url.pathname === "/api/tree-navigate" && req.method === "POST") {
|
|
6171
6306
|
const body = await readJsonBody(req);
|
|
6172
6307
|
const tab = getRequestedTab(req, url, body);
|
|
@@ -6176,7 +6311,7 @@ const server = createServer(async (req, res) => {
|
|
|
6176
6311
|
}
|
|
6177
6312
|
|
|
6178
6313
|
if (url.pathname === "/api/optional-feature-install" && req.method === "POST") {
|
|
6179
|
-
|
|
6314
|
+
requireLocalhostRoute(req, url.pathname);
|
|
6180
6315
|
const body = await readJsonBody(req);
|
|
6181
6316
|
const data = await installOptionalFeaturePackage(String(body.featureId || ""));
|
|
6182
6317
|
sendJson(res, 200, { ok: true, data });
|
|
@@ -6216,7 +6351,7 @@ const server = createServer(async (req, res) => {
|
|
|
6216
6351
|
}
|
|
6217
6352
|
|
|
6218
6353
|
if (url.pathname === "/api/skill-file" && req.method === "POST") {
|
|
6219
|
-
|
|
6354
|
+
requireLocalhostRoute(req, url.pathname);
|
|
6220
6355
|
const body = await readJsonBody(req, { limitBytes: SKILL_FILE_BODY_LIMIT_BYTES });
|
|
6221
6356
|
const tab = getRequestedTab(req, url, body);
|
|
6222
6357
|
sendJson(res, 200, { ok: true, data: await saveSkillFileData(tab, body) });
|
|
@@ -6367,11 +6502,15 @@ const server = createServer(async (req, res) => {
|
|
|
6367
6502
|
maybeNameTabForConversation(tab, command);
|
|
6368
6503
|
markTabWorking(tab);
|
|
6369
6504
|
}
|
|
6370
|
-
|
|
6505
|
+
let response = command.type === "set_thinking_level"
|
|
6371
6506
|
? await setThinkingLevelForTab(tab, command.level)
|
|
6372
6507
|
: command.type === "bash"
|
|
6373
6508
|
? await sendQueuedBashCommand(tab, command)
|
|
6374
6509
|
: await tab.rpc.send(command);
|
|
6510
|
+
if (command.type === "bash" && response.success !== false) {
|
|
6511
|
+
const trustWarning = remoteShellTrustWarning(req, networkStatus().open);
|
|
6512
|
+
if (trustWarning) response = { ...response, warnings: [trustWarning] };
|
|
6513
|
+
}
|
|
6375
6514
|
if (response.success === false && startsVisibleWork) markTabIdle(tab);
|
|
6376
6515
|
if (response.success !== false && command.type === "new_session") {
|
|
6377
6516
|
tab.conversationStarted = false;
|
|
@@ -6406,6 +6545,14 @@ server.on("error", (error) => {
|
|
|
6406
6545
|
process.exit(1);
|
|
6407
6546
|
});
|
|
6408
6547
|
|
|
6548
|
+
function sweepWebuiTempArtifacts() {
|
|
6549
|
+
sweepStaleTempEntries(UPLOAD_TEMP_ROOT, { ttlMs: UPLOAD_TEMP_TTL_MS }).catch(() => {});
|
|
6550
|
+
sweepStaleTempEntries(NATIVE_EXPORT_TEMP_ROOT, { ttlMs: NATIVE_EXPORT_TEMP_TTL_MS }).catch(() => {});
|
|
6551
|
+
}
|
|
6552
|
+
|
|
6553
|
+
sweepWebuiTempArtifacts();
|
|
6554
|
+
setInterval(sweepWebuiTempArtifacts, TEMP_ARTIFACT_SWEEP_INTERVAL_MS).unref();
|
|
6555
|
+
|
|
6409
6556
|
server.listen(options.port, currentHost, () => {
|
|
6410
6557
|
const urlHost = formatUrlHost(currentHost);
|
|
6411
6558
|
console.log(`Pi Web UI: http://${urlHost}:${options.port}/`);
|
|
Binary file
|