opencode-handoff 0.3.1 → 0.4.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
@@ -13,22 +13,29 @@ Inspired by Amp's handoff command - see their [post](https://ampcode.com/news/ha
13
13
 
14
14
  ## Requirements
15
15
 
16
- - [OpenCode](https://opencode.ai/) v1.0.143 or later
16
+ - [OpenCode](https://opencode.ai/) v1.0.188 or later
17
17
 
18
18
  ## Installation
19
19
 
20
- Add to your OpenCode config (`~/.config/opencode/config.json`):
20
+ Add to your OpenCode config (`~/.config/opencode/opencode.json`):
21
21
 
22
22
  ```json
23
23
  {
24
- "plugin": ["opencode-handoff@0.3.1"]
24
+ "plugin": ["opencode-handoff"]
25
25
  }
26
26
  ```
27
27
 
28
28
  Restart OpenCode and you're ready to go.
29
29
 
30
- Pin to a specific version to ensure updates work correctly - OpenCode's lockfile won't re-resolve unpinned versions. To upgrade, change the version and restart.
30
+ Optionally, pin to a specific version for stability:
31
31
 
32
+ ```json
33
+ {
34
+ "plugin": ["opencode-handoff@0.4.0"]
35
+ }
36
+ ```
37
+
38
+ OpenCode fetches unpinned plugins from npm on each startup; pinned versions are cached and require a manual version bump to update.
32
39
  ### Local Development
33
40
 
34
41
  If you want to customize or contribute:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-handoff",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Create focused handoff prompts for continuing work in new OpenCode sessions",
6
6
  "author": "Josh Thomas <josh@joshthomas.dev>",
@@ -21,8 +21,8 @@
21
21
  "typecheck": "tsc --noEmit"
22
22
  },
23
23
  "dependencies": {
24
- "@opencode-ai/plugin": "^1.0.143",
25
- "@opencode-ai/sdk": "^1.0.143",
24
+ "@opencode-ai/plugin": "^1.0.188",
25
+ "@opencode-ai/sdk": "^1.0.188",
26
26
  "zod": "^4.1.13"
27
27
  },
28
28
  "devDependencies": {
package/src/files.ts CHANGED
@@ -2,12 +2,13 @@
2
2
  * File reference parsing and building for handoff sessions.
3
3
  *
4
4
  * Handles extraction of @file references from handoff prompts and
5
- * building file parts for injection into new sessions.
5
+ * building synthetic text parts that match OpenCode's Read tool output format.
6
6
  */
7
7
 
8
8
  import * as path from "node:path"
9
9
  import * as fs from "node:fs/promises"
10
- import type { FilePartInput } from "@opencode-ai/sdk"
10
+ import type { TextPartInput } from "@opencode-ai/sdk"
11
+ import { isBinaryFile, formatFileContent } from "./vendor"
11
12
 
12
13
  /**
13
14
  * File reference regex matching OpenCode's internal pattern.
@@ -34,33 +35,53 @@ export function parseFileReferences(text: string): Set<string> {
34
35
  }
35
36
 
36
37
  /**
37
- * Build file parts for files that exist.
38
+ * Build synthetic text parts matching OpenCode's Read tool output.
39
+ *
40
+ * Creates two synthetic text parts for each file:
41
+ * 1. Header describing the Read tool call
42
+ * 2. Formatted file content with line numbers
38
43
  *
39
44
  * @param directory - Project directory to resolve relative paths against
40
45
  * @param refs - Set of file path references to check
41
- * @returns Array of file parts for existing files (non-existent files are skipped)
46
+ * @returns Array of synthetic text parts (non-existent and binary files are skipped)
42
47
  */
43
- export async function buildFileParts(
48
+ export async function buildSyntheticFileParts(
44
49
  directory: string,
45
50
  refs: Set<string>
46
- ): Promise<FilePartInput[]> {
47
- const fileParts: FilePartInput[] = []
51
+ ): Promise<TextPartInput[]> {
52
+ const parts: TextPartInput[] = []
48
53
 
49
54
  for (const ref of refs) {
50
55
  const filepath = path.resolve(directory, ref)
51
56
 
52
57
  try {
53
- await fs.stat(filepath)
54
- fileParts.push({
55
- type: "file",
56
- mime: "text/plain",
57
- url: `file://${filepath}`,
58
- filename: ref,
58
+ // Check if file exists
59
+ const stats = await fs.stat(filepath)
60
+ if (!stats.isFile()) continue
61
+
62
+ // Skip binary files
63
+ if (await isBinaryFile(filepath)) continue
64
+
65
+ // Read file content
66
+ const content = await fs.readFile(filepath, "utf-8")
67
+
68
+ // Create header part (matching OpenCode's prompt.ts:820 format)
69
+ parts.push({
70
+ type: "text",
71
+ synthetic: true,
72
+ text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: filepath })}`
73
+ })
74
+
75
+ // Create content part (matching OpenCode's ReadTool format)
76
+ parts.push({
77
+ type: "text",
78
+ synthetic: true,
79
+ text: formatFileContent(filepath, content)
59
80
  })
60
81
  } catch {
61
- // Skip silently if file doesn't exist
82
+ // Skip silently if file can't be read
62
83
  }
63
84
  }
64
85
 
65
- return fileParts
86
+ return parts
66
87
  }
package/src/plugin.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin"
2
2
  import { HandoffSession, ReadSession } from "./tools"
3
- import { parseFileReferences, buildFileParts } from "./files"
3
+ import { parseFileReferences, buildSyntheticFileParts } from "./files"
4
4
 
5
5
  const HANDOFF_COMMAND = `GOAL: You are creating a handoff message to continue work in a new session.
6
6
 
@@ -29,7 +29,9 @@ The user controls what context matters. If they mentioned something to preserve,
29
29
  </instructions>
30
30
 
31
31
  <user_input>
32
- The user's guidance for continuing work. If empty, the handoff should capture a natural continuation of the current conversation's direction.
32
+ 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.
33
+
34
+ If empty, capture a natural continuation of the current conversation's direction.
33
35
 
34
36
  USER: $ARGUMENTS
35
37
  </user_input>
@@ -76,7 +78,7 @@ export const HandoffPlugin: Plugin = async (ctx) => {
76
78
  const fileRefs = parseFileReferences(text)
77
79
  if (fileRefs.size === 0) return
78
80
 
79
- const fileParts = await buildFileParts(ctx.directory, fileRefs)
81
+ const fileParts = await buildSyntheticFileParts(ctx.directory, fileRefs)
80
82
  if (fileParts.length === 0) return
81
83
 
82
84
  // Inject file parts via noReply
package/src/tools.ts CHANGED
@@ -32,16 +32,7 @@ export const HandoffSession = (client: OpencodeClient) => {
32
32
  ? `${sessionReference}\n\n${fileRefs}\n\n${args.prompt}`
33
33
  : `${sessionReference}\n\n${args.prompt}`
34
34
 
35
- // Double-append workaround for textarea resize bug:
36
- // appendPrompt uses insertText() which bypasses onContentChange, so resize never triggers.
37
- // First append sets height in old session, session_new preserves textarea element,
38
- // second append populates new session with already-expanded textarea.
39
- await client.tui.clearPrompt()
40
- await new Promise(r => setTimeout(r, 50))
41
- await client.tui.appendPrompt({ body: { text: fullPrompt } })
42
35
  await client.tui.executeCommand({ body: { command: "session_new" } })
43
- await client.tui.clearPrompt()
44
- await new Promise(r => setTimeout(r, 50))
45
36
  await client.tui.appendPrompt({ body: { text: fullPrompt } })
46
37
 
47
38
  await client.tui.showToast({
package/src/vendor.ts ADDED
@@ -0,0 +1,102 @@
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
+ import * as path from "node:path"
12
+ import * as fs from "node:fs/promises"
13
+
14
+ /**
15
+ * Constants from OpenCode's ReadTool
16
+ */
17
+ export const DEFAULT_READ_LIMIT = 2000
18
+ export const MAX_LINE_LENGTH = 2000
19
+
20
+ /**
21
+ * Binary file extensions (from OpenCode's ReadTool)
22
+ */
23
+ const BINARY_EXTENSIONS = new Set([
24
+ ".zip", ".tar", ".gz", ".exe", ".dll", ".so", ".class", ".jar", ".war",
25
+ ".7z", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".odt", ".ods",
26
+ ".odp", ".bin", ".dat", ".obj", ".o", ".a", ".lib", ".wasm", ".pyc", ".pyo"
27
+ ])
28
+
29
+ /**
30
+ * Check if a file is binary (copied from OpenCode's ReadTool)
31
+ */
32
+ export async function isBinaryFile(filepath: string): Promise<boolean> {
33
+ const ext = path.extname(filepath).toLowerCase()
34
+
35
+ // Check extension first
36
+ if (BINARY_EXTENSIONS.has(ext)) {
37
+ return true
38
+ }
39
+
40
+ try {
41
+ const buffer = await fs.readFile(filepath)
42
+ if (!buffer) return false
43
+
44
+ const fileSize = buffer.length
45
+ if (fileSize === 0) return false
46
+
47
+ const bufferSize = Math.min(4096, fileSize)
48
+ const bytes = buffer.subarray(0, bufferSize)
49
+
50
+ let nonPrintableCount = 0
51
+ for (let i = 0; i < bytes.length; i++) {
52
+ const byte = bytes[i]
53
+ if (byte === undefined) continue
54
+ if (byte === 0) return true
55
+ if (byte < 9 || (byte > 13 && byte < 32)) {
56
+ nonPrintableCount++
57
+ }
58
+ }
59
+
60
+ // If >30% non-printable characters, consider it binary
61
+ return nonPrintableCount / bytes.length > 0.3
62
+ } catch {
63
+ return false
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Format file content matching OpenCode's Read tool output format.
69
+ *
70
+ * @param _filepath - Absolute path to the file (unused in output, kept for signature compatibility)
71
+ * @param content - File content as string
72
+ * @returns Formatted output with line numbers in <file> tags
73
+ */
74
+ export function formatFileContent(_filepath: string, content: string): string {
75
+ const lines = content.split("\n")
76
+ const limit = DEFAULT_READ_LIMIT
77
+ const offset = 0
78
+
79
+ const raw = lines.slice(offset, offset + limit).map((line) => {
80
+ return line.length > MAX_LINE_LENGTH ? line.substring(0, MAX_LINE_LENGTH) + "..." : line
81
+ })
82
+
83
+ const formatted = raw.map((line, index) => {
84
+ return `${(index + offset + 1).toString().padStart(5, "0")}| ${line}`
85
+ })
86
+
87
+ let output = "<file>\n"
88
+ output += formatted.join("\n")
89
+
90
+ const totalLines = lines.length
91
+ const lastReadLine = offset + formatted.length
92
+ const hasMoreLines = totalLines > lastReadLine
93
+
94
+ if (hasMoreLines) {
95
+ output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${lastReadLine})`
96
+ } else {
97
+ output += `\n\n(End of file - total ${totalLines} lines)`
98
+ }
99
+ output += "\n</file>"
100
+
101
+ return output
102
+ }