import {tool, type PluginInput} from "@opencode-ai/plugin"; import type {SessionMessagesResponse} from "@opencode-ai/sdk"; import {readFile} from "node:fs/promises"; import {buildHandoffBundle, runHandoff} from "../agent-plugin-core/typescript/src/handoff.ts"; type NativeMessage = SessionMessagesResponse[number]; /** OpenCode's native filterCompacted order: latest summary, retained tail, later turns. */ export function activeMessages(messages: NativeMessage[]): NativeMessage[] { for (let i = messages.length - 1; i >= 0; i--) { const info = messages[i].info; if (info.role !== "assistant" || !info.summary || !info.finish || info.error) continue; const boundary = messages.findIndex(m => m.info.id === info.parentID && m.parts.some(p => p.type === "compaction")); if (boundary < 0) throw new Error("OpenCode compaction boundary is unavailable."); const part = messages[boundary].parts.find(p => p.type === "compaction") as {tail_start_id?: string}; if (!part.tail_start_id) return messages.slice(boundary); const tail = messages.findIndex(m => m.info.id === part.tail_start_id); if (tail < 0 || tail >= boundary) throw new Error("OpenCode retained compaction history is unavailable."); return [...messages.slice(boundary, i + 1), ...messages.slice(tail, boundary), ...messages.slice(i + 1)]; } return messages; } export function createHandoffTool(client: PluginInput["client"]) { return tool({ description: "Only on explicit user request, continue this OpenCode session in a new Codex task with its full active conversation and completed tool results. Requires Python 3.11+ and an installed, signed-in Codex CLI.", args: {}, async execute(_args, context) { const [session, history] = await Promise.all([ client.session.get({path: {id: context.sessionID}, throwOnError: true}), client.session.messages({path: {id: context.sessionID}, throwOnError: true}), ]); if (!session.data || !history.data) throw new Error("The active OpenCode session is unavailable."); const messages: {role: string; content: unknown}[] = []; let omittedInvocation = false; let pruned = false; for (const message of activeMessages(history.data)) { if (message.info.role === "assistant" && message.info.error) { throw new Error("OpenCode assistant response was interrupted or failed; complete it before handoff."); } if (message.info.role === "assistant" && message.info.id !== context.messageID && !message.info.time.completed) { throw new Error("OpenCode has another unfinished assistant response; finish it before handoff."); } for (const part of message.parts) { if (part.type === "text") { if (!part.ignored) messages.push({role: message.info.role, content: part.text}); } else if (part.type === "reasoning") { messages.push({role: message.info.role, content: [{type: "reasoning", text: part.text}]}); } else if (part.type === "tool") { if (message.info.id === context.messageID && part.tool === "mem0_handoff" && part.state.status === "running") { if (omittedInvocation) throw new Error("Multiple active handoff calls; invoke one handoff at a time."); omittedInvocation = true; continue; } if (part.state.status !== "completed" && part.state.status !== "error") throw new Error(`OpenCode tool ${part.tool} is unfinished; finish it before handoff.`); messages.push({role: "assistant", content: [{type: "toolCall", id: part.callID, name: part.tool, arguments: part.state.input}]}); const compacted = part.state.status === "completed" && Boolean(part.state.time.compacted); pruned ||= compacted; const interruptedOutput = part.state.status === "error" && part.state.metadata?.interrupted === true && typeof part.state.metadata.output === "string" ? part.state.metadata.output : undefined; const content: unknown[] = [{type: "text", text: part.state.status === "error" ? interruptedOutput ?? part.state.error : compacted ? "[Old tool result content cleared]" : part.state.output}]; if (part.state.status === "completed" && !compacted) for (const attachment of part.state.attachments ?? []) content.push(await fileContent(attachment)); messages.push({role: "toolResult", content, ...{toolCallId: part.callID, isError: part.state.status === "error" && interruptedOutput === undefined}}); } else if (part.type === "file") { // OpenCode expands these descriptors into adjacent text parts before saving. if (message.info.role !== "user" || !["text/plain", "application/x-directory"].includes(part.mime)) { messages.push({role: message.info.role, content: [await fileContent(part)]}); } } else if (part.type === "compaction") { messages.push({role: "user", content: "What did we do so far?"}); } else if (part.type === "subtask") { messages.push({role: "user", content: "The following tool was executed by the user"}); } else if (!["step-start", "step-finish", "snapshot", "patch", "agent", "retry"].includes(part.type)) { throw new Error(`Unsupported OpenCode session part: ${part.type}`); } } } const bundle = await buildHandoffBundle({host: "opencode", session_id: context.sessionID, title: session.data.title, cwd: session.data.directory}, messages); if (pruned) bundle.warnings.push("OpenCode already cleared older tool outputs; its active-context placeholders were preserved."); return runHandoff(new URL("./session_handoff.py", import.meta.url), bundle); }, }); } async function fileContent(file: {mime: string; url: string}) { if (!file.mime.startsWith("image/")) throw new Error(`Unsupported OpenCode attachment type: ${file.mime}`); const url = file.url.startsWith("data:") ? file.url : file.url.startsWith("file:") ? `data:${file.mime};base64,${(await readFile(new URL(file.url))).toString("base64")}` : undefined; if (!url) throw new Error("OpenCode attachment bytes are unavailable locally."); return {type: "image", image_url: url}; } export function registerHandoffCommand(config: {command?: Record}) { config.command ??= {}; config.command["mem0-handoff"] = { description: "Continue this OpenCode session in Codex", template: `Transfer this OpenCode session to Codex using mem0_handoff. Usage: /mem0-handoff codex\nArguments: $ARGUMENTS\nRequire the single target codex. If invalid, show usage and do not invoke the tool. Do not summarize or read other sessions. Call mem0_handoff directly, alone, and report its result.`, }; }