import { shellEscape, type Agent, type AgentSessionInfo, type AgentLaunchConfig, type ActivityDetection, 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); interface OpenCodeSessionListEntry { id: string; title?: string; updated?: string; } const OPENCODE_SESSION_ID_RE = /^ses_[A-Za-z0-9_-]+$/; function asValidOpenCodeSessionId(value: unknown): string | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); if (trimmed.length === 0) return undefined; return OPENCODE_SESSION_ID_RE.test(trimmed) ? trimmed : undefined; } function parseSessionList(raw: string): OpenCodeSessionListEntry[] { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return []; } if (!Array.isArray(parsed)) return []; return parsed.filter((item): item is OpenCodeSessionListEntry => { if (!item || typeof item !== "object") return false; const record = item as Record; return asValidOpenCodeSessionId(record["id"]) !== undefined; }); } function asOptionalString(value: unknown): string | undefined { return typeof value === "string" && value.length > 0 ? value : undefined; } // ============================================================================= // 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 options: string[] = []; const sharedOptions: string[] = []; const existingSessionId = asOptionalString( config.projectConfig.agentConfig?.["opencodeSessionId"], ); if (existingSessionId) { options.push("--session", shellEscape(existingSessionId)); } // Select specific OpenCode subagent if configured if (config.subagent) { sharedOptions.push("--agent", shellEscape(config.subagent)); } let promptValue: string | undefined; if (config.prompt) { if (config.systemPromptFile) { promptValue = `"$(cat ${shellEscape(config.systemPromptFile)}; printf '\\n\\n'; printf %s ${shellEscape(config.prompt)})"`; } else if (config.systemPrompt) { promptValue = shellEscape(`${config.systemPrompt}\n\n${config.prompt}`); } else { promptValue = shellEscape(config.prompt); } } else if (config.systemPromptFile) { promptValue = `"$(cat ${shellEscape(config.systemPromptFile)})"`; } else if (config.systemPrompt) { promptValue = shellEscape(config.systemPrompt); } if (promptValue) { options.push("--prompt", promptValue); } if (config.model) { sharedOptions.push("--model", shellEscape(config.model)); } if (!existingSessionId) { const runOptions = ["--title", shellEscape(`AO:${config.sessionId}`), ...sharedOptions]; const runCommand = promptValue ? ["opencode", "run", ...runOptions, promptValue].join(" ") : ["opencode", "run", ...runOptions, "--command", "true"].join(" "); const continueSession = `"$(opencode session list --format json | node -e ${shellEscape("let input='';process.stdin.on('data',c=>input+=c).on('end',()=>{const title=process.argv[1];const rows=JSON.parse(input);if(!Array.isArray(rows))process.exit(1);const matches=rows.filter((r)=>r&&r.title===title&&typeof r.id==='string').sort((a,b)=>{const aUpdated=Date.parse(typeof a.updated==='string'?a.updated:'');const bUpdated=Date.parse(typeof b.updated==='string'?b.updated:'');const aScore=Number.isNaN(aUpdated)?0:aUpdated;const bScore=Number.isNaN(bUpdated)?0:bUpdated;return bScore-aScore;});if(matches.length===0)process.exit(1);process.stdout.write(matches[0].id);});")} ${shellEscape(`AO:${config.sessionId}`)})"`; const continueCommand = ["opencode", "--session", continueSession, ...sharedOptions].join( " ", ); return `${runCommand} && exec ${continueCommand}`; } options.push(...sharedOptions); return ["opencode", ...options].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 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 }; if (session.metadata?.opencodeSessionId) { try { const { stdout } = await execFileAsync( "opencode", ["session", "list", "--format", "json"], { timeout: 30_000 }, ); const sessions = parseSessionList(stdout); const targetSession = sessions.find((s) => s.id === session.metadata.opencodeSessionId); if (targetSession) { const lastActivity = targetSession.updated ? new Date(targetSession.updated) : undefined; return { state: "active", ...(lastActivity && !Number.isNaN(lastActivity.getTime()) && { timestamp: lastActivity }), }; } } catch { return null; } } 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; }, async postLaunchSetup(_session: Session): Promise { return; }, }; } // ============================================================================= // Plugin Export // ============================================================================= export function create(): Agent { return createOpenCodeAgent(); } export default { manifest, create } satisfies PluginModule;