## CHANGES

- 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 🎯
This commit is contained in:
Pedram Amini
2025-12-17 18:32:38 -06:00
parent 66e198fe05
commit 142c022e2c
23 changed files with 543 additions and 124 deletions

View File

@@ -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`:

View File

@@ -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();
});

View File

@@ -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);
});

View File

@@ -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', () => {

View File

@@ -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', () => {

View File

@@ -228,7 +228,7 @@ describe('InputArea', () => {
});
render(<InputArea {...props} />);
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', () => {

View File

@@ -1652,8 +1652,8 @@ describe('MainPanel', () => {
render(<MainPanel {...defaultProps} activeSession={session} />);
// 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(<MainPanel {...defaultProps} activeSession={session} />);
// 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%', () => {

View File

@@ -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', () => {

View File

@@ -82,6 +82,16 @@ const AGENT_DEFINITIONS: Omit<AgentConfig, 'available' | 'path' | 'capabilities'
readOnlyArgs: ['--sandbox', 'read-only'], // Read-only/plan mode
yoloModeArgs: ['--dangerously-bypass-approvals-and-sandbox'], // Full access mode
workingDirArgs: (dir: string) => ['-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<AgentConfig, 'available' | 'path' | 'capabilities'
return [];
},
},
{
key: 'contextWindow',
type: 'number',
label: 'Context Window Size',
description: 'Maximum context window size in tokens. Required for context usage display. Varies by model (e.g., 200000 for Claude, 128000 for GPT-4).',
default: 128000, // Default for common models (GPT-4, etc.)
},
],
},
];

View File

@@ -225,12 +225,20 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void
...(config.prompt && { prompt: config.prompt.length > 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, {

View File

@@ -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 {

View File

@@ -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<string, number> = {
// 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 {

View File

@@ -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: [

View File

@@ -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 {

View File

@@ -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);
}
});
}

View File

@@ -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

View File

@@ -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

View File

@@ -303,6 +303,42 @@ export function AgentSelectionPanel({
)}
</div>
)}
{option.type === 'number' && (
<div className="p-3 rounded border" style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgMain }}>
<div className="mb-2">
<div className="font-medium" style={{ color: theme.colors.textMain }}>
{option.label}
</div>
<div className="text-xs opacity-50 mt-0.5" style={{ color: theme.colors.textDim }}>
{option.description}
</div>
</div>
<input
type="number"
value={agentConfigs[selectedAgent.id]?.[option.key] ?? option.default}
onChange={(e) => {
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 }}
/>
</div>
)}
</div>
))}
</div>

View File

@@ -571,6 +571,13 @@ export function DocumentsPanel({
</div>
</div>
{/* Hint for enabling loop mode */}
{documents.length === 1 && (
<p className="mt-1.5 text-xs text-center" style={{ color: theme.colors.textDim }}>
You can enable loops with two or more documents
</p>
)}
{/* Missing Documents Warning */}
{hasMissingDocs && (
<div

View File

@@ -5,6 +5,7 @@ import type { TabCompletionSuggestion, TabCompletionFilter } from '../hooks/useT
import { ThinkingStatusPill } from './ThinkingStatusPill';
import { ExecutionQueueIndicator } from './ExecutionQueueIndicator';
import { useAgentCapabilities } from '../hooks/useAgentCapabilities';
import { getProviderDisplayName } from '../utils/sessionValidation';
interface SlashCommand {
command: string;
@@ -545,7 +546,7 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
ref={inputRef}
className={`flex-1 bg-transparent text-sm outline-none ${isTerminalMode ? 'pl-1.5' : 'pl-3'} pt-3 pr-3 resize-none min-h-[2.5rem] scrollbar-thin`}
style={{ color: theme.colors.textMain, maxHeight: '7rem' }}
placeholder={isTerminalMode ? "Run shell command..." : `Talking to ${session.name} powered by Claude`}
placeholder={isTerminalMode ? "Run shell command..." : `Talking to ${session.name} powered by ${getProviderDisplayName(session.toolType)}`}
value={inputValue}
onFocus={onInputFocus}
onBlur={onInputBlur}

View File

@@ -640,8 +640,8 @@ export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function Ma
</span>
)}
{/* 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 && (
<div
className="flex flex-col items-end mr-2 relative cursor-pointer"
onMouseEnter={() => {
@@ -744,32 +744,35 @@ export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function Ma
</span>
</div>
<div className="border-t pt-2 mt-2" style={{ borderColor: theme.colors.border }}>
<div className="flex justify-between items-center">
<span className="text-xs font-bold" style={{ color: theme.colors.textDim }}>Context Tokens</span>
<span className="text-xs font-mono font-bold" style={{ color: theme.colors.accent }}>
{(
(activeTab?.usageStats?.inputTokens ?? 0) +
(activeTab?.usageStats?.outputTokens ?? 0)
).toLocaleString()}
</span>
{/* Context usage section - only shown when contextWindow is configured */}
{(activeTab?.usageStats?.contextWindow ?? 0) > 0 && (
<div className="border-t pt-2 mt-2" style={{ borderColor: theme.colors.border }}>
<div className="flex justify-between items-center">
<span className="text-xs font-bold" style={{ color: theme.colors.textDim }}>Context Tokens</span>
<span className="text-xs font-mono font-bold" style={{ color: theme.colors.accent }}>
{(
(activeTab?.usageStats?.inputTokens ?? 0) +
(activeTab?.usageStats?.outputTokens ?? 0)
).toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center mt-1">
<span className="text-xs font-bold" style={{ color: theme.colors.textDim }}>Context Size</span>
<span className="text-xs font-mono font-bold" style={{ color: theme.colors.textMain }}>
{activeTab.usageStats.contextWindow.toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center mt-1">
<span className="text-xs font-bold" style={{ color: theme.colors.textDim }}>Usage</span>
<span
className="text-xs font-mono font-bold"
style={{ color: getContextColor(activeTabContextUsage, theme) }}
>
{activeTabContextUsage}%
</span>
</div>
</div>
<div className="flex justify-between items-center mt-1">
<span className="text-xs font-bold" style={{ color: theme.colors.textDim }}>Context Size</span>
<span className="text-xs font-mono font-bold" style={{ color: theme.colors.textMain }}>
{(activeTab?.usageStats?.contextWindow ?? 200000).toLocaleString()}
</span>
</div>
<div className="flex justify-between items-center mt-1">
<span className="text-xs font-bold" style={{ color: theme.colors.textDim }}>Usage</span>
<span
className="text-xs font-mono font-bold"
style={{ color: getContextColor(activeTabContextUsage, theme) }}
>
{activeTabContextUsage}%
</span>
</div>
</div>
)}
</div>
</div>
</div>

View File

@@ -578,10 +578,12 @@ export function TabSwitcherModal({
</div>
</div>
{/* Context Gauge */}
<div className="flex-shrink-0">
<ContextGauge percentage={contextPct} theme={theme} />
</div>
{/* Context Gauge - only show when context window is configured */}
{(tab.usageStats?.contextWindow ?? 0) > 0 && (
<div className="flex-shrink-0">
<ContextGauge percentage={contextPct} theme={theme} />
</div>
)}
</button>
);
} else {

View File

@@ -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<ToolType, string> = {
'claude-code': 'Claude Code',
'claude': 'Claude',
'aider': 'Aider',
'opencode': 'OpenCode',
'codex': 'Codex',
'terminal': 'Terminal'
};
return displayNames[toolType] || toolType;