The summarize request parks server-side on the current run's done until the loop executing this tool finishes, so awaiting it never resolves. Fire it without awaiting, poll for the persisted compaction message, then wait for the handoff prompt to appear before returning.
189 lines
No EOL
7.5 KiB
TypeScript
189 lines
No EOL
7.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 = {}
|
|
|
|
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 }
|
|
}
|
|
|
|
const HANDOFF_MARKER = "Work was restarted in this session with a clean context window."
|
|
|
|
async function waitFor(
|
|
client: PluginClient,
|
|
sessionID: string,
|
|
test: (messages: { info: import("@opencode-ai/sdk").Message; parts: import("@opencode-ai/sdk").Part[] }[]) => boolean,
|
|
timeoutMs = 30000,
|
|
intervalMs = 250,
|
|
) {
|
|
const deadline = Date.now() + timeoutMs
|
|
while (true) {
|
|
const page = await client.session.messages({ path: { id: sessionID } })
|
|
if (test(page.data ?? [])) return
|
|
if (Date.now() >= deadline) throw new Error(`timed out after ${timeoutMs}ms waiting for the session to change`)
|
|
await new Promise((resolve) => setTimeout(resolve, intervalMs))
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
void client.session
|
|
.summarize({
|
|
path: { id: ctx.sessionID },
|
|
body: { providerID, modelID },
|
|
})
|
|
.catch(() => {
|
|
// The summarize request parks server-side until the running loop
|
|
// finishes; the compaction message it writes is what matters, and it
|
|
// is created synchronously before that wait. Errors here are
|
|
// surface-level (the loop below still processes the compaction).
|
|
})
|
|
|
|
await waitFor(client, ctx.sessionID, (messages) =>
|
|
messages.some(
|
|
(entry) =>
|
|
entry.info.role === "user" &&
|
|
entry.parts.some((part) => part.type === "compaction"),
|
|
),
|
|
)
|
|
|
|
const staticText = await resolveStaticText(client, ctx.agent, args.agent, options.static_text)
|
|
const handoff = buildHandoff(args.task, args.notes, staticText)
|
|
await client.session.promptAsync({
|
|
path: { id: ctx.sessionID },
|
|
body: {
|
|
parts: [{ type: "text", text: handoff } as TextPartInput],
|
|
...(args.model ? { model: normalizeModel(args.model) } : {}),
|
|
...(args.agent ? { agent: args.agent } : {}),
|
|
},
|
|
})
|
|
|
|
await waitFor(client, ctx.sessionID, (messages) =>
|
|
messages.some(
|
|
(entry) =>
|
|
entry.info.role === "user" &&
|
|
entry.parts.some((part) => part.type === "text" && part.text?.includes(HANDOFF_MARKER)),
|
|
),
|
|
)
|
|
|
|
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 } |