@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/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
- const NATIVE_SLASH_COMMANDS = [
96
- { name: "settings", description: "Open settings menu" },
97
- { name: "model", description: "Select model (opens selector UI)" },
98
- { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" },
99
- { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" },
100
- { name: "import", description: "Import and resume a session from a JSONL file" },
101
- { name: "share", description: "Share session as a secret GitHub gist" },
102
- { name: "copy", description: "Copy last agent message to clipboard" },
103
- { name: "name", description: "Set session display name" },
104
- { name: "session", description: "Show session info and stats" },
105
- { name: "changelog", description: "Show changelog entries" },
106
- { name: "hotkeys", description: "Show all keyboard shortcuts" },
107
- { name: "fork", description: "Create a new fork from a previous user message" },
108
- { name: "clone", description: "Duplicate the current session at the current position" },
109
- { name: "tree", description: "Navigate session tree (switch branches)" },
110
- { name: "login", description: "Configure provider authentication" },
111
- { name: "logout", description: "Remove provider authentication" },
112
- { name: "new", description: "Start a new session" },
113
- { name: "compact", description: "Manually compact the session context" },
114
- { name: "resume", description: "Resume a different session" },
115
- { name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes" },
116
- { name: "quit", description: "Quit Pi" },
117
- ].map((command) => ({ ...command, source: "native", location: "Pi" }));
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
- async function handleNativeSlashCommand(tab, body) {
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
- throw makeHttpError(400, `/${parsed.name} is a native Pi TUI command, but this Web UI cannot run that interactive command yet.`);
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;