@firstpick/pi-package-webui 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -24
- package/WEBUI_TUI_NATIVE_PARITY.json +666 -0
- package/bin/pi-webui.mjs +686 -29
- package/package.json +6 -3
- package/public/app.js +1007 -94
- package/public/index.html +36 -18
- package/public/styles.css +286 -82
- package/start-webui.ps1 +323 -0
- package/start-webui.sh +461 -0
- package/tests/mobile-static.test.mjs +126 -12
- package/tests/native-parity.test.mjs +148 -0
package/bin/pi-webui.mjs
CHANGED
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { createReadStream } from "node:fs";
|
|
4
5
|
import { createServer } from "node:http";
|
|
5
6
|
import { createRequire } from "node:module";
|
|
6
|
-
import { access, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
|
|
7
|
+
import { access, copyFile, mkdir, readFile, readdir, rename, stat, writeFile } from "node:fs/promises";
|
|
7
8
|
import { homedir, networkInterfaces, tmpdir } from "node:os";
|
|
8
9
|
import path from "node:path";
|
|
9
10
|
import { StringDecoder } from "node:string_decoder";
|
|
10
11
|
import { fileURLToPath } from "node:url";
|
|
11
|
-
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { AuthStorage, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
12
13
|
|
|
13
14
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
15
|
const require = createRequire(import.meta.url);
|
|
15
16
|
const packageRoot = path.resolve(__dirname, "..");
|
|
16
17
|
const publicDir = path.join(packageRoot, "public");
|
|
17
18
|
const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8"));
|
|
19
|
+
const nativeParityMatrix = JSON.parse(await readFile(path.join(packageRoot, "WEBUI_TUI_NATIVE_PARITY.json"), "utf8"));
|
|
18
20
|
|
|
19
21
|
const DEFAULT_HOST = "127.0.0.1";
|
|
20
22
|
const DEFAULT_PORT = 31415;
|
|
21
23
|
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
24
|
+
const CODEX_USAGE_TIMEOUT_MS = 15 * 1000;
|
|
25
|
+
const CODEX_TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000;
|
|
26
|
+
const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
|
|
27
|
+
const OPENAI_CODEX_USAGE_ENDPOINT = process.env.PI_WEBUI_CODEX_USAGE_URL || "https://chatgpt.com/backend-api/wham/usage";
|
|
22
28
|
const BODY_LIMIT_BYTES = 1024 * 1024;
|
|
23
29
|
const PROMPT_BODY_LIMIT_BYTES = 24 * 1024 * 1024;
|
|
24
30
|
const UPLOAD_BODY_LIMIT_BYTES = 96 * 1024 * 1024;
|
|
@@ -42,6 +48,7 @@ const SESSION_SELECTOR_LIMIT = 200;
|
|
|
42
48
|
const TREE_SELECTOR_TEXT_LIMIT = 260;
|
|
43
49
|
const NETWORK_REBIND_DELAY_MS = 100;
|
|
44
50
|
const NETWORK_REBIND_FORCE_CLOSE_MS = 750;
|
|
51
|
+
const NATIVE_DOWNLOAD_TOKEN_TTL_MS = 10 * 60 * 1000;
|
|
45
52
|
const AUTO_TAB_TITLE_MAX_LENGTH = 44;
|
|
46
53
|
const AUTO_TAB_TITLE_WORD_LIMIT = 8;
|
|
47
54
|
const AUTO_TAB_TITLE_STOP_WORDS = new Set([
|
|
@@ -84,6 +91,7 @@ const AUTO_TAB_TITLE_STOP_WORDS = new Set([
|
|
|
84
91
|
|
|
85
92
|
const MIME_TYPES = new Map([
|
|
86
93
|
[".html", "text/html; charset=utf-8"],
|
|
94
|
+
[".jsonl", "application/x-ndjson; charset=utf-8"],
|
|
87
95
|
[".js", "text/javascript; charset=utf-8"],
|
|
88
96
|
[".css", "text/css; charset=utf-8"],
|
|
89
97
|
[".svg", "image/svg+xml"],
|
|
@@ -92,29 +100,32 @@ const MIME_TYPES = new Map([
|
|
|
92
100
|
[".webmanifest", "application/manifest+json; charset=utf-8"],
|
|
93
101
|
]);
|
|
94
102
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
103
|
+
function nativeParitySurfaces(matrix = nativeParityMatrix) {
|
|
104
|
+
return Array.isArray(matrix?.surfaces) ? matrix.surfaces : [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function nativeSlashCommandEntries(matrix = nativeParityMatrix) {
|
|
108
|
+
return nativeParitySurfaces(matrix)
|
|
109
|
+
.filter((surface) => surface?.kind === "slash-command")
|
|
110
|
+
.map((surface) => {
|
|
111
|
+
const name = String(surface.command?.name || surface.id || "").replace(/^\//, "").trim();
|
|
112
|
+
return {
|
|
113
|
+
name,
|
|
114
|
+
description: String(surface.command?.description || surface.title || `/${name}`),
|
|
115
|
+
source: "native",
|
|
116
|
+
location: "Pi",
|
|
117
|
+
nativeParity: {
|
|
118
|
+
status: surface.webStatus || "unsupported",
|
|
119
|
+
priority: surface.priority || "P2",
|
|
120
|
+
guards: Array.isArray(surface.guards) ? surface.guards : [],
|
|
121
|
+
sensitive: surface.sensitive === true,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
})
|
|
125
|
+
.filter((command) => command.name);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const NATIVE_SLASH_COMMANDS = nativeSlashCommandEntries();
|
|
118
129
|
const NATIVE_SLASH_COMMAND_NAMES = new Set(NATIVE_SLASH_COMMANDS.map((command) => command.name));
|
|
119
130
|
const OPTIONAL_FEATURE_PACKAGES = new Map([
|
|
120
131
|
["gitWorkflow", "@firstpick/pi-prompts-git-pr"],
|
|
@@ -478,6 +489,71 @@ function rpcSuccess(command, data = {}) {
|
|
|
478
489
|
return { type: "response", command, success: true, data };
|
|
479
490
|
}
|
|
480
491
|
|
|
492
|
+
const nativeDownloadTokens = new Map();
|
|
493
|
+
|
|
494
|
+
function pruneNativeDownloadTokens(now = Date.now()) {
|
|
495
|
+
for (const [token, item] of nativeDownloadTokens) {
|
|
496
|
+
if (!item || item.expiresAt <= now) nativeDownloadTokens.delete(token);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function safeDownloadFileName(name, fallback = "pi-export") {
|
|
501
|
+
const text = String(name || fallback).replace(/[\r\n\\/]+/g, " ").replace(/\s+/g, " ").trim();
|
|
502
|
+
return (text || fallback).slice(0, 180);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function contentDispositionAttachment(fileName) {
|
|
506
|
+
const safeName = safeDownloadFileName(fileName);
|
|
507
|
+
const asciiName = safeName.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
|
|
508
|
+
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(safeName)}`;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function registerNativeDownload(filePath, { fileName, contentType, command = "native" } = {}) {
|
|
512
|
+
pruneNativeDownloadTokens();
|
|
513
|
+
const token = randomUUID();
|
|
514
|
+
const expiresAt = Date.now() + NATIVE_DOWNLOAD_TOKEN_TTL_MS;
|
|
515
|
+
const record = {
|
|
516
|
+
path: filePath,
|
|
517
|
+
fileName: safeDownloadFileName(fileName || path.basename(filePath)),
|
|
518
|
+
contentType: contentType || MIME_TYPES.get(path.extname(filePath).toLowerCase()) || "application/octet-stream",
|
|
519
|
+
command,
|
|
520
|
+
expiresAt,
|
|
521
|
+
};
|
|
522
|
+
nativeDownloadTokens.set(token, record);
|
|
523
|
+
return {
|
|
524
|
+
url: `/api/native-download/${encodeURIComponent(token)}`,
|
|
525
|
+
fileName: record.fileName,
|
|
526
|
+
contentType: record.contentType,
|
|
527
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
async function sendNativeDownload(res, token) {
|
|
532
|
+
pruneNativeDownloadTokens();
|
|
533
|
+
const item = nativeDownloadTokens.get(token);
|
|
534
|
+
if (!item) throw makeHttpError(404, "Download token expired or not found");
|
|
535
|
+
const fileStats = await stat(item.path).catch(() => null);
|
|
536
|
+
if (!fileStats?.isFile()) {
|
|
537
|
+
nativeDownloadTokens.delete(token);
|
|
538
|
+
throw makeHttpError(404, "Download file expired or not found");
|
|
539
|
+
}
|
|
540
|
+
res.writeHead(200, {
|
|
541
|
+
"content-type": item.contentType,
|
|
542
|
+
"content-length": String(fileStats.size),
|
|
543
|
+
"content-disposition": contentDispositionAttachment(item.fileName),
|
|
544
|
+
"cache-control": "no-store",
|
|
545
|
+
"x-content-type-options": "nosniff",
|
|
546
|
+
});
|
|
547
|
+
await new Promise((resolve, reject) => {
|
|
548
|
+
const stream = createReadStream(item.path);
|
|
549
|
+
stream.on("error", reject);
|
|
550
|
+
res.on("error", reject);
|
|
551
|
+
res.on("close", resolve);
|
|
552
|
+
stream.on("end", resolve);
|
|
553
|
+
stream.pipe(res);
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
481
557
|
const ACTION_FEEDBACK_REACTIONS = new Set(["up", "down", "question"]);
|
|
482
558
|
|
|
483
559
|
function trimFeedbackField(value, maxLength) {
|
|
@@ -839,6 +915,302 @@ async function readJsonFileIfExists(filePath) {
|
|
|
839
915
|
}
|
|
840
916
|
}
|
|
841
917
|
|
|
918
|
+
function firstDefined(...values) {
|
|
919
|
+
return values.find((value) => value !== undefined && value !== null);
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function numericValue(value) {
|
|
923
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
924
|
+
const number = Number(value);
|
|
925
|
+
return Number.isFinite(number) ? number : undefined;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function booleanValue(value) {
|
|
929
|
+
return typeof value === "boolean" ? value : undefined;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function isoTimestamp(value) {
|
|
933
|
+
const number = numericValue(value);
|
|
934
|
+
if (number !== undefined) {
|
|
935
|
+
const milliseconds = number > 1e12 ? number : number * 1000;
|
|
936
|
+
const date = new Date(milliseconds);
|
|
937
|
+
return Number.isFinite(date.getTime()) ? date.toISOString() : undefined;
|
|
938
|
+
}
|
|
939
|
+
if (typeof value === "string" && value.trim()) {
|
|
940
|
+
const date = new Date(value);
|
|
941
|
+
return Number.isFinite(date.getTime()) ? date.toISOString() : undefined;
|
|
942
|
+
}
|
|
943
|
+
return undefined;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function decodeJwtPayload(token) {
|
|
947
|
+
try {
|
|
948
|
+
const payload = String(token || "").split(".")[1];
|
|
949
|
+
if (!payload) return null;
|
|
950
|
+
const padded = `${payload}${"=".repeat((4 - (payload.length % 4)) % 4)}`;
|
|
951
|
+
return JSON.parse(Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
|
|
952
|
+
} catch {
|
|
953
|
+
return null;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function codexAccountIdFromAccessToken(accessToken) {
|
|
958
|
+
const payload = decodeJwtPayload(accessToken);
|
|
959
|
+
const auth = payload?.["https://api.openai.com/auth"];
|
|
960
|
+
const accountId = auth?.chatgpt_account_id;
|
|
961
|
+
return typeof accountId === "string" && accountId ? accountId : null;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function normalizeCodexRateLimitWindow(rawWindow) {
|
|
965
|
+
if (!rawWindow || typeof rawWindow !== "object") return null;
|
|
966
|
+
const windowDurationSeconds = firstDefined(
|
|
967
|
+
numericValue(rawWindow.windowDurationSeconds),
|
|
968
|
+
numericValue(rawWindow.limitWindowSeconds),
|
|
969
|
+
numericValue(rawWindow.limit_window_seconds),
|
|
970
|
+
numericValue(rawWindow.windowDurationMins) !== undefined ? numericValue(rawWindow.windowDurationMins) * 60 : undefined,
|
|
971
|
+
);
|
|
972
|
+
const windowDurationMins = firstDefined(
|
|
973
|
+
numericValue(rawWindow.windowDurationMins),
|
|
974
|
+
windowDurationSeconds !== undefined ? windowDurationSeconds / 60 : undefined,
|
|
975
|
+
);
|
|
976
|
+
const normalized = {
|
|
977
|
+
usedPercent: numericValue(firstDefined(rawWindow.usedPercent, rawWindow.used_percent)),
|
|
978
|
+
windowDurationSeconds,
|
|
979
|
+
windowDurationMins,
|
|
980
|
+
resetAfterSeconds: numericValue(firstDefined(rawWindow.resetAfterSeconds, rawWindow.reset_after_seconds)),
|
|
981
|
+
resetsAt: isoTimestamp(firstDefined(rawWindow.resetsAt, rawWindow.resetAt, rawWindow.reset_at)),
|
|
982
|
+
};
|
|
983
|
+
return Object.values(normalized).some((value) => value !== undefined) ? normalized : null;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function normalizeCodexCredits(rawCredits) {
|
|
987
|
+
if (!rawCredits || typeof rawCredits !== "object") return null;
|
|
988
|
+
return {
|
|
989
|
+
hasCredits: booleanValue(firstDefined(rawCredits.hasCredits, rawCredits.has_credits)),
|
|
990
|
+
unlimited: booleanValue(rawCredits.unlimited),
|
|
991
|
+
balance: firstDefined(rawCredits.balance),
|
|
992
|
+
approxLocalMessages: firstDefined(rawCredits.approxLocalMessages, rawCredits.approx_local_messages),
|
|
993
|
+
approxCloudMessages: firstDefined(rawCredits.approxCloudMessages, rawCredits.approx_cloud_messages),
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function normalizeCodexRateLimitDetails(rawDetails) {
|
|
998
|
+
if (!rawDetails || typeof rawDetails !== "object") return { primary: null, secondary: null };
|
|
999
|
+
return {
|
|
1000
|
+
allowed: booleanValue(rawDetails.allowed),
|
|
1001
|
+
limitReached: booleanValue(firstDefined(rawDetails.limitReached, rawDetails.limit_reached)),
|
|
1002
|
+
primary: normalizeCodexRateLimitWindow(firstDefined(rawDetails.primary, rawDetails.primaryWindow, rawDetails.primary_window)),
|
|
1003
|
+
secondary: normalizeCodexRateLimitWindow(firstDefined(rawDetails.secondary, rawDetails.secondaryWindow, rawDetails.secondary_window)),
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function normalizeCodexRateLimitReachedType(rawType) {
|
|
1008
|
+
if (typeof rawType === "string" && rawType) return rawType;
|
|
1009
|
+
if (rawType && typeof rawType === "object") {
|
|
1010
|
+
const value = firstDefined(rawType.type, rawType.kind);
|
|
1011
|
+
return typeof value === "string" && value ? value : null;
|
|
1012
|
+
}
|
|
1013
|
+
return null;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function makeCodexUsageSnapshot({ limitId, limitName, rateLimit, credits, planType, rateLimitReachedType }) {
|
|
1017
|
+
const details = normalizeCodexRateLimitDetails(rateLimit);
|
|
1018
|
+
return {
|
|
1019
|
+
limitId: limitId || null,
|
|
1020
|
+
limitName: limitName || null,
|
|
1021
|
+
primary: details.primary,
|
|
1022
|
+
secondary: details.secondary,
|
|
1023
|
+
allowed: details.allowed,
|
|
1024
|
+
limitReached: details.limitReached,
|
|
1025
|
+
credits: normalizeCodexCredits(credits),
|
|
1026
|
+
planType: planType || null,
|
|
1027
|
+
rateLimitReachedType: rateLimitReachedType || null,
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function normalizeCodexUsagePayload(rawPayload) {
|
|
1032
|
+
const payload = rawPayload && typeof rawPayload === "object" ? rawPayload : {};
|
|
1033
|
+
const planType = firstDefined(payload.planType, payload.plan_type, null);
|
|
1034
|
+
const rateLimitReachedType = normalizeCodexRateLimitReachedType(firstDefined(payload.rateLimitReachedType, payload.rate_limit_reached_type));
|
|
1035
|
+
const snapshotsByKey = new Map();
|
|
1036
|
+
const addSnapshot = (snapshot) => {
|
|
1037
|
+
if (!snapshot) return;
|
|
1038
|
+
const key = snapshot.limitId || snapshot.limitName || `snapshot-${snapshotsByKey.size + 1}`;
|
|
1039
|
+
if (!snapshotsByKey.has(key)) snapshotsByKey.set(key, snapshot);
|
|
1040
|
+
};
|
|
1041
|
+
|
|
1042
|
+
const directRateLimits = firstDefined(payload.rateLimits, payload.rate_limits);
|
|
1043
|
+
if (directRateLimits && typeof directRateLimits === "object" && (directRateLimits.primary || directRateLimits.primary_window || directRateLimits.primaryWindow)) {
|
|
1044
|
+
addSnapshot(makeCodexUsageSnapshot({
|
|
1045
|
+
limitId: firstDefined(directRateLimits.limitId, directRateLimits.limit_id, "codex"),
|
|
1046
|
+
limitName: firstDefined(directRateLimits.limitName, directRateLimits.limit_name),
|
|
1047
|
+
rateLimit: directRateLimits,
|
|
1048
|
+
credits: firstDefined(directRateLimits.credits, payload.credits),
|
|
1049
|
+
planType: firstDefined(directRateLimits.planType, directRateLimits.plan_type, planType),
|
|
1050
|
+
rateLimitReachedType: firstDefined(directRateLimits.rateLimitReachedType, directRateLimits.rate_limit_reached_type, rateLimitReachedType),
|
|
1051
|
+
}));
|
|
1052
|
+
} else {
|
|
1053
|
+
addSnapshot(makeCodexUsageSnapshot({
|
|
1054
|
+
limitId: "codex",
|
|
1055
|
+
rateLimit: firstDefined(payload.rateLimit, payload.rate_limit),
|
|
1056
|
+
credits: payload.credits,
|
|
1057
|
+
planType,
|
|
1058
|
+
rateLimitReachedType,
|
|
1059
|
+
}));
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
const byLimitId = firstDefined(payload.rateLimitsByLimitId, payload.rate_limits_by_limit_id);
|
|
1063
|
+
if (byLimitId && typeof byLimitId === "object" && !Array.isArray(byLimitId)) {
|
|
1064
|
+
for (const [limitId, rawSnapshot] of Object.entries(byLimitId)) {
|
|
1065
|
+
if (!rawSnapshot || typeof rawSnapshot !== "object") continue;
|
|
1066
|
+
addSnapshot(makeCodexUsageSnapshot({
|
|
1067
|
+
limitId: firstDefined(rawSnapshot.limitId, rawSnapshot.limit_id, limitId),
|
|
1068
|
+
limitName: firstDefined(rawSnapshot.limitName, rawSnapshot.limit_name),
|
|
1069
|
+
rateLimit: rawSnapshot,
|
|
1070
|
+
credits: rawSnapshot.credits,
|
|
1071
|
+
planType: firstDefined(rawSnapshot.planType, rawSnapshot.plan_type, planType),
|
|
1072
|
+
rateLimitReachedType: firstDefined(rawSnapshot.rateLimitReachedType, rawSnapshot.rate_limit_reached_type),
|
|
1073
|
+
}));
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
const additionalRateLimits = firstDefined(payload.additionalRateLimits, payload.additional_rate_limits);
|
|
1078
|
+
if (Array.isArray(additionalRateLimits)) {
|
|
1079
|
+
for (const item of additionalRateLimits) {
|
|
1080
|
+
if (!item || typeof item !== "object") continue;
|
|
1081
|
+
addSnapshot(makeCodexUsageSnapshot({
|
|
1082
|
+
limitId: firstDefined(item.limitId, item.limit_id, item.meteredFeature, item.metered_feature, item.limitName, item.limit_name),
|
|
1083
|
+
limitName: firstDefined(item.limitName, item.limit_name),
|
|
1084
|
+
rateLimit: firstDefined(item.rateLimit, item.rate_limit),
|
|
1085
|
+
credits: item.credits,
|
|
1086
|
+
planType,
|
|
1087
|
+
}));
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
const snapshots = [...snapshotsByKey.values()];
|
|
1092
|
+
const selected = snapshots.find((snapshot) => snapshot.limitId === "codex") || snapshots[0] || null;
|
|
1093
|
+
const rateLimitsByLimitId = Object.fromEntries(snapshots.filter((snapshot) => snapshot.limitId).map((snapshot) => [snapshot.limitId, snapshot]));
|
|
1094
|
+
return {
|
|
1095
|
+
planType: planType || selected?.planType || null,
|
|
1096
|
+
rateLimitReachedType: rateLimitReachedType || selected?.rateLimitReachedType || null,
|
|
1097
|
+
credits: normalizeCodexCredits(payload.credits) || selected?.credits || null,
|
|
1098
|
+
selected,
|
|
1099
|
+
snapshots,
|
|
1100
|
+
rateLimits: selected,
|
|
1101
|
+
rateLimitsByLimitId,
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
async function getOpenAICodexUsageCredentials({ forceRefresh = false } = {}) {
|
|
1106
|
+
const authStorage = AuthStorage.create();
|
|
1107
|
+
const stored = authStorage.get(OPENAI_CODEX_PROVIDER_ID);
|
|
1108
|
+
const storedExpires = numericValue(stored?.expires);
|
|
1109
|
+
const shouldRefresh = stored?.type === "oauth" && (forceRefresh || storedExpires === undefined || Date.now() + CODEX_TOKEN_REFRESH_SKEW_MS >= storedExpires);
|
|
1110
|
+
let accessToken;
|
|
1111
|
+
let refreshed = false;
|
|
1112
|
+
|
|
1113
|
+
if (shouldRefresh) {
|
|
1114
|
+
try {
|
|
1115
|
+
const refreshResult = await authStorage.refreshOAuthTokenWithLock(OPENAI_CODEX_PROVIDER_ID);
|
|
1116
|
+
if (refreshResult?.apiKey) {
|
|
1117
|
+
accessToken = refreshResult.apiKey;
|
|
1118
|
+
refreshed = forceRefresh || refreshResult.newCredentials?.access !== stored?.access;
|
|
1119
|
+
}
|
|
1120
|
+
} catch (error) {
|
|
1121
|
+
if (forceRefresh || !storedExpires || Date.now() >= storedExpires) {
|
|
1122
|
+
throw makeHttpError(401, "OpenAI Codex OAuth token refresh failed. Run /login and choose ChatGPT Plus/Pro (Codex Subscription) to re-authenticate.");
|
|
1123
|
+
}
|
|
1124
|
+
console.warn(`OpenAI Codex token refresh warning: ${sanitizeError(error)}`);
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
if (!accessToken) {
|
|
1129
|
+
accessToken = await authStorage.getApiKey(OPENAI_CODEX_PROVIDER_ID, { includeFallback: false });
|
|
1130
|
+
}
|
|
1131
|
+
if (!accessToken) {
|
|
1132
|
+
const status = authStorage.getAuthStatus(OPENAI_CODEX_PROVIDER_ID);
|
|
1133
|
+
if (status.configured) throw makeHttpError(401, "OpenAI Codex OAuth token is expired or unavailable. Run /login to refresh credentials.");
|
|
1134
|
+
throw makeHttpError(401, "OpenAI Codex OAuth is not configured. Run /login and choose ChatGPT Plus/Pro (Codex Subscription).");
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const latest = authStorage.get(OPENAI_CODEX_PROVIDER_ID) || stored || {};
|
|
1138
|
+
const accountId = latest.accountId || codexAccountIdFromAccessToken(accessToken);
|
|
1139
|
+
if (!accountId) {
|
|
1140
|
+
throw makeHttpError(401, "OpenAI Codex account id is unavailable. Run /login and choose ChatGPT Plus/Pro (Codex Subscription) again.");
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
return {
|
|
1144
|
+
accessToken,
|
|
1145
|
+
accountId,
|
|
1146
|
+
refreshed,
|
|
1147
|
+
source: latest.type === "oauth" ? "stored-oauth" : "api-key",
|
|
1148
|
+
expiresAt: numericValue(latest.expires) ? new Date(numericValue(latest.expires)).toISOString() : undefined,
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
async function fetchOpenAICodexUsagePayload(credentials) {
|
|
1153
|
+
const controller = new AbortController();
|
|
1154
|
+
const timer = setTimeout(() => controller.abort(), CODEX_USAGE_TIMEOUT_MS);
|
|
1155
|
+
timer.unref?.();
|
|
1156
|
+
try {
|
|
1157
|
+
const response = await fetch(OPENAI_CODEX_USAGE_ENDPOINT, {
|
|
1158
|
+
method: "GET",
|
|
1159
|
+
headers: {
|
|
1160
|
+
accept: "application/json",
|
|
1161
|
+
authorization: `Bearer ${credentials.accessToken}`,
|
|
1162
|
+
"chatgpt-account-id": credentials.accountId,
|
|
1163
|
+
originator: "pi-webui",
|
|
1164
|
+
},
|
|
1165
|
+
signal: controller.signal,
|
|
1166
|
+
});
|
|
1167
|
+
const text = await response.text().catch(() => "");
|
|
1168
|
+
if (!response.ok) {
|
|
1169
|
+
const error = makeHttpError(response.status === 401 ? 401 : 502, `OpenAI Codex usage request failed (${response.status}${response.statusText ? ` ${response.statusText}` : ""})`);
|
|
1170
|
+
error.openaiStatus = response.status;
|
|
1171
|
+
throw error;
|
|
1172
|
+
}
|
|
1173
|
+
try {
|
|
1174
|
+
return JSON.parse(text || "{}");
|
|
1175
|
+
} catch {
|
|
1176
|
+
throw makeHttpError(502, "OpenAI Codex usage response was not valid JSON");
|
|
1177
|
+
}
|
|
1178
|
+
} catch (error) {
|
|
1179
|
+
if (error?.name === "AbortError") throw makeHttpError(504, "OpenAI Codex usage request timed out");
|
|
1180
|
+
throw error;
|
|
1181
|
+
} finally {
|
|
1182
|
+
clearTimeout(timer);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
async function getOpenAICodexUsageStatus({ forceRefresh = false } = {}) {
|
|
1187
|
+
let credentials = await getOpenAICodexUsageCredentials({ forceRefresh });
|
|
1188
|
+
let rawPayload;
|
|
1189
|
+
try {
|
|
1190
|
+
rawPayload = await fetchOpenAICodexUsagePayload(credentials);
|
|
1191
|
+
} catch (error) {
|
|
1192
|
+
if (error?.openaiStatus === 401 && !credentials.refreshed) {
|
|
1193
|
+
credentials = await getOpenAICodexUsageCredentials({ forceRefresh: true });
|
|
1194
|
+
rawPayload = await fetchOpenAICodexUsagePayload(credentials);
|
|
1195
|
+
} else {
|
|
1196
|
+
throw error;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
return {
|
|
1201
|
+
available: true,
|
|
1202
|
+
providerId: OPENAI_CODEX_PROVIDER_ID,
|
|
1203
|
+
source: "chatgpt.com",
|
|
1204
|
+
fetchedAt: new Date().toISOString(),
|
|
1205
|
+
auth: {
|
|
1206
|
+
source: credentials.source,
|
|
1207
|
+
expiresAt: credentials.expiresAt,
|
|
1208
|
+
refreshed: credentials.refreshed,
|
|
1209
|
+
},
|
|
1210
|
+
...normalizeCodexUsagePayload(rawPayload),
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
|
|
842
1214
|
async function configuredScopedModelPatterns(cwd = options.cwd) {
|
|
843
1215
|
const cliPatterns = parseCliScopedModelPatterns();
|
|
844
1216
|
if (cliPatterns !== undefined) return { patterns: cliPatterns, source: "cli" };
|
|
@@ -898,6 +1270,40 @@ async function getScopedModelData(tab) {
|
|
|
898
1270
|
return { models: resolveScopedModelsFromPatterns(patterns, response.data?.models || []), patterns, source, rpcRunning: response.rpcRunning !== false };
|
|
899
1271
|
}
|
|
900
1272
|
|
|
1273
|
+
function modelKey(model) {
|
|
1274
|
+
return model?.provider && model?.id ? `${model.provider}/${model.id}` : "";
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
async function cycleTabModel(tab, direction = "forward") {
|
|
1278
|
+
const availableResponse = await tab.rpc.send({ type: "get_available_models" });
|
|
1279
|
+
if (availableResponse.success === false) return availableResponse;
|
|
1280
|
+
const allModels = Array.isArray(availableResponse.data?.models) ? availableResponse.data.models : [];
|
|
1281
|
+
const { patterns, source } = await configuredScopedModelPatterns(tab.cwd);
|
|
1282
|
+
const scopedModels = patterns.length ? resolveScopedModelsFromPatterns(patterns, allModels) : [];
|
|
1283
|
+
const candidates = scopedModels.length ? scopedModels : allModels;
|
|
1284
|
+
if (!candidates.length) throw makeHttpError(400, "No models are available to cycle.");
|
|
1285
|
+
|
|
1286
|
+
const state = await currentSessionState(tab).catch(() => tab.lastState || {});
|
|
1287
|
+
const currentKey = modelKey(state.model);
|
|
1288
|
+
const currentIndex = candidates.findIndex((model) => modelKey(model) === currentKey);
|
|
1289
|
+
const backwards = direction === "backward" || direction === "previous" || direction === "prev";
|
|
1290
|
+
let nextIndex;
|
|
1291
|
+
if (backwards) nextIndex = currentIndex > 0 ? currentIndex - 1 : candidates.length - 1;
|
|
1292
|
+
else nextIndex = currentIndex >= 0 && currentIndex < candidates.length - 1 ? currentIndex + 1 : 0;
|
|
1293
|
+
const nextModel = candidates[nextIndex];
|
|
1294
|
+
const response = await tab.rpc.send({ type: "set_model", provider: nextModel.provider, modelId: nextModel.id });
|
|
1295
|
+
if (response.success === false) return response;
|
|
1296
|
+
return rpcSuccess("cycle_model", {
|
|
1297
|
+
model: response.data || nextModel,
|
|
1298
|
+
direction: backwards ? "backward" : "forward",
|
|
1299
|
+
scoped: scopedModels.length > 0,
|
|
1300
|
+
scopeSource: scopedModels.length > 0 ? source : "all",
|
|
1301
|
+
index: nextIndex,
|
|
1302
|
+
count: candidates.length,
|
|
1303
|
+
tab: tabMeta(tab),
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
|
|
901
1307
|
function pathPickerRoots(activeCwd, viewedCwd) {
|
|
902
1308
|
const home = process.env.HOME || process.env.USERPROFILE;
|
|
903
1309
|
return uniquePathItems([
|
|
@@ -1518,6 +1924,13 @@ function commandFromPost(pathname, body) {
|
|
|
1518
1924
|
}
|
|
1519
1925
|
case "/api/abort":
|
|
1520
1926
|
return { type: "abort" };
|
|
1927
|
+
case "/api/bash": {
|
|
1928
|
+
const command = String(body.command || "").trim();
|
|
1929
|
+
if (!command) throw new Error("command is required");
|
|
1930
|
+
return { type: "bash", command, excludeFromContext: body.excludeFromContext === true };
|
|
1931
|
+
}
|
|
1932
|
+
case "/api/abort-bash":
|
|
1933
|
+
return { type: "abort_bash" };
|
|
1521
1934
|
case "/api/new-session":
|
|
1522
1935
|
return body.parentSession ? { type: "new_session", parentSession: String(body.parentSession) } : { type: "new_session" };
|
|
1523
1936
|
case "/api/model": {
|
|
@@ -1533,6 +1946,8 @@ function commandFromPost(pathname, body) {
|
|
|
1533
1946
|
}
|
|
1534
1947
|
return { type: "set_thinking_level", level };
|
|
1535
1948
|
}
|
|
1949
|
+
case "/api/thinking-cycle":
|
|
1950
|
+
return { type: "cycle_thinking_level" };
|
|
1536
1951
|
case "/api/steering-mode": {
|
|
1537
1952
|
const mode = String(body.mode || "").trim();
|
|
1538
1953
|
if (!["all", "one-at-a-time"].includes(mode)) throw new Error("Invalid steering mode");
|
|
@@ -1713,6 +2128,77 @@ function pendingExtensionUiMap(tab) {
|
|
|
1713
2128
|
return tab.pendingExtensionUiRequests;
|
|
1714
2129
|
}
|
|
1715
2130
|
|
|
2131
|
+
function bashQueueForTab(tab) {
|
|
2132
|
+
if (!tab.bashQueue) tab.bashQueue = [];
|
|
2133
|
+
return tab.bashQueue;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
function settleBashQueueItem(item, kind, value) {
|
|
2137
|
+
if (!item || item.settled) return;
|
|
2138
|
+
item.settled = true;
|
|
2139
|
+
if (kind === "resolve") item.resolve(value);
|
|
2140
|
+
else item.reject(value);
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
function bashQueueEvent(tab) {
|
|
2144
|
+
const queue = bashQueueForTab(tab);
|
|
2145
|
+
const activeItem = tab.bashQueueDraining ? queue[0] : null;
|
|
2146
|
+
return {
|
|
2147
|
+
type: "webui_bash_queue_update",
|
|
2148
|
+
tabId: tab.id,
|
|
2149
|
+
tabTitle: tab.title,
|
|
2150
|
+
activeCommand: activeItem?.command?.command,
|
|
2151
|
+
queueLength: Math.max(0, queue.length - (activeItem ? 1 : 0)),
|
|
2152
|
+
tabActivity: tabActivitySnapshot(tab),
|
|
2153
|
+
};
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
function broadcastBashQueueUpdate(tab) {
|
|
2157
|
+
if (tab?.sseClients) broadcastTabEvent(tab, bashQueueEvent(tab));
|
|
2158
|
+
}
|
|
2159
|
+
|
|
2160
|
+
function rejectTabBashQueue(tab, error) {
|
|
2161
|
+
const queue = tab?.bashQueue;
|
|
2162
|
+
if (!queue?.length) return;
|
|
2163
|
+
for (const item of queue.splice(0)) settleBashQueueItem(item, "reject", error);
|
|
2164
|
+
tab.bashQueueDraining = false;
|
|
2165
|
+
broadcastBashQueueUpdate(tab);
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
async function drainTabBashQueue(tab) {
|
|
2169
|
+
if (tab.bashQueueDraining) return;
|
|
2170
|
+
const queue = bashQueueForTab(tab);
|
|
2171
|
+
tab.bashQueueDraining = true;
|
|
2172
|
+
try {
|
|
2173
|
+
while (queue.length > 0) {
|
|
2174
|
+
const item = queue[0];
|
|
2175
|
+
broadcastBashQueueUpdate(tab);
|
|
2176
|
+
try {
|
|
2177
|
+
const response = await tab.rpc.send(item.command);
|
|
2178
|
+
settleBashQueueItem(item, "resolve", response);
|
|
2179
|
+
} catch (error) {
|
|
2180
|
+
settleBashQueueItem(item, "reject", error);
|
|
2181
|
+
} finally {
|
|
2182
|
+
const index = queue.indexOf(item);
|
|
2183
|
+
if (index >= 0) queue.splice(index, 1);
|
|
2184
|
+
broadcastBashQueueUpdate(tab);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
} finally {
|
|
2188
|
+
tab.bashQueueDraining = false;
|
|
2189
|
+
broadcastBashQueueUpdate(tab);
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
function sendQueuedBashCommand(tab, command) {
|
|
2194
|
+
return new Promise((resolve, reject) => {
|
|
2195
|
+
const queue = bashQueueForTab(tab);
|
|
2196
|
+
queue.push({ id: randomUUID(), command, resolve, reject, settled: false, queuedAt: new Date().toISOString() });
|
|
2197
|
+
broadcastBashQueueUpdate(tab);
|
|
2198
|
+
void drainTabBashQueue(tab);
|
|
2199
|
+
});
|
|
2200
|
+
}
|
|
2201
|
+
|
|
1716
2202
|
function isPendingExtensionUiRequest(event) {
|
|
1717
2203
|
return event?.type === "extension_ui_request" && EXTENSION_UI_BLOCKING_METHODS.has(event.method) && event.id;
|
|
1718
2204
|
}
|
|
@@ -1968,6 +2454,8 @@ async function createTab({ id: requestedId, index, title, titleSource, conversat
|
|
|
1968
2454
|
lastState: null,
|
|
1969
2455
|
activity: createTabActivity(createdAt),
|
|
1970
2456
|
pendingExtensionUiRequests: new Map(),
|
|
2457
|
+
bashQueue: [],
|
|
2458
|
+
bashQueueDraining: false,
|
|
1971
2459
|
rpc: undefined,
|
|
1972
2460
|
rpcUnsubscribe: undefined,
|
|
1973
2461
|
sseClients: new Set(),
|
|
@@ -2127,6 +2615,7 @@ async function updateTabCwd(id, cwd) {
|
|
|
2127
2615
|
const oldRpc = tab.rpc;
|
|
2128
2616
|
tab.rpcUnsubscribe?.();
|
|
2129
2617
|
tab.rpcUnsubscribe = undefined;
|
|
2618
|
+
rejectTabBashQueue(tab, new Error("Pi tab is restarting; queued bash commands were cancelled"));
|
|
2130
2619
|
oldRpc.stop();
|
|
2131
2620
|
|
|
2132
2621
|
tab.cwd = nextCwd;
|
|
@@ -2162,6 +2651,7 @@ async function restartTabRpc(tab, reason = "reload") {
|
|
|
2162
2651
|
const oldRpc = tab.rpc;
|
|
2163
2652
|
tab.rpcUnsubscribe?.();
|
|
2164
2653
|
tab.rpcUnsubscribe = undefined;
|
|
2654
|
+
rejectTabBashQueue(tab, new Error("Pi tab is reloading; queued bash commands were cancelled"));
|
|
2165
2655
|
oldRpc.stop();
|
|
2166
2656
|
|
|
2167
2657
|
resetTabActivity(tab);
|
|
@@ -2484,6 +2974,102 @@ function formatSessionOutput(tab, state, stats) {
|
|
|
2484
2974
|
].filter(Boolean).join("\n");
|
|
2485
2975
|
}
|
|
2486
2976
|
|
|
2977
|
+
function nativeExportBaseName(tab, state = {}) {
|
|
2978
|
+
const source = state.sessionName || tab?.title || state.sessionId || "pi-session";
|
|
2979
|
+
const date = new Date().toISOString().replace(/[:.]/g, "-");
|
|
2980
|
+
return safeDownloadFileName(`${source}-${date}`, "pi-session").replace(/\s+/g, "-");
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
async function nativeExportTempPath(tab, state = {}, ext = ".html") {
|
|
2984
|
+
const dir = path.join(tmpdir(), "pi-webui-native-exports");
|
|
2985
|
+
await mkdir(dir, { recursive: true });
|
|
2986
|
+
return path.join(dir, `${nativeExportBaseName(tab, state)}-${randomUUID()}${ext}`);
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
function exportTargetExtension(targetPath) {
|
|
2990
|
+
return path.extname(targetPath).toLowerCase();
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
async function exportTargetExists(targetPath) {
|
|
2994
|
+
const targetStats = await stat(targetPath).catch(() => null);
|
|
2995
|
+
return !!targetStats;
|
|
2996
|
+
}
|
|
2997
|
+
|
|
2998
|
+
async function handleNativeExportCommand(tab, args, req) {
|
|
2999
|
+
const explicitTarget = String(args || "").trim();
|
|
3000
|
+
const state = await currentSessionState(tab).catch(() => tab.lastState || {});
|
|
3001
|
+
|
|
3002
|
+
if (!explicitTarget) {
|
|
3003
|
+
const outputPath = await nativeExportTempPath(tab, state, ".html");
|
|
3004
|
+
const response = await tab.rpc.send({ type: "export_html", outputPath });
|
|
3005
|
+
if (response.success === false) return response;
|
|
3006
|
+
const exportedPath = response.data?.path || outputPath;
|
|
3007
|
+
const download = registerNativeDownload(exportedPath, {
|
|
3008
|
+
command: "export",
|
|
3009
|
+
fileName: `${nativeExportBaseName(tab, state)}.html`,
|
|
3010
|
+
contentType: MIME_TYPES.get(".html"),
|
|
3011
|
+
});
|
|
3012
|
+
return nativeCommandResponse("export", {
|
|
3013
|
+
status: "succeeded",
|
|
3014
|
+
level: "info",
|
|
3015
|
+
message: `Exported current session to HTML.\nDownload: ${download.fileName}\nLink expires: ${download.expiresAt}`,
|
|
3016
|
+
download,
|
|
3017
|
+
result: response.data,
|
|
3018
|
+
});
|
|
3019
|
+
}
|
|
3020
|
+
|
|
3021
|
+
if (!isLocalAddress(req?.socket?.remoteAddress)) {
|
|
3022
|
+
return nativeCommandResponse("export", {
|
|
3023
|
+
status: "unavailable",
|
|
3024
|
+
level: "warn",
|
|
3025
|
+
reason: "Server-side export paths are only allowed from localhost.",
|
|
3026
|
+
safetyRestriction: "Explicit /export paths write files on the server and are blocked for non-local browser clients.",
|
|
3027
|
+
message: "Explicit /export paths are only allowed from localhost. Run /export without a path for a browser download, or retry from the local machine.",
|
|
3028
|
+
});
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
const targetPath = resolveTabPath(tab, explicitTarget);
|
|
3032
|
+
const ext = exportTargetExtension(targetPath);
|
|
3033
|
+
if (![".html", ".jsonl"].includes(ext)) throw makeHttpError(400, "Usage: /export [path.html|path.jsonl]");
|
|
3034
|
+
if (await exportTargetExists(targetPath)) {
|
|
3035
|
+
return nativeCommandResponse("export", {
|
|
3036
|
+
status: "confirmation_required",
|
|
3037
|
+
level: "warn",
|
|
3038
|
+
reason: `Export target already exists: ${targetPath}`,
|
|
3039
|
+
safetyRestriction: "Overwrites require an explicit confirmation flow, which is not available from plain slash-command text yet.",
|
|
3040
|
+
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.`,
|
|
3041
|
+
});
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
3045
|
+
|
|
3046
|
+
if (ext === ".html") {
|
|
3047
|
+
const response = await tab.rpc.send({ type: "export_html", outputPath: targetPath });
|
|
3048
|
+
if (response.success === false) return response;
|
|
3049
|
+
return nativeCommandResponse("export", {
|
|
3050
|
+
status: "succeeded",
|
|
3051
|
+
level: "info",
|
|
3052
|
+
message: `Exported current session HTML to server path:\n${response.data?.path || targetPath}`,
|
|
3053
|
+
serverPath: response.data?.path || targetPath,
|
|
3054
|
+
result: response.data,
|
|
3055
|
+
});
|
|
3056
|
+
}
|
|
3057
|
+
|
|
3058
|
+
requirePersistentSessions();
|
|
3059
|
+
const sessionFile = state.sessionFile || tabRestorableSessionFile(tab);
|
|
3060
|
+
if (!sessionFile) throw makeHttpError(400, "No persisted session file is available for JSONL export.");
|
|
3061
|
+
const sourceStats = await stat(sessionFile).catch(() => null);
|
|
3062
|
+
if (!sourceStats?.isFile()) throw makeHttpError(404, `Current session file not found: ${sessionFile}`);
|
|
3063
|
+
await copyFile(sessionFile, targetPath);
|
|
3064
|
+
return nativeCommandResponse("export", {
|
|
3065
|
+
status: "succeeded",
|
|
3066
|
+
level: "info",
|
|
3067
|
+
message: `Copied current session JSONL to server path:\n${targetPath}`,
|
|
3068
|
+
serverPath: targetPath,
|
|
3069
|
+
result: { path: targetPath, sourcePath: sessionFile },
|
|
3070
|
+
});
|
|
3071
|
+
}
|
|
3072
|
+
|
|
2487
3073
|
function webuiHotkeysOutput() {
|
|
2488
3074
|
return [
|
|
2489
3075
|
"Web UI hotkeys:",
|
|
@@ -2496,7 +3082,46 @@ function webuiHotkeysOutput() {
|
|
|
2496
3082
|
].join("\n");
|
|
2497
3083
|
}
|
|
2498
3084
|
|
|
2499
|
-
|
|
3085
|
+
function nativeParitySurfaceForCommand(name) {
|
|
3086
|
+
return nativeParitySurfaces().find((surface) => surface.kind === "slash-command" && surface.command?.name === name) || null;
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
function nativeCommandResponse(command, data = {}) {
|
|
3090
|
+
const surface = nativeParitySurfaceForCommand(command);
|
|
3091
|
+
const status = data.status || (surface?.webStatus === "implemented" ? "succeeded" : surface?.webStatus === "degraded" ? "degraded" : "unavailable");
|
|
3092
|
+
const level = data.level || (status === "succeeded" ? "info" : "warn");
|
|
3093
|
+
return rpcSuccess("native_slash_command", {
|
|
3094
|
+
command,
|
|
3095
|
+
status,
|
|
3096
|
+
level,
|
|
3097
|
+
nativeParity: surface ? {
|
|
3098
|
+
webStatus: surface.webStatus,
|
|
3099
|
+
priority: surface.priority,
|
|
3100
|
+
sensitive: surface.sensitive === true,
|
|
3101
|
+
guards: Array.isArray(surface.guards) ? surface.guards : [],
|
|
3102
|
+
} : undefined,
|
|
3103
|
+
...data,
|
|
3104
|
+
});
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
function nativeCommandUnavailable(command, details = {}) {
|
|
3108
|
+
const surface = nativeParitySurfaceForCommand(command);
|
|
3109
|
+
const guards = Array.isArray(surface?.guards) ? surface.guards.filter((guard) => guard !== "none") : [];
|
|
3110
|
+
const reason = details.reason || surface?.currentBehavior || "This native Pi TUI command is not implemented in the Web UI yet.";
|
|
3111
|
+
const nextActions = details.nextActions || [
|
|
3112
|
+
surface?.targetBehavior ? `Planned Web UI behavior: ${surface.targetBehavior}` : "Use the Pi TUI for this command until Web UI parity is implemented.",
|
|
3113
|
+
];
|
|
3114
|
+
return nativeCommandResponse(command, {
|
|
3115
|
+
status: "unavailable",
|
|
3116
|
+
level: "warn",
|
|
3117
|
+
reason,
|
|
3118
|
+
safetyRestriction: details.safetyRestriction || (guards.length ? `Guarded by: ${guards.join(", ")}.` : undefined),
|
|
3119
|
+
nextActions,
|
|
3120
|
+
message: details.message || [`/${command} is not available in the Web UI yet.`, reason, ...nextActions].filter(Boolean).join("\n"),
|
|
3121
|
+
});
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
async function handleNativeSlashCommand(tab, body, req) {
|
|
2500
3125
|
const parsed = parseSlashCommand(body.message);
|
|
2501
3126
|
if (!parsed) return undefined;
|
|
2502
3127
|
|
|
@@ -2530,6 +3155,9 @@ async function handleNativeSlashCommand(tab, body) {
|
|
|
2530
3155
|
if (state.success === false) return state;
|
|
2531
3156
|
return rpcSuccess("native_slash_command", { command: "session", message: formatSessionOutput(tab, state.data || {}, stats.success === false ? null : stats.data) });
|
|
2532
3157
|
}
|
|
3158
|
+
case "export": {
|
|
3159
|
+
return handleNativeExportCommand(tab, parsed.args, req);
|
|
3160
|
+
}
|
|
2533
3161
|
case "copy": {
|
|
2534
3162
|
const response = await tab.rpc.send({ type: "get_last_assistant_text" });
|
|
2535
3163
|
if (response.success === false) return response;
|
|
@@ -2545,7 +3173,7 @@ async function handleNativeSlashCommand(tab, body) {
|
|
|
2545
3173
|
return response.success === false ? response : rpcSuccess("native_slash_command", { command: "clone", message: response.data?.message || "Cloned the current session.", result: response.data?.result });
|
|
2546
3174
|
}
|
|
2547
3175
|
default:
|
|
2548
|
-
|
|
3176
|
+
return nativeCommandUnavailable(parsed.name);
|
|
2549
3177
|
}
|
|
2550
3178
|
}
|
|
2551
3179
|
|
|
@@ -2569,6 +3197,7 @@ async function closeTab(id) {
|
|
|
2569
3197
|
}
|
|
2570
3198
|
tab.sseClients.clear();
|
|
2571
3199
|
tab.rpcUnsubscribe?.();
|
|
3200
|
+
rejectTabBashQueue(tab, new Error("Pi tab closed; queued bash commands were cancelled"));
|
|
2572
3201
|
tab.rpc.stop();
|
|
2573
3202
|
tabs.delete(id);
|
|
2574
3203
|
return tab;
|
|
@@ -2949,11 +3578,31 @@ const server = createServer(async (req, res) => {
|
|
|
2949
3578
|
return;
|
|
2950
3579
|
}
|
|
2951
3580
|
|
|
3581
|
+
if (url.pathname === "/api/native-parity" && req.method === "GET") {
|
|
3582
|
+
sendJson(res, 200, { ok: true, data: nativeParityMatrix });
|
|
3583
|
+
return;
|
|
3584
|
+
}
|
|
3585
|
+
|
|
3586
|
+
if (url.pathname.startsWith("/api/native-download/") && req.method === "GET") {
|
|
3587
|
+
await sendNativeDownload(res, decodeURIComponent(url.pathname.slice("/api/native-download/".length)));
|
|
3588
|
+
return;
|
|
3589
|
+
}
|
|
3590
|
+
|
|
2952
3591
|
if (url.pathname === "/api/themes" && req.method === "GET") {
|
|
2953
3592
|
sendJson(res, 200, { ok: true, data: await readBundledThemes() });
|
|
2954
3593
|
return;
|
|
2955
3594
|
}
|
|
2956
3595
|
|
|
3596
|
+
if (url.pathname === "/api/codex-usage" && req.method === "GET") {
|
|
3597
|
+
try {
|
|
3598
|
+
const forceRefresh = ["1", "true", "yes"].includes(String(url.searchParams.get("refresh") || "").toLowerCase());
|
|
3599
|
+
sendJson(res, 200, { ok: true, data: await getOpenAICodexUsageStatus({ forceRefresh }) });
|
|
3600
|
+
} catch (error) {
|
|
3601
|
+
sendJson(res, error?.statusCode || 500, { ok: false, error: error?.message || "Failed to read OpenAI Codex usage" });
|
|
3602
|
+
}
|
|
3603
|
+
return;
|
|
3604
|
+
}
|
|
3605
|
+
|
|
2957
3606
|
if (url.pathname === "/api/network" && req.method === "GET") {
|
|
2958
3607
|
sendJson(res, 200, { ok: true, data: networkStatus() });
|
|
2959
3608
|
return;
|
|
@@ -3035,6 +3684,14 @@ const server = createServer(async (req, res) => {
|
|
|
3035
3684
|
return;
|
|
3036
3685
|
}
|
|
3037
3686
|
|
|
3687
|
+
if (url.pathname === "/api/model-cycle" && req.method === "POST") {
|
|
3688
|
+
const body = await readJsonBody(req);
|
|
3689
|
+
const tab = getRequestedTab(req, url, body);
|
|
3690
|
+
const response = await cycleTabModel(tab, body.direction || body.mode);
|
|
3691
|
+
sendJson(res, response.success === false ? 400 : 200, responseWithTab(response, tab));
|
|
3692
|
+
return;
|
|
3693
|
+
}
|
|
3694
|
+
|
|
3038
3695
|
if (url.pathname === "/api/fork-messages" && req.method === "GET") {
|
|
3039
3696
|
const tab = getRequestedTab(req, url);
|
|
3040
3697
|
sendJson(res, 200, { ok: true, data: await getForkMessagesData(tab) });
|
|
@@ -3110,7 +3767,7 @@ const server = createServer(async (req, res) => {
|
|
|
3110
3767
|
if (url.pathname === "/api/prompt" && req.method === "POST") {
|
|
3111
3768
|
const body = await readJsonBody(req, { limitBytes: requestBodyLimitForPath(url.pathname) });
|
|
3112
3769
|
const tab = getRequestedTab(req, url, body);
|
|
3113
|
-
const nativeResponse = await handleNativeSlashCommand(tab, body);
|
|
3770
|
+
const nativeResponse = await handleNativeSlashCommand(tab, body, req);
|
|
3114
3771
|
if (nativeResponse) {
|
|
3115
3772
|
sendJson(res, nativeResponse.success === false ? 400 : 200, responseWithTab(nativeResponse, tab));
|
|
3116
3773
|
return;
|
|
@@ -3178,7 +3835,7 @@ const server = createServer(async (req, res) => {
|
|
|
3178
3835
|
maybeNameTabForConversation(tab, command);
|
|
3179
3836
|
markTabWorking(tab);
|
|
3180
3837
|
}
|
|
3181
|
-
const response = await tab.rpc.send(command);
|
|
3838
|
+
const response = command.type === "bash" ? await sendQueuedBashCommand(tab, command) : await tab.rpc.send(command);
|
|
3182
3839
|
if (response.success === false && startsVisibleWork) markTabIdle(tab);
|
|
3183
3840
|
if (response.success !== false && command.type === "new_session") {
|
|
3184
3841
|
tab.conversationStarted = false;
|