From 19a6de1c479833887a1b8b435add48092b6671ac Mon Sep 17 00:00:00 2001 From: jSydorowicz21 Date: Fri, 26 Jun 2026 14:08:03 -0500 Subject: [PATCH 1/4] feat(agents): add Oh My Pi (omp) agent Adds omp (Oh My Pi, @oh-my-pi/pi-coding-agent) as a real, selectable BETA agent registered everywhere gemini-cli is. Spawns non-interactively as 'omp -p --mode json' with --resume and --model; new custom OmpOutputParser handles omp's JSON event stream (session/message_update[text_delta]/message_end[usage+cost]/agent_end), modeled on the sibling Pi parser. Includes capabilities, error patterns, path discovery, NewInstanceModal entry, README, and tests (parser fixture, definitions, capabilities, metadata, ids); agent-completeness passes. --- README.md | 5 +- .../main/agents/capabilities.test.ts | 19 + src/__tests__/main/agents/definitions.test.ts | 15 + src/__tests__/main/parsers/index.test.ts | 30 +- .../main/parsers/omp-output-parser.test.ts | 83 +++++ src/__tests__/shared/agentIds.test.ts | 1 + src/__tests__/shared/agentMetadata.test.ts | 3 + src/main/agents/capabilities.ts | 34 ++ src/main/agents/definitions.ts | 34 ++ src/main/agents/path-prober.ts | 16 + src/main/parsers/error-patterns.ts | 40 +++ src/main/parsers/index.ts | 3 + src/main/parsers/omp-output-parser.ts | 337 ++++++++++++++++++ src/main/parsers/parser-factory.ts | 2 + .../components/NewInstanceModal/types.ts | 1 + src/renderer/constants/agentIcons.ts | 3 + src/shared/agentConstants.ts | 1 + src/shared/agentIds.ts | 1 + src/shared/agentMetadata.ts | 2 + 19 files changed, 623 insertions(+), 7 deletions(-) create mode 100644 src/__tests__/main/parsers/omp-output-parser.test.ts create mode 100644 src/main/parsers/omp-output-parser.ts 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/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..c9d4d5510c --- /dev/null +++ b/src/__tests__/main/parsers/omp-output-parser.test.ts @@ -0,0 +1,83 @@ +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' }); + }); +}); 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..beeef43f02 100644 --- a/src/main/agents/capabilities.ts +++ b/src/main/agents/capabilities.ts @@ -265,6 +265,40 @@ 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, + 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..762881bb53 100644 --- a/src/main/agents/definitions.ts +++ b/src/main/agents/definitions.ts @@ -421,6 +421,40 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [ }, ], }, + { + id: 'omp', + name: 'Oh My Pi', + binaryName: 'omp', + command: 'omp', + args: [], + batchModePrefix: ['-p'], + jsonOutputArgs: ['--mode', 'json'], + noPromptSeparator: true, + resumeArgs: (sessionId: string) => ['--resume', sessionId], + modelArgs: (modelId: string) => ['--model', modelId], + workingDirArgs: (dir: string) => ['--cwd', dir], + imageArgs: (imagePath: string) => [`@${imagePath}`], + configOptions: [ + { + key: 'model', + type: 'select' as const, + label: 'Model', + description: + 'Fuzzy model selector passed to --model (for example, opus, sonnet, or anthropic/claude-opus-4-8). Leave empty for the CLI default.', + options: ['', 'opus', 'sonnet', 'haiku', 'gpt-5', 'gemini-2.5-pro'], + 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/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..374e5c39ea --- /dev/null +++ b/src/main/parsers/omp-output-parser.ts @@ -0,0 +1,337 @@ +/** + * 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, + }; + } + return { + type: 'result', + text: finalMessage ? 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', ]); /** From 99ad8d11e73c12d878d6825f366f10f36d6b8b8b Mon Sep 17 00:00:00 2001 From: jSydorowicz21 Date: Fri, 26 Jun 2026 14:45:41 -0500 Subject: [PATCH 2/4] fix(agents): omp prompt-separator, tab-naming tools, parser fallback (#omp) Review fixes for the Oh My Pi agent: drop noPromptSeparator so omp uses Maestro's '--' end-of-options separator (verified omp supports it), protecting flag-like prompts while keeping @image args before the separator; add noToolsArgs ['--no-tools'] so tab-naming launches name-only (mirrors Pi); make OmpOutputParser fall back to assembled streamed text when agent_end has no/empty messages. Declined --append-system-prompt (Windows uses --append-system-prompt-file which omp lacks; cross-cutting) and model free-text (SELECT matches gemini/qwen convention); deferred read-only allowlist. --- .../main/parsers/omp-output-parser.test.ts | 27 +++++++++++++++++++ src/main/agents/capabilities.ts | 4 +++ src/main/agents/definitions.ts | 5 +++- src/main/parsers/omp-output-parser.ts | 9 ++++++- 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/__tests__/main/parsers/omp-output-parser.test.ts b/src/__tests__/main/parsers/omp-output-parser.test.ts index c9d4d5510c..70ece887d2 100644 --- a/src/__tests__/main/parsers/omp-output-parser.test.ts +++ b/src/__tests__/main/parsers/omp-output-parser.test.ts @@ -80,4 +80,31 @@ describe('OmpOutputParser', () => { 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/main/agents/capabilities.ts b/src/main/agents/capabilities.ts index beeef43f02..605cd49760 100644 --- a/src/main/agents/capabilities.ts +++ b/src/main/agents/capabilities.ts @@ -295,6 +295,10 @@ export const AGENT_CAPABILITIES: Record = { 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, }, diff --git a/src/main/agents/definitions.ts b/src/main/agents/definitions.ts index 762881bb53..acf5ed17c1 100644 --- a/src/main/agents/definitions.ts +++ b/src/main/agents/definitions.ts @@ -429,8 +429,11 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [ args: [], batchModePrefix: ['-p'], jsonOutputArgs: ['--mode', 'json'], - noPromptSeparator: true, + // 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}`], diff --git a/src/main/parsers/omp-output-parser.ts b/src/main/parsers/omp-output-parser.ts index 374e5c39ea..48f9405267 100644 --- a/src/main/parsers/omp-output-parser.ts +++ b/src/main/parsers/omp-output-parser.ts @@ -156,9 +156,16 @@ export class OmpOutputParser implements AgentOutputParser { 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: finalMessage ? this.extractMessageText(finalMessage) : '', + text: this.extractMessageText(finalMessage), sessionId, raw: event, }; From 6907aee5743bfd4bd7b44cec72f4a4dc09157502 Mon Sep 17 00:00:00 2001 From: jSydorowicz21 Date: Fri, 26 Jun 2026 23:55:08 -0500 Subject: [PATCH 3/4] fix(agents): omp model selector is a free-text fuzzy override, not a fixed dropdown omp --model is fuzzy and multi-provider (opus, gpt-5.2, openai/gpt-5.2, ollama/..., 20+ providers). The hardcoded 6-item select (incl. stale gpt-5) couldn't represent that; switched to type:'text' matching its sibling pi/hermes/opencode/codex agents. --- src/main/agents/definitions.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/agents/definitions.ts b/src/main/agents/definitions.ts index acf5ed17c1..1ccbca1026 100644 --- a/src/main/agents/definitions.ts +++ b/src/main/agents/definitions.ts @@ -440,11 +440,10 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [ configOptions: [ { key: 'model', - type: 'select' as const, + type: 'text', label: 'Model', description: - 'Fuzzy model selector passed to --model (for example, opus, sonnet, or anthropic/claude-opus-4-8). Leave empty for the CLI default.', - options: ['', 'opus', 'sonnet', 'haiku', 'gpt-5', 'gemini-2.5-pro'], + '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()] : []), }, From 9cc36d288e1e25f6dafd0b0e7e58264ba6d4dcfe Mon Sep 17 00:00:00 2001 From: jSydorowicz21 Date: Sat, 27 Jun 2026 00:40:18 -0500 Subject: [PATCH 4/4] feat(agents): populate omp model picker via 'omp models --json' Adds an omp case to runModelDiscovery so the model combobox lists omp's full multi-provider catalog (provider-qualified selectors, de-duplicated). Without it the picker showed no models. Mirrors the OpenCode discovery pattern; parseJsonWithBom for robustness. --- src/__tests__/main/agents/detector.test.ts | 31 ++++++++++++++++++ src/main/agents/detector.ts | 37 ++++++++++++++++++++++ 2 files changed, 68 insertions(+) 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/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);