diff --git a/packages/cli/package.json b/packages/cli/package.json index ccdf24425..fd03b54e9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -35,6 +35,7 @@ "dependencies": { "@aoagents/ao-core": "workspace:*", "@aoagents/ao-plugin-agent-aider": "workspace:*", + "@aoagents/ao-plugin-agent-amp": "workspace:*", "@aoagents/ao-plugin-agent-claude-code": "workspace:*", "@aoagents/ao-plugin-agent-codex": "workspace:*", "@aoagents/ao-plugin-agent-cursor": "workspace:*", diff --git a/packages/cli/src/lib/detect-agent.ts b/packages/cli/src/lib/detect-agent.ts index b29f7ee6f..61ab511f0 100644 --- a/packages/cli/src/lib/detect-agent.ts +++ b/packages/cli/src/lib/detect-agent.ts @@ -17,6 +17,7 @@ export interface DetectedAgent { const AGENT_PLUGINS: Array<{ name: string; pkg: string }> = [ { name: "claude-code", pkg: "@aoagents/ao-plugin-agent-claude-code" }, { name: "aider", pkg: "@aoagents/ao-plugin-agent-aider" }, + { name: "amp", pkg: "@aoagents/ao-plugin-agent-amp" }, { name: "codex", pkg: "@aoagents/ao-plugin-agent-codex" }, { name: "cursor", pkg: "@aoagents/ao-plugin-agent-cursor" }, { name: "kimicode", pkg: "@aoagents/ao-plugin-agent-kimicode" }, diff --git a/packages/cli/src/lib/plugins.ts b/packages/cli/src/lib/plugins.ts index fc17acede..6b34eada8 100644 --- a/packages/cli/src/lib/plugins.ts +++ b/packages/cli/src/lib/plugins.ts @@ -2,6 +2,7 @@ import type { Agent, OrchestratorConfig, PluginRegistry, SCM } from "@aoagents/a import claudeCodePlugin from "@aoagents/ao-plugin-agent-claude-code"; import codexPlugin from "@aoagents/ao-plugin-agent-codex"; import aiderPlugin from "@aoagents/ao-plugin-agent-aider"; +import ampPlugin from "@aoagents/ao-plugin-agent-amp"; import cursorPlugin from "@aoagents/ao-plugin-agent-cursor"; import kimicodePlugin from "@aoagents/ao-plugin-agent-kimicode"; import opencodePlugin from "@aoagents/ao-plugin-agent-opencode"; @@ -11,6 +12,7 @@ const agentPlugins: Record = { "claude-code": claudeCodePlugin, codex: codexPlugin, aider: aiderPlugin, + amp: ampPlugin, cursor: cursorPlugin, kimicode: kimicodePlugin, opencode: opencodePlugin, diff --git a/packages/core/src/plugin-registry.ts b/packages/core/src/plugin-registry.ts index d67a0d32d..267066892 100644 --- a/packages/core/src/plugin-registry.ts +++ b/packages/core/src/plugin-registry.ts @@ -43,6 +43,7 @@ const BUILTIN_PLUGINS: Array<{ slot: PluginSlot; name: string; pkg: string }> = { slot: "agent", name: "claude-code", pkg: "@aoagents/ao-plugin-agent-claude-code" }, { slot: "agent", name: "codex", pkg: "@aoagents/ao-plugin-agent-codex" }, { slot: "agent", name: "aider", pkg: "@aoagents/ao-plugin-agent-aider" }, + { slot: "agent", name: "amp", pkg: "@aoagents/ao-plugin-agent-amp" }, { slot: "agent", name: "cursor", pkg: "@aoagents/ao-plugin-agent-cursor" }, { slot: "agent", name: "kimicode", pkg: "@aoagents/ao-plugin-agent-kimicode" }, { slot: "agent", name: "opencode", pkg: "@aoagents/ao-plugin-agent-opencode" }, diff --git a/packages/plugins/agent-amp/package.json b/packages/plugins/agent-amp/package.json new file mode 100644 index 000000000..6fdd7f5a7 --- /dev/null +++ b/packages/plugins/agent-amp/package.json @@ -0,0 +1,46 @@ +{ + "name": "@aoagents/ao-plugin-agent-amp", + "version": "0.1.0", + "description": "Agent plugin: Amp CLI", + "license": "MIT", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "https://github.com/ComposioHQ/agent-orchestrator.git", + "directory": "packages/plugins/agent-amp" + }, + "homepage": "https://github.com/ComposioHQ/agent-orchestrator", + "bugs": { + "url": "https://github.com/ComposioHQ/agent-orchestrator/issues" + }, + "engines": { + "node": ">=20.0.0" + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@aoagents/ao-core": "workspace:*", + "which": "^6.0.1" + }, + "devDependencies": { + "@types/which": "^3.0.4", + "@types/node": "^25.2.3", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + } +} diff --git a/packages/plugins/agent-amp/src/index.test.ts b/packages/plugins/agent-amp/src/index.test.ts new file mode 100644 index 000000000..37ec5d4c4 --- /dev/null +++ b/packages/plugins/agent-amp/src/index.test.ts @@ -0,0 +1,507 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { Session, RuntimeHandle, AgentLaunchConfig } from "@aoagents/ao-core"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const packageJson = require("../package.json") as { + name: string; + version: string; + description: string; +}; +const PACKAGE_NAME_PREFIX = "@aoagents/ao-plugin-agent-"; +const pluginName = packageJson.name.startsWith(PACKAGE_NAME_PREFIX) + ? packageJson.name.slice(PACKAGE_NAME_PREFIX.length) + : packageJson.name; + +const { + mockReadLastActivityEntry, + mockRecordTerminalActivity, + mockSetupPathWrapperWorkspace, + mockExecFileAsync, + mockWhichSync, +} = vi.hoisted(() => ({ + mockReadLastActivityEntry: vi.fn().mockResolvedValue(null), + mockRecordTerminalActivity: vi.fn().mockResolvedValue(undefined), + mockSetupPathWrapperWorkspace: vi.fn().mockResolvedValue(undefined), + mockExecFileAsync: vi.fn(), + mockWhichSync: vi.fn(), +})); + +vi.mock("@aoagents/ao-core", async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + readLastActivityEntry: mockReadLastActivityEntry, + recordTerminalActivity: mockRecordTerminalActivity, + setupPathWrapperWorkspace: mockSetupPathWrapperWorkspace, + }; +}); + +vi.mock("which", () => ({ + default: { + sync: mockWhichSync, + }, + sync: mockWhichSync, +})); + +vi.mock("node:child_process", () => ({ + execFile: (...args: unknown[]) => { + const callback = args[args.length - 1]; + const result = mockExecFileAsync(...args.slice(0, -1)); + if (typeof callback === "function" && result && typeof result.then === "function") { + result.then( + (value: { stdout: string; stderr: string }) => callback(null, value), + (err: Error) => callback(err), + ); + } + }, +})); + +import { create, detect, manifest, default as defaultExport } from "./index.js"; + +const VALID_AMP_THREAD_ID = "T-amp-thread-123"; + +function makeSession(overrides: Partial = {}): Session { + return { + id: "test-1", + projectId: "test-project", + status: "working", + activity: "active", + branch: "feat/test", + issueId: null, + pr: null, + workspacePath: "/workspace/test", + runtimeHandle: null, + agentInfo: null, + createdAt: new Date(), + lastActivityAt: new Date(), + metadata: {}, + ...overrides, + }; +} + +function makeTmuxHandle(id = "test-session"): RuntimeHandle { + return { id, runtimeName: "tmux", data: {} }; +} + +function makeProcessHandle(pid?: number | string): RuntimeHandle { + return { id: "proc-1", runtimeName: "process", data: pid !== undefined ? { pid } : {} }; +} + +function makeLaunchConfig(overrides: Partial = {}): AgentLaunchConfig { + return { + sessionId: "sess-1", + projectConfig: { + name: "my-project", + repo: "owner/repo", + path: "/workspace/repo", + defaultBranch: "main", + sessionPrefix: "my", + agentConfig: { + model: "smart", + }, + }, + ...overrides, + }; +} + +function makeActivityResult( + state: "active" | "ready" | "idle" | "waiting_input" | "blocked", + ts: Date, +): { + entry: { + state: "active" | "ready" | "idle" | "waiting_input" | "blocked"; + ts: string; + source: string; + }; + modifiedAt: Date; +} { + return { + entry: { + state, + ts: ts.toISOString(), + source: "terminal", + }, + modifiedAt: ts, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockWhichSync.mockReset(); +}); + +describe("manifest", () => { + it("has correct Amp manifest", () => { + expect(manifest).toEqual({ + name: pluginName, + slot: "agent", + description: packageJson.description, + version: packageJson.version, + displayName: "Amp", + }); + }); +}); + +describe("create", () => { + it("uses amp as process name", () => { + const agent = create(); + expect(agent.name).toBe(pluginName); + expect(agent.processName).toBe(pluginName); + }); + + it("exports plugin module shape", () => { + expect(defaultExport.manifest).toBe(manifest); + expect(typeof defaultExport.create).toBe("function"); + }); +}); + +describe("detect", () => { + it("returns true when which resolves", async () => { + mockWhichSync.mockReturnValue("/usr/local/bin/amp"); + expect(detect()).toBe(true); + }); + + it("returns false when which fails", async () => { + mockWhichSync.mockImplementation(() => { + throw new Error("not found"); + }); + expect(detect()).toBe(false); + }); +}); + +describe("getLaunchCommand", () => { + const agent = create(); + + it("uses interactive launch without thread id", () => { + const cmd = agent.getLaunchCommand(makeLaunchConfig()); + expect(cmd).toBe("amp"); + }); + + it("uses threads continue when configured", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ + projectConfig: { + ...makeLaunchConfig().projectConfig, + agentConfig: { ampThreadId: VALID_AMP_THREAD_ID }, + }, + }), + ); + expect(cmd).toBe(`amp threads continue '${VALID_AMP_THREAD_ID}'`); + }); + + it("pipes task prompts into interactive Amp launches", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ + prompt: "Do work", + }), + ); + expect(cmd).toContain("node -e "); + expect(cmd).toContain('"args":[]'); + expect(cmd).toContain('"promptParts":[{"type":"text","value":"Do work"}]'); + expect(cmd).not.toContain(" | "); + expect(cmd).not.toContain("-x"); + expect(cmd).not.toContain("--execute"); + }); + + it("pipes system prompt and task prompt into Amp launches", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ + prompt: "Do work", + systemPrompt: "You are helpful", + }), + ); + expect(cmd).toContain( + '"promptParts":[{"type":"text","value":"You are helpful"},{"type":"text","value":"Do work"}]', + ); + expect(cmd).not.toContain(" | "); + }); + + it("uses system prompt file when provided", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ + prompt: "Do work", + systemPrompt: "You are helpful", + systemPromptFile: "/tmp/prompt.md", + }), + ); + expect(cmd).toContain( + '"promptParts":[{"type":"file","value":"/tmp/prompt.md"},{"type":"text","value":"Do work"}]', + ); + expect(cmd).not.toContain(" | "); + }); + + it("uses Node streams to deliver prompts when continuing a configured thread", () => { + const cmd = agent.getLaunchCommand( + makeLaunchConfig({ + prompt: "Continue work", + projectConfig: { + ...makeLaunchConfig().projectConfig, + agentConfig: { ampThreadId: VALID_AMP_THREAD_ID }, + }, + }), + ); + expect(cmd).toContain('"args":["threads","continue","T-amp-thread-123"]'); + expect(cmd).toContain('"promptParts":[{"type":"text","value":"Continue work"}]'); + expect(cmd).not.toContain(" | "); + }); +}); + +describe("getEnvironment", () => { + const agent = create(); + + it("writes AO session keys and amp flags", () => { + const env = agent.getEnvironment(makeLaunchConfig()); + expect(env["AO_SESSION_ID"]).toBe("sess-1"); + expect(env["AO_ISSUE_ID"]).toBeUndefined(); + expect(env["NO_ANIMATION"]).toBe("1"); + expect(env["PATH"]).toBeUndefined(); + expect(env["GH_PATH"]).toBeUndefined(); + }); + + it("includes AO_ISSUE_ID when provided", () => { + const env = agent.getEnvironment(makeLaunchConfig({ issueId: "INT-42" })); + expect(env["AO_ISSUE_ID"]).toBe("INT-42"); + }); +}); + +describe("isProcessRunning", () => { + const agent = create(); + + it("returns true when amp is on tmux pane", async () => { + mockExecFileAsync.mockImplementation((cmd: string, _args: string[]) => { + if (cmd === "tmux") { + return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" }); + } + if (cmd === "ps") { + return Promise.resolve({ + stdout: ` PID TT ARGS\n 789 ttys003 amp --no-color\n`, + stderr: "", + }); + } + return Promise.reject(new Error("unexpected")); + }); + + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(true); + }); + + it("returns false when tmux process missing", async () => { + mockExecFileAsync.mockImplementation((cmd: string) => { + if (cmd === "tmux") return Promise.resolve({ stdout: "/dev/ttys003\n", stderr: "" }); + if (cmd === "ps") { + return Promise.resolve({ stdout: " PID TT ARGS\n 789 ttys003 bash\n", stderr: "" }); + } + return Promise.reject(new Error("unexpected")); + }); + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false); + }); + + it("returns false for tmux handles on Windows without shelling out", async () => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + try { + expect(await agent.isProcessRunning(makeTmuxHandle())).toBe(false); + expect(mockExecFileAsync).not.toHaveBeenCalled(); + } finally { + Object.defineProperty(process, "platform", { + value: originalPlatform, + configurable: true, + }); + } + }); + + it("returns true when process handle pid is alive", async () => { + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + expect(await agent.isProcessRunning(makeProcessHandle(123))).toBe(true); + expect(killSpy).toHaveBeenCalledWith(123, 0); + killSpy.mockRestore(); + }); + + it("treats EPERM as a running process", async () => { + const err = Object.assign(new Error("permission denied"), { code: "EPERM" }); + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => { + throw err; + }); + expect(await agent.isProcessRunning(makeProcessHandle(123))).toBe(true); + killSpy.mockRestore(); + }); + + it("returns false when process handle pid is dead", async () => { + const err = Object.assign(new Error("not found"), { code: "ESRCH" }); + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => { + throw err; + }); + expect(await agent.isProcessRunning(makeProcessHandle(123))).toBe(false); + killSpy.mockRestore(); + }); +}); + +describe("recordActivity", () => { + const agent = create(); + + it("classifies idle terminal output when recording activity", async () => { + await agent.recordActivity?.(makeSession(), "foo\n> "); + expect(mockRecordTerminalActivity).toHaveBeenCalledWith( + "/workspace/test", + "foo\n> ", + expect.any(Function), + ); + const classify = mockRecordTerminalActivity.mock.calls[0]?.[2] as + | ((output: string) => string) + | undefined; + expect(classify?.("foo\n> ")).toBe("idle"); + }); + + it("classifies active terminal output when recording activity", async () => { + await agent.recordActivity?.(makeSession(), "Reading files\nUpdating implementation"); + const classify = mockRecordTerminalActivity.mock.calls[0]?.[2] as + | ((output: string) => string) + | undefined; + expect(classify?.("Reading files\nUpdating implementation")).toBe("active"); + }); + + it("classifies waiting_input terminal output when recording activity", async () => { + await agent.recordActivity?.(makeSession(), "Allow Bash command? [y/N]"); + const classify = mockRecordTerminalActivity.mock.calls[0]?.[2] as + | ((output: string) => string) + | undefined; + expect(classify?.("Allow Bash command? [y/N]")).toBe("waiting_input"); + }); + + it("classifies blocked terminal output when recording activity", async () => { + await agent.recordActivity?.(makeSession(), "Error: API key missing"); + const classify = mockRecordTerminalActivity.mock.calls[0]?.[2] as + | ((output: string) => string) + | undefined; + expect(classify?.("Error: API key missing")).toBe("blocked"); + }); + + it("skips recording when workspacePath is missing", async () => { + await agent.recordActivity?.(makeSession({ workspacePath: null }), "still running"); + expect(mockRecordTerminalActivity).not.toHaveBeenCalled(); + }); +}); + +describe("getActivityState", () => { + const agent = create(); + + it("falls back to exited when runtime handle missing", async () => { + const state = await agent.getActivityState(makeSession({ runtimeHandle: null })); + expect(state).toMatchObject({ state: "exited" }); + }); + + it("falls back to activity JSONL state", async () => { + mockReadLastActivityEntry.mockResolvedValue(makeActivityResult("waiting_input", new Date())); + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + const state = await agent.getActivityState( + makeSession({ + runtimeHandle: makeProcessHandle(101), + metadata: { ampThreadId: VALID_AMP_THREAD_ID }, + }), + ); + expect(state?.state).toBe("waiting_input"); + killSpy.mockRestore(); + }); + + it("decays JSONL fallback state to idle when the entry is stale", async () => { + const activityAt = new Date(Date.now() - 24 * 60 * 60 * 1000); + mockReadLastActivityEntry.mockResolvedValue(makeActivityResult("active", activityAt)); + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + const state = await agent.getActivityState( + makeSession({ + runtimeHandle: makeProcessHandle(101), + metadata: { ampThreadId: VALID_AMP_THREAD_ID }, + }), + ); + expect(state?.state).toBe("idle"); + expect(state?.timestamp.toISOString()).toBe(activityAt.toISOString()); + killSpy.mockRestore(); + }); + + it("returns null when no JSONL activity data is available", async () => { + mockReadLastActivityEntry.mockResolvedValue(null); + const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true); + const state = await agent.getActivityState( + makeSession({ + runtimeHandle: makeProcessHandle(101), + metadata: { ampThreadId: VALID_AMP_THREAD_ID }, + }), + ); + expect(state).toBeNull(); + killSpy.mockRestore(); + }); +}); + +describe("getSessionInfo", () => { + const agent = create(); + + it("returns null without thread id", async () => { + expect(await agent.getSessionInfo(makeSession())).toBeNull(); + }); + + it("returns the amp thread id when metadata is available", async () => { + const info = await agent.getSessionInfo( + makeSession({ metadata: { ampThreadId: VALID_AMP_THREAD_ID } }), + ); + expect(info).toEqual({ + agentSessionId: VALID_AMP_THREAD_ID, + summary: null, + }); + }); +}); + +describe("getRestoreCommand", () => { + const agent = create(); + + it("builds restore command using the thread id only", async () => { + const cmd = await agent.getRestoreCommand?.( + makeSession({ metadata: { ampThreadId: VALID_AMP_THREAD_ID } }), + { + name: "my-project", + repo: "owner/repo", + path: "/workspace/repo", + defaultBranch: "main", + sessionPrefix: "my", + agentConfig: { model: "smart" }, + }, + ); + expect(cmd).toBe(`amp threads continue '${VALID_AMP_THREAD_ID}'`); + }); + + it("returns null when no thread id is available", async () => { + const cmd = await agent.getRestoreCommand?.(makeSession(), { + name: "my-project", + repo: "owner/repo", + path: "/workspace/repo", + defaultBranch: "main", + sessionPrefix: "my", + agentConfig: { model: "smart" }, + }); + expect(cmd).toBeNull(); + }); +}); + +describe("workspace hooks", () => { + const agent = create(); + + it("sets up path wrapper workspace hooks", async () => { + await expect( + agent.setupWorkspaceHooks?.("/workspace/test", { dataDir: "/data", sessionId: "sess-1" }), + ).resolves.toBeUndefined(); + expect(mockSetupPathWrapperWorkspace).toHaveBeenCalledWith("/workspace/test"); + }); + + it("sets up path wrapper after launch", async () => { + await expect( + agent.postLaunchSetup?.(makeSession({ workspacePath: "/workspace/test" })), + ).resolves.toBeUndefined(); + expect(mockSetupPathWrapperWorkspace).toHaveBeenCalledWith("/workspace/test"); + }); + + it("skips post-launch setup when workspacePath is missing", async () => { + await expect( + agent.postLaunchSetup?.(makeSession({ workspacePath: null })), + ).resolves.toBeUndefined(); + expect(mockSetupPathWrapperWorkspace).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/plugins/agent-amp/src/index.ts b/packages/plugins/agent-amp/src/index.ts new file mode 100644 index 000000000..69a3ebfc4 --- /dev/null +++ b/packages/plugins/agent-amp/src/index.ts @@ -0,0 +1,292 @@ +import { + DEFAULT_READY_THRESHOLD_MS, + DEFAULT_ACTIVE_WINDOW_MS, + shellEscape, + isWindows, + readLastActivityEntry, + checkActivityLogState, + getActivityFallbackState, + recordTerminalActivity, + setupPathWrapperWorkspace, + type Agent, + type AgentSessionInfo, + type AgentLaunchConfig, + type ActivityDetection, + type ActivityState, + type PluginModule, + type ProjectConfig, + type RuntimeHandle, + type Session, + type WorkspaceHooksConfig, + type AgentSpecificConfig, +} from "@aoagents/ao-core"; +import { execFile } from "node:child_process"; +import { createRequire } from "node:module"; +import { promisify } from "node:util"; +import which from "which"; + +const require = createRequire(import.meta.url); +const packageJson = require("../package.json") as { + name: string; + version: string; + description: string; +}; +const PACKAGE_NAME_PREFIX = "@aoagents/ao-plugin-agent-"; +const pluginName = packageJson.name.startsWith(PACKAGE_NAME_PREFIX) + ? packageJson.name.slice(PACKAGE_NAME_PREFIX.length) + : packageJson.name; + +const execFileAsync = promisify(execFile); + +interface AmpAgentConfig extends AgentSpecificConfig { + ampThreadId?: unknown; +} + +interface AmpPromptPart { + type: "text" | "file"; + value: string; +} + +function asAmpThreadReference(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + if (!trimmed) return undefined; + if (/\s/.test(trimmed)) return undefined; + for (let index = 0; index < trimmed.length; index += 1) { + const code = trimmed.charCodeAt(index); + if (code < 0x20 || code === 0x7f) return undefined; + } + return trimmed; +} + +const AMP_PROMPT_LAUNCHER_SCRIPT = [ + 'const {spawn}=require("node:child_process");', + 'const fs=require("node:fs");', + "const payload=JSON.parse(process.argv[1]);", + "const promptParts=Array.isArray(payload.promptParts)?payload.promptParts:[];", + "const input=promptParts.map((part)=>part.type==='file'?fs.readFileSync(part.value,'utf8'):String(part.value??'')).filter(Boolean).join('\\n\\n');", + "const args=Array.isArray(payload.args)?payload.args:[];", + "const child=spawn('amp',args,{stdio:['pipe','inherit','inherit'],shell:process.platform==='win32',windowsHide:true});", + "child.on('error',(err)=>{console.error(err?.message||String(err));process.exit(1);});", + "child.on('exit',(code)=>process.exit(code??0));", + "child.stdin.end(input);", +].join(""); + +function buildAmpPromptParts(config: AgentLaunchConfig): AmpPromptPart[] { + const promptParts: AmpPromptPart[] = []; + if (config.systemPromptFile) { + promptParts.push({ type: "file", value: config.systemPromptFile }); + } else if (config.systemPrompt) { + promptParts.push({ type: "text", value: config.systemPrompt }); + } + + if (config.prompt) { + promptParts.push({ type: "text", value: config.prompt }); + } + + return promptParts; +} + +function buildAmpLaunchCommand( + baseCommand: string, + args: string[], + config: AgentLaunchConfig, +): string { + const promptParts = buildAmpPromptParts(config); + if (promptParts.length === 0) { + return baseCommand; + } + + const payload = JSON.stringify({ args, promptParts }); + return `node -e ${shellEscape(AMP_PROMPT_LAUNCHER_SCRIPT)} ${shellEscape(payload)}`; +} + +const ANSI_ESCAPE_RE = new RegExp( + `${String.fromCharCode(27)}(?:[@-Z\\-_]|\\[[0-?]*[ -/]*[@-~])`, + "g", +); +const AMP_WAITING_PROMPT_RE = + /^(?:[?›>❯]\s*)?(?:do you want|would you like|allow|approve|proceed|continue|select|choose|confirm)\b.*(?:\?|:)\s*(?:\[[^\]]+\]|\([^)]*\)|[Yy]\/[Nn])?\s*$/i; +const AMP_BLOCKED_RE = + /\b(error|failed|exception|not logged in|login required|authentication required|api key missing)\b/i; + +function classifyAmpTerminalOutput(terminalOutput: string): ActivityState { + const normalizedOutput = terminalOutput.replaceAll(ANSI_ESCAPE_RE, "").trim(); + if (!normalizedOutput) return "idle"; + + const lines = normalizedOutput.split("\n").map((line) => line.trim()); + const lastLine = lines[lines.length - 1] ?? ""; + const lastNonEmptyLine = [...lines].reverse().find(Boolean) ?? ""; + + if (/^(?:[│┃┆┊]\s*)?[>$#]\s*$/.test(lastLine)) return "idle"; + if (AMP_WAITING_PROMPT_RE.test(lastNonEmptyLine)) return "waiting_input"; + if (AMP_BLOCKED_RE.test(lastLine)) return "blocked"; + + return "active"; +} + +export const manifest = { + name: pluginName, + slot: "agent" as const, + description: packageJson.description, + version: packageJson.version, + displayName: "Amp", +}; + +function createAmpAgent(): Agent { + return { + name: pluginName, + processName: pluginName, + + getLaunchCommand(config: AgentLaunchConfig): string { + const threadId = asAmpThreadReference( + (config.projectConfig.agentConfig as AmpAgentConfig | undefined)?.ampThreadId, + ); + if (!threadId) { + return buildAmpLaunchCommand("amp", [], config); + } + return buildAmpLaunchCommand( + `amp threads continue ${shellEscape(threadId)}`, + ["threads", "continue", threadId], + config, + ); + }, + + getEnvironment(config: AgentLaunchConfig): Record { + const env: Record = {}; + env["AO_SESSION_ID"] = config.sessionId; + if (config.issueId) { + env["AO_ISSUE_ID"] = config.issueId; + } + + env["NO_ANIMATION"] = "1"; + + return env; + }, + + detectActivity(terminalOutput: string): ActivityState { + return classifyAmpTerminalOutput(terminalOutput); + }, + + async getActivityState( + session: Session, + readyThresholdMs?: number, + ): Promise { + const threshold = readyThresholdMs ?? DEFAULT_READY_THRESHOLD_MS; + const activeWindowMs = Math.min(DEFAULT_ACTIVE_WINDOW_MS, threshold); + + const exitedAt = new Date(); + if (!session.runtimeHandle) return { state: "exited", timestamp: exitedAt }; + const running = await this.isProcessRunning(session.runtimeHandle); + if (!running) return { state: "exited", timestamp: exitedAt }; + + let activityResult: Awaited> = null; + if (session.workspacePath) { + activityResult = await readLastActivityEntry(session.workspacePath); + const activityState = checkActivityLogState(activityResult); + if (activityState) return activityState; + } + + const fallback = getActivityFallbackState(activityResult, activeWindowMs, threshold); + if (fallback) return fallback; + + return null; + }, + + async recordActivity(session: Session, terminalOutput: string): Promise { + if (!session.workspacePath) return; + await recordTerminalActivity(session.workspacePath, terminalOutput, (output: string) => + classifyAmpTerminalOutput(output), + ); + }, + + async isProcessRunning(handle: RuntimeHandle): Promise { + try { + if (handle.runtimeName === "tmux" && handle.id) { + if (isWindows()) return false; + const { stdout: ttyOut } = await execFileAsync( + "tmux", + ["list-panes", "-t", handle.id, "-F", "#{pane_tty}"], + { timeout: 30_000 }, + ); + const ttys = ttyOut + .trim() + .split("\n") + .map((t) => t.trim()) + .filter(Boolean); + if (ttys.length === 0) return false; + + const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], { + timeout: 30_000, + }); + const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, ""))); + const processRe = /(?:^|\/)amp(?:\s|$)/; + for (const line of psOut.split("\n")) { + const cols = line.trimStart().split(/\s+/); + if (cols.length < 3 || !ttySet.has(cols[1] ?? "")) continue; + const args = cols.slice(2).join(" "); + if (processRe.test(args)) { + return true; + } + } + return false; + } + + const rawPid = handle.data["pid"]; + const pid = typeof rawPid === "number" ? rawPid : Number(rawPid); + if (Number.isFinite(pid) && pid > 0) { + try { + process.kill(pid, 0); + return true; + } catch (err: unknown) { + if (err instanceof Error && (err as NodeJS.ErrnoException).code === "EPERM") { + return true; + } + return false; + } + } + return false; + } catch { + return false; + } + }, + + async getSessionInfo(session: Session): Promise { + const threadId = asAmpThreadReference(session.metadata?.ampThreadId); + if (!threadId) return null; + return { + agentSessionId: threadId, + summary: null, + }; + }, + + async getRestoreCommand(session: Session, _project: ProjectConfig): Promise { + const threadId = asAmpThreadReference(session.metadata?.ampThreadId); + if (!threadId) return null; + return `amp threads continue ${shellEscape(threadId)}`; + }, + + async setupWorkspaceHooks(workspacePath: string, _config: WorkspaceHooksConfig): Promise { + await setupPathWrapperWorkspace(workspacePath); + }, + + async postLaunchSetup(session: Session): Promise { + if (!session.workspacePath) return; + await setupPathWrapperWorkspace(session.workspacePath); + }, + }; +} + +export function create(): Agent { + return createAmpAgent(); +} + +export function detect(): boolean { + try { + return Boolean(which.sync("amp")); + } catch { + return false; + } +} + +export default { manifest, create, detect } satisfies PluginModule; diff --git a/packages/plugins/agent-amp/tsconfig.json b/packages/plugins/agent-amp/tsconfig.json new file mode 100644 index 000000000..556dd3e4e --- /dev/null +++ b/packages/plugins/agent-amp/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.node.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/web/package.json b/packages/web/package.json index 480e3a0b4..c6e5fe06f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@aoagents/ao-core": "workspace:*", + "@aoagents/ao-plugin-agent-amp": "workspace:*", "@aoagents/ao-plugin-agent-claude-code": "workspace:*", "@aoagents/ao-plugin-agent-codex": "workspace:*", "@aoagents/ao-plugin-agent-cursor": "workspace:*", diff --git a/packages/web/src/lib/services.ts b/packages/web/src/lib/services.ts index ef0c5402c..7b6ba24e8 100644 --- a/packages/web/src/lib/services.ts +++ b/packages/web/src/lib/services.ts @@ -37,6 +37,7 @@ import pluginRuntimeTmux from "@aoagents/ao-plugin-runtime-tmux"; import pluginRuntimeProcess from "@aoagents/ao-plugin-runtime-process"; import pluginAgentClaudeCode from "@aoagents/ao-plugin-agent-claude-code"; import pluginAgentCodex from "@aoagents/ao-plugin-agent-codex"; +import pluginAgentAmp from "@aoagents/ao-plugin-agent-amp"; import pluginAgentCursor from "@aoagents/ao-plugin-agent-cursor"; import pluginAgentKimicode from "@aoagents/ao-plugin-agent-kimicode"; import pluginAgentOpencode from "@aoagents/ao-plugin-agent-opencode"; @@ -109,6 +110,7 @@ async function initServices(): Promise { registry.register(pluginRuntimeProcess); registry.register(pluginAgentClaudeCode); registry.register(pluginAgentCodex); + registry.register(pluginAgentAmp); registry.register(pluginAgentCursor); registry.register(pluginAgentKimicode); registry.register(pluginAgentOpencode); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 632a7cb12..42be15c2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,6 +58,9 @@ importers: '@aoagents/ao-plugin-agent-aider': specifier: workspace:* version: link:../plugins/agent-aider + '@aoagents/ao-plugin-agent-amp': + specifier: workspace:* + version: link:../plugins/agent-amp '@aoagents/ao-plugin-agent-claude-code': specifier: workspace:* version: link:../plugins/agent-claude-code @@ -277,6 +280,28 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + packages/plugins/agent-amp: + dependencies: + '@aoagents/ao-core': + specifier: workspace:* + version: link:../../core + which: + specifier: ^6.0.1 + version: 6.0.1 + devDependencies: + '@types/node': + specifier: ^25.2.3 + version: 25.6.0 + '@types/which': + specifier: ^3.0.4 + version: 3.0.4 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(jsdom@25.0.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3) + packages/plugins/agent-claude-code: dependencies: '@aoagents/ao-core': @@ -646,6 +671,9 @@ importers: '@aoagents/ao-core': specifier: workspace:* version: link:../core + '@aoagents/ao-plugin-agent-amp': + specifier: workspace:* + version: link:../plugins/agent-amp '@aoagents/ao-plugin-agent-claude-code': specifier: workspace:* version: link:../plugins/agent-claude-code @@ -2081,6 +2109,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/which@3.0.4': + resolution: {integrity: sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w==} + '@types/wrap-ansi@3.0.0': resolution: {integrity: sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==} @@ -5587,6 +5618,8 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/which@3.0.4': {} + '@types/wrap-ansi@3.0.0': {} '@types/ws@8.18.1':