From 142c022e2cd65ed168a1d25890a34e796775a14f Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Wed, 17 Dec 2025 18:32:38 -0600 Subject: [PATCH] ## CHANGES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added user-configurable context window settings for agents ๐ŸŽฏ - Improved error detection to only parse JSON events, not text ๐Ÿ›ก๏ธ - Fixed stderr routing for AI processes to correct tabs ๐Ÿ”ง - Added context window support for Codex with model detection ๐Ÿงฎ - Hide context usage widget when window size is zero ๐Ÿ‘ป - Added number input type for agent configuration options ๐Ÿ”ข - Strip ANSI codes from stderr for cleaner output ๐Ÿงน - Added hint for enabling loop mode with documents ๐Ÿ’ก - Display correct provider name in input placeholder text โœ๏ธ - Improved network error patterns to reduce false positives ๐ŸŽฏ --- AGENT_SUPPORT.md | 36 +++++ .../integration/provider-integration.test.ts | 36 +++-- .../main/parsers/claude-output-parser.test.ts | 36 +++-- .../main/parsers/codex-output-parser.test.ts | 28 ++-- .../main/parsers/error-patterns.test.ts | 81 ++++++++++ .../renderer/components/InputArea.test.tsx | 2 +- .../renderer/components/MainPanel.test.tsx | 10 +- .../components/TabSwitcherModal.test.tsx | 11 +- src/main/agent-detector.ts | 17 +++ src/main/ipc/handlers/process.ts | 10 +- src/main/parsers/claude-output-parser.ts | 30 +++- src/main/parsers/codex-output-parser.ts | 138 +++++++++++++++++- src/main/parsers/error-patterns.ts | 15 +- src/main/parsers/opencode-output-parser.ts | 30 +++- src/main/process-manager.ts | 18 ++- src/main/utils/terminalFilter.ts | 19 +++ src/renderer/App.tsx | 34 ++++- .../components/AgentSelectionPanel.tsx | 36 +++++ src/renderer/components/DocumentsPanel.tsx | 7 + src/renderer/components/InputArea.tsx | 3 +- src/renderer/components/MainPanel.tsx | 57 ++++---- src/renderer/components/TabSwitcherModal.tsx | 10 +- src/renderer/utils/sessionValidation.ts | 3 +- 23 files changed, 543 insertions(+), 124 deletions(-) diff --git a/AGENT_SUPPORT.md b/AGENT_SUPPORT.md index 67852966..1db904d0 100644 --- a/AGENT_SUPPORT.md +++ b/AGENT_SUPPORT.md @@ -143,6 +143,42 @@ interface AgentCapabilities { | `supportsSessionId` | Session ID pill | Pill hidden | | `supportsResultMessages` | Show only final result | Shows all messages | +### Context Window Configuration + +For agents where context window size varies by model (like OpenCode or Codex), Maestro provides a user-configurable setting: + +**Configuration Location:** Settings โ†’ Agent Configuration โ†’ Context Window Size + +**How It Works:** +1. **Parser-reported value:** If the agent reports `contextWindow` in JSON output, that value takes priority +2. **User configuration:** If the parser doesn't report context window, the user-configured value is used +3. **Hidden when zero:** If no value is configured (0), the context usage widget is hidden entirely + +**Agent-Specific Behavior:** + +| Agent | Default Context Window | Notes | +|-------|----------------------|-------| +| Claude Code | 200,000 | Always reported in JSON output | +| Codex | 200,000 | Default for GPT-5.x models; user can override in settings | +| OpenCode | 128,000 | Default for common models (GPT-4, etc.); user can override in settings | + +**Adding Context Window Config to an Agent:** + +```typescript +// In agent-detector.ts, add to configOptions: +configOptions: [ + { + key: 'contextWindow', + type: 'number', + label: 'Context Window Size', + description: 'Maximum context window size in tokens. Required for context usage display.', + default: 128000, // Set a sane default for the agent's typical model + }, +], +``` + +The value is passed to `ProcessManager.spawn()` and used when emitting usage stats if the parser doesn't provide a context window value. + ### Starting Point: All False When adding a new agent, start with all capabilities set to `false`: diff --git a/src/__tests__/integration/provider-integration.test.ts b/src/__tests__/integration/provider-integration.test.ts index 511c9fb0..f3cb612d 100644 --- a/src/__tests__/integration/provider-integration.test.ts +++ b/src/__tests__/integration/provider-integration.test.ts @@ -118,11 +118,11 @@ const PROVIDERS: ProviderConfig[] = [ prompt, ], parseSessionId: (output: string) => { - // Codex outputs thread_id in JSON lines (turn.started events) + // Codex outputs thread_id in thread.started events for (const line of output.split('\n')) { try { const json = JSON.parse(line); - if (json.type === 'turn.started' && json.thread_id) { + if (json.type === 'thread.started' && json.thread_id) { return json.thread_id; } } catch { /* ignore non-JSON lines */ } @@ -130,21 +130,15 @@ const PROVIDERS: ProviderConfig[] = [ return null; }, parseResponse: (output: string) => { - // Codex outputs agent_message events with text + // Codex outputs item.completed events with item.type === 'agent_message' const responses: string[] = []; for (const line of output.split('\n')) { try { const json = JSON.parse(line); - if (json.type === 'agent_message') { - // agent_message can have content array or direct text - if (json.content && Array.isArray(json.content)) { - for (const item of json.content) { - if (item.type === 'text' && item.text) { - responses.push(item.text); - } - } - } else if (json.text) { - responses.push(json.text); + if (json.type === 'item.completed' && json.item?.type === 'agent_message') { + // agent_message item has text field directly + if (json.item.text) { + responses.push(json.item.text); } } } catch { /* ignore non-JSON lines */ } @@ -181,25 +175,25 @@ const PROVIDERS: ProviderConfig[] = [ prompt, ], parseSessionId: (output: string) => { - // OpenCode outputs session_id in run.started events + // OpenCode outputs sessionID in events (step_start, text, step_finish) for (const line of output.split('\n')) { try { const json = JSON.parse(line); - if (json.type === 'run.started' && json.session_id) { - return json.session_id; + if (json.sessionID) { + return json.sessionID; } } catch { /* ignore non-JSON lines */ } } return null; }, parseResponse: (output: string) => { - // OpenCode outputs text events + // OpenCode outputs text events with part.text const responses: string[] = []; for (const line of output.split('\n')) { try { const json = JSON.parse(line); - if (json.type === 'text' && json.text) { - responses.push(json.text); + if (json.type === 'text' && json.part?.text) { + responses.push(json.part.text); } } catch { /* ignore non-JSON lines */ } } @@ -239,8 +233,12 @@ function runProvider( cwd, env: { ...process.env }, shell: false, + stdio: ['pipe', 'pipe', 'pipe'], }); + // Close stdin immediately to signal EOF (prevents processes waiting for input) + proc.stdin?.end(); + proc.stdout?.on('data', (data) => { stdout += data.toString(); }); diff --git a/src/__tests__/main/parsers/claude-output-parser.test.ts b/src/__tests__/main/parsers/claude-output-parser.test.ts index b843e68d..6b1b4ea6 100644 --- a/src/__tests__/main/parsers/claude-output-parser.test.ts +++ b/src/__tests__/main/parsers/claude-output-parser.test.ts @@ -347,8 +347,9 @@ describe('ClaudeOutputParser', () => { expect(parser.detectErrorFromLine('Hello, how can I help you?')).toBeNull(); }); - it('should detect auth errors', () => { - const error = parser.detectErrorFromLine('Error: Invalid API key'); + it('should detect auth errors from JSON', () => { + const line = JSON.stringify({ type: 'error', message: 'Invalid API key' }); + const error = parser.detectErrorFromLine(line); expect(error).not.toBeNull(); expect(error?.type).toBe('auth_expired'); expect(error?.agentId).toBe('claude-code'); @@ -356,31 +357,27 @@ describe('ClaudeOutputParser', () => { expect(error?.timestamp).toBeGreaterThan(0); }); - it('should detect token exhaustion errors', () => { - const error = parser.detectErrorFromLine('Error: context is too long'); + it('should detect token exhaustion errors from JSON', () => { + const line = JSON.stringify({ error: 'context is too long' }); + const error = parser.detectErrorFromLine(line); expect(error).not.toBeNull(); expect(error?.type).toBe('token_exhaustion'); }); - it('should detect rate limit errors', () => { - const error = parser.detectErrorFromLine('Rate limit exceeded'); + it('should detect rate limit errors from JSON', () => { + const line = JSON.stringify({ type: 'error', message: 'Rate limit exceeded' }); + const error = parser.detectErrorFromLine(line); expect(error).not.toBeNull(); expect(error?.type).toBe('rate_limited'); }); - it('should detect network errors', () => { - const error = parser.detectErrorFromLine('Connection failed'); + it('should detect network errors from JSON', () => { + const line = JSON.stringify({ error: 'Connection failed' }); + const error = parser.detectErrorFromLine(line); expect(error).not.toBeNull(); expect(error?.type).toBe('network_error'); }); - it('should extract error from JSON error messages', () => { - const jsonLine = JSON.stringify({ type: 'error', message: 'Invalid API key' }); - const error = parser.detectErrorFromLine(jsonLine); - expect(error).not.toBeNull(); - expect(error?.type).toBe('auth_expired'); - }); - it('should extract error from JSON error field', () => { const jsonLine = JSON.stringify({ error: 'rate limit exceeded' }); const error = parser.detectErrorFromLine(jsonLine); @@ -388,8 +385,15 @@ describe('ClaudeOutputParser', () => { expect(error?.type).toBe('rate_limited'); }); + it('should NOT detect errors from plain text (only JSON)', () => { + // Plain text errors should come through stderr or exit codes, not stdout + expect(parser.detectErrorFromLine('Error: Invalid API key')).toBeNull(); + expect(parser.detectErrorFromLine('Rate limit exceeded')).toBeNull(); + expect(parser.detectErrorFromLine('Connection failed')).toBeNull(); + }); + it('should preserve the original line in raw data', () => { - const line = 'Error: Invalid API key'; + const line = JSON.stringify({ type: 'error', message: 'Invalid API key' }); const error = parser.detectErrorFromLine(line); expect(error?.raw?.errorLine).toBe(line); }); diff --git a/src/__tests__/main/parsers/codex-output-parser.test.ts b/src/__tests__/main/parsers/codex-output-parser.test.ts index 0b3c8e50..054a2e51 100644 --- a/src/__tests__/main/parsers/codex-output-parser.test.ts +++ b/src/__tests__/main/parsers/codex-output-parser.test.ts @@ -419,33 +419,33 @@ describe('CodexOutputParser', () => { expect(parser.detectErrorFromLine(' ')).toBeNull(); }); - it('should detect authentication errors', () => { - const error = parser.detectErrorFromLine('invalid api key'); + it('should detect authentication errors from JSON', () => { + const line = JSON.stringify({ type: 'error', error: 'invalid api key' }); + const error = parser.detectErrorFromLine(line); expect(error).not.toBeNull(); expect(error?.type).toBe('auth_expired'); expect(error?.agentId).toBe('codex'); }); - it('should detect rate limit errors', () => { - const error = parser.detectErrorFromLine('rate limit exceeded'); + it('should detect rate limit errors from JSON', () => { + const line = JSON.stringify({ error: 'rate limit exceeded' }); + const error = parser.detectErrorFromLine(line); expect(error).not.toBeNull(); expect(error?.type).toBe('rate_limited'); }); - it('should detect token exhaustion errors', () => { - const error = parser.detectErrorFromLine('maximum tokens exceeded'); + it('should detect token exhaustion errors from JSON', () => { + const line = JSON.stringify({ type: 'error', error: 'maximum tokens exceeded' }); + const error = parser.detectErrorFromLine(line); expect(error).not.toBeNull(); expect(error?.type).toBe('token_exhaustion'); }); - it('should detect JSON error messages', () => { - const line = JSON.stringify({ - type: 'error', - error: 'rate limit exceeded', - }); - const error = parser.detectErrorFromLine(line); - expect(error).not.toBeNull(); - expect(error?.type).toBe('rate_limited'); + it('should NOT detect errors from plain text (only JSON)', () => { + // Plain text errors should come through stderr or exit codes, not stdout + expect(parser.detectErrorFromLine('invalid api key')).toBeNull(); + expect(parser.detectErrorFromLine('rate limit exceeded')).toBeNull(); + expect(parser.detectErrorFromLine('maximum tokens exceeded')).toBeNull(); }); it('should return null for non-error lines', () => { diff --git a/src/__tests__/main/parsers/error-patterns.test.ts b/src/__tests__/main/parsers/error-patterns.test.ts index e0817c15..61542b28 100644 --- a/src/__tests__/main/parsers/error-patterns.test.ts +++ b/src/__tests__/main/parsers/error-patterns.test.ts @@ -55,6 +55,87 @@ describe('error-patterns', () => { expect(OPENCODE_ERROR_PATTERNS).toBeDefined(); expect(Object.keys(OPENCODE_ERROR_PATTERNS).length).toBeGreaterThan(0); }); + + describe('network_error patterns', () => { + it('should match "connection failed"', () => { + const result = matchErrorPattern(OPENCODE_ERROR_PATTERNS, 'connection failed'); + expect(result).not.toBeNull(); + expect(result?.type).toBe('network_error'); + }); + + it('should match "connection refused"', () => { + const result = matchErrorPattern(OPENCODE_ERROR_PATTERNS, 'connection refused'); + expect(result).not.toBeNull(); + expect(result?.type).toBe('network_error'); + }); + + it('should match "connection error"', () => { + const result = matchErrorPattern(OPENCODE_ERROR_PATTERNS, 'connection error'); + expect(result).not.toBeNull(); + expect(result?.type).toBe('network_error'); + }); + + it('should match "connection timed out"', () => { + const result = matchErrorPattern(OPENCODE_ERROR_PATTERNS, 'connection timed out'); + expect(result).not.toBeNull(); + expect(result?.type).toBe('network_error'); + }); + + it('should match "ECONNREFUSED"', () => { + const result = matchErrorPattern(OPENCODE_ERROR_PATTERNS, 'Error: ECONNREFUSED'); + expect(result).not.toBeNull(); + expect(result?.type).toBe('network_error'); + }); + + it('should match "ETIMEDOUT"', () => { + const result = matchErrorPattern(OPENCODE_ERROR_PATTERNS, 'Error: ETIMEDOUT'); + expect(result).not.toBeNull(); + expect(result?.type).toBe('network_error'); + }); + + it('should match "request timed out"', () => { + const result = matchErrorPattern(OPENCODE_ERROR_PATTERNS, 'request timed out'); + expect(result).not.toBeNull(); + expect(result?.type).toBe('network_error'); + }); + + it('should match "network error"', () => { + const result = matchErrorPattern(OPENCODE_ERROR_PATTERNS, 'network error occurred'); + expect(result).not.toBeNull(); + expect(result?.type).toBe('network_error'); + }); + + it('should NOT match normal text containing "connection" as part of a word or phrase', () => { + // These are false positive cases that should NOT trigger errors + const falsePositives = [ + 'Retry Connection', + 'I will establish a connection', + 'the connection is healthy', + 'check the connection string', + 'database connection pool', + ]; + + for (const text of falsePositives) { + const result = matchErrorPattern(OPENCODE_ERROR_PATTERNS, text); + expect(result).toBeNull(); + } + }); + + it('should NOT match normal text containing "timeout" as part of a phrase', () => { + // These are false positive cases that should NOT trigger errors + const falsePositives = [ + 'set timeout to 30', + 'the timeout value is', + 'default timeout setting', + 'with a timeout of 5 seconds', + ]; + + for (const text of falsePositives) { + const result = matchErrorPattern(OPENCODE_ERROR_PATTERNS, text); + expect(result).toBeNull(); + } + }); + }); }); describe('CODEX_ERROR_PATTERNS', () => { diff --git a/src/__tests__/renderer/components/InputArea.test.tsx b/src/__tests__/renderer/components/InputArea.test.tsx index 27c02a3d..e4772bd3 100644 --- a/src/__tests__/renderer/components/InputArea.test.tsx +++ b/src/__tests__/renderer/components/InputArea.test.tsx @@ -228,7 +228,7 @@ describe('InputArea', () => { }); render(); - expect(screen.getByPlaceholderText('Talking to MySession powered by Claude')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Talking to MySession powered by Claude Code')).toBeInTheDocument(); }); it('shows attach image button in AI mode when agent supports image input', () => { diff --git a/src/__tests__/renderer/components/MainPanel.test.tsx b/src/__tests__/renderer/components/MainPanel.test.tsx index cc186bd4..95ee273e 100644 --- a/src/__tests__/renderer/components/MainPanel.test.tsx +++ b/src/__tests__/renderer/components/MainPanel.test.tsx @@ -1652,8 +1652,8 @@ describe('MainPanel', () => { render(); - // Should render without crashing - expect(screen.getByText('Context Window')).toBeInTheDocument(); + // Should render without crashing - Context Window widget is hidden when contextWindow is not configured + expect(screen.queryByText('Context Window')).not.toBeInTheDocument(); }); it('should handle missing git status from context gracefully', async () => { @@ -1717,7 +1717,7 @@ describe('MainPanel', () => { }); describe('Context usage calculation edge cases', () => { - it('should handle zero context window', () => { + it('should hide context widget when context window is zero', () => { const session = createSession({ aiTabs: [{ id: 'tab-1', @@ -1739,8 +1739,8 @@ describe('MainPanel', () => { render(); - // Should render without crashing - expect(screen.getByText('Context Window')).toBeInTheDocument(); + // Context Window widget should be hidden when contextWindow is 0 (not configured) + expect(screen.queryByText('Context Window')).not.toBeInTheDocument(); }); it('should cap context usage at 100%', () => { diff --git a/src/__tests__/renderer/components/TabSwitcherModal.test.tsx b/src/__tests__/renderer/components/TabSwitcherModal.test.tsx index edfbd04c..f633efc7 100644 --- a/src/__tests__/renderer/components/TabSwitcherModal.test.tsx +++ b/src/__tests__/renderer/components/TabSwitcherModal.test.tsx @@ -473,7 +473,7 @@ describe('TabSwitcherModal', () => { }); describe('getContextPercentage', () => { - it('returns 0 when no usageStats', () => { + it('hides context gauge when no usageStats', () => { const tab = createTestTab({ usageStats: undefined }); renderWithLayerStack( @@ -488,11 +488,11 @@ describe('TabSwitcherModal', () => { /> ); - // Should show 0% - expect(screen.getByText('0%')).toBeInTheDocument(); + // Context gauge should not be rendered when contextWindow is not configured + expect(screen.queryByText(/^\d+%$/)).not.toBeInTheDocument(); }); - it('returns 0 when contextWindow is 0', () => { + it('hides context gauge when contextWindow is 0', () => { const tab = createTestTab({ usageStats: { inputTokens: 1000, @@ -516,7 +516,8 @@ describe('TabSwitcherModal', () => { /> ); - expect(screen.getByText('0%')).toBeInTheDocument(); + // Context gauge should not be rendered when contextWindow is 0 + expect(screen.queryByText(/^\d+%$/)).not.toBeInTheDocument(); }); it('calculates correct percentage', () => { diff --git a/src/main/agent-detector.ts b/src/main/agent-detector.ts index ff268bc6..ac31dfbb 100644 --- a/src/main/agent-detector.ts +++ b/src/main/agent-detector.ts @@ -82,6 +82,16 @@ const AGENT_DEFINITIONS: Omit ['-C', dir], // Set working directory + // Agent-specific configuration options shown in UI + configOptions: [ + { + key: 'contextWindow', + type: 'number', + label: 'Context Window Size', + description: 'Maximum context window size in tokens. Required for context usage display. Common values: 128000 (o4-mini), 200000 (o3).', + default: 200000, // Default for GPT-5.x models + }, + ], }, { id: 'gemini-cli', @@ -126,6 +136,13 @@ const AGENT_DEFINITIONS: Omit 500 ? config.prompt.substring(0, 500) + '...' : config.prompt }) }); + // Get contextWindow from agent config (for agents like OpenCode/Codex that need user configuration) + // Falls back to the agent's configOptions default (e.g., 200000 for Codex, 128000 for OpenCode) + const agentConfig = agentConfigsStore.get('configs', {})[config.toolType] || {}; + const contextWindowOption = agent?.configOptions?.find(opt => opt.key === 'contextWindow'); + const contextWindowDefault = contextWindowOption?.default ?? 0; + const contextWindow = typeof agentConfig.contextWindow === 'number' ? agentConfig.contextWindow : contextWindowDefault; + const result = processManager.spawn({ ...config, args: finalArgs, requiresPty: agent?.requiresPty, prompt: config.prompt, - shell: shellToUse + shell: shellToUse, + contextWindow, // Pass configured context window to process manager }); logger.info(`Process spawned successfully`, LOG_CONTEXT, { diff --git a/src/main/parsers/claude-output-parser.ts b/src/main/parsers/claude-output-parser.ts index be5332b0..27ce720f 100644 --- a/src/main/parsers/claude-output-parser.ts +++ b/src/main/parsers/claude-output-parser.ts @@ -220,6 +220,15 @@ export class ClaudeOutputParser implements AgentOutputParser { /** * Detect an error from a line of agent output + * + * IMPORTANT: Only detect errors from structured JSON error events, not from + * arbitrary text content. Pattern matching on conversational text leads to + * false positives (e.g., AI discussing "timeout" triggers timeout error). + * + * Error detection sources (in order of reliability): + * 1. Structured JSON: { type: "error", message: "..." } or { error: "..." } + * 2. stderr output (handled separately by process-manager) + * 3. Non-zero exit code (handled by detectErrorFromExit) */ detectErrorFromLine(line: string): AgentError | null { // Skip empty lines @@ -227,23 +236,32 @@ export class ClaudeOutputParser implements AgentOutputParser { return null; } - // Try to parse as JSON first to check for error messages in structured output - let textToCheck = line; + // Only detect errors from structured JSON error events + // Do NOT pattern match on arbitrary text - it causes false positives + let errorText: string | null = null; try { const parsed = JSON.parse(line); // Check for error type messages if (parsed.type === 'error' && parsed.message) { - textToCheck = parsed.message; + errorText = parsed.message; } else if (parsed.error) { - textToCheck = typeof parsed.error === 'string' ? parsed.error : JSON.stringify(parsed.error); + errorText = typeof parsed.error === 'string' ? parsed.error : JSON.stringify(parsed.error); } + // If no error field in JSON, this is normal output - don't check it } catch { - // Not JSON, check the raw line + // Not JSON - skip pattern matching entirely + // Errors should come through structured JSON, stderr, or exit codes + // Pattern matching on arbitrary text causes false positives + } + + // If no error text was extracted, no error to detect + if (!errorText) { + return null; } // Match against error patterns const patterns = getErrorPatterns(this.agentId); - const match = matchErrorPattern(patterns, textToCheck); + const match = matchErrorPattern(patterns, errorText); if (match) { return { diff --git a/src/main/parsers/codex-output-parser.ts b/src/main/parsers/codex-output-parser.ts index 6ce6d958..b543ca2c 100644 --- a/src/main/parsers/codex-output-parser.ts +++ b/src/main/parsers/codex-output-parser.ts @@ -24,6 +24,95 @@ import type { ToolType, AgentError } from '../../shared/types'; import type { AgentOutputParser, ParsedEvent } from './agent-output-parser'; import { getErrorPatterns, matchErrorPattern } from './error-patterns'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +/** + * Known OpenAI model context window sizes (in tokens) + * Source: https://platform.openai.com/docs/models + */ +const MODEL_CONTEXT_WINDOWS: Record = { + // GPT-4o family + 'gpt-4o': 128000, + 'gpt-4o-mini': 128000, + 'gpt-4o-2024-05-13': 128000, + 'gpt-4o-2024-08-06': 128000, + 'gpt-4o-2024-11-20': 128000, + // o1/o3/o4 reasoning models + 'o1': 200000, + 'o1-mini': 128000, + 'o1-preview': 128000, + 'o3': 200000, + 'o3-mini': 200000, + 'o4-mini': 200000, + // GPT-4 Turbo + 'gpt-4-turbo': 128000, + 'gpt-4-turbo-preview': 128000, + 'gpt-4-1106-preview': 128000, + // GPT-4 (original) + 'gpt-4': 8192, + 'gpt-4-32k': 32768, + // GPT-5 family (Codex default) + 'gpt-5': 200000, + 'gpt-5.1': 200000, + 'gpt-5.1-codex-max': 200000, + // Default fallback + 'default': 128000, +}; + +/** + * Get the context window size for a given model + */ +function getModelContextWindow(model: string): number { + // Try exact match first + if (MODEL_CONTEXT_WINDOWS[model]) { + return MODEL_CONTEXT_WINDOWS[model]; + } + // Try prefix match (e.g., "gpt-4o-2024-11-20" matches "gpt-4o") + for (const [prefix, size] of Object.entries(MODEL_CONTEXT_WINDOWS)) { + if (model.startsWith(prefix)) { + return size; + } + } + return MODEL_CONTEXT_WINDOWS['default']; +} + +/** + * Read Codex configuration from ~/.codex/config.toml + * Returns the model name and context window override if set + */ +function readCodexConfig(): { model?: string; contextWindow?: number } { + try { + const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex'); + const configPath = path.join(codexHome, 'config.toml'); + + if (!fs.existsSync(configPath)) { + return {}; + } + + const content = fs.readFileSync(configPath, 'utf8'); + const result: { model?: string; contextWindow?: number } = {}; + + // Simple TOML parsing for the fields we care about + // model = "gpt-5.1" + const modelMatch = content.match(/^\s*model\s*=\s*"([^"]+)"/m); + if (modelMatch) { + result.model = modelMatch[1]; + } + + // model_context_window = 128000 + const windowMatch = content.match(/^\s*model_context_window\s*=\s*(\d+)/m); + if (windowMatch) { + result.contextWindow = parseInt(windowMatch[1], 10); + } + + return result; + } catch { + // Config file doesn't exist or can't be read - use defaults + return {}; + } +} /** * Raw message structure from Codex JSON output @@ -73,6 +162,19 @@ interface CodexUsage { export class CodexOutputParser implements AgentOutputParser { readonly agentId: ToolType = 'codex'; + // Cached context window - read once from config + private contextWindow: number; + private model: string; + + constructor() { + // Read config once at initialization + const config = readCodexConfig(); + this.model = config.model || 'gpt-5.1-codex-max'; + + // Priority: 1) explicit model_context_window in config, 2) lookup by model name + this.contextWindow = config.contextWindow || getModelContextWindow(this.model); + } + /** * Parse a single JSON line from Codex output * @@ -270,10 +372,16 @@ export class CodexOutputParser implements AgentOutputParser { return { inputTokens, outputTokens: totalOutputTokens, + // Note: For OpenAI/Codex, cached_input_tokens is a SUBSET of input_tokens (already included) + // Unlike Claude where cache tokens are separate and need to be added to get total context. + // We still report cacheReadTokens for display purposes (shows cache efficiency). + // Context calculations should use inputTokens + outputTokens, not add cache tokens again. cacheReadTokens: cachedInputTokens, // Note: Codex doesn't report cache creation tokens cacheCreationTokens: 0, // Note: costUsd omitted - Codex doesn't provide cost and pricing varies by model + // Context window from Codex config (~/.codex/config.toml) or model lookup table + contextWindow: this.contextWindow, // Store reasoning tokens separately for UI display reasoningTokens: reasoningOutputTokens, }; @@ -314,6 +422,15 @@ export class CodexOutputParser implements AgentOutputParser { /** * Detect an error from a line of agent output + * + * IMPORTANT: Only detect errors from structured JSON error events, not from + * arbitrary text content. Pattern matching on conversational text leads to + * false positives (e.g., AI discussing "timeout" triggers timeout error). + * + * Error detection sources (in order of reliability): + * 1. Structured JSON: { type: "error", error: "..." } or { error: "..." } + * 2. stderr output (handled separately by process-manager) + * 3. Non-zero exit code (handled by detectErrorFromExit) */ detectErrorFromLine(line: string): AgentError | null { // Skip empty lines @@ -321,26 +438,35 @@ export class CodexOutputParser implements AgentOutputParser { return null; } - // Try to parse as JSON first to check for error messages in structured output - let textToCheck = line; + // Only detect errors from structured JSON error events + // Do NOT pattern match on arbitrary text - it causes false positives + let errorText: string | null = null; try { const parsed = JSON.parse(line); // Check for error type messages if (parsed.type === 'error' && parsed.error) { - textToCheck = parsed.error; + errorText = parsed.error; } else if (parsed.error) { - textToCheck = + errorText = typeof parsed.error === 'string' ? parsed.error : JSON.stringify(parsed.error); } + // If no error field in JSON, this is normal output - don't check it } catch { - // Not JSON, check the raw line + // Not JSON - skip pattern matching entirely + // Errors should come through structured JSON, stderr, or exit codes + // Pattern matching on arbitrary text causes false positives + } + + // If no error text was extracted, no error to detect + if (!errorText) { + return null; } // Match against error patterns const patterns = getErrorPatterns(this.agentId); - const match = matchErrorPattern(patterns, textToCheck); + const match = matchErrorPattern(patterns, errorText); if (match) { return { diff --git a/src/main/parsers/error-patterns.ts b/src/main/parsers/error-patterns.ts index 837e18ba..a6c517cf 100644 --- a/src/main/parsers/error-patterns.ts +++ b/src/main/parsers/error-patterns.ts @@ -257,15 +257,26 @@ export const OPENCODE_ERROR_PATTERNS: AgentErrorPatterns = { network_error: [ { - pattern: /connection/i, + // More specific patterns to avoid false positives from normal output + pattern: /connection\s*(failed|refused|error|reset|closed|timed?\s*out)/i, message: 'Connection error. Check your network.', recoverable: true, }, { - pattern: /timeout/i, + pattern: /ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND/i, + message: 'Network error. Check your connection.', + recoverable: true, + }, + { + pattern: /request\s+timed?\s*out|timed?\s*out\s+waiting/i, message: 'Request timed out.', recoverable: true, }, + { + pattern: /network\s+(error|failure|unavailable)/i, + message: 'Network error occurred. Please check your connection.', + recoverable: true, + }, ], agent_crashed: [ diff --git a/src/main/parsers/opencode-output-parser.ts b/src/main/parsers/opencode-output-parser.ts index 774f363d..ede060e6 100644 --- a/src/main/parsers/opencode-output-parser.ts +++ b/src/main/parsers/opencode-output-parser.ts @@ -254,6 +254,15 @@ export class OpenCodeOutputParser implements AgentOutputParser { /** * Detect an error from a line of agent output + * + * IMPORTANT: Only detect errors from structured JSON error events, not from + * arbitrary text content. Pattern matching on conversational text leads to + * false positives (e.g., AI discussing "timeout" triggers timeout error). + * + * Error detection sources (in order of reliability): + * 1. Structured JSON: { error: "message" } or { type: "error", message: "..." } + * 2. stderr output (handled separately by process-manager) + * 3. Non-zero exit code (handled by detectErrorFromExit) */ detectErrorFromLine(line: string): AgentError | null { // Skip empty lines @@ -261,23 +270,32 @@ export class OpenCodeOutputParser implements AgentOutputParser { return null; } - // Try to parse as JSON first to check for error messages in structured output - let textToCheck = line; + // Only detect errors from structured JSON error events + // Do NOT pattern match on arbitrary text - it causes false positives + let errorText: string | null = null; try { const parsed = JSON.parse(line); // OpenCode uses an 'error' field for errors if (parsed.error) { - textToCheck = parsed.error; + errorText = parsed.error; } else if (parsed.type === 'error' && parsed.message) { - textToCheck = parsed.message; + errorText = parsed.message; } + // If no error field in JSON, this is normal output - don't check it } catch { - // Not JSON, check the raw line + // Not JSON - skip pattern matching entirely + // Errors should come through structured JSON, stderr, or exit codes + // Pattern matching on arbitrary text causes false positives + } + + // If no error text was extracted, no error to detect + if (!errorText) { + return null; } // Match against error patterns const patterns = getErrorPatterns(this.agentId); - const match = matchErrorPattern(patterns, textToCheck); + const match = matchErrorPattern(patterns, errorText); if (match) { return { diff --git a/src/main/process-manager.ts b/src/main/process-manager.ts index f1c4d7f3..2b59a216 100644 --- a/src/main/process-manager.ts +++ b/src/main/process-manager.ts @@ -1,7 +1,7 @@ import { spawn, ChildProcess } from 'child_process'; import { EventEmitter } from 'events'; import * as pty from 'node-pty'; -import { stripControlSequences } from './utils/terminalFilter'; +import { stripControlSequences, stripAllAnsiCodes } from './utils/terminalFilter'; import { logger } from './utils/logger'; import { getOutputParser, type ParsedEvent, type AgentOutputParser } from './parsers'; import { aggregateModelUsage } from './parsers/usage-aggregator'; @@ -48,6 +48,7 @@ interface ProcessConfig { prompt?: string; // For batch mode agents like Claude (passed as CLI argument) shell?: string; // Shell to use for terminal sessions (e.g., 'zsh', 'bash', 'fish') images?: string[]; // Base64 data URLs for images (passed via stream-json input) + contextWindow?: number; // Configured context window size (0 or undefined = not configured, hide UI) } interface ManagedProcess { @@ -70,6 +71,7 @@ interface ManagedProcess { stderrBuffer?: string; // Buffer for accumulating stderr output (for error detection) stdoutBuffer?: string; // Buffer for accumulating stdout output (for error detection at exit) streamedText?: string; // Buffer for accumulating streamed text from partial events (OpenCode, Codex) + contextWindow?: number; // Configured context window size (0 or undefined = not configured) } /** @@ -139,7 +141,7 @@ export class ProcessManager extends EventEmitter { * Spawn a new process for a session */ spawn(config: ProcessConfig): { pid: number; success: boolean } { - const { sessionId, toolType, cwd, command, args, requiresPty, prompt, shell, images } = config; + const { sessionId, toolType, cwd, command, args, requiresPty, prompt, shell, images, contextWindow } = config; // For batch mode with images, use stream-json mode and send message via stdin // For batch mode without images, append prompt to args with -- separator @@ -348,6 +350,7 @@ export class ProcessManager extends EventEmitter { outputParser, stderrBuffer: '', // Initialize stderr buffer for error detection at exit stdoutBuffer: '', // Initialize stdout buffer for error detection at exit + contextWindow, // User-configured context window size (0 = not configured) }; this.processes.set(sessionId, managedProcess); @@ -422,13 +425,15 @@ export class ProcessManager extends EventEmitter { const usage = outputParser.extractUsage(event); if (usage) { // Map parser's usage format to UsageStats + // For contextWindow: prefer parser-reported value, then configured value, then 0 (means not available) + // A value of 0 signals the UI to hide context usage display const usageStats = { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens, cacheReadInputTokens: usage.cacheReadTokens || 0, cacheCreationInputTokens: usage.cacheCreationTokens || 0, totalCostUsd: usage.costUsd || 0, - contextWindow: usage.contextWindow || 200000, + contextWindow: usage.contextWindow || managedProcess.contextWindow || 0, reasoningTokens: usage.reasoningTokens, }; this.emit('usage', sessionId, usageStats); @@ -541,7 +546,12 @@ export class ProcessManager extends EventEmitter { } } - this.emit('data', sessionId, `[stderr] ${stderrData}`); + // Strip ANSI codes and only emit if there's actual content + const cleanedStderr = stripAllAnsiCodes(stderrData).trim(); + if (cleanedStderr) { + // Emit to separate 'stderr' event for AI processes (consistent with runCommand) + this.emit('stderr', sessionId, cleanedStderr); + } }); } diff --git a/src/main/utils/terminalFilter.ts b/src/main/utils/terminalFilter.ts index fc18eb01..5cbcd076 100644 --- a/src/main/utils/terminalFilter.ts +++ b/src/main/utils/terminalFilter.ts @@ -127,6 +127,25 @@ export function stripControlSequences(text: string, lastCommand?: string, isTerm return cleaned; } +/** + * Strip ALL ANSI escape codes from text (including color codes). + * This is more aggressive than stripControlSequences and removes everything. + * Use this for stderr from AI agents where we don't want any formatting. + */ +export function stripAllAnsiCodes(text: string): string { + // Remove all ANSI escape sequences including color codes + // Format: ESC [ ... m (SGR sequences for colors/styles) + // Format: ESC [ ... other letters (cursor, scrolling, etc.) + // Format: ESC ] ... BEL/ST (OSC sequences) + return text + .replace(/\x1b\[[0-9;]*m/g, '') // SGR color/style codes + .replace(/\x1b\[[\d;]*[A-Za-z]/g, '') // Other CSI sequences + .replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '') // OSC sequences + .replace(/\x1b[()][AB012]/g, '') // Character set selection + .replace(/\x07/g, '') // BEL character + .replace(/[\x00-\x08\x0B-\x0C\x0E-\x1A\x1C-\x1F]/g, ''); // Other control chars +} + /** * Detect if a line is likely a command echo in terminal output * This helps identify when the terminal is echoing back the command the user typed diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f57ecdb9..0cfb7bc9 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1318,16 +1318,38 @@ export default function MaestroConsole() { })); }); - // Handle stderr from runCommand (BATCHED - separate from stdout) + // Handle stderr from processes (BATCHED - separate from stdout) + // Supports both AI processes (sessionId format: {id}-ai-{tabId}) and terminal commands (plain sessionId) const unsubscribeStderr = window.maestro.process.onStderr((sessionId: string, data: string) => { - // runCommand uses plain session ID (no suffix) - const actualSessionId = sessionId; - // Filter out empty stderr (only whitespace) if (!data.trim()) return; - // Use batched append for stderr (isStderr = true) - batchedUpdater.appendLog(actualSessionId, null, false, data, true); + // Parse sessionId to determine which process this is from + // Same logic as onData handler + let actualSessionId: string; + let tabIdFromSession: string | undefined; + let isFromAi = false; + + const aiTabMatch = sessionId.match(/^(.+)-ai-(.+)$/); + if (aiTabMatch) { + actualSessionId = aiTabMatch[1]; + tabIdFromSession = aiTabMatch[2]; + isFromAi = true; + } else if (sessionId.includes('-batch-')) { + // Ignore batch task stderr + return; + } else { + // Plain session ID = runCommand (terminal commands) + actualSessionId = sessionId; + } + + if (isFromAi && tabIdFromSession) { + // AI process stderr - route to the correct tab as a system log entry + batchedUpdater.appendLog(actualSessionId, tabIdFromSession, true, `[stderr] ${data}`, false); + } else { + // Terminal command stderr - route to shell logs + batchedUpdater.appendLog(actualSessionId, null, false, data, true); + } }); // Handle command exit from runCommand diff --git a/src/renderer/components/AgentSelectionPanel.tsx b/src/renderer/components/AgentSelectionPanel.tsx index 675f53cd..5c4d5fa1 100644 --- a/src/renderer/components/AgentSelectionPanel.tsx +++ b/src/renderer/components/AgentSelectionPanel.tsx @@ -303,6 +303,42 @@ export function AgentSelectionPanel({ )} )} + {option.type === 'number' && ( +
+
+
+ {option.label} +
+
+ {option.description} +
+
+ { + const value = e.target.value === '' ? 0 : parseInt(e.target.value, 10); + const newConfig = { + ...agentConfigs[selectedAgent.id], + [option.key]: isNaN(value) ? 0 : value + }; + setAgentConfigs(prev => ({ + ...prev, + [selectedAgent.id]: newConfig + })); + }} + onBlur={() => { + // Only persist on blur to avoid excessive writes + const currentConfig = agentConfigs[selectedAgent.id] || {}; + window.maestro.agents.setConfig(selectedAgent.id, currentConfig); + }} + placeholder={option.default?.toString() || '0'} + min={0} + className="w-full p-2 rounded border bg-transparent outline-none text-sm font-mono" + style={{ borderColor: theme.colors.border, color: theme.colors.textMain }} + /> +
+ )} ))} diff --git a/src/renderer/components/DocumentsPanel.tsx b/src/renderer/components/DocumentsPanel.tsx index a82fbb15..686a3a23 100644 --- a/src/renderer/components/DocumentsPanel.tsx +++ b/src/renderer/components/DocumentsPanel.tsx @@ -571,6 +571,13 @@ export function DocumentsPanel({ + {/* Hint for enabling loop mode */} + {documents.length === 1 && ( +

+ You can enable loops with two or more documents +

+ )} + {/* Missing Documents Warning */} {hasMissingDocs && (
(function Ma )} - {/* Context Window Widget with Tooltip - only show for tabs with agent session and if agent supports usage stats */} - {activeSession.inputMode === 'ai' && activeTab?.agentSessionId && hasCapability('supportsUsageStats') && ( + {/* Context Window Widget with Tooltip - only show when context window is configured and agent supports usage stats */} + {activeSession.inputMode === 'ai' && activeTab?.agentSessionId && hasCapability('supportsUsageStats') && (activeTab?.usageStats?.contextWindow ?? 0) > 0 && (
{ @@ -744,32 +744,35 @@ export const MainPanel = forwardRef(function Ma
-
-
- Context Tokens - - {( - (activeTab?.usageStats?.inputTokens ?? 0) + - (activeTab?.usageStats?.outputTokens ?? 0) - ).toLocaleString()} - + {/* Context usage section - only shown when contextWindow is configured */} + {(activeTab?.usageStats?.contextWindow ?? 0) > 0 && ( +
+
+ Context Tokens + + {( + (activeTab?.usageStats?.inputTokens ?? 0) + + (activeTab?.usageStats?.outputTokens ?? 0) + ).toLocaleString()} + +
+
+ Context Size + + {activeTab.usageStats.contextWindow.toLocaleString()} + +
+
+ Usage + + {activeTabContextUsage}% + +
-
- Context Size - - {(activeTab?.usageStats?.contextWindow ?? 200000).toLocaleString()} - -
-
- Usage - - {activeTabContextUsage}% - -
-
+ )}
diff --git a/src/renderer/components/TabSwitcherModal.tsx b/src/renderer/components/TabSwitcherModal.tsx index e47596f2..ba94a261 100644 --- a/src/renderer/components/TabSwitcherModal.tsx +++ b/src/renderer/components/TabSwitcherModal.tsx @@ -578,10 +578,12 @@ export function TabSwitcherModal({ - {/* Context Gauge */} -
- -
+ {/* Context Gauge - only show when context window is configured */} + {(tab.usageStats?.contextWindow ?? 0) > 0 && ( +
+ +
+ )} ); } else { diff --git a/src/renderer/utils/sessionValidation.ts b/src/renderer/utils/sessionValidation.ts index 724573aa..f11af160 100644 --- a/src/renderer/utils/sessionValidation.ts +++ b/src/renderer/utils/sessionValidation.ts @@ -96,12 +96,13 @@ function normalizeDirectory(dir: string): string { /** * Get a human-readable display name for a provider/tool type. */ -function getProviderDisplayName(toolType: ToolType): string { +export function getProviderDisplayName(toolType: ToolType): string { const displayNames: Record = { 'claude-code': 'Claude Code', 'claude': 'Claude', 'aider': 'Aider', 'opencode': 'OpenCode', + 'codex': 'Codex', 'terminal': 'Terminal' }; return displayNames[toolType] || toolType;