Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/__tests__/main/agents/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -181,6 +199,7 @@ describe('agent-capabilities', () => {
'opencode',
'factory-droid',
'copilot-cli',
'omp',
];

for (const agentId of knownAgents) {
Expand Down
15 changes: 15 additions & 0 deletions src/__tests__/main/agents/definitions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -163,6 +177,7 @@ describe('agent-definitions', () => {
'opencode',
'gemini-cli',
'copilot-cli',
'omp',
];
for (const agentId of knownAgents) {
const def = getAgentDefinition(agentId);
Expand Down
31 changes: 31 additions & 0 deletions src/__tests__/main/agents/detector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
30 changes: 25 additions & 5 deletions src/__tests__/main/parsers/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
CodexOutputParser,
CopilotOutputParser,
PiOutputParser,
OmpOutputParser,
} from '../../../main/parsers';

describe('parsers/index', () => {
Expand Down Expand Up @@ -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);
});
});

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down
110 changes: 110 additions & 0 deletions src/__tests__/main/parsers/omp-output-parser.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
1 change: 1 addition & 0 deletions src/__tests__/shared/agentIds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
3 changes: 3 additions & 0 deletions src/__tests__/shared/agentMetadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});

Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
38 changes: 38 additions & 0 deletions src/main/agents/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,44 @@ export const AGENT_CAPABILITIES: Record<string, AgentCapabilities> = {
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Expose OMP's read-only tool allowlist

With supportsReadOnlyMode false, Maestro hides the Read-Only toggle for OMP even though OMP accepts a --tools allowlist like the Pi integration uses. Since OMP's default approval mode allows write and exec tools, users running OMP on untrusted prompts have no way to start a read-only turn from the UI; add read-only args such as a read/search tool allowlist and mark this capability true.

Useful? React with 👍 / 👎.

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Add OMP-specific SSH model discovery

I checked discoverModelsRemote in src/main/ipc/handlers/agents.ts: once this capability is true, SSH model refreshes run the generic omp models command and split stdout by line. OMP documents that models prints provider-grouped tables, not one selector per line, so SSH sessions can populate the dropdown with headers or table rows; choosing one saves that row and later passes it to --model. Add an OMP-specific remote discovery path that requests and parses JSON selectors, or disable remote model suggestions for OMP.

Useful? React with 👍 / 👎.

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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use OMP's native append-system-prompt flag

OMP exposes --append-system-prompt, but this capability is set to false, so process:spawn falls back to prepending Maestro's system prompt into the first user message and then skips re-sending it on resumed turns. For OMP agents with nudge or custom system instructions, that makes the instructions part of the conversation transcript instead of system metadata and prevents updates from applying on subsequent resumes; mark this true so the existing spawn path passes OMP's native flag.

Useful? React with 👍 / 👎.

supportsProjectMemory: false,
},

/**
* OpenCode - Open source coding assistant
* https://github.com/opencode-ai/opencode
Expand Down
Loading