pi extensions update
This commit is contained in:
@@ -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:",
|
||||
|
||||
Reference in New Issue
Block a user