pi extensions update
This commit is contained in:
@@ -36,6 +36,64 @@ const PROFILES_DIR = path.join(HOME, ".pi/agent/profiles");
|
|||||||
|
|
||||||
type Account = "personal" | "work";
|
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 ────────────────────────────────────────────────────────
|
// ── Profile helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function profilePath(account: Account): string {
|
function profilePath(account: Account): string {
|
||||||
@@ -50,22 +108,38 @@ function hasProfile(account: Account): boolean {
|
|||||||
* Save auth.json content directly to a profile file.
|
* Save auth.json content directly to a profile file.
|
||||||
* This captures the exact on-disk state, including any tokens that were
|
* This captures the exact on-disk state, including any tokens that were
|
||||||
* refreshed behind our back by the auth system.
|
* 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): void {
|
function saveCurrentAuthToProfile(account: Account): boolean {
|
||||||
fs.mkdirSync(PROFILES_DIR, { recursive: true });
|
fs.mkdirSync(PROFILES_DIR, { recursive: true });
|
||||||
if (fs.existsSync(AUTH_JSON)) {
|
if (!fs.existsSync(AUTH_JSON)) return false;
|
||||||
fs.copyFileSync(AUTH_JSON, profilePath(account));
|
try {
|
||||||
fs.chmodSync(profilePath(account), 0o600);
|
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
|
* Copy a profile file to auth.json. This is an atomic-ish swap that
|
||||||
* replaces the entire file rather than merging per-provider.
|
* 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 {
|
function restoreProfileToAuth(account: Account): void {
|
||||||
fs.copyFileSync(profilePath(account), AUTH_JSON);
|
const raw = fs.readFileSync(profilePath(account), "utf-8");
|
||||||
fs.chmodSync(AUTH_JSON, 0o600);
|
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 {
|
function setMarker(account: Account): void {
|
||||||
@@ -131,6 +205,15 @@ function statusLabel(account: Account | "unknown"): string {
|
|||||||
export default function (pi: ExtensionAPI) {
|
export default function (pi: ExtensionAPI) {
|
||||||
let currentAccount: Account | "unknown" = "unknown";
|
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) => {
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
// Proper-lockfile creates auth.json.lock as a *directory* (atomic mkdir).
|
// 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),
|
// If a regular file exists at that path (e.g. left by an older pi version),
|
||||||
@@ -148,6 +231,21 @@ export default function (pi: ExtensionAPI) {
|
|||||||
// lock doesn't exist or we can't stat it — nothing to fix
|
// 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();
|
currentAccount = getCurrentAccount();
|
||||||
ctx.ui.setStatus("claude-account", statusLabel(currentAccount));
|
ctx.ui.setStatus("claude-account", statusLabel(currentAccount));
|
||||||
});
|
});
|
||||||
@@ -189,13 +287,14 @@ export default function (pi: ExtensionAPI) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ── Resolve target account (direct arg or interactive) ──────────
|
// ── Resolve target account (direct arg or interactive) ──────────
|
||||||
let newAccount: Account;
|
let newAccount: Account;
|
||||||
if (trimmed === "personal" || trimmed === "work") {
|
if (trimmed === "personal" || trimmed === "work") {
|
||||||
newAccount = trimmed;
|
newAccount = trimmed;
|
||||||
} else if (trimmed === "") {
|
} else if (trimmed === "") {
|
||||||
const personalLabel = ` personal${currentAccount === "personal" ? " ← current" : ""}${!hasProfile("personal") ? " (no profile saved)" : ""}`;
|
const personalLabel = ` personal${currentAccount === "personal" ? " ← current" : ""}${!hasProfile("personal") ? " (no profile saved)" : ""}${sessionSummary("personal")}`;
|
||||||
const workLabel = ` work${currentAccount === "work" ? " ← current" : ""}${!hasProfile("work") ? " (no profile saved)" : ""}`;
|
const workLabel = ` work${currentAccount === "work" ? " ← current" : ""}${!hasProfile("work") ? " (no profile saved)" : ""}${sessionSummary("work")}`;
|
||||||
|
|
||||||
const accountChoice = await ctx.ui.select(
|
const accountChoice = await ctx.ui.select(
|
||||||
"Switch Claude account:",
|
"Switch Claude account:",
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export interface UsageData {
|
|||||||
session: number;
|
session: number;
|
||||||
weekly: number;
|
weekly: number;
|
||||||
sessionResetsIn?: string;
|
sessionResetsIn?: string;
|
||||||
|
/** Unix ms timestamp of when the session window resets (from the raw API response). */
|
||||||
|
sessionResetsAt?: number;
|
||||||
weeklyResetsIn?: string;
|
weeklyResetsIn?: string;
|
||||||
extraSpend?: number;
|
extraSpend?: number;
|
||||||
extraLimit?: number;
|
extraLimit?: number;
|
||||||
@@ -520,10 +522,15 @@ export async function fetchClaudeUsage(token: string, config: RequestConfig = {}
|
|||||||
if (!result.ok) return { session: 0, weekly: 0, error: result.error };
|
if (!result.ok) return { session: 0, weekly: 0, error: result.error };
|
||||||
|
|
||||||
const data = result.data;
|
const data = result.data;
|
||||||
|
const sessionResetsAt = data?.five_hour?.resets_at
|
||||||
|
? new Date(data.five_hour.resets_at).getTime()
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const usage: UsageData = {
|
const usage: UsageData = {
|
||||||
session: readPercentCandidate(data?.five_hour?.utilization) ?? 0,
|
session: readPercentCandidate(data?.five_hour?.utilization) ?? 0,
|
||||||
weekly: readPercentCandidate(data?.seven_day?.utilization) ?? 0,
|
weekly: readPercentCandidate(data?.seven_day?.utilization) ?? 0,
|
||||||
sessionResetsIn: data?.five_hour?.resets_at ? formatResetsAt(data.five_hour.resets_at) : undefined,
|
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,
|
weeklyResetsIn: data?.seven_day?.resets_at ? formatResetsAt(data.seven_day.resets_at) : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -293,6 +293,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
|
|
||||||
let pollInFlight: Promise<void> | null = null;
|
let pollInFlight: Promise<void> | null = null;
|
||||||
let pollQueued = false;
|
let pollQueued = false;
|
||||||
|
let pollStartedAt = 0;
|
||||||
let streamingTimer: ReturnType<typeof setInterval> | null = null;
|
let streamingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let ctx: any = null;
|
let ctx: any = null;
|
||||||
|
|
||||||
@@ -309,6 +310,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
session: data.session,
|
session: data.session,
|
||||||
weekly: data.weekly,
|
weekly: data.weekly,
|
||||||
sessionResetsIn: data.sessionResetsIn,
|
sessionResetsIn: data.sessionResetsIn,
|
||||||
|
sessionResetsAt: data.sessionResetsAt,
|
||||||
weeklyResetsIn: data.weeklyResetsIn,
|
weeklyResetsIn: data.weeklyResetsIn,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -364,7 +366,7 @@ export default function (pi: ExtensionAPI) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Polling
|
// Polling
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
async function runPoll(options: PollOptions = {}) {
|
async function runPollInner(options: PollOptions = {}) {
|
||||||
const auth = readAuth();
|
const auth = readAuth();
|
||||||
const active = state.activeProvider;
|
const active = state.activeProvider;
|
||||||
|
|
||||||
@@ -378,7 +380,14 @@ export default function (pi: ExtensionAPI) {
|
|||||||
|
|
||||||
const blockedUntil = cache?.rateLimitedUntil?.[active] ?? 0;
|
const blockedUntil = cache?.rateLimitedUntil?.[active] ?? 0;
|
||||||
if (now < blockedUntil) {
|
if (now < blockedUntil) {
|
||||||
if (cache?.data?.[active]) state[active] = cache.data[active]!;
|
if (cache?.data?.[active]) {
|
||||||
|
state[active] = cache.data[active]!;
|
||||||
|
} else {
|
||||||
|
// Rate-limited but no cached data — show a meaningful status instead
|
||||||
|
// of leaving state null (which shows eternal "loading…").
|
||||||
|
const retryMin = Math.ceil((blockedUntil - now) / 60000);
|
||||||
|
state[active] = { session: 0, weekly: 0, error: `rate limited (retry in ${retryMin}m)` };
|
||||||
|
}
|
||||||
state.lastPoll = now; updateStatus(); return;
|
state.lastPoll = now; updateStatus(); return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,7 +405,11 @@ export default function (pi: ExtensionAPI) {
|
|||||||
const tokenExpiredOrMissing = !creds?.access || (expires > 0 && Date.now() + 60_000 >= expires);
|
const tokenExpiredOrMissing = !creds?.access || (expires > 0 && Date.now() + 60_000 >= expires);
|
||||||
if (tokenExpiredOrMissing && creds?.refresh) {
|
if (tokenExpiredOrMissing && creds?.refresh) {
|
||||||
try {
|
try {
|
||||||
const refreshed = await ensureFreshAuthForProviders([oauthId as OAuthProviderId], { auth, persist: true });
|
const refreshPromise = ensureFreshAuthForProviders([oauthId as OAuthProviderId], { auth, persist: true });
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error("OAuth refresh timeout")), 15_000),
|
||||||
|
);
|
||||||
|
const refreshed = await Promise.race([refreshPromise, timeoutPromise]);
|
||||||
if (refreshed.auth) effectiveAuth = refreshed.auth;
|
if (refreshed.auth) effectiveAuth = refreshed.auth;
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -452,11 +465,40 @@ export default function (pi: ExtensionAPI) {
|
|||||||
updateStatus();
|
updateStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runPoll(options: PollOptions = {}): Promise<void> {
|
||||||
|
const timeout = new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error("runPoll timeout")), 25_000),
|
||||||
|
);
|
||||||
|
await Promise.race([runPollInner(options), timeout]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const POLL_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
async function poll(options: PollOptions = {}) {
|
async function poll(options: PollOptions = {}) {
|
||||||
|
// If a previous poll has been running longer than POLL_TIMEOUT_MS, abandon it
|
||||||
|
// so we don't queue forever behind a stuck request.
|
||||||
|
if (pollInFlight && pollStartedAt > 0 && Date.now() - pollStartedAt > POLL_TIMEOUT_MS) {
|
||||||
|
pollInFlight = null;
|
||||||
|
pollQueued = false;
|
||||||
|
const active = state.activeProvider;
|
||||||
|
if (active && !state[active]) {
|
||||||
|
state[active] = { session: 0, weekly: 0, error: "poll timeout" };
|
||||||
|
updateStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (pollInFlight) { pollQueued = true; await pollInFlight; return; }
|
if (pollInFlight) { pollQueued = true; await pollInFlight; return; }
|
||||||
do {
|
do {
|
||||||
pollQueued = false;
|
pollQueued = false;
|
||||||
pollInFlight = runPoll(options).catch(() => {}).finally(() => { pollInFlight = null; });
|
pollStartedAt = Date.now();
|
||||||
|
pollInFlight = runPoll(options).catch(() => {
|
||||||
|
// If runPoll threw, ensure we don't leave status stuck at "loading…"
|
||||||
|
const active = state.activeProvider;
|
||||||
|
if (active && !state[active]) {
|
||||||
|
state[active] = { session: 0, weekly: 0, error: "poll failed" };
|
||||||
|
updateStatus();
|
||||||
|
}
|
||||||
|
}).finally(() => { pollInFlight = null; pollStartedAt = 0; });
|
||||||
await pollInFlight;
|
await pollInFlight;
|
||||||
} while (pollQueued);
|
} while (pollQueued);
|
||||||
}
|
}
|
||||||
|
|||||||
506
pi/.pi/agent/extensions/worktree.ts
Normal file
506
pi/.pi/agent/extensions/worktree.ts
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
/**
|
||||||
|
* Git Worktree Extension
|
||||||
|
*
|
||||||
|
* Spin up an AI agent in a git worktree to implement tasks in the background
|
||||||
|
* while you continue working in the main branch.
|
||||||
|
*
|
||||||
|
* Commands:
|
||||||
|
* /worktree <branch> <task> — create worktree + start agent
|
||||||
|
* /worktrees — list all worktree agents and their status
|
||||||
|
* /worktree-log <branch> — show recent log output (and log file path)
|
||||||
|
* /worktree-done <branch> — remove a finished worktree
|
||||||
|
*
|
||||||
|
* The worktree is placed as a sibling of your repo:
|
||||||
|
* <repoRoot>/../<repoName>-<branch>
|
||||||
|
*
|
||||||
|
* Agent output is streamed to:
|
||||||
|
* /tmp/pi-worktrees/<branch>.log
|
||||||
|
*
|
||||||
|
* Follow live: !tail -f /tmp/pi-worktrees/<branch>.log
|
||||||
|
*
|
||||||
|
* DESIGN NOTES
|
||||||
|
* ─────────────
|
||||||
|
* Context: The full conversation history is serialized and prepended to the
|
||||||
|
* task prompt so the agent sees the plan (or whatever was discussed).
|
||||||
|
*
|
||||||
|
* Visibility: Spawns pi in --mode json so we can parse events. The widget above
|
||||||
|
* the editor shows ⏳/✓/✗ per agent, elapsed time, and the last few
|
||||||
|
* tool calls / text snippets the agent produced.
|
||||||
|
*
|
||||||
|
* Questions: The agent runs in -p (batch) mode and cannot pause to ask you
|
||||||
|
* anything. Give it enough context upfront (the conversation carry-
|
||||||
|
* over handles this). If the agent gets stuck it will make its best
|
||||||
|
* guess or fail — check the log and use /worktree-done to clean up.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as os from "node:os";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import type {
|
||||||
|
ExtensionAPI,
|
||||||
|
ExtensionCommandContext,
|
||||||
|
ExtensionContext,
|
||||||
|
SessionEntry,
|
||||||
|
} from "@mariozechner/pi-coding-agent";
|
||||||
|
import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface WorktreeAgent {
|
||||||
|
branch: string;
|
||||||
|
worktreePath: string;
|
||||||
|
task: string;
|
||||||
|
logFile: string;
|
||||||
|
startTime: number;
|
||||||
|
status: "running" | "done" | "error";
|
||||||
|
exitCode?: number;
|
||||||
|
/** Last few activity lines parsed from the JSON event stream */
|
||||||
|
recentActivity: string[];
|
||||||
|
/** Accumulated final-text output from all assistant turns */
|
||||||
|
finalOutput: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Ctx = ExtensionContext | ExtensionCommandContext;
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function shortenPath(p: string): string {
|
||||||
|
const home = os.homedir();
|
||||||
|
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatToolCall(toolName: string, args: Record<string, unknown>): string {
|
||||||
|
switch (toolName) {
|
||||||
|
case "bash": {
|
||||||
|
const cmd = ((args.command as string) || "").split("\n")[0].slice(0, 60);
|
||||||
|
return `$ ${cmd}`;
|
||||||
|
}
|
||||||
|
case "read":
|
||||||
|
return `read ${shortenPath((args.file_path ?? args.path ?? "?") as string)}`;
|
||||||
|
case "write":
|
||||||
|
return `write ${shortenPath((args.file_path ?? args.path ?? "?") as string)}`;
|
||||||
|
case "edit":
|
||||||
|
return `edit ${shortenPath((args.file_path ?? args.path ?? "?") as string)}`;
|
||||||
|
case "grep":
|
||||||
|
return `grep /${args.pattern}/ in ${shortenPath((args.path ?? ".") as string)}`;
|
||||||
|
case "find":
|
||||||
|
return `find ${args.pattern} in ${shortenPath((args.path ?? ".") as string)}`;
|
||||||
|
default:
|
||||||
|
return `${toolName}(${JSON.stringify(args).slice(0, 40)})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Extension ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function (pi: ExtensionAPI) {
|
||||||
|
const agents = new Map<string, WorktreeAgent>();
|
||||||
|
|
||||||
|
// ── Git helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getRepoRoot(cwd: string): string | null {
|
||||||
|
try {
|
||||||
|
return execSync(`git -C "${cwd}" rev-parse --show-toplevel`, { encoding: "utf-8" }).trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchExists(repoRoot: string, branch: string): boolean {
|
||||||
|
try {
|
||||||
|
execSync(`git -C "${repoRoot}" rev-parse --verify "${branch}"`, { stdio: "ignore" });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Widget ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function refreshWidget(ctx: Ctx) {
|
||||||
|
const lines: string[] = [];
|
||||||
|
for (const wt of agents.values()) {
|
||||||
|
const icon = wt.status === "running" ? "⏳" : wt.status === "done" ? "✓" : "✗";
|
||||||
|
const elapsed = Date.now() - wt.startTime;
|
||||||
|
const timeStr = elapsed < 60_000 ? `${Math.round(elapsed / 1000)}s` : `${Math.round(elapsed / 60_000)}m`;
|
||||||
|
lines.push(`${icon} ${wt.branch} [${timeStr}]`);
|
||||||
|
for (const line of wt.recentActivity.slice(-3)) {
|
||||||
|
lines.push(` ${line}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.ui.setWidget("worktrees", lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
pi.on("turn_start", async (_e, ctx) => refreshWidget(ctx));
|
||||||
|
pi.on("agent_end", async (_e, ctx) => refreshWidget(ctx));
|
||||||
|
|
||||||
|
// ── /worktree <branch> <task> ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
pi.registerCommand("worktree", {
|
||||||
|
description: "Create a git worktree and run an agent in it: /worktree <branch> <task>",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const trimmed = args.trim();
|
||||||
|
const spaceIdx = trimmed.indexOf(" ");
|
||||||
|
if (spaceIdx === -1) {
|
||||||
|
ctx.ui.notify("Usage: /worktree <branch-name> <task description>", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const branch = trimmed.slice(0, spaceIdx);
|
||||||
|
const task = trimmed.slice(spaceIdx + 1).trim();
|
||||||
|
if (!task) {
|
||||||
|
ctx.ui.notify("Please provide a task description after the branch name", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agents.get(branch)?.status === "running") {
|
||||||
|
ctx.ui.notify(`An agent for branch '${branch}' is already running`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoRoot = getRepoRoot(ctx.cwd);
|
||||||
|
if (!repoRoot) {
|
||||||
|
ctx.ui.notify("Not inside a git repository", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Serialize current conversation as context ──────────────────────
|
||||||
|
// The agent needs to see whatever was discussed (e.g. the plan) so it
|
||||||
|
// can act on "implement the above plan" style instructions.
|
||||||
|
const branch_ = ctx.sessionManager.getBranch();
|
||||||
|
const sessionMessages = branch_
|
||||||
|
.filter((e): e is SessionEntry & { type: "message" } => e.type === "message")
|
||||||
|
.map((e) => e.message);
|
||||||
|
|
||||||
|
let conversationContext = "";
|
||||||
|
if (sessionMessages.length > 0) {
|
||||||
|
try {
|
||||||
|
const llmMessages = convertToLlm(sessionMessages);
|
||||||
|
conversationContext = serializeConversation(llmMessages);
|
||||||
|
} catch {
|
||||||
|
// If serialization fails, proceed without context
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create worktree ────────────────────────────────────────────────
|
||||||
|
const repoName = path.basename(repoRoot);
|
||||||
|
const safeBranch = branch.replace(/[^\w.-]/g, "_");
|
||||||
|
const worktreePath = path.resolve(repoRoot, "..", `${repoName}-${safeBranch}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (branchExists(repoRoot, branch)) {
|
||||||
|
execSync(`git -C "${repoRoot}" worktree add "${worktreePath}" "${branch}"`, { stdio: "pipe" });
|
||||||
|
} else {
|
||||||
|
execSync(`git -C "${repoRoot}" worktree add "${worktreePath}" -b "${branch}"`, { stdio: "pipe" });
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!fs.existsSync(path.join(worktreePath, ".git"))) {
|
||||||
|
const msg = (e as any).stderr?.toString().trim() || (e as Error).message;
|
||||||
|
ctx.ui.notify(`Failed to create worktree: ${msg}`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Already registered — continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Log + temp files ───────────────────────────────────────────────
|
||||||
|
const logDir = path.join(os.tmpdir(), "pi-worktrees");
|
||||||
|
fs.mkdirSync(logDir, { recursive: true });
|
||||||
|
const logFile = path.join(logDir, `${safeBranch}.log`);
|
||||||
|
|
||||||
|
// Build the full prompt the agent will receive.
|
||||||
|
// If there is prior conversation, prepend it so the agent has all
|
||||||
|
// the context it needs (e.g. the plan written in the last message).
|
||||||
|
const fullPrompt =
|
||||||
|
conversationContext.trim()
|
||||||
|
? [
|
||||||
|
"The following is the conversation that led to this task. Use it as context.",
|
||||||
|
"",
|
||||||
|
"<conversation>",
|
||||||
|
conversationContext.trim(),
|
||||||
|
"</conversation>",
|
||||||
|
"",
|
||||||
|
"Your task:",
|
||||||
|
task,
|
||||||
|
].join("\n")
|
||||||
|
: task;
|
||||||
|
|
||||||
|
// Temp file for the prompt (avoids shell-quoting issues with long text)
|
||||||
|
const promptFile = path.join(logDir, `${safeBranch}-prompt.md`);
|
||||||
|
fs.writeFileSync(promptFile, fullPrompt);
|
||||||
|
|
||||||
|
// System-prompt addendum: ground the agent in its worktree context
|
||||||
|
const systemFile = path.join(logDir, `${safeBranch}-system.md`);
|
||||||
|
fs.writeFileSync(
|
||||||
|
systemFile,
|
||||||
|
[
|
||||||
|
`You are an AI coding agent working in a git worktree for branch '${branch}'.`,
|
||||||
|
`Working directory: ${worktreePath}`,
|
||||||
|
`Main repository: ${repoRoot}`,
|
||||||
|
``,
|
||||||
|
`Implement the requested changes fully and correctly.`,
|
||||||
|
`When you are finished, stage all changed files and create a git commit with a descriptive message.`,
|
||||||
|
`You cannot ask the user questions — work autonomously with the context provided.`,
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Register + spawn ───────────────────────────────────────────────
|
||||||
|
const agent: WorktreeAgent = {
|
||||||
|
branch,
|
||||||
|
worktreePath,
|
||||||
|
task,
|
||||||
|
logFile,
|
||||||
|
startTime: Date.now(),
|
||||||
|
status: "running",
|
||||||
|
recentActivity: [],
|
||||||
|
finalOutput: "",
|
||||||
|
};
|
||||||
|
agents.set(branch, agent);
|
||||||
|
|
||||||
|
const logStream = fs.createWriteStream(logFile);
|
||||||
|
logStream.write(
|
||||||
|
[
|
||||||
|
`=== pi-worktree agent started ===`,
|
||||||
|
`Branch: ${branch}`,
|
||||||
|
`Path: ${worktreePath}`,
|
||||||
|
`Task: ${task}`,
|
||||||
|
`Started: ${new Date().toISOString()}`,
|
||||||
|
`Context: ${sessionMessages.length} messages from current session`,
|
||||||
|
`Log: tail -f ${logFile}`,
|
||||||
|
``,
|
||||||
|
``,
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Use --mode json so we can parse events and populate the widget.
|
||||||
|
// The prompt is passed via @file reference to avoid shell-length limits.
|
||||||
|
const proc = spawn(
|
||||||
|
"pi",
|
||||||
|
[
|
||||||
|
"--mode", "json",
|
||||||
|
"-p",
|
||||||
|
"--no-session",
|
||||||
|
"--append-system-prompt", systemFile,
|
||||||
|
`@${promptFile}`,
|
||||||
|
],
|
||||||
|
{ cwd: worktreePath, stdio: ["ignore", "pipe", "pipe"] },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Parse JSON event stream for widget updates; also mirror to log file
|
||||||
|
let buffer = "";
|
||||||
|
proc.stdout?.on("data", (chunk: Buffer) => {
|
||||||
|
const text = chunk.toString();
|
||||||
|
logStream.write(text);
|
||||||
|
buffer += text;
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() ?? "";
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(line) as Record<string, unknown>;
|
||||||
|
handleJsonEvent(agent, event);
|
||||||
|
} catch {
|
||||||
|
// Non-JSON line — ignore for widget purposes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.stderr?.on("data", (d: Buffer) => {
|
||||||
|
logStream.write(`[stderr] ${d}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on("close", (code) => {
|
||||||
|
const wt = agents.get(branch);
|
||||||
|
if (wt) {
|
||||||
|
wt.status = code === 0 ? "done" : "error";
|
||||||
|
wt.exitCode = code ?? undefined;
|
||||||
|
wt.recentActivity.push(code === 0 ? "✓ done" : `✗ exited ${code}`);
|
||||||
|
}
|
||||||
|
logStream.write(`\n=== Agent exited with code ${code} — ${new Date().toISOString()} ===\n`);
|
||||||
|
logStream.end();
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
// ── Hand results back to the main session ──────────────────────
|
||||||
|
// Inject a message into the main conversation so you (and the main
|
||||||
|
// agent) can see what the worktree agent did and discuss it.
|
||||||
|
const elapsed = wt ? Math.round((Date.now() - wt.startTime) / 1000) : 0;
|
||||||
|
const summary = wt?.finalOutput.trim() || "(no text output)";
|
||||||
|
const statusLine = code === 0
|
||||||
|
? `✓ Worktree agent for **${branch}** finished in ${elapsed}s`
|
||||||
|
: `✗ Worktree agent for **${branch}** failed (exit ${code}) after ${elapsed}s`;
|
||||||
|
|
||||||
|
pi.sendMessage(
|
||||||
|
{
|
||||||
|
customType: "worktree-result",
|
||||||
|
content: [
|
||||||
|
statusLine,
|
||||||
|
`Worktree: \`${worktreePath}\``,
|
||||||
|
`Task: ${task}`,
|
||||||
|
``,
|
||||||
|
`**Agent output:**`,
|
||||||
|
summary,
|
||||||
|
].join("\n"),
|
||||||
|
display: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Queue for the next time the user sends a message.
|
||||||
|
// Change to "steer" + triggerTurn:true if you want an
|
||||||
|
// immediate LLM response the moment the agent finishes.
|
||||||
|
deliverAs: "nextTurn",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
proc.on("error", (err) => {
|
||||||
|
const wt = agents.get(branch);
|
||||||
|
if (wt) {
|
||||||
|
wt.status = "error";
|
||||||
|
wt.recentActivity.push(`spawn error: ${err.message}`);
|
||||||
|
}
|
||||||
|
logStream.write(`\n[spawn error] ${err.message}\n`);
|
||||||
|
logStream.end();
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
try { fs.unlinkSync(systemFile); } catch {}
|
||||||
|
try { fs.unlinkSync(promptFile); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify(`🌿 Worktree agent started for '${branch}' — tail -f ${logFile}`, "info");
|
||||||
|
refreshWidget(ctx);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── /worktrees ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pi.registerCommand("worktrees", {
|
||||||
|
description: "List all worktree agents and their status",
|
||||||
|
handler: async (_args, ctx) => {
|
||||||
|
if (agents.size === 0) {
|
||||||
|
ctx.ui.notify("No worktree agents active this session", "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const wt of agents.values()) {
|
||||||
|
const icon = wt.status === "running" ? "⏳" : wt.status === "done" ? "✓" : "✗";
|
||||||
|
const elapsed = Date.now() - wt.startTime;
|
||||||
|
const timeStr = elapsed < 60_000 ? `${Math.round(elapsed / 1000)}s` : `${Math.round(elapsed / 60_000)}m`;
|
||||||
|
const last = wt.recentActivity.at(-1) ?? "(no activity yet)";
|
||||||
|
ctx.ui.notify(`${icon} ${wt.branch} [${timeStr}]\n task: ${wt.task}\n last: ${last}`, "info");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── /worktree-log <branch> ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pi.registerCommand("worktree-log", {
|
||||||
|
description: "Show recent log for a worktree agent: /worktree-log <branch>",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const branch = args.trim();
|
||||||
|
const wt = agents.get(branch);
|
||||||
|
if (!wt) {
|
||||||
|
ctx.ui.notify(
|
||||||
|
`No worktree agent for '${branch}'\nKnown: ${[...agents.keys()].join(", ") || "none"}`,
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.ui.notify(`Log: ${wt.logFile}\nLive follow: !tail -f ${wt.logFile}`, "info");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(wt.logFile, "utf-8");
|
||||||
|
// Log is JSON-mode output — skip JSON lines, show text content only
|
||||||
|
const readable = raw
|
||||||
|
.split("\n")
|
||||||
|
.filter((l) => {
|
||||||
|
if (!l.trim()) return false;
|
||||||
|
try { JSON.parse(l); return false; } catch { return true; }
|
||||||
|
})
|
||||||
|
.slice(-30)
|
||||||
|
.join("\n");
|
||||||
|
if (readable) ctx.ui.notify(`[${branch} — last readable lines]\n\n${readable}`, "info");
|
||||||
|
} catch {
|
||||||
|
// Log not readable yet
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── /worktree-done <branch> ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pi.registerCommand("worktree-done", {
|
||||||
|
description: "Remove a finished worktree: /worktree-done <branch>",
|
||||||
|
handler: async (args, ctx) => {
|
||||||
|
const branch = args.trim();
|
||||||
|
const wt = agents.get(branch);
|
||||||
|
if (!wt) {
|
||||||
|
ctx.ui.notify(`No worktree agent for '${branch}'`, "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wt.status === "running") {
|
||||||
|
const ok = await ctx.ui.confirm(
|
||||||
|
"Agent still running",
|
||||||
|
`Force-remove the worktree for '${branch}' even though the agent has not finished?`,
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoRoot = getRepoRoot(ctx.cwd);
|
||||||
|
if (repoRoot) {
|
||||||
|
try {
|
||||||
|
execSync(`git -C "${repoRoot}" worktree remove "${wt.worktreePath}" --force`, { stdio: "pipe" });
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = (e as any).stderr?.toString().trim() || (e as Error).message;
|
||||||
|
ctx.ui.notify(`Warning: could not remove worktree directory: ${msg}`, "warning");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
agents.delete(branch);
|
||||||
|
refreshWidget(ctx);
|
||||||
|
ctx.ui.notify(`Removed worktree '${branch}'`, "info");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── JSON event parser ──────────────────────────────────────────────────────
|
||||||
|
// Parses --mode json events to populate recentActivity for the widget.
|
||||||
|
|
||||||
|
function handleJsonEvent(agent: WorktreeAgent, event: Record<string, unknown>) {
|
||||||
|
// Tool starting — show what it's about to do
|
||||||
|
if (event.type === "tool_execution_start") {
|
||||||
|
const name = event.toolName as string;
|
||||||
|
const rawArgs = event.args as Record<string, unknown> | undefined;
|
||||||
|
if (name && rawArgs) {
|
||||||
|
pushActivity(agent, `→ ${formatToolCall(name, rawArgs)}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assistant text — grab a brief preview for the widget and accumulate
|
||||||
|
// the full text so we can hand it back to the main session on exit.
|
||||||
|
if (event.type === "message_end") {
|
||||||
|
const msg = event.message as { role?: string; content?: unknown[] } | undefined;
|
||||||
|
if (msg?.role === "assistant" && Array.isArray(msg.content)) {
|
||||||
|
for (const part of msg.content) {
|
||||||
|
const p = part as { type?: string; text?: string };
|
||||||
|
if (p.type === "text" && p.text) {
|
||||||
|
// Widget: short preview
|
||||||
|
const preview = p.text.replace(/\s+/g, " ").slice(0, 70);
|
||||||
|
pushActivity(agent, `💬 ${preview}${p.text.length > 70 ? "…" : ""}`);
|
||||||
|
// Handback: accumulate full text across all turns
|
||||||
|
agent.finalOutput += (agent.finalOutput ? "\n\n" : "") + p.text;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushActivity(agent: WorktreeAgent, line: string) {
|
||||||
|
agent.recentActivity.push(line);
|
||||||
|
// Keep a rolling window so memory doesn't grow unboundedly
|
||||||
|
if (agent.recentActivity.length > 50) {
|
||||||
|
agent.recentActivity.splice(0, agent.recentActivity.length - 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user