@andrewhampton/opencode-handoff 0.1.0 → 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 +31 -15
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +391 -0
- package/dist/plugin/files.d.ts +39 -0
- package/dist/plugin/files.d.ts.map +1 -0
- package/dist/plugin/handoff.d.ts +3 -0
- package/dist/plugin/handoff.d.ts.map +1 -0
- package/dist/plugin/tools.d.ts +25 -0
- package/dist/plugin/tools.d.ts.map +1 -0
- package/dist/plugin/vendor.d.ts +27 -0
- package/dist/plugin/vendor.d.ts.map +1 -0
- package/package.json +20 -6
- package/index.ts +0 -1
- package/plugin/handoff.ts +0 -182
package/README.md
CHANGED
|
@@ -6,32 +6,34 @@ An [OpenCode](https://opencode.ai) plugin that implements the `/handoff` command
|
|
|
6
6
|
|
|
7
7
|
When you run `/handoff <instruction>`, the plugin:
|
|
8
8
|
|
|
9
|
-
1. Asks the AI to
|
|
10
|
-
2. Creates a new session
|
|
11
|
-
3.
|
|
12
|
-
4.
|
|
9
|
+
1. Asks the AI to analyze the conversation and identify relevant files
|
|
10
|
+
2. Creates a new session with a handoff prompt
|
|
11
|
+
3. Links the new session to the original (for lookback via `read_session`)
|
|
12
|
+
4. Automatically injects referenced files into the new session's context
|
|
13
|
+
5. Starts the AI responding immediately
|
|
13
14
|
|
|
14
15
|
The new session uses the same model as the original session.
|
|
15
16
|
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- **Auto-registered command** - No manual command configuration needed
|
|
20
|
+
- **Session linkage** - New sessions can use `read_session` to fetch details from the source session
|
|
21
|
+
- **File context injection** - `@file` references in the handoff are automatically loaded
|
|
22
|
+
- **Smart file selection** - AI is guided to include 8-15 relevant files (up to 20 for complex work)
|
|
23
|
+
|
|
16
24
|
## Installation
|
|
17
25
|
|
|
18
|
-
Add the plugin
|
|
26
|
+
Add the plugin to your `opencode.json` (or `opencode.jsonc`):
|
|
19
27
|
|
|
20
28
|
```json
|
|
21
29
|
{
|
|
22
|
-
"plugin": ["opencode-handoff"]
|
|
23
|
-
"command": {
|
|
24
|
-
"handoff": {
|
|
25
|
-
"description": "Create a handoff prompt in a new session",
|
|
26
|
-
"template": "Create a handoff prompt based on the instruction:\n\n$ARGUMENTS\n\nRequirements:\n- Review the current thread before responding.\n- Produce a prompt for a new thread that includes relevant context from this thread.\n- Conclude with a verbatim copy of the instruction above."
|
|
27
|
-
}
|
|
28
|
-
}
|
|
30
|
+
"plugin": ["@andrewhampton/opencode-handoff"]
|
|
29
31
|
}
|
|
30
32
|
```
|
|
31
33
|
|
|
32
|
-
Then restart OpenCode.
|
|
34
|
+
Then restart OpenCode. The `/handoff` command is automatically registered.
|
|
33
35
|
|
|
34
|
-
> **Note:**
|
|
36
|
+
> **Note:** You can override the default command template by adding your own `handoff` command to the config.
|
|
35
37
|
|
|
36
38
|
## Usage
|
|
37
39
|
|
|
@@ -46,7 +48,21 @@ For example:
|
|
|
46
48
|
/handoff Continue implementing the user authentication feature
|
|
47
49
|
```
|
|
48
50
|
|
|
49
|
-
The AI will
|
|
51
|
+
The AI will:
|
|
52
|
+
1. Review the current thread
|
|
53
|
+
2. Identify relevant files to include
|
|
54
|
+
3. Create a focused summary with context
|
|
55
|
+
4. Start a new session with everything the next AI needs
|
|
56
|
+
|
|
57
|
+
## Tools
|
|
58
|
+
|
|
59
|
+
The plugin provides a `read_session` tool that allows the AI in the new session to fetch additional context from the source session:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
read_session(sessionID: string, limit?: number)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
This is automatically available when the handoff prompt includes session linkage.
|
|
50
66
|
|
|
51
67
|
## Limitations
|
|
52
68
|
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAA"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// plugin/tools.ts
|
|
2
|
+
import { tool } from "@opencode-ai/plugin";
|
|
3
|
+
function formatTranscript(messages, limit) {
|
|
4
|
+
const lines = [];
|
|
5
|
+
for (const msg of messages) {
|
|
6
|
+
if (msg.info.role === "user") {
|
|
7
|
+
lines.push("## User");
|
|
8
|
+
for (const part of msg.parts) {
|
|
9
|
+
if (part.type === "text" && !part.ignored) {
|
|
10
|
+
lines.push(part.text ?? "");
|
|
11
|
+
}
|
|
12
|
+
if (part.type === "file") {
|
|
13
|
+
lines.push(`[Attached: ${part.filename || "file"}]`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
lines.push("");
|
|
17
|
+
}
|
|
18
|
+
if (msg.info.role === "assistant") {
|
|
19
|
+
lines.push("## Assistant");
|
|
20
|
+
for (const part of msg.parts) {
|
|
21
|
+
if (part.type === "text") {
|
|
22
|
+
lines.push(part.text ?? "");
|
|
23
|
+
}
|
|
24
|
+
if (part.type === "tool" && part.state?.status === "completed") {
|
|
25
|
+
lines.push(`[Tool: ${part.tool}] ${part.state.title}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
lines.push("");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const output = lines.join(`
|
|
32
|
+
`).trim();
|
|
33
|
+
if (messages.length >= (limit ?? 100)) {
|
|
34
|
+
return output + `
|
|
35
|
+
|
|
36
|
+
(Showing ${messages.length} most recent messages. Use a higher 'limit' to see more.)`;
|
|
37
|
+
}
|
|
38
|
+
return output + `
|
|
39
|
+
|
|
40
|
+
(End of session - ${messages.length} messages)`;
|
|
41
|
+
}
|
|
42
|
+
var ReadSession = (client) => {
|
|
43
|
+
return tool({
|
|
44
|
+
description: "Read the conversation transcript from a previous session. Use this when you need specific information from the source session that wasn't included in the handoff summary.",
|
|
45
|
+
args: {
|
|
46
|
+
sessionID: tool.schema.string().describe("The full session ID (e.g., ses_01jxyz...)"),
|
|
47
|
+
limit: tool.schema.number().optional().describe("Maximum number of messages to read (defaults to 100, max 500)")
|
|
48
|
+
},
|
|
49
|
+
async execute(args) {
|
|
50
|
+
const limit = Math.min(args.limit ?? 100, 500);
|
|
51
|
+
try {
|
|
52
|
+
const response = await client.session.messages({
|
|
53
|
+
path: { id: args.sessionID },
|
|
54
|
+
query: { limit }
|
|
55
|
+
});
|
|
56
|
+
const data = response.data ?? response;
|
|
57
|
+
if (!data || Array.isArray(data) && data.length === 0) {
|
|
58
|
+
return "Session has no messages or does not exist.";
|
|
59
|
+
}
|
|
60
|
+
return formatTranscript(data, limit);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return `Could not read session ${args.sessionID}: ${error instanceof Error ? error.message : "Unknown error"}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// plugin/files.ts
|
|
69
|
+
import * as path2 from "node:path";
|
|
70
|
+
import * as fs2 from "node:fs/promises";
|
|
71
|
+
|
|
72
|
+
// plugin/vendor.ts
|
|
73
|
+
import * as path from "node:path";
|
|
74
|
+
import * as fs from "node:fs/promises";
|
|
75
|
+
var DEFAULT_READ_LIMIT = 2000;
|
|
76
|
+
var MAX_LINE_LENGTH = 2000;
|
|
77
|
+
var BINARY_EXTENSIONS = new Set([
|
|
78
|
+
".zip",
|
|
79
|
+
".tar",
|
|
80
|
+
".gz",
|
|
81
|
+
".exe",
|
|
82
|
+
".dll",
|
|
83
|
+
".so",
|
|
84
|
+
".class",
|
|
85
|
+
".jar",
|
|
86
|
+
".war",
|
|
87
|
+
".7z",
|
|
88
|
+
".doc",
|
|
89
|
+
".docx",
|
|
90
|
+
".xls",
|
|
91
|
+
".xlsx",
|
|
92
|
+
".ppt",
|
|
93
|
+
".pptx",
|
|
94
|
+
".odt",
|
|
95
|
+
".ods",
|
|
96
|
+
".odp",
|
|
97
|
+
".bin",
|
|
98
|
+
".dat",
|
|
99
|
+
".obj",
|
|
100
|
+
".o",
|
|
101
|
+
".a",
|
|
102
|
+
".lib",
|
|
103
|
+
".wasm",
|
|
104
|
+
".pyc",
|
|
105
|
+
".pyo"
|
|
106
|
+
]);
|
|
107
|
+
async function isBinaryFile(filepath) {
|
|
108
|
+
const ext = path.extname(filepath).toLowerCase();
|
|
109
|
+
if (BINARY_EXTENSIONS.has(ext)) {
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const buffer = await fs.readFile(filepath);
|
|
114
|
+
if (!buffer)
|
|
115
|
+
return false;
|
|
116
|
+
const fileSize = buffer.length;
|
|
117
|
+
if (fileSize === 0)
|
|
118
|
+
return false;
|
|
119
|
+
const bufferSize = Math.min(4096, fileSize);
|
|
120
|
+
const bytes = buffer.subarray(0, bufferSize);
|
|
121
|
+
let nonPrintableCount = 0;
|
|
122
|
+
for (let i = 0;i < bytes.length; i++) {
|
|
123
|
+
const byte = bytes[i];
|
|
124
|
+
if (byte === undefined)
|
|
125
|
+
continue;
|
|
126
|
+
if (byte === 0)
|
|
127
|
+
return true;
|
|
128
|
+
if (byte < 9 || byte > 13 && byte < 32) {
|
|
129
|
+
nonPrintableCount++;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return nonPrintableCount / bytes.length > 0.3;
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function formatFileContent(_filepath, content) {
|
|
138
|
+
const lines = content.split(`
|
|
139
|
+
`);
|
|
140
|
+
const limit = DEFAULT_READ_LIMIT;
|
|
141
|
+
const offset = 0;
|
|
142
|
+
const raw = lines.slice(offset, offset + limit).map((line) => {
|
|
143
|
+
return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line;
|
|
144
|
+
});
|
|
145
|
+
const formatted = raw.map((line, index) => {
|
|
146
|
+
return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`;
|
|
147
|
+
});
|
|
148
|
+
let output = `<file>
|
|
149
|
+
`;
|
|
150
|
+
output += formatted.join(`
|
|
151
|
+
`);
|
|
152
|
+
const totalLines = lines.length;
|
|
153
|
+
const lastReadLine = offset + formatted.length;
|
|
154
|
+
const hasMoreLines = totalLines > lastReadLine;
|
|
155
|
+
if (hasMoreLines) {
|
|
156
|
+
output += `
|
|
157
|
+
|
|
158
|
+
(File has more lines. Use 'offset' parameter to read beyond line ${lastReadLine})`;
|
|
159
|
+
} else {
|
|
160
|
+
output += `
|
|
161
|
+
|
|
162
|
+
(End of file - total ${totalLines} lines)`;
|
|
163
|
+
}
|
|
164
|
+
output += `
|
|
165
|
+
</file>`;
|
|
166
|
+
return output;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// plugin/files.ts
|
|
170
|
+
var FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g;
|
|
171
|
+
function parseFileReferences(text) {
|
|
172
|
+
const fileRefs = new Set;
|
|
173
|
+
for (const match of text.matchAll(FILE_REGEX)) {
|
|
174
|
+
if (match[1]) {
|
|
175
|
+
fileRefs.add(match[1]);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return fileRefs;
|
|
179
|
+
}
|
|
180
|
+
async function buildSyntheticFileParts(directory, refs) {
|
|
181
|
+
const parts = [];
|
|
182
|
+
for (const ref of refs) {
|
|
183
|
+
const filepath = path2.resolve(directory, ref);
|
|
184
|
+
try {
|
|
185
|
+
const stats = await fs2.stat(filepath);
|
|
186
|
+
if (!stats.isFile())
|
|
187
|
+
continue;
|
|
188
|
+
if (await isBinaryFile(filepath))
|
|
189
|
+
continue;
|
|
190
|
+
const content = await fs2.readFile(filepath, "utf-8");
|
|
191
|
+
parts.push({
|
|
192
|
+
type: "text",
|
|
193
|
+
synthetic: true,
|
|
194
|
+
text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: filepath })}`
|
|
195
|
+
});
|
|
196
|
+
parts.push({
|
|
197
|
+
type: "text",
|
|
198
|
+
synthetic: true,
|
|
199
|
+
text: formatFileContent(filepath, content)
|
|
200
|
+
});
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
return parts;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// plugin/handoff.ts
|
|
207
|
+
var HANDOFF_COMMAND = `GOAL: You are creating a handoff message to continue work in a new session.
|
|
208
|
+
|
|
209
|
+
<context>
|
|
210
|
+
When an AI assistant starts a fresh session, it spends significant time exploring the codebase—grepping, reading files, searching—before it can begin actual work. This "file archaeology" is wasteful when the previous session already discovered what matters.
|
|
211
|
+
|
|
212
|
+
A good handoff frontloads everything the next session needs so it can start implementing immediately.
|
|
213
|
+
</context>
|
|
214
|
+
|
|
215
|
+
<instructions>
|
|
216
|
+
Analyze this conversation and extract what matters for continuing the work.
|
|
217
|
+
|
|
218
|
+
1. Identify all relevant files that should be loaded into the next session's context
|
|
219
|
+
|
|
220
|
+
Include files that will be edited, dependencies being touched, relevant tests, configs, and key reference docs. Be generous—the cost of an extra file is low; missing a critical one means another archaeology dig. Target 8-15 files, up to 20 for complex work.
|
|
221
|
+
|
|
222
|
+
2. Draft the context and goal description
|
|
223
|
+
|
|
224
|
+
Describe what we're working on and provide whatever context helps continue the work. Structure it based on what fits the conversation—could be tasks, findings, a simple paragraph, or detailed steps.
|
|
225
|
+
|
|
226
|
+
Preserve: decisions, constraints, user preferences, technical patterns.
|
|
227
|
+
|
|
228
|
+
Exclude: conversation back-and-forth, dead ends, meta-commentary.
|
|
229
|
+
|
|
230
|
+
The user controls what context matters. If they mentioned something to preserve, include it—trust their judgment about their workflow.
|
|
231
|
+
</instructions>
|
|
232
|
+
|
|
233
|
+
<user_input>
|
|
234
|
+
This is what the next session should focus on. Use it to shape your handoff's direction—don't investigate or search, just incorporate the intent into your context and goals.
|
|
235
|
+
|
|
236
|
+
If empty, capture a natural continuation of the current conversation's direction.
|
|
237
|
+
|
|
238
|
+
USER: $ARGUMENTS
|
|
239
|
+
</user_input>
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
Produce a prompt for a new thread that includes relevant context from this thread. Use @filepath format for file references. Conclude with a verbatim copy of the user's instruction above.`;
|
|
244
|
+
var getResponseData = (response) => {
|
|
245
|
+
if (typeof response === "object" && response !== null && "data" in response) {
|
|
246
|
+
return response.data;
|
|
247
|
+
}
|
|
248
|
+
return response;
|
|
249
|
+
};
|
|
250
|
+
var HandoffPlugin = async ({ client, directory }) => {
|
|
251
|
+
const log = async (level, message, extra) => {
|
|
252
|
+
try {
|
|
253
|
+
await client.app.log({
|
|
254
|
+
body: {
|
|
255
|
+
service: "handoff",
|
|
256
|
+
level,
|
|
257
|
+
message,
|
|
258
|
+
extra
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
} catch {}
|
|
262
|
+
};
|
|
263
|
+
const promptForInstruction = async () => {
|
|
264
|
+
await client.tui.showToast({
|
|
265
|
+
body: {
|
|
266
|
+
message: "Provide a handoff instruction after /handoff",
|
|
267
|
+
variant: "warning"
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
await client.tui.appendPrompt({ body: { text: "/handoff " } });
|
|
271
|
+
};
|
|
272
|
+
const extractMessageText = (entry) => {
|
|
273
|
+
if (!entry) {
|
|
274
|
+
return "";
|
|
275
|
+
}
|
|
276
|
+
return entry.parts.filter((part) => part.type === "text").map((part) => part.text ?? "").join("").trim();
|
|
277
|
+
};
|
|
278
|
+
const buildHandoff = async (sessionID, assistantMessageID, instruction) => {
|
|
279
|
+
await log("info", "buildHandoff called", { sessionID, assistantMessageID });
|
|
280
|
+
let messages;
|
|
281
|
+
try {
|
|
282
|
+
const response = await client.session.messages({
|
|
283
|
+
path: { id: sessionID }
|
|
284
|
+
});
|
|
285
|
+
messages = getResponseData(response);
|
|
286
|
+
await log("info", `Fetched ${messages.length} messages`);
|
|
287
|
+
} catch (err) {
|
|
288
|
+
await log("error", `Failed to fetch messages: ${err}`);
|
|
289
|
+
throw err;
|
|
290
|
+
}
|
|
291
|
+
const assistantMessage = messages.find((message) => message.info.id === assistantMessageID);
|
|
292
|
+
await log("info", `Looking for message id=${assistantMessageID}`);
|
|
293
|
+
await log("info", `Assistant message found: ${!!assistantMessage}`);
|
|
294
|
+
const handoffText = extractMessageText(assistantMessage);
|
|
295
|
+
if (!handoffText) {
|
|
296
|
+
await log("error", "No handoff text found");
|
|
297
|
+
throw new Error("No handoff content returned from the command");
|
|
298
|
+
}
|
|
299
|
+
await log("info", `Handoff text length: ${handoffText.length}`);
|
|
300
|
+
const providerID = assistantMessage?.info.providerID;
|
|
301
|
+
const modelID = assistantMessage?.info.modelID;
|
|
302
|
+
await log("info", `Using model: ${providerID}/${modelID}`);
|
|
303
|
+
const fileRefs = parseFileReferences(handoffText);
|
|
304
|
+
await log("info", `Found ${fileRefs.size} file references`, { files: Array.from(fileRefs) });
|
|
305
|
+
let fileParts = [];
|
|
306
|
+
if (fileRefs.size > 0) {
|
|
307
|
+
fileParts = await buildSyntheticFileParts(directory, fileRefs);
|
|
308
|
+
await log("info", `Built ${fileParts.length} synthetic file parts`);
|
|
309
|
+
}
|
|
310
|
+
let createdSessionId;
|
|
311
|
+
try {
|
|
312
|
+
const created = await client.session.create({ body: {} });
|
|
313
|
+
const createdSession = getResponseData(created);
|
|
314
|
+
createdSessionId = createdSession.id;
|
|
315
|
+
await log("info", `Created session: ${createdSessionId}`);
|
|
316
|
+
} catch (err) {
|
|
317
|
+
await log("error", `Failed to create session: ${err}`);
|
|
318
|
+
throw err;
|
|
319
|
+
}
|
|
320
|
+
await client.tui.showToast({
|
|
321
|
+
body: {
|
|
322
|
+
message: `Handoff session created`,
|
|
323
|
+
variant: "success",
|
|
324
|
+
duration: 5000
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
const sessionReference = `Continuing work from session ${sessionID}. When you lack specific information you can use read_session to get it.`;
|
|
328
|
+
const fullHandoffText = `${sessionReference}
|
|
329
|
+
|
|
330
|
+
${handoffText}`;
|
|
331
|
+
const promptBody = {
|
|
332
|
+
parts: [{ type: "text", text: fullHandoffText }, ...fileParts]
|
|
333
|
+
};
|
|
334
|
+
if (providerID && modelID) {
|
|
335
|
+
promptBody.model = { providerID, modelID };
|
|
336
|
+
}
|
|
337
|
+
client.session.prompt({
|
|
338
|
+
path: { id: createdSessionId },
|
|
339
|
+
body: promptBody
|
|
340
|
+
}).then(() => {
|
|
341
|
+
log("info", "AI response completed in handoff session");
|
|
342
|
+
}).catch((err) => {
|
|
343
|
+
log("error", `AI response failed: ${err}`);
|
|
344
|
+
});
|
|
345
|
+
await log("info", "Handoff session created, AI responding in background");
|
|
346
|
+
};
|
|
347
|
+
return {
|
|
348
|
+
config: async (config) => {
|
|
349
|
+
config.command = config.command || {};
|
|
350
|
+
config.command["handoff"] = {
|
|
351
|
+
description: "Create a focused handoff prompt for a new session",
|
|
352
|
+
template: HANDOFF_COMMAND
|
|
353
|
+
};
|
|
354
|
+
},
|
|
355
|
+
tool: {
|
|
356
|
+
read_session: ReadSession(client)
|
|
357
|
+
},
|
|
358
|
+
event: async ({ event }) => {
|
|
359
|
+
try {
|
|
360
|
+
if (event.type === "command.executed") {
|
|
361
|
+
await log("info", `Event received: ${event.type}`, { name: event.properties.name });
|
|
362
|
+
if (event.properties.name !== "handoff") {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const props = event.properties;
|
|
366
|
+
await log("info", "Handoff command matched", props);
|
|
367
|
+
const instruction = props.arguments?.trim() ?? "";
|
|
368
|
+
if (!instruction) {
|
|
369
|
+
await promptForInstruction();
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
await log("info", "About to call buildHandoff");
|
|
373
|
+
await buildHandoff(props.sessionID, props.messageID, instruction);
|
|
374
|
+
await log("info", "buildHandoff completed");
|
|
375
|
+
}
|
|
376
|
+
} catch (err) {
|
|
377
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
378
|
+
await log("error", `Event handler error: ${message}`);
|
|
379
|
+
await client.tui.showToast({
|
|
380
|
+
body: {
|
|
381
|
+
message: `Handoff failed: ${message}`,
|
|
382
|
+
variant: "error"
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
};
|
|
389
|
+
export {
|
|
390
|
+
HandoffPlugin
|
|
391
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File reference parsing and building for handoff sessions.
|
|
3
|
+
*
|
|
4
|
+
* Handles extraction of @file references from handoff prompts and
|
|
5
|
+
* building synthetic text parts that match OpenCode's Read tool output format.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* File reference regex matching OpenCode's internal pattern.
|
|
9
|
+
* Matches @file references like @src/plugin.ts
|
|
10
|
+
*/
|
|
11
|
+
export declare const FILE_REGEX: RegExp;
|
|
12
|
+
/**
|
|
13
|
+
* Synthetic text part matching OpenCode's TextPartInput structure.
|
|
14
|
+
*/
|
|
15
|
+
export type SyntheticTextPart = {
|
|
16
|
+
type: "text";
|
|
17
|
+
synthetic: true;
|
|
18
|
+
text: string;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Parse @file references from text.
|
|
22
|
+
*
|
|
23
|
+
* @param text - Text to search for @file references
|
|
24
|
+
* @returns Set of file paths referenced in the text
|
|
25
|
+
*/
|
|
26
|
+
export declare function parseFileReferences(text: string): Set<string>;
|
|
27
|
+
/**
|
|
28
|
+
* Build synthetic text parts matching OpenCode's Read tool output.
|
|
29
|
+
*
|
|
30
|
+
* Creates two synthetic text parts for each file:
|
|
31
|
+
* 1. Header describing the Read tool call
|
|
32
|
+
* 2. Formatted file content with line numbers
|
|
33
|
+
*
|
|
34
|
+
* @param directory - Project directory to resolve relative paths against
|
|
35
|
+
* @param refs - Set of file path references to check
|
|
36
|
+
* @returns Array of synthetic text parts (non-existent and binary files are skipped)
|
|
37
|
+
*/
|
|
38
|
+
export declare function buildSyntheticFileParts(directory: string, refs: Set<string>): Promise<SyntheticTextPart[]>;
|
|
39
|
+
//# sourceMappingURL=files.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"files.d.ts","sourceRoot":"","sources":["../../plugin/files.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH;;;GAGG;AACH,eAAO,MAAM,UAAU,QAA+C,CAAA;AAEtE;;GAEG;AACH,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,MAAM,CAAA;IACZ,SAAS,EAAE,IAAI,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;CACb,CAAA;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAU7D;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,uBAAuB,CAC3C,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,GAAG,CAAC,MAAM,CAAC,GAChB,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAoC9B"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"handoff.d.ts","sourceRoot":"","sources":["../../plugin/handoff.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAA;AA6DjD,eAAO,MAAM,aAAa,EAAE,MA8L3B,CAAA"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool definitions for opencode-handoff plugin.
|
|
3
|
+
*
|
|
4
|
+
* Factory functions that create tool definitions with injected dependencies:
|
|
5
|
+
* - ReadSession: Read conversation transcript from a session
|
|
6
|
+
*/
|
|
7
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
8
|
+
export type OpencodeClient = PluginInput["client"];
|
|
9
|
+
/**
|
|
10
|
+
* Create the read_session tool.
|
|
11
|
+
*
|
|
12
|
+
* Takes the OpenCode client as a dependency for session.messages() calls.
|
|
13
|
+
*/
|
|
14
|
+
export declare const ReadSession: (client: OpencodeClient) => {
|
|
15
|
+
description: string;
|
|
16
|
+
args: {
|
|
17
|
+
sessionID: import("zod").ZodString;
|
|
18
|
+
limit: import("zod").ZodOptional<import("zod").ZodNumber>;
|
|
19
|
+
};
|
|
20
|
+
execute(args: {
|
|
21
|
+
sessionID: string;
|
|
22
|
+
limit?: number | undefined;
|
|
23
|
+
}, context: import("@opencode-ai/plugin").ToolContext): Promise<string>;
|
|
24
|
+
};
|
|
25
|
+
//# sourceMappingURL=tools.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../../plugin/tools.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAGtD,MAAM,MAAM,cAAc,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAA;AAoDlD;;;;GAIG;AACH,eAAO,MAAM,WAAW,GAAI,QAAQ,cAAc;;;;;;;;;;CA2BjD,CAAA"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code extracted from OpenCode for compatibility.
|
|
3
|
+
*
|
|
4
|
+
* Source: https://github.com/sst/opencode
|
|
5
|
+
* File: packages/opencode/src/tool/read.ts
|
|
6
|
+
*
|
|
7
|
+
* These functions and constants are copied to ensure our synthetic file parts
|
|
8
|
+
* match OpenCode's Read tool output exactly.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Constants from OpenCode's ReadTool
|
|
12
|
+
*/
|
|
13
|
+
export declare const DEFAULT_READ_LIMIT = 2000;
|
|
14
|
+
export declare const MAX_LINE_LENGTH = 2000;
|
|
15
|
+
/**
|
|
16
|
+
* Check if a file is binary (copied from OpenCode's ReadTool)
|
|
17
|
+
*/
|
|
18
|
+
export declare function isBinaryFile(filepath: string): Promise<boolean>;
|
|
19
|
+
/**
|
|
20
|
+
* Format file content matching OpenCode's Read tool output format.
|
|
21
|
+
*
|
|
22
|
+
* @param _filepath - Absolute path to the file (unused in output, kept for signature compatibility)
|
|
23
|
+
* @param content - File content as string
|
|
24
|
+
* @returns Formatted output with line numbers in <file> tags
|
|
25
|
+
*/
|
|
26
|
+
export declare function formatFileContent(_filepath: string, content: string): string;
|
|
27
|
+
//# sourceMappingURL=vendor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"vendor.d.ts","sourceRoot":"","sources":["../../plugin/vendor.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH;;GAEG;AACH,eAAO,MAAM,kBAAkB,OAAO,CAAA;AACtC,eAAO,MAAM,eAAe,OAAO,CAAA;AAWnC;;GAEG;AACH,wBAAsB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAiCrE;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CA4B5E"}
|
package/package.json
CHANGED
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andrewhampton/opencode-handoff",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Handoff command plugin for OpenCode - transfer context to a new session",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "index.
|
|
6
|
+
"main": "dist/index.js",
|
|
7
7
|
"exports": {
|
|
8
|
-
".":
|
|
8
|
+
".": {
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts"
|
|
11
|
+
}
|
|
9
12
|
},
|
|
10
|
-
"types": "./index.ts",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
11
14
|
"files": [
|
|
12
|
-
"
|
|
13
|
-
"plugin/"
|
|
15
|
+
"dist"
|
|
14
16
|
],
|
|
15
17
|
"sideEffects": false,
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "bun build index.ts --outfile dist/index.js --target node --packages external && bun run build:types",
|
|
20
|
+
"build:types": "tsc --emitDeclarationOnly --declaration --outDir dist",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"test": "bun test",
|
|
23
|
+
"prepublishOnly": "bun run build"
|
|
24
|
+
},
|
|
16
25
|
"keywords": [
|
|
17
26
|
"opencode",
|
|
18
27
|
"plugin",
|
|
@@ -33,6 +42,11 @@
|
|
|
33
42
|
"peerDependencies": {
|
|
34
43
|
"@opencode-ai/plugin": ">=1.0.0"
|
|
35
44
|
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@opencode-ai/plugin": ">=1.0.0",
|
|
47
|
+
"@types/node": "^25.0.9",
|
|
48
|
+
"typescript": "^5.9.3"
|
|
49
|
+
},
|
|
36
50
|
"publishConfig": {
|
|
37
51
|
"access": "public"
|
|
38
52
|
}
|
package/index.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { HandoffPlugin } from "./plugin/handoff.ts"
|
package/plugin/handoff.ts
DELETED
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
import type { Plugin } from "@opencode-ai/plugin"
|
|
2
|
-
|
|
3
|
-
const getResponseData = <T>(response: { data?: T } | T): T => {
|
|
4
|
-
if (typeof response === "object" && response !== null && "data" in response) {
|
|
5
|
-
return response.data as T
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
return response as T
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
type MessageEntry = {
|
|
12
|
-
info: {
|
|
13
|
-
id: string
|
|
14
|
-
role: "user" | "assistant"
|
|
15
|
-
parentID?: string
|
|
16
|
-
providerID?: string
|
|
17
|
-
modelID?: string
|
|
18
|
-
}
|
|
19
|
-
parts: Array<{ type: string; text?: string }>
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export const HandoffPlugin: Plugin = async ({ client }) => {
|
|
23
|
-
const log = async (level: "info" | "error", message: string, extra?: object) => {
|
|
24
|
-
try {
|
|
25
|
-
await client.app.log({
|
|
26
|
-
body: {
|
|
27
|
-
service: "handoff",
|
|
28
|
-
level,
|
|
29
|
-
message,
|
|
30
|
-
extra,
|
|
31
|
-
},
|
|
32
|
-
})
|
|
33
|
-
} catch {
|
|
34
|
-
// Ignore log failures
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const promptForInstruction = async () => {
|
|
39
|
-
await client.tui.showToast({
|
|
40
|
-
body: {
|
|
41
|
-
message: "Provide a handoff instruction after /handoff",
|
|
42
|
-
variant: "warning",
|
|
43
|
-
},
|
|
44
|
-
})
|
|
45
|
-
await client.tui.appendPrompt({ body: { text: "/handoff " } })
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const extractMessageText = (entry?: MessageEntry) => {
|
|
49
|
-
if (!entry) {
|
|
50
|
-
return ""
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return entry.parts
|
|
54
|
-
.filter((part) => part.type === "text")
|
|
55
|
-
.map((part) => part.text ?? "")
|
|
56
|
-
.join("")
|
|
57
|
-
.trim()
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const buildHandoff = async (sessionID: string, assistantMessageID: string, instruction: string) => {
|
|
61
|
-
await log("info", "buildHandoff called", { sessionID, assistantMessageID })
|
|
62
|
-
|
|
63
|
-
let messages: Array<MessageEntry>
|
|
64
|
-
try {
|
|
65
|
-
const response = await client.session.messages({
|
|
66
|
-
path: { id: sessionID },
|
|
67
|
-
})
|
|
68
|
-
messages = getResponseData<Array<MessageEntry>>(response)
|
|
69
|
-
await log("info", `Fetched ${messages.length} messages`)
|
|
70
|
-
} catch (err) {
|
|
71
|
-
await log("error", `Failed to fetch messages: ${err}`)
|
|
72
|
-
throw err
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// The messageID from command.executed is the assistant message itself
|
|
76
|
-
const assistantMessage = messages.find(
|
|
77
|
-
(message) => message.info.id === assistantMessageID
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
await log("info", `Looking for message id=${assistantMessageID}`)
|
|
81
|
-
await log("info", `Assistant message found: ${!!assistantMessage}`)
|
|
82
|
-
|
|
83
|
-
const handoffText = extractMessageText(assistantMessage)
|
|
84
|
-
|
|
85
|
-
if (!handoffText) {
|
|
86
|
-
await log("error", "No handoff text found")
|
|
87
|
-
throw new Error("No handoff content returned from the command")
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
await log("info", `Handoff text length: ${handoffText.length}`)
|
|
91
|
-
|
|
92
|
-
// Get the model from the assistant message
|
|
93
|
-
const providerID = assistantMessage?.info.providerID
|
|
94
|
-
const modelID = assistantMessage?.info.modelID
|
|
95
|
-
await log("info", `Using model: ${providerID}/${modelID}`)
|
|
96
|
-
|
|
97
|
-
let createdSessionId: string
|
|
98
|
-
try {
|
|
99
|
-
const created = await client.session.create({ body: {} })
|
|
100
|
-
const createdSession = getResponseData<{ id: string }>(created)
|
|
101
|
-
createdSessionId = createdSession.id
|
|
102
|
-
await log("info", `Created session: ${createdSessionId}`)
|
|
103
|
-
} catch (err) {
|
|
104
|
-
await log("error", `Failed to create session: ${err}`)
|
|
105
|
-
throw err
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// Show toast immediately so user knows handoff is ready
|
|
109
|
-
await client.tui.showToast({
|
|
110
|
-
body: {
|
|
111
|
-
message: `Handoff session created`,
|
|
112
|
-
variant: "success",
|
|
113
|
-
duration: 5000,
|
|
114
|
-
},
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
// Send the prompt and trigger AI response (don't await - let it run in background)
|
|
118
|
-
const promptBody: {
|
|
119
|
-
parts: Array<{ type: "text"; text: string }>
|
|
120
|
-
model?: { providerID: string; modelID: string }
|
|
121
|
-
} = {
|
|
122
|
-
parts: [{ type: "text", text: handoffText }],
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (providerID && modelID) {
|
|
126
|
-
promptBody.model = { providerID, modelID }
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
client.session.prompt({
|
|
130
|
-
path: { id: createdSessionId },
|
|
131
|
-
body: promptBody,
|
|
132
|
-
}).then(() => {
|
|
133
|
-
log("info", "AI response completed in handoff session")
|
|
134
|
-
}).catch((err) => {
|
|
135
|
-
log("error", `AI response failed: ${err}`)
|
|
136
|
-
})
|
|
137
|
-
|
|
138
|
-
await log("info", "Handoff session created, AI responding in background")
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return {
|
|
142
|
-
event: async ({ event }) => {
|
|
143
|
-
try {
|
|
144
|
-
if (event.type === "command.executed") {
|
|
145
|
-
await log("info", `Event received: ${event.type}`, { name: (event.properties as { name?: string }).name })
|
|
146
|
-
|
|
147
|
-
if ((event.properties as { name?: string }).name !== "handoff") {
|
|
148
|
-
return
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const props = event.properties as {
|
|
152
|
-
name: string
|
|
153
|
-
sessionID: string
|
|
154
|
-
messageID: string
|
|
155
|
-
arguments?: string
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
await log("info", "Handoff command matched", props)
|
|
159
|
-
|
|
160
|
-
const instruction = props.arguments?.trim() ?? ""
|
|
161
|
-
if (!instruction) {
|
|
162
|
-
await promptForInstruction()
|
|
163
|
-
return
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
await log("info", "About to call buildHandoff")
|
|
167
|
-
await buildHandoff(props.sessionID, props.messageID, instruction)
|
|
168
|
-
await log("info", "buildHandoff completed")
|
|
169
|
-
}
|
|
170
|
-
} catch (err) {
|
|
171
|
-
const message = err instanceof Error ? err.message : String(err)
|
|
172
|
-
await log("error", `Event handler error: ${message}`)
|
|
173
|
-
await client.tui.showToast({
|
|
174
|
-
body: {
|
|
175
|
-
message: `Handoff failed: ${message}`,
|
|
176
|
-
variant: "error",
|
|
177
|
-
},
|
|
178
|
-
})
|
|
179
|
-
}
|
|
180
|
-
},
|
|
181
|
-
}
|
|
182
|
-
}
|