diff --git a/packages/cli/__tests__/commands/spawn.test.ts b/packages/cli/__tests__/commands/spawn.test.ts index 594f2de86..a422bb06c 100644 --- a/packages/cli/__tests__/commands/spawn.test.ts +++ b/packages/cli/__tests__/commands/spawn.test.ts @@ -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"]), diff --git a/packages/cli/src/commands/spawn.ts b/packages/cli/src/commands/spawn.ts index 52fdfed0d..f912450b5 100644 --- a/packages/cli/src/commands/spawn.ts +++ b/packages/cli/src/commands/spawn.ts @@ -11,6 +11,7 @@ async function spawnSession( projectId: string, issueId?: string, openTab?: boolean, + agent?: string, ): Promise { 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 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 ", "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); diff --git a/packages/core/src/__tests__/plugin-registry.test.ts b/packages/core/src/__tests__/plugin-registry.test.ts index 0fc85c619..e0132c4eb 100644 --- a/packages/core/src/__tests__/plugin-registry.test.ts +++ b/packages/core/src/__tests__/plugin-registry.test.ts @@ -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)", () => { diff --git a/packages/core/src/__tests__/session-manager.test.ts b/packages/core/src/__tests__/session-manager.test.ts index 96d56edff..5722ebaaa 100644 --- a/packages/core/src/__tests__/session-manager.test.ts +++ b/packages/core/src/__tests__/session-manager.test.ts @@ -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", diff --git a/packages/core/src/lifecycle-manager.ts b/packages/core/src/lifecycle-manager.ts index 0ab2df839..d35d75303 100644 --- a/packages/core/src/lifecycle-manager.ts +++ b/packages/core/src/lifecycle-manager.ts @@ -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", project.agent ?? config.defaults.agent); + const agentName = session.metadata["agent"] ?? project.agent ?? config.defaults.agent; + const agent = registry.get("agent", agentName); const scm = project.scm ? registry.get("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= 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 || diff --git a/packages/core/src/metadata.ts b/packages/core/src/metadata.ts index 67e5242f1..0e0fb1db5 100644 --- a/packages/core/src/metadata.ts +++ b/packages/core/src/metadata.ts @@ -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) diff --git a/packages/core/src/session-manager.ts b/packages/core/src/session-manager.ts index c58a286cd..9ccc2c007 100644 --- a/packages/core/src/session-manager.ts +++ b/packages/core/src/session-manager.ts @@ -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", project.runtime ?? config.defaults.runtime); - const agent = registry.get("agent", project.agent ?? config.defaults.agent); + const agent = registry.get("agent", agentOverride ?? project.agent ?? config.defaults.agent); const workspace = registry.get( "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", 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((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 diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6de795c8f..7860db3a0 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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; diff --git a/packages/plugins/agent-codex/src/index.test.ts b/packages/plugins/agent-codex/src/index.test.ts index 71a7422d8..00dbeb119 100644 --- a/packages/plugins/agent-codex/src/index.test.ts +++ b/packages/plugins/agent-codex/src/index.test.ts @@ -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 { return { @@ -62,8 +93,10 @@ function makeLaunchConfig(overrides: Partial = {}): 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 { + 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"); + }); + }); }); diff --git a/packages/plugins/agent-codex/src/index.ts b/packages/plugins/agent-codex/src/index.ts index 0c6952052..916d0f293 100644 --- a/packages/plugins/agent-codex/src/index.ts +++ b/packages/plugins/agent-codex/src/index.ts @@ -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 + +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 +\`\`\` +`; +/* 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 { + 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 // ============================================================================= @@ -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 { // 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 { + await setupCodexWorkspace(workspacePath); + }, + + async postLaunchSetup(session: Session): Promise { + if (!session.workspacePath) return; + await setupCodexWorkspace(session.workspacePath); + }, }; }