Browse Source

Adding handoff

Alvin Unreal 3 months ago
parent
commit
ad90b8fe83

+ 1 - 0
README.md

@@ -490,6 +490,7 @@ Use this section as a map: start with installation, then jump to features, confi
 | **[Session Management](docs/session-management.md)** | Reuse recent child-agent sessions with short aliases instead of starting over |
 | **[Todo Continuation](docs/todo-continuation.md)** | Auto-continue orchestrator sessions with cooldowns and safety checks |
 | **[Preset Switching](docs/preset-switching.md)** | Switch agent model presets at runtime with `/preset` |
+| **[Handoff](docs/handoff.md)** | Spawn a child orchestrator with `/handoff`, do the requested work, and return a summary to the main session |
 | **[Codemap](docs/codemap.md)** | Generate hierarchical codemaps to understand large codebases faster |
 | **[Interview](docs/interview.md)** | Turn rough ideas into a structured markdown spec through a browser-based Q&A flow |
 | **[Divoom Display](docs/divoom.md)** | Mirror orchestrator and specialist-agent activity to a Divoom MiniToo Bluetooth display |

+ 59 - 0
docs/handoff.md

@@ -0,0 +1,59 @@
+# Handoff
+
+`/handoff` starts a boomerang-style worker session for the user’s requested
+goal, then returns a compact completion summary to the original session.
+
+## Usage
+
+```text
+/handoff <what the worker should do>
+```
+
+The command asks the current orchestrator to call `handoff_session` with the
+worker prompt and any clearly relevant files.
+
+## Flow
+
+1. The main session calls `handoff_session`.
+2. Slim creates a real child session with `parentID` set to the main session.
+3. The child runs as `orchestrator`, so it can use the normal specialist-agent
+   workflow and delegate through `task` when useful.
+4. Referenced files are loaded into the child as synthetic Read-tool context.
+5. When the child finishes, Slim extracts its assistant output and returns it to
+   the main session inside `<handoff_summary>`.
+6. The child session is aborted for cleanup after the summary is extracted.
+
+In tmux or zellij, the child appears like other delegated work because it is a
+real child session. Existing session-depth and pane cleanup handling apply.
+
+## What to put in the prompt
+
+The user prompt controls scope. Keep it direct:
+
+```text
+/handoff finish the docs for handoff and run the relevant checks
+/handoff investigate the flaky auth test and report what changed
+/handoff implement the small UI polish we discussed
+```
+
+The handoff prompt intentionally avoids prescribing extra actions. It should do
+what the user asks, then summarize what happened, files changed, validation run,
+and any remaining risks or follow-up.
+
+## Tools
+
+| Tool | Purpose |
+|------|---------|
+| `handoff_session` | Creates the child worker session and returns its summary |
+| `read_session` | Lets a handoff worker read details from the parent/source session |
+
+## Safety
+
+- Nested handoffs are blocked: a handoff worker should finish its current task
+  and return a summary instead of spawning another handoff worker.
+- File context is restricted to the workspace real path, including symlink
+  checks.
+- Binary files are skipped.
+- Large files are capped before being injected as context.
+- Child sessions use normal OpenCode session lifecycle events, so multiplexer
+  cleanup remains consistent with other delegated agents.

+ 20 - 0
docs/tools.md

@@ -34,6 +34,26 @@ Fast, structural code search and refactoring — more powerful than plain text g
 
 ---
 
+## Session Handoff
+
+Run a boomerang-style worker session and return its summary to the caller.
+
+| Command / Tool | Description |
+|----------------|-------------|
+| `/handoff <goal>` | Ask the current agent to summarize context, relevant files, decisions, and next steps for a new session |
+| `handoff_session` | Runs a child handoff worker session and returns its summary to the caller |
+| `read_session` | Reads transcript details from the source session when the handoff summary is missing specifics |
+
+Handoff prompts include `@file` references. Slim creates a real child session
+with the current session as `parentID`, lets the handoff worker read the provided
+context and files, then returns the worker's `<handoff_summary>` back to the
+main session as normal tool output. In tmux/zellij this appears like other child
+agent work: a pane can open for the worker and close when the summary returns.
+
+See [Handoff](handoff.md) for the full workflow.
+
+---
+
 ## Formatters
 
 OpenCode automatically formats files after they are written or edited, using language-specific formatters. No manual step needed.

+ 11 - 1
src/index.ts

@@ -41,7 +41,10 @@ import {
   ast_grep_replace,
   ast_grep_search,
   createCouncilTool,
+  createHandoffCommandManager,
+  createHandoffSessionTool,
   createPresetManager,
+  createReadSessionTool,
   createWebfetchTool,
 } from './tools';
 import { recordTuiAgentModel, recordTuiAgentModels } from './tui-state';
@@ -142,6 +145,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let rewriteDisplayNameMentions: ReturnType<
     typeof createDisplayNameMentionRewriter
   >;
+  let handoffCommandManager: ReturnType<typeof createHandoffCommandManager>;
 
   // Counters for post-init health check (set inside try, checked outside)
   let toolCount = 0;
@@ -312,11 +316,14 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     presetManager = createPresetManager(ctx, config);
     divoomManager = createDivoomManager(config.divoom);
 
+    handoffCommandManager = createHandoffCommandManager(ctx);
+
     toolCount =
       Object.keys(councilTools).length +
       Object.keys(todoContinuationHook.tool).length +
       1 + // webfetch
-      2; // ast_grep_search, ast_grep_replace
+      2 + // ast_grep_search, ast_grep_replace
+      2; // handoff_session, read_session
   } catch (err) {
     // Plugin init failed: log visibly before re-throwing so the user
     // sees something actionable instead of a silent "loaded but empty".
@@ -386,6 +393,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       ...todoContinuationHook.tool,
       ast_grep_search,
       ast_grep_replace,
+      handoff_session: createHandoffSessionTool(ctx),
+      read_session: createReadSessionTool(ctx.client),
     },
 
     mcp: mcps,
@@ -721,6 +730,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
       interviewManager.registerCommand(opencodeConfig);
       presetManager.registerCommand(opencodeConfig);
+      handoffCommandManager.registerCommand(opencodeConfig);
     },
 
     event: async (input) => {

+ 23 - 0
src/tools/handoff/command.test.ts

@@ -0,0 +1,23 @@
+import { describe, expect, test } from 'bun:test';
+import { createHandoffCommandManager } from './command';
+
+function createContext() {
+  return {
+    directory: '/tmp/test',
+    client: {},
+  } as any;
+}
+
+describe('createHandoffCommandManager', () => {
+  test('registers the /handoff command', () => {
+    const manager = createHandoffCommandManager(createContext());
+    const config: Record<string, unknown> = {};
+
+    manager.registerCommand(config);
+
+    const commands = config.command as Record<string, { template: string }>;
+    expect(commands.handoff).toBeDefined();
+    expect(commands.handoff.template).toContain('handoff_session');
+    expect(commands.handoff.template).toContain('$ARGUMENTS');
+  });
+});

+ 60 - 0
src/tools/handoff/command.ts

@@ -0,0 +1,60 @@
+/**
+ * Command registration manager for handoff functionality.
+ *
+ * Manages the /handoff slash command registration and the HANDOFF_COMMAND
+ * template that guides the AI in generating handoff prompts.
+ */
+
+import type { PluginInput } from '@opencode-ai/plugin';
+
+const COMMAND_NAME = 'handoff';
+
+/**
+ * The handoff command template that guides the AI in generating handoff
+ * prompts.
+ */
+const HANDOFF_COMMAND_TEMPLATE = `Start a handoff worker session.
+
+Use the user's request below as the source of truth for what the worker should do. Keep scope and emphasis exactly aligned with the user's request.
+
+USER: $ARGUMENTS
+
+Call handoff_session with the worker prompt and any clearly relevant files:
+\`handoff_session(prompt="...", files=["src/foo.ts", "src/bar.ts", ...])\``;
+
+/**
+ * Creates a handoff command manager.
+ *
+ * Handles registration of the /handoff command and processing of chat
+ * messages to inject synthetic file parts for handoff sessions.
+ */
+export function createHandoffCommandManager(
+  _ctx: PluginInput,
+  _processedSessions?: Set<string>,
+) {
+  /**
+   * Register the /handoff command in the OpenCode config.
+   */
+  function registerCommand(opencodeConfig: Record<string, unknown>): void {
+    const configCommand = opencodeConfig.command as
+      | Record<string, unknown>
+      | undefined;
+    if (!configCommand?.[COMMAND_NAME]) {
+      if (!opencodeConfig.command) {
+        opencodeConfig.command = {};
+      }
+      (opencodeConfig.command as Record<string, unknown>)[COMMAND_NAME] = {
+        description: 'Create a focused handoff prompt for a new session',
+        template: HANDOFF_COMMAND_TEMPLATE,
+      };
+    }
+  }
+
+  return {
+    registerCommand,
+  };
+}
+
+export type HandoffCommandManager = ReturnType<
+  typeof createHandoffCommandManager
+>;

+ 146 - 0
src/tools/handoff/files.test.ts

@@ -0,0 +1,146 @@
+/**
+ * Tests for handoff file reference parsing.
+ */
+
+import { describe, expect, it } from 'bun:test';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import {
+  buildSyntheticFileParts,
+  FILE_REGEX,
+  parseFileReferences,
+} from './files';
+
+describe('parseFileReferences', () => {
+  it('should parse simple @file references', () => {
+    const text = 'Check @src/index.ts for the main entry';
+    const refs = parseFileReferences(text);
+    expect(refs.has('src/index.ts')).toBe(true);
+  });
+
+  it('should parse multiple @file references', () => {
+    const text =
+      'See @src/foo.ts and @src/bar.ts for details. Also check @README.md';
+    const refs = parseFileReferences(text);
+    expect(refs.size).toBe(3);
+    expect(refs.has('src/foo.ts')).toBe(true);
+    expect(refs.has('src/bar.ts')).toBe(true);
+    expect(refs.has('README.md')).toBe(true);
+  });
+
+  it('should parse @ references outside backticks', () => {
+    // Note: The regex doesn't fully exclude backtick-wrapped content,
+    // but it does exclude @ preceded by word chars or backticks
+    const text = 'Check @src/file.ts for details';
+    const refs = parseFileReferences(text);
+    expect(refs.size).toBe(1);
+    expect(refs.has('src/file.ts')).toBe(true);
+  });
+
+  it('should not parse email-like patterns', () => {
+    const text = 'Email me at user@example.com or check @src/file.ts';
+    const refs = parseFileReferences(text);
+    expect(refs.size).toBe(1);
+    expect(refs.has('src/file.ts')).toBe(true);
+  });
+
+  it('should handle paths with dots', () => {
+    const text = 'Config in @package.json and @tsconfig.json';
+    const refs = parseFileReferences(text);
+    expect(refs.size).toBe(2);
+    expect(refs.has('package.json')).toBe(true);
+    expect(refs.has('tsconfig.json')).toBe(true);
+  });
+
+  it('should handle paths with hyphens', () => {
+    const text = 'See @my-file.ts and @some-other_file.js';
+    const refs = parseFileReferences(text);
+    expect(refs.size).toBe(2);
+    expect(refs.has('my-file.ts')).toBe(true);
+    expect(refs.has('some-other_file.js')).toBe(true);
+  });
+
+  it('should return empty set for text without references', () => {
+    const text = 'Just some regular text without any file references';
+    const refs = parseFileReferences(text);
+    expect(refs.size).toBe(0);
+  });
+
+  it('should handle @ with leading dot for relative paths', () => {
+    const text = 'Check @./relative/path.ts and @../parent/file.ts';
+    const refs = parseFileReferences(text);
+    expect(refs.size).toBe(2);
+    expect(refs.has('./relative/path.ts')).toBe(true);
+    expect(refs.has('../parent/file.ts')).toBe(true);
+  });
+
+  it('should handle references with trailing punctuation', () => {
+    // Note: The regex includes trailing punctuation as part of the path
+    // This is the vendored behavior from opencode-handoff
+    const text = 'See @src/file.ts, @src/other.ts. And @src/more.ts!';
+    const refs = parseFileReferences(text);
+    // The regex captures the trailing punctuation, so these won't match
+    // the clean paths. This is expected vendored behavior.
+    expect(refs.size).toBeGreaterThanOrEqual(0);
+  });
+});
+
+describe('FILE_REGEX', () => {
+  it('should match basic file patterns', () => {
+    const text = '@src/index.ts';
+    const matches = [...text.matchAll(FILE_REGEX)];
+    expect(matches.length).toBe(1);
+    expect(matches[0]?.[1]).toBe('src/index.ts');
+  });
+
+  it('should not match when preceded by word character', () => {
+    const text = 'word@file.ts';
+    const matches = [...text.matchAll(FILE_REGEX)];
+    expect(matches.length).toBe(0);
+  });
+
+  it('should not match when preceded by backtick', () => {
+    const text = '`@code`';
+    const matches = [...text.matchAll(FILE_REGEX)];
+    expect(matches.length).toBe(0);
+  });
+});
+
+describe('buildSyntheticFileParts', () => {
+  it('loads readable files inside the workspace', async () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-handoff-files-'));
+    try {
+      fs.writeFileSync(path.join(dir, 'file.ts'), 'const x = 1;\n');
+
+      const parts = await buildSyntheticFileParts(dir, new Set(['file.ts']));
+
+      expect(parts).toHaveLength(2);
+      expect(parts[1]?.text).toContain('<type>file</type>');
+      expect(parts[1]?.text).toContain('1: const x = 1;');
+    } finally {
+      fs.rmSync(dir, { recursive: true, force: true });
+    }
+  });
+
+  it('skips path traversal and symlinks outside the workspace', async () => {
+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-handoff-files-'));
+    const outside = fs.mkdtempSync(
+      path.join(os.tmpdir(), 'omos-handoff-outside-'),
+    );
+    try {
+      fs.writeFileSync(path.join(outside, 'secret.txt'), 'secret\n');
+      fs.symlinkSync(path.join(outside, 'secret.txt'), path.join(dir, 'link'));
+
+      const parts = await buildSyntheticFileParts(
+        dir,
+        new Set(['../secret.txt', path.join(outside, 'secret.txt'), 'link']),
+      );
+
+      expect(parts).toHaveLength(0);
+    } finally {
+      fs.rmSync(dir, { recursive: true, force: true });
+      fs.rmSync(outside, { recursive: true, force: true });
+    }
+  });
+});

+ 98 - 0
src/tools/handoff/files.ts

@@ -0,0 +1,98 @@
+/**
+ * File reference parsing and synthetic file parts for handoff sessions.
+ *
+ * Handles extraction of @file references from handoff prompts and
+ * building synthetic text parts that match OpenCode's Read tool output
+ * format.
+ */
+
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+import type { TextPartInput } from '@opencode-ai/sdk';
+import { formatFileContent, isBinaryFile } from './vendor';
+
+/**
+ * File reference regex matching OpenCode's internal pattern.
+ * Matches @file references like @src/plugin.ts
+ */
+export const FILE_REGEX = /(?<![\w`])@(\.?[^\s`,.]*(?:\.[^\s`,.]+)*)/g;
+
+/**
+ * Parse @file references from text.
+ *
+ * @param text - Text to search for @file references
+ * @returns Set of file paths referenced in the text
+ */
+export function parseFileReferences(text: string): Set<string> {
+  const fileRefs = new Set<string>();
+
+  for (const match of text.matchAll(FILE_REGEX)) {
+    if (match[1]) {
+      fileRefs.add(match[1]);
+    }
+  }
+
+  return fileRefs;
+}
+
+/**
+ * Build synthetic text parts matching OpenCode's Read tool output.
+ *
+ * Creates two synthetic text parts for each file:
+ * 1. Header describing the Read tool call
+ * 2. Formatted file content with line numbers
+ *
+ * @param directory - Project directory to resolve relative paths against
+ * @param refs - Set of file path references to check
+ * @returns Array of synthetic text parts (non-existent and binary files are
+ *   skipped)
+ */
+export async function buildSyntheticFileParts(
+  directory: string,
+  refs: Set<string>,
+): Promise<TextPartInput[]> {
+  const parts: TextPartInput[] = [];
+  const realDirectory = await fs.realpath(directory);
+
+  for (const ref of refs) {
+    const filepath = path.resolve(directory, ref);
+    const relative = path.relative(directory, filepath);
+    if (relative.startsWith('..') || path.isAbsolute(relative)) continue;
+
+    try {
+      const realFilepath = await fs.realpath(filepath);
+      const realRelative = path.relative(realDirectory, realFilepath);
+      if (realRelative.startsWith('..') || path.isAbsolute(realRelative)) {
+        continue;
+      }
+
+      // Check if file exists
+      const stats = await fs.stat(realFilepath);
+      if (!stats.isFile()) continue;
+
+      // Skip binary files
+      if (await isBinaryFile(realFilepath)) continue;
+
+      // Read file content
+      const content = await fs.readFile(realFilepath, 'utf-8');
+
+      // Create header part (matching OpenCode's prompt.ts:820 format)
+      parts.push({
+        type: 'text',
+        synthetic: true,
+        text: `Called the Read tool with the following input: ${JSON.stringify({ filePath: realFilepath })}`,
+      });
+
+      // Create content part (matching OpenCode's ReadTool format)
+      parts.push({
+        type: 'text',
+        synthetic: true,
+        text: formatFileContent(realFilepath, content),
+      });
+    } catch {
+      // Skip silently if file can't be read
+    }
+  }
+
+  return parts;
+}

+ 28 - 0
src/tools/handoff/index.ts

@@ -0,0 +1,28 @@
+/**
+ * Handoff functionality for session continuation.
+ *
+ * Provides tools and commands for creating handoff prompts that allow
+ * work to continue seamlessly in new sessions with preloaded context.
+ */
+
+export {
+  createHandoffCommandManager,
+  type HandoffCommandManager,
+} from './command';
+export {
+  buildSyntheticFileParts,
+  FILE_REGEX,
+  parseFileReferences,
+} from './files';
+export {
+  createHandoffSessionTool,
+  createReadSessionTool,
+  type OpencodeClient,
+} from './tools';
+export {
+  DEFAULT_READ_LIMIT,
+  formatFileContent,
+  isBinaryFile,
+  MAX_BYTES,
+  MAX_LINE_LENGTH,
+} from './vendor';

+ 159 - 0
src/tools/handoff/tools.test.ts

@@ -0,0 +1,159 @@
+import { describe, expect, mock, test } from 'bun:test';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { createHandoffSessionTool, createReadSessionTool } from './tools';
+
+function makeTempDir() {
+  return fs.mkdtempSync(path.join(os.tmpdir(), 'omos-handoff-tool-'));
+}
+
+describe('handoff_session tool', () => {
+  test('runs a worker child session and returns its handoff summary', async () => {
+    const directory = makeTempDir();
+    try {
+      fs.mkdirSync(path.join(directory, 'src'));
+      fs.writeFileSync(path.join(directory, 'src/index.ts'), 'export {}\n');
+
+      const sessionCreate = mock(async () => ({ data: { id: 'ses_new' } }));
+      const sessionPrompt = mock(async () => ({}));
+      const sessionMessages = mock(async () => ({
+        data: [
+          {
+            info: { role: 'assistant' },
+            parts: [{ type: 'text', text: 'Summary from worker' }],
+          },
+        ],
+      }));
+      const sessionAbort = mock(async () => ({}));
+      const tool = createHandoffSessionTool({
+        directory,
+        client: {
+          session: {
+            abort: sessionAbort,
+            create: sessionCreate,
+            messages: sessionMessages,
+            prompt: sessionPrompt,
+          },
+        },
+      } as any);
+
+      const result = await tool.execute(
+        { prompt: 'Continue implementation', files: ['src/index.ts'] },
+        { sessionID: 'ses_old' } as any,
+      );
+
+      expect(result).toContain('task_id: ses_new');
+      expect(result).toContain('<handoff_summary>');
+      expect(result).toContain('Summary from worker');
+      expect(sessionCreate).toHaveBeenCalledWith({
+        responseStyle: 'data',
+        throwOnError: true,
+        query: { directory },
+        body: {
+          parentID: 'ses_old',
+          title: 'Handoff worker from ses_old',
+        },
+      });
+      expect(sessionPrompt).toHaveBeenCalledTimes(1);
+      const promptCall = sessionPrompt.mock.calls[0]?.[0] as {
+        path: { id: string };
+        body: {
+          agent: string;
+          parts: Array<Record<string, unknown>>;
+          tools?: Record<string, boolean>;
+        };
+      };
+      expect(promptCall.path.id).toBe('ses_new');
+      expect(promptCall.body.agent).toBe('orchestrator');
+      expect(promptCall.body.tools).toBeUndefined();
+      expect(promptCall.body.parts[0]).toMatchObject({
+        type: 'text',
+        text: expect.stringContaining(
+          'Work on behalf of parent session ses_old',
+        ),
+      });
+      expect(promptCall.body.parts).toContainEqual(
+        expect.objectContaining({ synthetic: true, type: 'text' }),
+      );
+      expect(sessionMessages).toHaveBeenCalledWith({
+        path: { id: 'ses_new' },
+        query: { directory },
+      });
+      expect(sessionAbort).toHaveBeenCalledWith({
+        path: { id: 'ses_new' },
+        query: { directory },
+      });
+    } finally {
+      fs.rmSync(directory, { recursive: true, force: true });
+    }
+  });
+
+  test('blocks nested handoff calls from a handoff worker', async () => {
+    const directory = makeTempDir();
+    try {
+      let nestedResult = '';
+      const tool = createHandoffSessionTool({
+        directory,
+        client: {
+          session: {
+            abort: mock(async () => ({})),
+            create: mock(async () => ({ data: { id: 'ses_handoff' } })),
+            messages: mock(async () => ({
+              data: [
+                {
+                  info: { role: 'assistant' },
+                  parts: [{ type: 'text', text: 'done' }],
+                },
+              ],
+            })),
+            prompt: mock(async () => {
+              nestedResult = String(
+                await tool.execute({ prompt: 'nested handoff' }, {
+                  sessionID: 'ses_handoff',
+                } as any),
+              );
+            }),
+          },
+        },
+      } as any);
+
+      await tool.execute({ prompt: 'outer handoff' }, {
+        sessionID: 'ses_old',
+      } as any);
+
+      expect(nestedResult).toContain('Nested handoff is disabled');
+    } finally {
+      fs.rmSync(directory, { recursive: true, force: true });
+    }
+  });
+});
+
+describe('read_session tool', () => {
+  test('formats session transcripts', async () => {
+    const messages = mock(async () => ({
+      data: [
+        { info: { role: 'user' }, parts: [{ type: 'text', text: 'Hi' }] },
+        {
+          info: { role: 'assistant' },
+          parts: [
+            { type: 'text', text: 'Hello' },
+            {
+              type: 'tool',
+              tool: 'read',
+              state: { status: 'completed', title: 'Read file' },
+            },
+          ],
+        },
+      ],
+    }));
+    const tool = createReadSessionTool({ session: { messages } } as any);
+
+    const result = await tool.execute({ sessionID: 'ses_old' }, {} as any);
+
+    expect(result).toContain('## User');
+    expect(result).toContain('Hi');
+    expect(result).toContain('## Assistant');
+    expect(result).toContain('[Tool: read] Read file');
+  });
+});

+ 248 - 0
src/tools/handoff/tools.ts

@@ -0,0 +1,248 @@
+/**
+ * Tool definitions for handoff functionality.
+ *
+ * Factory functions that create tool definitions with injected dependencies:
+ * - createHandoffSessionTool: Create a new session with handoff prompt
+ * - createReadSessionTool: Read conversation transcript from a session
+ */
+
+import type { PluginInput, ToolDefinition } from '@opencode-ai/plugin';
+import { tool } from '@opencode-ai/plugin';
+import { extractSessionResult, promptWithTimeout } from '../../utils/session';
+import { buildSyntheticFileParts, parseFileReferences } from './files';
+
+export type OpencodeClient = PluginInput['client'];
+const HANDOFF_TIMEOUT_MS = 5 * 60 * 1000;
+
+/**
+ * Create the handoff_session tool.
+ *
+ * Takes the OpenCode client as a dependency for TUI and session operations.
+ */
+export function createHandoffSessionTool(ctx: PluginInput): ToolDefinition {
+  const client = ctx.client;
+  const activeHandoffSessions = new Set<string>();
+
+  return tool({
+    description:
+      'Run a child worker session and return its completion summary to the caller',
+    args: {
+      prompt: tool.schema.string().describe('The generated handoff prompt'),
+      files: tool.schema
+        .array(tool.schema.string())
+        .optional()
+        .describe("Array of file paths to load into the new session's context"),
+    },
+    async execute(args, context) {
+      const directory =
+        context &&
+        typeof context === 'object' &&
+        'directory' in context &&
+        typeof (context as { directory?: unknown }).directory === 'string'
+          ? (context as { directory: string }).directory
+          : ctx.directory;
+      const sessionID =
+        context && typeof context === 'object' && 'sessionID' in context
+          ? (context as { sessionID: string }).sessionID
+          : 'unknown';
+      if (activeHandoffSessions.has(sessionID)) {
+        return 'Nested handoff is disabled: this session is already a handoff worker. Finish this worker and return its summary to the parent session instead.';
+      }
+
+      const sessionReference = `Work on behalf of parent session ${sessionID}. When you lack specific information you can use read_session to get it.`;
+      const files = new Set([
+        ...parseFileReferences(args.prompt),
+        ...(args.files ?? []).map((file) => file.replace(/^@/, '')),
+      ]);
+      const fileRefs =
+        files.size > 0 ? [...files].map((f) => `@${f}`).join(' ') : '';
+      const fullPrompt = fileRefs
+        ? `${sessionReference}\n\n${fileRefs}\n\n${args.prompt}`
+        : `${sessionReference}\n\n${args.prompt}`;
+
+      let childSessionID: string | undefined;
+      try {
+        const session = await client.session.create({
+          responseStyle: 'data',
+          throwOnError: true,
+          query: { directory },
+          body: {
+            parentID: sessionID === 'unknown' ? undefined : sessionID,
+            title: `Handoff worker from ${sessionID}`,
+          },
+        });
+
+        childSessionID =
+          (session as { data?: { id?: string }; id?: string })?.data?.id ??
+          (session as { data?: { id?: string }; id?: string })?.id;
+        if (!childSessionID) {
+          throw new Error('Handoff worker session did not return an id');
+        }
+        activeHandoffSessions.add(childSessionID);
+
+        await promptWithTimeout(
+          client,
+          {
+            responseStyle: 'data',
+            throwOnError: true,
+            query: { directory },
+            path: { id: childSessionID },
+            body: {
+              agent: 'orchestrator',
+              parts: [
+                {
+                  type: 'text',
+                  text: `${fullPrompt}\n\nDo the requested work. When finished, return a concise summary of what you did, files changed, validation run, and any remaining risks or follow-up. Let the user's prompt determine scope and emphasis.`,
+                },
+                ...(await buildSyntheticFileParts(directory, files)),
+              ],
+            },
+          },
+          HANDOFF_TIMEOUT_MS,
+        );
+
+        const extraction = await extractSessionResult(client, childSessionID, {
+          directory,
+          includeReasoning: false,
+        });
+        if (extraction.empty) {
+          throw new Error('Handoff worker returned no summary');
+        }
+
+        return [
+          `task_id: ${childSessionID}`,
+          '',
+          '<handoff_summary>',
+          extraction.text,
+          '</handoff_summary>',
+        ].join('\n');
+      } finally {
+        if (childSessionID) {
+          activeHandoffSessions.delete(childSessionID);
+          client.session
+            .abort({ path: { id: childSessionID }, query: { directory } })
+            .catch(() => {});
+        }
+      }
+    },
+  });
+}
+
+/**
+ * Format a conversation transcript for display.
+ *
+ * @param messages - Array of messages from session.messages()
+ * @param limit - Optional limit to indicate if results are truncated
+ * @returns Formatted transcript with user/assistant sections
+ */
+function formatTranscript(
+  messages: Array<{ info: { role?: string }; parts: unknown[] }>,
+  limit?: number,
+): string {
+  const lines: string[] = [];
+
+  for (const msg of messages) {
+    const role = msg.info?.role;
+    const parts = msg.parts as Array<{
+      type: string;
+      text?: string;
+      ignored?: boolean;
+      filename?: string;
+      tool?: string;
+      state?: { status: string; title?: string };
+    }>;
+
+    if (role === 'user') {
+      lines.push('## User');
+      for (const part of parts) {
+        if (
+          part.type === 'text' &&
+          !part.ignored &&
+          typeof part.text === 'string'
+        ) {
+          lines.push(part.text);
+        }
+        if (part.type === 'file') {
+          lines.push(`[Attached: ${part.filename || 'file'}]`);
+        }
+      }
+      lines.push('');
+    }
+
+    if (role === 'assistant') {
+      lines.push('## Assistant');
+      for (const part of parts) {
+        if (part.type === 'text' && typeof part.text === 'string') {
+          lines.push(part.text);
+        }
+        if (
+          part.type === 'tool' &&
+          part.state?.status === 'completed' &&
+          part.tool
+        ) {
+          lines.push(`[Tool: ${part.tool}] ${part.state.title ?? ''}`);
+        }
+      }
+      lines.push('');
+    }
+  }
+
+  const output = lines.join('\n').trim();
+
+  if (messages.length >= (limit ?? 100)) {
+    return (
+      output +
+      `\n\n(Showing ${messages.length} most recent messages. Use a higher 'limit' to see more.)`
+    );
+  }
+
+  return `${output}\n\n(End of session - ${messages.length} messages)`;
+}
+
+/**
+ * Create the read_session tool.
+ *
+ * Takes the OpenCode client as a dependency for session.messages() calls.
+ */
+export function createReadSessionTool(client: OpencodeClient): ToolDefinition {
+  return tool({
+    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.",
+    args: {
+      sessionID: tool.schema
+        .string()
+        .describe('The full session ID (e.g., sess_01jxyz...)'),
+      limit: tool.schema
+        .number()
+        .optional()
+        .describe(
+          'Maximum number of messages to read (defaults to 100, max 500)',
+        ),
+    },
+    async execute(args, context) {
+      const limit = Math.min(args.limit ?? 100, 500);
+      const directory =
+        context &&
+        typeof context === 'object' &&
+        'directory' in context &&
+        typeof (context as { directory?: unknown }).directory === 'string'
+          ? (context as { directory: string }).directory
+          : undefined;
+
+      try {
+        const response = (await client.session.messages({
+          path: { id: args.sessionID },
+          query: { limit, ...(directory ? { directory } : {}) },
+        })) as { data?: Array<{ info: { role?: string }; parts: unknown[] }> };
+
+        if (!response.data || response.data.length === 0) {
+          return 'Session has no messages or does not exist.';
+        }
+
+        return formatTranscript(response.data, limit);
+      } catch (error) {
+        return `Could not read session ${args.sessionID}: ${error instanceof Error ? error.message : 'Unknown error'}`;
+      }
+    },
+  });
+}

+ 104 - 0
src/tools/handoff/vendor.test.ts

@@ -0,0 +1,104 @@
+/**
+ * Tests for handoff vendor helpers.
+ */
+
+import { describe, expect, it } from 'bun:test';
+import {
+  DEFAULT_READ_LIMIT,
+  formatFileContent,
+  isBinaryFile,
+  MAX_LINE_LENGTH,
+} from './vendor';
+
+describe('formatFileContent', () => {
+  it('should format content with line numbers', () => {
+    const content = 'line1\nline2\nline3';
+    const result = formatFileContent('/path/to/file.ts', content);
+    expect(result).toContain('1: line1');
+    expect(result).toContain('2: line2');
+    expect(result).toContain('3: line3');
+    expect(result).toContain('<path>/path/to/file.ts</path>');
+    expect(result).toContain('<type>file</type>');
+    expect(result).toContain('<content>');
+    expect(result).toContain('</content>');
+  });
+
+  it('should truncate lines exceeding MAX_LINE_LENGTH', () => {
+    const longLine = 'a'.repeat(MAX_LINE_LENGTH + 100);
+    const content = `short\n${longLine}\nshort2`;
+    const result = formatFileContent('/path/to/file.ts', content);
+    expect(result).toContain('...');
+    expect(result).not.toContain(longLine);
+  });
+
+  it('should indicate when file has more lines', () => {
+    const lines = Array(DEFAULT_READ_LIMIT + 10)
+      .fill('line')
+      .join('\n');
+    const result = formatFileContent('/path/to/file.ts', lines);
+    expect(result).toContain('Use offset=');
+  });
+
+  it('should show end of file message when all lines read', () => {
+    const content = 'line1\nline2\nline3';
+    const result = formatFileContent('/path/to/file.ts', content);
+    expect(result).toContain('End of file');
+    expect(result).toContain('total 3 lines');
+  });
+
+  it('should handle empty content', () => {
+    const result = formatFileContent('/path/to/file.ts', '');
+    expect(result).toContain('1: ');
+    expect(result).toContain('End of file');
+  });
+
+  it('should handle single line content', () => {
+    const result = formatFileContent('/path/to/file.ts', 'single line');
+    expect(result).toContain('1: single line');
+    expect(result).toContain('total 1 lines');
+  });
+});
+
+describe('isBinaryFile', () => {
+  it('should detect binary by extension', async () => {
+    const binaryExtensions = [
+      '/path/to/file.zip',
+      '/path/to/file.exe',
+      '/path/to/file.dll',
+      '/path/to/file.pyc',
+      '/path/to/file.wasm',
+    ];
+
+    for (const filepath of binaryExtensions) {
+      const result = await isBinaryFile(filepath);
+      expect(result).toBe(true);
+    }
+  });
+
+  it('should not flag text file extensions as binary', async () => {
+    // These don't exist, so they'll return false (not binary)
+    const textExtensions = [
+      '/path/to/file.ts',
+      '/path/to/file.js',
+      '/path/to/file.txt',
+      '/path/to/file.md',
+    ];
+
+    for (const filepath of textExtensions) {
+      const result = await isBinaryFile(filepath);
+      expect(result).toBe(false);
+    }
+  });
+
+  it('should handle case insensitive extensions', async () => {
+    expect(await isBinaryFile('/path/to/file.ZIP')).toBe(true);
+    expect(await isBinaryFile('/path/to/file.EXE')).toBe(true);
+  });
+});
+
+describe('constants', () => {
+  it('should have expected default values', () => {
+    expect(DEFAULT_READ_LIMIT).toBe(2000);
+    expect(MAX_LINE_LENGTH).toBe(2000);
+  });
+});

+ 144 - 0
src/tools/handoff/vendor.ts

@@ -0,0 +1,144 @@
+/**
+ * Vendored read-format helpers from OpenCode.
+ *
+ * Source: https://github.com/sst/opencode
+ * File: packages/opencode/src/tool/read.ts
+ *
+ * These functions and constants are copied to ensure synthetic file parts
+ * match OpenCode's Read tool output exactly.
+ */
+
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+
+/**
+ * Constants from OpenCode's ReadTool
+ */
+export const DEFAULT_READ_LIMIT = 2000;
+export const MAX_LINE_LENGTH = 2000;
+export const MAX_BYTES = 50 * 1024;
+const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`;
+const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`;
+const SAMPLE_BYTES = 4096;
+
+/**
+ * Binary file extensions (from OpenCode's ReadTool)
+ */
+const BINARY_EXTENSIONS = new Set([
+  '.zip',
+  '.tar',
+  '.gz',
+  '.exe',
+  '.dll',
+  '.so',
+  '.class',
+  '.jar',
+  '.war',
+  '.7z',
+  '.doc',
+  '.docx',
+  '.xls',
+  '.xlsx',
+  '.ppt',
+  '.pptx',
+  '.odt',
+  '.ods',
+  '.odp',
+  '.bin',
+  '.dat',
+  '.obj',
+  '.o',
+  '.a',
+  '.lib',
+  '.wasm',
+  '.pyc',
+  '.pyo',
+]);
+
+/**
+ * Check if a file is binary (copied from OpenCode's ReadTool)
+ */
+export async function isBinaryFile(filepath: string): Promise<boolean> {
+  const ext = path.extname(filepath).toLowerCase();
+
+  // Check extension first
+  if (BINARY_EXTENSIONS.has(ext)) {
+    return true;
+  }
+
+  try {
+    const file = await fs.open(filepath, 'r');
+    try {
+      const buffer = Buffer.alloc(SAMPLE_BYTES);
+      const result = await file.read(buffer, 0, SAMPLE_BYTES, 0);
+      if (result.bytesRead === 0) return false;
+
+      const bytes = buffer.subarray(0, result.bytesRead);
+
+      let nonPrintableCount = 0;
+      for (let i = 0; i < bytes.length; i++) {
+        const byte = bytes[i];
+        if (byte === undefined) continue;
+        if (byte === 0) return true;
+        if (byte < 9 || (byte > 13 && byte < 32)) {
+          nonPrintableCount++;
+        }
+      }
+
+      // If >30% non-printable characters, consider it binary
+      return nonPrintableCount / bytes.length > 0.3;
+    } finally {
+      await file.close();
+    }
+  } catch {
+    return false;
+  }
+}
+
+/**
+ * Format file content matching OpenCode's Read tool output format.
+ *
+ * @param _filepath - Absolute path to the file (unused in output, kept for
+ *   signature compatibility)
+ * @param content - File content as string
+ * @returns Formatted output with line numbers in <file> tags
+ */
+export function formatFileContent(_filepath: string, content: string): string {
+  const cappedContent = Buffer.byteLength(content, 'utf8') > MAX_BYTES;
+  const contentToFormat = cappedContent ? content.slice(0, MAX_BYTES) : content;
+  const lines = contentToFormat.split('\n');
+  const limit = DEFAULT_READ_LIMIT;
+  const offset = 0;
+
+  const raw = lines.slice(offset, offset + limit).map((line) => {
+    return line.length > MAX_LINE_LENGTH
+      ? `${line.substring(0, MAX_LINE_LENGTH)}${MAX_LINE_SUFFIX}`
+      : line;
+  });
+
+  const formatted = raw.map((line, index) => {
+    return `${index + offset + 1}: ${line}`;
+  });
+
+  let output = [
+    `<path>${_filepath}</path>`,
+    '<type>file</type>',
+    '<content>\n',
+  ].join('\n');
+  output += formatted.join('\n');
+
+  const totalLines = lines.length;
+  const lastReadLine = offset + formatted.length;
+  const hasMoreLines = totalLines > lastReadLine;
+
+  if (cappedContent) {
+    output += `\n\n(Output capped at ${MAX_BYTES_LABEL}. Showing lines 1-${lastReadLine}. Use offset=${lastReadLine + 1} to continue.)`;
+  } else if (hasMoreLines) {
+    output += `\n\n(Showing lines 1-${lastReadLine} of ${totalLines}. Use offset=${lastReadLine + 1} to continue.)`;
+  } else {
+    output += `\n\n(End of file - total ${totalLines} lines)`;
+  }
+  output += '\n</content>';
+
+  return output;
+}

+ 6 - 0
src/tools/index.ts

@@ -1,6 +1,12 @@
 // AST-grep tools
 export { ast_grep_replace, ast_grep_search } from './ast-grep';
 export { createCouncilTool } from './council';
+export type { HandoffCommandManager } from './handoff';
+export {
+  createHandoffCommandManager,
+  createHandoffSessionTool,
+  createReadSessionTool,
+} from './handoff';
 export type { PresetManager } from './preset-manager';
 export { createPresetManager } from './preset-manager';
 export { createWebfetchTool } from './smartfetch';

+ 2 - 1
src/utils/session.ts

@@ -157,12 +157,13 @@ export interface SessionExtractionResult {
 export async function extractSessionResult(
   client: OpencodeClient,
   sessionId: string,
-  options?: { includeReasoning?: boolean },
+  options?: { directory?: string; includeReasoning?: boolean },
 ): Promise<SessionExtractionResult> {
   const includeReasoning = options?.includeReasoning ?? true;
 
   const messagesResult = await client.session.messages({
     path: { id: sessionId },
+    ...(options?.directory ? { query: { directory: options.directory } } : {}),
   });
   const messages = (messagesResult.data ?? []) as Array<{
     info?: { role: string };