diff --git a/src/__tests__/cli/services/agent-spawner.test.ts b/src/__tests__/cli/services/agent-spawner.test.ts index 548d90500..58d87c948 100644 --- a/src/__tests__/cli/services/agent-spawner.test.ts +++ b/src/__tests__/cli/services/agent-spawner.test.ts @@ -1176,7 +1176,8 @@ Some text with [x] in it that's not a checkbox expect(args).toContain('plan'); // Should still have base args expect(args).toContain('--print'); - expect(args).toContain('--dangerously-skip-permissions'); + // Should NOT have permission bypass in read-only mode + expect(args).not.toContain('--dangerously-skip-permissions'); mockStdout.emit('data', Buffer.from('{"type":"result","result":"Done"}\n')); mockChild.emit('close', 0); @@ -1193,6 +1194,8 @@ Some text with [x] in it that's not a checkbox const [, args] = mockSpawn.mock.calls[0]; expect(args).not.toContain('--permission-mode'); expect(args).not.toContain('plan'); + // Should have permission bypass in normal mode + expect(args).toContain('--dangerously-skip-permissions'); mockStdout.emit('data', Buffer.from('{"type":"result","result":"Done"}\n')); mockChild.emit('close', 0); diff --git a/src/cli/commands/send.ts b/src/cli/commands/send.ts index 57b8801e7..6c7bcce8d 100644 --- a/src/cli/commands/send.ts +++ b/src/cli/commands/send.ts @@ -110,6 +110,7 @@ export async function send( // Spawn agent — spawnAgent handles --resume vs --session-id internally const result = await spawnAgent(agent.toolType, agent.cwd, message, options.session, { readOnlyMode: options.readOnly, + customModel: agent.customModel, }); const response = buildResponse(agentId, agent.name, result, agent.toolType); diff --git a/src/cli/services/agent-spawner.ts b/src/cli/services/agent-spawner.ts index 1f0d9fe84..90ecb25d2 100644 --- a/src/cli/services/agent-spawner.ts +++ b/src/cli/services/agent-spawner.ts @@ -22,9 +22,11 @@ const CLAUDE_ARGS = [ '--verbose', '--output-format', 'stream-json', - '--dangerously-skip-permissions', ]; +// Permission bypass arg for Claude — skipped in read-only mode +const CLAUDE_YOLO_ARGS = ['--dangerously-skip-permissions']; + // Cached paths per agent type (resolved once at startup) const cachedPaths: Map = new Map(); @@ -192,6 +194,9 @@ async function spawnClaudeAgent( if (def?.readOnlyEnvOverrides) { Object.assign(env, def.readOnlyEnvOverrides); } + } else { + // Only bypass permissions in non-read-only mode + args.push(...CLAUDE_YOLO_ARGS); } if (agentSessionId) { @@ -355,7 +360,8 @@ async function spawnJsonLineAgent( cwd: string, prompt: string, agentSessionId?: string, - _readOnlyMode?: boolean + readOnlyMode?: boolean, + customModel?: string ): Promise { return new Promise((resolve) => { const env = buildExpandedEnv(); @@ -368,11 +374,31 @@ async function spawnJsonLineAgent( } } + // Apply read-only mode env overrides from agent definition + if (readOnlyMode && def?.readOnlyEnvOverrides) { + Object.assign(env, def.readOnlyEnvOverrides); + } + // Build args from agent definition const args: string[] = []; if (def?.batchModePrefix) args.push(...def.batchModePrefix); - if (def?.batchModeArgs) args.push(...def.batchModeArgs); + + // In read-only mode, filter out YOLO/bypass args from batchModeArgs + // (they override read-only flags). In normal mode, apply all batchModeArgs. + // Skip filtering for agents without CLI-level read-only enforcement + // (e.g., Gemini CLI needs -y to avoid interactive prompts that hang with closed stdin). + if (def?.batchModeArgs) { + if (readOnlyMode && def.readOnlyCliEnforced !== false && def.yoloModeArgs?.length) { + const yoloSet = new Set(def.yoloModeArgs); + args.push(...def.batchModeArgs.filter((a) => !yoloSet.has(a))); + } else { + args.push(...def.batchModeArgs); + } + } + if (def?.jsonOutputArgs) args.push(...def.jsonOutputArgs); + if (readOnlyMode && def?.readOnlyArgs) args.push(...def.readOnlyArgs); + if (customModel && def?.modelArgs) args.push(...def.modelArgs(customModel)); if (agentSessionId && def?.resumeArgs) { args.push(...def.resumeArgs(agentSessionId)); @@ -477,6 +503,8 @@ export interface SpawnAgentOptions { agentSessionId?: string; /** Run in read-only/plan mode (uses centralized agent definitions for provider-specific flags) */ readOnlyMode?: boolean; + /** Custom model ID from agent config (e.g., 'github-copilot/gpt-5-mini') */ + customModel?: string; } /** @@ -490,13 +518,14 @@ export async function spawnAgent( options?: SpawnAgentOptions ): Promise { const readOnly = options?.readOnlyMode; + const customModel = options?.customModel; if (toolType === 'claude-code') { return spawnClaudeAgent(cwd, prompt, agentSessionId, readOnly); } if (hasCapability(toolType, 'usesJsonLineOutput')) { - return spawnJsonLineAgent(toolType, cwd, prompt, agentSessionId); + return spawnJsonLineAgent(toolType, cwd, prompt, agentSessionId, readOnly, customModel); } return { diff --git a/src/cli/services/batch-processor.ts b/src/cli/services/batch-processor.ts index 20e2e19a1..15b8f40e6 100644 --- a/src/cli/services/batch-processor.ts +++ b/src/cli/services/batch-processor.ts @@ -440,7 +440,9 @@ export async function* runPlaybook( } // Spawn agent with combined prompt + document - const result = await spawnAgent(session.toolType, session.cwd, finalPrompt); + const result = await spawnAgent(session.toolType, session.cwd, finalPrompt, undefined, { + customModel: session.customModel, + }); const elapsedMs = Date.now() - taskStartTime; diff --git a/src/main/agents/definitions.ts b/src/main/agents/definitions.ts index 869383045..8ee7aee68 100644 --- a/src/main/agents/definitions.ts +++ b/src/main/agents/definitions.ts @@ -275,10 +275,12 @@ export const AGENT_DEFINITIONS: AgentDefinition[] = [ OPENCODE_CONFIG_CONTENT: '{"permission":{"*":"allow","external_directory":"allow","question":"deny"},"tools":{"question":false}}', }, - // In read-only mode, strip blanket permission grants so the plan agent can't auto-approve file writes. + // In read-only mode, keep blanket permission grants to prevent stdin prompts that hang batch mode. + // Read-only enforcement comes from --agent plan (readOnlyArgs), not env config. // Keep question tool disabled to prevent stdin hangs in batch mode. readOnlyEnvOverrides: { - OPENCODE_CONFIG_CONTENT: '{"permission":{"question":"deny"},"tools":{"question":false}}', + OPENCODE_CONFIG_CONTENT: + '{"permission":{"*":"allow","external_directory":"allow","question":"deny"},"tools":{"question":false}}', }, // Agent-specific configuration options shown in UI configOptions: [ diff --git a/src/shared/types.ts b/src/shared/types.ts index 6641b60f1..7de98589f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -36,6 +36,7 @@ export interface SessionInfo { cwd: string; projectRoot: string; autoRunFolderPath?: string; + customModel?: string; } // Usage statistics from AI agent CLI (Claude Code, Codex, etc.)