import { logger } from "./logger.js"; /** * 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 { randomUUID } from "node:crypto"; import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent"; import { AgentManager } from "./agent-manager.js"; import { getDebugCapturePaths, isDebugCaptureEnabled, isSchedulingEnabled, reloadCustomAgents, setAnimationStyle, setDashboardKeybindings, setDashboardRefreshInterval, setDebugCapture, setDebugCapturePaths, setDefaultJoinMode, setFooterStatusConfig, setOrchestrationMode, setPromptCompressionLevel, setSchedulingEnabled, setShowActivityStream, setShowAgentTopWidget, setShowTokenUsage, setShowTurnProgress, setUiStyle, } from "./agent-registry.js"; import { setDefaultMaxTurns, setGraceTurns, setMaxEndHookRevisions } from "./agent-runner.js"; import { BatchOrchestrator } from "./batch-orchestrator.js"; import { registerAgentsCommand } from "./commands/agents.js"; import { registerHooksCommand } from "./commands/hooks.js"; import { registerTemplatesCommand } from "./commands/templates.js"; import { createTokenAuthProvider, PROTOCOL_VERSION, registerRpcHandlers, } from "./cross-extension-rpc.js"; import { appendAgentEvent, appendError, appendRpcAudit, appendScheduleEvent, disable as disableDebugCapture, enable as enableDebugCapture, isDebugCaptureEnabled as isDebugCaptureSinkOn, } from "./debug-capture.js"; import { GroupJoinManager } from "./group-join.js"; import { HookRegistry } from "./hooks.js"; import { NotificationHub } from "./notification-hub.js"; import { formatPartialFinalizationLabel } from "./orchestration-dispatch.js"; import { clearSubagentsApi, registerSubagentsApi } from "./public-api.js"; import type { ScheduleChangeEvent } from "./schedule.js"; import { SubagentScheduler } from "./schedule.js"; import { resolveStorePath, ScheduleStore } from "./schedule-store.js"; import { applyAndEmitLoaded, capturedDispatchNotices, extractCapturedDispatchLimits, type SubagentsSettings, } from "./settings.js"; import { sessionBudgetWarningMessage, spendBudgetWarningMessage, utilization, utilizationLabel, } from "./spend.js"; import { SwarmCoordinator, setActiveSwarmCoordinator } from "./swarm-join.js"; import { onTelemetry } from "./telemetry.js"; import { buildNotificationDetails, formatTaskNotification } from "./tool-result-helpers.js"; import { createAgentTool } from "./tools/agent.js"; import { createGetResultTool } from "./tools/get-result.js"; import { createSteerTool } from "./tools/steer.js"; import { type AgentRecord, type NotificationDetails } from "./types.js"; import { AgentTopWidget } from "./ui/agent-top-widget.js"; import type { AgentActivity, UICtx } from "./ui/agent-ui-types.js"; import { AgentWidget } from "./ui/agent-widget.js"; import { setSpinnerStyle } from "./ui/animation.js"; import { clearWidgetMetrics, setWidgetMetrics } from "./ui/global-registry.js"; import { LiveWidgets } from "./ui/live-widgets.js"; import { createNotificationRenderer } from "./ui/notification-renderer.js"; export default async function (pi: ExtensionAPI) { // ---- Register custom notification renderer ---- pi.registerMessageRenderer( "subagent-notification", (message, opts, theme) => createNotificationRenderer(theme)(message, opts), ); // Initial load await reloadCustomAgents(); // ---- Agent activity tracking + live widgets ---- const agentActivity = new Map(); // Assigned after AgentManager construction (widgets need the manager). // Closures below capture the binding; they only run after init completes. let liveWidgets!: LiveWidgets; // ---- Completion notifications (debounced, cancellable) ---- // Extracted to notification-hub.ts: pending-nudge hold, individual nudge // emission, and lifetime usage → event payload mapping. const notifications = new NotificationHub({ pi, agentActivity, getWidgets: () => liveWidgets }); // ---- Group join manager ---- const groupJoin = new GroupJoinManager( (records, partial, meta) => { for (const r of records) { agentActivity.delete(r.id); liveWidgets.markFinished(r.id); } const groupKey = `group:${records.map(r => r.id).join(",")}`; notifications.schedule(groupKey, () => { // Re-check at send time const unconsumed = records.filter(r => !r.resultConsumed); if (unconsumed.length === 0) { liveWidgets.update(); return; } const notifications = unconsumed.map(r => formatTaskNotification(r, 300)).join('\n\n'); // R5: a group whose fan-out failed mid-spawn finalizes with an // explicit partial status naming spawned/missing counts — never as // an unqualified success while a member is missing. const label = meta?.missingMembers ? formatPartialFinalizationLabel(unconsumed.length, meta.completedAgents, meta.missingMembers) : 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 }); }); liveWidgets.update(); }, 30_000, ); // ---- Swarm coordinator (dynamic collaborative groups) ---- // Supports runtime join (the "swarm mode" feature) and provides query APIs // for the rich AgentDashboard. const swarmJoin = new SwarmCoordinator( (records, partial, swarmId, meta) => { for (const r of records) { agentActivity.delete(r.id); liveWidgets.markFinished(r.id); } const swarmKey = `swarm:${swarmId}`; notifications.schedule(swarmKey, () => { const unconsumed = records.filter(r => !r.resultConsumed); if (unconsumed.length === 0) { liveWidgets.update(); return; } const notifications = unconsumed.map(r => formatTaskNotification(r, 300)).join('\n\n'); // R5: same explicit partial status as groups — a swarm whose fan-out // failed mid-spawn names spawned/missing counts instead of success. const label = meta?.missingMembers ? formatPartialFinalizationLabel(unconsumed.length, meta.contributorCount, meta.missingMembers, "swarm agent(s)") : partial ? `${unconsumed.length} swarm agent(s) finished (partial — swarm still active)` : `Swarm ${swarmId} wave completed`; 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: `Swarm update: ${label}\n\n${notifications}\n\nUse get_subagent_result for full output.`, display: true, details, }, { deliverAs: "followUp", triggerTurn: true }); }); liveWidgets.update(); }, 30_000, ); // Make the real coordinator available to the dashboard / output-handler layer // so 'w' hotkey actions can actually create and join swarms at runtime. setActiveSwarmCoordinator(swarmJoin); // Background completion: route through group join or send individual nudge const hookRegistry = new HookRegistry(); 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 = notifications.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. // outcome/outcomeReason carry the R4 outcome contract so history readers // can tell a budget cut or a silent run from a successful completion. pi.appendEntry("subagents:record", { id: record.id, type: record.type, description: record.description, status: record.status, result: record.result, error: record.error, outcome: record.outcome, outcomeReason: record.outcomeReason, startedAt: record.startedAt, completedAt: record.completedAt, }); // Skip notification if result was already consumed via get_subagent_result if (record.resultConsumed) { agentActivity.delete(record.id); liveWidgets.markFinished(record.id); liveWidgets.update(); return; } // If this agent is pending batch finalization (debounce window still open), // don't send an individual nudge — batch orchestrator will pick it up retroactively. if (batchOrchestrator.isPendingBatchFinalization(record.id)) { liveWidgets.update(); return; } const groupResult = groupJoin.onAgentComplete(record); const swarmResult = swarmJoin.onAgentComplete(record); if (groupResult === 'pass' && swarmResult === 'pass') { notifications.sendIndividual(record); } // 'held' or 'delivered' for either → notification handled by the respective coordinator liveWidgets.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, }); }); // Wire up agentActivity cleanup: when records are removed from the // manager (cleanup cycle, clearCompleted), purge corresponding activity entries. manager.onRecordRemoved = (id: string) => { agentActivity.delete(id); }; // Attach the global hook registry to the agent manager manager.hooks = hookRegistry; // ---- Debug-capture wiring ---- // Always-on hook/telemetry/pi-event handlers that forward payloads to the // optional local capture sink. Each handler is a no-op when the sink is // disabled so there's no observable runtime cost in the default path. // The sink itself is a pure module (`debug-capture.ts`) — wiring stays // here so the sink stays dependency-free and trivially testable. const HOOK_EVENTS_TO_CAPTURE = [ "subagent:start", "subagent:end", "subagent:error", "subagent:spawn", "subagent:steer", "tool:call", "tool:result", "compaction:start", "compaction:end", "turn:start", "turn:end", "swarm:join", "swarm:leave", "validation:start", "validation:end", ] as const; // Always-on hook handlers at background priority. They never block or // modify (would freeze agent syscalls) and gate on `isDebugCaptureSinkOn()` // so the no-op cost is one bool read per event. The handler list is // process-scoped — registered once at extension load, never unregistered. for (const event of HOOK_EVENTS_TO_CAPTURE) { hookRegistry.register( event, (payload) => { if (!isDebugCaptureSinkOn()) return "allow"; appendAgentEvent(payload.agentId, payload.event, payload.data); if (payload.event === "subagent:error" && payload.data) { const data = payload.data as { error?: unknown }; if (data.error !== undefined) { appendError(payload.agentId, data.error, { hookEvent: payload.event }); } } return "allow"; }, { priority: "background", id: `debug-capture-${event}` }, ); } // Telemetry-driven captures: agent completion metrics + RPC audit mirror. const debugTelemetryUnsubs: Array<() => void> = []; debugTelemetryUnsubs.push( onTelemetry("agent:completed", (payload) => { if (!isDebugCaptureSinkOn()) return; // Use a synthetic agent-id so different runs don't collide under one // `metrics.json` snapshot — the `type` field already disambiguates. const syntheticId = `${payload.type}@${new Date().toISOString().slice(0, 19)}`; appendAgentEvent(syntheticId, "agent:completed", payload); }), ); debugTelemetryUnsubs.push( onTelemetry("rpc:audit", (payload) => { if (!isDebugCaptureSinkOn()) return; appendRpcAudit(payload as Record); }), ); // Schedule firings + errors arrive on the cross-extension event bus. const scheduleUnsub = pi.events.on("subagents:scheduled", (payload) => { if (!isDebugCaptureSinkOn()) return; const evt = payload as ScheduleChangeEvent; // Discriminated-union narrowing via switch: TS verifies exhaustiveness. let jobId: string; let jobName: string; switch (evt.type) { case "added": case "updated": jobId = evt.job.id; jobName = evt.job.name; break; case "fired": jobId = evt.jobId; jobName = evt.name; break; case "error": case "removed": jobId = evt.jobId; jobName = evt.jobId; break; } appendScheduleEvent(jobId, jobName, evt.type, evt); }); // Budget warnings: session-limit thresholds (once per threshold) + per-subagent // spend (50/80/100% of the token cap). Emitted as pi.events so the dashboard // can show them, and as a single non-blocking notification message. Message // text (R1 utilization SSOT + R3 operator-action hint) is built by the pure // helpers in spend.ts so every warning names a concrete action. manager.setBudgetWarningHandler((type, usage, limits) => { if (type.startsWith("spend_")) { // The crossed threshold is a utilization level of the per-agent cap; the // builder routes it through the shared helper so every warning percentage // comes from the same math SSOT (R1 — never computed independently of // the counter). const spendPct = type === "spend_50" ? 50 : type === "spend_80" ? 80 : 100; const message = spendBudgetWarningMessage({ thresholdPct: spendPct, perAgentTokenLimit: manager.getPerAgentTokenLimit(), agentCount: usage.spawnedAgents, }); const pct = utilization(spendPct, 100); pi.events.emit("subagents:budget_warning", { type, usage, limits, threshold: `spend_${pct}`, message }); pi.sendMessage({ customType: "subagent-notification", content: message, display: true }); return; } const isCritical = type === "agents_at_90" || type === "turns_at_90"; const isAgents = type === "agents_at_80" || type === "agents_at_90"; // R1/AE1: percentage and counter render from the SAME used/cap pair — // above the cap the true ratio shows (e.g. "120% used (30/25)"), never a // threshold label detached from the counter. const used = isAgents ? usage.spawnedAgents : usage.totalTurns; const cap = isAgents ? limits.maxAgents : limits.maxTurns; const threshold = `${isAgents ? "agent" : "turn"} budget ${utilizationLabel(used, cap)}`; // R3: the message names the operator actions (raise the limit, restart, // or deny further work); raising a limit re-arms the threshold in the // manager so a later crossing warns again. const message = sessionBudgetWarningMessage({ kind: isAgents ? "agents" : "turns", used, cap, critical: isCritical, }); pi.events.emit("subagents:budget_warning", { type, usage, limits, threshold, message }); pi.sendMessage({ customType: "subagent-notification", content: message, display: true }); }); // Host-issued RPC capability token. Peers must present this via // authContext.authToken; trust-on-claim extensionId alone is rejected. const rpcAuthToken = randomUUID(); // Publish the typed public API on `globalThis` so peer extensions and tests // can discover and consume it. See `src/public-api.ts` for the contract. // Hands out the real HookRegistry, a token-wired RPC client, typed event // helpers, and a read-only SubagentManagerHandle (`pi-subagents:manager`). registerSubagentsApi(pi.events, hookRegistry, manager, { extensionId: "pi-agent-orchestrator", authToken: rpcAuthToken, }); // Expose widget render metrics via Symbol.for() global registry for dashboard access. // The dashboard reads this lazily via getWidgetMetrics() from global-registry.ts. // --- 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(); async 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 = await ScheduleStore.create(path); await 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. logger.warn("Failed to start scheduler:", { error: err instanceof Error ? err.message : String(err) }); } } // Capture ctx from session_start for RPC spawn handler + start the scheduler. pi.on("session_start", async (_event, ctx) => { currentCtx = ctx; manager.clearCompleted(); manager.resetSessionUsage(); if (isSchedulingEnabled() && !scheduler.isActive()) await startScheduler(ctx); // Activate the debug-capture sink if the persisted setting is on. No-op // when off (the default) — every append* call short-circuits internally. if (isDebugCaptureEnabled()) { try { // `getDebugCapturePaths()` returns `{ project, personal }` (the resolved // defaults or user overrides); `enableDebugCapture` accepts the // `{ projectPath, personalPath }` shape. Map deterministically. const resolved = getDebugCapturePaths(); enableDebugCapture( { projectPath: resolved.project, personalPath: resolved.personal }, ctx.sessionManager?.getSessionId?.(), ); } catch (err) { logger.debug(`debug-capture init failed: ${err instanceof Error ? err.message : String(err)}`); } } }); pi.on("session_before_switch", () => { manager.clearCompleted(); manager.resetSessionUsage(); scheduler.stop(); }); // Token-bound auth: authProvider overrides spoofed payload identifiers unless the // host-issued rpcAuthToken is present (see docs/api-reference.md security constraints). const { unsubPing: unsubPingRpc, unsubSpawn: unsubSpawnRpc, unsubStop: unsubStopRpc, unsubSessionUsage: unsubSessionUsageRpc, unsubSwarmHealth: unsubSwarmHealthRpc } = registerRpcHandlers({ events: pi.events, pi, getCtx: () => currentCtx, manager, sessionManager: manager, swarmCoordinator: swarmJoin, authProvider: createTokenAuthProvider(rpcAuthToken), }); // Broadcast readiness so extensions loaded after us can discover us (includes RPC token). pi.events.emit("subagents:ready", { rpcAuthToken, protocolVersion: PROTOCOL_VERSION }); // 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(); unsubSessionUsageRpc?.(); unsubSwarmHealthRpc?.(); currentCtx = undefined; clearSubagentsApi(); clearWidgetMetrics(); liveWidgets.dispose(); scheduler.stop(); manager.abortAll(); notifications.dispose(); await batchOrchestrator.dispose(); manager.dispose(); // Tear down debug-capture last so any final events from the dispose // chain above still land in the sink. Best-effort: enable() failures // already swallow errors, and disable() is idempotent. disableDebugCapture(true); for (const unsub of debugTelemetryUnsubs) unsub(); if (scheduleUnsub) scheduleUnsub(); unsubLimitNotices?.(); }); // Live widgets above the editor: agent tree + persistent AGENT TOP strip const widget = new AgentWidget(manager, agentActivity); const topWidget = new AgentTopWidget(manager, agentActivity); liveWidgets = new LiveWidgets(widget, topWidget); function bindWidgetUiCtx(ctx: ExtensionContext | undefined) { const uiCtx = ctx && typeof ctx.ui === "object" ? (ctx.ui as UICtx) : undefined; if (!uiCtx) return; liveWidgets.bind(uiCtx); } setWidgetMetrics({ getSnapshot: () => widget.getRenderMetrics(), }); // Footer status bar + editor widget bind on session start (no tool call required after reload). pi.on("session_start", async (_event, ctx) => { bindWidgetUiCtx(ctx); }); // ---- Batch orchestrator for smart/group/swarm join modes ---- const batchOrchestrator = new BatchOrchestrator({ manager, groupJoin, swarmJoin, onAgentHandled: (r) => notifications.sendIndividual(r), onWidgetUpdate: () => liveWidgets.update(), }); // Track tool calls per turn so we only age the widget once per turn boundary, // avoiding premature agent aging during validator retries within a turn. let currentTurnToolCount = 0; // Grab UI context from tool execution + clear lingering widget on new turn pi.on("tool_execution_start", async (_event, ctx) => { bindWidgetUiCtx(ctx); currentTurnToolCount++; if (currentTurnToolCount === 1) { liveWidgets.onTurnStart(); } }); // Reset tool counter at end of each turn pi.on("turn_end", () => { currentTurnToolCount = 0; }); // 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. const loadedSettings = applyAndEmitLoaded( { setMaxConcurrent: (n) => manager.setMaxConcurrent(n), setPerAgentTokenLimit: (n) => manager.setPerAgentTokenLimit(n), setSessionLimits: (limits) => manager.setSessionLimits(limits), setDefaultMaxTurns, setGraceTurns, setMaxEndHookRevisions, setDefaultJoinMode, setSchedulingEnabled, setAnimationStyle: (style) => { setAnimationStyle(style); setSpinnerStyle(style); }, setUiStyle, setShowActivityStream, setShowTokenUsage, setShowTurnProgress, setShowAgentTopWidget: (enabled) => { setShowAgentTopWidget(enabled); topWidget.forceRefresh(); }, setOrchestrationMode, setDashboardRefreshInterval, setPromptCompressionLevel, setDebugCapture, setDebugCapturePaths, setDashboardKeybindings, setFooterStatusConfig, }, (event, payload) => pi.events.emit(event, payload), ); // R2 captured-value notices: dispatch-captured limits (the effective // per-agent max turns baked into every spawn by tools/agent.ts) reach only // the NEXT dispatch when changed mid-session. `subagents:settings_changed` // fires exactly once per accepted settings change (saveAndEmitChanged), so // diffing the captured limits here emits at most one notice per change — // never per turn. Live-enforced limits (session agent/turn gates, the // per-agent spend cap) are not part of the diff and never notify. let capturedLimits = extractCapturedDispatchLimits(loadedSettings); const unsubLimitNotices = pi.events.on("subagents:settings_changed", (payload) => { const next = extractCapturedDispatchLimits((payload as { settings?: SubagentsSettings }).settings); for (const notice of capturedDispatchNotices(capturedLimits, next)) { pi.events.emit("subagents:limit_change_notice", notice); pi.sendMessage({ customType: "subagent-notification", content: notice.message, display: true }); } capturedLimits = next; }); // ---- Tool context — shared dependency bag for extracted tool modules ---- const toolCtx = { pi, manager, liveWidgets, agentActivity, batchOrchestrator, scheduler, swarmJoin, hookRegistry, sendIndividualNudge: (r: AgentRecord) => notifications.sendIndividual(r), cancelNudge: (k: string) => notifications.cancel(k), scheduleNudge: (k: string, s: () => void, d?: number) => notifications.schedule(k, s, d), }; // ---- Tools ---- pi.registerTool(createAgentTool(toolCtx)); pi.registerTool(createGetResultTool(toolCtx)); pi.registerTool(createSteerTool(toolCtx)); registerAgentsCommand(pi, manager, scheduler, agentActivity, swarmJoin, () => topWidget.forceRefresh()); registerHooksCommand(pi, hookRegistry); registerTemplatesCommand(pi); }