@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 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 summarize the current thread context
10
- 2. Creates a new session
11
- 3. Sends the handoff prompt (with your instruction) to the new session
12
- 4. Shows a toast notification when complete
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 and command to your `opencode.json` (or `opencode.jsonc`). The plugin is available on npm as `opencode-handoff`, and OpenCode will install it automatically when you restart:
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:** Both the plugin and command are required. The command tells the AI how to generate the handoff summary, and the plugin listens for the command's completion to create the new session.
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 review the current thread, create a summary with relevant context, and start a new session with that context plus your instruction.
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
 
@@ -0,0 +1,2 @@
1
+ export { HandoffPlugin } from "./plugin/handoff";
2
+ //# sourceMappingURL=index.d.ts.map
@@ -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,3 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ export declare const HandoffPlugin: Plugin;
3
+ //# sourceMappingURL=handoff.d.ts.map
@@ -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.1.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.ts",
6
+ "main": "dist/index.js",
7
7
  "exports": {
8
- ".": "./index.ts"
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
- "index.ts",
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
- }