From f6d909dd021eaaeb4b6acc4dee96f6508d44fdd2 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 30 May 2026 23:45:16 -0500 Subject: [PATCH 1/3] feat(harness): register coven-code as a first-class agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds OpenCoven's coven-code (the Claurst-based coding TUI) to comux's AGENT_REGISTRY so it appears in the pane-creation agent picker, the auto-detect / install scan, and ritual configs alongside claude, codex, opencode, etc. Registry entry: - shortLabel: cv (unique among the 11 existing two-letter codes) - promptTransport: positional (coven-code accepts the seed prompt as its trailing arg, same as claude/codex) - permissionFlags: maps comux's plan/acceptEdits/bypassPermissions to coven-code's --permission-mode plan/accept-edits/bypass-permissions - resumeCommandTemplate: `coven-code --resume{permissions}` for comux's "reopen worktree" continuation flow No other code changes needed — every comux site that enumerates agents iterates AGENT_IDS / AGENT_REGISTRY so coven-code now flows through pane creation, conflict-resolution agents, rituals, the worktree-reopen preferred order, and the settings filter automatically. Step 1 of the comux ↔ coven-code integration. Next: MCP bridge so familiars running inside coven-code can spawn comux panes, and the Tauri desktop merge (comux as the host shell). --- src/utils/agentLaunch.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/utils/agentLaunch.ts b/src/utils/agentLaunch.ts index 72c934f..dc6158f 100644 --- a/src/utils/agentLaunch.ts +++ b/src/utils/agentLaunch.ts @@ -20,6 +20,7 @@ export const AGENT_IDS = [ 'cursor', 'copilot', 'crush', + 'coven-code', ] as const; export type AgentName = typeof AGENT_IDS[number]; @@ -312,6 +313,30 @@ export const AGENT_REGISTRY: Readonly> = { }, defaultEnabled: false, }, + 'coven-code': { + id: 'coven-code', + name: 'Coven Code', + shortLabel: 'cv', + description: 'OpenCoven coding harness — Claurst-based TUI with familiar personas', + slugSuffix: 'coven-code', + installTestCommand: 'command -v coven-code 2>/dev/null || which coven-code 2>/dev/null', + commonPaths: [ + ...homePath('.local/bin/coven-code'), + '/opt/homebrew/bin/coven-code', + '/usr/local/bin/coven-code', + ...homePath('bin/coven-code'), + ...homePath('.npm-global/bin/coven-code'), + ], + promptCommand: 'coven-code', + promptTransport: 'positional', + permissionFlags: { + plan: '--permission-mode plan', + acceptEdits: '--permission-mode accept-edits', + bypassPermissions: '--permission-mode bypass-permissions', + }, + defaultEnabled: true, + resumeCommandTemplate: 'coven-code --resume{permissions}', + }, }; for (const agentId of AGENT_IDS) { From eb2932841471a5bc4d1f0855c2c818e0573e5d58 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 30 May 2026 23:53:39 -0500 Subject: [PATCH 2/3] feat(mcp): expose comux's pane surface to MCP-capable clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `comux mcp` subcommand that boots a stdio JSON-RPC 2.0 MCP server, so any MCP client (coven-code, Claude Code, OpenCode, etc.) can let its familiar fan work into parallel comux panes mid- conversation without leaving its session. Wire-up on the client (e.g. ~/.coven-code/settings.json): { "mcp_servers": [ { "name": "comux", "command": "comux", "args": ["mcp"], "type": "stdio" } ] } Tool surface, this commit: - `comux_list_panes` — live. Reads `/.comux/comux.config.json` via the existing `daemon/panes.ts:listPanes` so the MCP path and the Ink TUI path share state. - `comux_create_pane` — shape-only stub. Schema is final; behaviour lands in the next commit (wires into paneCreation + TmuxService). - `comux_kill_pane` — shape-only stub. Implementation notes: - Hand-rolled JSON-RPC 2.0 (no `@modelcontextprotocol/sdk` dep yet) — the surface is small enough that adding a runtime dep this early isn't worth it. Easy to swap if the tool list grows. - stderr is reserved for logs; stdout carries protocol frames only. - Server stays alive until stdin EOF — clients signal end-of-session by closing their write end. Smoke-tested locally: (echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'; \ echo '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'; \ echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"comux_list_panes","arguments":{}}}') \ | comux mcp returns initialize + tools/list + an empty `panes: []` for a project without comux state, exactly as expected. Step 2 of the comux ↔ coven-code integration. Next commits in this PR series: wire create/kill, add list_rituals/run_ritual, list_worktrees, send_to_pane, get_pane_output. --- src/index.ts | 9 ++ src/mcp/server.ts | 276 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 src/mcp/server.ts diff --git a/src/index.ts b/src/index.ts index 233fda6..3136054 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1590,6 +1590,15 @@ class Comux { return; } + if (process.argv[2] === 'mcp') { + // stdio MCP server — exposes comux's pane/ritual/worktree surface to + // MCP-capable clients (coven-code, Claude Code, OpenCode, etc.). + // Lives in src/mcp/server.ts; reuses src/daemon primitives for state. + const { runMcpServer } = await import('./mcp/server.js'); + await runMcpServer(); + return; + } + const remotePaneActionArg = getArgValue('--remote-pane-action'); if (remotePaneActionArg) { process.exit(await handleRemotePaneActionCli(remotePaneActionArg)); diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 0000000..c4d1f13 --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,276 @@ +/** + * comux MCP server (stdio JSON-RPC 2.0). + * + * Exposes comux's pane/ritual/worktree surface to MCP-capable clients + * (coven-code, Claude Code, OpenCode, etc.) so any familiar can fan work + * into parallel comux panes mid-conversation without leaving its session. + * + * Wire-up on the client side (e.g. ~/.coven-code/settings.json): + * + * { + * "mcp_servers": [ + * { "name": "comux", "command": "comux", "args": ["mcp"], "type": "stdio" } + * ] + * } + * + * Protocol: a minimal JSON-RPC 2.0 implementation of the MCP `initialize`, + * `tools/list`, and `tools/call` methods. We intentionally hand-roll instead + * of pulling in `@modelcontextprotocol/sdk` so this first ship has zero new + * runtime dependencies — easy to revisit if the surface grows. + * + * Reuses comux's existing pane primitives from `../daemon/panes.ts` so the + * MCP path and the Ink TUI path share state and don't fork. + */ + +import { createInterface } from 'node:readline'; +import { listPanes } from '../daemon/panes.js'; +import type { PaneSummary } from '../daemon/protocol.js'; + +const PROTOCOL_VERSION = '2025-06-18'; +const SERVER_NAME = 'comux'; +const SERVER_VERSION = '0.0.1'; + +// ---- JSON-RPC plumbing ---------------------------------------------------- + +type JsonRpcId = string | number | null; + +interface JsonRpcRequest { + jsonrpc: '2.0'; + id?: JsonRpcId; + method: string; + params?: unknown; +} + +interface JsonRpcSuccess { + jsonrpc: '2.0'; + id: JsonRpcId; + result: T; +} + +interface JsonRpcError { + jsonrpc: '2.0'; + id: JsonRpcId; + error: { code: number; message: string; data?: unknown }; +} + +type JsonRpcResponse = JsonRpcSuccess | JsonRpcError; + +const ERR_PARSE = -32700; +const ERR_INVALID_REQUEST = -32600; +const ERR_METHOD_NOT_FOUND = -32601; +const ERR_INVALID_PARAMS = -32602; +const ERR_INTERNAL = -32603; + +function writeResponse(res: JsonRpcResponse): void { + process.stdout.write(JSON.stringify(res) + '\n'); +} + +function ok(id: JsonRpcId, result: T): void { + writeResponse({ jsonrpc: '2.0', id, result }); +} + +function fail(id: JsonRpcId, code: number, message: string, data?: unknown): void { + writeResponse({ jsonrpc: '2.0', id, error: { code, message, data } }); +} + +// ---- Tool registry -------------------------------------------------------- + +interface ToolDef { + name: string; + description: string; + inputSchema: Record; + handler: (args: Record) => Promise; +} + +function resolveProjectRoot(args: Record): string { + const raw = args.project_root ?? args.projectRoot; + if (typeof raw === 'string' && raw.length > 0) return raw; + return process.env.COMUX_PROJECT_ROOT ?? process.cwd(); +} + +const TOOLS: ToolDef[] = [ + { + name: 'comux_list_panes', + description: + 'List all comux panes for the active project. Each entry includes the tmux pane id, working directory, branch, agent, and human-readable title.', + inputSchema: { + type: 'object', + properties: { + project_root: { + type: 'string', + description: + 'Absolute path to the project root whose panes to list. Defaults to $COMUX_PROJECT_ROOT then process.cwd() if omitted.', + }, + }, + }, + handler: async (args) => { + const projectRoot = resolveProjectRoot(args); + const panes: PaneSummary[] = await listPanes(projectRoot); + return { + project_root: projectRoot, + count: panes.length, + panes, + }; + }, + }, + { + name: 'comux_create_pane', + description: + '[STUB — wiring in progress] Create a new comux pane with the given prompt, agent, and optional worktree/branch. Returns the new pane id once the daemon-driven path is hooked up.', + inputSchema: { + type: 'object', + required: ['prompt', 'agent'], + properties: { + prompt: { type: 'string', description: 'Initial prompt to seed the harness with.' }, + agent: { + type: 'string', + description: + "Harness id (`claude`, `codex`, `opencode`, `coven-code`, `cline`, `gemini`, `qwen`, `amp`, `pi`, `cursor`, `copilot`, `crush`).", + }, + worktree: { + type: 'string', + description: 'Existing worktree path. If omitted, comux creates a new worktree from the project root.', + }, + branch: { + type: 'string', + description: 'Branch name for the new worktree. If omitted, comux derives one from the prompt slug.', + }, + project_root: { type: 'string' }, + }, + }, + handler: async (_args) => { + // TODO(step-2b): wire to comux's pane-creation flow + // (src/utils/paneCreation.ts → TmuxService.createPane + AgentLaunch). + // Tonight ships the shape; behaviour lands in the next commit. + throw new Error( + 'comux_create_pane is not yet wired — coming in the next MCP commit. Use the comux TUI for now.', + ); + }, + }, + { + name: 'comux_kill_pane', + description: + '[STUB — wiring in progress] Terminate the named comux pane and clean up its worktree.', + inputSchema: { + type: 'object', + required: ['pane_id'], + properties: { + pane_id: { type: 'string', description: 'tmux pane id (e.g. `%3`) returned by `comux_list_panes`.' }, + project_root: { type: 'string' }, + }, + }, + handler: async (_args) => { + throw new Error( + 'comux_kill_pane is not yet wired — coming in the next MCP commit. Use the comux TUI for now.', + ); + }, + }, +]; + +// ---- MCP method dispatch -------------------------------------------------- + +async function handleInitialize(_params: unknown): Promise { + return { + protocolVersion: PROTOCOL_VERSION, + serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, + capabilities: { + tools: { listChanged: false }, + }, + }; +} + +async function handleToolsList(_params: unknown): Promise { + return { + tools: TOOLS.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + })), + }; +} + +async function handleToolsCall(params: unknown): Promise { + const p = (params ?? {}) as { name?: string; arguments?: Record }; + if (!p.name || typeof p.name !== 'string') { + throw Object.assign(new Error('tools/call requires `name`'), { code: ERR_INVALID_PARAMS }); + } + const tool = TOOLS.find((t) => t.name === p.name); + if (!tool) { + throw Object.assign(new Error(`Unknown tool: ${p.name}`), { code: ERR_METHOD_NOT_FOUND }); + } + const result = await tool.handler(p.arguments ?? {}); + // MCP `tools/call` wraps the result in a content array of text/json blocks. + // We always emit a single JSON block — clients that prefer text can stringify. + return { + content: [ + { + type: 'text', + text: typeof result === 'string' ? result : JSON.stringify(result, null, 2), + }, + ], + }; +} + +async function dispatch(req: JsonRpcRequest): Promise { + const id = req.id ?? null; + try { + let result: unknown; + switch (req.method) { + case 'initialize': + result = await handleInitialize(req.params); + break; + case 'notifications/initialized': + // Notifications have no response. + return; + case 'tools/list': + result = await handleToolsList(req.params); + break; + case 'tools/call': + result = await handleToolsCall(req.params); + break; + case 'ping': + result = {}; + break; + default: + fail(id, ERR_METHOD_NOT_FOUND, `Method not found: ${req.method}`); + return; + } + ok(id, result); + } catch (err) { + const code = (err as { code?: number }).code ?? ERR_INTERNAL; + const message = err instanceof Error ? err.message : String(err); + fail(id, code, message); + } +} + +// ---- stdio loop ----------------------------------------------------------- + +export async function runMcpServer(): Promise { + // MCP frames are newline-delimited JSON-RPC objects on stdin/stdout. + // stderr is reserved for log output so it doesn't corrupt the protocol. + const rl = createInterface({ input: process.stdin, terminal: false }); + + rl.on('line', (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + + let req: JsonRpcRequest; + try { + req = JSON.parse(trimmed) as JsonRpcRequest; + } catch { + fail(null, ERR_PARSE, 'Parse error: stdin is not valid JSON'); + return; + } + if (req.jsonrpc !== '2.0' || typeof req.method !== 'string') { + fail(req.id ?? null, ERR_INVALID_REQUEST, 'Invalid JSON-RPC 2.0 request'); + return; + } + void dispatch(req); + }); + + // Stay alive until stdin closes — clients (coven-code, etc.) signal end-of- + // session by closing their write end. + await new Promise((resolve) => { + rl.on('close', () => resolve()); + }); +} From 0de75b3a5177b2d5e56f50062c59001819d20a72 Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Sat, 30 May 2026 23:56:00 -0500 Subject: [PATCH 3/3] =?UTF-8?q?feat(mcp):=20land=203=20more=20read-only=20?= =?UTF-8?q?tools=20=E2=80=94=20output=20/=20rituals=20/=20worktrees?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the comux MCP bridge from "shape-only" to actually-useful for any familiar that wants to inspect a comux project without leaving its chat. Live tools after this commit (4 of 6 from the planned surface): - `comux_list_panes` (already) - `comux_get_pane_output` — `tmux capture-pane -p -e -J -S - -t ` via the existing `daemon/panes.ts:capturePaneSync`. Returns ANSI- escaped bytes; optional `strip_ansi: true` flag for callers that just want the plain text. - `comux_list_rituals` — `getBuiltInRituals()` + `listProjectRituals(root)` from `utils/rituals.ts`. Returns built-ins + project-saved rituals with `scope: "builtin" | "project"`. - `comux_list_worktrees` — `git -C worktree list --porcelain` parsed to `[{path, head, branch, bare?, detached?, locked?}]`. Still stubs (need to coordinate with the comux daemon for tmux-session ownership, landing in the next commit): - `comux_create_pane` - `comux_kill_pane` Smoke-tested locally against the live comux repo: list_worktrees returns 4 real worktrees, list_rituals returns the 5 built-in rituals. --- src/mcp/server.ts | 112 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index c4d1f13..81bce18 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -22,9 +22,11 @@ * MCP path and the Ink TUI path share state and don't fork. */ +import { execFileSync } from 'node:child_process'; import { createInterface } from 'node:readline'; -import { listPanes } from '../daemon/panes.js'; +import { capturePaneSync, listPanes } from '../daemon/panes.js'; import type { PaneSummary } from '../daemon/protocol.js'; +import { getBuiltInRituals, listProjectRituals } from '../utils/rituals.js'; const PROTOCOL_VERSION = '2025-06-18'; const SERVER_NAME = 'comux'; @@ -165,6 +167,114 @@ const TOOLS: ToolDef[] = [ ); }, }, + { + name: 'comux_get_pane_output', + description: + "Capture the current visible buffer plus scrollback of a comux pane. Returns ANSI-escaped text — strip codes on the caller if you just want the plain content. Use this to read what a running agent has produced so far without attaching.", + inputSchema: { + type: 'object', + required: ['pane_id'], + properties: { + pane_id: { type: 'string', description: 'tmux pane id (e.g. `%3`) returned by `comux_list_panes`.' }, + strip_ansi: { + type: 'boolean', + description: 'When true, strip ANSI escape sequences before returning. Default false (preserves colour for terminal renderers).', + }, + }, + }, + handler: async (args) => { + const paneId = String(args.pane_id ?? '').trim(); + if (!paneId) { + throw Object.assign(new Error('comux_get_pane_output requires `pane_id`'), { code: ERR_INVALID_PARAMS }); + } + const buf = capturePaneSync(paneId); + let text = buf.toString('utf8'); + if (args.strip_ansi === true) { + // OSC, CSI, and standalone ESC sequences. Same surface as `strip-ansi` + // npm but avoids the runtime dep. + text = text.replace(/\x1B\][^\x07]*\x07/g, '').replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, ''); + } + return { pane_id: paneId, bytes: buf.length, content: text }; + }, + }, + { + name: 'comux_list_rituals', + description: + "List every ritual available to the active project — both comux built-ins (Start Coding, Terminal First, Review Stack, Release Check, Fix OpenClaw, …) and project-saved rituals from `/.comux/rituals/`. Each entry includes its id, name, scope (`builtin`|`project`), description, and pane spec.", + inputSchema: { + type: 'object', + properties: { + project_root: { type: 'string' }, + }, + }, + handler: async (args) => { + const projectRoot = resolveProjectRoot(args); + const builtin = getBuiltInRituals().map((r) => ({ ...r, scope: 'builtin' as const })); + const project = listProjectRituals(projectRoot).map((r) => ({ ...r, scope: 'project' as const })); + return { + project_root: projectRoot, + builtin, + project, + count: builtin.length + project.length, + }; + }, + }, + { + name: 'comux_list_worktrees', + description: + "List every git worktree associated with the active project's repository, including the path, branch, current HEAD sha, and whether it is the main worktree. Useful when you need to know which branches are already checked out before suggesting a new pane.", + inputSchema: { + type: 'object', + properties: { + project_root: { type: 'string' }, + }, + }, + handler: async (args) => { + const projectRoot = resolveProjectRoot(args); + let raw: string; + try { + raw = execFileSync('git', ['-C', projectRoot, 'worktree', 'list', '--porcelain'], { + encoding: 'utf8', + timeout: 5000, + }); + } catch (err) { + throw Object.assign( + new Error(`git worktree list failed: ${err instanceof Error ? err.message : String(err)}`), + { code: ERR_INTERNAL }, + ); + } + + const worktrees: Array<{ + path: string; + head?: string; + branch?: string; + bare?: boolean; + detached?: boolean; + locked?: boolean; + }> = []; + + let current: (typeof worktrees)[number] | null = null; + for (const line of raw.split('\n')) { + if (line.startsWith('worktree ')) { + if (current) worktrees.push(current); + current = { path: line.slice('worktree '.length) }; + } else if (current && line.startsWith('HEAD ')) { + current.head = line.slice('HEAD '.length); + } else if (current && line.startsWith('branch ')) { + current.branch = line.slice('branch '.length); + } else if (current && line === 'bare') { + current.bare = true; + } else if (current && line === 'detached') { + current.detached = true; + } else if (current && line.startsWith('locked')) { + current.locked = true; + } + } + if (current) worktrees.push(current); + + return { project_root: projectRoot, count: worktrees.length, worktrees }; + }, + }, ]; // ---- MCP method dispatch --------------------------------------------------