@hyperdrive.bot/bmad-workflow 1.0.3 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@
4
4
  /**
5
5
  * Supported AI providers
6
6
  */
7
- export type AIProvider = 'claude' | 'gemini';
7
+ export type AIProvider = 'claude' | 'gemini' | 'opencode';
8
8
  /**
9
9
  * Provider-specific configuration
10
10
  */
@@ -15,4 +15,9 @@ export const PROVIDER_CONFIGS = {
15
15
  flags: ['-p', '--yolo'],
16
16
  supportsFileReferences: false,
17
17
  },
18
+ opencode: {
19
+ command: 'opencode',
20
+ flags: ['run', '--format', 'json'],
21
+ supportsFileReferences: true,
22
+ },
18
23
  };
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { ClaudeAgentRunner } from './claude-agent-runner.js';
7
7
  import { GeminiAgentRunner } from './gemini-agent-runner.js';
8
+ import { OpenCodeAgentRunner } from './opencode-agent-runner.js';
8
9
  /**
9
10
  * Create an AI provider runner for the specified provider
10
11
  *
@@ -28,6 +29,9 @@ export function createAgentRunner(provider, logger) {
28
29
  case 'gemini': {
29
30
  return new GeminiAgentRunner(logger);
30
31
  }
32
+ case 'opencode': {
33
+ return new OpenCodeAgentRunner(logger);
34
+ }
31
35
  default: {
32
36
  throw new Error(`Unsupported AI provider: ${provider}`);
33
37
  }
@@ -40,5 +44,5 @@ export function createAgentRunner(provider, logger) {
40
44
  * @returns true if the provider is supported
41
45
  */
42
46
  export function isProviderSupported(provider) {
43
- return provider === 'claude' || provider === 'gemini';
47
+ return provider === 'claude' || provider === 'gemini' || provider === 'opencode';
44
48
  }
@@ -5,3 +5,4 @@ export * from './agent-runner-factory.js';
5
5
  export * from './agent-runner.js';
6
6
  export * from './claude-agent-runner.js';
7
7
  export * from './gemini-agent-runner.js';
8
+ export * from './opencode-agent-runner.js';
@@ -5,3 +5,4 @@ export * from './agent-runner-factory.js';
5
5
  export * from './agent-runner.js';
6
6
  export * from './claude-agent-runner.js';
7
7
  export * from './gemini-agent-runner.js';
8
+ export * from './opencode-agent-runner.js';
@@ -0,0 +1,92 @@
1
+ /**
2
+ * OpenCodeAgentRunner Service
3
+ *
4
+ * Encapsulates Open Code CLI process spawning with comprehensive error handling,
5
+ * logging, and timeout management for reliable AI agent execution.
6
+ */
7
+ import type pino from 'pino';
8
+ import type { AgentOptions, AgentResult } from '../../models/index.js';
9
+ import type { AIProvider } from '../../models/provider.js';
10
+ import type { AIProviderRunner } from './agent-runner.js';
11
+ /**
12
+ * OpenCodeAgentRunner service for executing Open Code AI agents
13
+ *
14
+ * Spawns Open Code CLI processes to execute AI agents with specified prompts.
15
+ * Handles timeout, error collection, result formatting, and process cleanup.
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * const logger = createLogger({ namespace: 'agent-runner' })
20
+ * const runner = new OpenCodeAgentRunner(logger)
21
+ * const result = await runner.runAgent({
22
+ * prompt: '@.bmad-core/agents/architect.md Create epic for user auth',
23
+ * agentType: 'architect',
24
+ * timeout: 300000
25
+ * })
26
+ * ```
27
+ */
28
+ export declare class OpenCodeAgentRunner implements AIProviderRunner {
29
+ readonly provider: AIProvider;
30
+ private readonly config;
31
+ private readonly logger;
32
+ /**
33
+ * Create a new OpenCodeAgentRunner instance
34
+ *
35
+ * @param logger - Logger instance for structured logging
36
+ */
37
+ constructor(logger: pino.Logger);
38
+ /**
39
+ * Get the count of active processes
40
+ * (Useful for testing and monitoring purposes)
41
+ *
42
+ * @returns Number of currently active child processes
43
+ */
44
+ getActiveProcessCount(): number;
45
+ /**
46
+ * Execute an Open Code AI agent with the specified prompt and options
47
+ *
48
+ * This method spawns an Open Code CLI process, captures output, handles errors,
49
+ * and enforces timeouts. It never throws exceptions - all errors are returned
50
+ * as typed AgentResult objects.
51
+ *
52
+ * @param prompt - The prompt to execute with the agent
53
+ * @param options - Agent execution options (without prompt)
54
+ * @returns AgentResult with success status, output, errors, and metadata
55
+ *
56
+ * @example
57
+ * ```typescript
58
+ * const result = await runner.runAgent('@.bmad-core/agents/architect.md Create epic for user auth', {
59
+ * agentType: 'architect',
60
+ * timeout: 300000
61
+ * })
62
+ *
63
+ * if (result.success) {
64
+ * console.log('Output:', result.output)
65
+ * } else {
66
+ * console.error('Error:', result.errors)
67
+ * }
68
+ * ```
69
+ */
70
+ runAgent(prompt: string, options: Omit<AgentOptions, 'prompt'>): Promise<AgentResult>;
71
+ /**
72
+ * Parse JSON output from Open Code CLI
73
+ *
74
+ * Open Code's --format json outputs structured events.
75
+ * This method extracts the actual response content.
76
+ *
77
+ * @param stdoutData - Raw stdout from Open Code CLI
78
+ * @returns Extracted content string
79
+ * @private
80
+ */
81
+ private parseJsonOutput;
82
+ /**
83
+ * Build detailed error context for debugging process failures
84
+ * @private
85
+ */
86
+ private buildErrorContext;
87
+ /**
88
+ * Invoke onResponse callback and return result
89
+ * @private
90
+ */
91
+ private returnWithCallback;
92
+ }
@@ -0,0 +1,392 @@
1
+ /**
2
+ * OpenCodeAgentRunner Service
3
+ *
4
+ * Encapsulates Open Code CLI process spawning with comprehensive error handling,
5
+ * logging, and timeout management for reliable AI agent execution.
6
+ */
7
+ import { exec } from 'node:child_process';
8
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { promisify } from 'node:util';
12
+ const execAsync = promisify(exec);
13
+ import { PROVIDER_CONFIGS } from '../../models/provider.js';
14
+ /**
15
+ * Track active child processes for cleanup on SIGINT
16
+ */
17
+ const activeProcesses = new Set();
18
+ /**
19
+ * Track if SIGINT handler has been registered
20
+ */
21
+ let sigintHandlerRegistered = false;
22
+ /**
23
+ * Register SIGINT handler for graceful process cleanup
24
+ */
25
+ const registerSigintHandler = (logger) => {
26
+ if (sigintHandlerRegistered)
27
+ return;
28
+ process.on('SIGINT', () => {
29
+ logger.info({
30
+ activeProcessCount: activeProcesses.size,
31
+ }, 'Received SIGINT, cleaning up active processes');
32
+ // Kill all active child processes
33
+ for (const childProcess of activeProcesses) {
34
+ try {
35
+ childProcess.kill('SIGTERM');
36
+ }
37
+ catch (error) {
38
+ logger.error({
39
+ error: error.message,
40
+ }, 'Error killing child process');
41
+ }
42
+ }
43
+ // Clear the set
44
+ activeProcesses.clear();
45
+ // Exit the main process
46
+ process.exit(130); // 130 = 128 + SIGINT signal number (2)
47
+ });
48
+ sigintHandlerRegistered = true;
49
+ };
50
+ /**
51
+ * OpenCodeAgentRunner service for executing Open Code AI agents
52
+ *
53
+ * Spawns Open Code CLI processes to execute AI agents with specified prompts.
54
+ * Handles timeout, error collection, result formatting, and process cleanup.
55
+ *
56
+ * @example
57
+ * ```typescript
58
+ * const logger = createLogger({ namespace: 'agent-runner' })
59
+ * const runner = new OpenCodeAgentRunner(logger)
60
+ * const result = await runner.runAgent({
61
+ * prompt: '@.bmad-core/agents/architect.md Create epic for user auth',
62
+ * agentType: 'architect',
63
+ * timeout: 300000
64
+ * })
65
+ * ```
66
+ */
67
+ export class OpenCodeAgentRunner {
68
+ provider = 'opencode';
69
+ config = PROVIDER_CONFIGS.opencode;
70
+ logger;
71
+ /**
72
+ * Create a new OpenCodeAgentRunner instance
73
+ *
74
+ * @param logger - Logger instance for structured logging
75
+ */
76
+ constructor(logger) {
77
+ this.logger = logger;
78
+ registerSigintHandler(logger);
79
+ }
80
+ /**
81
+ * Get the count of active processes
82
+ * (Useful for testing and monitoring purposes)
83
+ *
84
+ * @returns Number of currently active child processes
85
+ */
86
+ getActiveProcessCount() {
87
+ return activeProcesses.size;
88
+ }
89
+ /**
90
+ * Execute an Open Code AI agent with the specified prompt and options
91
+ *
92
+ * This method spawns an Open Code CLI process, captures output, handles errors,
93
+ * and enforces timeouts. It never throws exceptions - all errors are returned
94
+ * as typed AgentResult objects.
95
+ *
96
+ * @param prompt - The prompt to execute with the agent
97
+ * @param options - Agent execution options (without prompt)
98
+ * @returns AgentResult with success status, output, errors, and metadata
99
+ *
100
+ * @example
101
+ * ```typescript
102
+ * const result = await runner.runAgent('@.bmad-core/agents/architect.md Create epic for user auth', {
103
+ * agentType: 'architect',
104
+ * timeout: 300000
105
+ * })
106
+ *
107
+ * if (result.success) {
108
+ * console.log('Output:', result.output)
109
+ * } else {
110
+ * console.error('Error:', result.errors)
111
+ * }
112
+ * ```
113
+ */
114
+ async runAgent(prompt, options) {
115
+ const startTime = Date.now();
116
+ const timeout = options.timeout ?? 1_800_000; // Default 30 minutes
117
+ // Input validation
118
+ if (!prompt || prompt.trim().length === 0) {
119
+ return this.returnWithCallback({
120
+ agentType: options.agentType,
121
+ duration: Date.now() - startTime,
122
+ errors: 'Prompt is required and cannot be empty',
123
+ exitCode: -1,
124
+ output: '',
125
+ success: false,
126
+ }, options.onResponse);
127
+ }
128
+ // Log execution start with metadata
129
+ this.logger.info({
130
+ agentType: options.agentType,
131
+ promptLength: prompt.length,
132
+ references: options.references?.length ?? 0,
133
+ timeout,
134
+ }, 'Executing Open Code agent');
135
+ // Invoke onPrompt callback if provided
136
+ if (options.onPrompt) {
137
+ try {
138
+ // Create options object without callbacks for the callback
139
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
140
+ const { onPrompt, onResponse, ...callbackOptions } = options;
141
+ await onPrompt(prompt, callbackOptions);
142
+ }
143
+ catch (error) {
144
+ this.logger.warn({ error: error.message }, 'Error in onPrompt callback');
145
+ }
146
+ }
147
+ // For large prompts or prompts with special characters, use a temp file
148
+ // This avoids shell escaping issues
149
+ let tempDir = null;
150
+ let tempFile = null;
151
+ let stdoutData = '';
152
+ let stderrData = '';
153
+ try {
154
+ // Create temp directory and file
155
+ tempDir = await mkdtemp(join(tmpdir(), 'opencode-prompt-'));
156
+ tempFile = join(tempDir, 'prompt.txt');
157
+ await writeFile(tempFile, prompt, 'utf8');
158
+ // Build command with --file flag (Open Code specific)
159
+ // Format: opencode run --format json --file "${tempFile}"
160
+ const flags = this.config.flags.join(' ');
161
+ const command = `${this.config.command} ${flags} --file "${tempFile}"`;
162
+ // Log the command being executed
163
+ this.logger.info({
164
+ command,
165
+ promptLength: prompt.length,
166
+ tempFile,
167
+ }, 'Executing command with temp file');
168
+ // Use exec for better shell compatibility
169
+ const { stderr, stdout } = await execAsync(command, {
170
+ env: process.env,
171
+ maxBuffer: 10 * 1024 * 1024, // 10MB buffer
172
+ shell: process.env.SHELL || '/bin/bash',
173
+ timeout,
174
+ });
175
+ stdoutData = stdout;
176
+ stderrData = stderr;
177
+ const duration = Date.now() - startTime;
178
+ this.logger.info({
179
+ duration,
180
+ stderrLength: stderrData.length,
181
+ stdoutLength: stdoutData.length,
182
+ }, 'Open Code CLI process completed successfully');
183
+ // Parse JSON output from Open Code
184
+ const parsedOutput = this.parseJsonOutput(stdoutData);
185
+ const result = {
186
+ agentType: options.agentType,
187
+ duration,
188
+ errors: stderrData,
189
+ exitCode: 0,
190
+ output: parsedOutput,
191
+ success: true,
192
+ };
193
+ return this.returnWithCallback(result, options.onResponse);
194
+ }
195
+ catch (error) {
196
+ // Handle exec errors (includes timeout, non-zero exit, etc.)
197
+ const duration = Date.now() - startTime;
198
+ // Extract stdout/stderr from exec error if available
199
+ const execError = error;
200
+ if (execError.stdout)
201
+ stdoutData = execError.stdout;
202
+ if (execError.stderr)
203
+ stderrData = execError.stderr;
204
+ const exitCode = execError.code ?? -1;
205
+ const isTimeout = execError.killed === true && execError.signal === 'SIGTERM';
206
+ const wasKilled = execError.killed === true;
207
+ const signal = execError.signal ?? null;
208
+ // Build detailed error context for debugging
209
+ const errorContext = this.buildErrorContext({
210
+ cmd: execError.cmd,
211
+ duration,
212
+ exitCode,
213
+ isTimeout,
214
+ signal,
215
+ stderrData,
216
+ stdoutData,
217
+ wasKilled,
218
+ });
219
+ this.logger.error({
220
+ cmd: execError.cmd,
221
+ duration,
222
+ error: execError.message,
223
+ exitCode,
224
+ isTimeout,
225
+ signal,
226
+ stderrLength: stderrData.length,
227
+ stdoutLength: stdoutData.length,
228
+ wasKilled,
229
+ }, isTimeout ? 'Open Code CLI process timeout' : 'Open Code CLI process error');
230
+ return this.returnWithCallback({
231
+ agentType: options.agentType,
232
+ duration,
233
+ errors: errorContext,
234
+ exitCode: isTimeout ? 124 : exitCode,
235
+ output: stdoutData,
236
+ success: false,
237
+ }, options.onResponse);
238
+ }
239
+ finally {
240
+ // Clean up temp file and directory
241
+ if (tempDir) {
242
+ try {
243
+ await rm(tempDir, { force: true, recursive: true });
244
+ this.logger.debug({ tempDir }, 'Cleaned up temp directory');
245
+ }
246
+ catch (cleanupError) {
247
+ this.logger.warn({ error: cleanupError.message, tempDir }, 'Failed to clean up temp directory');
248
+ }
249
+ }
250
+ }
251
+ }
252
+ /**
253
+ * Parse JSON output from Open Code CLI
254
+ *
255
+ * Open Code's --format json outputs structured events.
256
+ * This method extracts the actual response content.
257
+ *
258
+ * @param stdoutData - Raw stdout from Open Code CLI
259
+ * @returns Extracted content string
260
+ * @private
261
+ */
262
+ parseJsonOutput(stdoutData) {
263
+ if (!stdoutData || stdoutData.trim().length === 0) {
264
+ return '';
265
+ }
266
+ try {
267
+ const parsed = JSON.parse(stdoutData);
268
+ // Try common JSON response structures
269
+ // Open Code may output different structures depending on version
270
+ if (typeof parsed === 'string') {
271
+ return parsed;
272
+ }
273
+ if (parsed.content) {
274
+ return String(parsed.content);
275
+ }
276
+ if (parsed.message) {
277
+ return String(parsed.message);
278
+ }
279
+ if (parsed.response) {
280
+ return String(parsed.response);
281
+ }
282
+ if (parsed.output) {
283
+ return String(parsed.output);
284
+ }
285
+ if (parsed.text) {
286
+ return String(parsed.text);
287
+ }
288
+ // If we have a result array, try to extract content
289
+ if (Array.isArray(parsed)) {
290
+ const messages = parsed
291
+ .filter((item) => item.type === 'message' || item.content || item.text)
292
+ .map((item) => item.content || item.text || item.message || '')
293
+ .filter(Boolean);
294
+ if (messages.length > 0) {
295
+ return messages.join('\n');
296
+ }
297
+ }
298
+ // Fallback: stringify the parsed object
299
+ this.logger.debug({ parsedKeys: Object.keys(parsed) }, 'Unknown JSON structure, returning stringified');
300
+ return JSON.stringify(parsed, null, 2);
301
+ }
302
+ catch {
303
+ // JSON parsing failed, return raw output
304
+ this.logger.debug({ stdoutLength: stdoutData.length }, 'JSON parsing failed, returning raw output');
305
+ return stdoutData;
306
+ }
307
+ }
308
+ /**
309
+ * Build detailed error context for debugging process failures
310
+ * @private
311
+ */
312
+ buildErrorContext(info) {
313
+ const lines = [];
314
+ // Determine the type of failure
315
+ if (info.isTimeout) {
316
+ lines.push(`Process timeout after ${info.duration}ms`);
317
+ }
318
+ else if (info.wasKilled) {
319
+ lines.push('Process was killed externally');
320
+ // Explain common exit codes
321
+ switch (info.exitCode) {
322
+ case 130: {
323
+ lines.push('Exit code 130 = SIGINT (user interrupt, Ctrl+C)');
324
+ break;
325
+ }
326
+ case 137: {
327
+ lines.push('Exit code 137 = SIGKILL (process forcefully killed)', 'Possible causes:', ' - System OOM killer', ' - Exceeded memory limits', ' - Docker/container killed the process');
328
+ break;
329
+ }
330
+ case 143: {
331
+ lines.push('Exit code 143 = SIGTERM (process terminated)', 'Possible causes:', ' - System OOM killer terminated the process', ' - Another process sent SIGTERM', ' - Parent process cleanup', ' - Docker/container resource limits');
332
+ break;
333
+ }
334
+ // No default
335
+ }
336
+ if (info.signal) {
337
+ lines.push(`Signal received: ${info.signal}`);
338
+ }
339
+ else {
340
+ lines.push('No signal information available (signal: null)');
341
+ }
342
+ }
343
+ else {
344
+ lines.push(`Process exited with code ${info.exitCode}`);
345
+ }
346
+ // Add duration context
347
+ lines.push(`Duration: ${(info.duration / 1000).toFixed(2)}s`);
348
+ // Add output context
349
+ if (info.stdoutData.length > 0) {
350
+ lines.push(`Stdout (${info.stdoutData.length} chars):`);
351
+ // Truncate if too long, show last 500 chars which are often most relevant
352
+ const truncatedStdout = info.stdoutData.length > 500 ? `...[truncated]...\n${info.stdoutData.slice(-500)}` : info.stdoutData;
353
+ lines.push(truncatedStdout);
354
+ }
355
+ else {
356
+ lines.push('Stdout: (empty - no output captured)');
357
+ }
358
+ if (info.stderrData.length > 0) {
359
+ lines.push(`Stderr (${info.stderrData.length} chars):`);
360
+ const truncatedStderr = info.stderrData.length > 500 ? `...[truncated]...\n${info.stderrData.slice(-500)}` : info.stderrData;
361
+ lines.push(truncatedStderr);
362
+ }
363
+ else {
364
+ lines.push('Stderr: (empty - no error output)');
365
+ }
366
+ // Add command for reference (truncate if too long)
367
+ if (info.cmd) {
368
+ const truncatedCmd = info.cmd.length > 200 ? `${info.cmd.slice(0, 200)}...[truncated]` : info.cmd;
369
+ lines.push(`Command: ${truncatedCmd}`);
370
+ }
371
+ // Add troubleshooting suggestions for empty output
372
+ if (info.stdoutData.length === 0 && info.stderrData.length === 0) {
373
+ lines.push('', 'Troubleshooting (no output captured):', ' 1. Check system resources (memory, CPU)', ' 2. Verify Open Code CLI is properly installed: `opencode --version`', ' 3. Check for Open Code API issues', ' 4. Review system logs: `dmesg | tail -50` (for OOM killer)', ' 5. Try running the command manually to see output');
374
+ }
375
+ return lines.join('\n');
376
+ }
377
+ /**
378
+ * Invoke onResponse callback and return result
379
+ * @private
380
+ */
381
+ async returnWithCallback(result, onResponse) {
382
+ if (onResponse) {
383
+ try {
384
+ await onResponse(result);
385
+ }
386
+ catch (error) {
387
+ this.logger.warn({ error: error.message }, 'Error in onResponse callback');
388
+ }
389
+ }
390
+ return result;
391
+ }
392
+ }