pi extensions update

This commit is contained in:
Jonas H
2026-03-24 09:10:04 +01:00
parent 0b11a0d315
commit 677f5d8ca5
4 changed files with 666 additions and 12 deletions

View File

@@ -36,6 +36,64 @@ const PROFILES_DIR = path.join(HOME, ".pi/agent/profiles");
type Account = "personal" | "work";
// ── Session-window helpers ─────────────────────────────────────────────────
//
// We store the actual `resets_at` timestamp returned by Claude's usage API
// (via the usage:update event) so the switch menu can show a live countdown
// to the next session reset rather than a guessed switchedAt + 5h window.
function sessionStampPath(account: Account): string {
return path.join(PROFILES_DIR, `session-${account}.json`);
}
/** Persist the actual session-reset timestamp for an account. */
function saveSessionResetsAt(account: Account, resetsAt: number): void {
try {
fs.mkdirSync(PROFILES_DIR, { recursive: true });
fs.writeFileSync(
sessionStampPath(account),
JSON.stringify({ resetsAt }, null, 2),
{ mode: 0o600 },
);
} catch {}
}
/** Load the stored session-reset timestamp (ms epoch), or null. */
function loadSessionResetsAt(account: Account): number | null {
try {
const raw = fs.readFileSync(sessionStampPath(account), "utf-8");
const { resetsAt } = JSON.parse(raw) as { resetsAt: number };
if (typeof resetsAt === "number") return resetsAt;
} catch {}
return null;
}
/** Format milliseconds as a compact duration string. */
function formatDuration(ms: number): string {
const totalSec = Math.ceil(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m`;
return `${s}s`;
}
/**
* One-line session summary appended to each account option in the select menu.
*
* Window still running: [resets in Xh Ym]
* Window already passed: [0 (ready)]
* Never recorded: (empty)
*/
function sessionSummary(account: Account): string {
const resetsAt = loadSessionResetsAt(account);
if (resetsAt === null) return "";
const remaining = resetsAt - Date.now();
if (remaining <= 0) return " [0 (ready)]";
return ` [resets in ${formatDuration(remaining)}]`;
}
// ── Profile helpers ────────────────────────────────────────────────────────
function profilePath(account: Account): string {
@@ -50,22 +108,38 @@ function hasProfile(account: Account): boolean {
* Save auth.json content directly to a profile file.
* This captures the exact on-disk state, including any tokens that were
* refreshed behind our back by the auth system.
*
* We parse + re-serialize the JSON to guard against corrupt auth.json
* (e.g. trailing commas left by buggy serializers). If the file can't
* be parsed, we skip the save rather than propagate bad data.
*/
function saveCurrentAuthToProfile(account: Account): void {
function saveCurrentAuthToProfile(account: Account): boolean {
fs.mkdirSync(PROFILES_DIR, { recursive: true });
if (fs.existsSync(AUTH_JSON)) {
fs.copyFileSync(AUTH_JSON, profilePath(account));
fs.chmodSync(profilePath(account), 0o600);
if (!fs.existsSync(AUTH_JSON)) return false;
try {
const raw = fs.readFileSync(AUTH_JSON, "utf-8");
const parsed = JSON.parse(raw); // validates JSON
const clean = JSON.stringify(parsed, null, 2);
fs.writeFileSync(profilePath(account), clean, { mode: 0o600 });
return true;
} catch {
// auth.json is missing or corrupt — don't propagate bad data
return false;
}
}
/**
* Copy a profile file to auth.json. This is an atomic-ish swap that
* replaces the entire file rather than merging per-provider.
*
* Like saveCurrentAuthToProfile, we round-trip through JSON.parse to
* ensure we never write corrupt data to auth.json.
*/
function restoreProfileToAuth(account: Account): void {
fs.copyFileSync(profilePath(account), AUTH_JSON);
fs.chmodSync(AUTH_JSON, 0o600);
const raw = fs.readFileSync(profilePath(account), "utf-8");
const parsed = JSON.parse(raw); // throws on corrupt profile
const clean = JSON.stringify(parsed, null, 2);
fs.writeFileSync(AUTH_JSON, clean, { mode: 0o600 });
}
function setMarker(account: Account): void {
@@ -131,6 +205,15 @@ function statusLabel(account: Account | "unknown"): string {
export default function (pi: ExtensionAPI) {
let currentAccount: Account | "unknown" = "unknown";
// Whenever usage-bars fetches fresh data, save the real resets_at for the
// current account so the switch menu shows an accurate live countdown.
pi.events.on("usage:update", (event: unknown) => {
const e = event as { sessionResetsAt?: number };
if (currentAccount !== "unknown" && typeof e.sessionResetsAt === "number") {
saveSessionResetsAt(currentAccount, e.sessionResetsAt);
}
});
pi.on("session_start", async (_event, ctx) => {
// Proper-lockfile creates auth.json.lock as a *directory* (atomic mkdir).
// If a regular file exists at that path (e.g. left by an older pi version),
@@ -148,6 +231,21 @@ export default function (pi: ExtensionAPI) {
// lock doesn't exist or we can't stat it — nothing to fix
}
// Guard against corrupt auth.json (e.g. trailing commas from buggy
// serializers). Re-serialize to clean JSON and reload so the auth
// system picks up valid credentials.
try {
const raw = fs.readFileSync(AUTH_JSON, "utf-8");
const parsed = JSON.parse(raw);
const clean = JSON.stringify(parsed, null, 2);
if (clean !== raw) {
fs.writeFileSync(AUTH_JSON, clean, { mode: 0o600 });
ctx.modelRegistry.authStorage.reload();
}
} catch {
// auth.json missing or unparseable — nothing we can fix here
}
currentAccount = getCurrentAccount();
ctx.ui.setStatus("claude-account", statusLabel(currentAccount));
});
@@ -189,13 +287,14 @@ export default function (pi: ExtensionAPI) {
return;
}
// ── Resolve target account (direct arg or interactive) ──────────
let newAccount: Account;
if (trimmed === "personal" || trimmed === "work") {
newAccount = trimmed;
} else if (trimmed === "") {
const personalLabel = ` personal${currentAccount === "personal" ? " ← current" : ""}${!hasProfile("personal") ? " (no profile saved)" : ""}`;
const workLabel = `󰃖 work${currentAccount === "work" ? " ← current" : ""}${!hasProfile("work") ? " (no profile saved)" : ""}`;
const personalLabel = ` personal${currentAccount === "personal" ? " ← current" : ""}${!hasProfile("personal") ? " (no profile saved)" : ""}${sessionSummary("personal")}`;
const workLabel = `󰃖 work${currentAccount === "work" ? " ← current" : ""}${!hasProfile("work") ? " (no profile saved)" : ""}${sessionSummary("work")}`;
const accountChoice = await ctx.ui.select(
"Switch Claude account:",