opencode-amp-like-handoff 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 ADDED
@@ -0,0 +1,191 @@
1
+ # opencode-amp-like-handoff
2
+
3
+ Amp-like new-session handoff plugin for [OpenCode](https://opencode.ai).
4
+
5
+ ## What it does
6
+
7
+ When an AI coding session accumulates enough context that starting fresh would be more efficient, `/handoff` distills the relevant context into a new session — avoiding the costly "file archaeology" phase where the AI re-discovers files, decisions, and patterns from scratch.
8
+
9
+ - Registers a `/handoff <goal>` command that triggers a structured context-transfer workflow
10
+ - The model analyzes the current conversation, extracts context relevant to the stated goal, and identifies the most important files (targeting 8–15, up to 20 for complex work)
11
+ - Creates a new session with the handoff prompt as an **editable draft** — the user reviews it, edits if needed, and sends when ready
12
+ - Files are referenced as `@file` markers in the draft (e.g., `@src/plugin.ts`); their contents are **auto-injected** when the user sends the message
13
+ - File contents match **OpenCode's Read tool format** (5-digit zero-padded line numbers, `<file>` tags), so the AI treats them identically to files it read itself
14
+ - Binary files are detected by extension and byte analysis, and silently skipped
15
+ - Records the source session ID so the new session knows where it came from
16
+ - Provides `read_session` to pull conversation transcript from the source session on demand
17
+ - Logs all operations via `app.log` for observability
18
+
19
+ ## Usage
20
+
21
+ ```
22
+ /handoff <your next goal>
23
+ ```
24
+
25
+ **Example:**
26
+
27
+ ```
28
+ /handoff Refactor the auth module to use JWT instead of sessions
29
+ ```
30
+
31
+ When invoked, the model:
32
+
33
+ 1. Reads the current conversation to infer task status
34
+ 2. Extracts decisions, constraints, user preferences, technical patterns, blockers, and exact next steps relevant to the stated goal
35
+ 3. Identifies 8–15 relevant files (source files, dependencies, tests, configs)
36
+ 4. Calls `handoff_session` with a focused continuation prompt and file list
37
+ 5. A new session opens with the handoff as an **editable draft** — review it, make changes, and press Enter to send
38
+ 6. When the message is sent, the `chat.message` hook automatically parses `@file` references, reads file contents from disk, and injects them as synthetic parts matching the Read tool format
39
+
40
+ In the new session, `read_session` is available to pull message history from the source session on demand if more detail is needed.
41
+
42
+ ## Tools
43
+
44
+ ### `handoff_session`
45
+
46
+ Creates a new session with the handoff prompt as an editable draft. File contents are auto-loaded when the user sends the message.
47
+
48
+ | Argument | Type | Required | Description |
49
+ |---|---|---|---|
50
+ | `prompt` | `string` | yes | The generated handoff prompt with context and goals. Must be non-empty. |
51
+ | `files` | `string[]` | no | File paths to load into the new session context (8–15 recommended). Leading `@` is stripped automatically. Duplicates and blank entries are ignored. |
52
+
53
+ **Success response:**
54
+
55
+ ```ts
56
+ {
57
+ ok: true,
58
+ sourceSessionId: string, // ID of the session that initiated the handoff
59
+ files: string[], // deduplicated list of file paths
60
+ message: string // confirmation message
61
+ }
62
+ ```
63
+
64
+ **Error response:**
65
+
66
+ ```ts
67
+ { ok: false, error: string }
68
+ ```
69
+
70
+ Errors are returned (not thrown) for: empty prompt, or failure to create the draft session.
71
+
72
+ ---
73
+
74
+ ### `read_session`
75
+
76
+ Reads conversation transcript from a previous session. Returns a formatted markdown transcript with `## User` and `## Assistant` sections.
77
+
78
+ | Argument | Type | Required | Description |
79
+ |---|---|---|---|
80
+ | `sessionId` | `string` | yes | The full session ID (e.g., `sess_01jxyz...`). Must be non-empty. |
81
+ | `limit` | `number` | no | Maximum number of messages to read. Clamped to 1–500. Defaults to `100`. |
82
+
83
+ **Success response:**
84
+
85
+ ```ts
86
+ {
87
+ ok: true,
88
+ sessionId: string,
89
+ count: number,
90
+ transcript: string // formatted markdown transcript
91
+ }
92
+ ```
93
+
94
+ **Error response:**
95
+
96
+ ```ts
97
+ { ok: false, error: string }
98
+ ```
99
+
100
+ ## How it works
101
+
102
+ ### Editable draft workflow
103
+
104
+ Unlike a direct `session.create()` + `session.prompt()` approach that auto-injects context without user review, this plugin uses an editable draft workflow:
105
+
106
+ 1. `handoff_session` calls `tui.executeCommand({ command: "session_new" })` to create a new session and navigate to it
107
+ 2. After a short delay (200ms for the TUI to mount), it calls `tui.appendPrompt()` to populate the prompt input with the handoff text
108
+ 3. The user sees the full handoff prompt as editable text — they can modify, remove, or add context before sending
109
+
110
+ ### Automatic file injection
111
+
112
+ When the user sends the handoff message, the `chat.message` hook:
113
+
114
+ 1. Detects messages containing "Continuing work from session" (the marker placed by the handoff tool)
115
+ 2. Parses `@file` references using the `FILE_REGEX` pattern
116
+ 3. Reads each file from disk, skipping binary files and unreadable paths
117
+ 4. Formats content with 5-digit zero-padded line numbers and `<file>` tags, matching OpenCode's Read tool output
118
+ 5. Injects synthetic text parts into the session via `session.prompt({ noReply: true })` — the AI sees them as if it called the Read tool itself
119
+
120
+ Each session is tracked in a `processedSessions` set to prevent duplicate injection. The set is cleaned up via the `event` hook when sessions are deleted.
121
+
122
+ ### File format
123
+
124
+ Files are formatted to match OpenCode's Read tool output:
125
+
126
+ ```
127
+ Called the Read tool with the following input: {"filePath": "/abs/path/to/file.ts"}
128
+
129
+ <file>
130
+ 00001| import { foo } from "./bar"
131
+ 00002|
132
+ 00003| export function hello() {
133
+ 00004| return "world"
134
+ 00005| }
135
+ 00006|
136
+
137
+ (End of file - total 6 lines)
138
+ </file>
139
+ ```
140
+
141
+ - Lines are zero-padded to 5 digits with a `| ` separator
142
+ - Lines longer than 2000 characters are truncated
143
+ - Files longer than 2000 lines show only the first 2000
144
+
145
+ ## Project structure
146
+
147
+ ```
148
+ src/
149
+ plugin.ts — Main plugin: hooks, handoff template, session lifecycle
150
+ tools.ts — HandoffSession and ReadSession tool definitions
151
+ files.ts — Binary detection, Read tool format, @file reference parsing
152
+ ```
153
+
154
+ ## Install locally
155
+
156
+ Place this plugin in a plugins directory supported by OpenCode:
157
+
158
+ - `.opencode/plugins/opencode-amp-like-handoff/` — project-level (checked into the repo)
159
+ - `~/.config/opencode/plugins/opencode-amp-like-handoff/` — user-level (applies to all projects)
160
+
161
+ Example directory tree:
162
+
163
+ ```text
164
+ .opencode/
165
+ plugins/
166
+ opencode-amp-like-handoff/
167
+ package.json
168
+ tsconfig.json
169
+ src/
170
+ plugin.ts
171
+ tools.ts
172
+ files.ts
173
+ ```
174
+
175
+ OpenCode picks up the plugin automatically on startup. If the plugin directory contains a `package.json`, OpenCode runs `bun install` to resolve dependencies before loading.
176
+
177
+ ## Development
178
+
179
+ ```bash
180
+ # Install dependencies
181
+ bun install
182
+
183
+ # Type-check without emitting output
184
+ bun run typecheck
185
+ ```
186
+
187
+ TypeScript configuration: strict mode, `bundler` module resolution, `ESNext` target. The plugin runs directly as TypeScript via Bun — no build step required.
188
+
189
+ ## License
190
+
191
+ MIT
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "opencode-amp-like-handoff",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "Amp-like new-session handoff plugin for OpenCode",
6
+ "main": "src/plugin.ts",
7
+ "exports": {
8
+ ".": "./src/plugin.ts"
9
+ },
10
+ "files": ["src"],
11
+ "author": "cuongnt3",
12
+ "license": "MIT",
13
+ "keywords": ["opencode", "plugin", "handoff", "session"],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/cuongntr/opencode-amp-like-handoff.git"
17
+ },
18
+ "homepage": "https://github.com/cuongntr/opencode-amp-like-handoff#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/cuongntr/opencode-amp-like-handoff/issues"
21
+ },
22
+ "scripts": {
23
+ "typecheck": "tsc --noEmit"
24
+ },
25
+ "peerDependencies": {
26
+ "@opencode-ai/plugin": "^1.3.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^25.5.0",
30
+ "typescript": "^6.0.2"
31
+ }
32
+ }
package/src/files.ts ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * File reference parsing, binary detection, and synthetic part building.
3
+ *
4
+ * Handles @file references from handoff prompts and builds synthetic text
5
+ * parts that match OpenCode's Read tool output format (line numbers, <file>
6
+ * tags) so the AI treats them identically to files it read itself.
7
+ */
8
+
9
+ import * as path from "node:path"
10
+ import * as fs from "node:fs/promises"
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Constants (matching OpenCode's ReadTool)
14
+ // ---------------------------------------------------------------------------
15
+
16
+ const DEFAULT_READ_LIMIT = 2000
17
+ const MAX_LINE_LENGTH = 2000
18
+
19
+ const BINARY_EXTENSIONS = new Set([
20
+ // Archives
21
+ ".7z",
22
+ ".gz",
23
+ ".jar",
24
+ ".tar",
25
+ ".war",
26
+ ".zip",
27
+ // Compiled / object
28
+ ".a",
29
+ ".bin",
30
+ ".class",
31
+ ".dat",
32
+ ".dll",
33
+ ".exe",
34
+ ".lib",
35
+ ".o",
36
+ ".obj",
37
+ ".pyc",
38
+ ".pyo",
39
+ ".so",
40
+ ".wasm",
41
+ // Documents
42
+ ".doc",
43
+ ".docx",
44
+ ".ods",
45
+ ".odt",
46
+ ".odp",
47
+ ".pdf",
48
+ ".ppt",
49
+ ".pptx",
50
+ ".xls",
51
+ ".xlsx",
52
+ // Images
53
+ ".bmp",
54
+ ".gif",
55
+ ".ico",
56
+ ".jpeg",
57
+ ".jpg",
58
+ ".png",
59
+ ".tiff",
60
+ ".webp",
61
+ // Audio / Video
62
+ ".mp3",
63
+ ".mp4",
64
+ ".ogg",
65
+ ".wav",
66
+ ".webm",
67
+ // Fonts
68
+ ".eot",
69
+ ".otf",
70
+ ".ttf",
71
+ ".woff",
72
+ ".woff2",
73
+ ])
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Types
77
+ // ---------------------------------------------------------------------------
78
+
79
+ export type SyntheticTextPart = {
80
+ type: "text"
81
+ text: string
82
+ synthetic: true
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // @file reference regex
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * Matches @file references like `@src/plugin.ts` or `@./config.json`.
91
+ * Must not be preceded by a word char or backtick to avoid false positives
92
+ * inside inline code or email addresses.
93
+ */
94
+ export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Public API
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /**
101
+ * Extract unique @file references from text.
102
+ */
103
+ export function parseFileReferences(text: string): Set<string> {
104
+ const refs = new Set<string>()
105
+ for (const match of text.matchAll(FILE_REGEX)) {
106
+ if (match[1]) refs.add(match[1])
107
+ }
108
+ return refs
109
+ }
110
+
111
+ /**
112
+ * Check whether a file is binary by extension or by sampling content bytes.
113
+ */
114
+ export async function isBinaryFile(filepath: string): Promise<boolean> {
115
+ const ext = path.extname(filepath).toLowerCase()
116
+ if (BINARY_EXTENSIONS.has(ext)) return true
117
+
118
+ try {
119
+ const buffer = await fs.readFile(filepath)
120
+ if (!buffer || buffer.length === 0) return false
121
+
122
+ const sample = buffer.subarray(0, Math.min(4096, buffer.length))
123
+ let nonPrintable = 0
124
+
125
+ for (let i = 0; i < sample.length; i++) {
126
+ const byte = sample[i]
127
+ if (byte === undefined) continue
128
+ // Null byte → binary
129
+ if (byte === 0) return true
130
+ // Control chars outside whitespace range
131
+ if (byte < 9 || (byte > 13 && byte < 32)) nonPrintable++
132
+ }
133
+
134
+ return nonPrintable / sample.length > 0.3
135
+ } catch {
136
+ return false
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Format file content matching OpenCode's Read tool output.
142
+ *
143
+ * - 5-digit zero-padded line numbers with `| ` separator
144
+ * - Lines truncated at MAX_LINE_LENGTH
145
+ * - Content limited to DEFAULT_READ_LIMIT lines
146
+ * - Wrapped in `<file>` / `</file>` tags
147
+ */
148
+ export function formatFileContent(
149
+ _filepath: string,
150
+ content: string,
151
+ ): string {
152
+ const lines = content.split("\n")
153
+ const limit = DEFAULT_READ_LIMIT
154
+
155
+ const visible = lines.slice(0, limit).map((line) =>
156
+ line.length > MAX_LINE_LENGTH
157
+ ? line.substring(0, MAX_LINE_LENGTH) + "..."
158
+ : line,
159
+ )
160
+
161
+ const numbered = visible.map(
162
+ (line, i) => `${(i + 1).toString().padStart(5, "0")}| ${line}`,
163
+ )
164
+
165
+ let output = "<file>\n"
166
+ output += numbered.join("\n")
167
+
168
+ if (lines.length > visible.length) {
169
+ output += `\n\n(File has more lines. Use 'offset' parameter to read beyond line ${visible.length})`
170
+ } else {
171
+ output += `\n\n(End of file - total ${lines.length} lines)`
172
+ }
173
+ output += "\n</file>"
174
+
175
+ return output
176
+ }
177
+
178
+ /**
179
+ * Build synthetic text parts for a set of @file references.
180
+ *
181
+ * Creates two synthetic parts per file, matching what OpenCode's Read tool
182
+ * produces:
183
+ * 1. Header: "Called the Read tool with the following input: ..."
184
+ * 2. Body: Formatted file content with line numbers inside <file> tags
185
+ *
186
+ * Binary files and unreadable files are silently skipped.
187
+ */
188
+ export async function buildSyntheticFileParts(
189
+ directory: string,
190
+ refs: Set<string>,
191
+ ): Promise<SyntheticTextPart[]> {
192
+ const parts: SyntheticTextPart[] = []
193
+
194
+ for (const ref of refs) {
195
+ const filepath = path.resolve(directory, ref)
196
+
197
+ try {
198
+ const stats = await fs.stat(filepath)
199
+ if (!stats.isFile()) continue
200
+ if (await isBinaryFile(filepath)) continue
201
+
202
+ const content = await fs.readFile(filepath, "utf-8")
203
+
204
+ // Header part — matches OpenCode's prompt.ts synthetic format
205
+ parts.push({
206
+ type: "text",
207
+ synthetic: true,
208
+ text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: filepath })}`,
209
+ })
210
+
211
+ // Content part with line numbers and <file> tags
212
+ parts.push({
213
+ type: "text",
214
+ synthetic: true,
215
+ text: formatFileContent(filepath, content),
216
+ })
217
+ } catch {
218
+ // Skip files that cannot be read — one bad path should not
219
+ // block the rest of the handoff.
220
+ }
221
+ }
222
+
223
+ return parts
224
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,178 @@
1
+ /**
2
+ * Amp-like session handoff plugin for OpenCode.
3
+ *
4
+ * Provides `/handoff <goal>` to create a focused continuation session.
5
+ * The handoff prompt appears as an editable draft — the user reviews it,
6
+ * edits if needed, and sends. File contents are auto-injected when the
7
+ * message arrives, matching OpenCode's Read tool format so the AI treats
8
+ * them as if it read the files itself.
9
+ */
10
+
11
+ import type { Plugin } from "@opencode-ai/plugin"
12
+ import { HandoffSession, ReadSession } from "./tools"
13
+ import { parseFileReferences, buildSyntheticFileParts } from "./files"
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Handoff template
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const HANDOFF_TEMPLATE = `GOAL: You are creating a handoff message to continue work in a new session.
20
+
21
+ <context>
22
+ When an AI assistant starts a fresh session, it spends significant time
23
+ exploring the codebase — grepping, reading files, searching — before it can
24
+ begin actual work. This "file archaeology" is wasteful when the previous
25
+ session already discovered what matters.
26
+
27
+ A good handoff frontloads everything the next session needs so it can start
28
+ implementing immediately.
29
+ </context>
30
+
31
+ <instructions>
32
+ Analyze this conversation and extract what matters for continuing the work.
33
+
34
+ 1. Identify all relevant files that should be loaded into the next session
35
+
36
+ Include files that will be edited, dependencies being touched, relevant
37
+ tests, configs, and key reference docs. Be generous — the cost of an
38
+ extra file is low; missing a critical one means another archaeology dig.
39
+ Target 8-15 files, up to 20 for complex work.
40
+
41
+ 2. Draft the context and goal description
42
+
43
+ Describe what we're working on and provide whatever context helps
44
+ continue the work. Structure it based on what fits the conversation —
45
+ could be tasks, findings, a simple paragraph, or detailed steps.
46
+
47
+ Preserve: decisions, constraints, user preferences, technical patterns,
48
+ blockers, and exact next steps.
49
+ Exclude: conversation back-and-forth, dead ends, meta-commentary.
50
+
51
+ The user controls what context matters. If they mentioned something to
52
+ preserve, include it — trust their judgment about their workflow.
53
+ </instructions>
54
+
55
+ <user_input>
56
+ This is what the next session should focus on. Use it to shape your
57
+ handoff's direction — don't investigate or search, just incorporate the
58
+ intent into your context and goals.
59
+
60
+ If empty, capture a natural continuation of the current conversation's
61
+ direction.
62
+
63
+ USER: $ARGUMENTS
64
+ </user_input>
65
+
66
+ ---
67
+
68
+ After generating the handoff message, IMMEDIATELY call handoff_session with
69
+ your prompt and files:
70
+ \`handoff_session(prompt="...", files=["src/foo.ts", "src/bar.ts", ...])\``
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Plugin
74
+ // ---------------------------------------------------------------------------
75
+
76
+ export const AmpLikeHandoffPlugin: Plugin = async (ctx) => {
77
+ // Track sessions that have already had file parts injected to avoid
78
+ // duplicate processing when chat.message fires multiple times.
79
+ const processedSessions = new Set<string>()
80
+
81
+ return {
82
+ // -----------------------------------------------------------------
83
+ // Register /handoff command
84
+ // -----------------------------------------------------------------
85
+ config: async (config) => {
86
+ config.command ||= {}
87
+ config.command.handoff = {
88
+ description: "Create a focused handoff prompt for a new session",
89
+ template: HANDOFF_TEMPLATE,
90
+ }
91
+ },
92
+
93
+ // -----------------------------------------------------------------
94
+ // Expose tools
95
+ // -----------------------------------------------------------------
96
+ tool: {
97
+ handoff_session: HandoffSession(ctx.client),
98
+ read_session: ReadSession(ctx.client),
99
+ },
100
+
101
+ // -----------------------------------------------------------------
102
+ // Auto-inject file contents when a handoff message arrives
103
+ // -----------------------------------------------------------------
104
+ "chat.message": async (_input, output) => {
105
+ const sessionID = output.message.sessionID
106
+
107
+ // Skip if we already injected files into this session
108
+ if (processedSessions.has(sessionID)) return
109
+
110
+ // Extract non-synthetic text from the message parts
111
+ const text = output.parts
112
+ .filter(
113
+ (p): p is typeof p & { type: "text"; text: string } =>
114
+ p.type === "text" && !p.synthetic && typeof p.text === "string",
115
+ )
116
+ .map((p) => p.text)
117
+ .join("\n")
118
+
119
+ // Only process messages that look like handoff continuations
120
+ if (!text.includes("Continuing work from session")) return
121
+
122
+ // Mark as processed immediately to prevent re-entry
123
+ processedSessions.add(sessionID)
124
+
125
+ // Parse @file references from the message text
126
+ const fileRefs = parseFileReferences(text)
127
+ if (fileRefs.size === 0) return
128
+
129
+ const fileParts = await buildSyntheticFileParts(ctx.directory, fileRefs)
130
+ if (fileParts.length === 0) return
131
+
132
+ // Inject synthetic file parts into the session without triggering a
133
+ // reply. Pass model and agent from the current message to prevent
134
+ // mode or model switching.
135
+ try {
136
+ await ctx.client.session.prompt({
137
+ path: { id: sessionID },
138
+ body: {
139
+ noReply: true,
140
+ model: output.message.model,
141
+ agent: output.message.agent,
142
+ parts: fileParts,
143
+ },
144
+ })
145
+
146
+ await ctx.client.app.log({
147
+ body: {
148
+ service: "handoff",
149
+ level: "info",
150
+ message: "Injected synthetic file parts",
151
+ extra: { sessionID, fileCount: fileRefs.size },
152
+ },
153
+ })
154
+ } catch (err) {
155
+ const error = err instanceof Error ? err.message : String(err)
156
+ await ctx.client.app.log({
157
+ body: {
158
+ service: "handoff",
159
+ level: "error",
160
+ message: "Failed to inject file parts",
161
+ extra: { sessionID, error },
162
+ },
163
+ })
164
+ }
165
+ },
166
+
167
+ // -----------------------------------------------------------------
168
+ // Cleanup: remove tracking when sessions are deleted
169
+ // -----------------------------------------------------------------
170
+ event: async ({ event }) => {
171
+ if (event.type === "session.deleted") {
172
+ processedSessions.delete(event.properties.info.id)
173
+ }
174
+ },
175
+ }
176
+ }
177
+
178
+ export default AmpLikeHandoffPlugin
package/src/tools.ts ADDED
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Tool definitions for the handoff plugin.
3
+ *
4
+ * - HandoffSession: Creates new session with the handoff prompt as an
5
+ * editable draft. File contents are injected later via chat.message hook.
6
+ * - ReadSession: Reads conversation transcript from a previous session with
7
+ * formatted markdown output.
8
+ */
9
+
10
+ import type { PluginInput } from "@opencode-ai/plugin"
11
+ import { tool } from "@opencode-ai/plugin"
12
+
13
+ export type OpencodeClient = PluginInput["client"]
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // HandoffSession
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export const HandoffSession = (client: OpencodeClient) => {
20
+ return tool({
21
+ description:
22
+ "Create a new session with the handoff prompt as an editable draft. " +
23
+ "The user can review, edit, and send when ready. " +
24
+ "File contents are auto-loaded when the message is sent.",
25
+ args: {
26
+ prompt: tool.schema
27
+ .string()
28
+ .describe("The generated handoff prompt with context and goals"),
29
+ files: tool.schema
30
+ .array(tool.schema.string())
31
+ .optional()
32
+ .describe(
33
+ "File paths to load into the new session context (8-15 recommended)",
34
+ ),
35
+ },
36
+ async execute(
37
+ args: { prompt: string; files?: string[] },
38
+ context: { sessionID: string },
39
+ ) {
40
+ const prompt = args.prompt?.trim()
41
+ if (!prompt) {
42
+ return JSON.stringify({ ok: false, error: "Handoff prompt must not be empty." })
43
+ }
44
+
45
+ const sourceSessionId = context.sessionID
46
+ const sessionReference = `Continuing work from session ${sourceSessionId}. Use read_session if you need details not included here.`
47
+
48
+ // Format files as @file references — the chat.message hook will parse
49
+ // these and inject synthetic file parts when the user sends the message.
50
+ const uniqueFiles = [
51
+ ...new Set(
52
+ (args.files ?? [])
53
+ .map((f) => f.trim().replace(/^@/, ""))
54
+ .filter(Boolean),
55
+ ),
56
+ ]
57
+ const fileRefs = uniqueFiles.length
58
+ ? uniqueFiles.map((f) => `@${f}`).join(" ")
59
+ : ""
60
+
61
+ const fullPrompt = fileRefs
62
+ ? `${sessionReference}\n\n${fileRefs}\n\n${prompt}`
63
+ : `${sessionReference}\n\n${prompt}`
64
+
65
+ // Create a new session via TUI command — this also navigates to it
66
+ // automatically, so the user lands in the new session ready to review.
67
+ try {
68
+ await client.tui.executeCommand({
69
+ body: { command: "session_new" },
70
+ })
71
+ // session_new fires asynchronously. The TUI needs a moment to
72
+ // navigate and mount the new prompt input before we can append text.
73
+ await new Promise((r) => setTimeout(r, 200))
74
+ await client.tui.appendPrompt({ body: { text: fullPrompt } })
75
+ } catch (err) {
76
+ const error = err instanceof Error ? err.message : String(err)
77
+ await client.app.log({
78
+ body: {
79
+ service: "handoff",
80
+ level: "error",
81
+ message: "Failed to create handoff draft",
82
+ extra: { error, sourceSessionId },
83
+ },
84
+ })
85
+ return JSON.stringify({ ok: false, error: `Failed to create handoff draft: ${error}` })
86
+ }
87
+
88
+ // Non-fatal toast notification
89
+ try {
90
+ await client.tui.showToast({
91
+ body: {
92
+ title: "Handoff Ready",
93
+ message: "Review the draft, edit if needed, then send",
94
+ variant: "success",
95
+ duration: 4000,
96
+ },
97
+ })
98
+ } catch {
99
+ // TUI toast may not be available in headless/test contexts
100
+ }
101
+
102
+ await client.app.log({
103
+ body: {
104
+ service: "handoff",
105
+ level: "info",
106
+ message: "Handoff draft created",
107
+ extra: { fileCount: uniqueFiles.length, sourceSessionId },
108
+ },
109
+ })
110
+
111
+ return JSON.stringify({
112
+ ok: true,
113
+ sourceSessionId,
114
+ files: uniqueFiles,
115
+ message:
116
+ "Handoff prompt created as editable draft in new session. " +
117
+ "File contents will be loaded automatically when the user sends.",
118
+ })
119
+ },
120
+ })
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Transcript formatting
125
+ // ---------------------------------------------------------------------------
126
+
127
+ function formatTranscript(
128
+ messages: Array<{
129
+ info: Record<string, unknown>
130
+ parts: Array<Record<string, unknown>>
131
+ }>,
132
+ limit: number,
133
+ ): string {
134
+ const lines: string[] = []
135
+
136
+ for (const msg of messages) {
137
+ const role = msg.info.role as string
138
+
139
+ if (role === "user") {
140
+ lines.push("## User")
141
+ for (const part of msg.parts) {
142
+ if (part.type === "text" && !part.ignored) {
143
+ lines.push(part.text as string)
144
+ }
145
+ if (part.type === "file") {
146
+ lines.push(
147
+ `[Attached: ${(part.filename as string) || "file"}]`,
148
+ )
149
+ }
150
+ }
151
+ lines.push("")
152
+ }
153
+
154
+ if (role === "assistant") {
155
+ lines.push("## Assistant")
156
+ for (const part of msg.parts) {
157
+ if (part.type === "text") {
158
+ lines.push(part.text as string)
159
+ }
160
+ if (part.type === "tool") {
161
+ const state = part.state as Record<string, unknown> | undefined
162
+ if (state?.status === "completed") {
163
+ lines.push(
164
+ `[Tool: ${part.tool as string}] ${(state.title as string) ?? ""}`,
165
+ )
166
+ }
167
+ }
168
+ }
169
+ lines.push("")
170
+ }
171
+ }
172
+
173
+ const output = lines.join("\n").trim()
174
+ const suffix =
175
+ messages.length >= limit
176
+ ? `\n\n(Showing ${messages.length} most recent messages. Use a higher 'limit' to see more.)`
177
+ : `\n\n(End of session — ${messages.length} messages)`
178
+
179
+ return output + suffix
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // ReadSession
184
+ // ---------------------------------------------------------------------------
185
+
186
+ export const ReadSession = (client: OpencodeClient) => {
187
+ return tool({
188
+ description:
189
+ "Read conversation transcript from a previous session. " +
190
+ "Returns a formatted markdown transcript. " +
191
+ "Use when you need specific information not included in the handoff summary.",
192
+ args: {
193
+ sessionId: tool.schema
194
+ .string()
195
+ .describe("The full session ID (e.g., sess_01jxyz...)"),
196
+ limit: tool.schema
197
+ .number()
198
+ .optional()
199
+ .describe("Max messages to read (default 100, max 500)"),
200
+ },
201
+ async execute(args: { sessionId: string; limit?: number }) {
202
+ if (!args.sessionId?.trim()) {
203
+ return JSON.stringify({ ok: false, error: "sessionId must not be empty." })
204
+ }
205
+
206
+ const limit = Math.max(1, Math.min(args.limit ?? 100, 500))
207
+
208
+ try {
209
+ const response = (await client.session.messages({
210
+ path: { id: args.sessionId },
211
+ query: { limit },
212
+ })) as
213
+ | { data?: unknown[] }
214
+ | unknown[]
215
+
216
+ const messages = (
217
+ Array.isArray(response) ? response : (response.data ?? [])
218
+ ) as Array<{
219
+ info: Record<string, unknown>
220
+ parts: Array<Record<string, unknown>>
221
+ }>
222
+
223
+ if (messages.length === 0) {
224
+ return JSON.stringify({
225
+ ok: false,
226
+ error: "Session has no messages or does not exist.",
227
+ })
228
+ }
229
+
230
+ return JSON.stringify({
231
+ ok: true,
232
+ sessionId: args.sessionId,
233
+ count: messages.length,
234
+ transcript: formatTranscript(messages, limit),
235
+ })
236
+ } catch (err) {
237
+ const error = err instanceof Error ? err.message : String(err)
238
+ return JSON.stringify({
239
+ ok: false,
240
+ error: `Failed to read session ${args.sessionId}: ${error}`,
241
+ })
242
+ }
243
+ },
244
+ })
245
+ }