feat(nixos): migrate system configuration

This commit is contained in:
2026-08-30 19:40:21 -04:00
parent c5f34e9460
commit 7a6dc4d6f3
101 changed files with 6247 additions and 0 deletions

View File

@@ -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<void> | 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<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
}
function windowSeconds(raw: Record<string, unknown>): 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<string, unknown>): 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<void> {
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<string, string> = { 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);
},
});
}

View File

@@ -0,0 +1,171 @@
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,47 @@
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) {
+ for (const line of child.render(width)) {
+ lines.push(line);
+ }
+ }
+ 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 +376,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 +519,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 +748,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 +2083,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();

View File

@@ -0,0 +1,23 @@
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,8 @@
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;
+ // Downloaded generic Linux binaries can exist but fail on NixOS before they print a version.
+ 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;
}

View File

@@ -0,0 +1,81 @@
{
config,
lib,
pkgs,
...
}:
# Pi, a terminal coding agent, for the primary user, configured through
# home-manager, which ships the package and manages ~/.pi/agent.
# The login credential is left unmanaged, so it survives rebuilds.
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 ];
phases = [ "installPhase" ];
installPhase = ''
mkdir -p $TMPDIR/home/.pi/agent/extensions
HOME=$TMPDIR/home herdr integration install pi
mkdir -p $out
cp $TMPDIR/home/.pi/agent/extensions/herdr-agent-state.ts $out/herdr-agent-state.ts
'';
};
piExtensions = pkgs.stdenvNoCC.mkDerivation {
name = "pi-extensions";
phases = [ "installPhase" ];
installPhase = ''
mkdir -p $out
cp -R ${./extensions}/. $out/
cp ${herdrPiIntegration}/herdr-agent-state.ts $out/herdr-agent-state.ts
'';
};
in
{
options.modules.agents.pi = {
enable = lib.mkEnableOption ''
Pi, a terminal coding agent, configured via home-manager'';
};
config = lib.mkIf cfg.enable {
home-manager.users.${user} = {
programs.pi-coding-agent = {
enable = true;
package = patchedPi;
settings = {
defaultProvider = "openai-codex";
defaultModel = "gpt-5.5";
defaultThinkingLevel = "medium";
theme = "dark";
enableInstallTelemetry = false;
enableAnalytics = false;
};
};
home.file =
{
# The first declarative rollout replaces the interactive settings file.
# Login state stays in auth.json, which this module does not manage.
"${piDir}/settings.json".force = true;
"${piDir}/extensions" = {
source = piExtensions;
recursive = true;
};
"${piDir}/prompts" = {
source = ./prompts;
recursive = true;
};
};
};
};
}