commit 007f4fed6daf0f0743d6fc4f1c3353a1d692eefa Author: emidab Date: Tue Sep 15 21:32:43 2026 +0000 feat: add session_restart tool plugin Registers a session_restart tool that spawns a fresh child session on the current project, prompts it with a focused task (+ optional notes) via promptAsync, and aborts the current session by default so a single focused task continues in a clean context window instead of compacting. Includes an experimental.compaction.autocontinue hook to reject the synthetic continue turn after compaction (default on). diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6b3b97d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tsbuildinfo +.DS_Store \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..69e3079 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# opencode-session-restart + +opencode plugin that lets the agent restart a session into a fresh child session with a clean context window and a focused prompt, dodging context compaction loss. + +When a session runs long enough to trigger compaction, opencode summarizes the conversation into a prompt and keeps going — fidelity you did not choose. This plugin gives the agent a first-class escape hatch: start a new child session on the same project, prompt it with a curated fresh prompt, and (by default) freeze the old session. The single focused task continues in a fresh window. + +## Install + +Add the plugin to your opencode config: + +```json +{ + "plugin": ["opencode-session-restart"] +} +``` + +or from a local clone: + +```json +{ + "plugin": [["/path/to/opencode-session-restart", { "abort_current": true }]] +} +``` + +## What it does + +The plugin registers one tool, `session_restart`, that the agent can call when the session is getting long: + +- Creates a child session (via the SDK `session.create` with `parentID`), so it shows up in the session tree under the original. +- Immediately prompts it (via `session.promptAsync`) with the focused `task` plus optional carry-over `notes` and a fresh-start preamble that tells the new session not to reconstruct the old conversation and to orient itself from the working tree. +- Aborts the current session by default, so only the restarted session stays active. +- Returns the new session ID so the agent can tell you where to switch. + +### Tool arguments + +| argument | type | default | description | +| --------------- | ------- | ------- | ----------- | +| `task` | string | — | The single focused task for the restarted session, written as a fresh prompt. | +| `notes` | string | — | Durable context to carry over: decisions, constraints, files touched, unfinished steps. Omit to rely on the working tree only. | +| `model` | string | current | `"providerID/modelID"` for the restarted session. | +| `agent` | string | current | Agent for the restarted session. | +| `abort_current` | boolean | `true` | Abort this session after restarting. | + +### Notes on behavior + +- The new session keeps the fresh prompt as its user-turn text and immediately starts working. On-disk state (git status, staged or uncommitted changes) carries over because it is the same project directory. +- `abort_current` freezes the original session after the new one is started. Because abort cancels the current agent turn, the tool's returned text may not be delivered to the old session — the side effects (create + prompt) complete first. +- `disable_compaction_continue` (default `true`) rejects the synthetic "continue" turn opencode would append after a compaction, so a compaction that does slip through never silently continues. Set it to `false` to keep stock behavior. + +## Development + +```bash +bun install +bunx tsc --noEmit +``` + +## License + +MIT \ No newline at end of file diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..d59f561 --- /dev/null +++ b/index.ts @@ -0,0 +1,128 @@ +import { type Plugin, type PluginOptions, tool } from "@opencode-ai/plugin" +import type { TextPartInput } from "@opencode-ai/sdk" + +export const id = "opencode-session-restart" + +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 +} + +const DEFAULT_OPTIONS: RestartPluginOptions = { + abort_current: true, + disable_compaction_continue: true, +} + +const MAX_TITLE_LENGTH = 80 + +function buildHandoff(task: string, notes: 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 notes below and the current on-disk state (working tree, git status, staged or uncommitted changes) are available context.", + "", + "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") +} + +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 parts: TextPartInput[] = [{ type: "text", text: buildHandoff(args.task, args.notes) }] + 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 } \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..1c97893 --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "opencode-session-restart", + "version": "0.1.0", + "description": "opencode plugin that lets the agent restart a session into a fresh child session with a focused prompt, dodging compaction.", + "type": "module", + "main": "./index.ts", + "files": ["index.ts"], + "license": "MIT", + "keywords": ["opencode", "plugin", "session", "restart", "compaction"], + "peerDependencies": { + "@opencode-ai/plugin": ">=1.0.0", + "@opencode-ai/sdk": ">=1.0.0" + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f5cd72c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["index.ts"] +} \ No newline at end of file