diff --git a/AGENTS.md b/AGENTS.md index cc8307e..3a61264 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,4 +81,5 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla - Flake-managed Pi extension, prompt, and skill directories may still be written directly for throwaway development or local experiments. The risk is that a later Home Manager activation can overwrite or hide those unmanaged files, so finished work must be promoted into the dotfiles module before it counts as deployed. - Pi's tool discovery checks `~/.pi/agent/bin` before `PATH`, and downloaded generic Linux binaries there can be unusable on NixOS with the stub-ld error. - A copied or patched Pi launcher that only prepends Nix `fd`/`rg` to `PATH` may still break `@` autocomplete unless the local tool path is removed or Pi validates the local binary before using it. + This flake patches Pi to validate local tool binaries before selecting them, so it falls back to usable `fd`/`rg` from `PATH` instead. + Stale unpatched launchers are the remaining failure mode for broken `@` autocomplete. diff --git a/modules/agents/pi/extensions/compact-status.ts b/modules/agents/pi/extensions/compact-status.ts new file mode 100644 index 0000000..bb4c00c --- /dev/null +++ b/modules/agents/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/modules/agents/pi/patches/pi-flex-spacer.patch b/modules/agents/pi/patches/pi-flex-spacer.patch new file mode 100644 index 0000000..1c2d733 --- /dev/null +++ b/modules/agents/pi/patches/pi-flex-spacer.patch @@ -0,0 +1,169 @@ +diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts +--- a/packages/tui/src/tui.ts 2026-08-02 00:16:00.000000000 -0400 ++++ b/packages/tui/src/tui.ts 2026-08-02 00:16:00.000000000 -0400 +@@ -310,7 +310,7 @@ export class TUI extends Container { + private cursorRow = 0; // Logical cursor row (end of rendered content) + private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning) + private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1"; +- private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off) ++ private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK !== "0"; // Clear empty rows when content shrinks (default: on) + private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered) + private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves + private fullRedrawCount = 0; + +diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts +--- a/packages/coding-agent/src/core/settings-manager.ts 2026-08-02 00:34:00.000000000 -0400 ++++ b/packages/coding-agent/src/core/settings-manager.ts 2026-08-02 00:34:00.000000000 -0400 +@@ -1093,11 +1093,11 @@ export class SettingsManager { + } + + getClearOnShrink(): boolean { +- // Settings takes precedence, then env var, then default false ++ // Settings takes precedence, then env var, then default true + if (this.settings.terminal?.clearOnShrink !== undefined) { + return this.settings.terminal.clearOnShrink; + } +- return process.env.PI_CLEAR_ON_SHRINK === "1"; ++ return process.env.PI_CLEAR_ON_SHRINK !== "0"; + } + + setClearOnShrink(enabled: boolean): void { + +diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts +--- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts 2026-08-01 18:41:36.963495957 -0400 ++++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts 2026-08-01 18:43:04.876341236 -0400 +@@ -210,6 +210,45 @@ + return code !== undefined && DEAD_TERMINAL_ERROR_CODES.has(code); + } + ++class FlexSpacerBottomLayout implements Component { ++ private readonly ui: TUI; ++ private readonly flowChildren: Component[]; ++ private readonly pinnedChildren: Component[]; ++ ++ constructor(ui: TUI, flowChildren: Component[], pinnedChildren: Component[]) { ++ this.ui = ui; ++ this.flowChildren = flowChildren; ++ this.pinnedChildren = pinnedChildren; ++ } ++ ++ invalidate(): void { ++ for (const child of [...this.flowChildren, ...this.pinnedChildren]) { ++ child.invalidate(); ++ } ++ } ++ ++ private renderGroup(children: Component[], width: number): string[] { ++ const lines: string[] = []; ++ for (const child of children) { ++ lines.push(...child.render(width)); ++ } ++ return lines; ++ } ++ ++ render(width: number): string[] { ++ const flowLines = this.renderGroup(this.flowChildren, width); ++ const pinnedLines = this.renderGroup(this.pinnedChildren, width); ++ const terminalRows = this.ui.terminal.rows; ++ const spacerRows = Math.max(0, terminalRows - flowLines.length - pinnedLines.length); ++ ++ return [ ++ ...flowLines, ++ ...Array.from({ length: spacerRows }, () => ""), ++ ...pinnedLines, ++ ]; ++ } ++} ++ + const ANTHROPIC_SUBSCRIPTION_AUTH_WARNING = + "Anthropic subscription auth is active. Third-party harness usage draws from extra usage and is billed per token, not your Claude plan limits. Manage extra usage at https://claude.ai/settings/usage. Disable this warning in /settings."; + +@@ -335,6 +374,7 @@ + private fdPath: string | undefined; + private editorContainer: Container; + private footer: FooterComponent; ++ private footerContainer: Container; + private footerDataProvider: FooterDataProvider; + // Stored so the same manager can be injected into custom editors, selectors, and extension UI. + private keybindings: KeybindingsManager; +@@ -477,7 +517,9 @@ + this.editorContainer = new Container(); + this.editorContainer.addChild(this.editor as Component); + this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd()); ++ this.footerContainer = new Container(); + this.footer = new FooterComponent(this.session, this.footerDataProvider); ++ this.footerContainer.addChild(this.footer); + this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled); + + // Load hide thinking block setting +@@ -704,19 +746,25 @@ + console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`)); + } + +- // Add header container as first child. Populate it after applying theme settings. +- // Keep loaded resources before chat so restored session messages never precede them. +- this.ui.addChild(this.headerContainer); +- this.ui.addChild(this.loadedResourcesContainer); +- +- this.ui.addChild(this.chatContainer); +- this.ui.addChild(this.pendingMessagesContainer); +- this.ui.addChild(this.statusContainer); + this.renderWidgets(); // Initialize with default spacer +- this.ui.addChild(this.widgetContainerAbove); +- this.ui.addChild(this.editorContainer); +- this.ui.addChild(this.widgetContainerBelow); +- this.ui.addChild(this.footer); ++ this.ui.addChild( ++ new FlexSpacerBottomLayout( ++ this.ui, ++ [ ++ this.headerContainer, ++ this.loadedResourcesContainer, ++ this.chatContainer, ++ ], ++ [ ++ this.pendingMessagesContainer, ++ this.statusContainer, ++ this.widgetContainerAbove, ++ this.editorContainer, ++ this.widgetContainerBelow, ++ this.footerContainer, ++ ], ++ ), ++ ); + this.ui.setFocus(this.editor); + + this.setupKeyHandlers(); +@@ -2033,25 +2081,25 @@ + | ((tui: TUI, thm: Theme, footerData: ReadonlyFooterDataProvider) => Component & { dispose?(): void }) + | undefined, + ): void { +- // Dispose existing custom footer ++ // Dispose existing custom footer + if (this.customFooter?.dispose) { + this.customFooter.dispose(); + } + +- // Remove current footer from UI ++ // Remove current footer from its pinned layout slot. + if (this.customFooter) { +- this.ui.removeChild(this.customFooter); ++ this.footerContainer.removeChild(this.customFooter); + } else { +- this.ui.removeChild(this.footer); ++ this.footerContainer.removeChild(this.footer); + } + + if (factory) { + // Create and add custom footer, passing the data provider + this.customFooter = factory(this.ui, theme, this.footerDataProvider); +- this.ui.addChild(this.customFooter); ++ this.footerContainer.addChild(this.customFooter); + } else { + // Restore built-in footer + this.customFooter = undefined; +- this.ui.addChild(this.footer); ++ this.footerContainer.addChild(this.footer); + } + + this.ui.requestRender(); diff --git a/modules/agents/pi/patches/pi-tool-lookup-validation.patch b/modules/agents/pi/patches/pi-tool-lookup-validation.patch new file mode 100644 index 0000000..9364a7e --- /dev/null +++ b/modules/agents/pi/patches/pi-tool-lookup-validation.patch @@ -0,0 +1,22 @@ +diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts +--- a/packages/coding-agent/src/utils/tools-manager.ts 2026-08-01 18:41:36.970496010 -0400 ++++ b/packages/coding-agent/src/utils/tools-manager.ts 2026-08-01 18:41:37.028186009 -0400 +@@ -74,8 +74,7 @@ + function commandExists(cmd: string): boolean { + try { + const result = spawnSync(cmd, ["--version"], { stdio: "pipe" }); +- // Check for ENOENT error (command not found) +- return result.error === undefined || result.error === null; ++ return (result.error === undefined || result.error === null) && result.status === 0; + } catch { + return false; + } +@@ -88,7 +87,7 @@ + + // Check our tools directory first + const localPath = join(TOOLS_DIR, config.binaryName + (platform() === "win32" ? ".exe" : "")); +- if (existsSync(localPath)) { ++ if (existsSync(localPath) && commandExists(localPath)) { + return localPath; + } + diff --git a/modules/agents/pi/pi.nix b/modules/agents/pi/pi.nix index a880d0c..293d74f 100644 --- a/modules/agents/pi/pi.nix +++ b/modules/agents/pi/pi.nix @@ -11,6 +11,12 @@ let cfg = config.modules.agents.pi; user = config.user.name; piDir = "${config.users.users.${user}.home}/.pi/agent"; + patchedPi = pkgs.pi-coding-agent.overrideAttrs (old: { + patches = (old.patches or [ ]) ++ [ + ./patches/pi-flex-spacer.patch + ./patches/pi-tool-lookup-validation.patch + ]; + }); herdrPiIntegration = pkgs.stdenvNoCC.mkDerivation { name = "herdr-pi-integration"; nativeBuildInputs = [ pkgs.herdr ]; @@ -40,6 +46,7 @@ in home-manager.users.${user} = { programs.pi-coding-agent = { enable = true; + package = patchedPi; settings = { defaultProvider = "openai-codex";