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.
This commit is contained in:
emidab 2026-09-16 11:16:05 +00:00
parent a5c73367d6
commit f991c4d0f8
2 changed files with 53 additions and 36 deletions

View file

@ -1,8 +1,8 @@
# opencode-session-restart
opencode plugin that lets the agent restart a session into a fresh child session with a clean context window and a focused prompt, dodging context compaction loss.
opencode plugin that lets the agent restart a session **in place**: clear the model context and continue working in the same session with a fresh, focused prompt, dodging context compaction loss and runaway context windows.
When a session runs long enough to trigger compaction, opencode summarizes the conversation into a prompt and keeps going — fidelity you did not choose. This plugin gives the agent a first-class escape hatch: start a new child session on the same project, prompt it with a curated fresh prompt, and (by default) keep the current session alive so its full history stays readable. The single focused task continues in a fresh window.
When a session runs long enough to trigger compaction, opencode summarizes the conversation into a prompt and keeps going — fidelity you did not choose. This plugin gives the agent a first-class escape hatch: it compacts the **current** session (pruning the old conversation from the model context while keeping it visible in the transcript) and then prompts a curated fresh task into the same session. No session switching, no child sessions.
## Install
@ -26,10 +26,11 @@ or from a local clone:
The plugin registers one tool, `session_restart`, that the agent can call when the session is getting long:
- Creates a child session (via the SDK `session.create` with `parentID`), so it shows up in the session tree under the original.
- Immediately prompts it (via `session.promptAsync`) with the focused `task` plus optional carry-over `notes` and a fresh-start preamble that points at the parent session for the full earlier conversation and tells the new session to orient itself from the working tree.
- Leaves the current session running, so its full history stays visible and readable in the session tree.
- Returns the new session ID so the agent can tell you where to switch.
1. **Compacts the current session** via the SDK `session.summarize` endpoint with the current session model. Compaction advances the context boundary: earlier messages fall out of the model context but stay in the session transcript, exactly like a manual `/compact`.
2. **Prompts the same session** (via `session.promptAsync`) with the focused `task` plus optional carry-over `notes` and a fresh-start preamble stating that the earlier conversation remains readable in the transcript but is no longer part of the model context.
3. Returns immediately; the hand-off work continues in the same session.
The session ID never changes and the full history stays visible in the scrollback.
## Static handoff text
@ -60,16 +61,17 @@ Either way the text is prepended to the handoff prompt under a `STANDING INSTRUC
### Tool arguments
| argument | type | default | description |
| --------------- | ------- | ------- | ----------- |
| -------- | ------ | ------- | ----------- |
| `task` | string | — | The single focused task for the restarted session, written as a fresh prompt. |
| `notes` | string | — | Durable context to carry over: decisions, constraints, files touched, unfinished steps. Omit to rely on the working tree only. |
| `model` | string | current | `"providerID/modelID"` for the restarted session. |
| `model` | string | current | `"providerID/modelID"` to compact with. |
| `agent` | string | current | Agent for the restarted session. |
### Notes on behavior
- The new session keeps the fresh prompt as its user-turn text and immediately starts working. On-disk state (git status, staged or uncommitted changes) carries over because it is the same project directory.
- The original session is always left running and its full transcript remains in the session tree — the clean context lives in the child session. The handoff prompt includes the parent session ID so both you and the model know where to read the earlier conversation.
- Compaction runs one model call (the default `summarize` behavior). If no explicit `model` is given, the tool resolves the current session model from the last user message.
- The same session continues; on-disk state (git status, staged or uncommitted changes) carries over because it is the same project directory.
- The handoff message is a normal user turn in the same session, so it shows up in the transcript and is not counted as a fork/child session.
## Development

View file

@ -16,14 +16,12 @@ export type RestartPluginOptions = {
const DEFAULT_OPTIONS: RestartPluginOptions = {}
const MAX_TITLE_LENGTH = 80
function buildHandoff(task: string, notes: string | undefined, staticText: string | undefined, parentSessionID: string) {
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.",
"Work was restarted in this 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.`,
"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]
: []),
@ -65,6 +63,21 @@ function normalizeModel(value: string) {
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,
@ -75,10 +88,11 @@ export const server: Plugin = async ({ client }, rawOptions = {}) => {
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.",
"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()
@ -92,24 +106,22 @@ export const server: Plugin = async ({ client }, rawOptions = {}) => {
model: tool
.schema.string()
.optional()
.describe('Model for the restarted session as "providerID/modelID". Defaults to this session\'s model.'),
.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 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 { providerID, modelID } = await resolveCompactModel(client, ctx.sessionID, args.model)
await client.session.summarize({
path: { id: ctx.sessionID },
body: { providerID, modelID },
})
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) },
{ type: "text", text: buildHandoff(args.task, args.notes, staticText) },
]
await client.session.promptAsync({
path: { id: restartedID },
path: { id: ctx.sessionID },
body: {
parts,
...(args.model ? { model: normalizeModel(args.model) } : {}),
@ -118,11 +130,14 @@ export const server: Plugin = async ({ client }, rawOptions = {}) => {
})
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.`,
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: {
restartedSessionID: restartedID,
parentSessionID: ctx.sessionID,
sessionID: ctx.sessionID,
compacted: { providerID, modelID },
restarted: true,
},
}
},