@raingor/pi-web-switch 0.8.0 → 0.8.3

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/dist/index.html CHANGED
@@ -9,8 +9,8 @@
9
9
  <link rel="manifest" href="./manifest.webmanifest" />
10
10
  <meta name="theme-color" content="#05090d" />
11
11
  <meta name="description" content="Web UI for pi coding agent — configuration management, session browser, and usage dashboard" />
12
- <script type="module" crossorigin src="./assets/main-DJ0fZLIg.js"></script>
13
- <link rel="stylesheet" crossorigin href="./assets/main-K50Fa8uc.css">
12
+ <script type="module" crossorigin src="./assets/main-4u-qsOhX.js"></script>
13
+ <link rel="stylesheet" crossorigin href="./assets/main-Cz1qHtDa.css">
14
14
  </head>
15
15
  <body>
16
16
  <div id="root"></div>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@raingor/pi-web-switch",
3
3
  "private": false,
4
- "version": "0.8.0",
4
+ "version": "0.8.3",
5
5
  "type": "module",
6
6
  "main": "dist/index.html",
7
7
  "description": "Web UI for pi coding agent — live configuration management, session browser, and memory viewer",
@@ -54,6 +54,87 @@ export function writeSettings(settings: any): boolean {
54
54
  }
55
55
  }
56
56
 
57
+ export interface CodexUsageWindow {
58
+ windowSeconds: number;
59
+ usedPercent: number;
60
+ remainingPercent: number;
61
+ resetAfterSeconds: number | null;
62
+ resetAt: number | null;
63
+ }
64
+
65
+ export interface CodexUsageStatus {
66
+ loggedIn: boolean;
67
+ provider: "openai-codex";
68
+ planType?: string;
69
+ primary?: CodexUsageWindow;
70
+ secondary?: CodexUsageWindow;
71
+ checkedAt: string;
72
+ error?: string;
73
+ }
74
+
75
+ let codexUsageStatusCache: { value: CodexUsageStatus; at: number } | null = null;
76
+ const CODEX_USAGE_STATUS_TTL_MS = 30_000;
77
+
78
+ function codexUsageWindow(value: any): CodexUsageWindow | undefined {
79
+ if (!value || typeof value !== "object" || typeof value.limit_window_seconds !== "number") return undefined;
80
+ const usedPercent = Math.min(100, Math.max(0, Number(value.used_percent) || 0));
81
+ return {
82
+ windowSeconds: value.limit_window_seconds,
83
+ usedPercent,
84
+ remainingPercent: Math.max(0, 100 - usedPercent),
85
+ resetAfterSeconds: typeof value.reset_after_seconds === "number" ? value.reset_after_seconds : null,
86
+ resetAt: typeof value.reset_at === "number" ? value.reset_at : null,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Read the locally saved openai-codex OAuth session and query OpenAI's own
92
+ * Codex usage endpoint. Only a sanitized quota summary leaves the server;
93
+ * OAuth access/refresh tokens are never returned or written anywhere.
94
+ */
95
+ export async function getCodexUsageStatus(force = false): Promise<CodexUsageStatus> {
96
+ if (!force && codexUsageStatusCache && Date.now() - codexUsageStatusCache.at < CODEX_USAGE_STATUS_TTL_MS) {
97
+ return codexUsageStatusCache.value;
98
+ }
99
+
100
+ const checkedAt = new Date().toISOString();
101
+ const codex = readAuth()?.["openai-codex"];
102
+ if (codex?.type !== "oauth" || typeof codex.access !== "string" || !codex.access || typeof codex.accountId !== "string" || !codex.accountId) {
103
+ return { loggedIn: false, provider: "openai-codex", checkedAt };
104
+ }
105
+
106
+ try {
107
+ const response = await fetch("https://chatgpt.com/backend-api/wham/usage", {
108
+ headers: {
109
+ Authorization: `Bearer ${codex.access}`,
110
+ "chatgpt-account-id": codex.accountId,
111
+ },
112
+ signal: AbortSignal.timeout(15_000),
113
+ });
114
+ if (!response.ok) {
115
+ const value: CodexUsageStatus = { loggedIn: true, provider: "openai-codex", checkedAt, error: `OpenAI returned ${response.status}` };
116
+ codexUsageStatusCache = { value, at: Date.now() };
117
+ return value;
118
+ }
119
+ const payload = await response.json() as any;
120
+ const rateLimit = payload?.rate_limit;
121
+ const value: CodexUsageStatus = {
122
+ loggedIn: true,
123
+ provider: "openai-codex",
124
+ planType: typeof payload?.plan_type === "string" ? payload.plan_type : undefined,
125
+ primary: codexUsageWindow(rateLimit?.primary_window),
126
+ secondary: codexUsageWindow(rateLimit?.secondary_window),
127
+ checkedAt,
128
+ };
129
+ codexUsageStatusCache = { value, at: Date.now() };
130
+ return value;
131
+ } catch {
132
+ const value: CodexUsageStatus = { loggedIn: true, provider: "openai-codex", checkedAt, error: "Unable to query OpenAI usage" };
133
+ codexUsageStatusCache = { value, at: Date.now() };
134
+ return value;
135
+ }
136
+ }
137
+
57
138
  // ─── Official Usage Query ──────────────────────────────
58
139
 
59
140
  export type OfficialUsageAuthMode = "auto" | "bearer" | "x-api-key" | "api-key";
@@ -1954,8 +2035,8 @@ export interface UpdateCheckResult {
1954
2035
  checkedAt: number;
1955
2036
  }
1956
2037
 
1957
- /** Discover the installed pi version: PI_BINARY env → PATH → known locations. */
1958
- function getPiVersion(): string | null {
2038
+ /** Discover the pi executable: PI_BINARY env → PATH → known global-install locations. */
2039
+ function resolvePiBinary(): { bin: string; version: string } | null {
1959
2040
  const home = homedir();
1960
2041
  const candidates = [
1961
2042
  process.env.PI_BINARY,
@@ -1971,7 +2052,7 @@ function getPiVersion(): string | null {
1971
2052
  const out = spawnSync(bin, ["--version"], { encoding: "utf8", timeout: 15000 });
1972
2053
  if (out.status === 0) {
1973
2054
  const v = out.stdout.trim();
1974
- if (v) return v;
2055
+ if (v) return { bin, version: v };
1975
2056
  }
1976
2057
  } catch {
1977
2058
  // try next candidate
@@ -1980,6 +2061,11 @@ function getPiVersion(): string | null {
1980
2061
  return null;
1981
2062
  }
1982
2063
 
2064
+ /** Installed pi version, or null when no pi executable could be found. */
2065
+ function getPiVersion(): string | null {
2066
+ return resolvePiBinary()?.version ?? null;
2067
+ }
2068
+
1983
2069
  function readJsonFile<T>(filePath: string): T | null {
1984
2070
  try {
1985
2071
  return JSON.parse(readFileSync(filePath, "utf-8")) as T;
@@ -2131,15 +2217,37 @@ export interface ApplyUpdateResult {
2131
2217
  }
2132
2218
 
2133
2219
  /**
2134
- * One-click update: npm install <name>@latest inside ~/.pi/agent/npm.
2135
- * Only packages already installed there are accepted (pi core is excluded —
2136
- * its install method is unknown, so it must be updated via its installer).
2220
+ * Update pi core itself via `pi update`.
2221
+ *
2222
+ * pi core is not installed under ~/.pi/agent/npm, so `npm install` there would
2223
+ * be wrong. `pi update` with no target updates pi only — deliberately without
2224
+ * `--extensions`, which would instead update the packages and leave pi alone.
2225
+ */
2226
+ function applyPiCoreUpdate(): ApplyUpdateResult {
2227
+ const name = PI_CORE_PACKAGE;
2228
+ const pi = resolvePiBinary();
2229
+ if (!pi) return { name, success: false, message: "pi executable not found" };
2230
+ try {
2231
+ const out = spawnSync(pi.bin, ["update"], { encoding: "utf8", timeout: 300000 });
2232
+ if (out.status === 0) return { name, success: true };
2233
+ const stderr = (out.stderr || out.stdout || "").trim().split("\n").slice(-3).join(" ");
2234
+ return { name, success: false, message: stderr || `pi update exited with ${out.status}` };
2235
+ } catch (e) {
2236
+ return { name, success: false, message: String(e) };
2237
+ }
2238
+ }
2239
+
2240
+ /**
2241
+ * One-click update. Extensions use `npm install <name>@latest` inside
2242
+ * ~/.pi/agent/npm; pi core is routed to `pi update` instead, since it lives
2243
+ * outside that directory and has its own updater.
2137
2244
  */
2138
2245
  export function applyExtensionUpdates(names: string[]): ApplyUpdateResult[] {
2139
2246
  const dir = join(PI_DIR, "npm");
2140
2247
  const installed = new Set(listInstalledExtensions().map((e) => e.name));
2141
2248
 
2142
2249
  return names.map((name) => {
2250
+ if (name === PI_CORE_PACKAGE) return applyPiCoreUpdate();
2143
2251
  if (!installed.has(name)) {
2144
2252
  return { name, success: false, message: "not an installed extension" };
2145
2253
  }
@@ -14,6 +14,35 @@ import {
14
14
 
15
15
  // ─── Types ──────────────────────────────────────────────
16
16
 
17
+ interface CodexUsageStatus {
18
+ loggedIn: boolean;
19
+ provider: "openai-codex";
20
+ planType?: string;
21
+ primary?: { windowSeconds: number; usedPercent: number; remainingPercent: number; resetAfterSeconds: number | null; resetAt: number | null };
22
+ secondary?: { windowSeconds: number; usedPercent: number; remainingPercent: number; resetAfterSeconds: number | null; resetAt: number | null };
23
+ checkedAt: string;
24
+ error?: string;
25
+ }
26
+
27
+ function formatRemainingTime(seconds: number | null | undefined): string | null {
28
+ if (typeof seconds !== "number" || seconds < 0) return null;
29
+ const hours = Math.floor(seconds / 3600);
30
+ const minutes = Math.floor((seconds % 3600) / 60);
31
+ return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
32
+ }
33
+
34
+ function formatResetAt(unixSeconds: number | null | undefined, lang: string): string | null {
35
+ if (typeof unixSeconds !== "number" || unixSeconds <= 0) return null;
36
+ return new Intl.DateTimeFormat(lang, {
37
+ year: "numeric",
38
+ month: "2-digit",
39
+ day: "2-digit",
40
+ hour: "2-digit",
41
+ minute: "2-digit",
42
+ hour12: false,
43
+ }).format(new Date(unixSeconds * 1000));
44
+ }
45
+
17
46
  interface UsageRangeData {
18
47
  totalTokens: number;
19
48
  totalInput: number;
@@ -364,6 +393,7 @@ export function DashboardPage() {
364
393
  const [providerSort, setProviderSort] = useState<{ key: string; dir: SortDir }>({ key: "totalCost", dir: "desc" });
365
394
  const [modelSort, setModelSort] = useState<{ key: string; dir: SortDir }>({ key: "totalCost", dir: "desc" });
366
395
  const [prevTotals, setPrevTotals] = useState<{ tokens: number; cost: number } | null>(null);
396
+ const [codexUsage, setCodexUsage] = useState<CodexUsageStatus | null>(null);
367
397
 
368
398
  const customInvalid = range === "custom" && !!customFrom && !!customTo && customFrom > customTo;
369
399
 
@@ -402,6 +432,22 @@ export function DashboardPage() {
402
432
 
403
433
  useEffect(() => { fetchData(); }, [fetchData]);
404
434
 
435
+ // Official Codex quotas use the locally logged-in OAuth session. The API
436
+ // returns only a sanitized summary; OAuth credentials never reach the UI.
437
+ useEffect(() => {
438
+ if (source !== "pi") return;
439
+ let cancelled = false;
440
+ const load = () => {
441
+ fetch("/api/pi/codex-usage-status")
442
+ .then((r) => r.json())
443
+ .then((status: CodexUsageStatus) => { if (!cancelled) setCodexUsage(status); })
444
+ .catch(() => { if (!cancelled) setCodexUsage(null); });
445
+ };
446
+ load();
447
+ const id = window.setInterval(load, 30_000);
448
+ return () => { cancelled = true; window.clearInterval(id); };
449
+ }, [source]);
450
+
405
451
  // Reset request-log pagination when the queried range or source changes
406
452
  useEffect(() => { setLogPage(1); }, [range, customFrom, customTo, source]);
407
453
 
@@ -659,6 +705,29 @@ export function DashboardPage() {
659
705
  ))}
660
706
  </div>
661
707
  {source === "chatgpt" && <span className="dashboard-source-note">{t("dashboard.source_chatgpt_note")}</span>}
708
+ {source === "pi" && codexUsage && (
709
+ <div className="ml-auto flex flex-wrap items-center gap-2 text-xs" style={{ color: "var(--muted-text)" }}>
710
+ <span className={cn("inline-flex items-center gap-1 font-medium", codexUsage.loggedIn ? "text-emerald-400" : "text-gray-500")}>
711
+ <span className="h-1.5 w-1.5 rounded-full bg-current" />
712
+ {codexUsage.loggedIn ? t("dashboard.codex_logged_in") : t("dashboard.codex_not_logged_in")}
713
+ </span>
714
+ {codexUsage.loggedIn && codexUsage.primary && codexUsage.secondary && (
715
+ <>
716
+ <span className="rounded border border-gray-700 px-2 py-1 font-mono">
717
+ {t("dashboard.codex_5h")}: {t("dashboard.codex_remaining", `${codexUsage.primary.remainingPercent}%`)}
718
+ {formatRemainingTime(codexUsage.primary.resetAfterSeconds) && ` · ${formatRemainingTime(codexUsage.primary.resetAfterSeconds)}`}
719
+ {formatResetAt(codexUsage.primary.resetAt, lang) && ` · ${t("dashboard.codex_resets", formatResetAt(codexUsage.primary.resetAt, lang)!)}`}
720
+ </span>
721
+ <span className="rounded border border-gray-700 px-2 py-1 font-mono">
722
+ {t("dashboard.codex_7d")}: {t("dashboard.codex_remaining", `${codexUsage.secondary.remainingPercent}%`)}
723
+ {formatRemainingTime(codexUsage.secondary.resetAfterSeconds) && ` · ${formatRemainingTime(codexUsage.secondary.resetAfterSeconds)}`}
724
+ {formatResetAt(codexUsage.secondary.resetAt, lang) && ` · ${t("dashboard.codex_resets", formatResetAt(codexUsage.secondary.resetAt, lang)!)}`}
725
+ </span>
726
+ </>
727
+ )}
728
+ {codexUsage.error && <span className="text-amber-400">{t("dashboard.codex_quota_unavailable")}</span>}
729
+ </div>
730
+ )}
662
731
  </div>
663
732
 
664
733
  {/* Custom Date Picker */}