refactor(pi): move extensions into monorepo config
This commit is contained in:
@@ -1 +0,0 @@
|
|||||||
../../configs/nixos/modules/shell/pi/extensions/compact-status.ts
|
|
||||||
254
.pi/extensions/compact-status.ts
Normal file
254
.pi/extensions/compact-status.ts
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../configs/nixos/modules/shell/pi/extensions/herdr-agent-state.ts
|
|
||||||
287
.pi/extensions/herdr-agent-state.ts
Normal file
287
.pi/extensions/herdr-agent-state.ts
Normal file
@@ -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<boolean> {
|
||||||
|
if (!enabled()) {
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | 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<void> {
|
||||||
|
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<string, unknown>): Record<string, unknown> {
|
||||||
|
if (currentAgentSessionPath) {
|
||||||
|
return { ...params, agent_session_path: currentAgentSessionPath };
|
||||||
|
}
|
||||||
|
if (currentAgentSessionId) {
|
||||||
|
return { ...params, agent_session_id: currentAgentSessionId };
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSessionRef(): Record<string, unknown> | undefined {
|
||||||
|
if (currentAgentSessionPath) {
|
||||||
|
return { agent_session_path: currentAgentSessionPath };
|
||||||
|
}
|
||||||
|
if (currentAgentSessionId) {
|
||||||
|
return { agent_session_id: currentAgentSessionId };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportSession(sessionStartSource?: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -32,11 +32,11 @@
|
|||||||
|
|
||||||
- Use produced literals for Herdr key names that include shifted punctuation.
|
- Use produced literals for Herdr key names that include shifted punctuation.
|
||||||
For example, use `prefix+<` instead of `prefix+shift+comma`.
|
For example, use `prefix+<` instead of `prefix+shift+comma`.
|
||||||
- Put global Pi extension sources in `configs/nixos/modules/shell/pi/extensions/`.
|
- Put monorepo Pi extension sources in `.pi/extensions/`.
|
||||||
Home Manager deploys them to `~/.pi/agent/extensions/`.
|
Pi discovers them from subdirectories through the local ancestor-resource patch.
|
||||||
- Treat Home Manager-managed Pi extension, prompt, and skill directories as generated output.
|
- Treat Home Manager-managed Pi settings as generated output.
|
||||||
Do not edit those directories except for throwaway development or local experiments.
|
Do not edit them except for throwaway development or local experiments.
|
||||||
Promote finished work into the NixOS configuration before treating it as deployed.
|
Promote finished settings into the NixOS configuration before treating them as deployed.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
|
|||||||
@@ -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<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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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<boolean> {
|
|
||||||
if (!enabled()) {
|
|
||||||
return Promise.resolve(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
let done = false;
|
|
||||||
let timeout: ReturnType<typeof setTimeout> | 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<void> {
|
|
||||||
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<string, unknown>): Record<string, unknown> {
|
|
||||||
if (currentAgentSessionPath) {
|
|
||||||
return { ...params, agent_session_path: currentAgentSessionPath };
|
|
||||||
}
|
|
||||||
if (currentAgentSessionId) {
|
|
||||||
return { ...params, agent_session_id: currentAgentSessionId };
|
|
||||||
}
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentSessionRef(): Record<string, unknown> | undefined {
|
|
||||||
if (currentAgentSessionPath) {
|
|
||||||
return { agent_session_path: currentAgentSessionPath };
|
|
||||||
}
|
|
||||||
if (currentAgentSessionId) {
|
|
||||||
return { agent_session_id: currentAgentSessionId };
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function reportSession(sessionStartSource?: string): Promise<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user