From fff80f12f34651041df08fcce4bf3ba29821cf0f Mon Sep 17 00:00:00 2001 From: alexion Date: Mon, 7 Sep 2026 22:45:23 -0400 Subject: [PATCH] refactor(pi): move extensions into monorepo config --- .pi/extensions/compact-status.ts | 255 +++++++++++++++- .pi/extensions/herdr-agent-state.ts | 288 +++++++++++++++++- configs/nixos/AGENTS.md | 10 +- .../shell/pi/extensions/compact-status.ts | 254 --------------- .../shell/pi/extensions/herdr-agent-state.ts | 287 ----------------- 5 files changed, 546 insertions(+), 548 deletions(-) mode change 120000 => 100644 .pi/extensions/compact-status.ts mode change 120000 => 100644 .pi/extensions/herdr-agent-state.ts delete mode 100644 configs/nixos/modules/shell/pi/extensions/compact-status.ts delete mode 100644 configs/nixos/modules/shell/pi/extensions/herdr-agent-state.ts diff --git a/.pi/extensions/compact-status.ts b/.pi/extensions/compact-status.ts deleted file mode 120000 index a8a3f62..0000000 --- a/.pi/extensions/compact-status.ts +++ /dev/null @@ -1 +0,0 @@ -../../configs/nixos/modules/shell/pi/extensions/compact-status.ts \ No newline at end of file diff --git a/.pi/extensions/compact-status.ts b/.pi/extensions/compact-status.ts new file mode 100644 index 0000000..bb4c00c --- /dev/null +++ b/.pi/extensions/compact-status.ts @@ -0,0 +1,254 @@ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, join } from "node:path"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; + +type QuotaState = + | { status: "idle" | "loading" } + | { status: "ok"; detail: string; refreshedAt: number; weeklyRemaining?: number; shortRemaining?: number } + | { status: "missing" | "error"; detail: string; refreshedAt?: number }; + +const CODEX_USAGE_ENDPOINTS = [ + "https://chatgpt.com/backend-api/wham/usage", + "https://chatgpt.com/backend-api/codex/usage", +]; +const QUOTA_REFRESH_MS = 5 * 60 * 1000; +const REQUEST_TIMEOUT_MS = 10_000; + +let quotaState: QuotaState = { status: "idle" }; +let quotaRefreshPromise: Promise | null = null; + +function shortCwd(cwd: string): string { + const home = process.env.HOME; + if (home && cwd.startsWith(`${home}/`)) return `~/${basename(cwd)}`; + return basename(cwd) || cwd; +} + +function gitBranch(cwd: string): string | null { + try { + const out = execFileSync("git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + return out || null; + } catch { + return null; + } +} + +function authPath(): string { + return join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "auth.json"); +} + +function readCodexCredentials(): { access: string; accountId?: string } | null { + const file = authPath(); + if (!existsSync(file)) return null; + try { + const auth = JSON.parse(readFileSync(file, "utf8")); + const credential = auth?.["openai-codex"]; + if (credential?.type !== "oauth" || typeof credential.access !== "string") return null; + if (typeof credential.expires === "number" && credential.expires <= Date.now() + 30_000) return null; + return { + access: credential.access, + accountId: typeof credential.accountId === "string" ? credential.accountId : undefined, + }; + } catch { + return null; + } +} + +function numberValue(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +function objectValue(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; +} + +function windowSeconds(raw: Record): number | undefined { + const seconds = numberValue(raw.limit_window_seconds ?? raw.windowSeconds); + if (seconds !== undefined) return seconds; + const mins = numberValue(raw.windowDurationMins ?? raw.window_duration_mins); + return mins === undefined ? undefined : mins * 60; +} + +function usedPercent(raw: Record): number | undefined { + const value = numberValue(raw.used_percent ?? raw.usedPercent); + if (value === undefined) return undefined; + return Math.max(0, Math.min(100, value)); +} + +function collectWindows(raw: unknown, out: Array<{ seconds?: number; used: number; key: string }> = [], key = "root") { + if (Array.isArray(raw)) { + raw.forEach((item, index) => collectWindows(item, out, `${key}.${index}`)); + return out; + } + const obj = objectValue(raw); + if (!obj) return out; + const used = usedPercent(obj); + if (used !== undefined) out.push({ seconds: windowSeconds(obj), used, key }); + for (const [childKey, value] of Object.entries(obj)) { + if (value && typeof value === "object") collectWindows(value, out, `${key}.${childKey}`); + } + return out; +} + +function pickQuotaWindows(raw: unknown): { weeklyRemaining?: number; shortRemaining?: number } | null { + const windows = collectWindows(raw); + if (windows.length === 0) return null; + const weekly = windows.find((window) => window.seconds !== undefined && Math.abs(window.seconds - 604_800) <= 60 * 60) + ?? windows.find((window) => /week|weekly|secondary/i.test(window.key)); + const short = windows.find((window) => window.seconds !== undefined && Math.abs(window.seconds - 18_000) <= 60 * 30) + ?? windows.find((window) => /five|session|primary|short/i.test(window.key)); + return { + weeklyRemaining: weekly ? Math.max(0, Math.min(100, 100 - weekly.used)) : undefined, + shortRemaining: short ? Math.max(0, Math.min(100, 100 - short.used)) : undefined, + }; +} + +function safeFg(theme: any, color: string, text: string): string { + try { + return theme.fg(color, text); + } catch { + return theme.fg("accent", text); + } +} + +function contextColor(percent: number): string { + if (percent >= 90) return "error"; + if (percent >= 70) return "warning"; + return "success"; +} + +function quotaColor(percent: number): string { + if (percent >= 80) return "error"; + if (percent >= 50) return "warning"; + return "border"; +} + +function bar(theme: any, width: number, percent: number | null, glyph: string, colorForPercent: (percent: number) => string): string { + const barWidth = Math.max(12, width); + if (percent === null) return theme.fg("muted", glyph.repeat(barWidth)); + const clamped = Math.max(0, Math.min(100, percent)); + const filled = Math.max(0, Math.min(barWidth, Math.round((clamped / 100) * barWidth))); + const empty = Math.max(0, barWidth - filled); + return safeFg(theme, colorForPercent(clamped), glyph.repeat(filled)) + theme.fg("dim", glyph.repeat(empty)); +} + +async function fetchCodexQuota(force = false): Promise { + const fresh = quotaState.status === "ok" && Date.now() - quotaState.refreshedAt < QUOTA_REFRESH_MS; + if (!force && fresh) return; + if (quotaRefreshPromise) return quotaRefreshPromise; + + quotaState = { status: "loading" }; + quotaRefreshPromise = (async () => { + const credentials = readCodexCredentials(); + if (!credentials) { + quotaState = { status: "missing", detail: "OpenAI Codex OAuth credentials were not found or are expired" }; + return; + } + + let lastError = "quota unavailable"; + for (const endpoint of CODEX_USAGE_ENDPOINTS) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const headers: Record = { Authorization: `Bearer ${credentials.access}` }; + if (credentials.accountId) headers["ChatGPT-Account-Id"] = credentials.accountId; + const response = await fetch(endpoint, { headers, signal: controller.signal }); + if (!response.ok) { + lastError = `${response.status} ${response.statusText}`; + continue; + } + const windows = pickQuotaWindows(await response.json()); + if (!windows || (windows.weeklyRemaining === undefined && windows.shortRemaining === undefined)) { + lastError = "response had no recognized quota windows"; + continue; + } + const details = []; + if (windows.weeklyRemaining !== undefined) details.push(`weekly ${Math.round(windows.weeklyRemaining)}%`); + if (windows.shortRemaining !== undefined) details.push(`short ${Math.round(windows.shortRemaining)}%`); + quotaState = { + status: "ok", + detail: `Codex quota remaining: ${details.join(", ")}`, + weeklyRemaining: windows.weeklyRemaining, + shortRemaining: windows.shortRemaining, + refreshedAt: Date.now(), + }; + return; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } finally { + clearTimeout(timeout); + } + } + quotaState = { status: "error", detail: `Codex quota failed: ${lastError}`, refreshedAt: Date.now() }; + })().finally(() => { + quotaRefreshPromise = null; + }); + return quotaRefreshPromise; +} + +function statusLines(ctx: any, theme: any, width: number): string[] { + const cwd = ctx.sessionManager?.getCwd?.() ?? ctx.cwd ?? process.cwd(); + const branch = gitBranch(cwd); + const where = branch ? `󰉋 ${shortCwd(cwd)} 󰘬 ${branch}` : `󰉋 ${shortCwd(cwd)}`; + const model = ctx.model?.id ?? process.env.PI_MODEL ?? "no-model"; + const thinking = ctx.thinkingLevel ?? process.env.PI_REASONING_LEVEL ?? "off"; + const left = theme.fg("accent", where); + const right = theme.fg("dim", `${model} • ${thinking}`); + const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right))); + const contextPercentRaw = ctx.getContextUsage?.()?.percent; + const contextPercent = typeof contextPercentRaw === "number" && Number.isFinite(contextPercentRaw) ? contextPercentRaw : null; + const quotaConsumed = quotaState.status === "ok" && quotaState.weeklyRemaining !== undefined + ? 100 - quotaState.weeklyRemaining + : null; + return [ + truncateToWidth(left + pad + right, width), + bar(theme, width, contextPercent, "▃", contextColor), + bar(theme, width, quotaConsumed, "▔", quotaColor), + ]; +} + +function setCompactStatusUi(ctx: any) { + if (!ctx.hasUI) return; + ctx.ui.setWidget("compact-status", (_tui: any, theme: any) => ({ + invalidate() {}, + render(width: number) { + return statusLines(ctx, theme, width); + }, + })); + ctx.ui.setFooter(() => ({ invalidate() {}, render: () => [] })); +} + +export default function compactStatus(pi: ExtensionAPI) { + function refreshUi(ctx: any) { + setCompactStatusUi(ctx); + } + + pi.on("session_start", (_event, ctx) => { + refreshUi(ctx); + void fetchCodexQuota(false).then(() => refreshUi(ctx)); + }); + pi.on("model_select", (_event, ctx) => refreshUi(ctx)); + pi.on("agent_settled", (_event, ctx) => refreshUi(ctx)); + + pi.registerCommand("codex-quota", { + description: "Refresh and show ChatGPT Codex quota", + handler: async (_args, ctx) => { + refreshUi(ctx); + await fetchCodexQuota(true); + refreshUi(ctx); + const level = quotaState.status === "ok" ? "info" : quotaState.status === "missing" ? "warning" : "error"; + ctx.ui.notify(quotaState.status === "idle" || quotaState.status === "loading" ? "Codex quota refresh in progress" : quotaState.detail, level); + }, + }); +} diff --git a/.pi/extensions/herdr-agent-state.ts b/.pi/extensions/herdr-agent-state.ts deleted file mode 120000 index fc25abc..0000000 --- a/.pi/extensions/herdr-agent-state.ts +++ /dev/null @@ -1 +0,0 @@ -../../configs/nixos/modules/shell/pi/extensions/herdr-agent-state.ts \ No newline at end of file diff --git a/.pi/extensions/herdr-agent-state.ts b/.pi/extensions/herdr-agent-state.ts new file mode 100644 index 0000000..caa50ed --- /dev/null +++ b/.pi/extensions/herdr-agent-state.ts @@ -0,0 +1,287 @@ +// installed by herdr +// managed by herdr; reinstalling or updating the integration overwrites this file. +// add custom hooks/plugins beside this file instead of editing it. +// HERDR_INTEGRATION_ID=pi +// HERDR_INTEGRATION_VERSION=6 +// @ts-nocheck + +import net from "node:net"; + +const HERDR_ENV = process.env.HERDR_ENV; +const socketPath = process.env.HERDR_SOCKET_PATH; +const socketEndpoint = + process.platform === "win32" && socketPath ? `\\\\.\\pipe\\${socketPath}` : socketPath; +const paneId = process.env.HERDR_PANE_ID; +const source = "herdr:pi"; + +function enabled() { + return HERDR_ENV === "1" && !!socketPath && !!paneId; +} + +function sendRequestAttempt(request: unknown, timeoutMs: number): Promise { + if (!enabled()) { + return Promise.resolve(true); + } + + return new Promise((resolve) => { + let done = false; + let timeout: ReturnType | undefined; + const finish = (delivered: boolean) => { + if (done) return; + done = true; + if (timeout) { + clearTimeout(timeout); + } + socket.destroy(); + resolve(delivered); + }; + + const socket = net.createConnection(socketEndpoint!); + socket.on("error", () => finish(false)); + socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`)); + socket.on("data", () => finish(true)); + socket.on("end", () => finish(false)); + timeout = setTimeout(() => finish(false), timeoutMs); + timeout.unref?.(); + }); +} + +async function sendRequest(request: unknown): Promise { + if (await sendRequestAttempt(request, 500)) { + return; + } + await sendRequestAttempt(request, 1500); +} + +type AgentState = "working" | "blocked" | "idle"; + +type QueuedState = { + state: AgentState; + message?: string; + seq: number; +}; + +let reportSeq = Date.now() * 1000; +let currentAgentSessionId: string | undefined; +let currentAgentSessionPath: string | undefined; + +function nextReportSeq(): number { + reportSeq += 1; + return reportSeq; +} + +function updateSessionRef(ctx: any): void { + try { + const file = ctx?.sessionManager?.getSessionFile?.(); + currentAgentSessionPath = + typeof file === "string" && file.startsWith("/") ? file : undefined; + } catch { + currentAgentSessionPath = undefined; + } + + try { + const id = ctx?.sessionManager?.getSessionId?.(); + currentAgentSessionId = typeof id === "string" && id.length > 0 ? id : undefined; + } catch { + currentAgentSessionId = undefined; + } +} + +function withSessionRef(params: Record): Record { + if (currentAgentSessionPath) { + return { ...params, agent_session_path: currentAgentSessionPath }; + } + if (currentAgentSessionId) { + return { ...params, agent_session_id: currentAgentSessionId }; + } + return params; +} + +function currentSessionRef(): Record | undefined { + if (currentAgentSessionPath) { + return { agent_session_path: currentAgentSessionPath }; + } + if (currentAgentSessionId) { + return { agent_session_id: currentAgentSessionId }; + } + return undefined; +} + +function reportSession(sessionStartSource?: string): Promise { + const sessionRef = currentSessionRef(); + if (!sessionRef) { + return Promise.resolve(); + } + + return sendRequest({ + id: `${source}:session:${Date.now()}:${Math.random().toString(36).slice(2)}`, + method: "pane.report_agent_session", + params: { + pane_id: paneId, + source, + agent: "pi", + seq: nextReportSeq(), + session_start_source: sessionStartSource, + ...sessionRef, + }, + }); +} + +function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise { + return sendRequest({ + id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`, + method: "pane.report_agent", + params: withSessionRef({ + pane_id: paneId, + source, + agent: "pi", + state, + message, + seq, + }), + }); +} + +function releaseAgent(): Promise { + return sendRequest({ + id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`, + method: "pane.release_agent", + params: { + pane_id: paneId, + source, + agent: "pi", + seq: nextReportSeq(), + }, + }); +} + +function shouldReleaseOnSessionShutdown(event: any): boolean { + // Pi tears down and rebinds extension runtimes for internal lifecycle actions + // such as /reload, /new, /resume, and /fork. Those do not mean the pane's + // agent process has exited, and releasing hook authority there can suppress + // legitimate reports from the replacement runtime. Only a user/process quit + // should release Herdr's full-lifecycle authority. + const reason = event?.reason; + return reason === "quit"; +} + +let sendInFlight = false; +let queuedState: QueuedState | undefined; + +function queueState(state: AgentState, message?: string): void { + queuedState = { state, message, seq: nextReportSeq() }; + if (!sendInFlight) { + void drainStateQueue(); + } +} + +async function drainStateQueue(): Promise { + if (sendInFlight) { + return; + } + + sendInFlight = true; + try { + while (queuedState) { + const next = queuedState; + queuedState = undefined; + await sendState(next.state, next.message, next.seq); + } + } finally { + sendInFlight = false; + if (queuedState) { + void drainStateQueue(); + } + } +} + +export default function (pi) { + if (!enabled()) { + return; + } + + let agentActive = false; + let blockedCount = 0; + let blockedMessage: string | undefined; + let lastState: AgentState | undefined; + let lastMessage: string | undefined; + let rootSession = false; + + function desiredState() { + if (blockedCount > 0) { + return { state: "blocked" as const, message: blockedMessage }; + } + if (agentActive) { + return { state: "working" as const, message: undefined }; + } + return { state: "idle" as const, message: undefined }; + } + + function publishState(force = false) { + const next = desiredState(); + if (!force && next.state === lastState && next.message === lastMessage) { + return; + } + lastState = next.state; + lastMessage = next.message; + queueState(next.state, next.message); + } + + pi.events.on("herdr:blocked", (data) => { + if (!rootSession) { + return; + } + if (!data?.active) { + blockedCount = Math.max(0, blockedCount - 1); + if (blockedCount === 0) { + blockedMessage = undefined; + } + publishState(); + return; + } + + blockedCount += 1; + blockedMessage = data.label; + publishState(); + }); + + pi.on("session_start", async (event, ctx) => { + if (ctx?.hasUI !== true) { + return; + } + rootSession = true; + updateSessionRef(ctx); + await reportSession(event?.reason); + // A reload can replace this extension mid-run without emitting another agent_start. + agentActive = ctx?.isIdle?.() === false; + publishState(true); + }); + + pi.on("agent_start", (_event, ctx) => { + if (!rootSession) { + return; + } + updateSessionRef(ctx); + void reportSession(); + agentActive = true; + publishState(); + }); + + pi.on("agent_settled", (_event, ctx) => { + if (!rootSession || ctx?.isIdle?.() !== true) { + return; + } + + agentActive = false; + publishState(); + }); + + pi.on("session_shutdown", async (event) => { + if (!rootSession) { + return; + } + if (shouldReleaseOnSessionShutdown(event)) { + await releaseAgent(); + } + }); +} diff --git a/configs/nixos/AGENTS.md b/configs/nixos/AGENTS.md index e2b862f..eaffab5 100644 --- a/configs/nixos/AGENTS.md +++ b/configs/nixos/AGENTS.md @@ -32,11 +32,11 @@ - Use produced literals for Herdr key names that include shifted punctuation. For example, use `prefix+<` instead of `prefix+shift+comma`. -- Put global Pi extension sources in `configs/nixos/modules/shell/pi/extensions/`. - Home Manager deploys them to `~/.pi/agent/extensions/`. -- Treat Home Manager-managed Pi extension, prompt, and skill directories as generated output. - Do not edit those directories except for throwaway development or local experiments. - Promote finished work into the NixOS configuration before treating it as deployed. +- Put monorepo Pi extension sources in `.pi/extensions/`. + Pi discovers them from subdirectories through the local ancestor-resource patch. +- Treat Home Manager-managed Pi settings as generated output. + Do not edit them except for throwaway development or local experiments. + Promote finished settings into the NixOS configuration before treating them as deployed. ## Gotchas diff --git a/configs/nixos/modules/shell/pi/extensions/compact-status.ts b/configs/nixos/modules/shell/pi/extensions/compact-status.ts deleted file mode 100644 index bb4c00c..0000000 --- a/configs/nixos/modules/shell/pi/extensions/compact-status.ts +++ /dev/null @@ -1,254 +0,0 @@ -import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { basename, join } from "node:path"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; - -type QuotaState = - | { status: "idle" | "loading" } - | { status: "ok"; detail: string; refreshedAt: number; weeklyRemaining?: number; shortRemaining?: number } - | { status: "missing" | "error"; detail: string; refreshedAt?: number }; - -const CODEX_USAGE_ENDPOINTS = [ - "https://chatgpt.com/backend-api/wham/usage", - "https://chatgpt.com/backend-api/codex/usage", -]; -const QUOTA_REFRESH_MS = 5 * 60 * 1000; -const REQUEST_TIMEOUT_MS = 10_000; - -let quotaState: QuotaState = { status: "idle" }; -let quotaRefreshPromise: Promise | null = null; - -function shortCwd(cwd: string): string { - const home = process.env.HOME; - if (home && cwd.startsWith(`${home}/`)) return `~/${basename(cwd)}`; - return basename(cwd) || cwd; -} - -function gitBranch(cwd: string): string | null { - try { - const out = execFileSync("git", ["--no-optional-locks", "symbolic-ref", "--quiet", "--short", "HEAD"], { - cwd, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); - return out || null; - } catch { - return null; - } -} - -function authPath(): string { - return join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "auth.json"); -} - -function readCodexCredentials(): { access: string; accountId?: string } | null { - const file = authPath(); - if (!existsSync(file)) return null; - try { - const auth = JSON.parse(readFileSync(file, "utf8")); - const credential = auth?.["openai-codex"]; - if (credential?.type !== "oauth" || typeof credential.access !== "string") return null; - if (typeof credential.expires === "number" && credential.expires <= Date.now() + 30_000) return null; - return { - access: credential.access, - accountId: typeof credential.accountId === "string" ? credential.accountId : undefined, - }; - } catch { - return null; - } -} - -function numberValue(value: unknown): number | undefined { - if (typeof value === "number" && Number.isFinite(value)) return value; - if (typeof value === "string" && value.trim() !== "") { - const parsed = Number(value); - if (Number.isFinite(parsed)) return parsed; - } - return undefined; -} - -function objectValue(value: unknown): Record | undefined { - return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; -} - -function windowSeconds(raw: Record): number | undefined { - const seconds = numberValue(raw.limit_window_seconds ?? raw.windowSeconds); - if (seconds !== undefined) return seconds; - const mins = numberValue(raw.windowDurationMins ?? raw.window_duration_mins); - return mins === undefined ? undefined : mins * 60; -} - -function usedPercent(raw: Record): number | undefined { - const value = numberValue(raw.used_percent ?? raw.usedPercent); - if (value === undefined) return undefined; - return Math.max(0, Math.min(100, value)); -} - -function collectWindows(raw: unknown, out: Array<{ seconds?: number; used: number; key: string }> = [], key = "root") { - if (Array.isArray(raw)) { - raw.forEach((item, index) => collectWindows(item, out, `${key}.${index}`)); - return out; - } - const obj = objectValue(raw); - if (!obj) return out; - const used = usedPercent(obj); - if (used !== undefined) out.push({ seconds: windowSeconds(obj), used, key }); - for (const [childKey, value] of Object.entries(obj)) { - if (value && typeof value === "object") collectWindows(value, out, `${key}.${childKey}`); - } - return out; -} - -function pickQuotaWindows(raw: unknown): { weeklyRemaining?: number; shortRemaining?: number } | null { - const windows = collectWindows(raw); - if (windows.length === 0) return null; - const weekly = windows.find((window) => window.seconds !== undefined && Math.abs(window.seconds - 604_800) <= 60 * 60) - ?? windows.find((window) => /week|weekly|secondary/i.test(window.key)); - const short = windows.find((window) => window.seconds !== undefined && Math.abs(window.seconds - 18_000) <= 60 * 30) - ?? windows.find((window) => /five|session|primary|short/i.test(window.key)); - return { - weeklyRemaining: weekly ? Math.max(0, Math.min(100, 100 - weekly.used)) : undefined, - shortRemaining: short ? Math.max(0, Math.min(100, 100 - short.used)) : undefined, - }; -} - -function safeFg(theme: any, color: string, text: string): string { - try { - return theme.fg(color, text); - } catch { - return theme.fg("accent", text); - } -} - -function contextColor(percent: number): string { - if (percent >= 90) return "error"; - if (percent >= 70) return "warning"; - return "success"; -} - -function quotaColor(percent: number): string { - if (percent >= 80) return "error"; - if (percent >= 50) return "warning"; - return "border"; -} - -function bar(theme: any, width: number, percent: number | null, glyph: string, colorForPercent: (percent: number) => string): string { - const barWidth = Math.max(12, width); - if (percent === null) return theme.fg("muted", glyph.repeat(barWidth)); - const clamped = Math.max(0, Math.min(100, percent)); - const filled = Math.max(0, Math.min(barWidth, Math.round((clamped / 100) * barWidth))); - const empty = Math.max(0, barWidth - filled); - return safeFg(theme, colorForPercent(clamped), glyph.repeat(filled)) + theme.fg("dim", glyph.repeat(empty)); -} - -async function fetchCodexQuota(force = false): Promise { - const fresh = quotaState.status === "ok" && Date.now() - quotaState.refreshedAt < QUOTA_REFRESH_MS; - if (!force && fresh) return; - if (quotaRefreshPromise) return quotaRefreshPromise; - - quotaState = { status: "loading" }; - quotaRefreshPromise = (async () => { - const credentials = readCodexCredentials(); - if (!credentials) { - quotaState = { status: "missing", detail: "OpenAI Codex OAuth credentials were not found or are expired" }; - return; - } - - let lastError = "quota unavailable"; - for (const endpoint of CODEX_USAGE_ENDPOINTS) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - try { - const headers: Record = { Authorization: `Bearer ${credentials.access}` }; - if (credentials.accountId) headers["ChatGPT-Account-Id"] = credentials.accountId; - const response = await fetch(endpoint, { headers, signal: controller.signal }); - if (!response.ok) { - lastError = `${response.status} ${response.statusText}`; - continue; - } - const windows = pickQuotaWindows(await response.json()); - if (!windows || (windows.weeklyRemaining === undefined && windows.shortRemaining === undefined)) { - lastError = "response had no recognized quota windows"; - continue; - } - const details = []; - if (windows.weeklyRemaining !== undefined) details.push(`weekly ${Math.round(windows.weeklyRemaining)}%`); - if (windows.shortRemaining !== undefined) details.push(`short ${Math.round(windows.shortRemaining)}%`); - quotaState = { - status: "ok", - detail: `Codex quota remaining: ${details.join(", ")}`, - weeklyRemaining: windows.weeklyRemaining, - shortRemaining: windows.shortRemaining, - refreshedAt: Date.now(), - }; - return; - } catch (error) { - lastError = error instanceof Error ? error.message : String(error); - } finally { - clearTimeout(timeout); - } - } - quotaState = { status: "error", detail: `Codex quota failed: ${lastError}`, refreshedAt: Date.now() }; - })().finally(() => { - quotaRefreshPromise = null; - }); - return quotaRefreshPromise; -} - -function statusLines(ctx: any, theme: any, width: number): string[] { - const cwd = ctx.sessionManager?.getCwd?.() ?? ctx.cwd ?? process.cwd(); - const branch = gitBranch(cwd); - const where = branch ? `󰉋 ${shortCwd(cwd)} 󰘬 ${branch}` : `󰉋 ${shortCwd(cwd)}`; - const model = ctx.model?.id ?? process.env.PI_MODEL ?? "no-model"; - const thinking = ctx.thinkingLevel ?? process.env.PI_REASONING_LEVEL ?? "off"; - const left = theme.fg("accent", where); - const right = theme.fg("dim", `${model} • ${thinking}`); - const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right))); - const contextPercentRaw = ctx.getContextUsage?.()?.percent; - const contextPercent = typeof contextPercentRaw === "number" && Number.isFinite(contextPercentRaw) ? contextPercentRaw : null; - const quotaConsumed = quotaState.status === "ok" && quotaState.weeklyRemaining !== undefined - ? 100 - quotaState.weeklyRemaining - : null; - return [ - truncateToWidth(left + pad + right, width), - bar(theme, width, contextPercent, "▃", contextColor), - bar(theme, width, quotaConsumed, "▔", quotaColor), - ]; -} - -function setCompactStatusUi(ctx: any) { - if (!ctx.hasUI) return; - ctx.ui.setWidget("compact-status", (_tui: any, theme: any) => ({ - invalidate() {}, - render(width: number) { - return statusLines(ctx, theme, width); - }, - })); - ctx.ui.setFooter(() => ({ invalidate() {}, render: () => [] })); -} - -export default function compactStatus(pi: ExtensionAPI) { - function refreshUi(ctx: any) { - setCompactStatusUi(ctx); - } - - pi.on("session_start", (_event, ctx) => { - refreshUi(ctx); - void fetchCodexQuota(false).then(() => refreshUi(ctx)); - }); - pi.on("model_select", (_event, ctx) => refreshUi(ctx)); - pi.on("agent_settled", (_event, ctx) => refreshUi(ctx)); - - pi.registerCommand("codex-quota", { - description: "Refresh and show ChatGPT Codex quota", - handler: async (_args, ctx) => { - refreshUi(ctx); - await fetchCodexQuota(true); - refreshUi(ctx); - const level = quotaState.status === "ok" ? "info" : quotaState.status === "missing" ? "warning" : "error"; - ctx.ui.notify(quotaState.status === "idle" || quotaState.status === "loading" ? "Codex quota refresh in progress" : quotaState.detail, level); - }, - }); -} diff --git a/configs/nixos/modules/shell/pi/extensions/herdr-agent-state.ts b/configs/nixos/modules/shell/pi/extensions/herdr-agent-state.ts deleted file mode 100644 index caa50ed..0000000 --- a/configs/nixos/modules/shell/pi/extensions/herdr-agent-state.ts +++ /dev/null @@ -1,287 +0,0 @@ -// installed by herdr -// managed by herdr; reinstalling or updating the integration overwrites this file. -// add custom hooks/plugins beside this file instead of editing it. -// HERDR_INTEGRATION_ID=pi -// HERDR_INTEGRATION_VERSION=6 -// @ts-nocheck - -import net from "node:net"; - -const HERDR_ENV = process.env.HERDR_ENV; -const socketPath = process.env.HERDR_SOCKET_PATH; -const socketEndpoint = - process.platform === "win32" && socketPath ? `\\\\.\\pipe\\${socketPath}` : socketPath; -const paneId = process.env.HERDR_PANE_ID; -const source = "herdr:pi"; - -function enabled() { - return HERDR_ENV === "1" && !!socketPath && !!paneId; -} - -function sendRequestAttempt(request: unknown, timeoutMs: number): Promise { - if (!enabled()) { - return Promise.resolve(true); - } - - return new Promise((resolve) => { - let done = false; - let timeout: ReturnType | undefined; - const finish = (delivered: boolean) => { - if (done) return; - done = true; - if (timeout) { - clearTimeout(timeout); - } - socket.destroy(); - resolve(delivered); - }; - - const socket = net.createConnection(socketEndpoint!); - socket.on("error", () => finish(false)); - socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`)); - socket.on("data", () => finish(true)); - socket.on("end", () => finish(false)); - timeout = setTimeout(() => finish(false), timeoutMs); - timeout.unref?.(); - }); -} - -async function sendRequest(request: unknown): Promise { - if (await sendRequestAttempt(request, 500)) { - return; - } - await sendRequestAttempt(request, 1500); -} - -type AgentState = "working" | "blocked" | "idle"; - -type QueuedState = { - state: AgentState; - message?: string; - seq: number; -}; - -let reportSeq = Date.now() * 1000; -let currentAgentSessionId: string | undefined; -let currentAgentSessionPath: string | undefined; - -function nextReportSeq(): number { - reportSeq += 1; - return reportSeq; -} - -function updateSessionRef(ctx: any): void { - try { - const file = ctx?.sessionManager?.getSessionFile?.(); - currentAgentSessionPath = - typeof file === "string" && file.startsWith("/") ? file : undefined; - } catch { - currentAgentSessionPath = undefined; - } - - try { - const id = ctx?.sessionManager?.getSessionId?.(); - currentAgentSessionId = typeof id === "string" && id.length > 0 ? id : undefined; - } catch { - currentAgentSessionId = undefined; - } -} - -function withSessionRef(params: Record): Record { - if (currentAgentSessionPath) { - return { ...params, agent_session_path: currentAgentSessionPath }; - } - if (currentAgentSessionId) { - return { ...params, agent_session_id: currentAgentSessionId }; - } - return params; -} - -function currentSessionRef(): Record | undefined { - if (currentAgentSessionPath) { - return { agent_session_path: currentAgentSessionPath }; - } - if (currentAgentSessionId) { - return { agent_session_id: currentAgentSessionId }; - } - return undefined; -} - -function reportSession(sessionStartSource?: string): Promise { - const sessionRef = currentSessionRef(); - if (!sessionRef) { - return Promise.resolve(); - } - - return sendRequest({ - id: `${source}:session:${Date.now()}:${Math.random().toString(36).slice(2)}`, - method: "pane.report_agent_session", - params: { - pane_id: paneId, - source, - agent: "pi", - seq: nextReportSeq(), - session_start_source: sessionStartSource, - ...sessionRef, - }, - }); -} - -function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise { - return sendRequest({ - id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`, - method: "pane.report_agent", - params: withSessionRef({ - pane_id: paneId, - source, - agent: "pi", - state, - message, - seq, - }), - }); -} - -function releaseAgent(): Promise { - return sendRequest({ - id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`, - method: "pane.release_agent", - params: { - pane_id: paneId, - source, - agent: "pi", - seq: nextReportSeq(), - }, - }); -} - -function shouldReleaseOnSessionShutdown(event: any): boolean { - // Pi tears down and rebinds extension runtimes for internal lifecycle actions - // such as /reload, /new, /resume, and /fork. Those do not mean the pane's - // agent process has exited, and releasing hook authority there can suppress - // legitimate reports from the replacement runtime. Only a user/process quit - // should release Herdr's full-lifecycle authority. - const reason = event?.reason; - return reason === "quit"; -} - -let sendInFlight = false; -let queuedState: QueuedState | undefined; - -function queueState(state: AgentState, message?: string): void { - queuedState = { state, message, seq: nextReportSeq() }; - if (!sendInFlight) { - void drainStateQueue(); - } -} - -async function drainStateQueue(): Promise { - if (sendInFlight) { - return; - } - - sendInFlight = true; - try { - while (queuedState) { - const next = queuedState; - queuedState = undefined; - await sendState(next.state, next.message, next.seq); - } - } finally { - sendInFlight = false; - if (queuedState) { - void drainStateQueue(); - } - } -} - -export default function (pi) { - if (!enabled()) { - return; - } - - let agentActive = false; - let blockedCount = 0; - let blockedMessage: string | undefined; - let lastState: AgentState | undefined; - let lastMessage: string | undefined; - let rootSession = false; - - function desiredState() { - if (blockedCount > 0) { - return { state: "blocked" as const, message: blockedMessage }; - } - if (agentActive) { - return { state: "working" as const, message: undefined }; - } - return { state: "idle" as const, message: undefined }; - } - - function publishState(force = false) { - const next = desiredState(); - if (!force && next.state === lastState && next.message === lastMessage) { - return; - } - lastState = next.state; - lastMessage = next.message; - queueState(next.state, next.message); - } - - pi.events.on("herdr:blocked", (data) => { - if (!rootSession) { - return; - } - if (!data?.active) { - blockedCount = Math.max(0, blockedCount - 1); - if (blockedCount === 0) { - blockedMessage = undefined; - } - publishState(); - return; - } - - blockedCount += 1; - blockedMessage = data.label; - publishState(); - }); - - pi.on("session_start", async (event, ctx) => { - if (ctx?.hasUI !== true) { - return; - } - rootSession = true; - updateSessionRef(ctx); - await reportSession(event?.reason); - // A reload can replace this extension mid-run without emitting another agent_start. - agentActive = ctx?.isIdle?.() === false; - publishState(true); - }); - - pi.on("agent_start", (_event, ctx) => { - if (!rootSession) { - return; - } - updateSessionRef(ctx); - void reportSession(); - agentActive = true; - publishState(); - }); - - pi.on("agent_settled", (_event, ctx) => { - if (!rootSession || ctx?.isIdle?.() !== true) { - return; - } - - agentActive = false; - publishState(); - }); - - pi.on("session_shutdown", async (event) => { - if (!rootSession) { - return; - } - if (shouldReleaseOnSessionShutdown(event)) { - await releaseAgent(); - } - }); -}