agent-orchestrator/packages/plugins/agent-codex/src/index.ts

163 lines
5.3 KiB
TypeScript

import {
shellEscape,
type Agent,
type AgentSessionInfo,
type AgentLaunchConfig,
type ActivityState,
type PluginModule,
type RuntimeHandle,
type Session,
} from "@composio/ao-core";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
// =============================================================================
// Plugin Manifest
// =============================================================================
export const manifest = {
name: "codex",
slot: "agent" as const,
description: "Agent plugin: OpenAI Codex CLI",
version: "0.1.0",
};
// =============================================================================
// Agent Implementation
// =============================================================================
function createCodexAgent(): Agent {
return {
name: "codex",
processName: "codex",
getLaunchCommand(config: AgentLaunchConfig): string {
const parts: string[] = ["codex"];
if (config.permissions === "skip") {
parts.push("--approval-mode", "full-auto");
}
if (config.model) {
parts.push("--model", shellEscape(config.model));
}
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<string, string> {
const env: Record<string, string> = {};
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;
}
return env;
},
detectActivity(terminalOutput: string): ActivityState {
if (!terminalOutput.trim()) return "idle";
// Codex doesn't have rich terminal output patterns yet
return "active";
},
async getActivityState(session: Session): Promise<ActivityState> {
// Check if process is running first
if (!session.runtimeHandle) return "exited";
const running = await this.isProcessRunning(session.runtimeHandle);
if (!running) return "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 fall back
// to process-running check only. See issue #13 for details.
//
// TODO: Implement proper per-session activity detection when Codex supports it.
// For now, return "active" if process is running (conservative approach).
return "active";
},
async isProcessRunning(handle: RuntimeHandle): Promise<boolean> {
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;
}
},
// NOTE: Codex lacks introspection to distinguish "processing" from "idle at prompt".
// Falling back to process liveness until richer detection is implemented (see #17).
async isProcessing(session: Session): Promise<boolean> {
if (!session.runtimeHandle) return false;
return this.isProcessRunning(session.runtimeHandle);
},
async getSessionInfo(_session: Session): Promise<AgentSessionInfo | null> {
// Codex doesn't have JSONL session files for introspection yet
return null;
},
};
}
// =============================================================================
// Plugin Export
// =============================================================================
export function create(): Agent {
return createCodexAgent();
}
export default { manifest, create } satisfies PluginModule<Agent>;