feat: add session_restart tool plugin
Registers a session_restart tool that spawns a fresh child session on the current project, prompts it with a focused task (+ optional notes) via promptAsync, and aborts the current session by default so a single focused task continues in a clean context window instead of compacting. Includes an experimental.compaction.autocontinue hook to reject the synthetic continue turn after compaction (default on).
This commit is contained in:
commit
007f4fed6d
5 changed files with 217 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.DS_Store
|
||||||
59
README.md
Normal file
59
README.md
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# 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.
|
||||||
|
|
||||||
|
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) freeze the old session. The single focused task continues in a fresh window.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
Add the plugin to your opencode config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plugin": ["opencode-session-restart"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
or from a local clone:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plugin": [["/path/to/opencode-session-restart", { "abort_current": true }]]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
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 tells the new session not to reconstruct the old conversation and to orient itself from the working tree.
|
||||||
|
- Aborts the current session by default, so only the restarted session stays active.
|
||||||
|
- Returns the new session ID so the agent can tell you where to switch.
|
||||||
|
|
||||||
|
### 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. |
|
||||||
|
| `abort_current` | boolean | `true` | Abort this session after restarting. |
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
- `abort_current` freezes the original session after the new one is started. Because abort cancels the current agent turn, the tool's returned text may not be delivered to the old session — the side effects (create + prompt) complete first.
|
||||||
|
- `disable_compaction_continue` (default `true`) rejects the synthetic "continue" turn opencode would append after a compaction, so a compaction that does slip through never silently continues. Set it to `false` to keep stock behavior.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
bunx tsc --noEmit
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
128
index.ts
Normal file
128
index.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
import { type Plugin, type PluginOptions, tool } from "@opencode-ai/plugin"
|
||||||
|
import type { TextPartInput } from "@opencode-ai/sdk"
|
||||||
|
|
||||||
|
export const id = "opencode-session-restart"
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_OPTIONS: RestartPluginOptions = {
|
||||||
|
abort_current: true,
|
||||||
|
disable_compaction_continue: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_TITLE_LENGTH = 80
|
||||||
|
|
||||||
|
function buildHandoff(task: string, notes: 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 notes below and the current on-disk state (working tree, git status, staged or uncommitted changes) are available context.",
|
||||||
|
"",
|
||||||
|
"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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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 parts: TextPartInput[] = [{ type: "text", text: buildHandoff(args.task, args.notes) }]
|
||||||
|
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 }
|
||||||
14
package.json
Normal file
14
package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"name": "opencode-session-restart",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "opencode plugin that lets the agent restart a session into a fresh child session with a focused prompt, dodging compaction.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./index.ts",
|
||||||
|
"files": ["index.ts"],
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["opencode", "plugin", "session", "restart", "compaction"],
|
||||||
|
"peerDependencies": {
|
||||||
|
"@opencode-ai/plugin": ">=1.0.0",
|
||||||
|
"@opencode-ai/sdk": ">=1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["index.ts"]
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue