diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index 67e17dcbc..59bec670f 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -16,7 +16,10 @@ import { isMcpToolReadOnly } from "@posthog/agent"; import { hydrateSessionJsonl } from "@posthog/agent/adapters/claude/session/jsonl-hydration"; import { getEffortOptions } from "@posthog/agent/adapters/claude/session/models"; import { Agent } from "@posthog/agent/agent"; -import { getAvailableModes } from "@posthog/agent/execution-mode"; +import { + getAvailableCodexModes, + getAvailableModes, +} from "@posthog/agent/execution-mode"; import { DEFAULT_CODEX_MODEL, DEFAULT_GATEWAY_MODEL, @@ -1635,18 +1638,21 @@ For git operations while detached: }); } - const modeOptions = getAvailableModes().map((mode) => ({ + const modes = + adapter === "codex" ? getAvailableCodexModes() : getAvailableModes(); + const modeOptions = modes.map((mode) => ({ value: mode.id, name: mode.name, description: mode.description ?? undefined, })); + const defaultMode = adapter === "codex" ? "auto" : "plan"; const configOptions: SessionConfigOption[] = [ { id: "mode", name: "Approval Preset", type: "select", - currentValue: "plan", + currentValue: defaultMode, options: modeOptions, category: "mode", description: diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index 0c055e51f..1daab1d0f 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -196,8 +196,11 @@ export function TaskInput({ // Defaults ensure values are always passed even before the preview config loads. const currentModel = modelOption?.type === "select" ? modelOption.currentValue : undefined; + const adapterDefault = adapter === "codex" ? "auto" : "plan"; const modeFallback = - defaultInitialTaskMode === "last_used" ? lastUsedInitialTaskMode : "plan"; + defaultInitialTaskMode === "last_used" + ? (lastUsedInitialTaskMode ?? adapterDefault) + : adapterDefault; const currentExecutionMode = getCurrentModeFromConfigOptions(modeOption ? [modeOption] : undefined) ?? modeFallback; diff --git a/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts b/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts index 4687b214a..8e206e314 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts +++ b/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts @@ -61,10 +61,40 @@ export function usePreviewConfig( const { defaultInitialTaskMode, lastUsedInitialTaskMode } = useSettingsStore.getState(); - const initialMode = - defaultInitialTaskMode === "last_used" - ? lastUsedInitialTaskMode - : "plan"; + + // Use the mode option's existing currentValue (set by the server + // based on the adapter) when the user hasn't chosen a preference, + // or when their last-used mode doesn't match the current adapter's + // available modes. + const modeOpt = options.find((o) => o.id === "mode"); + const serverDefault = modeOpt?.currentValue; + const availableValues: string[] = + modeOpt?.type === "select" + ? ( + modeOpt.options as Array<{ + value?: string; + options?: Array<{ value: string }>; + }> + ).flatMap((o) => + o.options + ? o.options.map((go) => go.value) + : o.value + ? [o.value] + : [], + ) + : []; + + let initialMode: string; + if ( + defaultInitialTaskMode === "last_used" && + lastUsedInitialTaskMode && + availableValues.includes(lastUsedInitialTaskMode) + ) { + initialMode = lastUsedInitialTaskMode; + } else { + initialMode = + typeof serverDefault === "string" ? serverDefault : "plan"; + } const withMode = options.map((opt) => opt.id === "mode" diff --git a/apps/code/src/shared/types.ts b/apps/code/src/shared/types.ts index 87a9f3619..eb1aeb3a2 100644 --- a/apps/code/src/shared/types.ts +++ b/apps/code/src/shared/types.ts @@ -7,6 +7,9 @@ export const executionModeSchema = z.enum([ "acceptEdits", "plan", "bypassPermissions", + "auto", + "read-only", + "full-access", ]); export type ExecutionMode = z.infer; diff --git a/packages/agent/src/adapters/codex/codex-agent.ts b/packages/agent/src/adapters/codex/codex-agent.ts index 5d02be28e..6ce65adce 100644 --- a/packages/agent/src/adapters/codex/codex-agent.ts +++ b/packages/agent/src/adapters/codex/codex-agent.ts @@ -36,6 +36,11 @@ import { } from "@agentclientprotocol/sdk"; import packageJson from "../../../package.json" with { type: "json" }; import { POSTHOG_NOTIFICATIONS } from "../../acp-extensions"; +import { + CODEX_NATIVE_MODES, + type CodexNativeMode, + type PermissionMode, +} from "../../execution-mode"; import type { ProcessSpawnedCallback } from "../../types"; import { Logger } from "../../utils/logger"; import { @@ -80,6 +85,13 @@ type CodexSession = BaseSession & { settingsManager: CodexSettingsManager; }; +function toPermissionMode(mode?: string): PermissionMode { + if (mode && (CODEX_NATIVE_MODES as readonly string[]).includes(mode)) { + return mode as CodexNativeMode; + } + return "auto"; +} + export class CodexAcpAgent extends BaseAcpAgent { readonly adapterName = "codex"; declare session: CodexSession; @@ -125,7 +137,7 @@ export class CodexAcpAgent extends BaseAcpAgent { this.sessionState ?? { sessionId: "", cwd: "", - modeId: "default", + modeId: "auto", configOptions: [], accumulatedUsage: { inputTokens: 0, @@ -133,6 +145,7 @@ export class CodexAcpAgent extends BaseAcpAgent { cachedReadTokens: 0, cachedWriteTokens: 0, }, + permissionMode: "auto", cancelled: false, }, ), @@ -182,6 +195,7 @@ export class CodexAcpAgent extends BaseAcpAgent { taskId: meta?.taskId ?? meta?.persistence?.taskId, modeId: response.modes?.currentModeId ?? "default", modelId: response.models?.currentModelId, + permissionMode: toPermissionMode(meta?.permissionMode), }); this.sessionId = response.sessionId; this.sessionState.configOptions = response.configOptions ?? []; @@ -353,9 +367,16 @@ export class CodexAcpAgent extends BaseAcpAgent { async setSessionMode( params: SetSessionModeRequest, ): Promise { - const response = await this.codexConnection.setSessionMode(params); + const permissionMode = toPermissionMode(params.modeId); + + const response = await this.codexConnection.setSessionMode({ + ...params, + modeId: permissionMode, + }); + if (this.sessionState) { - this.sessionState.modeId = params.modeId; + this.sessionState.modeId = permissionMode; + this.sessionState.permissionMode = permissionMode; } return response ?? {}; } diff --git a/packages/agent/src/adapters/codex/codex-client.ts b/packages/agent/src/adapters/codex/codex-client.ts index 6306cf742..5e92ed75e 100644 --- a/packages/agent/src/adapters/codex/codex-client.ts +++ b/packages/agent/src/adapters/codex/codex-client.ts @@ -23,11 +23,13 @@ import type { TerminalHandle, TerminalOutputRequest, TerminalOutputResponse, + ToolKind, WaitForTerminalExitRequest, WaitForTerminalExitResponse, WriteTextFileRequest, WriteTextFileResponse, } from "@agentclientprotocol/sdk"; +import type { PermissionMode } from "../../execution-mode"; import type { Logger } from "../../utils/logger"; import type { CodexSessionState } from "./session-state"; @@ -36,6 +38,32 @@ export interface CodexClientCallbacks { onUsageUpdate?: (update: Record) => void; } +const AUTO_APPROVED_KINDS: Partial>> = { + auto: new Set(["read", "search", "fetch", "think"]), + "read-only": new Set(["read", "search", "fetch", "think"]), + "full-access": new Set([ + "read", + "edit", + "delete", + "move", + "search", + "execute", + "think", + "fetch", + "switch_mode", + "other", + ]), +}; + +function shouldAutoApprove( + mode: PermissionMode, + kind: ToolKind | null | undefined, +): boolean { + if (mode === "full-access") return true; + if (!kind) return false; + return AUTO_APPROVED_KINDS[mode]?.has(kind) ?? false; +} + /** * Creates an ACP Client that delegates all requests from codex-acp * to the upstream PostHog Code client (via AgentSideConnection). @@ -46,16 +74,31 @@ export function createCodexClient( sessionState: CodexSessionState, callbacks?: CodexClientCallbacks, ): Client { - // Track terminal handles for delegation const terminalHandles = new Map(); return { async requestPermission( params: RequestPermissionRequest, ): Promise { - logger.debug("Relaying permission request to upstream", { - sessionId: params.sessionId, - }); + const kind = params.toolCall?.kind as ToolKind | null | undefined; + + if (shouldAutoApprove(sessionState.permissionMode, kind)) { + logger.debug("Auto-approving permission", { + mode: sessionState.permissionMode, + kind, + toolCallId: params.toolCall?.toolCallId, + }); + const allowOption = params.options?.find( + (o) => o.kind === "allow_once" || o.kind === "allow_always", + ); + return { + outcome: { + outcome: "selected", + optionId: allowOption?.optionId ?? "allow", + }, + }; + } + return upstreamClient.requestPermission(params); }, diff --git a/packages/agent/src/adapters/codex/session-state.ts b/packages/agent/src/adapters/codex/session-state.ts index a1555df23..e4fdebd72 100644 --- a/packages/agent/src/adapters/codex/session-state.ts +++ b/packages/agent/src/adapters/codex/session-state.ts @@ -4,6 +4,7 @@ */ import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import type { PermissionMode } from "../../execution-mode"; export interface CodexUsage { inputTokens: number; @@ -21,6 +22,7 @@ export interface CodexSessionState { accumulatedUsage: CodexUsage; contextSize?: number; contextUsed?: number; + permissionMode: PermissionMode; cancelled: boolean; interruptReason?: string; taskRunId?: string; @@ -35,12 +37,13 @@ export function createSessionState( taskId?: string; modeId?: string; modelId?: string; + permissionMode?: PermissionMode; }, ): CodexSessionState { return { sessionId, cwd, - modeId: opts?.modeId ?? "default", + modeId: opts?.modeId ?? "auto", modelId: opts?.modelId, configOptions: [], accumulatedUsage: { @@ -49,6 +52,7 @@ export function createSessionState( cachedReadTokens: 0, cachedWriteTokens: 0, }, + permissionMode: opts?.permissionMode ?? "auto", cancelled: false, taskRunId: opts?.taskRunId, taskId: opts?.taskId, diff --git a/packages/agent/src/execution-mode.ts b/packages/agent/src/execution-mode.ts index e2468f54a..d3eba723d 100644 --- a/packages/agent/src/execution-mode.ts +++ b/packages/agent/src/execution-mode.ts @@ -1,7 +1,7 @@ import { IS_ROOT } from "./utils/common"; export interface ModeInfo { - id: CodeExecutionMode; + id: string; name: string; description: string; } @@ -57,3 +57,39 @@ export function getAvailableModes(): ModeInfo[] { ? availableModes.filter((m) => m.id !== "bypassPermissions") : availableModes; } + +// --- Codex-native modes --- + +export const CODEX_NATIVE_MODES = ["auto", "read-only", "full-access"] as const; + +export type CodexNativeMode = (typeof CODEX_NATIVE_MODES)[number]; + +/** Union of all permission mode IDs across adapters */ +export type PermissionMode = CodeExecutionMode | CodexNativeMode; + +const codexModes: ModeInfo[] = [ + { + id: "read-only", + name: "Read Only", + description: "Read-only access, no file modifications", + }, + { + id: "auto", + name: "Auto", + description: "Standard behavior, prompts for dangerous operations", + }, +]; + +if (ALLOW_BYPASS) { + codexModes.push({ + id: "full-access", + name: "Full Access", + description: "Auto-accept all permission requests", + }); +} + +export function getAvailableCodexModes(): ModeInfo[] { + return IS_ROOT + ? codexModes.filter((m) => m.id !== "full-access") + : codexModes; +}