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 +11 -4
- package/package.json +3 -3
- package/src/files.ts +36 -15
- package/src/plugin.ts +5 -3
- package/src/tools.ts +0 -9
- package/src/vendor.ts +102 -0
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.
|
|
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/
|
|
20
|
+
Add to your OpenCode config (`~/.config/opencode/opencode.json`):
|
|
21
21
|
|
|
22
22
|
```json
|
|
23
23
|
{
|
|
24
|
-
"plugin": ["opencode-handoff
|
|
24
|
+
"plugin": ["opencode-handoff"]
|
|
25
25
|
}
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
Restart OpenCode and you're ready to go.
|
|
29
29
|
|
|
30
|
-
|
|
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
|
+
"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.
|
|
25
|
-
"@opencode-ai/sdk": "^1.0.
|
|
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
|
|
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 {
|
|
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
|
|
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
|
|
46
|
+
* @returns Array of synthetic text parts (non-existent and binary files are skipped)
|
|
42
47
|
*/
|
|
43
|
-
export async function
|
|
48
|
+
export async function buildSyntheticFileParts(
|
|
44
49
|
directory: string,
|
|
45
50
|
refs: Set<string>
|
|
46
|
-
): Promise<
|
|
47
|
-
const
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
82
|
+
// Skip silently if file can't be read
|
|
62
83
|
}
|
|
63
84
|
}
|
|
64
85
|
|
|
65
|
-
return
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|