This commit is contained in:
Jonas H
2026-03-07 21:16:43 +01:00
parent c4da7c9f84
commit 683c770cbc
19 changed files with 2163 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
---
name: explorer
description: Deep codebase and knowledge-base explorer using Claude Haiku with semantic search (QMD) and HDC-indexed context retrieval (opty). Use for thorough exploration, cross-cutting queries across docs and code, or when the local scout's Qwen model isn't cutting it.
tools: read, bash, write, mcp:qmd, mcp:opty
model: anthropic/claude-haiku-4-5
output: context.md
defaultProgress: true
---
You are an explorer. Thoroughly investigate a codebase or knowledge base and return structured, actionable findings.
Prefer semantic tools first:
1. Use qmd_query / qmd_get / qmd_multi_get for semantic and hybrid search of indexed docs and code
2. Use opty MCP tools for HDC-indexed context retrieval
3. Fall back to bash (grep/find) only when semantic tools don't surface what you need
4. Read key sections of files — not entire files unless necessary
Thoroughness (infer from task, default thorough):
- Quick: targeted lookups, answer from search results alone
- Medium: follow the most important cross-references, read critical sections
- Thorough: trace all dependencies, check related files, synthesize a full picture
Your output format (context.md):
# Exploration Context
## Query
What was explored and why.
## Files & Docs Retrieved
List with exact line ranges or doc IDs:
1. `path/to/file.ts` (lines 10-50) — Description
2. `#docid` — Description
## Key Findings
Critical types, interfaces, functions, or facts with actual snippets.
## Architecture / Structure
How the pieces connect; data flow; key abstractions.
## Gaps & Unknowns
What couldn't be determined and why.
## Start Here
Which file or doc to look at first and why.

View File

@@ -0,0 +1,44 @@
---
name: scout
description: Fast codebase recon using local Qwen model — searches, reads, returns compressed findings
tools: read, grep, find, ls, bash, write, mcp:qmd, mcp:opty
model: llama-cpp/unsloth/Qwen3.5-4B-GGUF:Q5_K_M
output: context.md
defaultProgress: true
---
You are a scout. Quickly investigate a codebase and return structured findings.
When running in a chain, you'll receive instructions about where to write your output.
When running solo, write to the provided output path and summarize what you found.
Thoroughness (infer from task, default medium):
- Quick: Targeted lookups, key files only
- Medium: Follow imports, read critical sections
- Thorough: Trace all dependencies, check tests/types
Strategy:
1. Use qmd tools for semantic/hybrid code search (preferred)
2. Use opty tools for HDC-indexed context retrieval
3. Fall back to grep/find only if qmd/opty don't find what you need
4. Read key sections (not entire files)
5. Identify types, interfaces, key functions
6. Note dependencies between files
Your output format (context.md):
# Code Context
## Files Retrieved
List with exact line ranges:
1. `path/to/file.ts` (lines 10-50) - Description
2. `path/to/other.ts` (lines 100-150) - Description
## Key Code
Critical types, interfaces, or functions with actual code snippets.
## Architecture
Brief explanation of how the pieces connect.
## Start Here
Which file to look at first and why.

8
pi/.pi/agent/auth.json Normal file
View File

@@ -0,0 +1,8 @@
{
"anthropic": {
"type": "oauth",
"refresh": "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",
"access": "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",
"expires": 1772856280733
}
}

View File

@@ -0,0 +1 @@
work

View File

@@ -0,0 +1,190 @@
/**
* Claude Account Switch Extension
*
* Switches between two Claude Pro accounts (personal and work).
* Tokens are saved from pi's own OAuth sessions (not Claude CLI),
* so token refresh works correctly.
*
* Setup (one-time per account):
* 1. /login → authenticate with personal account
* 2. /switch-claude save personal
* 3. /login → authenticate with work account
* 4. /switch-claude save work
*
* Usage:
* /switch-claude — pick account interactively
* /switch-claude save <name> — save current pi login as a named profile
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
const HOME = os.homedir();
const AUTH_FILE = path.join(HOME, ".pi/agent/auth.json");
const AGENT_DIR = path.join(HOME, ".pi/agent");
const MARKER_FILE = path.join(HOME, ".pi/agent/auth.json.current");
const PROFILES_DIR = path.join(HOME, ".pi/agent/profiles");
type Account = "personal" | "work";
function profilePath(account: Account): string {
return path.join(PROFILES_DIR, `auth-${account}.json`);
}
function hasProfile(account: Account): boolean {
return fs.existsSync(profilePath(account));
}
function saveProfile(account: Account): void {
fs.mkdirSync(PROFILES_DIR, { recursive: true });
fs.copyFileSync(AUTH_FILE, profilePath(account));
fs.chmodSync(profilePath(account), 0o600);
fs.writeFileSync(MARKER_FILE, account);
}
function loadProfile(account: Account): void {
fs.copyFileSync(profilePath(account), AUTH_FILE);
fs.chmodSync(AUTH_FILE, 0o600);
fs.writeFileSync(MARKER_FILE, account);
}
function getCurrentAccount(): Account | "unknown" {
try {
const marker = fs.readFileSync(MARKER_FILE, "utf8").trim();
if (marker === "personal" || marker === "work") return marker;
} catch {}
return "unknown";
}
function statusLabel(account: Account | "unknown"): string {
switch (account) {
case "personal": return "🏠 personal";
case "work": return "💼 work";
default: return "❓ claude";
}
}
/**
* Sync auth.json into the active profile file whenever pi rotates tokens.
*
* Pi writes fresh OAuth tokens to auth.json (via direct write or atomic
* rename). Without this watcher the profile file would keep the original
* snapshot token, which Anthropic invalidates after its first use as a
* refresh-token rotation. The next /switch-claude would restore the dead
* refresh token and force a full re-login.
*
* We watch the agent directory (more reliable than watching the file
* directly across atomic renames) and copy auth.json → active profile
* on every change, debounced to avoid duplicate writes.
*/
function startProfileSyncer(getAccount: () => Account | "unknown"): fs.FSWatcher | null {
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const syncNow = () => {
const account = getAccount();
if (account === "unknown") return;
if (!fs.existsSync(AUTH_FILE)) return;
try {
const content = fs.readFileSync(AUTH_FILE, "utf8");
JSON.parse(content); // only sync valid JSON
const dest = profilePath(account);
fs.mkdirSync(PROFILES_DIR, { recursive: true });
fs.writeFileSync(dest, content, "utf8");
fs.chmodSync(dest, 0o600);
} catch {
// ignore transient read errors (file mid-write, etc.)
}
};
try {
return fs.watch(AGENT_DIR, { persistent: false }, (_event, filename) => {
if (filename !== "auth.json") return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(syncNow, 200);
});
} catch {
return null;
}
}
export default function (pi: ExtensionAPI) {
let currentAccount: Account | "unknown" = "unknown";
let syncer: fs.FSWatcher | null = null;
pi.on("session_start", async (_event, ctx) => {
currentAccount = getCurrentAccount();
ctx.ui.setStatus("claude-account", statusLabel(currentAccount));
// Start watching auth.json so token refreshes are mirrored into
// the active profile file. Only one watcher is needed per session.
if (!syncer) {
syncer = startProfileSyncer(() => currentAccount);
}
});
pi.on("session_shutdown", async () => {
if (syncer) {
syncer.close();
syncer = null;
}
});
pi.registerCommand("switch-claude", {
description: "Switch between personal (🏠) and work (💼) Claude accounts. Use 'save <name>' to save current login as a profile.",
handler: async (args, ctx) => {
const trimmed = args?.trim() ?? "";
// Save current auth.json as a named profile
if (trimmed.startsWith("save ")) {
const name = trimmed.slice(5).trim();
if (name !== "personal" && name !== "work") {
ctx.ui.notify("Usage: /switch-claude save personal|work", "warning");
return;
}
saveProfile(name as Account);
currentAccount = name as Account;
ctx.ui.setStatus("claude-account", statusLabel(currentAccount));
ctx.ui.notify(`Saved current login as ${statusLabel(name as Account)} profile`, "info");
return;
}
// Switch between profiles
const personalLabel = `🏠 personal${currentAccount === "personal" ? " ← current" : ""}${!hasProfile("personal") ? " (no profile saved)" : ""}`;
const workLabel = `💼 work${currentAccount === "work" ? " ← current" : ""}${!hasProfile("work") ? " (no profile saved)" : ""}`;
const choice = await ctx.ui.select("Switch Claude account:", [personalLabel, workLabel]);
if (choice === undefined) return;
const newAccount: Account = choice.startsWith("🏠") ? "personal" : "work";
if (newAccount === currentAccount) {
ctx.ui.notify(`Already using ${statusLabel(newAccount)}`, "info");
return;
}
if (!hasProfile(newAccount)) {
ctx.ui.notify(
`No profile saved for ${newAccount}. Run /login then /switch-claude save ${newAccount}`,
"warning"
);
return;
}
try {
// Persist any token refreshes pi made since the last save so
// we don't restore a stale refresh token when we come back.
if (currentAccount !== "unknown") saveProfile(currentAccount);
loadProfile(newAccount);
currentAccount = newAccount;
ctx.ui.setStatus("claude-account", statusLabel(currentAccount));
pi.events.emit("claude-account:switched", { account: newAccount });
ctx.ui.notify(`Switched to ${statusLabel(newAccount)} — restart pi to apply`, "info");
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
ctx.ui.notify(`Failed to switch: ${msg}`, "error");
}
},
});
}

View File

@@ -0,0 +1,201 @@
/**
* llama-server Schema Sanitization Proxy
*
* llama-server strictly validates JSON Schema and rejects any schema node
* that lacks a `type` field. Some of pi's built-in tools (e.g. `subagent`)
* have complex union-type parameters represented as `{"description": "..."}` with
* no `type`, which causes llama-server to return a 400 error.
*
* This extension starts a tiny local HTTP proxy on port 8081 that:
* 1. Intercepts outgoing OpenAI-compatible API calls
* 2. Walks tool schemas and adds `"type": "string"` to any schema node
* that is missing a type declaration
* 3. Forwards the fixed request to llama-server on port 8080
* 4. Streams the response back transparently
*
* It also overrides the `llama-cpp` provider's baseUrl to point at the proxy,
* so no changes to models.json are needed (beyond what's already there).
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import * as http from "http";
const PROXY_PORT = 8081;
const TARGET_HOST = "127.0.0.1";
const TARGET_PORT = 8080;
// ---------------------------------------------------------------------------
// Schema sanitizer
// ---------------------------------------------------------------------------
/**
* Recursively walk a JSON Schema object and add `"type": "string"` to any
* node that has no `type` and no composition keywords (oneOf/anyOf/allOf/$ref).
* This satisfies llama-server's strict validation without breaking valid nodes.
*/
function sanitizeSchema(schema: unknown): unknown {
if (!schema || typeof schema !== "object") return schema;
if (Array.isArray(schema)) return schema.map(sanitizeSchema);
const obj = schema as Record<string, unknown>;
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (key === "properties" && value && typeof value === "object" && !Array.isArray(value)) {
result[key] = Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, sanitizeSchema(v)]),
);
} else if (key === "items") {
result[key] = sanitizeSchema(value);
} else if (key === "additionalProperties" && value && typeof value === "object") {
result[key] = sanitizeSchema(value);
} else if (
(key === "oneOf" || key === "anyOf" || key === "allOf") &&
Array.isArray(value)
) {
result[key] = value.map(sanitizeSchema);
} else {
result[key] = value;
}
}
// If this schema node has no type and no composition keywords, default to "string"
const hasType = "type" in result;
const hasComposition =
"oneOf" in result || "anyOf" in result || "allOf" in result || "$ref" in result;
const hasEnum = "enum" in result || "const" in result;
if (!hasType && !hasComposition && !hasEnum) {
result["type"] = "string";
}
return result;
}
/**
* Patch the `tools` array in a parsed request body, if present.
*/
function sanitizeRequestBody(body: Record<string, unknown>): Record<string, unknown> {
if (!Array.isArray(body.tools)) return body;
return {
...body,
tools: (body.tools as unknown[]).map((tool) => {
if (!tool || typeof tool !== "object") return tool;
const t = tool as Record<string, unknown>;
if (!t.function || typeof t.function !== "object") return t;
const fn = t.function as Record<string, unknown>;
if (!fn.parameters) return t;
return {
...t,
function: {
...fn,
parameters: sanitizeSchema(fn.parameters),
},
};
}),
};
}
// ---------------------------------------------------------------------------
// Proxy server
// ---------------------------------------------------------------------------
function startProxy(): http.Server {
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
const rawBody = Buffer.concat(chunks).toString("utf-8");
// Attempt to sanitize schemas in JSON bodies
let forwardBody = rawBody;
const contentType = req.headers["content-type"] ?? "";
if (contentType.includes("application/json") && rawBody.trim().startsWith("{")) {
try {
const parsed = JSON.parse(rawBody) as Record<string, unknown>;
const sanitized = sanitizeRequestBody(parsed);
forwardBody = JSON.stringify(sanitized);
} catch {
// Not valid JSON — send as-is
}
}
const forwardBuffer = Buffer.from(forwardBody, "utf-8");
// Build forwarded headers, updating host and content-length
const forwardHeaders: Record<string, string | string[]> = {};
for (const [k, v] of Object.entries(req.headers)) {
if (k === "host") continue; // rewrite below
if (v !== undefined) forwardHeaders[k] = v as string | string[];
}
forwardHeaders["host"] = `${TARGET_HOST}:${TARGET_PORT}`;
forwardHeaders["content-length"] = String(forwardBuffer.byteLength);
const proxyReq = http.request(
{
host: TARGET_HOST,
port: TARGET_PORT,
path: req.url,
method: req.method,
headers: forwardHeaders,
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode ?? 200, proxyRes.headers);
proxyRes.pipe(res, { end: true });
},
);
proxyReq.on("error", (err) => {
const msg = `Proxy error forwarding to llama-server: ${err.message}`;
if (!res.headersSent) {
res.writeHead(502, { "content-type": "text/plain" });
}
res.end(msg);
});
proxyReq.write(forwardBuffer);
proxyReq.end();
});
req.on("error", (err) => {
console.error("[llama-proxy] request error:", err);
});
});
server.listen(PROXY_PORT, "127.0.0.1", () => {
// Server is up
});
server.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "EADDRINUSE") {
console.warn(
`[llama-proxy] Port ${PROXY_PORT} already in use — proxy not started. ` +
`If a previous pi session left it running, kill it and reload.`,
);
} else {
console.error("[llama-proxy] Server error:", err);
}
});
return server;
}
// ---------------------------------------------------------------------------
// Extension entry point
// ---------------------------------------------------------------------------
export default function (pi: ExtensionAPI) {
const server = startProxy();
// Override the llama-cpp provider's baseUrl to route through our proxy.
// models.json model definitions are preserved; only the endpoint changes.
pi.registerProvider("llama-cpp", {
baseUrl: `http://127.0.0.1:${PROXY_PORT}/v1`,
});
pi.on("session_end", async () => {
server.close();
});
}

View File

@@ -0,0 +1,15 @@
/**
* Local Explorer Extension
*
* Previously auto-injected a scout hint into every cloud-model session.
* Now a no-op — scout delegation is opt-in via the `local-scout` skill.
* Invoke with /skill:local-scout or by mentioning "use scout" in a prompt.
*
* Placed in: ~/.pi/agent/extensions/local-explorer.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
export default function (_pi: ExtensionAPI) {
// No automatic injection — scout is opt-in via the local-scout skill.
}

View File

@@ -0,0 +1,82 @@
/**
* new-with-context extension
*
* Provides a /new-with-context command that starts a fresh session but carries
* only the last message from the current conversation into the new session.
*
* Usage:
* /new-with-context
*
* What it does:
* 1. Finds the last user or assistant message in the current branch
* 2. Starts a new session
* 3. Injects that last message as the opening context
*/
import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent";
export default function (pi: ExtensionAPI) {
pi.registerCommand("new-with-context", {
description: "Start a new session keeping only the last message as context",
handler: async (args, ctx) => {
await ctx.waitForIdle();
// Collect all message entries from the current branch
const branch = ctx.sessionManager.getBranch();
const messageEntries = branch.filter(
(entry): entry is SessionEntry & { type: "message" } => entry.type === "message",
);
if (messageEntries.length === 0) {
ctx.ui.notify("No messages in current session to carry over.", "info");
await ctx.newSession();
return;
}
// Grab the last message entry
const lastEntry = messageEntries[messageEntries.length - 1];
const lastMessage = lastEntry.message;
const currentSessionFile = ctx.sessionManager.getSessionFile();
// Create a new session and inject the last message as opening context
const result = await ctx.newSession({
parentSession: currentSessionFile ?? undefined,
setup: async (sm) => {
sm.appendMessage(lastMessage);
},
});
if (result.cancelled) {
ctx.ui.notify("New session cancelled.", "info");
return;
}
// Give a brief summary of what was carried over
const role = lastMessage.role;
let preview = "";
if (role === "user" || role === "assistant") {
const content = lastMessage.content;
if (typeof content === "string") {
preview = content.slice(0, 80);
} else if (Array.isArray(content)) {
const textBlock = content.find((c: any) => c.type === "text");
if (textBlock && "text" in textBlock) {
preview = (textBlock as { type: "text"; text: string }).text.slice(0, 80);
}
}
} else if (role === "toolResult") {
preview = `[tool result: ${(lastMessage as any).toolName}]`;
} else {
preview = `[${role} message]`;
}
if (preview.length === 80) preview += "…";
ctx.ui.notify(
`New session started. Carried over last message (${role}): "${preview}"`,
"success",
);
},
});
}

View File

@@ -0,0 +1,731 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
// ---------------------------------------------------------------------------
// Shared disk cache — lets multiple concurrent pi sessions coordinate so only
// one actually hits the API per cache window, regardless of how many sessions
// are open. Modelled after claude-pulse's cache.json approach.
// ---------------------------------------------------------------------------
export interface UsageCache {
timestamp: number;
data: Partial<Record<ProviderKey, UsageData>>;
/** ISO timestamp until which a provider is rate-limited (429 backoff). */
rateLimitedUntil?: Partial<Record<ProviderKey, number>>;
}
const USAGE_CACHE_FILE = path.join(os.homedir(), ".pi", "agent", "usage-cache.json");
export function readUsageCache(): UsageCache | null {
try {
const raw = fs.readFileSync(USAGE_CACHE_FILE, "utf-8");
const parsed = JSON.parse(raw);
if (typeof parsed?.timestamp === "number") return parsed as UsageCache;
} catch {}
return null;
}
export function writeUsageCache(cache: UsageCache): void {
try {
const dir = path.dirname(USAGE_CACHE_FILE);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const tmp = `${USAGE_CACHE_FILE}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tmp, JSON.stringify(cache, null, 2));
fs.renameSync(tmp, USAGE_CACHE_FILE);
} catch {}
}
export type ProviderKey = "codex" | "claude" | "zai" | "gemini" | "antigravity";
export type OAuthProviderId = "openai-codex" | "anthropic" | "google-gemini-cli" | "google-antigravity";
export interface AuthData {
"openai-codex"?: { access?: string; refresh?: string; expires?: number };
anthropic?: { access?: string; refresh?: string; expires?: number };
zai?: { key?: string; access?: string; refresh?: string; expires?: number };
"google-gemini-cli"?: { access?: string; refresh?: string; projectId?: string; expires?: number };
"google-antigravity"?: { access?: string; refresh?: string; projectId?: string; expires?: number };
}
export interface UsageData {
session: number;
weekly: number;
sessionResetsIn?: string;
weeklyResetsIn?: string;
extraSpend?: number;
extraLimit?: number;
error?: string;
}
export type UsageByProvider = Record<ProviderKey, UsageData | null>;
export interface UsageEndpoints {
zai: string;
gemini: string;
antigravity: string;
googleLoadCodeAssistEndpoints: string[];
}
export interface FetchResponseLike {
ok: boolean;
status: number;
json(): Promise<any>;
}
export type FetchLike = (input: string, init?: RequestInit) => Promise<FetchResponseLike>;
export interface RequestConfig {
fetchFn?: FetchLike;
timeoutMs?: number;
}
export interface FetchConfig extends RequestConfig {
endpoints?: UsageEndpoints;
env?: NodeJS.ProcessEnv;
}
export interface OAuthApiKeyResult {
newCredentials: Record<string, any>;
apiKey: string;
}
export type OAuthApiKeyResolver = (
providerId: OAuthProviderId,
credentials: Record<string, Record<string, any>>,
) => Promise<OAuthApiKeyResult | null>;
export interface EnsureFreshAuthConfig {
auth?: AuthData | null;
authFile?: string;
oauthResolver?: OAuthApiKeyResolver;
nowMs?: number;
persist?: boolean;
}
export interface FreshAuthResult {
auth: AuthData | null;
changed: boolean;
refreshErrors: Partial<Record<OAuthProviderId, string>>;
}
export interface FetchAllUsagesConfig extends FetchConfig, EnsureFreshAuthConfig {
auth?: AuthData | null;
authFile?: string;
}
const DEFAULT_FETCH_TIMEOUT_MS = 12_000;
const TOKEN_REFRESH_SKEW_MS = 60_000;
export const DEFAULT_AUTH_FILE = path.join(os.homedir(), ".pi", "agent", "auth.json");
export const DEFAULT_ZAI_USAGE_ENDPOINT = "https://api.z.ai/api/monitor/usage/quota/limit";
export const GOOGLE_QUOTA_ENDPOINT = "https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota";
export const GOOGLE_LOAD_CODE_ASSIST_ENDPOINTS = [
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
"https://daily-cloudcode-pa.sandbox.googleapis.com/v1internal:loadCodeAssist",
];
export function resolveUsageEndpoints(): UsageEndpoints {
return {
zai: DEFAULT_ZAI_USAGE_ENDPOINT,
gemini: GOOGLE_QUOTA_ENDPOINT,
antigravity: GOOGLE_QUOTA_ENDPOINT,
googleLoadCodeAssistEndpoints: GOOGLE_LOAD_CODE_ASSIST_ENDPOINTS,
};
}
function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
if (error.name === "AbortError") return "request timeout";
return error.message || String(error);
}
return String(error);
}
function asObject(value: unknown): Record<string, any> | null {
if (!value || typeof value !== "object") return null;
return value as Record<string, any>;
}
function normalizeUsagePair(session: number, weekly: number): { session: number; weekly: number } {
const clean = (v: number) => {
if (!Number.isFinite(v)) return 0;
return Number(v.toFixed(2));
};
return { session: clean(session), weekly: clean(weekly) };
}
async function requestJson(url: string, init: RequestInit, config: RequestConfig = {}): Promise<{ ok: true; data: any } | { ok: false; error: string }> {
const fetchFn = config.fetchFn ?? ((fetch as unknown) as FetchLike);
const timeoutMs = config.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
const controller = new AbortController();
const timeout = timeoutMs > 0 ? setTimeout(() => controller.abort(), timeoutMs) : null;
try {
const response = await fetchFn(url, { ...init, signal: controller.signal });
if (!response.ok) return { ok: false, error: `HTTP ${response.status}` };
try {
const data = await response.json();
return { ok: true, data };
} catch {
return { ok: false, error: "invalid JSON response" };
}
} catch (error) {
return { ok: false, error: toErrorMessage(error) };
} finally {
if (timeout) clearTimeout(timeout);
}
}
export function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return "now";
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0 && h > 0) return `${d}d ${h}h`;
if (d > 0) return `${d}d`;
if (h > 0 && m > 0) return `${h}h ${m}m`;
if (h > 0) return `${h}h`;
if (m > 0) return `${m}m`;
return "<1m";
}
export function formatResetsAt(isoDate: string, nowMs = Date.now()): string {
const resetTime = new Date(isoDate).getTime();
if (!Number.isFinite(resetTime)) return "";
const diffSeconds = Math.max(0, (resetTime - nowMs) / 1000);
return formatDuration(diffSeconds);
}
export function readAuth(authFile = DEFAULT_AUTH_FILE): AuthData | null {
try {
const parsed = JSON.parse(fs.readFileSync(authFile, "utf-8"));
return asObject(parsed) as AuthData;
} catch {
return null;
}
}
export function writeAuth(auth: AuthData, authFile = DEFAULT_AUTH_FILE): boolean {
try {
const dir = path.dirname(authFile);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
const tmpPath = `${authFile}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tmpPath, JSON.stringify(auth, null, 2));
fs.renameSync(tmpPath, authFile);
return true;
} catch {
return false;
}
}
let cachedOAuthResolver: OAuthApiKeyResolver | null = null;
async function getDefaultOAuthResolver(): Promise<OAuthApiKeyResolver> {
if (cachedOAuthResolver) return cachedOAuthResolver;
const mod = await import("@mariozechner/pi-ai");
if (typeof (mod as any).getOAuthApiKey !== "function") {
throw new Error("oauth resolver unavailable");
}
cachedOAuthResolver = (providerId, credentials) =>
(mod as any).getOAuthApiKey(providerId, credentials) as Promise<OAuthApiKeyResult | null>;
return cachedOAuthResolver;
}
function isCredentialExpired(creds: { expires?: number } | undefined, nowMs: number): boolean {
if (!creds) return false;
if (typeof creds.expires !== "number") return false;
return nowMs + TOKEN_REFRESH_SKEW_MS >= creds.expires;
}
export async function ensureFreshAuthForProviders(
providerIds: OAuthProviderId[],
config: EnsureFreshAuthConfig = {},
): Promise<FreshAuthResult> {
const authFile = config.authFile ?? DEFAULT_AUTH_FILE;
const auth = config.auth ?? readAuth(authFile);
if (!auth) {
return { auth: null, changed: false, refreshErrors: {} };
}
const nowMs = config.nowMs ?? Date.now();
const uniqueProviders = Array.from(new Set(providerIds));
const nextAuth: AuthData = { ...auth };
const refreshErrors: Partial<Record<OAuthProviderId, string>> = {};
let changed = false;
for (const providerId of uniqueProviders) {
const creds = (nextAuth as any)[providerId] as { access?: string; refresh?: string; expires?: number } | undefined;
if (!creds?.refresh) continue;
const needsRefresh = !creds.access || isCredentialExpired(creds, nowMs);
if (!needsRefresh) continue;
try {
const resolver = config.oauthResolver ?? (await getDefaultOAuthResolver());
const resolved = await resolver(providerId, nextAuth as any);
if (!resolved?.newCredentials) {
refreshErrors[providerId] = "missing OAuth credentials";
continue;
}
(nextAuth as any)[providerId] = {
...(nextAuth as any)[providerId],
...resolved.newCredentials,
};
changed = true;
} catch (error) {
refreshErrors[providerId] = toErrorMessage(error);
}
}
if (changed && config.persist !== false) {
writeAuth(nextAuth, authFile);
}
return { auth: nextAuth, changed, refreshErrors };
}
export function readPercentCandidate(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
if (value >= 0 && value <= 1) {
if (Number.isInteger(value)) return value;
return value * 100;
}
if (value >= 0 && value <= 100) return value;
return null;
}
export function readLimitPercent(limit: any): number | null {
const direct = [
limit?.percentage,
limit?.utilization,
limit?.used_percent,
limit?.usedPercent,
limit?.usagePercent,
limit?.usage_percent,
]
.map(readPercentCandidate)
.find((v) => v != null);
if (direct != null) return direct;
const current = typeof limit?.currentValue === "number" ? limit.currentValue : null;
const remaining = typeof limit?.remaining === "number" ? limit.remaining : null;
if (current != null && remaining != null && current + remaining > 0) {
return (current / (current + remaining)) * 100;
}
return null;
}
export function extractUsageFromPayload(data: any): { session: number; weekly: number } | null {
const limitArrays = [data?.data?.limits, data?.limits, data?.quota?.limits, data?.data?.quota?.limits];
const limits = limitArrays.find((arr) => Array.isArray(arr)) as any[] | undefined;
if (limits) {
const byType = (types: string[]) =>
limits.find((l) => {
const t = String(l?.type || "").toUpperCase();
return types.some((x) => t === x);
});
const sessionLimit = byType(["TIME_LIMIT", "SESSION_LIMIT", "REQUEST_LIMIT", "RPM_LIMIT", "RPD_LIMIT"]);
const weeklyLimit = byType(["TOKENS_LIMIT", "TOKEN_LIMIT", "WEEK_LIMIT", "WEEKLY_LIMIT", "TPM_LIMIT", "DAILY_LIMIT"]);
const s = readLimitPercent(sessionLimit);
const w = readLimitPercent(weeklyLimit);
if (s != null && w != null) return normalizeUsagePair(s, w);
}
const sessionCandidates = [
data?.session,
data?.sessionPercent,
data?.session_percent,
data?.five_hour?.utilization,
data?.rate_limit?.primary_window?.used_percent,
data?.limits?.session?.utilization,
data?.usage?.session,
data?.data?.session,
data?.data?.sessionPercent,
data?.data?.session_percent,
data?.data?.usage?.session,
data?.quota?.session?.percentage,
data?.data?.quota?.session?.percentage,
];
const weeklyCandidates = [
data?.weekly,
data?.weeklyPercent,
data?.weekly_percent,
data?.seven_day?.utilization,
data?.rate_limit?.secondary_window?.used_percent,
data?.limits?.weekly?.utilization,
data?.usage?.weekly,
data?.data?.weekly,
data?.data?.weeklyPercent,
data?.data?.weekly_percent,
data?.data?.usage?.weekly,
data?.quota?.weekly?.percentage,
data?.data?.quota?.weekly?.percentage,
data?.quota?.daily?.percentage,
data?.data?.quota?.daily?.percentage,
];
const session = sessionCandidates.map(readPercentCandidate).find((v) => v != null);
const weekly = weeklyCandidates.map(readPercentCandidate).find((v) => v != null);
if (session == null || weekly == null) return null;
return normalizeUsagePair(session, weekly);
}
export function googleMetadata(projectId?: string) {
return {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
...(projectId ? { duetProject: projectId } : {}),
};
}
export function googleHeaders(token: string, projectId?: string) {
return {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
"User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"X-Goog-Api-Client": "gl-node/22.17.0",
"Client-Metadata": JSON.stringify(googleMetadata(projectId)),
};
}
export async function discoverGoogleProjectId(token: string, config: FetchConfig = {}): Promise<string | undefined> {
const env = config.env ?? process.env;
const envProjectId = env.GOOGLE_CLOUD_PROJECT || env.GOOGLE_CLOUD_PROJECT_ID;
if (envProjectId) return envProjectId;
const endpoints = config.endpoints ?? resolveUsageEndpoints();
for (const endpoint of endpoints.googleLoadCodeAssistEndpoints) {
const result = await requestJson(
endpoint,
{
method: "POST",
headers: googleHeaders(token),
body: JSON.stringify({ metadata: googleMetadata() }),
},
config,
);
if (!result.ok) continue;
const data = result.data;
if (typeof data?.cloudaicompanionProject === "string" && data.cloudaicompanionProject) {
return data.cloudaicompanionProject;
}
if (data?.cloudaicompanionProject && typeof data.cloudaicompanionProject === "object") {
const id = data.cloudaicompanionProject.id;
if (typeof id === "string" && id) return id;
}
}
return undefined;
}
export function usedPercentFromRemainingFraction(value: unknown): number | null {
if (typeof value !== "number" || !Number.isFinite(value)) return null;
const remaining = Math.max(0, Math.min(1, value));
return (1 - remaining) * 100;
}
export function pickMostUsedBucket(buckets: any[]): any | null {
let best: any | null = null;
let bestUsed = -1;
for (const bucket of buckets) {
const used = usedPercentFromRemainingFraction(bucket?.remainingFraction);
if (used == null) continue;
if (used > bestUsed) {
bestUsed = used;
best = bucket;
}
}
return best;
}
export function parseGoogleQuotaBuckets(data: any, provider: "gemini" | "antigravity"): { session: number; weekly: number } | null {
const allBuckets = Array.isArray(data?.buckets) ? data.buckets : [];
if (!allBuckets.length) return null;
const requestBuckets = allBuckets.filter((b: any) => String(b?.tokenType || "").toUpperCase() === "REQUESTS");
const buckets = requestBuckets.length ? requestBuckets : allBuckets;
const modelId = (b: any) => String(b?.modelId || "").toLowerCase();
const claudeNonThinking = buckets.filter((b: any) => modelId(b).includes("claude") && !modelId(b).includes("thinking"));
const geminiPro = buckets.filter((b: any) => modelId(b).includes("gemini") && modelId(b).includes("pro"));
const geminiFlash = buckets.filter((b: any) => modelId(b).includes("gemini") && modelId(b).includes("flash"));
const primaryBucket =
provider === "antigravity"
? pickMostUsedBucket(claudeNonThinking) || pickMostUsedBucket(geminiPro) || pickMostUsedBucket(geminiFlash) || pickMostUsedBucket(buckets)
: pickMostUsedBucket(geminiPro) || pickMostUsedBucket(geminiFlash) || pickMostUsedBucket(buckets);
const secondaryBucket = pickMostUsedBucket(geminiFlash) || pickMostUsedBucket(geminiPro) || pickMostUsedBucket(buckets);
const session = usedPercentFromRemainingFraction(primaryBucket?.remainingFraction);
const weekly = usedPercentFromRemainingFraction(secondaryBucket?.remainingFraction);
if (session == null || weekly == null) return null;
return normalizeUsagePair(session, weekly);
}
export async function fetchCodexUsage(token: string, config: RequestConfig = {}): Promise<UsageData> {
const result = await requestJson(
"https://chatgpt.com/backend-api/wham/usage",
{ headers: { Authorization: `Bearer ${token}` } },
config,
);
if (!result.ok) return { session: 0, weekly: 0, error: result.error };
const primary = result.data?.rate_limit?.primary_window;
const secondary = result.data?.rate_limit?.secondary_window;
return {
session: readPercentCandidate(primary?.used_percent) ?? 0,
weekly: readPercentCandidate(secondary?.used_percent) ?? 0,
sessionResetsIn: typeof primary?.reset_after_seconds === "number" ? formatDuration(primary.reset_after_seconds) : undefined,
weeklyResetsIn: typeof secondary?.reset_after_seconds === "number" ? formatDuration(secondary.reset_after_seconds) : undefined,
};
}
export async function fetchClaudeUsage(token: string, config: RequestConfig = {}): Promise<UsageData> {
const result = await requestJson(
"https://api.anthropic.com/api/oauth/usage",
{
headers: {
Authorization: `Bearer ${token}`,
"anthropic-beta": "oauth-2025-04-20",
},
},
config,
);
if (!result.ok) return { session: 0, weekly: 0, error: result.error };
const data = result.data;
const usage: UsageData = {
session: readPercentCandidate(data?.five_hour?.utilization) ?? 0,
weekly: readPercentCandidate(data?.seven_day?.utilization) ?? 0,
sessionResetsIn: data?.five_hour?.resets_at ? formatResetsAt(data.five_hour.resets_at) : undefined,
weeklyResetsIn: data?.seven_day?.resets_at ? formatResetsAt(data.seven_day.resets_at) : undefined,
};
if (data?.extra_usage?.is_enabled) {
usage.extraSpend = typeof data.extra_usage.used_credits === "number" ? data.extra_usage.used_credits : undefined;
usage.extraLimit = typeof data.extra_usage.monthly_limit === "number" ? data.extra_usage.monthly_limit : undefined;
}
return usage;
}
export async function fetchZaiUsage(token: string, config: FetchConfig = {}): Promise<UsageData> {
const endpoint = (config.endpoints ?? resolveUsageEndpoints()).zai;
if (!endpoint) return { session: 0, weekly: 0, error: "usage endpoint unavailable" };
const result = await requestJson(
endpoint,
{ headers: { Authorization: `Bearer ${token}` } },
config,
);
if (!result.ok) return { session: 0, weekly: 0, error: result.error };
const parsed = extractUsageFromPayload(result.data);
if (!parsed) return { session: 0, weekly: 0, error: "unrecognized response shape" };
return parsed;
}
export async function fetchGoogleUsage(
token: string,
endpoint: string,
projectId: string | undefined,
provider: "gemini" | "antigravity",
config: FetchConfig = {},
): Promise<UsageData> {
if (!endpoint) return { session: 0, weekly: 0, error: "configure endpoint" };
const discoveredProjectId = projectId || (await discoverGoogleProjectId(token, config));
if (!discoveredProjectId) {
return { session: 0, weekly: 0, error: "missing projectId (try /login again)" };
}
const result = await requestJson(
endpoint,
{
method: "POST",
headers: googleHeaders(token, discoveredProjectId),
body: JSON.stringify({ project: discoveredProjectId }),
},
config,
);
if (!result.ok) return { session: 0, weekly: 0, error: result.error };
const quota = parseGoogleQuotaBuckets(result.data, provider);
if (quota) return quota;
const parsed = extractUsageFromPayload(result.data);
if (!parsed) return { session: 0, weekly: 0, error: "unrecognized response shape" };
return parsed;
}
export function detectProvider(
model: { provider?: string; id?: string; name?: string; api?: string } | string | undefined | null,
): ProviderKey | null {
if (!model) return null;
if (typeof model === "string") return null;
const provider = (model.provider || "").toLowerCase();
if (provider === "openai-codex") return "codex";
if (provider === "anthropic") return "claude";
if (provider === "zai") return "zai";
if (provider === "google-gemini-cli") return "gemini";
if (provider === "google-antigravity") return "antigravity";
return null;
}
export function providerToOAuthProviderId(active: ProviderKey | null): OAuthProviderId | null {
if (active === "codex") return "openai-codex";
if (active === "claude") return "anthropic";
if (active === "gemini") return "google-gemini-cli";
if (active === "antigravity") return "google-antigravity";
return null;
}
export function canShowForProvider(active: ProviderKey | null, auth: AuthData | null, endpoints: UsageEndpoints): boolean {
if (!active || !auth) return false;
if (active === "codex") return !!(auth["openai-codex"]?.access || auth["openai-codex"]?.refresh);
if (active === "claude") return !!(auth.anthropic?.access || auth.anthropic?.refresh);
if (active === "zai") return !!(auth.zai?.access || auth.zai?.key) && !!endpoints.zai;
if (active === "gemini") {
return !!(auth["google-gemini-cli"]?.access || auth["google-gemini-cli"]?.refresh) && !!endpoints.gemini;
}
if (active === "antigravity") {
return !!(auth["google-antigravity"]?.access || auth["google-antigravity"]?.refresh) && !!endpoints.antigravity;
}
return false;
}
export function clampPercent(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, Math.round(value)));
}
export function colorForPercent(value: number): "success" | "warning" | "error" {
if (value >= 90) return "error";
if (value >= 70) return "warning";
return "success";
}
export async function fetchAllUsages(config: FetchAllUsagesConfig = {}): Promise<UsageByProvider> {
const authFile = config.authFile ?? DEFAULT_AUTH_FILE;
const auth = config.auth ?? readAuth(authFile);
const endpoints = config.endpoints ?? resolveUsageEndpoints();
const results: UsageByProvider = {
codex: null,
claude: null,
zai: null,
gemini: null,
antigravity: null,
};
if (!auth) return results;
const oauthProviders: OAuthProviderId[] = [
"openai-codex",
"anthropic",
"google-gemini-cli",
"google-antigravity",
];
const refreshed = await ensureFreshAuthForProviders(oauthProviders, {
...config,
auth,
authFile,
});
const authData = refreshed.auth ?? auth;
const refreshError = (providerId: OAuthProviderId): string | null => {
const error = refreshed.refreshErrors[providerId];
return error ? `auth refresh failed (${error})` : null;
};
const tasks: Promise<void>[] = [];
const assign = (provider: ProviderKey, task: Promise<UsageData>) => {
tasks.push(
task
.then((usage) => {
results[provider] = usage;
})
.catch((error) => {
results[provider] = { session: 0, weekly: 0, error: toErrorMessage(error) };
}),
);
};
if (authData["openai-codex"]?.access) {
const err = refreshError("openai-codex");
if (err) results.codex = { session: 0, weekly: 0, error: err };
else assign("codex", fetchCodexUsage(authData["openai-codex"].access, config));
}
if (authData.anthropic?.access) {
const err = refreshError("anthropic");
if (err) results.claude = { session: 0, weekly: 0, error: err };
else assign("claude", fetchClaudeUsage(authData.anthropic.access, config));
}
if (authData.zai?.access || authData.zai?.key) {
assign("zai", fetchZaiUsage(authData.zai.access || authData.zai.key!, { ...config, endpoints }));
}
if (authData["google-gemini-cli"]?.access) {
const err = refreshError("google-gemini-cli");
if (err) {
results.gemini = { session: 0, weekly: 0, error: err };
} else {
const creds = authData["google-gemini-cli"];
assign(
"gemini",
fetchGoogleUsage(creds.access!, endpoints.gemini, creds.projectId, "gemini", { ...config, endpoints }),
);
}
}
if (authData["google-antigravity"]?.access) {
const err = refreshError("google-antigravity");
if (err) {
results.antigravity = { session: 0, weekly: 0, error: err };
} else {
const creds = authData["google-antigravity"];
assign(
"antigravity",
fetchGoogleUsage(creds.access!, endpoints.antigravity, creds.projectId, "antigravity", { ...config, endpoints }),
);
}
}
await Promise.all(tasks);
return results;
}

View File

@@ -0,0 +1,576 @@
/**
* Usage Extension - Minimal API usage indicator for pi
*
* Shows Codex (OpenAI), Anthropic (Claude), Z.AI, and optionally
* Google Gemini CLI / Antigravity usage as color-coded percentages
* in the footer status bar.
*/
import { DynamicBorder, type ExtensionAPI } from "@mariozechner/pi-coding-agent";
import {
Container,
Input,
Spacer,
Text,
getEditorKeybindings,
type Focusable,
} from "@mariozechner/pi-tui";
import {
canShowForProvider,
clampPercent,
colorForPercent,
detectProvider,
fetchAllUsages,
fetchClaudeUsage,
fetchCodexUsage,
fetchGoogleUsage,
fetchZaiUsage,
providerToOAuthProviderId,
readAuth,
readUsageCache,
resolveUsageEndpoints,
writeUsageCache,
type ProviderKey,
type UsageByProvider,
type UsageData,
} from "./core";
const CACHE_TTL_MS = 15 * 60 * 1000; // reuse cached data for 15 min
const RATE_LIMITED_BACKOFF_MS = 60 * 60 * 1000; // 1 hour back-off after 429
const STATUS_KEY = "usage-bars";
const PROVIDER_LABELS: Record<ProviderKey, string> = {
codex: "Codex",
claude: "Claude",
zai: "Z.AI",
gemini: "Gemini",
antigravity: "Antigravity",
};
interface SubscriptionItem {
name: string;
provider: ProviderKey;
data: UsageData | null;
isActive: boolean;
}
class UsageSelectorComponent extends Container implements Focusable {
private searchInput: Input;
private listContainer: Container;
private hintText: Text;
private tui: any;
private theme: any;
private onCancelCallback: () => void;
private allItems: SubscriptionItem[] = [];
private filteredItems: SubscriptionItem[] = [];
private selectedIndex = 0;
private loading = true;
private activeProvider: ProviderKey | null;
private fetchAllFn: () => Promise<UsageByProvider>;
private _focused = false;
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.searchInput.focused = value;
}
constructor(
tui: any,
theme: any,
activeProvider: ProviderKey | null,
fetchAll: () => Promise<UsageByProvider>,
onCancel: () => void,
) {
super();
this.tui = tui;
this.theme = theme;
this.activeProvider = activeProvider;
this.fetchAllFn = fetchAll;
this.onCancelCallback = onCancel;
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
this.addChild(new Spacer(1));
this.hintText = new Text(theme.fg("dim", "Fetching usage from all providers…"), 0, 0);
this.addChild(this.hintText);
this.addChild(new Spacer(1));
this.searchInput = new Input();
this.addChild(this.searchInput);
this.addChild(new Spacer(1));
this.listContainer = new Container();
this.addChild(this.listContainer);
this.addChild(new Spacer(1));
this.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
this.fetchAllFn()
.then((results) => {
this.loading = false;
this.buildItems(results);
this.updateList();
this.hintText.setText(
theme.fg("muted", "Only showing providers with credentials. ") +
theme.fg("dim", "✓ = active provider"),
);
this.tui.requestRender();
})
.catch(() => {
this.loading = false;
this.hintText.setText(theme.fg("error", "Failed to fetch usage data"));
this.tui.requestRender();
});
this.updateList();
}
private buildItems(results: UsageByProvider) {
const providers: Array<{ key: ProviderKey; name: string }> = [
{ key: "codex", name: "Codex" },
{ key: "claude", name: "Claude" },
{ key: "zai", name: "Z.AI" },
{ key: "gemini", name: "Gemini" },
{ key: "antigravity", name: "Antigravity" },
];
this.allItems = [];
for (const p of providers) {
if (results[p.key] !== null) {
this.allItems.push({
name: p.name,
provider: p.key,
data: results[p.key],
isActive: this.activeProvider === p.key,
});
}
}
this.filteredItems = this.allItems;
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
}
private filterItems(query: string) {
if (!query) {
this.filteredItems = this.allItems;
} else {
const q = query.toLowerCase();
this.filteredItems = this.allItems.filter(
(item) => item.name.toLowerCase().includes(q) || item.provider.toLowerCase().includes(q),
);
}
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.filteredItems.length - 1));
}
private renderBar(pct: number, width = 16): string {
const value = clampPercent(pct);
const filled = Math.round((value / 100) * width);
const color = colorForPercent(value);
const full = "█".repeat(Math.max(0, filled));
const empty = "░".repeat(Math.max(0, width - filled));
return this.theme.fg(color, full) + this.theme.fg("dim", empty);
}
private renderItem(item: SubscriptionItem, isSelected: boolean) {
const t = this.theme;
const pointer = isSelected ? t.fg("accent", "→ ") : " ";
const activeBadge = item.isActive ? t.fg("success", " ✓") : "";
const name = isSelected ? t.fg("accent", t.bold(item.name)) : item.name;
this.listContainer.addChild(new Text(`${pointer}${name}${activeBadge}`, 0, 0));
const indent = " ";
if (!item.data) {
this.listContainer.addChild(new Text(indent + t.fg("dim", "No credentials"), 0, 0));
} else if (item.data.error) {
this.listContainer.addChild(new Text(indent + t.fg("error", item.data.error), 0, 0));
} else {
const session = clampPercent(item.data.session);
const weekly = clampPercent(item.data.weekly);
const sessionReset = item.data.sessionResetsIn
? t.fg("dim", ` resets in ${item.data.sessionResetsIn}`)
: "";
const weeklyReset = item.data.weeklyResetsIn
? t.fg("dim", ` resets in ${item.data.weeklyResetsIn}`)
: "";
this.listContainer.addChild(
new Text(
indent +
t.fg("muted", "Session ") +
this.renderBar(session) +
" " +
t.fg(colorForPercent(session), `${session}%`.padStart(4)) +
sessionReset,
0,
0,
),
);
this.listContainer.addChild(
new Text(
indent +
t.fg("muted", "Weekly ") +
this.renderBar(weekly) +
" " +
t.fg(colorForPercent(weekly), `${weekly}%`.padStart(4)) +
weeklyReset,
0,
0,
),
);
if (typeof item.data.extraSpend === "number" && typeof item.data.extraLimit === "number") {
this.listContainer.addChild(
new Text(
indent +
t.fg("muted", "Extra ") +
t.fg("dim", `$${item.data.extraSpend.toFixed(2)} / $${item.data.extraLimit}`),
0,
0,
),
);
}
}
this.listContainer.addChild(new Spacer(1));
}
private updateList() {
this.listContainer.clear();
if (this.loading) {
this.listContainer.addChild(new Text(this.theme.fg("muted", " Loading…"), 0, 0));
return;
}
if (this.filteredItems.length === 0) {
this.listContainer.addChild(new Text(this.theme.fg("muted", " No matching providers"), 0, 0));
return;
}
for (let i = 0; i < this.filteredItems.length; i++) {
this.renderItem(this.filteredItems[i]!, i === this.selectedIndex);
}
}
handleInput(keyData: string): void {
const kb = getEditorKeybindings();
if (kb.matches(keyData, "selectUp")) {
if (this.filteredItems.length === 0) return;
this.selectedIndex =
this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1;
this.updateList();
return;
}
if (kb.matches(keyData, "selectDown")) {
if (this.filteredItems.length === 0) return;
this.selectedIndex =
this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1;
this.updateList();
return;
}
if (kb.matches(keyData, "selectCancel") || kb.matches(keyData, "selectConfirm")) {
this.onCancelCallback();
return;
}
this.searchInput.handleInput(keyData);
this.filterItems(this.searchInput.getValue());
this.updateList();
}
}
interface UsageState extends UsageByProvider {
lastPoll: number;
activeProvider: ProviderKey | null;
}
export default function (pi: ExtensionAPI) {
const endpoints = resolveUsageEndpoints();
const state: UsageState = {
codex: null,
claude: null,
zai: null,
gemini: null,
antigravity: null,
lastPoll: 0,
activeProvider: null,
};
let pollInFlight: Promise<void> | null = null;
let pollQueued = false;
let ctx: any = null;
function renderPercent(theme: any, value: number): string {
const v = clampPercent(value);
return theme.fg(colorForPercent(v), `${v}%`);
}
function renderBar(theme: any, value: number): string {
const v = clampPercent(value);
const width = 8;
const filled = Math.round((v / 100) * width);
const full = "█".repeat(Math.max(0, Math.min(width, filled)));
const empty = "░".repeat(Math.max(0, width - filled));
return theme.fg(colorForPercent(v), full) + theme.fg("dim", empty);
}
function pickDataForProvider(provider: ProviderKey | null): UsageData | null {
if (!provider) return null;
return state[provider];
}
function updateStatus() {
const active = state.activeProvider;
const data = pickDataForProvider(active);
if (data && !data.error) {
pi.events.emit("usage:update", {
session: data.session,
weekly: data.weekly,
sessionResetsIn: data.sessionResetsIn,
weeklyResetsIn: data.weeklyResetsIn,
});
}
if (!ctx?.hasUI) return;
if (!active) {
ctx.ui.setStatus(STATUS_KEY, undefined);
return;
}
const auth = readAuth();
if (!canShowForProvider(active, auth, endpoints)) {
ctx.ui.setStatus(STATUS_KEY, undefined);
return;
}
const theme = ctx.ui.theme;
const label = PROVIDER_LABELS[active];
if (!data) {
ctx.ui.setStatus(STATUS_KEY, theme.fg("dim", `${label} usage: loading…`));
return;
}
if (data.error) {
const cache = readUsageCache();
const blockedUntil = active ? (cache?.rateLimitedUntil?.[active] ?? 0) : 0;
const backoffNote = blockedUntil > Date.now()
? ` — retry in ${Math.ceil((blockedUntil - Date.now()) / 60000)}m`
: "";
ctx.ui.setStatus(STATUS_KEY, theme.fg("warning", `${label} usage unavailable (${data.error}${backoffNote})`));
return;
}
const session = clampPercent(data.session);
const weekly = clampPercent(data.weekly);
const sessionReset = data.sessionResetsIn ? theme.fg("dim", `${data.sessionResetsIn}`) : "";
const weeklyReset = data.weeklyResetsIn ? theme.fg("dim", `${data.weeklyResetsIn}`) : "";
const status =
theme.fg("dim", `${label} `) +
theme.fg("muted", "S ") +
renderBar(theme, session) +
" " +
renderPercent(theme, session) +
sessionReset +
theme.fg("muted", " W ") +
renderBar(theme, weekly) +
" " +
renderPercent(theme, weekly) +
weeklyReset;
ctx.ui.setStatus(STATUS_KEY, status);
}
function updateProviderFrom(modelLike: any): boolean {
const previous = state.activeProvider;
state.activeProvider = detectProvider(modelLike);
if (previous !== state.activeProvider) {
updateStatus();
return true;
}
return false;
}
async function runPoll() {
const auth = readAuth();
const active = state.activeProvider;
if (!canShowForProvider(active, auth, endpoints) || !auth || !active) {
state.lastPoll = Date.now();
updateStatus();
return;
}
// --- Shared disk cache check ---
// All pi sessions read and write the same cache file so that only one
// process hits the API per CACHE_TTL_MS window, no matter how many
// sessions are open at once.
const cache = readUsageCache();
const now = Date.now();
// Respect cross-session rate-limit back-off written by any session.
const blockedUntil = cache?.rateLimitedUntil?.[active] ?? 0;
if (now < blockedUntil) {
// Use whatever data is in the cache so the bar still shows something.
if (cache?.data?.[active]) state[active] = cache.data[active]!;
state.lastPoll = now;
updateStatus();
return;
}
// If another session already polled recently, use their result.
if (cache && now - cache.timestamp < CACHE_TTL_MS && cache.data?.[active]) {
state[active] = cache.data[active]!;
state.lastPoll = now;
updateStatus();
return;
}
// --- Actually hit the API ---
// Skip independent token refresh — pi manages OAuth tokens and refreshes
// them in memory. A parallel refresh here would cause token rotation
// conflicts (Anthropic invalidates the old refresh token on use).
let result: UsageData;
if (active === "codex") {
const access = auth["openai-codex"]?.access;
result = access
? await fetchCodexUsage(access)
: { session: 0, weekly: 0, error: "missing access token (try /login again)" };
} else if (active === "claude") {
const access = auth.anthropic?.access;
result = access
? await fetchClaudeUsage(access)
: { session: 0, weekly: 0, error: "missing access token (try /login again)" };
} else if (active === "zai") {
const token = auth.zai?.access || auth.zai?.key;
result = token
? await fetchZaiUsage(token, { endpoints })
: { session: 0, weekly: 0, error: "missing token (try /login again)" };
} else if (active === "gemini") {
const creds = auth["google-gemini-cli"];
result = creds?.access
? await fetchGoogleUsage(creds.access, endpoints.gemini, creds.projectId, "gemini", { endpoints })
: { session: 0, weekly: 0, error: "missing access token (try /login again)" };
} else {
const creds = auth["google-antigravity"];
result = creds?.access
? await fetchGoogleUsage(creds.access, endpoints.antigravity, creds.projectId, "antigravity", { endpoints })
: { session: 0, weekly: 0, error: "missing access token (try /login again)" };
}
state[active] = result;
// Write result + rate-limit state to shared cache so other sessions
// (and our own next timer tick) don't need to re-hit the API.
const nextCache: import("./core").UsageCache = {
timestamp: now,
data: { ...(cache?.data ?? {}), [active]: result },
rateLimitedUntil: { ...(cache?.rateLimitedUntil ?? {}) },
};
if (result.error === "HTTP 429") {
nextCache.rateLimitedUntil![active] = now + RATE_LIMITED_BACKOFF_MS;
} else {
delete nextCache.rateLimitedUntil![active];
}
writeUsageCache(nextCache);
state.lastPoll = now;
updateStatus();
}
async function poll() {
if (pollInFlight) {
pollQueued = true;
await pollInFlight;
return;
}
do {
pollQueued = false;
pollInFlight = runPoll()
.catch(() => {
// Never crash extension event handlers on transient polling errors.
})
.finally(() => {
pollInFlight = null;
});
await pollInFlight;
} while (pollQueued);
}
pi.on("session_start", async (_event, _ctx) => {
ctx = _ctx;
updateProviderFrom(_ctx.model);
await poll();
});
pi.on("session_shutdown", async (_event, _ctx) => {
if (_ctx?.hasUI) {
_ctx.ui.setStatus(STATUS_KEY, undefined);
}
});
// Refresh usage on every turn (like claude-pulse's UserPromptSubmit hook).
// The disk cache means the API is only hit at most once per CACHE_TTL_MS
// regardless of how many turns or sessions are active.
pi.on("turn_start", async (_event, _ctx) => {
ctx = _ctx;
updateProviderFrom(_ctx.model);
await poll();
});
pi.on("model_select", async (event, _ctx) => {
ctx = _ctx;
const changed = updateProviderFrom(event.model ?? _ctx.model);
if (changed) await poll();
});
pi.registerCommand("usage", {
description: "Show API usage for all subscriptions",
handler: async (_args, _ctx) => {
ctx = _ctx;
updateProviderFrom(_ctx.model);
try {
if (_ctx?.hasUI) {
await _ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
const selector = new UsageSelectorComponent(
tui,
theme,
state.activeProvider,
() => fetchAllUsages({ endpoints }),
() => done(),
);
return selector;
});
}
} finally {
await poll();
}
},
});
// Re-poll immediately when the Claude account is switched via /switch-claude
pi.events.on("claude-account:switched", () => {
void poll();
});
}

140
pi/.pi/agent/mcp-cache.json Normal file
View File

@@ -0,0 +1,140 @@
{
"version": 1,
"servers": {
"qmd": {
"configHash": "fd16eaf87d17a4ce5efee10dc65237dbbe1403353bbbfc4a7de196abe21ab5f9",
"tools": [
{
"name": "query",
"description": "Search the knowledge base using a query document — one or more typed sub-queries combined for best recall.\n\n## Query Types\n\n**lex** — BM25 keyword search. Fast, exact, no LLM needed.\nFull lex syntax:\n- `term` — prefix match (\"perf\" matches \"performance\")\n- `\"exact phrase\"` — phrase must appear verbatim\n- `-term` or `-\"phrase\"` — exclude documents containing this\n\nGood lex examples:\n- `\"connection pool\" timeout -redis`\n- `\"machine learning\" -sports -athlete`\n- `handleError async typescript`\n\n**vec** — Semantic vector search. Write a natural language question. Finds documents by meaning, not exact words.\n- `how does the rate limiter handle burst traffic?`\n- `what is the tradeoff between consistency and availability?`\n\n**hyde** — Hypothetical document. Write 50-100 words that look like the answer. Often the most powerful for nuanced topics.\n- `The rate limiter uses a token bucket algorithm. When a client exceeds 100 req/min, subsequent requests return 429 until the window resets.`\n\n## Strategy\n\nCombine types for best results. First sub-query gets 2× weight — put your strongest signal first.\n\n| Goal | Approach |\n|------|----------|\n| Know exact term/name | `lex` only |\n| Concept search | `vec` only |\n| Best recall | `lex` + `vec` |\n| Complex/nuanced | `lex` + `vec` + `hyde` |\n| Unknown vocabulary | Use a standalone natural-language query (no typed lines) so the server can auto-expand it |\n\n## Examples\n\nSimple lookup:\n```json\n[{ \"type\": \"lex\", \"query\": \"CAP theorem\" }]\n```\n\nBest recall on a technical topic:\n```json\n[\n { \"type\": \"lex\", \"query\": \"\\\"connection pool\\\" timeout -redis\" },\n { \"type\": \"vec\", \"query\": \"why do database connections time out under load\" },\n { \"type\": \"hyde\", \"query\": \"Connection pool exhaustion occurs when all connections are in use and new requests must wait. This typically happens under high concurrency when queries run longer than expected.\" }\n]\n```\n\nIntent-aware lex (C++ performance, not sports):\n```json\n[\n { \"type\": \"lex\", \"query\": \"\\\"C++ performance\\\" optimization -sports -athlete\" },\n { \"type\": \"vec\", \"query\": \"how to optimize C++ program performance\" }\n]\n```",
"inputSchema": {
"type": "object",
"properties": {
"searches": {
"minItems": 1,
"maxItems": 10,
"type": "array",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"lex",
"vec",
"hyde"
],
"description": "lex = BM25 keywords (supports \"phrase\" and -negation); vec = semantic question; hyde = hypothetical answer passage"
},
"query": {
"type": "string",
"description": "The query text. For lex: use keywords, \"quoted phrases\", and -negation. For vec: natural language question. For hyde: 50-100 word answer passage."
}
},
"required": [
"type",
"query"
]
},
"description": "Typed sub-queries to execute (lex/vec/hyde). First gets 2x weight."
},
"limit": {
"default": 10,
"description": "Max results (default: 10)",
"type": "number"
},
"minScore": {
"default": 0,
"description": "Min relevance 0-1 (default: 0)",
"type": "number"
},
"collections": {
"description": "Filter to collections (OR match)",
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"searches"
],
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "get",
"description": "Retrieve the full content of a document by its file path or docid. Use paths or docids (#abc123) from search results. Suggests similar files if not found.",
"inputSchema": {
"type": "object",
"properties": {
"file": {
"type": "string",
"description": "File path or docid from search results (e.g., 'pages/meeting.md', '#abc123', or 'pages/meeting.md:100' to start at line 100)"
},
"fromLine": {
"description": "Start from this line number (1-indexed)",
"type": "number"
},
"maxLines": {
"description": "Maximum number of lines to return",
"type": "number"
},
"lineNumbers": {
"default": false,
"description": "Add line numbers to output (format: 'N: content')",
"type": "boolean"
}
},
"required": [
"file"
],
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "multi_get",
"description": "Retrieve multiple documents by glob pattern (e.g., 'journals/2025-05*.md') or comma-separated list. Skips files larger than maxBytes.",
"inputSchema": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "Glob pattern or comma-separated list of file paths"
},
"maxLines": {
"description": "Maximum lines per file",
"type": "number"
},
"maxBytes": {
"default": 10240,
"description": "Skip files larger than this (default: 10240 = 10KB)",
"type": "number"
},
"lineNumbers": {
"default": false,
"description": "Add line numbers to output (format: 'N: content')",
"type": "boolean"
}
},
"required": [
"pattern"
],
"$schema": "http://json-schema.org/draft-07/schema#"
}
},
{
"name": "status",
"description": "Show the status of the QMD index: collections, document counts, and health information.",
"inputSchema": {
"type": "object",
"properties": {},
"$schema": "http://json-schema.org/draft-07/schema#"
}
}
],
"resources": [],
"cachedAt": 1772656106222
}
}
}

17
pi/.pi/agent/mcp.json Normal file
View File

@@ -0,0 +1,17 @@
{
"mcpServers": {
"qmd": {
"command": "qmd",
"args": [
"mcp"
],
"directTools": true
},
"opty": {
"command": "opty",
"args": [
"mcp"
]
}
}
}

41
pi/.pi/agent/models.json Normal file
View File

@@ -0,0 +1,41 @@
{
"providers": {
"llama-cpp": {
"baseUrl": "http://localhost:8080/v1",
"api": "openai-completions",
"apiKey": "sk-no-key",
"models": [
{
"id": "unsloth/Qwen3.5-9B-GGUF:Q5_K_M",
"name": "Qwen 3.5 9B Q5_K_M (Local M1 Max - Unsloth)",
"reasoning": true,
"input": ["text"],
"contextWindow": 262144,
"maxTokens": 32768,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
"compat": {
"supportsDeveloperRole": false,
"supportsReasoningEffort": false,
"maxTokensField": "max_tokens",
"thinkingFormat": "qwen"
}
},
{
"id": "unsloth/Qwen3.5-4B-GGUF:Q5_K_M",
"name": "Qwen 3.5 4B Q5_K_M (Local M1 Max - Unsloth)",
"reasoning": true,
"input": ["text"],
"contextWindow": 262144,
"maxTokens": 32768,
"cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
"compat": {
"supportsDeveloperRole": false,
"supportsReasoningEffort": false,
"maxTokensField": "max_tokens",
"thinkingFormat": "qwen"
}
}
]
}
}
}

View File

@@ -0,0 +1,8 @@
{"agent":"scout","task":"I need to understand the editor and rendering architecture for implementing entity picking. Please find and summarize:\n\n1. How editor mode is toggled (look for key 'I' handling, editor mode state)\n2. ","ts":1772639557,"status":"ok","duration":90399}
{"agent":"scout","task":"I need to understand the editor and rendering architecture for implementing entity picking. Please find and summarize:\n\n1. How editor mode is toggled (look for key 'I' handling, editor mode flag)\n2. T","ts":1772654920,"status":"ok","duration":1217167}
{"agent":"scout","task":"I need to understand the codebase to plan entity picking in the editor. Find and summarize:\n\n1. How the editor mode works (toggled with 'I' key) - find the editor module/system, what it currently does","ts":1772655378,"status":"ok","duration":282743}
{"agent":"scout","task":"I need to understand the codebase to plan entity picking in the editor. Find and summarize:\n\n1. How editor mode works - look for editor-related code, the \"I\" key toggle, inspector UI\n2. How entities a","ts":1772655580,"status":"ok","duration":174603}
{"agent":"scout","task":"Explore the codebase structure. Find: 1) the main game loop and input handling, 2) the ECS system and component definitions, 3) any existing UI/rendering systems, 4) entity types (player, trees, etc),","ts":1772656483,"status":"ok","duration":451373}
{"agent":"scout","task":"I need to understand the rendering pipeline, scene loading, and transform usage for implementing entity picking with a visual selection indicator. Find and read:\n\n1. `src/render/mod.rs` - the full ren","ts":1772656502,"status":"ok","duration":82458}
{"agent":"scout","task":"Explore the codebase at /home/jonas/projects/snow_trail_sdl and give me a detailed summary of:\n1. src/loaders/mesh.rs - full content, especially the Mesh struct and all constructor functions\n2. src/pi","ts":1772658265,"status":"ok","duration":330549}
{"agent":"scout","task":"Find and summarize: 1) how the camera follow/update logic works, 2) how editor mode is tracked/detected. Look for camera systems, editor state, and any existing checks for editor mode in camera code.","ts":1772659168,"status":"ok","duration":5992}

View File

@@ -0,0 +1,13 @@
{
"lastChangelogVersion": "0.56.1",
"packages": [
"npm:pi-subagents",
"npm:@aliou/pi-guardrails",
"npm:pi-mcp-adapter",
"npm:pi-ask-tool-extension",
"npm:pi-web-access"
],
"defaultProvider": "anthropic",
"defaultModel": "claude-sonnet-4-6",
"hideThinkingBlock": true
}

View File

@@ -0,0 +1,29 @@
---
name: local-scout
description: "Delegates codebase exploration to the local scout subagent (runs on a fast local Qwen model with QMD + opty tools). Load this skill only when the user explicitly asks to use scout, use local scout, or scout a task. Do NOT load automatically for general exploration — only when scout is explicitly requested."
---
# Local Scout
Delegate codebase exploration to the `scout` subagent, which runs on a fast local model (Qwen) augmented with semantic search via QMD and HDC-indexed context retrieval via opty. It is cheap, fast, and keeps the main context clean.
## When to use
- User says "use scout to find …", "scout: …", or "use local scout"
- You need to gather broad codebase context before planning
- The task is primarily "look around the codebase" rather than making precise edits
## How to invoke
```javascript
subagent({ agent: "scout", task: "Find and summarize the authentication flow" })
```
The scout writes its findings to `context.md` and returns a summary. Use the summary or read `context.md` for the full structured output.
## Tips
- Be specific in the task description — the scout infers thoroughness from it
- For deep traces, prefix with "Thorough:" e.g. `"Thorough: trace all usages of X"`
- For quick lookups, prefix with "Quick:" e.g. `"Quick: where is the config loaded?"`
- Do your own reading only when you need precise line-level content to reference in your response

View File

@@ -0,0 +1,12 @@
{
"timestamp": 1772832937691,
"data": {
"claude": {
"session": 82,
"weekly": 9,
"sessionResetsIn": "3h 24m",
"weeklyResetsIn": "6d 22h"
}
},
"rateLimitedUntil": {}
}