From c64b5e981350ffed8640f9f9a0f64314337c6859 Mon Sep 17 00:00:00 2001 From: Nolan Clark Date: Thu, 12 Mar 2026 20:31:57 -0500 Subject: [PATCH 01/15] Feature: Add GH CLI Support --- CLAUDE-AGENTS.md | 24 +- CLAUDE.md | 15 +- CONTRIBUTING.md | 15 +- README.md | 1 + docs/installation.md | 1 + src/__tests__/main/agents/definitions.test.ts | 15 + src/__tests__/main/agents/detector.test.ts | 9 +- .../main/agents/session-storage.test.ts | 62 +++ .../parsers/copilot-output-parser.test.ts | 255 ++++++++++ src/__tests__/main/parsers/index.test.ts | 32 +- .../handlers/StdoutHandler.test.ts | 245 ++++++++++ .../spawners/ChildProcessSpawner.test.ts | 33 ++ .../spawners/PtySpawner.test.ts | 90 ++++ src/__tests__/main/utils/agent-args.test.ts | 11 +- src/__tests__/main/utils/remote-fs.test.ts | 14 + .../renderer/hooks/useAgentListeners.test.ts | 52 ++ src/__tests__/shared/agentIds.test.ts | 4 + src/__tests__/shared/agentMetadata.test.ts | 3 + src/main/agents/capabilities.ts | 33 ++ src/main/agents/definitions.ts | 47 ++ src/main/agents/path-prober.ts | 32 ++ src/main/parsers/agent-output-parser.ts | 5 + src/main/parsers/copilot-output-parser.ts | 349 ++++++++++++++ src/main/parsers/error-patterns.ts | 91 ++++ src/main/parsers/index.ts | 3 + .../process-manager/handlers/StdoutHandler.ts | 199 +++++++- .../spawners/ChildProcessSpawner.ts | 12 +- .../process-manager/spawners/PtySpawner.ts | 16 +- src/main/process-manager/types.ts | 1 + src/main/storage/copilot-session-storage.ts | 445 ++++++++++++++++++ src/main/storage/index.ts | 3 + src/main/utils/agent-args.ts | 6 +- src/main/utils/remote-fs.ts | 48 +- src/renderer/components/NewInstanceModal.tsx | 2 +- .../UsageDashboard/AgentEfficiencyChart.tsx | 1 + .../UsageDashboard/LongestAutoRunsTable.tsx | 1 + .../UsageDashboard/SessionStats.tsx | 1 + .../Wizard/screens/AgentSelectionScreen.tsx | 34 +- src/renderer/constants/agentIcons.ts | 3 + src/renderer/hooks/agent/useAgentListeners.ts | 37 +- src/shared/agentConstants.ts | 1 + src/shared/agentIds.ts | 1 + src/shared/agentMetadata.ts | 2 + 43 files changed, 2188 insertions(+), 66 deletions(-) create mode 100644 src/__tests__/main/parsers/copilot-output-parser.test.ts create mode 100644 src/__tests__/main/process-manager/spawners/PtySpawner.test.ts create mode 100644 src/main/parsers/copilot-output-parser.ts create mode 100644 src/main/storage/copilot-session-storage.ts diff --git a/CLAUDE-AGENTS.md b/CLAUDE-AGENTS.md index 8f1205fd07..70e8e76de1 100644 --- a/CLAUDE-AGENTS.md +++ b/CLAUDE-AGENTS.md @@ -4,13 +4,14 @@ Agent support documentation for the Maestro codebase. For the main guide, see [[ ## Supported Agents -| ID | Name | Status | Notes | -| --------------- | ------------- | ---------- | ---------------------------------------------------------------- | -| `claude-code` | Claude Code | **Active** | Primary agent, `--print --verbose --output-format stream-json` | -| `codex` | Codex | **Active** | Full support, `--json`, YOLO mode default | -| `opencode` | OpenCode | **Active** | Multi-provider support (75+ LLMs), stub provider session storage | -| `factory-droid` | Factory Droid | **Active** | Factory's AI coding assistant, `-o stream-json` | -| `terminal` | Terminal | Internal | Hidden from UI, used for shell sessions | +| ID | Name | Status | Notes | +| --------------- | -------------- | ---------- | ---------------------------------------------------------------- | +| `claude-code` | Claude Code | **Active** | Primary agent, `--print --verbose --output-format stream-json` | +| `codex` | Codex | **Active** | Full support, `--json`, YOLO mode default | +| `opencode` | OpenCode | **Active** | Multi-provider support (75+ LLMs), stub provider session storage | +| `factory-droid` | Factory Droid | **Active** | Factory's AI coding assistant, `-o stream-json` | +| `copilot` | GitHub Copilot | **Beta** | `-p/--prompt`, `--output-format json`, `--resume`, session-state | +| `terminal` | Terminal | Internal | Hidden from UI, used for shell sessions | ## Agent Capabilities @@ -90,6 +91,15 @@ Centralized in `src/shared/agentMetadata.ts` (importable from any process): - **YOLO Mode:** Auto-enabled in batch mode (no flag needed) - **Multi-Provider:** Supports 75+ LLMs including Ollama, LM Studio, llama.cpp +### GitHub Copilot CLI + +- **Binary:** `copilot` +- **JSON Output:** `--output-format json` +- **Batch Mode:** `-p, --prompt ` +- **Resume:** `--continue`, `--resume[=session-id]` +- **Read-only:** Interactive `/plan` only (no verified startup flag) +- **Session Storage:** `~/.copilot/session-state//` + ## Adding New Agents To add support for a new agent: diff --git a/CLAUDE.md b/CLAUDE.md index 12eb0d3b48..92bd01dada 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,13 +97,14 @@ Maestro is an Electron desktop app for managing multiple AI coding assistants si ### Supported Agents -| ID | Name | Status | -| --------------- | ------------- | ---------- | -| `claude-code` | Claude Code | **Active** | -| `codex` | OpenAI Codex | **Active** | -| `opencode` | OpenCode | **Active** | -| `factory-droid` | Factory Droid | **Active** | -| `terminal` | Terminal | Internal | +| ID | Name | Status | +| --------------- | -------------- | ---------- | +| `claude-code` | Claude Code | **Active** | +| `codex` | OpenAI Codex | **Active** | +| `opencode` | OpenCode | **Active** | +| `factory-droid` | Factory Droid | **Active** | +| `copilot` | GitHub Copilot | **Beta** | +| `terminal` | Terminal | Internal | See [[CLAUDE-AGENTS.md]] for capabilities and integration details. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b0687922f8..5e71ecb848 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -644,13 +644,14 @@ Based on capabilities, these UI features are automatically enabled/disabled: ### Supported Agents Reference -| Agent | Resume | Read-Only | JSON | Images | Sessions | Cost | Status | -| ------------- | --------------------- | --------------------------- | ---- | ------ | ----------------------------- | ---------------- | ----------- | -| Claude Code | ✅ `--resume` | ✅ `--permission-mode plan` | ✅ | ✅ | ✅ `~/.claude/` | ✅ | ✅ Complete | -| Codex | ✅ `exec resume` | ✅ `--sandbox read-only` | ✅ | ✅ | ✅ `~/.codex/` | ❌ (tokens only) | ✅ Complete | -| OpenCode | ✅ `--session` | ✅ `--agent plan` | ✅ | ✅ | ✅ `~/.local/share/opencode/` | ✅ | ✅ Complete | -| Factory Droid | ✅ `-s, --session-id` | ✅ (default mode) | ✅ | ✅ | ✅ `~/.factory/` | ❌ (tokens only) | ✅ Complete | -| Gemini CLI | TBD | TBD | TBD | TBD | TBD | ✅ | 📋 Planned | +| Agent | Resume | Read-Only | JSON | Images | Sessions | Cost | Status | +| ------------------ | ---------------------------- | --------------------------- | ---- | ------ | ------------------------------ | ---------------- | ----------- | +| Claude Code | ✅ `--resume` | ✅ `--permission-mode plan` | ✅ | ✅ | ✅ `~/.claude/` | ✅ | ✅ Complete | +| Codex | ✅ `exec resume` | ✅ `--sandbox read-only` | ✅ | ✅ | ✅ `~/.codex/` | ❌ (tokens only) | ✅ Complete | +| OpenCode | ✅ `--session` | ✅ `--agent plan` | ✅ | ✅ | ✅ `~/.local/share/opencode/` | ✅ | ✅ Complete | +| Factory Droid | ✅ `-s, --session-id` | ✅ (default mode) | ✅ | ✅ | ✅ `~/.factory/` | ❌ (tokens only) | ✅ Complete | +| GitHub Copilot CLI | ✅ `--resume` / `--continue` | ⚠️ Interactive `/plan` only | ✅ | ❌ | ✅ `~/.copilot/session-state/` | ❌ | 🧪 Beta | +| Gemini CLI | TBD | TBD | TBD | TBD | TBD | ✅ | 📋 Planned | For detailed implementation guide, see [AGENT_SUPPORT.md](AGENT_SUPPORT.md). diff --git a/README.md b/README.md index 0463ec2a57..6d23667b3a 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,7 @@ npm run dev - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) - Anthropic's AI coding assistant - [OpenAI Codex](https://github.com/openai/codex) - OpenAI's coding agent - [OpenCode](https://github.com/sst/opencode) - Open-source AI coding assistant + - [GitHub Copilot CLI](https://docs.github.com/copilot/how-tos/copilot-cli) - GitHub's terminal coding agent - Git (optional, for git-aware features) ### Essential Keyboard Shortcuts diff --git a/docs/installation.md b/docs/installation.md index 2a7d4b0b0a..dcc585d8c8 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -20,6 +20,7 @@ Download the latest release for your platform from the [Releases](https://github - [Codex](https://github.com/openai/codex) — OpenAI's coding agent (fully integrated) - [OpenCode](https://github.com/sst/opencode) — Open-source AI coding assistant (fully integrated) - [Factory Droid](https://docs.factory.ai/cli) — Factory's AI coding assistant (fully integrated) + - [GitHub Copilot CLI](https://docs.github.com/copilot/how-tos/copilot-cli) — GitHub's terminal coding agent (beta integration) - [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Qwen3 Coder](https://github.com/QwenLM/Qwen-Agent) — Planned support - Git (optional, for git-aware features) diff --git a/src/__tests__/main/agents/definitions.test.ts b/src/__tests__/main/agents/definitions.test.ts index c8772aa3eb..42e9f1495c 100644 --- a/src/__tests__/main/agents/definitions.test.ts +++ b/src/__tests__/main/agents/definitions.test.ts @@ -71,6 +71,13 @@ describe('agent-definitions', () => { expect(opencode?.noPromptSeparator).toBeUndefined(); }); + it('should have copilot configured to use a PTY for interactive sessions', () => { + const copilot = AGENT_DEFINITIONS.find((def) => def.id === 'copilot'); + expect(copilot).toBeDefined(); + expect(copilot?.requiresPty).toBe(true); + expect(copilot?.jsonOutputArgs).toEqual(['--output-format', 'json']); + }); + it('should have opencode with default env vars for YOLO mode and disabled question tool', () => { const opencode = AGENT_DEFINITIONS.find((def) => def.id === 'opencode'); expect(opencode?.defaultEnvVars).toBeDefined(); @@ -196,6 +203,14 @@ describe('agent-definitions', () => { expect(args).toEqual(['-C', '/path/to/project']); }); + it('should use = syntax for Copilot resume args', () => { + const copilot = getAgentDefinition('copilot'); + expect(copilot?.resumeArgs).toBeDefined(); + + const args = copilot?.resumeArgs?.('session-789'); + expect(args).toEqual(['--resume=session-789']); + }); + it('should have imageArgs function for codex', () => { const codex = getAgentDefinition('codex'); expect(codex?.imageArgs).toBeDefined(); diff --git a/src/__tests__/main/agents/detector.test.ts b/src/__tests__/main/agents/detector.test.ts index cfedf5f4f6..f6dc00fe14 100644 --- a/src/__tests__/main/agents/detector.test.ts +++ b/src/__tests__/main/agents/detector.test.ts @@ -278,8 +278,8 @@ describe('agent-detector', () => { const agents = await detector.detectAgents(); - // Should have all 8 agents (terminal, claude-code, codex, gemini-cli, qwen3-coder, opencode, factory-droid, aider) - expect(agents.length).toBe(8); + // Should have all 9 agents (terminal, claude-code, codex, gemini-cli, qwen3-coder, opencode, factory-droid, aider, copilot) + expect(agents.length).toBe(9); const agentIds = agents.map((a) => a.id); expect(agentIds).toContain('terminal'); @@ -290,6 +290,7 @@ describe('agent-detector', () => { expect(agentIds).toContain('opencode'); expect(agentIds).toContain('factory-droid'); expect(agentIds).toContain('aider'); + expect(agentIds).toContain('copilot'); }); it('should mark agents as available when binary is found', async () => { @@ -924,8 +925,8 @@ describe('agent-detector', () => { const result = await detectPromise; expect(result).toBeDefined(); - // Should have all 8 agents (terminal, claude-code, codex, gemini-cli, qwen3-coder, opencode, factory-droid, aider) - expect(result.length).toBe(8); + // Should have all 9 agents (terminal, claude-code, codex, gemini-cli, qwen3-coder, opencode, factory-droid, aider, copilot) + expect(result.length).toBe(9); }); it('should handle very long PATH', async () => { diff --git a/src/__tests__/main/agents/session-storage.test.ts b/src/__tests__/main/agents/session-storage.test.ts index 28ff3f88b3..c809478262 100644 --- a/src/__tests__/main/agents/session-storage.test.ts +++ b/src/__tests__/main/agents/session-storage.test.ts @@ -22,6 +22,7 @@ vi.mock('os', async () => { const mocked = { ...actual, homedir: vi.fn(() => '/tmp/maestro-session-storage-home'), + tmpdir: vi.fn(() => '/tmp'), }; return { ...mocked, @@ -368,6 +369,67 @@ describe('CodexSessionStorage', () => { }); }); +describe('CopilotSessionStorage', () => { + it('should be importable', async () => { + const { CopilotSessionStorage } = await import('../../../main/storage/copilot-session-storage'); + expect(CopilotSessionStorage).toBeDefined(); + }); + + it('should have copilot as agentId', async () => { + const { CopilotSessionStorage } = await import('../../../main/storage/copilot-session-storage'); + const storage = new CopilotSessionStorage(); + expect(storage.agentId).toBe('copilot'); + }); + + it('should return empty results for non-existent projects', async () => { + const { CopilotSessionStorage } = await import('../../../main/storage/copilot-session-storage'); + const storage = new CopilotSessionStorage(); + + const sessions = await storage.listSessions('/test/nonexistent/project'); + expect(sessions).toEqual([]); + + const messages = await storage.readSessionMessages('/test/nonexistent/project', 'session-123'); + expect(messages.messages).toEqual([]); + expect(messages.total).toBe(0); + }); + + it('should return local events path for getSessionPath', async () => { + const { CopilotSessionStorage } = await import('../../../main/storage/copilot-session-storage'); + const storage = new CopilotSessionStorage(); + + const sessionPath = storage.getSessionPath('/test/project', 'session-123'); + expect(sessionPath).toContain('.copilot'); + expect(sessionPath).toContain('session-state'); + expect(sessionPath).toContain('session-123'); + expect(sessionPath).toContain('events.jsonl'); + }); + + it('should return remote events path for getSessionPath with sshConfig', async () => { + const { CopilotSessionStorage } = await import('../../../main/storage/copilot-session-storage'); + const storage = new CopilotSessionStorage(); + + const sessionPath = storage.getSessionPath('/test/project', 'session-123', { + id: 'test-ssh', + name: 'Test SSH Server', + host: 'test-server.example.com', + port: 22, + username: 'testuser', + useSshConfig: false, + enabled: true, + }); + expect(sessionPath).toBe('~/.copilot/session-state/session-123/events.jsonl'); + }); + + it('should report delete as unsupported', async () => { + const { CopilotSessionStorage } = await import('../../../main/storage/copilot-session-storage'); + const storage = new CopilotSessionStorage(); + + const result = await storage.deleteMessagePair('/test/project', 'session-123', 'uuid-456'); + expect(result.success).toBe(false); + expect(result.error).toContain('not supported'); + }); +}); + describe('Storage Module Initialization', () => { it('should export initializeSessionStorages function', async () => { const { initializeSessionStorages } = await import('../../../main/storage/index'); diff --git a/src/__tests__/main/parsers/copilot-output-parser.test.ts b/src/__tests__/main/parsers/copilot-output-parser.test.ts new file mode 100644 index 0000000000..e211ea1bbe --- /dev/null +++ b/src/__tests__/main/parsers/copilot-output-parser.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect } from 'vitest'; +import { CopilotOutputParser } from '../../../main/parsers/copilot-output-parser'; + +describe('CopilotOutputParser', () => { + it('parses final assistant messages as result events', () => { + const parser = new CopilotOutputParser(); + + const event = parser.parseJsonObject({ + type: 'assistant.message', + data: { + content: 'DONE', + phase: 'final_answer', + }, + }); + + expect(event).toEqual( + expect.objectContaining({ + type: 'result', + text: 'DONE', + }) + ); + expect(event && parser.isResultMessage(event)).toBe(true); + }); + + it('treats tool-only final assistant messages as result events', () => { + const parser = new CopilotOutputParser(); + + const event = parser.parseJsonObject({ + type: 'assistant.message', + data: { + content: '', + phase: 'final_answer', + toolRequests: [ + { + toolCallId: 'call_123', + name: 'view', + arguments: { path: '/tmp/project' }, + }, + ], + }, + }); + + expect(event).toEqual( + expect.objectContaining({ + type: 'result', + toolUseBlocks: [ + { + name: 'view', + id: 'call_123', + input: { path: '/tmp/project' }, + }, + ], + }) + ); + expect(event && parser.isResultMessage(event)).toBe(true); + }); + + it('tracks tool request metadata from commentary messages for later tool completion events', () => { + const parser = new CopilotOutputParser(); + + const commentaryEvent = parser.parseJsonObject({ + type: 'assistant.message', + data: { + content: '', + phase: 'commentary', + toolRequests: [ + { + toolCallId: 'call_123', + name: 'view', + arguments: { path: '/tmp/project' }, + }, + ], + }, + }); + + expect(commentaryEvent).toEqual( + expect.objectContaining({ + type: 'text', + isPartial: true, + }) + ); + + const completionEvent = parser.parseJsonObject({ + type: 'tool.execution_complete', + data: { + toolCallId: 'call_123', + success: true, + result: { + content: 'README.md', + }, + }, + }); + + expect(completionEvent).toEqual( + expect.objectContaining({ + type: 'tool_use', + toolName: 'view', + toolState: { + status: 'completed', + output: 'README.md', + }, + }) + ); + }); + + it('parses assistant message deltas as partial text events', () => { + const parser = new CopilotOutputParser(); + + const event = parser.parseJsonObject({ + type: 'assistant.message_delta', + data: { + deltaContent: 'OK', + }, + }); + + expect(event).toEqual( + expect.objectContaining({ + type: 'text', + text: 'OK', + isPartial: true, + }) + ); + }); + + it('tracks tool execution start and completion by toolCallId', () => { + const parser = new CopilotOutputParser(); + + const startEvent = parser.parseJsonObject({ + type: 'tool.execution_start', + data: { + toolCallId: 'call_123', + toolName: 'view', + arguments: { path: '/tmp/project' }, + }, + }); + + expect(startEvent).toEqual( + expect.objectContaining({ + type: 'tool_use', + toolName: 'view', + toolCallId: 'call_123', + toolState: { + status: 'running', + input: { path: '/tmp/project' }, + }, + }) + ); + + const completeEvent = parser.parseJsonObject({ + type: 'tool.execution_complete', + data: { + toolCallId: 'call_123', + success: true, + result: { + content: 'README.md', + }, + }, + }); + + expect(completeEvent).toEqual( + expect.objectContaining({ + type: 'tool_use', + toolName: 'view', + toolCallId: 'call_123', + toolState: { + status: 'completed', + output: 'README.md', + }, + }) + ); + }); + + it('treats failed tool execution as tool state, not a top-level agent error', () => { + const parser = new CopilotOutputParser(); + + const event = parser.parseJsonObject({ + type: 'tool.execution_complete', + data: { + toolCallId: 'call_123', + toolName: 'read_bash', + success: false, + error: + 'Invalid shell ID: $SHELL_2. Please supply a valid shell ID to read output from. ', + }, + }); + + const error = parser.detectErrorFromParsed({ + type: 'tool.execution_complete', + data: { + toolCallId: 'call_123', + toolName: 'read_bash', + success: false, + error: + 'Invalid shell ID: $SHELL_2. Please supply a valid shell ID to read output from. ', + }, + }); + + expect(event).toEqual( + expect.objectContaining({ + type: 'tool_use', + toolCallId: 'call_123', + toolState: { + status: 'failed', + output: + 'Invalid shell ID: $SHELL_2. Please supply a valid shell ID to read output from. ', + }, + }) + ); + expect(error).toBeNull(); + }); + + it('extracts session ids from result events', () => { + const parser = new CopilotOutputParser(); + const event = parser.parseJsonObject({ + type: 'result', + sessionId: '8654632e-5527-4b25-8994-66b1be2c6cc8', + exitCode: 0, + }); + + expect(event?.type).toBe('system'); + expect(event && parser.extractSessionId(event)).toBe('8654632e-5527-4b25-8994-66b1be2c6cc8'); + }); + + it('detects structured error events', () => { + const parser = new CopilotOutputParser(); + const error = parser.detectErrorFromParsed({ + type: 'error', + error: { message: 'Authentication expired. Please run /login.' }, + }); + + expect(error).toEqual( + expect.objectContaining({ + agentId: 'copilot', + message: expect.any(String), + }) + ); + }); + + it('maps no-tty interactive launch failures to a clearer crash message', () => { + const parser = new CopilotOutputParser(); + const error = parser.detectErrorFromExit( + 1, + 'No prompt provided. Run in an interactive terminal or provide a prompt with -p or via standard in.', + '' + ); + + expect(error).toEqual( + expect.objectContaining({ + type: 'agent_crashed', + message: expect.stringContaining('require PTY mode'), + agentId: 'copilot', + }) + ); + }); +}); diff --git a/src/__tests__/main/parsers/index.test.ts b/src/__tests__/main/parsers/index.test.ts index 792f404fea..bf9713c0bf 100644 --- a/src/__tests__/main/parsers/index.test.ts +++ b/src/__tests__/main/parsers/index.test.ts @@ -9,6 +9,7 @@ import { ClaudeOutputParser, OpenCodeOutputParser, CodexOutputParser, + CopilotOutputParser, } from '../../../main/parsers'; describe('parsers/index', () => { @@ -49,21 +50,29 @@ describe('parsers/index', () => { expect(hasOutputParser('factory-droid')).toBe(true); }); - it('should register exactly 4 parsers', () => { + it('should register Copilot parser', () => { + expect(hasOutputParser('copilot')).toBe(false); + + initializeOutputParsers(); + + expect(hasOutputParser('copilot')).toBe(true); + }); + + it('should register exactly 5 parsers', () => { initializeOutputParsers(); const parsers = getAllOutputParsers(); - expect(parsers.length).toBe(4); // Claude, OpenCode, Codex, Factory Droid + expect(parsers.length).toBe(5); // Claude, OpenCode, Codex, Factory Droid, Copilot }); it('should clear existing parsers before registering', () => { // First initialization initializeOutputParsers(); - expect(getAllOutputParsers().length).toBe(4); + expect(getAllOutputParsers().length).toBe(5); - // Second initialization should still have exactly 4 + // Second initialization should still have exactly 5 initializeOutputParsers(); - expect(getAllOutputParsers().length).toBe(4); + expect(getAllOutputParsers().length).toBe(5); }); }); @@ -73,7 +82,7 @@ describe('parsers/index', () => { ensureParsersInitialized(); - expect(getAllOutputParsers().length).toBe(4); + expect(getAllOutputParsers().length).toBe(5); }); it('should be idempotent after first call', () => { @@ -119,6 +128,12 @@ describe('parsers/index', () => { const parser = getOutputParser('unknown'); expect(parser).toBeNull(); }); + + it('should return CopilotOutputParser for copilot', () => { + const parser = getOutputParser('copilot'); + expect(parser).not.toBeNull(); + expect(parser).toBeInstanceOf(CopilotOutputParser); + }); }); describe('parser exports', () => { @@ -136,6 +151,11 @@ describe('parsers/index', () => { const parser = new CodexOutputParser(); expect(parser.agentId).toBe('codex'); }); + + it('should export CopilotOutputParser class', () => { + const parser = new CopilotOutputParser(); + expect(parser.agentId).toBe('copilot'); + }); }); describe('integration', () => { diff --git a/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts b/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts index 9b14d1561d..223cd4deb5 100644 --- a/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts +++ b/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts @@ -42,12 +42,15 @@ vi.mock('../../../../main/parsers/usage-aggregator', () => ({ })); vi.mock('../../../../main/parsers/error-patterns', () => ({ + getErrorPatterns: vi.fn(() => ({})), + matchErrorPattern: vi.fn(() => null), matchSshErrorPattern: vi.fn(() => null), })); // ── Imports (after mocks) ────────────────────────────────────────────────── import { StdoutHandler } from '../../../../main/process-manager/handlers/StdoutHandler'; +import { CopilotOutputParser } from '../../../../main/parsers/copilot-output-parser'; import type { ManagedProcess } from '../../../../main/process-manager/types'; // ── Helpers ──────────────────────────────────────────────────────────────── @@ -207,6 +210,248 @@ describe('StdoutHandler', () => { handler.handleData(sessionId, '\n\n\n'); expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); }); + + it('should process concatenated Copilot JSON objects without newlines', () => { + const parser = new CopilotOutputParser(); + const { handler, bufferManager, emitter, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + const sessionIdSpy = vi.fn(); + emitter.on('session-id', sessionIdSpy); + + const payload = [ + JSON.stringify({ + type: 'assistant.message', + data: { + content: 'Working on it...', + phase: 'commentary', + }, + }), + JSON.stringify({ + type: 'assistant.message', + data: { + content: 'Final answer', + phase: 'final_answer', + }, + }), + JSON.stringify({ + type: 'result', + sessionId: 'copilot-session-123', + exitCode: 0, + }), + ].join(' '); + + handler.handleData(sessionId, payload); + + expect(bufferManager.emitDataBuffered).toHaveBeenCalledWith(sessionId, 'Final answer'); + expect(sessionIdSpy).toHaveBeenCalledWith(sessionId, 'copilot-session-123'); + expect(proc.jsonBuffer).toBe(''); + }); + + it('should buffer partial Copilot JSON objects across chunks', () => { + const parser = new CopilotOutputParser(); + const { handler, bufferManager, emitter, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + const sessionIdSpy = vi.fn(); + emitter.on('session-id', sessionIdSpy); + + const chunkOne = JSON.stringify({ + type: 'assistant.message', + data: { + content: 'Working on it...', + phase: 'commentary', + }, + }); + const chunkTwo = [ + JSON.stringify({ + type: 'assistant.message', + data: { + content: 'Final answer', + phase: 'final_answer', + }, + }), + JSON.stringify({ + type: 'result', + sessionId: 'copilot-session-456', + exitCode: 0, + }), + ].join(''); + + handler.handleData(sessionId, chunkOne.slice(0, 25)); + expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); + expect(proc.jsonBuffer).toBe(chunkOne.slice(0, 25)); + + handler.handleData(sessionId, chunkOne.slice(25) + chunkTwo); + + expect(bufferManager.emitDataBuffered).toHaveBeenCalledWith(sessionId, 'Final answer'); + expect(sessionIdSpy).toHaveBeenCalledWith(sessionId, 'copilot-session-456'); + expect(proc.jsonBuffer).toBe(''); + }); + + it('should discard Copilot preamble noise once JSON output begins', () => { + const parser = new CopilotOutputParser(); + const { handler, bufferManager, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + + handler.handleData( + sessionId, + 'Authenticating...\n' + + JSON.stringify({ + type: 'assistant.message', + data: { + content: 'Final answer', + phase: 'final_answer', + }, + }) + ); + + expect(bufferManager.emitDataBuffered).toHaveBeenNthCalledWith( + 1, + sessionId, + 'Authenticating...' + ); + expect(bufferManager.emitDataBuffered).toHaveBeenNthCalledWith(2, sessionId, 'Final answer'); + expect(proc.jsonBuffer).toBe(''); + }); + + it('should emit non-JSON Copilot output immediately when no JSON payload follows', () => { + const parser = new CopilotOutputParser(); + const { handler, bufferManager, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + + handler.handleData(sessionId, 'Authenticating...'); + + expect(bufferManager.emitDataBuffered).toHaveBeenCalledWith(sessionId, 'Authenticating...'); + expect(proc.jsonBuffer).toBe(''); + }); + + it('should still emit Copilot session IDs from result events with non-zero exit codes', () => { + const parser = new CopilotOutputParser(); + const { handler, emitter, sessionId } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + const sessionIdSpy = vi.fn(); + const errorSpy = vi.fn(); + emitter.on('session-id', sessionIdSpy); + emitter.on('agent-error', errorSpy); + + handler.handleData( + sessionId, + JSON.stringify({ + type: 'result', + sessionId: 'copilot-session-error', + exitCode: 1, + }) + ); + + expect(sessionIdSpy).toHaveBeenCalledWith(sessionId, 'copilot-session-error'); + expect(errorSpy).toHaveBeenCalledTimes(1); + }); + + it('should dedupe Copilot tool starts emitted from tool.execution_start and final toolUseBlocks', () => { + const parser = new CopilotOutputParser(); + const { handler, emitter, sessionId } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + const toolSpy = vi.fn(); + emitter.on('tool-execution', toolSpy); + + handler.handleData( + sessionId, + JSON.stringify({ + type: 'tool.execution_start', + data: { + toolCallId: 'call_123', + toolName: 'view', + arguments: { path: '/tmp/project' }, + }, + }) + ); + + handler.handleData( + sessionId, + JSON.stringify({ + type: 'assistant.message', + data: { + content: 'Done', + phase: 'final_answer', + toolRequests: [ + { + toolCallId: 'call_123', + name: 'view', + arguments: { path: '/tmp/project' }, + }, + ], + }, + }) + ); + + expect(toolSpy).toHaveBeenCalledTimes(1); + expect(toolSpy).toHaveBeenCalledWith( + sessionId, + expect.objectContaining({ + toolName: 'view', + state: { + status: 'running', + input: { path: '/tmp/project' }, + }, + }) + ); + }); + + it('should keep failed Copilot tool executions as tool events instead of agent errors', () => { + const parser = new CopilotOutputParser(); + const { handler, emitter, sessionId } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + const toolSpy = vi.fn(); + const errorSpy = vi.fn(); + emitter.on('tool-execution', toolSpy); + emitter.on('agent-error', errorSpy); + + handler.handleData( + sessionId, + JSON.stringify({ + type: 'tool.execution_complete', + data: { + toolCallId: 'call_456', + toolName: 'read_bash', + success: false, + error: + 'Invalid shell ID: $SHELL_2. Please supply a valid shell ID to read output from. ', + }, + }) + ); + + expect(errorSpy).not.toHaveBeenCalled(); + expect(toolSpy).toHaveBeenCalledWith( + sessionId, + expect.objectContaining({ + state: { + status: 'failed', + output: + 'Invalid shell ID: $SHELL_2. Please supply a valid shell ID to read output from. ', + }, + }) + ); + }); }); // ── Legacy message handling (no outputParser) ────────────────────────── diff --git a/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts b/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts index 83d84e3ecf..6c9b7fb1be 100644 --- a/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts +++ b/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts @@ -94,6 +94,7 @@ vi.mock('../../../../main/process-manager/utils/shellEscape', () => ({ import { ChildProcessSpawner } from '../../../../main/process-manager/spawners/ChildProcessSpawner'; import type { ManagedProcess, ProcessConfig } from '../../../../main/process-manager/types'; import { getAgentCapabilities } from '../../../../main/agents'; +import { buildChildProcessEnv } from '../../../../main/process-manager/utils/envBuilder'; import { buildStreamJsonMessage } from '../../../../main/process-manager/utils/streamJsonBuilder'; import { saveImageToTempFile, @@ -178,6 +179,38 @@ describe('ChildProcessSpawner', () => { expect(proc?.isStreamJsonMode).toBe(true); }); + it('should enable stream-json mode when args contain "--output-format" and "json"', () => { + const { processes, spawner } = createTestContext(); + + spawner.spawn( + createBaseConfig({ + toolType: 'copilot', + command: 'copilot', + args: ['--output-format', 'json'], + prompt: 'test prompt', + }) + ); + + const proc = processes.get('test-session'); + expect(proc?.isStreamJsonMode).toBe(true); + expect(proc?.isBatchMode).toBe(true); + }); + + it('treats --resume= as a resumed session when building env', () => { + const { spawner } = createTestContext(); + + spawner.spawn( + createBaseConfig({ + toolType: 'copilot', + command: 'copilot', + args: ['--output-format', 'json', '--resume=session-123'], + prompt: 'continue', + }) + ); + + expect(buildChildProcessEnv).toHaveBeenCalledWith(undefined, true, undefined); + }); + it('should enable stream-json mode when sendPromptViaStdin is true', () => { const { processes, spawner } = createTestContext(); diff --git a/src/__tests__/main/process-manager/spawners/PtySpawner.test.ts b/src/__tests__/main/process-manager/spawners/PtySpawner.test.ts new file mode 100644 index 0000000000..5618fe924f --- /dev/null +++ b/src/__tests__/main/process-manager/spawners/PtySpawner.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; + +const mockPtySpawn = vi.fn(); + +vi.mock('node-pty', () => ({ + spawn: (...args: unknown[]) => mockPtySpawn(...args), +})); + +vi.mock('../../../../main/utils/logger', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock('../../../../main/process-manager/utils/envBuilder', () => ({ + buildPtyTerminalEnv: vi.fn(() => ({ PATH: '/usr/bin' })), + buildChildProcessEnv: vi.fn(() => ({ PATH: '/usr/bin' })), +})); + +vi.mock('../../../../shared/platformDetection', () => ({ + isWindows: vi.fn(() => true), +})); + +import { PtySpawner } from '../../../../main/process-manager/spawners/PtySpawner'; + +function createMockPtyProcess() { + return { + pid: 12345, + onData: vi.fn(), + onExit: vi.fn(), + write: vi.fn(), + resize: vi.fn(), + }; +} + +describe('PtySpawner', () => { + const originalComSpec = process.env.ComSpec; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'; + mockPtySpawn.mockReturnValue(createMockPtyProcess()); + }); + + afterEach(() => { + if (originalComSpec === undefined) { + delete process.env.ComSpec; + } else { + process.env.ComSpec = originalComSpec; + } + }); + + it('wraps Windows .cmd agents in cmd.exe for PTY launches', () => { + const processes = new Map(); + const emitter = new EventEmitter(); + const bufferManager = { + emitDataBuffered: vi.fn(), + flushDataBuffer: vi.fn(), + }; + const spawner = new PtySpawner(processes as any, emitter, bufferManager as any); + + spawner.spawn({ + sessionId: 'copilot-session', + toolType: 'copilot', + cwd: 'C:\\repo', + command: 'C:\\Users\\nolan\\AppData\\Roaming\\npm\\copilot.cmd', + args: ['--resume', 'session-123'], + requiresPty: true, + }); + + expect(mockPtySpawn).toHaveBeenCalledWith( + 'C:\\Windows\\System32\\cmd.exe', + [ + '/d', + '/s', + '/c', + expect.stringContaining( + 'C:\\Users\\nolan\\AppData\\Roaming\\npm\\copilot.cmd --resume session-123' + ), + ], + expect.objectContaining({ + cwd: 'C:\\repo', + }) + ); + }); +}); diff --git a/src/__tests__/main/utils/agent-args.test.ts b/src/__tests__/main/utils/agent-args.test.ts index a1593e57fe..1e3473d3e0 100644 --- a/src/__tests__/main/utils/agent-args.test.ts +++ b/src/__tests__/main/utils/agent-args.test.ts @@ -86,16 +86,23 @@ describe('buildAgentArgs', () => { }); // -- jsonOutputArgs -- - it('adds jsonOutputArgs when not already present', () => { + it('adds jsonOutputArgs when prompt provided and not already present', () => { const agent = makeAgent({ jsonOutputArgs: ['--format', 'json'] }); - const result = buildAgentArgs(agent, { baseArgs: ['--print'] }); + const result = buildAgentArgs(agent, { baseArgs: ['--print'], prompt: 'hello' }); expect(result).toEqual(['--print', '--format', 'json']); }); + it('does not add jsonOutputArgs for interactive sessions without a prompt', () => { + const agent = makeAgent({ jsonOutputArgs: ['--format', 'json'] }); + const result = buildAgentArgs(agent, { baseArgs: ['--print'] }); + expect(result).toEqual(['--print']); + }); + it('does not duplicate jsonOutputArgs when already present', () => { const agent = makeAgent({ jsonOutputArgs: ['--format', 'json'] }); const result = buildAgentArgs(agent, { baseArgs: ['--print', '--format', 'stream'], + prompt: 'hello', }); // '--format' is already in baseArgs, so jsonOutputArgs should not be added expect(result).toEqual(['--print', '--format', 'stream']); diff --git a/src/__tests__/main/utils/remote-fs.test.ts b/src/__tests__/main/utils/remote-fs.test.ts index 05976e0ba3..c050f4d311 100644 --- a/src/__tests__/main/utils/remote-fs.test.ts +++ b/src/__tests__/main/utils/remote-fs.test.ts @@ -167,6 +167,20 @@ describe('remote-fs', () => { // Path should be properly escaped in the command expect(remoteCommand).toContain("'/path/with spaces/and'\\''quotes'"); }); + + it('expands remote home-relative paths before executing over SSH', async () => { + const deps = createMockDeps({ + stdout: 'file.txt\n', + stderr: '', + exitCode: 0, + }); + + await readDirRemote('~/.copilot/session-state', baseConfig, deps); + + const call = (deps.execSsh as any).mock.calls[0][1]; + const remoteCommand = call[call.length - 1]; + expect(remoteCommand).toContain('"$HOME/.copilot/session-state"'); + }); }); describe('readFileRemote', () => { diff --git a/src/__tests__/renderer/hooks/useAgentListeners.test.ts b/src/__tests__/renderer/hooks/useAgentListeners.test.ts index 532e537fbe..de1f9332fc 100644 --- a/src/__tests__/renderer/hooks/useAgentListeners.test.ts +++ b/src/__tests__/renderer/hooks/useAgentListeners.test.ts @@ -414,6 +414,58 @@ describe('useAgentListeners', () => { expect.any(Number) ); }); + + it('removes a recovered agent error log when successful data resumes', () => { + const deps = createMockDeps(); + const recoveredError: AgentError = { + type: 'permission_denied', + message: 'Permission denied. Check file and directory permissions.', + recoverable: false, + agentId: 'copilot', + timestamp: 1700000000000, + }; + const session = createMockSession({ + id: 'sess-1', + state: 'error', + toolType: 'copilot', + agentError: recoveredError, + agentErrorTabId: 'tab-1', + agentErrorPaused: true, + aiTabs: [ + createMockTab({ + id: 'tab-1', + agentError: recoveredError, + logs: [ + { + id: 'log-error', + timestamp: recoveredError.timestamp, + source: 'error', + text: recoveredError.message, + agentError: recoveredError, + }, + ], + }), + ], + activeTabId: 'tab-1', + }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 'sess-1', + }); + + renderHook(() => useAgentListeners(deps)); + + onDataHandler?.('sess-1-ai-tab-1', 'Final answer'); + + const updated = useSessionStore.getState().sessions.find((s) => s.id === 'sess-1'); + expect(updated?.agentError).toBeUndefined(); + expect(updated?.agentErrorTabId).toBeUndefined(); + expect(updated?.agentErrorPaused).toBe(false); + expect(updated?.state).toBe('busy'); + expect(updated?.aiTabs[0]?.agentError).toBeUndefined(); + expect(updated?.aiTabs[0]?.logs).toEqual([]); + expect(window.maestro.agentError.clearError).toHaveBeenCalledWith('sess-1'); + }); }); // ======================================================================== diff --git a/src/__tests__/shared/agentIds.test.ts b/src/__tests__/shared/agentIds.test.ts index 19bb21d461..d578494dab 100644 --- a/src/__tests__/shared/agentIds.test.ts +++ b/src/__tests__/shared/agentIds.test.ts @@ -27,6 +27,10 @@ describe('agentIds', () => { expect(AGENT_IDS).toContain('aider'); }); + it('should contain beta agents', () => { + expect(AGENT_IDS).toContain('copilot'); + }); + it('should have no duplicates', () => { const unique = new Set(AGENT_IDS); expect(unique.size).toBe(AGENT_IDS.length); diff --git a/src/__tests__/shared/agentMetadata.test.ts b/src/__tests__/shared/agentMetadata.test.ts index 0692cc336d..af0e4d4be4 100644 --- a/src/__tests__/shared/agentMetadata.test.ts +++ b/src/__tests__/shared/agentMetadata.test.ts @@ -30,6 +30,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['aider']).toBe('Aider'); + expect(AGENT_DISPLAY_NAMES['copilot']).toBe('GitHub Copilot'); expect(AGENT_DISPLAY_NAMES['terminal']).toBe('Terminal'); }); @@ -77,6 +78,7 @@ describe('agentMetadata', () => { expect(BETA_AGENTS.has('codex')).toBe(true); expect(BETA_AGENTS.has('opencode')).toBe(true); expect(BETA_AGENTS.has('factory-droid')).toBe(true); + expect(BETA_AGENTS.has('copilot')).toBe(true); }); it('should not contain non-beta agents', () => { @@ -99,6 +101,7 @@ describe('agentMetadata', () => { expect(isBetaAgent('codex')).toBe(true); expect(isBetaAgent('opencode')).toBe(true); expect(isBetaAgent('factory-droid')).toBe(true); + expect(isBetaAgent('copilot')).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 fece986819..d067471175 100644 --- a/src/main/agents/capabilities.ts +++ b/src/main/agents/capabilities.ts @@ -385,6 +385,39 @@ export const AGENT_CAPABILITIES: Record = { usesJsonLineOutput: false, // PLACEHOLDER usesCombinedContextWindow: false, // PLACEHOLDER }, + + /** + * GitHub Copilot CLI - AI coding assistant from GitHub + * https://github.com/github/copilot-cli + * + * Capabilities based on verified CLI help output (copilot --help). + * Conservative approach: only mark capabilities as true if explicitly verified. + */ + copilot: { + supportsResume: true, // --continue, --resume[=sessionId] + supportsReadOnlyMode: false, // Interactive /plan exists, but no verified startup flag for batch mode + supportsJsonOutput: true, // --output-format json (JSONL) + supportsSessionId: true, // result event includes sessionId + supportsImageInput: false, // Not verified in help output + supportsImageInputOnResume: false, // Not verified + supportsSlashCommands: true, // Interactive mode supports slash commands + supportsSessionStorage: true, // ~/.copilot/session-state// + supportsCostTracking: false, // Not verified + supportsUsageStats: false, // Live JSON output reports request stats but not token counts + supportsBatchMode: true, // -p, --prompt for batch mode + requiresPromptToStart: false, // Default interactive mode works without prompt, -i flag allows initial prompt + supportsStreaming: true, // Streams assistant/tool execution events as JSONL + supportsResultMessages: true, // assistant.message with phase=final_answer + supportsModelSelection: true, // --model + supportsStreamJsonInput: false, // Not verified + supportsThinkingDisplay: false, // Reasoning events exist, but we do not render them separately yet + supportsContextMerge: false, // Not verified - PLACEHOLDER + supportsContextExport: false, // Not verified - PLACEHOLDER + supportsWizard: false, // PLACEHOLDER - not verified + supportsGroupChatModeration: false, // PLACEHOLDER - not verified + usesJsonLineOutput: true, // --output-format json produces JSONL + usesCombinedContextWindow: false, // Default Copilot model is Claude Sonnet; model-specific behavior varies + }, }; /** diff --git a/src/main/agents/definitions.ts b/src/main/agents/definitions.ts index 8693830459..1417c57623 100644 --- a/src/main/agents/definitions.ts +++ b/src/main/agents/definitions.ts @@ -399,6 +399,53 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [ command: 'aider', args: [], // Base args (placeholder - to be configured when implemented) }, + { + id: 'copilot', + name: 'GitHub Copilot', + binaryName: 'copilot', + command: 'copilot', + args: [], // Base args for interactive mode (default copilot) + requiresPty: true, // Interactive Copilot exits immediately when launched over plain pipes without a TTY + // GitHub Copilot CLI argument builders + // Interactive mode: copilot (default) + // Interactive with initial prompt: copilot -i "prompt" + // Batch mode: copilot -p "prompt" (or --prompt "prompt") + // Silent/non-interactive: -s, --silent + batchModePrefix: [], // No exec subcommand needed + batchModeArgs: ['--allow-all-tools', '--silent'], // Non-interactive mode requires tool auto-approval + jsonOutputArgs: ['--output-format', 'json'], // JSONL output + resumeArgs: (sessionId: string) => [`--resume=${sessionId}`], // Resume with session ID (--continue or --resume=sessionId) + readOnlyArgs: [], // No verified startup flag for read-only mode (interactive /plan exists but no batch flag confirmed) + readOnlyCliEnforced: false, // No CLI-enforced read-only mode verified from help + modelArgs: (modelId: string) => ['--model', modelId], // Model selection + yoloModeArgs: ['--allow-all-tools'], // Auto-approve all tools (--allow-all-tools or --allow-all) + promptArgs: (prompt: string) => ['-p', prompt], // Batch mode prompt arg + // Agent-specific configuration options + configOptions: [ + { + key: 'model', + type: 'text', + label: 'Model', + description: + 'Model to use (e.g., claude-sonnet-4.6, gpt-5.3-codex). Leave empty for default.', + default: '', // Empty = use Copilot's default model + argBuilder: (value: string) => { + if (value && value.trim()) { + return ['--model', value.trim()]; + } + return []; + }, + }, + { + key: 'contextWindow', + type: 'number', + label: 'Context Window Size', + description: + 'Maximum context window size in tokens. Required for context usage display. Varies by model.', + default: 200000, // Default for Claude/GPT-5 models + }, + ], + }, ]; /** diff --git a/src/main/agents/path-prober.ts b/src/main/agents/path-prober.ts index 689dcf9bc2..98114b1864 100644 --- a/src/main/agents/path-prober.ts +++ b/src/main/agents/path-prober.ts @@ -308,6 +308,25 @@ function getWindowsKnownPaths(binaryName: string): string[] { // npm (has known issues on Windows, but check anyway) ...npmGlobal('opencode'), ], + copilot: [ + // GitHub CLI installation (primary method) + path.join(programFiles, 'GitHub CLI', 'copilot.exe'), + // npm global installation + ...npmGlobal('copilot'), + // Scoop installation + path.join(home, 'scoop', 'shims', 'copilot.exe'), + path.join(home, 'scoop', 'apps', 'copilot', 'current', 'copilot.exe'), + // Chocolatey installation + path.join( + process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', + 'bin', + 'copilot.exe' + ), + // Standalone installation + ...localBin('copilot'), + // Winget + ...wingetLinks('copilot'), + ], gemini: [ // npm global installation ...npmGlobal('gemini'), @@ -410,6 +429,19 @@ function getUnixKnownPaths(binaryName: string): string[] { // Node version managers (nvm, fnm, volta, etc.) ...nodeVersionManagers('opencode'), ], + copilot: [ + // Homebrew installation (primary method on macOS) + ...homebrew('copilot'), + // GitHub CLI installation + '/usr/local/bin/copilot', + path.join(home, '.local', 'bin', 'copilot'), + // npm global + ...npmGlobal('copilot'), + // User bin + path.join(home, 'bin', 'copilot'), + // Node version managers + ...nodeVersionManagers('copilot'), + ], gemini: [ // npm global paths ...npmGlobal('gemini'), diff --git a/src/main/parsers/agent-output-parser.ts b/src/main/parsers/agent-output-parser.ts index 0e5afa5584..205ec801dc 100644 --- a/src/main/parsers/agent-output-parser.ts +++ b/src/main/parsers/agent-output-parser.ts @@ -74,6 +74,11 @@ export interface ParsedEvent { */ toolName?: string; + /** + * Tool call identifier (for agents that provide stable tool call IDs) + */ + toolCallId?: string; + /** * Tool execution state (for 'tool_use' type) * Format varies by agent, preserved for UI rendering diff --git a/src/main/parsers/copilot-output-parser.ts b/src/main/parsers/copilot-output-parser.ts new file mode 100644 index 0000000000..d691067e83 --- /dev/null +++ b/src/main/parsers/copilot-output-parser.ts @@ -0,0 +1,349 @@ +/** + * GitHub Copilot CLI Output Parser + * + * Parses structured output from `copilot --output-format json`. + * + * Verified locally against Copilot CLI 1.0.5 output. The CLI emits JSON + * events, and the live stdout stream may concatenate multiple objects in a + * single chunk without newline separators. The events include: + * - session.tools_updated + * - user.message + * - assistant.turn_start / assistant.turn_end + * - assistant.message + * - assistant.reasoning + * - tool.execution_start / tool.execution_complete + * - result + */ + +import type { ToolType, AgentError } from '../../shared/types'; +import type { AgentOutputParser, ParsedEvent } from './agent-output-parser'; +import { getErrorPatterns, matchErrorPattern } from './error-patterns'; + +interface CopilotToolRequest { + toolCallId?: string; + name?: string; + arguments?: unknown; +} + +interface CopilotToolExecutionResult { + content?: string; + detailedContent?: string; +} + +interface CopilotRawMessage { + type?: string; + id?: string; + timestamp?: string; + sessionId?: string; + exitCode?: number; + data?: { + sessionId?: string; + content?: string; + deltaContent?: string; + phase?: string; + toolRequests?: CopilotToolRequest[]; + toolCallId?: string; + toolName?: string; + arguments?: unknown; + success?: boolean; + result?: CopilotToolExecutionResult; + error?: string; + message?: string; + }; + error?: string | { message?: string }; +} + +function extractErrorText(value: unknown): string | null { + if (!value) return null; + if (typeof value === 'string' && value.trim()) return value.trim(); + if (typeof value === 'object' && value !== null) { + const message = (value as { message?: string }).message; + if (typeof message === 'string' && message.trim()) { + return message.trim(); + } + } + return null; +} + +function extractToolOutput(result: CopilotToolExecutionResult | undefined): string { + if (!result) return ''; + return result.content || result.detailedContent || ''; +} + +export class CopilotOutputParser implements AgentOutputParser { + readonly agentId: ToolType = 'copilot'; + + private toolNames = new Map(); + + 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 msg = parsed as CopilotRawMessage; + + switch (msg.type) { + case 'assistant.message': + return this.parseAssistantMessage(msg); + case 'assistant.message_delta': + return this.parseAssistantMessageDelta(msg); + case 'assistant.reasoning': + case 'assistant.turn_start': + case 'assistant.turn_end': + case 'session.tools_updated': + case 'user.message': + return { + type: 'system', + raw: msg, + }; + case 'session.start': + return { + type: 'init', + sessionId: msg.data?.sessionId, + raw: msg, + }; + case 'tool.execution_start': + return this.parseToolExecutionStart(msg); + case 'tool.execution_complete': + return this.parseToolExecutionComplete(msg); + case 'result': + return { + type: 'system', + sessionId: msg.sessionId, + raw: msg, + }; + case 'error': + return { + type: 'error', + text: + extractErrorText(msg.error || msg.data?.error || msg.data?.message) || 'Unknown error', + raw: msg, + }; + default: + return { + type: 'system', + raw: msg, + }; + } + } + + private parseAssistantMessage(msg: CopilotRawMessage): ParsedEvent { + const content = msg.data?.content || ''; + const phase = msg.data?.phase; + const toolRequests = msg.data?.toolRequests || []; + + const toolUseBlocks = toolRequests + .filter( + (tool): tool is Required> & CopilotToolRequest => + !!tool.name + ) + .map((tool) => { + if (tool.toolCallId && tool.name) { + this.toolNames.set(tool.toolCallId, tool.name); + } + return { + name: tool.name, + id: tool.toolCallId, + input: tool.arguments, + }; + }); + + if (phase === 'final_answer') { + return { + type: 'result', + text: content, + toolUseBlocks: toolUseBlocks.length > 0 ? toolUseBlocks : undefined, + raw: msg, + }; + } + + return { + type: 'text', + text: content, + isPartial: true, + raw: msg, + }; + } + + private parseAssistantMessageDelta(msg: CopilotRawMessage): ParsedEvent | null { + const deltaContent = msg.data?.deltaContent || ''; + if (!deltaContent) { + return null; + } + + return { + type: 'text', + text: deltaContent, + isPartial: true, + raw: msg, + }; + } + + private parseToolExecutionStart(msg: CopilotRawMessage): ParsedEvent { + const callId = msg.data?.toolCallId; + const toolName = msg.data?.toolName; + if (callId && toolName) { + this.toolNames.set(callId, toolName); + } + + return { + type: 'tool_use', + toolName, + toolCallId: callId, + toolState: { + status: 'running', + input: msg.data?.arguments, + }, + raw: msg, + }; + } + + private parseToolExecutionComplete(msg: CopilotRawMessage): ParsedEvent { + const callId = msg.data?.toolCallId; + const toolName = (callId && this.toolNames.get(callId)) || msg.data?.toolName || undefined; + const success = msg.data?.success !== false; + const toolOutput = extractToolOutput(msg.data?.result); + const errorOutput = extractErrorText(msg.data?.error); + + if (callId) { + this.toolNames.delete(callId); + } + + return { + type: 'tool_use', + toolName, + toolCallId: callId, + toolState: { + status: success ? 'completed' : 'failed', + output: toolOutput || (!success ? errorOutput || '' : ''), + }, + raw: msg, + }; + } + + isResultMessage(event: ParsedEvent): boolean { + return event.type === 'result' && (!!event.text || !!event.toolUseBlocks?.length); + } + + extractSessionId(event: ParsedEvent): string | null { + if (event.sessionId) return event.sessionId; + + const raw = event.raw as CopilotRawMessage | undefined; + return raw?.sessionId || raw?.data?.sessionId || null; + } + + extractUsage(event: ParsedEvent): ParsedEvent['usage'] | null { + return event.usage || null; + } + + extractSlashCommands(_event: ParsedEvent): string[] | null { + return null; + } + + detectErrorFromLine(line: string): AgentError | null { + if (!line.trim()) { + return null; + } + + try { + const error = this.detectErrorFromParsed(JSON.parse(line)); + if (error) { + error.raw = { ...(error.raw as Record), errorLine: line }; + } + return error; + } catch { + return null; + } + } + + detectErrorFromParsed(parsed: unknown): AgentError | null { + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const msg = parsed as CopilotRawMessage; + if (msg.type === 'tool.execution_complete') { + return null; + } + + const errorText = + extractErrorText(msg.error) || + extractErrorText(msg.data?.error) || + (msg.type === 'result' && msg.exitCode && msg.exitCode !== 0 + ? `Copilot exited with code ${msg.exitCode}` + : null); + + if (!errorText) { + return null; + } + + const patterns = getErrorPatterns(this.agentId); + const match = matchErrorPattern(patterns, errorText); + + if (match) { + return { + type: match.type, + message: match.message, + recoverable: match.recoverable, + agentId: this.agentId, + timestamp: Date.now(), + parsedJson: parsed, + }; + } + + return { + type: 'unknown', + message: errorText, + 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 combined = `${stderr}\n${stdout}`; + const patterns = getErrorPatterns(this.agentId); + const match = matchErrorPattern(patterns, combined); + + if (match) { + return { + type: match.type, + message: match.message, + recoverable: match.recoverable, + agentId: this.agentId, + timestamp: Date.now(), + raw: { exitCode, stderr, stdout }, + }; + } + + return { + type: 'agent_crashed', + message: `Agent exited with code ${exitCode}`, + recoverable: true, + agentId: this.agentId, + timestamp: Date.now(), + raw: { exitCode, stderr, stdout }, + }; + } +} diff --git a/src/main/parsers/error-patterns.ts b/src/main/parsers/error-patterns.ts index 350733b40e..0a3cdb5e9c 100644 --- a/src/main/parsers/error-patterns.ts +++ b/src/main/parsers/error-patterns.ts @@ -855,6 +855,96 @@ export const SSH_ERROR_PATTERNS: AgentErrorPatterns = { ], }; +// ============================================================================ +// GitHub Copilot CLI Error Patterns +// ============================================================================ + +const COPILOT_ERROR_PATTERNS: AgentErrorPatterns = { + auth_expired: [ + { + pattern: /authentication failed/i, + message: 'Authentication failed. Please run "gh auth login" to re-authenticate.', + recoverable: true, + }, + { + pattern: /not authenticated/i, + message: 'Not authenticated. Please run "gh auth login" to authenticate.', + recoverable: true, + }, + { + pattern: /unauthorized/i, + message: 'Unauthorized. Please check your GitHub authentication.', + recoverable: true, + }, + { + pattern: /invalid.*token/i, + message: 'Invalid GitHub token. Please re-authenticate with "gh auth login".', + recoverable: true, + }, + ], + + rate_limited: [ + { + pattern: /rate limit exceeded/i, + message: 'GitHub API rate limit exceeded. Please wait and try again.', + recoverable: true, + }, + { + pattern: /quota.*exceeded/i, + message: 'API quota exceeded. Resume when quota resets.', + recoverable: true, + }, + ], + + network_error: [ + { + pattern: /connection failed/i, + message: 'Connection failed. Check your internet connection.', + recoverable: true, + }, + { + pattern: /network error/i, + message: 'Network error. Please check your connection.', + recoverable: true, + }, + { + pattern: /timeout/i, + message: 'Request timed out. Please try again.', + recoverable: true, + }, + ], + + permission_denied: [ + { + pattern: /permission denied/i, + message: 'Permission denied. Check file and directory permissions.', + recoverable: false, + }, + ], + + session_not_found: [ + { + pattern: /session.*not found/i, + message: 'Session not found. Starting fresh conversation.', + recoverable: true, + }, + ], + + agent_crashed: [ + { + pattern: /no prompt provided.*interactive terminal/i, + message: + 'GitHub Copilot was launched without a terminal. Interactive Copilot sessions require PTY mode.', + recoverable: true, + }, + { + pattern: /unexpected error/i, + message: 'An unexpected error occurred in the agent.', + recoverable: true, + }, + ], +}; + // ============================================================================ // Pattern Registry // ============================================================================ @@ -864,6 +954,7 @@ const patternRegistry = new Map([ ['opencode', OPENCODE_ERROR_PATTERNS], ['codex', CODEX_ERROR_PATTERNS], ['factory-droid', FACTORY_DROID_ERROR_PATTERNS], + ['copilot', COPILOT_ERROR_PATTERNS], ]); /** diff --git a/src/main/parsers/index.ts b/src/main/parsers/index.ts index d2f55e477f..d61a818075 100644 --- a/src/main/parsers/index.ts +++ b/src/main/parsers/index.ts @@ -54,6 +54,7 @@ import { ClaudeOutputParser } from './claude-output-parser'; import { OpenCodeOutputParser } from './opencode-output-parser'; import { CodexOutputParser } from './codex-output-parser'; import { FactoryDroidOutputParser } from './factory-droid-output-parser'; +import { CopilotOutputParser } from './copilot-output-parser'; import { registerOutputParser, clearParserRegistry, @@ -66,6 +67,7 @@ export { ClaudeOutputParser } from './claude-output-parser'; export { OpenCodeOutputParser } from './opencode-output-parser'; export { CodexOutputParser } from './codex-output-parser'; export { FactoryDroidOutputParser } from './factory-droid-output-parser'; +export { CopilotOutputParser } from './copilot-output-parser'; const LOG_CONTEXT = '[OutputParsers]'; @@ -82,6 +84,7 @@ export function initializeOutputParsers(): void { registerOutputParser(new OpenCodeOutputParser()); registerOutputParser(new CodexOutputParser()); registerOutputParser(new FactoryDroidOutputParser()); + registerOutputParser(new CopilotOutputParser()); // Log registered parsers for debugging const registeredParsers = getAllOutputParsers().map((p) => p.agentId); diff --git a/src/main/process-manager/handlers/StdoutHandler.ts b/src/main/process-manager/handlers/StdoutHandler.ts index 170fb608f9..374e0de876 100644 --- a/src/main/process-manager/handlers/StdoutHandler.ts +++ b/src/main/process-manager/handlers/StdoutHandler.ts @@ -97,6 +97,116 @@ function normalizeUsageToDelta( }; } +function extractConcatenatedJsonObjects(buffer: string): { messages: string[]; remainder: string } { + const messages: string[] = []; + let start = -1; + let depth = 0; + let inString = false; + let isEscaped = false; + + for (let i = 0; i < buffer.length; i++) { + const char = buffer[i]; + + if (start === -1) { + if (/\s/.test(char)) { + continue; + } + + if (char !== '{') { + return { + messages, + remainder: buffer.slice(i), + }; + } + + start = i; + depth = 1; + inString = false; + isEscaped = false; + continue; + } + + if (inString) { + if (isEscaped) { + isEscaped = false; + continue; + } + + if (char === '\\') { + isEscaped = true; + continue; + } + + if (char === '"') { + inString = false; + } + continue; + } + + if (char === '"') { + inString = true; + continue; + } + + if (char === '{') { + depth++; + continue; + } + + if (char === '}') { + depth--; + if (depth === 0) { + messages.push(buffer.slice(start, i + 1)); + start = -1; + } + } + } + + return { + messages, + remainder: start === -1 ? '' : buffer.slice(start), + }; +} + +function extractCopilotSessionId(parsed: unknown): string | null { + if (!parsed || typeof parsed !== 'object') { + return null; + } + + const raw = parsed as { + sessionId?: unknown; + data?: { + sessionId?: unknown; + }; + }; + + if (typeof raw.sessionId === 'string' && raw.sessionId.trim()) { + return raw.sessionId; + } + + if (typeof raw.data?.sessionId === 'string' && raw.data.sessionId.trim()) { + return raw.data.sessionId; + } + + return null; +} + +function getToolStatus(toolState: unknown): string | null { + if (!toolState || typeof toolState !== 'object') { + return null; + } + + const status = (toolState as { status?: unknown }).status; + return typeof status === 'string' ? status : null; +} + +function getEmittedToolCallIds(managedProcess: ManagedProcess): Set { + if (!managedProcess.emittedToolCallIds) { + managedProcess.emittedToolCallIds = new Set(); + } + return managedProcess.emittedToolCallIds; +} + /** * Handles stdout data processing for child processes. * Extracts session IDs, usage stats, and result data from agent output. @@ -146,6 +256,44 @@ export class StdoutHandler { ): void { managedProcess.jsonBuffer = (managedProcess.jsonBuffer || '') + output; + if (managedProcess.toolType === 'copilot') { + const firstNonWhitespaceIndex = managedProcess.jsonBuffer.search(/\S/); + if ( + firstNonWhitespaceIndex >= 0 && + managedProcess.jsonBuffer[firstNonWhitespaceIndex] !== '{' + ) { + const firstJsonStart = managedProcess.jsonBuffer.indexOf('{', firstNonWhitespaceIndex); + if (firstJsonStart === -1) { + const plainText = managedProcess.jsonBuffer.trim(); + if (plainText) { + this.bufferManager.emitDataBuffered(sessionId, plainText); + } + managedProcess.jsonBuffer = ''; + return; + } + + if (firstJsonStart > firstNonWhitespaceIndex) { + const prefix = managedProcess.jsonBuffer.slice(0, firstJsonStart).trim(); + if (prefix) { + this.bufferManager.emitDataBuffered(sessionId, prefix); + } + managedProcess.jsonBuffer = managedProcess.jsonBuffer.slice(firstJsonStart); + } + } + + const { messages, remainder } = extractConcatenatedJsonObjects(managedProcess.jsonBuffer); + managedProcess.jsonBuffer = remainder; + + for (const message of messages) { + managedProcess.stdoutBuffer = appendToBuffer( + managedProcess.stdoutBuffer || '', + message + '\n' + ); + this.processLine(sessionId, managedProcess, message); + } + return; + } + const lines = managedProcess.jsonBuffer.split('\n'); managedProcess.jsonBuffer = lines.pop() || ''; @@ -171,6 +319,10 @@ export class StdoutHandler { // Not valid JSON — handled in the else branch below } + if (parsed !== null && toolType === 'copilot') { + this.emitSessionIdIfNeeded(sessionId, managedProcess, extractCopilotSessionId(parsed)); + } + // ── Error detection from parser ── if (outputParser && !managedProcess.errorEmitted) { // Use pre-parsed object when available; fall back to line-based detection @@ -298,15 +450,7 @@ export class StdoutHandler { // Extract session ID const eventSessionId = outputParser.extractSessionId(event); - if (eventSessionId && !managedProcess.sessionIdEmitted) { - managedProcess.sessionIdEmitted = true; - logger.debug('[ProcessManager] Emitting session-id event', 'ProcessManager', { - sessionId, - eventSessionId, - toolType: managedProcess.toolType, - }); - this.emitter.emit('session-id', sessionId, eventSessionId); - } + this.emitSessionIdIfNeeded(sessionId, managedProcess, eventSessionId); // Extract slash commands const slashCommands = outputParser.extractSlashCommands(event); @@ -338,6 +482,17 @@ export class StdoutHandler { // Handle tool execution events (OpenCode, Codex) if (event.type === 'tool_use' && event.toolName) { + const toolStatus = getToolStatus(event.toolState); + if (event.toolCallId && toolStatus === 'running') { + const emittedToolCallIds = getEmittedToolCallIds(managedProcess); + if (emittedToolCallIds.has(event.toolCallId)) { + return; + } + emittedToolCallIds.add(event.toolCallId); + } else if (event.toolCallId && (toolStatus === 'completed' || toolStatus === 'failed')) { + getEmittedToolCallIds(managedProcess).delete(event.toolCallId); + } + this.emitter.emit('tool-execution', sessionId, { toolName: event.toolName, state: event.toolState, @@ -348,6 +503,14 @@ export class StdoutHandler { // Handle tool_use blocks embedded in text events (Claude Code mixed content) if (event.toolUseBlocks?.length) { for (const tool of event.toolUseBlocks) { + if (tool.id) { + const emittedToolCallIds = getEmittedToolCallIds(managedProcess); + if (emittedToolCallIds.has(tool.id)) { + continue; + } + emittedToolCallIds.add(tool.id); + } + this.emitter.emit('tool-execution', sessionId, { toolName: tool.name, state: { status: 'running', input: tool.input }, @@ -511,4 +674,22 @@ export class StdoutHandler { reasoningTokens: usage.reasoningTokens, }; } + + private emitSessionIdIfNeeded( + sessionId: string, + managedProcess: ManagedProcess, + eventSessionId: string | null | undefined + ): void { + if (!eventSessionId || managedProcess.sessionIdEmitted) { + return; + } + + managedProcess.sessionIdEmitted = true; + logger.debug('[ProcessManager] Emitting session-id event', 'ProcessManager', { + sessionId, + eventSessionId, + toolType: managedProcess.toolType, + }); + this.emitter.emit('session-id', sessionId, eventSessionId); + } } diff --git a/src/main/process-manager/spawners/ChildProcessSpawner.ts b/src/main/process-manager/spawners/ChildProcessSpawner.ts index b66f9c7531..d6b6d611a1 100644 --- a/src/main/process-manager/spawners/ChildProcessSpawner.ts +++ b/src/main/process-manager/spawners/ChildProcessSpawner.ts @@ -208,7 +208,9 @@ export class ChildProcessSpawner { try { // Build environment - const isResuming = finalArgs.includes('--resume') || finalArgs.includes('--session'); + const isResuming = + finalArgs.some((arg) => arg === '--resume' || arg.startsWith('--resume=')) || + finalArgs.includes('--session'); const env = buildChildProcessEnv(customEnvVars, isResuming, shellEnvVars); // Log environment variable application for troubleshooting @@ -337,10 +339,16 @@ export class ChildProcessSpawner { // parser won't process JSON output from remote agents, causing raw JSON to display. // NOTE: sendPromptViaStdinRaw sends RAW text (not JSON), so it should NOT set isStreamJsonMode const argsContain = (pattern: string) => finalArgs.some((arg) => arg.includes(pattern)); + const argsHaveFlagValue = (flag: string, value: string) => + finalArgs.some( + (arg, index) => + arg === `${flag}=${value}` || (arg === flag && finalArgs[index + 1] === value) + ); const isStreamJsonMode = argsContain('stream-json') || argsContain('--json') || - (argsContain('--format') && argsContain('json')) || + argsHaveFlagValue('--format', 'json') || + argsHaveFlagValue('--output-format', 'json') || (hasImages && !!prompt) || !!config.sendPromptViaStdin || !!config.sshStdinScript; diff --git a/src/main/process-manager/spawners/PtySpawner.ts b/src/main/process-manager/spawners/PtySpawner.ts index c3d291951b..cb1f6eec5d 100644 --- a/src/main/process-manager/spawners/PtySpawner.ts +++ b/src/main/process-manager/spawners/PtySpawner.ts @@ -2,9 +2,11 @@ import { EventEmitter } from 'events'; import * as pty from 'node-pty'; import { stripControlSequences } from '../../utils/terminalFilter'; import { logger } from '../../utils/logger'; +import { needsWindowsShell } from '../../utils/execFile'; import type { ProcessConfig, ManagedProcess, SpawnResult } from '../types'; import type { DataBufferManager } from '../handlers/DataBufferManager'; import { buildPtyTerminalEnv, buildChildProcessEnv } from '../utils/envBuilder'; +import { escapeArgsForShell } from '../utils/shellEscape'; import { isWindows } from '../../../shared/platformDetection'; /** @@ -72,8 +74,18 @@ export class PtySpawner { } } else { // Spawn the AI agent directly with PTY support - ptyCommand = command; - ptyArgs = args; + if (isWindows() && needsWindowsShell(command)) { + ptyCommand = process.env.ComSpec || 'cmd.exe'; + ptyArgs = [ + '/d', + '/s', + '/c', + escapeArgsForShell([command, ...args], ptyCommand).join(' '), + ]; + } else { + ptyCommand = command; + ptyArgs = args; + } } // Build environment for PTY process diff --git a/src/main/process-manager/types.ts b/src/main/process-manager/types.ts index 6b2ccb03af..79450b9a6f 100644 --- a/src/main/process-manager/types.ts +++ b/src/main/process-manager/types.ts @@ -67,6 +67,7 @@ export interface ManagedProcess { args?: string[]; lastUsageTotals?: UsageTotals; usageIsCumulative?: boolean; + emittedToolCallIds?: Set; querySource?: 'user' | 'auto'; tabId?: string; projectPath?: string; diff --git a/src/main/storage/copilot-session-storage.ts b/src/main/storage/copilot-session-storage.ts new file mode 100644 index 0000000000..363b19d14c --- /dev/null +++ b/src/main/storage/copilot-session-storage.ts @@ -0,0 +1,445 @@ +import path from 'path'; +import os from 'os'; +import fs from 'fs/promises'; +import { logger } from '../utils/logger'; +import { readFileRemote, readDirRemote, directorySizeRemote } from '../utils/remote-fs'; +import type { + AgentSessionInfo, + SessionMessagesResult, + SessionReadOptions, + SessionMessage, +} from '../agents'; +import type { ToolType, SshRemoteConfig } from '../../shared/types'; +import { BaseSessionStorage } from './base-session-storage'; +import type { SearchableMessage } from './base-session-storage'; + +const LOG_CONTEXT = '[CopilotSessionStorage]'; + +function getLocalCopilotSessionStateDir(): string { + const configDir = process.env.COPILOT_CONFIG_DIR || path.join(os.homedir(), '.copilot'); + return path.join(configDir, 'session-state'); +} + +interface CopilotWorkspaceMetadata { + id: string; + cwd?: string; + git_root?: string; + repository?: string; + branch?: string; + summary?: string; + created_at?: string; + updated_at?: string; +} + +interface CopilotToolRequest { + toolCallId?: string; + name?: string; + arguments?: unknown; +} + +interface CopilotSessionStats { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + durationSeconds: number; +} + +interface ParsedCopilotSessionData { + messages: SessionMessage[]; + firstAssistantMessage: string; + firstUserMessage: string; + stats: CopilotSessionStats; +} + +interface CopilotEvent { + type?: string; + id?: string; + timestamp?: string; + usage?: { + sessionDurationMs?: number; + }; + data?: { + content?: string; + toolRequests?: CopilotToolRequest[]; + sessionDurationMs?: number; + modelMetrics?: Record< + string, + { + usage?: { + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + }; + } + >; + }; +} + +function normalizeYamlScalar(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +function parseSimpleYaml(content: string): Record { + const result: Record = {}; + + for (const line of content.split('\n')) { + if (!line.trim()) continue; + const match = line.match(/^([a-z_]+):\s*(.*)$/); + if (!match) continue; + result[match[1]] = normalizeYamlScalar(match[2]); + } + + return result; +} + +function normalizePath(value?: string): string | null { + if (!value) return null; + return value.replace(/\\/g, '/').replace(/\/+$/, ''); +} + +function matchesProject(metadata: CopilotWorkspaceMetadata, projectPath: string): boolean { + const normalizedProject = normalizePath(projectPath); + const gitRoot = normalizePath(metadata.git_root); + const cwd = normalizePath(metadata.cwd); + + if (!normalizedProject) return true; + return ( + gitRoot === normalizedProject || + cwd === normalizedProject || + cwd?.startsWith(`${normalizedProject}/`) === true + ); +} + +function buildToolUse(toolRequests?: CopilotToolRequest[]): unknown { + if (!toolRequests?.length) return undefined; + return toolRequests + .filter((tool) => tool.name) + .map((tool) => ({ + name: tool.name, + id: tool.toolCallId, + input: tool.arguments, + })); +} + +function parseEvents(content: string): ParsedCopilotSessionData { + const messages: SessionMessage[] = []; + let firstAssistantMessage = ''; + let firstUserMessage = ''; + const stats: CopilotSessionStats = { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + durationSeconds: 0, + }; + + for (const line of content.split('\n')) { + if (!line.trim()) continue; + + try { + const entry = JSON.parse(line) as CopilotEvent; + + if (entry.type === 'user.message') { + const contentText = entry.data?.content || ''; + if (contentText.trim()) { + firstUserMessage ||= contentText; + messages.push({ + type: 'user', + role: 'user', + content: contentText, + timestamp: entry.timestamp || '', + uuid: entry.id || `copilot-user-${messages.length}`, + }); + } + continue; + } + + if (entry.type === 'assistant.message') { + const contentText = entry.data?.content || ''; + const toolUse = buildToolUse(entry.data?.toolRequests); + if (contentText.trim() || toolUse) { + firstAssistantMessage ||= contentText; + messages.push({ + type: 'assistant', + role: 'assistant', + content: contentText, + timestamp: entry.timestamp || '', + uuid: entry.id || `copilot-assistant-${messages.length}`, + toolUse, + }); + } + continue; + } + + if (entry.type === 'session.shutdown') { + const modelMetrics = entry.data?.modelMetrics || {}; + for (const metric of Object.values(modelMetrics)) { + stats.inputTokens += metric.usage?.inputTokens || 0; + stats.outputTokens += metric.usage?.outputTokens || 0; + stats.cacheReadTokens += metric.usage?.cacheReadTokens || 0; + stats.cacheCreationTokens += metric.usage?.cacheWriteTokens || 0; + } + if (entry.data?.sessionDurationMs) { + stats.durationSeconds = Math.max(0, Math.floor(entry.data.sessionDurationMs / 1000)); + } + continue; + } + + if (entry.type === 'result' && entry.usage?.sessionDurationMs) { + stats.durationSeconds = Math.max(0, Math.floor(entry.usage.sessionDurationMs / 1000)); + } + } catch { + // Ignore malformed lines so a single bad event does not hide the whole session. + } + } + + return { + messages, + firstAssistantMessage, + firstUserMessage, + stats, + }; +} + +async function getLocalDirectorySize(sessionDir: string): Promise { + try { + const entries = await fs.readdir(sessionDir, { withFileTypes: true }); + let total = 0; + for (const entry of entries) { + const entryPath = path.join(sessionDir, entry.name); + if (entry.isDirectory()) { + total += await getLocalDirectorySize(entryPath); + } else { + const stat = await fs.stat(entryPath); + total += stat.size; + } + } + return total; + } catch { + return 0; + } +} + +export class CopilotSessionStorage extends BaseSessionStorage { + readonly agentId: ToolType = 'copilot'; + + private getRemoteSessionStateDir(): string { + return '~/.copilot/session-state'; + } + + private getSessionStateDir(sshConfig?: SshRemoteConfig): string { + return sshConfig ? this.getRemoteSessionStateDir() : getLocalCopilotSessionStateDir(); + } + + private getSessionDir(sessionId: string, sshConfig?: SshRemoteConfig): string { + return sshConfig + ? path.posix.join(this.getRemoteSessionStateDir(), sessionId) + : path.join(getLocalCopilotSessionStateDir(), sessionId); + } + + private getWorkspacePath(sessionId: string, sshConfig?: SshRemoteConfig): string { + return sshConfig + ? path.posix.join(this.getSessionDir(sessionId, sshConfig), 'workspace.yaml') + : path.join(this.getSessionDir(sessionId), 'workspace.yaml'); + } + + private getEventsPath(sessionId: string, sshConfig?: SshRemoteConfig): string { + return sshConfig + ? path.posix.join(this.getSessionDir(sessionId, sshConfig), 'events.jsonl') + : path.join(this.getSessionDir(sessionId), 'events.jsonl'); + } + + async listSessions( + projectPath: string, + sshConfig?: SshRemoteConfig + ): Promise { + const sessionIds = await this.listSessionIds(sshConfig); + const sessions = await Promise.all( + sessionIds.map((sessionId) => this.loadSessionInfo(projectPath, sessionId, sshConfig)) + ); + + return sessions + .filter((session): session is AgentSessionInfo => session !== null) + .sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()); + } + + async readSessionMessages( + _projectPath: string, + sessionId: string, + options?: SessionReadOptions, + sshConfig?: SshRemoteConfig + ): Promise { + const eventsContent = await this.readEventsFile(sessionId, sshConfig); + if (!eventsContent) { + return { messages: [], total: 0, hasMore: false }; + } + + const { messages } = parseEvents(eventsContent); + return BaseSessionStorage.applyMessagePagination(messages, options); + } + + protected async getSearchableMessages( + sessionId: string, + _projectPath: string, + sshConfig?: SshRemoteConfig + ): Promise { + const eventsContent = await this.readEventsFile(sessionId, sshConfig); + if (!eventsContent) { + return []; + } + + return parseEvents(eventsContent) + .messages.filter((message) => message.role === 'user' || message.role === 'assistant') + .map((message) => ({ + role: message.role as 'user' | 'assistant', + textContent: message.content, + })) + .filter((message) => message.textContent.trim().length > 0); + } + + getSessionPath( + _projectPath: string, + sessionId: string, + sshConfig?: SshRemoteConfig + ): string | null { + return this.getEventsPath(sessionId, sshConfig); + } + + async deleteMessagePair( + _projectPath: string, + _sessionId: string, + _userMessageUuid: string, + _fallbackContent?: string, + _sshConfig?: SshRemoteConfig + ): Promise<{ success: boolean; error?: string; linesRemoved?: number }> { + return { + success: false, + error: 'Deleting Copilot session history is not supported.', + }; + } + + private async listSessionIds(sshConfig?: SshRemoteConfig): Promise { + const sessionStateDir = this.getSessionStateDir(sshConfig); + if (sshConfig) { + const result = await readDirRemote(sessionStateDir, sshConfig); + if (!result.success || !result.data) { + return []; + } + return result.data.filter((entry) => entry.isDirectory).map((entry) => entry.name); + } + + try { + const entries = await fs.readdir(sessionStateDir, { withFileTypes: true }); + return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name); + } catch { + return []; + } + } + + private async loadSessionInfo( + projectPath: string, + sessionId: string, + sshConfig?: SshRemoteConfig + ): Promise { + const sessionDir = this.getSessionDir(sessionId, sshConfig); + const workspacePath = this.getWorkspacePath(sessionId, sshConfig); + try { + const workspaceContent = sshConfig + ? await this.readRemoteFile(workspacePath, sshConfig) + : await fs.readFile(workspacePath, 'utf8'); + if (!workspaceContent) { + return null; + } + + const parsed = parseSimpleYaml(workspaceContent); + const metadata: CopilotWorkspaceMetadata = { + id: parsed.id || sessionId, + cwd: parsed.cwd, + git_root: parsed.git_root, + repository: parsed.repository, + branch: parsed.branch, + summary: parsed.summary, + created_at: parsed.created_at, + updated_at: parsed.updated_at, + }; + + if (!matchesProject(metadata, projectPath)) { + return null; + } + + const eventsContent = await this.readEventsFile(sessionId, sshConfig); + const parsedEvents = eventsContent ? parseEvents(eventsContent) : null; + const sizeBytes = sshConfig + ? await this.getRemoteDirectorySize(sessionDir, sshConfig) + : await getLocalDirectorySize(sessionDir); + const projectRoot = metadata.git_root || metadata.cwd || projectPath; + const timestamp = metadata.created_at || new Date().toISOString(); + const modifiedAt = metadata.updated_at || timestamp; + const preview = + parsedEvents?.firstAssistantMessage || + parsedEvents?.firstUserMessage || + metadata.summary || + 'Copilot session'; + + return { + sessionId: metadata.id, + projectPath: projectRoot, + timestamp, + modifiedAt, + firstMessage: preview.slice(0, 200), + messageCount: parsedEvents?.messages.length || 0, + sizeBytes, + inputTokens: parsedEvents?.stats.inputTokens || 0, + outputTokens: parsedEvents?.stats.outputTokens || 0, + cacheReadTokens: parsedEvents?.stats.cacheReadTokens || 0, + cacheCreationTokens: parsedEvents?.stats.cacheCreationTokens || 0, + durationSeconds: parsedEvents?.stats.durationSeconds || 0, + }; + } catch (error) { + logger.debug(`Failed to load Copilot session metadata for ${sessionId}`, LOG_CONTEXT, { + error, + }); + return null; + } + } + + private async readEventsFile( + sessionId: string, + sshConfig?: SshRemoteConfig + ): Promise { + const eventsPath = this.getEventsPath(sessionId, sshConfig); + + try { + return sshConfig + ? await this.readRemoteFile(eventsPath, sshConfig) + : await fs.readFile(eventsPath, 'utf8'); + } catch { + return null; + } + } + + private async readRemoteFile( + filePath: string, + sshConfig: SshRemoteConfig + ): Promise { + const result = await readFileRemote(filePath, sshConfig); + return result.success && result.data ? result.data : null; + } + + private async getRemoteDirectorySize( + sessionDir: string, + sshConfig: SshRemoteConfig + ): Promise { + const result = await directorySizeRemote(sessionDir, sshConfig); + return result.success && result.data ? result.data : 0; + } +} diff --git a/src/main/storage/index.ts b/src/main/storage/index.ts index 71981b0301..cf84761f91 100644 --- a/src/main/storage/index.ts +++ b/src/main/storage/index.ts @@ -9,6 +9,7 @@ export { ClaudeSessionStorage, ClaudeSessionOriginsData } from './claude-session export { OpenCodeSessionStorage } from './opencode-session-storage'; export { CodexSessionStorage } from './codex-session-storage'; export { FactoryDroidSessionStorage } from './factory-droid-session-storage'; +export { CopilotSessionStorage } from './copilot-session-storage'; import Store from 'electron-store'; import { registerSessionStorage } from '../agents'; @@ -16,6 +17,7 @@ import { ClaudeSessionStorage, ClaudeSessionOriginsData } from './claude-session import { OpenCodeSessionStorage } from './opencode-session-storage'; import { CodexSessionStorage } from './codex-session-storage'; import { FactoryDroidSessionStorage } from './factory-droid-session-storage'; +import { CopilotSessionStorage } from './copilot-session-storage'; /** * Options for initializing session storages @@ -36,4 +38,5 @@ export function initializeSessionStorages(options?: InitializeSessionStoragesOpt registerSessionStorage(new OpenCodeSessionStorage()); registerSessionStorage(new CodexSessionStorage()); registerSessionStorage(new FactoryDroidSessionStorage()); + registerSessionStorage(new CopilotSessionStorage()); } diff --git a/src/main/utils/agent-args.ts b/src/main/utils/agent-args.ts index 222487120e..a1ffaf74b1 100644 --- a/src/main/utils/agent-args.ts +++ b/src/main/utils/agent-args.ts @@ -66,7 +66,11 @@ export function buildAgentArgs( } } - if (agent.jsonOutputArgs && !finalArgs.some((arg) => agent.jsonOutputArgs!.includes(arg))) { + if ( + agent.jsonOutputArgs && + options.prompt && + !finalArgs.some((arg) => agent.jsonOutputArgs!.includes(arg)) + ) { finalArgs = [...finalArgs, ...agent.jsonOutputArgs]; } diff --git a/src/main/utils/remote-fs.ts b/src/main/utils/remote-fs.ts index ec1fd5dbde..1b3cbfb439 100644 --- a/src/main/utils/remote-fs.ts +++ b/src/main/utils/remote-fs.ts @@ -11,7 +11,7 @@ import { SshRemoteConfig } from '../../shared/types'; import { execFileNoThrow, ExecResult } from './execFile'; -import { shellEscape } from './shell-escape'; +import { shellEscape, shellEscapeForDoubleQuotes } from './shell-escape'; import { sshRemoteManager } from '../ssh-remote-manager'; import { logger } from './logger'; import { resolveSshPath } from './cliDetection'; @@ -191,6 +191,26 @@ async function execRemoteCommand( return lastResult!; } +function shellEscapeRemotePath(filePath: string): string { + if (filePath === '~') { + return '"$HOME"'; + } + + if (filePath.startsWith('~/')) { + return `"$HOME/${shellEscapeForDoubleQuotes(filePath.slice(2))}"`; + } + + if (filePath === '$HOME') { + return '"$HOME"'; + } + + if (filePath.startsWith('$HOME/')) { + return `"$HOME/${shellEscapeForDoubleQuotes(filePath.slice('$HOME/'.length))}"`; + } + + return shellEscape(filePath); +} + /** * Read directory contents from a remote host via SSH. * @@ -217,7 +237,7 @@ export async function readDirRemote( // -F: Append indicator (/ for dirs, @ for symlinks, * for executables) // --color=never: Disable color codes in output // We avoid -l because parsing long format is complex and locale-dependent - const escapedPath = shellEscape(dirPath); + const escapedPath = shellEscapeRemotePath(dirPath); const remoteCommand = `ls -1AF --color=never ${escapedPath} 2>/dev/null || echo "__LS_ERROR__"`; const result = await execRemoteCommand(sshRemote, remoteCommand, deps); @@ -297,7 +317,7 @@ export async function readFileRemote( sshRemote: SshRemoteConfig, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedPath = shellEscape(filePath); + const escapedPath = shellEscapeRemotePath(filePath); // Use cat with explicit error handling const remoteCommand = `cat ${escapedPath}`; @@ -343,7 +363,7 @@ export async function statRemote( sshRemote: SshRemoteConfig, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedPath = shellEscape(filePath); + const escapedPath = shellEscapeRemotePath(filePath); // Use stat with format string: // %s = size in bytes // %F = file type (regular file, directory, symbolic link, etc.) @@ -421,7 +441,7 @@ export async function directorySizeRemote( sshRemote: SshRemoteConfig, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedPath = shellEscape(dirPath); + const escapedPath = shellEscapeRemotePath(dirPath); // Use du with: // -s: summarize (total only) // -b: apparent size in bytes (GNU) @@ -490,7 +510,7 @@ export async function writeFileRemote( sshRemote: SshRemoteConfig, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedPath = shellEscape(filePath); + const escapedPath = shellEscapeRemotePath(filePath); // Use base64 encoding to safely transfer the content // This avoids issues with special characters, quotes, and newlines @@ -532,7 +552,7 @@ export async function existsRemote( sshRemote: SshRemoteConfig, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedPath = shellEscape(remotePath); + const escapedPath = shellEscapeRemotePath(remotePath); const remoteCommand = `test -e ${escapedPath} && echo "EXISTS" || echo "NOT_EXISTS"`; const result = await execRemoteCommand(sshRemote, remoteCommand, deps); @@ -565,7 +585,7 @@ export async function mkdirRemote( recursive: boolean = true, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedPath = shellEscape(dirPath); + const escapedPath = shellEscapeRemotePath(dirPath); const mkdirFlag = recursive ? '-p' : ''; const remoteCommand = `mkdir ${mkdirFlag} ${escapedPath}`; @@ -601,8 +621,8 @@ export async function renameRemote( sshRemote: SshRemoteConfig, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedOldPath = shellEscape(oldPath); - const escapedNewPath = shellEscape(newPath); + const escapedOldPath = shellEscapeRemotePath(oldPath); + const escapedNewPath = shellEscapeRemotePath(newPath); const remoteCommand = `mv ${escapedOldPath} ${escapedNewPath}`; const result = await execRemoteCommand(sshRemote, remoteCommand, deps); @@ -637,7 +657,7 @@ export async function deleteRemote( recursive: boolean = true, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedPath = shellEscape(targetPath); + const escapedPath = shellEscapeRemotePath(targetPath); // Use rm -rf for recursive delete (directories), rm -f for files // The -f flag prevents errors if file doesn't exist const rmFlags = recursive ? '-rf' : '-f'; @@ -673,7 +693,7 @@ export async function countItemsRemote( sshRemote: SshRemoteConfig, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedPath = shellEscape(dirPath); + const escapedPath = shellEscapeRemotePath(dirPath); // Use find to count files and directories separately // -type f for files, -type d for directories (excluding the root dir itself) const remoteCommand = `echo "FILES:$(find ${escapedPath} -type f 2>/dev/null | wc -l)" && echo "DIRS:$(find ${escapedPath} -mindepth 1 -type d 2>/dev/null | wc -l)"`; @@ -745,7 +765,7 @@ export async function incrementalScanRemote( sinceTimestamp: number, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedPath = shellEscape(dirPath); + const escapedPath = shellEscapeRemotePath(dirPath); const scanTime = Math.floor(Date.now() / 1000); // Use find with -newermt to find files modified after the given timestamp @@ -805,7 +825,7 @@ export async function listAllFilesRemote( maxDepth: number = 10, deps: RemoteFsDeps = defaultDeps ): Promise> { - const escapedPath = shellEscape(dirPath); + const escapedPath = shellEscapeRemotePath(dirPath); // Use find with -maxdepth to list all files // Exclude node_modules and __pycache__ diff --git a/src/renderer/components/NewInstanceModal.tsx b/src/renderer/components/NewInstanceModal.tsx index 50b211c9de..d88f2c5cbd 100644 --- a/src/renderer/components/NewInstanceModal.tsx +++ b/src/renderer/components/NewInstanceModal.tsx @@ -77,7 +77,7 @@ interface EditAgentModalProps { } // Supported agents that are fully implemented -const SUPPORTED_AGENTS = ['claude-code', 'opencode', 'codex', 'factory-droid']; +const SUPPORTED_AGENTS = ['claude-code', 'opencode', 'codex', 'factory-droid', 'copilot']; export function NewInstanceModal({ isOpen, diff --git a/src/renderer/components/UsageDashboard/AgentEfficiencyChart.tsx b/src/renderer/components/UsageDashboard/AgentEfficiencyChart.tsx index 2c4b73b3b7..dfafae2157 100644 --- a/src/renderer/components/UsageDashboard/AgentEfficiencyChart.tsx +++ b/src/renderer/components/UsageDashboard/AgentEfficiencyChart.tsx @@ -58,6 +58,7 @@ function formatAgentName(agent: string): string { 'gemini-cli': 'Gemini CLI', 'qwen3-coder': 'Qwen3 Coder', 'factory-droid': 'Factory Droid', + copilot: 'GitHub Copilot', terminal: 'Terminal', }; return names[agent] || agent; diff --git a/src/renderer/components/UsageDashboard/LongestAutoRunsTable.tsx b/src/renderer/components/UsageDashboard/LongestAutoRunsTable.tsx index 9d14323233..60775eda84 100644 --- a/src/renderer/components/UsageDashboard/LongestAutoRunsTable.tsx +++ b/src/renderer/components/UsageDashboard/LongestAutoRunsTable.tsx @@ -75,6 +75,7 @@ function formatAgentName(agentType: string): string { 'gemini-cli': 'Gemini CLI', 'qwen3-coder': 'Qwen3 Coder', 'factory-droid': 'Factory Droid', + copilot: 'GitHub Copilot', terminal: 'Terminal', }; return names[agentType] || agentType; diff --git a/src/renderer/components/UsageDashboard/SessionStats.tsx b/src/renderer/components/UsageDashboard/SessionStats.tsx index e4e6a48388..bccdc79adc 100644 --- a/src/renderer/components/UsageDashboard/SessionStats.tsx +++ b/src/renderer/components/UsageDashboard/SessionStats.tsx @@ -94,6 +94,7 @@ function formatAgentName(toolType: ToolType): string { 'gemini-cli': 'Gemini CLI', 'qwen3-coder': 'Qwen3 Coder', 'factory-droid': 'Factory Droid', + copilot: 'GitHub Copilot', terminal: 'Terminal', }; return names[toolType] || toolType; diff --git a/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx b/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx index c31ca84a64..baee2d158b 100644 --- a/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx +++ b/src/renderer/components/Wizard/screens/AgentSelectionScreen.tsx @@ -38,7 +38,7 @@ export interface AgentTile { /** * Define the agents to display in the grid - * Supported agents: Claude Code, Codex, OpenCode (shown first) + * Supported agents: Claude Code, Codex, OpenCode, Factory Droid, Copilot (shown first) * Unsupported agents: shown ghosted with "Coming soon" (at bottom) */ export const AGENT_TILES: AgentTile[] = [ @@ -71,6 +71,13 @@ export const AGENT_TILES: AgentTile[] = [ description: "Factory's AI coding assistant", brandColor: '#3B82F6', // Factory blue }, + { + id: 'copilot', + name: 'GitHub Copilot', + supported: true, + description: "GitHub's AI coding assistant", + brandColor: '#24292F', // GitHub dark gray + }, // Coming soon agents at the bottom { id: 'gemini-cli', @@ -88,9 +95,9 @@ export const AGENT_TILES: AgentTile[] = [ }, ]; -// Grid dimensions for keyboard navigation (3 cols for 6 items) +// Grid dimensions for keyboard navigation const GRID_COLS = 3; -const GRID_ROWS = 2; +const GRID_ROWS = Math.ceil(AGENT_TILES.length / GRID_COLS); /** * Get SVG logo for an agent with brand colors @@ -275,6 +282,27 @@ export function AgentLogo({ ); + case 'copilot': + return ( + + + + + + + ); + default: return (
diff --git a/src/renderer/constants/agentIcons.ts b/src/renderer/constants/agentIcons.ts index e84a0344d1..5e9be0ffef 100644 --- a/src/renderer/constants/agentIcons.ts +++ b/src/renderer/constants/agentIcons.ts @@ -45,6 +45,9 @@ export const AGENT_ICONS: Record = { // Enterprise 'factory-droid': '🏭', + // GitHub + copilot: '✈️', + // Terminal/shell (internal) terminal: '💻', }; diff --git a/src/renderer/hooks/agent/useAgentListeners.ts b/src/renderer/hooks/agent/useAgentListeners.ts index 6759cdf987..5164a8d2c5 100644 --- a/src/renderer/hooks/agent/useAgentListeners.ts +++ b/src/renderer/hooks/agent/useAgentListeners.ts @@ -132,6 +132,29 @@ export interface UseAgentListenersDeps { // Helpers // ============================================================================ +function isMatchingAgentErrorLog(log: LogEntry, agentError: AgentError): boolean { + if (log.source !== 'error' || !log.agentError) { + return false; + } + + return ( + log.agentError.timestamp === agentError.timestamp && + log.agentError.type === agentError.type && + log.agentError.message === agentError.message && + log.agentError.agentId === agentError.agentId + ); +} + +function removeMatchingAgentErrorLog(logs: LogEntry[], agentError: AgentError): LogEntry[] { + for (let index = logs.length - 1; index >= 0; index -= 1) { + if (isMatchingAgentErrorLog(logs[index], agentError)) { + return [...logs.slice(0, index), ...logs.slice(index + 1)]; + } + } + + return logs; +} + /** * Get a human-readable title for an agent error type. * Used for toast notifications and history entries. @@ -248,11 +271,23 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { // Clear error state if session had an error but is now receiving successful data const sessionForErrorCheck = getSessions().find((s) => s.id === actualSessionId); if (sessionForErrorCheck?.agentError) { + const activeAgentError = sessionForErrorCheck.agentError; + const errorTabId = sessionForErrorCheck.agentErrorTabId ?? targetTabId; + setSessions((prev) => prev.map((s) => { if (s.id !== actualSessionId) return s; const updatedAiTabs = s.aiTabs.map((tab) => - tab.id === targetTabId ? { ...tab, agentError: undefined } : tab + tab.id === targetTabId || tab.id === errorTabId + ? { + ...tab, + logs: + tab.id === errorTabId + ? removeMatchingAgentErrorLog(tab.logs, activeAgentError) + : tab.logs, + agentError: undefined, + } + : tab ); return { ...s, diff --git a/src/shared/agentConstants.ts b/src/shared/agentConstants.ts index 38cc1ba03e..d5724c3bef 100644 --- a/src/shared/agentConstants.ts +++ b/src/shared/agentConstants.ts @@ -18,6 +18,7 @@ export const DEFAULT_CONTEXT_WINDOWS: Partial> = { codex: 200000, // OpenAI o3/o4-mini context window opencode: 128000, // OpenCode (depends on model, 128k is conservative default) 'factory-droid': 200000, // Factory Droid (varies by model, defaults to Claude Opus) + copilot: 200000, // GitHub Copilot (varies by model, defaults to Claude Sonnet) terminal: 0, // Terminal has no context window }; diff --git a/src/shared/agentIds.ts b/src/shared/agentIds.ts index c6716d4dfd..673efadb61 100644 --- a/src/shared/agentIds.ts +++ b/src/shared/agentIds.ts @@ -22,6 +22,7 @@ export const AGENT_IDS = [ 'opencode', 'factory-droid', 'aider', + 'copilot', ] as const; /** diff --git a/src/shared/agentMetadata.ts b/src/shared/agentMetadata.ts index 81b24d9f3d..3025104a52 100644 --- a/src/shared/agentMetadata.ts +++ b/src/shared/agentMetadata.ts @@ -21,6 +21,7 @@ export const AGENT_DISPLAY_NAMES: Record = { opencode: 'OpenCode', 'factory-droid': 'Factory Droid', aider: 'Aider', + copilot: 'GitHub Copilot', }; /** @@ -42,6 +43,7 @@ export const BETA_AGENTS: ReadonlySet = new Set([ 'codex', 'opencode', 'factory-droid', + 'copilot', ]); /** From f405f9df70c0c9f3818382bcdb167cc9101c2cce Mon Sep 17 00:00:00 2001 From: Nolan Clark Date: Thu, 12 Mar 2026 20:39:06 -0500 Subject: [PATCH 02/15] Format doc --- docs/releases.md | 550 ++++++++++++++++++++++++----------------------- 1 file changed, 282 insertions(+), 268 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index d053eb9477..81e0e7f113 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -17,60 +17,60 @@ Maestro can update itself automatically! This feature was introduced in **v0.8.7 **Latest: v0.15.2** | Released March 12, 2026 -# Major 0.15.x Additions - -🎶 **Maestro Symphony** — Contribute to open source with AI assistance! Browse curated issues from projects with the `runmaestro.ai` label, clone repos with one click, and automatically process the relevant Auto Run playbooks. Track your contributions, streaks, and stats. You're contributing CPU and tokens towards your favorite open source projects and features. - -🎬 **Director's Notes** — Aggregates history across all agents into a unified timeline with search, filters, and an activity graph. Includes an AI Overview tab that generates a structured synopsis of recent work. Off by default, gated behind a new "Encore Features" panel under settings. This is a precursor to an eventual plugin system, allowing for extensions and customizations without bloating the core app. - -🏷️ **Conductor Profile** — Available under Settings > General. Provide a short description on how Maestro agents should interface with you. - -🧠 **Three-State Thinking Toggle** — The thinking toggle now cycles through three modes: off, on, and sticky. Sticky mode keeps thinking content visible after the response completes. Cycle with CMD/CTRL+SHIFT+K. - -🤖 **Factory.ai Droid Support** — Added support for the [Factory.ai](https://factory.ai/product/cli) droid agent. Full session management and output parsing integration. - -## Change in v0.15.2 - -Patch release with bug fixes, UX improvements, and cherry-picks from the 0.16.0 RC. - -### New Features - -- **Cmd+0 → Last Tab:** Remapped Cmd+0 to jump to last tab; Cmd+Shift+0 now resets font size -- **Unsent draft protection:** Confirm dialog before closing tabs with unsent draft input -- **Read-only CLI flag:** Added `--read-only` flag to `maestro-cli send` command -- **Gemini read-only enforcement:** Gemini `-y` flag now works in read-only mode -- **Capability-based providers:** Replaced hardcoded agent ID checks with capability flags and shared metadata - -### Bug Fixes - -- **Sticky overlay scroll:** Fixed sticky overlays breaking tab scroll-into-view -- **Director's Notes stats:** Count only agents with entries in lookback window -- **SSH remote config:** Check `sessionSshRemoteConfig` as primary SSH remote ID source -- **.maestro file tree:** Always show .maestro directory even when dotfiles are hidden -- **Provider hardening:** Prototype safety, capability gates, stale map cleanup -- **Session search:** Per-session error resilience and metadata-based title matching -- **File tree stale loads:** Load sequence counter prevents stale file tree updates -- **File tree Unicode:** NFC normalization prevents duplicate entries -- **File tree duplicates:** Tree-structured data resolves duplicate entries -- **File tree auto-refresh:** Timer no longer destroyed on right panel tab switch -- **Menu z-index:** Branding header menu renders above sidebar content -- **Dropdown clipping:** Fixed hamburger menu and live overlay dropdown clipping -- **Font size shortcuts:** Restored Cmd+/- font size shortcuts lost with custom menu -- **Draft input preservation:** Replaying a previous message no longer discards current draft -- **SSH directory collision:** Skip warning when agents are on different SSH hosts -- **IPC error handling:** Handle expected IPC errors gracefully -- **Auto-focus on mode switch:** Input field auto-focuses when toggling AI/Shell mode -- **OpenCode parser:** Preserve JSON error events; reset resultEmitted on step_start -- **NDJSON performance:** Eliminated triple JSON parsing on hot path -- **Agent config overrides:** Apply config overrides in context groomer before spawning -- **Stale closure fix:** Resolved model not saving in wizard agent config - -### Visual Polish - -- **Light theme contrast:** Improved syntax highlighting contrast across all light themes -- **Context warning sash:** Dark text colors in light mode for readability -- **Session name dimming:** Use `textMain` color to prevent visual dimming -- **Session name pill:** Allow shrinking so date doesn't collide with type pill +# Major 0.15.x Additions + +🎶 **Maestro Symphony** — Contribute to open source with AI assistance! Browse curated issues from projects with the `runmaestro.ai` label, clone repos with one click, and automatically process the relevant Auto Run playbooks. Track your contributions, streaks, and stats. You're contributing CPU and tokens towards your favorite open source projects and features. + +🎬 **Director's Notes** — Aggregates history across all agents into a unified timeline with search, filters, and an activity graph. Includes an AI Overview tab that generates a structured synopsis of recent work. Off by default, gated behind a new "Encore Features" panel under settings. This is a precursor to an eventual plugin system, allowing for extensions and customizations without bloating the core app. + +🏷️ **Conductor Profile** — Available under Settings > General. Provide a short description on how Maestro agents should interface with you. + +🧠 **Three-State Thinking Toggle** — The thinking toggle now cycles through three modes: off, on, and sticky. Sticky mode keeps thinking content visible after the response completes. Cycle with CMD/CTRL+SHIFT+K. + +🤖 **Factory.ai Droid Support** — Added support for the [Factory.ai](https://factory.ai/product/cli) droid agent. Full session management and output parsing integration. + +## Change in v0.15.2 + +Patch release with bug fixes, UX improvements, and cherry-picks from the 0.16.0 RC. + +### New Features + +- **Cmd+0 → Last Tab:** Remapped Cmd+0 to jump to last tab; Cmd+Shift+0 now resets font size +- **Unsent draft protection:** Confirm dialog before closing tabs with unsent draft input +- **Read-only CLI flag:** Added `--read-only` flag to `maestro-cli send` command +- **Gemini read-only enforcement:** Gemini `-y` flag now works in read-only mode +- **Capability-based providers:** Replaced hardcoded agent ID checks with capability flags and shared metadata + +### Bug Fixes + +- **Sticky overlay scroll:** Fixed sticky overlays breaking tab scroll-into-view +- **Director's Notes stats:** Count only agents with entries in lookback window +- **SSH remote config:** Check `sessionSshRemoteConfig` as primary SSH remote ID source +- **.maestro file tree:** Always show .maestro directory even when dotfiles are hidden +- **Provider hardening:** Prototype safety, capability gates, stale map cleanup +- **Session search:** Per-session error resilience and metadata-based title matching +- **File tree stale loads:** Load sequence counter prevents stale file tree updates +- **File tree Unicode:** NFC normalization prevents duplicate entries +- **File tree duplicates:** Tree-structured data resolves duplicate entries +- **File tree auto-refresh:** Timer no longer destroyed on right panel tab switch +- **Menu z-index:** Branding header menu renders above sidebar content +- **Dropdown clipping:** Fixed hamburger menu and live overlay dropdown clipping +- **Font size shortcuts:** Restored Cmd+/- font size shortcuts lost with custom menu +- **Draft input preservation:** Replaying a previous message no longer discards current draft +- **SSH directory collision:** Skip warning when agents are on different SSH hosts +- **IPC error handling:** Handle expected IPC errors gracefully +- **Auto-focus on mode switch:** Input field auto-focuses when toggling AI/Shell mode +- **OpenCode parser:** Preserve JSON error events; reset resultEmitted on step_start +- **NDJSON performance:** Eliminated triple JSON parsing on hot path +- **Agent config overrides:** Apply config overrides in context groomer before spawning +- **Stale closure fix:** Resolved model not saving in wizard agent config + +### Visual Polish + +- **Light theme contrast:** Improved syntax highlighting contrast across all light themes +- **Context warning sash:** Dark text colors in light mode for readability +- **Session name dimming:** Use `textMain` color to prevent visual dimming +- **Session name pill:** Allow shrinking so date doesn't collide with type pill - **Scroll-to-bottom arrow:** Removed noisy indicator from terminal output view ### Previous Releases in this Series @@ -83,41 +83,41 @@ Patch release with bug fixes, UX improvements, and cherry-picks from the 0.16.0 **Latest: v0.14.5** | Released January 24, 2026 -Changes in this point release include: - -- Desktop app performance improvements (more to come on this, we want Maestro blazing fast) 🐌 -- Added local manifest feature for custom playbooks 📖 -- Agents are now inherently aware of your activity history as seen in the history panel 📜 (this is built-in cross context memory!) -- Added markdown rendering support for AI responses in mobile view 📱 -- Bugfix in tracking costs from JSONL files that were aged out 🏦 -- Added BlueSky social media handle for leaderboard 🦋 -- Added options to disable GPU rendering and confetti 🎊 -- Better handling of large files in preview 🗄️ -- Bug fix in Claude context calculation 🧮 -- Addressed bug in OpenSpec version reporting 🐛 - -The major contributions to 0.14.x remain: - -🗄️ Document Graphs. Launch from file preview or from the FIle tree panel. Explore relationships between Markdown documents that contain links between documents and to URLs. - -📶 SSH support for agents. Manage a remote agent with feature parity over SSH. Includes support for Git and File tree panels. Manage agents on remote systems or in containers. This even works for Group Chat, which is rad as hell. - -🧙‍♂️ Added an in-tab wizard for generating Auto Run Playbooks via `/wizard` or a new button in the Auto Run panel. - -# Smaller Changes in 014.x - -- Improved User Dashboard, available from hamburger menu, command palette or hotkey 🎛️ -- Leaderboard tracking now works across multiple systems and syncs level from cloud 🏆 -- Agent duplication. Pro tip: Consider a group of unused "Template" agents ✌️ -- New setting to prevent system from going to sleep while agents are active 🛏️ -- The tab menu has a new "Publish as GitHub Gist" option 📝 -- The tab menu has options to move the tab to the first or last position 🔀 -- [Maestro-Playbooks](https://github.com/pedramamini/Maestro-Playbooks) can now contain non-markdown assets 📙 -- Improved default shell detection 🐚 -- Added logic to prevent overlapping TTS notifications 💬 -- Added "Toggle Bookmark" shortcut (CTRL/CMD+SHIFT+B) ⌨️ -- Gist publishing now shows previous URLs with copy button 📋 - +Changes in this point release include: + +- Desktop app performance improvements (more to come on this, we want Maestro blazing fast) 🐌 +- Added local manifest feature for custom playbooks 📖 +- Agents are now inherently aware of your activity history as seen in the history panel 📜 (this is built-in cross context memory!) +- Added markdown rendering support for AI responses in mobile view 📱 +- Bugfix in tracking costs from JSONL files that were aged out 🏦 +- Added BlueSky social media handle for leaderboard 🦋 +- Added options to disable GPU rendering and confetti 🎊 +- Better handling of large files in preview 🗄️ +- Bug fix in Claude context calculation 🧮 +- Addressed bug in OpenSpec version reporting 🐛 + +The major contributions to 0.14.x remain: + +🗄️ Document Graphs. Launch from file preview or from the FIle tree panel. Explore relationships between Markdown documents that contain links between documents and to URLs. + +📶 SSH support for agents. Manage a remote agent with feature parity over SSH. Includes support for Git and File tree panels. Manage agents on remote systems or in containers. This even works for Group Chat, which is rad as hell. + +🧙‍♂️ Added an in-tab wizard for generating Auto Run Playbooks via `/wizard` or a new button in the Auto Run panel. + +# Smaller Changes in 014.x + +- Improved User Dashboard, available from hamburger menu, command palette or hotkey 🎛️ +- Leaderboard tracking now works across multiple systems and syncs level from cloud 🏆 +- Agent duplication. Pro tip: Consider a group of unused "Template" agents ✌️ +- New setting to prevent system from going to sleep while agents are active 🛏️ +- The tab menu has a new "Publish as GitHub Gist" option 📝 +- The tab menu has options to move the tab to the first or last position 🔀 +- [Maestro-Playbooks](https://github.com/pedramamini/Maestro-Playbooks) can now contain non-markdown assets 📙 +- Improved default shell detection 🐚 +- Added logic to prevent overlapping TTS notifications 💬 +- Added "Toggle Bookmark" shortcut (CTRL/CMD+SHIFT+B) ⌨️ +- Gist publishing now shows previous URLs with copy button 📋 + Thanks for the contributions: @t1mmen @aejfager @Crumbgrabber @whglaser @b3nw @deandebeer @shadown @breki @charles-dyfis-net @ronaldeddings @jlengrand @ksylvan ### Previous Releases in this Series @@ -136,20 +136,22 @@ Thanks for the contributions: @t1mmen @aejfager @Crumbgrabber @whglaser @b3nw @d ### Changes -- TAKE TWO! Fixed Linux ARM64 build architecture contamination issues 🏗️ - -### v0.13.1 Changes -- Fixed Linux ARM64 build architecture contamination issues 🏗️ -- Enhanced error handling for Auto Run batch processing 🚨 - -### v0.13.0 Changes -- Added a global usage dashboard, data collection begins with this install 🎛️ -- Added a Playbook Exchange for downloading pre-defined Auto Run playbooks from [Maestro-Playbooks](https://github.com/pedramamini/Maestro-Playbooks) 📕 -- Bundled OpenSpec commands for structured change proposals 📝 -- Added pre-release channel support for beta/RC updates 🧪 -- Implemented global hands-on time tracking across sessions ⏱️ -- Added new keyboard shortcut for agent settings (Opt+Cmd+, | Ctrl+Alt+,) ⌨️ -- Added directory size calculation with file/folder counts in file explorer 📊 +- TAKE TWO! Fixed Linux ARM64 build architecture contamination issues 🏗️ + +### v0.13.1 Changes + +- Fixed Linux ARM64 build architecture contamination issues 🏗️ +- Enhanced error handling for Auto Run batch processing 🚨 + +### v0.13.0 Changes + +- Added a global usage dashboard, data collection begins with this install 🎛️ +- Added a Playbook Exchange for downloading pre-defined Auto Run playbooks from [Maestro-Playbooks](https://github.com/pedramamini/Maestro-Playbooks) 📕 +- Bundled OpenSpec commands for structured change proposals 📝 +- Added pre-release channel support for beta/RC updates 🧪 +- Implemented global hands-on time tracking across sessions ⏱️ +- Added new keyboard shortcut for agent settings (Opt+Cmd+, | Ctrl+Alt+,) ⌨️ +- Added directory size calculation with file/folder counts in file explorer 📊 - Added sleep detection to exclude laptop sleep from time tracking ⏰ ### Previous Releases in this Series @@ -163,22 +165,26 @@ Thanks for the contributions: @t1mmen @aejfager @Crumbgrabber @whglaser @b3nw @d **Latest: v0.12.3** | Released December 28, 2025 -The big changes in the v0.12.x line are the following three: - -## Show Thinking -🤔 There is now a toggle to show thinking for the agent, the default for new tabs is off, though this can be changed under Settings > General. The toggle shows next to History and Read-Only. Very similar pattern. This has been the #1 most requested feature, though personally, I don't think I'll use it as I prefer to not see the details of the work, but the results of the work. Just as we work with our colleagues. - -## GitHub Spec-Kit Integration -🎯 Added [GitHub Spec-Kit](https://github.com/github/spec-kit) commands into Maestro with a built in updater to grab the latest prompts from the repository. We do override `/speckit-implement` (the final step) to create Auto Run docs and guide the user through their execution, which thanks to Wortrees from v0.11.x allows us to run in parallel! - -## Context Management Tools -📖 Added context management options from tab right-click menu. You can now compress, merge, and transfer contexts between agents. You will received (configurable) warnings at 60% and 80% context consumption with a hint to compact. - -## Changes Specific to v0.12.3: -- We now have hosted documentation through Mintlify 📚 -- Export any tab conversation as self-contained themed HTML file 📄 -- Publish files as private/public Gists 🌐 -- Added tab hover overlay menu with close operations and export 📋 +The big changes in the v0.12.x line are the following three: + +## Show Thinking + +🤔 There is now a toggle to show thinking for the agent, the default for new tabs is off, though this can be changed under Settings > General. The toggle shows next to History and Read-Only. Very similar pattern. This has been the #1 most requested feature, though personally, I don't think I'll use it as I prefer to not see the details of the work, but the results of the work. Just as we work with our colleagues. + +## GitHub Spec-Kit Integration + +🎯 Added [GitHub Spec-Kit](https://github.com/github/spec-kit) commands into Maestro with a built in updater to grab the latest prompts from the repository. We do override `/speckit-implement` (the final step) to create Auto Run docs and guide the user through their execution, which thanks to Wortrees from v0.11.x allows us to run in parallel! + +## Context Management Tools + +📖 Added context management options from tab right-click menu. You can now compress, merge, and transfer contexts between agents. You will received (configurable) warnings at 60% and 80% context consumption with a hint to compact. + +## Changes Specific to v0.12.3: + +- We now have hosted documentation through Mintlify 📚 +- Export any tab conversation as self-contained themed HTML file 📄 +- Publish files as private/public Gists 🌐 +- Added tab hover overlay menu with close operations and export 📋 - Added social handles to achievement share images 🏆 ### Previous Releases in this Series @@ -192,12 +198,12 @@ The big changes in the v0.12.x line are the following three: **Latest: v0.11.0** | Released December 22, 2025 -🌳 Github Worktree support was added. Any agent bound to a Git repository has the option to enable worktrees, each of which show up as a sub-agent with their own write-lock and Auto Run capability. Now you can truly develop in parallel on the same project and issue PRs when you're ready, all from within Maestro. Huge improvement, major thanks to @petersilberman. - -# Other Changes - -- @ file mentions now include documents from your Auto Run folder (which may not live in your agent working directory) 🗄️ -- The wizard is now capable of detecting and continuing on past started projects 🧙 +🌳 Github Worktree support was added. Any agent bound to a Git repository has the option to enable worktrees, each of which show up as a sub-agent with their own write-lock and Auto Run capability. Now you can truly develop in parallel on the same project and issue PRs when you're ready, all from within Maestro. Huge improvement, major thanks to @petersilberman. + +# Other Changes + +- @ file mentions now include documents from your Auto Run folder (which may not live in your agent working directory) 🗄️ +- The wizard is now capable of detecting and continuing on past started projects 🧙 - Bug fixes 🐛🐜🐞 --- @@ -208,14 +214,14 @@ The big changes in the v0.12.x line are the following three: ### Changes -- Export group chats as self-contained HTML ⬇️ -- Enhanced system process viewer now has details view with full process args 💻 -- Update button hides until platform binaries are available in releases. ⏳ -- Added Auto Run stall detection at the loop level, if no documents are updated after a loop 🔁 -- Improved Codex session discovery 🔍 -- Windows compatibility fixes 🐛 -- 64-bit Linux ARM build issue fixed (thanks @LilYoopug) 🐜 -- Addressed session enumeration issues with Codex and OpenCode 🐞 +- Export group chats as self-contained HTML ⬇️ +- Enhanced system process viewer now has details view with full process args 💻 +- Update button hides until platform binaries are available in releases. ⏳ +- Added Auto Run stall detection at the loop level, if no documents are updated after a loop 🔁 +- Improved Codex session discovery 🔍 +- Windows compatibility fixes 🐛 +- 64-bit Linux ARM build issue fixed (thanks @LilYoopug) 🐜 +- Addressed session enumeration issues with Codex and OpenCode 🐞 - Addressed pathing issues around gh command (thanks @oliveiraantoniocc) 🐝 ### Previous Releases in this Series @@ -231,13 +237,13 @@ The big changes in the v0.12.x line are the following three: ### Changes -- Add Sentry crashing reporting monitoring with opt-out 🐛 -- Stability fixes on v0.9.0 along with all the changes it brought along, including... - - Major refactor to enable supporting of multiple providers 👨‍👩‍👧‍👦 - - Added OpenAI Codex support 👨‍💻 - - Added OpenCode support 👩‍💻 - - Error handling system detects and recovers from agent failures 🚨 - - Added option to specify CLI arguments to AI providers ✨ +- Add Sentry crashing reporting monitoring with opt-out 🐛 +- Stability fixes on v0.9.0 along with all the changes it brought along, including... + - Major refactor to enable supporting of multiple providers 👨‍👩‍👧‍👦 + - Added OpenAI Codex support 👨‍💻 + - Added OpenCode support 👩‍💻 + - Error handling system detects and recovers from agent failures 🚨 + - Added option to specify CLI arguments to AI providers ✨ - Bunch of other little tweaks and additions 💎 ### Previous Releases in this Series @@ -252,19 +258,19 @@ The big changes in the v0.12.x line are the following three: ### Changes -- Added "Nudge" messages. Short static copy to include with every interactive message sent, perhaps to remind the agent on how to work 📌 -- Addressed various resource consumption issues to reduce battery cost 📉 -- Implemented fuzzy file search in quick actions for instant navigation 🔍 -- Added "clear" command support to clean terminal shell logs 🧹 -- Simplified search highlighting by integrating into markdown pipeline ✨ -- Enhanced update checker to filter prerelease tags like -rc, -beta 🚀 -- Fixed RPM package compatibility for OpenSUSE Tumbleweed 🐧 (H/T @JOduMonT) -- Added libuuid1 support alongside standard libuuid dependency 📦 -- Introduced Cmd+Shift+U shortcut for tab unread toggle ⌨️ -- Enhanced keyboard navigation for marking tabs unread 🎯 -- Expanded Linux distribution support with smart dependencies 🌐 -- Major underlying code re-structuring for maintainability 🧹 -- Improved stall detection to allow for individual docs to stall out while not affecting the entire playbook 📖 (H/T @mattjay) +- Added "Nudge" messages. Short static copy to include with every interactive message sent, perhaps to remind the agent on how to work 📌 +- Addressed various resource consumption issues to reduce battery cost 📉 +- Implemented fuzzy file search in quick actions for instant navigation 🔍 +- Added "clear" command support to clean terminal shell logs 🧹 +- Simplified search highlighting by integrating into markdown pipeline ✨ +- Enhanced update checker to filter prerelease tags like -rc, -beta 🚀 +- Fixed RPM package compatibility for OpenSUSE Tumbleweed 🐧 (H/T @JOduMonT) +- Added libuuid1 support alongside standard libuuid dependency 📦 +- Introduced Cmd+Shift+U shortcut for tab unread toggle ⌨️ +- Enhanced keyboard navigation for marking tabs unread 🎯 +- Expanded Linux distribution support with smart dependencies 🌐 +- Major underlying code re-structuring for maintainability 🧹 +- Improved stall detection to allow for individual docs to stall out while not affecting the entire playbook 📖 (H/T @mattjay) - Added option to select a static listening port for remote control 🎮 (H/T @b3nw) ### Previous Releases in this Series @@ -284,35 +290,40 @@ The big changes in the v0.12.x line are the following three: **Latest: v0.7.4** | Released December 12, 2025 -Minor bugfixes on top of v0.7.3: - -# Onboarding, Wizard, and Tours -- Implemented comprehensive onboarding wizard with integrated tour system 🚀 -- Added project-understanding confidence display to wizard UI 🎨 -- Enhanced keyboard navigation across all wizard screens ⌨️ -- Added analytics tracking for wizard and tour completion 📈 -- Added First Run Celebration modal with confetti animation 🎉 - -# UI / UX Enhancements -- Added expand-to-fullscreen button for Auto Run interface 🖥️ -- Created dedicated modal component and improved modal priority constants for expanded Auto Run view 📐 -- Enhanced user experience with fullscreen editing capabilities ✨ -- Fixed tab name display to correctly show full name for active tabs 🏷️ -- Added performance optimizations with throttling and caching for scrolling ⚡ -- Implemented drag-and-drop reordering for execution queue items 🎯 -- Enhanced toast context with agent name for OS notifications 📢 - -# Auto Run Workflow Improvements -- Created phase document generation for Auto Run workflow 📄 -- Added real-time log streaming to the LogViewer component 📊 - -# Application Behavior / Core Fixes -- Added validation to prevent nested worktrees inside the main repository 🚫 -- Fixed process manager to properly emit exit events on errors 🔧 -- Fixed process exit handling to ensure proper cleanup 🧹 - -# Update System -- Implemented automatic update checking on application startup 🚀 +Minor bugfixes on top of v0.7.3: + +# Onboarding, Wizard, and Tours + +- Implemented comprehensive onboarding wizard with integrated tour system 🚀 +- Added project-understanding confidence display to wizard UI 🎨 +- Enhanced keyboard navigation across all wizard screens ⌨️ +- Added analytics tracking for wizard and tour completion 📈 +- Added First Run Celebration modal with confetti animation 🎉 + +# UI / UX Enhancements + +- Added expand-to-fullscreen button for Auto Run interface 🖥️ +- Created dedicated modal component and improved modal priority constants for expanded Auto Run view 📐 +- Enhanced user experience with fullscreen editing capabilities ✨ +- Fixed tab name display to correctly show full name for active tabs 🏷️ +- Added performance optimizations with throttling and caching for scrolling ⚡ +- Implemented drag-and-drop reordering for execution queue items 🎯 +- Enhanced toast context with agent name for OS notifications 📢 + +# Auto Run Workflow Improvements + +- Created phase document generation for Auto Run workflow 📄 +- Added real-time log streaming to the LogViewer component 📊 + +# Application Behavior / Core Fixes + +- Added validation to prevent nested worktrees inside the main repository 🚫 +- Fixed process manager to properly emit exit events on errors 🔧 +- Fixed process exit handling to ensure proper cleanup 🧹 + +# Update System + +- Implemented automatic update checking on application startup 🚀 - Added settings toggle for enabling/disabling startup update checks ⚙️ ### Previous Releases in this Series @@ -328,38 +339,40 @@ Minor bugfixes on top of v0.7.3: **Latest: v0.6.1** | Released December 4, 2025 -In this release... -- Added recursive subfolder support for Auto Run markdown files 🗂️ -- Enhanced document tree display with expandable folder navigation 🌳 -- Enabled creating documents in subfolders with path selection 📁 -- Improved batch runner UI with inline progress bars and loop indicators 📊 -- Fixed execution queue display bug for immediate command processing 🐛 -- Added folder icons and better visual hierarchy for document browser 🎨 -- Implemented dynamic task re-counting for batch run loop iterations 🔄 -- Enhanced create document modal with location selector dropdown 📍 -- Improved progress tracking with per-document completion visualization 📈 -- Added support for nested folder structures in document management 🏗️ - -Plus the pre-release ALPHA... -- Template vars now set context in default autorun prompt 🚀 -- Added Enter key support for queued message confirmation dialog ⌨️ -- Kill process capability added to System Process Monitor 💀 -- Toggle markdown rendering added to Cmd+K Quick Actions 📝 -- Fixed cloudflared detection in packaged app environments 🔧 -- Added debugging logs for process exit diagnostics 🐛 -- Tab switcher shows last activity timestamps and filters by project 🕐 -- Slash commands now fill text on Tab/Enter instead of executing ⚡ -- Added GitHub Actions workflow for auto-assigning issues/PRs 🤖 -- Graceful handling for playbooks with missing documents implemented ✨ -- Added multi-document batch processing for Auto Run 🚀 -- Introduced Git worktree support for parallel execution 🌳 -- Created playbook system for saving run configurations 📚 -- Implemented document reset-on-completion with loop mode 🔄 -- Added drag-and-drop document reordering interface 🎯 -- Built Auto Run folder selector with file management 📁 -- Enhanced progress tracking with per-document metrics 📊 -- Integrated PR creation after worktree completion 🔀 -- Added undo/redo support in document editor ↩️ +In this release... + +- Added recursive subfolder support for Auto Run markdown files 🗂️ +- Enhanced document tree display with expandable folder navigation 🌳 +- Enabled creating documents in subfolders with path selection 📁 +- Improved batch runner UI with inline progress bars and loop indicators 📊 +- Fixed execution queue display bug for immediate command processing 🐛 +- Added folder icons and better visual hierarchy for document browser 🎨 +- Implemented dynamic task re-counting for batch run loop iterations 🔄 +- Enhanced create document modal with location selector dropdown 📍 +- Improved progress tracking with per-document completion visualization 📈 +- Added support for nested folder structures in document management 🏗️ + +Plus the pre-release ALPHA... + +- Template vars now set context in default autorun prompt 🚀 +- Added Enter key support for queued message confirmation dialog ⌨️ +- Kill process capability added to System Process Monitor 💀 +- Toggle markdown rendering added to Cmd+K Quick Actions 📝 +- Fixed cloudflared detection in packaged app environments 🔧 +- Added debugging logs for process exit diagnostics 🐛 +- Tab switcher shows last activity timestamps and filters by project 🕐 +- Slash commands now fill text on Tab/Enter instead of executing ⚡ +- Added GitHub Actions workflow for auto-assigning issues/PRs 🤖 +- Graceful handling for playbooks with missing documents implemented ✨ +- Added multi-document batch processing for Auto Run 🚀 +- Introduced Git worktree support for parallel execution 🌳 +- Created playbook system for saving run configurations 📚 +- Implemented document reset-on-completion with loop mode 🔄 +- Added drag-and-drop document reordering interface 🎯 +- Built Auto Run folder selector with file management 📁 +- Enhanced progress tracking with per-document metrics 📊 +- Integrated PR creation after worktree completion 🔀 +- Added undo/redo support in document editor ↩️ - Implemented auto-save with 5-second debounce 💾 ### Previous Releases in this Series @@ -374,15 +387,15 @@ Plus the pre-release ALPHA... ### Changes -- Added "Made with Maestro" badge to README header 🎯 -- Redesigned app icon with darker purple color scheme 🎨 -- Created new SVG badge for project attribution 🏷️ -- Added side-by-side image diff viewer for git changes 🖼️ -- Enhanced confetti animation with realistic cannon-style bursts 🎊 -- Fixed z-index layering for standing ovation overlay 📊 -- Improved tab switcher to show all named sessions 🔍 -- Enhanced batch synopsis prompts for cleaner summaries 📝 -- Added binary file detection in git diff parser 🔧 +- Added "Made with Maestro" badge to README header 🎯 +- Redesigned app icon with darker purple color scheme 🎨 +- Created new SVG badge for project attribution 🏷️ +- Added side-by-side image diff viewer for git changes 🖼️ +- Enhanced confetti animation with realistic cannon-style bursts 🎊 +- Fixed z-index layering for standing ovation overlay 📊 +- Improved tab switcher to show all named sessions 🔍 +- Enhanced batch synopsis prompts for cleaner summaries 📝 +- Added binary file detection in git diff parser 🔧 - Implemented git file reading at specific refs 📁 ### Previous Releases in this Series @@ -397,24 +410,24 @@ Plus the pre-release ALPHA... ### Changes -- Added Tab Switcher modal for quick navigation between AI tabs 🚀 -- Implemented @ mention file completion for AI mode references 📁 -- Added navigation history with back/forward through sessions and tabs ⏮️ -- Introduced tab completion filters for branches, tags, and files 🌳 -- Added unread tab indicators and filtering for better organization 📬 -- Implemented token counting display with human-readable formatting 🔢 -- Added markdown rendering toggle for AI responses in terminal 📝 -- Removed built-in slash commands in favor of custom AI commands 🎯 -- Added context menu for sessions with rename, bookmark, move options 🖱️ -- Enhanced file preview with stats showing size, tokens, timestamps 📊 -- Added token counting with js-tiktoken for file preview stats bar 🔢 -- Implemented Tab Switcher modal for fuzzy-search navigation (Opt+Cmd+T) 🔍 -- Added Save to History toggle (Cmd+S) for automatic work synopsis tracking 💾 -- Enhanced tab completion with @ mentions for file references in AI prompts 📎 -- Implemented navigation history with back/forward shortcuts (Cmd+Shift+,/.) 🔙 -- Added git branches and tags to intelligent tab completion system 🌿 -- Enhanced markdown rendering with syntax highlighting and toggle view 📝 -- Added right-click context menus for session management and organization 🖱️ +- Added Tab Switcher modal for quick navigation between AI tabs 🚀 +- Implemented @ mention file completion for AI mode references 📁 +- Added navigation history with back/forward through sessions and tabs ⏮️ +- Introduced tab completion filters for branches, tags, and files 🌳 +- Added unread tab indicators and filtering for better organization 📬 +- Implemented token counting display with human-readable formatting 🔢 +- Added markdown rendering toggle for AI responses in terminal 📝 +- Removed built-in slash commands in favor of custom AI commands 🎯 +- Added context menu for sessions with rename, bookmark, move options 🖱️ +- Enhanced file preview with stats showing size, tokens, timestamps 📊 +- Added token counting with js-tiktoken for file preview stats bar 🔢 +- Implemented Tab Switcher modal for fuzzy-search navigation (Opt+Cmd+T) 🔍 +- Added Save to History toggle (Cmd+S) for automatic work synopsis tracking 💾 +- Enhanced tab completion with @ mentions for file references in AI prompts 📎 +- Implemented navigation history with back/forward shortcuts (Cmd+Shift+,/.) 🔙 +- Added git branches and tags to intelligent tab completion system 🌿 +- Enhanced markdown rendering with syntax highlighting and toggle view 📝 +- Added right-click context menus for session management and organization 🖱️ - Improved mobile app with better WebSocket reconnection and status badges 📱 ### Previous Releases in this Series @@ -429,15 +442,15 @@ Plus the pre-release ALPHA... ### Changes -- Fixed tab handling requiring explicitly selected Claude session 🔧 -- Added auto-scroll navigation for slash command list selection ⚡ -- Implemented TTS audio feedback for toast notifications speak 🔊 -- Fixed shortcut case sensitivity using lowercase key matching 🔤 -- Added Cmd+Shift+J shortcut to jump to bottom instantly ⬇️ -- Sorted shortcuts alphabetically in help modal for discovery 📑 -- Display full commit message body in git log view 📝 -- Added expand/collapse all buttons to process tree header 🌳 -- Support synopsis process type in process tree parsing 🔍 +- Fixed tab handling requiring explicitly selected Claude session 🔧 +- Added auto-scroll navigation for slash command list selection ⚡ +- Implemented TTS audio feedback for toast notifications speak 🔊 +- Fixed shortcut case sensitivity using lowercase key matching 🔤 +- Added Cmd+Shift+J shortcut to jump to bottom instantly ⬇️ +- Sorted shortcuts alphabetically in help modal for discovery 📑 +- Display full commit message body in git log view 📝 +- Added expand/collapse all buttons to process tree header 🌳 +- Support synopsis process type in process tree parsing 🔍 - Renamed "No Group" to "UNGROUPED" for better clarity ✨ ### Previous Releases in this Series @@ -450,15 +463,15 @@ Plus the pre-release ALPHA... **Latest: v0.2.3** | Released November 29, 2025 -• Enhanced mobile web interface with session sync and history panel 📱 -• Added ThinkingStatusPill showing real-time token counts and elapsed time ⏱️ -• Implemented task count badges and session deduplication for batch runner 📊 -• Added TTS stop control and improved voice synthesis compatibility 🔊 -• Created image lightbox with navigation, clipboard, and delete features 🖼️ -• Fixed UI bugs in search, auto-scroll, and sidebar interactions 🐛 -• Added global Claude stats with streaming updates across projects 📈 -• Improved markdown checkbox styling and collapsed palette hover UX ✨ -• Enhanced scratchpad with search, image paste, and attachment support 🔍 +• Enhanced mobile web interface with session sync and history panel 📱 +• Added ThinkingStatusPill showing real-time token counts and elapsed time ⏱️ +• Implemented task count badges and session deduplication for batch runner 📊 +• Added TTS stop control and improved voice synthesis compatibility 🔊 +• Created image lightbox with navigation, clipboard, and delete features 🖼️ +• Fixed UI bugs in search, auto-scroll, and sidebar interactions 🐛 +• Added global Claude stats with streaming updates across projects 📈 +• Improved markdown checkbox styling and collapsed palette hover UX ✨ +• Enhanced scratchpad with search, image paste, and attachment support 🔍 • Added splash screen with logo and progress bar during startup 🎨 ### Previous Releases in this Series @@ -473,15 +486,15 @@ Plus the pre-release ALPHA... **Latest: v0.1.6** | Released November 27, 2025 -• Added template variables for dynamic AI command customization 🎯 -• Implemented session bookmarking with star icons and dedicated section ⭐ -• Enhanced Git Log Viewer with smarter date formatting 📅 -• Improved GitHub release workflow to handle partial failures gracefully 🔧 -• Added collapsible template documentation in AI Commands panel 📚 -• Updated default commit command with session ID traceability 🔍 -• Added tag indicators for custom-named sessions visually 🏷️ -• Improved Git Log search UX with better focus handling 🎨 -• Fixed input placeholder spacing for better readability 📝 +• Added template variables for dynamic AI command customization 🎯 +• Implemented session bookmarking with star icons and dedicated section ⭐ +• Enhanced Git Log Viewer with smarter date formatting 📅 +• Improved GitHub release workflow to handle partial failures gracefully 🔧 +• Added collapsible template documentation in AI Commands panel 📚 +• Updated default commit command with session ID traceability 🔍 +• Added tag indicators for custom-named sessions visually 🏷️ +• Improved Git Log search UX with better focus handling 🎨 +• Fixed input placeholder spacing for better readability 📝 • Updated documentation with new features and template references 📖 ### Previous Releases in this Series @@ -500,6 +513,7 @@ Plus the pre-release ALPHA... All releases are available on the [GitHub Releases page](https://github.com/RunMaestro/Maestro/releases). Maestro is available for: + - **macOS** - Apple Silicon (arm64) and Intel (x64) - **Windows** - x64 - **Linux** - x64 and arm64, AppImage, deb, and rpm packages From fab607daf6f4df0ec2df47f1a7a94d0cd09a2b01 Mon Sep 17 00:00:00 2001 From: Nolan Clark Date: Thu, 12 Mar 2026 21:06:07 -0500 Subject: [PATCH 03/15] Local PR Comments --- .../main/agents/session-storage.test.ts | 111 ++++++++++++++++ .../handlers/StdoutHandler.test.ts | 27 ++++ .../process-manager/handlers/StdoutHandler.ts | 21 +++ src/main/storage/copilot-session-storage.ts | 121 +++++++++++++----- 4 files changed, 251 insertions(+), 29 deletions(-) diff --git a/src/__tests__/main/agents/session-storage.test.ts b/src/__tests__/main/agents/session-storage.test.ts index c809478262..758cfd84a6 100644 --- a/src/__tests__/main/agents/session-storage.test.ts +++ b/src/__tests__/main/agents/session-storage.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import path from 'path'; +import fs from 'fs/promises'; import type Store from 'electron-store'; import type { ClaudeSessionOriginsData } from '../../../main/storage/claude-session-storage'; import { @@ -370,6 +371,39 @@ describe('CodexSessionStorage', () => { }); describe('CopilotSessionStorage', () => { + const copilotSessionStateDir = path.join( + '/tmp/maestro-session-storage-home', + '.copilot', + 'session-state' + ); + + async function writeCopilotSessionFixture( + sessionId: string, + workspaceContent: string, + eventsContent?: string + ): Promise { + const sessionDir = path.join(copilotSessionStateDir, sessionId); + await fs.mkdir(sessionDir, { recursive: true }); + await fs.writeFile(path.join(sessionDir, 'workspace.yaml'), workspaceContent, 'utf8'); + if (eventsContent !== undefined) { + await fs.writeFile(path.join(sessionDir, 'events.jsonl'), eventsContent, 'utf8'); + } + } + + beforeEach(async () => { + await fs.rm(path.join('/tmp/maestro-session-storage-home', '.copilot'), { + recursive: true, + force: true, + }); + }); + + afterEach(async () => { + await fs.rm(path.join('/tmp/maestro-session-storage-home', '.copilot'), { + recursive: true, + force: true, + }); + }); + it('should be importable', async () => { const { CopilotSessionStorage } = await import('../../../main/storage/copilot-session-storage'); expect(CopilotSessionStorage).toBeDefined(); @@ -428,6 +462,83 @@ describe('CopilotSessionStorage', () => { expect(result.success).toBe(false); expect(result.error).toContain('not supported'); }); + + it('should parse camelCase workspace metadata keys when loading sessions', async () => { + await writeCopilotSessionFixture( + 'session-camel', + [ + 'id: session-camel', + 'cwd: /test/project', + 'gitRoot: /test/project', + 'createdAt: 2026-03-13T00:00:00.000Z', + 'updatedAt: 2026-03-13T00:05:00.000Z', + 'summary: Camel case metadata', + ].join('\n'), + [ + JSON.stringify({ + type: 'user.message', + id: 'user-1', + timestamp: '2026-03-13T00:00:00.000Z', + data: { content: 'Hello from Copilot' }, + }), + ].join('\n') + ); + + const { CopilotSessionStorage } = await import('../../../main/storage/copilot-session-storage'); + const storage = new CopilotSessionStorage(); + const sessions = await storage.listSessions('/test/project'); + + expect(sessions).toHaveLength(1); + expect(sessions[0]).toEqual( + expect.objectContaining({ + sessionId: 'session-camel', + projectPath: '/test/project', + timestamp: '2026-03-13T00:00:00.000Z', + modifiedAt: '2026-03-13T00:05:00.000Z', + firstMessage: 'Hello from Copilot', + messageCount: 1, + }) + ); + }); + + it('should skip missing, empty, and malformed Copilot event logs', async () => { + await writeCopilotSessionFixture( + 'session-valid', + ['id: session-valid', 'cwd: /test/project', 'git_root: /test/project'].join('\n'), + [ + JSON.stringify({ + type: 'assistant.message', + id: 'assistant-1', + timestamp: '2026-03-13T00:00:00.000Z', + data: { content: 'Ready', phase: 'final_answer' }, + }), + ].join('\n') + ); + + await writeCopilotSessionFixture( + 'session-empty', + ['id: session-empty', 'cwd: /test/project', 'git_root: /test/project'].join('\n'), + ' \n' + ); + + await writeCopilotSessionFixture( + 'session-malformed', + ['id: session-malformed', 'cwd: /test/project', 'git_root: /test/project'].join('\n'), + 'not-json\nstill-not-json\n' + ); + + await writeCopilotSessionFixture( + 'session-missing-events', + ['id: session-missing-events', 'cwd: /test/project', 'git_root: /test/project'].join('\n') + ); + + const { CopilotSessionStorage } = await import('../../../main/storage/copilot-session-storage'); + const storage = new CopilotSessionStorage(); + const sessions = await storage.listSessions('/test/project'); + + expect(sessions).toHaveLength(1); + expect(sessions[0]?.sessionId).toBe('session-valid'); + }); }); describe('Storage Module Initialization', () => { diff --git a/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts b/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts index 223cd4deb5..0d898a8b4c 100644 --- a/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts +++ b/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts @@ -52,6 +52,7 @@ vi.mock('../../../../main/parsers/error-patterns', () => ({ import { StdoutHandler } from '../../../../main/process-manager/handlers/StdoutHandler'; import { CopilotOutputParser } from '../../../../main/parsers/copilot-output-parser'; import type { ManagedProcess } from '../../../../main/process-manager/types'; +import { logger } from '../../../../main/utils/logger'; // ── Helpers ──────────────────────────────────────────────────────────────── @@ -293,6 +294,32 @@ describe('StdoutHandler', () => { expect(proc.jsonBuffer).toBe(''); }); + it('should drop oversized incomplete Copilot JSON buffers', () => { + const parser = new CopilotOutputParser(); + const { handler, bufferManager, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + + const oversizedPayload = + '{"type":"assistant.message","data":{"content":"' + 'x'.repeat(1024 * 1024 + 64); + + handler.handleData(sessionId, oversizedPayload); + + expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); + expect(proc.jsonBuffer).toBe(''); + expect(logger.warn).toHaveBeenCalledWith( + '[ProcessManager] Dropping oversized Copilot JSON buffer remainder', + 'ProcessManager', + expect.objectContaining({ + sessionId, + bufferLength: oversizedPayload.length, + maxBufferLength: 1024 * 1024, + }) + ); + }); + it('should discard Copilot preamble noise once JSON output begins', () => { const parser = new CopilotOutputParser(); const { handler, bufferManager, sessionId, proc } = createTestContext({ diff --git a/src/main/process-manager/handlers/StdoutHandler.ts b/src/main/process-manager/handlers/StdoutHandler.ts index 374e0de876..7b611606fd 100644 --- a/src/main/process-manager/handlers/StdoutHandler.ts +++ b/src/main/process-manager/handlers/StdoutHandler.ts @@ -16,6 +16,8 @@ interface StdoutHandlerDependencies { bufferManager: DataBufferManager; } +const MAX_COPILOT_JSON_BUFFER_LENGTH = 1024 * 1024; + /** * Normalize usage stats to handle cumulative vs per-turn usage reporting. * @@ -207,6 +209,24 @@ function getEmittedToolCallIds(managedProcess: ManagedProcess): Set { return managedProcess.emittedToolCallIds; } +function resetOversizedCopilotJsonBuffer(sessionId: string, managedProcess: ManagedProcess): void { + const bufferLength = managedProcess.jsonBuffer?.length || 0; + if (bufferLength <= MAX_COPILOT_JSON_BUFFER_LENGTH) { + return; + } + + logger.warn( + '[ProcessManager] Dropping oversized Copilot JSON buffer remainder', + 'ProcessManager', + { + sessionId, + bufferLength, + maxBufferLength: MAX_COPILOT_JSON_BUFFER_LENGTH, + } + ); + managedProcess.jsonBuffer = ''; +} + /** * Handles stdout data processing for child processes. * Extracts session IDs, usage stats, and result data from agent output. @@ -283,6 +303,7 @@ export class StdoutHandler { const { messages, remainder } = extractConcatenatedJsonObjects(managedProcess.jsonBuffer); managedProcess.jsonBuffer = remainder; + resetOversizedCopilotJsonBuffer(sessionId, managedProcess); for (const message of messages) { managedProcess.stdoutBuffer = appendToBuffer( diff --git a/src/main/storage/copilot-session-storage.ts b/src/main/storage/copilot-session-storage.ts index 363b19d14c..581991681d 100644 --- a/src/main/storage/copilot-session-storage.ts +++ b/src/main/storage/copilot-session-storage.ts @@ -50,6 +50,9 @@ interface ParsedCopilotSessionData { firstAssistantMessage: string; firstUserMessage: string; stats: CopilotSessionStats; + parsedEventCount: number; + malformedEventCount: number; + hasMeaningfulContent: boolean; } interface CopilotEvent { @@ -78,27 +81,65 @@ interface CopilotEvent { } function normalizeYamlScalar(value: string): string { - const trimmed = value.trim(); + let trimmed = value.trim(); if ( (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'")) ) { return trimmed.slice(1, -1); } + + const inlineCommentIndex = trimmed.search(/\s+#/); + if (inlineCommentIndex >= 0) { + trimmed = trimmed.slice(0, inlineCommentIndex).trim(); + } + return trimmed; } -function parseSimpleYaml(content: string): Record { - const result: Record = {}; +const WORKSPACE_METADATA_KEYS = new Set([ + 'id', + 'cwd', + 'git_root', + 'repository', + 'branch', + 'summary', + 'created_at', + 'updated_at', +]); + +function normalizeWorkspaceMetadataKey(key: string): keyof CopilotWorkspaceMetadata | null { + const normalized = key + .trim() + .replace(/-/g, '_') + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .toLowerCase(); + + return WORKSPACE_METADATA_KEYS.has(normalized as keyof CopilotWorkspaceMetadata) + ? (normalized as keyof CopilotWorkspaceMetadata) + : null; +} - for (const line of content.split('\n')) { - if (!line.trim()) continue; - const match = line.match(/^([a-z_]+):\s*(.*)$/); +function parseWorkspaceMetadata(content: string, sessionId: string): CopilotWorkspaceMetadata { + const metadata: CopilotWorkspaceMetadata = { id: sessionId }; + + for (const rawLine of content.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#') || line === '---' || line === '...') continue; + + const match = rawLine.match(/^\s*([A-Za-z0-9_-]+)\s*:\s*(.*)$/); if (!match) continue; - result[match[1]] = normalizeYamlScalar(match[2]); + + const key = normalizeWorkspaceMetadataKey(match[1]); + if (!key) continue; + + const value = normalizeYamlScalar(match[2]); + if (!value) continue; + + metadata[key] = value; } - return result; + return metadata; } function normalizePath(value?: string): string | null { @@ -134,6 +175,8 @@ function parseEvents(content: string): ParsedCopilotSessionData { const messages: SessionMessage[] = []; let firstAssistantMessage = ''; let firstUserMessage = ''; + let parsedEventCount = 0; + let malformedEventCount = 0; const stats: CopilotSessionStats = { inputTokens: 0, outputTokens: 0, @@ -142,11 +185,12 @@ function parseEvents(content: string): ParsedCopilotSessionData { durationSeconds: 0, }; - for (const line of content.split('\n')) { + for (const line of content.split(/\r?\n/)) { if (!line.trim()) continue; try { const entry = JSON.parse(line) as CopilotEvent; + parsedEventCount += 1; if (entry.type === 'user.message') { const contentText = entry.data?.content || ''; @@ -198,15 +242,27 @@ function parseEvents(content: string): ParsedCopilotSessionData { stats.durationSeconds = Math.max(0, Math.floor(entry.usage.sessionDurationMs / 1000)); } } catch { + malformedEventCount += 1; // Ignore malformed lines so a single bad event does not hide the whole session. } } + const hasMeaningfulContent = + messages.length > 0 || + stats.inputTokens > 0 || + stats.outputTokens > 0 || + stats.cacheReadTokens > 0 || + stats.cacheCreationTokens > 0 || + stats.durationSeconds > 0; + return { messages, firstAssistantMessage, firstUserMessage, stats, + parsedEventCount, + malformedEventCount, + hasMeaningfulContent, }; } @@ -360,24 +416,31 @@ export class CopilotSessionStorage extends BaseSessionStorage { return null; } - const parsed = parseSimpleYaml(workspaceContent); - const metadata: CopilotWorkspaceMetadata = { - id: parsed.id || sessionId, - cwd: parsed.cwd, - git_root: parsed.git_root, - repository: parsed.repository, - branch: parsed.branch, - summary: parsed.summary, - created_at: parsed.created_at, - updated_at: parsed.updated_at, - }; + const metadata = parseWorkspaceMetadata(workspaceContent, sessionId); if (!matchesProject(metadata, projectPath)) { return null; } const eventsContent = await this.readEventsFile(sessionId, sshConfig); - const parsedEvents = eventsContent ? parseEvents(eventsContent) : null; + if (!eventsContent?.trim()) { + logger.debug(`Skipping Copilot session ${sessionId} with empty events log`, LOG_CONTEXT); + return null; + } + + const parsedEvents = parseEvents(eventsContent); + if (!parsedEvents.hasMeaningfulContent) { + logger.debug( + `Skipping Copilot session ${sessionId} without meaningful event content`, + LOG_CONTEXT, + { + parsedEventCount: parsedEvents.parsedEventCount, + malformedEventCount: parsedEvents.malformedEventCount, + } + ); + return null; + } + const sizeBytes = sshConfig ? await this.getRemoteDirectorySize(sessionDir, sshConfig) : await getLocalDirectorySize(sessionDir); @@ -385,8 +448,8 @@ export class CopilotSessionStorage extends BaseSessionStorage { const timestamp = metadata.created_at || new Date().toISOString(); const modifiedAt = metadata.updated_at || timestamp; const preview = - parsedEvents?.firstAssistantMessage || - parsedEvents?.firstUserMessage || + parsedEvents.firstAssistantMessage || + parsedEvents.firstUserMessage || metadata.summary || 'Copilot session'; @@ -396,13 +459,13 @@ export class CopilotSessionStorage extends BaseSessionStorage { timestamp, modifiedAt, firstMessage: preview.slice(0, 200), - messageCount: parsedEvents?.messages.length || 0, + messageCount: parsedEvents.messages.length, sizeBytes, - inputTokens: parsedEvents?.stats.inputTokens || 0, - outputTokens: parsedEvents?.stats.outputTokens || 0, - cacheReadTokens: parsedEvents?.stats.cacheReadTokens || 0, - cacheCreationTokens: parsedEvents?.stats.cacheCreationTokens || 0, - durationSeconds: parsedEvents?.stats.durationSeconds || 0, + inputTokens: parsedEvents.stats.inputTokens, + outputTokens: parsedEvents.stats.outputTokens, + cacheReadTokens: parsedEvents.stats.cacheReadTokens, + cacheCreationTokens: parsedEvents.stats.cacheCreationTokens, + durationSeconds: parsedEvents.stats.durationSeconds, }; } catch (error) { logger.debug(`Failed to load Copilot session metadata for ${sessionId}`, LOG_CONTEXT, { From 9247928e0035bb6f72b86be567c6dd2342b7fc99 Mon Sep 17 00:00:00 2001 From: Nolan Clark Date: Thu, 12 Mar 2026 21:25:48 -0500 Subject: [PATCH 04/15] PR comments --- docs/releases.md | 24 ++++++------ .../handlers/StdoutHandler.test.ts | 34 ++++++++++++++++- .../spawners/ChildProcessSpawner.test.ts | 2 +- src/__tests__/main/utils/agent-args.test.ts | 24 +++++++++++- src/main/parsers/copilot-output-parser.ts | 15 ++++++-- src/main/parsers/index.ts | 3 ++ src/main/parsers/parser-factory.ts | 37 +++++++++++++++++++ .../process-manager/handlers/StdoutHandler.ts | 14 +++++++ .../spawners/ChildProcessSpawner.ts | 7 ++-- src/main/process-manager/types.ts | 3 ++ src/main/storage/copilot-session-storage.ts | 6 ++- src/main/utils/agent-args.ts | 32 +++++++++++++++- 12 files changed, 175 insertions(+), 26 deletions(-) create mode 100644 src/main/parsers/parser-factory.ts diff --git a/docs/releases.md b/docs/releases.md index 81e0e7f113..903482000d 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -17,7 +17,7 @@ Maestro can update itself automatically! This feature was introduced in **v0.8.7 **Latest: v0.15.2** | Released March 12, 2026 -# Major 0.15.x Additions +### Major 0.15.x Additions 🎶 **Maestro Symphony** — Contribute to open source with AI assistance! Browse curated issues from projects with the `runmaestro.ai` label, clone repos with one click, and automatically process the relevant Auto Run playbooks. Track your contributions, streaks, and stats. You're contributing CPU and tokens towards your favorite open source projects and features. @@ -29,7 +29,7 @@ Maestro can update itself automatically! This feature was introduced in **v0.8.7 🤖 **Factory.ai Droid Support** — Added support for the [Factory.ai](https://factory.ai/product/cli) droid agent. Full session management and output parsing integration. -## Change in v0.15.2 +### Changes in v0.15.2 Patch release with bug fixes, UX improvements, and cherry-picks from the 0.16.0 RC. @@ -98,13 +98,13 @@ Changes in this point release include: The major contributions to 0.14.x remain: -🗄️ Document Graphs. Launch from file preview or from the FIle tree panel. Explore relationships between Markdown documents that contain links between documents and to URLs. +🗄️ Document Graphs. Launch from file preview or from the File tree panel. Explore relationships between Markdown documents that contain links between documents and to URLs. 📶 SSH support for agents. Manage a remote agent with feature parity over SSH. Includes support for Git and File tree panels. Manage agents on remote systems or in containers. This even works for Group Chat, which is rad as hell. 🧙‍♂️ Added an in-tab wizard for generating Auto Run Playbooks via `/wizard` or a new button in the Auto Run panel. -# Smaller Changes in 014.x +### Smaller Changes in 0.14.x - Improved User Dashboard, available from hamburger menu, command palette or hotkey 🎛️ - Leaderboard tracking now works across multiple systems and syncs level from cloud 🏆 @@ -173,7 +173,7 @@ The big changes in the v0.12.x line are the following three: ## GitHub Spec-Kit Integration -🎯 Added [GitHub Spec-Kit](https://github.com/github/spec-kit) commands into Maestro with a built in updater to grab the latest prompts from the repository. We do override `/speckit-implement` (the final step) to create Auto Run docs and guide the user through their execution, which thanks to Wortrees from v0.11.x allows us to run in parallel! +🎯 Added [GitHub Spec-Kit](https://github.com/github/spec-kit) commands into Maestro with a built-in updater to grab the latest prompts from the repository. We do override `/speckit-implement` (the final step) to create Auto Run docs and guide the user through their execution, which thanks to Wortrees from v0.11.x allows us to run in parallel! ## Context Management Tools @@ -198,9 +198,9 @@ The big changes in the v0.12.x line are the following three: **Latest: v0.11.0** | Released December 22, 2025 -🌳 Github Worktree support was added. Any agent bound to a Git repository has the option to enable worktrees, each of which show up as a sub-agent with their own write-lock and Auto Run capability. Now you can truly develop in parallel on the same project and issue PRs when you're ready, all from within Maestro. Huge improvement, major thanks to @petersilberman. +🌳 GitHub Worktree support was added. Any agent bound to a Git repository has the option to enable worktrees, each of which show up as a sub-agent with their own write-lock and Auto Run capability. Now you can truly develop in parallel on the same project and issue PRs when you're ready, all from within Maestro. Huge improvement, major thanks to @petersilberman. -# Other Changes +### Other Changes - @ file mentions now include documents from your Auto Run folder (which may not live in your agent working directory) 🗄️ - The wizard is now capable of detecting and continuing on past started projects 🧙 @@ -292,7 +292,7 @@ The big changes in the v0.12.x line are the following three: Minor bugfixes on top of v0.7.3: -# Onboarding, Wizard, and Tours +### Onboarding, Wizard, and Tours - Implemented comprehensive onboarding wizard with integrated tour system 🚀 - Added project-understanding confidence display to wizard UI 🎨 @@ -300,7 +300,7 @@ Minor bugfixes on top of v0.7.3: - Added analytics tracking for wizard and tour completion 📈 - Added First Run Celebration modal with confetti animation 🎉 -# UI / UX Enhancements +### UI / UX Enhancements - Added expand-to-fullscreen button for Auto Run interface 🖥️ - Created dedicated modal component and improved modal priority constants for expanded Auto Run view 📐 @@ -310,18 +310,18 @@ Minor bugfixes on top of v0.7.3: - Implemented drag-and-drop reordering for execution queue items 🎯 - Enhanced toast context with agent name for OS notifications 📢 -# Auto Run Workflow Improvements +### Auto Run Workflow Improvements - Created phase document generation for Auto Run workflow 📄 - Added real-time log streaming to the LogViewer component 📊 -# Application Behavior / Core Fixes +### Application Behavior / Core Fixes - Added validation to prevent nested worktrees inside the main repository 🚫 - Fixed process manager to properly emit exit events on errors 🔧 - Fixed process exit handling to ensure proper cleanup 🧹 -# Update System +### Update System - Implemented automatic update checking on application startup 🚀 - Added settings toggle for enabling/disabling startup update checks ⚙️ diff --git a/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts b/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts index 0d898a8b4c..7f44070f0d 100644 --- a/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts +++ b/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts @@ -309,6 +309,7 @@ describe('StdoutHandler', () => { expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); expect(proc.jsonBuffer).toBe(''); + expect(proc.jsonBufferCorrupted).toBe(true); expect(logger.warn).toHaveBeenCalledWith( '[ProcessManager] Dropping oversized Copilot JSON buffer remainder', 'ProcessManager', @@ -320,6 +321,34 @@ describe('StdoutHandler', () => { ); }); + it('should resync after corrupted buffer on the next valid JSON object', () => { + const parser = new CopilotOutputParser(); + const { handler, bufferManager, emitter, sessionId, proc } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + const sessionIdSpy = vi.fn(); + emitter.on('session-id', sessionIdSpy); + + // Force corrupted state + proc.jsonBufferCorrupted = true; + + // Send trailing garbage from old object, then a clean new object + const payload = + 'leftover junk from old object"}' + + JSON.stringify({ + type: 'assistant.message', + data: { content: 'Recovered', phase: 'final_answer' }, + }); + + handler.handleData(sessionId, payload); + + expect(proc.jsonBufferCorrupted).toBe(false); + expect(bufferManager.emitDataBuffered).toHaveBeenCalledWith(sessionId, 'Recovered'); + expect(proc.jsonBuffer).toBe(''); + }); + it('should discard Copilot preamble noise once JSON output begins', () => { const parser = new CopilotOutputParser(); const { handler, bufferManager, sessionId, proc } = createTestContext({ @@ -384,8 +413,11 @@ describe('StdoutHandler', () => { }) ); + // Session ID should still be extracted from bare exit-code result events expect(sessionIdSpy).toHaveBeenCalledWith(sessionId, 'copilot-session-error'); - expect(errorSpy).toHaveBeenCalledTimes(1); + // Bare exit codes without error text should NOT trigger an inline error — + // the richer detectErrorFromExit() runs at process exit with stderr context + expect(errorSpy).not.toHaveBeenCalled(); }); it('should dedupe Copilot tool starts emitted from tool.execution_start and final toolUseBlocks', () => { diff --git a/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts b/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts index 6c9b7fb1be..6b5b011ebc 100644 --- a/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts +++ b/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts @@ -51,7 +51,7 @@ vi.mock('../../../../main/utils/logger', () => ({ })); vi.mock('../../../../main/parsers', () => ({ - getOutputParser: vi.fn(() => ({ + createOutputParser: vi.fn(() => ({ agentId: 'claude-code', parseJsonLine: vi.fn(), extractUsage: vi.fn(), diff --git a/src/__tests__/main/utils/agent-args.test.ts b/src/__tests__/main/utils/agent-args.test.ts index 1e3473d3e0..6d259d3ab0 100644 --- a/src/__tests__/main/utils/agent-args.test.ts +++ b/src/__tests__/main/utils/agent-args.test.ts @@ -98,16 +98,36 @@ describe('buildAgentArgs', () => { expect(result).toEqual(['--print']); }); - it('does not duplicate jsonOutputArgs when already present', () => { + it('does not duplicate jsonOutputArgs when exact sequence already present', () => { + const agent = makeAgent({ jsonOutputArgs: ['--format', 'json'] }); + const result = buildAgentArgs(agent, { + baseArgs: ['--print', '--format', 'json'], + prompt: 'hello', + }); + // '--format json' exact sequence is already in baseArgs, so jsonOutputArgs should not be added + expect(result).toEqual(['--print', '--format', 'json']); + }); + + it('does not duplicate jsonOutputArgs when same flag key present with different value', () => { const agent = makeAgent({ jsonOutputArgs: ['--format', 'json'] }); const result = buildAgentArgs(agent, { baseArgs: ['--print', '--format', 'stream'], prompt: 'hello', }); - // '--format' is already in baseArgs, so jsonOutputArgs should not be added + // '--format' flag key is already present, so jsonOutputArgs should not be added expect(result).toEqual(['--print', '--format', 'stream']); }); + it('does not false-match jsonOutputArgs on bare value token', () => { + const agent = makeAgent({ jsonOutputArgs: ['--output-format', 'json'] }); + const result = buildAgentArgs(agent, { + baseArgs: ['--print', 'json'], + prompt: 'hello', + }); + // 'json' is a positional arg, not the '--output-format' flag, so jsonOutputArgs should be added + expect(result).toEqual(['--print', 'json', '--output-format', 'json']); + }); + // -- workingDirArgs -- it('adds workingDirArgs when cwd provided', () => { const agent = makeAgent({ diff --git a/src/main/parsers/copilot-output-parser.ts b/src/main/parsers/copilot-output-parser.ts index d691067e83..96ab04bdde 100644 --- a/src/main/parsers/copilot-output-parser.ts +++ b/src/main/parsers/copilot-output-parser.ts @@ -238,7 +238,13 @@ export class CopilotOutputParser implements AgentOutputParser { } isResultMessage(event: ParsedEvent): boolean { - return event.type === 'result' && (!!event.text || !!event.toolUseBlocks?.length); + if (event.type !== 'result') return false; + + // Treat any final_answer event as a result, including empty ones (tool-only responses) + const raw = event.raw as CopilotRawMessage | undefined; + if (raw?.data?.phase === 'final_answer') return true; + + return !!event.text || !!event.toolUseBlocks?.length; } extractSessionId(event: ParsedEvent): string | null { @@ -285,10 +291,11 @@ export class CopilotOutputParser implements AgentOutputParser { const errorText = extractErrorText(msg.error) || extractErrorText(msg.data?.error) || - (msg.type === 'result' && msg.exitCode && msg.exitCode !== 0 - ? `Copilot exited with code ${msg.exitCode}` - : null); + extractErrorText(msg.data?.message); + // Do NOT synthesize an error for bare non-zero exit codes. + // Returning null here lets detectErrorFromExit() run with full + // stderr+stdout context for richer error classification. if (!errorText) { return null; } diff --git a/src/main/parsers/index.ts b/src/main/parsers/index.ts index d61a818075..5d6458972d 100644 --- a/src/main/parsers/index.ts +++ b/src/main/parsers/index.ts @@ -37,6 +37,9 @@ export { clearParserRegistry, } from './agent-output-parser'; +// Re-export factory function +export { createOutputParser } from './parser-factory'; + // Re-export error pattern utilities (access patterns via getErrorPatterns(agentId)) export type { ErrorPattern, AgentErrorPatterns } from './error-patterns'; export { diff --git a/src/main/parsers/parser-factory.ts b/src/main/parsers/parser-factory.ts new file mode 100644 index 0000000000..0139c96770 --- /dev/null +++ b/src/main/parsers/parser-factory.ts @@ -0,0 +1,37 @@ +/** + * Output Parser Factory + * + * Creates fresh parser instances per-process to avoid shared mutable state. + * Parsers like CopilotOutputParser and CodexOutputParser track tool names + * on the instance; sharing a singleton across concurrent sessions causes + * cross-session state leakage. + * + * Use createOutputParser() when assigning a parser to a ManagedProcess. + * Use getOutputParser() only for capability checks or read-only queries. + */ + +import type { ToolType } from '../../shared/types'; +import type { AgentOutputParser } from './agent-output-parser'; +import { ClaudeOutputParser } from './claude-output-parser'; +import { OpenCodeOutputParser } from './opencode-output-parser'; +import { CodexOutputParser } from './codex-output-parser'; +import { FactoryDroidOutputParser } from './factory-droid-output-parser'; +import { CopilotOutputParser } from './copilot-output-parser'; + +const PARSER_CONSTRUCTORS: Record AgentOutputParser> = { + 'claude-code': () => new ClaudeOutputParser(), + opencode: () => new OpenCodeOutputParser(), + codex: () => new CodexOutputParser(), + 'factory-droid': () => new FactoryDroidOutputParser(), + copilot: () => new CopilotOutputParser(), +}; + +/** + * Create a fresh output parser instance for a given agent type. + * Each call returns a new instance so per-process mutable state + * (e.g., tool name tracking) is session-isolated. + */ +export function createOutputParser(agentId: ToolType | string): AgentOutputParser | null { + const factory = PARSER_CONSTRUCTORS[agentId]; + return factory ? factory() : null; +} diff --git a/src/main/process-manager/handlers/StdoutHandler.ts b/src/main/process-manager/handlers/StdoutHandler.ts index 7b611606fd..6ac77b2e31 100644 --- a/src/main/process-manager/handlers/StdoutHandler.ts +++ b/src/main/process-manager/handlers/StdoutHandler.ts @@ -225,6 +225,8 @@ function resetOversizedCopilotJsonBuffer(sessionId: string, managedProcess: Mana } ); managedProcess.jsonBuffer = ''; + // Mark corrupted so subsequent chunks discard until a clean resync point + managedProcess.jsonBufferCorrupted = true; } /** @@ -277,6 +279,18 @@ export class StdoutHandler { managedProcess.jsonBuffer = (managedProcess.jsonBuffer || '') + output; if (managedProcess.toolType === 'copilot') { + // If a previous buffer overflow corrupted state, discard data until + // we find a top-level '{' that starts a fresh JSON object. + if (managedProcess.jsonBufferCorrupted) { + const resyncIndex = managedProcess.jsonBuffer.indexOf('{'); + if (resyncIndex === -1) { + managedProcess.jsonBuffer = ''; + return; + } + managedProcess.jsonBuffer = managedProcess.jsonBuffer.slice(resyncIndex); + managedProcess.jsonBufferCorrupted = false; + } + const firstNonWhitespaceIndex = managedProcess.jsonBuffer.search(/\S/); if ( firstNonWhitespaceIndex >= 0 && diff --git a/src/main/process-manager/spawners/ChildProcessSpawner.ts b/src/main/process-manager/spawners/ChildProcessSpawner.ts index d6b6d611a1..e86cecba9b 100644 --- a/src/main/process-manager/spawners/ChildProcessSpawner.ts +++ b/src/main/process-manager/spawners/ChildProcessSpawner.ts @@ -5,7 +5,7 @@ import { EventEmitter } from 'events'; import * as path from 'path'; import * as fs from 'fs'; import { logger } from '../../utils/logger'; -import { getOutputParser } from '../../parsers'; +import { createOutputParser } from '../../parsers'; import { getAgentCapabilities } from '../../agents'; import type { ProcessConfig, ManagedProcess, SpawnResult } from '../types'; import type { DataBufferManager } from '../handlers/DataBufferManager'; @@ -353,8 +353,9 @@ export class ChildProcessSpawner { !!config.sendPromptViaStdin || !!config.sshStdinScript; - // Get the output parser for this agent type - const outputParser = getOutputParser(toolType) || undefined; + // Create a fresh output parser instance for this process (not the shared singleton) + // to isolate mutable state like tool name tracking across concurrent sessions + const outputParser = createOutputParser(toolType) || undefined; logger.debug('[ProcessManager] Output parser lookup', 'ProcessManager', { sessionId, diff --git a/src/main/process-manager/types.ts b/src/main/process-manager/types.ts index 79450b9a6f..4561e95e9b 100644 --- a/src/main/process-manager/types.ts +++ b/src/main/process-manager/types.ts @@ -52,6 +52,9 @@ export interface ManagedProcess { isBatchMode?: boolean; isStreamJsonMode?: boolean; jsonBuffer?: string; + /** When true, the JSON buffer was force-cleared after exceeding size limits. + * Subsequent chunks are discarded until a clean top-level `{` resync point. */ + jsonBufferCorrupted?: boolean; lastCommand?: string; sessionIdEmitted?: boolean; resultEmitted?: boolean; diff --git a/src/main/storage/copilot-session-storage.ts b/src/main/storage/copilot-session-storage.ts index 581991681d..4809c3dbec 100644 --- a/src/main/storage/copilot-session-storage.ts +++ b/src/main/storage/copilot-session-storage.ts @@ -86,7 +86,9 @@ function normalizeYamlScalar(value: string): string { (trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'")) ) { - return trimmed.slice(1, -1); + const inner = trimmed.slice(1, -1); + // Unescape common sequences within double-quoted scalars + return trimmed.startsWith('"') ? inner.replace(/\\"/g, '"').replace(/\\\\/g, '\\') : inner; } const inlineCommentIndex = trimmed.search(/\s+#/); @@ -445,7 +447,7 @@ export class CopilotSessionStorage extends BaseSessionStorage { ? await this.getRemoteDirectorySize(sessionDir, sshConfig) : await getLocalDirectorySize(sessionDir); const projectRoot = metadata.git_root || metadata.cwd || projectPath; - const timestamp = metadata.created_at || new Date().toISOString(); + const timestamp = metadata.created_at || metadata.updated_at || new Date().toISOString(); const modifiedAt = metadata.updated_at || timestamp; const preview = parsedEvents.firstAssistantMessage || diff --git a/src/main/utils/agent-args.ts b/src/main/utils/agent-args.ts index a1ffaf74b1..11c5d6a081 100644 --- a/src/main/utils/agent-args.ts +++ b/src/main/utils/agent-args.ts @@ -42,6 +42,32 @@ function parseCustomArgs(customArgs?: string): string[] { }); } +function hasJsonOutputFlag(haystack: string[], jsonOutputArgs: string[]): boolean { + if (jsonOutputArgs.length === 0) return true; + + // Check if the exact arg sequence is already present + for (let i = 0; i <= haystack.length - jsonOutputArgs.length; i++) { + let match = true; + for (let j = 0; j < jsonOutputArgs.length; j++) { + if (haystack[i + j] !== jsonOutputArgs[j]) { + match = false; + break; + } + } + if (match) return true; + } + + // Also check if the flag key (e.g., --format, --output-format) is already + // present with a different value — avoid appending a conflicting duplicate + // that the dedup step would mangle. + const flagKey = jsonOutputArgs[0]; + if (flagKey?.startsWith('-') && jsonOutputArgs.length > 1) { + return haystack.includes(flagKey); + } + + return false; +} + export function buildAgentArgs( agent: AgentConfig | null | undefined, options: BuildAgentArgsOptions @@ -66,10 +92,14 @@ export function buildAgentArgs( } } + // Only inject JSON output args when a prompt is provided (batch/non-interactive mode). + // Interactive sessions must not receive these flags (e.g., Copilot rejects --output-format json + // in interactive mode). Agents that need JSON output in interactive mode should include + // the relevant flags in their base `args` or `batchModeArgs` instead. if ( agent.jsonOutputArgs && options.prompt && - !finalArgs.some((arg) => agent.jsonOutputArgs!.includes(arg)) + !hasJsonOutputFlag(finalArgs, agent.jsonOutputArgs) ) { finalArgs = [...finalArgs, ...agent.jsonOutputArgs]; } From b3dd1220fc89891233c868ffb44895fe345c0a74 Mon Sep 17 00:00:00 2001 From: Nolan Clark Date: Thu, 12 Mar 2026 21:46:47 -0500 Subject: [PATCH 05/15] PR comments --- docs/releases.md | 4 ++-- .../main/process-manager/handlers/StdoutHandler.test.ts | 8 +++++++- src/main/process-manager/handlers/StdoutHandler.ts | 2 ++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 903482000d..ccdeb241e9 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -19,7 +19,7 @@ Maestro can update itself automatically! This feature was introduced in **v0.8.7 ### Major 0.15.x Additions -🎶 **Maestro Symphony** — Contribute to open source with AI assistance! Browse curated issues from projects with the `runmaestro.ai` label, clone repos with one click, and automatically process the relevant Auto Run playbooks. Track your contributions, streaks, and stats. You're contributing CPU and tokens towards your favorite open source projects and features. +🎶 **Maestro Symphony** — Contribute to open source with AI assistance! Browse curated issues from projects with the `runmaestro.ai` label, clone repos with one click, and automatically process the relevant Auto Run playbooks. Track your contributions, streaks, and stats. You're contributing CPU and tokens towards your favorite open-source projects and features. 🎬 **Director's Notes** — Aggregates history across all agents into a unified timeline with search, filters, and an activity graph. Includes an AI Overview tab that generates a structured synopsis of recent work. Off by default, gated behind a new "Encore Features" panel under settings. This is a precursor to an eventual plugin system, allowing for extensions and customizations without bloating the core app. @@ -87,7 +87,7 @@ Changes in this point release include: - Desktop app performance improvements (more to come on this, we want Maestro blazing fast) 🐌 - Added local manifest feature for custom playbooks 📖 -- Agents are now inherently aware of your activity history as seen in the history panel 📜 (this is built-in cross context memory!) +- Agents are now inherently aware of your activity history as seen in the history panel 📜 (this is built-in cross-context memory!) - Added markdown rendering support for AI responses in mobile view 📱 - Bugfix in tracking costs from JSONL files that were aged out 🏦 - Added BlueSky social media handle for leaderboard 🦋 diff --git a/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts b/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts index 7f44070f0d..a7fed597f8 100644 --- a/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts +++ b/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts @@ -302,6 +302,9 @@ describe('StdoutHandler', () => { outputParser: parser, }); + // Seed stale tool IDs to verify they get cleared on corruption + proc.emittedToolCallIds = new Set(['stale_call_1']); + const oversizedPayload = '{"type":"assistant.message","data":{"content":"' + 'x'.repeat(1024 * 1024 + 64); @@ -310,6 +313,7 @@ describe('StdoutHandler', () => { expect(bufferManager.emitDataBuffered).not.toHaveBeenCalled(); expect(proc.jsonBuffer).toBe(''); expect(proc.jsonBufferCorrupted).toBe(true); + expect(proc.emittedToolCallIds?.size).toBe(0); expect(logger.warn).toHaveBeenCalledWith( '[ProcessManager] Dropping oversized Copilot JSON buffer remainder', 'ProcessManager', @@ -331,8 +335,9 @@ describe('StdoutHandler', () => { const sessionIdSpy = vi.fn(); emitter.on('session-id', sessionIdSpy); - // Force corrupted state + // Force corrupted state with stale tool IDs proc.jsonBufferCorrupted = true; + proc.emittedToolCallIds = new Set(['stale_call_2']); // Send trailing garbage from old object, then a clean new object const payload = @@ -345,6 +350,7 @@ describe('StdoutHandler', () => { handler.handleData(sessionId, payload); expect(proc.jsonBufferCorrupted).toBe(false); + expect(proc.emittedToolCallIds?.size).toBe(0); expect(bufferManager.emitDataBuffered).toHaveBeenCalledWith(sessionId, 'Recovered'); expect(proc.jsonBuffer).toBe(''); }); diff --git a/src/main/process-manager/handlers/StdoutHandler.ts b/src/main/process-manager/handlers/StdoutHandler.ts index 6ac77b2e31..1b2238c2c4 100644 --- a/src/main/process-manager/handlers/StdoutHandler.ts +++ b/src/main/process-manager/handlers/StdoutHandler.ts @@ -227,6 +227,7 @@ function resetOversizedCopilotJsonBuffer(sessionId: string, managedProcess: Mana managedProcess.jsonBuffer = ''; // Mark corrupted so subsequent chunks discard until a clean resync point managedProcess.jsonBufferCorrupted = true; + managedProcess.emittedToolCallIds?.clear(); } /** @@ -289,6 +290,7 @@ export class StdoutHandler { } managedProcess.jsonBuffer = managedProcess.jsonBuffer.slice(resyncIndex); managedProcess.jsonBufferCorrupted = false; + managedProcess.emittedToolCallIds?.clear(); } const firstNonWhitespaceIndex = managedProcess.jsonBuffer.search(/\S/); From 542f1878e63a4001790ca2ad0be2008eda066091 Mon Sep 17 00:00:00 2001 From: Nolan Clark Date: Thu, 12 Mar 2026 22:01:36 -0500 Subject: [PATCH 06/15] PR comments 3 --- docs/releases.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index ccdeb241e9..1be22cd876 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -177,7 +177,7 @@ The big changes in the v0.12.x line are the following three: ## Context Management Tools -📖 Added context management options from tab right-click menu. You can now compress, merge, and transfer contexts between agents. You will received (configurable) warnings at 60% and 80% context consumption with a hint to compact. +📖 Added context management options from tab right-click menu. You can now compress, merge, and transfer contexts between agents. You will receive (configurable) warnings at 60% and 80% context consumption with a hint to compact. ## Changes Specific to v0.12.3: @@ -263,7 +263,7 @@ The big changes in the v0.12.x line are the following three: - Implemented fuzzy file search in quick actions for instant navigation 🔍 - Added "clear" command support to clean terminal shell logs 🧹 - Simplified search highlighting by integrating into markdown pipeline ✨ -- Enhanced update checker to filter prerelease tags like -rc, -beta 🚀 +- Enhanced update checker to filter pre-release tags like -rc, -beta 🚀 - Fixed RPM package compatibility for OpenSUSE Tumbleweed 🐧 (H/T @JOduMonT) - Added libuuid1 support alongside standard libuuid dependency 📦 - Introduced Cmd+Shift+U shortcut for tab unread toggle ⌨️ From 461435274a308be85778397048877ab5500eb351 Mon Sep 17 00:00:00 2001 From: Nolan Clark Date: Thu, 12 Mar 2026 22:11:17 -0500 Subject: [PATCH 07/15] More typos --- docs/releases.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/releases.md b/docs/releases.md index 1be22cd876..66df707df0 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -173,7 +173,7 @@ The big changes in the v0.12.x line are the following three: ## GitHub Spec-Kit Integration -🎯 Added [GitHub Spec-Kit](https://github.com/github/spec-kit) commands into Maestro with a built-in updater to grab the latest prompts from the repository. We do override `/speckit-implement` (the final step) to create Auto Run docs and guide the user through their execution, which thanks to Wortrees from v0.11.x allows us to run in parallel! +🎯 Added [GitHub Spec-Kit](https://github.com/github/spec-kit) commands into Maestro with a built-in updater to grab the latest prompts from the repository. We do override `/speckit-implement` (the final step) to create Auto Run docs and guide the user through their execution, which thanks to Worktrees from v0.11.x allows us to run in parallel! ## Context Management Tools From 1752c2f4b75157e840987de795f955b77e9037cd Mon Sep 17 00:00:00 2001 From: Nolan Clark Date: Thu, 12 Mar 2026 22:22:53 -0500 Subject: [PATCH 08/15] Add docstrings --- src/main/parsers/copilot-output-parser.ts | 22 ++++++++++++++ .../process-manager/handlers/StdoutHandler.ts | 11 +++++++ src/main/storage/copilot-session-storage.ts | 30 +++++++++++++++++++ src/main/utils/agent-args.ts | 5 ++++ 4 files changed, 68 insertions(+) diff --git a/src/main/parsers/copilot-output-parser.ts b/src/main/parsers/copilot-output-parser.ts index 96ab04bdde..58f1e4319d 100644 --- a/src/main/parsers/copilot-output-parser.ts +++ b/src/main/parsers/copilot-output-parser.ts @@ -53,6 +53,7 @@ interface CopilotRawMessage { error?: string | { message?: string }; } +/** Extract a human-readable error message from a string or { message } object. */ function extractErrorText(value: unknown): string | null { if (!value) return null; if (typeof value === 'string' && value.trim()) return value.trim(); @@ -65,16 +66,25 @@ function extractErrorText(value: unknown): string | null { return null; } +/** Extract tool output text from a Copilot tool execution result. */ function extractToolOutput(result: CopilotToolExecutionResult | undefined): string { if (!result) return ''; return result.content || result.detailedContent || ''; } +/** + * Parses GitHub Copilot CLI JSON output into normalized ParsedEvents. + * + * Handles concatenated JSON objects (no newline separators), tracks tool + * names across execution_start/complete events, and detects agent errors + * from structured error events and non-zero exit codes. + */ export class CopilotOutputParser implements AgentOutputParser { readonly agentId: ToolType = 'copilot'; private toolNames = new Map(); + /** Parse a single JSON line from Copilot's JSONL output stream. */ parseJsonLine(line: string): ParsedEvent | null { if (!line.trim()) { return null; @@ -91,6 +101,7 @@ export class CopilotOutputParser implements AgentOutputParser { } } + /** Parse an already-deserialized JSON object into a normalized ParsedEvent. */ parseJsonObject(parsed: unknown): ParsedEvent | null { if (!parsed || typeof parsed !== 'object') { return null; @@ -143,6 +154,7 @@ export class CopilotOutputParser implements AgentOutputParser { } } + /** Parse assistant.message events, detecting final_answer phase as result events. */ private parseAssistantMessage(msg: CopilotRawMessage): ParsedEvent { const content = msg.data?.content || ''; const phase = msg.data?.phase; @@ -181,6 +193,7 @@ export class CopilotOutputParser implements AgentOutputParser { }; } + /** Parse assistant.message_delta events as partial streaming text. */ private parseAssistantMessageDelta(msg: CopilotRawMessage): ParsedEvent | null { const deltaContent = msg.data?.deltaContent || ''; if (!deltaContent) { @@ -195,6 +208,7 @@ export class CopilotOutputParser implements AgentOutputParser { }; } + /** Parse tool.execution_start and register the tool name for later correlation. */ private parseToolExecutionStart(msg: CopilotRawMessage): ParsedEvent { const callId = msg.data?.toolCallId; const toolName = msg.data?.toolName; @@ -214,6 +228,7 @@ export class CopilotOutputParser implements AgentOutputParser { }; } + /** Parse tool.execution_complete, resolving tool name from the tracked map. */ private parseToolExecutionComplete(msg: CopilotRawMessage): ParsedEvent { const callId = msg.data?.toolCallId; const toolName = (callId && this.toolNames.get(callId)) || msg.data?.toolName || undefined; @@ -237,6 +252,7 @@ export class CopilotOutputParser implements AgentOutputParser { }; } + /** Check whether a parsed event represents a completed agent response. */ isResultMessage(event: ParsedEvent): boolean { if (event.type !== 'result') return false; @@ -247,6 +263,7 @@ export class CopilotOutputParser implements AgentOutputParser { return !!event.text || !!event.toolUseBlocks?.length; } + /** Extract the Copilot session ID from a parsed event, if present. */ extractSessionId(event: ParsedEvent): string | null { if (event.sessionId) return event.sessionId; @@ -254,14 +271,17 @@ export class CopilotOutputParser implements AgentOutputParser { return raw?.sessionId || raw?.data?.sessionId || null; } + /** Extract usage/token statistics from a parsed event. */ extractUsage(event: ParsedEvent): ParsedEvent['usage'] | null { return event.usage || null; } + /** Extract slash commands from events. Returns null — Copilot slash commands are interactive-only. */ extractSlashCommands(_event: ParsedEvent): string[] | null { return null; } + /** Detect agent errors from a raw JSON line string. */ detectErrorFromLine(line: string): AgentError | null { if (!line.trim()) { return null; @@ -278,6 +298,7 @@ export class CopilotOutputParser implements AgentOutputParser { } } + /** Detect agent errors from an already-parsed JSON object. Skips bare exit codes to allow detectErrorFromExit to classify with full context. */ detectErrorFromParsed(parsed: unknown): AgentError | null { if (!parsed || typeof parsed !== 'object') { return null; @@ -324,6 +345,7 @@ export class CopilotOutputParser implements AgentOutputParser { }; } + /** Detect agent errors from process exit code and stderr/stdout content. */ detectErrorFromExit(exitCode: number, stderr: string, stdout: string): AgentError | null { if (exitCode === 0) { return null; diff --git a/src/main/process-manager/handlers/StdoutHandler.ts b/src/main/process-manager/handlers/StdoutHandler.ts index 1b2238c2c4..409d36b371 100644 --- a/src/main/process-manager/handlers/StdoutHandler.ts +++ b/src/main/process-manager/handlers/StdoutHandler.ts @@ -99,6 +99,7 @@ function normalizeUsageToDelta( }; } +/** Split a buffer of concatenated JSON objects (no newline separators) into individual complete objects and a partial remainder. */ function extractConcatenatedJsonObjects(buffer: string): { messages: string[]; remainder: string } { const messages: string[] = []; let start = -1; @@ -170,6 +171,7 @@ function extractConcatenatedJsonObjects(buffer: string): { messages: string[]; r }; } +/** Extract the Copilot session ID from a parsed JSON event's top-level or nested data field. */ function extractCopilotSessionId(parsed: unknown): string | null { if (!parsed || typeof parsed !== 'object') { return null; @@ -193,6 +195,7 @@ function extractCopilotSessionId(parsed: unknown): string | null { return null; } +/** Extract the status string from a tool execution state object. */ function getToolStatus(toolState: unknown): string | null { if (!toolState || typeof toolState !== 'object') { return null; @@ -202,6 +205,7 @@ function getToolStatus(toolState: unknown): string | null { return typeof status === 'string' ? status : null; } +/** Get or lazily initialize the per-process set of emitted tool call IDs for deduplication. */ function getEmittedToolCallIds(managedProcess: ManagedProcess): Set { if (!managedProcess.emittedToolCallIds) { managedProcess.emittedToolCallIds = new Set(); @@ -209,6 +213,7 @@ function getEmittedToolCallIds(managedProcess: ManagedProcess): Set { return managedProcess.emittedToolCallIds; } +/** Drop the Copilot JSON remainder buffer if it exceeds the safety limit. Sets the corrupted flag and clears stale tool state. */ function resetOversizedCopilotJsonBuffer(sessionId: string, managedProcess: ManagedProcess): void { const bufferLength = managedProcess.jsonBuffer?.length || 0; if (bufferLength <= MAX_COPILOT_JSON_BUFFER_LENGTH) { @@ -272,6 +277,7 @@ export class StdoutHandler { } } + /** Process stdout data in stream-JSON mode. Handles Copilot concatenated JSON and standard newline-delimited JSON. */ private handleStreamJsonData( sessionId: string, managedProcess: ManagedProcess, @@ -343,6 +349,7 @@ export class StdoutHandler { } } + /** Parse a single JSON line: detect errors, extract session IDs, and dispatch to the event handler. */ private processLine(sessionId: string, managedProcess: ManagedProcess, line: string): void { const { outputParser, toolType } = managedProcess; @@ -423,6 +430,7 @@ export class StdoutHandler { } } + /** Handle a parsed JSON event: extract usage, session IDs, tool executions, and result data. */ private handleParsedEvent( sessionId: string, managedProcess: ManagedProcess, @@ -632,6 +640,7 @@ export class StdoutHandler { } } + /** Handle legacy (non-parser) JSON messages for Claude Code's native format. */ private handleLegacyMessage( sessionId: string, managedProcess: ManagedProcess, @@ -687,6 +696,7 @@ export class StdoutHandler { } } + /** Build a normalized UsageStats object from parser-extracted token counts. */ private buildUsageStats( managedProcess: ManagedProcess, usage: { @@ -712,6 +722,7 @@ export class StdoutHandler { }; } + /** Emit session-id event at most once per managed process lifecycle. */ private emitSessionIdIfNeeded( sessionId: string, managedProcess: ManagedProcess, diff --git a/src/main/storage/copilot-session-storage.ts b/src/main/storage/copilot-session-storage.ts index 4809c3dbec..b9ed1b8fa0 100644 --- a/src/main/storage/copilot-session-storage.ts +++ b/src/main/storage/copilot-session-storage.ts @@ -15,6 +15,7 @@ import type { SearchableMessage } from './base-session-storage'; const LOG_CONTEXT = '[CopilotSessionStorage]'; +/** Resolve the local Copilot session state directory, respecting COPILOT_CONFIG_DIR. */ function getLocalCopilotSessionStateDir(): string { const configDir = process.env.COPILOT_CONFIG_DIR || path.join(os.homedir(), '.copilot'); return path.join(configDir, 'session-state'); @@ -80,6 +81,7 @@ interface CopilotEvent { }; } +/** Strip surrounding quotes and unescape common YAML sequences in scalar values. */ function normalizeYamlScalar(value: string): string { let trimmed = value.trim(); if ( @@ -110,6 +112,7 @@ const WORKSPACE_METADATA_KEYS = new Set([ 'updated_at', ]); +/** Normalize a workspace metadata key from camelCase/kebab-case to the canonical snake_case form. */ function normalizeWorkspaceMetadataKey(key: string): keyof CopilotWorkspaceMetadata | null { const normalized = key .trim() @@ -122,6 +125,7 @@ function normalizeWorkspaceMetadataKey(key: string): keyof CopilotWorkspaceMetad : null; } +/** Parse workspace.yaml content into typed metadata, tolerating format variations. */ function parseWorkspaceMetadata(content: string, sessionId: string): CopilotWorkspaceMetadata { const metadata: CopilotWorkspaceMetadata = { id: sessionId }; @@ -144,11 +148,13 @@ function parseWorkspaceMetadata(content: string, sessionId: string): CopilotWork return metadata; } +/** Normalize a filesystem path for cross-platform comparison. */ function normalizePath(value?: string): string | null { if (!value) return null; return value.replace(/\\/g, '/').replace(/\/+$/, ''); } +/** Check whether session metadata matches the given project path. */ function matchesProject(metadata: CopilotWorkspaceMetadata, projectPath: string): boolean { const normalizedProject = normalizePath(projectPath); const gitRoot = normalizePath(metadata.git_root); @@ -162,6 +168,7 @@ function matchesProject(metadata: CopilotWorkspaceMetadata, projectPath: string) ); } +/** Convert Copilot tool requests into a normalized tool-use structure. */ function buildToolUse(toolRequests?: CopilotToolRequest[]): unknown { if (!toolRequests?.length) return undefined; return toolRequests @@ -173,6 +180,7 @@ function buildToolUse(toolRequests?: CopilotToolRequest[]): unknown { })); } +/** Parse events.jsonl content into messages, statistics, and content indicators. */ function parseEvents(content: string): ParsedCopilotSessionData { const messages: SessionMessage[] = []; let firstAssistantMessage = ''; @@ -268,6 +276,7 @@ function parseEvents(content: string): ParsedCopilotSessionData { }; } +/** Recursively calculate the total size of a local directory in bytes. */ async function getLocalDirectorySize(sessionDir: string): Promise { try { const entries = await fs.readdir(sessionDir, { withFileTypes: true }); @@ -287,35 +296,47 @@ async function getLocalDirectorySize(sessionDir: string): Promise { } } +/** + * Session storage implementation for GitHub Copilot CLI. + * + * Reads session metadata from `~/.copilot/session-state//workspace.yaml` + * and conversation history from `events.jsonl`. Supports both local and SSH remote access. + */ export class CopilotSessionStorage extends BaseSessionStorage { readonly agentId: ToolType = 'copilot'; + /** Remote session state directory path using POSIX tilde expansion. */ private getRemoteSessionStateDir(): string { return '~/.copilot/session-state'; } + /** Resolve the session state base directory (local or remote). */ private getSessionStateDir(sshConfig?: SshRemoteConfig): string { return sshConfig ? this.getRemoteSessionStateDir() : getLocalCopilotSessionStateDir(); } + /** Resolve the directory path for a specific session. */ private getSessionDir(sessionId: string, sshConfig?: SshRemoteConfig): string { return sshConfig ? path.posix.join(this.getRemoteSessionStateDir(), sessionId) : path.join(getLocalCopilotSessionStateDir(), sessionId); } + /** Resolve the workspace.yaml path for a specific session. */ private getWorkspacePath(sessionId: string, sshConfig?: SshRemoteConfig): string { return sshConfig ? path.posix.join(this.getSessionDir(sessionId, sshConfig), 'workspace.yaml') : path.join(this.getSessionDir(sessionId), 'workspace.yaml'); } + /** Resolve the events.jsonl path for a specific session. */ private getEventsPath(sessionId: string, sshConfig?: SshRemoteConfig): string { return sshConfig ? path.posix.join(this.getSessionDir(sessionId, sshConfig), 'events.jsonl') : path.join(this.getSessionDir(sessionId), 'events.jsonl'); } + /** List all Copilot sessions matching the given project path. */ async listSessions( projectPath: string, sshConfig?: SshRemoteConfig @@ -330,6 +351,7 @@ export class CopilotSessionStorage extends BaseSessionStorage { .sort((a, b) => new Date(b.modifiedAt).getTime() - new Date(a.modifiedAt).getTime()); } + /** Read messages from a Copilot session's events.jsonl file. */ async readSessionMessages( _projectPath: string, sessionId: string, @@ -345,6 +367,7 @@ export class CopilotSessionStorage extends BaseSessionStorage { return BaseSessionStorage.applyMessagePagination(messages, options); } + /** Get searchable user/assistant messages for session search. */ protected async getSearchableMessages( sessionId: string, _projectPath: string, @@ -364,6 +387,7 @@ export class CopilotSessionStorage extends BaseSessionStorage { .filter((message) => message.textContent.trim().length > 0); } + /** Get the filesystem path to a session's events.jsonl file. */ getSessionPath( _projectPath: string, sessionId: string, @@ -372,6 +396,7 @@ export class CopilotSessionStorage extends BaseSessionStorage { return this.getEventsPath(sessionId, sshConfig); } + /** Delete a message pair. Not supported for Copilot sessions. */ async deleteMessagePair( _projectPath: string, _sessionId: string, @@ -385,6 +410,7 @@ export class CopilotSessionStorage extends BaseSessionStorage { }; } + /** List all session directory names from the session state directory. */ private async listSessionIds(sshConfig?: SshRemoteConfig): Promise { const sessionStateDir = this.getSessionStateDir(sshConfig); if (sshConfig) { @@ -403,6 +429,7 @@ export class CopilotSessionStorage extends BaseSessionStorage { } } + /** Load session metadata and event statistics for a single session. Returns null if the session doesn't match the project or lacks meaningful content. */ private async loadSessionInfo( projectPath: string, sessionId: string, @@ -477,6 +504,7 @@ export class CopilotSessionStorage extends BaseSessionStorage { } } + /** Read the events.jsonl file content for a session. Returns null on missing/unreadable files. */ private async readEventsFile( sessionId: string, sshConfig?: SshRemoteConfig @@ -492,6 +520,7 @@ export class CopilotSessionStorage extends BaseSessionStorage { } } + /** Read a file from a remote host via SSH. Returns null on failure. */ private async readRemoteFile( filePath: string, sshConfig: SshRemoteConfig @@ -500,6 +529,7 @@ export class CopilotSessionStorage extends BaseSessionStorage { return result.success && result.data ? result.data : null; } + /** Calculate the total size of a session directory on a remote host. */ private async getRemoteDirectorySize( sessionDir: string, sshConfig: SshRemoteConfig diff --git a/src/main/utils/agent-args.ts b/src/main/utils/agent-args.ts index 11c5d6a081..e965b85d06 100644 --- a/src/main/utils/agent-args.ts +++ b/src/main/utils/agent-args.ts @@ -28,6 +28,7 @@ type AgentConfigResolution = { modelSource: 'session' | 'agent' | 'default'; }; +/** Parse a space-separated custom args string into an array, respecting quoted segments. */ function parseCustomArgs(customArgs?: string): string[] { if (!customArgs || typeof customArgs !== 'string') { return []; @@ -42,6 +43,7 @@ function parseCustomArgs(customArgs?: string): string[] { }); } +/** Check whether jsonOutputArgs (exact sequence or flag key) are already present in the args list. */ function hasJsonOutputFlag(haystack: string[], jsonOutputArgs: string[]): boolean { if (jsonOutputArgs.length === 0) return true; @@ -68,6 +70,7 @@ function hasJsonOutputFlag(haystack: string[], jsonOutputArgs: string[]): boolea return false; } +/** Build the final CLI arguments for an agent process based on mode, config, and user options. */ export function buildAgentArgs( agent: AgentConfig | null | undefined, options: BuildAgentArgsOptions @@ -149,6 +152,7 @@ export function buildAgentArgs( return dedupedArgs; } +/** Apply agent configuration overrides (custom args, env vars, model selection) to base args. */ export function applyAgentConfigOverrides( agent: AgentConfig | null | undefined, baseArgs: string[], @@ -236,6 +240,7 @@ export function applyAgentConfigOverrides( }; } +/** Resolve the effective context window size from session, agent config, or defaults. */ export function getContextWindowValue( agent: AgentConfig | null | undefined, agentConfigValues: Record, From dce62b4ad2c347c1a89d08518c4445e6ec2f82af Mon Sep 17 00:00:00 2001 From: Nolan Clark Date: Thu, 12 Mar 2026 22:47:34 -0500 Subject: [PATCH 09/15] PR Comments --- docs/releases.md | 2 +- src/main/storage/copilot-session-storage.ts | 22 ++++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/docs/releases.md b/docs/releases.md index 66df707df0..0f9d178cfe 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -237,7 +237,7 @@ The big changes in the v0.12.x line are the following three: ### Changes -- Add Sentry crashing reporting monitoring with opt-out 🐛 +- Added Sentry crash reporting with opt-out 🐛 - Stability fixes on v0.9.0 along with all the changes it brought along, including... - Major refactor to enable supporting of multiple providers 👨‍👩‍👧‍👦 - Added OpenAI Codex support 👨‍💻 diff --git a/src/main/storage/copilot-session-storage.ts b/src/main/storage/copilot-session-storage.ts index b9ed1b8fa0..97d87b0ec9 100644 --- a/src/main/storage/copilot-session-storage.ts +++ b/src/main/storage/copilot-session-storage.ts @@ -2,6 +2,7 @@ import path from 'path'; import os from 'os'; import fs from 'fs/promises'; import { logger } from '../utils/logger'; +import { captureException } from '../utils/sentry'; import { readFileRemote, readDirRemote, directorySizeRemote } from '../utils/remote-fs'; import type { AgentSessionInfo, @@ -148,10 +149,15 @@ function parseWorkspaceMetadata(content: string, sessionId: string): CopilotWork return metadata; } -/** Normalize a filesystem path for cross-platform comparison. */ +/** Normalize a filesystem path for cross-platform comparison. Case-folds Windows-style paths (drive letter prefix). */ function normalizePath(value?: string): string | null { if (!value) return null; - return value.replace(/\\/g, '/').replace(/\/+$/, ''); + let normalized = value.replace(/\\/g, '/').replace(/\/+$/, ''); + // Case-fold Windows-style paths (e.g., C:/Users) for case-insensitive comparison + if (/^[A-Za-z]:/.test(normalized)) { + normalized = normalized.toLowerCase(); + } + return normalized; } /** Check whether session metadata matches the given project path. */ @@ -497,9 +503,15 @@ export class CopilotSessionStorage extends BaseSessionStorage { durationSeconds: parsedEvents.stats.durationSeconds, }; } catch (error) { - logger.debug(`Failed to load Copilot session metadata for ${sessionId}`, LOG_CONTEXT, { - error, - }); + const code = (error as NodeJS.ErrnoException)?.code; + if (code === 'ENOENT' || code === 'EACCES' || code === 'EPERM') { + logger.debug(`Expected failure loading Copilot session ${sessionId}: ${code}`, LOG_CONTEXT); + } else { + logger.warn(`Unexpected failure loading Copilot session ${sessionId}`, LOG_CONTEXT, { + error, + }); + captureException(error, { operation: 'copilotStorage:loadSessionInfo', sessionId }); + } return null; } } From 0382e8d4a8081b8dbbb6ba9ef9f9ec32328bae53 Mon Sep 17 00:00:00 2001 From: Nolan Clark Date: Thu, 12 Mar 2026 22:50:33 -0500 Subject: [PATCH 10/15] Fix code outside diff --- src/main/process-manager/handlers/StdoutHandler.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/process-manager/handlers/StdoutHandler.ts b/src/main/process-manager/handlers/StdoutHandler.ts index 409d36b371..b580cef9be 100644 --- a/src/main/process-manager/handlers/StdoutHandler.ts +++ b/src/main/process-manager/handlers/StdoutHandler.ts @@ -605,7 +605,12 @@ export class StdoutHandler { !managedProcess.resultEmitted ) { managedProcess.resultEmitted = true; - const resultText = event.text || managedProcess.streamedText || ''; + // For Copilot, streamedText holds transient commentary that was already + // emitted as partial text — falling back to it would duplicate content. + // Only Codex-style agents use streamedText as the final answer accumulator. + const resultText = + event.text || + (managedProcess.toolType !== 'copilot' ? managedProcess.streamedText || '' : ''); // Log synopsis result processing (for debugging empty synopsis issue) if (sessionId.includes('-synopsis-')) { From d17e0422c8b41d802d769e485b5baa50abfa8d3d Mon Sep 17 00:00:00 2001 From: Nolan Clark Date: Thu, 12 Mar 2026 22:57:03 -0500 Subject: [PATCH 11/15] PR Comment --- src/main/storage/copilot-session-storage.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/storage/copilot-session-storage.ts b/src/main/storage/copilot-session-storage.ts index 97d87b0ec9..db47e1f282 100644 --- a/src/main/storage/copilot-session-storage.ts +++ b/src/main/storage/copilot-session-storage.ts @@ -480,7 +480,20 @@ export class CopilotSessionStorage extends BaseSessionStorage { ? await this.getRemoteDirectorySize(sessionDir, sshConfig) : await getLocalDirectorySize(sessionDir); const projectRoot = metadata.git_root || metadata.cwd || projectPath; - const timestamp = metadata.created_at || metadata.updated_at || new Date().toISOString(); + + // Prefer metadata timestamps; fall back to workspace file mtime (local only) + // before using current time as a last resort. + let fallbackTimestamp: string | undefined; + if (!metadata.created_at && !metadata.updated_at && !sshConfig) { + try { + const workspaceStat = await fs.stat(workspacePath); + fallbackTimestamp = new Date(workspaceStat.mtimeMs).toISOString(); + } catch { + // stat failure is non-critical + } + } + const timestamp = + metadata.created_at || metadata.updated_at || fallbackTimestamp || new Date().toISOString(); const modifiedAt = metadata.updated_at || timestamp; const preview = parsedEvents.firstAssistantMessage || From e9f7b61c097e9869bf441b32ad1fc41275bcfbd0 Mon Sep 17 00:00:00 2001 From: Nolan Clark Date: Thu, 12 Mar 2026 23:10:33 -0500 Subject: [PATCH 12/15] PR Comments --- src/main/agents/path-prober.ts | 4 ++-- src/main/storage/copilot-session-storage.ts | 17 ++++++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/main/agents/path-prober.ts b/src/main/agents/path-prober.ts index 98114b1864..ed288228ce 100644 --- a/src/main/agents/path-prober.ts +++ b/src/main/agents/path-prober.ts @@ -309,8 +309,8 @@ function getWindowsKnownPaths(binaryName: string): string[] { ...npmGlobal('opencode'), ], copilot: [ - // GitHub CLI installation (primary method) - path.join(programFiles, 'GitHub CLI', 'copilot.exe'), + // WinGet installation (primary method on Windows) + path.join(programFiles, 'GitHub Copilot CLI', 'copilot.exe'), // npm global installation ...npmGlobal('copilot'), // Scoop installation diff --git a/src/main/storage/copilot-session-storage.ts b/src/main/storage/copilot-session-storage.ts index db47e1f282..3a650f085c 100644 --- a/src/main/storage/copilot-session-storage.ts +++ b/src/main/storage/copilot-session-storage.ts @@ -177,13 +177,14 @@ function matchesProject(metadata: CopilotWorkspaceMetadata, projectPath: string) /** Convert Copilot tool requests into a normalized tool-use structure. */ function buildToolUse(toolRequests?: CopilotToolRequest[]): unknown { if (!toolRequests?.length) return undefined; - return toolRequests + const toolUse = toolRequests .filter((tool) => tool.name) .map((tool) => ({ name: tool.name, id: tool.toolCallId, input: tool.arguments, })); + return toolUse.length > 0 ? toolUse : undefined; } /** Parse events.jsonl content into messages, statistics, and content indicators. */ @@ -297,7 +298,12 @@ async function getLocalDirectorySize(sessionDir: string): Promise { } } return total; - } catch { + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + if (code === 'ENOENT' || code === 'EACCES' || code === 'EPERM') { + return 0; + } + captureException(error, { operation: 'copilotStorage:getLocalDirectorySize', sessionDir }); return 0; } } @@ -430,7 +436,12 @@ export class CopilotSessionStorage extends BaseSessionStorage { try { const entries = await fs.readdir(sessionStateDir, { withFileTypes: true }); return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name); - } catch { + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + if (code === 'ENOENT' || code === 'EACCES' || code === 'EPERM') { + return []; + } + captureException(error, { operation: 'copilotStorage:listSessionIds' }); return []; } } From 763299e64f2f5eb09e0f07475775c8a28b9e97ee Mon Sep 17 00:00:00 2001 From: Nolan Clark Date: Thu, 12 Mar 2026 23:22:47 -0500 Subject: [PATCH 13/15] PR comment --- src/main/storage/copilot-session-storage.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/storage/copilot-session-storage.ts b/src/main/storage/copilot-session-storage.ts index 3a650f085c..bcd9c686d2 100644 --- a/src/main/storage/copilot-session-storage.ts +++ b/src/main/storage/copilot-session-storage.ts @@ -551,7 +551,12 @@ export class CopilotSessionStorage extends BaseSessionStorage { return sshConfig ? await this.readRemoteFile(eventsPath, sshConfig) : await fs.readFile(eventsPath, 'utf8'); - } catch { + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + if (code === 'ENOENT' || code === 'EACCES' || code === 'EPERM') { + return null; + } + captureException(error, { operation: 'copilotStorage:readEventsFile', sessionId }); return null; } } From d0babb669b3e42cc8c6da1bc98a187f36e130c5a Mon Sep 17 00:00:00 2001 From: Nolan Clark Date: Fri, 13 Mar 2026 01:04:30 -0500 Subject: [PATCH 14/15] Improve GitHub CLI support and progress UX Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CLAUDE-AGENTS.md | 20 +- CONTRIBUTING.md | 16 +- .../main/agents/capabilities.test.ts | 18 ++ src/__tests__/main/agents/definitions.test.ts | 18 ++ .../parsers/copilot-output-parser.test.ts | 38 +++ .../handlers/StdoutHandler.test.ts | 49 ++++ .../spawners/ChildProcessSpawner.test.ts | 28 ++ .../main/utils/ssh-command-builder.test.ts | 22 ++ .../components/TerminalOutput.test.tsx | 33 +++ .../renderer/hooks/useAgentListeners.test.ts | 175 +++++++++++ src/main/agents/capabilities.ts | 8 +- src/main/agents/definitions.ts | 13 +- src/main/ipc/handlers/agents.ts | 3 +- src/main/ipc/handlers/process.ts | 11 +- src/main/parsers/copilot-output-parser.ts | 33 +++ .../spawners/ChildProcessSpawner.ts | 38 ++- src/main/process-manager/types.ts | 1 + src/main/utils/ssh-command-builder.ts | 17 +- src/renderer/components/TerminalOutput.tsx | 93 ++++-- src/renderer/hooks/agent/useAgentListeners.ts | 275 +++++++++++++++++- src/renderer/types/index.ts | 6 +- src/renderer/utils/hiddenProgress.ts | 11 + 22 files changed, 860 insertions(+), 66 deletions(-) create mode 100644 src/renderer/utils/hiddenProgress.ts diff --git a/CLAUDE-AGENTS.md b/CLAUDE-AGENTS.md index 70e8e76de1..c1a747c1c5 100644 --- a/CLAUDE-AGENTS.md +++ b/CLAUDE-AGENTS.md @@ -4,14 +4,14 @@ Agent support documentation for the Maestro codebase. For the main guide, see [[ ## Supported Agents -| ID | Name | Status | Notes | -| --------------- | -------------- | ---------- | ---------------------------------------------------------------- | -| `claude-code` | Claude Code | **Active** | Primary agent, `--print --verbose --output-format stream-json` | -| `codex` | Codex | **Active** | Full support, `--json`, YOLO mode default | -| `opencode` | OpenCode | **Active** | Multi-provider support (75+ LLMs), stub provider session storage | -| `factory-droid` | Factory Droid | **Active** | Factory's AI coding assistant, `-o stream-json` | -| `copilot` | GitHub Copilot | **Beta** | `-p/--prompt`, `--output-format json`, `--resume`, session-state | -| `terminal` | Terminal | Internal | Hidden from UI, used for shell sessions | +| ID | Name | Status | Notes | +| --------------- | -------------- | ---------- | ---------------------------------------------------------------------------------------------------------- | +| `claude-code` | Claude Code | **Active** | Primary agent, `--print --verbose --output-format stream-json` | +| `codex` | Codex | **Active** | Full support, `--json`, YOLO mode default | +| `opencode` | OpenCode | **Active** | Multi-provider support (75+ LLMs), stub provider session storage | +| `factory-droid` | Factory Droid | **Active** | Factory's AI coding assistant, `-o stream-json` | +| `copilot` | GitHub Copilot | **Beta** | `-p/--prompt`, `--output-format json`, `--resume`, `@image` mentions, permission filters, reasoning stream | +| `terminal` | Terminal | Internal | Hidden from UI, used for shell sessions | ## Agent Capabilities @@ -97,7 +97,9 @@ Centralized in `src/shared/agentMetadata.ts` (importable from any process): - **JSON Output:** `--output-format json` - **Batch Mode:** `-p, --prompt ` - **Resume:** `--continue`, `--resume[=session-id]` -- **Read-only:** Interactive `/plan` only (no verified startup flag) +- **Read-only:** CLI-enforced via `--allow-tool=read,url`, `--deny-tool=write,shell,memory,github`, `--no-ask-user` +- **Thinking Display:** Streams `assistant.reasoning_delta` / `assistant.reasoning` into Maestro's thinking panel +- **Images:** Prompt-embedded `@/tmp/...` mentions (maps Maestro uploads to Copilot file/image mentions) - **Session Storage:** `~/.copilot/session-state//` ## Adding New Agents diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5e71ecb848..a858c72707 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -644,14 +644,14 @@ Based on capabilities, these UI features are automatically enabled/disabled: ### Supported Agents Reference -| Agent | Resume | Read-Only | JSON | Images | Sessions | Cost | Status | -| ------------------ | ---------------------------- | --------------------------- | ---- | ------ | ------------------------------ | ---------------- | ----------- | -| Claude Code | ✅ `--resume` | ✅ `--permission-mode plan` | ✅ | ✅ | ✅ `~/.claude/` | ✅ | ✅ Complete | -| Codex | ✅ `exec resume` | ✅ `--sandbox read-only` | ✅ | ✅ | ✅ `~/.codex/` | ❌ (tokens only) | ✅ Complete | -| OpenCode | ✅ `--session` | ✅ `--agent plan` | ✅ | ✅ | ✅ `~/.local/share/opencode/` | ✅ | ✅ Complete | -| Factory Droid | ✅ `-s, --session-id` | ✅ (default mode) | ✅ | ✅ | ✅ `~/.factory/` | ❌ (tokens only) | ✅ Complete | -| GitHub Copilot CLI | ✅ `--resume` / `--continue` | ⚠️ Interactive `/plan` only | ✅ | ❌ | ✅ `~/.copilot/session-state/` | ❌ | 🧪 Beta | -| Gemini CLI | TBD | TBD | TBD | TBD | TBD | ✅ | 📋 Planned | +| Agent | Resume | Read-Only | JSON | Images | Sessions | Cost | Status | +| ------------------ | ---------------------------- | --------------------------- | ---- | ------ | ------------------------------ | ----------------------- | ----------- | +| Claude Code | ✅ `--resume` | ✅ `--permission-mode plan` | ✅ | ✅ | ✅ `~/.claude/` | ✅ | ✅ Complete | +| Codex | ✅ `exec resume` | ✅ `--sandbox read-only` | ✅ | ✅ | ✅ `~/.codex/` | ❌ (tokens only) | ✅ Complete | +| OpenCode | ✅ `--session` | ✅ `--agent plan` | ✅ | ✅ | ✅ `~/.local/share/opencode/` | ✅ | ✅ Complete | +| Factory Droid | ✅ `-s, --session-id` | ✅ (default mode) | ✅ | ✅ | ✅ `~/.factory/` | ❌ (tokens only) | ✅ Complete | +| GitHub Copilot CLI | ✅ `--resume` / `--continue` | ✅ permission rules | ✅ | ✅ | ✅ `~/.copilot/session-state/` | ❌ (not exposed by CLI) | 🧪 Beta | +| Gemini CLI | TBD | TBD | TBD | TBD | TBD | ✅ | 📋 Planned | For detailed implementation guide, see [AGENT_SUPPORT.md](AGENT_SUPPORT.md). diff --git a/src/__tests__/main/agents/capabilities.test.ts b/src/__tests__/main/agents/capabilities.test.ts index 33f0f46b51..bdaeca4675 100644 --- a/src/__tests__/main/agents/capabilities.test.ts +++ b/src/__tests__/main/agents/capabilities.test.ts @@ -135,6 +135,23 @@ describe('agent-capabilities', () => { expect(capabilities.supportsResultMessages).toBe(true); }); + it('should have verified capabilities for copilot', () => { + const capabilities = AGENT_CAPABILITIES['copilot']; + expect(capabilities).toBeDefined(); + expect(capabilities.supportsResume).toBe(true); + expect(capabilities.supportsReadOnlyMode).toBe(true); + expect(capabilities.supportsJsonOutput).toBe(true); + expect(capabilities.supportsSessionId).toBe(true); + expect(capabilities.supportsImageInput).toBe(true); + expect(capabilities.supportsImageInputOnResume).toBe(true); + expect(capabilities.supportsSlashCommands).toBe(true); + expect(capabilities.supportsSessionStorage).toBe(true); + expect(capabilities.supportsBatchMode).toBe(true); + expect(capabilities.supportsStreaming).toBe(true); + expect(capabilities.supportsResultMessages).toBe(true); + expect(capabilities.supportsThinkingDisplay).toBe(true); + }); + it('should define capabilities for all known agents', () => { const knownAgents = [ 'claude-code', @@ -145,6 +162,7 @@ describe('agent-capabilities', () => { 'opencode', 'factory-droid', 'aider', + 'copilot', ]; for (const agentId of knownAgents) { diff --git a/src/__tests__/main/agents/definitions.test.ts b/src/__tests__/main/agents/definitions.test.ts index 42e9f1495c..21edacc3e8 100644 --- a/src/__tests__/main/agents/definitions.test.ts +++ b/src/__tests__/main/agents/definitions.test.ts @@ -76,6 +76,12 @@ describe('agent-definitions', () => { expect(copilot).toBeDefined(); expect(copilot?.requiresPty).toBe(true); expect(copilot?.jsonOutputArgs).toEqual(['--output-format', 'json']); + expect(copilot?.readOnlyArgs).toEqual([ + '--allow-tool=read,url', + '--deny-tool=write,shell,memory,github', + '--no-ask-user', + ]); + expect(copilot?.readOnlyCliEnforced).toBe(true); }); it('should have opencode with default env vars for YOLO mode and disabled question tool', () => { @@ -226,6 +232,18 @@ describe('agent-definitions', () => { const args = opencode?.imageArgs?.('/path/to/image.png'); expect(args).toEqual(['-f', '/path/to/image.png']); }); + + it('should embed Copilot images into prompts using @mentions', () => { + const copilot = getAgentDefinition('copilot'); + expect(copilot?.imagePromptBuilder).toBeDefined(); + + const promptPrefix = copilot?.imagePromptBuilder?.([ + '/tmp/screenshot-1.png', + '/tmp/screenshot-2.jpg', + ]); + expect(promptPrefix).toContain('@/tmp/screenshot-1.png'); + expect(promptPrefix).toContain('@/tmp/screenshot-2.jpg'); + }); }); describe('Agent config options', () => { diff --git a/src/__tests__/main/parsers/copilot-output-parser.test.ts b/src/__tests__/main/parsers/copilot-output-parser.test.ts index e211ea1bbe..19548ba419 100644 --- a/src/__tests__/main/parsers/copilot-output-parser.test.ts +++ b/src/__tests__/main/parsers/copilot-output-parser.test.ts @@ -122,6 +122,44 @@ describe('CopilotOutputParser', () => { ); }); + it('parses assistant reasoning events as partial text events', () => { + const parser = new CopilotOutputParser(); + + const event = parser.parseJsonObject({ + type: 'assistant.reasoning', + data: { + content: 'Thinking through the repository structure...', + }, + }); + + expect(event).toEqual( + expect.objectContaining({ + type: 'text', + text: 'Thinking through the repository structure...', + isPartial: true, + }) + ); + }); + + it('parses assistant reasoning delta events as partial text events', () => { + const parser = new CopilotOutputParser(); + + const event = parser.parseJsonObject({ + type: 'assistant.reasoning_delta', + data: { + deltaContent: 'Thinking live...', + }, + }); + + expect(event).toEqual( + expect.objectContaining({ + type: 'text', + text: 'Thinking live...', + isPartial: true, + }) + ); + }); + it('tracks tool execution start and completion by toolCallId', () => { const parser = new CopilotOutputParser(); diff --git a/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts b/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts index a7fed597f8..c684e282e9 100644 --- a/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts +++ b/src/__tests__/main/process-manager/handlers/StdoutHandler.test.ts @@ -479,6 +479,55 @@ describe('StdoutHandler', () => { ); }); + it('should emit Copilot reasoning events as thinking chunks', () => { + const parser = new CopilotOutputParser(); + const { handler, emitter, sessionId } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + const thinkingSpy = vi.fn(); + emitter.on('thinking-chunk', thinkingSpy); + + handler.handleData( + sessionId, + JSON.stringify({ + type: 'assistant.reasoning', + data: { + content: 'Analyzing the codebase before making edits...', + }, + }) + ); + + expect(thinkingSpy).toHaveBeenCalledWith( + sessionId, + 'Analyzing the codebase before making edits...' + ); + }); + + it('should emit Copilot reasoning delta events as thinking chunks', () => { + const parser = new CopilotOutputParser(); + const { handler, emitter, sessionId } = createTestContext({ + isStreamJsonMode: true, + toolType: 'copilot', + outputParser: parser, + }); + const thinkingSpy = vi.fn(); + emitter.on('thinking-chunk', thinkingSpy); + + handler.handleData( + sessionId, + JSON.stringify({ + type: 'assistant.reasoning_delta', + data: { + deltaContent: 'Live reasoning chunk...', + }, + }) + ); + + expect(thinkingSpy).toHaveBeenCalledWith(sessionId, 'Live reasoning chunk...'); + }); + it('should keep failed Copilot tool executions as tool events instead of agent errors', () => { const parser = new CopilotOutputParser(); const { handler, emitter, sessionId } = createTestContext({ diff --git a/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts b/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts index 6b5b011ebc..4cbf7fb231 100644 --- a/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts +++ b/src/__tests__/main/process-manager/spawners/ChildProcessSpawner.test.ts @@ -607,6 +607,34 @@ describe('ChildProcessSpawner', () => { // Should NOT have --input-format since this agent doesn't support it expect(spawnArgs).not.toContain('--input-format'); }); + + it('should embed Copilot image paths into the prompt when imagePromptBuilder is provided', () => { + vi.mocked(getAgentCapabilities).mockReturnValueOnce({ + supportsStreamJsonInput: false, + } as any); + vi.mocked(saveImageToTempFile).mockReturnValueOnce('/tmp/maestro-image-0.png'); + + const { spawner } = createTestContext(); + + spawner.spawn( + createBaseConfig({ + toolType: 'copilot', + command: 'copilot', + args: ['--output-format', 'json'], + images: ['data:image/png;base64,abc123'], + prompt: 'describe this image', + imagePromptBuilder: (paths: string[]) => + `Use these attached images as context:\n${paths.map((imagePath) => `@${imagePath}`).join('\n')}\n\n`, + promptArgs: (prompt: string) => ['-p', prompt], + }) + ); + + const spawnArgs = mockSpawn.mock.calls[0][1] as string[]; + expect(spawnArgs).toContain('-p'); + const promptArg = spawnArgs[spawnArgs.indexOf('-p') + 1]; + expect(promptArg).toContain('@/tmp/maestro-image-0.png'); + expect(promptArg).toContain('describe this image'); + }); }); describe('resume mode with prompt-embed image handling', () => { diff --git a/src/__tests__/main/utils/ssh-command-builder.test.ts b/src/__tests__/main/utils/ssh-command-builder.test.ts index 13077edb26..ad46e1bc80 100644 --- a/src/__tests__/main/utils/ssh-command-builder.test.ts +++ b/src/__tests__/main/utils/ssh-command-builder.test.ts @@ -1036,6 +1036,28 @@ describe('ssh-command-builder', () => { expect(result.stdinScript).toContain('; rm -f'); }); + it('embeds Copilot image @mentions when imagePromptBuilder is provided', async () => { + const testImage = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=='; + const result = await buildSshCommandWithStdin(baseConfig, { + command: 'copilot', + args: ['--output-format', 'json'], + stdinInput: 'describe this image', + images: [testImage], + imagePromptBuilder: (paths: string[]) => + `Use these attached images as context:\n${paths.map((imagePath) => `@${imagePath}`).join('\n')}\n\n`, + }); + + expect(result.stdinScript).toContain('base64 -d >'); + const cmdLine = result.stdinScript?.split('\n').find((line) => line.startsWith('copilot ')); + expect(cmdLine).toBeDefined(); + expect(cmdLine).not.toContain("'-i'"); + expect(cmdLine).toContain('; rm -f'); + + const afterCmd = result.stdinScript?.split(cmdLine + '\n')[1]; + expect(afterCmd).toContain('@/tmp/maestro-image-'); + expect(afterCmd).toContain('describe this image'); + }); + it('does not embed image paths when imageResumeMode is not set (default behavior)', async () => { const testImage = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=='; const result = await buildSshCommandWithStdin(baseConfig, { diff --git a/src/__tests__/renderer/components/TerminalOutput.test.tsx b/src/__tests__/renderer/components/TerminalOutput.test.tsx index e6ebfeabaf..1d22012085 100644 --- a/src/__tests__/renderer/components/TerminalOutput.test.tsx +++ b/src/__tests__/renderer/components/TerminalOutput.test.tsx @@ -1911,6 +1911,39 @@ describe('TerminalOutput', () => { }); }); + describe('hidden progress rendering', () => { + it('renders hidden tool progress with the polished activity treatment', () => { + const logs: LogEntry[] = [ + createLogEntry({ + id: 'hidden-progress:tab-1', + text: 'Reading src/renderer/App.tsx', + source: 'system', + metadata: { + toolState: { + status: 'running', + input: { path: 'src/renderer/App.tsx' }, + }, + hiddenProgress: { + kind: 'tool', + toolName: 'view', + }, + }, + }), + ]; + + const session = createDefaultSession({ + tabs: [{ id: 'tab-1', agentSessionId: 'claude-123', logs, isUnread: false }], + activeTabId: 'tab-1', + }); + + render(); + + expect(screen.getByText('view')).toBeInTheDocument(); + expect(screen.getByText('Reading src/renderer/App.tsx')).toBeInTheDocument(); + expect(screen.queryByTestId('react-markdown')).not.toBeInTheDocument(); + }); + }); + describe('local filter functionality', () => { it('shows filter button for terminal output entries', () => { const logs: LogEntry[] = [createLogEntry({ text: 'Terminal output', source: 'stdout' })]; diff --git a/src/__tests__/renderer/hooks/useAgentListeners.test.ts b/src/__tests__/renderer/hooks/useAgentListeners.test.ts index de1f9332fc..c71e7c7b35 100644 --- a/src/__tests__/renderer/hooks/useAgentListeners.test.ts +++ b/src/__tests__/renderer/hooks/useAgentListeners.test.ts @@ -243,6 +243,7 @@ beforeEach(() => { afterEach(() => { vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); // ============================================================================ @@ -889,6 +890,145 @@ describe('useAgentListeners', () => { }); }); + // ======================================================================== + // onThinkingChunk handler + // ======================================================================== + + describe('onThinkingChunk', () => { + it('shows lightweight progress when thinking is hidden', async () => { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + + const deps = createMockDeps(); + const session = createMockSession({ + id: 'sess-1', + aiTabs: [createMockTab({ id: 'tab-1', showThinking: 'off' })], + activeTabId: 'tab-1', + }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 'sess-1', + }); + + renderHook(() => useAgentListeners(deps)); + + onThinkingChunkHandler?.('sess-1-ai-tab-1', 'reasoning...'); + await new Promise((r) => setTimeout(r, 0)); + + const updated = useSessionStore.getState().sessions.find((s) => s.id === 'sess-1'); + expect(updated?.aiTabs[0]?.logs).toHaveLength(1); + expect(updated?.aiTabs[0]?.logs[0]).toMatchObject({ + source: 'system', + text: 'Thinking through the next step...', + }); + }); + + it('keeps active tool progress visible while thinking stays hidden', async () => { + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + + const deps = createMockDeps(); + const session = createMockSession({ + id: 'sess-1', + aiTabs: [createMockTab({ id: 'tab-1', showThinking: 'off' })], + activeTabId: 'tab-1', + }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 'sess-1', + }); + + renderHook(() => useAgentListeners(deps)); + + onToolExecutionHandler?.('sess-1-ai-tab-1', { + toolName: 'view', + state: { status: 'running', input: { path: 'src/App.tsx' } }, + timestamp: 1700000000000, + }); + onThinkingChunkHandler?.('sess-1-ai-tab-1', 'reasoning...'); + await new Promise((r) => setTimeout(r, 0)); + + const updated = useSessionStore.getState().sessions.find((s) => s.id === 'sess-1'); + expect(updated?.aiTabs[0]?.logs[0]).toMatchObject({ + text: 'Reading src/App.tsx', + }); + }); + + it('removes hidden progress once visible output arrives', () => { + const deps = createMockDeps(); + const session = createMockSession({ + id: 'sess-1', + state: 'busy', + aiTabs: [ + createMockTab({ + id: 'tab-1', + showThinking: 'off', + logs: [ + { + id: 'hidden-progress:tab-1', + timestamp: 1700000000000, + source: 'system', + text: 'Thinking through the next step...', + }, + ], + }), + ], + activeTabId: 'tab-1', + }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 'sess-1', + }); + + renderHook(() => useAgentListeners(deps)); + + onDataHandler?.('sess-1-ai-tab-1', 'Visible response'); + + const updated = useSessionStore.getState().sessions.find((s) => s.id === 'sess-1'); + expect(updated?.aiTabs[0]?.logs).toEqual([]); + }); + }); + + // ======================================================================== + // onToolExecution handler + // ======================================================================== + + describe('onToolExecution', () => { + it('shows lightweight tool status when thinking is hidden', () => { + const deps = createMockDeps(); + const session = createMockSession({ + id: 'sess-1', + aiTabs: [createMockTab({ id: 'tab-1', showThinking: 'off' })], + activeTabId: 'tab-1', + }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 'sess-1', + }); + + renderHook(() => useAgentListeners(deps)); + + onToolExecutionHandler?.('sess-1-ai-tab-1', { + toolName: 'view', + state: { status: 'running', input: { path: 'src/renderer/App.tsx' } }, + timestamp: 1700000000000, + }); + + const updated = useSessionStore.getState().sessions.find((s) => s.id === 'sess-1'); + expect(updated?.aiTabs[0]?.logs).toHaveLength(1); + expect(updated?.aiTabs[0]?.logs[0]).toMatchObject({ + source: 'system', + text: 'Reading src/renderer/App.tsx', + }); + }); + }); + // ======================================================================== // onSshRemote handler // ======================================================================== @@ -952,6 +1092,41 @@ describe('useAgentListeners', () => { expect(updated?.state).toBe('idle'); }); + it('clears hidden progress logs on AI exit', async () => { + const deps = createMockDeps(); + const tab = createMockTab({ + id: 'tab-1', + showThinking: 'off', + logs: [ + { + id: 'hidden-progress:tab-1', + timestamp: 1700000000000, + source: 'system', + text: 'Reading src/renderer/App.tsx', + }, + ], + }); + const session = createMockSession({ + id: 'sess-1', + state: 'busy', + busySource: 'ai', + aiTabs: [tab], + activeTabId: 'tab-1', + }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 'sess-1', + }); + + renderHook(() => useAgentListeners(deps)); + + await onExitHandler?.('sess-1-ai-tab-1'); + await new Promise((r) => setTimeout(r, 50)); + + const updated = useSessionStore.getState().sessions.find((s) => s.id === 'sess-1'); + expect(updated?.aiTabs[0]?.logs).toEqual([]); + }); + it('processes execution queue on exit', async () => { const processQueuedItem = vi.fn().mockResolvedValue(undefined); const deps = createMockDeps({ diff --git a/src/main/agents/capabilities.ts b/src/main/agents/capabilities.ts index d067471175..9e9486a15f 100644 --- a/src/main/agents/capabilities.ts +++ b/src/main/agents/capabilities.ts @@ -395,11 +395,11 @@ export const AGENT_CAPABILITIES: Record = { */ copilot: { supportsResume: true, // --continue, --resume[=sessionId] - supportsReadOnlyMode: false, // Interactive /plan exists, but no verified startup flag for batch mode + supportsReadOnlyMode: true, // Maestro enforces read-only via Copilot's CLI tool permission rules supportsJsonOutput: true, // --output-format json (JSONL) supportsSessionId: true, // result event includes sessionId - supportsImageInput: false, // Not verified in help output - supportsImageInputOnResume: false, // Not verified + supportsImageInput: true, // Copilot supports @file/@image mentions; Maestro maps uploads to temp-file mentions + supportsImageInputOnResume: true, // Prompt-based @image mentions work for resumed sessions as well supportsSlashCommands: true, // Interactive mode supports slash commands supportsSessionStorage: true, // ~/.copilot/session-state// supportsCostTracking: false, // Not verified @@ -410,7 +410,7 @@ export const AGENT_CAPABILITIES: Record = { supportsResultMessages: true, // assistant.message with phase=final_answer supportsModelSelection: true, // --model supportsStreamJsonInput: false, // Not verified - supportsThinkingDisplay: false, // Reasoning events exist, but we do not render them separately yet + supportsThinkingDisplay: true, // assistant.reasoning events are rendered through Maestro's thinking-chunk pipeline supportsContextMerge: false, // Not verified - PLACEHOLDER supportsContextExport: false, // Not verified - PLACEHOLDER supportsWizard: false, // PLACEHOLDER - not verified diff --git a/src/main/agents/definitions.ts b/src/main/agents/definitions.ts index 1417c57623..9db1bdd43a 100644 --- a/src/main/agents/definitions.ts +++ b/src/main/agents/definitions.ts @@ -94,6 +94,7 @@ export interface AgentConfig { yoloModeArgs?: string[]; // Args for YOLO/full-access mode (e.g., ['--dangerously-bypass-approvals-and-sandbox']) workingDirArgs?: (dir: string) => string[]; // Function to build working directory args (e.g., ['-C', dir]) imageArgs?: (imagePath: string) => string[]; // Function to build image attachment args (e.g., ['-i', imagePath] for Codex) + imagePromptBuilder?: (imagePaths: string[]) => string; // Function to embed image references into the prompt (e.g., Copilot @mentions) promptArgs?: (prompt: string) => string[]; // Function to build prompt args (e.g., ['-p', prompt] for OpenCode) noPromptSeparator?: boolean; // If true, don't add '--' before the prompt in batch mode (OpenCode doesn't support it) defaultEnvVars?: Record; // Default environment variables for this agent (merged with user customEnvVars) @@ -415,10 +416,18 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [ batchModeArgs: ['--allow-all-tools', '--silent'], // Non-interactive mode requires tool auto-approval jsonOutputArgs: ['--output-format', 'json'], // JSONL output resumeArgs: (sessionId: string) => [`--resume=${sessionId}`], // Resume with session ID (--continue or --resume=sessionId) - readOnlyArgs: [], // No verified startup flag for read-only mode (interactive /plan exists but no batch flag confirmed) - readOnlyCliEnforced: false, // No CLI-enforced read-only mode verified from help + readOnlyArgs: [ + '--allow-tool=read,url', + '--deny-tool=write,shell,memory,github', + '--no-ask-user', + ], // Enforce read-only by denying write/shell/memory/github actions at the Copilot CLI layer + readOnlyCliEnforced: true, // CLI-enforced via explicit tool permission rules modelArgs: (modelId: string) => ['--model', modelId], // Model selection yoloModeArgs: ['--allow-all-tools'], // Auto-approve all tools (--allow-all-tools or --allow-all) + imagePromptBuilder: (imagePaths: string[]) => + imagePaths.length > 0 + ? `Use these attached images as context:\n${imagePaths.map((imagePath) => `@${imagePath}`).join('\n')}\n\n` + : '', promptArgs: (prompt: string) => ['-p', prompt], // Batch mode prompt arg // Agent-specific configuration options configOptions: [ diff --git a/src/main/ipc/handlers/agents.ts b/src/main/ipc/handlers/agents.ts index babed9f318..47fa058653 100644 --- a/src/main/ipc/handlers/agents.ts +++ b/src/main/ipc/handlers/agents.ts @@ -73,7 +73,7 @@ function getSshRemoteById( * Helper to strip non-serializable functions from agent configs. * Agent configs can have function properties that cannot be sent over IPC: * - argBuilder in configOptions - * - resumeArgs, modelArgs, workingDirArgs, imageArgs, promptArgs on the agent config + * - resumeArgs, modelArgs, workingDirArgs, imageArgs, imagePromptBuilder, promptArgs on the agent config */ function stripAgentFunctions(agent: any) { if (!agent) return null; @@ -84,6 +84,7 @@ function stripAgentFunctions(agent: any) { modelArgs: _modelArgs, workingDirArgs: _workingDirArgs, imageArgs: _imageArgs, + imagePromptBuilder: _imagePromptBuilder, promptArgs: _promptArgs, ...serializableAgent } = agent; diff --git a/src/main/ipc/handlers/process.ts b/src/main/ipc/handlers/process.ts index 6d2bf6bf39..becbbadbf5 100644 --- a/src/main/ipc/handlers/process.ts +++ b/src/main/ipc/handlers/process.ts @@ -446,13 +446,21 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void // File-based image agents (Codex, OpenCode): pass images for remote temp file creation // Also needed for resume-with-prompt-embed (still creates temp files, just no -i args) images: - hasImages && agent?.imageArgs && !agent?.capabilities?.supportsStreamJsonInput + hasImages && + (agent?.imageArgs || agent?.imagePromptBuilder) && + !agent?.capabilities?.supportsStreamJsonInput ? config.images : undefined, imageArgs: hasImages && agent?.imageArgs && !agent?.capabilities?.supportsStreamJsonInput ? agent.imageArgs : undefined, + imagePromptBuilder: + hasImages && + agent?.imagePromptBuilder && + !agent?.capabilities?.supportsStreamJsonInput + ? agent.imagePromptBuilder + : undefined, // Signal resume mode for prompt embedding instead of -i CLI args imageResumeMode: isResumeWithImages ? 'prompt-embed' : undefined, }); @@ -516,6 +524,7 @@ export function registerProcessHandlers(deps: ProcessHandlerDependencies): void // When using SSH, env vars are passed in the stdin script, not locally customEnvVars: customEnvVarsToPass, imageArgs: agent?.imageArgs, // Function to build image CLI args (for Codex, OpenCode) + imagePromptBuilder: agent?.imagePromptBuilder, // Function to embed image refs into prompts (for Copilot) promptArgs: agent?.promptArgs, // Function to build prompt args (e.g., ['-p', prompt] for OpenCode) noPromptSeparator: agent?.noPromptSeparator, // Some agents don't support '--' before prompt // Stats tracking: use cwd as projectPath if not explicitly provided diff --git a/src/main/parsers/copilot-output-parser.ts b/src/main/parsers/copilot-output-parser.ts index 58f1e4319d..6bd9b634e5 100644 --- a/src/main/parsers/copilot-output-parser.ts +++ b/src/main/parsers/copilot-output-parser.ts @@ -10,6 +10,7 @@ * - user.message * - assistant.turn_start / assistant.turn_end * - assistant.message + * - assistant.reasoning_delta * - assistant.reasoning * - tool.execution_start / tool.execution_complete * - result @@ -53,6 +54,17 @@ interface CopilotRawMessage { error?: string | { message?: string }; } +/** Extract non-empty text from strings or simple string arrays. */ +function extractTextValue(value: unknown): string { + if (typeof value === 'string') { + return value; + } + if (Array.isArray(value)) { + return value.filter((part): part is string => typeof part === 'string').join(''); + } + return ''; +} + /** Extract a human-readable error message from a string or { message } object. */ function extractErrorText(value: unknown): string | null { if (!value) return null; @@ -114,7 +126,9 @@ export class CopilotOutputParser implements AgentOutputParser { return this.parseAssistantMessage(msg); case 'assistant.message_delta': return this.parseAssistantMessageDelta(msg); + case 'assistant.reasoning_delta': case 'assistant.reasoning': + return this.parseAssistantReasoning(msg); case 'assistant.turn_start': case 'assistant.turn_end': case 'session.tools_updated': @@ -208,6 +222,25 @@ export class CopilotOutputParser implements AgentOutputParser { }; } + /** Parse assistant.reasoning events as partial reasoning text for thinking-chunk UI. */ + private parseAssistantReasoning(msg: CopilotRawMessage): ParsedEvent | null { + const reasoningText = + extractTextValue(msg.data?.deltaContent) || + extractTextValue(msg.data?.content) || + extractTextValue(msg.data?.message); + + if (!reasoningText) { + return null; + } + + return { + type: 'text', + text: reasoningText, + isPartial: true, + raw: msg, + }; + } + /** Parse tool.execution_start and register the tool name for later correlation. */ private parseToolExecutionStart(msg: CopilotRawMessage): ParsedEvent { const callId = msg.data?.toolCallId; diff --git a/src/main/process-manager/spawners/ChildProcessSpawner.ts b/src/main/process-manager/spawners/ChildProcessSpawner.ts index e86cecba9b..43b4038cd9 100644 --- a/src/main/process-manager/spawners/ChildProcessSpawner.ts +++ b/src/main/process-manager/spawners/ChildProcessSpawner.ts @@ -61,6 +61,7 @@ export class ChildProcessSpawner { prompt, images, imageArgs, + imagePromptBuilder, promptArgs, contextWindow, customEnvVars, @@ -107,8 +108,9 @@ export class ChildProcessSpawner { : []; finalArgs = [...args, ...needsInputFormat]; // Prompt will be sent via stdin as stream-json with embedded images (not in CLI args) - } else if (hasImages && prompt && imageArgs) { - // For agents that use file-based image args (like Codex, OpenCode) + } else if (hasImages && prompt && (imageArgs || imagePromptBuilder)) { + // For agents that use file-based image args (like Codex, OpenCode) or + // prompt-embedded image mentions (like Copilot's @path syntax) finalArgs = [...args]; tempImageFiles = []; for (let i = 0; i < images.length; i++) { @@ -120,10 +122,14 @@ export class ChildProcessSpawner { const isResumeWithPromptEmbed = capabilities.imageResumeMode === 'prompt-embed' && args.some((a) => a === 'resume'); - - if (isResumeWithPromptEmbed) { - // Resume mode: embed file paths in prompt text, don't use -i flag - const imagePrefix = buildImagePromptPrefix(tempImageFiles); + const shouldEmbedImagesInPrompt = !!imagePromptBuilder || isResumeWithPromptEmbed; + + if (shouldEmbedImagesInPrompt) { + // Some agents consume images by mentioning temp file paths inside the prompt + // instead of accepting a dedicated CLI image flag. + const imagePrefix = imagePromptBuilder + ? imagePromptBuilder(tempImageFiles) + : buildImagePromptPrefix(tempImageFiles); effectivePrompt = imagePrefix + prompt; if (!promptViaStdin) { if (promptArgs) { @@ -135,19 +141,19 @@ export class ChildProcessSpawner { } promptAddedToArgs = true; } - logger.debug( - '[ProcessManager] Resume mode: embedded image paths in prompt', - 'ProcessManager', - { - sessionId, - imageCount: images.length, - tempFiles: tempImageFiles, - promptViaStdin, - } - ); + logger.debug('[ProcessManager] Embedded image paths in prompt', 'ProcessManager', { + sessionId, + imageCount: images.length, + tempFiles: tempImageFiles, + embedMode: imagePromptBuilder ? 'prompt-builder' : 'resume-prompt-embed', + promptViaStdin, + }); } else { // Initial spawn: use -i flag as before for (const tempPath of tempImageFiles) { + if (!imageArgs) { + continue; + } finalArgs = [...finalArgs, ...imageArgs(tempPath)]; } if (!promptViaStdin) { diff --git a/src/main/process-manager/types.ts b/src/main/process-manager/types.ts index 4561e95e9b..0f7195bd5e 100644 --- a/src/main/process-manager/types.ts +++ b/src/main/process-manager/types.ts @@ -19,6 +19,7 @@ export interface ProcessConfig { shellEnvVars?: Record; images?: string[]; imageArgs?: (imagePath: string) => string[]; + imagePromptBuilder?: (imagePaths: string[]) => string; promptArgs?: (prompt: string) => string[]; contextWindow?: number; customEnvVars?: Record; diff --git a/src/main/utils/ssh-command-builder.ts b/src/main/utils/ssh-command-builder.ts index 555be34073..94137b5cab 100644 --- a/src/main/utils/ssh-command-builder.ts +++ b/src/main/utils/ssh-command-builder.ts @@ -300,6 +300,8 @@ export async function buildSshCommandWithStdin( images?: string[]; /** Function to build CLI args for each image path (e.g., (path) => ['-i', path]) */ imageArgs?: (imagePath: string) => string[]; + /** Function to embed image references into the prompt/stdinInput (e.g., Copilot @mentions). */ + imagePromptBuilder?: (imagePaths: string[]) => string; /** When set to 'prompt-embed', embed image paths in the prompt/stdinInput instead of adding -i CLI args. * Used for resumed Codex sessions where the resume command doesn't support -i flag. */ imageResumeMode?: 'prompt-embed'; @@ -374,7 +376,11 @@ export async function buildSshCommandWithStdin( const remoteImagePaths: string[] = []; /** All remote temp file paths created during image decoding (for cleanup) */ const allRemoteTempPaths: string[] = []; - if (remoteOptions.images && remoteOptions.images.length > 0 && remoteOptions.imageArgs) { + if ( + remoteOptions.images && + remoteOptions.images.length > 0 && + (remoteOptions.imageArgs || remoteOptions.imagePromptBuilder) + ) { const timestamp = Date.now(); for (let i = 0; i < remoteOptions.images.length; i++) { const parsed = parseDataUrl(remoteOptions.images[i]); @@ -388,10 +394,13 @@ export async function buildSshCommandWithStdin( scriptLines.push(`base64 -d > ${shellEscape(remoteTempPath)} <<'MAESTRO_IMG_${i}_EOF'`); scriptLines.push(parsed.base64); scriptLines.push(`MAESTRO_IMG_${i}_EOF`); - if (remoteOptions.imageResumeMode === 'prompt-embed') { + if (remoteOptions.imagePromptBuilder || remoteOptions.imageResumeMode === 'prompt-embed') { // Resume mode: collect paths for prompt embedding instead of CLI args remoteImagePaths.push(remoteTempPath); } else { + if (!remoteOptions.imageArgs) { + continue; + } // Normal mode: add -i (or equivalent) CLI args imageArgParts.push( ...remoteOptions.imageArgs(remoteTempPath).map((arg) => shellEscape(arg)) @@ -410,7 +419,9 @@ export async function buildSshCommandWithStdin( // For prompt-embed mode (resumed sessions), prepend image paths to stdinInput/prompt if (remoteImagePaths.length > 0) { - const imagePrefix = buildImagePromptPrefix(remoteImagePaths); + const imagePrefix = remoteOptions.imagePromptBuilder + ? remoteOptions.imagePromptBuilder(remoteImagePaths) + : buildImagePromptPrefix(remoteImagePaths); if (remoteOptions.stdinInput !== undefined) { remoteOptions.stdinInput = imagePrefix + remoteOptions.stdinInput; } else if (remoteOptions.prompt) { diff --git a/src/renderer/components/TerminalOutput.tsx b/src/renderer/components/TerminalOutput.tsx index 4eb599483f..58bd05cddc 100644 --- a/src/renderer/components/TerminalOutput.tsx +++ b/src/renderer/components/TerminalOutput.tsx @@ -66,6 +66,28 @@ const summarizeTodos = (v: unknown): string | null => { return `${label} (${completed}/${todos.length})`; }; +/** Extract a concise tool detail string from common tool input shapes */ +const extractToolDetail = (toolInput: Record | undefined): string | null => + toolInput + ? safeCommand(toolInput.command) || + safeStr(toolInput.pattern) || + safeStr(toolInput.file_path) || + safeStr(toolInput.filePath) || // OpenCode read tool + safeStr(toolInput.query) || + safeStr(toolInput.description) || // Task tool + safeStr(toolInput.prompt) || // Task tool fallback + safeStr(toolInput.task_id) || // TaskOutput tool + summarizeTodos(toolInput.todos) || // TodoWrite tool + safeStr(toolInput.path) || // Codex file operations + safeStr(toolInput.cmd) || // Codex shell commands + safeStr(toolInput.code) || // Codex code execution + truncateStr(toolInput.content, 100) || // Codex write operations (truncated) + null + : null; + +const isHiddenProgressEntry = (log: LogEntry): boolean => + log.source === 'system' && log.id.startsWith('hidden-progress:'); + // ============================================================================ // LogItem - Memoized component for individual log entries // ============================================================================ @@ -537,6 +559,51 @@ const LogItemComponent = memo(
)} + {isHiddenProgressEntry(log) && ( +
+
+ + {log.metadata?.hiddenProgress?.kind === 'tool' + ? log.metadata.hiddenProgress.toolName || 'working' + : 'thinking'} + + {log.metadata?.toolState?.status === 'completed' ? ( + + ✓ + + ) : log.metadata?.toolState?.status === 'failed' ? ( + + × + + ) : ( + + ● + + )} + + {log.text} + +
+
+ )} {/* Special rendering for tool execution events (shown alongside thinking) */} {log.source === 'tool' && (() => { @@ -544,23 +611,7 @@ const LogItemComponent = memo( const toolInput = log.metadata?.toolState?.input as | Record | undefined; - const toolDetail = toolInput - ? safeCommand(toolInput.command) || - safeStr(toolInput.pattern) || - safeStr(toolInput.file_path) || - safeStr(toolInput.filePath) || // OpenCode read tool - safeStr(toolInput.query) || - safeStr(toolInput.description) || // Task tool - safeStr(toolInput.prompt) || // Task tool fallback - safeStr(toolInput.task_id) || // TaskOutput tool - summarizeTodos(toolInput.todos) || // TodoWrite tool - // Codex-specific tool arg patterns - safeStr(toolInput.path) || // Codex file operations - safeStr(toolInput.cmd) || // Codex shell commands - safeStr(toolInput.code) || // Codex code execution - truncateStr(toolInput.content, 100) || // Codex write operations (truncated) - null - : null; + const toolDetail = extractToolDetail(toolInput); return (
)} + {log.metadata?.toolState?.status === 'failed' && ( + + ! + + )} {toolDetail && ( ); })()} - {log.source !== 'error' && + {!isHiddenProgressEntry(log) && + log.source !== 'error' && log.source !== 'thinking' && log.source !== 'tool' && (hasNoMatches ? ( diff --git a/src/renderer/hooks/agent/useAgentListeners.ts b/src/renderer/hooks/agent/useAgentListeners.ts index 5164a8d2c5..558f1424b2 100644 --- a/src/renderer/hooks/agent/useAgentListeners.ts +++ b/src/renderer/hooks/agent/useAgentListeners.ts @@ -50,11 +50,189 @@ import { parseSynopsis } from '../../../shared/synopsis'; import { autorunSynopsisPrompt } from '../../../prompts'; import type { RightPanelHandle } from '../../components/RightPanel'; import { useGroupChatStore } from '../../stores/groupChatStore'; +import { buildHiddenProgressLogId } from '../../utils/hiddenProgress'; // ============================================================================ // Types // ============================================================================ +type HiddenProgressKind = 'thinking' | 'tool'; +type ToolProgressState = NonNullable['toolState']; + +function truncateProgressText(value: string, maxLength = 80): string { + const trimmed = value.trim().replace(/\s+/g, ' '); + if (!trimmed) return ''; + return trimmed.length <= maxLength ? trimmed : `${trimmed.slice(0, maxLength - 3)}...`; +} + +function safeProgressString(value: unknown, maxLength = 80): string | null { + return typeof value === 'string' && value.trim() ? truncateProgressText(value, maxLength) : null; +} + +function summarizeTodoProgress(todos: unknown): string | null { + if (!Array.isArray(todos) || todos.length === 0) { + return null; + } + + const total = todos.length; + const inProgress = todos.find( + (todo): todo is { activeForm?: string; content?: string; status?: string } => + typeof todo === 'object' && todo !== null && 'status' in todo && todo.status === 'in_progress' + ); + + const label = + (typeof inProgress?.activeForm === 'string' && inProgress.activeForm.trim()) || + (typeof inProgress?.content === 'string' && inProgress.content.trim()) || + (typeof todos[0] === 'object' && + todos[0] !== null && + 'content' in todos[0] && + typeof todos[0].content === 'string' + ? todos[0].content + : null); + + return label + ? `${truncateProgressText(label, 60)} (${total} todo${total === 1 ? '' : 's'})` + : null; +} + +function extractToolProgressDetail(input: unknown): string | null { + if (!input || typeof input !== 'object') { + return null; + } + + const toolInput = input as Record; + + return ( + safeProgressString(toolInput.command, 90) || + safeProgressString(toolInput.pattern, 70) || + safeProgressString(toolInput.file_path, 70) || + safeProgressString(toolInput.filePath, 70) || + safeProgressString(toolInput.path, 70) || + safeProgressString(toolInput.query, 70) || + safeProgressString(toolInput.description, 70) || + safeProgressString(toolInput.prompt, 70) || + safeProgressString(toolInput.task_id, 70) || + safeProgressString(toolInput.cmd, 90) || + safeProgressString(toolInput.code, 90) || + safeProgressString(toolInput.content, 90) || + summarizeTodoProgress(toolInput.todos) + ); +} + +function formatToolProgressText(toolName: string, toolState?: ToolProgressState): string { + const normalizedToolName = toolName.trim() || 'tool'; + const toolNameLower = normalizedToolName.toLowerCase(); + const detail = extractToolProgressDetail(toolState?.input); + const status = toolState?.status; + + const withDetail = (prefix: string, fallbackSuffix = '...') => + detail ? `${prefix} ${detail}` : `${prefix}${fallbackSuffix}`; + + if (status === 'failed') { + if (toolNameLower === 'bash' || toolNameLower === 'shell') { + return detail ? `Command failed: ${detail}` : 'Command failed'; + } + return detail ? `${normalizedToolName} failed: ${detail}` : `${normalizedToolName} failed`; + } + + if (status === 'completed') { + if (toolNameLower === 'view' || toolNameLower === 'read') { + return withDetail('Read', ''); + } + if ( + toolNameLower === 'rg' || + toolNameLower === 'grep' || + toolNameLower === 'glob' || + toolNameLower === 'search' + ) { + return withDetail('Searched', ''); + } + if (toolNameLower === 'bash' || toolNameLower === 'shell') { + return withDetail('Ran', ''); + } + if ( + toolNameLower === 'write' || + toolNameLower === 'edit' || + toolNameLower === 'apply_patch' || + toolNameLower === 'create' + ) { + return withDetail('Updated', ''); + } + return detail + ? `Completed ${normalizedToolName}: ${detail}` + : `Completed ${normalizedToolName}`; + } + + if (toolNameLower === 'view' || toolNameLower === 'read') { + return withDetail('Reading'); + } + if ( + toolNameLower === 'rg' || + toolNameLower === 'grep' || + toolNameLower === 'glob' || + toolNameLower === 'search' + ) { + return withDetail('Searching'); + } + if (toolNameLower === 'bash' || toolNameLower === 'shell') { + return withDetail('Running'); + } + if ( + toolNameLower === 'write' || + toolNameLower === 'edit' || + toolNameLower === 'apply_patch' || + toolNameLower === 'create' + ) { + return withDetail('Editing'); + } + if (toolNameLower === 'task') { + return withDetail('Delegating'); + } + if (toolNameLower === 'todowrite') { + return withDetail('Updating'); + } + + return detail ? `Using ${normalizedToolName}: ${detail}` : `Using ${normalizedToolName}...`; +} + +function upsertHiddenProgressLog( + logs: LogEntry[], + tabId: string, + text: string, + timestamp: number, + kind: HiddenProgressKind, + toolState?: ToolProgressState, + toolName?: string +): LogEntry[] { + const hiddenLogId = buildHiddenProgressLogId(tabId); + const existingIndex = logs.findIndex((log) => log.id === hiddenLogId); + const hiddenLog: LogEntry = { + id: hiddenLogId, + timestamp, + source: 'system', + text, + metadata: { + toolState, + hiddenProgress: { + kind, + toolName, + }, + }, + }; + + if (existingIndex === -1) { + return [...logs, hiddenLog]; + } + + return logs.map((log, index) => (index === existingIndex ? hiddenLog : log)); +} + +function removeHiddenProgressLog(logs: LogEntry[], tabId: string): LogEntry[] { + const hiddenLogId = buildHiddenProgressLogId(tabId); + const updatedLogs = logs.filter((log) => log.id !== hiddenLogId); + return updatedLogs.length === logs.length ? logs : updatedLogs; +} + /** Batched updater interface (subset used by IPC listeners) */ export interface BatchedUpdater { appendLog: ( @@ -197,10 +375,14 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { // Internal refs — only used by IPC listeners, not needed outside this hook const thinkingChunkBufferRef = useRef>(new Map()); const thinkingChunkRafIdRef = useRef(null); + const activeHiddenToolRef = useRef< + Map + >(new Map()); useEffect(() => { // Copy ref value to local variable for cleanup (React ESLint rule) const thinkingChunkBuffer = thinkingChunkBufferRef.current; + const activeHiddenTools = activeHiddenToolRef.current; // Stable references from stores (Zustand actions are referentially stable) const setSessions = useSessionStore.getState().setSessions; @@ -263,6 +445,23 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { return; } + activeHiddenTools.delete(`${actualSessionId}:${targetTabId}`); + + setSessions((prev) => + prev.map((s) => { + if (s.id !== actualSessionId) return s; + let didChange = false; + const updatedTabs = s.aiTabs.map((tab) => { + if (tab.id !== targetTabId) return tab; + const updatedLogs = removeHiddenProgressLog(tab.logs, targetTabId); + if (updatedLogs === tab.logs) return tab; + didChange = true; + return { ...tab, logs: updatedLogs }; + }); + return didChange ? { ...s, aiTabs: updatedTabs } : s; + }) + ); + // Batch the log append, delivery mark, unread mark, and byte tracking deps.batchedUpdater.appendLog(actualSessionId, targetTabId, true, data); deps.batchedUpdater.markDelivered(actualSessionId, targetTabId); @@ -348,6 +547,10 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { isFromAi = false; } + if (isFromAi && tabIdFromSession) { + activeHiddenTools.delete(`${actualSessionId}:${tabIdFromSession}`); + } + // SAFETY CHECK: Verify the process is actually gone if (isFromAi) { try { @@ -546,6 +749,7 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { return tab.id === tabIdFromSession ? { ...tab, + logs: removeHiddenProgressLog(tab.logs, tab.id), state: 'idle' as const, thinkingStartTime: undefined, } @@ -554,6 +758,7 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { return tab.state === 'busy' ? { ...tab, + logs: removeHiddenProgressLog(tab.logs, tab.id), state: 'idle' as const, thinkingStartTime: undefined, } @@ -600,6 +805,7 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { if (tabIdFromSession && tab.id === tabIdFromSession) { return { ...tab, + logs: removeHiddenProgressLog(tab.logs, tab.id), state: 'idle' as const, }; } @@ -643,6 +849,7 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { return tab.id === tabIdFromSession ? { ...tab, + logs: removeHiddenProgressLog(tab.logs, tab.id), state: 'idle' as const, thinkingStartTime: undefined, } @@ -651,6 +858,7 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { return tab.state === 'busy' ? { ...tab, + logs: removeHiddenProgressLog(tab.logs, tab.id), state: 'idle' as const, thinkingStartTime: undefined, } @@ -1233,6 +1441,10 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { agentError: isSessionNotFound ? undefined : agentError, }; + if (tabIdFromSession) { + activeHiddenTools.delete(`${actualSessionId}:${tabIdFromSession}`); + } + setSessions((prev) => prev.map((s) => { if (s.id !== actualSessionId) return s; @@ -1245,7 +1457,7 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { tab.id === targetTab.id ? { ...tab, - logs: [...tab.logs, errorLogEntry], + logs: [...removeHiddenProgressLog(tab.logs, tab.id), errorLogEntry], agentError: isSessionNotFound ? undefined : agentError, } : tab @@ -1383,7 +1595,28 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { const targetTab = updatedTabs.find((t) => t.id === chunkTabId); if (!targetTab) continue; - if (!targetTab.showThinking || targetTab.showThinking === 'off') continue; + if (!targetTab.showThinking || targetTab.showThinking === 'off') { + const activeTool = activeHiddenTools.get(key); + if (activeTool?.toolState?.status === 'running') { + continue; + } + + updatedTabs = updatedTabs.map((tab) => + tab.id === chunkTabId + ? { + ...tab, + logs: upsertHiddenProgressLog( + tab.logs, + chunkTabId, + 'Thinking through the next step...', + Date.now(), + 'thinking' + ), + } + : tab + ); + continue; + } if (isLikelyConcatenatedToolNames(bufferedContent)) { console.warn( @@ -1545,7 +1778,42 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { if (s.id !== actualSessionId) return s; const targetTab = s.aiTabs.find((t) => t.id === tabId); - if (!targetTab?.showThinking || targetTab.showThinking === 'off') return s; + if (!targetTab) return s; + + if (!targetTab.showThinking || targetTab.showThinking === 'off') { + const toolState = toolEvent.state as ToolProgressState | undefined; + const hiddenToolKey = `${actualSessionId}:${tabId}`; + const statusText = formatToolProgressText(toolEvent.toolName, toolState); + + if (toolState?.status === 'running') { + activeHiddenTools.set(hiddenToolKey, { + toolName: toolEvent.toolName, + toolState, + }); + } else { + activeHiddenTools.delete(hiddenToolKey); + } + + return { + ...s, + aiTabs: s.aiTabs.map((tab) => + tab.id === tabId + ? { + ...tab, + logs: upsertHiddenProgressLog( + tab.logs, + tabId, + statusText, + toolEvent.timestamp, + 'tool', + toolState, + toolEvent.toolName + ), + } + : tab + ), + }; + } const toolLog: LogEntry = { id: `tool-${Date.now()}-${toolEvent.toolName}`, @@ -1594,6 +1862,7 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { thinkingChunkRafIdRef.current = null; } thinkingChunkBuffer.clear(); + activeHiddenTools.clear(); }; }, []); } diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index c8dd6b294f..7136b94f3d 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -197,10 +197,14 @@ export interface LogEntry { // For tool execution entries - stores tool state and details metadata?: { toolState?: { - status?: 'running' | 'completed' | 'error'; + status?: 'running' | 'completed' | 'error' | 'failed'; input?: unknown; output?: unknown; }; + hiddenProgress?: { + kind: 'thinking' | 'tool'; + toolName?: string; + }; }; } diff --git a/src/renderer/utils/hiddenProgress.ts b/src/renderer/utils/hiddenProgress.ts new file mode 100644 index 0000000000..b13e847883 --- /dev/null +++ b/src/renderer/utils/hiddenProgress.ts @@ -0,0 +1,11 @@ +import type { LogEntry } from '../types'; + +export const HIDDEN_PROGRESS_LOG_PREFIX = 'hidden-progress:'; + +export function buildHiddenProgressLogId(tabId: string): string { + return `${HIDDEN_PROGRESS_LOG_PREFIX}${tabId}`; +} + +export function isHiddenProgressLog(log: Pick): boolean { + return log.source === 'system' && log.id.startsWith(HIDDEN_PROGRESS_LOG_PREFIX); +} From a050cf965cd37e41d7bbc278490cf21d224ba99d Mon Sep 17 00:00:00 2001 From: Nolan Clark Date: Fri, 13 Mar 2026 13:46:14 -0500 Subject: [PATCH 15/15] Remaining Copilot CLI feature support --- .../main/agents/capabilities.test.ts | 6 ++ src/__tests__/main/agents/definitions.test.ts | 26 +++++++ .../main/ipc/handlers/agents.test.ts | 33 +++++++++ .../parsers/copilot-output-parser.test.ts | 12 ++++ .../components/TerminalOutput.test.tsx | 29 ++++++++ .../renderer/hooks/useAgentListeners.test.ts | 72 +++++++++++++++++++ .../renderer/hooks/useWizardHandlers.test.ts | 50 +++++++++++++ .../services/inlineWizardConversation.test.ts | 60 ++++++++++++++++ src/main/agents/capabilities.ts | 8 +-- src/main/agents/definitions.ts | 51 +++++++++++++ src/main/ipc/handlers/agents.ts | 34 +++++++++ src/main/parsers/copilot-output-parser.ts | 5 +- src/main/preload/agents.ts | 7 ++ src/renderer/components/TerminalOutput.tsx | 2 +- .../Wizard/services/conversationManager.ts | 16 +++++ src/renderer/constants/app.ts | 48 +++++++++++-- src/renderer/hooks/agent/useAgentListeners.ts | 28 +++++--- .../hooks/wizard/useWizardHandlers.ts | 9 ++- .../services/inlineWizardConversation.ts | 37 ++++++++-- 19 files changed, 498 insertions(+), 35 deletions(-) diff --git a/src/__tests__/main/agents/capabilities.test.ts b/src/__tests__/main/agents/capabilities.test.ts index bdaeca4675..99b91fa355 100644 --- a/src/__tests__/main/agents/capabilities.test.ts +++ b/src/__tests__/main/agents/capabilities.test.ts @@ -150,6 +150,10 @@ describe('agent-capabilities', () => { expect(capabilities.supportsStreaming).toBe(true); expect(capabilities.supportsResultMessages).toBe(true); expect(capabilities.supportsThinkingDisplay).toBe(true); + expect(capabilities.supportsContextMerge).toBe(true); + expect(capabilities.supportsContextExport).toBe(true); + expect(capabilities.supportsWizard).toBe(true); + expect(capabilities.supportsGroupChatModeration).toBe(true); }); it('should define capabilities for all known agents', () => { @@ -250,6 +254,7 @@ describe('agent-capabilities', () => { expect(hasCapability('claude-code', 'supportsWizard')).toBe(true); expect(hasCapability('codex', 'supportsWizard')).toBe(true); expect(hasCapability('opencode', 'supportsWizard')).toBe(true); + expect(hasCapability('copilot', 'supportsWizard')).toBe(true); expect(hasCapability('factory-droid', 'supportsWizard')).toBe(false); expect(hasCapability('terminal', 'supportsWizard')).toBe(false); @@ -257,6 +262,7 @@ describe('agent-capabilities', () => { expect(hasCapability('claude-code', 'supportsGroupChatModeration')).toBe(true); expect(hasCapability('codex', 'supportsGroupChatModeration')).toBe(true); expect(hasCapability('opencode', 'supportsGroupChatModeration')).toBe(true); + expect(hasCapability('copilot', 'supportsGroupChatModeration')).toBe(true); expect(hasCapability('factory-droid', 'supportsGroupChatModeration')).toBe(true); expect(hasCapability('terminal', 'supportsGroupChatModeration')).toBe(false); diff --git a/src/__tests__/main/agents/definitions.test.ts b/src/__tests__/main/agents/definitions.test.ts index 21edacc3e8..efe2eac9dd 100644 --- a/src/__tests__/main/agents/definitions.test.ts +++ b/src/__tests__/main/agents/definitions.test.ts @@ -273,6 +273,32 @@ describe('agent-definitions', () => { expect(modelOption?.argBuilder?.('')).toEqual([]); expect(modelOption?.argBuilder?.(' ')).toEqual([]); }); + + it('should have Copilot-specific config options for supported CLI flags', () => { + const copilot = getAgentDefinition('copilot'); + expect(copilot?.configOptions).toBeDefined(); + + const reasoningEffort = copilot?.configOptions?.find((opt) => opt.key === 'reasoningEffort'); + expect(reasoningEffort?.type).toBe('select'); + expect(reasoningEffort?.argBuilder?.('high')).toEqual(['--reasoning-effort', 'high']); + + const autopilot = copilot?.configOptions?.find((opt) => opt.key === 'autopilot'); + expect(autopilot?.type).toBe('checkbox'); + expect(autopilot?.argBuilder?.(true)).toEqual(['--autopilot']); + expect(autopilot?.argBuilder?.(false)).toEqual([]); + + const allowAllPaths = copilot?.configOptions?.find((opt) => opt.key === 'allowAllPaths'); + expect(allowAllPaths?.argBuilder?.(true)).toEqual(['--allow-all-paths']); + + const allowAllUrls = copilot?.configOptions?.find((opt) => opt.key === 'allowAllUrls'); + expect(allowAllUrls?.argBuilder?.(true)).toEqual(['--allow-all-urls']); + + const experimental = copilot?.configOptions?.find((opt) => opt.key === 'experimental'); + expect(experimental?.argBuilder?.(true)).toEqual(['--experimental']); + + const screenReader = copilot?.configOptions?.find((opt) => opt.key === 'screenReader'); + expect(screenReader?.argBuilder?.(true)).toEqual(['--screen-reader']); + }); }); describe('Type definitions', () => { diff --git a/src/__tests__/main/ipc/handlers/agents.test.ts b/src/__tests__/main/ipc/handlers/agents.test.ts index 02c829cc11..b700366804 100644 --- a/src/__tests__/main/ipc/handlers/agents.test.ts +++ b/src/__tests__/main/ipc/handlers/agents.test.ts @@ -1091,6 +1091,39 @@ describe('agents IPC handlers', () => { expect(execFileNoThrow).not.toHaveBeenCalled(); }); + it('should return built-in commands for copilot', async () => { + const mockAgent = { + id: 'copilot', + available: true, + path: '/usr/bin/copilot', + }; + + mockAgentDetector.getAgent.mockResolvedValue(mockAgent); + + const handler = handlers.get('agents:discoverSlashCommands'); + const result = await handler!({} as any, 'copilot', '/test'); + + expect(result).toEqual( + expect.arrayContaining([ + 'help', + 'clear', + 'compact', + 'context', + 'model', + 'review', + 'usage', + 'session', + 'share', + 'mcp', + 'fleet', + 'tasks', + 'delegate', + ]) + ); + expect(result).toHaveLength(13); + expect(execFileNoThrow).not.toHaveBeenCalled(); + }); + it('should return null when agent is not available', async () => { mockAgentDetector.getAgent.mockResolvedValue({ id: 'claude-code', available: false }); diff --git a/src/__tests__/main/parsers/copilot-output-parser.test.ts b/src/__tests__/main/parsers/copilot-output-parser.test.ts index 19548ba419..e0ae2543f4 100644 --- a/src/__tests__/main/parsers/copilot-output-parser.test.ts +++ b/src/__tests__/main/parsers/copilot-output-parser.test.ts @@ -274,6 +274,18 @@ describe('CopilotOutputParser', () => { ); }); + it('does not treat reasoning message content as an agent error', () => { + const parser = new CopilotOutputParser(); + const error = parser.detectErrorFromParsed({ + type: 'assistant.reasoning', + data: { + message: 'Thinking through the repository structure...', + }, + }); + + expect(error).toBeNull(); + }); + it('maps no-tty interactive launch failures to a clearer crash message', () => { const parser = new CopilotOutputParser(); const error = parser.detectErrorFromExit( diff --git a/src/__tests__/renderer/components/TerminalOutput.test.tsx b/src/__tests__/renderer/components/TerminalOutput.test.tsx index 1d22012085..86a5ced498 100644 --- a/src/__tests__/renderer/components/TerminalOutput.test.tsx +++ b/src/__tests__/renderer/components/TerminalOutput.test.tsx @@ -1942,6 +1942,35 @@ describe('TerminalOutput', () => { expect(screen.getByText('Reading src/renderer/App.tsx')).toBeInTheDocument(); expect(screen.queryByTestId('react-markdown')).not.toBeInTheDocument(); }); + + it('uses the standard failed icon treatment for hidden progress', () => { + const logs: LogEntry[] = [ + createLogEntry({ + id: 'hidden-progress:tab-1', + text: 'Command failed', + source: 'system', + metadata: { + toolState: { + status: 'failed', + }, + hiddenProgress: { + kind: 'tool', + toolName: 'bash', + }, + }, + }), + ]; + + const session = createDefaultSession({ + tabs: [{ id: 'tab-1', agentSessionId: 'claude-123', logs, isUnread: false }], + activeTabId: 'tab-1', + }); + + render(); + + expect(screen.getByText('!')).toBeInTheDocument(); + expect(screen.queryByText('×')).not.toBeInTheDocument(); + }); }); describe('local filter functionality', () => { diff --git a/src/__tests__/renderer/hooks/useAgentListeners.test.ts b/src/__tests__/renderer/hooks/useAgentListeners.test.ts index c71e7c7b35..312385eeff 100644 --- a/src/__tests__/renderer/hooks/useAgentListeners.test.ts +++ b/src/__tests__/renderer/hooks/useAgentListeners.test.ts @@ -1027,6 +1027,78 @@ describe('useAgentListeners', () => { text: 'Reading src/renderer/App.tsx', }); }); + + it('preserves prior tool detail when completion events omit input metadata', () => { + const deps = createMockDeps(); + const session = createMockSession({ + id: 'sess-1', + aiTabs: [createMockTab({ id: 'tab-1', showThinking: 'off' })], + activeTabId: 'tab-1', + }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 'sess-1', + }); + + renderHook(() => useAgentListeners(deps)); + + onToolExecutionHandler?.('sess-1-ai-tab-1', { + toolName: 'view', + state: { status: 'running', input: { path: 'src/renderer/App.tsx' } }, + timestamp: 1700000000000, + }); + onToolExecutionHandler?.('sess-1-ai-tab-1', { + toolName: 'view', + state: { status: 'completed', output: 'done' }, + timestamp: 1700000000100, + }); + + const updated = useSessionStore.getState().sessions.find((s) => s.id === 'sess-1'); + expect(updated?.aiTabs[0]?.logs).toHaveLength(1); + expect(updated?.aiTabs[0]?.logs[0]).toMatchObject({ + source: 'system', + text: 'Read src/renderer/App.tsx', + metadata: { + toolState: expect.objectContaining({ + status: 'completed', + input: { path: 'src/renderer/App.tsx' }, + }), + }, + }); + }); + + it('uses explicit completion copy when a completed tool event has no detail', () => { + const deps = createMockDeps(); + const session = createMockSession({ + id: 'sess-1', + aiTabs: [createMockTab({ id: 'tab-1', showThinking: 'off' })], + activeTabId: 'tab-1', + }); + useSessionStore.setState({ + sessions: [session], + activeSessionId: 'sess-1', + }); + + renderHook(() => useAgentListeners(deps)); + + onToolExecutionHandler?.('sess-1-ai-tab-1', { + toolName: 'view', + state: { status: 'completed', output: 'done' }, + timestamp: 1700000000000, + }); + + const updated = useSessionStore.getState().sessions.find((s) => s.id === 'sess-1'); + expect(updated?.aiTabs[0]?.logs).toHaveLength(1); + expect(updated?.aiTabs[0]?.logs[0]).toMatchObject({ + source: 'system', + text: 'Finished reading', + metadata: { + toolState: expect.objectContaining({ + status: 'completed', + }), + }, + }); + }); }); // ======================================================================== diff --git a/src/__tests__/renderer/hooks/useWizardHandlers.test.ts b/src/__tests__/renderer/hooks/useWizardHandlers.test.ts index 2c2d8efad3..3ace681d4e 100644 --- a/src/__tests__/renderer/hooks/useWizardHandlers.test.ts +++ b/src/__tests__/renderer/hooks/useWizardHandlers.test.ts @@ -403,6 +403,56 @@ describe('useWizardHandlers', () => { expect(consoleSpy).toHaveBeenCalled(); consoleSpy.mockRestore(); }); + + it('only discovers agent slash commands for copilot sessions', async () => { + const session = createMockSession({ + toolType: 'copilot' as any, + agentCommands: undefined, + }); + useSessionStore.setState({ sessions: [session], activeSessionId: 'session-1' }); + + (window as any).maestro.agents.discoverSlashCommands.mockResolvedValue(['help', 'model']); + + const deps = createMockDeps(); + renderHook(() => useWizardHandlers(deps)); + + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + expect((window as any).maestro.claude.getCommands).not.toHaveBeenCalled(); + expect((window as any).maestro.agents.discoverSlashCommands).toHaveBeenCalledWith( + 'copilot', + '/projects/test', + undefined + ); + + const updatedSession = useSessionStore.getState().sessions[0]; + expect(updatedSession.agentCommands).toEqual( + expect.arrayContaining([ + expect.objectContaining({ command: '/help' }), + expect.objectContaining({ command: '/model' }), + ]) + ); + }); + + it('does not run slash command discovery for opencode sessions', async () => { + const session = createMockSession({ + toolType: 'opencode' as any, + agentCommands: undefined, + }); + useSessionStore.setState({ sessions: [session], activeSessionId: 'session-1' }); + + const deps = createMockDeps(); + renderHook(() => useWizardHandlers(deps)); + + await act(async () => { + await new Promise((r) => setTimeout(r, 50)); + }); + + expect((window as any).maestro.claude.getCommands).not.toHaveBeenCalled(); + expect((window as any).maestro.agents.discoverSlashCommands).not.toHaveBeenCalled(); + }); }); // ======================================================================== diff --git a/src/__tests__/renderer/services/inlineWizardConversation.test.ts b/src/__tests__/renderer/services/inlineWizardConversation.test.ts index 6985a05a94..45b248db38 100644 --- a/src/__tests__/renderer/services/inlineWizardConversation.test.ts +++ b/src/__tests__/renderer/services/inlineWizardConversation.test.ts @@ -280,6 +280,66 @@ describe('inlineWizardConversation', () => { await messagePromise; }); + + it('should apply Copilot read-only wizard args and parse final_answer responses', async () => { + const mockAgent = { + id: 'copilot', + available: true, + command: 'copilot', + args: [], + readOnlyArgs: [ + '--allow-tool=read,url', + '--deny-tool=write,shell,memory,github', + '--no-ask-user', + ], + }; + mockMaestro.agents.get.mockResolvedValue(mockAgent); + mockMaestro.process.spawn.mockResolvedValue(undefined); + + const session = await startInlineWizardConversation({ + agentType: 'copilot', + directoryPath: '/test/project', + projectName: 'Test Project', + mode: 'ask', + }); + + const messagePromise = sendWizardMessage(session, 'Hello', []); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const spawnCall = mockMaestro.process.spawn.mock.calls[0][0]; + expect(spawnCall.args).toEqual( + expect.arrayContaining([ + '--allow-tool=read,url', + '--deny-tool=write,shell,memory,github', + '--no-ask-user', + ]) + ); + + const dataCallback = mockMaestro.process.onData.mock.calls[0][0]; + dataCallback( + session.sessionId, + '{"type":"assistant.message","data":{"phase":"final_answer","content":"{\\"confidence\\":91,\\"ready\\":true,\\"message\\":\\"Ready to proceed\\"}"}}\n' + ); + dataCallback( + session.sessionId, + '{"type":"result","sessionId":"copilot-session-123","exitCode":0,"usage":{"sessionDurationMs":1200}}\n' + ); + + const exitCallback = mockMaestro.process.onExit.mock.calls[0][0]; + exitCallback(session.sessionId, 0); + + await expect(messagePromise).resolves.toEqual( + expect.objectContaining({ + success: true, + agentSessionId: 'copilot-session-123', + response: expect.objectContaining({ + confidence: 91, + ready: true, + message: 'Ready to proceed', + }), + }) + ); + }); }); describe('activity-based timeout', () => { diff --git a/src/main/agents/capabilities.ts b/src/main/agents/capabilities.ts index 9e9486a15f..15d4cd5143 100644 --- a/src/main/agents/capabilities.ts +++ b/src/main/agents/capabilities.ts @@ -411,10 +411,10 @@ export const AGENT_CAPABILITIES: Record = { supportsModelSelection: true, // --model supportsStreamJsonInput: false, // Not verified supportsThinkingDisplay: true, // assistant.reasoning events are rendered through Maestro's thinking-chunk pipeline - supportsContextMerge: false, // Not verified - PLACEHOLDER - supportsContextExport: false, // Not verified - PLACEHOLDER - supportsWizard: false, // PLACEHOLDER - not verified - supportsGroupChatModeration: false, // PLACEHOLDER - not verified + supportsContextMerge: true, // Can receive merged context via prompts + supportsContextExport: true, // Session storage supports context export + supportsWizard: true, // Wizard structured output works with Copilot JSON final_answer events + supportsGroupChatModeration: true, // Group chat moderation uses the standard batch-mode orchestration path usesJsonLineOutput: true, // --output-format json produces JSONL usesCombinedContextWindow: false, // Default Copilot model is Claude Sonnet; model-specific behavior varies }, diff --git a/src/main/agents/definitions.ts b/src/main/agents/definitions.ts index 9db1bdd43a..1503f2ca84 100644 --- a/src/main/agents/definitions.ts +++ b/src/main/agents/definitions.ts @@ -453,6 +453,57 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [ 'Maximum context window size in tokens. Required for context usage display. Varies by model.', default: 200000, // Default for Claude/GPT-5 models }, + { + key: 'reasoningEffort', + type: 'select', + label: 'Reasoning Effort', + description: 'Control how much deliberate reasoning Copilot uses before responding.', + options: ['', 'low', 'medium', 'high', 'xhigh'], + default: '', + argBuilder: (value: string) => + value && value.trim() ? ['--reasoning-effort', value.trim()] : [], + }, + { + key: 'autopilot', + type: 'checkbox', + label: 'Autopilot', + description: 'Allow Copilot to continue with follow-up turns automatically in prompt mode.', + default: false, + argBuilder: (value: boolean) => (value ? ['--autopilot'] : []), + }, + { + key: 'allowAllPaths', + type: 'checkbox', + label: 'Allow All Paths', + description: + 'Disable file path verification and allow access to any path without prompting.', + default: false, + argBuilder: (value: boolean) => (value ? ['--allow-all-paths'] : []), + }, + { + key: 'allowAllUrls', + type: 'checkbox', + label: 'Allow All URLs', + description: 'Allow network access to any URL without prompting.', + default: false, + argBuilder: (value: boolean) => (value ? ['--allow-all-urls'] : []), + }, + { + key: 'experimental', + type: 'checkbox', + label: 'Experimental Features', + description: 'Enable Copilot CLI experimental features for this agent.', + default: false, + argBuilder: (value: boolean) => (value ? ['--experimental'] : []), + }, + { + key: 'screenReader', + type: 'checkbox', + label: 'Screen Reader Mode', + description: 'Enable Copilot CLI screen reader optimizations.', + default: false, + argBuilder: (value: boolean) => (value ? ['--screen-reader'] : []), + }, ], }, ]; diff --git a/src/main/ipc/handlers/agents.ts b/src/main/ipc/handlers/agents.ts index 47fa058653..28c785cfdb 100644 --- a/src/main/ipc/handlers/agents.ts +++ b/src/main/ipc/handlers/agents.ts @@ -27,6 +27,35 @@ const handlerOpts = ( operation, }); +// Copilot CLI built-in slash commands (always available in interactive mode) +const COPILOT_BUILTIN_COMMANDS = [ + 'help', + 'clear', + 'compact', + 'context', + 'model', + 'usage', + 'session', + 'share', + 'mcp', + 'fleet', + 'tasks', + 'delegate', + 'review', +]; + +/** + * Discover GitHub Copilot CLI slash commands. + * + * Unlike Claude Code (which emits commands via its init JSON event), Copilot + * commands are interactive-only and cannot be discovered by spawning the CLI + * in batch mode. We return a static list of well-documented built-in commands. + */ +function discoverCopilotSlashCommands(): string[] { + logger.info(`Discovered ${COPILOT_BUILTIN_COMMANDS.length} Copilot slash commands`, LOG_CONTEXT); + return [...COPILOT_BUILTIN_COMMANDS]; +} + /** * Interface for agent configuration store data */ @@ -851,6 +880,11 @@ export function registerAgentsHandlers(deps: AgentsHandlerDependencies): void { return null; } + // Agent-specific discovery paths + if (agentId === 'copilot') { + return discoverCopilotSlashCommands(); + } + // Only Claude Code supports slash command discovery via init message if (agentId !== 'claude-code') { logger.debug(`Agent ${agentId} does not support slash command discovery`, LOG_CONTEXT); diff --git a/src/main/parsers/copilot-output-parser.ts b/src/main/parsers/copilot-output-parser.ts index 6bd9b634e5..73c093fc7a 100644 --- a/src/main/parsers/copilot-output-parser.ts +++ b/src/main/parsers/copilot-output-parser.ts @@ -342,10 +342,7 @@ export class CopilotOutputParser implements AgentOutputParser { return null; } - const errorText = - extractErrorText(msg.error) || - extractErrorText(msg.data?.error) || - extractErrorText(msg.data?.message); + const errorText = extractErrorText(msg.error) || extractErrorText(msg.data?.error); // Do NOT synthesize an error for bare non-zero exit codes. // Returning null here lets detectErrorFromExit() run with full diff --git a/src/main/preload/agents.ts b/src/main/preload/agents.ts index e023d7b1a3..9099e05d40 100644 --- a/src/main/preload/agents.ts +++ b/src/main/preload/agents.ts @@ -31,6 +31,13 @@ export interface AgentCapabilities { supportsResultMessages: boolean; supportsModelSelection: boolean; supportsStreamJsonInput: boolean; + supportsThinkingDisplay: boolean; + supportsContextMerge: boolean; + supportsContextExport: boolean; + supportsWizard: boolean; + supportsGroupChatModeration: boolean; + usesJsonLineOutput: boolean; + usesCombinedContextWindow: boolean; } /** diff --git a/src/renderer/components/TerminalOutput.tsx b/src/renderer/components/TerminalOutput.tsx index 58bd05cddc..d7e5496b60 100644 --- a/src/renderer/components/TerminalOutput.tsx +++ b/src/renderer/components/TerminalOutput.tsx @@ -585,7 +585,7 @@ const LogItemComponent = memo( ) : log.metadata?.toolState?.status === 'failed' ? ( - × + ! ) : ( = { }; /** - * Get description for Claude Code slash commands - * Built-in commands have known descriptions, custom ones use a generic description + * Built-in GitHub Copilot CLI slash commands with their descriptions */ -export function getSlashCommandDescription(cmd: string): string { +export const COPILOT_BUILTIN_COMMANDS: Record = { + help: 'Show available commands and their usage', + clear: 'Clear conversation history and start fresh', + compact: 'Summarize conversation to reduce context usage', + context: 'Show current context window and token usage', + model: 'Switch to a different AI model', + usage: 'Show token and premium request usage', + session: 'Display session details and metrics', + share: 'Export session as markdown or Gist', + mcp: 'Manage MCP server configurations', + fleet: 'Run tasks in parallel with multiple subagents', + tasks: 'Monitor and manage fleet subtask progress', + delegate: 'Delegate a task to another Copilot agent', + review: 'Review code changes', +}; + +/** + * Agent-specific built-in command maps, keyed by agent ID + */ +const AGENT_BUILTIN_COMMANDS: Record> = { + 'claude-code': CLAUDE_BUILTIN_COMMANDS, + copilot: COPILOT_BUILTIN_COMMANDS, +}; + +/** + * Get description for agent slash commands. + * Checks agent-specific built-in command maps first, then falls back to generic description. + */ +export function getSlashCommandDescription(cmd: string, agentId?: string): string { // Remove leading slash if present const cmdName = cmd.startsWith('/') ? cmd.slice(1) : cmd; - // Check for built-in command - if (CLAUDE_BUILTIN_COMMANDS[cmdName]) { - return CLAUDE_BUILTIN_COMMANDS[cmdName]; + // If a specific agent is provided, check that agent's commands first + if (agentId && AGENT_BUILTIN_COMMANDS[agentId]?.[cmdName]) { + return AGENT_BUILTIN_COMMANDS[agentId][cmdName]; + } + + // Check all agent command maps (for backwards compatibility when agentId is not provided) + for (const commands of Object.values(AGENT_BUILTIN_COMMANDS)) { + if (commands[cmdName]) { + return commands[cmdName]; + } } // For plugin commands (e.g., "plugin-name:command"), use the full name as description hint @@ -108,5 +142,5 @@ export function getSlashCommandDescription(cmd: string): string { } // Generic description for unknown commands - return 'Claude Code command'; + return 'Agent command'; } diff --git a/src/renderer/hooks/agent/useAgentListeners.ts b/src/renderer/hooks/agent/useAgentListeners.ts index 558f1424b2..72175aab24 100644 --- a/src/renderer/hooks/agent/useAgentListeners.ts +++ b/src/renderer/hooks/agent/useAgentListeners.ts @@ -137,7 +137,7 @@ function formatToolProgressText(toolName: string, toolState?: ToolProgressState) if (status === 'completed') { if (toolNameLower === 'view' || toolNameLower === 'read') { - return withDetail('Read', ''); + return detail ? `Read ${detail}` : 'Finished reading'; } if ( toolNameLower === 'rg' || @@ -145,10 +145,10 @@ function formatToolProgressText(toolName: string, toolState?: ToolProgressState) toolNameLower === 'glob' || toolNameLower === 'search' ) { - return withDetail('Searched', ''); + return detail ? `Searched ${detail}` : 'Search complete'; } if (toolNameLower === 'bash' || toolNameLower === 'shell') { - return withDetail('Ran', ''); + return detail ? `Ran ${detail}` : 'Command completed'; } if ( toolNameLower === 'write' || @@ -156,7 +156,7 @@ function formatToolProgressText(toolName: string, toolState?: ToolProgressState) toolNameLower === 'apply_patch' || toolNameLower === 'create' ) { - return withDetail('Updated', ''); + return detail ? `Updated ${detail}` : 'Update complete'; } return detail ? `Completed ${normalizedToolName}: ${detail}` @@ -1211,20 +1211,19 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { ); // ================================================================ - // onSlashCommands — Handle slash commands from Claude Code init + // onSlashCommands — Handle slash commands from agent init/discovery // ================================================================ const unsubscribeSlashCommands = window.maestro.process.onSlashCommands( (sessionId: string, slashCommands: string[]) => { const actualSessionId = parseSessionId(sessionId).baseSessionId; - const commands = slashCommands.map((cmd) => ({ - command: cmd.startsWith('/') ? cmd : `/${cmd}`, - description: getSlashCommandDescription(cmd), - })); - setSessions((prev) => prev.map((s) => { if (s.id !== actualSessionId) return s; + const commands = slashCommands.map((cmd) => ({ + command: cmd.startsWith('/') ? cmd : `/${cmd}`, + description: getSlashCommandDescription(cmd, s.toolType), + })); return { ...s, agentCommands: commands }; }) ); @@ -1781,8 +1780,15 @@ export function useAgentListeners(deps: UseAgentListenersDeps): void { if (!targetTab) return s; if (!targetTab.showThinking || targetTab.showThinking === 'off') { - const toolState = toolEvent.state as ToolProgressState | undefined; + const incomingToolState = toolEvent.state as ToolProgressState | undefined; const hiddenToolKey = `${actualSessionId}:${tabId}`; + const previousHiddenTool = activeHiddenTools.get(hiddenToolKey); + const toolState: ToolProgressState | undefined = incomingToolState + ? { + ...incomingToolState, + input: incomingToolState.input ?? previousHiddenTool?.toolState?.input, + } + : previousHiddenTool?.toolState; const statusText = formatToolProgressText(toolEvent.toolName, toolState); if (toolState?.status === 'running') { diff --git a/src/renderer/hooks/wizard/useWizardHandlers.ts b/src/renderer/hooks/wizard/useWizardHandlers.ts index abb26cc1dd..b6a464cbbf 100644 --- a/src/renderer/hooks/wizard/useWizardHandlers.ts +++ b/src/renderer/hooks/wizard/useWizardHandlers.ts @@ -180,7 +180,8 @@ export function useWizardHandlers(deps: UseWizardHandlersDeps): UseWizardHandler .getState() .sessions.find((s) => s.id === activeSession?.id); if (!currentSession) return; - if (currentSession.toolType !== 'claude-code') return; + const agentsWithSlashCommands = ['claude-code', 'copilot']; + if (!agentsWithSlashCommands.includes(currentSession.toolType)) return; if (currentSession.agentCommands && currentSession.agentCommands.length > 0) return; const sessionId = currentSession.id; @@ -242,7 +243,7 @@ export function useWizardHandlers(deps: UseWizardHandlersDeps): UseWizardHandler const agentCommandObjects = ((agentSlashCommands || []) as string[]).map((cmd) => ({ command: cmd.startsWith('/') ? cmd : `/${cmd}`, - description: getSlashCommandDescription(cmd), + description: getSlashCommandDescription(cmd, currentSession.toolType), })); if (agentCommandObjects.length > 0) { @@ -264,7 +265,9 @@ export function useWizardHandlers(deps: UseWizardHandlersDeps): UseWizardHandler } }; - fetchCustomCommands(); + if (currentSession.toolType === 'claude-code') { + fetchCustomCommands(); + } discoverAgentCommands(); return () => { diff --git a/src/renderer/services/inlineWizardConversation.ts b/src/renderer/services/inlineWizardConversation.ts index 40f5845052..1e4ee02ad6 100644 --- a/src/renderer/services/inlineWizardConversation.ts +++ b/src/renderer/services/inlineWizardConversation.ts @@ -365,9 +365,8 @@ export function parseWizardResponse(response: string): WizardResponse | null { } /** - * Extract the agent session ID (session_id) from Claude Code JSON output. - * This is the Claude-side session ID that can be used to resume the session. - * Returns the first session_id found in init or result messages. + * Extract the provider session ID from agent JSON output. + * Returns the first session identifier found in init or result-style messages. */ function extractAgentSessionIdFromOutput(output: string): string | null { try { @@ -376,10 +375,15 @@ function extractAgentSessionIdFromOutput(output: string): string | null { if (!line.trim()) continue; try { const msg = JSON.parse(line); - // session_id appears in init and result messages if (msg.session_id) { return msg.session_id; } + if (msg.sessionId) { + return msg.sessionId; + } + if (msg.data?.sessionId) { + return msg.data.sessionId; + } } catch { // Ignore non-JSON lines } @@ -392,7 +396,7 @@ function extractAgentSessionIdFromOutput(output: string): string | null { /** * Extract the result text from agent JSON output. - * Handles different agent output formats (Claude Code stream-json, etc.) + * Handles different agent output formats (Claude Code, Copilot, OpenCode, Codex). */ function extractResultFromStreamJson(output: string, agentType: ToolType): string | null { try { @@ -443,6 +447,21 @@ function extractResultFromStreamJson(output: string, agentType: ToolType): strin } } + // For Copilot: final answers arrive as assistant.message with phase=final_answer + if (agentType === 'copilot') { + for (const line of lines) { + if (!line.trim()) continue; + try { + const msg = JSON.parse(line); + if (msg.type === 'assistant.message' && msg.data?.phase === 'final_answer') { + return typeof msg.data?.content === 'string' ? msg.data.content : null; + } + } catch { + // Ignore non-JSON lines + } + } + } + // For Claude Code: look for result message for (const line of lines) { if (!line.trim()) continue; @@ -511,6 +530,14 @@ function buildArgsForAgent(agent: any): string[] { return args; } + case 'copilot': { + const args = [...(agent.args || [])]; + if (agent.readOnlyArgs) { + args.push(...agent.readOnlyArgs); + } + return args; + } + default: { return [...(agent.args || [])]; }