diff --git a/docs/adapters/codex-local.md b/docs/adapters/codex-local.md index ff30263b94..927e7cf3b0 100644 --- a/docs/adapters/codex-local.md +++ b/docs/adapters/codex-local.md @@ -20,6 +20,7 @@ The `codex_local` adapter runs OpenAI's Codex CLI locally. It supports session p | `env` | object | No | Environment variables (supports secret refs) | | `timeoutSec` | number | No | Process timeout (0 = no timeout) | | `graceSec` | number | No | Grace period before force-kill | +| `fastMode` | boolean | No | Enables Codex Fast mode. Currently supported on `gpt-5.4` only and burns credits faster | | `dangerouslyBypassApprovalsAndSandbox` | boolean | No | Skip safety checks (dev only) | ## Session Persistence @@ -30,8 +31,22 @@ Codex uses `previous_response_id` for session continuity. The adapter serializes The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten. +## Fast Mode + +When `fastMode` is enabled, Paperclip adds Codex config overrides equivalent to: + +```sh +-c 'service_tier="fast"' -c 'features.fast_mode=true' +``` + +Paperclip currently applies that only when the selected model is `gpt-5.4`. On other models, the toggle is preserved in config but ignored at execution time to avoid unsupported runs. + +## Managed `CODEX_HOME` + When Paperclip is running inside a managed worktree instance (`PAPERCLIP_IN_WORKTREE=true`), the adapter instead uses a worktree-isolated `CODEX_HOME` under the Paperclip instance so Codex skills, sessions, logs, and other runtime state do not leak across checkouts. It seeds that isolated home from the user's main Codex home for shared auth/config continuity. +## Manual Local CLI + For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use: ```sh diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 9d81929f30..534a5a5975 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -372,6 +372,7 @@ export interface CreateConfigValues { chrome: boolean; dangerouslySkipPermissions: boolean; search: boolean; + fastMode: boolean; dangerouslyBypassSandbox: boolean; command: string; args: string; diff --git a/packages/adapters/codex-local/src/index.ts b/packages/adapters/codex-local/src/index.ts index ca795cb518..cbafb2d1d1 100644 --- a/packages/adapters/codex-local/src/index.ts +++ b/packages/adapters/codex-local/src/index.ts @@ -2,6 +2,14 @@ export const type = "codex_local"; export const label = "Codex (local)"; export const DEFAULT_CODEX_LOCAL_MODEL = "gpt-5.3-codex"; export const DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX = true; +export const CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS = ["gpt-5.4"] as const; + +export function isCodexLocalFastModeSupported(model: string | null | undefined): boolean { + const normalizedModel = typeof model === "string" ? model.trim() : ""; + return CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS.includes( + normalizedModel as (typeof CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS)[number], + ); +} export const models = [ { id: "gpt-5.4", label: "gpt-5.4" }, @@ -27,6 +35,7 @@ Core fields: - modelReasoningEffort (string, optional): reasoning effort override (minimal|low|medium|high|xhigh) passed via -c model_reasoning_effort=... - promptTemplate (string, optional): run prompt template - search (boolean, optional): run codex with --search +- fastMode (boolean, optional): enable Codex Fast mode; currently supported on GPT-5.4 only and consumes credits faster - dangerouslyBypassApprovalsAndSandbox (boolean, optional): run with bypass flag - command (string, optional): defaults to "codex" - extraArgs (string[], optional): additional CLI args @@ -45,5 +54,6 @@ Notes: - Paperclip injects desired local skills into the effective CODEX_HOME/skills/ directory at execution time so Codex can discover "$paperclip" and related skills without polluting the project working directory. In managed-home mode (the default) this is ~/.paperclip/instances//companies//codex-home/skills/; when CODEX_HOME is explicitly overridden in adapter config, that override is used instead. - Unless explicitly overridden in adapter config, Paperclip runs Codex with a per-company managed CODEX_HOME under the active Paperclip instance and seeds auth/config from the shared Codex home (the CODEX_HOME env var, when set, or ~/.codex). - Some model/tool combinations reject certain effort levels (for example minimal with web search enabled). +- Fast mode is currently supported on GPT-5.4 only. When enabled, Paperclip applies \`service_tier="fast"\` and \`features.fast_mode=true\`. - When Paperclip realizes a workspace/runtime for a run, it injects PAPERCLIP_WORKSPACE_* and PAPERCLIP_RUNTIME_* env vars for agent-side tooling. `; diff --git a/packages/adapters/codex-local/src/server/codex-args.test.ts b/packages/adapters/codex-local/src/server/codex-args.test.ts new file mode 100644 index 0000000000..c291ae53b7 --- /dev/null +++ b/packages/adapters/codex-local/src/server/codex-args.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { buildCodexExecArgs } from "./codex-args.js"; + +describe("buildCodexExecArgs", () => { + it("enables Codex fast mode overrides for GPT-5.4", () => { + const result = buildCodexExecArgs({ + model: "gpt-5.4", + search: true, + fastMode: true, + }); + + expect(result.fastModeRequested).toBe(true); + expect(result.fastModeApplied).toBe(true); + expect(result.fastModeIgnoredReason).toBeNull(); + expect(result.args).toEqual([ + "--search", + "exec", + "--json", + "--model", + "gpt-5.4", + "-c", + 'service_tier="fast"', + "-c", + "features.fast_mode=true", + "-", + ]); + }); + + it("ignores fast mode for unsupported models", () => { + const result = buildCodexExecArgs({ + model: "gpt-5.3-codex", + fastMode: true, + }); + + expect(result.fastModeRequested).toBe(true); + expect(result.fastModeApplied).toBe(false); + expect(result.fastModeIgnoredReason).toContain("currently only supported on gpt-5.4"); + expect(result.args).toEqual([ + "exec", + "--json", + "--model", + "gpt-5.3-codex", + "-", + ]); + }); +}); diff --git a/packages/adapters/codex-local/src/server/codex-args.ts b/packages/adapters/codex-local/src/server/codex-args.ts new file mode 100644 index 0000000000..7675681418 --- /dev/null +++ b/packages/adapters/codex-local/src/server/codex-args.ts @@ -0,0 +1,74 @@ +import { asBoolean, asString, asStringArray } from "@paperclipai/adapter-utils/server-utils"; +import { + CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS, + isCodexLocalFastModeSupported, +} from "../index.js"; + +export type BuildCodexExecArgsResult = { + args: string[]; + model: string; + fastModeRequested: boolean; + fastModeApplied: boolean; + fastModeIgnoredReason: string | null; +}; + +function readExtraArgs(config: unknown): string[] { + const fromExtraArgs = asStringArray(asRecord(config).extraArgs); + if (fromExtraArgs.length > 0) return fromExtraArgs; + return asStringArray(asRecord(config).args); +} + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function formatFastModeSupportedModels(): string { + return CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS.join(", "); +} + +export function buildCodexExecArgs( + config: unknown, + options: { resumeSessionId?: string | null } = {}, +): BuildCodexExecArgsResult { + const record = asRecord(config); + const model = asString(record.model, "").trim(); + const modelReasoningEffort = asString( + record.modelReasoningEffort, + asString(record.reasoningEffort, ""), + ).trim(); + const search = asBoolean(record.search, false); + const fastModeRequested = asBoolean(record.fastMode, false); + const fastModeApplied = fastModeRequested && isCodexLocalFastModeSupported(model); + const bypass = asBoolean( + record.dangerouslyBypassApprovalsAndSandbox, + asBoolean(record.dangerouslyBypassSandbox, false), + ); + const extraArgs = readExtraArgs(record); + + const args = ["exec", "--json"]; + if (search) args.unshift("--search"); + if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox"); + if (model) args.push("--model", model); + if (modelReasoningEffort) { + args.push("-c", `model_reasoning_effort=${JSON.stringify(modelReasoningEffort)}`); + } + if (fastModeApplied) { + args.push("-c", 'service_tier="fast"', "-c", "features.fast_mode=true"); + } + if (extraArgs.length > 0) args.push(...extraArgs); + if (options.resumeSessionId) args.push("resume", options.resumeSessionId, "-"); + else args.push("-"); + + return { + args, + model, + fastModeRequested, + fastModeApplied, + fastModeIgnoredReason: + fastModeRequested && !fastModeApplied + ? `Configured fast mode is currently only supported on ${formatFastModeSupportedModels()}; Paperclip will ignore it for model ${model || "(default)"}.` + : null, + }; +} diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index c6ba3e9ba9..f34c09ca53 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -5,8 +5,6 @@ import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type Adapter import { asString, asNumber, - asBoolean, - asStringArray, parseObject, buildPaperclipEnv, buildInvocationEnvForLogs, @@ -26,6 +24,7 @@ import { import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js"; import { resolveCodexDesiredSkillNames } from "./skills.js"; +import { buildCodexExecArgs } from "./codex-args.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); const CODEX_ROLLOUT_NOISE_RE = @@ -223,15 +222,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - const fromExtraArgs = asStringArray(config.extraArgs); - if (fromExtraArgs.length > 0) return fromExtraArgs; - return asStringArray(config.args); - })(); const runtimeSessionParams = parseObject(runtime.sessionParams); const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); @@ -499,26 +484,19 @@ export async function execute(ctx: AdapterExecutionContext): Promise { - const args = ["exec", "--json"]; - if (search) args.unshift("--search"); - if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox"); - if (model) args.push("--model", model); - if (modelReasoningEffort) args.push("-c", `model_reasoning_effort=${JSON.stringify(modelReasoningEffort)}`); - if (extraArgs.length > 0) args.push(...extraArgs); - if (resumeSessionId) args.push("resume", resumeSessionId, "-"); - else args.push("-"); - return args; - }; - const runAttempt = async (resumeSessionId: string | null) => { - const args = buildArgs(resumeSessionId); + const execArgs = buildCodexExecArgs(config, { resumeSessionId }); + const args = execArgs.args; + const commandNotesWithFastMode = + execArgs.fastModeIgnoredReason == null + ? commandNotes + : [...commandNotes, execArgs.fastModeIgnoredReason]; if (onMeta) { await onMeta({ adapterType: "codex_local", command: resolvedCommand, cwd, - commandNotes, + commandNotes: commandNotesWithFastMode, commandArgs: args.map((value, idx) => { if (idx === args.length - 1 && value !== "-") return ``; return value; diff --git a/packages/adapters/codex-local/src/server/test.ts b/packages/adapters/codex-local/src/server/test.ts index 64af601b7b..f19ed0078c 100644 --- a/packages/adapters/codex-local/src/server/test.ts +++ b/packages/adapters/codex-local/src/server/test.ts @@ -5,8 +5,6 @@ import type { } from "@paperclipai/adapter-utils"; import { asString, - asBoolean, - asStringArray, parseObject, ensureAbsoluteDirectory, ensureCommandResolvable, @@ -16,6 +14,7 @@ import { import path from "node:path"; import { parseCodexJsonl } from "./parse.js"; import { codexHomeDir, readCodexAuthInfo } from "./quota.js"; +import { buildCodexExecArgs } from "./codex-args.js"; function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] { if (checks.some((check) => check.level === "error")) return "fail"; @@ -140,31 +139,16 @@ export async function testEnvironment( hint: "Use the `codex` CLI command to run the automatic login and installation probe.", }); } else { - const model = asString(config.model, "").trim(); - const modelReasoningEffort = asString( - config.modelReasoningEffort, - asString(config.reasoningEffort, ""), - ).trim(); - const search = asBoolean(config.search, false); - const bypass = asBoolean( - config.dangerouslyBypassApprovalsAndSandbox, - asBoolean(config.dangerouslyBypassSandbox, false), - ); - const extraArgs = (() => { - const fromExtraArgs = asStringArray(config.extraArgs); - if (fromExtraArgs.length > 0) return fromExtraArgs; - return asStringArray(config.args); - })(); - - const args = ["exec", "--json"]; - if (search) args.unshift("--search"); - if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox"); - if (model) args.push("--model", model); - if (modelReasoningEffort) { - args.push("-c", `model_reasoning_effort=${JSON.stringify(modelReasoningEffort)}`); + const execArgs = buildCodexExecArgs({ ...config, fastMode: false }); + const args = execArgs.args; + if (execArgs.fastModeIgnoredReason) { + checks.push({ + code: "codex_fast_mode_unsupported_model", + level: "warn", + message: execArgs.fastModeIgnoredReason, + hint: "Switch the agent model to GPT-5.4 to enable Codex Fast mode.", + }); } - if (extraArgs.length > 0) args.push(...extraArgs); - args.push("-"); const probe = await runChildProcess( `codex-envtest-${Date.now()}-${Math.random().toString(16).slice(2)}`, diff --git a/packages/adapters/codex-local/src/ui/build-config.test.ts b/packages/adapters/codex-local/src/ui/build-config.test.ts new file mode 100644 index 0000000000..734d16872f --- /dev/null +++ b/packages/adapters/codex-local/src/ui/build-config.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { buildCodexLocalConfig } from "./build-config.js"; +import type { CreateConfigValues } from "@paperclipai/adapter-utils"; + +function makeValues(overrides: Partial = {}): CreateConfigValues { + return { + adapterType: "codex_local", + cwd: "", + instructionsFilePath: "", + promptTemplate: "", + model: "gpt-5.4", + thinkingEffort: "", + chrome: false, + dangerouslySkipPermissions: true, + search: false, + fastMode: false, + dangerouslyBypassSandbox: true, + command: "", + args: "", + extraArgs: "", + envVars: "", + envBindings: {}, + url: "", + bootstrapPrompt: "", + payloadTemplateJson: "", + workspaceStrategyType: "project_primary", + workspaceBaseRef: "", + workspaceBranchTemplate: "", + worktreeParentDir: "", + runtimeServicesJson: "", + maxTurnsPerRun: 1000, + heartbeatEnabled: false, + intervalSec: 300, + ...overrides, + }; +} + +describe("buildCodexLocalConfig", () => { + it("persists the fastMode toggle into adapter config", () => { + const config = buildCodexLocalConfig( + makeValues({ + search: true, + fastMode: true, + }), + ); + + expect(config).toMatchObject({ + model: "gpt-5.4", + search: true, + fastMode: true, + dangerouslyBypassApprovalsAndSandbox: true, + }); + }); +}); diff --git a/packages/adapters/codex-local/src/ui/build-config.ts b/packages/adapters/codex-local/src/ui/build-config.ts index 7c8f0d9cc5..8721ba768f 100644 --- a/packages/adapters/codex-local/src/ui/build-config.ts +++ b/packages/adapters/codex-local/src/ui/build-config.ts @@ -85,6 +85,7 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record 0) ac.env = env; ac.search = v.search; + ac.fastMode = v.fastMode; ac.dangerouslyBypassApprovalsAndSandbox = typeof v.dangerouslyBypassSandbox === "boolean" ? v.dangerouslyBypassSandbox diff --git a/ui/src/adapters/codex-local/config-fields.tsx b/ui/src/adapters/codex-local/config-fields.tsx index 86bef6009b..b1a24bb924 100644 --- a/ui/src/adapters/codex-local/config-fields.tsx +++ b/ui/src/adapters/codex-local/config-fields.tsx @@ -7,6 +7,10 @@ import { } from "../../components/agent-config-primitives"; import { ChoosePathButton } from "../../components/PathInstructionsModal"; import { LocalWorkspaceRuntimeFields } from "../local-workspace-runtime-fields"; +import { + CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS, + isCodexLocalFastModeSupported, +} from "@paperclipai/adapter-codex-local"; const inputClass = "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40"; @@ -27,6 +31,14 @@ export function CodexLocalConfigFields({ }: AdapterConfigFieldsProps) { const bypassEnabled = config.dangerouslyBypassApprovalsAndSandbox === true || config.dangerouslyBypassSandbox === true; + const fastModeEnabled = isCreate + ? Boolean(values!.fastMode) + : eff("adapterConfig", "fastMode", Boolean(config.fastMode)); + const currentModel = isCreate + ? String(values!.model ?? "") + : eff("adapterConfig", "model", String(config.model ?? "")); + const fastModeSupported = isCodexLocalFastModeSupported(currentModel); + const supportedModelsLabel = CODEX_LOCAL_FAST_MODE_SUPPORTED_MODELS.join(", "); return ( <> @@ -88,6 +100,23 @@ export function CodexLocalConfigFields({ : mark("adapterConfig", "search", v) } /> + + isCreate + ? set!({ fastMode: v }) + : mark("adapterConfig", "fastMode", v) + } + /> + {fastModeEnabled && ( +
+ {fastModeSupported + ? "Fast mode consumes credits/tokens much faster than standard Codex runs." + : `Fast mode currently only works on ${supportedModelsLabel}. Paperclip will ignore this toggle until the model is switched.`} +
+ )} = { dangerouslySkipPermissions: "Run unattended by auto-approving adapter permission prompts when supported.", dangerouslyBypassSandbox: "Run Codex without sandbox restrictions. Required for filesystem/network access.", search: "Enable Codex web search capability during runs.", + fastMode: "Enable Codex Fast mode. This burns credits/tokens much faster and is currently supported on GPT-5.4 only.", workspaceStrategy: "How Paperclip should realize an execution workspace for this agent. Keep project_primary for normal cwd execution, or use git_worktree for issue-scoped isolated checkouts.", workspaceBaseRef: "Base git ref used when creating a worktree branch. Leave blank to use the resolved workspace ref or HEAD.", workspaceBranchTemplate: "Template for naming derived branches. Supports {{issue.identifier}}, {{issue.title}}, {{agent.name}}, {{project.id}}, {{workspace.repoRef}}, and {{slug}}.",