diff --git a/docs/cli.md b/docs/cli.md index a47f7df62..6a4769ce8 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -75,9 +75,9 @@ On failure, `success` is `false` and an `error` field is included: } ``` -Error codes: `AGENT_NOT_FOUND`, `AGENT_UNSUPPORTED`, `CLAUDE_NOT_FOUND`, `CODEX_NOT_FOUND`. +Error codes: `AGENT_NOT_FOUND`, `AGENT_UNSUPPORTED`, `CLAUDE_NOT_FOUND`, `CODEX_NOT_FOUND`, `OPENCODE_NOT_FOUND`, `DROID_NOT_FOUND`. -Supported agent types: `claude-code`, `codex`. +Supported agent types: `claude-code`, `codex`, `opencode`, `factory-droid`. The `send` command supports all four. ### Listing Sessions @@ -241,5 +241,5 @@ The `send` command always outputs JSON (no `--json` flag needed). ## Requirements -- At least one AI agent CLI must be installed and in PATH (Claude Code, Codex, or OpenCode) +- At least one AI agent CLI must be installed in PATH, set via customPath, or configured via sshRemoteConfig (Claude Code, Codex, OpenCode, or Factory Droid — can run on an SSH remote host) - Maestro config files must exist (created automatically when you use the GUI) diff --git a/package-lock.json b/package-lock.json index 7482623e1..60bdec598 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "maestro", - "version": "0.15.0", + "version": "0.15.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "maestro", - "version": "0.15.0", + "version": "0.15.1", "hasInstallScript": true, "license": "AGPL 3.0", "dependencies": { diff --git a/src/__tests__/cli/commands/send.test.ts b/src/__tests__/cli/commands/send.test.ts index 85241621e..05e66f990 100644 --- a/src/__tests__/cli/commands/send.test.ts +++ b/src/__tests__/cli/commands/send.test.ts @@ -81,6 +81,7 @@ describe('send command', () => { 'claude-code', '/path/to/project', 'Hello world', + undefined, undefined ); expect(consoleSpy).toHaveBeenCalledTimes(1); @@ -128,7 +129,8 @@ describe('send command', () => { 'claude-code', '/path/to/project', 'Continue from before', - 'session-xyz-789' + 'session-xyz-789', + undefined ); const output = JSON.parse(consoleSpy.mock.calls[0][0]); @@ -153,6 +155,7 @@ describe('send command', () => { 'claude-code', '/custom/project/path', 'Do something', + undefined, undefined ); }); @@ -173,7 +176,13 @@ describe('send command', () => { expect(detectCodex).toHaveBeenCalled(); expect(detectClaude).not.toHaveBeenCalled(); - expect(spawnAgent).toHaveBeenCalledWith('codex', expect.any(String), 'Use codex', undefined); + expect(spawnAgent).toHaveBeenCalledWith( + 'codex', + expect.any(String), + 'Use codex', + undefined, + undefined + ); }); it('should exit with error when agent ID is not found', async () => { diff --git a/src/__tests__/cli/services/agent-spawner.test.ts b/src/__tests__/cli/services/agent-spawner.test.ts index 3e69a0772..b850025f2 100644 --- a/src/__tests__/cli/services/agent-spawner.test.ts +++ b/src/__tests__/cli/services/agent-spawner.test.ts @@ -18,6 +18,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { EventEmitter } from 'events'; +import type { AgentSshRemoteConfig } from '../../../shared/types'; // Create mock spawn function at module level const mockSpawn = vi.fn(); @@ -35,11 +36,28 @@ const mockChild = Object.assign(new EventEmitter(), { // Mock child_process before imports vi.mock('child_process', async (importOriginal) => { const actual = await importOriginal(); + const actualDefault = ( + actual as typeof import('child_process') & { + default?: typeof import('child_process'); + } + ).default; + const execFile = + actual.execFile ?? + actualDefault?.execFile ?? + ((...args: unknown[]) => { + const callback = args[args.length - 1]; + if (typeof callback === 'function') { + callback(null, '', ''); + } + }); return { ...actual, + execFile, spawn: (...args: unknown[]) => mockSpawn(...args), default: { + ...(actualDefault ?? {}), ...actual, + execFile, spawn: (...args: unknown[]) => mockSpawn(...args), }, }; @@ -69,8 +87,25 @@ vi.mock('os', () => ({ // Mock storage service const mockGetAgentCustomPath = vi.fn(); +const mockGetAgentConfigValues = vi.fn(() => ({})); +const mockReadSshRemotes = vi.fn(() => []); + +const mockWrapSpawnWithSsh = vi.fn(async () => ({ + command: 'wrapped-cmd', + args: ['wrapped-arg-1', 'wrapped-arg-2'], + cwd: '/wrapped', + customEnvVars: undefined, + prompt: undefined, + sshRemoteUsed: null, +})); vi.mock('../../../cli/services/storage', () => ({ getAgentCustomPath: (...args: unknown[]) => mockGetAgentCustomPath(...args), + getAgentConfigValues: (...args: unknown[]) => mockGetAgentConfigValues(...args), + readSshRemotes: (...args: unknown[]) => mockReadSshRemotes(...args), +})); + +vi.mock('../../../main/utils/ssh-spawn-wrapper', () => ({ + wrapSpawnWithSsh: (...args: unknown[]) => mockWrapSpawnWithSsh(...args), })); import { @@ -678,6 +713,136 @@ Some text with [x] in it that's not a checkbox }); }); + describe('detectOpenCode', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('should detect OpenCode via PATH detection', async () => { + mockGetAgentCustomPath.mockReturnValue(undefined); + mockSpawn.mockReturnValue(mockChild); + + const { detectOpenCode: freshDetectOpenCode } = + await import('../../../cli/services/agent-spawner'); + + const resultPromise = freshDetectOpenCode(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + mockStdout.emit('data', Buffer.from('/usr/local/bin/opencode\n')); + await new Promise((resolve) => setTimeout(resolve, 0)); + mockChild.emit('close', 0); + + const result = await resultPromise; + + expect(result.available).toBe(true); + expect(result.path).toBe('/usr/local/bin/opencode'); + expect(result.source).toBe('path'); + }); + + it('should short-circuit to remote availability when SSH is enabled', async () => { + const sshRemoteConfig: AgentSshRemoteConfig = { + enabled: true, + remoteId: 'remote-opencode', + }; + mockGetAgentCustomPath.mockReturnValue(undefined); + mockSpawn.mockReturnValue(mockChild); + + vi.resetModules(); + const { detectOpenCode: freshDetectOpenCode } = + await import('../../../cli/services/agent-spawner'); + + const result = await freshDetectOpenCode('/custom/opencode', sshRemoteConfig); + + expect(result.available).toBe(true); + expect(result.path).toBeUndefined(); + expect(result.source).toBe('settings'); + }); + + it('should preserve cached source when override matches cached path', async () => { + mockGetAgentCustomPath.mockReturnValue(undefined); + mockSpawn.mockReturnValue(mockChild); + + const { detectOpenCode: freshDetectOpenCode } = + await import('../../../cli/services/agent-spawner'); + + const resultPromise = freshDetectOpenCode(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + mockStdout.emit('data', Buffer.from('/usr/local/bin/opencode\n')); + await new Promise((resolve) => setTimeout(resolve, 0)); + mockChild.emit('close', 0); + + const result1 = await resultPromise; + expect(result1.source).toBe('path'); + + const result2 = await freshDetectOpenCode('/usr/local/bin/opencode'); + expect(result2.source).toBe('path'); + }); + }); + + describe('detectDroid', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('should detect Factory Droid via PATH detection', async () => { + mockGetAgentCustomPath.mockReturnValue(undefined); + mockSpawn.mockReturnValue(mockChild); + + const { detectDroid: freshDetectDroid } = await import('../../../cli/services/agent-spawner'); + + const resultPromise = freshDetectDroid(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + mockStdout.emit('data', Buffer.from('/usr/local/bin/droid\n')); + await new Promise((resolve) => setTimeout(resolve, 0)); + mockChild.emit('close', 0); + + const result = await resultPromise; + + expect(result.available).toBe(true); + expect(result.path).toBe('/usr/local/bin/droid'); + expect(result.source).toBe('path'); + }); + + it('should short-circuit to remote availability when SSH is enabled', async () => { + const sshRemoteConfig: AgentSshRemoteConfig = { + enabled: true, + remoteId: 'remote-droid', + }; + mockGetAgentCustomPath.mockReturnValue(undefined); + mockSpawn.mockReturnValue(mockChild); + + const { detectDroid: freshDetectDroid } = await import('../../../cli/services/agent-spawner'); + + const result = await freshDetectDroid('/custom/droid', sshRemoteConfig); + + expect(result.available).toBe(true); + expect(result.path).toBeUndefined(); + expect(result.source).toBe('settings'); + }); + + it('should preserve cached source when override matches cached path', async () => { + mockGetAgentCustomPath.mockReturnValue(undefined); + mockSpawn.mockReturnValue(mockChild); + + const { detectDroid: freshDetectDroid } = await import('../../../cli/services/agent-spawner'); + + const resultPromise = freshDetectDroid(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + mockStdout.emit('data', Buffer.from('/usr/local/bin/droid\n')); + await new Promise((resolve) => setTimeout(resolve, 0)); + mockChild.emit('close', 0); + + const result1 = await resultPromise; + expect(result1.source).toBe('path'); + + const result2 = await freshDetectDroid('/usr/local/bin/droid'); + expect(result2.source).toBe('path'); + }); + }); + describe('spawnAgent', () => { beforeEach(() => { mockSpawn.mockReturnValue(mockChild); @@ -1150,6 +1315,80 @@ Some text with [x] in it that's not a checkbox await resultPromise; }); + it('should wrap OpenCode spawn with SSH when enabled', async () => { + const sshRemoteConfig: AgentSshRemoteConfig = { + enabled: true, + remoteId: 'remote-opencode', + }; + mockWrapSpawnWithSsh.mockClear(); + + const resultPromise = spawnAgent( + 'opencode', + '/project/path', + 'OpenCode remote task', + undefined, + { sshRemoteConfig } + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockWrapSpawnWithSsh).toHaveBeenCalledTimes(1); + const [config, configSshRemote, configStore] = mockWrapSpawnWithSsh.mock.calls[0]; + + expect(config).toMatchObject({ + command: expect.any(String), + args: expect.any(Array), + cwd: '/project/path', + }); + expect(configSshRemote).toEqual(sshRemoteConfig); + expect(configStore).toEqual({ + getSshRemotes: expect.any(Function), + }); + + mockStdout.emit('data', Buffer.from('{"type":"result","text":"OpenCode done"}\n')); + mockChild.emit('close', 0); + + const result = await resultPromise; + expect(result.success).toBe(true); + }); + + it('should wrap Factory Droid spawn with SSH when enabled', async () => { + const sshRemoteConfig: AgentSshRemoteConfig = { + enabled: true, + remoteId: 'remote-droid', + }; + mockWrapSpawnWithSsh.mockClear(); + + const resultPromise = spawnAgent( + 'factory-droid', + '/project/path', + 'Droid remote task', + undefined, + { sshRemoteConfig } + ); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockWrapSpawnWithSsh).toHaveBeenCalledTimes(1); + const [config, configSshRemote, configStore] = mockWrapSpawnWithSsh.mock.calls[0]; + + expect(config).toMatchObject({ + command: expect.any(String), + args: expect.any(Array), + cwd: '/project/path', + }); + expect(configSshRemote).toEqual(sshRemoteConfig); + expect(configStore).toEqual({ + getSshRemotes: expect.any(Function), + }); + + mockStdout.emit('data', Buffer.from('{"type":"result","text":"Droid done"}\n')); + mockChild.emit('close', 0); + + const result = await resultPromise; + expect(result.success).toBe(true); + }); + it('should include user home paths', async () => { const resultPromise = spawnAgent('claude-code', '/project', 'prompt'); await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/src/cli/commands/run-playbook.ts b/src/cli/commands/run-playbook.ts index 34a26bd95..9e5c902fd 100644 --- a/src/cli/commands/run-playbook.ts +++ b/src/cli/commands/run-playbook.ts @@ -4,7 +4,7 @@ import { getSessionById } from '../services/storage'; import { findPlaybookById } from '../services/playbooks'; import { runPlaybook as executePlaybook } from '../services/batch-processor'; -import { detectClaude, detectCodex } from '../services/agent-spawner'; +import { detectClaude, detectCodex, detectOpenCode, detectDroid } from '../services/agent-spawner'; import { emitError } from '../output/jsonl'; import { formatRunEvent, @@ -149,7 +149,7 @@ export async function runPlaybook(playbookId: string, options: RunPlaybookOption // Check if agent CLI is available if (agent.toolType === 'codex') { - const codex = await detectCodex(); + const codex = await detectCodex(agent.customPath, agent.sshRemoteConfig); if (!codex.available) { if (useJson) { emitError('Codex CLI not found. Please install codex CLI.', 'CODEX_NOT_FOUND'); @@ -159,7 +159,7 @@ export async function runPlaybook(playbookId: string, options: RunPlaybookOption process.exit(1); } } else if (agent.toolType === 'claude-code') { - const claude = await detectClaude(); + const claude = await detectClaude(agent.customPath, agent.sshRemoteConfig); if (!claude.available) { if (useJson) { emitError('Claude Code not found. Please install claude-code CLI.', 'CLAUDE_NOT_FOUND'); @@ -168,6 +168,26 @@ export async function runPlaybook(playbookId: string, options: RunPlaybookOption } process.exit(1); } + } else if (agent.toolType === 'opencode') { + const opencode = await detectOpenCode(agent.customPath, agent.sshRemoteConfig); + if (!opencode.available) { + if (useJson) { + emitError('OpenCode CLI not found. Please install opencode CLI.', 'OPENCODE_NOT_FOUND'); + } else { + console.error(formatError('OpenCode CLI not found. Please install opencode CLI.')); + } + process.exit(1); + } + } else if (agent.toolType === 'factory-droid') { + const droid = await detectDroid(agent.customPath, agent.sshRemoteConfig); + if (!droid.available) { + if (useJson) { + emitError('Factory Droid CLI not found. Please install droid CLI.', 'DROID_NOT_FOUND'); + } else { + console.error(formatError('Factory Droid CLI not found. Please install droid CLI.')); + } + process.exit(1); + } } else { const message = `Agent type "${agent.toolType}" is not supported in CLI batch mode yet.`; if (useJson) { diff --git a/src/cli/commands/send.ts b/src/cli/commands/send.ts index 0180538dd..ebeae6bc8 100644 --- a/src/cli/commands/send.ts +++ b/src/cli/commands/send.ts @@ -1,7 +1,15 @@ // Send command - send a message to an agent and get a JSON response // Requires a Maestro agent ID. Optionally resumes an existing agent session. -import { spawnAgent, detectClaude, detectCodex, type AgentResult } from '../services/agent-spawner'; +import { + spawnAgent, + detectClaude, + detectCodex, + detectOpenCode, + detectDroid, + type AgentResult, + type AgentSpawnOverrides, +} from '../services/agent-spawner'; import { resolveAgentId, getSessionById } from '../services/storage'; import { estimateContextUsage } from '../../main/parsers/usage-aggregator'; import type { ToolType } from '../../shared/types'; @@ -87,8 +95,11 @@ export async function send( process.exit(1); } + const ssh = + agent.sshRemoteConfig && agent.sshRemoteConfig.enabled ? agent.sshRemoteConfig : undefined; + // Validate agent type is supported for CLI spawning - const supportedTypes: ToolType[] = ['claude-code', 'codex']; + const supportedTypes: ToolType[] = ['claude-code', 'codex', 'opencode', 'factory-droid']; if (!supportedTypes.includes(agent.toolType)) { emitErrorJson( `Agent type "${agent.toolType}" is not supported for send mode. Supported: ${supportedTypes.join(', ')}`, @@ -99,7 +110,7 @@ export async function send( // Verify agent CLI is available if (agent.toolType === 'claude-code') { - const claude = await detectClaude(); + const claude = await detectClaude(agent.customPath, ssh); if (!claude.available) { emitErrorJson( 'Claude Code CLI not found. Install with: npm install -g @anthropic-ai/claude-code', @@ -108,7 +119,7 @@ export async function send( process.exit(1); } } else if (agent.toolType === 'codex') { - const codex = await detectCodex(); + const codex = await detectCodex(agent.customPath, ssh); if (!codex.available) { emitErrorJson( 'Codex CLI not found. Install with: npm install -g @openai/codex', @@ -116,10 +127,50 @@ export async function send( ); process.exit(1); } + } else if (agent.toolType === 'opencode') { + const opencode = await detectOpenCode(agent.customPath, ssh); + if (!opencode.available) { + emitErrorJson( + 'OpenCode CLI not found. Install with: npm install -g opencode', + 'OPENCODE_NOT_FOUND' + ); + process.exit(1); + } + } else if (agent.toolType === 'factory-droid') { + const droid = await detectDroid(agent.customPath, ssh); + if (!droid.available) { + emitErrorJson( + 'Factory Droid CLI not found. Install with: https://factory.ai/product/cli', + 'DROID_NOT_FOUND' + ); + process.exit(1); + } } + const overrides: AgentSpawnOverrides | undefined = (() => { + const next: AgentSpawnOverrides = {}; + + if (agent.customPath !== undefined) { + next.customPath = agent.customPath; + } + if (agent.customArgs !== undefined) { + next.customArgs = agent.customArgs; + } + if (agent.customEnvVars !== undefined) { + next.customEnvVars = agent.customEnvVars; + } + if (agent.customModel !== undefined) { + next.customModel = agent.customModel; + } + if (ssh !== undefined) { + next.sshRemoteConfig = ssh; + } + + return Object.keys(next).length === 0 ? undefined : next; + })(); + // Spawn agent — spawnAgent handles --resume vs --session-id internally - const result = await spawnAgent(agent.toolType, agent.cwd, message, options.session); + const result = await spawnAgent(agent.toolType, agent.cwd, message, options.session, overrides); const response = buildResponse(agentId, agent.name, result, agent.toolType); console.log(JSON.stringify(response, null, 2)); diff --git a/src/cli/services/agent-spawner.ts b/src/cli/services/agent-spawner.ts index 78f8022e3..19e0e79cf 100644 --- a/src/cli/services/agent-spawner.ts +++ b/src/cli/services/agent-spawner.ts @@ -1,15 +1,24 @@ // Agent spawner service for CLI -// Spawns agent CLIs (Claude Code, Codex) and parses their output +// Spawns agent CLIs (Claude Code, Codex, OpenCode, Factory Droid) and parses their output import { spawn, SpawnOptions } from 'child_process'; import * as fs from 'fs'; -import type { ToolType, UsageStats } from '../../shared/types'; +import type { ToolType, UsageStats, AgentSshRemoteConfig } from '../../shared/types'; import { CodexOutputParser } from '../../main/parsers/codex-output-parser'; +import { OpenCodeOutputParser } from '../../main/parsers/opencode-output-parser'; +import { FactoryDroidOutputParser } from '../../main/parsers/factory-droid-output-parser'; import { aggregateModelUsage } from '../../main/parsers/usage-aggregator'; -import { getAgentCustomPath } from './storage'; +import { getAgentDefinition } from '../../main/agents/definitions'; +import { + applyAgentConfigOverrides, + buildAgentArgs, + getContextWindowValue, +} from '../../main/utils/agent-args'; +import { getAgentConfigValues, getAgentCustomPath, readSshRemotes } from './storage'; import { generateUUID } from '../../shared/uuid'; import { buildExpandedPath, buildExpandedEnv } from '../../shared/pathUtils'; import { isWindows, getWhichCommand } from '../../shared/platformDetection'; +import { wrapSpawnWithSsh } from '../../main/utils/ssh-spawn-wrapper'; // Claude Code default command and arguments (same as Electron app) const CLAUDE_DEFAULT_COMMAND = 'claude'; @@ -21,8 +30,10 @@ const CLAUDE_ARGS = [ '--dangerously-skip-permissions', ]; +type CachedPath = { path: string; source: 'settings' | 'path' }; + // Cached Claude path (resolved once at startup) -let cachedClaudePath: string | null = null; +let cachedClaudePath: CachedPath | null = null; // Codex default command and arguments (batch mode) const CODEX_DEFAULT_COMMAND = 'codex'; @@ -34,7 +45,19 @@ const CODEX_ARGS = [ ]; // Cached Codex path (resolved once at startup) -let cachedCodexPath: string | null = null; +let cachedCodexPath: CachedPath | null = null; + +// OpenCode default command +const OPENCODE_DEFAULT_COMMAND = 'opencode'; + +// Cached OpenCode path (resolved once at startup) +let cachedOpenCodePath: CachedPath | null = null; + +// Factory Droid default command +const DROID_DEFAULT_COMMAND = 'droid'; + +// Cached Factory Droid path (resolved once at startup) +let cachedDroidPath: CachedPath | null = null; // Result from spawning an agent export interface AgentResult { @@ -45,6 +68,20 @@ export interface AgentResult { error?: string; } +export interface AgentSpawnOverrides { + customPath?: string; + customArgs?: string; + customEnvVars?: Record; + customModel?: string; + sshRemoteConfig?: AgentSshRemoteConfig; +} + +function getCliSshRemoteStore() { + return { + getSshRemotes: () => readSshRemotes(), + }; +} + /** * Build an expanded PATH that includes common binary installation locations */ @@ -75,14 +112,14 @@ async function isExecutable(filePath: string): Promise { } /** - * Find Claude in PATH using 'which' command + * Find a command in PATH using 'which' command */ -async function findClaudeInPath(): Promise { +async function findCommandInPath(commandName: string): Promise { return new Promise((resolve) => { const env = { ...process.env, PATH: getExpandedPath() }; const command = getWhichCommand(); - const proc = spawn(command, [CLAUDE_DEFAULT_COMMAND], { env }); + const proc = spawn(command, [commandName], { env }); let stdout = ''; proc.stdout?.on('data', (data) => { @@ -103,54 +140,58 @@ async function findClaudeInPath(): Promise { }); } +/** + * Find Claude in PATH using 'which' command + */ +async function findClaudeInPath(): Promise { + return findCommandInPath(CLAUDE_DEFAULT_COMMAND); +} + /** * Find Codex in PATH using 'which' command */ async function findCodexInPath(): Promise { - return new Promise((resolve) => { - const env = { ...process.env, PATH: getExpandedPath() }; - const command = getWhichCommand(); - - const proc = spawn(command, [CODEX_DEFAULT_COMMAND], { env }); - let stdout = ''; - - proc.stdout?.on('data', (data) => { - stdout += data.toString(); - }); - - proc.on('close', (code) => { - if (code === 0 && stdout.trim()) { - resolve(stdout.trim().split('\n')[0]); // First match - } else { - resolve(undefined); - } - }); - - proc.on('error', () => { - resolve(undefined); - }); - }); + return findCommandInPath(CODEX_DEFAULT_COMMAND); } /** * Check if Claude Code is available * First checks for a custom path in settings, then falls back to PATH detection */ -export async function detectClaude(): Promise<{ +export async function detectClaude( + customPathOverride?: string, + sshRemoteConfig?: AgentSshRemoteConfig +): Promise<{ available: boolean; path?: string; source?: 'settings' | 'path'; }> { + if (sshRemoteConfig?.enabled) { + return { available: true, source: 'settings' }; + } + if (customPathOverride) { + if (await isExecutable(customPathOverride)) { + return { available: true, path: customPathOverride, source: 'settings' }; + } + console.error( + `Warning: Custom Claude path "${customPathOverride}" is not executable, falling back to PATH detection` + ); + } + // Return cached result if available if (cachedClaudePath) { - return { available: true, path: cachedClaudePath, source: 'settings' }; + return { + available: true, + path: cachedClaudePath.path, + source: cachedClaudePath.source, + }; } // 1. Check for custom path in settings (same settings as desktop app) const customPath = getAgentCustomPath('claude-code'); if (customPath) { if (await isExecutable(customPath)) { - cachedClaudePath = customPath; + cachedClaudePath = { path: customPath, source: 'settings' }; return { available: true, path: customPath, source: 'settings' }; } // Custom path is set but invalid - warn but continue to PATH detection @@ -162,7 +203,7 @@ export async function detectClaude(): Promise<{ // 2. Fall back to PATH detection const pathResult = await findClaudeInPath(); if (pathResult) { - cachedClaudePath = pathResult; + cachedClaudePath = { path: pathResult, source: 'path' }; return { available: true, path: pathResult, source: 'path' }; } @@ -173,19 +214,38 @@ export async function detectClaude(): Promise<{ * Check if Codex CLI is available * First checks for a custom path in settings, then falls back to PATH detection */ -export async function detectCodex(): Promise<{ +export async function detectCodex( + customPathOverride?: string, + sshRemoteConfig?: AgentSshRemoteConfig +): Promise<{ available: boolean; path?: string; source?: 'settings' | 'path'; }> { + if (sshRemoteConfig?.enabled) { + return { available: true, source: 'settings' }; + } + if (customPathOverride) { + if (await isExecutable(customPathOverride)) { + return { available: true, path: customPathOverride, source: 'settings' }; + } + console.error( + `Warning: Custom Codex path "${customPathOverride}" is not executable, falling back to PATH detection` + ); + } + if (cachedCodexPath) { - return { available: true, path: cachedCodexPath, source: 'settings' }; + return { + available: true, + path: cachedCodexPath.path, + source: cachedCodexPath.source, + }; } const customPath = getAgentCustomPath('codex'); if (customPath) { if (await isExecutable(customPath)) { - cachedCodexPath = customPath; + cachedCodexPath = { path: customPath, source: 'settings' }; return { available: true, path: customPath, source: 'settings' }; } console.error( @@ -195,7 +255,111 @@ export async function detectCodex(): Promise<{ const pathResult = await findCodexInPath(); if (pathResult) { - cachedCodexPath = pathResult; + cachedCodexPath = { path: pathResult, source: 'path' }; + return { available: true, path: pathResult, source: 'path' }; + } + + return { available: false }; +} + +/** + * Check if OpenCode CLI is available + * First checks for a custom path in settings, then falls back to PATH detection + */ +export async function detectOpenCode( + customPathOverride?: string, + sshRemoteConfig?: AgentSshRemoteConfig +): Promise<{ + available: boolean; + path?: string; + source?: 'settings' | 'path'; +}> { + if (sshRemoteConfig?.enabled) { + return { available: true, source: 'settings' }; + } + if (customPathOverride) { + if (await isExecutable(customPathOverride)) { + return { available: true, path: customPathOverride, source: 'settings' }; + } + console.error( + `Warning: Custom OpenCode path "${customPathOverride}" is not executable, falling back to PATH detection` + ); + } + + if (cachedOpenCodePath) { + return { + available: true, + path: cachedOpenCodePath.path, + source: cachedOpenCodePath.source, + }; + } + + const customPath = getAgentCustomPath('opencode'); + if (customPath) { + if (await isExecutable(customPath)) { + cachedOpenCodePath = { path: customPath, source: 'settings' }; + return { available: true, path: customPath, source: 'settings' }; + } + console.error( + `Warning: Custom OpenCode path "${customPath}" is not executable, falling back to PATH detection` + ); + } + + const pathResult = await findCommandInPath(OPENCODE_DEFAULT_COMMAND); + if (pathResult) { + cachedOpenCodePath = { path: pathResult, source: 'path' }; + return { available: true, path: pathResult, source: 'path' }; + } + + return { available: false }; +} + +/** + * Check if Factory Droid CLI is available + * First checks for a custom path in settings, then falls back to PATH detection + */ +export async function detectDroid( + customPathOverride?: string, + sshRemoteConfig?: AgentSshRemoteConfig +): Promise<{ + available: boolean; + path?: string; + source?: 'settings' | 'path'; +}> { + if (sshRemoteConfig?.enabled) { + return { available: true, source: 'settings' }; + } + if (customPathOverride) { + if (await isExecutable(customPathOverride)) { + return { available: true, path: customPathOverride, source: 'settings' }; + } + console.error( + `Warning: Custom Droid path "${customPathOverride}" is not executable, falling back to PATH detection` + ); + } + + if (cachedDroidPath) { + return { + available: true, + path: cachedDroidPath.path, + source: cachedDroidPath.source, + }; + } + + const customPath = getAgentCustomPath('factory-droid'); + if (customPath) { + if (await isExecutable(customPath)) { + cachedDroidPath = { path: customPath, source: 'settings' }; + return { available: true, path: customPath, source: 'settings' }; + } + console.error( + `Warning: Custom Droid path "${customPath}" is not executable, falling back to PATH detection` + ); + } + + const pathResult = await findCommandInPath(DROID_DEFAULT_COMMAND); + if (pathResult) { + cachedDroidPath = { path: pathResult, source: 'path' }; return { available: true, path: pathResult, source: 'path' }; } @@ -206,61 +370,125 @@ export async function detectCodex(): Promise<{ * Get the resolved Claude command/path for spawning * Uses cached path from detectClaude() or falls back to default command */ -export function getClaudeCommand(): string { - return cachedClaudePath || CLAUDE_DEFAULT_COMMAND; +export function getClaudeCommand(customPath?: string): string { + if (customPath && customPath.trim()) { + return customPath; + } + return cachedClaudePath?.path || CLAUDE_DEFAULT_COMMAND; } /** * Get the resolved Codex command/path for spawning * Uses cached path from detectCodex() or falls back to default command */ -export function getCodexCommand(): string { - return cachedCodexPath || CODEX_DEFAULT_COMMAND; +export function getCodexCommand(customPath?: string): string { + if (customPath && customPath.trim()) { + return customPath; + } + return cachedCodexPath?.path || CODEX_DEFAULT_COMMAND; +} + +/** + * Get the resolved OpenCode command/path for spawning + */ +export function getOpenCodeCommand(customPath?: string): string { + if (customPath && customPath.trim()) { + return customPath; + } + return cachedOpenCodePath?.path || OPENCODE_DEFAULT_COMMAND; +} + +/** + * Get the resolved Factory Droid command/path for spawning + */ +export function getDroidCommand(customPath?: string): string { + if (customPath && customPath.trim()) { + return customPath; + } + return cachedDroidPath?.path || DROID_DEFAULT_COMMAND; } /** * Spawn Claude Code with a prompt and return the result. * - * NOTE: CLI spawner does not apply applyAgentConfigOverrides() or SSH wrapping. - * Designed for headless batch execution without access to the Electron settings - * store or per-session agent configuration. Custom model, args, env vars, and - * SSH remote execution are not supported in CLI mode. + * NOTE: CLI spawner can SSH-wrap when sshRemoteConfig is provided, but it does not use + * the Electron settingsStore/global shell env. Session overrides (model, args, env vars, + * and custom CLI path) are still applied via applyAgentConfigOverrides(). */ async function spawnClaudeAgent( cwd: string, prompt: string, - agentSessionId?: string + agentSessionId?: string, + overrides?: AgentSpawnOverrides ): Promise { - return new Promise((resolve) => { - // Note: CLI agent spawner doesn't have access to settingsStore with global shell env vars. - // For CLI, we rely on the environment that Maestro itself is running in. - // Global shell env vars are primarily used by the desktop app's process manager. - const env = buildExpandedEnv(); - - // Build args: base args + session handling + prompt - const args = [...CLAUDE_ARGS]; - - if (agentSessionId) { - // Resume an existing session (e.g., for synopsis generation) - args.push('--resume', agentSessionId); - } else { - // Force a fresh, isolated session for each task execution - // This prevents context bleeding between tasks in Auto Run - args.push('--session-id', generateUUID()); - } + const agentDef = getAgentDefinition('claude-code'); + const agentConfigValues = getAgentConfigValues('claude-code') as Record; + + // Build args: base args + session handling (prompt appended after overrides) + const baseArgs = [...CLAUDE_ARGS]; + + if (agentSessionId) { + // Resume an existing session (e.g., for synopsis generation) + baseArgs.push('--resume', agentSessionId); + } else { + // Force a fresh, isolated session for each task execution + // This prevents context bleeding between tasks in Auto Run + baseArgs.push('--session-id', generateUUID()); + } - // Add prompt as positional argument - args.push('--', prompt); + const { args: resolvedArgs, effectiveCustomEnvVars } = applyAgentConfigOverrides( + agentDef, + baseArgs, + { + agentConfigValues, + sessionCustomModel: overrides?.customModel, + sessionCustomArgs: overrides?.customArgs, + sessionCustomEnvVars: overrides?.customEnvVars, + } + ); + + // Add prompt as positional argument after overrides + const args = [...resolvedArgs, '--', prompt]; + + // Note: CLI agent spawner doesn't have access to settingsStore with global shell env vars. + // For CLI, we rely on the environment that Maestro itself is running in. + // Global shell env vars are primarily used by the desktop app's process manager. + const env = buildExpandedEnv(effectiveCustomEnvVars); + + let spawnCommand = + overrides?.sshRemoteConfig && !overrides?.customPath && agentDef?.binaryName + ? agentDef.binaryName + : getClaudeCommand(overrides?.customPath); + let spawnArgs = args; + let spawnCwd = cwd; + let spawnEnv = env; + + if (overrides?.sshRemoteConfig) { + const sshWrapped = await wrapSpawnWithSsh( + { + command: spawnCommand, + args: spawnArgs, + cwd, + customEnvVars: effectiveCustomEnvVars, + agentBinaryName: agentDef?.binaryName, + }, + overrides.sshRemoteConfig, + getCliSshRemoteStore() + ); + spawnCommand = sshWrapped.command; + spawnArgs = sshWrapped.args; + spawnCwd = sshWrapped.cwd; + spawnEnv = buildExpandedEnv(sshWrapped.customEnvVars); + } + return new Promise((resolve) => { const options: SpawnOptions = { - cwd, - env, + cwd: spawnCwd, + env: spawnEnv, stdio: ['pipe', 'pipe', 'pipe'], }; - // Use the resolved Claude path (from settings or PATH detection) - const claudeCommand = getClaudeCommand(); - const child = spawn(claudeCommand, args, options); + const child = spawn(spawnCommand, spawnArgs, options); let jsonBuffer = ''; let result: string | undefined; @@ -376,40 +604,279 @@ function mergeUsageStats( return merged; } +type StreamJsonParser = { + parseJsonLine: (line: string) => any | null; + extractSessionId: (event: any) => string | null | undefined; + isResultMessage: (event: any) => boolean; + extractUsage: (event: any) => + | { + inputTokens: number; + outputTokens: number; + cacheReadTokens?: number; + cacheCreationTokens?: number; + costUsd?: number; + contextWindow?: number; + reasoningTokens?: number; + } + | null + | undefined; +}; + +async function spawnStreamingAgent( + toolType: 'opencode' | 'factory-droid', + cwd: string, + prompt: string, + agentSessionId: string | undefined, + overrides: AgentSpawnOverrides | undefined, + commandGetter: (customPath?: string) => string, + createParser: () => StreamJsonParser, + agentLabel: string +): Promise { + const { args, env, contextWindow, effectiveCustomEnvVars } = resolveAgentInvocation( + toolType, + cwd, + prompt, + agentSessionId, + overrides + ); + const agentDef = getAgentDefinition(toolType); + + let spawnCommand = + overrides?.sshRemoteConfig && !overrides?.customPath && agentDef?.binaryName + ? agentDef.binaryName + : commandGetter(overrides?.customPath); + let spawnArgs = args; + let spawnCwd = cwd; + let spawnEnv = env; + + if (overrides?.sshRemoteConfig) { + const sshWrapped = await wrapSpawnWithSsh( + { + command: spawnCommand, + args: spawnArgs, + cwd, + customEnvVars: effectiveCustomEnvVars, + agentBinaryName: agentDef?.binaryName, + }, + overrides.sshRemoteConfig, + getCliSshRemoteStore() + ); + spawnCommand = sshWrapped.command; + spawnArgs = sshWrapped.args; + spawnCwd = sshWrapped.cwd; + spawnEnv = buildExpandedEnv(sshWrapped.customEnvVars); + } + + return new Promise((resolve) => { + const options: SpawnOptions = { + cwd: spawnCwd, + env: spawnEnv, + stdio: ['pipe', 'pipe', 'pipe'], + }; + + const child = spawn(spawnCommand, spawnArgs, options); + + const parser = createParser(); + let jsonBuffer = ''; + let result: string | undefined; + let sessionId: string | undefined; + let usageStats: UsageStats | undefined; + let stderr = ''; + let errorText: string | undefined; + + child.stdout?.on('data', (data: Buffer) => { + jsonBuffer += data.toString(); + const lines = jsonBuffer.split('\n'); + jsonBuffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + const event = parser.parseJsonLine(line); + if (!event) continue; + + const extractedSessionId = parser.extractSessionId(event); + if (extractedSessionId && !sessionId) { + sessionId = extractedSessionId; + } + + if (parser.isResultMessage(event) && event.text) { + result = result ? `${result}\n${event.text}` : event.text; + } + + if (event.type === 'error' && event.text && !errorText) { + errorText = event.text; + } + + const usage = parser.extractUsage(event); + if (usage) { + usageStats = mergeUsageStats(usageStats, { + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + cacheReadTokens: usage.cacheReadTokens, + cacheCreationTokens: usage.cacheCreationTokens, + costUsd: usage.costUsd, + contextWindow: usage.contextWindow, + reasoningTokens: usage.reasoningTokens, + }); + } + } + }); + + child.stderr?.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + child.stdin?.end(); + + child.on('close', (code) => { + if (usageStats && (!usageStats.contextWindow || usageStats.contextWindow === 0)) { + usageStats.contextWindow = contextWindow; + } + + if (code === 0 && !errorText) { + resolve({ + success: true, + response: result, + agentSessionId: sessionId, + usageStats, + }); + } else { + resolve({ + success: false, + error: errorText || stderr || `Process exited with code ${code}`, + agentSessionId: sessionId, + usageStats, + }); + } + }); + + child.on('error', (error) => { + resolve({ + success: false, + error: `Failed to spawn ${agentLabel}: ${error.message}`, + }); + }); + }); +} + +function resolveAgentInvocation( + toolType: ToolType, + cwd: string, + prompt: string, + agentSessionId: string | undefined, + overrides?: AgentSpawnOverrides +): { + args: string[]; + env: NodeJS.ProcessEnv; + contextWindow: number; + effectiveCustomEnvVars?: Record; +} { + const agentDef = getAgentDefinition(toolType); + const agentConfigValues = getAgentConfigValues(toolType) as Record; + + const baseArgs = buildAgentArgs(agentDef, { + baseArgs: [], + prompt, + cwd, + agentSessionId, + }); + + const { args: resolvedArgs, effectiveCustomEnvVars } = applyAgentConfigOverrides( + agentDef, + baseArgs, + { + agentConfigValues, + sessionCustomModel: overrides?.customModel, + sessionCustomArgs: overrides?.customArgs, + sessionCustomEnvVars: overrides?.customEnvVars, + } + ); + + const finalArgs = [...resolvedArgs]; + if (!agentDef?.noPromptSeparator) { + finalArgs.push('--'); + } + finalArgs.push(prompt); + + const env = buildExpandedEnv(effectiveCustomEnvVars); + const contextWindow = getContextWindowValue(agentDef, agentConfigValues); + + return { args: finalArgs, env, contextWindow, effectiveCustomEnvVars }; +} + /** * Spawn Codex with a prompt and return the result. * - * NOTE: Same limitations as spawnClaudeAgent — no applyAgentConfigOverrides() - * or SSH wrapping in CLI mode. + * NOTE: Same limitations as spawnClaudeAgent (no global settingsStore); per-session + * overrides are still applied. */ async function spawnCodexAgent( cwd: string, prompt: string, - agentSessionId?: string + agentSessionId?: string, + overrides?: AgentSpawnOverrides ): Promise { - return new Promise((resolve) => { - // Note: CLI agent spawner doesn't have access to settingsStore with global shell env vars. - // For CLI, we rely on the environment that Maestro itself is running in. - // Global shell env vars are primarily used by the desktop app's process manager. - const env = buildExpandedEnv(); + const agentDef = getAgentDefinition('codex'); + const agentConfigValues = getAgentConfigValues('codex') as Record; - const args = [...CODEX_ARGS]; - args.push('-C', cwd); + const baseArgs = [...CODEX_ARGS]; + baseArgs.push('-C', cwd); - if (agentSessionId) { - args.push('resume', agentSessionId); - } + if (agentSessionId) { + baseArgs.push('resume', agentSessionId); + } - args.push('--', prompt); + const { args: resolvedArgs, effectiveCustomEnvVars } = applyAgentConfigOverrides( + agentDef, + baseArgs, + { + agentConfigValues, + sessionCustomModel: overrides?.customModel, + sessionCustomArgs: overrides?.customArgs, + sessionCustomEnvVars: overrides?.customEnvVars, + } + ); + + const args = [...resolvedArgs, '--', prompt]; + + // Note: CLI agent spawner doesn't have access to settingsStore with global shell env vars. + // For CLI, we rely on the environment that Maestro itself is running in. + // Global shell env vars are primarily used by the desktop app's process manager. + const env = buildExpandedEnv(effectiveCustomEnvVars); + + let spawnCommand = !overrides?.customPath + ? agentDef?.binaryName || getCodexCommand() + : getCodexCommand(overrides?.customPath); + let spawnArgs = args; + let spawnCwd = cwd; + let spawnEnv = env; + + if (overrides?.sshRemoteConfig) { + const sshWrapped = await wrapSpawnWithSsh( + { + command: spawnCommand, + args: spawnArgs, + cwd, + customEnvVars: effectiveCustomEnvVars, + agentBinaryName: agentDef?.binaryName, + }, + overrides.sshRemoteConfig, + getCliSshRemoteStore() + ); + spawnCommand = sshWrapped.command; + spawnArgs = sshWrapped.args; + spawnCwd = sshWrapped.cwd; + spawnEnv = buildExpandedEnv(sshWrapped.customEnvVars); + } + return new Promise((resolve) => { const options: SpawnOptions = { - cwd, - env, + cwd: spawnCwd, + env: spawnEnv, stdio: ['pipe', 'pipe', 'pipe'], }; - const codexCommand = getCodexCommand(); - const child = spawn(codexCommand, args, options); + const child = spawn(spawnCommand, spawnArgs, options); const parser = new CodexOutputParser(); let jsonBuffer = ''; @@ -481,6 +948,48 @@ async function spawnCodexAgent( }); } +/** + * Spawn OpenCode with a prompt and return the result + */ +async function spawnOpenCodeAgent( + cwd: string, + prompt: string, + agentSessionId?: string, + overrides?: AgentSpawnOverrides +): Promise { + return spawnStreamingAgent( + 'opencode', + cwd, + prompt, + agentSessionId, + overrides, + getOpenCodeCommand, + () => new OpenCodeOutputParser(), + 'OpenCode' + ); +} + +/** + * Spawn Factory Droid with a prompt and return the result + */ +async function spawnFactoryDroidAgent( + cwd: string, + prompt: string, + agentSessionId?: string, + overrides?: AgentSpawnOverrides +): Promise { + return spawnStreamingAgent( + 'factory-droid', + cwd, + prompt, + agentSessionId, + overrides, + getDroidCommand, + () => new FactoryDroidOutputParser(), + 'Factory Droid' + ); +} + /** * Spawn an agent with a prompt and return the result */ @@ -488,14 +997,24 @@ export async function spawnAgent( toolType: ToolType, cwd: string, prompt: string, - agentSessionId?: string + agentSessionId?: string, + overrides?: AgentSpawnOverrides ): Promise { + // Claude + Codex have bespoke spawners; OpenCode + Factory Droid share the streaming JSON path. if (toolType === 'codex') { - return spawnCodexAgent(cwd, prompt, agentSessionId); + return spawnCodexAgent(cwd, prompt, agentSessionId, overrides); } if (toolType === 'claude-code') { - return spawnClaudeAgent(cwd, prompt, agentSessionId); + return spawnClaudeAgent(cwd, prompt, agentSessionId, overrides); + } + + if (toolType === 'opencode') { + return spawnOpenCodeAgent(cwd, prompt, agentSessionId, overrides); + } + + if (toolType === 'factory-droid') { + return spawnFactoryDroidAgent(cwd, prompt, agentSessionId, overrides); } return { diff --git a/src/cli/services/batch-processor.ts b/src/cli/services/batch-processor.ts index 3127cac6a..4b9facc9b 100644 --- a/src/cli/services/batch-processor.ts +++ b/src/cli/services/batch-processor.ts @@ -2,7 +2,13 @@ // Executes playbooks and yields JSONL events import { execFileSync } from 'child_process'; -import type { Playbook, SessionInfo, UsageStats, HistoryEntry } from '../../shared/types'; +import type { + Playbook, + SessionInfo, + UsageStats, + HistoryEntry, + AgentSshRemoteConfig, +} from '../../shared/types'; import type { JsonlEvent } from '../output/jsonl'; import { spawnAgent, @@ -439,7 +445,28 @@ export async function* runPlaybook( } // Spawn agent with combined prompt + document - const result = await spawnAgent(session.toolType, session.cwd, finalPrompt); + const spawnOverrides: { + customPath?: string; + customArgs?: string; + customEnvVars?: Record; + customModel?: string; + sshRemoteConfig?: AgentSshRemoteConfig; + } = { + customPath: session.customPath, + customArgs: session.customArgs, + customEnvVars: session.customEnvVars, + customModel: session.customModel, + }; + if (!session.sshRemoteConfig?.enabled) { + spawnOverrides.sshRemoteConfig = session.sshRemoteConfig; + } + const result = await spawnAgent( + session.toolType, + session.cwd, + finalPrompt, + undefined, + spawnOverrides + ); const elapsedMs = Date.now() - taskStartTime; @@ -476,7 +503,8 @@ export async function* runPlaybook( session.toolType, session.cwd, BATCH_SYNOPSIS_PROMPT, - result.agentSessionId + result.agentSessionId, + spawnOverrides ); if (synopsisResult.success && synopsisResult.response) { diff --git a/src/cli/services/storage.ts b/src/cli/services/storage.ts index d3e2d6c13..7a611dee8 100644 --- a/src/cli/services/storage.ts +++ b/src/cli/services/storage.ts @@ -4,7 +4,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import type { Group, SessionInfo, HistoryEntry } from '../../shared/types'; +import type { Group, SessionInfo, HistoryEntry, SshRemoteConfig } from '../../shared/types'; import { HISTORY_VERSION, MAX_ENTRIES_PER_SESSION, @@ -218,6 +218,15 @@ export function readSettings(): SettingsStore { return data || {}; } +/** + * Read SSH remote configurations from settings storage + */ +export function readSshRemotes(): SshRemoteConfig[] { + const settings = readSettings(); + const remotes = settings.sshRemotes; + return Array.isArray(remotes) ? (remotes as SshRemoteConfig[]) : []; +} + /** * Read agent configurations from storage * This includes custom paths set by the user in the desktop app @@ -240,6 +249,14 @@ export function getAgentCustomPath(agentId: string): string | undefined { return undefined; } +/** + * Get stored agent config values for a specific agent (e.g., customArgs, customEnvVars, model) + */ +export function getAgentConfigValues(agentId: string): Record { + const configs = readAgentConfigs(); + return configs[agentId] || {}; +} + /** * Resolve a partial ID to a full ID * Returns: { id, ambiguous, matches } diff --git a/src/main/utils/agent-args.ts b/src/main/utils/agent-args.ts index bd8763e0d..cdb0c2c52 100644 --- a/src/main/utils/agent-args.ts +++ b/src/main/utils/agent-args.ts @@ -1,4 +1,6 @@ -import type { AgentConfig } from '../agents'; +import type { AgentConfig, AgentDefinition } from '../agents'; + +type AgentArgsConfig = AgentConfig | AgentDefinition | null | undefined; type BuildAgentArgsOptions = { baseArgs: string[]; @@ -39,10 +41,7 @@ function parseCustomArgs(customArgs?: string): string[] { }); } -export function buildAgentArgs( - agent: AgentConfig | null | undefined, - options: BuildAgentArgsOptions -): string[] { +export function buildAgentArgs(agent: AgentArgsConfig, options: BuildAgentArgsOptions): string[] { let finalArgs = [...options.baseArgs]; if (!agent) { @@ -99,7 +98,7 @@ export function buildAgentArgs( } export function applyAgentConfigOverrides( - agent: AgentConfig | null | undefined, + agent: AgentArgsConfig, baseArgs: string[], overrides: AgentConfigOverrides ): AgentConfigResolution { @@ -186,7 +185,7 @@ export function applyAgentConfigOverrides( } export function getContextWindowValue( - agent: AgentConfig | null | undefined, + agent: AgentArgsConfig, agentConfigValues: Record, sessionCustomContextWindow?: number ): number { diff --git a/src/shared/types.ts b/src/shared/types.ts index 24d4da21e..2633b07dc 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -28,6 +28,11 @@ export interface SessionInfo { cwd: string; projectRoot: string; autoRunFolderPath?: string; + customPath?: string; + customArgs?: string; + customEnvVars?: Record; + customModel?: string; + sshRemoteConfig?: AgentSshRemoteConfig; } // Usage statistics from AI agent CLI (Claude Code, Codex, etc.)