import { shellEscape, type Agent, type AgentSessionInfo, type AgentLaunchConfig, type ActivityState, type PluginModule, type RuntimeHandle, type Session, } from "@composio/ao-core"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); // ============================================================================= // Plugin Manifest // ============================================================================= export const manifest = { name: "opencode", slot: "agent" as const, description: "Agent plugin: OpenCode", version: "0.1.0", }; // ============================================================================= // Agent Implementation // ============================================================================= function createOpenCodeAgent(): Agent { return { name: "opencode", processName: "opencode", getLaunchCommand(config: AgentLaunchConfig): string { const parts: string[] = ["opencode"]; if (config.prompt) { parts.push("run", shellEscape(config.prompt)); } if (config.model) { parts.push("--model", shellEscape(config.model)); } return parts.join(" "); }, getEnvironment(config: AgentLaunchConfig): Record { const env: Record = {}; env["AO_SESSION_ID"] = config.sessionId; // NOTE: AO_PROJECT_ID is the caller's responsibility (spawn.ts sets it) if (config.issueId) { env["AO_ISSUE_ID"] = config.issueId; } return env; }, detectActivity(terminalOutput: string): ActivityState { if (!terminalOutput.trim()) return "idle"; // OpenCode doesn't have rich terminal output patterns yet return "active"; }, async getActivityState(session: Session, _readyThresholdMs?: number): Promise { // Check if process is running first if (!session.runtimeHandle) return "exited"; const running = await this.isProcessRunning(session.runtimeHandle); if (!running) return "exited"; // NOTE: OpenCode stores all session data in a single global SQLite database // at ~/.local/share/opencode/opencode.db without per-workspace scoping. When // multiple OpenCode sessions run in parallel, database modifications from any // session will cause all sessions to appear active. Until OpenCode provides // per-workspace session tracking, we return null (unknown) rather than guessing. // // TODO: Implement proper per-session activity detection when OpenCode supports it. return null; }, async isProcessRunning(handle: RuntimeHandle): Promise { try { if (handle.runtimeName === "tmux" && handle.id) { const { stdout: ttyOut } = await execFileAsync( "tmux", ["list-panes", "-t", handle.id, "-F", "#{pane_tty}"], { timeout: 30_000 }, ); const ttys = ttyOut .trim() .split("\n") .map((t) => t.trim()) .filter(Boolean); if (ttys.length === 0) return false; const { stdout: psOut } = await execFileAsync("ps", ["-eo", "pid,tty,args"], { timeout: 30_000, }); const ttySet = new Set(ttys.map((t) => t.replace(/^\/dev\//, ""))); const processRe = /(?:^|\/)opencode(?:\s|$)/; for (const line of psOut.split("\n")) { const cols = line.trimStart().split(/\s+/); if (cols.length < 3 || !ttySet.has(cols[1] ?? "")) continue; const args = cols.slice(2).join(" "); if (processRe.test(args)) { return true; } } return false; } const rawPid = handle.data["pid"]; const pid = typeof rawPid === "number" ? rawPid : Number(rawPid); if (Number.isFinite(pid) && pid > 0) { try { process.kill(pid, 0); return true; } catch (err: unknown) { if (err instanceof Error && "code" in err && err.code === "EPERM") { return true; } return false; } } return false; } catch { return false; } }, async getSessionInfo(_session: Session): Promise { // OpenCode doesn't have JSONL session files for introspection yet return null; }, }; } // ============================================================================= // Plugin Export // ============================================================================= export function create(): Agent { return createOpenCodeAgent(); } export default { manifest, create } satisfies PluginModule;