/** * pi-agents — A pi extension providing Claude Code-style autonomous sub-agents. * * Tools: * Agent — LLM-callable: spawn a sub-agent * get_subagent_result — LLM-callable: check background agent status/result * steer_subagent — LLM-callable: send a steering message to a running agent * * Commands: * /agents — Interactive agent management menu */ import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; import { join } from "node:path"; import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir } from "@mariozechner/pi-coding-agent"; import { Text } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; import { AgentManager } from "./agent-manager.js"; import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js"; import { BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, getDefaultAgentNames, getUserAgentNames, registerAgents, resolveType } from "./agent-types.js"; import { registerRpcHandlers } from "./cross-extension-rpc.js"; import { loadCustomAgents } from "./custom-agents.js"; import { buildForkedMessages, buildWorktreeNotice, FORK_SUBAGENT_TYPE, isForkSubagentEnabled, isInForkChild } from "./fork-subagent.js"; import { GroupJoinManager } from "./group-join.js"; import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js"; import { type ModelRegistry, resolveModel } from "./model-resolver.js"; import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js"; import { SubagentScheduler } from "./schedule.js"; import { resolveStorePath, ScheduleStore } from "./schedule-store.js"; import { applyAndEmitLoaded, type SubagentsSettings, saveAndEmitChanged } from "./settings.js"; import { type AgentConfig, type AgentRecord, type JoinMode, type NotificationDetails, type SubagentType } from "./types.js"; import { showAgentsMenu } from "./ui/agent-menu.js"; import { type AgentActivity, type AgentDetails, AgentWidget, describeActivity, formatDuration, formatMs, formatTokens, formatTurns, getDisplayName, getPromptModeLabel, SPINNER, type UICtx, } from "./ui/agent-widget.js"; import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js"; // ---- Shared helpers ---- /** Tool execute return value for a text response. */ function textResult(msg: string, details?: AgentDetails) { return { content: [{ type: "text" as const, text: msg }], details: details as any }; } /** Format an agent's lifetime token total, or "" when zero. */ function formatLifetimeTokens(o: { lifetimeUsage: LifetimeUsage }): string { const t = getLifetimeTotal(o.lifetimeUsage); return t > 0 ? formatTokens(t) : ""; } /** * Create an AgentActivity state and spawn callbacks for tracking tool usage. * Used by both foreground and background paths to avoid duplication. */ function createActivityTracker(maxTurns?: number, onStreamUpdate?: () => void) { const state: AgentActivity = { activeTools: new Map(), toolUses: 0, turnCount: 1, maxTurns, responseText: "", session: undefined, lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 }, }; const callbacks = { onToolActivity: (activity: { type: "start" | "end"; toolName: string }) => { if (activity.type === "start") { state.activeTools.set(activity.toolName + "_" + Date.now(), activity.toolName); } else { for (const [key, name] of state.activeTools) { if (name === activity.toolName) { state.activeTools.delete(key); break; } } state.toolUses++; } onStreamUpdate?.(); }, onTextDelta: (_delta: string, fullText: string) => { state.responseText = fullText; onStreamUpdate?.(); }, onTurnEnd: (turnCount: number) => { state.turnCount = turnCount; onStreamUpdate?.(); }, onSessionCreated: (session: any) => { state.session = session; }, onAssistantUsage: (usage: { input: number; output: number; cacheWrite: number }) => { addUsage(state.lifetimeUsage, usage); onStreamUpdate?.(); }, }; return { state, callbacks }; } /** Human-readable status label for agent completion. */ function getStatusLabel(status: string, error?: string): string { switch (status) { case "error": return `Error: ${error ?? "unknown"}`; case "aborted": return "Aborted (max turns exceeded)"; case "steered": return "Wrapped up (turn limit)"; case "stopped": return "Stopped"; default: return "Done"; } } /** Parenthetical status note for completed agent result text. */ function getStatusNote(status: string): string { switch (status) { case "aborted": return " (aborted — max turns exceeded, output may be incomplete)"; case "steered": return " (wrapped up — reached turn limit)"; case "stopped": return " (stopped by user)"; default: return ""; } } /** Escape XML special characters to prevent injection in structured notifications. */ function escapeXml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } /** Format a structured task notification matching Claude Code's XML. */ function formatTaskNotification(record: AgentRecord, resultMaxLen: number): string { const status = getStatusLabel(record.status, record.error); const durationMs = record.completedAt ? record.completedAt - record.startedAt : 0; const totalTokens = getLifetimeTotal(record.lifetimeUsage); const contextPercent = getSessionContextPercent(record.session); const ctxXml = contextPercent !== null ? `${Math.round(contextPercent)}` : ""; const compactXml = record.compactionCount ? `${record.compactionCount}` : ""; const resultPreview = record.result ? record.result.length > resultMaxLen ? record.result.slice(0, resultMaxLen) + "\n...(truncated, use get_subagent_result for full output)" : record.result : "No output."; return [ ``, `${record.id}`, record.toolCallId ? `${escapeXml(record.toolCallId)}` : null, record.outputFile ? `${escapeXml(record.outputFile)}` : null, `${escapeXml(status)}`, `Agent "${escapeXml(record.description)}" ${record.status}`, `${escapeXml(resultPreview)}`, `${totalTokens}${record.toolUses}${ctxXml}${compactXml}${durationMs}`, ``, ].filter(Boolean).join('\n'); } /** Build AgentDetails from a base + record-specific fields. */ function buildDetails( base: Pick, record: { toolUses: number; startedAt: number; completedAt?: number; status: string; error?: string; id?: string; session?: any; lifetimeUsage: LifetimeUsage }, activity?: AgentActivity, overrides?: Partial, ): AgentDetails { return { ...base, toolUses: record.toolUses, tokens: formatLifetimeTokens(record), turnCount: activity?.turnCount, maxTurns: activity?.maxTurns, durationMs: (record.completedAt ?? Date.now()) - record.startedAt, status: record.status as AgentDetails["status"], agentId: record.id, error: record.error, ...overrides, }; } /** Build notification details for the custom message renderer. */ function buildNotificationDetails(record: AgentRecord, resultMaxLen: number, activity?: AgentActivity): NotificationDetails { const totalTokens = getLifetimeTotal(record.lifetimeUsage); return { id: record.id, description: record.description, status: record.status, toolUses: record.toolUses, turnCount: activity?.turnCount ?? 0, maxTurns: activity?.maxTurns, totalTokens, durationMs: record.completedAt ? record.completedAt - record.startedAt : 0, outputFile: record.outputFile, error: record.error, resultPreview: record.result ? record.result.length > resultMaxLen ? record.result.slice(0, resultMaxLen) + "…" : record.result : "No output.", }; } export default function (pi: ExtensionAPI) { // ---- Register custom notification renderer ---- pi.registerMessageRenderer( "subagent-notification", (message, { expanded }, theme) => { const d = message.details; if (!d) return undefined; function renderOne(d: NotificationDetails): string { const isError = d.status === "error" || d.status === "stopped" || d.status === "aborted"; const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓"); const statusText = isError ? d.status : d.status === "steered" ? "completed (steered)" : "completed"; // Line 1: icon + agent description + status let line = `${icon} ${theme.bold(d.description)} ${theme.fg("dim", statusText)}`; // Line 2: stats const parts: string[] = []; if (d.turnCount > 0) parts.push(formatTurns(d.turnCount, d.maxTurns)); if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`); if (d.totalTokens > 0) parts.push(formatTokens(d.totalTokens)); if (d.durationMs > 0) parts.push(formatMs(d.durationMs)); if (parts.length) { line += "\n " + parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " "); } // Line 3: result preview (collapsed) or full (expanded) if (expanded) { const lines = d.resultPreview.split("\n").slice(0, 30); for (const l of lines) line += "\n" + theme.fg("dim", ` ${l}`); } else { const preview = d.resultPreview.split("\n")[0]?.slice(0, 80) ?? ""; line += "\n " + theme.fg("dim", `⎿ ${preview}`); } // Line 4: output file link (if present) if (d.outputFile) { line += "\n " + theme.fg("muted", `transcript: ${d.outputFile}`); } return line; } const all = [d, ...(d.others ?? [])]; return new Text(all.map(renderOne).join("\n"), 0, 0); } ); /** Reload agents from .pi/agents/*.md and merge with defaults (called on init and each Agent invocation). */ const reloadCustomAgents = () => { const userAgents = loadCustomAgents(process.cwd()); registerAgents(userAgents); }; // Initial load reloadCustomAgents(); // ---- Agent activity tracking + widget ---- const agentActivity = new Map(); // ---- Cancellable pending notifications ---- // Holds notifications briefly so get_subagent_result can cancel them // before they reach pi.sendMessage (fire-and-forget). const pendingNudges = new Map>(); const NUDGE_HOLD_MS = 200; function scheduleNudge(key: string, send: () => void, delay = NUDGE_HOLD_MS) { cancelNudge(key); pendingNudges.set(key, setTimeout(() => { pendingNudges.delete(key); try { send(); } catch { /* ignore stale completion side-effect errors */ } }, delay)); } function cancelNudge(key: string) { const timer = pendingNudges.get(key); if (timer != null) { clearTimeout(timer); pendingNudges.delete(key); } } // ---- Individual nudge helper (async join mode) ---- function emitIndividualNudge(record: AgentRecord) { if (record.resultConsumed) return; // re-check at send time const notification = formatTaskNotification(record, 500); const footer = record.outputFile ? `\nFull transcript available at: ${record.outputFile}` : ''; pi.sendMessage({ customType: "subagent-notification", content: notification + footer, display: true, details: buildNotificationDetails(record, 500, agentActivity.get(record.id)), }, { deliverAs: "followUp", triggerTurn: true }); } function sendIndividualNudge(record: AgentRecord) { agentActivity.delete(record.id); widget.markFinished(record.id); scheduleNudge(record.id, () => emitIndividualNudge(record)); widget.update(); } // ---- Group join manager ---- const groupJoin = new GroupJoinManager( (records, partial) => { for (const r of records) { agentActivity.delete(r.id); widget.markFinished(r.id); } const groupKey = `group:${records.map(r => r.id).join(",")}`; scheduleNudge(groupKey, () => { // Re-check at send time const unconsumed = records.filter(r => !r.resultConsumed); if (unconsumed.length === 0) { widget.update(); return; } const notifications = unconsumed.map(r => formatTaskNotification(r, 300)).join('\n\n'); const label = partial ? `${unconsumed.length} agent(s) finished (partial — others still running)` : `${unconsumed.length} agent(s) finished`; const [first, ...rest] = unconsumed; const details = buildNotificationDetails(first, 300, agentActivity.get(first.id)); if (rest.length > 0) { details.others = rest.map(r => buildNotificationDetails(r, 300, agentActivity.get(r.id))); } pi.sendMessage({ customType: "subagent-notification", content: `Background agent group completed: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`, display: true, details, }, { deliverAs: "followUp", triggerTurn: true }); }); widget.update(); }, 30_000, ); /** Helper: build event data for lifecycle events from an AgentRecord. */ function buildEventData(record: AgentRecord) { const durationMs = record.completedAt ? record.completedAt - record.startedAt : Date.now() - record.startedAt; // All three fields are lifetime-accumulated (Σ over every assistant message_end), // so they survive compaction together — input + output ≤ total always. // tokens is omitted when nothing was ever produced (e.g. agent errored before // any message_end fired), preserving prior payload shape. const u = record.lifetimeUsage; const total = getLifetimeTotal(u); const tokens = total > 0 ? { input: u.input, output: u.output, total } : undefined; return { id: record.id, type: record.type, description: record.description, result: record.result, error: record.error, status: record.status, toolUses: record.toolUses, durationMs, tokens, }; } // Background completion: route through group join or send individual nudge const manager = new AgentManager((record) => { // Emit lifecycle event based on terminal status const isError = record.status === "error" || record.status === "stopped" || record.status === "aborted"; const eventData = buildEventData(record); if (isError) { pi.events.emit("subagents:failed", eventData); } else { pi.events.emit("subagents:completed", eventData); } // Persist final record for cross-extension history reconstruction pi.appendEntry("subagents:record", { id: record.id, type: record.type, description: record.description, status: record.status, result: record.result, error: record.error, startedAt: record.startedAt, completedAt: record.completedAt, }); // Skip notification if result was already consumed via get_subagent_result if (record.resultConsumed) { agentActivity.delete(record.id); widget.markFinished(record.id); widget.update(); return; } // If this agent is pending batch finalization (debounce window still open), // don't send an individual nudge — finalizeBatch will pick it up retroactively. if (currentBatchAgents.some(a => a.id === record.id)) { widget.update(); return; } const result = groupJoin.onAgentComplete(record); if (result === 'pass') { sendIndividualNudge(record); } // 'held' → do nothing, group will fire later // 'delivered' → group callback already fired widget.update(); }, undefined, (record) => { // Emit started event when agent transitions to running (including from queue) pi.events.emit("subagents:started", { id: record.id, type: record.type, description: record.description, }); }, (record, info) => { // Emit compacted event when agent's session compacts (preserves count on record). pi.events.emit("subagents:compacted", { id: record.id, type: record.type, description: record.description, reason: info.reason, tokensBefore: info.tokensBefore, compactionCount: record.compactionCount, }); }); // Expose manager via Symbol.for() global registry for cross-package access. // Standard Node.js pattern for cross-package singletons (used by OpenTelemetry, etc.). const MANAGER_KEY = Symbol.for("pi-subagents:manager"); (globalThis as any)[MANAGER_KEY] = { waitForAll: () => manager.waitForAll(), hasRunning: () => manager.hasRunning(), spawn: (piRef: any, ctx: any, type: string, prompt: string, options: any) => manager.spawn(piRef, ctx, type, prompt, options), getRecord: (id: string) => manager.getRecord(id), }; // --- Cross-extension RPC via pi.events --- let currentCtx: ExtensionContext | undefined; // ---- Subagent scheduler ---- // Session-scoped: store is constructed inside session_start once sessionId // is available. Mirrors pi-chonky-tasks's session-scoped task store — // schedules reset on /new, restore on /resume. const scheduler = new SubagentScheduler(); function startScheduler(ctx: ExtensionContext) { try { const sessionId = ctx.sessionManager?.getSessionId?.(); if (!sessionId) return; // sessionId not yet available — try again on next event const path = resolveStorePath(ctx.cwd, sessionId); const store = new ScheduleStore(path); scheduler.start(pi, ctx, manager, store); pi.events.emit("subagents:scheduler_ready", { sessionId, jobCount: store.list().length }); } catch (err) { // Scheduling is non-essential — log and move on so the rest of the // extension keeps working if e.g. .pi/ is unwritable. console.warn("[pi-subagents] Failed to start scheduler:", err); } } // Capture ctx from session_start for RPC spawn handler + start the scheduler. pi.on("session_start", async (_event, ctx) => { currentCtx = ctx; manager.clearCompleted(); if (isSchedulingEnabled() && !scheduler.isActive()) startScheduler(ctx); }); pi.on("session_before_switch", () => { manager.clearCompleted(); scheduler.stop(); }); const { unsubPing: unsubPingRpc, unsubSpawn: unsubSpawnRpc, unsubStop: unsubStopRpc } = registerRpcHandlers({ events: pi.events, pi, getCtx: () => currentCtx, manager, }); // Broadcast readiness so extensions loaded after us can discover us pi.events.emit("subagents:ready", {}); // On shutdown, abort all agents immediately and clean up. // If the session is going down, there's nothing left to consume agent results. pi.on("session_shutdown", async () => { unsubSpawnRpc(); unsubStopRpc(); unsubPingRpc(); currentCtx = undefined; delete (globalThis as any)[MANAGER_KEY]; scheduler.stop(); manager.abortAll(); for (const timer of pendingNudges.values()) clearTimeout(timer); pendingNudges.clear(); manager.dispose(); }); // Live widget: show running agents above editor const widget = new AgentWidget(manager, agentActivity); // ---- Join mode configuration ---- let defaultJoinMode: JoinMode = 'smart'; function getDefaultJoinMode(): JoinMode { return defaultJoinMode; } function setDefaultJoinMode(mode: JoinMode) { defaultJoinMode = mode; } // Master switch for the schedule subagent feature. Defaults to enabled. // Read once at extension init (before tool registration) so the Agent tool's // param schema reflects the persisted setting. Runtime toggles via /agents // → Settings short-circuit the menu entry + the execute-time addJob path // immediately, but the schema-level removal only takes effect on next // extension load (next pi session). Documented in CHANGELOG/README. let schedulingEnabled = true; function isSchedulingEnabled(): boolean { return schedulingEnabled; } function setSchedulingEnabled(b: boolean) { schedulingEnabled = b; } // ---- Batch tracking for smart join mode ---- // Collects background agent IDs spawned in the current turn for smart grouping. // Uses a debounced timer: each new agent resets the 100ms window so that all // parallel tool calls (which may be dispatched across multiple microtasks by the // framework) are captured in the same batch. let currentBatchAgents: { id: string; joinMode: JoinMode }[] = []; let batchFinalizeTimer: ReturnType | undefined; let batchCounter = 0; /** Finalize the current batch: if 2+ smart-mode agents, register as a group. */ function finalizeBatch() { batchFinalizeTimer = undefined; const batchAgents = [...currentBatchAgents]; currentBatchAgents = []; const smartAgents = batchAgents.filter(a => a.joinMode === 'smart' || a.joinMode === 'group'); if (smartAgents.length >= 2) { const groupId = `batch-${++batchCounter}`; const ids = smartAgents.map(a => a.id); groupJoin.registerGroup(groupId, ids); // Retroactively process agents that already completed during the debounce window. // Their onComplete fired but was deferred (agent was in currentBatchAgents), // so we feed them into the group now. for (const id of ids) { const record = manager.getRecord(id); if (!record) continue; record.groupId = groupId; if (record.completedAt != null && !record.resultConsumed) { groupJoin.onAgentComplete(record); } } } else { // No group formed — send individual nudges for any agents that completed // during the debounce window and had their notification deferred. for (const { id } of batchAgents) { const record = manager.getRecord(id); if (record?.completedAt != null && !record.resultConsumed) { sendIndividualNudge(record); } } } } // Grab UI context from first tool execution + clear lingering widget on new turn pi.on("tool_execution_start", async (_event, ctx) => { widget.setUICtx(ctx.ui as UICtx); widget.onTurnStart(); }); /** Build the full type list text dynamically from the unified registry. */ const buildTypeListText = () => { const defaultNames = getDefaultAgentNames(); const userNames = getUserAgentNames(); const defaultDescs = defaultNames.map((name) => { const cfg = getAgentConfig(name); const modelSuffix = cfg?.model ? ` (${getModelLabelFromConfig(cfg.model)})` : ""; return `- ${name}: ${cfg?.description ?? name}${modelSuffix}`; }); const customDescs = userNames.map((name) => { const cfg = getAgentConfig(name); return `- ${name}: ${cfg?.description ?? name}`; }); return [ "Default agents:", ...defaultDescs, ...(customDescs.length > 0 ? ["", "Custom agents:", ...customDescs] : []), "", `Custom agents can be defined in .pi/agents/.md (project) or ${getAgentDir()}/agents/.md (global) — they are picked up automatically. Project-level agents override global ones. Creating a .md file with the same name as a default agent overrides it.`, ].join("\n"); }; /** Derive a short model label from a model string. */ function getModelLabelFromConfig(model: string): string { // Strip provider prefix (e.g. "anthropic/claude-sonnet-4-6" → "claude-sonnet-4-6") const name = model.includes("/") ? model.split("/").pop()! : model; // Strip trailing date suffix (e.g. "claude-haiku-4-5-20251001" → "claude-haiku-4-5") return name.replace(/-\d{8}$/, ""); } const typeListText = buildTypeListText(); // Apply persisted settings on startup and emit `subagents:settings_loaded`. // Global + project merged; missing → defaults; corrupt file emits a warning // to stderr and falls back to defaults. applyAndEmitLoaded( { setMaxConcurrent: (n) => manager.setMaxConcurrent(n), setDefaultMaxTurns, setGraceTurns, setDefaultJoinMode, setSchedulingEnabled, }, (event, payload) => pi.events.emit(event, payload), ); // ---- Agent tool ---- // Schedule param + its guideline are gated on `schedulingEnabled` (read once // at registration; flipping the setting later requires next pi session for // the schema to update). Defining the shape once and spreading it via Partial // preserves Type.Object's inference when present and produces a // `schedule`-free schema when absent — zero LLM-context cost in disabled mode. const scheduleParamShape = { schedule: Type.Optional( Type.String({ description: 'Opt-in only — fire later instead of now. Omit to run immediately (the default, almost always correct). ' + 'Formats: 6-field cron ("0 0 9 * * 1" = 9am Mon), interval ("5m"/"1h"), one-shot ("+10m" or ISO). ' + 'Forces run_in_background; incompatible with inherit_context and resume. Returns job ID.', }), ), }; const scheduleParam: Partial = isSchedulingEnabled() ? scheduleParamShape : {}; const scheduleGuideline = isSchedulingEnabled() ? `\n- Use \`schedule\` only when the user explicitly asked for scheduled / recurring / delayed execution (e.g. "every Monday", "in an hour"). Don't auto-schedule from vague intent like "monitor X" — run once now or ask.` : ""; pi.registerTool(defineTool({ name: "Agent", label: "Agent", description: `Launch a new agent to handle complex, multi-step tasks autonomously. The Agent tool launches specialized agents that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it. Available agent types: ${typeListText} Guidelines: - For parallel work, use run_in_background: true on each agent. Foreground calls run sequentially — only one executes at a time. - Use Explore for codebase searches and code understanding. - Use Plan for architecture and implementation planning. - Use general-purpose for complex tasks that need file editing. - Provide clear, detailed prompts so the agent can work autonomously. - Agent results are returned as text — summarize them for the user. - Use run_in_background for work you don't need immediately. You will be notified when it completes. - Omit subagent_type for an implicit fork: the child inherits your full conversation context (system prompt, history, tools) and runs as a background agent. - Use resume with an agent ID to continue a previous agent's work. - Use steer_subagent to send mid-run messages to a running background agent. - Use model to specify a different model (as "provider/modelId", or fuzzy e.g. "haiku", "sonnet"). - Use thinking to control extended thinking level. - Use inherit_context if the agent needs the parent conversation history. - Use isolation: "worktree" to run the agent in an isolated git worktree (safe parallel file modifications).${scheduleGuideline}`, parameters: Type.Object({ prompt: Type.String({ description: "The task for the agent to perform.", }), description: Type.String({ description: "A short (3-5 word) description of the task (shown in UI).", }), subagent_type: Type.Optional( Type.String({ description: `Agent type. Omit for implicit fork (inherits full conversation context). Available types: ${getAvailableTypes().join(", ")}. Custom agents from .pi/agents/*.md (project) or ${getAgentDir()}/agents/*.md (global) are also available.`, }), ), model: Type.Optional( Type.String({ description: 'Optional model override. Accepts "provider/modelId" or fuzzy name (e.g. "haiku", "sonnet"). Omit to use the agent type\'s default.', }), ), thinking: Type.Optional( Type.String({ description: "Thinking level: off, minimal, low, medium, high, xhigh. Overrides agent default.", }), ), max_turns: Type.Optional( Type.Number({ description: "Maximum number of agentic turns before stopping. Omit for unlimited (default).", minimum: 1, }), ), run_in_background: Type.Optional( Type.Boolean({ description: "Set to true to run in background. Returns agent ID immediately. You will be notified on completion.", }), ), resume: Type.Optional( Type.String({ description: "Optional agent ID to resume from. Continues from previous context.", }), ), isolated: Type.Optional( Type.Boolean({ description: "If true, agent gets no extension/MCP tools — only built-in tools.", }), ), inherit_context: Type.Optional( Type.Boolean({ description: "If true, fork parent conversation into the agent. Default: false (fresh context).", }), ), isolation: Type.Optional( Type.Literal("worktree", { description: 'Set to "worktree" to run the agent in a temporary git worktree (isolated copy of the repo). Changes are saved to a branch on completion.', }), ), ...scheduleParam, }), // ---- Custom rendering: Claude Code style ---- renderCall(args, theme) { const displayName = args.subagent_type ? getDisplayName(args.subagent_type) : "Agent"; const desc = args.description ?? ""; return new Text("▸ " + theme.fg("toolTitle", theme.bold(displayName)) + (desc ? " " + theme.fg("muted", desc) : ""), 0, 0); }, renderResult(result, { expanded, isPartial }, theme) { const details = result.details as AgentDetails | undefined; if (!details) { const text = result.content[0]?.type === "text" ? result.content[0].text : ""; return new Text(text, 0, 0); } // Helper: build "haiku · thinking: high · ⟳5≤30 · 3 tool uses · 33.8k tokens" stats string const stats = (d: AgentDetails) => { const parts: string[] = []; if (d.modelName) parts.push(d.modelName); if (d.tags) parts.push(...d.tags); if (d.turnCount != null && d.turnCount > 0) { parts.push(formatTurns(d.turnCount, d.maxTurns)); } if (d.toolUses > 0) parts.push(`${d.toolUses} tool use${d.toolUses === 1 ? "" : "s"}`); if (d.tokens) parts.push(d.tokens); return parts.map(p => theme.fg("dim", p)).join(" " + theme.fg("dim", "·") + " "); }; // ---- While running (streaming) ---- if (isPartial || details.status === "running") { const frame = SPINNER[details.spinnerFrame ?? 0]; const s = stats(details); let line = theme.fg("accent", frame) + (s ? " " + s : ""); line += "\n" + theme.fg("dim", ` ⎿ ${details.activity ?? "thinking…"}`); return new Text(line, 0, 0); } // ---- Background agent launched ---- if (details.status === "background") { return new Text(theme.fg("dim", ` ⎿ Running in background (ID: ${details.agentId})`), 0, 0); } // ---- Completed / Steered ---- if (details.status === "completed" || details.status === "steered") { const duration = formatMs(details.durationMs); const isSteered = details.status === "steered"; const icon = isSteered ? theme.fg("warning", "✓") : theme.fg("success", "✓"); const s = stats(details); let line = icon + (s ? " " + s : ""); line += " " + theme.fg("dim", "·") + " " + theme.fg("dim", duration); if (expanded) { const resultText = result.content[0]?.type === "text" ? result.content[0].text : ""; if (resultText) { const lines = resultText.split("\n").slice(0, 50); for (const l of lines) { line += "\n" + theme.fg("dim", ` ${l}`); } if (resultText.split("\n").length > 50) { line += "\n" + theme.fg("muted", " ... (use get_subagent_result with verbose for full output)"); } } } else { const doneText = isSteered ? "Wrapped up (turn limit)" : "Done"; line += "\n" + theme.fg("dim", ` ⎿ ${doneText}`); } return new Text(line, 0, 0); } // ---- Stopped (user-initiated abort) ---- if (details.status === "stopped") { const s = stats(details); let line = theme.fg("dim", "■") + (s ? " " + s : ""); line += "\n" + theme.fg("dim", " ⎿ Stopped"); return new Text(line, 0, 0); } // ---- Error / Aborted (hard max_turns) ---- const s = stats(details); let line = theme.fg("error", "✗") + (s ? " " + s : ""); if (details.status === "error") { line += "\n" + theme.fg("error", ` ⎿ Error: ${details.error ?? "unknown"}`); } else { line += "\n" + theme.fg("warning", " ⎿ Aborted (max turns exceeded)"); } return new Text(line, 0, 0); }, // ---- Execute ---- execute: async (toolCallId, params, signal, onUpdate, ctx) => { // Ensure we have UI context for widget rendering widget.setUICtx(ctx.ui as UICtx); // Reload custom agents so new .pi/agents/*.md files are picked up without restart reloadCustomAgents(); // ---- Fork path (implicit, no subagent_type) ---- if (!params.subagent_type) { if (!isForkSubagentEnabled()) { return textResult("Cannot fork: fork subagent feature is disabled."); } // Guard: recursive fork const branch = ctx.sessionManager.getBranch(); const messages = branch?.map((e: any) => e.message).filter(Boolean) ?? []; if (isInForkChild(messages)) { return textResult("Cannot fork: you are already a forked worker process. Execute your directive directly instead of spawning sub-agents."); } // Get the last assistant message from the parent conversation const lastAssistant = messages.filter((m: any) => m.role === "assistant").pop(); if (!lastAssistant) { return textResult("Cannot fork: no parent conversation to fork from."); } const directive: string = params.prompt; const forkedMessages = buildForkedMessages(directive, lastAssistant); // Get parent's exact system prompt (byte-identical) const parentSystemPrompt = ctx.getSystemPrompt(); // Append worktree notice if applicable if (params.isolation === "worktree") { const lastUserMsg = forkedMessages[forkedMessages.length - 1]; if (lastUserMsg?.role === "user" && Array.isArray(lastUserMsg.content)) { const textBlocks = lastUserMsg.content.filter((c: any) => c.type === "text"); if (textBlocks.length > 0) { const lastText = textBlocks[textBlocks.length - 1]; lastText.text += "\n\n" + buildWorktreeNotice(ctx.cwd, "(worktree)"); } } } // Fork children always run in background const { state: forkBgState, callbacks: forkBgCallbacks } = createActivityTracker(); let forkId: string; try { forkId = manager.spawn(pi, ctx, FORK_SUBAGENT_TYPE, directive, { description: params.description, model: undefined, maxTurns: normalizeMaxTurns(params.max_turns), thinkingLevel: params.thinking as any, isolation: params.isolation, isBackground: true, forkMessages: forkedMessages, parentSystemPrompt, ...forkBgCallbacks, }); } catch (err) { return textResult(err instanceof Error ? err.message : String(err)); } agentActivity.set(forkId, forkBgState); widget.ensureTimer(); widget.update(); pi.events.emit("subagents:created", { id: forkId, type: FORK_SUBAGENT_TYPE, description: params.description, isBackground: true, }); return textResult( `Fork agent started in background.\n` + `Agent ID: ${forkId}\n` + `Description: ${params.description}\n` + `\nYou will be notified when this agent completes.\n` + `Use get_subagent_result to retrieve full results.\n` + `Do not duplicate this agent's work.`, { displayName: "Fork", description: params.description, subagentType: FORK_SUBAGENT_TYPE, modelName: undefined, tags: undefined, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: forkId, }, ); } const rawType = params.subagent_type as SubagentType; const resolved = resolveType(rawType); const subagentType = resolved ?? "general-purpose"; const fellBack = resolved === undefined; const displayName = getDisplayName(subagentType); // Get agent config (if any) const customConfig = getAgentConfig(subagentType); const resolvedConfig = resolveAgentInvocationConfig(customConfig, params); // Resolve model from agent config first; tool-call params only fill gaps. let model = ctx.model; if (resolvedConfig.modelInput) { const resolved = resolveModel(resolvedConfig.modelInput, ctx.modelRegistry); if (typeof resolved === "string") { if (resolvedConfig.modelFromParams) return textResult(resolved); // config-specified: silent fallback to parent } else { model = resolved; } } const thinking = resolvedConfig.thinking; const inheritContext = resolvedConfig.inheritContext; const runInBackground = resolvedConfig.runInBackground; const isolated = resolvedConfig.isolated; const isolation = resolvedConfig.isolation; // Build display tags for non-default config const parentModelId = ctx.model?.id; const effectiveModelId = model?.id; const agentModelName = effectiveModelId && effectiveModelId !== parentModelId ? (model?.name ?? effectiveModelId).replace(/^Claude\s+/i, "").toLowerCase() : undefined; const agentTags: string[] = []; const modeLabel = getPromptModeLabel(subagentType); if (modeLabel) agentTags.push(modeLabel); if (thinking) agentTags.push(`thinking: ${thinking}`); if (isolated) agentTags.push("isolated"); if (isolation === "worktree") agentTags.push("worktree"); const effectiveMaxTurns = normalizeMaxTurns(resolvedConfig.maxTurns ?? getDefaultMaxTurns()); // Shared base fields for all AgentDetails in this call const detailBase = { displayName, description: params.description, subagentType, modelName: agentModelName, tags: agentTags.length > 0 ? agentTags : undefined, }; // ---- Schedule: register a job, don't spawn now ---- if (params.schedule) { if (!isSchedulingEnabled()) { return textResult("Scheduling is disabled in this project. Enable via /agents → Settings → Scheduling."); } if (params.resume) { return textResult("Cannot combine `schedule` with `resume` — schedules create fresh agents."); } if (params.inherit_context) { return textResult("Cannot combine `schedule` with `inherit_context` — there is no parent conversation at fire time."); } if (params.run_in_background === false) { return textResult("Cannot combine `schedule` with `run_in_background: false` — scheduled jobs always run in background."); } if (!scheduler.isActive()) { return textResult("Scheduler is not active in this session yet. Try again after the session has fully started."); } try { const job = scheduler.addJob({ name: params.description as string, description: params.description as string, schedule: params.schedule as string, subagent_type: subagentType, prompt: params.prompt as string, model: params.model as string | undefined, thinking: thinking, max_turns: effectiveMaxTurns, isolated: isolated, isolation: isolation, }); const next = scheduler.getNextRun(job.id); return textResult( `Scheduled "${job.name}" (id: ${job.id}, type: ${job.scheduleType}). ` + `Next run: ${next ?? "(unknown)"}. ` + `Manage via /agents → Scheduled jobs.`, ); } catch (err) { return textResult(err instanceof Error ? err.message : String(err)); } } // Resume existing agent if (params.resume) { const existing = manager.getRecord(params.resume); if (!existing) { return textResult(`Agent not found: "${params.resume}". It may have been cleaned up.`); } if (!existing.session) { return textResult(`Agent "${params.resume}" has no active session to resume.`); } const record = await manager.resume(params.resume, params.prompt, signal); if (!record) { return textResult(`Failed to resume agent "${params.resume}".`); } return textResult( record.result?.trim() || record.error?.trim() || "No output.", buildDetails(detailBase, record), ); } // Background execution if (runInBackground) { const { state: bgState, callbacks: bgCallbacks } = createActivityTracker(effectiveMaxTurns); // Wrap onSessionCreated to wire output file streaming. // The callback lazily reads record.outputFile (set right after spawn) // rather than closing over a value that doesn't exist yet. let id: string; const origBgOnSession = bgCallbacks.onSessionCreated; bgCallbacks.onSessionCreated = (session: any) => { origBgOnSession(session); const rec = manager.getRecord(id); if (rec?.outputFile) { rec.outputCleanup = streamToOutputFile(session, rec.outputFile, id, ctx.cwd); } }; try { id = manager.spawn(pi, ctx, subagentType, params.prompt, { description: params.description, model, maxTurns: effectiveMaxTurns, isolated, inheritContext, thinkingLevel: thinking, isBackground: true, isolation, ...bgCallbacks, }); } catch (err) { return textResult(err instanceof Error ? err.message : String(err)); } // Set output file + join mode synchronously after spawn, before the // event loop yields — onSessionCreated is async so this is safe. const joinMode = resolveJoinMode(defaultJoinMode, true); const record = manager.getRecord(id); if (record && joinMode) { record.joinMode = joinMode; record.toolCallId = toolCallId; record.outputFile = createOutputFilePath(ctx.cwd, id, ctx.sessionManager.getSessionId()); writeInitialEntry(record.outputFile, id, params.prompt, ctx.cwd); } if (joinMode == null || joinMode === 'async') { // Foreground/no join mode or explicit async — not part of any batch } else { // smart or group — add to current batch currentBatchAgents.push({ id, joinMode }); // Debounce: reset timer on each new agent so parallel tool calls // dispatched across multiple event loop ticks are captured together if (batchFinalizeTimer) clearTimeout(batchFinalizeTimer); batchFinalizeTimer = setTimeout(finalizeBatch, 100); } agentActivity.set(id, bgState); widget.ensureTimer(); widget.update(); // Emit created event pi.events.emit("subagents:created", { id, type: subagentType, description: params.description, isBackground: true, }); const isQueued = record?.status === "queued"; return textResult( `Agent ${isQueued ? "queued" : "started"} in background.\n` + `Agent ID: ${id}\n` + `Type: ${displayName}\n` + `Description: ${params.description}\n` + (record?.outputFile ? `Output file: ${record.outputFile}\n` : "") + (isQueued ? `Position: queued (max ${manager.getMaxConcurrent()} concurrent)\n` : "") + `\nYou will be notified when this agent completes.\n` + `Use get_subagent_result to retrieve full results, or steer_subagent to send it messages.\n` + `Do not duplicate this agent's work.`, { ...detailBase, toolUses: 0, tokens: "", durationMs: 0, status: "background" as const, agentId: id }, ); } // Foreground (synchronous) execution — stream progress via onUpdate let spinnerFrame = 0; const startedAt = Date.now(); let fgId: string | undefined; const streamUpdate = () => { const details: AgentDetails = { ...detailBase, toolUses: fgState.toolUses, tokens: formatLifetimeTokens(fgState), turnCount: fgState.turnCount, maxTurns: fgState.maxTurns, durationMs: Date.now() - startedAt, status: "running", activity: describeActivity(fgState.activeTools, fgState.responseText), spinnerFrame: spinnerFrame % SPINNER.length, }; onUpdate?.({ content: [{ type: "text", text: `${fgState.toolUses} tool uses...` }], details: details as any, }); }; const { state: fgState, callbacks: fgCallbacks } = createActivityTracker(effectiveMaxTurns, streamUpdate); // Wire session creation to register in widget const origOnSession = fgCallbacks.onSessionCreated; fgCallbacks.onSessionCreated = (session: any) => { origOnSession(session); for (const a of manager.listAgents()) { if (a.session === session) { fgId = a.id; agentActivity.set(a.id, fgState); widget.ensureTimer(); break; } } }; // Animate spinner at ~80ms (smooth rotation through 10 braille frames) const spinnerInterval = setInterval(() => { spinnerFrame++; streamUpdate(); }, 80); streamUpdate(); let record: AgentRecord; try { record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, { description: params.description, model, maxTurns: effectiveMaxTurns, isolated, inheritContext, thinkingLevel: thinking, isolation, signal, ...fgCallbacks, }); } catch (err) { clearInterval(spinnerInterval); return textResult(err instanceof Error ? err.message : String(err)); } clearInterval(spinnerInterval); // Clean up foreground agent from widget if (fgId) { agentActivity.delete(fgId); widget.markFinished(fgId); } // Get final token count const tokenText = formatLifetimeTokens(fgState); const details = buildDetails(detailBase, record, fgState, { tokens: tokenText }); const fallbackNote = fellBack ? `Note: Unknown agent type "${rawType}" — using general-purpose.\n\n` : ""; if (record.status === "error") { return textResult(`${fallbackNote}Agent failed: ${record.error}`, details); } const durationMs = (record.completedAt ?? Date.now()) - record.startedAt; const statsParts = [`${record.toolUses} tool uses`]; if (tokenText) statsParts.push(tokenText); return textResult( `${fallbackNote}Agent completed in ${formatMs(durationMs)} (${statsParts.join(", ")})${getStatusNote(record.status)}.\n\n` + (record.result?.trim() || "No output."), details, ); }, })); // ---- get_subagent_result tool ---- pi.registerTool(defineTool({ name: "get_subagent_result", label: "Get Agent Result", description: "Check status and retrieve results from a background agent. Use the agent ID returned by Agent with run_in_background.", parameters: Type.Object({ agent_id: Type.String({ description: "The agent ID to check.", }), wait: Type.Optional( Type.Boolean({ description: "If true, wait for the agent to complete before returning. Default: false.", }), ), verbose: Type.Optional( Type.Boolean({ description: "If true, include the agent's full conversation (messages + tool calls). Default: false.", }), ), }), execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => { const record = manager.getRecord(params.agent_id); if (!record) { return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`); } // Wait for completion if requested. // Pre-mark resultConsumed BEFORE awaiting: onComplete fires inside .then() // (attached earlier at spawn time) and always runs before this await resumes. // Setting the flag here prevents a redundant follow-up notification. if (params.wait && record.status === "running" && record.promise) { record.resultConsumed = true; cancelNudge(params.agent_id); await record.promise; } const displayName = getDisplayName(record.type); const duration = formatDuration(record.startedAt, record.completedAt); const tokens = formatLifetimeTokens(record); const contextPercent = getSessionContextPercent(record.session); const statsParts = [`Tool uses: ${record.toolUses}`]; if (tokens) statsParts.push(tokens); if (contextPercent !== null) statsParts.push(`Context: ${Math.round(contextPercent)}%`); if (record.compactionCount) statsParts.push(`Compactions: ${record.compactionCount}`); statsParts.push(`Duration: ${duration}`); let output = `Agent: ${record.id}\n` + `Type: ${displayName} | Status: ${record.status} | ${statsParts.join(" | ")}\n` + `Description: ${record.description}\n\n`; if (record.status === "running") { output += "Agent is still running. Use wait: true or check back later."; } else if (record.status === "error") { output += `Error: ${record.error}`; } else { output += record.result?.trim() || "No output."; } // Mark result as consumed — suppresses the completion notification if (record.status !== "running" && record.status !== "queued") { record.resultConsumed = true; cancelNudge(params.agent_id); } // Verbose: include full conversation if (params.verbose && record.session) { const conversation = getAgentConversation(record.session); if (conversation) { output += `\n\n--- Agent Conversation ---\n${conversation}`; } } return textResult(output); }, })); // ---- steer_subagent tool ---- pi.registerTool(defineTool({ name: "steer_subagent", label: "Steer Agent", description: "Send a steering message to a running agent. The message will interrupt the agent after its current tool execution " + "and be injected into its conversation, allowing you to redirect its work mid-run. Only works on running agents.", parameters: Type.Object({ agent_id: Type.String({ description: "The agent ID to steer (must be currently running).", }), message: Type.String({ description: "The steering message to send. This will appear as a user message in the agent's conversation.", }), }), execute: async (_toolCallId, params, _signal, _onUpdate, _ctx) => { const record = manager.getRecord(params.agent_id); if (!record) { return textResult(`Agent not found: "${params.agent_id}". It may have been cleaned up.`); } if (record.status !== "running") { return textResult(`Agent "${params.agent_id}" is not running (status: ${record.status}). Cannot steer a non-running agent.`); } if (!record.session) { // Session not ready yet — queue the steer for delivery once initialized if (!record.pendingSteers) record.pendingSteers = []; record.pendingSteers.push(params.message); pi.events.emit("subagents:steered", { id: record.id, message: params.message }); return textResult(`Steering message queued for agent ${record.id}. It will be delivered once the session initializes.`); } try { await steerAgent(record.session, params.message); pi.events.emit("subagents:steered", { id: record.id, message: params.message }); const tokens = formatLifetimeTokens(record); const contextPercent = getSessionContextPercent(record.session); const stateParts: string[] = []; if (tokens) stateParts.push(tokens); stateParts.push(`${record.toolUses} tool ${record.toolUses === 1 ? "use" : "uses"}`); if (contextPercent !== null) stateParts.push(`context ${Math.round(contextPercent)}% full`); if (record.compactionCount) stateParts.push(`${record.compactionCount} compaction${record.compactionCount === 1 ? "" : "s"}`); return textResult( `Steering message sent to agent ${record.id}. The agent will process it after its current tool execution.\n` + `Current state: ${stateParts.join(" · ")}`, ); } catch (err) { return textResult(`Failed to steer agent: ${err instanceof Error ? err.message : String(err)}`); } }, })); // ---- /agents interactive menu ---- const projectAgentsDir = () => join(process.cwd(), ".pi", "agents"); const personalAgentsDir = () => join(getAgentDir(), "agents"); /** Find the file path of a custom agent by name (project first, then global). */ function findAgentFile(name: string): { path: string; location: "project" | "personal" } | undefined { const projectPath = join(projectAgentsDir(), `${name}.md`); if (existsSync(projectPath)) return { path: projectPath, location: "project" }; const personalPath = join(personalAgentsDir(), `${name}.md`); if (existsSync(personalPath)) return { path: personalPath, location: "personal" }; return undefined; } function getModelLabel(type: string, registry?: ModelRegistry): string { const cfg = getAgentConfig(type); if (!cfg?.model) return "inherit"; // If registry provided, check if the model actually resolves if (registry) { const resolved = resolveModel(cfg.model, registry); if (typeof resolved === "string") return "inherit"; // model not available } return getModelLabelFromConfig(cfg.model); } async function _showAgentsMenu(ctx: ExtensionCommandContext) { reloadCustomAgents(); await showAgentsMenu(ctx, manager, scheduler, pi); } async function _showAllAgentsList(ctx: ExtensionCommandContext) { const allNames = getAllTypes(); if (allNames.length === 0) { ctx.ui.notify("No agents.", "info"); return; } // Source indicators: defaults unmarked, custom agents get • (project) or ◦ (global) // Disabled agents get ✕ prefix const sourceIndicator = (cfg: AgentConfig | undefined) => { const disabled = cfg?.enabled === false; if (cfg?.source === "project") return disabled ? "✕• " : "• "; if (cfg?.source === "global") return disabled ? "✕◦ " : "◦ "; if (disabled) return "✕ "; return " "; }; const entries = allNames.map(name => { const cfg = getAgentConfig(name); const disabled = cfg?.enabled === false; const model = getModelLabel(name, ctx.modelRegistry); const indicator = sourceIndicator(cfg); const prefix = `${indicator}${name} · ${model}`; const desc = disabled ? "(disabled)" : (cfg?.description ?? name); return { name, prefix, desc }; }); const maxPrefix = Math.max(...entries.map(e => e.prefix.length)); const hasCustom = allNames.some(n => { const c = getAgentConfig(n); return c && !c.isDefault && c.enabled !== false; }); const hasDisabled = allNames.some(n => getAgentConfig(n)?.enabled === false); const legendParts: string[] = []; if (hasCustom) legendParts.push("• = project ◦ = global"); if (hasDisabled) legendParts.push("✕ = disabled"); const legend = legendParts.length ? "\n" + legendParts.join(" ") : ""; const options = entries.map(({ prefix, desc }) => `${prefix.padEnd(maxPrefix)} — ${desc}`, ); if (legend) options.push(legend); const choice = await ctx.ui.select("Agent types", options); if (!choice) return; const agentName = choice.split(" · ")[0].replace(/^[•◦✕\s]+/, "").trim(); if (getAgentConfig(agentName)) { await showAgentDetail(ctx, agentName); await _showAllAgentsList(ctx); } } async function _showRunningAgents(ctx: ExtensionCommandContext) { const agents = manager.listAgents(); if (agents.length === 0) { ctx.ui.notify("No agents.", "info"); return; } const options = agents.map(a => { const dn = getDisplayName(a.type); const dur = formatDuration(a.startedAt, a.completedAt); return `${dn} (${a.description}) · ${a.toolUses} tools · ${a.status} · ${dur}`; }); const choice = await ctx.ui.select("Running agents", options); if (!choice) return; // Find the selected agent by matching the option index const idx = options.indexOf(choice); if (idx < 0) return; const record = agents[idx]; await viewAgentConversation(ctx, record); // Back-navigation: re-show the list await _showRunningAgents(ctx); } async function viewAgentConversation(ctx: ExtensionCommandContext, record: AgentRecord) { if (!record.session) { ctx.ui.notify(`Agent is ${record.status === "queued" ? "queued" : "expired"} — no session available.`, "info"); return; } const { ConversationViewer } = await import("./ui/conversation-viewer.js"); const session = record.session; const activity = agentActivity.get(record.id); await ctx.ui.custom( (tui, theme, _keybindings, done) => { return new ConversationViewer(tui, session, record, activity, theme, done); }, { overlay: true, overlayOptions: { anchor: "center", width: "90%" }, }, ); } async function showAgentDetail(ctx: ExtensionCommandContext, name: string) { const cfg = getAgentConfig(name); if (!cfg) { ctx.ui.notify(`Agent config not found for "${name}".`, "warning"); return; } const file = findAgentFile(name); const isDefault = cfg.isDefault === true; const disabled = cfg.enabled === false; let menuOptions: string[]; if (disabled && file) { // Disabled agent with a file — offer Enable menuOptions = isDefault ? ["Enable", "Edit", "Reset to default", "Delete", "Back"] : ["Enable", "Edit", "Delete", "Back"]; } else if (isDefault && !file) { // Default agent with no .md override menuOptions = ["Eject (export as .md)", "Disable", "Back"]; } else if (isDefault && file) { // Default agent with .md override (ejected) menuOptions = ["Edit", "Disable", "Reset to default", "Delete", "Back"]; } else { // User-defined agent menuOptions = ["Edit", "Disable", "Delete", "Back"]; } const choice = await ctx.ui.select(name, menuOptions); if (!choice || choice === "Back") return; if (choice === "Edit" && file) { const content = readFileSync(file.path, "utf-8"); const edited = await ctx.ui.editor(`Edit ${name}`, content); if (edited !== undefined && edited !== content) { const { writeFileSync } = await import("node:fs"); writeFileSync(file.path, edited, "utf-8"); reloadCustomAgents(); ctx.ui.notify(`Updated ${file.path}`, "info"); } } else if (choice === "Delete") { if (file) { const confirmed = await ctx.ui.confirm("Delete agent", `Delete ${name} from ${file.location} (${file.path})?`); if (confirmed) { unlinkSync(file.path); reloadCustomAgents(); ctx.ui.notify(`Deleted ${file.path}`, "info"); } } } else if (choice === "Reset to default" && file) { const confirmed = await ctx.ui.confirm("Reset to default", `Delete override ${file.path} and restore embedded default?`); if (confirmed) { unlinkSync(file.path); reloadCustomAgents(); ctx.ui.notify(`Restored default ${name}`, "info"); } } else if (choice.startsWith("Eject")) { await ejectAgent(ctx, name, cfg); } else if (choice === "Disable") { await disableAgent(ctx, name); } else if (choice === "Enable") { await enableAgent(ctx, name); } } /** Eject a default agent: write its embedded config as a .md file. */ async function ejectAgent(ctx: ExtensionCommandContext, name: string, cfg: AgentConfig) { const location = await ctx.ui.select("Choose location", [ "Project (.pi/agents/)", `Personal (${personalAgentsDir()})`, ]); if (!location) return; const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir(); mkdirSync(targetDir, { recursive: true }); const targetPath = join(targetDir, `${name}.md`); if (existsSync(targetPath)) { const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`); if (!overwrite) return; } // Build the .md file content const fmFields: string[] = []; fmFields.push(`description: ${cfg.description}`); if (cfg.displayName) fmFields.push(`display_name: ${cfg.displayName}`); fmFields.push(`tools: ${cfg.builtinToolNames?.join(", ") || "all"}`); if (cfg.model) fmFields.push(`model: ${cfg.model}`); if (cfg.thinking) fmFields.push(`thinking: ${cfg.thinking}`); if (cfg.maxTurns) fmFields.push(`max_turns: ${cfg.maxTurns}`); fmFields.push(`prompt_mode: ${cfg.promptMode}`); if (cfg.extensions === false) fmFields.push("extensions: false"); else if (Array.isArray(cfg.extensions)) fmFields.push(`extensions: ${cfg.extensions.join(", ")}`); if (cfg.skills === false) fmFields.push("skills: false"); else if (Array.isArray(cfg.skills)) fmFields.push(`skills: ${cfg.skills.join(", ")}`); if (cfg.disallowedTools?.length) fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`); if (cfg.inheritContext) fmFields.push("inherit_context: true"); if (cfg.runInBackground) fmFields.push("run_in_background: true"); if (cfg.isolated) fmFields.push("isolated: true"); if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`); if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`); const content = `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`; const { writeFileSync } = await import("node:fs"); writeFileSync(targetPath, content, "utf-8"); reloadCustomAgents(); ctx.ui.notify(`Ejected ${name} to ${targetPath}`, "info"); } /** Disable an agent: set enabled: false in its .md file, or create a stub for built-in defaults. */ async function disableAgent(ctx: ExtensionCommandContext, name: string) { const file = findAgentFile(name); if (file) { // Existing file — set enabled: false in frontmatter (idempotent) const content = readFileSync(file.path, "utf-8"); if (content.includes("\nenabled: false\n")) { ctx.ui.notify(`${name} is already disabled.`, "info"); return; } const updated = content.replace(/^---\n/, "---\nenabled: false\n"); const { writeFileSync } = await import("node:fs"); writeFileSync(file.path, updated, "utf-8"); reloadCustomAgents(); ctx.ui.notify(`Disabled ${name} (${file.path})`, "info"); return; } // No file (built-in default) — create a stub const location = await ctx.ui.select("Choose location", [ "Project (.pi/agents/)", `Personal (${personalAgentsDir()})`, ]); if (!location) return; const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir(); mkdirSync(targetDir, { recursive: true }); const targetPath = join(targetDir, `${name}.md`); const { writeFileSync } = await import("node:fs"); writeFileSync(targetPath, "---\nenabled: false\n---\n", "utf-8"); reloadCustomAgents(); ctx.ui.notify(`Disabled ${name} (${targetPath})`, "info"); } /** Enable a disabled agent by removing enabled: false from its frontmatter. */ async function enableAgent(ctx: ExtensionCommandContext, name: string) { const file = findAgentFile(name); if (!file) return; const content = readFileSync(file.path, "utf-8"); const updated = content.replace(/^(---\n)enabled: false\n/, "$1"); const { writeFileSync } = await import("node:fs"); // If the file was just a stub ("---\n---\n"), delete it to restore the built-in default if (updated.trim() === "---\n---" || updated.trim() === "---\n---\n") { unlinkSync(file.path); reloadCustomAgents(); ctx.ui.notify(`Enabled ${name} (removed ${file.path})`, "info"); } else { writeFileSync(file.path, updated, "utf-8"); reloadCustomAgents(); ctx.ui.notify(`Enabled ${name} (${file.path})`, "info"); } } async function _showCreateWizard(ctx: ExtensionCommandContext) { const location = await ctx.ui.select("Choose location", [ "Project (.pi/agents/)", `Personal (${personalAgentsDir()})`, ]); if (!location) return; const targetDir = location.startsWith("Project") ? projectAgentsDir() : personalAgentsDir(); const method = await ctx.ui.select("Creation method", [ "Generate with Claude (recommended)", "Manual configuration", ]); if (!method) return; if (method.startsWith("Generate")) { await showGenerateWizard(ctx, targetDir); } else { await showManualWizard(ctx, targetDir); } } async function showGenerateWizard(ctx: ExtensionCommandContext, targetDir: string) { const description = await ctx.ui.input("Describe what this agent should do"); if (!description) return; const name = await ctx.ui.input("Agent name (filename, no spaces)"); if (!name) return; mkdirSync(targetDir, { recursive: true }); const targetPath = join(targetDir, `${name}.md`); if (existsSync(targetPath)) { const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`); if (!overwrite) return; } ctx.ui.notify("Generating agent definition...", "info"); const generatePrompt = `Create a custom pi sub-agent definition file based on this description: "${description}" Write a markdown file to: ${targetPath} The file format is a markdown file with YAML frontmatter and a system prompt body: \`\`\`markdown --- description: tools: model: thinking: max_turns: prompt_mode: <"replace" (body IS the full system prompt) or "append" (body is appended to default prompt). Default: replace> extensions: skills: disallowed_tools: inherit_context: run_in_background: isolated: memory: <"user" (global), "project" (per-project), or "local" (gitignored per-project) for persistent memory. Omit for none> isolation: <"worktree" to run in isolated git worktree. Omit for normal> --- \`\`\` Guidelines for choosing settings: - For read-only tasks (review, analysis): tools: read, bash, grep, find, ls - For code modification tasks: include edit, write - Use prompt_mode: append if the agent should keep the default system prompt and add specialization on top - Use prompt_mode: replace for fully custom agents with their own personality/instructions - Set inherit_context: true if the agent needs to know what was discussed in the parent conversation - Set isolated: true if the agent should NOT have access to MCP servers or other extensions - Only include frontmatter fields that differ from defaults — omit fields where the default is fine Write the file using the write tool. Only write the file, nothing else.`; const record = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, { description: `Generate ${name} agent`, maxTurns: 5, }); if (record.status === "error") { ctx.ui.notify(`Generation failed: ${record.error}`, "warning"); return; } reloadCustomAgents(); if (existsSync(targetPath)) { ctx.ui.notify(`Created ${targetPath}`, "info"); } else { ctx.ui.notify("Agent generation completed but file was not created. Check the agent output.", "warning"); } } async function showManualWizard(ctx: ExtensionCommandContext, targetDir: string) { // 1. Name const name = await ctx.ui.input("Agent name (filename, no spaces)"); if (!name) return; // 2. Description const description = await ctx.ui.input("Description (one line)"); if (!description) return; // 3. Tools const toolChoice = await ctx.ui.select("Tools", ["all", "none", "read-only (read, bash, grep, find, ls)", "custom..."]); if (!toolChoice) return; let tools: string; if (toolChoice === "all") { tools = BUILTIN_TOOL_NAMES.join(", "); } else if (toolChoice === "none") { tools = "none"; } else if (toolChoice.startsWith("read-only")) { tools = "read, bash, grep, find, ls"; } else { const customTools = await ctx.ui.input("Tools (comma-separated)", BUILTIN_TOOL_NAMES.join(", ")); if (!customTools) return; tools = customTools; } // 4. Model const modelChoice = await ctx.ui.select("Model", [ "inherit (parent model)", "haiku", "sonnet", "opus", "custom...", ]); if (!modelChoice) return; let modelLine = ""; if (modelChoice === "haiku") modelLine = "\nmodel: anthropic/claude-haiku-4-5-20251001"; else if (modelChoice === "sonnet") modelLine = "\nmodel: anthropic/claude-sonnet-4-6"; else if (modelChoice === "opus") modelLine = "\nmodel: anthropic/claude-opus-4-6"; else if (modelChoice === "custom...") { const customModel = await ctx.ui.input("Model (provider/modelId)"); if (customModel) modelLine = `\nmodel: ${customModel}`; } // 5. Thinking const thinkingChoice = await ctx.ui.select("Thinking level", [ "inherit", "off", "minimal", "low", "medium", "high", "xhigh", ]); if (!thinkingChoice) return; let thinkingLine = ""; if (thinkingChoice !== "inherit") thinkingLine = `\nthinking: ${thinkingChoice}`; // 6. System prompt const systemPrompt = await ctx.ui.editor("System prompt", ""); if (systemPrompt === undefined) return; // Build the file const content = `--- description: ${description} tools: ${tools}${modelLine}${thinkingLine} prompt_mode: replace --- ${systemPrompt} `; mkdirSync(targetDir, { recursive: true }); const targetPath = join(targetDir, `${name}.md`); if (existsSync(targetPath)) { const overwrite = await ctx.ui.confirm("Overwrite", `${targetPath} already exists. Overwrite?`); if (!overwrite) return; } const { writeFileSync } = await import("node:fs"); writeFileSync(targetPath, content, "utf-8"); reloadCustomAgents(); ctx.ui.notify(`Created ${targetPath}`, "info"); } function snapshotSettings(): SubagentsSettings { return { maxConcurrent: manager.getMaxConcurrent(), // 0 = unlimited — per SubagentsSettings.defaultMaxTurns docstring and // normalizeMaxTurns() in agent-runner.ts (which maps 0 → undefined). defaultMaxTurns: getDefaultMaxTurns() ?? 0, graceTurns: getGraceTurns(), defaultJoinMode: getDefaultJoinMode(), schedulingEnabled: isSchedulingEnabled(), }; } async function _showSettings(ctx: ExtensionCommandContext) { const choice = await ctx.ui.select("Settings", [ `Max concurrency (current: ${manager.getMaxConcurrent()})`, `Default max turns (current: ${getDefaultMaxTurns() ?? "unlimited"})`, `Grace turns (current: ${getGraceTurns()})`, `Join mode (current: ${getDefaultJoinMode()})`, `Scheduling (current: ${isSchedulingEnabled() ? "enabled" : "disabled"})`, ]); if (!choice) return; if (choice.startsWith("Max concurrency")) { const val = await ctx.ui.input("Max concurrent background agents", String(manager.getMaxConcurrent())); if (val) { const n = parseInt(val, 10); if (n >= 1) { manager.setMaxConcurrent(n); notifyApplied(ctx, `Max concurrency set to ${n}`); } else { ctx.ui.notify("Must be a positive integer.", "warning"); } } } else if (choice.startsWith("Default max turns")) { const val = await ctx.ui.input("Default max turns before wrap-up (0 = unlimited)", String(getDefaultMaxTurns() ?? 0)); if (val) { const n = parseInt(val, 10); if (n === 0) { setDefaultMaxTurns(undefined); notifyApplied(ctx, "Default max turns set to unlimited"); } else if (n >= 1) { setDefaultMaxTurns(n); notifyApplied(ctx, `Default max turns set to ${n}`); } else { ctx.ui.notify("Must be 0 (unlimited) or a positive integer.", "warning"); } } } else if (choice.startsWith("Grace turns")) { const val = await ctx.ui.input("Grace turns after wrap-up steer", String(getGraceTurns())); if (val) { const n = parseInt(val, 10); if (n >= 1) { setGraceTurns(n); notifyApplied(ctx, `Grace turns set to ${n}`); } else { ctx.ui.notify("Must be a positive integer.", "warning"); } } } else if (choice.startsWith("Join mode")) { const val = await ctx.ui.select("Default join mode for background agents", [ "smart — auto-group 2+ agents in same turn (default)", "async — always notify individually", "group — always group background agents", ]); if (val) { const mode = val.split(" ")[0] as JoinMode; setDefaultJoinMode(mode); notifyApplied(ctx, `Default join mode set to ${mode}`); } } else if (choice.startsWith("Scheduling")) { const val = await ctx.ui.select( "Schedule subagent feature", [ "enabled — Agent tool accepts a `schedule` param; /agents → Scheduled jobs visible", "disabled — `schedule` removed from Agent tool spec (no LLM-context cost); menu hidden", ], ); if (val) { const enabled = val.startsWith("enabled"); if (enabled === isSchedulingEnabled()) { ctx.ui.notify(`Scheduling already ${enabled ? "enabled" : "disabled"}.`, "info"); } else { setSchedulingEnabled(enabled); if (!enabled) scheduler.stop(); // immediate kill — outstanding fires stop ticking notifyApplied( ctx, `Scheduling ${enabled ? "enabled" : "disabled"}. Tool spec change takes effect on next pi session.`, ); } } } } // Persist the current snapshot, emit `subagents:settings_changed`, and surface // the right toast. Successful saves show info; persistence failures downgrade // to warning so users aren't silently reverted on restart. Event fires regardless // of outcome so listeners see the in-memory change. function notifyApplied(ctx: ExtensionCommandContext, successMsg: string) { const { message, level } = saveAndEmitChanged( snapshotSettings(), successMsg, (event, payload) => pi.events.emit(event, payload), ); ctx.ui.notify(message, level); } }