/** * pi-mission-control Extension Entry Point * * Visual mission orchestration with agent hierarchy and durable state. * Provides /mission command and tools for mission management. */ import * as path from "path"; import { existsSync } from "fs"; import { fileURLToPath } from "url"; import { Type } from "@sinclair/typebox"; import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext, SessionStartEvent, } from "@mariozechner/pi-coding-agent"; import { withFileMutationQueue } from "@mariozechner/pi-coding-agent"; import type { Model } from "@mariozechner/pi-ai"; // Define event types locally (not exported from main pi-coding-agent index) interface ResourcesDiscoverEvent { type: "resources_discover"; cwd: string; reason: "startup" | "reload"; } interface ResourcesDiscoverResult { skillPaths?: string[]; promptPaths?: string[]; themePaths?: string[]; } // State and tools import { readState, writeState, defaultState, isScaffolded, getRunFilePath, getStateFilePath, getProjectRoot, } from "./state.js"; import { missionScaffold, missionInit, addPhase, addTask, updatePhase, updateTask, missionComplete, missionResume, } from "./tools/index.js"; import { MissionDashboard } from "./tui/dashboard.js"; import { readText, writeText } from "./state.js"; // ============================================================================ // TypeBox Schemas for Agent Tools // ============================================================================ const AddPhaseSchema = Type.Object({ name: Type.String({ description: "Human-readable phase name (e.g., 'Auth Backend')" }), file: Type.String({ description: "Path to phase definition/reference file" }), }); const AddTaskSchema = Type.Object({ phase_id: Type.String({ description: "ID of the phase to add task to (e.g., 'phase1')" }), name: Type.String({ description: "Human-readable task name (e.g., 'Setup DB Schema')" }), file: Type.String({ description: "Path to task contract file" }), }); const UpdatePhaseSchema = Type.Object({ phase_id: Type.String({ description: "ID of the phase to update" }), status: Type.Union([ Type.Literal("pending"), Type.Literal("in_progress"), Type.Literal("done"), Type.Literal("removed"), ], { description: "New status for the phase" }), }); const UpdateTaskSchema = Type.Object({ task_id: Type.String({ description: "ID of the task to update (e.g., 'phase1-task1')" }), status: Type.Union([ Type.Literal("pending"), Type.Literal("in_progress"), Type.Literal("done"), Type.Literal("failed"), Type.Literal("removed"), ], { description: "New status for the task" }), }); const MissionCompleteSchema = Type.Object({ run_id: Type.String({ description: "ID of the run to mark as complete" }), }); const MissionResumeSchema = Type.Object({ run_id: Type.String({ description: "ID of the run to resume" }), phase: Type.Optional(Type.String({ description: "Optional phase to set as current" })), statusMessage: Type.Optional(Type.String({ description: "Optional status message" })), }); // ============================================================================ // Tool Result Formatters // ============================================================================ function formatAddPhaseResult(result: Awaited>): string { if (!result.success) { return `❌ Failed to add phase: ${result.message}\n${result.errors.map(e => ` - ${e}`).join("\n")}`; } return `✅ Phase added: ${result.phaseId}\n Name: ${result.message.replace("Phase added: ", "")}`; } function formatAddTaskResult(result: Awaited>): string { if (!result.success) { return `❌ Failed to add task: ${result.message}\n${result.errors.map(e => ` - ${e}`).join("\n")}`; } return `✅ Task added: ${result.taskId}\n Name: ${result.message.replace("Task added: ", "")}`; } function formatUpdatePhaseResult(result: Awaited>): string { if (!result.success) { return `❌ Failed to update phase: ${result.message}\n${result.errors.map(e => ` - ${e}`).join("\n")}`; } const lines = [`✅ Phase ${result.phaseId} updated: ${result.previousStatus} → ${result.newStatus}`]; if (result.startedAt) lines.push(` Started at: ${result.startedAt}`); if (result.finishAt) lines.push(` Finished at: ${result.finishAt}`); return lines.join("\n"); } function formatUpdateTaskResult(result: Awaited>): string { if (!result.success) { return `❌ Failed to update task: ${result.message}\n${result.errors.map(e => ` - ${e}`).join("\n")}`; } const lines = [`✅ Task ${result.taskId} in ${result.phaseId} updated: ${result.previousStatus} → ${result.newStatus}`]; if (result.startedAt) lines.push(` Started at: ${result.startedAt}`); if (result.finishAt) lines.push(` Finished at: ${result.finishAt}`); return lines.join("\n"); } function formatMissionCompleteResult(result: Awaited>): string { if (!result.success) { return `❌ Failed to complete mission: ${result.message}\n${result.errors.map(e => ` - ${e}`).join("\n")}`; } return `✅ Mission ${result.runId} marked as complete\n Previous status: ${result.previousStatus}\n Finished at: ${result.finishAt}`; } function formatMissionResumeResult(result: Awaited>): string { if (!result.success) { return `❌ Failed to resume mission: ${result.message}\n${result.errors.map(e => ` - ${e}`).join("\n")}`; } return `✅ Mission resumed: ${result.runId}\n Run status: ${result.runStatus}`; } function resolveBundledSkillsDir(): string | null { const projectSkillFile = path.join(getProjectRoot(), ".pi", "skills", "mission-orchestrator", "SKILL.md"); if (existsSync(projectSkillFile)) { return null; } const extensionDir = path.dirname(fileURLToPath(import.meta.url)); return path.join(extensionDir, "..", "skills"); } function queueOnActiveRun(operation: () => T): Promise { const activeRunId = readState().active_run_id; const runFilePath = activeRunId ? getRunFilePath(activeRunId) : getStateFilePath(); return withFileMutationQueue(runFilePath, async () => operation()); } function queueOnRunAndState(runId: string, operation: () => T): Promise { const runFilePath = getRunFilePath(runId); const stateFilePath = getStateFilePath(); return withFileMutationQueue(runFilePath, async () => withFileMutationQueue(stateFilePath, async () => operation()), ); } // ============================================================================ // Agent Model Configuration // ============================================================================ const AGENT_FILES = { worker: ".pi/agents/worker.md", auditor: ".pi/agents/auditor.md", }; // Placeholder/default model values that indicate unconfigured state const DEFAULT_MODEL_VALUES = ["light", "default", "placeholder", ""]; interface AgentFrontmatter { model?: string; [key: string]: unknown; } /** * Parse YAML frontmatter from markdown content. * Returns parsed frontmatter and the content after frontmatter. */ function parseFrontmatter(content: string): { frontmatter: AgentFrontmatter | null; body: string } { const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/); if (!match) { return { frontmatter: null, body: content }; } const yamlText = match[1]; const body = match[2]; const frontmatter: AgentFrontmatter = {}; // Simple YAML parsing for key: value pairs for (const line of yamlText.split("\n")) { const colonIdx = line.indexOf(":"); if (colonIdx > 0) { const key = line.slice(0, colonIdx).trim(); const value = line.slice(colonIdx + 1).trim(); // Remove quotes if present frontmatter[key] = value.replace(/^["']|["']$/g, ""); } } return { frontmatter, body }; } /** * Serialize frontmatter and body back to markdown. */ function serializeFrontmatter(frontmatter: AgentFrontmatter, body: string): string { const lines = ["---"]; for (const [key, value] of Object.entries(frontmatter)) { if (value === undefined) continue; // Quote string values that contain special characters const strValue = String(value); const needsQuotes = strValue.includes(":") || strValue.includes("#") || strValue.startsWith(" ") || strValue === ""; lines.push(`${key}: ${needsQuotes ? `"${strValue}"` : strValue}`); } lines.push("---"); lines.push(""); lines.push(body); return lines.join("\n"); } /** * Check if agent file has unconfigured/placeholder model. */ function hasUnconfiguredModel(agentPath: string): boolean { const content = readText(agentPath); if (!content) return true; // File doesn't exist = unconfigured const { frontmatter } = parseFrontmatter(content); if (!frontmatter) return true; // No frontmatter = unconfigured const model = frontmatter.model?.toLowerCase().trim() ?? ""; return DEFAULT_MODEL_VALUES.includes(model); } /** * Update agent file with selected model. */ function updateAgentModel(agentPath: string, model: string): boolean { const content = readText(agentPath); if (!content) return false; const { frontmatter, body } = parseFrontmatter(content); if (!frontmatter) return false; frontmatter.model = model; writeText(agentPath, serializeFrontmatter(frontmatter, body)); return true; } /** * Get sorted list of available models for selection. * Prefers cheaper models and current model (if available). * Falls back to authenticated models if scoped-model access unavailable. */ function getSortedAvailableModels( availableModels: Model[], currentModel: Model | undefined, ): Model[] { // Sort by cost (input + output), then by whether it's the current model return [...availableModels].sort((a, b) => { // Current model gets highest priority if (currentModel) { if (a.id === currentModel.id && a.provider === currentModel.provider) return -1; if (b.id === currentModel.id && b.provider === currentModel.provider) return 1; } // Then sort by total cost (cheaper first) const costA = a.cost.input + a.cost.output; const costB = b.cost.input + b.cost.output; return costA - costB; }); } function formatContextWindow(tokens: number): string { if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M ctx`; if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}k ctx`; return `${tokens} ctx`; } function getModelCapabilityTags(model: Model): string[] { const tags: string[] = []; const totalCost = model.cost.input + model.cost.output; if (totalCost === 0) tags.push("free"); else if (totalCost <= 1) tags.push("cheap"); else if (totalCost >= 10) tags.push("premium"); if (model.reasoning) tags.push("reasoning"); if (model.input.includes("image")) tags.push("vision"); if (model.contextWindow >= 500_000) tags.push("long-context"); else if (model.contextWindow >= 100_000) tags.push("128k+"); return tags; } function formatModelOption(model: Model): string { const totalCost = model.cost.input + model.cost.output; const cost = totalCost > 0 ? `$${totalCost.toFixed(2)}/1M` : "free"; const displayName = model.name && model.name !== model.id ? model.name : `${model.provider}/${model.id}`; const tags = getModelCapabilityTags(model); const suffix = [formatContextWindow(model.contextWindow), cost, tags.join(", ")].filter(Boolean).join(" • "); return `${displayName} — ${model.provider}/${model.id} (${suffix})`; } /** * Prompt user to configure agent models after scaffold. * Uses available authenticated models; notes if scoped-model access unavailable. */ async function promptAgentModelSetup( ctx: ExtensionCommandContext, extensionApi: ExtensionAPI, ): Promise { const projectRoot = getProjectRoot(); const workerPath = path.join(projectRoot, AGENT_FILES.worker); const auditorPath = path.join(projectRoot, AGENT_FILES.auditor); // Check if either agent needs configuration const workerNeedsConfig = hasUnconfiguredModel(workerPath); const auditorNeedsConfig = hasUnconfiguredModel(auditorPath); if (!workerNeedsConfig && !auditorNeedsConfig) { return; // Both already configured } // Warn if required tools are missing const hasAskUser = extensionApi.getAllTools().some((tool) => tool.name === "ask_user"); const hasSubagent = extensionApi.getAllTools().some((tool) => tool.name === "subagent"); if (!hasAskUser) { ctx.ui.notify("Mission Control: ask_user tool not available. Structured interviews and approvals will be less guided.", "warning"); } if (!hasSubagent) { ctx.ui.notify("Mission Control: subagent tool not available. Worker/Auditor delegation will not run until pi-subagents is loaded.", "warning"); } // Get available models from registry // Note: getAvailable() returns authenticated models. If scoped-model access // is unavailable, this still provides usable models but without scope filtering. const availableModels = ctx.modelRegistry.getAvailable(); if (availableModels.length === 0) { ctx.ui.notify("Mission Control: No authenticated models available. Please configure API keys first.", "warning"); return; } // Sort models (prefer cheaper + current model) const sortedModels = getSortedAvailableModels(availableModels, ctx.model); const modelOptions = sortedModels.map(formatModelOption); const selection = await ctx.ui.select( "Choose a shared model for Worker + Auditor (sorted cheapest first)", modelOptions, { timeout: 60000 }, ); if (!selection) { ctx.ui.notify("Mission Control: Agent model setup cancelled. Using defaults.", "info"); return; } // Extract model ID from selection const selectedModel = sortedModels[modelOptions.indexOf(selection)]; if (!selectedModel) { ctx.ui.notify("Mission Control: Invalid model selection.", "error"); return; } // Format as provider/modelId for the frontmatter const modelValue = `${selectedModel.provider}/${selectedModel.id}`; // Update both agent files let updatedCount = 0; if (workerNeedsConfig) { if (updateAgentModel(workerPath, modelValue)) { updatedCount++; } else { ctx.ui.notify(`Mission Control: Failed to update ${AGENT_FILES.worker}`, "error"); } } if (auditorNeedsConfig) { if (updateAgentModel(auditorPath, modelValue)) { updatedCount++; } else { ctx.ui.notify(`Mission Control: Failed to update ${AGENT_FILES.auditor}`, "error"); } } if (updatedCount > 0) { ctx.ui.notify( `Mission Control: Set agent models to ${selectedModel.name} (${modelValue})`, "info", ); } } // ============================================================================ // Extension Factory // ============================================================================ export default function missionControlExtension(pi: ExtensionAPI) { // ======================================================================== // Register /mission command // ======================================================================== pi.registerCommand("mission", { description: "Open Mission Control dashboard for visual mission orchestration", handler: async (args: string, ctx: ExtensionCommandContext) => { if (!isScaffolded()) { const result = missionScaffold(); if (!result.success) { ctx.ui.notify(`Scaffolding failed: ${result.message}`, "error"); return; } ctx.ui.notify("Mission Control scaffolding complete", "info"); } if (ctx.hasUI) { await promptAgentModelSetup(ctx, pi); } const trimmedArgs = args.trim().toLowerCase(); let dashboardResult: string | null = null; if (trimmedArgs === "init") { dashboardResult = "init"; } else if (!ctx.hasUI) { ctx.ui.notify("/mission init works without interactive UI", "info"); return; } else { dashboardResult = await ctx.ui.custom( (tui, _theme, _keybindings, done) => { const dashboard = new MissionDashboard({ onClose: () => { dashboard.dispose(); done(null); }, onInitMission: () => { dashboard.dispose(); done("init"); }, }); dashboard.startRefresh(tui); return { render: (width: number) => dashboard.render(width, ctx.ui.theme), handleInput: (data: string) => dashboard.handleInput(data), invalidate: () => dashboard.invalidate(), dispose: () => dashboard.dispose(), }; }, { overlay: true }, ); } if (dashboardResult !== "init") { return; } const initResult = missionInit({ phase: "research", statusMessage: "Mission initialized - waiting for requirements", }); if (!initResult.success || !initResult.runId) { ctx.ui.notify(`Failed to initialize mission: ${initResult.message}`, "error"); return; } ctx.ui.notify(`Mission initialized: ${initResult.runId}`, "info"); pi.sendUserMessage(`/skill:mission-orchestrator New mission initialized with run ID ${initResult.runId}. Start the requirements interview now.`); }, }); // ======================================================================== // Register Agent Tools // ======================================================================== // add_phase tool pi.registerTool({ name: "add_phase", label: "Add Mission Phase", description: "Add a new phase to the current mission run. Auto-generates phase_id (phase1, phase2, ...). " + "Sets status to 'pending'. Use this to structure the mission into logical phases.", promptSnippet: "add_phase(name, file) → adds a mission phase", parameters: AddPhaseSchema as never, execute: async (_toolCallId, params: Parameters[0]) => { const result = await queueOnActiveRun(() => addPhase(params)); return { isError: !result.success, content: [{ type: "text" as const, text: formatAddPhaseResult(result) }], details: result, }; }, }); // add_task tool pi.registerTool({ name: "add_task", label: "Add Mission Task", description: "Add a new task to a phase. Auto-generates task_id from phase prefix (phase1-task1, phase1-task2, ...). " + "Sets status to 'pending'. Use this to break down phases into executable tasks.", promptSnippet: "add_task(phase_id, name, file) → adds a task to a phase", parameters: AddTaskSchema as never, execute: async (_toolCallId, params: Parameters[0]) => { const result = await queueOnActiveRun(() => addTask(params)); return { isError: !result.success, content: [{ type: "text" as const, text: formatAddTaskResult(result) }], details: result, }; }, }); // update_phase tool pi.registerTool({ name: "update_phase", label: "Update Phase Status", description: "Update a phase's status. Auto-sets started_at when transitioning to 'in_progress'. " + "Auto-sets finish_at when transitioning to 'done' or 'removed'. " + "Valid statuses: pending, in_progress, done, removed.", promptSnippet: "update_phase(phase_id, status) → updates phase status", parameters: UpdatePhaseSchema as never, execute: async (_toolCallId, params: Parameters[0]) => { const result = await queueOnActiveRun(() => updatePhase(params)); return { isError: !result.success, content: [{ type: "text" as const, text: formatUpdatePhaseResult(result) }], details: result, }; }, }); // update_task tool pi.registerTool({ name: "update_task", label: "Update Task Status", description: "Update a task's status. Auto-sets started_at when transitioning to 'in_progress'. " + "Auto-sets finish_at when transitioning to 'done', 'failed', or 'removed'. " + "Valid statuses: pending, in_progress, done, failed, removed.", promptSnippet: "update_task(task_id, status) → updates task status", parameters: UpdateTaskSchema as never, execute: async (_toolCallId, params: Parameters[0]) => { const result = await queueOnActiveRun(() => updateTask(params)); return { isError: !result.success, content: [{ type: "text" as const, text: formatUpdateTaskResult(result) }], details: result, }; }, }); // mission_complete tool pi.registerTool({ name: "mission_complete", label: "Complete Mission", description: "Mark a mission run as complete. Sets run.json status to 'done' and finish_at timestamp. " + "Clears active_run_id from state.json. Use when all phases are finished successfully.", promptSnippet: "mission_complete(run_id) → marks mission as complete", parameters: MissionCompleteSchema as never, execute: async (_toolCallId, params: Parameters[0]) => { const result = await queueOnRunAndState(params.run_id, () => missionComplete(params)); return { isError: !result.success, content: [{ type: "text" as const, text: formatMissionCompleteResult(result) }], details: result, }; }, }); // mission_resume tool pi.registerTool({ name: "mission_resume", label: "Resume Mission", description: "Resume a previously created mission run. Sets state.json active_run_id to the specified run_id. " + "Use when the user wants to continue work on an existing mission.", promptSnippet: "mission_resume(run_id, phase?, statusMessage?) → resumes a mission", parameters: MissionResumeSchema as never, execute: async (_toolCallId, params: Parameters[0]) => { const result = await queueOnRunAndState(params.run_id, () => missionResume(params)); return { isError: !result.success, content: [{ type: "text" as const, text: formatMissionResumeResult(result) }], details: result, }; }, }); // ======================================================================== // Register resources_discover for bundled skills // ======================================================================== pi.on("resources_discover", (_event: ResourcesDiscoverEvent): ResourcesDiscoverResult => { const bundledSkillsDir = resolveBundledSkillsDir(); return bundledSkillsDir ? { skillPaths: [bundledSkillsDir] } : {}; }); // ======================================================================== // Handle session_start/reset for state reset // ======================================================================== pi.on("session_start", (event: SessionStartEvent, ctx: ExtensionContext) => { if (event.reason !== "reload" && isScaffolded()) { const currentState = readState(); writeState({ ...defaultState }); if (currentState.active_run_id) { ctx.ui.notify( `Previous mission run ${currentState.active_run_id} is inactive in this pi session. Use mission_resume to continue it.`, "info", ); } } const hasSubagent = pi.getAllTools().some((tool) => tool.name === "subagent"); if (!hasSubagent) { ctx.ui.notify( "Mission Control: subagent tool not available. Worker/Auditor delegation will not run until pi-subagents is loaded.", "warning", ); } const hasAskUser = pi.getAllTools().some((tool) => tool.name === "ask_user"); if (!hasAskUser) { ctx.ui.notify( "Mission Control: ask_user tool not available. Install pi-ask-user for better interviews and approval prompts.", "warning", ); } }); }