opencode-session-restart/index.ts
emidab f991c4d0f8 feat: restart session in place via summarize instead of child session
Compacts the current session (summarize) to advance the context boundary,
then prompts the focused task into the same session with promptAsync. The
session ID never changes and older messages stay visible in the transcript.
2026-09-16 11:16:05 +00:00

149 lines
No EOL
5.9 KiB
TypeScript

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<string, unknown> | 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 }
}
export const server: Plugin = async ({ client }, rawOptions = {}) => {
const options: RestartPluginOptions = {
...DEFAULT_OPTIONS,
...(rawOptions as Partial<RestartPluginOptions>),
}
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)
await client.session.summarize({
path: { id: ctx.sessionID },
body: { providerID, modelID },
})
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: ctx.sessionID },
body: {
parts,
...(args.model ? { model: normalizeModel(args.model) } : {}),
...(args.agent ? { agent: args.agent } : {}),
},
})
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 }