Merge pull request #154 from suraj-markup/suraj/codex-maturity

feat(agent-codex): mature Codex plugin to match Claude Code support
This commit is contained in:
suraj_markup 2026-02-24 09:27:49 +05:30 committed by GitHub
commit ae508c90ee
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1147 additions and 31 deletions

View File

@ -234,6 +234,62 @@ describe("spawn command", () => {
expect(output).toContain("8474d6f29887-app-7");
});
it("passes --agent flag to sessionManager.spawn()", async () => {
const fakeSession: Session = {
id: "app-1",
projectId: "my-app",
status: "spawning",
activity: null,
branch: null,
issueId: null,
pr: null,
workspacePath: "/tmp/wt",
runtimeHandle: { id: "hash-app-1", runtimeName: "tmux", data: {} },
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
};
mockSessionManager.spawn.mockResolvedValue(fakeSession);
await program.parseAsync(["node", "test", "spawn", "my-app", "--agent", "codex"]);
expect(mockSessionManager.spawn).toHaveBeenCalledWith({
projectId: "my-app",
issueId: undefined,
agent: "codex",
});
});
it("passes --agent flag with issue ID", async () => {
const fakeSession: Session = {
id: "app-1",
projectId: "my-app",
status: "spawning",
activity: null,
branch: "feat/INT-42",
issueId: "INT-42",
pr: null,
workspacePath: "/tmp/wt",
runtimeHandle: { id: "hash-app-1", runtimeName: "tmux", data: {} },
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
};
mockSessionManager.spawn.mockResolvedValue(fakeSession);
await program.parseAsync(["node", "test", "spawn", "my-app", "INT-42", "--agent", "codex"]);
expect(mockSessionManager.spawn).toHaveBeenCalledWith({
projectId: "my-app",
issueId: "INT-42",
agent: "codex",
});
});
it("rejects unknown project ID", async () => {
await expect(
program.parseAsync(["node", "test", "spawn", "nonexistent"]),

View File

@ -11,6 +11,7 @@ async function spawnSession(
projectId: string,
issueId?: string,
openTab?: boolean,
agent?: string,
): Promise<string> {
const spinner = ora("Creating session").start();
@ -21,6 +22,7 @@ async function spawnSession(
const session = await sm.spawn({
projectId,
issueId,
agent,
});
spinner.succeed(`Session ${chalk.green(session.id)} created`);
@ -58,7 +60,8 @@ export function registerSpawn(program: Command): void {
.argument("<project>", "Project ID from config")
.argument("[issue]", "Issue identifier (e.g. INT-1234, #42) - must exist in tracker")
.option("--open", "Open session in terminal tab")
.action(async (projectId: string, issueId: string | undefined, opts: { open?: boolean }) => {
.option("--agent <name>", "Override the agent plugin (e.g. codex, claude-code)")
.action(async (projectId: string, issueId: string | undefined, opts: { open?: boolean; agent?: string }) => {
const config = loadConfig();
if (!config.projects[projectId]) {
console.error(
@ -70,7 +73,7 @@ export function registerSpawn(program: Command): void {
}
try {
await spawnSession(config, projectId, issueId, opts.open);
await spawnSession(config, projectId, issueId, opts.open, opts.agent);
} catch (err) {
console.error(chalk.red(`${err}`));
process.exit(1);

View File

@ -140,6 +140,26 @@ describe("loadBuiltins", () => {
// In the test environment, most are not resolvable — should not throw.
await expect(registry.loadBuiltins()).resolves.toBeUndefined();
});
it("registers multiple agent plugins from importFn", async () => {
const registry = createPluginRegistry();
const fakeClaudeCode = makePlugin("agent", "claude-code");
const fakeCodex = makePlugin("agent", "codex");
await registry.loadBuiltins(undefined, async (pkg: string) => {
if (pkg === "@composio/ao-plugin-agent-claude-code") return fakeClaudeCode;
if (pkg === "@composio/ao-plugin-agent-codex") return fakeCodex;
throw new Error(`Not found: ${pkg}`);
});
const agents = registry.list("agent");
expect(agents).toContainEqual(expect.objectContaining({ name: "claude-code", slot: "agent" }));
expect(agents).toContainEqual(expect.objectContaining({ name: "codex", slot: "agent" }));
expect(registry.get("agent", "codex")).not.toBeNull();
expect(registry.get("agent", "claude-code")).not.toBeNull();
});
});
describe("extractPluginConfig (via register with config)", () => {

View File

@ -227,6 +227,94 @@ describe("spawn", () => {
await expect(sm.spawn({ projectId: "my-app" })).rejects.toThrow("not found");
});
describe("agent override", () => {
let mockCodexAgent: Agent;
let registryWithMultipleAgents: PluginRegistry;
beforeEach(() => {
mockCodexAgent = {
name: "codex",
processName: "codex",
getLaunchCommand: vi.fn().mockReturnValue("codex --start"),
getEnvironment: vi.fn().mockReturnValue({ CODEX_VAR: "1" }),
detectActivity: vi.fn().mockReturnValue("active"),
getActivityState: vi.fn().mockResolvedValue(null),
isProcessRunning: vi.fn().mockResolvedValue(true),
getSessionInfo: vi.fn().mockResolvedValue(null),
};
registryWithMultipleAgents = {
...mockRegistry,
get: vi.fn().mockImplementation((slot: string, name: string) => {
if (slot === "runtime") return mockRuntime;
if (slot === "agent") {
if (name === "mock-agent") return mockAgent;
if (name === "codex") return mockCodexAgent;
return null;
}
if (slot === "workspace") return mockWorkspace;
return null;
}),
};
});
it("uses overridden agent when spawnConfig.agent is provided", async () => {
const sm = createSessionManager({ config, registry: registryWithMultipleAgents });
await sm.spawn({ projectId: "my-app", agent: "codex" });
expect(mockCodexAgent.getLaunchCommand).toHaveBeenCalled();
expect(mockAgent.getLaunchCommand).not.toHaveBeenCalled();
});
it("throws when agent override plugin is not found", async () => {
const sm = createSessionManager({ config, registry: registryWithMultipleAgents });
await expect(
sm.spawn({ projectId: "my-app", agent: "nonexistent" }),
).rejects.toThrow("Agent plugin 'nonexistent' not found");
});
it("uses default agent when no override specified", async () => {
const sm = createSessionManager({ config, registry: registryWithMultipleAgents });
await sm.spawn({ projectId: "my-app" });
expect(mockAgent.getLaunchCommand).toHaveBeenCalled();
expect(mockCodexAgent.getLaunchCommand).not.toHaveBeenCalled();
});
it("persists agent name in metadata when override is used", async () => {
const sm = createSessionManager({ config, registry: registryWithMultipleAgents });
await sm.spawn({ projectId: "my-app", agent: "codex" });
const meta = readMetadataRaw(sessionsDir, "app-1");
expect(meta).not.toBeNull();
expect(meta!["agent"]).toBe("codex");
});
it("persists default agent name in metadata when no override", async () => {
const sm = createSessionManager({ config, registry: registryWithMultipleAgents });
await sm.spawn({ projectId: "my-app" });
const meta = readMetadataRaw(sessionsDir, "app-1");
expect(meta).not.toBeNull();
expect(meta!["agent"]).toBe("mock-agent");
});
it("readMetadata returns agent field (typed SessionMetadata)", async () => {
const sm = createSessionManager({ config, registry: registryWithMultipleAgents });
await sm.spawn({ projectId: "my-app", agent: "codex" });
const meta = readMetadata(sessionsDir, "app-1");
expect(meta).not.toBeNull();
expect(meta!.agent).toBe("codex");
});
});
it("validates issue exists when issueId provided", async () => {
const mockTracker: Tracker = {
name: "mock-tracker",

View File

@ -183,7 +183,8 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
const project = config.projects[session.projectId];
if (!project) return session.status;
const agent = registry.get<Agent>("agent", project.agent ?? config.defaults.agent);
const agentName = session.metadata["agent"] ?? project.agent ?? config.defaults.agent;
const agent = registry.get<Agent>("agent", agentName);
const scm = project.scm ? registry.get<SCM>("scm", project.scm.plugin) : null;
// 1. Check if runtime is alive
@ -229,7 +230,26 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
}
}
// 3. Check PR state if PR exists
// 3. Auto-detect PR by branch if metadata.pr is missing.
// This is critical for agents without auto-hook systems (Codex, Aider,
// OpenCode) that can't reliably write pr=<url> to metadata on their own.
if (!session.pr && scm && session.branch) {
try {
const detectedPR = await scm.detectPR(session, project);
if (detectedPR) {
session.pr = detectedPR;
// Persist PR URL so subsequent polls don't need to re-query.
// Don't write status here — step 4 below will determine the
// correct status (merged, ci_failed, etc.) on this same cycle.
const sessionsDir = getSessionsDir(config.configPath, project.path);
updateMetadata(sessionsDir, session.id, { pr: detectedPR.url });
}
} catch {
// SCM detection failed — will retry next poll
}
}
// 4. Check PR state if PR exists
if (session.pr && scm) {
try {
const prState = await scm.getPRState(session.pr);
@ -257,7 +277,7 @@ export function createLifecycleManager(deps: LifecycleManagerDeps): LifecycleMan
}
}
// 4. Default: if agent is active, it's working
// 5. Default: if agent is active, it's working
if (
session.status === "spawning" ||
session.status === SESSION_STATUS.STUCK ||

View File

@ -97,6 +97,7 @@ export function readMetadata(dataDir: string, sessionId: SessionId): SessionMeta
pr: raw["pr"],
summary: raw["summary"],
project: raw["project"],
agent: raw["agent"],
createdAt: raw["createdAt"],
runtimeHandle: raw["runtimeHandle"],
dashboardPort: raw["dashboardPort"] ? Number(raw["dashboardPort"]) : undefined,
@ -139,6 +140,7 @@ export function writeMetadata(
if (metadata.pr) data["pr"] = metadata.pr;
if (metadata.summary) data["summary"] = metadata.summary;
if (metadata.project) data["project"] = metadata.project;
if (metadata.agent) data["agent"] = metadata.agent;
if (metadata.createdAt) data["createdAt"] = metadata.createdAt;
if (metadata.runtimeHandle) data["runtimeHandle"] = metadata.runtimeHandle;
if (metadata.dashboardPort !== undefined)

View File

@ -210,9 +210,9 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
}
/** Resolve which plugins to use for a project. */
function resolvePlugins(project: ProjectConfig) {
function resolvePlugins(project: ProjectConfig, agentOverride?: string) {
const runtime = registry.get<Runtime>("runtime", project.runtime ?? config.defaults.runtime);
const agent = registry.get<Agent>("agent", project.agent ?? config.defaults.agent);
const agent = registry.get<Agent>("agent", agentOverride ?? project.agent ?? config.defaults.agent);
const workspace = registry.get<Workspace>(
"workspace",
project.workspace ?? config.defaults.workspace,
@ -322,6 +322,16 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
if (!plugins.runtime) {
throw new Error(`Runtime plugin '${project.runtime ?? config.defaults.runtime}' not found`);
}
// Allow --agent override to swap the agent plugin for this session
if (spawnConfig.agent) {
const overrideAgent = registry.get<Agent>("agent", spawnConfig.agent);
if (!overrideAgent) {
throw new Error(`Agent plugin '${spawnConfig.agent}' not found`);
}
plugins.agent = overrideAgent;
}
if (!plugins.agent) {
throw new Error(`Agent plugin '${project.agent ?? config.defaults.agent}' not found`);
}
@ -515,6 +525,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
tmuxName, // Store tmux name for mapping
issue: spawnConfig.issueId,
project: spawnConfig.projectId,
agent: plugins.agent.name, // Persist agent name for lifecycle manager
createdAt: new Date().toISOString(),
runtimeHandle: JSON.stringify(handle),
});
@ -693,7 +704,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
const session = metadataToSession(sessionName, raw, createdAt, modifiedAt);
const plugins = resolvePlugins(project);
const plugins = resolvePlugins(project, raw["agent"]);
// Cap per-session enrichment at 2s — subprocess calls (tmux/ps) can be
// slow under load. If we time out, session keeps its metadata values.
const enrichTimeout = new Promise<void>((resolve) => setTimeout(resolve, 2_000));
@ -727,7 +738,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
const session = metadataToSession(sessionId, raw, createdAt, modifiedAt);
const plugins = resolvePlugins(project);
const plugins = resolvePlugins(project, raw["agent"]);
await ensureHandleAndEnrich(session, sessionId, project, plugins);
return session;
@ -967,7 +978,7 @@ export function createSessionManager(deps: SessionManagerDeps): SessionManager {
// session (status "working", agent exited) would not be detected as terminal
// and isRestorable would reject it.
const session = metadataToSession(sessionId, raw);
const plugins = resolvePlugins(project);
const plugins = resolvePlugins(project, raw["agent"]);
await enrichSessionWithRuntimeState(session, plugins, true);
// 3. Validate restorability

View File

@ -176,6 +176,8 @@ export interface SessionSpawnConfig {
issueId?: string;
branch?: string;
prompt?: string;
/** Override the agent plugin for this session (e.g. "codex", "claude-code") */
agent?: string;
}
/** Config for creating an orchestrator session */
@ -963,6 +965,7 @@ export interface SessionMetadata {
pr?: string;
summary?: string;
project?: string;
agent?: string; // Agent plugin name (e.g. "codex", "claude-code") — persisted for lifecycle
createdAt?: string;
runtimeHandle?: string;
restoredAt?: string;

View File

@ -2,10 +2,22 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Session, RuntimeHandle, AgentLaunchConfig } from "@composio/ao-core";
// ---------------------------------------------------------------------------
// Hoisted mocks
// Hoisted mocks — available inside vi.mock factories
// ---------------------------------------------------------------------------
const { mockExecFileAsync } = vi.hoisted(() => ({
const {
mockExecFileAsync,
mockWriteFile,
mockMkdir,
mockReadFile,
mockRename,
mockHomedir,
} = vi.hoisted(() => ({
mockExecFileAsync: vi.fn(),
mockWriteFile: vi.fn().mockResolvedValue(undefined),
mockMkdir: vi.fn().mockResolvedValue(undefined),
mockReadFile: vi.fn(),
mockRename: vi.fn().mockResolvedValue(undefined),
mockHomedir: vi.fn(() => "/mock/home"),
}));
vi.mock("node:child_process", () => {
@ -15,10 +27,29 @@ vi.mock("node:child_process", () => {
return { execFile: fn };
});
vi.mock("node:fs/promises", () => ({
writeFile: mockWriteFile,
mkdir: mockMkdir,
readFile: mockReadFile,
rename: mockRename,
}));
vi.mock("node:crypto", () => ({
randomBytes: () => ({ toString: () => "abc123" }),
}));
vi.mock("node:fs", () => ({
existsSync: vi.fn(() => false),
}));
vi.mock("node:os", () => ({
homedir: mockHomedir,
}));
import { create, manifest, default as defaultExport } from "./index.js";
// ---------------------------------------------------------------------------
// Helpers
// Test helpers
// ---------------------------------------------------------------------------
function makeSession(overrides: Partial<Session> = {}): Session {
return {
@ -62,8 +93,10 @@ function makeLaunchConfig(overrides: Partial<AgentLaunchConfig> = {}): AgentLaun
}
function mockTmuxWithProcess(processName: string, found = true) {
mockExecFileAsync.mockImplementation((cmd: string) => {
if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" });
mockExecFileAsync.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "tmux" && args[0] === "list-panes") {
return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" });
}
if (cmd === "ps") {
const line = found ? ` 789 ttys003 ${processName}` : " 789 ttys003 bash";
return Promise.resolve({
@ -71,12 +104,13 @@ function mockTmuxWithProcess(processName: string, found = true) {
stderr: "",
});
}
return Promise.reject(new Error("unexpected"));
return Promise.reject(new Error(`Unexpected: ${cmd} ${args.join(" ")}`));
});
}
beforeEach(() => {
vi.clearAllMocks();
mockHomedir.mockReturnValue("/mock/home");
});
// =========================================================================
@ -114,9 +148,9 @@ describe("getLaunchCommand", () => {
expect(agent.getLaunchCommand(makeLaunchConfig())).toBe("codex");
});
it("includes --approval-mode full-auto when permissions=skip", () => {
it("includes --full-auto when permissions=skip", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ permissions: "skip" }));
expect(cmd).toContain("--approval-mode full-auto");
expect(cmd).toContain("--full-auto");
});
it("includes --model with shell-escaped value", () => {
@ -133,7 +167,7 @@ describe("getLaunchCommand", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ permissions: "skip", model: "o3", prompt: "Go" }),
);
expect(cmd).toBe("codex --approval-mode full-auto --model 'o3' -- 'Go'");
expect(cmd).toBe("codex --full-auto --model 'o3' -- 'Go'");
});
it("escapes single quotes in prompt (POSIX shell escaping)", () => {
@ -141,10 +175,37 @@ describe("getLaunchCommand", () => {
expect(cmd).toContain("-- 'it'\\''s broken'");
});
it("escapes dangerous characters in prompt", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ prompt: "$(rm -rf /); `evil`; $HOME" }),
);
// Single-quoted strings prevent shell expansion
expect(cmd).toContain("-- '$(rm -rf /); `evil`; $HOME'");
});
it("includes -c model_instructions_file when systemPromptFile is set", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPromptFile: "/tmp/prompt.md" }));
expect(cmd).toContain("-c model_instructions_file='/tmp/prompt.md'");
});
it("prefers systemPromptFile over systemPrompt", () => {
const cmd = agent.getLaunchCommand(
makeLaunchConfig({ systemPromptFile: "/tmp/prompt.md", systemPrompt: "Ignored" }),
);
expect(cmd).toContain("model_instructions_file='/tmp/prompt.md'");
expect(cmd).not.toContain("'Ignored'");
});
it("includes -c developer_instructions when systemPrompt is set", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig({ systemPrompt: "Be helpful" }));
expect(cmd).toContain("-c developer_instructions='Be helpful'");
});
it("omits optional flags when not provided", () => {
const cmd = agent.getLaunchCommand(makeLaunchConfig());
expect(cmd).not.toContain("--approval-mode");
expect(cmd).not.toContain("--full-auto");
expect(cmd).not.toContain("--model");
expect(cmd).not.toContain("-c");
});
});
@ -169,6 +230,27 @@ describe("getEnvironment", () => {
const env = agent.getEnvironment(makeLaunchConfig());
expect(env["AO_ISSUE_ID"]).toBeUndefined();
});
it("prepends ~/.ao/bin to PATH for shell wrappers", () => {
const env = agent.getEnvironment(makeLaunchConfig());
expect(env["PATH"]).toMatch(/^.*\/\.ao\/bin:/);
});
it("PATH starts with the ao bin dir specifically", () => {
const env = agent.getEnvironment(makeLaunchConfig());
expect(env["PATH"]?.startsWith("/mock/home/.ao/bin:")).toBe(true);
});
it("falls back to /usr/bin:/bin when process.env.PATH is undefined", () => {
const originalPath = process.env["PATH"];
delete process.env["PATH"];
try {
const env = agent.getEnvironment(makeLaunchConfig());
expect(env["PATH"]).toContain("/usr/bin:/bin");
} finally {
process.env["PATH"] = originalPath;
}
});
});
// =========================================================================
@ -187,6 +269,11 @@ describe("isProcessRunning", () => {
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
});
it("returns false when tmux list-panes returns empty", async () => {
mockExecFileAsync.mockResolvedValue({ stdout: "", stderr: "" });
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
});
it("returns true for process handle with alive PID", async () => {
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
expect(await agent.isProcessRunning(makeProcessHandle(123))).toBe(true);
@ -205,6 +292,8 @@ describe("isProcessRunning", () => {
it("returns false for unknown runtime without PID", async () => {
const handle: RuntimeHandle = { id: "x", runtimeName: "other", data: {} };
expect(await agent.isProcessRunning(handle)).toBe(false);
// Must NOT call external commands — could match wrong session
expect(mockExecFileAsync).not.toHaveBeenCalled();
});
it("returns false on tmux command failure", async () => {
@ -222,8 +311,8 @@ describe("isProcessRunning", () => {
});
it("finds codex on any pane in multi-pane session", async () => {
mockExecFileAsync.mockImplementation((cmd: string) => {
if (cmd === "tmux") {
mockExecFileAsync.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "tmux" && args[0] === "list-panes") {
return Promise.resolve({ stdout: "/dev/ttys001\n/dev/ttys002\n", stderr: "" });
}
if (cmd === "ps") {
@ -236,6 +325,33 @@ describe("isProcessRunning", () => {
});
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true);
});
it("does not match similar process names like codex-something", async () => {
mockExecFileAsync.mockImplementation((cmd: string, args: string[]) => {
if (cmd === "tmux" && args[0] === "list-panes") {
return Promise.resolve({ stdout: "/dev/ttys001\n", stderr: "" });
}
if (cmd === "ps") {
return Promise.resolve({
stdout: " PID TT ARGS\n 100 ttys001 /usr/bin/codex-helper\n",
stderr: "",
});
}
return Promise.reject(new Error("unexpected"));
});
expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false);
});
it("handles string PID by converting to number", async () => {
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
expect(await agent.isProcessRunning(makeProcessHandle("456"))).toBe(true);
expect(killSpy).toHaveBeenCalledWith(456, 0);
killSpy.mockRestore();
});
it("returns false for non-numeric PID", async () => {
expect(await agent.isProcessRunning(makeProcessHandle("not-a-pid"))).toBe(false);
});
});
// =========================================================================
@ -244,6 +360,7 @@ describe("isProcessRunning", () => {
describe("detectActivity", () => {
const agent = create();
// -- Idle states --
it("returns idle for empty terminal output", () => {
expect(agent.detectActivity("")).toBe("idle");
});
@ -252,9 +369,113 @@ describe("detectActivity", () => {
expect(agent.detectActivity(" \n ")).toBe("idle");
});
it("returns active for non-empty terminal output", () => {
it("returns idle when last line is a bare > prompt", () => {
expect(agent.detectActivity("some output\n> ")).toBe("idle");
});
it("returns idle when last line is a bare $ prompt", () => {
expect(agent.detectActivity("some output\n$ ")).toBe("idle");
});
it("returns idle when last line is a bare # prompt", () => {
expect(agent.detectActivity("some output\n# ")).toBe("idle");
});
it("returns idle when prompt follows historical activity indicators", () => {
// Key regression test: historical active output in the buffer
// should NOT override an idle prompt on the last line.
expect(agent.detectActivity("✶ Reading files\nDone.\n> ")).toBe("idle");
expect(agent.detectActivity("Working on task (esc to interrupt)\nFinished.\n$ ")).toBe("idle");
});
// -- Waiting input states --
it("returns waiting_input for approval required text", () => {
expect(agent.detectActivity("some output\napproval required\n")).toBe("waiting_input");
});
it("returns waiting_input for (y)es / (n)o prompt", () => {
expect(agent.detectActivity("Do you want to continue?\n(y)es / (n)o\n")).toBe("waiting_input");
});
it("returns waiting_input when permission prompt follows historical activity", () => {
// Permission prompt at the bottom should NOT be overridden by historical
// spinner/esc output higher in the buffer.
expect(
agent.detectActivity("✶ Writing files\nDone.\napproval required\n"),
).toBe("waiting_input");
expect(
agent.detectActivity("Working (esc to interrupt)\nFinished\n(y)es / (n)o\n"),
).toBe("waiting_input");
});
// -- Active states --
it("returns active for non-empty terminal output with no special patterns", () => {
expect(agent.detectActivity("codex is running some task\n")).toBe("active");
});
it("returns active when (esc to interrupt) is present", () => {
expect(agent.detectActivity("Working on task (esc to interrupt)\n")).toBe("active");
});
it("returns active for spinner symbols with -ing words", () => {
expect(agent.detectActivity("✶ Reading files\n")).toBe("active");
expect(agent.detectActivity("⏺ Writing to disk\n")).toBe("active");
expect(agent.detectActivity("✽ Searching codebase\n")).toBe("active");
expect(agent.detectActivity("⏳ Installing packages\n")).toBe("active");
});
it("returns active (not idle) for spinner symbol without -ing word", () => {
// Spinner symbols alone without -ing words should still fall through to active
expect(agent.detectActivity("✶ done\n")).toBe("active");
});
it("returns active for multi-line output with activity in the middle", () => {
expect(agent.detectActivity("Starting\n(esc to interrupt)\nstill going\n")).toBe("active");
});
});
// =========================================================================
// getActivityState
// =========================================================================
describe("getActivityState", () => {
const agent = create();
it("returns exited when no runtimeHandle", async () => {
const session = makeSession({ runtimeHandle: null });
const result = await agent.getActivityState(session);
expect(result).toEqual({ state: "exited" });
});
it("returns exited when process is not running", async () => {
mockExecFileAsync.mockRejectedValue(new Error("tmux not running"));
const session = makeSession({ runtimeHandle: makeTmuxHandle() });
const result = await agent.getActivityState(session);
expect(result).toEqual({ state: "exited" });
});
it("returns null (unknown) when process is running", async () => {
mockTmuxWithProcess("codex");
const session = makeSession({ runtimeHandle: makeTmuxHandle() });
expect(await agent.getActivityState(session)).toBeNull();
});
it("returns exited when process handle has dead PID", async () => {
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => {
throw new Error("ESRCH");
});
const session = makeSession({ runtimeHandle: makeProcessHandle(999) });
const result = await agent.getActivityState(session);
expect(result).toEqual({ state: "exited" });
killSpy.mockRestore();
});
it("does not include timestamp in exited state", async () => {
const session = makeSession({ runtimeHandle: null });
const result = await agent.getActivityState(session);
// The Codex implementation returns { state: "exited" } without timestamp
expect(result).toEqual({ state: "exited" });
expect(result?.timestamp).toBeUndefined();
});
});
// =========================================================================
@ -267,4 +488,422 @@ describe("getSessionInfo", () => {
expect(await agent.getSessionInfo(makeSession())).toBeNull();
expect(await agent.getSessionInfo(makeSession({ workspacePath: "/some/path" }))).toBeNull();
});
it("returns null even with null workspacePath", async () => {
expect(await agent.getSessionInfo(makeSession({ workspacePath: null }))).toBeNull();
});
});
// =========================================================================
// setupWorkspaceHooks — file writing behavior
// =========================================================================
describe("setupWorkspaceHooks", () => {
const agent = create();
it("has setupWorkspaceHooks method", () => {
expect(typeof agent.setupWorkspaceHooks).toBe("function");
});
it("creates ~/.ao/bin directory", async () => {
// Version marker doesn't exist — triggers full install
mockReadFile.mockRejectedValue(new Error("ENOENT"));
await agent.setupWorkspaceHooks!("/workspace/test", {
dataDir: "/data",
sessionId: "sess-1",
});
expect(mockMkdir).toHaveBeenCalledWith("/mock/home/.ao/bin", { recursive: true });
});
it("writes ao-metadata-helper.sh with executable permissions via atomic write", async () => {
mockReadFile.mockRejectedValue(new Error("ENOENT"));
await agent.setupWorkspaceHooks!("/workspace/test", {
dataDir: "/data",
sessionId: "sess-1",
});
// Atomic write: writes to .tmp file first, then renames
const helperWriteCall = mockWriteFile.mock.calls.find(
(call: [string, string, object]) =>
typeof call[0] === "string" && call[0].includes("ao-metadata-helper.sh.tmp."),
);
expect(helperWriteCall).toBeDefined();
expect(helperWriteCall![1]).toContain("update_ao_metadata()");
expect(helperWriteCall![2]).toEqual({ encoding: "utf-8", mode: 0o755 });
// Then renamed to final path
const helperRenameCall = mockRename.mock.calls.find(
(call: string[]) => typeof call[1] === "string" && call[1].endsWith("ao-metadata-helper.sh"),
);
expect(helperRenameCall).toBeDefined();
});
it("writes gh and git wrappers atomically when version marker is missing", async () => {
mockReadFile.mockRejectedValue(new Error("ENOENT"));
await agent.setupWorkspaceHooks!("/workspace/test", {
dataDir: "/data",
sessionId: "sess-1",
});
// gh wrapper: written to temp, then renamed
const ghWriteCall = mockWriteFile.mock.calls.find(
(call: [string, string, object]) =>
typeof call[0] === "string" && call[0].includes("/gh.tmp."),
);
expect(ghWriteCall).toBeDefined();
expect(ghWriteCall![1]).toContain("ao gh wrapper");
const ghRenameCall = mockRename.mock.calls.find(
(call: string[]) => typeof call[1] === "string" && call[1].endsWith("/gh"),
);
expect(ghRenameCall).toBeDefined();
// git wrapper: written to temp, then renamed
const gitWriteCall = mockWriteFile.mock.calls.find(
(call: [string, string, object]) =>
typeof call[0] === "string" && call[0].includes("/git.tmp."),
);
expect(gitWriteCall).toBeDefined();
expect(gitWriteCall![1]).toContain("ao git wrapper");
const gitRenameCall = mockRename.mock.calls.find(
(call: string[]) => typeof call[1] === "string" && call[1].endsWith("/git"),
);
expect(gitRenameCall).toBeDefined();
});
it("sets executable permissions on gh and git wrappers via writeFile mode", async () => {
mockReadFile.mockRejectedValue(new Error("ENOENT"));
await agent.setupWorkspaceHooks!("/workspace/test", {
dataDir: "/data",
sessionId: "sess-1",
});
const ghWriteCall = mockWriteFile.mock.calls.find(
(call: [string, string, object]) =>
typeof call[0] === "string" && call[0].includes("/gh.tmp."),
);
expect(ghWriteCall![2]).toEqual({ encoding: "utf-8", mode: 0o755 });
const gitWriteCall = mockWriteFile.mock.calls.find(
(call: [string, string, object]) =>
typeof call[0] === "string" && call[0].includes("/git.tmp."),
);
expect(gitWriteCall![2]).toEqual({ encoding: "utf-8", mode: 0o755 });
});
it("skips wrapper writes when version marker matches", async () => {
// First call for version marker — matches current version
// Second call for AGENTS.md — file doesn't exist
mockReadFile.mockImplementation((path: string) => {
if (typeof path === "string" && path.endsWith(".ao-version")) {
return Promise.resolve("0.1.0");
}
// AGENTS.md read attempt
return Promise.reject(new Error("ENOENT"));
});
await agent.setupWorkspaceHooks!("/workspace/test", {
dataDir: "/data",
sessionId: "sess-1",
});
// Should still write the metadata helper (always written)
const helperWriteCall = mockWriteFile.mock.calls.find(
(call: [string, string, object]) =>
typeof call[0] === "string" && call[0].includes("ao-metadata-helper.sh.tmp."),
);
expect(helperWriteCall).toBeDefined();
// But should NOT write gh/git wrappers (version matches)
const ghWriteCall = mockWriteFile.mock.calls.find(
(call: [string, string, object]) =>
typeof call[0] === "string" && call[0].includes("/gh.tmp."),
);
expect(ghWriteCall).toBeUndefined();
});
it("writes version marker after installing wrappers", async () => {
mockReadFile.mockRejectedValue(new Error("ENOENT"));
await agent.setupWorkspaceHooks!("/workspace/test", {
dataDir: "/data",
sessionId: "sess-1",
});
// Version marker is also atomically written
const versionWriteCall = mockWriteFile.mock.calls.find(
(call: [string, string, object]) =>
typeof call[0] === "string" && call[0].includes(".ao-version.tmp."),
);
expect(versionWriteCall).toBeDefined();
expect(versionWriteCall![1]).toBe("0.1.0");
const versionRenameCall = mockRename.mock.calls.find(
(call: string[]) => typeof call[1] === "string" && call[1].endsWith(".ao-version"),
);
expect(versionRenameCall).toBeDefined();
});
it("appends ao section to AGENTS.md when not present", async () => {
// Version marker matches (skip wrapper install)
// AGENTS.md exists without ao section
mockReadFile.mockImplementation((path: string) => {
if (typeof path === "string" && path.endsWith(".ao-version")) {
return Promise.resolve("0.1.0");
}
if (typeof path === "string" && path.endsWith("AGENTS.md")) {
return Promise.resolve("# Existing Content\n\nSome stuff here.\n");
}
return Promise.reject(new Error("ENOENT"));
});
await agent.setupWorkspaceHooks!("/workspace/test", {
dataDir: "/data",
sessionId: "sess-1",
});
const agentsMdCall = mockWriteFile.mock.calls.find(
(call: string[]) => typeof call[0] === "string" && call[0].endsWith("AGENTS.md"),
);
expect(agentsMdCall).toBeDefined();
expect(agentsMdCall![1]).toContain("Agent Orchestrator (ao) Session");
expect(agentsMdCall![1]).toContain("# Existing Content");
});
it("creates AGENTS.md if it does not exist", async () => {
// Version marker matches, AGENTS.md doesn't exist
mockReadFile.mockImplementation((path: string) => {
if (typeof path === "string" && path.endsWith(".ao-version")) {
return Promise.resolve("0.1.0");
}
return Promise.reject(new Error("ENOENT"));
});
await agent.setupWorkspaceHooks!("/workspace/test", {
dataDir: "/data",
sessionId: "sess-1",
});
const agentsMdCall = mockWriteFile.mock.calls.find(
(call: string[]) => typeof call[0] === "string" && call[0].endsWith("AGENTS.md"),
);
expect(agentsMdCall).toBeDefined();
expect(agentsMdCall![1]).toContain("Agent Orchestrator (ao) Session");
});
it("uses atomic write (temp + rename) to prevent partial reads from concurrent sessions", async () => {
mockReadFile.mockRejectedValue(new Error("ENOENT"));
await agent.setupWorkspaceHooks!("/workspace/test", {
dataDir: "/data",
sessionId: "sess-1",
});
// Every wrapper file should be written to a .tmp file first, then renamed
// This ensures concurrent readers never see a partially written file
const tmpWrites = mockWriteFile.mock.calls.filter(
(call: [string, string, object]) =>
typeof call[0] === "string" && call[0].includes(".tmp."),
);
const renames = mockRename.mock.calls;
// We expect atomic writes for: helper, gh, git, version marker = 4
expect(tmpWrites.length).toBe(4);
expect(renames.length).toBe(4);
// Each rename should move a .tmp file to the final path
for (const [src, dst] of renames) {
expect(src).toContain(".tmp.");
expect(dst).not.toContain(".tmp.");
}
});
it("does not duplicate ao section in AGENTS.md if already present", async () => {
mockReadFile.mockImplementation((path: string) => {
if (typeof path === "string" && path.endsWith(".ao-version")) {
return Promise.resolve("0.1.0");
}
if (typeof path === "string" && path.endsWith("AGENTS.md")) {
return Promise.resolve("# Existing\n\n## Agent Orchestrator (ao) Session\n\nAlready here.\n");
}
return Promise.reject(new Error("ENOENT"));
});
await agent.setupWorkspaceHooks!("/workspace/test", {
dataDir: "/data",
sessionId: "sess-1",
});
const agentsMdCall = mockWriteFile.mock.calls.find(
(call: string[]) => typeof call[0] === "string" && call[0].endsWith("AGENTS.md"),
);
// Should NOT write AGENTS.md since the section already exists
expect(agentsMdCall).toBeUndefined();
});
});
// =========================================================================
// postLaunchSetup
// =========================================================================
describe("postLaunchSetup", () => {
const agent = create();
it("has postLaunchSetup method", () => {
expect(typeof agent.postLaunchSetup).toBe("function");
});
it("runs setup when session has workspacePath", async () => {
mockReadFile.mockRejectedValue(new Error("ENOENT"));
await agent.postLaunchSetup!(makeSession({ workspacePath: "/workspace/test" }));
expect(mockMkdir).toHaveBeenCalled();
});
it("returns early when session has no workspacePath", async () => {
await agent.postLaunchSetup!(makeSession({ workspacePath: undefined }));
expect(mockMkdir).not.toHaveBeenCalled();
});
});
// =========================================================================
// Shell wrapper content verification
// =========================================================================
describe("shell wrapper content", () => {
const agent = create();
beforeEach(() => {
// Force wrapper installation by making version marker miss
mockReadFile.mockRejectedValue(new Error("ENOENT"));
});
async function getWrapperContent(name: string): Promise<string> {
await agent.setupWorkspaceHooks!("/workspace/test", {
dataDir: "/data",
sessionId: "sess-1",
});
// With atomic writes, content is written to a .tmp. file
const call = mockWriteFile.mock.calls.find(
(c: [string, string, object]) =>
typeof c[0] === "string" && c[0].includes(`/${name}.tmp.`),
);
return call ? call[1] as string : "";
}
describe("metadata helper", () => {
it("contains update_ao_metadata function", async () => {
const content = await getWrapperContent("ao-metadata-helper.sh");
expect(content).toContain("update_ao_metadata()");
});
it("uses AO_DATA_DIR and AO_SESSION env vars", async () => {
const content = await getWrapperContent("ao-metadata-helper.sh");
expect(content).toContain("AO_DATA_DIR");
expect(content).toContain("AO_SESSION");
});
it("escapes sed metacharacters in values", async () => {
const content = await getWrapperContent("ao-metadata-helper.sh");
// Should contain the sed escaping logic for &, |, and \
expect(content).toContain("escaped_value");
expect(content).toMatch(/sed.*\\\\&/);
});
it("uses atomic temp file + mv pattern", async () => {
const content = await getWrapperContent("ao-metadata-helper.sh");
expect(content).toContain("temp_file");
expect(content).toContain("mv");
});
it("validates session name has no path separators", async () => {
const content = await getWrapperContent("ao-metadata-helper.sh");
// Rejects session names containing / or ..
expect(content).toContain("*/*");
expect(content).toContain("*..*");
});
it("validates ao_dir is an absolute path under expected locations", async () => {
const content = await getWrapperContent("ao-metadata-helper.sh");
// Only allows paths under $HOME/.ao/, $HOME/.agent-orchestrator/, or /tmp/
expect(content).toContain('$HOME"/.ao/*');
expect(content).toContain('$HOME"/.agent-orchestrator/*');
expect(content).toContain("/tmp/*");
});
it("resolves symlinks and verifies file stays within ao_dir", async () => {
const content = await getWrapperContent("ao-metadata-helper.sh");
expect(content).toContain("pwd -P");
expect(content).toContain("real_ao_dir");
expect(content).toContain("real_dir");
});
});
describe("gh wrapper", () => {
it("uses grep -Fxv for PATH cleaning (not regex grep)", async () => {
const content = await getWrapperContent("gh");
expect(content).toContain("grep -Fxv");
expect(content).not.toMatch(/grep -v "\^\$ao_bin_dir\$"/);
});
it("only captures output for pr/create and pr/merge", async () => {
const content = await getWrapperContent("gh");
expect(content).toContain("pr/create|pr/merge");
});
it("uses exec for non-PR commands (transparent passthrough)", async () => {
const content = await getWrapperContent("gh");
expect(content).toContain('exec "$real_gh"');
});
it("extracts PR URL from gh pr create output", async () => {
const content = await getWrapperContent("gh");
expect(content).toContain("https://github");
expect(content).toContain("update_ao_metadata pr");
});
it("updates status to merged on gh pr merge", async () => {
const content = await getWrapperContent("gh");
expect(content).toContain("update_ao_metadata status merged");
});
it("cleans up temp file on exit", async () => {
const content = await getWrapperContent("gh");
expect(content).toContain("trap");
expect(content).toContain("rm -f");
});
});
describe("git wrapper", () => {
it("uses grep -Fxv for PATH cleaning (not regex grep)", async () => {
const content = await getWrapperContent("git");
expect(content).toContain("grep -Fxv");
expect(content).not.toMatch(/grep -v "\^\$ao_bin_dir\$"/);
});
it("captures branch name from checkout -b", async () => {
const content = await getWrapperContent("git");
expect(content).toContain("checkout/-b");
expect(content).toContain("update_ao_metadata branch");
});
it("captures branch name from switch -c", async () => {
const content = await getWrapperContent("git");
expect(content).toContain("switch/-c");
});
it("only updates metadata on success (exit code 0)", async () => {
const content = await getWrapperContent("git");
expect(content).toContain("exit_code -eq 0");
});
it("sources the metadata helper", async () => {
const content = await getWrapperContent("git");
expect(content).toContain("source");
expect(content).toContain("ao-metadata-helper.sh");
});
});
});

View File

@ -3,17 +3,25 @@ import {
type Agent,
type AgentSessionInfo,
type AgentLaunchConfig,
type ActivityDetection,
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
// =============================================================================
@ -25,6 +33,243 @@ export const manifest = {
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.
*/
/* eslint-disable no-useless-escape -- \$ escapes are intentional: bash scripts in JS template literals */
const AO_METADATA_HELPER = `#!/usr/bin/env bash
# ao-metadata-helper shared by gh/git wrappers
# Provides: update_ao_metadata <key> <value>
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 <key> <value>
\`\`\`
`;
/* eslint-enable no-useless-escape */
/**
* 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<void> {
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<void> {
// 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
// =============================================================================
@ -38,7 +283,7 @@ function createCodexAgent(): Agent {
const parts: string[] = ["codex"];
if (config.permissions === "skip") {
parts.push("--approval-mode", "full-auto");
parts.push("--full-auto");
}
if (config.model) {
@ -46,9 +291,11 @@ function createCodexAgent(): Agent {
}
if (config.systemPromptFile) {
parts.push("--system-prompt", `"$(cat ${shellEscape(config.systemPromptFile)})"`);
// Codex reads developer instructions from a file via config override
parts.push("-c", `model_instructions_file=${shellEscape(config.systemPromptFile)}`);
} else if (config.systemPrompt) {
parts.push("--system-prompt", shellEscape(config.systemPrompt));
// Codex accepts inline developer instructions via config override
parts.push("-c", `developer_instructions=${shellEscape(config.systemPrompt)}`);
}
if (config.prompt) {
@ -67,21 +314,39 @@ function createCodexAgent(): Agent {
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";
// Codex doesn't have rich terminal output patterns yet
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";
// Default to active — specific patterns (esc to interrupt, spinner
// symbols) all map to "active" so no need to check them individually.
return "active";
},
async getActivityState(session: Session, _readyThresholdMs?: number): Promise<ActivityDetection | null> {
// Check if process is running first
const exitedAt = new Date();
if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt };
if (!session.runtimeHandle) return { state: "exited" };
const running = await this.isProcessRunning(session.runtimeHandle);
if (!running) return { state: "exited", timestamp: exitedAt };
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
@ -148,6 +413,15 @@ function createCodexAgent(): Agent {
// Codex doesn't have JSONL session files for introspection yet
return null;
},
async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise<void> {
await setupCodexWorkspace(workspacePath);
},
async postLaunchSetup(session: Session): Promise<void> {
if (!session.workspacePath) return;
await setupCodexWorkspace(session.workspacePath);
},
};
}