From 779a0a8a77d0764fec31f06b7ed0286b1e4e0792 Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Fri, 26 Jun 2026 21:09:47 -0700 Subject: [PATCH] Suppress Codex yolo permission attention Co-authored-by: Orca --- src/main/index.ts | 42 ++- src/main/runtime/orca-runtime.ts | 31 +++ ...-approval-notification-suppression.test.ts | 229 +++++++++++++--- ...-auto-approval-notification-suppression.ts | 64 ++++- .../terminal-pane/pty-connection.test.ts | 181 +++++++++++++ .../terminal-pane/pty-connection.ts | 29 +- ...gent-hook-completion-notifications.test.ts | 18 +- src/renderer/src/hooks/useIpcEvents.test.ts | 251 ++++++++++++++++++ src/renderer/src/hooks/useIpcEvents.ts | 15 ++ src/renderer/src/store/slices/agent-status.ts | 45 ++++ src/shared/tui-agent-permissions.test.ts | 37 +++ src/shared/tui-agent-permissions.ts | 75 ++++-- 12 files changed, 930 insertions(+), 87 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index d684845923a..1ac7888c6b1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -145,6 +145,7 @@ import { type SyntheticAgentTitleProfile } from '../shared/synthetic-agent-title' import type { AgentStatusState } from '../shared/agent-status-types' +import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions' import { KeybindingService } from './keybindings/keybinding-service' import { applyElectronProxySettings } from './network/proxy-settings' import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' @@ -841,7 +842,20 @@ function openMainWindow(): BrowserWindow { // Why: some native OSC titles miss terminal idle/permission frames. // Inject hook-derived frames so the renderer title tracker updates too. const profile = getSyntheticAgentTitleProfile(payload.agentType) - if (profile && shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state)) { + const suppressSyntheticCodexAutoApprovalTitle = + payload.agentType === 'codex' && + (payload.state === 'waiting' || payload.state === 'blocked') + ? shouldSuppressCodexAutoApprovalSyntheticTitleFromHook({ + agentType: payload.agentType, + state: payload.state, + launchConfig: runtime?.getAgentStatusLaunchConfigForPaneKey(paneKey, { launchToken }) + }) + : false + if ( + profile && + shouldDriveSyntheticAgentTitleFromHook(payload.agentType, payload.state) && + !suppressSyntheticCodexAutoApprovalTitle + ) { driveSyntheticTitleFromHook(paneKey, payload.state, profile) } } @@ -1261,6 +1275,32 @@ function driveSyntheticTitleFromHook( }) } +function shouldSuppressCodexAutoApprovalSyntheticTitleFromHook(args: { + agentType: string | null | undefined + state: AgentStatusState + launchConfig: + | { + agentArgs?: string | null + agentEnv?: Record | null + } + | null + | undefined +}): boolean { + if (args.agentType !== 'codex' || (args.state !== 'waiting' && args.state !== 'blocked')) { + return false + } + if (!args.launchConfig) { + return false + } + return ( + resolveTuiAgentPermissionMode({ + agent: 'codex', + agentArgs: args.launchConfig.agentArgs, + agentEnv: args.launchConfig.agentEnv + }) === 'yolo' + ) +} + app.whenReady().then(async () => { logStartupMilestone('app-ready') electronApp.setAppUserModelId(devInstanceIdentity.appUserModelId) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index fef93b96e19..53623f58287 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -17459,6 +17459,20 @@ export class OrcaRuntimeService { return this.getTerminalHandleForPaneKey(paneKey) ?? undefined } + getAgentStatusLaunchConfigForPaneKey( + paneKey: string, + args?: { launchToken?: string } + ): SleepingAgentLaunchConfig | undefined { + const pty = this.getPtyRecordForPaneKey(paneKey) + if (!pty?.launchConfig) { + return undefined + } + if (pty.launchToken === null || pty.launchToken !== args?.launchToken) { + return undefined + } + return copySleepingAgentLaunchConfig(pty.launchConfig) + } + private buildAgentOrchestrationByPaneKey(): | Record | undefined { @@ -17569,6 +17583,23 @@ export class OrcaRuntimeService { return null } + private getPtyRecordForPaneKey(paneKey: string): RuntimePtyWorktreeRecord | null { + const parsed = parsePaneKey(paneKey) + if (parsed) { + const leaf = this.leaves.get(this.getLeafKey(parsed.tabId, parsed.leafId)) + const pty = leaf?.ptyId ? this.ptysById.get(leaf.ptyId) : undefined + if (pty) { + return pty + } + } + for (const pty of this.ptysById.values()) { + if (pty.paneKey === paneKey) { + return pty + } + } + return null + } + private getPaneKeyForTerminalHandle(handle: string): string | null { const livePty = this.getLivePtyForHandle(handle) if (livePty?.pty.paneKey) { diff --git a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts index c5d2e59f0e9..f3a0611f87d 100644 --- a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts +++ b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.test.ts @@ -2,6 +2,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { YOLO_TUI_AGENT_ARGS } from '../../../../shared/tui-agent-permissions' import { createTestStore, makeTab } from '../../store/slices/store-test-helpers' import type { AppState } from '../../store/types' +import { + createCodexAutoApprovalHookCompletionSuppressor, + shouldSuppressCodexAutoApprovalSyntheticTitle, + shouldSuppressCodexAutoApprovalStatus +} from './codex-auto-approval-notification-suppression' let testStore: ReturnType @@ -11,64 +16,206 @@ vi.mock('@/store', () => ({ } })) -import { isAutoApprovedCodexPermissionStatus } from './codex-auto-approval-notification-suppression' +const paneKey = 'tab-1:leaf-1' +const launchToken = 'launch-token-1' +const providerSession = { key: 'session_id' as const, id: 'codex-session-1' } -describe('Codex auto-approval notification suppression', () => { +function seedTab(): void { + testStore.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + } + } as Partial) +} + +function registerCodexLaunchConfig(args: { + agentArgs: string + launchToken?: string + providerSession?: typeof providerSession +}): void { + testStore.getState().registerAgentLaunchConfig( + paneKey, + { + agentArgs: args.agentArgs, + agentEnv: {} + }, + { + agentType: 'codex', + tabId: 'tab-1', + leafId: 'leaf-1', + ...(args.launchToken ? { launchToken: args.launchToken } : {}), + ...(args.providerSession ? { providerSession: args.providerSession } : {}) + } + ) +} + +describe('Codex auto-approval status suppression', () => { beforeEach(() => { testStore = createTestStore() + seedTab() }) - it('uses the production launch-config resolver for launch-token registered Codex panes', () => { - const paneKey = 'tab-1:leaf-1' - const launchToken = 'launch-token-1' - const providerSession = { key: 'session_id' as const, id: 'codex-session-1' } - - testStore.setState({ - tabsByWorktree: { - 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] - } - } as Partial) - testStore.getState().registerAgentLaunchConfig( - paneKey, - { - agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', - agentEnv: {} - }, - { agentType: 'codex', launchToken, tabId: 'tab-1', leafId: 'leaf-1' } - ) - testStore - .getState() - .setAgentStatus( - paneKey, - { state: 'waiting', prompt: 'implement notifications', agentType: 'codex' }, - 'Codex', - { updatedAt: 10, stateStartedAt: 10 }, - { tabId: 'tab-1', worktreeId: 'wt-1' }, - { providerSession, launchToken } - ) + it('suppresses the first auto-approved Codex waiting status with matching launch token', () => { + registerCodexLaunchConfig({ + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + launchToken + }) expect( - isAutoApprovedCodexPermissionStatus( + shouldSuppressCodexAutoApprovalStatus( { state: 'waiting', prompt: 'implement notifications', agentType: 'codex' }, - paneKey + { paneKey, tabId: 'tab-1', launchToken } ) ).toBe(true) }) - it('preserves notifications when the live status has no resolvable launch config', () => { - const paneKey = 'tab-1:leaf-1' - - testStore.getState().setAgentStatus(paneKey, { - state: 'waiting', - prompt: 'implement notifications', - agentType: 'codex' + it('suppresses auto-approved Codex blocked statuses', () => { + registerCodexLaunchConfig({ + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + launchToken }) expect( - isAutoApprovedCodexPermissionStatus( + shouldSuppressCodexAutoApprovalStatus( + { state: 'blocked', prompt: 'implement notifications', agentType: 'codex' }, + { paneKey, tabId: 'tab-1', launchToken } + ) + ).toBe(true) + }) + + it('preserves manual Codex permission attention', () => { + registerCodexLaunchConfig({ agentArgs: '', launchToken }) + + expect( + shouldSuppressCodexAutoApprovalStatus( { state: 'waiting', prompt: 'implement notifications', agentType: 'codex' }, - paneKey + { paneKey, tabId: 'tab-1', launchToken } ) ).toBe(false) }) + + it('preserves mixed Codex permission attention', () => { + registerCodexLaunchConfig({ agentArgs: '--ask-for-approval on-request', launchToken }) + + expect( + shouldSuppressCodexAutoApprovalStatus( + { state: 'waiting', prompt: 'implement notifications', agentType: 'codex' }, + { paneKey, tabId: 'tab-1', launchToken } + ) + ).toBe(false) + }) + + it('preserves missing-attribution Codex permission attention', () => { + expect( + shouldSuppressCodexAutoApprovalStatus( + { state: 'waiting', prompt: 'implement notifications', agentType: 'codex' }, + { paneKey, tabId: 'tab-1', launchToken } + ) + ).toBe(false) + }) + + it('fails open when a stale yolo launch token does not match', () => { + registerCodexLaunchConfig({ + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + launchToken + }) + + expect( + shouldSuppressCodexAutoApprovalStatus( + { state: 'waiting', prompt: 'manual prompt', agentType: 'codex' }, + { paneKey, tabId: 'tab-1', launchToken: 'manual-launch' } + ) + ).toBe(false) + }) + + it('fails open when a stale launch token conflicts with matching provider session', () => { + registerCodexLaunchConfig({ + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + launchToken, + providerSession + }) + + expect( + shouldSuppressCodexAutoApprovalStatus( + { state: 'waiting', prompt: 'manual prompt', agentType: 'codex' }, + { + paneKey, + tabId: 'tab-1', + launchToken: 'manual-launch', + providerSession + } + ) + ).toBe(false) + }) + + it('matches provider session attribution when launch token is absent', () => { + registerCodexLaunchConfig({ + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + providerSession + }) + + expect( + shouldSuppressCodexAutoApprovalStatus( + { state: 'waiting', prompt: 'implement notifications', agentType: 'codex' }, + { paneKey, tabId: 'tab-1', providerSession } + ) + ).toBe(true) + }) + + it('does not suppress non-Codex or done statuses', () => { + registerCodexLaunchConfig({ + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + launchToken + }) + + expect( + shouldSuppressCodexAutoApprovalStatus( + { state: 'waiting', prompt: 'implement notifications', agentType: 'claude' }, + { paneKey, tabId: 'tab-1', launchToken } + ) + ).toBe(false) + expect( + shouldSuppressCodexAutoApprovalStatus( + { state: 'done', prompt: 'implement notifications', agentType: 'codex' }, + { paneKey, tabId: 'tab-1', launchToken } + ) + ).toBe(false) + }) + + it('uses the same predicate for hook-completion fallback suppression', () => { + registerCodexLaunchConfig({ + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + launchToken + }) + const suppressor = createCodexAutoApprovalHookCompletionSuppressor(paneKey, () => ({ + tabId: 'tab-1', + launchToken + })) + + expect( + suppressor({ state: 'waiting', prompt: 'implement notifications', agentType: 'codex' }) + ).toBe(true) + }) + + it('suppresses synthetic Codex permission titles only when launch attribution is yolo', () => { + registerCodexLaunchConfig({ + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + launchToken + }) + + expect( + shouldSuppressCodexAutoApprovalSyntheticTitle('Codex - action required', { + paneKey, + tabId: 'tab-1', + launchToken + }) + ).toBe(true) + expect( + shouldSuppressCodexAutoApprovalSyntheticTitle('Codex ready', { + paneKey, + tabId: 'tab-1', + launchToken + }) + ).toBe(false) + }) }) diff --git a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts index 11244bc20f0..dadede174df 100644 --- a/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts +++ b/src/renderer/src/components/terminal-pane/codex-auto-approval-notification-suppression.ts @@ -1,40 +1,82 @@ -import { YOLO_TUI_AGENT_ARGS } from '../../../../shared/tui-agent-permissions' +import type { AgentProviderSessionMetadata } from '../../../../shared/agent-session-resume' +import { getSyntheticAgentTitleProfile } from '../../../../shared/synthetic-agent-title' +import { resolveTuiAgentPermissionMode } from '../../../../shared/tui-agent-permissions' import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinator-types' import { useAppStore } from '@/store' const CODEX_AUTO_APPROVED_PERMISSION_STATES = ['waiting', 'blocked'] as const -const CODEX_AUTO_APPROVED_ARGS = (YOLO_TUI_AGENT_ARGS.codex ?? '').trim() + +export type CodexAutoApprovalStatusContext = { + paneKey: string + tabId?: string + terminalHandle?: string + launchToken?: string + providerSession?: AgentProviderSessionMetadata + existingProviderSession?: AgentProviderSessionMetadata +} function isCodexAutoApprovedPermissionState( state: AgentCompletionStatusSnapshot['state'] ): state is (typeof CODEX_AUTO_APPROVED_PERMISSION_STATES)[number] { - return state === 'waiting' || state === 'blocked' + return CODEX_AUTO_APPROVED_PERMISSION_STATES.some((permissionState) => permissionState === state) } -export function isAutoApprovedCodexPermissionStatus( +export function shouldSuppressCodexAutoApprovalStatus( payload: AgentCompletionStatusSnapshot, - paneKey: string + context: CodexAutoApprovalStatusContext ): boolean { if (payload.agentType !== 'codex' || !isCodexAutoApprovedPermissionState(payload.state)) { return false } const state = useAppStore.getState() - const statusEntry = state.agentStatusByPaneKey[paneKey] - if (!statusEntry) { + if (typeof state.getAgentLaunchConfigForStatusMetadata !== 'function') { return false } - const launchConfig = state.getAgentLaunchConfigForStatusEntry(statusEntry) + const launchConfig = state.getAgentLaunchConfigForStatusMetadata({ + paneKey: context.paneKey, + agentType: 'codex', + tabId: context.tabId, + terminalHandle: context.terminalHandle, + launchToken: context.launchToken, + providerSession: context.providerSession, + existingProviderSession: context.existingProviderSession + }) if (!launchConfig) { return false } - return launchConfig.agentArgs.trim() === CODEX_AUTO_APPROVED_ARGS + return ( + resolveTuiAgentPermissionMode({ + agent: 'codex', + agentArgs: launchConfig.agentArgs, + agentEnv: launchConfig.agentEnv + }) === 'yolo' + ) +} + +export function shouldSuppressCodexAutoApprovalSyntheticTitle( + title: string, + context: CodexAutoApprovalStatusContext +): boolean { + if (title !== getSyntheticAgentTitleProfile('codex')?.permissionLabel) { + return false + } + + return shouldSuppressCodexAutoApprovalStatus( + { state: 'waiting', prompt: '', agentType: 'codex' }, + context + ) } export function createCodexAutoApprovalHookCompletionSuppressor( - paneKey: string + paneKey: string, + getContext?: () => Omit ): (payload: AgentCompletionStatusSnapshot) => boolean { - return (payload) => isAutoApprovedCodexPermissionStatus(payload, paneKey) + return (payload) => + shouldSuppressCodexAutoApprovalStatus(payload, { + paneKey, + ...getContext?.() + }) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index da2705e5288..e949701a065 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -11,6 +11,7 @@ import type * as UseNotificationDispatchModule from './use-notification-dispatch import { getEagerPtyBufferHandle } from './pty-dispatcher' import { makePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalLayoutSnapshot } from '../../../../shared/types' +import { YOLO_TUI_AGENT_ARGS } from '../../../../shared/tui-agent-permissions' // Repro command: // pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts -t "OpenTUI-style small ANSI redraw" @@ -114,6 +115,7 @@ type StoreState = { sleepingAgentSessionsByPaneKey: Record agentLaunchConfigByPaneKey: Record getAgentLaunchConfigForStatusEntry: ReturnType + getAgentLaunchConfigForStatusMetadata: ReturnType clearSleepingAgentSession: ReturnType registerAgentLaunchConfig: ReturnType clearAgentLaunchConfig: ReturnType @@ -519,6 +521,13 @@ describe('connectPanePty', () => { getAgentLaunchConfigForStatusEntry: vi.fn((entry: { paneKey: string }) => { return mockStoreState.agentLaunchConfigByPaneKey[entry.paneKey]?.launchConfig }), + getAgentLaunchConfigForStatusMetadata: vi.fn( + (metadata: { paneKey: string; launchToken?: string }) => { + return metadata.launchToken + ? mockStoreState.agentLaunchConfigByPaneKey[metadata.paneKey]?.launchConfig + : undefined + } + ), clearSleepingAgentSession: vi.fn((paneKey: string) => { delete mockStoreState.sleepingAgentSessionsByPaneKey[paneKey] }), @@ -4228,6 +4237,7 @@ describe('connectPanePty', () => { } }, getAgentLaunchConfigForStatusEntry: vi.fn(() => undefined), + getAgentLaunchConfigForStatusMetadata: vi.fn(() => undefined), sleepingAgentSessionsByPaneKey: { [paneKey]: { paneKey, @@ -7903,6 +7913,177 @@ describe('connectPanePty', () => { ) }) + it('suppresses PTY-owned Codex auto-approved permission statuses before status or notification work', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-hook') + transportFactoryQueue.push(transport) + enableActiveRuntimeEnvironment() + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + agentEnv: {} + } + } + + const launchConfig = { + agentCommand: 'codex', + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + agentEnv: {} + } + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + startup: { + command: 'codex', + launchConfig, + launchToken: 'launch-yolo', + launchAgent: 'codex' + } + }) + + connectPanePty(pane as never, manager as never, deps as never) + + const statusHandler = createdTransportOptions[0]?.onAgentStatus as + | ((payload: { + state: 'waiting' + prompt: string + agentType: 'codex' + toolName: string + toolInput: string + }) => void) + | undefined + if (!statusHandler) { + throw new Error('Expected onAgentStatus to be registered') + } + + statusHandler({ + state: 'waiting', + prompt: 'auto-approved permission', + agentType: 'codex', + toolName: 'exec_command', + toolInput: 'git status' + }) + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + expect(deps.dispatchNotification).not.toHaveBeenCalled() + }) + + it('suppresses synthetic Codex auto-approved permission titles before title work', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-hook') + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + agentEnv: {} + } + } + + const pane = createPane(1) + const manager = createManager(1) + manager.getActivePane.mockReturnValue({ id: 1 }) + const deps = createDeps({ + startup: { + command: 'codex', + launchConfig: { + agentCommand: 'codex', + agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', + agentEnv: {} + }, + launchToken: 'launch-yolo', + launchAgent: 'codex' + } + }) + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + mockStoreState.getAgentLaunchConfigForStatusMetadata.mockClear() + + titleHandler('Codex - action required', 'Codex - action required') + + expect(mockStoreState.getAgentLaunchConfigForStatusMetadata).toHaveBeenCalledTimes(1) + expect(deps.setRuntimePaneTitle).not.toHaveBeenCalled() + expect(deps.updateTabTitle).not.toHaveBeenCalled() + expect(manager.setPaneGpuRendering).not.toHaveBeenCalled() + }) + + it('does not resolve launch config for ordinary title changes', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-hook') + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + mockStoreState.getAgentLaunchConfigForStatusMetadata.mockClear() + + for (let index = 0; index < 100; index += 1) { + titleHandler(`build output ${index}`, `build output ${index}`) + } + + expect(mockStoreState.getAgentLaunchConfigForStatusMetadata).not.toHaveBeenCalled() + }) + + it('preserves synthetic Codex manual permission titles', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-hook') + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentLaunchConfigByPaneKey[paneKey] = { + launchConfig: { + agentArgs: '', + agentEnv: {} + } + } + + const pane = createPane(1) + const manager = createManager(1) + manager.getActivePane.mockReturnValue({ id: 1 }) + const deps = createDeps({ + startup: { + command: 'codex', + launchConfig: { + agentCommand: 'codex', + agentArgs: '', + agentEnv: {} + }, + launchToken: 'launch-manual', + launchAgent: 'codex' + } + }) + + connectPanePty(pane as never, manager as never, deps as never) + + const titleHandler = createdTransportOptions[0]?.onTitleChange as + | ((title: string, rawTitle: string) => void) + | undefined + if (!titleHandler) { + throw new Error('Expected onTitleChange to be registered') + } + + titleHandler('Codex - action required', 'Codex - action required') + + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Codex - action required') + expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Codex - action required') + }) + it('resolves synthetic terminal titles for remote hook status updates', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-devin') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 504d6b472bd..72b50c4a498 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -76,7 +76,11 @@ import { type AgentInterruptInputIntent } from '../../../../shared/agent-interrupt-intent' import { createAgentCompletionCoordinator } from './agent-completion-coordinator' -import { createCodexAutoApprovalHookCompletionSuppressor } from './codex-auto-approval-notification-suppression' +import { + createCodexAutoApprovalHookCompletionSuppressor, + shouldSuppressCodexAutoApprovalSyntheticTitle, + shouldSuppressCodexAutoApprovalStatus +} from './codex-auto-approval-notification-suppression' import type { AgentCompletionStatusSnapshot } from './agent-completion-coordinator-types' import { markTerminalBracketedPasteInterrupted, @@ -1284,7 +1288,10 @@ export function connectPanePty( } return (useAppStore.getState().ptyIdsByTabId[deps.tabId] ?? []).length > 0 }, - shouldSuppressHookCompletion: createCodexAutoApprovalHookCompletionSuppressor(cacheKey) + shouldSuppressHookCompletion: createCodexAutoApprovalHookCompletionSuppressor(cacheKey, () => ({ + tabId: deps.tabId, + ...(launchToken ? { launchToken } : {}) + })) }) const onExit = (ptyId: string): void => { @@ -1340,6 +1347,15 @@ export function connectPanePty( let allowInitialIdleCacheSeed = false const onTitleChange = (title: string, rawTitle: string): void => { + if ( + shouldSuppressCodexAutoApprovalSyntheticTitle(title, { + paneKey: cacheKey, + tabId: deps.tabId, + ...(launchToken ? { launchToken } : {}) + }) + ) { + return + } manager.setPaneGpuRendering(pane.id, !isGeminiTerminalTitle(rawTitle)) deps.setRuntimePaneTitle(deps.tabId, pane.id, title) if (syncAgentTaskCompleteTrackingEnabled()) { @@ -1832,6 +1848,15 @@ export function connectPanePty( ...(shouldOwnAgentStatusInRenderer ? { onAgentStatus: (payload) => { + if ( + shouldSuppressCodexAutoApprovalStatus(payload, { + paneKey: cacheKey, + tabId: deps.tabId, + ...(launchToken ? { launchToken } : {}) + }) + ) { + return + } // Why: capture the store snapshot once so the title lookup and the // setAgentStatus call observe the same state. Re-reading getState() // between the two lines opens a brief window where the title could diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts index f2080df3fac..8919ee71ffc 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.test.ts @@ -46,6 +46,10 @@ type MockStoreState = { getAgentLaunchConfigForStatusEntry: (entry: { paneKey: string }) => { agentArgs: string; agentEnv: Record } | undefined + getAgentLaunchConfigForStatusMetadata: (metadata: { + paneKey: string + launchToken?: string + }) => { agentArgs: string; agentEnv: Record } | undefined } let mockStoreState: MockStoreState @@ -114,7 +118,11 @@ describe('agent hook completion notifications', () => { agentLaunchConfigByPaneKey: {}, agentStatusByPaneKey: {}, getAgentLaunchConfigForStatusEntry: (entry) => - mockStoreState.agentLaunchConfigByPaneKey[entry.paneKey]?.launchConfig + mockStoreState.agentLaunchConfigByPaneKey[entry.paneKey]?.launchConfig, + getAgentLaunchConfigForStatusMetadata: (metadata) => + metadata.launchToken + ? mockStoreState.agentLaunchConfigByPaneKey[metadata.paneKey]?.launchConfig + : undefined } }) @@ -458,7 +466,7 @@ describe('agent hook completion notifications', () => { expect(dispatchTerminalNotification).not.toHaveBeenCalled() }) - it('does not notify for Codex auto-approved permission requests', async () => { + it('fails open for Codex auto-approved permission requests without launch proof', async () => { seedCodexPaneLaunchConfig(paneKey, YOLO_TUI_AGENT_ARGS.codex ?? '') const { observeAgentHookCompletionForNotification } = await import('./agent-hook-completion-notifications') @@ -480,7 +488,7 @@ describe('agent hook completion notifications', () => { } }) - expect(dispatchTerminalNotification).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) }) it('still notifies for manual Codex permission requests', async () => { @@ -516,7 +524,7 @@ describe('agent hook completion notifications', () => { ) }) - it('does not notify for Codex auto-approved blocked permission requests', async () => { + it('fails open for Codex auto-approved blocked permission requests without launch proof', async () => { seedCodexPaneLaunchConfig(paneKey, YOLO_TUI_AGENT_ARGS.codex ?? '') const { observeAgentHookCompletionForNotification } = await import('./agent-hook-completion-notifications') @@ -538,7 +546,7 @@ describe('agent hook completion notifications', () => { } }) - expect(dispatchTerminalNotification).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) }) it('suppresses an internal milestone completion when hook work resumes before quiet', async () => { diff --git a/src/renderer/src/hooks/useIpcEvents.test.ts b/src/renderer/src/hooks/useIpcEvents.test.ts index a455cc712be..9f0e47099fb 100644 --- a/src/renderer/src/hooks/useIpcEvents.test.ts +++ b/src/renderer/src/hooks/useIpcEvents.test.ts @@ -13,6 +13,7 @@ import { import type { SleepingAgentLaunchConfig } from '../../../shared/agent-session-resume' import type { TuiAgent } from '../../../shared/types' import { makePaneKey } from '../../../shared/stable-pane-id' +import { YOLO_TUI_AGENT_ARGS } from '../../../shared/tui-agent-permissions' const { closeTerminalTabMock } = vi.hoisted(() => ({ closeTerminalTabMock: vi.fn() @@ -3563,6 +3564,8 @@ describe('useIpcEvents agent status snapshot integration', () => { lastAssistantMessage?: string interrupted?: boolean terminalHandle?: string + launchToken?: string + providerSession?: { key: 'session_id'; id: string } orchestration?: { taskId?: string dispatchId?: string @@ -3630,6 +3633,7 @@ describe('useIpcEvents agent status snapshot integration', () => { runtimePaneTitlesByTabId: {}, terminalLayoutsByTabId: {}, agentStatusByPaneKey: {}, + getAgentLaunchConfigForStatusMetadata: vi.fn(() => undefined), recentlyClosedAgentStatusTabIds: {}, repos: [], worktreesByRepo: {}, @@ -4170,6 +4174,253 @@ describe('useIpcEvents agent status snapshot integration', () => { ) }) + it('suppresses auto-approved Codex permission attention before status and title mutation', async () => { + const setAgentStatus = vi.fn() + const updateTabTitle = vi.fn() + const observeAgentHookCompletionForNotification = vi.fn() + const getAgentLaunchConfigForStatusMetadata = vi.fn((metadata: { launchToken?: string }) => + metadata.launchToken === 'launch-yolo' + ? { agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', agentEnv: {} } + : undefined + ) + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + updateTabTitle, + getAgentLaunchConfigForStatusMetadata, + workspaceSessionReady: true, + settings: { terminalFontSize: 13, notifications: { enabled: true, agentTaskComplete: true } }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Codex' }] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + } + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + vi.doMock('./agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification, + resetAgentHookCompletionNotificationCoordinators: vi.fn(), + syncAgentHookCompletionNotificationSettings: vi.fn() + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + await Promise.resolve() + + if (typeof onSetListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + + onSetListenerRef.current({ + paneKey: FUTURE_PANE_KEY, + tabId: 'tab-future', + worktreeId: 'wt-1', + state: 'waiting', + prompt: 'auto-approved permission', + agentType: 'codex', + launchToken: 'launch-yolo', + receivedAt: 1_700_000_000_300, + stateStartedAt: 1_699_999_999_300 + }) + + expect(getAgentLaunchConfigForStatusMetadata).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: FUTURE_PANE_KEY, launchToken: 'launch-yolo' }) + ) + expect(setAgentStatus).not.toHaveBeenCalled() + expect(updateTabTitle).not.toHaveBeenCalled() + expect(observeAgentHookCompletionForNotification).not.toHaveBeenCalled() + }) + + it('keeps manual or missing-attribution Codex permission attention actionable', async () => { + const setAgentStatus = vi.fn() + const updateTabTitle = vi.fn() + const observeAgentHookCompletionForNotification = vi.fn() + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + updateTabTitle, + workspaceSessionReady: true, + settings: { terminalFontSize: 13, notifications: { enabled: true, agentTaskComplete: true } }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Codex' }] + }, + terminalLayoutsByTabId: { + 'tab-future': { + root: { type: 'leaf', leafId: FUTURE_LEAF_ID }, + activeLeafId: FUTURE_LEAF_ID, + expandedLeafId: null + } + } + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + vi.doMock('./agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification, + resetAgentHookCompletionNotificationCoordinators: vi.fn(), + syncAgentHookCompletionNotificationSettings: vi.fn() + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + await Promise.resolve() + + if (typeof onSetListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + + onSetListenerRef.current({ + paneKey: FUTURE_PANE_KEY, + tabId: 'tab-future', + worktreeId: 'wt-1', + state: 'waiting', + prompt: 'manual permission', + agentType: 'codex', + receivedAt: 1_700_000_000_400, + stateStartedAt: 1_699_999_999_400 + }) + + expect(setAgentStatus).toHaveBeenCalledTimes(1) + expect(setAgentStatus).toHaveBeenCalledWith( + FUTURE_PANE_KEY, + expect.objectContaining({ + state: 'waiting', + prompt: 'manual permission', + agentType: 'codex' + }), + 'Codex - action required', + { updatedAt: 1_700_000_000_400, stateStartedAt: 1_699_999_999_400 }, + expectWorktreeRouting('wt-1'), + undefined + ) + expect(updateTabTitle).toHaveBeenCalledWith('tab-future', 'Codex - action required') + expect(observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(1) + }) + + it('keeps auto-approved Codex done statuses on the completion path', async () => { + const setAgentStatus = vi.fn() + const observeAgentHookCompletionForNotification = vi.fn() + const getAgentLaunchConfigForStatusMetadata = vi.fn((metadata: { launchToken?: string }) => + metadata.launchToken === 'launch-yolo' + ? { agentArgs: YOLO_TUI_AGENT_ARGS.codex ?? '', agentEnv: {} } + : undefined + ) + const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { + current: null + } + + const storeState: StoreLike = buildStoreState({ + setAgentStatus, + getAgentLaunchConfigForStatusMetadata, + workspaceSessionReady: true, + settings: { terminalFontSize: 13, notifications: { enabled: true, agentTaskComplete: true } }, + tabsByWorktree: { + 'wt-1': [{ id: 'tab-future', ptyId: 'pty-1', worktreeId: 'wt-1', title: 'Codex' }] + }, + terminalLayoutsByTabId: {} + }) + + stubReactSyncEffect() + vi.doMock('../store', () => ({ + useAppStore: { + subscribe: vi.fn(() => () => {}), + getState: () => storeState + } + })) + vi.doMock('./agent-hook-completion-notifications', () => ({ + observeAgentHookCompletionForNotification, + resetAgentHookCompletionNotificationCoordinators: vi.fn(), + syncAgentHookCompletionNotificationSettings: vi.fn() + })) + stubAuxiliaryModules() + vi.stubGlobal( + 'window', + buildWindowApi({ + onSet: (cb) => { + onSetListenerRef.current = cb + return () => {} + } + }) + ) + + const { useIpcEvents } = await import('./useIpcEvents') + + useIpcEvents() + await Promise.resolve() + + if (typeof onSetListenerRef.current !== 'function') { + throw new Error('Expected agentStatus.onSet listener to be registered') + } + + onSetListenerRef.current({ + paneKey: FUTURE_PANE_KEY, + tabId: 'tab-future', + worktreeId: 'wt-1', + state: 'done', + prompt: 'auto-approved task', + agentType: 'codex', + launchToken: 'launch-yolo', + lastAssistantMessage: 'Done.', + receivedAt: 1_700_000_000_500, + stateStartedAt: 1_699_999_999_500 + }) + + expect(setAgentStatus).toHaveBeenCalledTimes(1) + expect(observeAgentHookCompletionForNotification).toHaveBeenCalledTimes(1) + expect(observeAgentHookCompletionForNotification).toHaveBeenCalledWith( + expect.objectContaining({ + paneKey: FUTURE_PANE_KEY, + worktreeId: 'wt-1', + payload: expect.objectContaining({ state: 'done', agentType: 'codex' }) + }) + ) + }) + it('drops late push events for a recently closed terminal tab', async () => { const setAgentStatus = vi.fn() const onSetListenerRef: { current: ((data: AgentStatusSetData) => void) | null } = { diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index d19cea07d4e..4b991045403 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -112,6 +112,7 @@ import { resetAgentHookCompletionNotificationCoordinators, syncAgentHookCompletionNotificationSettings } from './agent-hook-completion-notifications' +import { shouldSuppressCodexAutoApprovalStatus } from '@/components/terminal-pane/codex-auto-approval-notification-suppression' import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' import { resolveAgentStatusTerminalTitle } from '@/lib/agent-status-terminal-title' import { titleHasAgentName } from '../../../shared/agent-detection' @@ -2880,6 +2881,20 @@ export function useIpcEvents(): void { // Keep the defensive store guard and completion notification path in sync. return 'dropped' } + if ( + shouldSuppressCodexAutoApprovalStatus(statusPayload, { + paneKey: data.paneKey, + tabId: data.tabId, + terminalHandle: data.terminalHandle, + launchToken: data.launchToken, + providerSession: data.providerSession, + existingProviderSession: existingStatus?.providerSession + }) + ) { + // Why: Codex yolo permission hooks are not user-actionable, and must + // not drive status, synthetic titles, unread badges, or notifications. + return 'dropped' + } const terminalTitle = resolveAgentStatusTerminalTitle(statusPayload, title) const statusWorktreeId = data.worktreeId ?? owningWorktreeId store.setAgentStatus( diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index df638d3f144..65a7d4e24a1 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -66,6 +66,17 @@ type AgentLaunchConfigRegistrationMetadata = { providerSession?: AgentProviderSessionMetadata } +type AgentLaunchConfigStatusMetadata = { + paneKey: string + agentType?: AgentType + tabId?: string + terminalHandle?: string + launchToken?: string + providerSession?: AgentProviderSessionMetadata + existingProviderSession?: AgentProviderSessionMetadata + providerSessionChanged?: boolean +} + type AgentLaunchConfigRegistryEntry = { launchConfig: SleepingAgentLaunchConfig registeredAt: number @@ -130,6 +141,9 @@ export type AgentStatusSlice = { getAgentLaunchConfigForStatusEntry: ( entry: AgentStatusEntry ) => SleepingAgentLaunchConfig | undefined + getAgentLaunchConfigForStatusMetadata: ( + metadata: AgentLaunchConfigStatusMetadata + ) => SleepingAgentLaunchConfig | undefined clearAgentLaunchConfig: (paneKey: string) => void setRuntimeAgentOrchestrationByPaneKey: ( @@ -640,6 +654,15 @@ function registryEntryMatchesStatus(args: { ) { return false } + if ( + identity.launchToken !== undefined && + args.launchToken !== undefined && + identity.launchToken !== args.launchToken + ) { + // Why: an explicit mismatched launch token is stale launch proof even if a + // provider session id was reused by a later manual/mixed Codex run. + return false + } if (identity.providerSession !== undefined) { return providerSessionsEqual(identity.providerSession, args.providerSession) } @@ -691,6 +714,26 @@ function getLaunchConfigForEntry( : undefined } +function getLaunchConfigForStatusMetadata( + state: AppState, + metadata: AgentLaunchConfigStatusMetadata +): SleepingAgentLaunchConfig | undefined { + const registryEntry = state.agentLaunchConfigByPaneKey[metadata.paneKey] + return registryEntryMatchesStatus({ + entry: registryEntry, + paneKey: metadata.paneKey, + agentType: metadata.agentType, + tabId: metadata.tabId ?? getTabIdFromPaneKey(metadata.paneKey) ?? undefined, + terminalHandle: metadata.terminalHandle, + launchToken: metadata.launchToken, + providerSession: metadata.providerSession, + existingProviderSession: metadata.existingProviderSession, + providerSessionChanged: metadata.providerSessionChanged ?? false + }) + ? registryEntry?.launchConfig + : undefined +} + function pruneMigrationUnsupportedEntries( entries: Record, predicate: (entry: MigrationUnsupportedPtyEntry) => boolean @@ -916,6 +959,8 @@ export const createAgentStatusSlice: StateCreator getLaunchConfigForEntry(get(), entry), + getAgentLaunchConfigForStatusMetadata: (metadata) => + getLaunchConfigForStatusMetadata(get(), metadata), clearAgentLaunchConfig: (paneKey) => { set((s) => { diff --git a/src/shared/tui-agent-permissions.test.ts b/src/shared/tui-agent-permissions.test.ts index 7ed1ff440c3..e1fa10e1993 100644 --- a/src/shared/tui-agent-permissions.test.ts +++ b/src/shared/tui-agent-permissions.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { applyAgentPermissionMode, resolveAgentPermissionModeSummary, + resolveTuiAgentPermissionMode, YOLO_TUI_AGENT_ARGS, YOLO_TUI_AGENT_ENV } from './tui-agent-permissions' @@ -48,4 +49,40 @@ describe('tui agent permissions', () => { }) ).toBe('mixed') }) + + it('resolves one Codex yolo launch as yolo', () => { + expect( + resolveTuiAgentPermissionMode({ + agent: 'codex', + agentArgs: YOLO_TUI_AGENT_ARGS.codex, + agentEnv: {} + }) + ).toBe('yolo') + }) + + it('resolves one empty Codex launch as manual', () => { + expect(resolveTuiAgentPermissionMode({ agent: 'codex', agentArgs: '', agentEnv: {} })).toBe( + 'manual' + ) + }) + + it('resolves custom Codex permission arguments as mixed', () => { + expect( + resolveTuiAgentPermissionMode({ + agent: 'codex', + agentArgs: '--ask-for-approval on-request', + agentEnv: {} + }) + ).toBe('mixed') + }) + + it('resolves env-driven yolo launches', () => { + expect( + resolveTuiAgentPermissionMode({ + agent: 'goose', + agentArgs: '', + agentEnv: YOLO_TUI_AGENT_ENV.goose + }) + ).toBe('yolo') + }) }) diff --git a/src/shared/tui-agent-permissions.ts b/src/shared/tui-agent-permissions.ts index 09b2902bbd8..9833bfca193 100644 --- a/src/shared/tui-agent-permissions.ts +++ b/src/shared/tui-agent-permissions.ts @@ -71,37 +71,18 @@ function resolveAgentEnvPermissionMode( return sameEnv(env, yoloEnv) ? 'yolo' : 'mixed' } -export function resolveAgentPermissionModeSummary(args: { - agentDefaultArgs?: Partial> | null - agentDefaultEnv?: Partial>> | null -}): AgentPermissionMode { +function combinePermissionModes(modes: AgentPermissionMode[]): AgentPermissionMode { let sawYolo = false let sawManual = false let sawMixed = false - for (const agent of PERMISSION_AGENT_IDS) { - const modes: AgentPermissionMode[] = [] - if (agent in YOLO_TUI_AGENT_ARGS) { - modes.push( - resolveAgentPermissionMode( - normalizeArgs(args.agentDefaultArgs?.[agent]), - YOLO_TUI_AGENT_ARGS[agent] ?? '' - ) - ) - } - if (agent in YOLO_TUI_AGENT_ENV) { - modes.push( - resolveAgentEnvPermissionMode(args.agentDefaultEnv?.[agent], YOLO_TUI_AGENT_ENV[agent]) - ) - } - for (const mode of modes) { - if (mode === 'yolo') { - sawYolo = true - } else if (mode === 'manual') { - sawManual = true - } else { - sawMixed = true - } + for (const mode of modes) { + if (mode === 'yolo') { + sawYolo = true + } else if (mode === 'manual') { + sawManual = true + } else { + sawMixed = true } } @@ -111,6 +92,46 @@ export function resolveAgentPermissionModeSummary(args: { return sawYolo ? 'yolo' : 'manual' } +export function resolveTuiAgentPermissionMode(args: { + agent: TuiAgent + agentArgs?: string | null + agentEnv?: Record | null +}): AgentPermissionMode { + const modes: AgentPermissionMode[] = [] + if (args.agent in YOLO_TUI_AGENT_ARGS) { + modes.push( + resolveAgentPermissionMode( + normalizeArgs(args.agentArgs), + YOLO_TUI_AGENT_ARGS[args.agent] ?? '' + ) + ) + } + if (args.agent in YOLO_TUI_AGENT_ENV) { + modes.push(resolveAgentEnvPermissionMode(args.agentEnv, YOLO_TUI_AGENT_ENV[args.agent])) + } + + return combinePermissionModes(modes) +} + +export function resolveAgentPermissionModeSummary(args: { + agentDefaultArgs?: Partial> | null + agentDefaultEnv?: Partial>> | null +}): AgentPermissionMode { + const modes: AgentPermissionMode[] = [] + + for (const agent of PERMISSION_AGENT_IDS) { + modes.push( + resolveTuiAgentPermissionMode({ + agent, + agentArgs: args.agentDefaultArgs?.[agent], + agentEnv: args.agentDefaultEnv?.[agent] + }) + ) + } + + return combinePermissionModes(modes) +} + export function applyAgentPermissionMode(args: { mode: Exclude agentDefaultArgs?: Partial> | null