opencode-amp-like-handoff 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +191 -0
- package/package.json +32 -0
- package/src/files.ts +224 -0
- package/src/plugin.ts +178 -0
- package/src/tools.ts +245 -0
package/README.md
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# opencode-amp-like-handoff
|
|
2
|
+
|
|
3
|
+
Amp-like new-session handoff plugin for [OpenCode](https://opencode.ai).
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
When an AI coding session accumulates enough context that starting fresh would be more efficient, `/handoff` distills the relevant context into a new session — avoiding the costly "file archaeology" phase where the AI re-discovers files, decisions, and patterns from scratch.
|
|
8
|
+
|
|
9
|
+
- Registers a `/handoff <goal>` command that triggers a structured context-transfer workflow
|
|
10
|
+
- The model analyzes the current conversation, extracts context relevant to the stated goal, and identifies the most important files (targeting 8–15, up to 20 for complex work)
|
|
11
|
+
- Creates a new session with the handoff prompt as an **editable draft** — the user reviews it, edits if needed, and sends when ready
|
|
12
|
+
- Files are referenced as `@file` markers in the draft (e.g., `@src/plugin.ts`); their contents are **auto-injected** when the user sends the message
|
|
13
|
+
- File contents match **OpenCode's Read tool format** (5-digit zero-padded line numbers, `<file>` tags), so the AI treats them identically to files it read itself
|
|
14
|
+
- Binary files are detected by extension and byte analysis, and silently skipped
|
|
15
|
+
- Records the source session ID so the new session knows where it came from
|
|
16
|
+
- Provides `read_session` to pull conversation transcript from the source session on demand
|
|
17
|
+
- Logs all operations via `app.log` for observability
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
/handoff <your next goal>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Example:**
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
/handoff Refactor the auth module to use JWT instead of sessions
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
When invoked, the model:
|
|
32
|
+
|
|
33
|
+
1. Reads the current conversation to infer task status
|
|
34
|
+
2. Extracts decisions, constraints, user preferences, technical patterns, blockers, and exact next steps relevant to the stated goal
|
|
35
|
+
3. Identifies 8–15 relevant files (source files, dependencies, tests, configs)
|
|
36
|
+
4. Calls `handoff_session` with a focused continuation prompt and file list
|
|
37
|
+
5. A new session opens with the handoff as an **editable draft** — review it, make changes, and press Enter to send
|
|
38
|
+
6. When the message is sent, the `chat.message` hook automatically parses `@file` references, reads file contents from disk, and injects them as synthetic parts matching the Read tool format
|
|
39
|
+
|
|
40
|
+
In the new session, `read_session` is available to pull message history from the source session on demand if more detail is needed.
|
|
41
|
+
|
|
42
|
+
## Tools
|
|
43
|
+
|
|
44
|
+
### `handoff_session`
|
|
45
|
+
|
|
46
|
+
Creates a new session with the handoff prompt as an editable draft. File contents are auto-loaded when the user sends the message.
|
|
47
|
+
|
|
48
|
+
| Argument | Type | Required | Description |
|
|
49
|
+
|---|---|---|---|
|
|
50
|
+
| `prompt` | `string` | yes | The generated handoff prompt with context and goals. Must be non-empty. |
|
|
51
|
+
| `files` | `string[]` | no | File paths to load into the new session context (8–15 recommended). Leading `@` is stripped automatically. Duplicates and blank entries are ignored. |
|
|
52
|
+
|
|
53
|
+
**Success response:**
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
{
|
|
57
|
+
ok: true,
|
|
58
|
+
sourceSessionId: string, // ID of the session that initiated the handoff
|
|
59
|
+
files: string[], // deduplicated list of file paths
|
|
60
|
+
message: string // confirmation message
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Error response:**
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
{ ok: false, error: string }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Errors are returned (not thrown) for: empty prompt, or failure to create the draft session.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
### `read_session`
|
|
75
|
+
|
|
76
|
+
Reads conversation transcript from a previous session. Returns a formatted markdown transcript with `## User` and `## Assistant` sections.
|
|
77
|
+
|
|
78
|
+
| Argument | Type | Required | Description |
|
|
79
|
+
|---|---|---|---|
|
|
80
|
+
| `sessionId` | `string` | yes | The full session ID (e.g., `sess_01jxyz...`). Must be non-empty. |
|
|
81
|
+
| `limit` | `number` | no | Maximum number of messages to read. Clamped to 1–500. Defaults to `100`. |
|
|
82
|
+
|
|
83
|
+
**Success response:**
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
{
|
|
87
|
+
ok: true,
|
|
88
|
+
sessionId: string,
|
|
89
|
+
count: number,
|
|
90
|
+
transcript: string // formatted markdown transcript
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
**Error response:**
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
{ ok: false, error: string }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## How it works
|
|
101
|
+
|
|
102
|
+
### Editable draft workflow
|
|
103
|
+
|
|
104
|
+
Unlike a direct `session.create()` + `session.prompt()` approach that auto-injects context without user review, this plugin uses an editable draft workflow:
|
|
105
|
+
|
|
106
|
+
1. `handoff_session` calls `tui.executeCommand({ command: "session_new" })` to create a new session and navigate to it
|
|
107
|
+
2. After a short delay (200ms for the TUI to mount), it calls `tui.appendPrompt()` to populate the prompt input with the handoff text
|
|
108
|
+
3. The user sees the full handoff prompt as editable text — they can modify, remove, or add context before sending
|
|
109
|
+
|
|
110
|
+
### Automatic file injection
|
|
111
|
+
|
|
112
|
+
When the user sends the handoff message, the `chat.message` hook:
|
|
113
|
+
|
|
114
|
+
1. Detects messages containing "Continuing work from session" (the marker placed by the handoff tool)
|
|
115
|
+
2. Parses `@file` references using the `FILE_REGEX` pattern
|
|
116
|
+
3. Reads each file from disk, skipping binary files and unreadable paths
|
|
117
|
+
4. Formats content with 5-digit zero-padded line numbers and `<file>` tags, matching OpenCode's Read tool output
|
|
118
|
+
5. Injects synthetic text parts into the session via `session.prompt({ noReply: true })` — the AI sees them as if it called the Read tool itself
|
|
119
|
+
|
|
120
|
+
Each session is tracked in a `processedSessions` set to prevent duplicate injection. The set is cleaned up via the `event` hook when sessions are deleted.
|
|
121
|
+
|
|
122
|
+
### File format
|
|
123
|
+
|
|
124
|
+
Files are formatted to match OpenCode's Read tool output:
|
|
125
|
+
|
|
126
|
+
```
|
|
127
|
+
Called the Read tool with the following input: {"filePath": "/abs/path/to/file.ts"}
|
|
128
|
+
|
|
129
|
+
<file>
|
|
130
|
+
00001| import { foo } from "./bar"
|
|
131
|
+
00002|
|
|
132
|
+
00003| export function hello() {
|
|
133
|
+
00004| return "world"
|
|
134
|
+
00005| }
|
|
135
|
+
00006|
|
|
136
|
+
|
|
137
|
+
(End of file - total 6 lines)
|
|
138
|
+
</file>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
- Lines are zero-padded to 5 digits with a `| ` separator
|
|
142
|
+
- Lines longer than 2000 characters are truncated
|
|
143
|
+
- Files longer than 2000 lines show only the first 2000
|
|
144
|
+
|
|
145
|
+
## Project structure
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
src/
|
|
149
|
+
plugin.ts — Main plugin: hooks, handoff template, session lifecycle
|
|
150
|
+
tools.ts — HandoffSession and ReadSession tool definitions
|
|
151
|
+
files.ts — Binary detection, Read tool format, @file reference parsing
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
## Install locally
|
|
155
|
+
|
|
156
|
+
Place this plugin in a plugins directory supported by OpenCode:
|
|
157
|
+
|
|
158
|
+
- `.opencode/plugins/opencode-amp-like-handoff/` — project-level (checked into the repo)
|
|
159
|
+
- `~/.config/opencode/plugins/opencode-amp-like-handoff/` — user-level (applies to all projects)
|
|
160
|
+
|
|
161
|
+
Example directory tree:
|
|
162
|
+
|
|
163
|
+
```text
|
|
164
|
+
.opencode/
|
|
165
|
+
plugins/
|
|
166
|
+
opencode-amp-like-handoff/
|
|
167
|
+
package.json
|
|
168
|
+
tsconfig.json
|
|
169
|
+
src/
|
|
170
|
+
plugin.ts
|
|
171
|
+
tools.ts
|
|
172
|
+
files.ts
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
OpenCode picks up the plugin automatically on startup. If the plugin directory contains a `package.json`, OpenCode runs `bun install` to resolve dependencies before loading.
|
|
176
|
+
|
|
177
|
+
## Development
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
# Install dependencies
|
|
181
|
+
bun install
|
|
182
|
+
|
|
183
|
+
# Type-check without emitting output
|
|
184
|
+
bun run typecheck
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
TypeScript configuration: strict mode, `bundler` module resolution, `ESNext` target. The plugin runs directly as TypeScript via Bun — no build step required.
|
|
188
|
+
|
|
189
|
+
## License
|
|
190
|
+
|
|
191
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-amp-like-handoff",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Amp-like new-session handoff plugin for OpenCode",
|
|
6
|
+
"main": "src/plugin.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/plugin.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": ["src"],
|
|
11
|
+
"author": "cuongnt3",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"keywords": ["opencode", "plugin", "handoff", "session"],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/cuongntr/opencode-amp-like-handoff.git"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/cuongntr/opencode-amp-like-handoff#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/cuongntr/opencode-amp-like-handoff/issues"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"typecheck": "tsc --noEmit"
|
|
24
|
+
},
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"@opencode-ai/plugin": "^1.3.0"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^25.5.0",
|
|
30
|
+
"typescript": "^6.0.2"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/files.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File reference parsing, binary detection, and synthetic part building.
|
|
3
|
+
*
|
|
4
|
+
* Handles @file references from handoff prompts and builds synthetic text
|
|
5
|
+
* parts that match OpenCode's Read tool output format (line numbers, <file>
|
|
6
|
+
* tags) so the AI treats them identically to files it read itself.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as path from "node:path"
|
|
10
|
+
import * as fs from "node:fs/promises"
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Constants (matching OpenCode's ReadTool)
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const DEFAULT_READ_LIMIT = 2000
|
|
17
|
+
const MAX_LINE_LENGTH = 2000
|
|
18
|
+
|
|
19
|
+
const BINARY_EXTENSIONS = new Set([
|
|
20
|
+
// Archives
|
|
21
|
+
".7z",
|
|
22
|
+
".gz",
|
|
23
|
+
".jar",
|
|
24
|
+
".tar",
|
|
25
|
+
".war",
|
|
26
|
+
".zip",
|
|
27
|
+
// Compiled / object
|
|
28
|
+
".a",
|
|
29
|
+
".bin",
|
|
30
|
+
".class",
|
|
31
|
+
".dat",
|
|
32
|
+
".dll",
|
|
33
|
+
".exe",
|
|
34
|
+
".lib",
|
|
35
|
+
".o",
|
|
36
|
+
".obj",
|
|
37
|
+
".pyc",
|
|
38
|
+
".pyo",
|
|
39
|
+
".so",
|
|
40
|
+
".wasm",
|
|
41
|
+
// Documents
|
|
42
|
+
".doc",
|
|
43
|
+
".docx",
|
|
44
|
+
".ods",
|
|
45
|
+
".odt",
|
|
46
|
+
".odp",
|
|
47
|
+
".pdf",
|
|
48
|
+
".ppt",
|
|
49
|
+
".pptx",
|
|
50
|
+
".xls",
|
|
51
|
+
".xlsx",
|
|
52
|
+
// Images
|
|
53
|
+
".bmp",
|
|
54
|
+
".gif",
|
|
55
|
+
".ico",
|
|
56
|
+
".jpeg",
|
|
57
|
+
".jpg",
|
|
58
|
+
".png",
|
|
59
|
+
".tiff",
|
|
60
|
+
".webp",
|
|
61
|
+
// Audio / Video
|
|
62
|
+
".mp3",
|
|
63
|
+
".mp4",
|
|
64
|
+
".ogg",
|
|
65
|
+
".wav",
|
|
66
|
+
".webm",
|
|
67
|
+
// Fonts
|
|
68
|
+
".eot",
|
|
69
|
+
".otf",
|
|
70
|
+
".ttf",
|
|
71
|
+
".woff",
|
|
72
|
+
".woff2",
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Types
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export type SyntheticTextPart = {
|
|
80
|
+
type: "text"
|
|
81
|
+
text: string
|
|
82
|
+
synthetic: true
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// @file reference regex
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Matches @file references like `@src/plugin.ts` or `@./config.json`.
|
|
91
|
+
* Must not be preceded by a word char or backtick to avoid false positives
|
|
92
|
+
* inside inline code or email addresses.
|
|
93
|
+
*/
|
|
94
|
+
export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Public API
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Extract unique @file references from text.
|
|
102
|
+
*/
|
|
103
|
+
export function parseFileReferences(text: string): Set<string> {
|
|
104
|
+
const refs = new Set<string>()
|
|
105
|
+
for (const match of text.matchAll(FILE_REGEX)) {
|
|
106
|
+
if (match[1]) refs.add(match[1])
|
|
107
|
+
}
|
|
108
|
+
return refs
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Check whether a file is binary by extension or by sampling content bytes.
|
|
113
|
+
*/
|
|
114
|
+
export async function isBinaryFile(filepath: string): Promise<boolean> {
|
|
115
|
+
const ext = path.extname(filepath).toLowerCase()
|
|
116
|
+
if (BINARY_EXTENSIONS.has(ext)) return true
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const buffer = await fs.readFile(filepath)
|
|
120
|
+
if (!buffer || buffer.length === 0) return false
|
|
121
|
+
|
|
122
|
+
const sample = buffer.subarray(0, Math.min(4096, buffer.length))
|
|
123
|
+
let nonPrintable = 0
|
|
124
|
+
|
|
125
|
+
for (let i = 0; i < sample.length; i++) {
|
|
126
|
+
const byte = sample[i]
|
|
127
|
+
if (byte === undefined) continue
|
|
128
|
+
// Null byte → binary
|
|
129
|
+
if (byte === 0) return true
|
|
130
|
+
// Control chars outside whitespace range
|
|
131
|
+
if (byte < 9 || (byte > 13 && byte < 32)) nonPrintable++
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return nonPrintable / sample.length > 0.3
|
|
135
|
+
} catch {
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Format file content matching OpenCode's Read tool output.
|
|
142
|
+
*
|
|
143
|
+
* - 5-digit zero-padded line numbers with `| ` separator
|
|
144
|
+
* - Lines truncated at MAX_LINE_LENGTH
|
|
145
|
+
* - Content limited to DEFAULT_READ_LIMIT lines
|
|
146
|
+
* - Wrapped in `<file>` / `</file>` tags
|
|
147
|
+
*/
|
|
148
|
+
export function formatFileContent(
|
|
149
|
+
_filepath: string,
|
|
150
|
+
content: string,
|
|
151
|
+
): string {
|
|
152
|
+
const lines = content.split("\n")
|
|
153
|
+
const limit = DEFAULT_READ_LIMIT
|
|
154
|
+
|
|
155
|
+
const visible = lines.slice(0, limit).map((line) =>
|
|
156
|
+
line.length > MAX_LINE_LENGTH
|
|
157
|
+
? line.substring(0, MAX_LINE_LENGTH) + "..."
|
|
158
|
+
: line,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const numbered = visible.map(
|
|
162
|
+
(line, i) => `${(i + 1).toString().padStart(5, "0")}| ${line}`,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
let output = "<file>\n"
|
|
166
|
+
output += numbered.join("\n")
|
|
167
|
+
|
|
168
|
+
if (lines.length > visible.length) {
|
|
169
|
+
output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${visible.length})`
|
|
170
|
+
} else {
|
|
171
|
+
output += `\n\n(End of file - total ${lines.length} lines)`
|
|
172
|
+
}
|
|
173
|
+
output += "\n</file>"
|
|
174
|
+
|
|
175
|
+
return output
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Build synthetic text parts for a set of @file references.
|
|
180
|
+
*
|
|
181
|
+
* Creates two synthetic parts per file, matching what OpenCode's Read tool
|
|
182
|
+
* produces:
|
|
183
|
+
* 1. Header: "Called the Read tool with the following input: ..."
|
|
184
|
+
* 2. Body: Formatted file content with line numbers inside <file> tags
|
|
185
|
+
*
|
|
186
|
+
* Binary files and unreadable files are silently skipped.
|
|
187
|
+
*/
|
|
188
|
+
export async function buildSyntheticFileParts(
|
|
189
|
+
directory: string,
|
|
190
|
+
refs: Set<string>,
|
|
191
|
+
): Promise<SyntheticTextPart[]> {
|
|
192
|
+
const parts: SyntheticTextPart[] = []
|
|
193
|
+
|
|
194
|
+
for (const ref of refs) {
|
|
195
|
+
const filepath = path.resolve(directory, ref)
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const stats = await fs.stat(filepath)
|
|
199
|
+
if (!stats.isFile()) continue
|
|
200
|
+
if (await isBinaryFile(filepath)) continue
|
|
201
|
+
|
|
202
|
+
const content = await fs.readFile(filepath, "utf-8")
|
|
203
|
+
|
|
204
|
+
// Header part — matches OpenCode's prompt.ts synthetic format
|
|
205
|
+
parts.push({
|
|
206
|
+
type: "text",
|
|
207
|
+
synthetic: true,
|
|
208
|
+
text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: filepath })}`,
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
// Content part with line numbers and <file> tags
|
|
212
|
+
parts.push({
|
|
213
|
+
type: "text",
|
|
214
|
+
synthetic: true,
|
|
215
|
+
text: formatFileContent(filepath, content),
|
|
216
|
+
})
|
|
217
|
+
} catch {
|
|
218
|
+
// Skip files that cannot be read — one bad path should not
|
|
219
|
+
// block the rest of the handoff.
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return parts
|
|
224
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Amp-like session handoff plugin for OpenCode.
|
|
3
|
+
*
|
|
4
|
+
* Provides `/handoff <goal>` to create a focused continuation session.
|
|
5
|
+
* The handoff prompt appears as an editable draft — the user reviews it,
|
|
6
|
+
* edits if needed, and sends. File contents are auto-injected when the
|
|
7
|
+
* message arrives, matching OpenCode's Read tool format so the AI treats
|
|
8
|
+
* them as if it read the files itself.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
12
|
+
import { HandoffSession, ReadSession } from "./tools"
|
|
13
|
+
import { parseFileReferences, buildSyntheticFileParts } from "./files"
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Handoff template
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
const HANDOFF_TEMPLATE = `GOAL: You are creating a handoff message to continue work in a new session.
|
|
20
|
+
|
|
21
|
+
<context>
|
|
22
|
+
When an AI assistant starts a fresh session, it spends significant time
|
|
23
|
+
exploring the codebase — grepping, reading files, searching — before it can
|
|
24
|
+
begin actual work. This "file archaeology" is wasteful when the previous
|
|
25
|
+
session already discovered what matters.
|
|
26
|
+
|
|
27
|
+
A good handoff frontloads everything the next session needs so it can start
|
|
28
|
+
implementing immediately.
|
|
29
|
+
</context>
|
|
30
|
+
|
|
31
|
+
<instructions>
|
|
32
|
+
Analyze this conversation and extract what matters for continuing the work.
|
|
33
|
+
|
|
34
|
+
1. Identify all relevant files that should be loaded into the next session
|
|
35
|
+
|
|
36
|
+
Include files that will be edited, dependencies being touched, relevant
|
|
37
|
+
tests, configs, and key reference docs. Be generous — the cost of an
|
|
38
|
+
extra file is low; missing a critical one means another archaeology dig.
|
|
39
|
+
Target 8-15 files, up to 20 for complex work.
|
|
40
|
+
|
|
41
|
+
2. Draft the context and goal description
|
|
42
|
+
|
|
43
|
+
Describe what we're working on and provide whatever context helps
|
|
44
|
+
continue the work. Structure it based on what fits the conversation —
|
|
45
|
+
could be tasks, findings, a simple paragraph, or detailed steps.
|
|
46
|
+
|
|
47
|
+
Preserve: decisions, constraints, user preferences, technical patterns,
|
|
48
|
+
blockers, and exact next steps.
|
|
49
|
+
Exclude: conversation back-and-forth, dead ends, meta-commentary.
|
|
50
|
+
|
|
51
|
+
The user controls what context matters. If they mentioned something to
|
|
52
|
+
preserve, include it — trust their judgment about their workflow.
|
|
53
|
+
</instructions>
|
|
54
|
+
|
|
55
|
+
<user_input>
|
|
56
|
+
This is what the next session should focus on. Use it to shape your
|
|
57
|
+
handoff's direction — don't investigate or search, just incorporate the
|
|
58
|
+
intent into your context and goals.
|
|
59
|
+
|
|
60
|
+
If empty, capture a natural continuation of the current conversation's
|
|
61
|
+
direction.
|
|
62
|
+
|
|
63
|
+
USER: $ARGUMENTS
|
|
64
|
+
</user_input>
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
After generating the handoff message, IMMEDIATELY call handoff_session with
|
|
69
|
+
your prompt and files:
|
|
70
|
+
\`handoff_session(prompt="...", files=["src/foo.ts", "src/bar.ts", ...])\``
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Plugin
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
export const AmpLikeHandoffPlugin: Plugin = async (ctx) => {
|
|
77
|
+
// Track sessions that have already had file parts injected to avoid
|
|
78
|
+
// duplicate processing when chat.message fires multiple times.
|
|
79
|
+
const processedSessions = new Set<string>()
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
// -----------------------------------------------------------------
|
|
83
|
+
// Register /handoff command
|
|
84
|
+
// -----------------------------------------------------------------
|
|
85
|
+
config: async (config) => {
|
|
86
|
+
config.command ||= {}
|
|
87
|
+
config.command.handoff = {
|
|
88
|
+
description: "Create a focused handoff prompt for a new session",
|
|
89
|
+
template: HANDOFF_TEMPLATE,
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
// -----------------------------------------------------------------
|
|
94
|
+
// Expose tools
|
|
95
|
+
// -----------------------------------------------------------------
|
|
96
|
+
tool: {
|
|
97
|
+
handoff_session: HandoffSession(ctx.client),
|
|
98
|
+
read_session: ReadSession(ctx.client),
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
// -----------------------------------------------------------------
|
|
102
|
+
// Auto-inject file contents when a handoff message arrives
|
|
103
|
+
// -----------------------------------------------------------------
|
|
104
|
+
"chat.message": async (_input, output) => {
|
|
105
|
+
const sessionID = output.message.sessionID
|
|
106
|
+
|
|
107
|
+
// Skip if we already injected files into this session
|
|
108
|
+
if (processedSessions.has(sessionID)) return
|
|
109
|
+
|
|
110
|
+
// Extract non-synthetic text from the message parts
|
|
111
|
+
const text = output.parts
|
|
112
|
+
.filter(
|
|
113
|
+
(p): p is typeof p & { type: "text"; text: string } =>
|
|
114
|
+
p.type === "text" && !p.synthetic && typeof p.text === "string",
|
|
115
|
+
)
|
|
116
|
+
.map((p) => p.text)
|
|
117
|
+
.join("\n")
|
|
118
|
+
|
|
119
|
+
// Only process messages that look like handoff continuations
|
|
120
|
+
if (!text.includes("Continuing work from session")) return
|
|
121
|
+
|
|
122
|
+
// Mark as processed immediately to prevent re-entry
|
|
123
|
+
processedSessions.add(sessionID)
|
|
124
|
+
|
|
125
|
+
// Parse @file references from the message text
|
|
126
|
+
const fileRefs = parseFileReferences(text)
|
|
127
|
+
if (fileRefs.size === 0) return
|
|
128
|
+
|
|
129
|
+
const fileParts = await buildSyntheticFileParts(ctx.directory, fileRefs)
|
|
130
|
+
if (fileParts.length === 0) return
|
|
131
|
+
|
|
132
|
+
// Inject synthetic file parts into the session without triggering a
|
|
133
|
+
// reply. Pass model and agent from the current message to prevent
|
|
134
|
+
// mode or model switching.
|
|
135
|
+
try {
|
|
136
|
+
await ctx.client.session.prompt({
|
|
137
|
+
path: { id: sessionID },
|
|
138
|
+
body: {
|
|
139
|
+
noReply: true,
|
|
140
|
+
model: output.message.model,
|
|
141
|
+
agent: output.message.agent,
|
|
142
|
+
parts: fileParts,
|
|
143
|
+
},
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
await ctx.client.app.log({
|
|
147
|
+
body: {
|
|
148
|
+
service: "handoff",
|
|
149
|
+
level: "info",
|
|
150
|
+
message: "Injected synthetic file parts",
|
|
151
|
+
extra: { sessionID, fileCount: fileRefs.size },
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
} catch (err) {
|
|
155
|
+
const error = err instanceof Error ? err.message : String(err)
|
|
156
|
+
await ctx.client.app.log({
|
|
157
|
+
body: {
|
|
158
|
+
service: "handoff",
|
|
159
|
+
level: "error",
|
|
160
|
+
message: "Failed to inject file parts",
|
|
161
|
+
extra: { sessionID, error },
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
// -----------------------------------------------------------------
|
|
168
|
+
// Cleanup: remove tracking when sessions are deleted
|
|
169
|
+
// -----------------------------------------------------------------
|
|
170
|
+
event: async ({ event }) => {
|
|
171
|
+
if (event.type === "session.deleted") {
|
|
172
|
+
processedSessions.delete(event.properties.info.id)
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export default AmpLikeHandoffPlugin
|
package/src/tools.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool definitions for the handoff plugin.
|
|
3
|
+
*
|
|
4
|
+
* - HandoffSession: Creates new session with the handoff prompt as an
|
|
5
|
+
* editable draft. File contents are injected later via chat.message hook.
|
|
6
|
+
* - ReadSession: Reads conversation transcript from a previous session with
|
|
7
|
+
* formatted markdown output.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { PluginInput } from "@opencode-ai/plugin"
|
|
11
|
+
import { tool } from "@opencode-ai/plugin"
|
|
12
|
+
|
|
13
|
+
export type OpencodeClient = PluginInput["client"]
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// HandoffSession
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export const HandoffSession = (client: OpencodeClient) => {
|
|
20
|
+
return tool({
|
|
21
|
+
description:
|
|
22
|
+
"Create a new session with the handoff prompt as an editable draft. " +
|
|
23
|
+
"The user can review, edit, and send when ready. " +
|
|
24
|
+
"File contents are auto-loaded when the message is sent.",
|
|
25
|
+
args: {
|
|
26
|
+
prompt: tool.schema
|
|
27
|
+
.string()
|
|
28
|
+
.describe("The generated handoff prompt with context and goals"),
|
|
29
|
+
files: tool.schema
|
|
30
|
+
.array(tool.schema.string())
|
|
31
|
+
.optional()
|
|
32
|
+
.describe(
|
|
33
|
+
"File paths to load into the new session context (8-15 recommended)",
|
|
34
|
+
),
|
|
35
|
+
},
|
|
36
|
+
async execute(
|
|
37
|
+
args: { prompt: string; files?: string[] },
|
|
38
|
+
context: { sessionID: string },
|
|
39
|
+
) {
|
|
40
|
+
const prompt = args.prompt?.trim()
|
|
41
|
+
if (!prompt) {
|
|
42
|
+
return JSON.stringify({ ok: false, error: "Handoff prompt must not be empty." })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sourceSessionId = context.sessionID
|
|
46
|
+
const sessionReference = `Continuing work from session ${sourceSessionId}. Use read_session if you need details not included here.`
|
|
47
|
+
|
|
48
|
+
// Format files as @file references — the chat.message hook will parse
|
|
49
|
+
// these and inject synthetic file parts when the user sends the message.
|
|
50
|
+
const uniqueFiles = [
|
|
51
|
+
...new Set(
|
|
52
|
+
(args.files ?? [])
|
|
53
|
+
.map((f) => f.trim().replace(/^@/, ""))
|
|
54
|
+
.filter(Boolean),
|
|
55
|
+
),
|
|
56
|
+
]
|
|
57
|
+
const fileRefs = uniqueFiles.length
|
|
58
|
+
? uniqueFiles.map((f) => `@${f}`).join(" ")
|
|
59
|
+
: ""
|
|
60
|
+
|
|
61
|
+
const fullPrompt = fileRefs
|
|
62
|
+
? `${sessionReference}\n\n${fileRefs}\n\n${prompt}`
|
|
63
|
+
: `${sessionReference}\n\n${prompt}`
|
|
64
|
+
|
|
65
|
+
// Create a new session via TUI command — this also navigates to it
|
|
66
|
+
// automatically, so the user lands in the new session ready to review.
|
|
67
|
+
try {
|
|
68
|
+
await client.tui.executeCommand({
|
|
69
|
+
body: { command: "session_new" },
|
|
70
|
+
})
|
|
71
|
+
// session_new fires asynchronously. The TUI needs a moment to
|
|
72
|
+
// navigate and mount the new prompt input before we can append text.
|
|
73
|
+
await new Promise((r) => setTimeout(r, 200))
|
|
74
|
+
await client.tui.appendPrompt({ body: { text: fullPrompt } })
|
|
75
|
+
} catch (err) {
|
|
76
|
+
const error = err instanceof Error ? err.message : String(err)
|
|
77
|
+
await client.app.log({
|
|
78
|
+
body: {
|
|
79
|
+
service: "handoff",
|
|
80
|
+
level: "error",
|
|
81
|
+
message: "Failed to create handoff draft",
|
|
82
|
+
extra: { error, sourceSessionId },
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
return JSON.stringify({ ok: false, error: `Failed to create handoff draft: ${error}` })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Non-fatal toast notification
|
|
89
|
+
try {
|
|
90
|
+
await client.tui.showToast({
|
|
91
|
+
body: {
|
|
92
|
+
title: "Handoff Ready",
|
|
93
|
+
message: "Review the draft, edit if needed, then send",
|
|
94
|
+
variant: "success",
|
|
95
|
+
duration: 4000,
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
} catch {
|
|
99
|
+
// TUI toast may not be available in headless/test contexts
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await client.app.log({
|
|
103
|
+
body: {
|
|
104
|
+
service: "handoff",
|
|
105
|
+
level: "info",
|
|
106
|
+
message: "Handoff draft created",
|
|
107
|
+
extra: { fileCount: uniqueFiles.length, sourceSessionId },
|
|
108
|
+
},
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
return JSON.stringify({
|
|
112
|
+
ok: true,
|
|
113
|
+
sourceSessionId,
|
|
114
|
+
files: uniqueFiles,
|
|
115
|
+
message:
|
|
116
|
+
"Handoff prompt created as editable draft in new session. " +
|
|
117
|
+
"File contents will be loaded automatically when the user sends.",
|
|
118
|
+
})
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// Transcript formatting
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
function formatTranscript(
|
|
128
|
+
messages: Array<{
|
|
129
|
+
info: Record<string, unknown>
|
|
130
|
+
parts: Array<Record<string, unknown>>
|
|
131
|
+
}>,
|
|
132
|
+
limit: number,
|
|
133
|
+
): string {
|
|
134
|
+
const lines: string[] = []
|
|
135
|
+
|
|
136
|
+
for (const msg of messages) {
|
|
137
|
+
const role = msg.info.role as string
|
|
138
|
+
|
|
139
|
+
if (role === "user") {
|
|
140
|
+
lines.push("## User")
|
|
141
|
+
for (const part of msg.parts) {
|
|
142
|
+
if (part.type === "text" && !part.ignored) {
|
|
143
|
+
lines.push(part.text as string)
|
|
144
|
+
}
|
|
145
|
+
if (part.type === "file") {
|
|
146
|
+
lines.push(
|
|
147
|
+
`[Attached: ${(part.filename as string) || "file"}]`,
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
lines.push("")
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (role === "assistant") {
|
|
155
|
+
lines.push("## Assistant")
|
|
156
|
+
for (const part of msg.parts) {
|
|
157
|
+
if (part.type === "text") {
|
|
158
|
+
lines.push(part.text as string)
|
|
159
|
+
}
|
|
160
|
+
if (part.type === "tool") {
|
|
161
|
+
const state = part.state as Record<string, unknown> | undefined
|
|
162
|
+
if (state?.status === "completed") {
|
|
163
|
+
lines.push(
|
|
164
|
+
`[Tool: ${part.tool as string}] ${(state.title as string) ?? ""}`,
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
lines.push("")
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const output = lines.join("\n").trim()
|
|
174
|
+
const suffix =
|
|
175
|
+
messages.length >= limit
|
|
176
|
+
? `\n\n(Showing ${messages.length} most recent messages. Use a higher 'limit' to see more.)`
|
|
177
|
+
: `\n\n(End of session — ${messages.length} messages)`
|
|
178
|
+
|
|
179
|
+
return output + suffix
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// ReadSession
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
export const ReadSession = (client: OpencodeClient) => {
|
|
187
|
+
return tool({
|
|
188
|
+
description:
|
|
189
|
+
"Read conversation transcript from a previous session. " +
|
|
190
|
+
"Returns a formatted markdown transcript. " +
|
|
191
|
+
"Use when you need specific information not included in the handoff summary.",
|
|
192
|
+
args: {
|
|
193
|
+
sessionId: tool.schema
|
|
194
|
+
.string()
|
|
195
|
+
.describe("The full session ID (e.g., sess_01jxyz...)"),
|
|
196
|
+
limit: tool.schema
|
|
197
|
+
.number()
|
|
198
|
+
.optional()
|
|
199
|
+
.describe("Max messages to read (default 100, max 500)"),
|
|
200
|
+
},
|
|
201
|
+
async execute(args: { sessionId: string; limit?: number }) {
|
|
202
|
+
if (!args.sessionId?.trim()) {
|
|
203
|
+
return JSON.stringify({ ok: false, error: "sessionId must not be empty." })
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const limit = Math.max(1, Math.min(args.limit ?? 100, 500))
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const response = (await client.session.messages({
|
|
210
|
+
path: { id: args.sessionId },
|
|
211
|
+
query: { limit },
|
|
212
|
+
})) as
|
|
213
|
+
| { data?: unknown[] }
|
|
214
|
+
| unknown[]
|
|
215
|
+
|
|
216
|
+
const messages = (
|
|
217
|
+
Array.isArray(response) ? response : (response.data ?? [])
|
|
218
|
+
) as Array<{
|
|
219
|
+
info: Record<string, unknown>
|
|
220
|
+
parts: Array<Record<string, unknown>>
|
|
221
|
+
}>
|
|
222
|
+
|
|
223
|
+
if (messages.length === 0) {
|
|
224
|
+
return JSON.stringify({
|
|
225
|
+
ok: false,
|
|
226
|
+
error: "Session has no messages or does not exist.",
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return JSON.stringify({
|
|
231
|
+
ok: true,
|
|
232
|
+
sessionId: args.sessionId,
|
|
233
|
+
count: messages.length,
|
|
234
|
+
transcript: formatTranscript(messages, limit),
|
|
235
|
+
})
|
|
236
|
+
} catch (err) {
|
|
237
|
+
const error = err instanceof Error ? err.message : String(err)
|
|
238
|
+
return JSON.stringify({
|
|
239
|
+
ok: false,
|
|
240
|
+
error: `Failed to read session ${args.sessionId}: ${error}`,
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
})
|
|
245
|
+
}
|