diff --git a/README.md b/README.md index 47bdd0b..3c5df50 100644 --- a/README.md +++ b/README.md @@ -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 @@ -59,17 +60,18 @@ 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. | -| `agent` | string | current | Agent for the restarted session. | +| 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"` 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 diff --git a/index.ts b/index.ts index 43d8578..bd79ae4 100644 --- a/index.ts +++ b/index.ts @@ -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, }, } },