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).
128 lines
No EOL
4.9 KiB
TypeScript
128 lines
No EOL
4.9 KiB
TypeScript
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<RestartPluginOptions>),
|
|
}
|
|
|
|
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 } |