@andrewhampton/opencode-handoff 0.1.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/LICENSE +21 -0
- package/README.md +58 -0
- package/index.ts +1 -0
- package/package.json +39 -0
- package/plugin/handoff.ts +182 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrew Hampton
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# opencode-handoff
|
|
2
|
+
|
|
3
|
+
An [OpenCode](https://opencode.ai) plugin that implements the `/handoff` command, allowing you to transfer context from one session to a new one.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
When you run `/handoff <instruction>`, the plugin:
|
|
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
|
|
13
|
+
|
|
14
|
+
The new session uses the same model as the original session.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
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:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
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
|
+
}
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then restart OpenCode.
|
|
33
|
+
|
|
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.
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
In any OpenCode session, type:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
/handoff <your instruction>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
For example:
|
|
45
|
+
```
|
|
46
|
+
/handoff Continue implementing the user authentication feature
|
|
47
|
+
```
|
|
48
|
+
|
|
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.
|
|
50
|
+
|
|
51
|
+
## Limitations
|
|
52
|
+
|
|
53
|
+
- No API to auto-switch to the new session (you need to manually switch via `ctrl+p` > sessions)
|
|
54
|
+
- The command runs after completion, so there's a brief delay while the AI generates the handoff prompt
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
MIT
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { HandoffPlugin } from "./plugin/handoff.ts"
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@andrewhampton/opencode-handoff",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Handoff command plugin for OpenCode - transfer context to a new session",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.ts"
|
|
9
|
+
},
|
|
10
|
+
"types": "./index.ts",
|
|
11
|
+
"files": [
|
|
12
|
+
"index.ts",
|
|
13
|
+
"plugin/"
|
|
14
|
+
],
|
|
15
|
+
"sideEffects": false,
|
|
16
|
+
"keywords": [
|
|
17
|
+
"opencode",
|
|
18
|
+
"plugin",
|
|
19
|
+
"handoff",
|
|
20
|
+
"session",
|
|
21
|
+
"context"
|
|
22
|
+
],
|
|
23
|
+
"author": "Andrew Hampton",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/andrewhampton/opencode-handoff.git"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/andrewhampton/opencode-handoff/issues"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/andrewhampton/opencode-handoff#readme",
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@opencode-ai/plugin": ">=1.0.0"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
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
|
+
}
|