import { type Plugin, type PluginInput, type PluginOptions, tool } from "@opencode-ai/plugin" import type { TextPartInput } from "@opencode-ai/sdk" export const id = "opencode-session-restart" type PluginClient = PluginInput["client"] export type RestartPluginOptions = { /** * Static text always prepended to every restart prompt, for agents that do * not define their own `handoff_text`. An agent-defined `handoff_text` * overrides this. Default: none. */ static_text?: string } const DEFAULT_OPTIONS: RestartPluginOptions = {} function buildHandoff(task: string, notes: string | undefined, staticText: string | undefined) { const state = notes?.trim() return [ "Work was restarted in this session with a clean context window.", "", "The full earlier conversation remains visible in the transcript above this message; it is no longer part of the model context. Do not replay it — only the standing instructions and state notes below, plus the current on-disk state (working tree, git status, staged or uncommitted changes), are available context.", ...(staticText ? ["", "STANDING INSTRUCTIONS (always apply):", staticText] : []), "", "STATE NOTES:", state && state.length > 0 ? state : "None provided — orient yourself by inspecting git status, the diff, and open files.", "", "FOCUSED TASK (this session only):", task.trim(), ].join("\n") } async function resolveStaticText( client: PluginClient, currentAgent: string, targetAgent: string | undefined, fallback: string | undefined, ) { const agentName = targetAgent ?? currentAgent try { const cfg = await client.config.get() const agent = cfg.data?.agent?.[agentName] const direct = agent?.handoff_text if (typeof direct === "string" && direct.trim()) return direct const opts = agent?.options as Record | undefined const folded = opts?.handoff_text if (typeof folded === "string" && folded.trim()) return folded } catch { // config lookup failed — fall through to the plugin-level static_text } return fallback } function normalizeModel(value: string) { const slash = value.indexOf("/") if (slash <= 0 || slash === value.length - 1) { throw new Error(`model must be "providerID/modelID", got "${value}"`) } return { providerID: value.slice(0, slash), modelID: value.slice(slash + 1) } } async function resolveCompactModel( client: PluginClient, sessionID: string, explicit: string | undefined, ): Promise<{ providerID: string; modelID: string }> { if (explicit) return normalizeModel(explicit) const page = await client.session.messages({ path: { id: sessionID } }) const lastUser = (page.data ?? []).filter((entry) => entry.info.role === "user").at(-1) const model = lastUser && "model" in lastUser.info ? lastUser.info.model : undefined if (!model?.providerID || !model.modelID) { throw new Error("could not resolve the current model — pass the model argument") } return { providerID: model.providerID, modelID: model.modelID } } const HANDOFF_MARKER = "Work was restarted in this session with a clean context window." async function waitFor( client: PluginClient, sessionID: string, test: (messages: { info: import("@opencode-ai/sdk").Message; parts: import("@opencode-ai/sdk").Part[] }[]) => boolean, timeoutMs = 30000, intervalMs = 250, ) { const deadline = Date.now() + timeoutMs while (true) { const page = await client.session.messages({ path: { id: sessionID } }) if (test(page.data ?? [])) return if (Date.now() >= deadline) throw new Error(`timed out after ${timeoutMs}ms waiting for the session to change`) await new Promise((resolve) => setTimeout(resolve, intervalMs)) } } export const server: Plugin = async ({ client }, rawOptions = {}) => { const options: RestartPluginOptions = { ...DEFAULT_OPTIONS, ...(rawOptions as Partial), } return { tool: { session_restart: tool({ description: "Restart the current session in place: clear the model context beyond the handoff prompt while keeping the " + "earlier conversation visible in the transcript. Compacts the session, then prompts a focused task into the " + "same session. Use this when the current session is getting too long or about to compact, so work continues " + "without the context window growing further. After calling this tool, stop working here and hand off — the " + "restarted session continues.", args: { task: tool.schema .string() .describe("The single focused task for the restarted session, written as a fresh prompt."), notes: tool .schema.string() .optional() .describe( "Durable context to carry over: decisions, constraints, files touched, unfinished steps, commands run. Omit to rely on the working tree only.", ), model: tool .schema.string() .optional() .describe('Model to compact with as "providerID/modelID". Defaults to the current session model.'), agent: tool.schema.string().optional().describe("Agent for the restarted session. Defaults to the current agent."), }, async execute(args, ctx) { const { providerID, modelID } = await resolveCompactModel(client, ctx.sessionID, args.model) void client.session .summarize({ path: { id: ctx.sessionID }, body: { providerID, modelID }, }) .catch(() => { // The summarize request parks server-side until the running loop // finishes; the compaction message it writes is what matters, and it // is created synchronously before that wait. Errors here are // surface-level (the loop below still processes the compaction). }) await waitFor(client, ctx.sessionID, (messages) => messages.some( (entry) => entry.info.role === "user" && entry.parts.some((part) => part.type === "compaction"), ), ) const staticText = await resolveStaticText(client, ctx.agent, args.agent, options.static_text) const handoff = buildHandoff(args.task, args.notes, staticText) await client.session.promptAsync({ path: { id: ctx.sessionID }, body: { parts: [{ type: "text", text: handoff } as TextPartInput], ...(args.model ? { model: normalizeModel(args.model) } : {}), ...(args.agent ? { agent: args.agent } : {}), }, }) await waitFor(client, ctx.sessionID, (messages) => messages.some( (entry) => entry.info.role === "user" && entry.parts.some((part) => part.type === "text" && part.text?.includes(HANDOFF_MARKER)), ), ) return { title: "Session context cleared", output: `Cleared this session's context (compacted) and sent the focused task as a new prompt in the same session. ` + `The earlier conversation stays visible in the transcript above. Continue the work here; no need to switch sessions.`, metadata: { sessionID: ctx.sessionID, compacted: { providerID, modelID }, restarted: true, }, } }, }), }, } } export default { id, server }