diff --git a/README.md b/README.md index 2daeaf98fa..d8c4085aed 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ Maestro is a cross-platform desktop app for orchestrating your fleet of AI agent Collaborate with AI to create detailed specification documents, then let Auto Run execute them automatically, each task in a fresh session with clean context. Allowing for long-running unattended sessions, my current record is nearly 24 hours of continuous runtime. -Run multiple agents in parallel with a Linear/Superhuman-level responsive interface. Currently supporting **Claude Code**, **OpenAI Codex**, **OpenCode**, **Factory Droid**, and **Copilot-CLI** (beta) with plans for additional agentic coding tools (Gemini CLI) based on user demand. +Run multiple agents in parallel with a Linear/Superhuman-level responsive interface. Currently supporting **Claude Code**, **OpenAI Codex**, **OpenCode**, **Factory Droid**, **Copilot-CLI** (beta), and **Oh My Pi** (beta) with plans for additional agentic coding tools (Gemini CLI) based on user demand. > **How It Works:** Maestro is a pass-through to your AI provider. Whatever MCP tools, skills, permissions, or authentication you have configured in Claude Code, Codex, or OpenCode works identically in Maestro. The only difference is we're not running interactively—each task gets a prompt and returns a response, whether it's a new session or resuming a prior one. @@ -83,7 +83,7 @@ Run multiple agents in parallel with a Linear/Superhuman-level responsive interf Additional interactions: Drag nodes to reposition, scroll to zoom, use mini-map for overview. -> **Note**: Maestro supports Claude Code, OpenAI Codex, OpenCode, Factory Droid, and Copilot-CLI (beta). Support for additional agents (Gemini CLI) may be added in future releases based on community demand. +> **Note**: Maestro supports Claude Code, OpenAI Codex, OpenCode, Factory Droid, Copilot-CLI (beta), and Oh My Pi (beta). Support for additional agents (Gemini CLI) may be added in future releases based on community demand. ## Quick Start @@ -107,6 +107,7 @@ npm run dev - [OpenAI Codex](https://github.com/openai/codex) - OpenAI's coding agent - [OpenCode](https://github.com/sst/opencode) - Open-source AI coding assistant - [Copilot-CLI](https://docs.github.com/copilot/how-tos/copilot-cli) - GitHub's terminal coding agent (beta, multi-model via [models.dev](https://models.dev)) + - [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent) - Multi-model coding agent (beta, `omp` CLI) - Git (optional, for git-aware features) ### Essential Keyboard Shortcuts diff --git a/src/__tests__/main/agents/capabilities.test.ts b/src/__tests__/main/agents/capabilities.test.ts index 71377a243f..993580316e 100644 --- a/src/__tests__/main/agents/capabilities.test.ts +++ b/src/__tests__/main/agents/capabilities.test.ts @@ -171,6 +171,24 @@ describe('agent-capabilities', () => { expect(capabilities.usesJsonLineOutput).toBe(true); }); + it('should expose Oh My Pi capabilities backed by its JSON event protocol', () => { + const capabilities = AGENT_CAPABILITIES.omp; + + expect(capabilities).toBeDefined(); + expect(capabilities.supportsResume).toBe(true); + expect(capabilities.supportsJsonOutput).toBe(true); + expect(capabilities.supportsSessionId).toBe(true); + expect(capabilities.supportsImageInput).toBe(true); + expect(capabilities.supportsModelSelection).toBe(true); + expect(capabilities.supportsCostTracking).toBe(true); + expect(capabilities.supportsUsageStats).toBe(true); + expect(capabilities.supportsBatchMode).toBe(true); + expect(capabilities.supportsStreaming).toBe(true); + expect(capabilities.supportsResultMessages).toBe(true); + expect(capabilities.supportsThinkingDisplay).toBe(true); + expect(capabilities.usesJsonLineOutput).toBe(true); + }); + it('should define capabilities for all known agents', () => { const knownAgents = [ 'claude-code', @@ -181,6 +199,7 @@ describe('agent-capabilities', () => { 'opencode', 'factory-droid', 'copilot-cli', + 'omp', ]; for (const agentId of knownAgents) { diff --git a/src/__tests__/main/agents/definitions.test.ts b/src/__tests__/main/agents/definitions.test.ts index aa669e10f4..d26e1f1bc9 100644 --- a/src/__tests__/main/agents/definitions.test.ts +++ b/src/__tests__/main/agents/definitions.test.ts @@ -28,6 +28,20 @@ describe('agent-definitions', () => { expect(agentIds).toContain('copilot-cli'); expect(agentIds).toContain('hermes'); expect(agentIds).toContain('pi'); + expect(agentIds).toContain('omp'); + }); + + it('should have omp with batch mode and json output configuration', () => { + const omp = AGENT_DEFINITIONS.find((def) => def.id === 'omp'); + expect(omp).toBeDefined(); + expect(omp?.name).toBe('Oh My Pi'); + expect(omp?.command).toBe('omp'); + expect(omp?.binaryName).toBe('omp'); + expect(omp?.batchModePrefix).toEqual(['-p']); + expect(omp?.jsonOutputArgs).toEqual(['--mode', 'json']); + expect(omp?.resumeArgs?.('abc')).toEqual(['--resume', 'abc']); + expect(omp?.modelArgs?.('opus')).toEqual(['--model', 'opus']); + expect(omp?.hidden).toBeFalsy(); }); it('should have required properties on all definitions', () => { @@ -163,6 +177,7 @@ describe('agent-definitions', () => { 'opencode', 'gemini-cli', 'copilot-cli', + 'omp', ]; for (const agentId of knownAgents) { const def = getAgentDefinition(agentId); diff --git a/src/__tests__/main/agents/detector.test.ts b/src/__tests__/main/agents/detector.test.ts index 67ef049bb0..89e5345b0a 100644 --- a/src/__tests__/main/agents/detector.test.ts +++ b/src/__tests__/main/agents/detector.test.ts @@ -1383,6 +1383,37 @@ describe('agent-detector', () => { expect(models).toEqual(['model1', 'model2']); }); + + it('should discover models for Oh My Pi from omp models --json', async () => { + mockExecFileNoThrow.mockImplementation(async (cmd, args) => { + if (cmd === '/usr/bin/omp' && args[0] === 'models' && args[1] === '--json') { + return { + stdout: JSON.stringify({ + models: [ + { id: 'claude-opus-4-8', selector: 'anthropic/claude-opus-4-8' }, + { id: 'gpt-5.2', selector: 'openai-codex/gpt-5.2' }, + { id: 'gpt-5.2', selector: 'openai-codex/gpt-5.2' }, + ], + }), + stderr: '', + exitCode: 0, + }; + } + if (args[0] === 'omp') { + return { stdout: '/usr/bin/omp\n', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 1 }; + }); + + detector.clearCache(); + detector.clearModelCache(); + await detector.detectAgents(); + + const models = await detector.discoverModels('omp'); + + // Prefers the provider-qualified selector and de-duplicates entries + expect(models).toEqual(['anthropic/claude-opus-4-8', 'openai-codex/gpt-5.2']); + }); }); describe('OpenCode batch mode configuration', () => { diff --git a/src/__tests__/main/parsers/index.test.ts b/src/__tests__/main/parsers/index.test.ts index 75e506da25..a800cf3219 100644 --- a/src/__tests__/main/parsers/index.test.ts +++ b/src/__tests__/main/parsers/index.test.ts @@ -10,6 +10,7 @@ import { CodexOutputParser, CopilotOutputParser, PiOutputParser, + OmpOutputParser, } from '../../../main/parsers'; describe('parsers/index', () => { @@ -66,21 +67,29 @@ describe('parsers/index', () => { expect(hasOutputParser('pi')).toBe(true); }); - it('should register exactly 6 parsers', () => { + it('should register Omp parser', () => { + expect(hasOutputParser('omp')).toBe(false); + + initializeOutputParsers(); + + expect(hasOutputParser('omp')).toBe(true); + }); + + it('should register exactly 7 parsers', () => { initializeOutputParsers(); const parsers = getAllOutputParsers(); - expect(parsers.length).toBe(6); + expect(parsers.length).toBe(7); }); it('should clear existing parsers before registering', () => { // First initialization initializeOutputParsers(); - expect(getAllOutputParsers().length).toBe(6); + expect(getAllOutputParsers().length).toBe(7); - // Second initialization should still have exactly 5 + // Second initialization should still have exactly 7 initializeOutputParsers(); - expect(getAllOutputParsers().length).toBe(6); + expect(getAllOutputParsers().length).toBe(7); }); }); @@ -128,6 +137,12 @@ describe('parsers/index', () => { expect(parser).not.toBeNull(); expect(parser).toBeInstanceOf(PiOutputParser); }); + + it('should return OmpOutputParser for omp', () => { + const parser = getOutputParser('omp'); + expect(parser).not.toBeNull(); + expect(parser).toBeInstanceOf(OmpOutputParser); + }); }); describe('parser exports', () => { @@ -155,6 +170,11 @@ describe('parsers/index', () => { const parser = new PiOutputParser(); expect(parser.agentId).toBe('pi'); }); + + it('should export OmpOutputParser class', () => { + const parser = new OmpOutputParser(); + expect(parser.agentId).toBe('omp'); + }); }); describe('integration', () => { diff --git a/src/__tests__/main/parsers/omp-output-parser.test.ts b/src/__tests__/main/parsers/omp-output-parser.test.ts new file mode 100644 index 0000000000..70ece887d2 --- /dev/null +++ b/src/__tests__/main/parsers/omp-output-parser.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { OmpOutputParser } from '../../../main/parsers/omp-output-parser'; +import type { ParsedEvent } from '../../../main/parsers/agent-output-parser'; + +/** + * Captured `omp -p --mode json "Reply with exactly the two characters: ok"` output. + * Source: local://omp-sample.jsonl (real run on this machine). + */ +const OMP_SAMPLE = `{"type":"session","version":3,"id":"019f053e-8426-7000-8d2a-b62b4fb55545","timestamp":"2026-06-26T18:43:30.984Z","cwd":"C:\\\\Users\\\\sydor\\\\AppData\\\\Local\\\\Temp\\\\omp-probe"} +{"type":"agent_start"} +{"type":"turn_start"} +{"type":"message_start","message":{"role":"user","content":[{"type":"text","text":"Reply with exactly the two characters: ok"}],"attribution":"user","timestamp":1782499411936}} +{"type":"message_end","message":{"role":"user","content":[{"type":"text","text":"Reply with exactly the two characters: ok"}],"attribution":"user","timestamp":1782499411936}} +{"type":"message_start","message":{"role":"assistant","content":[{"type":"text","text":"ok"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":4,"cacheRead":0,"cacheWrite":14443,"totalTokens":14449,"cost":{"input":0.00001,"output":0.0001,"cacheRead":0,"cacheWrite":0.09026875000000001,"total":0.09037875000000001},"cttl":{"ephemeral1h":14443}},"stopReason":"stop","timestamp":1782499414886,"responseId":"msg_01CoMSHtJYk8HJTJPP1KC9Ua","duration":1930,"ttft":1929}} +{"type":"message_update","assistantMessageEvent":{"type":"text_start","contentIndex":0,"partial":{"role":"assistant","content":[{"type":"text","text":"ok"}]}},"message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}} +{"type":"message_update","assistantMessageEvent":{"type":"text_delta","contentIndex":0,"delta":"ok","partial":{"role":"assistant","content":[{"type":"text","text":"ok"}]}},"message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}} +{"type":"message_update","assistantMessageEvent":{"type":"text_end","contentIndex":0,"content":"ok","partial":{"role":"assistant","content":[{"type":"text","text":"ok"}]}},"message":{"role":"assistant","content":[{"type":"text","text":"ok"}]}} +{"type":"message_end","message":{"role":"assistant","content":[{"type":"text","text":"ok"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-opus-4-8","usage":{"input":2,"output":4,"cacheRead":0,"cacheWrite":14443,"totalTokens":14449,"cost":{"input":0.00001,"output":0.0001,"cacheRead":0,"cacheWrite":0.09026875000000001,"total":0.09037875000000001}},"stopReason":"stop","timestamp":1782499414886,"responseId":"msg_01CoMSHtJYk8HJTJPP1KC9Ua","duration":1930,"ttft":1929}} +{"type":"turn_end","message":{"role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input":2,"output":4,"totalTokens":14449,"cost":{"total":0.09037875000000001}}},"toolResults":[]} +{"type":"agent_end","messages":[{"role":"user","content":[{"type":"text","text":"Reply with exactly the two characters: ok"}],"attribution":"user","timestamp":1782499411936},{"role":"assistant","content":[{"type":"text","text":"ok"}],"model":"claude-opus-4-8","usage":{"input":2,"output":4,"totalTokens":14449,"cost":{"total":0.09037875000000001}},"stopReason":"stop"}]}`; + +describe('OmpOutputParser', () => { + const parser = new OmpOutputParser(); + + const parseSample = (): ParsedEvent[] => + OMP_SAMPLE.split('\n') + .map((line) => parser.parseJsonLine(line)) + .filter((event): event is ParsedEvent => event !== null); + + it('identifies as the omp agent', () => { + expect(parser.agentId).toBe('omp'); + }); + + it('extracts the resumable session id from the session line', () => { + const events = parseSample(); + const init = events.find((event) => event.type === 'init'); + + expect(init).toBeDefined(); + expect(parser.extractSessionId(init!)).toBe('019f053e-8426-7000-8d2a-b62b4fb55545'); + }); + + it('assembles streamed assistant text from text_delta chunks', () => { + const events = parseSample(); + const streamedText = events + .filter((event) => event.type === 'text' && event.isPartial && !event.isReasoning) + .map((event) => event.text || '') + .join(''); + + expect(streamedText).toBe('ok'); + }); + + it('extracts usage with token totals and cost from the assistant message_end', () => { + const events = parseSample(); + const usageEvent = events.find((event) => event.type === 'usage'); + + expect(usageEvent).toBeDefined(); + const usage = parser.extractUsage(usageEvent!); + expect(usage).not.toBeNull(); + expect(usage!.inputTokens).toBe(2); + expect(usage!.outputTokens).toBe(4); + expect(usage!.cacheReadTokens).toBe(0); + expect(usage!.cacheCreationTokens).toBe(14443); + + // omp's totalTokens (14449) is the sum of input + output + cache read + cache write. + const totalTokens = + usage!.inputTokens + + usage!.outputTokens + + (usage!.cacheReadTokens || 0) + + (usage!.cacheCreationTokens || 0); + expect(totalTokens).toBe(14449); + + expect(usage!.costUsd).toBeCloseTo(0.0904, 4); + }); + + it('treats agent_end as the authoritative final result', () => { + const events = parseSample(); + const finalEvent = events.at(-1); + + expect(finalEvent).toBeDefined(); + expect(parser.isResultMessage(finalEvent!)).toBe(true); + expect(finalEvent).toMatchObject({ type: 'result', text: 'ok' }); + }); + + it('does not emit an empty result when agent_end has an empty messages array', () => { + const event = parser.parseJsonObject({ type: 'agent_end', messages: [] }); + + expect(event).not.toBeNull(); + expect(event!.type).toBe('system'); + expect(parser.isResultMessage(event!)).toBe(false); + }); + + it('does not emit an empty result when agent_end omits the messages array', () => { + const event = parser.parseJsonObject({ type: 'agent_end' }); + + expect(event).not.toBeNull(); + expect(event!.type).toBe('system'); + expect(parser.isResultMessage(event!)).toBe(false); + }); + + it('does not emit a result when agent_end carries only a user message', () => { + const event = parser.parseJsonObject({ + type: 'agent_end', + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + }); + + expect(event).not.toBeNull(); + expect(event!.type).toBe('system'); + expect(parser.isResultMessage(event!)).toBe(false); + }); +}); diff --git a/src/__tests__/shared/agentIds.test.ts b/src/__tests__/shared/agentIds.test.ts index 3919f4a1ad..83ae5ba376 100644 --- a/src/__tests__/shared/agentIds.test.ts +++ b/src/__tests__/shared/agentIds.test.ts @@ -30,6 +30,7 @@ describe('agentIds', () => { it('should contain beta agents', () => { expect(AGENT_IDS).toContain('copilot-cli'); + expect(AGENT_IDS).toContain('omp'); }); it('should have no duplicates', () => { diff --git a/src/__tests__/shared/agentMetadata.test.ts b/src/__tests__/shared/agentMetadata.test.ts index 3139ecd3fd..4310be16d6 100644 --- a/src/__tests__/shared/agentMetadata.test.ts +++ b/src/__tests__/shared/agentMetadata.test.ts @@ -33,6 +33,7 @@ describe('agentMetadata', () => { expect(AGENT_DISPLAY_NAMES['gemini-cli']).toBe('Gemini CLI'); expect(AGENT_DISPLAY_NAMES['qwen3-coder']).toBe('Qwen3 Coder'); expect(AGENT_DISPLAY_NAMES['copilot-cli']).toBe('Copilot-CLI'); + expect(AGENT_DISPLAY_NAMES['omp']).toBe('Oh My Pi'); expect(AGENT_DISPLAY_NAMES['terminal']).toBe('Terminal'); }); @@ -81,6 +82,7 @@ describe('agentMetadata', () => { expect(BETA_AGENTS.has('hermes')).toBe(true); expect(BETA_AGENTS.has('pi')).toBe(true); expect(BETA_AGENTS.has('copilot-cli')).toBe(true); + expect(BETA_AGENTS.has('omp')).toBe(true); }); it('should not contain non-beta agents', () => { @@ -105,6 +107,7 @@ describe('agentMetadata', () => { expect(isBetaAgent('hermes')).toBe(true); expect(isBetaAgent('pi')).toBe(true); expect(isBetaAgent('copilot-cli')).toBe(true); + expect(isBetaAgent('omp')).toBe(true); }); it('should return false for non-beta agents', () => { diff --git a/src/main/agents/capabilities.ts b/src/main/agents/capabilities.ts index 3ad215c6a3..605cd49760 100644 --- a/src/main/agents/capabilities.ts +++ b/src/main/agents/capabilities.ts @@ -265,6 +265,44 @@ export const AGENT_CAPABILITIES: Record = { supportsProjectMemory: false, }, + /** + * Oh My Pi - JSON event-stream batch integration via `omp -p --mode json`. + * Oh My Pi emits a session id, streaming message deltas, tool lifecycle + * events, and per-message usage/cost. It resumes sessions through `--resume` + * and selects models with a fuzzy `--model` matcher. + */ + omp: { + supportsResume: true, + supportsReadOnlyMode: false, + supportsJsonOutput: true, + supportsSessionId: true, + supportsImageInput: true, + supportsImageInputOnResume: true, + supportsSlashCommands: false, + supportsSessionStorage: false, + supportsCostTracking: true, + supportsUsageStats: true, + supportsBatchMode: true, + requiresPromptToStart: true, + supportsStreaming: true, + supportsResultMessages: true, + supportsModelSelection: true, + supportsStreamJsonInput: false, + supportsThinkingDisplay: true, + supportsContextMerge: true, + supportsContextExport: false, + supportsWizard: false, + supportsGroupChatModeration: false, + usesJsonLineOutput: true, + usesCombinedContextWindow: false, + // omp exposes only the inline `--append-system-prompt` flag, not the + // `--append-system-prompt-file` variant that Maestro's Windows-local + // delivery path emits when this is true. Enabling it would break omp on + // Windows (unknown flag), and the flag name is not overridable per agent. + supportsAppendSystemPrompt: false, + supportsProjectMemory: false, + }, + /** * OpenCode - Open source coding assistant * https://github.com/opencode-ai/opencode diff --git a/src/main/agents/definitions.ts b/src/main/agents/definitions.ts index 7670e9e537..1ccbca1026 100644 --- a/src/main/agents/definitions.ts +++ b/src/main/agents/definitions.ts @@ -421,6 +421,42 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [ }, ], }, + { + id: 'omp', + name: 'Oh My Pi', + binaryName: 'omp', + command: 'omp', + args: [], + batchModePrefix: ['-p'], + jsonOutputArgs: ['--mode', 'json'], + // No noPromptSeparator: use Maestro's default '--' end-of-options separator + // before the prompt. omp supports '--', so prompts beginning with '-' or + // '---' (markdown front matter) reach the model instead of being parsed as flags. + resumeArgs: (sessionId: string) => ['--resume', sessionId], + noToolsArgs: ['--no-tools'], // Tab naming disables all tools so a task-like first message yields a name, not a real agentic run (mirrors Pi) + modelArgs: (modelId: string) => ['--model', modelId], + workingDirArgs: (dir: string) => ['--cwd', dir], + imageArgs: (imagePath: string) => [`@${imagePath}`], + configOptions: [ + { + key: 'model', + type: 'text', + label: 'Model', + description: + 'Fuzzy model override passed to --model (for example, opus, gpt-5.2, or openai/gpt-5.2). Multi-provider; leave empty for the CLI default.', + default: '', + argBuilder: (value: string) => (value && value.trim() ? ['--model', value.trim()] : []), + }, + { + key: 'contextWindow', + type: 'number', + label: 'Context Window Size', + description: + 'Fallback context window size in tokens until Oh My Pi reports a runtime-specific value.', + default: 200000, + }, + ], + }, { id: 'opencode', name: 'OpenCode', diff --git a/src/main/agents/detector.ts b/src/main/agents/detector.ts index fc97b74d6b..4c8703ca16 100644 --- a/src/main/agents/detector.ts +++ b/src/main/agents/detector.ts @@ -468,6 +468,43 @@ export class AgentDetector { return userModel ? [userModel] : []; } + case 'omp': { + // Oh My Pi: `omp models --json` returns { models: [{ id, selector, ... }] } + // across every configured provider. Prefer the provider-qualified `selector` + // (e.g. anthropic/claude-opus-4-8), which is unambiguous for --model. + const result = await execFileNoThrow(command, ['models', '--json'], undefined, env); + if (result.exitCode !== 0) { + logger.warn( + `CLI model discovery failed for ${agentId}: exit code ${result.exitCode}`, + LOG_CONTEXT, + { stderr: result.stderr } + ); + return []; + } + let parsed: { models?: Array<{ id?: string; selector?: string }> }; + try { + parsed = parseJsonWithBom<{ models?: Array<{ id?: string; selector?: string }> }>( + result.stdout + ); + } catch (parseError) { + logger.warn('Failed to parse omp models --json output', LOG_CONTEXT, { + error: parseError, + }); + return []; + } + const seen = new Set(); + const models: string[] = []; + for (const entry of parsed.models ?? []) { + const modelId = entry.selector || entry.id; + if (modelId && !seen.has(modelId)) { + seen.add(modelId); + models.push(modelId); + } + } + logger.info(`Discovered ${models.length} models for ${agentId}`, LOG_CONTEXT); + return models; + } + default: // For agents without model discovery implemented, return empty array logger.debug(`No model discovery implemented for ${agentId}`, LOG_CONTEXT); diff --git a/src/main/agents/path-prober.ts b/src/main/agents/path-prober.ts index 70ccf069fe..a067b59b8a 100644 --- a/src/main/agents/path-prober.ts +++ b/src/main/agents/path-prober.ts @@ -349,6 +349,13 @@ function getWindowsKnownPaths(binaryName: string): string[] { ...npmGlobal('pi'), ...localBin('pi'), ], + omp: [ + // Bun global installation (primary method for Oh My Pi) + path.join(home, '.bun', 'bin', 'omp.exe'), + // npm global installation + ...npmGlobal('omp'), + ...localBin('omp'), + ], gh: [ // GitHub CLI official installer (MSI) path.join(programFiles, 'GitHub CLI', 'gh.exe'), @@ -491,6 +498,15 @@ function getUnixKnownPaths(binaryName: string): string[] { path.join(home, 'bin', 'pi'), ...nodeVersionManagers('pi'), ], + omp: [ + // Bun global installation (primary method for Oh My Pi) + path.join(home, '.bun', 'bin', 'omp'), + ...localBin('omp'), + ...homebrew('omp'), + ...npmGlobal('omp'), + path.join(home, 'bin', 'omp'), + ...nodeVersionManagers('omp'), + ], gh: [ // Homebrew (Apple Silicon + Intel) ...homebrew('gh'), diff --git a/src/main/parsers/error-patterns.ts b/src/main/parsers/error-patterns.ts index 1313cce58c..768d71c63b 100644 --- a/src/main/parsers/error-patterns.ts +++ b/src/main/parsers/error-patterns.ts @@ -1026,6 +1026,45 @@ const PI_ERROR_PATTERNS: AgentErrorPatterns = { ], }; +const OMP_ERROR_PATTERNS: AgentErrorPatterns = { + auth_expired: [ + { + pattern: + /invalid api key|authentication failed|unauthorized|not authenticated|missing api key/i, + message: 'Oh My Pi authentication failed. Check the selected provider credentials.', + recoverable: true, + }, + ], + rate_limited: [ + { + pattern: /rate limit|too many requests|\b429\b|quota exceeded/i, + message: 'Oh My Pi provider rate limit exceeded. Please wait and try again.', + recoverable: true, + }, + ], + token_exhaustion: [ + { + pattern: /context.*(exceeded|too long)|maximum.*tokens|prompt.*too long/i, + message: 'Oh My Pi context limit exceeded. Start a new session.', + recoverable: true, + }, + ], + network_error: [ + { + pattern: /connection (failed|refused|reset)|ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOTFOUND/i, + message: 'Oh My Pi could not reach the selected provider. Check your network connection.', + recoverable: true, + }, + ], + agent_crashed: [ + { + pattern: /panic|fatal error|unhandled exception|segmentation fault/i, + message: 'Oh My Pi crashed unexpectedly. Check the logs and try again.', + recoverable: true, + }, + ], +}; + // ============================================================================ // Pattern Registry // ============================================================================ @@ -1037,6 +1076,7 @@ const patternRegistry = new Map([ ['factory-droid', FACTORY_DROID_ERROR_PATTERNS], ['copilot-cli', COPILOT_ERROR_PATTERNS], ['pi', PI_ERROR_PATTERNS], + ['omp', OMP_ERROR_PATTERNS], ]); /** diff --git a/src/main/parsers/index.ts b/src/main/parsers/index.ts index eba821960d..5751b871da 100644 --- a/src/main/parsers/index.ts +++ b/src/main/parsers/index.ts @@ -59,6 +59,7 @@ import { CodexOutputParser } from './codex-output-parser'; import { FactoryDroidOutputParser } from './factory-droid-output-parser'; import { CopilotOutputParser } from './copilot-output-parser'; import { PiOutputParser } from './pi-output-parser'; +import { OmpOutputParser } from './omp-output-parser'; import { registerOutputParser, clearParserRegistry, @@ -73,6 +74,7 @@ export { CodexOutputParser } from './codex-output-parser'; export { FactoryDroidOutputParser } from './factory-droid-output-parser'; export { CopilotOutputParser } from './copilot-output-parser'; export { PiOutputParser } from './pi-output-parser'; +export { OmpOutputParser } from './omp-output-parser'; const LOG_CONTEXT = '[OutputParsers]'; @@ -91,6 +93,7 @@ export function initializeOutputParsers(): void { registerOutputParser(new FactoryDroidOutputParser()); registerOutputParser(new CopilotOutputParser()); registerOutputParser(new PiOutputParser()); + registerOutputParser(new OmpOutputParser()); // Log registered parsers for debugging const registeredParsers = getAllOutputParsers().map((p) => p.agentId); diff --git a/src/main/parsers/omp-output-parser.ts b/src/main/parsers/omp-output-parser.ts new file mode 100644 index 0000000000..48f9405267 --- /dev/null +++ b/src/main/parsers/omp-output-parser.ts @@ -0,0 +1,344 @@ +/** + * Parser for Oh My Pi's (`omp`) JSON event protocol (`omp -p --mode json ...`). + * + * Oh My Pi streams newline-delimited JSON, one event per line: + * - `session` first line, `.id` is the resumable session id. + * - `agent_start` / `turn_start` + * - `message_start` / `message_end` for user and assistant turns. + * - `message_update` carries streaming deltas via `assistantMessageEvent` + * (`text_delta` -> answer chunk, `thinking_delta` -> reasoning). + * - `message_end` (assistant) carries the authoritative `usage` (tokens + cost). + * - `turn_end` + * - `agent_end` final event; `.messages` holds the completed transcript. + * - `tool_execution_start|update|end` for tool lifecycle. + * + * Oh My Pi shares Pi's documented JSON shape but is registered as its own agent + * with its own parser instance and error patterns. + */ + +import type { AgentError, ToolType } from '../../shared/types'; +import type { AgentOutputParser, ParsedEvent } from './agent-output-parser'; +import { getErrorPatterns, matchErrorPattern } from './error-patterns'; +import { stripAllAnsiCodes } from '../utils/terminalFilter'; + +interface OmpUsage { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + totalTokens?: number; + cost?: number | { total?: number }; +} + +interface OmpContentBlock { + type?: string; + text?: string; + thinking?: string; +} + +interface OmpMessage { + role?: string; + content?: string | OmpContentBlock[]; + usage?: OmpUsage; + errorMessage?: string; +} + +interface OmpMessageDelta { + type?: string; + delta?: string; +} + +interface OmpRawEvent { + type?: string; + id?: string; + sessionId?: string; + session_id?: string; + message?: OmpMessage; + messages?: OmpMessage[]; + assistantMessageEvent?: OmpMessageDelta; + toolCallId?: string; + toolName?: string; + args?: unknown; + partialResult?: unknown; + result?: unknown; + isError?: boolean; + error?: unknown; + messageText?: string; + willRetry?: boolean; +} + +export class OmpOutputParser implements AgentOutputParser { + readonly agentId: ToolType = 'omp'; + + parseJsonLine(line: string): ParsedEvent | null { + if (!line.trim()) { + return null; + } + + try { + return this.parseJsonObject(JSON.parse(line)); + } catch { + return { + type: 'text', + text: line, + raw: line, + }; + } + } + + parseJsonObject(parsed: unknown): ParsedEvent | null { + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const event = parsed as OmpRawEvent; + const sessionId = + event.sessionId || event.session_id || (event.type === 'session' ? event.id : undefined); + + if (event.error || event.message?.errorMessage) { + return { + type: 'error', + text: this.extractErrorText(event), + sessionId, + raw: event, + }; + } + + switch (event.type) { + case 'session': + return { type: 'init', sessionId, raw: event }; + + case 'message_update': { + const update = event.assistantMessageEvent; + if (update?.type === 'text_delta') { + return { + type: 'text', + text: update.delta || '', + sessionId, + isPartial: true, + raw: event, + }; + } + if (update?.type === 'thinking_delta') { + return { + type: 'text', + text: update.delta || '', + sessionId, + isPartial: true, + isReasoning: true, + raw: event, + }; + } + return { type: 'system', sessionId, raw: event }; + } + + case 'message_end': + if (event.message?.role === 'assistant') { + return { + type: 'usage', + sessionId, + usage: this.extractUsageFromMessage(event.message), + raw: event, + }; + } + return { type: 'system', sessionId, raw: event }; + + case 'agent_end': { + if (event.willRetry) { + return { type: 'system', sessionId, raw: event }; + } + const finalMessage = this.findFinalAssistantMessage(event.messages); + if (finalMessage?.errorMessage) { + return { + type: 'error', + text: finalMessage.errorMessage, + sessionId, + raw: event, + }; + } + if (!finalMessage) { + // No assistant message in the final transcript (empty or missing + // `messages`). Return a non-result event so the stdout handler does + // not mark output as emitted with an empty string; the exit fallback + // then flushes the assembled streamed text the user already saw. + return { type: 'system', sessionId, raw: event }; + } + return { + type: 'result', + text: this.extractMessageText(finalMessage), + sessionId, + raw: event, + }; + } + + case 'tool_execution_start': + return { + type: 'tool_use', + toolCallId: event.toolCallId, + toolName: event.toolName, + toolState: { status: 'running', input: event.args }, + sessionId, + raw: event, + }; + + case 'tool_execution_update': + return { + type: 'tool_use', + toolCallId: event.toolCallId, + toolName: event.toolName, + toolState: { status: 'running', output: event.partialResult }, + sessionId, + raw: event, + }; + + case 'tool_execution_end': + return { + type: 'tool_use', + toolCallId: event.toolCallId, + toolName: event.toolName, + toolState: { + status: event.isError ? 'failed' : 'completed', + output: event.result, + }, + sessionId, + raw: event, + }; + + default: + return { type: 'system', sessionId, raw: event }; + } + } + + isResultMessage(event: ParsedEvent): boolean { + return event.type === 'result'; + } + + extractSessionId(event: ParsedEvent): string | null { + return event.sessionId || null; + } + + extractUsage(event: ParsedEvent): ParsedEvent['usage'] | null { + return event.usage || null; + } + + extractSlashCommands(event: ParsedEvent): string[] | null { + return event.slashCommands || null; + } + + detectErrorFromLine(line: string): AgentError | null { + if (!line.trim()) { + return null; + } + + try { + return this.detectErrorFromParsed(JSON.parse(line)); + } catch { + return null; + } + } + + detectErrorFromParsed(parsed: unknown): AgentError | null { + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const event = parsed as OmpRawEvent; + const errorText = this.extractErrorText(event); + if (!errorText) { + return null; + } + + const match = matchErrorPattern(getErrorPatterns(this.agentId), errorText, { minLength: 0 }); + return { + type: match?.type || 'unknown', + message: match?.message || errorText, + recoverable: match?.recoverable ?? true, + agentId: this.agentId, + timestamp: Date.now(), + parsedJson: parsed, + }; + } + + detectErrorFromExit(exitCode: number, stderr: string, stdout: string): AgentError | null { + if (exitCode === 0) { + return null; + } + + const cleanedOutput = stripAllAnsiCodes(`${stderr}\n${stdout}`).trim(); + const match = matchErrorPattern(getErrorPatterns(this.agentId), cleanedOutput, { + minLength: 0, + }); + return { + type: match?.type || 'agent_crashed', + message: + match?.message || + `Oh My Pi exited with code ${exitCode}${cleanedOutput ? `: ${cleanedOutput.split('\n')[0]}` : ''}`, + recoverable: match?.recoverable ?? true, + agentId: this.agentId, + timestamp: Date.now(), + raw: { exitCode, stderr, stdout }, + }; + } + + private extractMessageText(message: OmpMessage): string { + if (typeof message.content === 'string') { + return message.content; + } + if (!Array.isArray(message.content)) { + return ''; + } + return message.content + .filter((block) => block.type === 'text') + .map((block) => block.text || '') + .join(''); + } + + private findFinalAssistantMessage(messages?: OmpMessage[]): OmpMessage | undefined { + if (!messages) { + return undefined; + } + for (let index = messages.length - 1; index >= 0; index--) { + if (messages[index].role === 'assistant') { + return messages[index]; + } + } + return undefined; + } + + private extractUsageFromMessage(message: OmpMessage): ParsedEvent['usage'] | undefined { + const usage = message.usage; + if (!usage) { + return undefined; + } + return { + inputTokens: usage.input || 0, + outputTokens: usage.output || 0, + cacheReadTokens: usage.cacheRead || 0, + cacheCreationTokens: usage.cacheWrite || 0, + costUsd: typeof usage.cost === 'number' ? usage.cost : usage.cost?.total || 0, + }; + } + + private extractErrorText(event: OmpRawEvent): string { + if (event.message?.errorMessage) { + return event.message.errorMessage; + } + const finalMessage = this.findFinalAssistantMessage(event.messages); + if (finalMessage?.errorMessage) { + return finalMessage.errorMessage; + } + if (typeof event.error === 'string') { + return event.error; + } + if (event.error && typeof event.error === 'object') { + const error = event.error as Record; + if (typeof error.errorMessage === 'string') { + return error.errorMessage; + } + if (typeof error.message === 'string') { + return error.message; + } + } + return event.messageText || ''; + } +} diff --git a/src/main/parsers/parser-factory.ts b/src/main/parsers/parser-factory.ts index f2f07096f7..d031fd5057 100644 --- a/src/main/parsers/parser-factory.ts +++ b/src/main/parsers/parser-factory.ts @@ -18,6 +18,7 @@ import { CodexOutputParser } from './codex-output-parser'; import { FactoryDroidOutputParser } from './factory-droid-output-parser'; import { CopilotOutputParser } from './copilot-output-parser'; import { PiOutputParser } from './pi-output-parser'; +import { OmpOutputParser } from './omp-output-parser'; const PARSER_CONSTRUCTORS: Record AgentOutputParser> = { 'claude-code': () => new ClaudeOutputParser(), @@ -26,6 +27,7 @@ const PARSER_CONSTRUCTORS: Record AgentOutputParser> = { 'factory-droid': () => new FactoryDroidOutputParser(), 'copilot-cli': () => new CopilotOutputParser(), pi: () => new PiOutputParser(), + omp: () => new OmpOutputParser(), }; /** diff --git a/src/renderer/components/NewInstanceModal/types.ts b/src/renderer/components/NewInstanceModal/types.ts index 55e8f0b7fe..93e56154b5 100644 --- a/src/renderer/components/NewInstanceModal/types.ts +++ b/src/renderer/components/NewInstanceModal/types.ts @@ -12,6 +12,7 @@ export const SUPPORTED_AGENTS = [ 'codex', 'factory-droid', 'copilot-cli', + 'omp', ]; export interface AgentDebugInfo { diff --git a/src/renderer/constants/agentIcons.ts b/src/renderer/constants/agentIcons.ts index 374130fe22..b4005ef47b 100644 --- a/src/renderer/constants/agentIcons.ts +++ b/src/renderer/constants/agentIcons.ts @@ -44,6 +44,9 @@ export const AGENT_ICONS: Record = { hermes: '⚕', pi: 'π', + // Oh My Pi + omp: '🦉', + // Enterprise 'factory-droid': '🏭', diff --git a/src/shared/agentConstants.ts b/src/shared/agentConstants.ts index bfb00075d6..0b69343589 100644 --- a/src/shared/agentConstants.ts +++ b/src/shared/agentConstants.ts @@ -22,6 +22,7 @@ export const DEFAULT_CONTEXT_WINDOWS: Partial> = { hermes: 200000, // Conservative fallback until runtime-specific reporting lands pi: 200000, // Conservative fallback until runtime-specific reporting lands 'copilot-cli': 200000, // Copilot-CLI (varies by model, defaults to Claude Sonnet) + omp: 200000, // Oh My Pi (fallback until runtime-specific reporting lands) terminal: 0, // Terminal has no context window }; diff --git a/src/shared/agentIds.ts b/src/shared/agentIds.ts index c64bae7517..fb94df89fe 100644 --- a/src/shared/agentIds.ts +++ b/src/shared/agentIds.ts @@ -24,6 +24,7 @@ export const AGENT_IDS = [ 'hermes', 'pi', 'copilot-cli', + 'omp', ] as const; /** diff --git a/src/shared/agentMetadata.ts b/src/shared/agentMetadata.ts index efca2977e8..09dc7250b2 100644 --- a/src/shared/agentMetadata.ts +++ b/src/shared/agentMetadata.ts @@ -25,6 +25,7 @@ export const AGENT_DISPLAY_NAMES: Record = { hermes: 'Hermes', pi: 'Pi', 'copilot-cli': 'Copilot-CLI', + omp: 'Oh My Pi', }; /** @@ -76,6 +77,7 @@ export const BETA_AGENTS: ReadonlySet = new Set([ 'hermes', 'pi', 'copilot-cli', + 'omp', ]); /**