diff --git a/pi/.pi/agent/extensions/claude-account-switch.ts b/pi/.pi/agent/extensions/claude-account-switch.ts index 7ac71b6..25598e9 100644 --- a/pi/.pi/agent/extensions/claude-account-switch.ts +++ b/pi/.pi/agent/extensions/claude-account-switch.ts @@ -36,6 +36,64 @@ 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 { @@ -50,22 +108,38 @@ function hasProfile(account: Account): boolean { * 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): void { +function saveCurrentAuthToProfile(account: Account): boolean { fs.mkdirSync(PROFILES_DIR, { recursive: true }); - if (fs.existsSync(AUTH_JSON)) { - fs.copyFileSync(AUTH_JSON, profilePath(account)); - fs.chmodSync(profilePath(account), 0o600); + 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 { - fs.copyFileSync(profilePath(account), AUTH_JSON); - fs.chmodSync(AUTH_JSON, 0o600); + 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 { @@ -131,6 +205,15 @@ function statusLabel(account: Account | "unknown"): string { 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), @@ -148,6 +231,21 @@ export default function (pi: ExtensionAPI) { // 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)); }); @@ -189,13 +287,14 @@ export default function (pi: ExtensionAPI) { 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)" : ""}`; - const workLabel = `󰃖 work${currentAccount === "work" ? " ← current" : ""}${!hasProfile("work") ? " (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)" : ""}${sessionSummary("work")}`; const accountChoice = await ctx.ui.select( "Switch Claude account:", diff --git a/pi/.pi/agent/extensions/usage-bars/core.ts b/pi/.pi/agent/extensions/usage-bars/core.ts index 513a430..3d37d01 100644 --- a/pi/.pi/agent/extensions/usage-bars/core.ts +++ b/pi/.pi/agent/extensions/usage-bars/core.ts @@ -51,6 +51,8 @@ export interface UsageData { session: number; weekly: number; sessionResetsIn?: string; + /** Unix ms timestamp of when the session window resets (from the raw API response). */ + sessionResetsAt?: number; weeklyResetsIn?: string; extraSpend?: 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 }; const data = result.data; + const sessionResetsAt = data?.five_hour?.resets_at + ? new Date(data.five_hour.resets_at).getTime() + : undefined; + const usage: UsageData = { session: readPercentCandidate(data?.five_hour?.utilization) ?? 0, weekly: readPercentCandidate(data?.seven_day?.utilization) ?? 0, sessionResetsIn: data?.five_hour?.resets_at ? formatResetsAt(data.five_hour.resets_at) : undefined, + sessionResetsAt: Number.isFinite(sessionResetsAt) ? sessionResetsAt : undefined, weeklyResetsIn: data?.seven_day?.resets_at ? formatResetsAt(data.seven_day.resets_at) : undefined, }; diff --git a/pi/.pi/agent/extensions/usage-bars/index.ts b/pi/.pi/agent/extensions/usage-bars/index.ts index 9365b30..7e7b8f0 100644 --- a/pi/.pi/agent/extensions/usage-bars/index.ts +++ b/pi/.pi/agent/extensions/usage-bars/index.ts @@ -293,6 +293,7 @@ export default function (pi: ExtensionAPI) { let pollInFlight: Promise | null = null; let pollQueued = false; + let pollStartedAt = 0; let streamingTimer: ReturnType | null = null; let ctx: any = null; @@ -309,6 +310,7 @@ export default function (pi: ExtensionAPI) { session: data.session, weekly: data.weekly, sessionResetsIn: data.sessionResetsIn, + sessionResetsAt: data.sessionResetsAt, weeklyResetsIn: data.weeklyResetsIn, }); } @@ -364,7 +366,7 @@ export default function (pi: ExtensionAPI) { // --------------------------------------------------------------------------- // Polling // --------------------------------------------------------------------------- - async function runPoll(options: PollOptions = {}) { + async function runPollInner(options: PollOptions = {}) { const auth = readAuth(); const active = state.activeProvider; @@ -378,7 +380,14 @@ export default function (pi: ExtensionAPI) { const blockedUntil = cache?.rateLimitedUntil?.[active] ?? 0; 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; } @@ -396,7 +405,11 @@ export default function (pi: ExtensionAPI) { const tokenExpiredOrMissing = !creds?.access || (expires > 0 && Date.now() + 60_000 >= expires); if (tokenExpiredOrMissing && creds?.refresh) { try { - const refreshed = await ensureFreshAuthForProviders([oauthId as OAuthProviderId], { auth, persist: true }); + const refreshPromise = ensureFreshAuthForProviders([oauthId as OAuthProviderId], { auth, persist: true }); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error("OAuth refresh timeout")), 15_000), + ); + const refreshed = await Promise.race([refreshPromise, timeoutPromise]); if (refreshed.auth) effectiveAuth = refreshed.auth; } catch {} } @@ -452,11 +465,40 @@ export default function (pi: ExtensionAPI) { updateStatus(); } + async function runPoll(options: PollOptions = {}): Promise { + const timeout = new Promise((_, 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 = {}) { + // 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; } do { 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; } while (pollQueued); } diff --git a/pi/.pi/agent/extensions/worktree.ts b/pi/.pi/agent/extensions/worktree.ts new file mode 100644 index 0000000..2fc879c --- /dev/null +++ b/pi/.pi/agent/extensions/worktree.ts @@ -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 — create worktree + start agent + * /worktrees — list all worktree agents and their status + * /worktree-log — show recent log output (and log file path) + * /worktree-done — remove a finished worktree + * + * The worktree is placed as a sibling of your repo: + * /../- + * + * Agent output is streamed to: + * /tmp/pi-worktrees/.log + * + * Follow live: !tail -f /tmp/pi-worktrees/.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 { + 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(); + + // ── 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 ────────────────────────────────────────────── + + pi.registerCommand("worktree", { + description: "Create a git worktree and run an agent in it: /worktree ", + handler: async (args, ctx) => { + const trimmed = args.trim(); + const spaceIdx = trimmed.indexOf(" "); + if (spaceIdx === -1) { + ctx.ui.notify("Usage: /worktree ", "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.", + "", + "", + conversationContext.trim(), + "", + "", + "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; + 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 ───────────────────────────────────────────────── + + pi.registerCommand("worktree-log", { + description: "Show recent log for a worktree agent: /worktree-log ", + 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 ──────────────────────────────────────────────── + + pi.registerCommand("worktree-done", { + description: "Remove a finished worktree: /worktree-done ", + 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) { + // 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 | 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); + } + } +}