@firstpick/pi-package-webui 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -24
- package/bin/pi-webui.mjs +311 -1
- package/images/Guardrails_v0.1.7.png +0 -0
- package/images/Guided_GitWorkflow_v0.1.7.png +0 -0
- package/images/Main_Window_v0.1.7.png +0 -0
- package/images/Matrix_Theme_v0.1.7.png +0 -0
- package/package.json +3 -1
- package/public/app.js +452 -97
- package/public/index.html +14 -0
- package/public/styles.css +77 -0
- package/tests/mobile-static.test.mjs +12 -0
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Local browser companion for [Pi coding agent](https://www.npmjs.com/package/@earendil-works/pi-coding-agent).
|
|
4
4
|
|
|
5
|
+

|
|
6
|
+
|
|
5
7
|
This package provides:
|
|
6
8
|
|
|
7
9
|
- `pi-webui`: a local HTTP/SSE server that starts `pi --mode rpc`, serves the static browser UI, and proxies browser actions to Pi RPC commands.
|
|
@@ -64,30 +66,18 @@ pi-webui --cwd /path/to/project
|
|
|
64
66
|
|
|
65
67
|
## Features
|
|
66
68
|
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
-
-
|
|
70
|
-
-
|
|
71
|
-
-
|
|
72
|
-
-
|
|
73
|
-
-
|
|
74
|
-
-
|
|
75
|
-
-
|
|
76
|
-
-
|
|
77
|
-
-
|
|
78
|
-
-
|
|
79
|
-
- Tool, process, compaction, queue, and extension event log
|
|
80
|
-
- Collapsible side panel with independently collapsible sections for controls, optional features, session state, queue, available commands, events, local-network exposure status/control, and a theme picker
|
|
81
|
-
- Pi-style footer with token, cache, estimated Pi-context tokens, speed, cost, context usage, clickable per-tab cwd picker with server-persisted fast picks, git branch, changes, runtime, model, and thinking level
|
|
82
|
-
- Guided Git workflow: `git add .`, ask Pi to run `/git-staged-msg`, preview short/long messages, commit with the selected message, and `git push`
|
|
83
|
-
- Hover-expand Publish workflow menu beside Git workflow, currently offering NPM Release and AUR Release
|
|
84
|
-
- Basic rendering for user, assistant, tool result, bash execution, and thinking messages
|
|
85
|
-
- Feedback reactions (`👍`, `👎`, `?`) on final assistant output plus tool/bash action cards, with queued post-run submission that asks Pi to create/update a LEARNING
|
|
86
|
-
- Basic extension UI bridge for `notify`, `setStatus`, `setWidget`, `setTitle`, `set_editor_text`, `select`, `confirm`, `input`, and `editor`
|
|
87
|
-
- Specialized `/release-npm` and `/release-aur` widget rendering with scrollable live logs plus toggle/abort actions
|
|
88
|
-
- Side-panel theme picker backed by optional `@firstpick/pi-themes-bundle` themes when loaded
|
|
89
|
-
- PWA metadata, icons, and service worker for install-to-home-screen support when served from a secure context
|
|
90
|
-
- Static frontend: no bundler, no frontend install step
|
|
69
|
+
- Local browser chat over Pi RPC with isolated terminal tabs; each tab has its own `pi --mode rpc` subprocess, event stream, session state, prompt draft, cwd, and activity indicator.
|
|
70
|
+
- Automatic tab naming from the first prompt on default-named tabs, plus `/name <title>` to manually sync the Pi session and browser tab name.
|
|
71
|
+
- Live transcript with streamed assistant text/thinking, Markdown output, active-run status, tool/bash cards, queue/compaction events, jump-to-latest, sticky last-prompt navigation, and abort by button, Esc, or long press.
|
|
72
|
+
- Prompt composer for prompts, steer/follow-up, busy-session behavior, model/thinking controls, manual compact/new session, uploads by button/drag/drop/paste, slash-command autocomplete, and `@` file/path references with live suggestions.
|
|
73
|
+
- Browser-native selector dialogs for `/model`, `/settings`, `/theme`, `/fork`, `/clone`, `/resume`, `/tree`, and `/scoped-models`; `/login`/`/logout` show non-secret guidance instead of accepting credentials in the browser.
|
|
74
|
+
- Session/workspace helpers for per-tab cwd changes, a clickable footer cwd picker with server-persisted fast picks, fork/clone/resume/tree navigation, and restart-safe restoration of currently open tabs.
|
|
75
|
+
- Collapsible side-panel control deck for model/thinking/settings, optional features, Codex usage, session/queue/commands/events, local-network exposure, browser notifications, and Web UI themes/custom backgrounds.
|
|
76
|
+
- Pi-style footer with token/cache/context/cost/speed telemetry, estimated Pi-context tokens, cwd/git/runtime/model/thinking metadata, and a scoped-model picker.
|
|
77
|
+
- Optional companion management with capability-based enabled/disabled/install-needed status, localhost-only warned installs, Side-panel theme picker backed by optional `@firstpick/pi-themes-bundle` themes when loaded, guided Git commit/push workflow, NPM/AUR Publish menu, todo-progress rendering, and richer git/status/stats widgets.
|
|
78
|
+
- Extension UI bridge for `notify`, `setStatus`, `setWidget`, `setTitle`, `set_editor_text`, `select`, `confirm`, `input`, and `editor`, with browser notifications when a tab needs an extension UI response and an optional side-panel toggle for agent-done notifications.
|
|
79
|
+
- Feedback reactions (`👍`, `👎`, `?`) on final assistant output plus tool/bash action cards, with queued post-run submission that asks Pi to create/update a LEARNING.
|
|
80
|
+
- Mobile/PWA support: installable app shell, service worker/icons, backend-offline recovery panel, touch-friendly composer/tabs/footer, and a static frontend with no bundler or frontend install step.
|
|
91
81
|
|
|
92
82
|
## Mobile/PWA notes
|
|
93
83
|
|
package/bin/pi-webui.mjs
CHANGED
|
@@ -8,7 +8,7 @@ import { homedir, networkInterfaces, tmpdir } from "node:os";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { StringDecoder } from "node:string_decoder";
|
|
10
10
|
import { fileURLToPath } from "node:url";
|
|
11
|
-
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { AuthStorage, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
|
|
13
13
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
14
|
const require = createRequire(import.meta.url);
|
|
@@ -19,6 +19,10 @@ const packageJson = JSON.parse(await readFile(path.join(packageRoot, "package.js
|
|
|
19
19
|
const DEFAULT_HOST = "127.0.0.1";
|
|
20
20
|
const DEFAULT_PORT = 31415;
|
|
21
21
|
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
22
|
+
const CODEX_USAGE_TIMEOUT_MS = 15 * 1000;
|
|
23
|
+
const CODEX_TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1000;
|
|
24
|
+
const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
|
|
25
|
+
const OPENAI_CODEX_USAGE_ENDPOINT = process.env.PI_WEBUI_CODEX_USAGE_URL || "https://chatgpt.com/backend-api/wham/usage";
|
|
22
26
|
const BODY_LIMIT_BYTES = 1024 * 1024;
|
|
23
27
|
const PROMPT_BODY_LIMIT_BYTES = 24 * 1024 * 1024;
|
|
24
28
|
const UPLOAD_BODY_LIMIT_BYTES = 96 * 1024 * 1024;
|
|
@@ -839,6 +843,302 @@ async function readJsonFileIfExists(filePath) {
|
|
|
839
843
|
}
|
|
840
844
|
}
|
|
841
845
|
|
|
846
|
+
function firstDefined(...values) {
|
|
847
|
+
return values.find((value) => value !== undefined && value !== null);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function numericValue(value) {
|
|
851
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
852
|
+
const number = Number(value);
|
|
853
|
+
return Number.isFinite(number) ? number : undefined;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function booleanValue(value) {
|
|
857
|
+
return typeof value === "boolean" ? value : undefined;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function isoTimestamp(value) {
|
|
861
|
+
const number = numericValue(value);
|
|
862
|
+
if (number !== undefined) {
|
|
863
|
+
const milliseconds = number > 1e12 ? number : number * 1000;
|
|
864
|
+
const date = new Date(milliseconds);
|
|
865
|
+
return Number.isFinite(date.getTime()) ? date.toISOString() : undefined;
|
|
866
|
+
}
|
|
867
|
+
if (typeof value === "string" && value.trim()) {
|
|
868
|
+
const date = new Date(value);
|
|
869
|
+
return Number.isFinite(date.getTime()) ? date.toISOString() : undefined;
|
|
870
|
+
}
|
|
871
|
+
return undefined;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function decodeJwtPayload(token) {
|
|
875
|
+
try {
|
|
876
|
+
const payload = String(token || "").split(".")[1];
|
|
877
|
+
if (!payload) return null;
|
|
878
|
+
const padded = `${payload}${"=".repeat((4 - (payload.length % 4)) % 4)}`;
|
|
879
|
+
return JSON.parse(Buffer.from(padded.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
|
|
880
|
+
} catch {
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function codexAccountIdFromAccessToken(accessToken) {
|
|
886
|
+
const payload = decodeJwtPayload(accessToken);
|
|
887
|
+
const auth = payload?.["https://api.openai.com/auth"];
|
|
888
|
+
const accountId = auth?.chatgpt_account_id;
|
|
889
|
+
return typeof accountId === "string" && accountId ? accountId : null;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function normalizeCodexRateLimitWindow(rawWindow) {
|
|
893
|
+
if (!rawWindow || typeof rawWindow !== "object") return null;
|
|
894
|
+
const windowDurationSeconds = firstDefined(
|
|
895
|
+
numericValue(rawWindow.windowDurationSeconds),
|
|
896
|
+
numericValue(rawWindow.limitWindowSeconds),
|
|
897
|
+
numericValue(rawWindow.limit_window_seconds),
|
|
898
|
+
numericValue(rawWindow.windowDurationMins) !== undefined ? numericValue(rawWindow.windowDurationMins) * 60 : undefined,
|
|
899
|
+
);
|
|
900
|
+
const windowDurationMins = firstDefined(
|
|
901
|
+
numericValue(rawWindow.windowDurationMins),
|
|
902
|
+
windowDurationSeconds !== undefined ? windowDurationSeconds / 60 : undefined,
|
|
903
|
+
);
|
|
904
|
+
const normalized = {
|
|
905
|
+
usedPercent: numericValue(firstDefined(rawWindow.usedPercent, rawWindow.used_percent)),
|
|
906
|
+
windowDurationSeconds,
|
|
907
|
+
windowDurationMins,
|
|
908
|
+
resetAfterSeconds: numericValue(firstDefined(rawWindow.resetAfterSeconds, rawWindow.reset_after_seconds)),
|
|
909
|
+
resetsAt: isoTimestamp(firstDefined(rawWindow.resetsAt, rawWindow.resetAt, rawWindow.reset_at)),
|
|
910
|
+
};
|
|
911
|
+
return Object.values(normalized).some((value) => value !== undefined) ? normalized : null;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function normalizeCodexCredits(rawCredits) {
|
|
915
|
+
if (!rawCredits || typeof rawCredits !== "object") return null;
|
|
916
|
+
return {
|
|
917
|
+
hasCredits: booleanValue(firstDefined(rawCredits.hasCredits, rawCredits.has_credits)),
|
|
918
|
+
unlimited: booleanValue(rawCredits.unlimited),
|
|
919
|
+
balance: firstDefined(rawCredits.balance),
|
|
920
|
+
approxLocalMessages: firstDefined(rawCredits.approxLocalMessages, rawCredits.approx_local_messages),
|
|
921
|
+
approxCloudMessages: firstDefined(rawCredits.approxCloudMessages, rawCredits.approx_cloud_messages),
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function normalizeCodexRateLimitDetails(rawDetails) {
|
|
926
|
+
if (!rawDetails || typeof rawDetails !== "object") return { primary: null, secondary: null };
|
|
927
|
+
return {
|
|
928
|
+
allowed: booleanValue(rawDetails.allowed),
|
|
929
|
+
limitReached: booleanValue(firstDefined(rawDetails.limitReached, rawDetails.limit_reached)),
|
|
930
|
+
primary: normalizeCodexRateLimitWindow(firstDefined(rawDetails.primary, rawDetails.primaryWindow, rawDetails.primary_window)),
|
|
931
|
+
secondary: normalizeCodexRateLimitWindow(firstDefined(rawDetails.secondary, rawDetails.secondaryWindow, rawDetails.secondary_window)),
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
function normalizeCodexRateLimitReachedType(rawType) {
|
|
936
|
+
if (typeof rawType === "string" && rawType) return rawType;
|
|
937
|
+
if (rawType && typeof rawType === "object") {
|
|
938
|
+
const value = firstDefined(rawType.type, rawType.kind);
|
|
939
|
+
return typeof value === "string" && value ? value : null;
|
|
940
|
+
}
|
|
941
|
+
return null;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function makeCodexUsageSnapshot({ limitId, limitName, rateLimit, credits, planType, rateLimitReachedType }) {
|
|
945
|
+
const details = normalizeCodexRateLimitDetails(rateLimit);
|
|
946
|
+
return {
|
|
947
|
+
limitId: limitId || null,
|
|
948
|
+
limitName: limitName || null,
|
|
949
|
+
primary: details.primary,
|
|
950
|
+
secondary: details.secondary,
|
|
951
|
+
allowed: details.allowed,
|
|
952
|
+
limitReached: details.limitReached,
|
|
953
|
+
credits: normalizeCodexCredits(credits),
|
|
954
|
+
planType: planType || null,
|
|
955
|
+
rateLimitReachedType: rateLimitReachedType || null,
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function normalizeCodexUsagePayload(rawPayload) {
|
|
960
|
+
const payload = rawPayload && typeof rawPayload === "object" ? rawPayload : {};
|
|
961
|
+
const planType = firstDefined(payload.planType, payload.plan_type, null);
|
|
962
|
+
const rateLimitReachedType = normalizeCodexRateLimitReachedType(firstDefined(payload.rateLimitReachedType, payload.rate_limit_reached_type));
|
|
963
|
+
const snapshotsByKey = new Map();
|
|
964
|
+
const addSnapshot = (snapshot) => {
|
|
965
|
+
if (!snapshot) return;
|
|
966
|
+
const key = snapshot.limitId || snapshot.limitName || `snapshot-${snapshotsByKey.size + 1}`;
|
|
967
|
+
if (!snapshotsByKey.has(key)) snapshotsByKey.set(key, snapshot);
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
const directRateLimits = firstDefined(payload.rateLimits, payload.rate_limits);
|
|
971
|
+
if (directRateLimits && typeof directRateLimits === "object" && (directRateLimits.primary || directRateLimits.primary_window || directRateLimits.primaryWindow)) {
|
|
972
|
+
addSnapshot(makeCodexUsageSnapshot({
|
|
973
|
+
limitId: firstDefined(directRateLimits.limitId, directRateLimits.limit_id, "codex"),
|
|
974
|
+
limitName: firstDefined(directRateLimits.limitName, directRateLimits.limit_name),
|
|
975
|
+
rateLimit: directRateLimits,
|
|
976
|
+
credits: firstDefined(directRateLimits.credits, payload.credits),
|
|
977
|
+
planType: firstDefined(directRateLimits.planType, directRateLimits.plan_type, planType),
|
|
978
|
+
rateLimitReachedType: firstDefined(directRateLimits.rateLimitReachedType, directRateLimits.rate_limit_reached_type, rateLimitReachedType),
|
|
979
|
+
}));
|
|
980
|
+
} else {
|
|
981
|
+
addSnapshot(makeCodexUsageSnapshot({
|
|
982
|
+
limitId: "codex",
|
|
983
|
+
rateLimit: firstDefined(payload.rateLimit, payload.rate_limit),
|
|
984
|
+
credits: payload.credits,
|
|
985
|
+
planType,
|
|
986
|
+
rateLimitReachedType,
|
|
987
|
+
}));
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const byLimitId = firstDefined(payload.rateLimitsByLimitId, payload.rate_limits_by_limit_id);
|
|
991
|
+
if (byLimitId && typeof byLimitId === "object" && !Array.isArray(byLimitId)) {
|
|
992
|
+
for (const [limitId, rawSnapshot] of Object.entries(byLimitId)) {
|
|
993
|
+
if (!rawSnapshot || typeof rawSnapshot !== "object") continue;
|
|
994
|
+
addSnapshot(makeCodexUsageSnapshot({
|
|
995
|
+
limitId: firstDefined(rawSnapshot.limitId, rawSnapshot.limit_id, limitId),
|
|
996
|
+
limitName: firstDefined(rawSnapshot.limitName, rawSnapshot.limit_name),
|
|
997
|
+
rateLimit: rawSnapshot,
|
|
998
|
+
credits: rawSnapshot.credits,
|
|
999
|
+
planType: firstDefined(rawSnapshot.planType, rawSnapshot.plan_type, planType),
|
|
1000
|
+
rateLimitReachedType: firstDefined(rawSnapshot.rateLimitReachedType, rawSnapshot.rate_limit_reached_type),
|
|
1001
|
+
}));
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
const additionalRateLimits = firstDefined(payload.additionalRateLimits, payload.additional_rate_limits);
|
|
1006
|
+
if (Array.isArray(additionalRateLimits)) {
|
|
1007
|
+
for (const item of additionalRateLimits) {
|
|
1008
|
+
if (!item || typeof item !== "object") continue;
|
|
1009
|
+
addSnapshot(makeCodexUsageSnapshot({
|
|
1010
|
+
limitId: firstDefined(item.limitId, item.limit_id, item.meteredFeature, item.metered_feature, item.limitName, item.limit_name),
|
|
1011
|
+
limitName: firstDefined(item.limitName, item.limit_name),
|
|
1012
|
+
rateLimit: firstDefined(item.rateLimit, item.rate_limit),
|
|
1013
|
+
credits: item.credits,
|
|
1014
|
+
planType,
|
|
1015
|
+
}));
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
const snapshots = [...snapshotsByKey.values()];
|
|
1020
|
+
const selected = snapshots.find((snapshot) => snapshot.limitId === "codex") || snapshots[0] || null;
|
|
1021
|
+
const rateLimitsByLimitId = Object.fromEntries(snapshots.filter((snapshot) => snapshot.limitId).map((snapshot) => [snapshot.limitId, snapshot]));
|
|
1022
|
+
return {
|
|
1023
|
+
planType: planType || selected?.planType || null,
|
|
1024
|
+
rateLimitReachedType: rateLimitReachedType || selected?.rateLimitReachedType || null,
|
|
1025
|
+
credits: normalizeCodexCredits(payload.credits) || selected?.credits || null,
|
|
1026
|
+
selected,
|
|
1027
|
+
snapshots,
|
|
1028
|
+
rateLimits: selected,
|
|
1029
|
+
rateLimitsByLimitId,
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
async function getOpenAICodexUsageCredentials({ forceRefresh = false } = {}) {
|
|
1034
|
+
const authStorage = AuthStorage.create();
|
|
1035
|
+
const stored = authStorage.get(OPENAI_CODEX_PROVIDER_ID);
|
|
1036
|
+
const storedExpires = numericValue(stored?.expires);
|
|
1037
|
+
const shouldRefresh = stored?.type === "oauth" && (forceRefresh || storedExpires === undefined || Date.now() + CODEX_TOKEN_REFRESH_SKEW_MS >= storedExpires);
|
|
1038
|
+
let accessToken;
|
|
1039
|
+
let refreshed = false;
|
|
1040
|
+
|
|
1041
|
+
if (shouldRefresh) {
|
|
1042
|
+
try {
|
|
1043
|
+
const refreshResult = await authStorage.refreshOAuthTokenWithLock(OPENAI_CODEX_PROVIDER_ID);
|
|
1044
|
+
if (refreshResult?.apiKey) {
|
|
1045
|
+
accessToken = refreshResult.apiKey;
|
|
1046
|
+
refreshed = forceRefresh || refreshResult.newCredentials?.access !== stored?.access;
|
|
1047
|
+
}
|
|
1048
|
+
} catch (error) {
|
|
1049
|
+
if (forceRefresh || !storedExpires || Date.now() >= storedExpires) {
|
|
1050
|
+
throw makeHttpError(401, "OpenAI Codex OAuth token refresh failed. Run /login and choose ChatGPT Plus/Pro (Codex Subscription) to re-authenticate.");
|
|
1051
|
+
}
|
|
1052
|
+
console.warn(`OpenAI Codex token refresh warning: ${sanitizeError(error)}`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (!accessToken) {
|
|
1057
|
+
accessToken = await authStorage.getApiKey(OPENAI_CODEX_PROVIDER_ID, { includeFallback: false });
|
|
1058
|
+
}
|
|
1059
|
+
if (!accessToken) {
|
|
1060
|
+
const status = authStorage.getAuthStatus(OPENAI_CODEX_PROVIDER_ID);
|
|
1061
|
+
if (status.configured) throw makeHttpError(401, "OpenAI Codex OAuth token is expired or unavailable. Run /login to refresh credentials.");
|
|
1062
|
+
throw makeHttpError(401, "OpenAI Codex OAuth is not configured. Run /login and choose ChatGPT Plus/Pro (Codex Subscription).");
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const latest = authStorage.get(OPENAI_CODEX_PROVIDER_ID) || stored || {};
|
|
1066
|
+
const accountId = latest.accountId || codexAccountIdFromAccessToken(accessToken);
|
|
1067
|
+
if (!accountId) {
|
|
1068
|
+
throw makeHttpError(401, "OpenAI Codex account id is unavailable. Run /login and choose ChatGPT Plus/Pro (Codex Subscription) again.");
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
return {
|
|
1072
|
+
accessToken,
|
|
1073
|
+
accountId,
|
|
1074
|
+
refreshed,
|
|
1075
|
+
source: latest.type === "oauth" ? "stored-oauth" : "api-key",
|
|
1076
|
+
expiresAt: numericValue(latest.expires) ? new Date(numericValue(latest.expires)).toISOString() : undefined,
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
async function fetchOpenAICodexUsagePayload(credentials) {
|
|
1081
|
+
const controller = new AbortController();
|
|
1082
|
+
const timer = setTimeout(() => controller.abort(), CODEX_USAGE_TIMEOUT_MS);
|
|
1083
|
+
timer.unref?.();
|
|
1084
|
+
try {
|
|
1085
|
+
const response = await fetch(OPENAI_CODEX_USAGE_ENDPOINT, {
|
|
1086
|
+
method: "GET",
|
|
1087
|
+
headers: {
|
|
1088
|
+
accept: "application/json",
|
|
1089
|
+
authorization: `Bearer ${credentials.accessToken}`,
|
|
1090
|
+
"chatgpt-account-id": credentials.accountId,
|
|
1091
|
+
originator: "pi-webui",
|
|
1092
|
+
},
|
|
1093
|
+
signal: controller.signal,
|
|
1094
|
+
});
|
|
1095
|
+
const text = await response.text().catch(() => "");
|
|
1096
|
+
if (!response.ok) {
|
|
1097
|
+
const error = makeHttpError(response.status === 401 ? 401 : 502, `OpenAI Codex usage request failed (${response.status}${response.statusText ? ` ${response.statusText}` : ""})`);
|
|
1098
|
+
error.openaiStatus = response.status;
|
|
1099
|
+
throw error;
|
|
1100
|
+
}
|
|
1101
|
+
try {
|
|
1102
|
+
return JSON.parse(text || "{}");
|
|
1103
|
+
} catch {
|
|
1104
|
+
throw makeHttpError(502, "OpenAI Codex usage response was not valid JSON");
|
|
1105
|
+
}
|
|
1106
|
+
} catch (error) {
|
|
1107
|
+
if (error?.name === "AbortError") throw makeHttpError(504, "OpenAI Codex usage request timed out");
|
|
1108
|
+
throw error;
|
|
1109
|
+
} finally {
|
|
1110
|
+
clearTimeout(timer);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
async function getOpenAICodexUsageStatus({ forceRefresh = false } = {}) {
|
|
1115
|
+
let credentials = await getOpenAICodexUsageCredentials({ forceRefresh });
|
|
1116
|
+
let rawPayload;
|
|
1117
|
+
try {
|
|
1118
|
+
rawPayload = await fetchOpenAICodexUsagePayload(credentials);
|
|
1119
|
+
} catch (error) {
|
|
1120
|
+
if (error?.openaiStatus === 401 && !credentials.refreshed) {
|
|
1121
|
+
credentials = await getOpenAICodexUsageCredentials({ forceRefresh: true });
|
|
1122
|
+
rawPayload = await fetchOpenAICodexUsagePayload(credentials);
|
|
1123
|
+
} else {
|
|
1124
|
+
throw error;
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
return {
|
|
1129
|
+
available: true,
|
|
1130
|
+
providerId: OPENAI_CODEX_PROVIDER_ID,
|
|
1131
|
+
source: "chatgpt.com",
|
|
1132
|
+
fetchedAt: new Date().toISOString(),
|
|
1133
|
+
auth: {
|
|
1134
|
+
source: credentials.source,
|
|
1135
|
+
expiresAt: credentials.expiresAt,
|
|
1136
|
+
refreshed: credentials.refreshed,
|
|
1137
|
+
},
|
|
1138
|
+
...normalizeCodexUsagePayload(rawPayload),
|
|
1139
|
+
};
|
|
1140
|
+
}
|
|
1141
|
+
|
|
842
1142
|
async function configuredScopedModelPatterns(cwd = options.cwd) {
|
|
843
1143
|
const cliPatterns = parseCliScopedModelPatterns();
|
|
844
1144
|
if (cliPatterns !== undefined) return { patterns: cliPatterns, source: "cli" };
|
|
@@ -2954,6 +3254,16 @@ const server = createServer(async (req, res) => {
|
|
|
2954
3254
|
return;
|
|
2955
3255
|
}
|
|
2956
3256
|
|
|
3257
|
+
if (url.pathname === "/api/codex-usage" && req.method === "GET") {
|
|
3258
|
+
try {
|
|
3259
|
+
const forceRefresh = ["1", "true", "yes"].includes(String(url.searchParams.get("refresh") || "").toLowerCase());
|
|
3260
|
+
sendJson(res, 200, { ok: true, data: await getOpenAICodexUsageStatus({ forceRefresh }) });
|
|
3261
|
+
} catch (error) {
|
|
3262
|
+
sendJson(res, error?.statusCode || 500, { ok: false, error: error?.message || "Failed to read OpenAI Codex usage" });
|
|
3263
|
+
}
|
|
3264
|
+
return;
|
|
3265
|
+
}
|
|
3266
|
+
|
|
2957
3267
|
if (url.pathname === "/api/network" && req.method === "GET") {
|
|
2958
3268
|
sendJson(res, 200, { ok: true, data: networkStatus() });
|
|
2959
3269
|
return;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@firstpick/pi-package-webui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Pi Web UI companion package with a local browser UI CLI plus /webui-start and /webui-status commands.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"extension"
|
|
15
15
|
],
|
|
16
16
|
"pi": {
|
|
17
|
+
"image": "https://unpkg.com/@firstpick/pi-package-webui/images/Main_Window_v0.1.7.png",
|
|
17
18
|
"extensions": [
|
|
18
19
|
"./index.ts",
|
|
19
20
|
"../pi-extension-git-footer-status/index.ts",
|
|
@@ -63,6 +64,7 @@
|
|
|
63
64
|
"index.ts",
|
|
64
65
|
"bin",
|
|
65
66
|
"public",
|
|
67
|
+
"images",
|
|
66
68
|
"tests",
|
|
67
69
|
"README.md",
|
|
68
70
|
"LICENSE"
|