161 lines
No EOL
6.1 KiB
TypeScript
161 lines
No EOL
6.1 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 = {
|
|
/**
|
|
* 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<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) }
|
|
}
|
|
|
|
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 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 } |