From 06cd2e5559406e06ea65d07396e46e7d0f56a058 Mon Sep 17 00:00:00 2001 From: chr1syy Date: Fri, 6 Mar 2026 20:42:09 +0100 Subject: [PATCH 1/2] chore(cli): update run-playbook, send command and agent spawner --- src/cli/commands/run-playbook.ts | 25 ++- src/cli/commands/send.ts | 29 ++- src/cli/services/agent-spawner.ts | 334 ++++++++++++++++++++++++++++++ 3 files changed, 385 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/run-playbook.ts b/src/cli/commands/run-playbook.ts index 34a26bd95..16927bce9 100644 --- a/src/cli/commands/run-playbook.ts +++ b/src/cli/commands/run-playbook.ts @@ -4,7 +4,7 @@ import { getSessionById } from '../services/storage'; import { findPlaybookById } from '../services/playbooks'; import { runPlaybook as executePlaybook } from '../services/batch-processor'; -import { detectClaude, detectCodex } from '../services/agent-spawner'; +import { detectClaude, detectCodex, detectOpenCode, detectDroid } from '../services/agent-spawner'; import { emitError } from '../output/jsonl'; import { formatRunEvent, @@ -168,6 +168,29 @@ export async function runPlaybook(playbookId: string, options: RunPlaybookOption } process.exit(1); } + } else if (agent.toolType === 'opencode') { + const oc = await detectOpenCode(); + if (!oc.available) { + if (useJson) { + emitError('OpenCode CLI not found. Please install OpenCode.', 'OPENCODE_NOT_FOUND'); + } else { + console.error(formatError('OpenCode CLI not found. Please install OpenCode.')); + } + process.exit(1); + } + } else if (agent.toolType === 'factory-droid') { + const droid = await detectDroid(); + if (!droid.available) { + if (useJson) { + emitError( + 'Factory Droid CLI not found. Please install Factory Droid.', + 'DROID_NOT_FOUND' + ); + } else { + console.error(formatError('Factory Droid CLI not found. Please install Factory Droid.')); + } + process.exit(1); + } } else { const message = `Agent type "${agent.toolType}" is not supported in CLI batch mode yet.`; if (useJson) { diff --git a/src/cli/commands/send.ts b/src/cli/commands/send.ts index 0180538dd..64da8fa4f 100644 --- a/src/cli/commands/send.ts +++ b/src/cli/commands/send.ts @@ -1,7 +1,14 @@ // Send command - send a message to an agent and get a JSON response // Requires a Maestro agent ID. Optionally resumes an existing agent session. -import { spawnAgent, detectClaude, detectCodex, type AgentResult } from '../services/agent-spawner'; +import { + spawnAgent, + detectClaude, + detectCodex, + detectOpenCode, + detectDroid, + type AgentResult, +} from '../services/agent-spawner'; import { resolveAgentId, getSessionById } from '../services/storage'; import { estimateContextUsage } from '../../main/parsers/usage-aggregator'; import type { ToolType } from '../../shared/types'; @@ -88,7 +95,7 @@ export async function send( } // Validate agent type is supported for CLI spawning - const supportedTypes: ToolType[] = ['claude-code', 'codex']; + const supportedTypes: ToolType[] = ['claude-code', 'codex', 'opencode', 'factory-droid']; if (!supportedTypes.includes(agent.toolType)) { emitErrorJson( `Agent type "${agent.toolType}" is not supported for send mode. Supported: ${supportedTypes.join(', ')}`, @@ -116,6 +123,24 @@ export async function send( ); process.exit(1); } + } else if (agent.toolType === 'opencode') { + const oc = await detectOpenCode(); + if (!oc.available) { + emitErrorJson( + 'OpenCode CLI not found. See https://github.com/opencode-ai/opencode for installation instructions', + 'OPENCODE_NOT_FOUND' + ); + process.exit(1); + } + } else if (agent.toolType === 'factory-droid') { + const droid = await detectDroid(); + if (!droid.available) { + emitErrorJson( + 'Factory Droid CLI not found. See your provider documentation for installation instructions', + 'DROID_NOT_FOUND' + ); + process.exit(1); + } } // Spawn agent — spawnAgent handles --resume vs --session-id internally diff --git a/src/cli/services/agent-spawner.ts b/src/cli/services/agent-spawner.ts index 78f8022e3..f6abef57c 100644 --- a/src/cli/services/agent-spawner.ts +++ b/src/cli/services/agent-spawner.ts @@ -5,7 +5,10 @@ import { spawn, SpawnOptions } from 'child_process'; import * as fs from 'fs'; import type { ToolType, UsageStats } from '../../shared/types'; import { CodexOutputParser } from '../../main/parsers/codex-output-parser'; +import { OpenCodeOutputParser } from '../../main/parsers/opencode-output-parser'; +import { FactoryDroidOutputParser } from '../../main/parsers/factory-droid-output-parser'; import { aggregateModelUsage } from '../../main/parsers/usage-aggregator'; +import { getAgentDefinition } from '../../main/agents/definitions'; import { getAgentCustomPath } from './storage'; import { generateUUID } from '../../shared/uuid'; import { buildExpandedPath, buildExpandedEnv } from '../../shared/pathUtils'; @@ -36,6 +39,16 @@ const CODEX_ARGS = [ // Cached Codex path (resolved once at startup) let cachedCodexPath: string | null = null; +// OpenCode default command +const OPENCODE_DEFAULT_COMMAND = 'opencode'; +// Cached OpenCode path (resolved once at startup) +let cachedOpenCodePath: string | null = null; + +// Factory Droid default command +const DROID_DEFAULT_COMMAND = 'droid'; +// Cached Factory Droid path (resolved once at startup) +let cachedDroidPath: string | null = null; + // Result from spawning an agent export interface AgentResult { success: boolean; @@ -132,6 +145,35 @@ async function findCodexInPath(): Promise { }); } +/** + * Generic which-style lookup for arbitrary command names + */ +async function findCommandInPath(commandName: string): Promise { + return new Promise((resolve) => { + const env = { ...process.env, PATH: getExpandedPath() }; + const command = getWhichCommand(); + + const proc = spawn(command, [commandName], { env }); + let stdout = ''; + + proc.stdout?.on('data', (data) => { + stdout += data.toString(); + }); + + proc.on('close', (code) => { + if (code === 0 && stdout.trim()) { + resolve(stdout.trim().split('\n')[0]); + } else { + resolve(undefined); + } + }); + + proc.on('error', () => { + resolve(undefined); + }); + }); +} + /** * Check if Claude Code is available * First checks for a custom path in settings, then falls back to PATH detection @@ -202,6 +244,86 @@ export async function detectCodex(): Promise<{ return { available: false }; } +/** + * Check if OpenCode CLI is available + * First checks for a custom path in settings, then falls back to PATH detection + */ +export async function detectOpenCode(): Promise<{ + available: boolean; + path?: string; + source?: 'settings' | 'path'; +}> { + if (cachedOpenCodePath) { + return { available: true, path: cachedOpenCodePath, source: 'settings' }; + } + + const customPath = getAgentCustomPath('opencode'); + if (customPath) { + if (await isExecutable(customPath)) { + cachedOpenCodePath = customPath; + return { available: true, path: customPath, source: 'settings' }; + } + console.error( + `Warning: Custom OpenCode path "${customPath}" is not executable, falling back to PATH detection` + ); + } + + const pathResult = await findCommandInPath(OPENCODE_DEFAULT_COMMAND); + if (pathResult) { + cachedOpenCodePath = pathResult; + return { available: true, path: pathResult, source: 'path' }; + } + + return { available: false }; +} + +/** + * Check if Factory Droid CLI is available + * First checks for a custom path in settings, then falls back to PATH detection + */ +export async function detectDroid(): Promise<{ + available: boolean; + path?: string; + source?: 'settings' | 'path'; +}> { + if (cachedDroidPath) { + return { available: true, path: cachedDroidPath, source: 'settings' }; + } + + const customPath = getAgentCustomPath('factory-droid'); + if (customPath) { + if (await isExecutable(customPath)) { + cachedDroidPath = customPath; + return { available: true, path: customPath, source: 'settings' }; + } + console.error( + `Warning: Custom Droid path "${customPath}" is not executable, falling back to PATH detection` + ); + } + + const pathResult = await findCommandInPath(DROID_DEFAULT_COMMAND); + if (pathResult) { + cachedDroidPath = pathResult; + return { available: true, path: pathResult, source: 'path' }; + } + + return { available: false }; +} + +/** + * Get the resolved OpenCode command/path for spawning + */ +export function getOpenCodeCommand(): string { + return cachedOpenCodePath || OPENCODE_DEFAULT_COMMAND; +} + +/** + * Get the resolved Factory Droid command/path for spawning + */ +export function getDroidCommand(): string { + return cachedDroidPath || DROID_DEFAULT_COMMAND; +} + /** * Get the resolved Claude command/path for spawning * Uses cached path from detectClaude() or falls back to default command @@ -481,6 +603,210 @@ async function spawnCodexAgent( }); } +async function spawnOpenCodeAgent( + cwd: string, + prompt: string, + agentSessionId?: string +): Promise { + return new Promise((resolve) => { + const env = buildExpandedEnv(); + + // Ensure OpenCode default env vars (prevents interactive permission prompts) + const def = getAgentDefinition('opencode'); + if (def?.defaultEnvVars) { + for (const k of Object.keys(def.defaultEnvVars)) { + if (!env[k]) env[k] = def.defaultEnvVars[k]; + } + } + + const args: string[] = []; + // batchModePrefix + json output + const defArgs = def?.batchModePrefix || ['run']; + args.push(...defArgs); + if (def?.jsonOutputArgs) args.push(...def.jsonOutputArgs); + + if (agentSessionId && def?.resumeArgs) { + args.push(...def.resumeArgs(agentSessionId)); + } + + // OpenCode uses positional prompt without '--' + args.push(prompt); + + const options: SpawnOptions = { + cwd, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }; + + const openCodeCommand = getOpenCodeCommand(); + const child = spawn(openCodeCommand, args, options); + + const parser = new OpenCodeOutputParser(); + let jsonBuffer = ''; + let result: string | undefined; + let sessionId: string | undefined; + let usageStats: UsageStats | undefined; + let stderr = ''; + + child.stdout?.on('data', (data: Buffer) => { + jsonBuffer += data.toString(); + const lines = jsonBuffer.split('\n'); + jsonBuffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + const event = parser.parseJsonLine(line); + if (!event) continue; + + if (event.type === 'init' && event.sessionId && !sessionId) { + sessionId = event.sessionId; + } + + if (event.type === 'result' && event.text) { + result = result ? `${result}\n${event.text}` : event.text; + } + + const usage = parser.extractUsage(event as any); + if (usage) { + usageStats = mergeUsageStats(usageStats, { + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheReadTokens: usage.cacheReadTokens || 0, + cacheCreationTokens: usage.cacheCreationTokens || 0, + costUsd: usage.costUsd || 0, + contextWindow: usage.contextWindow || 0, + reasoningTokens: usage.reasoningTokens || 0, + }); + } + } + }); + + child.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + child.stdin?.end(); + + child.on('close', (code) => { + if (code === 0 && result) { + resolve({ success: true, response: result, agentSessionId: sessionId, usageStats }); + } else { + resolve({ + success: false, + error: stderr || `Process exited with code ${code}`, + agentSessionId: sessionId, + usageStats, + }); + } + }); + + child.on('error', (error) => { + resolve({ success: false, error: `Failed to spawn OpenCode: ${error.message}` }); + }); + }); +} + +async function spawnDroidAgent( + cwd: string, + prompt: string, + agentSessionId?: string +): Promise { + return new Promise((resolve) => { + const env = buildExpandedEnv(); + + const def = getAgentDefinition('factory-droid'); + + const args: string[] = []; + if (def?.batchModePrefix) args.push(...def.batchModePrefix); + if (def?.batchModeArgs) args.push(...def.batchModeArgs); + if (def?.jsonOutputArgs) args.push(...def.jsonOutputArgs); + + if (agentSessionId && def?.resumeArgs) { + args.push(...def.resumeArgs(agentSessionId)); + } + + // Factory Droid uses positional prompt + args.push(prompt); + + const options: SpawnOptions = { + cwd, + env, + stdio: ['pipe', 'pipe', 'pipe'], + }; + + const droidCommand = getDroidCommand(); + const child = spawn(droidCommand, args, options); + + const parser = new FactoryDroidOutputParser(); + let jsonBuffer = ''; + let result: string | undefined; + let sessionId: string | undefined; + let usageStats: UsageStats | undefined; + let stderr = ''; + let errorText: string | undefined; + + child.stdout?.on('data', (data: Buffer) => { + jsonBuffer += data.toString(); + const lines = jsonBuffer.split('\n'); + jsonBuffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + const event = parser.parseJsonLine(line); + if (!event) continue; + + if (event.type === 'init' && event.sessionId && !sessionId) { + sessionId = event.sessionId; + } + + if (event.type === 'result' && event.text) { + result = result ? `${result}\n${event.text}` : event.text; + } + + if (event.type === 'error' && event.text && !errorText) { + errorText = event.text; + } + + const usage = parser.extractUsage(event as any); + if (usage) { + usageStats = mergeUsageStats(usageStats, { + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheReadTokens: usage.cacheReadTokens || 0, + cacheCreationTokens: usage.cacheCreationTokens || 0, + costUsd: usage.costUsd || 0, + contextWindow: usage.contextWindow || 0, + reasoningTokens: usage.reasoningTokens || 0, + }); + } + } + }); + + child.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + child.stdin?.end(); + + child.on('close', (code) => { + if (code === 0 && !errorText) { + resolve({ success: true, response: result, agentSessionId: sessionId, usageStats }); + } else { + resolve({ + success: false, + error: errorText || stderr || `Process exited with code ${code}`, + agentSessionId: sessionId, + usageStats, + }); + } + }); + + child.on('error', (error) => { + resolve({ success: false, error: `Failed to spawn Factory Droid: ${error.message}` }); + }); + }); +} + /** * Spawn an agent with a prompt and return the result */ @@ -498,6 +824,14 @@ export async function spawnAgent( return spawnClaudeAgent(cwd, prompt, agentSessionId); } + if (toolType === 'opencode') { + return spawnOpenCodeAgent(cwd, prompt, agentSessionId); + } + + if (toolType === 'factory-droid') { + return spawnDroidAgent(cwd, prompt, agentSessionId); + } + return { success: false, error: `Unsupported agent type for batch mode: ${toolType}`, From f8477a94048e7d3fb51609397e59ee9aa914086f Mon Sep 17 00:00:00 2001 From: chr1syy Date: Fri, 6 Mar 2026 21:12:00 +0100 Subject: [PATCH 2/2] fix(cli): preserve OpenCode JSON error events in CLI spawner --- src/cli/services/agent-spawner.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/cli/services/agent-spawner.ts b/src/cli/services/agent-spawner.ts index f6abef57c..05cb5a6d7 100644 --- a/src/cli/services/agent-spawner.ts +++ b/src/cli/services/agent-spawner.ts @@ -647,6 +647,7 @@ async function spawnOpenCodeAgent( let sessionId: string | undefined; let usageStats: UsageStats | undefined; let stderr = ''; + let errorText: string | undefined; child.stdout?.on('data', (data: Buffer) => { jsonBuffer += data.toString(); @@ -666,6 +667,12 @@ async function spawnOpenCodeAgent( result = result ? `${result}\n${event.text}` : event.text; } + // Capture structured JSON error events emitted by OpenCode + // (opencode --format json can emit { type: 'error', error: ... }) + if (event.type === 'error' && event.text && !errorText) { + errorText = event.text; + } + const usage = parser.extractUsage(event as any); if (usage) { usageStats = mergeUsageStats(usageStats, { @@ -688,12 +695,15 @@ async function spawnOpenCodeAgent( child.stdin?.end(); child.on('close', (code) => { - if (code === 0 && result) { + // If OpenCode emitted a structured JSON 'error' event, treat it as a failure + // even when the process exits with code 0. This mirrors Codex/Factory Droid + // behaviour and preserves provider error messages emitted in JSON mode. + if (code === 0 && !errorText) { resolve({ success: true, response: result, agentSessionId: sessionId, usageStats }); } else { resolve({ success: false, - error: stderr || `Process exited with code ${code}`, + error: errorText || stderr || `Process exited with code ${code}`, agentSessionId: sessionId, usageStats, });