import { shellEscape, type Agent, type AgentSessionInfo, type AgentLaunchConfig, type ActivityState, type ActivityDetection, type PluginModule, type RuntimeHandle, type Session, type WorkspaceHooksConfig, } from "@composio/ao-core"; import { execFile } from "node:child_process"; import { writeFile, mkdir, readFile, rename } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { randomBytes } from "node:crypto"; const execFileAsync = promisify(execFile); /** Shared bin directory for ao shell wrappers (prepended to PATH) */ const AO_BIN_DIR = join(homedir(), ".ao", "bin"); // ============================================================================= // Plugin Manifest // ============================================================================= export const manifest = { name: "codex", slot: "agent" as const, description: "Agent plugin: OpenAI Codex CLI", version: "0.1.0", }; // ============================================================================= // Shell Wrappers (automatic metadata updates — like Claude Code's PostToolUse) // ============================================================================= /** * Helper script sourced by both gh and git wrappers. * Provides update_ao_metadata() for writing key=value to the session file. */ const AO_METADATA_HELPER = `#!/usr/bin/env bash # ao-metadata-helper — shared by gh/git wrappers # Provides: update_ao_metadata update_ao_metadata() { local key="\$1" value="\$2" local ao_dir="\${AO_DATA_DIR:-}" local ao_session="\${AO_SESSION:-}" [[ -z "\$ao_dir" || -z "\$ao_session" ]] && return 0 # Validate: session name must not contain path separators or traversal case "\$ao_session" in */* | *..*) return 0 ;; esac # Validate: ao_dir must be an absolute path under known ao directories or /tmp case "\$ao_dir" in "\$HOME"/.ao/* | "\$HOME"/.agent-orchestrator/* | /tmp/*) ;; *) return 0 ;; esac local metadata_file="\$ao_dir/\$ao_session" # Resolve and verify the file is still within ao_dir local real_dir real_ao_dir real_ao_dir="\$(cd "\$ao_dir" 2>/dev/null && pwd -P)" || return 0 real_dir="\$(cd "\$(dirname "\$metadata_file")" 2>/dev/null && pwd -P)" || return 0 [[ "\$real_dir" == "\$real_ao_dir"* ]] || return 0 [[ -f "\$metadata_file" ]] || return 0 local temp_file="\${metadata_file}.tmp.\$\$" # Strip newlines from value to prevent metadata line injection local clean_value="\$(printf '%s' "\$value" | tr -d '\\n')" # Escape sed metacharacters in value (& expands to matched text, | breaks delimiter) local escaped_value="\$(printf '%s' "\$clean_value" | sed 's/[&|\\\\]/\\\\&/g')" if grep -q "^\${key}=" "\$metadata_file" 2>/dev/null; then sed "s|^\${key}=.*|\${key}=\${escaped_value}|" "\$metadata_file" > "\$temp_file" else cp "\$metadata_file" "\$temp_file" printf '%s=%s\\n' "\$key" "\$clean_value" >> "\$temp_file" fi mv "\$temp_file" "\$metadata_file" } `; /** * gh wrapper — intercepts `gh pr create` and `gh pr merge` to auto-update * session metadata. All other commands pass through transparently. */ const GH_WRAPPER = `#!/usr/bin/env bash # ao gh wrapper — auto-updates session metadata on PR operations # Find real gh by removing our wrapper directory from PATH ao_bin_dir="\$(cd "\$(dirname "\$0")" && pwd)" clean_path="\$(echo "\$PATH" | tr ':' '\\n' | grep -Fxv "\$ao_bin_dir" | grep . | tr '\\n' ':')" clean_path="\${clean_path%:}" real_gh="\$(PATH="\$clean_path" command -v gh 2>/dev/null)" if [[ -z "\$real_gh" ]]; then echo "ao-wrapper: gh not found in PATH" >&2 exit 127 fi # Source the metadata helper source "\$ao_bin_dir/ao-metadata-helper.sh" 2>/dev/null || true # Only capture output for commands we need to parse (pr/create, pr/merge). # All other commands pass through transparently without stream merging. case "\$1/\$2" in pr/create|pr/merge) tmpout="\$(mktemp)" trap 'rm -f "\$tmpout"' EXIT "\$real_gh" "\$@" 2>&1 | tee "\$tmpout" exit_code=\${PIPESTATUS[0]} if [[ \$exit_code -eq 0 ]]; then output="\$(cat "\$tmpout")" case "\$1/\$2" in pr/create) pr_url="\$(echo "\$output" | grep -Eo 'https://github\\.com/[^/]+/[^/]+/pull/[0-9]+' | head -1)" if [[ -n "\$pr_url" ]]; then update_ao_metadata pr "\$pr_url" update_ao_metadata status pr_open fi ;; pr/merge) update_ao_metadata status merged ;; esac fi exit \$exit_code ;; *) exec "\$real_gh" "\$@" ;; esac `; /** * git wrapper — intercepts branch creation commands to auto-update metadata. * All other commands pass through transparently. */ const GIT_WRAPPER = `#!/usr/bin/env bash # ao git wrapper — auto-updates session metadata on branch operations # Find real git by removing our wrapper directory from PATH ao_bin_dir="\$(cd "\$(dirname "\$0")" && pwd)" clean_path="\$(echo "\$PATH" | tr ':' '\\n' | grep -Fxv "\$ao_bin_dir" | grep . | tr '\\n' ':')" clean_path="\${clean_path%:}" real_git="\$(PATH="\$clean_path" command -v git 2>/dev/null)" if [[ -z "\$real_git" ]]; then echo "ao-wrapper: git not found in PATH" >&2 exit 127 fi # Source the metadata helper source "\$ao_bin_dir/ao-metadata-helper.sh" 2>/dev/null || true # Run real git "\$real_git" "\$@" exit_code=\$? # Only update metadata on success if [[ \$exit_code -eq 0 ]]; then case "\$1/\$2" in checkout/-b) update_ao_metadata branch "\$3" ;; switch/-c) update_ao_metadata branch "\$3" ;; esac fi exit \$exit_code `; // ============================================================================= // Workspace Setup // ============================================================================= /** * Section appended to AGENTS.md as a secondary signal. The PATH-based wrappers * handle metadata updates automatically, but AGENTS.md reinforces the intent * and helps if the wrappers are bypassed. */ const AO_AGENTS_MD_SECTION = ` ## Agent Orchestrator (ao) Session You are running inside an Agent Orchestrator managed workspace. Session metadata is updated automatically via shell wrappers. If automatic updates fail, you can manually update metadata: \`\`\`bash ~/.ao/bin/ao-metadata-helper.sh # sourced automatically # Then call: update_ao_metadata \`\`\` `; /** * Atomically write a file by writing to a temp file in the same directory, * then renaming. This prevents concurrent sessions from reading partially * written wrapper scripts. */ async function atomicWriteFile(filePath: string, content: string, mode: number): Promise { const suffix = randomBytes(6).toString("hex"); const tmpPath = `${filePath}.tmp.${suffix}`; await writeFile(tmpPath, content, { encoding: "utf-8", mode }); await rename(tmpPath, filePath); } async function setupCodexWorkspace(workspacePath: string): Promise { // 1. Write shared wrappers to ~/.ao/bin/ await mkdir(AO_BIN_DIR, { recursive: true }); await atomicWriteFile( join(AO_BIN_DIR, "ao-metadata-helper.sh"), AO_METADATA_HELPER, 0o755, ); // Only write wrappers if they don't exist or are outdated (check marker) const markerPath = join(AO_BIN_DIR, ".ao-version"); const currentVersion = "0.1.0"; let needsUpdate = true; try { const existing = await readFile(markerPath, "utf-8"); if (existing.trim() === currentVersion) needsUpdate = false; } catch { // File doesn't exist — needs update } if (needsUpdate) { // Write wrappers atomically, then write the version marker last. // If we crash between wrapper writes and marker write, the next // invocation will redo the writes (safe: wrappers are idempotent). await atomicWriteFile(join(AO_BIN_DIR, "gh"), GH_WRAPPER, 0o755); await atomicWriteFile(join(AO_BIN_DIR, "git"), GIT_WRAPPER, 0o755); await atomicWriteFile(markerPath, currentVersion, 0o644); } // 2. Append ao section to AGENTS.md (create if missing, skip if already present) const agentsMdPath = join(workspacePath, "AGENTS.md"); let existing = ""; try { existing = await readFile(agentsMdPath, "utf-8"); } catch { // File doesn't exist yet } if (!existing.includes("Agent Orchestrator (ao) Session")) { const content = existing ? existing.trimEnd() + "\n" + AO_AGENTS_MD_SECTION : AO_AGENTS_MD_SECTION.trimStart(); await writeFile(agentsMdPath, content, "utf-8"); } } // ============================================================================= // Agent Implementation // ============================================================================= function createCodexAgent(): Agent { return { name: "codex", processName: "codex", getLaunchCommand(config: AgentLaunchConfig): string { const parts: string[] = ["codex"]; if (config.permissions === "skip") { parts.push("--dangerously-bypass-approvals-and-sandbox"); } if (config.model) { parts.push("--model", shellEscape(config.model)); } if (config.systemPromptFile) { parts.push("--system-prompt", `"$(cat ${shellEscape(config.systemPromptFile)})"`); } else if (config.systemPrompt) { parts.push("--system-prompt", shellEscape(config.systemPrompt)); } if (config.prompt) { // Use `--` to end option parsing so prompts starting with `-` aren't // misinterpreted as flags. parts.push("--", shellEscape(config.prompt)); } return parts.join(" "); }, getEnvironment(config: AgentLaunchConfig): Record { const env: Record = {}; env["AO_SESSION_ID"] = config.sessionId; // NOTE: AO_PROJECT_ID is the caller's responsibility (spawn.ts sets it) if (config.issueId) { env["AO_ISSUE_ID"] = config.issueId; } // Prepend ~/.ao/bin to PATH so our gh/git wrappers intercept commands. // The wrappers strip this directory from PATH before calling the real // binary, so there's no infinite recursion. env["PATH"] = `${AO_BIN_DIR}:${process.env["PATH"] ?? "/usr/bin:/bin"}`; return env; }, detectActivity(terminalOutput: string): ActivityState { if (!terminalOutput.trim()) return "idle"; const lines = terminalOutput.trim().split("\n"); const lastLine = lines[lines.length - 1]?.trim() ?? ""; // If Codex is showing its input prompt, it's idle if (/^[>$#]\s*$/.test(lastLine)) return "idle"; // Check last few lines for approval prompts const tail = lines.slice(-5).join("\n"); if (/approval required/i.test(tail)) return "waiting_input"; if (/\(y\)es.*\(n\)o/i.test(tail)) return "waiting_input"; // "(esc to interrupt)" appears while Codex is actively running a task if (/\(esc to interrupt\)/i.test(terminalOutput)) return "active"; // Progress spinner symbols + words ending in "ing" = actively working if (/[✶⏺✽⏳]/.test(terminalOutput) && /\w+ing\b/.test(terminalOutput)) return "active"; return "active"; }, async getActivityState(session: Session, _readyThresholdMs?: number): Promise { // Check if process is running first if (!session.runtimeHandle) return { state: "exited" }; const running = await this.isProcessRunning(session.runtimeHandle); if (!running) return { state: "exited" }; // NOTE: Codex stores rollout files in a global ~/.codex/sessions/ directory // without workspace-specific scoping. When multiple Codex sessions run in // parallel, we cannot reliably determine which rollout file belongs to which // session. Until Codex provides per-workspace session tracking, we return // null (unknown) rather than guessing. See issue #13 for details. // // TODO: Implement proper per-session activity detection when Codex supports it. return null; }, async isProcessRunning(handle: RuntimeHandle): Promise { try { if (handle.runtimeName === "tmux" && handle.id) { const { stdout: ttyOut } = await execFileAsync( "tmux", ["list-panes", "-t", handle.id, "-F", "#{pane_tty}"], { timeout: 30_000 }, ); const ttys = ttyOut .trim() .split("\n") .map((t) => t.trim()) .filter(Boolean); if (ttys.length === 0) return false; const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], { timeout: 30_000, }); const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, ""))); const processRe = /(?:^|\/)codex(?:\s|$)/; for (const line of psOut.split("\n")) { const cols = line.trimStart().split(/\s+/); if (cols.length < 3 || !ttySet.has(cols[1] ?? "")) continue; const args = cols.slice(2).join(" "); if (processRe.test(args)) { return true; } } return false; } const rawPid = handle.data["pid"]; const pid = typeof rawPid === "number" ? rawPid : Number(rawPid); if (Number.isFinite(pid) && pid > 0) { try { process.kill(pid, 0); return true; } catch (err: unknown) { if (err instanceof Error && "code" in err && err.code === "EPERM") { return true; } return false; } } return false; } catch { return false; } }, async getSessionInfo(_session: Session): Promise { // Codex doesn't have JSONL session files for introspection yet return null; }, async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise { await setupCodexWorkspace(workspacePath); }, async postLaunchSetup(session: Session): Promise { if (!session.workspacePath) return; await setupCodexWorkspace(session.workspacePath); }, }; } // ============================================================================= // Plugin Export // ============================================================================= export function create(): Agent { return createCodexAgent(); } export default { manifest, create } satisfies PluginModule;