Jelajahi Sumber

Merge pull request #461 from alvinunreal/subtask

Add subtask worker sessions
Alvin 2 bulan lalu
induk
melakukan
5c3549085b

+ 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` |
+| **[Subtask](docs/subtask.md)** | Run a bounded child worker with `/subtask` and return a structured 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 |

+ 109 - 0
docs/subtask.md

@@ -0,0 +1,109 @@
+# Subtask
+
+`/subtask` lets the current agent spin up a separate, bounded worker session for
+one specific piece of work. The worker runs as an orchestrator in a real child
+session, completes the requested task, and sends a structured summary back to
+the original conversation.
+
+Use it when a bounded, context-heavy task only needs to return a compact result
+to the main thread.
+
+## Usage
+
+```text
+/subtask <focused task for the worker>
+```
+
+Examples:
+
+```text
+/subtask update the subtask docs and run the relevant checks
+/subtask investigate why the auth retry test is flaky and report findings
+/subtask implement the small button spacing polish in the settings panel
+```
+
+Keep the request narrow. A good subtask has a clear finish line.
+
+## What happens
+
+1. The `/subtask` command asks the current agent to prepare a self-contained
+   worker prompt.
+2. The agent calls the `subtask` tool with that prompt and any clearly relevant
+   files.
+3. Slim creates a real child session with `parentID` pointing at the current
+   session.
+4. The child session runs as `orchestrator`, so it can use normal tools and
+   specialist delegation when useful.
+5. Referenced files are injected as synthetic Read-tool context before the
+   worker starts.
+6. If the worker needs missing conversation details, it can call `read_session`
+   to inspect only the source session that spawned it.
+7. When finished, the worker returns a `<subtask_summary>` with status, changes,
+   files touched, validation, and follow-up notes.
+8. Slim extracts the summary, returns it to the original session, and aborts the
+   child session for cleanup.
+
+In tmux or zellij, the subtask appears like other child-agent work because it is
+a real child session. Existing depth limits and pane cleanup handling apply.
+
+## Worker scope
+
+The worker prompt is intentionally bounded:
+
+- complete only the requested task,
+- do not broaden scope,
+- do not spawn another subtask,
+- use `read_session` only when needed context is missing,
+- run the most relevant validation checks when practical,
+- stop when the requested task is done.
+
+This keeps subtasks useful for focused execution rather than turning them into a
+second open-ended conversation.
+
+## Tools
+
+| Tool | Purpose |
+|------|---------|
+| `subtask` | Creates a child worker session and returns its summary |
+| `read_session` | Lets a subtask worker read the source session that spawned it |
+
+`read_session` is restricted to subtask workers and only allows reading the
+source session. It is not a general transcript-reading tool.
+
+## File context
+
+Files can be passed explicitly with the `files` argument or referenced in the
+worker prompt with `@path` syntax. Slim resolves those paths inside the current
+workspace and injects readable text files as synthetic context.
+
+Safety rules:
+
+- paths must stay inside the workspace real path,
+- symlinks that resolve outside the workspace are skipped,
+- binary files are skipped,
+- large files are capped before injection,
+- unreadable or missing files are skipped.
+
+## Summary format
+
+The worker is instructed to finish with:
+
+```text
+<subtask_summary>
+Status: completed | blocked | partial
+
+What changed:
+- ...
+
+Files touched:
+- ...
+
+Validation:
+- ...
+
+Risks / follow-up:
+- ...
+</subtask_summary>
+```
+
+The parent session receives that summary as normal tool output.

+ 21 - 0
docs/tools.md

@@ -34,6 +34,27 @@ Fast, structural code search and refactoring — more powerful than plain text g
 
 ---
 
+## Session Subtask
+
+Run a focused child worker session for a bounded task and return its summary to
+the caller.
+
+| Command / Tool | Description |
+|----------------|-------------|
+| `/subtask <goal>` | Ask the current agent to prepare and start a bounded worker for the requested task |
+| `subtask` | Creates a child orchestrator session and returns its structured summary |
+| `read_session` | Lets a subtask worker inspect the source session when needed context is missing |
+
+Slim creates a real child session with the current session as `parentID`, injects
+relevant file context, and asks the worker to complete only the requested task.
+The worker returns a `<subtask_summary>` with status, changes, files touched,
+validation, and follow-up notes. In tmux/zellij this appears like other child
+agent work: a pane can open for the worker and close after cleanup.
+
+See [Subtask](subtask.md) for the full workflow.
+
+---
+
 ## Formatters
 
 OpenCode automatically formats files after they are written or edited, using language-specific formatters. No manual step needed.

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "oh-my-opencode-slim",
-  "version": "1.0.7",
+  "version": "1.0.8",
   "description": "Lightweight agent orchestration plugin for OpenCode - a slimmed-down fork of oh-my-opencode",
   "main": "dist/index.js",
   "types": "dist/index.d.ts",

+ 19 - 0
src/agents/orchestrator.ts

@@ -173,6 +173,25 @@ ${enabledParallelExamples}
 
 Balance: respect dependencies, avoid parallelizing what must be sequential.
 
+### Context Isolation
+If no specialist delegation is needed, consider \`subtask\` before doing
+context-heavy work directly.
+
+Ask whether the parent context needs the details or only the result. Use
+\`subtask\` when the work is bounded, context-heavy, and the parent only needs a
+compact outcome.
+
+Use \`subtask\` for focused investigation, bounded analysis, cleanup, or
+verification across files/logs/messages.
+
+Do not use \`subtask\` for tiny tasks, open-ended work, interactive decisions,
+work better handled by a named specialist, or cases where the parent must reason
+over the details.
+
+When calling \`subtask\`, give a self-contained prompt with objective,
+constraints, relevant context, deliverable, and validation. Pass only clearly
+relevant files. Wait for the summary, then integrate and verify it.
+
 ### OpenCode subagent execution model
 - A delegated specialist runs in a separate child session.
 - Delegation is blocking for the parent at that point: send work out, then continue that line after results return.

+ 1 - 1
src/codemap.md

@@ -34,7 +34,7 @@
 
 - Connects directly to `@opencode-ai/plugin`: returns the plugin object, mutates runtime agent configuration, handles event hooks, and routes RPC via `ctx.client`/`ctx.client.session`.
 - Integrates with host multiplexer backends through `src/multiplexer`, and with session lifecycle constraints through `SubagentDepthTracker`.
-- Hooks/handoff integration points now include:
+- Hooks/subtask integration points now include:
   - `createTaskSessionManagerHook` for resumable Task sessions,
   - `createTodoContinuationHook`, `createPhaseReminderHook`, `createFilterAvailableSkillsHook`, and `createPostFileToolNudgeHook` for chat/tool behavior,
   - `createInterviewManager` / `createPresetManager` command handlers.

+ 26 - 1
src/index.ts

@@ -42,6 +42,10 @@ import {
   ast_grep_search,
   createCouncilTool,
   createPresetManager,
+  createReadSessionTool,
+  createSubtaskCommandManager,
+  createSubtaskState,
+  createSubtaskTool,
   createWebfetchTool,
 } from './tools';
 import { recordTuiAgentModel, recordTuiAgentModels } from './tui-state';
@@ -142,6 +146,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let rewriteDisplayNameMentions: ReturnType<
     typeof createDisplayNameMentionRewriter
   >;
+  let subtaskCommandManager: ReturnType<typeof createSubtaskCommandManager>;
+  let subtaskState: ReturnType<typeof createSubtaskState>;
 
   // Counters for post-init health check (set inside try, checked outside)
   let toolCount = 0;
@@ -312,11 +318,15 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     presetManager = createPresetManager(ctx, config);
     divoomManager = createDivoomManager(config.divoom);
 
+    subtaskState = createSubtaskState();
+    subtaskCommandManager = createSubtaskCommandManager(ctx, subtaskState);
+
     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; // subtask, 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 +396,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       ...todoContinuationHook.tool,
       ast_grep_search,
       ast_grep_replace,
+      subtask: createSubtaskTool(ctx, subtaskState, depthTracker),
+      read_session: createReadSessionTool(ctx.client, subtaskState),
     },
 
     mcp: mcps,
@@ -721,6 +733,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
       interviewManager.registerCommand(opencodeConfig);
       presetManager.registerCommand(opencodeConfig);
+      subtaskCommandManager.registerCommand(opencodeConfig);
     },
 
     event: async (input) => {
@@ -798,6 +811,18 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         },
       );
 
+      subtaskCommandManager.handleEvent(
+        input as {
+          event: {
+            type: string;
+            properties?: {
+              info?: { id?: string; parentID?: string };
+              sessionID?: string;
+            };
+          };
+        },
+      );
+
       if (
         event.type === 'permission.asked' ||
         event.type === 'question.asked'

+ 16 - 0
src/tools/codemap.md

@@ -7,6 +7,7 @@
 - AST-aware search/replace via `ast-grep` stack.
 - Remote fetch/transform utility via `smartfetch` (`webfetch` tool).
 - Council orchestration via `createCouncilTool` (`council.ts`).
+- Child-session subtasks via `subtask` and `/subtask` (`subtask/`).
 - Runtime preset switching via `/preset` hook via `createPresetManager` (`preset-manager.ts`).
 
 It is the bridge between plugin runtime integration (`src/index.ts`) and the lower-level
@@ -17,6 +18,8 @@ implementations in feature folders.
 - `ast_grep_search`, `ast_grep_replace` from `./ast-grep`
 - `createWebfetchTool`, `WEBFETCH_DESCRIPTION`, and related types from `./smartfetch`
 - `createCouncilTool`
+- `createSubtaskTool`, `createSubtaskCommandManager`, `createSubtaskState`, and
+  `createReadSessionTool` from `./subtask`
 - `createPresetManager` and `PresetManager` type
 
 ## Design patterns
@@ -57,6 +60,18 @@ implementations in feature folders.
   `displayName`).
 - In-memory `activePreset` supports immediate status display and updates after successful switches.
 
+### Subtask path
+
+- `createSubtaskCommandManager` registers `/subtask` and asks the current
+  agent to call the `subtask` tool with the worker prompt and relevant files.
+- `createSubtaskTool` creates a real child session with `parentID`, injects
+  referenced files as synthetic Read-tool context, waits for the worker to
+  finish, returns `<subtask_summary>`, then aborts the child for cleanup.
+- `createReadSessionTool` lets a subtask worker read only the source session
+  that spawned it when the summary prompt lacks details.
+- `SubtaskState` marks child sessions so nested subtasks can be blocked and
+  session.deleted events can clear stale markers.
+
 ### Smartfetch path
 
 - `createWebfetchTool` owns fetch orchestration, permission prompts, cache checks,
@@ -83,6 +98,7 @@ implementations in feature folders.
 - Tool registration:
   - `council` tools (only when `config.council` exists),
   - `webfetch`,
+  - `subtask`, `read_session`,
   - AST tools.
 - `presetManager` is initialized in plugin init and:
   - calls `registerCommand` during config hook,

+ 7 - 0
src/tools/index.ts

@@ -4,3 +4,10 @@ export { createCouncilTool } from './council';
 export type { PresetManager } from './preset-manager';
 export { createPresetManager } from './preset-manager';
 export { createWebfetchTool } from './smartfetch';
+export type { SubtaskCommandManager } from './subtask';
+export {
+  createReadSessionTool,
+  createSubtaskCommandManager,
+  createSubtaskState,
+  createSubtaskTool,
+} from './subtask';

+ 72 - 0
src/tools/subtask/command.test.ts

@@ -0,0 +1,72 @@
+import { describe, expect, test } from 'bun:test';
+import { createSubtaskCommandManager } from './command';
+import { createSubtaskState } from './state';
+
+function createContext() {
+  return {
+    directory: '/tmp/test',
+    client: {},
+  } as any;
+}
+
+describe('createSubtaskCommandManager', () => {
+  test('registers the /subtask command', () => {
+    const manager = createSubtaskCommandManager(
+      createContext(),
+      createSubtaskState(),
+    );
+    const config: Record<string, unknown> = {};
+
+    manager.registerCommand(config);
+
+    const commands = config.command as Record<string, { template: string }>;
+    expect(commands.subtask).toBeDefined();
+    expect(commands.subtask.template).toContain('focused subtask worker');
+    expect(commands.subtask.template).toContain('Do not broaden it');
+    expect(commands.subtask.template).toContain('$ARGUMENTS');
+  });
+
+  test('marks child sessions of subtask workers with the same source', () => {
+    const state = createSubtaskState();
+    state.markSession('ses_worker', 'ses_source');
+    const manager = createSubtaskCommandManager(createContext(), state);
+
+    manager.handleEvent({
+      event: {
+        type: 'session.created',
+        properties: { info: { id: 'ses_child', parentID: 'ses_worker' } },
+      },
+    });
+
+    expect(state.sourceFor('ses_child')).toBe('ses_source');
+  });
+
+  test('does not mark unrelated child sessions', () => {
+    const state = createSubtaskState();
+    const manager = createSubtaskCommandManager(createContext(), state);
+
+    manager.handleEvent({
+      event: {
+        type: 'session.created',
+        properties: { info: { id: 'ses_child', parentID: 'ses_parent' } },
+      },
+    });
+
+    expect(state.isSubtaskSession('ses_child')).toBe(false);
+  });
+
+  test('unmarks deleted subtask sessions', () => {
+    const state = createSubtaskState();
+    state.markSession('ses_worker', 'ses_source');
+    const manager = createSubtaskCommandManager(createContext(), state);
+
+    manager.handleEvent({
+      event: {
+        type: 'session.deleted',
+        properties: { info: { id: 'ses_worker' } },
+      },
+    });
+
+    expect(state.isSubtaskSession('ses_worker')).toBe(false);
+  });
+});

+ 93 - 0
src/tools/subtask/command.ts

@@ -0,0 +1,93 @@
+/**
+ * Command registration manager for subtask functionality.
+ *
+ * Manages the /subtask slash command registration and the SUBTASK_COMMAND
+ * template that guides the AI in generating subtask prompts.
+ */
+
+import type { PluginInput } from '@opencode-ai/plugin';
+import type { SubtaskState } from './state';
+
+const COMMAND_NAME = 'subtask';
+
+/**
+ * The subtask command template that guides the AI in generating subtask
+ * prompts.
+ */
+const SUBTASK_COMMAND_TEMPLATE = `Start a focused subtask worker.
+
+The user's request below is the full scope for the worker. Do not broaden it.
+Create a self-contained worker prompt that includes:
+- the exact objective
+- relevant context from this conversation
+- specific files/paths that matter
+- expected deliverables
+- validation the worker should run, if applicable
+
+USER REQUEST:
+$ARGUMENTS
+
+Then call the subtask tool:
+\`subtask(prompt="...", files=["src/foo.ts", "docs/bar.md"])\`
+
+Only include files that are clearly relevant. If no files are needed, omit files.`;
+
+/**
+ * Creates a subtask command manager.
+ *
+ * Handles registration of the /subtask command and processing of chat
+ * messages to inject synthetic file parts for subtask sessions.
+ */
+export function createSubtaskCommandManager(
+  _ctx: PluginInput,
+  state: SubtaskState,
+) {
+  /**
+   * Register the /subtask 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 subtask prompt for a new session',
+        template: SUBTASK_COMMAND_TEMPLATE,
+      };
+    }
+  }
+
+  return {
+    registerCommand,
+    handleEvent(input: {
+      event: {
+        type: string;
+        properties?: {
+          info?: { id?: string; parentID?: string };
+          sessionID?: string;
+        };
+      };
+    }): void {
+      if (input.event.type === 'session.created') {
+        const info = input.event.properties?.info;
+        if (!info?.id || !info.parentID) return;
+
+        const source = state.sourceFor(info.parentID);
+        if (source) state.markSession(info.id, source);
+        return;
+      }
+
+      if (input.event.type !== 'session.deleted') return;
+      const sessionID =
+        input.event.properties?.info?.id ?? input.event.properties?.sessionID;
+      if (sessionID) state.unmarkSession(sessionID);
+    },
+  };
+}
+
+export type SubtaskCommandManager = ReturnType<
+  typeof createSubtaskCommandManager
+>;

+ 144 - 0
src/tools/subtask/files.test.ts

@@ -0,0 +1,144 @@
+/**
+ * Tests for subtask 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', () => {
+    const text = 'See @src/file.ts, @src/other.ts. And @src/more.ts!';
+    const refs = parseFileReferences(text);
+    expect(refs).toEqual(
+      new Set(['src/file.ts', 'src/other.ts', 'src/more.ts']),
+    );
+  });
+});
+
+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-subtask-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-subtask-files-'));
+    const outside = fs.mkdtempSync(
+      path.join(os.tmpdir(), 'omos-subtask-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 });
+    }
+  });
+});

+ 103 - 0
src/tools/subtask/files.ts

@@ -0,0 +1,103 @@
+/**
+ * File reference parsing and synthetic file parts for subtask sessions.
+ *
+ * Handles extraction of @file references from subtask 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;
+const TRAILING_PATH_PUNCTUATION = /[!?:;]+$/;
+
+export function cleanFileReference(ref: string): string {
+  return ref.replace(/^@/, '').replace(TRAILING_PATH_PUNCTUATION, '');
+}
+
+/**
+ * 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(cleanFileReference(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;
+}

+ 29 - 0
src/tools/subtask/index.ts

@@ -0,0 +1,29 @@
+/**
+ * Subtask functionality for session continuation.
+ *
+ * Provides tools and commands for creating subtask prompts that allow
+ * work to continue seamlessly in new sessions with preloaded context.
+ */
+
+export {
+  createSubtaskCommandManager,
+  type SubtaskCommandManager,
+} from './command';
+export {
+  buildSyntheticFileParts,
+  FILE_REGEX,
+  parseFileReferences,
+} from './files';
+export { createSubtaskState, type SubtaskState } from './state';
+export {
+  createReadSessionTool,
+  createSubtaskTool,
+  type OpencodeClient,
+} from './tools';
+export {
+  DEFAULT_READ_LIMIT,
+  formatFileContent,
+  isBinaryFile,
+  MAX_BYTES,
+  MAX_LINE_LENGTH,
+} from './vendor';

+ 25 - 0
src/tools/subtask/state.ts

@@ -0,0 +1,25 @@
+export interface SubtaskState {
+  markSession(sessionID: string, sourceSessionID: string): void;
+  unmarkSession(sessionID: string): void;
+  isSubtaskSession(sessionID: string): boolean;
+  sourceFor(sessionID: string): string | undefined;
+}
+
+export function createSubtaskState(): SubtaskState {
+  const sourceBySession = new Map<string, string>();
+
+  return {
+    markSession(sessionID: string, sourceSessionID: string): void {
+      sourceBySession.set(sessionID, sourceSessionID);
+    },
+    unmarkSession(sessionID: string): void {
+      sourceBySession.delete(sessionID);
+    },
+    isSubtaskSession(sessionID: string): boolean {
+      return sourceBySession.has(sessionID);
+    },
+    sourceFor(sessionID: string): string | undefined {
+      return sourceBySession.get(sessionID);
+    },
+  };
+}

+ 293 - 0
src/tools/subtask/tools.test.ts

@@ -0,0 +1,293 @@
+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 { SubagentDepthTracker } from '../../utils/subagent-depth';
+import { createSubtaskState } from './state';
+import { createReadSessionTool, createSubtaskTool } from './tools';
+
+function makeTempDir() {
+  return fs.mkdtempSync(path.join(os.tmpdir(), 'omos-subtask-tool-'));
+}
+
+describe('subtask tool', () => {
+  test('runs a worker child session and returns its subtask 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: '<subtask_summary>\nSummary from worker\n</subtask_summary>',
+              },
+            ],
+          },
+        ],
+      }));
+      const sessionAbort = mock(async () => ({}));
+      const state = createSubtaskState();
+      const tool = createSubtaskTool(
+        {
+          directory,
+          client: {
+            session: {
+              abort: sessionAbort,
+              create: sessionCreate,
+              messages: sessionMessages,
+              prompt: sessionPrompt,
+            },
+          },
+        } as any,
+        state,
+        new SubagentDepthTracker(),
+      );
+
+      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('<subtask_summary>');
+      expect(result).toContain('Summary from worker');
+      expect(result.match(/<subtask_summary>/g)).toHaveLength(1);
+      expect(result.match(/<\/subtask_summary>/g)).toHaveLength(1);
+      expect(sessionCreate).toHaveBeenCalledWith({
+        responseStyle: 'data',
+        throwOnError: true,
+        query: { directory },
+        body: {
+          parentID: 'ses_old',
+          title: 'Subtask 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();
+      const workerPrompt = String(promptCall.body.parts[0]?.text);
+      expect(promptCall.body.parts[0]).toMatchObject({
+        type: 'text',
+        text: expect.stringContaining(
+          'You are a subtask worker spawned by parent session ses_old',
+        ),
+      });
+      expect(workerPrompt).toContain('Your job is bounded');
+      expect(workerPrompt).toContain('TASK:');
+      expect(workerPrompt).toContain('FILES PROVIDED:');
+      expect(workerPrompt).toContain('<subtask_summary>');
+      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('normalizes nested worker summary tags', async () => {
+    const directory = makeTempDir();
+    try {
+      const sessionCreate = mock(async () => ({ data: { id: 'ses_new' } }));
+      const sessionPrompt = mock(async () => ({}));
+      const sessionMessages = mock(async () => ({
+        data: [
+          {
+            info: { role: 'assistant' },
+            parts: [
+              {
+                type: 'text',
+                text: '<subtask_summary><subtask_summary>Inner</subtask_summary></subtask_summary>',
+              },
+            ],
+          },
+        ],
+      }));
+      const sessionAbort = mock(async () => ({}));
+      const state = createSubtaskState();
+      const tool = createSubtaskTool(
+        {
+          directory,
+          client: {
+            session: {
+              abort: sessionAbort,
+              create: sessionCreate,
+              messages: sessionMessages,
+              prompt: sessionPrompt,
+            },
+          },
+        } as any,
+        state,
+      );
+
+      const result = await tool.execute({ prompt: 'Summarize only' }, {
+        sessionID: 'ses_old',
+      } as any);
+
+      expect(result).toContain('Inner');
+      expect(result.match(/<subtask_summary>/g)).toHaveLength(1);
+      expect(result.match(/<\/subtask_summary>/g)).toHaveLength(1);
+    } finally {
+      fs.rmSync(directory, { recursive: true, force: true });
+    }
+  });
+
+  test('aborts child session when parent tool call is cancelled', async () => {
+    const directory = makeTempDir();
+    const controller = new AbortController();
+    try {
+      const sessionCreate = mock(async () => ({ data: { id: 'ses_new' } }));
+      const sessionPrompt = mock(() => {
+        setTimeout(() => controller.abort(), 0);
+        return new Promise(() => {});
+      });
+      const sessionMessages = mock(async () => ({ data: [] }));
+      const sessionAbort = mock(async () => ({}));
+      const state = createSubtaskState();
+      const tool = createSubtaskTool(
+        {
+          directory,
+          client: {
+            session: {
+              abort: sessionAbort,
+              create: sessionCreate,
+              messages: sessionMessages,
+              prompt: sessionPrompt,
+            },
+          },
+        } as any,
+        state,
+      );
+
+      await expect(
+        tool.execute({ prompt: 'Cancel me' }, {
+          sessionID: 'ses_old',
+          abort: controller.signal,
+        } as any),
+      ).rejects.toThrow('Prompt cancelled');
+
+      expect(sessionAbort).toHaveBeenCalledWith({
+        path: { id: 'ses_new' },
+        query: { directory },
+      });
+      expect(state.isSubtaskSession('ses_new')).toBe(false);
+      expect(sessionMessages).not.toHaveBeenCalled();
+    } finally {
+      fs.rmSync(directory, { recursive: true, force: true });
+    }
+  });
+
+  test('blocks nested subtask calls from a subtask worker', async () => {
+    const directory = makeTempDir();
+    try {
+      let nestedResult = '';
+      const state = createSubtaskState();
+      const tool = createSubtaskTool(
+        {
+          directory,
+          client: {
+            session: {
+              abort: mock(async () => ({})),
+              create: mock(async () => ({ data: { id: 'ses_subtask' } })),
+              messages: mock(async () => ({
+                data: [
+                  {
+                    info: { role: 'assistant' },
+                    parts: [{ type: 'text', text: 'done' }],
+                  },
+                ],
+              })),
+              prompt: mock(async () => {
+                nestedResult = String(
+                  await tool.execute({ prompt: 'nested subtask' }, {
+                    sessionID: 'ses_subtask',
+                  } as any),
+                );
+              }),
+            },
+          },
+        } as any,
+        state,
+        new SubagentDepthTracker(),
+      );
+
+      await tool.execute({ prompt: 'outer subtask' }, {
+        sessionID: 'ses_old',
+      } as any);
+
+      expect(nestedResult).toContain('Nested subtask 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 state = createSubtaskState();
+    state.markSession('ses_worker', 'ses_old');
+
+    const result = await createReadSessionTool(
+      { session: { messages } } as any,
+      state,
+    ).execute({ sessionID: 'ses_old' }, { sessionID: 'ses_worker' } as any);
+
+    expect(result).toContain('## User');
+    expect(result).toContain('Hi');
+    expect(result).toContain('## Assistant');
+    expect(result).toContain('[Tool: read] Read file');
+  });
+
+  test('blocks reads outside the source session', async () => {
+    const state = createSubtaskState();
+    state.markSession('ses_worker', 'ses_old');
+    const messages = mock(async () => ({ data: [] }));
+
+    const result = await createReadSessionTool(
+      { session: { messages } } as any,
+      state,
+    ).execute({ sessionID: 'ses_other' }, { sessionID: 'ses_worker' } as any);
+
+    expect(result).toContain('can only read the source session');
+    expect(messages).not.toHaveBeenCalled();
+  });
+});

+ 321 - 0
src/tools/subtask/tools.ts

@@ -0,0 +1,321 @@
+/**
+ * Tool definitions for subtask functionality.
+ *
+ * Factory functions that create tool definitions with injected dependencies:
+ * - createSubtaskTool: Create a new session with subtask 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 type { SubagentDepthTracker } from '../../utils/subagent-depth';
+import {
+  buildSyntheticFileParts,
+  cleanFileReference,
+  parseFileReferences,
+} from './files';
+import type { SubtaskState } from './state';
+
+export type OpencodeClient = PluginInput['client'];
+const SUBTASK_TIMEOUT_MS = 5 * 60 * 1000;
+const SUBTASK_SUMMARY_TAG_REGEX = /<\/?subtask_summary>/g;
+
+function normalizeSubtaskSummary(text: string): string {
+  return text.replace(SUBTASK_SUMMARY_TAG_REGEX, '').trim();
+}
+
+function getAbortSignal(context: unknown): AbortSignal | undefined {
+  if (!context || typeof context !== 'object' || !('abort' in context)) {
+    return undefined;
+  }
+
+  const signal = (context as { abort?: unknown }).abort;
+  return signal &&
+    typeof signal === 'object' &&
+    'addEventListener' in signal &&
+    'removeEventListener' in signal &&
+    'aborted' in signal
+    ? (signal as AbortSignal)
+    : undefined;
+}
+
+/**
+ * Create the subtask tool.
+ *
+ * Takes the OpenCode client as a dependency for TUI and session operations.
+ */
+export function createSubtaskTool(
+  ctx: PluginInput,
+  state: SubtaskState,
+  depthTracker?: SubagentDepthTracker,
+): ToolDefinition {
+  const client = ctx.client;
+
+  return tool({
+    description:
+      'Run a child worker session and return its completion summary to the caller',
+    args: {
+      prompt: tool.schema.string().describe('The generated subtask 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';
+      const abortSignal = getAbortSignal(context);
+      if (state.isSubtaskSession(sessionID)) {
+        return 'Nested subtask is disabled: this session is already a subtask worker. Finish this worker and return its summary to the parent session instead.';
+      }
+      if (
+        sessionID !== 'unknown' &&
+        depthTracker &&
+        depthTracker.getDepth(sessionID) + 1 > depthTracker.maxDepth
+      ) {
+        return `Subtask worker blocked: max subagent depth ${depthTracker.maxDepth} would be exceeded.`;
+      }
+
+      const sessionReference = `You are a subtask worker spawned by parent session ${sessionID}.
+
+Your job is bounded: complete only the task below. Do not expand scope.
+If needed context is missing, use read_session to inspect the parent session.
+Do not spawn another subtask.`;
+      const files = new Set([
+        ...parseFileReferences(args.prompt),
+        ...(args.files ?? []).map(cleanFileReference),
+      ]);
+      const fileRefs =
+        files.size > 0 ? [...files].map((f) => `@${f}`).join(' ') : '';
+      const fullPrompt = fileRefs
+        ? `${sessionReference}\n\nTASK:\n${args.prompt}\n\nFILES PROVIDED:\n${fileRefs}`
+        : `${sessionReference}\n\nTASK:\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: `Subtask 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('Subtask worker session did not return an id');
+        }
+        if (sessionID !== 'unknown' && depthTracker) {
+          const registered = depthTracker.registerChild(
+            sessionID,
+            childSessionID,
+          );
+          if (!registered) {
+            throw new Error(
+              'Subtask worker blocked: max subagent depth exceeded',
+            );
+          }
+        }
+        state.markSession(childSessionID, sessionID);
+
+        await promptWithTimeout(
+          client,
+          {
+            responseStyle: 'data',
+            throwOnError: true,
+            query: { directory },
+            path: { id: childSessionID },
+            body: {
+              agent: 'orchestrator',
+              parts: [
+                {
+                  type: 'text',
+                  text: `${fullPrompt}\n\nInstructions:\n1. Understand the task and relevant file context.\n2. Make only necessary changes.\n3. Run the most relevant validation checks when practical.\n4. Stop when the requested task is done.\n\nReturn your final response in this format:\n\n<subtask_summary>\nStatus: completed | blocked | partial\n\nWhat changed:\n- ...\n\nFiles touched:\n- ...\n\nValidation:\n- ...\n\nRisks / follow-up:\n- ...\n</subtask_summary>`,
+                },
+                ...(await buildSyntheticFileParts(directory, files)),
+              ],
+            },
+          },
+          SUBTASK_TIMEOUT_MS,
+          abortSignal,
+        );
+
+        const extraction = await extractSessionResult(client, childSessionID, {
+          directory,
+          includeReasoning: false,
+        });
+        if (extraction.empty) {
+          throw new Error('Subtask worker returned no summary');
+        }
+        const summary = normalizeSubtaskSummary(extraction.text);
+
+        return [
+          `task_id: ${childSessionID}`,
+          '',
+          '<subtask_summary>',
+          summary,
+          '</subtask_summary>',
+        ].join('\n');
+      } finally {
+        if (childSessionID) {
+          try {
+            await client.session.abort({
+              path: { id: childSessionID },
+              query: { directory },
+            });
+            state.unmarkSession(childSessionID);
+          } catch {
+            // Keep the subtask marker if abort fails; session.deleted cleanup
+            // will remove it when OpenCode eventually deletes the session.
+          }
+        }
+      }
+    },
+  });
+}
+
+/**
+ * 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,
+  state: SubtaskState,
+): 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 subtask 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;
+      const callerSessionID =
+        context && typeof context === 'object' && 'sessionID' in context
+          ? (context as { sessionID?: string }).sessionID
+          : undefined;
+      if (!callerSessionID || !state.isSubtaskSession(callerSessionID)) {
+        return 'read_session is only available from subtask worker sessions.';
+      }
+      if (state.sourceFor(callerSessionID) !== args.sessionID) {
+        return 'read_session can only read the source session for this subtask worker.';
+      }
+
+      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/subtask/vendor.test.ts

@@ -0,0 +1,104 @@
+/**
+ * Tests for subtask 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);
+  });
+});

+ 143 - 0
src/tools/subtask/vendor.ts

@@ -0,0 +1,143 @@
+/**
+ * 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 (used as `<path>` in the output)
+ * @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;
+}

+ 16 - 1
src/utils/session.ts

@@ -98,7 +98,10 @@ export async function promptWithTimeout(
   client: OpencodeClient,
   args: Parameters<OpencodeClient['session']['prompt']>[0],
   timeoutMs: number,
+  signal?: AbortSignal,
 ): Promise<void> {
+  if (signal?.aborted) throw new Error('Prompt cancelled');
+
   if (timeoutMs <= 0) {
     await client.session.prompt(args);
     return;
@@ -106,6 +109,7 @@ export async function promptWithTimeout(
 
   const sessionId = args.path.id;
   let timer: ReturnType<typeof setTimeout> | undefined;
+  let onAbort: (() => void) | undefined;
 
   try {
     const promptPromise = client.session.prompt(args);
@@ -120,6 +124,15 @@ export async function promptWithTimeout(
           );
         }, timeoutMs);
       }),
+      new Promise<never>((_, reject) => {
+        if (!signal) return;
+        if (signal.aborted) {
+          reject(new Error('Prompt cancelled'));
+          return;
+        }
+        onAbort = () => reject(new Error('Prompt cancelled'));
+        signal.addEventListener('abort', onAbort, { once: true });
+      }),
     ]);
   } catch (error) {
     if (error instanceof OperationTimeoutError) {
@@ -132,6 +145,7 @@ export async function promptWithTimeout(
     throw error;
   } finally {
     clearTimeout(timer);
+    if (onAbort) signal?.removeEventListener('abort', onAbort);
   }
 }
 
@@ -157,12 +171,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 };