// ABOUTME: Bridge extension that exposes Commander MCP tools as native Pi tools. // ABOUTME: Spawns commander-mcp as a subprocess and proxies JSON-RPC calls over stdio. import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { McpClient } from "./lib/mcp-client.ts"; import { createReadyGate, resolveGate, resetGate } from "./lib/commander-ready.ts"; // ── Configuration ─────────────────────────────────────────────────── const SERVER_PATH = "/Users/ricardo/Workshop/Github-Work/commander/services/commander-mcp/dist/server.js"; const SERVER_ENV: Record = { COMMANDER_WS_URL: process.env.COMMANDER_WS_URL || "ws://localhost:9002", JIRA_URL: process.env.JIRA_URL || "", JIRA_EMAIL: process.env.JIRA_EMAIL || "", JIRA_API_TOKEN: process.env.JIRA_API_TOKEN || "", AGENTMAIL_API_KEY: process.env.AGENTMAIL_API_KEY || "", }; // ── Tool definitions ──────────────────────────────────────────────── const TOOLS: { name: string; label: string; description: string }[] = [ { name: "commander_task", label: "Commander Task", description: `Unified task management - create, track, and execute work items. OPERATIONS BY CATEGORY: TASK CRUD: - "create": Start new task (requires description, working_directory) - "get": Get task details by task_id - "update": Modify task fields - "list": Find tasks with filters (status, agent_id, working_directory) LIFECYCLE (state transitions): - "claim": Start working on pending task (validates working_directory match) - "complete": Mark success with result summary - "fail": Mark failure with error_message GROUPS (batch operations): - "group:create": Create task group with multiple tasks (requires group_name, tasks[], initiative_summary, total_waves) - "group:get": Get group details and progress percentage - "group:list": List all groups (no group_id) or tasks in a group (with group_id) - "group:update": Update wave progress and overall_status COMMENTS & LOGS: - "comment:add": Add progress/error/handoff comment (ALWAYS include agent_name!) - "comment:list": View task comments - "log": Add real-time dashboard log entry POLICY: - "policy:update": Modify task execution policy (Warden-compatible) TASK WORKFLOW: 1. Create task → status='pending' 2. Claim task → status='working', validates working_directory 3. Complete/Fail → status='completed' or 'failed' EXAMPLE - Create and claim a task: { "operation": "create", "description": "Fix auth bug in login.ts", "working_directory": "/project/src" } { "operation": "claim", "task_id": 123, "agent_name": "claude" } EXAMPLE - Create task group: { "operation": "group:create", "group_name": "Auth Refactor", "initiative_summary": "Migrate JWT to OAuth", "total_waves": 3, "working_directory": "/project", "tasks": [{"description": "...", "task_prompt": "...", "dependency_order": 0, "context": "..."}] }`, }, { name: "commander_session", label: "Commander Session", description: `Unified session and terminal management - track agents and UI state. OPERATIONS BY CATEGORY: SESSION MANAGEMENT: - "create": Start new session (requires name) - "get": Get session by session_id - "list": List sessions (filter by working_directory, status) TERMINAL OPERATIONS: - "terminals:list": List active terminal processes (filter by cli_type, status) - "pipe": Send text to terminal (requires terminal_session_id, data) CLEANUP (housekeeping): - "cleanup:status": Check stale session counts and get recommendation - "cleanup:stale": Remove sessions older than min_age_hours (default 24h) - "cleanup:terminate": End specific session by session_id - "cleanup:self": Clean up calling agent's own session FILE VIEWER (Commander UI): - "file:open": Display file in floating window (requires file_path, supports line_range) - "file:close": Close viewer by viewer_id BEST PRACTICES FOR AGENTS: 1. At start of work: Call cleanup:status to check session health 2. If >10 stale sessions: Call cleanup:stale to clean up 3. When work is DONE: Call cleanup:self to clean up your own session EXAMPLE - Cleanup workflow: { "operation": "cleanup:status" } { "operation": "cleanup:stale", "min_age_hours": 24 }`, }, { name: "commander_workflow", label: "Commander Workflow", description: `Access development workflow documentation, templates, and standards. WORKFLOWS: "kiro", "contextos" OPERATIONS: - "doc:get": Retrieve instruction document (requires workflow, doc_type) - "doc:list": List available doc types for a workflow - "doc:search": Search instructions by query (requires query) - "template:get": Get template content (requires workflow, template_type) - "template:list": List available templates for a workflow - "steering:get": Get steering document (requires steering_type) - Kiro only - "steering:list": List available steering documents - Kiro only EXAMPLE - Get Kiro guidelines: { "operation": "doc:get", "workflow": "kiro", "doc_type": "guidelines" }`, }, { name: "commander_spec", label: "Commander Spec", description: `Manage development specs - structured feature specifications for spec-driven development. OPERATIONS: - "create": Start new spec (requires name, description, project_id) - "get": Get spec details by spec_id - "list": List all specs - "update": Modify spec status - "shape": Initiate AI shaping (requires spec_id, feature_idea) - "write": Generate requirements from shaped content (requires spec_id, shaped_content) - "create_tasks": Convert spec to executable tasks (requires spec_id, selected_tasks[]) SPEC WORKFLOW: 1. CREATE → 2. SHAPE → 3. WRITE → 4. CREATE_TASKS EXAMPLE - Start shaping a feature: { "operation": "shape", "spec_id": 1, "feature_idea": "Add OAuth login with Google and GitHub providers" }`, }, { name: "commander_jira", label: "Commander Jira", description: `Interact with Jira issues - get details, update status, add comments, and link PRs. OPERATIONS BY CATEGORY: ISSUE OPERATIONS: - "issue:get": Get issue details (requires issue_key) - "issue:update": Update issue fields (requires issue_key, plus fields to update) - "issue:search": Search using JQL (requires jql) TRANSITION OPERATIONS: - "transition:list": List available transitions for issue (requires issue_key) - "transition:execute": Change issue status (requires issue_key + transition_id OR transition_name) COMMENT OPERATIONS: - "comment:add": Add comment to issue (requires issue_key, body) - "comment:list": List issue comments (requires issue_key) LINK OPERATIONS: - "link:pr": Link PR to issue via formatted comment (requires issue_key, pr_url) STATUS OPERATIONS: - "status:check": Check Jira connection status EXAMPLE - Start working on issue: { "operation": "issue:get", "issue_key": "PROJ-123" } { "operation": "transition:execute", "issue_key": "PROJ-123", "transition_name": "In Progress" }`, }, { name: "commander_mailbox", label: "Commander Mailbox", description: `Inter-agent messaging and status broadcasting for Commander dashboard visibility. IMPORTANT: ALL agents MUST send status updates at key milestones. OPERATIONS: - "send": Send a message (requires from_agent, to_agent, body) - "inbox": Get inbox messages (requires agent_name) - "outbox": Get sent messages (requires agent_name) - "read": Mark message read (requires message_id) - "read_all": Mark all read (requires agent_name) - "thread": Get thread messages (requires thread_id) - "unread_count": Get unread count (requires agent_name) - "delete": Delete a message (requires message_id) MESSAGE TYPES: status, question, result, error, dispatch, escalation, health_check, worker_done, merge_ready PRIORITY: low, normal, high, urgent BROADCAST GROUPS: @all, @builders, @scouts, @reviewers, @leads, @coordinators EXAMPLE - Status update: { "operation": "send", "from_agent": "agent-task-42", "to_agent": "commander", "body": "Starting implementation", "message_type": "status", "task_id": 42 }`, }, { name: "commander_orchestration", label: "Commander Orchestration", description: `Agent registry and orchestration for hierarchical multi-agent coordination. OPERATIONS: - "agent:register": Register a new agent (requires name, agent_type) - "agent:deregister": Remove an agent (requires agent_id) - "agent:list": List registered agents (optional: active_only) - "agent:heartbeat": Record agent heartbeat (requires agent_id) - "agent:hierarchy": Get agent hierarchy tree (optional: parent_agent_id) - "agent:get_by_name": Find agent by name (requires name) - "agent:find_capable": Find agents with a capability (requires capability) - "agent:state": Update agent state (requires agent_id, state) - "dispatch": Assign a task to an agent (requires agent_id, task_id) - "health:check": Check for stale/zombie agents (optional: threshold_secs) HIERARCHY RULES: - Coordinator (depth 0) → Leads (depth 1) → Workers (depth 2) - Max 25 concurrent agents AGENT STATES: idle, spawning, running, working, stuck, done, stopped, dead EXAMPLE: { "operation": "agent:register", "name": "worker-1", "agent_type": "claude", "role": "worker" } { "operation": "dispatch", "agent_id": 1, "task_id": 42 }`, }, { name: "commander_dependency", label: "Commander Dependency", description: `Task dependency graph management for coordinating execution order. OPERATIONS: - "add": Create dependency between tasks (requires from_task_id, to_task_id) - "remove": Delete dependency by ID (requires dependency_id) - "remove_by_edge": Delete dependency by edge (requires from_task_id, to_task_id) - "get": Get all dependencies for a task (requires task_id) - "blockers": Get tasks that block this task (requires task_id) - "dependents": Get tasks that depend on this task (requires task_id) - "ready_tasks": Get tasks ready to work on (no open blocking deps) - "blocked_tasks": Get tasks currently blocked - "graph": Get full dependency graph (optional: group_id) - "rebuild_cache": Rebuild transitive blocking cache - "cached_blockers": Get cached blockers for a task (requires task_id) DEPENDENCY TYPES: blocks, parent_child, waits_for, related, conditional_blocks EXAMPLE - Create blocking dependency: { "operation": "add", "from_task_id": 1, "to_task_id": 2, "dependency_type": "blocks" } EXAMPLE - Find ready work: { "operation": "ready_tasks" }`, }, { name: "commander_agentmail", label: "Commander AgentMail", description: `Send emails via AgentMail — email reports, briefings, and custom messages. Sends emails from the "Commander Assistant" inbox via AgentMail API. Default recipient: ruizrica2@gmail.com OPERATIONS: - "send:report": Email a generated report (requires content, optional report_name) - "send:briefing": Email a morning briefing (requires content) - "send:custom": Send a custom email (requires subject + content) - "status:check": Check AgentMail connection and inbox status Content supports markdown (auto-converted to styled HTML), raw HTML, or plain text. EXAMPLE - Send a report: { "operation": "send:report", "report_name": "Weekly Code Review", "content": "# Report\\n..." } EXAMPLE - Send custom email: { "operation": "send:custom", "subject": "Task Update", "content": "The refactor is complete..." } EXAMPLE - Check status: { "operation": "status:check" }`, }, ]; // ── Per-tool schemas ──────────────────────────────────────────────── // Each tool gets a schema that explicitly defines its parameters so that // the model knows what to send. additionalProperties remains true for // forward-compatibility with new fields. const TaskParams = Type.Object({ operation: Type.String({ description: "Operation to perform" }), // CRUD description: Type.Optional(Type.String({ description: "Task description (for create)" })), working_directory: Type.Optional(Type.String({ description: "Working directory path (for create, list)" })), task_id: Type.Optional(Type.Number({ description: "Task ID (for get, update, claim, complete, fail)" })), status: Type.Optional(Type.String({ description: "Task status: pending, working, completed, failed, cancelled (for update, list)" })), agent_id: Type.Optional(Type.Number({ description: "Agent ID (for list filter)" })), // Lifecycle agent_name: Type.Optional(Type.String({ description: "Agent name (for claim)" })), result: Type.Optional(Type.String({ description: "Result summary (for complete)" })), error_message: Type.Optional(Type.String({ description: "Error message (for fail)" })), // Groups group_name: Type.Optional(Type.String({ description: "Group name (for group:create)" })), group_id: Type.Optional(Type.Number({ description: "Group ID (for group:get, group:list, group:update)" })), initiative_summary: Type.Optional(Type.String({ description: "Initiative summary (for group:create)" })), total_waves: Type.Optional(Type.Number({ description: "Total waves (for group:create)" })), tasks: Type.Optional(Type.Array(Type.Object({ description: Type.String(), task_prompt: Type.Optional(Type.String()), dependency_order: Type.Optional(Type.Number()), context: Type.Optional(Type.String()), }), { description: "Array of task definitions (for group:create)" })), overall_status: Type.Optional(Type.String({ description: "Overall group status (for group:update)" })), // Comments & Logs body: Type.Optional(Type.String({ description: "Comment body (for comment:add)" })), message: Type.Optional(Type.String({ description: "Log message (for log)" })), // Policy policy: Type.Optional(Type.Object({}, { additionalProperties: true, description: "Policy object (for policy:update)" })), }, { additionalProperties: true }); const SessionParams = Type.Object({ operation: Type.String({ description: "Operation to perform" }), name: Type.Optional(Type.String({ description: "Session name (for create)" })), session_id: Type.Optional(Type.Number({ description: "Session ID (for get, cleanup:terminate)" })), working_directory: Type.Optional(Type.String({ description: "Working directory filter (for list)" })), status: Type.Optional(Type.String({ description: "Status filter (for list)" })), // Terminal terminal_session_id: Type.Optional(Type.Number({ description: "Terminal session ID (for pipe)" })), cli_type: Type.Optional(Type.String({ description: "CLI type filter (for terminals:list)" })), data: Type.Optional(Type.String({ description: "Text to send to terminal (for pipe)" })), // File viewer file_path: Type.Optional(Type.String({ description: "File path to open (for file:open)" })), line_range: Type.Optional(Type.String({ description: "Line range like '45-60' (for file:open)" })), viewer_id: Type.Optional(Type.Number({ description: "Viewer ID (for file:close)" })), // Cleanup min_age_hours: Type.Optional(Type.Number({ description: "Minimum age in hours for stale cleanup (for cleanup:stale)" })), }, { additionalProperties: true }); const WorkflowParams = Type.Object({ operation: Type.String({ description: "Operation to perform" }), workflow: Type.Optional(Type.String({ description: "Workflow name: kiro, contextos (for doc:get, doc:list, template:get, template:list)" })), doc_type: Type.Optional(Type.String({ description: "Document type (for doc:get)" })), query: Type.Optional(Type.String({ description: "Search query (for doc:search)" })), template_type: Type.Optional(Type.String({ description: "Template type (for template:get)" })), steering_type: Type.Optional(Type.String({ description: "Steering document type (for steering:get)" })), }, { additionalProperties: true }); const SpecParams = Type.Object({ operation: Type.String({ description: "Operation to perform" }), spec_id: Type.Optional(Type.Number({ description: "Spec ID (for get, update, shape, write, create_tasks)" })), name: Type.Optional(Type.String({ description: "Spec name (for create)" })), description: Type.Optional(Type.String({ description: "Spec description (for create)" })), project_id: Type.Optional(Type.Number({ description: "Project ID (for create)" })), feature_idea: Type.Optional(Type.String({ description: "Feature idea text (for shape)" })), shaped_content: Type.Optional(Type.String({ description: "Shaped content (for write)" })), selected_tasks: Type.Optional(Type.Array(Type.Unknown(), { description: "Selected tasks to create (for create_tasks)" })), }, { additionalProperties: true }); const JiraParams = Type.Object({ operation: Type.String({ description: "Operation to perform" }), issue_key: Type.Optional(Type.String({ description: "Jira issue key like PROJ-123 (for most operations)" })), jql: Type.Optional(Type.String({ description: "JQL query string (for issue:search)" })), body: Type.Optional(Type.String({ description: "Comment body (for comment:add)" })), pr_url: Type.Optional(Type.String({ description: "PR URL to link (for link:pr)" })), transition_id: Type.Optional(Type.Number({ description: "Transition ID (for transition:execute)" })), transition_name: Type.Optional(Type.String({ description: "Transition name (for transition:execute)" })), }, { additionalProperties: true }); const MailboxParams = Type.Object({ operation: Type.String({ description: "Operation to perform" }), from_agent: Type.Optional(Type.String({ description: "Sender agent name (for send)" })), to_agent: Type.Optional(Type.String({ description: "Recipient agent name or broadcast group (for send)" })), agent_name: Type.Optional(Type.String({ description: "Agent name (for inbox, outbox, read_all, unread_count)" })), body: Type.Optional(Type.String({ description: "Message body (for send)" })), message_type: Type.Optional(Type.String({ description: "Message type: status, question, result, error, dispatch, escalation (for send)" })), message_id: Type.Optional(Type.Number({ description: "Message ID (for read, delete)" })), thread_id: Type.Optional(Type.Number({ description: "Thread ID (for thread)" })), task_id: Type.Optional(Type.Number({ description: "Related task ID (for send)" })), priority: Type.Optional(Type.String({ description: "Priority: low, normal, high, urgent (for send)" })), }, { additionalProperties: true }); const OrchestrationParams = Type.Object({ operation: Type.String({ description: "Operation to perform" }), name: Type.Optional(Type.String({ description: "Agent name (for agent:register, agent:get_by_name)" })), agent_type: Type.Optional(Type.String({ description: "Agent type (for agent:register)" })), role: Type.Optional(Type.String({ description: "Agent role: coordinator, lead, worker (for agent:register)" })), agent_id: Type.Optional(Type.Number({ description: "Agent ID (for agent:deregister, agent:heartbeat, agent:state, dispatch)" })), agent_name: Type.Optional(Type.String({ description: "Agent name for heartbeat (for agent:heartbeat)" })), task_id: Type.Optional(Type.Number({ description: "Task ID (for dispatch)" })), state: Type.Optional(Type.String({ description: "Agent state: idle, running, working, stuck, done, stopped (for agent:state)" })), capability: Type.Optional(Type.String({ description: "Capability to search for (for agent:find_capable)" })), parent_agent_id: Type.Optional(Type.Number({ description: "Parent agent ID (for agent:hierarchy)" })), active_only: Type.Optional(Type.Boolean({ description: "Filter to active agents only (for agent:list)" })), threshold_secs: Type.Optional(Type.Number({ description: "Staleness threshold in seconds (for health:check)" })), }, { additionalProperties: true }); const DependencyParams = Type.Object({ operation: Type.String({ description: "Operation to perform" }), task_id: Type.Optional(Type.Number({ description: "Task ID (for get, blockers, dependents, cached_blockers)" })), from_task_id: Type.Optional(Type.Number({ description: "Source task ID (for add, remove_by_edge)" })), to_task_id: Type.Optional(Type.Number({ description: "Target task ID (for add, remove_by_edge)" })), dependency_id: Type.Optional(Type.Number({ description: "Dependency ID (for remove)" })), dependency_type: Type.Optional(Type.String({ description: "Dependency type: blocks, parent_child, waits_for, related (for add)" })), group_id: Type.Optional(Type.Number({ description: "Group ID (for graph)" })), }, { additionalProperties: true }); const AgentMailParams = Type.Object({ operation: Type.String({ description: "Operation to perform: send:report, send:briefing, send:custom, status:check" }), to: Type.Optional(Type.String({ description: "Recipient email address (default: ruizrica2@gmail.com)" })), subject: Type.Optional(Type.String({ description: "Email subject line (for send:custom, or override for send:report)" })), content: Type.Optional(Type.String({ description: "Email content — markdown, HTML, or plain text" })), report_name: Type.Optional(Type.String({ description: "Report name (for send:report — used in subject line)" })), format: Type.Optional(Type.String({ description: "Content format: markdown (default), html, text" })), }, { additionalProperties: true }); // Map tool names to their specific parameter schemas const TOOL_PARAMS: Record> = { commander_task: TaskParams, commander_session: SessionParams, commander_workflow: WorkflowParams, commander_spec: SpecParams, commander_jira: JiraParams, commander_mailbox: MailboxParams, commander_orchestration: OrchestrationParams, commander_dependency: DependencyParams, commander_agentmail: AgentMailParams, }; // ── Extension entry point ─────────────────────────────────────────── export default function (pi: ExtensionAPI) { const client = new McpClient(SERVER_PATH, SERVER_ENV); const g = globalThis as any; let healthCheckTimer: ReturnType | undefined; // ── Ready gate — queues ops until probe resolves ──────────────── const gate = createReadyGate(); g.__piCommanderGate = gate; g.__piCommanderOnReady = g.__piCommanderOnReady || []; // Helper: drain queued ops after gate resolves to available function drainGateQueue(ops: { fn: (client: any) => Promise; label: string }[]): void { for (const op of ops) { op.fn(client).catch(() => {}); } } // Helper: drain onReady callbacks registered by other extensions function drainOnReadyCallbacks(): void { const cbs: Array<() => void> = g.__piCommanderOnReady || []; g.__piCommanderOnReady = []; for (const cb of cbs) { try { cb(); } catch {} } } // Helper: ensure connected before calling async function ensureConnected(): Promise { if (!client.isConnected()) { await client.connect(); } } // Register all 8 tools for (const tool of TOOLS) { pi.registerTool({ name: tool.name, label: tool.label, description: tool.description, parameters: TOOL_PARAMS[tool.name] || TaskParams, async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { try { await ensureConnected(); const isLightweight = tool.name === "commander_mailbox"; const timeoutMs = isLightweight ? 15000 : undefined; const result = await client.callTool(tool.name, params as Record, timeoutMs); return result; } catch (err: any) { return { content: [{ type: "text" as const, text: `Commander error: ${err.message}` }], }; } }, }); } // Lifecycle events pi.on("session_start", async (_event, ctx) => { // Fire-and-forget probe — don't block session_start chain // (other extensions like footer.ts must not wait for this) probeCommander(ctx).catch(() => {}); }); async function probeCommander(ctx: any) { try { await client.connect(); // Lightweight probe — 3s timeout await client.callTool("commander_session", { operation: "list" }, 3000); g.__piCommanderAvailable = true; g.__piCommanderClient = client; ctx.ui.setStatus("Commander: connected", "commander"); // Resolve gate — drain any ops queued while we were probing const queued = resolveGate(gate, true); drainGateQueue(queued); drainOnReadyCallbacks(); // Periodic health check (60s) healthCheckTimer = setInterval(async () => { try { if (!client.isConnected()) { await client.connect(); } await client.callTool("commander_session", { operation: "list" }, 3000); if (!g.__piCommanderAvailable) { g.__piCommanderAvailable = true; g.__piCommanderClient = client; ctx.ui.setStatus("Commander: connected", "commander"); // Recovery — resolve gate if it was reset during offline if (gate.state !== "available") { const queued = resolveGate(gate, true); drainGateQueue(queued); drainOnReadyCallbacks(); } } } catch { g.__piCommanderAvailable = false; ctx.ui.setStatus("Commander: offline", "commander"); // Reset gate so ops queue again until recovery if (gate.state === "available") { resetGate(gate); } } }, 60_000); } catch { g.__piCommanderAvailable = false; ctx.ui.setStatus("Commander: offline", "commander"); resolveGate(gate, false); } } pi.on("session_shutdown", async () => { if (healthCheckTimer) { clearInterval(healthCheckTimer); healthCheckTimer = undefined; } g.__piCommanderAvailable = false; resetGate(gate); client.disconnect(); }); }