BIG pi update with claude chat
This commit is contained in:
275
pi/.pi/agent/extensions/ask-claude.ts
Normal file
275
pi/.pi/agent/extensions/ask-claude.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
1371
pi/.pi/agent/extensions/chat-claude.ts
Normal file
1371
pi/.pi/agent/extensions/chat-claude.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
56
pi/.pi/agent/extensions/pi-ask-mcp/README.md
Normal file
56
pi/.pi/agent/extensions/pi-ask-mcp/README.md
Normal 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" }
|
||||
```
|
||||
7
pi/.pi/agent/extensions/pi-ask-mcp/package.json
Normal file
7
pi/.pi/agent/extensions/pi-ask-mcp/package.json
Normal 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."
|
||||
}
|
||||
195
pi/.pi/agent/extensions/pi-ask-mcp/server.js
Executable file
195
pi/.pi/agent/extensions/pi-ask-mcp/server.js
Executable 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);
|
||||
23
pi/.pi/agent/extensions/pi-ask-tool/README.md
Normal file
23
pi/.pi/agent/extensions/pi-ask-tool/README.md
Normal 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
|
||||
37
pi/.pi/agent/extensions/pi-ask-tool/cli.ts
Normal file
37
pi/.pi/agent/extensions/pi-ask-tool/cli.ts
Normal 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);
|
||||
}
|
||||
@@ -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));
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user