opencode-session-restart/index.ts

134 lines
No EOL
5.5 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 = {}
const MAX_TITLE_LENGTH = 80
function buildHandoff(task: string, notes: string | undefined, staticText: string | undefined, parentSessionID: string) {
const state = notes?.trim()
return [
"You are continuing work that was restarted into this fresh session with a clean context window.",
"",
`The full earlier conversation remains readable in the parent session ${parentSessionID} (switched to above this one in the session tree). Do not replay it here — 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, " +
"keeping the current session alive with its full history readable in the session tree. " +
"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 always left running: 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 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."),
},
async execute(args, ctx) {
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, ctx.sessionID) },
]
await client.session.promptAsync({
path: { id: restartedID },
body: {
parts,
...(args.model ? { model: normalizeModel(args.model) } : {}),
...(args.agent ? { agent: args.agent } : {}),
},
})
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. The current session stays alive with its full history readable. Switch to session ${restartedID} to continue the work.`,
metadata: {
restartedSessionID: restartedID,
parentSessionID: ctx.sessionID,
},
}
},
}),
},
}
}
export default { id, server }