diff --git a/.claude/cli-tools.json b/.claude/cli-tools.json
index 7e8d5267..412156ad 100644
--- a/.claude/cli-tools.json
+++ b/.claude/cli-tools.json
@@ -24,6 +24,13 @@
"isBuiltin": true,
"command": "claude",
"description": "Anthropic AI assistant"
+ },
+ "opencode": {
+ "enabled": true,
+ "isBuiltin": true,
+ "command": "opencode",
+ "description": "OpenCode AI assistant",
+ "primaryModel": "opencode/glm-4.7-free"
}
},
"customEndpoints": [],
diff --git a/ccw/src/templates/dashboard-js/components/cli-stream-viewer.js b/ccw/src/templates/dashboard-js/components/cli-stream-viewer.js
index aa967fa4..f7a6a101 100644
--- a/ccw/src/templates/dashboard-js/components/cli-stream-viewer.js
+++ b/ccw/src/templates/dashboard-js/components/cli-stream-viewer.js
@@ -347,6 +347,11 @@ function renderFormattedLine(line, searchFilter) {
content = content.replace(/`([^`]+)`/g, '$1');
// Type badge icons for backend chunkType (CliOutputUnit.type)
+ // Maps to different CLI tools' output types:
+ // - Gemini: init→metadata, message→stdout, result→metadata
+ // - Codex: reasoning→thought, agent_message→stdout, turn.completed→metadata
+ // - Claude: system→metadata, assistant→stdout, result→metadata
+ // - OpenCode: step_start→progress, text→stdout, step_finish→metadata
const CHUNK_TYPE_ICONS = {
thought: 'brain',
code: 'code',
@@ -354,7 +359,8 @@ function renderFormattedLine(line, searchFilter) {
progress: 'loader',
system: 'settings',
stderr: 'alert-circle',
- metadata: 'info'
+ metadata: 'info',
+ stdout: 'message-circle'
};
// Type badge labels for backend chunkType
@@ -365,7 +371,8 @@ function renderFormattedLine(line, searchFilter) {
progress: 'Progress',
system: 'System',
stderr: 'Error',
- metadata: 'Info'
+ metadata: 'Info',
+ stdout: 'Response'
};
// Build type badge - prioritize content prefix, then fall back to chunkType
diff --git a/ccw/src/tools/cli-config-manager.ts b/ccw/src/tools/cli-config-manager.ts
index 13047dc8..876a72ea 100644
--- a/ccw/src/tools/cli-config-manager.ts
+++ b/ccw/src/tools/cli-config-manager.ts
@@ -31,9 +31,12 @@ export const PREDEFINED_MODELS: Record = {
codex: ['gpt-5.2', 'gpt-4.1', 'o4-mini', 'o3'],
claude: ['sonnet', 'opus', 'haiku', 'claude-sonnet-4-5-20250929', 'claude-opus-4-5-20251101'],
opencode: [
+ 'opencode/glm-4.7-free',
+ 'opencode/gpt-5-nano',
+ 'opencode/grok-code',
+ 'opencode/minimax-m2.1-free',
'anthropic/claude-sonnet-4-20250514',
'anthropic/claude-opus-4-20250514',
- 'anthropic/claude-haiku',
'openai/gpt-4.1',
'openai/o3',
'google/gemini-2.5-pro',
@@ -66,8 +69,8 @@ export const DEFAULT_CONFIG: CliConfig = {
},
opencode: {
enabled: true,
- primaryModel: '', // Empty = use opencode's default config
- secondaryModel: ''
+ primaryModel: 'opencode/glm-4.7-free', // Free model as default
+ secondaryModel: 'opencode/glm-4.7-free'
}
}
};
diff --git a/ccw/src/tools/cli-executor-utils.ts b/ccw/src/tools/cli-executor-utils.ts
index a36b8cd6..37a5592b 100644
--- a/ccw/src/tools/cli-executor-utils.ts
+++ b/ccw/src/tools/cli-executor-utils.ts
@@ -196,6 +196,8 @@ export function buildCommand(params: {
if (include) {
args.push('--include-directories', include);
}
+ // Enable stream-json output for structured parsing
+ args.push('-o', 'stream-json');
break;
case 'qwen':
@@ -215,6 +217,8 @@ export function buildCommand(params: {
if (include) {
args.push('--include-directories', include);
}
+ // Enable stream-json output for structured parsing (same as gemini)
+ args.push('-o', 'stream-json');
break;
case 'codex':
@@ -240,6 +244,8 @@ export function buildCommand(params: {
args.push('--add-dir', addDir);
}
}
+ // Enable JSON output for structured parsing
+ args.push('--json');
// codex resume uses positional prompt argument, not stdin
// Format: codex resume [prompt]
useStdin = false;
@@ -260,6 +266,8 @@ export function buildCommand(params: {
args.push('--add-dir', addDir);
}
}
+ // Enable JSON output for structured parsing
+ args.push('--json');
args.push('-');
}
break;
@@ -288,8 +296,9 @@ export function buildCommand(params: {
} else {
args.push('--permission-mode', 'default');
}
- // Output format for better parsing
- args.push('--output-format', 'text');
+ // Output format: stream-json for structured parsing (requires --verbose)
+ args.push('--output-format', 'stream-json');
+ args.push('--verbose');
// Add directories
if (include) {
const dirs = include.split(',').map((d) => d.trim()).filter((d) => d);
@@ -317,8 +326,8 @@ export function buildCommand(params: {
if (model) {
args.push('--model', model);
}
- // Output format for parsing
- args.push('--format', 'default');
+ // Output format: json for structured parsing
+ args.push('--format', 'json');
// Add prompt as positional argument at the end
// OpenCode expects: opencode run [options] [message..]
args.push(prompt);
diff --git a/ccw/src/tools/cli-output-converter.ts b/ccw/src/tools/cli-output-converter.ts
index a12cc0a6..88e00be3 100644
--- a/ccw/src/tools/cli-output-converter.ts
+++ b/ccw/src/tools/cli-output-converter.ts
@@ -104,6 +104,72 @@ export class PlainTextParser implements IOutputParser {
export class JsonLinesParser implements IOutputParser {
private buffer: string = '';
+ /**
+ * Classify non-JSON content to determine appropriate output type
+ * Helps distinguish real errors from normal progress/output sent to stderr
+ * (Some CLI tools like Codex send all progress info to stderr)
+ */
+ private classifyNonJsonContent(content: string, originalType: 'stdout' | 'stderr'): 'stdout' | 'stderr' {
+ // If it came from stdout, keep it as stdout
+ if (originalType === 'stdout') {
+ return 'stdout';
+ }
+
+ // Check if content looks like an actual error
+ const errorPatterns = [
+ /^error:/i,
+ /^fatal:/i,
+ /^failed:/i,
+ /^exception:/i,
+ /\bERROR\b/,
+ /\bFATAL\b/,
+ /\bFAILED\b/,
+ /\bpanic:/i,
+ /traceback \(most recent/i,
+ /syntaxerror:/i,
+ /typeerror:/i,
+ /referenceerror:/i,
+ /\bstack trace\b/i,
+ /\bat line \d+\b/i,
+ /permission denied/i,
+ /access denied/i,
+ /authentication failed/i,
+ /connection refused/i,
+ /network error/i,
+ /unable to connect/i,
+ ];
+
+ for (const pattern of errorPatterns) {
+ if (pattern.test(content)) {
+ return 'stderr';
+ }
+ }
+
+ // Check for common CLI progress/info patterns that are NOT errors
+ const progressPatterns = [
+ /^[-=]+$/, // Separators: ----, ====
+ /^\s*\d+\s*$/, // Just numbers
+ /tokens?\s*(used|count)/i, // Token counts
+ /model:/i, // Model info
+ /session\s*id:/i, // Session info
+ /workdir:/i, // Working directory
+ /provider:/i, // Provider info
+ /^(user|assistant|codex|claude|gemini)$/i, // Role labels
+ /^mcp:/i, // MCP status
+ /^[-\s]*$/, // Empty or whitespace/dashes
+ ];
+
+ for (const pattern of progressPatterns) {
+ if (pattern.test(content)) {
+ return 'stdout'; // Treat as normal output, not error
+ }
+ }
+
+ // Default: if stderr but doesn't look like an error, treat as stdout
+ // This handles CLI tools that send everything to stderr (like Codex)
+ return 'stdout';
+ }
+
parse(chunk: Buffer, streamType: 'stdout' | 'stderr'): CliOutputUnit[] {
const text = chunk.toString('utf8');
this.buffer += text;
@@ -126,8 +192,11 @@ export class JsonLinesParser implements IOutputParser {
parsed = JSON.parse(trimmed);
} catch {
// Not valid JSON, treat as plain text
+ // For stderr content, check if it's actually an error or just normal output
+ // (Some CLI tools like Codex send all progress info to stderr)
+ const effectiveType = this.classifyNonJsonContent(line, streamType);
units.push({
- type: streamType,
+ type: effectiveType,
content: line,
timestamp: new Date().toISOString()
});
@@ -171,12 +240,269 @@ export class JsonLinesParser implements IOutputParser {
/**
* Map parsed JSON object to appropriate IR type
- * Handles various JSON event formats from different CLI tools
+ * Handles various JSON event formats from different CLI tools:
+ * - Gemini CLI: stream-json format (init, message, result)
+ * - Codex CLI: --json format (thread.started, item.completed, turn.completed)
+ * - Claude CLI: stream-json format (system, assistant, result)
+ * - OpenCode CLI: --format json (step_start, text, step_finish)
*/
private mapJsonToIR(json: any, fallbackStreamType: 'stdout' | 'stderr'): CliOutputUnit | null {
- const timestamp = json.timestamp || new Date().toISOString();
+ // Handle numeric timestamp (milliseconds) from OpenCode
+ const timestamp = typeof json.timestamp === 'number'
+ ? new Date(json.timestamp).toISOString()
+ : (json.timestamp || new Date().toISOString());
- // Detect type from JSON structure
+ // ========== Gemini CLI stream-json format ==========
+ // {"type":"init","timestamp":"...","session_id":"...","model":"..."}
+ // {"type":"message","timestamp":"...","role":"assistant","content":"...","delta":true}
+ // {"type":"result","timestamp":"...","status":"success","stats":{...}}
+ if (json.type === 'init' && json.session_id) {
+ return {
+ type: 'metadata',
+ content: {
+ tool: 'gemini',
+ sessionId: json.session_id,
+ model: json.model,
+ raw: json
+ },
+ timestamp
+ };
+ }
+
+ if (json.type === 'message' && json.role) {
+ // Gemini assistant/user message
+ if (json.role === 'assistant') {
+ return {
+ type: 'stdout',
+ content: json.content || '',
+ timestamp
+ };
+ }
+ // Skip user messages in output (they're echo of input)
+ return null;
+ }
+
+ if (json.type === 'result' && json.stats) {
+ return {
+ type: 'metadata',
+ content: {
+ tool: 'gemini',
+ status: json.status,
+ stats: json.stats,
+ raw: json
+ },
+ timestamp
+ };
+ }
+
+ // ========== Codex CLI --json format ==========
+ // {"type":"thread.started","thread_id":"..."}
+ // {"type":"turn.started"}
+ // {"type":"item.started","item":{"id":"...","type":"command_execution","status":"in_progress"}}
+ // {"type":"item.completed","item":{"id":"...","type":"reasoning","text":"..."}}
+ // {"type":"item.completed","item":{"id":"...","type":"agent_message","text":"..."}}
+ // {"type":"item.completed","item":{"id":"...","type":"command_execution","aggregated_output":"..."}}
+ // {"type":"turn.completed","usage":{"input_tokens":...,"output_tokens":...}}
+ if (json.type === 'thread.started' && json.thread_id) {
+ return {
+ type: 'metadata',
+ content: {
+ tool: 'codex',
+ threadId: json.thread_id,
+ raw: json
+ },
+ timestamp
+ };
+ }
+
+ if (json.type === 'turn.started') {
+ return {
+ type: 'progress',
+ content: {
+ message: 'Turn started',
+ tool: 'codex'
+ },
+ timestamp
+ };
+ }
+
+ // Handle item.started - command execution in progress
+ if (json.type === 'item.started' && json.item) {
+ const item = json.item;
+ if (item.type === 'command_execution') {
+ return {
+ type: 'progress',
+ content: {
+ message: `Executing: ${item.command || 'command'}`,
+ tool: 'codex',
+ status: item.status || 'in_progress'
+ },
+ timestamp
+ };
+ }
+ // Other item.started types
+ return {
+ type: 'progress',
+ content: {
+ message: `Starting: ${item.type}`,
+ tool: 'codex'
+ },
+ timestamp
+ };
+ }
+
+ if (json.type === 'item.completed' && json.item) {
+ const item = json.item;
+
+ if (item.type === 'reasoning') {
+ return {
+ type: 'thought',
+ content: item.text || item.summary || '',
+ timestamp
+ };
+ }
+
+ if (item.type === 'agent_message') {
+ return {
+ type: 'stdout',
+ content: item.text || '',
+ timestamp
+ };
+ }
+
+ // Handle command_execution output
+ if (item.type === 'command_execution') {
+ // Show command output as code block
+ const output = item.aggregated_output || '';
+ return {
+ type: 'code',
+ content: {
+ command: item.command,
+ output: output,
+ exitCode: item.exit_code,
+ status: item.status
+ },
+ timestamp
+ };
+ }
+
+ // Other item types (function_call, etc.)
+ return {
+ type: 'system',
+ content: {
+ itemType: item.type,
+ itemId: item.id,
+ raw: item
+ },
+ timestamp
+ };
+ }
+
+ if (json.type === 'turn.completed' && json.usage) {
+ return {
+ type: 'metadata',
+ content: {
+ tool: 'codex',
+ usage: json.usage,
+ raw: json
+ },
+ timestamp
+ };
+ }
+
+ // ========== Claude CLI stream-json format ==========
+ // {"type":"system","subtype":"init","cwd":"...","session_id":"...","tools":[...],"model":"..."}
+ // {"type":"assistant","message":{...},"session_id":"..."}
+ // {"type":"result","subtype":"success","duration_ms":...,"result":"...","total_cost_usd":...}
+ if (json.type === 'system' && json.subtype === 'init') {
+ return {
+ type: 'metadata',
+ content: {
+ tool: 'claude',
+ sessionId: json.session_id,
+ model: json.model,
+ cwd: json.cwd,
+ tools: json.tools,
+ mcpServers: json.mcp_servers,
+ raw: json
+ },
+ timestamp
+ };
+ }
+
+ if (json.type === 'assistant' && json.message) {
+ // Extract text content from Claude message
+ const message = json.message;
+ const textContent = message.content
+ ?.filter((c: any) => c.type === 'text')
+ .map((c: any) => c.text)
+ .join('\n') || '';
+
+ return {
+ type: 'stdout',
+ content: textContent,
+ timestamp
+ };
+ }
+
+ if (json.type === 'result' && json.subtype) {
+ return {
+ type: 'metadata',
+ content: {
+ tool: 'claude',
+ status: json.subtype,
+ result: json.result,
+ durationMs: json.duration_ms,
+ totalCostUsd: json.total_cost_usd,
+ usage: json.usage,
+ modelUsage: json.modelUsage,
+ raw: json
+ },
+ timestamp
+ };
+ }
+
+ // ========== OpenCode CLI --format json ==========
+ // {"type":"step_start","timestamp":...,"sessionID":"...","part":{...}}
+ // {"type":"text","timestamp":...,"sessionID":"...","part":{"type":"text","text":"..."}}
+ // {"type":"step_finish","timestamp":...,"part":{"tokens":{...}}}
+ if (json.type === 'step_start' && json.sessionID) {
+ return {
+ type: 'progress',
+ content: {
+ message: 'Step started',
+ tool: 'opencode',
+ sessionId: json.sessionID,
+ raw: json.part
+ },
+ timestamp
+ };
+ }
+
+ if (json.type === 'text' && json.part) {
+ return {
+ type: 'stdout',
+ content: json.part.text || '',
+ timestamp
+ };
+ }
+
+ if (json.type === 'step_finish' && json.part) {
+ return {
+ type: 'metadata',
+ content: {
+ tool: 'opencode',
+ reason: json.part.reason,
+ tokens: json.part.tokens,
+ cost: json.part.cost,
+ raw: json.part
+ },
+ timestamp
+ };
+ }
+
+ // ========== Legacy/Generic formats ==========
+ // Check for generic type field patterns
if (json.type) {
switch (json.type) {
case 'thought':
@@ -239,7 +565,7 @@ export class JsonLinesParser implements IOutputParser {
}
}
- // Check for Codex JSONL format
+ // Check for legacy Codex JSONL format (response_item)
if (json.type === 'response_item' && json.payload) {
const payloadType = json.payload.type;
@@ -274,7 +600,7 @@ export class JsonLinesParser implements IOutputParser {
}
}
- // Check for Gemini/Qwen message format
+ // Check for Gemini/Qwen message format (role-based)
if (json.role === 'user' || json.role === 'assistant') {
return {
type: 'stdout',
@@ -283,6 +609,7 @@ export class JsonLinesParser implements IOutputParser {
};
}
+ // Check for thoughts array
if (json.thoughts && Array.isArray(json.thoughts)) {
return {
type: 'thought',