BIG pi update with claude chat

This commit is contained in:
Jonas H
2026-04-24 14:22:59 +02:00
parent fbb00a49ba
commit 248667468c
24 changed files with 4225 additions and 1112 deletions

View File

@@ -0,0 +1,275 @@
/**
* ask-claude — Stream Claude agent reviews into pi.
*
* For AGENTS to use. Delegates to specialized Claude agents or raw models
* for review, analysis, debugging, and second opinions.
*
* Tool (callable by the LLM):
* ask_claude(prompt, agent?, model?, question?, session_id?)
* agent — any agent name from ~/.claude/agents/ (e.g. "plan_review", "code_review", "oracle", "debug")
* model — model override: "opus", "sonnet", or full model ID
* question — specific focus prepended as a review header
* session_id — resume a prior conversation (returned in every response)
*
* Commands:
* /review-plan — editor → Claude Opus plan_review → inject result
* /review-code — editor → Claude Sonnet code_review → inject result
*/
import { defineTool, getMarkdownTheme } from "@mariozechner/pi-coding-agent";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@mariozechner/pi-ai";
import { Box, Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import {
type ClaudeDetails,
type Theme,
renderToolCallLine,
renderToolResultBox,
renderToolBlock,
formatUsage,
buildEditDiff,
formatAnthropicError,
runClaude,
type RunClaudeResult,
} from "../shared/claude-stream.js";
// =============================================================================
// Rendering (ask-claude specific — uses shared tool renderers)
// =============================================================================
function buildLabel(agent?: string, model?: string): string {
if (agent) return `Claude [${agent}]`;
if (model) return `Claude [${model}]`;
return "Claude Sonnet";
}
// =============================================================================
// Tool definition
// =============================================================================
const AskClaudeParams = Type.Object({
prompt: Type.String({
description: "Full content to review/analyze. Include all relevant context: CLAUDE.md conventions, files explored, code or plan to review.",
}),
agent: Type.Optional(Type.String({
description: "Agent name from ~/.claude/agents/ (e.g. 'plan_review', 'code_review', 'oracle', 'debug'). Omit to use model= directly.",
})),
model: Type.Optional(Type.String({
description: "Model override: 'opus', 'sonnet', 'haiku', or a full model ID. When agent is set this overrides the agent's default. When agent is omitted this selects the model directly.",
})),
question: Type.Optional(Type.String({
description: "Specific question or focus area prepended to the prompt (e.g. 'Focus on security', 'Are there race conditions?').",
})),
session_id: Type.Optional(Type.String({
description: "Resume a prior conversation. Pass the session_id returned from a previous ask_claude call.",
})),
});
const askClaudeTool = defineTool({
name: "ask_claude",
label: "Ask Claude",
description: [
"Ask a Claude agent or model for review, analysis, or a second opinion.",
"agent=<name> runs a named agent from ~/.claude/agents/ (e.g. 'plan_review', 'code_review', 'oracle', 'debug').",
"Use model= alone for free-form requests without an agent. Use question= to specify a focus.",
"Pass session_id from a prior response to continue the same conversation across turns.",
"CLAUDE.md and .claude/skills are loaded automatically from the project root.",
].join(" "),
promptSnippet: "Ask a Claude agent or model for review, analysis, or a second opinion",
promptGuidelines: [
"Use ask_claude(agent=<name>) to invoke a specialized agent — include all relevant context in the prompt.",
"Use ask_claude(model='opus', question='...') for free-form deep analysis.",
"Always include the artifact to review (plan, code, problem description) in the prompt.",
"Pass session_id back from the previous response to continue the conversation.",
],
parameters: AskClaudeParams,
async execute(_toolCallId, params, signal, onUpdate, ctx) {
const fullPrompt = params.question
? `## Review Focus\n\n${params.question}\n\n## Content\n\n${params.prompt}`
: params.prompt;
const label = buildLabel(params.agent, params.model);
const details: ClaudeDetails = { label, done: false, blocks: [], finalText: "", isResume: !!params.session_id };
try {
const result: RunClaudeResult = await runClaude(fullPrompt, {
agent: params.agent,
model: params.model,
sessionId: params.session_id,
enrichEditDiffs: true, // ask-claude wants diff enrichment
cwd: ctx.cwd,
signal,
onUpdate: (partial) => {
Object.assign(details, partial);
onUpdate?.({
content: [{ type: "text", text: details.finalText || "(thinking…)" }],
details: { ...details },
});
},
});
details.done = true;
details.finalText = result.finalText;
details.blocks = result.blocks;
details.sessionId = result.sessionId;
details.costUsd = result.costUsd;
details.inputTokens = result.inputTokens;
details.outputTokens = result.outputTokens;
details.cacheReadTokens = result.cacheReadTokens;
details.cacheWriteTokens = result.cacheWriteTokens;
const sessionNote = result.sessionId
? `\n\n---\n*session_id: \`${result.sessionId}\`*`
: "";
return {
content: [{ type: "text", text: (result.finalText || "(no output)") + sessionNote }],
details: { ...details },
};
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
details.done = true;
details.finalText = `Error: ${errMsg}`;
return {
content: [{ type: "text", text: `**Claude error:** ${errMsg}` }],
details: { ...details },
};
}
},
renderCall(args, theme, _ctx) {
const label = buildLabel(args.agent, args.model);
let text = theme.fg("toolTitle", theme.bold("ask_claude ")) + theme.fg("accent", `[${label}]`);
if (args.question) {
text += "\n " + theme.fg("dim", theme.italic(args.question));
} else {
const lines = args.prompt.split("\n").filter((l) => l.trim()).slice(0, 3);
text += "\n " + theme.fg("dim", lines.join("\n "));
}
return new Text(text, 0, 0);
},
renderResult(result, { isPartial }, theme, _ctx) {
const d = result.details as ClaudeDetails | undefined;
if (!d) return new Text(theme.fg("muted", "…"), 0, 0);
const isDone = d.done && !isPartial;
const statusIcon = isDone ? theme.fg("success", "✓ ") : theme.fg("warning", "⏳ ");
const c = new Container();
const resume = d.isResume ? theme.fg("dim", " ↩") : "";
c.addChild(new Text(statusIcon + theme.fg("toolTitle", theme.bold(d.label)) + resume, 0, 0));
for (const block of d.blocks ?? []) {
if (block.type === "thinking" && block.text.trim()) {
c.addChild(new Text(theme.fg("dim", theme.italic(block.text.trimEnd())), 0, 0));
} else if (block.type === "tool") {
c.addChild(renderToolBlock(block, theme as any));
} else if (block.type === "text" && block.text.trim()) {
c.addChild(new Spacer(1));
if (isDone) {
c.addChild(new Markdown(block.text.trim(), 0, 0, getMarkdownTheme()));
} else {
c.addChild(new Text(theme.fg("dim", block.text.trimEnd()), 0, 0));
}
}
}
if (isDone) {
const usageLine = formatUsage(d);
const parts: string[] = [];
if (usageLine) parts.push(usageLine);
if (d.sessionId) parts.push(`session:${d.sessionId.slice(0, 8)}`);
if (parts.length > 0) {
c.addChild(new Spacer(1));
c.addChild(new Text(theme.fg("dim", parts.join(" ")), 0, 0));
}
}
return c;
},
});
// =============================================================================
// Extension entry point
// =============================================================================
export default function (pi: ExtensionAPI) {
pi.registerTool(askClaudeTool);
// ── /review-plan ───────────────────────────────────────────────────────
pi.registerCommand("review-plan", {
description: "Editor → Claude Opus plan_review → inject review into conversation",
handler: async (_args, ctx) => {
const input = await ctx.ui.editor(
"Plan Review · Claude Opus",
"Paste your plan or strategy. Claude will review for correctness, completeness, and risk.",
);
if (!input?.trim()) { ctx.ui.notify("Cancelled.", "info"); return; }
ctx.ui.setStatus("ask-claude", "Asking Claude Opus (plan_review)…");
try {
const r = await runClaude(input, { agent: "plan_review", cwd: ctx.cwd, onUpdate: () => {} });
if (!r.finalText.trim()) { ctx.ui.notify("No output from Claude.", "warning"); return; }
pi.sendMessage(
{ customType: "ask-claude-review", content: r.finalText.trim(), display: true,
details: { label: "Claude Opus · plan_review", output: r.finalText } },
{ triggerTurn: true },
);
} catch (err) {
ctx.ui.notify(`Claude error: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally { ctx.ui.setStatus("ask-claude", undefined); }
},
});
// ── /review-code ───────────────────────────────────────────────────────
pi.registerCommand("review-code", {
description: "Editor → Claude Sonnet code_review → inject review into conversation",
handler: async (_args, ctx) => {
const input = await ctx.ui.editor(
"Code Review · Claude Sonnet",
"Paste code to review. Include the plan it implements and any specific concerns.",
);
if (!input?.trim()) { ctx.ui.notify("Cancelled.", "info"); return; }
ctx.ui.setStatus("ask-claude", "Asking Claude Sonnet (code_review)…");
try {
const r = await runClaude(input, { agent: "code_review", cwd: ctx.cwd, onUpdate: () => {} });
if (!r.finalText.trim()) { ctx.ui.notify("No output from Claude.", "warning"); return; }
pi.sendMessage(
{ customType: "ask-claude-review", content: r.finalText.trim(), display: true,
details: { label: "Claude Sonnet · code_review", output: r.finalText } },
{ triggerTurn: true },
);
} catch (err) {
ctx.ui.notify(`Claude error: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally { ctx.ui.setStatus("ask-claude", undefined); }
},
});
// ── Message renderer for injected reviews ──────────────────────────────
pi.registerMessageRenderer("ask-claude-review", (message, { expanded }, theme) => {
const d = message.details as { label?: string; output?: string } | undefined;
const label = d?.label ?? "Claude";
const output = (d?.output ?? "").trim();
if (expanded) {
const c = new Container();
c.addChild(new Text(theme.fg("accent", "◆ ") + theme.fg("toolTitle", theme.bold(label)), 0, 0));
c.addChild(new Spacer(1));
c.addChild(new Markdown(output, 0, 0, getMarkdownTheme()));
return c;
}
let text = theme.fg("accent", "◆ ") + theme.fg("toolTitle", theme.bold(label));
const lines = output.split("\n").filter((l) => l.trim());
const preview = lines.slice(0, 4).join("\n");
if (preview) {
text += "\n" + theme.fg("dim", preview);
if (lines.length > 4) text += "\n" + theme.fg("muted", `${lines.length - 4} more (Ctrl+O)`);
}
return new Text(text, 0, 0);
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,419 +0,0 @@
/**
* Claude Account Switch Extension
*
* Switches between two Claude Pro accounts (personal and work) **without
* restarting pi**. Works by swapping auth.json files at the filesystem level,
* then reloading the auth storage and forcing an immediate token refresh to
* validate the switch.
*
* Why file-level swaps? Anthropic's OAuth rotates refresh tokens on every
* refresh. Calling authStorage.set() can appear to work, but the next
* getApiKey() call triggers refreshOAuthTokenWithLock(), which re-reads
* auth.json from disk — overwriting in-memory changes if persistence
* silently failed. Working at the file level avoids this entirely.
*
* 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";
import { execSync } from "node:child_process";
const HOME = os.homedir();
const AUTH_JSON = path.join(HOME, ".pi/agent/auth.json");
const MARKER_FILE = path.join(HOME, ".pi/agent/auth.json.current");
const PROFILES_DIR = path.join(HOME, ".pi/agent/profiles");
type Account = "personal" | "work";
// ── Session-window helpers ─────────────────────────────────────────────────
//
// We store the actual `resets_at` timestamp returned by Claude's usage API
// (via the usage:update event) so the switch menu can show a live countdown
// to the next session reset rather than a guessed switchedAt + 5h window.
function sessionStampPath(account: Account): string {
return path.join(PROFILES_DIR, `session-${account}.json`);
}
/** Persist the actual session-reset timestamp for an account. */
function saveSessionResetsAt(account: Account, resetsAt: number): void {
try {
fs.mkdirSync(PROFILES_DIR, { recursive: true });
fs.writeFileSync(
sessionStampPath(account),
JSON.stringify({ resetsAt }, null, 2),
{ mode: 0o600 },
);
} catch {}
}
/** Load the stored session-reset timestamp (ms epoch), or null. */
function loadSessionResetsAt(account: Account): number | null {
try {
const raw = fs.readFileSync(sessionStampPath(account), "utf-8");
const { resetsAt } = JSON.parse(raw) as { resetsAt: number };
if (typeof resetsAt === "number") return resetsAt;
} catch {}
return null;
}
/** Format milliseconds as a compact duration string. */
function formatDuration(ms: number): string {
const totalSec = Math.ceil(ms / 1000);
const h = Math.floor(totalSec / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m`;
return `${s}s`;
}
/**
* One-line session summary appended to each account option in the select menu.
*
* Window still running: [resets in Xh Ym]
* Window already passed: [0 (ready)]
* Never recorded: (empty)
*/
function sessionSummary(account: Account): string {
const resetsAt = loadSessionResetsAt(account);
if (resetsAt === null) return "";
const remaining = resetsAt - Date.now();
if (remaining <= 0) return " [0 (ready)]";
return ` [resets in ${formatDuration(remaining)}]`;
}
// ── Profile helpers ────────────────────────────────────────────────────────
function profilePath(account: Account): string {
return path.join(PROFILES_DIR, `auth-${account}.json`);
}
function hasProfile(account: Account): boolean {
return fs.existsSync(profilePath(account));
}
/**
* Save auth.json content directly to a profile file.
* This captures the exact on-disk state, including any tokens that were
* refreshed behind our back by the auth system.
*
* We parse + re-serialize the JSON to guard against corrupt auth.json
* (e.g. trailing commas left by buggy serializers). If the file can't
* be parsed, we skip the save rather than propagate bad data.
*/
function saveCurrentAuthToProfile(account: Account): boolean {
fs.mkdirSync(PROFILES_DIR, { recursive: true });
if (!fs.existsSync(AUTH_JSON)) return false;
try {
const raw = fs.readFileSync(AUTH_JSON, "utf-8");
const parsed = JSON.parse(raw); // validates JSON
const clean = JSON.stringify(parsed, null, 2);
fs.writeFileSync(profilePath(account), clean, { mode: 0o600 });
return true;
} catch {
// auth.json is missing or corrupt — don't propagate bad data
return false;
}
}
/**
* Copy a profile file to auth.json. This is an atomic-ish swap that
* replaces the entire file rather than merging per-provider.
*
* Like saveCurrentAuthToProfile, we round-trip through JSON.parse to
* ensure we never write corrupt data to auth.json.
*/
function restoreProfileToAuth(account: Account): void {
const raw = fs.readFileSync(profilePath(account), "utf-8");
const parsed = JSON.parse(raw); // throws on corrupt profile
const clean = JSON.stringify(parsed, null, 2);
fs.writeFileSync(AUTH_JSON, clean, { mode: 0o600 });
}
function setMarker(account: Account): void {
fs.writeFileSync(MARKER_FILE, account, "utf-8");
}
function getCurrentAccount(): Account | "unknown" {
try {
const marker = fs.readFileSync(MARKER_FILE, "utf8").trim();
if (marker === "personal" || marker === "work") return marker;
} catch {}
return "unknown";
}
// ── Other session detection ────────────────────────────────────────────────
function otherPiSessions(): number[] {
try {
const myPid = process.pid;
// Use a character class [c] trick so pgrep doesn't match its own process
const out = execSync("pgrep -f 'pi-[c]oding-agent' 2>/dev/null || true", {
encoding: "utf-8",
});
const pids = out
.trim()
.split("\n")
.map(Number)
.filter((p) => p && p !== myPid && !isNaN(p));
return pids;
} catch {
return [];
}
}
function killOtherSessions(pids: number[]): number {
let killed = 0;
for (const pid of pids) {
try {
process.kill(pid);
killed++;
} catch {
// already dead or permission denied
}
}
return killed;
}
// ── UI helpers ─────────────────────────────────────────────────────────────
function statusLabel(account: Account | "unknown"): string {
switch (account) {
case "personal":
return " personal";
case "work":
return "󰃖 work";
default:
return " claude";
}
}
// ── Extension ──────────────────────────────────────────────────────────────
export default function (pi: ExtensionAPI) {
let currentAccount: Account | "unknown" = "unknown";
// Whenever usage-bars fetches fresh data, save the real resets_at for the
// current account so the switch menu shows an accurate live countdown.
pi.events.on("usage:update", (event: unknown) => {
const e = event as { sessionResetsAt?: number };
if (currentAccount !== "unknown" && typeof e.sessionResetsAt === "number") {
saveSessionResetsAt(currentAccount, e.sessionResetsAt);
}
});
pi.on("session_start", async (_event, ctx) => {
// Proper-lockfile creates auth.json.lock as a *directory* (atomic mkdir).
// If a regular file exists at that path (e.g. left by an older pi version),
// rmdir fails with ENOTDIR → lock acquisition throws → loadError is set →
// credentials are never persisted after /login. Delete the stale file and
// reload so this session has working auth persistence.
const lockPath = AUTH_JSON + ".lock";
try {
const stat = fs.statSync(lockPath);
if (stat.isFile()) {
fs.unlinkSync(lockPath);
ctx.modelRegistry.authStorage.reload();
}
} catch {
// lock doesn't exist or we can't stat it — nothing to fix
}
// Guard against corrupt auth.json (e.g. trailing commas from buggy
// serializers). Re-serialize to clean JSON and reload so the auth
// system picks up valid credentials.
try {
const raw = fs.readFileSync(AUTH_JSON, "utf-8");
const parsed = JSON.parse(raw);
const clean = JSON.stringify(parsed, null, 2);
if (clean !== raw) {
fs.writeFileSync(AUTH_JSON, clean, { mode: 0o600 });
ctx.modelRegistry.authStorage.reload();
}
} catch {
// auth.json missing or unparseable — nothing we can fix here
}
currentAccount = getCurrentAccount();
ctx.ui.setStatus("claude-account", statusLabel(currentAccount));
});
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 authStorage = ctx.modelRegistry.authStorage;
const trimmed = args?.trim() ?? "";
// ── Save current auth state 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;
}
if (!authStorage.has("anthropic")) {
ctx.ui.notify(
"No Anthropic credentials found. Run /login first.",
"warning",
);
return;
}
saveCurrentAuthToProfile(name as Account);
currentAccount = name as Account;
setMarker(currentAccount);
ctx.ui.setStatus("claude-account", statusLabel(currentAccount));
ctx.ui.notify(
`Saved current login as ${statusLabel(name as Account)} profile`,
"info",
);
return;
}
// ── Resolve target account (direct arg or interactive) ──────────
let newAccount: Account;
if (trimmed === "personal" || trimmed === "work") {
newAccount = trimmed;
} else if (trimmed === "") {
const personalLabel = ` personal${currentAccount === "personal" ? " ← current" : ""}${!hasProfile("personal") ? " (no profile saved)" : ""}${sessionSummary("personal")}`;
const workLabel = `󰃖 work${currentAccount === "work" ? " ← current" : ""}${!hasProfile("work") ? " (no profile saved)" : ""}${sessionSummary("work")}`;
const accountChoice = await ctx.ui.select(
"Switch Claude account:",
[personalLabel, workLabel],
);
if (accountChoice === undefined) return;
newAccount = accountChoice.startsWith("") ? "personal" : "work";
} else {
ctx.ui.notify(
"Usage: /switch-claude [personal|work|save <name>]",
"warning",
);
return;
}
if (newAccount === currentAccount) {
ctx.ui.notify(
`Already using ${statusLabel(newAccount)}`,
"info",
);
return;
}
// ── Warn about other sessions ───────────────────────────────────
const otherPids = otherPiSessions();
if (otherPids.length > 0) {
const sessionChoice = await ctx.ui.select(
`⚠️ ${otherPids.length} other pi session(s) detected`,
[
"Continue anyway",
`Kill ${otherPids.length} other instance(s) and continue`,
"Cancel",
],
);
if (sessionChoice === undefined || sessionChoice.includes("Cancel"))
return;
if (sessionChoice.includes("Kill")) {
const killed = killOtherSessions(otherPids);
ctx.ui.notify(`Killed ${killed} pi session(s)`, "info");
}
}
if (!hasProfile(newAccount)) {
ctx.ui.notify(
`No profile saved for ${newAccount}. Run /login then /switch-claude save ${newAccount}`,
"warning",
);
return;
}
// ── Perform the switch ──────────────────────────────────────────
try {
// 1. Snapshot current auth.json → outgoing profile.
// This captures any tokens that were silently refreshed
// since the last save (the file is the source of truth,
// not the in-memory snapshot from getAll()).
if (currentAccount !== "unknown") {
saveCurrentAuthToProfile(currentAccount);
}
// 2. Copy incoming profile → auth.json (full file replace).
restoreProfileToAuth(newAccount);
// 3. Tell AuthStorage to re-read the file. This updates
// the in-memory credential cache from the new auth.json.
authStorage.reload();
// 4. Force an immediate token refresh to validate the switch.
// If the stored refresh token is stale, this will fail now
// rather than on the next chat message.
const apiKey = await authStorage.getApiKey("anthropic");
if (!apiKey) {
// Refresh failed → roll back to the previous account.
if (currentAccount !== "unknown") {
restoreProfileToAuth(currentAccount);
authStorage.reload();
}
ctx.ui.notify(
`❌ Switch failed: could not authenticate as ${newAccount}. ` +
`The saved refresh token may have expired. ` +
`Run /login then /switch-claude save ${newAccount} to re-save.`,
"error",
);
return;
}
// 5. Success — the refresh worked, auth.json now has fresh
// tokens. Save them back to the profile so next switch
// has the latest refresh token.
saveCurrentAuthToProfile(newAccount);
currentAccount = newAccount;
setMarker(currentAccount);
ctx.ui.setStatus("claude-account", statusLabel(currentAccount));
pi.events.emit("claude-account:switched", { account: newAccount });
ctx.ui.notify(
`Switched to ${statusLabel(newAccount)}`,
"info",
);
} catch (e: unknown) {
// Something went wrong → try to roll back.
try {
if (currentAccount !== "unknown" && hasProfile(currentAccount)) {
restoreProfileToAuth(currentAccount);
authStorage.reload();
}
} catch {
// rollback failed too — nothing more we can do
}
const msg = e instanceof Error ? e.message : String(e);
ctx.ui.notify(
`❌ Switch failed: ${msg}. Rolled back to ${statusLabel(currentAccount)}. ` +
`You may need to /login and /switch-claude save ${newAccount}.`,
"error",
);
}
},
});
}

View File

@@ -4,11 +4,10 @@
* Replaces the built-in pi footer with a single clean line that assembles
* status from all other extensions:
*
* \uEF85 | S ⣿⣶⣀⣀⣀ 34% 2h 55m | W ⣿⣿⣷⣀⣀ 68% ⟳ Fri 09:00 | C ⣿⣀⣀⣀⣀ 20% | Sonnet 4.6 | rust-analyzer | MCP: 1/2
* ~dir | S ⣿⣶⣀⣀⣀ 34% 2h 55m | W ⣿⣿⣷⣀⣀ 68% ⟳ Fri 09:00 | C ⣿⣀⣀⣀⣀ 20% | Sonnet 4.6 | rust-analyzer | MCP: 1/2
*
* Status sources:
* "claude-account" — set by claude-account-switch.ts → just the icon
* "usage-bars" — set by usage-bars extension → S/W bars (pass-through)
* usage:update event — set by usage-bars extension → S/W bars (Claude usage, always shown)
* ctx.getContextUsage() → C bar (rendered here)
* ctx.model → model short name
* "lsp" — set by lsp-pi extension → strip "LSP " prefix
@@ -77,11 +76,6 @@ function formatDurationMs(ms: number): string {
return "<1m";
}
// Nerd Font codepoints matched to what claude-account-switch.ts emits
const ICON_PERSONAL = "\uEF85"; // U+EF85 — home
const ICON_WORK = "\uDB80\uDCD6"; // U+F00D6 — briefcase (surrogate pair)
const ICON_UNKNOWN = "\uF420"; // U+F420 — claude default
export default function (pi: ExtensionAPI) {
let ctx: any = null;
let tuiRef: any = null;
@@ -112,18 +106,7 @@ export default function (pi: ExtensionAPI) {
: cwd;
parts.push(theme.fg("dim", dir));
// 2. Account icon
const acctRaw = statuses.get("claude-account");
if (acctRaw !== undefined) {
const clean = stripAnsi(acctRaw).trim();
let icon: string;
if (clean.includes("personal")) icon = ICON_PERSONAL;
else if (clean.includes("work")) icon = ICON_WORK;
else icon = ICON_UNKNOWN;
parts.push(theme.fg("dim", icon));
}
// 3. S / W usage bars + C bar — joined as one |-separated block
// 2. S / W usage bars + C bar — joined as one |-separated block
const usageRaw = statuses.get("usage-bars");
const contextUsage = ctx?.getContextUsage?.();
{
@@ -135,8 +118,8 @@ export default function (pi: ExtensionAPI) {
const session = Math.max(0, Math.min(100, Math.round(usageSession)));
const weekly = Math.max(0, Math.min(100, Math.round(usageWeekly)));
let sPart = theme.fg("muted", "S ") + renderBrailleBar(theme, session) + " " + theme.fg("dim", `${session}%`);
let wPart = theme.fg("muted", "W ") + renderBrailleBar(theme, weekly) + " " + theme.fg("dim", `${weekly}%`);
let sPart = theme.fg("muted", "\uF4F5 S ") + renderBrailleBar(theme, session) + " " + theme.fg("dim", `${session}%`);
let wPart = theme.fg("muted", "\uF4F5 W ") + renderBrailleBar(theme, weekly) + " " + theme.fg("dim", `${weekly}%`);
if (sessionResetsAt !== null) {
const msLeft = sessionResetsAt - Date.now();
@@ -156,34 +139,51 @@ export default function (pi: ExtensionAPI) {
if (contextUsage && contextUsage.percent !== null) {
const pct = Math.round(contextUsage.percent);
const chatStatus = statuses.get("chat-claude");
const isChatActive = !!chatStatus && chatStatus.includes("Claude");
// When chat is active and context is high, show warning indicator
let cLabel = "C";
let cColor = "muted";
if (isChatActive && pct >= 70) {
cLabel = pct >= 90 ? "C⚠" : "C⚡";
cColor = pct >= 90 ? "error" : "warning";
}
const cBar =
theme.fg("muted", "C ") +
theme.fg(cColor, cLabel + " ") +
renderBrailleBar(theme, pct) +
" " +
theme.fg("dim", `${pct}%`);
theme.fg(pct >= 70 && isChatActive ? "warning" : "dim", `${pct}%`);
block = block ? block + pipeSep + cBar : cBar;
}
if (block) parts.push(block);
}
// 4. Model short name
// 3. Model short name
const modelId = ctx?.model?.id;
if (modelId) parts.push(theme.fg("dim", getModelShortName(modelId)));
// 5. LSP — strip "LSP" prefix and activity dot
// 4. LSP — strip "LSP" prefix and activity dot
const lspRaw = statuses.get("lsp");
if (lspRaw) {
const clean = stripAnsi(lspRaw).trim().replace(/^LSP\s*[•·]?\s*/i, "").trim();
if (clean) parts.push(theme.fg("dim", clean));
}
// 6. MCP — strip " servers" suffix
// 5. MCP — strip " servers" suffix
const mcpRaw = statuses.get("mcp");
if (mcpRaw) {
const clean = stripAnsi(mcpRaw).trim().replace(/\s*servers?.*$/, "").trim();
if (clean) parts.push(theme.fg("dim", clean));
}
// 6. Active Claude chat session
const chatRaw = statuses.get("chat-claude");
if (chatRaw) {
parts.push(theme.fg("accent", stripAnsi(chatRaw).trim()));
}
return parts.join(sep);
}
@@ -209,7 +209,7 @@ export default function (pi: ExtensionAPI) {
// ---------------------------------------------------------------------------
// Event handlers
// ---------------------------------------------------------------------------
pi.on("session_start", async (_event, _ctx) => {
pi.on("session_start", (_event, _ctx) => {
ctx = _ctx;
installFooter(_ctx);
});
@@ -230,12 +230,7 @@ export default function (pi: ExtensionAPI) {
if (tuiRef) tuiRef.requestRender();
});
// Re-render when account switches (usage:update comes from usage-bars setStatus which
// already triggers a render, but account icon needs a nudge too)
pi.events.on("claude-account:switched", () => {
if (tuiRef) tuiRef.requestRender();
});
// Listen for usage updates — store raw values so we can build bars + dynamic
// countdown directly rather than parsing the ANSI status string from usage-bars.
pi.events.on("usage:update", (data: any) => {

View File

@@ -1,191 +0,0 @@
/**
* Footer Display Extension
*
* Replaces the built-in pi footer with a single clean line that assembles
* status from all other extensions:
*
* \uEF85 | S ⣿⣶⣀⣀⣀ 34% 2h 55m | W ⣿⣿⣷⣀⣀ 68% ⟳ Fri 09:00 | C ⣿⣀⣀⣀⣀ 20% | Sonnet 4.6 | rust-analyzer | MCP: 1/2
*
* Status sources:
* "claude-account" — set by claude-account-switch.ts → just the icon
* "usage-bars" — set by usage-bars extension → S/W bars (pass-through)
* ctx.getContextUsage() → C bar (rendered here)
* ctx.model → model short name
* "lsp" — set by lsp-pi extension → strip "LSP " prefix
* "mcp" — set by pi-mcp-adapter → strip " servers" suffix
*/
import os from "os";
import path from "path";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { truncateToWidth } from "@mariozechner/pi-tui";
// ---------------------------------------------------------------------------
// Braille gradient bar — used here only for the context (C) bar
// ---------------------------------------------------------------------------
const BRAILLE_GRADIENT = "\u28C0\u28C4\u28E4\u28E6\u28F6\u28F7\u28FF";
const BRAILLE_EMPTY = "\u28C0";
const BAR_WIDTH = 5;
function renderBrailleBar(theme: any, value: number): string {
const v = Math.max(0, Math.min(100, Math.round(value)));
const levels = BRAILLE_GRADIENT.length - 1;
const totalSteps = BAR_WIDTH * levels;
const filledSteps = Math.round((v / 100) * totalSteps);
const full = Math.floor(filledSteps / levels);
const partial = filledSteps % levels;
const empty = BAR_WIDTH - full - (partial ? 1 : 0);
const color = v >= 90 ? "error" : v >= 70 ? "warning" : "success";
const filled = BRAILLE_GRADIENT[BRAILLE_GRADIENT.length - 1]!.repeat(Math.max(0, full));
const partialChar = partial ? BRAILLE_GRADIENT[partial]! : "";
const emptyChars = BRAILLE_EMPTY.repeat(Math.max(0, empty));
return theme.fg(color, filled + partialChar) + theme.fg("dim", emptyChars);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function stripAnsi(text: string): string {
return text.replace(/\x1b\[[0-9;]*m/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
}
function getModelShortName(modelId: string): string {
// claude-haiku-4-5 → "Haiku 4.5", claude-sonnet-4-6 → "Sonnet 4.6"
const m = modelId.match(/^claude-([a-z]+)-([\d]+(?:-[\d]+)*)(?:-\d{8})?$/);
if (m) {
const family = m[1]!.charAt(0).toUpperCase() + m[1]!.slice(1);
return `${family} ${m[2]!.replace(/-/g, ".")}`;
}
// claude-3-5-sonnet, claude-3-opus, etc.
const m2 = modelId.match(/^claude-[\d-]+-([a-z]+)/);
if (m2) return m2[1]!.charAt(0).toUpperCase() + m2[1]!.slice(1);
return modelId.replace(/^claude-/, "");
}
// Nerd Font codepoints matched to what claude-account-switch.ts emits
const ICON_PERSONAL = "\uEF85"; // U+EF85 — home
const ICON_WORK = "\uDB80\uDCD6"; // U+F00D6 — briefcase (surrogate pair)
const ICON_UNKNOWN = "\uF420"; // U+F420 — claude default
export default function (pi: ExtensionAPI) {
let ctx: any = null;
let tuiRef: any = null;
let footerDataRef: any = null;
// ---------------------------------------------------------------------------
// Footer line builder — called on every render
// ---------------------------------------------------------------------------
function buildFooterLine(theme: any): string {
const sep = theme.fg("dim", " · ");
const pipeSep = theme.fg("dim", " | ");
const parts: string[] = [];
const statuses: ReadonlyMap<string, string> =
footerDataRef?.getExtensionStatuses?.() ?? new Map();
// 1. Current working directory
const home = os.homedir();
const cwd = process.cwd();
const dir = cwd.startsWith(home)
? "~" + path.sep + path.relative(home, cwd)
: cwd;
parts.push(theme.fg("dim", dir));
// 2. Account icon
const acctRaw = statuses.get("claude-account");
if (acctRaw !== undefined) {
const clean = stripAnsi(acctRaw).trim();
let icon: string;
if (clean.includes("personal")) icon = ICON_PERSONAL;
else if (clean.includes("work")) icon = ICON_WORK;
else icon = ICON_UNKNOWN;
parts.push(theme.fg("dim", icon));
}
// 3. S / W usage bars + C bar — joined as one |-separated block
const usageRaw = statuses.get("usage-bars");
const contextUsage = ctx?.getContextUsage?.();
{
let block = usageRaw ?? "";
if (contextUsage && contextUsage.percent !== null) {
const pct = Math.round(contextUsage.percent);
const cBar =
theme.fg("muted", "C ") +
renderBrailleBar(theme, pct) +
" " +
theme.fg("dim", `${pct}%`);
block = block ? block + pipeSep + cBar : cBar;
}
if (block) parts.push(block);
}
// 4. Model short name
const modelId = ctx?.model?.id;
if (modelId) parts.push(theme.fg("dim", getModelShortName(modelId)));
// 5. LSP — strip "LSP" prefix and activity dot
const lspRaw = statuses.get("lsp");
if (lspRaw) {
const clean = stripAnsi(lspRaw).trim().replace(/^LSP\s*[•·]?\s*/i, "").trim();
if (clean) parts.push(theme.fg("dim", clean));
}
// 6. MCP — strip " servers" suffix
const mcpRaw = statuses.get("mcp");
if (mcpRaw) {
const clean = stripAnsi(mcpRaw).trim().replace(/\s*servers?.*$/, "").trim();
if (clean) parts.push(theme.fg("dim", clean));
}
return parts.join(sep);
}
// ---------------------------------------------------------------------------
// Footer installation
// ---------------------------------------------------------------------------
function installFooter(_ctx: any) {
if (!_ctx?.hasUI) return;
_ctx.ui.setFooter((_tui: any, theme: any, footerData: any) => {
tuiRef = _tui;
footerDataRef = footerData;
const unsub = footerData.onBranchChange(() => _tui.requestRender());
return {
dispose: unsub,
invalidate() {},
render(width: number): string[] {
return [truncateToWidth(buildFooterLine(theme) || "", width)];
},
};
});
}
// ---------------------------------------------------------------------------
// Event handlers
// ---------------------------------------------------------------------------
pi.on("session_start", async (_event, _ctx) => {
ctx = _ctx;
installFooter(_ctx);
});
pi.on("session_shutdown", (_event, _ctx) => {
if (_ctx?.hasUI) _ctx.ui.setFooter(undefined);
});
// Re-render after turns so context usage stays current
pi.on("turn_end", (_event, _ctx) => {
ctx = _ctx;
if (tuiRef) tuiRef.requestRender();
});
// Re-render when model changes (updates model name in footer)
pi.on("model_select", (_event, _ctx) => {
ctx = _ctx;
if (tuiRef) tuiRef.requestRender();
});
// Re-render when account switches (usage:update comes from usage-bars setStatus which
// already triggers a render, but account icon needs a nudge too)
pi.events.on("claude-account:switched", () => {
if (tuiRef) tuiRef.requestRender();
});
}

View File

@@ -1,15 +0,0 @@
/**
* 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,56 @@
# pi-ask-mcp
A minimal MCP stdio server that gives Claude **one** tool — `ask` — which routes
structured questions back to pi's native ask UI instead of using Claude's
built-in `AskUserQuestion`.
This is **not** a regular pi extension. It is a subprocess of `claude`, which is
itself a subprocess of the `chat-claude` extension. The pi-side counterpart is
[`shared/pi-ask-bridge.ts`](../../shared/pi-ask-bridge.ts), which:
1. Opens a Unix-domain socket per chat session.
2. Generates an `--mcp-config` JSON pointing here, with `PI_ASK_SOCKET=<sock>`.
3. Translates `ask` requests off the socket into
`askSingleQuestionWithInlineNote` / `askQuestionsWithTabs` calls and writes
the result back.
## Architecture
```
pi
└── chat-claude
├── pi-ask-bridge (UDS server, owns ui.custom)
└── claude -p ... --mcp-config <generated.json> --disallowed-tools AskUserQuestion
└── pi-ask-mcp/server.js (this file)
↳ on tools/call ask → connect $PI_ASK_SOCKET → ask → reply
```
## Why a hand-written MCP server
No `@modelcontextprotocol/sdk` dependency, no transpile step, no
`node_modules`. The MCP stdio protocol is small enough (~6 method handlers)
that writing it directly keeps the file self-contained and trivially
portable. Claude CLI spawns it via `node server.js`.
## Wire format
Stdio (with Claude): JSON-RPC 2.0 over newline-delimited JSON.
Socket (with pi-ask-bridge): NDJSON, one request → one response, then close.
```jsonc
// → pi
{ "id": "uuid", "type": "ask",
"questions": [
{ "id": "auth", "question": "Auth method?",
"options": [{"label": "OAuth"}, {"label": "API key"}],
"multi": false, "recommended": 0 }
] }
// ← pi (success)
{ "id": "uuid", "type": "result",
"results": [{ "id": "auth", "selectedOptions": ["OAuth"] }] }
// ← pi (cancel / error)
{ "id": "uuid", "type": "error", "message": "cancelled" }
```

View File

@@ -0,0 +1,7 @@
{
"name": "pi-ask-mcp",
"private": true,
"type": "module",
"main": "server.js",
"description": "Minimal MCP stdio server bridging Claude → pi-ask-bridge."
}

View File

@@ -0,0 +1,195 @@
#!/usr/bin/env node
// pi-ask-mcp/server.js
//
// Minimal MCP stdio server that exposes ONE tool: `ask`.
// Bridges Claude → pi via a Unix-domain socket: when Claude calls the tool,
// this server forwards the question(s) to pi over $PI_ASK_SOCKET, awaits
// the user's answer, and returns it as the tool result.
//
// Wire format with Claude (stdin/stdout): JSON-RPC 2.0 over NDJSON.
// Wire format with pi (PI_ASK_SOCKET): NDJSON request/response, see
// ../../shared/pi-ask-bridge.ts.
//
// This file is INTENTIONALLY plain JavaScript (no transpile step, no
// node_modules) — Claude CLI spawns it via `node <path>`. Keep it small,
// dependency-free, and self-contained.
import { connect } from "node:net";
import { randomUUID } from "node:crypto";
import { createInterface } from "node:readline";
// ── Configuration ──────────────────────────────────────────────────────────
const SOCKET = process.env.PI_ASK_SOCKET;
if (!SOCKET) {
process.stderr.write("[pi-ask-mcp] PI_ASK_SOCKET env var is required\n");
process.exit(2);
}
const SERVER_INFO = { name: "pi", version: "0.1.0" };
const PROTOCOL_VERSION = "2024-11-05";
const SOCKET_TIMEOUT_MS = 15 * 60 * 1000; // matches runClaude's default
// ── Tool schema (kept in sync with pi-ask-tool/index.ts AskParamsSchema) ──
const ASK_INPUT_SCHEMA = {
type: "object",
required: ["questions"],
properties: {
questions: {
type: "array",
minItems: 1,
description: "One or more questions to ask the user.",
items: {
type: "object",
required: ["id", "question", "options"],
properties: {
id: { type: "string", description: "Stable id (e.g. 'auth', 'cache')." },
question: { type: "string", description: "Question text shown to the user." },
options: {
type: "array",
minItems: 1,
description: "2-5 concise options. Do NOT include 'Other' (UI adds it).",
items: {
type: "object",
required: ["label"],
properties: {
label: { type: "string", description: "Option display label." },
},
},
},
multi: { type: "boolean", description: "Allow multi-select. Defaults to false." },
recommended: { type: "number", description: "0-indexed recommended option (default highlight)." },
},
},
},
},
};
const ASK_DESCRIPTION = [
"Ask the user one or more structured questions through pi's native TUI.",
"Use this whenever a choice materially affects the outcome — instead of",
"guessing or the built-in AskUserQuestion. Provide 2-5 concise options.",
"Set multi=true when multiple answers are valid. Do NOT include an 'Other'",
"option (UI adds it automatically). The result is a JSON array of",
"{id, selectedOptions[], customInput?} per question — empty selectedOptions",
"means the user cancelled.",
].join(" ");
// ── stdio framing: NDJSON ──────────────────────────────────────────────────
const rl = createInterface({ input: process.stdin });
const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
const log = (msg) => process.stderr.write(`[pi-ask-mcp] ${msg}\n`);
// ── socket round-trip to pi-ask-bridge ─────────────────────────────────────
function askPi(args) {
return new Promise((resolve, reject) => {
const sock = connect(SOCKET);
const id = randomUUID();
let buf = "";
let settled = false;
const finish = (fn, val) => { if (settled) return; settled = true; clearTimeout(t); fn(val); try { sock.end(); } catch {} };
const t = setTimeout(
() => finish(reject, new Error(`pi-ask bridge timeout after ${SOCKET_TIMEOUT_MS / 1000}s`)),
SOCKET_TIMEOUT_MS,
);
sock.on("connect", () => sock.write(JSON.stringify({ id, type: "ask", ...args }) + "\n"));
sock.on("data", (d) => {
buf += d.toString();
const nl = buf.indexOf("\n");
if (nl < 0) return;
try { finish(resolve, JSON.parse(buf.slice(0, nl))); }
catch (err) { finish(reject, err); }
});
sock.on("error", (err) => finish(reject, err));
sock.on("close", () => {
if (!settled) finish(reject, new Error("pi-ask bridge closed connection without reply"));
});
});
}
// ── JSON-RPC method handlers ───────────────────────────────────────────────
async function handleRequest(req) {
const { id, method, params } = req;
try {
switch (method) {
case "initialize":
return ok(id, {
protocolVersion: PROTOCOL_VERSION,
capabilities: { tools: {} },
serverInfo: SERVER_INFO,
});
case "tools/list":
return ok(id, {
tools: [{ name: "ask", description: ASK_DESCRIPTION, inputSchema: ASK_INPUT_SCHEMA }],
});
case "tools/call": {
const name = params?.name;
const args = params?.arguments ?? {};
if (name !== "ask") return err(id, -32602, `unknown tool: ${name}`);
const reply = await askPi(args);
if (reply.type === "error") {
return ok(id, {
isError: true,
content: [{ type: "text", text: `(user did not answer: ${reply.message})` }],
});
}
return ok(id, {
content: [{ type: "text", text: JSON.stringify(reply.results, null, 2) }],
});
}
case "ping": return ok(id, {});
case "resources/list": return ok(id, { resources: [] });
case "prompts/list": return ok(id, { prompts: [] });
default: return err(id, -32601, `method not found: ${method}`);
}
} catch (e) {
return err(id, -32603, e instanceof Error ? e.message : String(e));
}
}
const ok = (id, result) => ({ jsonrpc: "2.0", id, result });
const err = (id, code, message) => ({ jsonrpc: "2.0", id, error: { code, message } });
// ── main loop ──────────────────────────────────────────────────────────────
//
// Track in-flight handlers so we don't exit before they finish. Without this,
// `node server.js <<<input` (or any case where stdin closes mid-request) would
// race the async tools/call handler against rl 'close' → process.exit, and
// the reply would silently disappear.
let inflight = 0;
let stdinClosed = false;
function drainAndExit(code = 0) {
if (inflight === 0) process.exit(code);
}
async function handleOne(msg) {
// Notifications carry no id and expect no response.
if (msg.id === undefined || msg.id === null) {
if (msg.method === "exit") drainAndExit(0);
return; // notifications/initialized, notifications/cancelled, etc.
}
inflight += 1;
try {
const reply = await handleRequest(msg);
if (reply) send(reply);
} finally {
inflight -= 1;
if (stdinClosed) drainAndExit(0);
}
}
rl.on("line", (line) => {
if (!line.trim()) return;
let msg;
try { msg = JSON.parse(line); } catch { return; }
if (Array.isArray(msg)) {
for (const m of msg) void handleOne(m);
} else {
void handleOne(msg);
}
});
rl.on("close", () => { stdinClosed = true; drainAndExit(0); });
process.on("SIGTERM", () => process.exit(0));
process.on("SIGINT", () => process.exit(0));
log("ready, socket=" + SOCKET);

View File

@@ -0,0 +1,23 @@
# Pi Ask Tool Extension
This extension bridges Claude Code's ask functionality into pi's TUI, allowing users to ask questions and receive answers directly in the TUI interface.
## Features
- Seamless integration with pi's TUI
- Support for all Claude agents (plan_review, code_review, debug, oracle)
- Multi-turn conversations with session management
- Context-aware responses based on codebase exploration
## Usage
1. Run the CLI agent: `pi-ask-tool`
2. Type your question in the TUI
3. Receive answers directly in the TUI interface
4. Continue the conversation or start new ones
## Configuration
Default agent: `code_review`
Default model: `sonnet`
Session persistence: Enabled

View File

@@ -0,0 +1,37 @@
// Pi Ask Tool CLI Agent
import { ask_claude } from "../../@piplugin/ask-claude"
import { sessionId } from '../shared';
export async function start() {
console.log('Pi Ask Tool initialized');
while (true) {
// Get user input from TUI (simplified for example)
const userInput = await getTUIInput('Ask a question:');
// Handle multi-turn sessions via sessionId
const response = await ask_claude({
prompt: userInput,
agent: 'code_review', // Default agent
session_id: sessionId
});
// Display answer in TUI
await showTUIResult(response);
// Update session context if needed
sessionId = response.session_id || sessionId;
}
}
// Mock TUI handlers - implement actual TUI integration
async function getTUIInput(question: string) {
// Replace with real TUI input method
const input = process.stdin.read().toString();
return input;
}
async function showTUIResult(result: any) {
console.log('Answer:', result.summary || result);
}

View File

@@ -1,378 +0,0 @@
/**
* Qwen Provider Extension
*
* Registers Qwen 3.5 models via the qwen.ai OAuth flow (chat.qwen.ai).
* Based on the upstream custom-provider-qwen-cli example.
*
* Models:
* - qwen3.5-plus (Qwen3.5 best — rivals Qwen3-Max, 1M ctx, cheaper)
* - qwen3.5-flash (Qwen3.5 fast & cheap, 1M ctx)
* - qwen3-max (Qwen3 flagship, strongest reasoning, 262K ctx)
* - qwen-plus (Qwen3 balanced, 1M ctx)
* - qwen-flash (Qwen3 fast, 1M ctx)
*
* Usage:
* /login qwen-cli (browser OAuth)
* or set QWEN_CLI_API_KEY=...
*/
import type { OAuthCredentials, OAuthLoginCallbacks } from "@mariozechner/pi-ai";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
// =============================================================================
// Constants
// =============================================================================
const QWEN_DEVICE_CODE_ENDPOINT = "https://chat.qwen.ai/api/v1/oauth2/device/code";
const QWEN_TOKEN_ENDPOINT = "https://chat.qwen.ai/api/v1/oauth2/token";
const QWEN_CLIENT_ID = "f0304373b74a44d2b584a3fb70ca9e56";
const QWEN_SCOPE = "openid profile email model.completion";
const QWEN_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
const QWEN_DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
const QWEN_POLL_INTERVAL_MS = 2000;
// =============================================================================
// PKCE Helpers
// =============================================================================
async function generatePKCE(): Promise<{ verifier: string; challenge: string }> {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
const verifier = btoa(String.fromCharCode(...array))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest("SHA-256", data);
const challenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return { verifier, challenge };
}
// =============================================================================
// OAuth Implementation
// =============================================================================
interface DeviceCodeResponse {
device_code: string;
user_code: string;
verification_uri: string;
verification_uri_complete?: string;
expires_in: number;
interval?: number;
}
interface TokenResponse {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
resource_url?: string;
}
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(new Error("Login cancelled"));
return;
}
const timeout = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(timeout);
reject(new Error("Login cancelled"));
},
{ once: true },
);
});
}
async function startDeviceFlow(): Promise<{ deviceCode: DeviceCodeResponse; verifier: string }> {
const { verifier, challenge } = await generatePKCE();
const body = new URLSearchParams({
client_id: QWEN_CLIENT_ID,
scope: QWEN_SCOPE,
code_challenge: challenge,
code_challenge_method: "S256",
});
const headers: Record<string, string> = {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
};
const requestId = globalThis.crypto?.randomUUID?.();
if (requestId) headers["x-request-id"] = requestId;
const response = await fetch(QWEN_DEVICE_CODE_ENDPOINT, {
method: "POST",
headers,
body: body.toString(),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Device code request failed: ${response.status} ${text}`);
}
const data = (await response.json()) as DeviceCodeResponse;
if (!data.device_code || !data.user_code || !data.verification_uri) {
throw new Error("Invalid device code response: missing required fields");
}
return { deviceCode: data, verifier };
}
async function pollForToken(
deviceCode: string,
verifier: string,
intervalSeconds: number | undefined,
expiresIn: number,
signal?: AbortSignal,
): Promise<TokenResponse> {
const deadline = Date.now() + expiresIn * 1000;
const resolvedIntervalSeconds =
typeof intervalSeconds === "number" && Number.isFinite(intervalSeconds) && intervalSeconds > 0
? intervalSeconds
: QWEN_POLL_INTERVAL_MS / 1000;
let intervalMs = Math.max(1000, Math.floor(resolvedIntervalSeconds * 1000));
const handleTokenError = async (error: string, description?: string): Promise<boolean> => {
switch (error) {
case "authorization_pending":
await abortableSleep(intervalMs, signal);
return true;
case "slow_down":
intervalMs = Math.min(intervalMs + 5000, 10000);
await abortableSleep(intervalMs, signal);
return true;
case "expired_token":
throw new Error("Device code expired. Please restart authentication.");
case "access_denied":
throw new Error("Authorization denied by user.");
default:
throw new Error(`Token request failed: ${error} - ${description || ""}`);
}
};
while (Date.now() < deadline) {
if (signal?.aborted) throw new Error("Login cancelled");
const body = new URLSearchParams({
grant_type: QWEN_GRANT_TYPE,
client_id: QWEN_CLIENT_ID,
device_code: deviceCode,
code_verifier: verifier,
});
const response = await fetch(QWEN_TOKEN_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: body.toString(),
});
const responseText = await response.text();
let data: (TokenResponse & { error?: string; error_description?: string }) | null = null;
if (responseText) {
try {
data = JSON.parse(responseText) as TokenResponse & { error?: string; error_description?: string };
} catch {
data = null;
}
}
const error = data?.error;
const errorDescription = data?.error_description;
if (!response.ok) {
if (error && (await handleTokenError(error, errorDescription))) continue;
throw new Error(`Token request failed: ${response.status} ${response.statusText}. Response: ${responseText}`);
}
if (data?.access_token) return data;
if (error && (await handleTokenError(error, errorDescription))) continue;
throw new Error("Token request failed: missing access token in response");
}
throw new Error("Authentication timed out. Please try again.");
}
async function loginQwen(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
const { deviceCode, verifier } = await startDeviceFlow();
const authUrl = deviceCode.verification_uri_complete || deviceCode.verification_uri;
const instructions = deviceCode.verification_uri_complete
? undefined
: `Enter code: ${deviceCode.user_code}`;
callbacks.onAuth({ url: authUrl, instructions });
const tokenResponse = await pollForToken(
deviceCode.device_code,
verifier,
deviceCode.interval,
deviceCode.expires_in,
callbacks.signal,
);
const expiresAt = Date.now() + tokenResponse.expires_in * 1000 - 5 * 60 * 1000;
return {
refresh: tokenResponse.refresh_token || "",
access: tokenResponse.access_token,
expires: expiresAt,
enterpriseUrl: tokenResponse.resource_url,
};
}
async function refreshQwenToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: credentials.refresh,
client_id: QWEN_CLIENT_ID,
});
const response = await fetch(QWEN_TOKEN_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: body.toString(),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Token refresh failed: ${response.status} ${text}`);
}
const data = (await response.json()) as TokenResponse;
if (!data.access_token) throw new Error("Token refresh failed: no access token in response");
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
return {
refresh: data.refresh_token || credentials.refresh,
access: data.access_token,
expires: expiresAt,
enterpriseUrl: data.resource_url ?? credentials.enterpriseUrl,
};
}
function getQwenBaseUrl(resourceUrl?: string): string {
if (!resourceUrl) return QWEN_DEFAULT_BASE_URL;
let url = resourceUrl.startsWith("http") ? resourceUrl : `https://${resourceUrl}`;
if (!url.endsWith("/v1")) url = `${url}/v1`;
return url;
}
// =============================================================================
// Extension Entry Point
// =============================================================================
export default function (pi: ExtensionAPI) {
pi.registerProvider("qwen-cli", {
baseUrl: QWEN_DEFAULT_BASE_URL,
apiKey: "QWEN_CLI_API_KEY",
api: "openai-completions",
models: [
{
id: "qwen3.5-plus",
name: "Qwen 3.5 Plus (Best — rivals Qwen3-Max)",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 65536,
compat: {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
thinkingFormat: "qwen",
},
},
{
id: "qwen3.5-flash",
name: "Qwen 3.5 Flash (Fast & Cheap)",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 65536,
compat: {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
thinkingFormat: "qwen",
},
},
{
id: "qwen3-max",
name: "Qwen 3 Max (Flagship, strongest reasoning)",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 32768,
compat: {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
thinkingFormat: "qwen",
},
},
{
id: "qwen-plus",
name: "Qwen 3 Plus (Balanced)",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 32768,
compat: {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
thinkingFormat: "qwen",
},
},
{
id: "qwen-flash",
name: "Qwen 3 Flash (Fast)",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000000,
maxTokens: 32768,
compat: {
supportsDeveloperRole: false,
supportsReasoningEffort: false,
maxTokensField: "max_tokens",
thinkingFormat: "qwen",
},
},
],
oauth: {
name: "Qwen CLI",
login: loginQwen,
refreshToken: refreshQwenToken,
getApiKey: (cred) => cred.access,
modifyModels: (models, cred) => {
const baseUrl = getQwenBaseUrl(cred.enterpriseUrl as string | undefined);
return models.map((m) => (m.provider === "qwen-cli" ? { ...m, baseUrl } : m));
},
},
});
}

View File

@@ -55,6 +55,8 @@ export interface UsageData {
/** Unix ms timestamp of when the session window resets (from the raw API response). */
sessionResetsAt?: number;
weeklyResetsIn?: string;
/** Unix ms timestamp of when the weekly window resets. */
weeklyResetsAt?: number;
extraSpend?: number;
extraLimit?: number;
error?: string;
@@ -321,13 +323,34 @@ export function formatResetsAt(isoDate: string, nowMs = Date.now()): string {
return formatDuration(diffSeconds);
}
const CLAUDE_CREDENTIALS_FILE = path.join(os.homedir(), ".claude", ".credentials.json");
export function readAuth(authFile = DEFAULT_AUTH_FILE): AuthData | null {
let result: AuthData | null = null;
// Read pi auth.json for non-Claude providers
try {
const parsed = JSON.parse(fs.readFileSync(authFile, "utf-8"));
return asObject(parsed) as AuthData;
result = asObject(parsed) as AuthData;
} catch {
return null;
result = {} as AuthData;
}
// Read Claude credentials from ~/.claude/.credentials.json
try {
const claudeRaw = fs.readFileSync(CLAUDE_CREDENTIALS_FILE, "utf-8");
const claudeCreds = JSON.parse(claudeRaw);
const oauth = claudeCreds?.claudeAiOauth;
if (oauth?.accessToken) {
result!.anthropic = {
access: oauth.accessToken,
refresh: oauth.refreshToken,
expires: typeof oauth.expiresAt === "number" ? oauth.expiresAt : undefined,
};
}
} catch {}
return result;
}
export function writeAuth(auth: AuthData, authFile = DEFAULT_AUTH_FILE): boolean {
@@ -647,6 +670,9 @@ export async function fetchClaudeUsage(token: string, config: RequestConfig = {}
const sessionResetsAt = data?.five_hour?.resets_at
? new Date(data.five_hour.resets_at).getTime()
: undefined;
const weeklyResetsAt = data?.seven_day?.resets_at
? new Date(data.seven_day.resets_at).getTime()
: undefined;
const usage: UsageData = {
session: readPercentCandidate(data?.five_hour?.utilization) ?? 0,
@@ -654,6 +680,7 @@ export async function fetchClaudeUsage(token: string, config: RequestConfig = {}
sessionResetsIn: data?.five_hour?.resets_at ? formatResetsAt(data.five_hour.resets_at) : undefined,
sessionResetsAt: Number.isFinite(sessionResetsAt) ? sessionResetsAt : undefined,
weeklyResetsIn: data?.seven_day?.resets_at ? formatResetsAt(data.seven_day.resets_at) : undefined,
weeklyResetsAt: Number.isFinite(weeklyResetsAt) ? weeklyResetsAt : undefined,
};
if (data?.extra_usage?.is_enabled) {

View File

@@ -306,14 +306,27 @@ export default function (pi: ExtensionAPI) {
const active = state.activeProvider;
const data = active ? state[active] : null;
// Always emit event for other extensions (e.g. footer-display)
if (data && !data.error) {
// Always emit Claude usage for other extensions (e.g. footer-display)
// so S/W bars are visible regardless of active model.
const claudeData = state.claude;
if (claudeData && !claudeData.error) {
pi.events.emit("usage:update", {
session: claudeData.session,
weekly: claudeData.weekly,
sessionResetsIn: claudeData.sessionResetsIn,
sessionResetsAt: claudeData.sessionResetsAt,
weeklyResetsIn: claudeData.weeklyResetsIn,
weeklyResetsAt: claudeData.weeklyResetsAt,
});
} else if (data && !data.error) {
// Fallback to active provider data if Claude data unavailable
pi.events.emit("usage:update", {
session: data.session,
weekly: data.weekly,
sessionResetsIn: data.sessionResetsIn,
sessionResetsAt: data.sessionResetsAt,
weeklyResetsIn: data.weeklyResetsIn,
weeklyResetsAt: data.weeklyResetsAt,
});
}
@@ -372,6 +385,48 @@ export default function (pi: ExtensionAPI) {
const auth = readAuth();
const active = state.activeProvider;
// Always try to fetch Claude data so S/W bars show regardless of active provider
if (auth && canShowForProvider("claude", auth, endpoints)) {
try {
const cache = readUsageCache();
const now = Date.now();
const cacheTtl = options.cacheTtl ?? CACHE_TTL_MS;
const claudeBlockedUntil = cache?.rateLimitedUntil?.claude ?? 0;
if (now < claudeBlockedUntil) {
if (cache?.data?.claude) state.claude = cache.data.claude;
} else {
const claudeCacheFresh = cache && now - cache.timestamp < cacheTtl && cache.data?.claude;
if (claudeCacheFresh && !options.forceFresh) {
state.claude = cache.data.claude;
} else {
const claudeAccess = auth.anthropic?.access;
if (claudeAccess) {
const claudeResult = await fetchClaudeUsage(claudeAccess);
state.claude = claudeResult;
if (!claudeResult.error) {
const nextCache: import("./core").UsageCache = {
timestamp: now,
data: { ...(cache?.data ?? {}), claude: claudeResult },
rateLimitedUntil: { ...(cache?.rateLimitedUntil ?? {}) },
};
delete nextCache.rateLimitedUntil!.claude;
writeUsageCache(nextCache);
} else if (claudeResult.error === "HTTP 429") {
// Record backoff even when Claude is not the active provider —
// without this the prefetch would hammer the API on every poll.
const nextCache: import("./core").UsageCache = {
timestamp: cache?.timestamp ?? now,
data: { ...(cache?.data ?? {}) },
rateLimitedUntil: { ...(cache?.rateLimitedUntil ?? {}), claude: now + RATE_LIMITED_BACKOFF_MS },
};
writeUsageCache(nextCache);
}
}
}
}
} catch {}
}
if (!canShowForProvider(active, auth, endpoints) || !auth || !active) {
state.lastPoll = Date.now(); updateStatus(); return;
}
@@ -478,7 +533,9 @@ export default function (pi: ExtensionAPI) {
await Promise.race([runPollInner(options), timeout]);
}
const POLL_TIMEOUT_MS = 30_000;
// Must be less than the 25 000 ms timeout inside runPoll so the guard fires
// before runPoll's finally-block clears pollInFlight.
const POLL_TIMEOUT_MS = 20_000;
async function poll(options: PollOptions = {}) {
// If a previous poll has been running longer than POLL_TIMEOUT_MS, abandon it
@@ -557,15 +614,6 @@ export default function (pi: ExtensionAPI) {
await poll({ cacheTtl: ACTIVE_CACHE_TTL_MS });
});
pi.events.on("claude-account:switched", () => {
const cache = readUsageCache();
if (cache?.data?.claude) {
const nextCache: import("./core").UsageCache = { ...cache, data: { ...cache.data } };
delete nextCache.data.claude;
writeUsageCache(nextCache);
}
void poll({ forceFresh: true });
});
// Listen for OpenCode Go spend events from other extensions
pi.events.on("opencode-go:spend", async (amount: number) => {

View File

@@ -31,7 +31,7 @@
import { execSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, unlinkSync, watch, writeFileSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
@@ -101,6 +101,7 @@ local mock_wezterm = {
log_warn = function() end,
log_error = function() end,
on = function() end,
add_to_config_reload_watch_list = function() end,
action = setmetatable({}, {
__index = function(_, k)
return function(...) return { action = k, args = {...} } end
@@ -397,48 +398,64 @@ function cleanupOldThemes(themesDir: string, keepFile: string): void {
}
}
export default function (pi: ExtensionAPI) {
pi.on("session_start", async (_event, ctx) => {
const configDir = findConfigDir();
if (!configDir) {
return;
}
function syncTheme(ctx: any) {
const configDir = findConfigDir();
if (!configDir) return;
const lua = findLua();
if (!lua) {
return;
}
const lua = findLua();
if (!lua) return;
const colors = getWeztermColors(configDir, lua);
if (!colors) {
return;
}
const colors = getWeztermColors(configDir, lua);
if (!colors) return;
const themesDir = join(homedir(), ".pi", "agent", "themes");
if (!existsSync(themesDir)) {
mkdirSync(themesDir, { recursive: true });
}
const themesDir = join(homedir(), ".pi", "agent", "themes");
if (!existsSync(themesDir)) {
mkdirSync(themesDir, { recursive: true });
}
const hash = computeThemeHash(colors);
const themeName = `wezterm-sync-${hash}`;
const themeFile = `${themeName}.json`;
const themePath = join(themesDir, themeFile);
const hash = computeThemeHash(colors);
const themeName = `wezterm-sync-${hash}`;
const themeFile = `${themeName}.json`;
const themePath = join(themesDir, themeFile);
// Skip if already on the correct synced theme (avoids repaint)
if (ctx.ui.theme.name === themeName) {
return;
}
// Skip if already on the correct synced theme (avoids repaint)
if (ctx.ui.theme.name === themeName) {
return;
}
const themeJson = generatePiTheme(colors, themeName);
writeFileSync(themePath, JSON.stringify(themeJson, null, 2));
const themeJson = generatePiTheme(colors, themeName);
writeFileSync(themePath, JSON.stringify(themeJson, null, 2));
// Remove old generated themes
cleanupOldThemes(themesDir, themeFile);
// Remove old generated themes
cleanupOldThemes(themesDir, themeFile);
// Set by name so pi loads from the file we just wrote
const result = ctx.ui.setTheme(themeName);
if (!result.success) {
ctx.ui.notify(`WezTerm theme sync failed: ${result.error}`, "error");
}
});
// Set by name so pi loads from the file we just wrote
const result = ctx.ui.setTheme(themeName);
if (!result.success) {
ctx.ui.notify(`WezTerm theme sync failed: ${result.error}`, "error");
}
}
export default function (pi: ExtensionAPI) {
let currentCtx: any = null;
pi.on("session_start", async (_event, ctx) => {
currentCtx = ctx;
syncTheme(ctx);
});
// Watch theme-state file for dark/light toggle changes
const stateFile = join(homedir(), ".config", "theme-state");
try {
watch(stateFile, { persistent: false }, (_event) => {
// Debounce: wait a tick for the file write to complete
setTimeout(() => {
if (currentCtx) {
syncTheme(currentCtx);
}
}, 100);
});
} catch {
// File may not exist yet — non-fatal
}
}