@firstpick/pi-package-webui 0.3.7 → 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 +233 -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 +547 -93
- 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 +44 -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;
|
|
@@ -3349,7 +3345,7 @@ async function saveUploadedAttachments(body) {
|
|
|
3349
3345
|
});
|
|
3350
3346
|
}
|
|
3351
3347
|
|
|
3352
|
-
const uploadDir = path.join(
|
|
3348
|
+
const uploadDir = path.join(UPLOAD_TEMP_ROOT, randomUUID());
|
|
3353
3349
|
await mkdir(uploadDir, { recursive: true });
|
|
3354
3350
|
const saved = [];
|
|
3355
3351
|
for (const [index, file] of decoded.entries()) {
|
|
@@ -4234,7 +4230,7 @@ async function checkLatestNpmPackageStatus(packageName, currentVersion) {
|
|
|
4234
4230
|
function updateStatusForRequest(status, req) {
|
|
4235
4231
|
return {
|
|
4236
4232
|
...status,
|
|
4237
|
-
canRunUpdate:
|
|
4233
|
+
canRunUpdate: isLocalRequest(req),
|
|
4238
4234
|
updateInProgress: piUpdateInProgress,
|
|
4239
4235
|
};
|
|
4240
4236
|
}
|
|
@@ -4374,9 +4370,16 @@ async function updateTabCwd(id, cwd) {
|
|
|
4374
4370
|
const nextCwd = await resolveCwd(cwd, tab.cwd);
|
|
4375
4371
|
if (nextCwd === tab.cwd) return { tab, changed: false };
|
|
4376
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
|
+
|
|
4377
4379
|
const piArgs = buildPiArgsForTab(tab.index, tab.title);
|
|
4380
|
+
if (sessionFile && !options.noSession) piArgs.push("--session", sessionFile);
|
|
4378
4381
|
const piCommand = await resolvePiCommand(piArgs);
|
|
4379
|
-
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 };
|
|
4380
4383
|
recordEvent(restartingEvent);
|
|
4381
4384
|
for (const client of tab.sseClients) {
|
|
4382
4385
|
sendSse(client, restartingEvent);
|
|
@@ -4390,15 +4393,16 @@ async function updateTabCwd(id, cwd) {
|
|
|
4390
4393
|
oldRpc.stop();
|
|
4391
4394
|
|
|
4392
4395
|
tab.cwd = nextCwd;
|
|
4393
|
-
forgetTabState(tab);
|
|
4394
4396
|
resetTabActivity(tab);
|
|
4395
4397
|
clearPendingExtensionUiRequests(tab);
|
|
4396
4398
|
clearExtensionStatuses(tab);
|
|
4397
4399
|
const rpc = new PiRpcProcess({ ...piCommand, cwd: tab.cwd });
|
|
4398
4400
|
attachRpcToTab(tab, rpc);
|
|
4399
4401
|
rpc.start();
|
|
4402
|
+
// Non-fatal: a failed start surfaces through pi_process_error/exit events.
|
|
4403
|
+
await primeTabRpc(tab).catch(() => {});
|
|
4400
4404
|
|
|
4401
|
-
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) };
|
|
4402
4406
|
recordEvent(changedEvent);
|
|
4403
4407
|
for (const client of tab.sseClients) {
|
|
4404
4408
|
sendSse(client, changedEvent);
|
|
@@ -5047,6 +5051,18 @@ function configuredSessionDir() {
|
|
|
5047
5051
|
return undefined;
|
|
5048
5052
|
}
|
|
5049
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
|
+
|
|
5050
5066
|
function requirePersistentSessions() {
|
|
5051
5067
|
if (options.noSession) throw makeHttpError(400, "Session selectors are unavailable when Web UI was started with --no-session.");
|
|
5052
5068
|
}
|
|
@@ -5215,6 +5231,7 @@ async function switchTabSession(tab, sessionPath) {
|
|
|
5215
5231
|
const targetPath = resolveTabPath(tab, sessionPath);
|
|
5216
5232
|
if (!targetPath) throw makeHttpError(400, "sessionPath is required");
|
|
5217
5233
|
if (!targetPath.endsWith(".jsonl")) throw makeHttpError(400, "sessionPath must point to a .jsonl session file");
|
|
5234
|
+
requireAllowedSessionPath(targetPath);
|
|
5218
5235
|
const targetStats = await stat(targetPath).catch(() => null);
|
|
5219
5236
|
if (!targetStats?.isFile()) throw makeHttpError(404, `Session file not found: ${targetPath}`);
|
|
5220
5237
|
const manager = SessionManager.open(targetPath, configuredSessionDir());
|
|
@@ -5232,6 +5249,51 @@ async function switchTabSession(tab, sessionPath) {
|
|
|
5232
5249
|
});
|
|
5233
5250
|
}
|
|
5234
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
|
+
|
|
5235
5297
|
async function navigateSessionTree(tab, body) {
|
|
5236
5298
|
requirePersistentSessions();
|
|
5237
5299
|
await requireIdleForSessionAction(tab, "navigating the session tree");
|
|
@@ -5278,9 +5340,8 @@ function nativeExportBaseName(tab, state = {}) {
|
|
|
5278
5340
|
}
|
|
5279
5341
|
|
|
5280
5342
|
async function nativeExportTempPath(tab, state = {}, ext = ".html") {
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
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}`);
|
|
5284
5345
|
}
|
|
5285
5346
|
|
|
5286
5347
|
function exportTargetExtension(targetPath) {
|
|
@@ -5306,22 +5367,24 @@ async function handleNativeExportCommand(tab, args, req) {
|
|
|
5306
5367
|
fileName: `${nativeExportBaseName(tab, state)}.html`,
|
|
5307
5368
|
contentType: MIME_TYPES.get(".html"),
|
|
5308
5369
|
});
|
|
5309
|
-
return
|
|
5370
|
+
return respondNative("export", {
|
|
5310
5371
|
status: "succeeded",
|
|
5311
5372
|
level: "info",
|
|
5312
5373
|
message: `Exported current session to HTML.\nDownload: ${download.fileName}\nLink expires: ${download.expiresAt}`,
|
|
5313
5374
|
download,
|
|
5314
5375
|
result: response.data,
|
|
5376
|
+
refresh: ["state"],
|
|
5315
5377
|
});
|
|
5316
5378
|
}
|
|
5317
5379
|
|
|
5318
|
-
if (!
|
|
5319
|
-
return
|
|
5320
|
-
status: "
|
|
5321
|
-
level: "
|
|
5380
|
+
if (!isLocalRequest(req)) {
|
|
5381
|
+
return respondNative("export", {
|
|
5382
|
+
status: "blocked",
|
|
5383
|
+
level: "error",
|
|
5322
5384
|
reason: "Server-side export paths are only allowed from localhost.",
|
|
5323
5385
|
safetyRestriction: "Explicit /export paths write files on the server and are blocked for non-local browser clients.",
|
|
5324
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: [],
|
|
5325
5388
|
});
|
|
5326
5389
|
}
|
|
5327
5390
|
|
|
@@ -5329,12 +5392,13 @@ async function handleNativeExportCommand(tab, args, req) {
|
|
|
5329
5392
|
const ext = exportTargetExtension(targetPath);
|
|
5330
5393
|
if (![".html", ".jsonl"].includes(ext)) throw makeHttpError(400, "Usage: /export [path.html|path.jsonl]");
|
|
5331
5394
|
if (await exportTargetExists(targetPath)) {
|
|
5332
|
-
return
|
|
5395
|
+
return respondNative("export", {
|
|
5333
5396
|
status: "confirmation_required",
|
|
5334
5397
|
level: "warn",
|
|
5335
5398
|
reason: `Export target already exists: ${targetPath}`,
|
|
5336
5399
|
safetyRestriction: "Overwrites require an explicit confirmation flow, which is not available from plain slash-command text yet.",
|
|
5337
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: [],
|
|
5338
5402
|
});
|
|
5339
5403
|
}
|
|
5340
5404
|
|
|
@@ -5343,12 +5407,13 @@ async function handleNativeExportCommand(tab, args, req) {
|
|
|
5343
5407
|
if (ext === ".html") {
|
|
5344
5408
|
const response = await tab.rpc.send({ type: "export_html", outputPath: targetPath });
|
|
5345
5409
|
if (response.success === false) return response;
|
|
5346
|
-
return
|
|
5410
|
+
return respondNative("export", {
|
|
5347
5411
|
status: "succeeded",
|
|
5348
5412
|
level: "info",
|
|
5349
5413
|
message: `Exported current session HTML to server path:\n${response.data?.path || targetPath}`,
|
|
5350
5414
|
serverPath: response.data?.path || targetPath,
|
|
5351
5415
|
result: response.data,
|
|
5416
|
+
refresh: ["state"],
|
|
5352
5417
|
});
|
|
5353
5418
|
}
|
|
5354
5419
|
|
|
@@ -5358,12 +5423,13 @@ async function handleNativeExportCommand(tab, args, req) {
|
|
|
5358
5423
|
const sourceStats = await stat(sessionFile).catch(() => null);
|
|
5359
5424
|
if (!sourceStats?.isFile()) throw makeHttpError(404, `Current session file not found: ${sessionFile}`);
|
|
5360
5425
|
await copyFile(sessionFile, targetPath);
|
|
5361
|
-
return
|
|
5426
|
+
return respondNative("export", {
|
|
5362
5427
|
status: "succeeded",
|
|
5363
5428
|
level: "info",
|
|
5364
5429
|
message: `Copied current session JSONL to server path:\n${targetPath}`,
|
|
5365
5430
|
serverPath: targetPath,
|
|
5366
5431
|
result: { path: targetPath, sourcePath: sessionFile },
|
|
5432
|
+
refresh: ["state"],
|
|
5367
5433
|
});
|
|
5368
5434
|
}
|
|
5369
5435
|
|
|
@@ -5379,70 +5445,68 @@ function webuiHotkeysOutput() {
|
|
|
5379
5445
|
].join("\n");
|
|
5380
5446
|
}
|
|
5381
5447
|
|
|
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
5448
|
async function handleNativeSlashCommand(tab, body, req) {
|
|
5422
5449
|
const parsed = parseSlashCommand(body.message);
|
|
5423
5450
|
if (!parsed) return undefined;
|
|
5424
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
|
+
|
|
5425
5467
|
switch (parsed.name) {
|
|
5426
5468
|
case "reload": {
|
|
5427
5469
|
const reloaded = await restartTabRpc(tab, "slash-command");
|
|
5428
|
-
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
|
+
});
|
|
5429
5476
|
}
|
|
5430
5477
|
case "new": {
|
|
5431
5478
|
const response = await tab.rpc.send({ type: "new_session" });
|
|
5432
5479
|
if (response.success === false) return response;
|
|
5433
5480
|
tab.conversationStarted = false;
|
|
5434
|
-
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
|
+
});
|
|
5435
5488
|
}
|
|
5436
5489
|
case "compact": {
|
|
5437
5490
|
const response = await tab.rpc.send(parsed.args ? { type: "compact", customInstructions: parsed.args } : { type: "compact" });
|
|
5438
|
-
|
|
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
|
+
});
|
|
5439
5498
|
}
|
|
5440
5499
|
case "name": {
|
|
5441
5500
|
if (!parsed.args) throw makeHttpError(400, "Usage: /name <session name>");
|
|
5442
5501
|
const response = await tab.rpc.send({ type: "set_session_name", name: parsed.args });
|
|
5443
5502
|
if (response.success === false) return response;
|
|
5444
5503
|
renameTab(tab, parsed.args, { source: "explicit" });
|
|
5445
|
-
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
|
+
});
|
|
5446
5510
|
}
|
|
5447
5511
|
case "session": {
|
|
5448
5512
|
const [state, stats] = await Promise.all([
|
|
@@ -5450,7 +5514,11 @@ async function handleNativeSlashCommand(tab, body, req) {
|
|
|
5450
5514
|
tab.rpc.send({ type: "get_session_stats" }).catch((error) => ({ success: false, error: sanitizeError(error) })),
|
|
5451
5515
|
]);
|
|
5452
5516
|
if (state.success === false) return state;
|
|
5453
|
-
return
|
|
5517
|
+
return respondNative("session", {
|
|
5518
|
+
status: "succeeded",
|
|
5519
|
+
message: formatSessionOutput(tab, state.data || {}, stats.success === false ? null : stats.data),
|
|
5520
|
+
refresh: ["state"],
|
|
5521
|
+
});
|
|
5454
5522
|
}
|
|
5455
5523
|
case "export": {
|
|
5456
5524
|
return handleNativeExportCommand(tab, parsed.args, req);
|
|
@@ -5460,17 +5528,32 @@ async function handleNativeSlashCommand(tab, body, req) {
|
|
|
5460
5528
|
if (response.success === false) return response;
|
|
5461
5529
|
const text = String(response.data?.text || "");
|
|
5462
5530
|
if (!text.trim()) throw makeHttpError(400, "No assistant message to copy.");
|
|
5463
|
-
return
|
|
5531
|
+
return respondNative("copy", {
|
|
5532
|
+
status: "succeeded",
|
|
5533
|
+
message: "Copied the last assistant message.",
|
|
5534
|
+
copyText: text,
|
|
5535
|
+
refresh: [],
|
|
5536
|
+
});
|
|
5464
5537
|
}
|
|
5465
5538
|
case "hotkeys": {
|
|
5466
|
-
return
|
|
5539
|
+
return respondNative("hotkeys", {
|
|
5540
|
+
status: "degraded",
|
|
5541
|
+
message: webuiHotkeysOutput(),
|
|
5542
|
+
refresh: [],
|
|
5543
|
+
});
|
|
5467
5544
|
}
|
|
5468
5545
|
case "clone": {
|
|
5469
5546
|
const response = await runCloneCommand(tab);
|
|
5470
|
-
|
|
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
|
+
});
|
|
5471
5554
|
}
|
|
5472
5555
|
default:
|
|
5473
|
-
return
|
|
5556
|
+
return unavailableNative(parsed.name);
|
|
5474
5557
|
}
|
|
5475
5558
|
}
|
|
5476
5559
|
|
|
@@ -5960,7 +6043,7 @@ const server = createServer(async (req, res) => {
|
|
|
5960
6043
|
}
|
|
5961
6044
|
|
|
5962
6045
|
if (url.pathname === "/api/network/open" && req.method === "POST") {
|
|
5963
|
-
|
|
6046
|
+
requireLocalhostRoute(req, url.pathname);
|
|
5964
6047
|
const before = networkStatus();
|
|
5965
6048
|
const shouldOpen = !before.open && !networkRebindInProgress;
|
|
5966
6049
|
sendJson(res, 202, { ok: true, data: { ...before, opening: shouldOpen || before.opening, closing: before.closing } }, { connection: "close" });
|
|
@@ -5971,6 +6054,7 @@ const server = createServer(async (req, res) => {
|
|
|
5971
6054
|
}
|
|
5972
6055
|
|
|
5973
6056
|
if (url.pathname === "/api/network/close" && req.method === "POST") {
|
|
6057
|
+
requireLocalhostRoute(req, url.pathname);
|
|
5974
6058
|
const before = networkStatus();
|
|
5975
6059
|
const shouldClose = before.open && !networkRebindInProgress;
|
|
5976
6060
|
sendJson(res, 202, { ok: true, data: { ...before, opening: before.opening, closing: shouldClose || before.closing } }, { connection: "close" });
|
|
@@ -5981,7 +6065,7 @@ const server = createServer(async (req, res) => {
|
|
|
5981
6065
|
}
|
|
5982
6066
|
|
|
5983
6067
|
if (url.pathname === "/api/restart" && req.method === "POST") {
|
|
5984
|
-
|
|
6068
|
+
requireLocalhostRoute(req, url.pathname);
|
|
5985
6069
|
const restorableTabs = await restorableTabsForRestart();
|
|
5986
6070
|
const child = spawnRestartServer(restorableTabs);
|
|
5987
6071
|
sendJson(res, 200, { ok: true, message: "Pi Web UI restarting", webuiPid: process.pid, nextWebuiPid: child.pid, restorableTabCount: restorableTabs.length });
|
|
@@ -5990,7 +6074,7 @@ const server = createServer(async (req, res) => {
|
|
|
5990
6074
|
}
|
|
5991
6075
|
|
|
5992
6076
|
if (url.pathname === "/api/update" && req.method === "POST") {
|
|
5993
|
-
|
|
6077
|
+
requireLocalhostRoute(req, url.pathname);
|
|
5994
6078
|
const data = await runPiUpdateAndPrepareRestart();
|
|
5995
6079
|
sendJson(res, 200, { ok: true, data });
|
|
5996
6080
|
setTimeout(() => shutdown("api update"), 20).unref();
|
|
@@ -5998,7 +6082,7 @@ const server = createServer(async (req, res) => {
|
|
|
5998
6082
|
}
|
|
5999
6083
|
|
|
6000
6084
|
if (url.pathname === "/api/shutdown" && req.method === "POST") {
|
|
6001
|
-
|
|
6085
|
+
requireLocalhostRoute(req, url.pathname);
|
|
6002
6086
|
sendJson(res, 200, { ok: true, message: "Pi Web UI shutting down", webuiPid: process.pid });
|
|
6003
6087
|
setTimeout(() => shutdown("api shutdown"), 20).unref();
|
|
6004
6088
|
return;
|
|
@@ -6167,6 +6251,33 @@ const server = createServer(async (req, res) => {
|
|
|
6167
6251
|
return;
|
|
6168
6252
|
}
|
|
6169
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
|
+
|
|
6170
6281
|
if (url.pathname === "/api/tree-navigate" && req.method === "POST") {
|
|
6171
6282
|
const body = await readJsonBody(req);
|
|
6172
6283
|
const tab = getRequestedTab(req, url, body);
|
|
@@ -6176,7 +6287,7 @@ const server = createServer(async (req, res) => {
|
|
|
6176
6287
|
}
|
|
6177
6288
|
|
|
6178
6289
|
if (url.pathname === "/api/optional-feature-install" && req.method === "POST") {
|
|
6179
|
-
|
|
6290
|
+
requireLocalhostRoute(req, url.pathname);
|
|
6180
6291
|
const body = await readJsonBody(req);
|
|
6181
6292
|
const data = await installOptionalFeaturePackage(String(body.featureId || ""));
|
|
6182
6293
|
sendJson(res, 200, { ok: true, data });
|
|
@@ -6216,7 +6327,7 @@ const server = createServer(async (req, res) => {
|
|
|
6216
6327
|
}
|
|
6217
6328
|
|
|
6218
6329
|
if (url.pathname === "/api/skill-file" && req.method === "POST") {
|
|
6219
|
-
|
|
6330
|
+
requireLocalhostRoute(req, url.pathname);
|
|
6220
6331
|
const body = await readJsonBody(req, { limitBytes: SKILL_FILE_BODY_LIMIT_BYTES });
|
|
6221
6332
|
const tab = getRequestedTab(req, url, body);
|
|
6222
6333
|
sendJson(res, 200, { ok: true, data: await saveSkillFileData(tab, body) });
|
|
@@ -6367,11 +6478,15 @@ const server = createServer(async (req, res) => {
|
|
|
6367
6478
|
maybeNameTabForConversation(tab, command);
|
|
6368
6479
|
markTabWorking(tab);
|
|
6369
6480
|
}
|
|
6370
|
-
|
|
6481
|
+
let response = command.type === "set_thinking_level"
|
|
6371
6482
|
? await setThinkingLevelForTab(tab, command.level)
|
|
6372
6483
|
: command.type === "bash"
|
|
6373
6484
|
? await sendQueuedBashCommand(tab, command)
|
|
6374
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
|
+
}
|
|
6375
6490
|
if (response.success === false && startsVisibleWork) markTabIdle(tab);
|
|
6376
6491
|
if (response.success !== false && command.type === "new_session") {
|
|
6377
6492
|
tab.conversationStarted = false;
|
|
@@ -6406,6 +6521,14 @@ server.on("error", (error) => {
|
|
|
6406
6521
|
process.exit(1);
|
|
6407
6522
|
});
|
|
6408
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
|
+
|
|
6409
6532
|
server.listen(options.port, currentHost, () => {
|
|
6410
6533
|
const urlHost = formatUrlHost(currentHost);
|
|
6411
6534
|
console.log(`Pi Web UI: http://${urlHost}:${options.port}/`);
|
|
Binary file
|
package/index.ts
CHANGED
|
@@ -391,14 +391,25 @@ function commandLooksLikeWebui(command: string, options: StartWebuiOptions): boo
|
|
|
391
391
|
return new RegExp(`(?:^|\\s)--port\\s+${escapedPort}(?:\\s|$)`).test(command);
|
|
392
392
|
}
|
|
393
393
|
|
|
394
|
-
async function
|
|
395
|
-
if (process.platform === "win32")
|
|
394
|
+
async function listProcessCommandLines(): Promise<string> {
|
|
395
|
+
if (process.platform === "win32") {
|
|
396
|
+
// tasklist has no command lines; CIM is the reliable way to find pi-webui.mjs --port matches.
|
|
397
|
+
const result = await runCommand(
|
|
398
|
+
"powershell.exe",
|
|
399
|
+
["-NoProfile", "-NonInteractive", "-Command", 'Get-CimInstance Win32_Process | ForEach-Object { "$($_.ProcessId) $($_.CommandLine)" }'],
|
|
400
|
+
5_000,
|
|
401
|
+
);
|
|
402
|
+
return result.exitCode === 0 ? result.stdout : "";
|
|
403
|
+
}
|
|
396
404
|
let result = await runCommand("ps", ["-Ao", "pid=,args="], 1_500);
|
|
397
405
|
if (result.exitCode !== 0) result = await runCommand("ps", ["-eo", "pid=,args="], 1_500);
|
|
398
|
-
|
|
406
|
+
return result.exitCode === 0 ? result.stdout : "";
|
|
407
|
+
}
|
|
399
408
|
|
|
409
|
+
async function findWebuiPidsByCommand(options: StartWebuiOptions): Promise<number[]> {
|
|
410
|
+
const processList = await listProcessCommandLines();
|
|
400
411
|
const pids: number[] = [];
|
|
401
|
-
for (const line of
|
|
412
|
+
for (const line of processList.split(/\r?\n/)) {
|
|
402
413
|
const match = line.match(/^\s*(\d+)\s+(.+)$/);
|
|
403
414
|
if (!match) continue;
|
|
404
415
|
const pid = Number.parseInt(match[1], 10);
|