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 = { /** * Abort the current session once the new session has been started. * Freezes the old context so the restarted session is the single active one. * Default: true. */ abort_current: boolean /** * Reject the synthetic "continue" turn opencode appends after compaction, * forcing a human (or the restart tool) to decide what happens next. * Default: true. */ disable_compaction_continue: boolean /** * 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 = { abort_current: true, disable_compaction_continue: true, } const MAX_TITLE_LENGTH = 80 function buildHandoff(task: string, notes: string | undefined, staticText: string | undefined) { const state = notes?.trim() return [ "You are continuing work that was restarted into this fresh session with a clean context window.", "", "Do not reconstruct or repeat the earlier conversation. 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) } } export const server: Plugin = async ({ client }, rawOptions = {}) => { const options: RestartPluginOptions = { ...DEFAULT_OPTIONS, ...(rawOptions as Partial), } return { tool: { session_restart: tool({ description: "Start a fresh child session on this project with a clean context window and prompt it to continue a single focused task. " + "Use this when the current session is getting too long or about to compact, so work continues without losing context fidelity. " + "The current session is aborted by default: after calling this tool, stop working and hand off.", 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 for the restarted session as "providerID/modelID". Defaults to this session\'s model.'), agent: tool.schema.string().optional().describe("Agent for the restarted session. Defaults to the current agent."), abort_current: tool .schema.boolean() .optional() .describe("Abort this session after restarting. Defaults to true."), }, async execute(args, ctx) { const abortCurrent = args.abort_current ?? options.abort_current const title = args.task.length > MAX_TITLE_LENGTH ? `${args.task.slice(0, MAX_TITLE_LENGTH - 1)}…` : args.task const created = await client.session.create({ body: { title, parentID: ctx.sessionID }, }) const restartedID = created.data?.id if (!restartedID) throw new Error("session.create returned no session id") const staticText = await resolveStaticText(client, ctx.agent, args.agent, options.static_text) const parts: TextPartInput[] = [{ type: "text", text: buildHandoff(args.task, args.notes, staticText) }] await client.session.promptAsync({ path: { id: restartedID }, body: { parts, ...(args.model ? { model: normalizeModel(args.model) } : {}), ...(args.agent ? { agent: args.agent } : {}), }, }) if (abortCurrent) { await client.session.abort({ path: { id: ctx.sessionID } }).catch(() => undefined) } return { title: `Restarted in session ${restartedID}`, output: `Started a fresh child session ${restartedID} with a clean context window. It is already running the focused task. Switch to that session to continue.`, metadata: { restartedSessionID: restartedID, parentSessionID: ctx.sessionID, abort_current: abortCurrent, }, } }, }), }, "experimental.compaction.autocontinue": async (_input, output) => { if (options.disable_compaction_continue) { output.enabled = false } }, } } export default { id, server }