import type { PluginCommandContext, PluginCommandResult } from "openclaw/plugin-sdk/plugin-entry"; import { CODEX_CONTROL_METHODS, type CodexControlMethod } from "./app-server/capabilities.js"; import { installCodexComputerUse, readCodexComputerUseStatus, type CodexComputerUseSetupParams, } from "./app-server/computer-use.js"; import type { CodexComputerUseConfig } from "./app-server/config.js"; import { listAllCodexAppServerModels } from "./app-server/models.js"; import { isJsonObject, type JsonValue } from "./app-server/protocol.js"; import { clearCodexAppServerBinding, readCodexAppServerBinding, writeCodexAppServerBinding, } from "./app-server/session-binding.js"; import { buildHelp, formatAccount, formatComputerUseStatus, formatCodexStatus, formatList, formatModels, formatThreads, readString, } from "./command-formatters.js"; import { codexControlRequest, readCodexStatusProbes, requestOptions, safeCodexControlRequest, type SafeValue, } from "./command-rpc.js"; import { readCodexConversationBindingData, resolveCodexDefaultWorkspaceDir, startCodexConversationThread, } from "./conversation-binding.js"; import { formatPermissionsMode, parseCodexFastModeArg, parseCodexPermissionsModeArg, readCodexConversationActiveTurn, setCodexConversationFastMode, setCodexConversationModel, setCodexConversationPermissions, steerCodexConversationTurn, stopCodexConversationTurn, } from "./conversation-control.js"; export type CodexCommandDeps = { codexControlRequest: CodexControlRequestFn; listCodexAppServerModels: typeof listAllCodexAppServerModels; readCodexStatusProbes: typeof readCodexStatusProbes; readCodexAppServerBinding: typeof readCodexAppServerBinding; requestOptions: typeof requestOptions; safeCodexControlRequest: SafeCodexControlRequestFn; writeCodexAppServerBinding: typeof writeCodexAppServerBinding; clearCodexAppServerBinding: typeof clearCodexAppServerBinding; readCodexComputerUseStatus: typeof readCodexComputerUseStatus; installCodexComputerUse: typeof installCodexComputerUse; resolveCodexDefaultWorkspaceDir: typeof resolveCodexDefaultWorkspaceDir; startCodexConversationThread: typeof startCodexConversationThread; readCodexConversationActiveTurn: typeof readCodexConversationActiveTurn; setCodexConversationFastMode: typeof setCodexConversationFastMode; setCodexConversationModel: typeof setCodexConversationModel; setCodexConversationPermissions: typeof setCodexConversationPermissions; steerCodexConversationTurn: typeof steerCodexConversationTurn; stopCodexConversationTurn: typeof stopCodexConversationTurn; }; type CodexControlRequestFn = ( pluginConfig: unknown, method: CodexControlMethod, requestParams: JsonValue | undefined, ) => Promise; type SafeCodexControlRequestFn = ( pluginConfig: unknown, method: CodexControlMethod, requestParams: JsonValue | undefined, ) => Promise>; const defaultCodexCommandDeps: CodexCommandDeps = { codexControlRequest, listCodexAppServerModels: listAllCodexAppServerModels, readCodexStatusProbes, readCodexAppServerBinding, requestOptions, safeCodexControlRequest, writeCodexAppServerBinding, clearCodexAppServerBinding, readCodexComputerUseStatus, installCodexComputerUse, resolveCodexDefaultWorkspaceDir, startCodexConversationThread, readCodexConversationActiveTurn, setCodexConversationFastMode, setCodexConversationModel, setCodexConversationPermissions, steerCodexConversationTurn, stopCodexConversationTurn, }; type ParsedBindArgs = { threadId?: string; cwd?: string; model?: string; provider?: string; help?: boolean; }; type ParsedComputerUseArgs = { action: "status" | "install"; overrides: Partial; hasOverrides: boolean; help?: boolean; }; export async function handleCodexSubcommand( ctx: PluginCommandContext, options: { pluginConfig?: unknown; deps?: Partial }, ): Promise { const deps: CodexCommandDeps = { ...defaultCodexCommandDeps, ...options.deps }; const [subcommand = "status", ...rest] = splitArgs(ctx.args); const normalized = subcommand.toLowerCase(); if (normalized === "help") { return { text: buildHelp() }; } if (normalized === "status") { return { text: formatCodexStatus(await deps.readCodexStatusProbes(options.pluginConfig)) }; } if (normalized === "models") { return { text: formatModels( await deps.listCodexAppServerModels(deps.requestOptions(options.pluginConfig, 100)), ), }; } if (normalized === "threads") { return { text: await buildThreads(deps, options.pluginConfig, rest.join(" ")) }; } if (normalized === "resume") { return { text: await resumeThread(deps, ctx, options.pluginConfig, rest[0]) }; } if (normalized === "bind") { return await bindConversation(deps, ctx, options.pluginConfig, rest); } if (normalized === "detach" || normalized === "unbind") { return { text: await detachConversation(deps, ctx) }; } if (normalized === "binding") { return { text: await describeConversationBinding(deps, ctx) }; } if (normalized === "stop") { return { text: await stopConversationTurn(deps, ctx, options.pluginConfig) }; } if (normalized === "steer") { return { text: await steerConversationTurn(deps, ctx, options.pluginConfig, rest.join(" ")) }; } if (normalized === "model") { return { text: await setConversationModel(deps, ctx, options.pluginConfig, rest.join(" ")) }; } if (normalized === "fast") { return { text: await setConversationFastMode(deps, ctx, options.pluginConfig, rest[0]) }; } if (normalized === "permissions") { return { text: await setConversationPermissions(deps, ctx, options.pluginConfig, rest[0]) }; } if (normalized === "compact") { return { text: await startThreadAction( deps, ctx, options.pluginConfig, CODEX_CONTROL_METHODS.compact, "compaction", ), }; } if (normalized === "review") { return { text: await startThreadAction( deps, ctx, options.pluginConfig, CODEX_CONTROL_METHODS.review, "review", ), }; } if (normalized === "computer-use" || normalized === "computeruse") { return { text: await handleComputerUseCommand(deps, options.pluginConfig, rest), }; } if (normalized === "mcp") { return { text: formatList( await deps.codexControlRequest(options.pluginConfig, CODEX_CONTROL_METHODS.listMcpServers, { limit: 100, }), "MCP servers", ), }; } if (normalized === "skills") { return { text: formatList( await deps.codexControlRequest(options.pluginConfig, CODEX_CONTROL_METHODS.listSkills, {}), "Codex skills", ), }; } if (normalized === "account") { const [account, limits] = await Promise.all([ deps.safeCodexControlRequest(options.pluginConfig, CODEX_CONTROL_METHODS.account, { refreshToken: false, }), deps.safeCodexControlRequest( options.pluginConfig, CODEX_CONTROL_METHODS.rateLimits, undefined, ), ]); return { text: formatAccount(account, limits) }; } return { text: `Unknown Codex command: ${subcommand}\n\n${buildHelp()}` }; } async function handleComputerUseCommand( deps: CodexCommandDeps, pluginConfig: unknown, args: string[], ): Promise { const parsed = parseComputerUseArgs(args); if (parsed.help) { return [ "Usage: /codex computer-use [status|install] [--source ] [--marketplace-path ] [--marketplace ]", "Checks or installs the configured Codex Computer Use plugin through app-server.", ].join("\n"); } const params: CodexComputerUseSetupParams = { pluginConfig, forceEnable: parsed.action === "install" || parsed.hasOverrides, ...(Object.keys(parsed.overrides).length > 0 ? { overrides: parsed.overrides } : {}), }; if (parsed.action === "install") { return formatComputerUseStatus(await deps.installCodexComputerUse(params)); } return formatComputerUseStatus(await deps.readCodexComputerUseStatus(params)); } async function bindConversation( deps: CodexCommandDeps, ctx: PluginCommandContext, pluginConfig: unknown, args: string[], ): Promise { if (!ctx.sessionFile) { return { text: "Cannot bind Codex because this command did not include an OpenClaw session file.", }; } const parsed = parseBindArgs(args); if (parsed.help) { return { text: "Usage: /codex bind [thread-id] [--cwd ] [--model ] [--provider ]", }; } const workspaceDir = parsed.cwd ?? deps.resolveCodexDefaultWorkspaceDir(pluginConfig); const data = await deps.startCodexConversationThread({ pluginConfig, sessionFile: ctx.sessionFile, workspaceDir, threadId: parsed.threadId, model: parsed.model, modelProvider: parsed.provider, }); const binding = await deps.readCodexAppServerBinding(ctx.sessionFile); const threadId = binding?.threadId ?? parsed.threadId ?? "new thread"; const summary = `Codex app-server thread ${threadId} in ${workspaceDir}`; let request: Awaited>; try { request = await ctx.requestConversationBinding({ summary, detachHint: "/codex detach", data, }); } catch (error) { await deps.clearCodexAppServerBinding(ctx.sessionFile); throw error; } if (request.status === "bound") { return { text: `Bound this conversation to Codex thread ${threadId} in ${workspaceDir}.` }; } if (request.status === "pending") { return request.reply; } await deps.clearCodexAppServerBinding(ctx.sessionFile); return { text: request.message }; } async function detachConversation( deps: CodexCommandDeps, ctx: PluginCommandContext, ): Promise { const current = await ctx.getCurrentConversationBinding(); const data = readCodexConversationBindingData(current); const detached = await ctx.detachConversationBinding(); if (data) { await deps.clearCodexAppServerBinding(data.sessionFile); } else if (ctx.sessionFile) { await deps.clearCodexAppServerBinding(ctx.sessionFile); } return detached.removed ? "Detached this conversation from Codex." : "No Codex conversation binding was attached."; } async function describeConversationBinding( deps: CodexCommandDeps, ctx: PluginCommandContext, ): Promise { const current = await ctx.getCurrentConversationBinding(); const data = readCodexConversationBindingData(current); if (!current || !data) { return "No Codex conversation binding is attached."; } const threadBinding = await deps.readCodexAppServerBinding(data.sessionFile); const active = deps.readCodexConversationActiveTurn(data.sessionFile); return [ "Codex conversation binding:", `- Thread: ${threadBinding?.threadId ?? "unknown"}`, `- Workspace: ${data.workspaceDir}`, `- Model: ${threadBinding?.model ?? "default"}`, `- Fast: ${threadBinding?.serviceTier === "fast" ? "on" : "off"}`, `- Permissions: ${threadBinding ? formatPermissionsMode(threadBinding) : "default"}`, `- Active run: ${active ? active.turnId : "none"}`, `- Session: ${data.sessionFile}`, ].join("\n"); } async function buildThreads( deps: CodexCommandDeps, pluginConfig: unknown, filter: string, ): Promise { const response = await deps.codexControlRequest(pluginConfig, CODEX_CONTROL_METHODS.listThreads, { limit: 10, ...(filter.trim() ? { searchTerm: filter.trim() } : {}), }); return formatThreads(response); } async function resumeThread( deps: CodexCommandDeps, ctx: PluginCommandContext, pluginConfig: unknown, threadId: string | undefined, ): Promise { const normalizedThreadId = threadId?.trim(); if (!normalizedThreadId) { return "Usage: /codex resume "; } if (!ctx.sessionFile) { return "Cannot attach a Codex thread because this command did not include an OpenClaw session file."; } const response = await deps.codexControlRequest( pluginConfig, CODEX_CONTROL_METHODS.resumeThread, { threadId: normalizedThreadId, persistExtendedHistory: true, }, ); const thread = isJsonObject(response) && isJsonObject(response.thread) ? response.thread : {}; const effectiveThreadId = readString(thread, "id") ?? normalizedThreadId; await deps.writeCodexAppServerBinding(ctx.sessionFile, { threadId: effectiveThreadId, cwd: readString(thread, "cwd") ?? "", model: isJsonObject(response) ? readString(response, "model") : undefined, modelProvider: isJsonObject(response) ? readString(response, "modelProvider") : undefined, }); return `Attached this OpenClaw session to Codex thread ${effectiveThreadId}.`; } async function stopConversationTurn( deps: CodexCommandDeps, ctx: PluginCommandContext, pluginConfig: unknown, ): Promise { const sessionFile = await resolveControlSessionFile(ctx); if (!sessionFile) { return "Cannot stop Codex because this command did not include an OpenClaw session file."; } return (await deps.stopCodexConversationTurn({ sessionFile, pluginConfig })).message; } async function steerConversationTurn( deps: CodexCommandDeps, ctx: PluginCommandContext, pluginConfig: unknown, message: string, ): Promise { const sessionFile = await resolveControlSessionFile(ctx); if (!sessionFile) { return "Cannot steer Codex because this command did not include an OpenClaw session file."; } return ( await deps.steerCodexConversationTurn({ sessionFile, pluginConfig, message, }) ).message; } async function setConversationModel( deps: CodexCommandDeps, ctx: PluginCommandContext, pluginConfig: unknown, model: string, ): Promise { const sessionFile = await resolveControlSessionFile(ctx); if (!sessionFile) { return "Cannot set Codex model because this command did not include an OpenClaw session file."; } const normalized = model.trim(); if (!normalized) { const binding = await deps.readCodexAppServerBinding(sessionFile); return binding?.model ? `Codex model: ${binding.model}` : "Usage: /codex model "; } return await deps.setCodexConversationModel({ sessionFile, pluginConfig, model: normalized, }); } async function setConversationFastMode( deps: CodexCommandDeps, ctx: PluginCommandContext, pluginConfig: unknown, value: string | undefined, ): Promise { const sessionFile = await resolveControlSessionFile(ctx); if (!sessionFile) { return "Cannot set Codex fast mode because this command did not include an OpenClaw session file."; } const parsed = parseCodexFastModeArg(value); if (value && parsed == null && value.trim().toLowerCase() !== "status") { return "Usage: /codex fast [on|off|status]"; } return await deps.setCodexConversationFastMode({ sessionFile, pluginConfig, enabled: parsed, }); } async function setConversationPermissions( deps: CodexCommandDeps, ctx: PluginCommandContext, pluginConfig: unknown, value: string | undefined, ): Promise { const sessionFile = await resolveControlSessionFile(ctx); if (!sessionFile) { return "Cannot set Codex permissions because this command did not include an OpenClaw session file."; } const parsed = parseCodexPermissionsModeArg(value); if (value && !parsed && value.trim().toLowerCase() !== "status") { return "Usage: /codex permissions [default|yolo|status]"; } return await deps.setCodexConversationPermissions({ sessionFile, pluginConfig, mode: parsed, }); } async function resolveControlSessionFile(ctx: PluginCommandContext): Promise { const binding = await ctx.getCurrentConversationBinding(); return readCodexConversationBindingData(binding)?.sessionFile ?? ctx.sessionFile; } async function startThreadAction( deps: CodexCommandDeps, ctx: PluginCommandContext, pluginConfig: unknown, method: typeof CODEX_CONTROL_METHODS.compact | typeof CODEX_CONTROL_METHODS.review, label: string, ): Promise { const sessionFile = await resolveControlSessionFile(ctx); if (!sessionFile) { return `Cannot start Codex ${label} because this command did not include an OpenClaw session file.`; } const binding = await deps.readCodexAppServerBinding(sessionFile); if (!binding?.threadId) { return `No Codex thread is attached to this OpenClaw session yet.`; } if (method === CODEX_CONTROL_METHODS.review) { await deps.codexControlRequest(pluginConfig, method, { threadId: binding.threadId, target: { type: "uncommittedChanges" }, }); } else { await deps.codexControlRequest(pluginConfig, method, { threadId: binding.threadId }); } return `Started Codex ${label} for thread ${binding.threadId}.`; } function splitArgs(value: string | undefined): string[] { return (value ?? "").trim().split(/\s+/).filter(Boolean); } function parseBindArgs(args: string[]): ParsedBindArgs { const parsed: ParsedBindArgs = {}; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--help" || arg === "-h") { parsed.help = true; continue; } if (arg === "--cwd") { parsed.cwd = args[index + 1]; index += 1; continue; } if (arg === "--model") { parsed.model = args[index + 1]; index += 1; continue; } if (arg === "--provider" || arg === "--model-provider") { parsed.provider = args[index + 1]; index += 1; continue; } if (!arg.startsWith("-") && !parsed.threadId) { parsed.threadId = arg; continue; } parsed.help = true; } parsed.threadId = normalizeOptionalString(parsed.threadId); parsed.cwd = normalizeOptionalString(parsed.cwd); parsed.model = normalizeOptionalString(parsed.model); parsed.provider = normalizeOptionalString(parsed.provider); return parsed; } function parseComputerUseArgs(args: string[]): ParsedComputerUseArgs { const parsed: ParsedComputerUseArgs = { action: "status", overrides: {}, hasOverrides: false, }; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--help" || arg === "-h") { parsed.help = true; continue; } if (arg === "status" || arg === "install") { parsed.action = arg; continue; } if (arg === "--source" || arg === "--marketplace-source") { const value = readRequiredOptionValue(args, index); if (!value) { parsed.help = true; continue; } parsed.overrides.marketplaceSource = value; index += 1; continue; } if (arg === "--marketplace-path" || arg === "--path") { const value = readRequiredOptionValue(args, index); if (!value) { parsed.help = true; continue; } parsed.overrides.marketplacePath = value; index += 1; continue; } if (arg === "--marketplace") { const value = readRequiredOptionValue(args, index); if (!value) { parsed.help = true; continue; } parsed.overrides.marketplaceName = value; index += 1; continue; } if (arg === "--plugin") { const value = readRequiredOptionValue(args, index); if (!value) { parsed.help = true; continue; } parsed.overrides.pluginName = value; index += 1; continue; } if (arg === "--server" || arg === "--mcp-server") { const value = readRequiredOptionValue(args, index); if (!value) { parsed.help = true; continue; } parsed.overrides.mcpServerName = value; index += 1; continue; } parsed.help = true; } parsed.overrides = normalizeComputerUseStringOverrides(parsed.overrides); parsed.hasOverrides = Object.values(parsed.overrides).some(Boolean); return parsed; } function readRequiredOptionValue(args: string[], index: number): string | undefined { const value = args[index + 1]; if (!value || value.startsWith("-")) { return undefined; } return value; } function normalizeComputerUseStringOverrides( overrides: Partial, ): Partial { const normalized: Partial = {}; const marketplaceSource = normalizeOptionalString(overrides.marketplaceSource); if (marketplaceSource) { normalized.marketplaceSource = marketplaceSource; } const marketplacePath = normalizeOptionalString(overrides.marketplacePath); if (marketplacePath) { normalized.marketplacePath = marketplacePath; } const marketplaceName = normalizeOptionalString(overrides.marketplaceName); if (marketplaceName) { normalized.marketplaceName = marketplaceName; } const pluginName = normalizeOptionalString(overrides.pluginName); if (pluginName) { normalized.pluginName = pluginName; } const mcpServerName = normalizeOptionalString(overrides.mcpServerName); if (mcpServerName) { normalized.mcpServerName = mcpServerName; } return normalized; } function normalizeOptionalString(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed || undefined; }