Explorar o código

feat: add session resumption recovery for delegated subagent tasks

When a delegated task returns an empty or interrupted result (provider error,
timeout, etc.), append a [task partial state available] note to the output
containing the task_id so the orchestrator can decide whether to resume
the session instead of spawning a fresh subagent.

Implements the core mechanism for issue #387:
- New delegate-task-resume hook (detector, guidance, hook modules)
- New TaskSessionTracker utility for tracking subagent sessions
- Orchestrator prompt updated with Session Resumption section
- Wired into tool.execute.after and session.created/deleted events
- 32 new tests (TaskSessionTracker + hook integration)

Bug fix: task_id is now parsed from the task tool XML output rather
than incorrectly using the orchestrator session ID as a fallback.
SweetSophia hai 3 meses
pai
achega
488b198e9e

+ 74 - 209
AGENTS.md

@@ -4,258 +4,123 @@ This document provides guidelines for AI agents operating in this repository.
 
 ## Project Overview
 
-**oh-my-opencode-slim** - A lightweight agent orchestration plugin for OpenCode, a slimmed-down fork of oh-my-opencode. Built with TypeScript, Bun, and Biome.
+**oh-my-opencode-slim** — An OpenCode plugin that adds specialist-agent orchestration. Built with TypeScript, Bun, and Biome. Not a standalone app; it registers agents, tools, hooks, and MCPs into the OpenCode runtime.
 
 ## Commands
 
 | Command | Description |
 |---------|-------------|
-| `bun run build` | Build TypeScript to `dist/` (both index.ts and cli/index.ts) |
-| `bun run typecheck` | Run TypeScript type checking without emitting |
-| `bun test` | Run all tests with Bun |
-| `bun run lint` | Run Biome linter on entire codebase |
-| `bun run format` | Format entire codebase with Biome |
-| `bun run check` | Run Biome check with auto-fix (lint + format + organize imports) |
-| `bun run check:ci` | Run Biome check without auto-fix (CI mode) |
-| `bun run dev` | Build and run with OpenCode |
-
-**Running a single test:** Use Bun's test filtering with the `-t` flag:
-```bash
-bun test -t "test-name-pattern"
-```
+| `bun run build` | Build plugin + CLI bundles to `dist/`, emit declarations, regenerate config schema |
+| `bun run typecheck` | `tsc --noEmit` — type check only |
+| `bun test` | Run all tests (root is `./src` via `bunfig.toml`) |
+| `bun run check` | Biome check with auto-fix (lint + format + organize imports) |
+| `bun run check:ci` | Biome check without auto-fix (CI gate) |
+| `bun run dev` | Build then launch OpenCode |
+| `bun run generate-schema` | Regenerate `oh-my-opencode-slim.schema.json` from Zod schema |
 
-## Code Style
+**Single test:** `bun test -t "test-name-pattern"`
+**Single file:** `bun test src/config/loader.test.ts`
 
-### General Rules
-- **Formatter/Linter:** Biome (configured in `biome.json`)
-- **Line width:** 80 characters
-- **Indentation:** 2 spaces
-- **Line endings:** LF (Unix)
-- **Quotes:** Single quotes in JavaScript/TypeScript
-- **Trailing commas:** Always enabled
-
-### TypeScript Guidelines
-- **Strict mode:** Enabled in `tsconfig.json`
-- **No explicit `any`:** Generates a linter warning (disabled for test files)
-- **Module resolution:** `bundler` strategy
-- **Declarations:** Generate `.d.ts` files in `dist/`
-
-### Imports
-- Biome auto-organizes imports on save (`organizeImports: "on"`)
-- Let the formatter handle import sorting
-- Use path aliases defined in TypeScript configuration if present
-
-### Naming Conventions
-- **Variables/functions:** camelCase
-- **Classes/interfaces:** PascalCase
-- **Constants:** SCREAMING_SNAKE_CASE
-- **Files:** kebab-case for most, PascalCase for React components
-
-### Error Handling
-- Use typed errors with descriptive messages
-- Let errors propagate appropriately rather than catching silently
-- Use Zod for runtime validation (already a dependency)
-
-### Git Integration
-- Biome integrates with git (VCS enabled)
-- Commits should pass `bun run check:ci` before pushing
+### Build internals
 
-## Project Structure
+The build is a 4-step pipeline (`bun run build`):
 
-```
-oh-my-opencode-slim/
-├── src/
-│   ├── agents/       # Agent factories (orchestrator, explorer, oracle, etc.)
-│   ├── cli/          # CLI entry point
-│   ├── config/       # Constants, schemas, MCP defaults
-│   ├── council/      # Council manager (multi-LLM session orchestration)
-│   ├── hooks/        # OpenCode lifecycle hooks
-│   ├── mcp/          # MCP server definitions
-│   ├── multiplexer/  # Tmux/Zellij pane integration for child sessions
-│   ├── skills/       # Skill definitions (included in package publish)
-│   ├── tools/        # Tool definitions (council, webfetch, AST-grep, etc.)
-│   └── utils/        # Shared utilities (tmux, session helpers)
-├── dist/             # Built JavaScript and declarations
-├── docs/             # User-facing documentation
-├── biome.json        # Biome configuration
-├── tsconfig.json     # TypeScript configuration
-└── package.json      # Project manifest and scripts
-```
+1. `build:plugin` — Bun-bundle `src/index.ts` → `dist/index.js` (ESM, Node target)
+2. `build:cli` — Bun-bundle `src/cli/index.ts` → `dist/cli/index.js`
+3. `tsc --emitDeclarationOnly` — Generate `.d.ts` files into `dist/`
+4. `generate-schema` — Produce JSON Schema from the Zod config schema
 
-## Key Dependencies
+**Adding a new dependency?** Check `build:plugin`/`build:cli` in `package.json` — packages listed in `--external` are not bundled. New packages that should remain external (native modules, host-provided SDKs) must be added there.
 
-- `@modelcontextprotocol/sdk` - MCP protocol implementation
-- `@opencode-ai/sdk` - OpenCode AI SDK
-- `zod` - Runtime validation
+`prepare` script runs `build` automatically on install.
+
+### CI pipeline
+
+CI (`.github/workflows/ci.yml`) runs: `lint → typecheck → test → build` on every push/PR to `main`/`master`. A second job (`package-smoke`) builds and runs `verify:release` + `verify:host-smoke` on both Ubuntu and macOS.
 
 ## Development Workflow
 
 1. Make code changes
-2. Update docs when behavior, commands, configuration, workflows, or user-facing output changes
+2. Update docs when behavior, commands, configuration, or user-facing output changes
    - Check `README.md` plus relevant files in `docs/`
    - Keep examples, command snippets, and feature lists in sync with the code
    - If no doc update is needed, explicitly confirm that in your final summary
-3. Run `bun run check:ci` to verify linting and formatting
-4. Run `bun run typecheck` to verify types
-5. Run `bun test` to verify tests pass
+3. `bun run check:ci` — lint + format gate
+4. `bun run typecheck` — type gate
+5. `bun test` — test gate
 6. Commit changes
 
-## Tmux Session Lifecycle Management
+## Code Style
 
-When working with tmux integration, understanding the session lifecycle is crucial for preventing orphaned processes and ghost panes.
+- **Formatter/Linter:** Biome (`biome.json`) — single quotes, 2-space indent, 80-char line width, trailing commas, LF endings
+- **Strict TypeScript:** `strict: true`, `moduleResolution: "bundler"`, declarations emitted to `dist/`
+- **No explicit `any`:** Linter warning in source, allowed in test files (Biome override)
+- **Imports:** Biome auto-organizes on check — don't manually sort
+- **`zod` v4:** Used for runtime validation and config schema. It is a **peerDependency** (`^4.0.0`), not a regular dep.
 
-### Session Lifecycle Flow
+## Project Structure
 
 ```
-Task Launch:
-  session.create() → tmux pane spawned → task runs
+src/
+├── agents/       # Agent factories (orchestrator, explorer, oracle, designer, fixer, librarian, council, councillor, observer)
+├── cli/          # CLI entry point — install, config, presets, skill packaging
+├── config/       # Zod config schema, loaders, constants, agent/MCP policy helpers
+├── council/      # Council manager (multi-LLM session orchestration and synthesis)
+├── hooks/        # OpenCode lifecycle hooks (apply-patch, auto-update, error recovery, todo-continuation, etc.)
+├── interview/    # /interview feature — browser-based Q&A flow for spec generation
+├── mcp/          # Built-in MCP server definitions (websearch, context7, grep_app)
+├── multiplexer/  # Tmux/Zellij pane integration for live session visualization
+├── skills/       # Skill payloads shipped at install time (codemap, simplify)
+├── tools/        # Tool definitions (ast-grep search/replace, smartfetch, council tool, preset manager)
+└── utils/        # Shared utilities (logger, tmux, session helpers, subagent depth tracking)
+```
 
-Task Completes Normally:
-  session.status (idle) → extract results → session.abort()
-  → session.deleted event → tmux pane closed
+Key entry points:
+- `src/index.ts` — Plugin bootstrap and composition root (840+ lines). Wires every subsystem together.
+- `src/cli/index.ts` — CLI entry point
+- `src/config/schema.ts` — Source-of-truth runtime config schema
 
-Task Cancelled:
-  cancel() → session.abort() → session.deleted event
-  → tmux pane closed
+## Tmux Session Lifecycle Management
 
-Session Deleted Externally:
-  session.deleted event → task cleanup → tmux pane closed
-```
+When working with tmux integration, orphaned processes and ghost panes are the primary failure mode.
 
-### Key Implementation Details
+### Lifecycle
 
-**1. Graceful Shutdown (src/utils/tmux.ts)**
-```typescript
-// Always send Ctrl+C before killing pane
-spawn([tmux, "send-keys", "-t", paneId, "C-c"])
-await delay(250)
-spawn([tmux, "kill-pane", "-t", paneId])
+```
+session.create() → tmux pane spawned → task runs
+  → session goes idle → extract results → session.abort()
+    → session.deleted event → tmux pane closed
 ```
 
-**2. Session Abort Timing (src/council/council-manager.ts)**
-- Call `session.abort()` AFTER extracting task results
-- This ensures content is preserved before session termination
-- Triggers `session.deleted` event for cleanup
-
-**3. Event Handlers (src/index.ts)**
-The multiplexer session handler must stay wired up:
-- `multiplexerSessionManager.onSessionDeleted()` - closes tmux/zellij panes
+### Key rules
 
-### Testing Tmux Integration
+1. **Graceful shutdown** (`src/multiplexer/tmux/`): Always send `C-c` before `kill-pane`, with a short delay
+2. **Abort timing** (`src/council/council-manager.ts`): Call `session.abort()` AFTER extracting task results — content is destroyed on abort
+3. **Event wiring** (`src/index.ts`): `multiplexerSessionManager.onSessionDeleted()` must stay connected to close panes
 
-After making changes to session management:
+### Testing tmux integration
 
 ```bash
-# 1. Build the plugin
 bun run build
-
-# 2. Run from local fork (in ~/.config/opencode/opencode.jsonc):
-# "plugin": ["file:///path/to/oh-my-opencode-slim"]
-
-# 3. Launch test tasks
-@explorer count files in src/
-@librarian search for Bun documentation
-
-# 4. Verify no orphans
+# Configure plugin as file:// in opencode.jsonc
+# Launch tasks, then verify no orphans:
 ps aux | grep "opencode attach" | grep -v grep
-# Should return 0 processes after tasks complete
 ```
 
-### Common Issues
-
-**Ghost panes remaining open:**
-- Check that `session.abort()` is called after result extraction
-- Verify `session.deleted` handler is wired in src/index.ts
-
-**Orphaned opencode attach processes:**
-- Ensure graceful shutdown sends Ctrl+C before kill-pane
-- Check that tmux pane closes before process termination
-
 ## Pre-Push Code Review
 
-Before pushing changes to the repository, always run a code review to catch issues like:
-- Duplicate code
-- Redundant function calls
-- Race conditions
-- Logic errors
-
-### Using `/review` Command (Recommended)
-
-OpenCode has a built-in `/review` command that automatically performs comprehensive code reviews:
-
-```bash
-# Review uncommitted changes (default)
-/review
-
-# Review specific commit
-/review <commit-hash>
-
-# Review branch comparison
-/review <branch-name>
-
-# Review PR
-/review <pr-url-or-number>
-```
-
-**Why use `/review` instead of asking @oracle manually?**
-- Standardized review process with consistent focus areas (bugs, structure, performance)
-- Automatically handles git operations (diff, status, etc.)
-- Context-aware: reads full files and convention files (AGENTS.md, etc.)
-- Delegates to specialized @build subagent with proper permissions
-- Provides actionable, matter-of-fact feedback
-
-### Workflow Before Pushing
-
-1. **Make your changes**
-   ```bash
-   # ... edit files ...
-   ```
-
-2. **Stage changes**
-   ```bash
-   git add .
-   ```
-
-3. **Run code review**
-   ```
-   /review
-   ```
-
-4. **Address any issues found**
-
-5. **Run checks**
-   ```bash
-   bun run check:ci
-   bun test
-   ```
-
-6. **Commit and push**
-   ```bash
-   git commit -m "..."
-   git push origin <branch>
-   ```
-
-**Note:** The `/review` command found issues in our PR #127 (duplicate code, redundant abort calls) that neither linter nor tests caught. Always use it before pushing!
-
-## Common Patterns
-
-- This is an OpenCode plugin - most functionality lives in `src/`
-- The CLI entry point is `src/cli/index.ts`
-- The main plugin export is `src/index.ts`
-- Agent factories are in `src/agents/` — each agent has its own file + optional `.test.ts`
-- Skills are located in `src/skills/` (included in package publish)
-- Multiplexer session management is in `src/multiplexer/`
-- Council manager (multi-LLM orchestration) is in `src/council/`
-- Tmux utilities are in `src/utils/tmux.ts`
-- 468 tests across 35 files — run `bun test` to verify
+Use `/review` before pushing — it catches issues (duplicate code, redundant calls, race conditions) that linter and tests miss. It delegates to a specialized subagent with full file access.
 
 ## Repository Map
 
-A full codemap is available at `codemap.md` in the project root.
+`codemap.md` in the project root contains a full architectural map. Read it before working on unfamiliar subsystems. Each `src/` subdirectory also has its own `codemap.md`.
 
-Before working on any task, read `codemap.md` to understand:
-- Project architecture and entry points
-- Directory responsibilities and design patterns
-- Data flow and integration points between modules
+## Common Patterns
 
-For deep work on a specific folder, also read that folder's `codemap.md`.
+- Agent prompts are in `src/agents/<name>.ts` — each agent has its own file, prompt overrides come from `.md` files resolved by the config loader
+- Custom user agents are defined via `config.agents.<name>` with `prompt` and `orchestratorPrompt` fields
+- Hooks are composed in `src/hooks/index.ts` and attached during plugin init
+- The `interview/` feature runs a local HTTP server for browser-based spec capture
+- Multiplexer backends (tmux, zellij) are selected at runtime via factory pattern in `src/multiplexer/`
+- The `apply-patch` hook is a complex pipeline with matching, resolution, and recovery stages — changes there need careful testing
+- `bunfig.toml` sets test root to `./src` — tests must live under `src/`

+ 23 - 0
src/agents/orchestrator.ts

@@ -197,6 +197,29 @@ ${enabledValidationRouting}
 - Confirm specialists completed successfully
 - Verify solution meets requirements
 
+## 7. Session Resumption
+When a delegated task returns an empty or incomplete result, the subagent session may still contain useful partial state (files read, edits made, tool calls executed).
+
+**If the task output contains \`[task partial state available]\`:**
+- The session was interrupted (provider error, timeout, etc.)
+- A \`task_id\` is provided in the output — pass it to the next task call to resume
+- The subagent will see all prior context and continue from where it stopped
+
+**When to resume (task_id):**
+- Task returned empty/interrupted but had partial progress
+- Provider error occurred during execution
+- User asks to continue/recover previous work
+
+**When NOT to resume:**
+- Previous task completed successfully (start fresh for new work)
+- Task failed due to wrong parameters (fix parameters, don't resume)
+- The follow-up work is unrelated to the interrupted task
+
+**Resuming example:**
+\`\`\`
+task(description="Continue fixing...", prompt="Pick up where you left off", task_id="ses_abc123", ...)
+\`\`\`
+
 </Workflow>
 
 <Communication>

+ 80 - 0
src/hooks/delegate-task-resume/detector.ts

@@ -0,0 +1,80 @@
+/**
+ * Detection logic for empty/interrupted task tool results.
+ *
+ * Distinguishes from parameter validation errors (handled by delegate-task-retry)
+ * by checking for error signals that indicate a provider/server interruption
+ * rather than a malformed task call.
+ */
+
+/** Patterns that suggest a provider error or interruption (not a parameter error). */
+const PROVIDER_ERROR_PATTERNS = [
+  /provider.*error/i,
+  /server.*error/i,
+  /connection.*error/i,
+  /\b429\b/,
+  /rate.?limit/i,
+  /too many requests/i,
+  /timeout/i,
+  /overloaded/i,
+  /quota.?exceeded/i,
+  /usage.?exceeded/i,
+  /resource.?exhausted/i,
+  /insufficient.?quota/i,
+];
+
+/**
+ * Signals that indicate a parameter validation error — these are handled by
+ * delegate-task-retry and should NOT trigger session-resume guidance.
+ */
+const PARAMETER_ERROR_SIGNALS = [
+  'Invalid arguments',
+  '[ERROR]',
+  'is not allowed. Allowed agents:',
+];
+
+/**
+ * Extract the task_id from a task tool output string.
+ * The output format is: <task_id>ses_xxx</task_id><task_result>...</task_result>
+ * Returns undefined if the task_id cannot be found.
+ */
+export function parseTaskId(output: string): string | undefined {
+  const match = output.match(/<task_id>([^<]+)<\/task_id>/);
+  return match ? match[1] : undefined;
+}
+
+/**
+ * Detect whether a task tool output looks like an interrupted/empty result
+ * that may contain recoverable partial state.
+ *
+ * Returns true when:
+ * - Output is empty or whitespace-only
+ * - Output contains a provider error signal
+ *
+ * Returns false when:
+ * - Output looks like a parameter validation error (delegate-task-retry handles those)
+ * - Output has substantial content (likely a successful result)
+ */
+export function detectInterruptedTask(output: string): boolean {
+  if (typeof output !== 'string') return false;
+
+  const trimmed = output.trim();
+
+  // Empty output — the most common interrupted case
+  if (trimmed.length === 0) return true;
+
+  // Empty task_result XML tag
+  if (/<task_result>\s*<\/task_result>/.test(trimmed)) return true;
+
+  // Check if this is a parameter error — those belong to delegate-task-retry
+  const isParameterError = PARAMETER_ERROR_SIGNALS.some((signal) =>
+    output.includes(signal),
+  );
+  if (isParameterError) return false;
+
+  // Check for provider error signals
+  const hasProviderError = PROVIDER_ERROR_PATTERNS.some((p) => p.test(trimmed));
+  if (hasProviderError) return true;
+
+  // Default: not interrupted
+  return false;
+}

+ 39 - 0
src/hooks/delegate-task-resume/guidance.ts

@@ -0,0 +1,39 @@
+/**
+ * Builds the recovery note appended to empty/interrupted task results.
+ *
+ * The note is informational — it tells the orchestrator that the session
+ * may contain partial state and how to resume it. The orchestrator decides
+ * whether to actually resume.
+ */
+
+import type { TrackedTaskSession } from '../../utils/task-session-tracker';
+
+export interface ResumeGuidanceOptions {
+  sessionId: string;
+  agent?: string;
+  status: TrackedTaskSession['status'];
+}
+
+/**
+ * Build a recovery note for an interrupted/empty task result.
+ */
+export function buildResumeGuidance(options: ResumeGuidanceOptions): string {
+  const { sessionId, agent, status } = options;
+
+  if (status !== 'interrupted' && status !== 'active') {
+    return '';
+  }
+
+  const agentLine = agent ? `\nAgent: @${agent}` : '';
+
+  return [
+    '',
+    '[task partial state available]',
+    'This task returned no final result, but its subagent session may contain partial state.',
+    `If the user asks to continue/recover this work, reuse this task_id to reconnect:${agentLine}`,
+    `  task_id: ${sessionId}`,
+    '',
+    'The subagent can see its prior messages, tool calls, reads, patches, and interrupted/error state.',
+    'This is recovery context, not an instruction to resume automatically.',
+  ].join('\n');
+}

+ 57 - 0
src/hooks/delegate-task-resume/hook.ts

@@ -0,0 +1,57 @@
+import type { PluginInput } from '@opencode-ai/plugin';
+import type { TaskSessionTracker } from '../../utils/task-session-tracker';
+import { detectInterruptedTask, parseTaskId } from './detector';
+import { buildResumeGuidance } from './guidance';
+
+export function createDelegateTaskResumeHook(
+  _ctx: PluginInput,
+  sessionTracker: TaskSessionTracker,
+) {
+  return {
+    'tool.execute.after': async (
+      input: { tool: string },
+      output: { output: unknown },
+    ): Promise<void> => {
+      const toolName = input.tool.toLowerCase();
+      if (toolName !== 'task') return;
+
+      if (typeof output.output !== 'string') return;
+
+      const taskOutput = output.output as string;
+
+      // Try to extract the subagent session ID from the tool output.
+      // This is the session ID that can be passed as task_id to resume.
+      const taskId = parseTaskId(taskOutput);
+
+      // Detect empty/interrupted results (not parameter errors)
+      if (!detectInterruptedTask(taskOutput)) {
+        // Successful result — mark the subagent session as completed if tracked
+        if (taskId) {
+          sessionTracker.markCompleted(taskId);
+        }
+        return;
+      }
+
+      // We have an interrupted/empty result.
+      // We need taskId to build the recovery note — without it we cannot
+      // identify which subagent session this refers to.
+      if (!taskId) return;
+
+      // Mark as interrupted in the tracker
+      sessionTracker.markInterrupted(taskId);
+
+      // Look up session info for the recovery note (agent name, etc.)
+      const sessionInfo = sessionTracker.get(taskId);
+
+      const guidance = buildResumeGuidance({
+        sessionId: taskId,
+        agent: sessionInfo?.agent,
+        status: sessionInfo?.status ?? 'interrupted',
+      });
+
+      if (guidance) {
+        output.output += guidance;
+      }
+    },
+  };
+}

+ 242 - 0
src/hooks/delegate-task-resume/index.test.ts

@@ -0,0 +1,242 @@
+import { describe, expect, test } from 'bun:test';
+import { TaskSessionTracker } from '../../utils/task-session-tracker';
+import { detectInterruptedTask, parseTaskId } from './detector';
+import { buildResumeGuidance } from './guidance';
+import { createDelegateTaskResumeHook } from './hook';
+
+// ---------------------------------------------------------------------------
+// detector.ts
+// ---------------------------------------------------------------------------
+
+describe('detectInterruptedTask', () => {
+  test('returns true for empty string', () => {
+    expect(detectInterruptedTask('')).toBe(true);
+  });
+
+  test('returns true for whitespace-only string', () => {
+    expect(detectInterruptedTask('   \n  ')).toBe(true);
+  });
+
+  test('returns true for empty task_result XML', () => {
+    expect(detectInterruptedTask('<task_result></task_result>')).toBe(true);
+    expect(detectInterruptedTask('<task_result>  \n  </task_result>')).toBe(
+      true,
+    );
+  });
+
+  test('returns true for provider error signals', () => {
+    expect(detectInterruptedTask('provider error: timeout')).toBe(true);
+    expect(detectInterruptedTask('Error: 429 rate limit exceeded')).toBe(true);
+    expect(detectInterruptedTask('server error: connection refused')).toBe(
+      true,
+    );
+    expect(detectInterruptedTask('quota exceeded for this API')).toBe(true);
+  });
+
+  test('returns false for parameter validation errors', () => {
+    expect(
+      detectInterruptedTask(
+        '[ERROR] Invalid arguments: Must provide either category or subagent_type',
+      ),
+    ).toBe(false);
+    expect(
+      detectInterruptedTask(
+        "Agent 'oracle' is not allowed. Allowed agents: explorer, fixer",
+      ),
+    ).toBe(false);
+  });
+
+  test('returns false for successful task results', () => {
+    expect(
+      detectInterruptedTask(
+        'I have completed the refactoring as requested. Here is a summary...',
+      ),
+    ).toBe(false);
+    expect(
+      detectInterruptedTask(
+        '<task_result>\nSuccessfully updated 3 files.\n</task_result>',
+      ),
+    ).toBe(false);
+  });
+
+  test('returns false for non-string input', () => {
+    expect(detectInterruptedTask(null as unknown as string)).toBe(false);
+    expect(detectInterruptedTask(undefined as unknown as string)).toBe(false);
+  });
+});
+
+// ---------------------------------------------------------------------------
+// guidance.ts
+// ---------------------------------------------------------------------------
+
+describe('buildResumeGuidance', () => {
+  test('returns guidance for interrupted session', () => {
+    const guidance = buildResumeGuidance({
+      sessionId: 'ses_abc123',
+      agent: 'fixer',
+      status: 'interrupted',
+    });
+
+    expect(guidance).toContain('[task partial state available]');
+    expect(guidance).toContain('task_id: ses_abc123');
+    expect(guidance).toContain('@fixer');
+    expect(guidance).toContain('recovery context');
+  });
+
+  test('returns guidance for active session', () => {
+    const guidance = buildResumeGuidance({
+      sessionId: 'ses_abc123',
+      status: 'active',
+    });
+
+    expect(guidance).toContain('[task partial state available]');
+    expect(guidance).toContain('task_id: ses_abc123');
+  });
+
+  test('returns empty string for completed session', () => {
+    const guidance = buildResumeGuidance({
+      sessionId: 'ses_abc123',
+      status: 'completed',
+    });
+
+    expect(guidance).toBe('');
+  });
+
+  test('omits agent line when agent is undefined', () => {
+    const guidance = buildResumeGuidance({
+      sessionId: 'ses_abc123',
+      status: 'interrupted',
+    });
+
+    expect(guidance).not.toContain('Agent:');
+  });
+});
+
+// ---------------------------------------------------------------------------
+// detector — parseTaskId
+// ---------------------------------------------------------------------------
+
+describe('parseTaskId', () => {
+  test('extracts task_id from task output', () => {
+    expect(
+      parseTaskId(
+        '<task_id>ses_abc123</task_id><task_result>done</task_result>',
+      ),
+    ).toBe('ses_abc123');
+  });
+
+  test('extracts task_id when task_result is empty', () => {
+    expect(
+      parseTaskId('<task_id>ses_abc123</task_id><task_result></task_result>'),
+    ).toBe('ses_abc123');
+  });
+
+  test('returns undefined when task_id not present', () => {
+    expect(parseTaskId('')).toBeUndefined();
+    expect(parseTaskId('<task_result>content</task_result>')).toBeUndefined();
+    expect(parseTaskId('some plain text output')).toBeUndefined();
+  });
+});
+
+// ---------------------------------------------------------------------------
+// hook.ts (integration)
+// ---------------------------------------------------------------------------
+
+describe('createDelegateTaskResumeHook', () => {
+  function createHook() {
+    const tracker = new TaskSessionTracker();
+    const hook = createDelegateTaskResumeHook({} as never, tracker);
+    return { tracker, hook };
+  }
+
+  /** Task output with empty result but parseable task_id. */
+  const emptyWithTaskId = (id: string) =>
+    `<task_id>${id}</task_id><task_result></task_result>`;
+
+  test('appends recovery note for empty task result with task_id', async () => {
+    const { tracker, hook } = createHook();
+    tracker.register('ses_child1', 'ses_parent', 'fixer');
+
+    const output = { output: emptyWithTaskId('ses_child1') };
+    await hook['tool.execute.after']({ tool: 'task' }, output);
+
+    expect(output.output).toContain('[task partial state available]');
+    expect(output.output).toContain('task_id: ses_child1');
+    expect(output.output).toContain('@fixer');
+  });
+
+  test('appends recovery note for provider error with task_id', async () => {
+    const { tracker, hook } = createHook();
+    tracker.register('ses_child1', 'ses_parent', 'explorer');
+
+    const output = {
+      output: `<task_id>ses_child1</task_id><task_result></task_result>\nError: 429 rate limit exceeded`,
+    };
+    await hook['tool.execute.after']({ tool: 'task' }, output);
+
+    expect(output.output).toContain('[task partial state available]');
+    expect(output.output).toContain('task_id: ses_child1');
+    expect(output.output).toContain('@explorer');
+  });
+
+  test('appends recovery note even for untracked task_id (OpenCode manages it)', async () => {
+    const { hook } = createHook();
+    // Tracker has no entry for this task_id — OpenCode still knows it
+    const output = { output: emptyWithTaskId('ses_external') };
+    await hook['tool.execute.after']({ tool: 'task' }, output);
+
+    expect(output.output).toContain('[task partial state available]');
+    expect(output.output).toContain('task_id: ses_external');
+  });
+
+  test('skips recovery note when task_id cannot be extracted', async () => {
+    const { hook } = createHook();
+    // Empty output with no parseable task_id — we cannot identify the session
+    const output = { output: '' };
+    await hook['tool.execute.after']({ tool: 'task' }, output);
+
+    expect(output.output).toBe('');
+  });
+
+  test('does NOT append recovery for parameter errors', async () => {
+    const { tracker, hook } = createHook();
+    tracker.register('ses_child1', 'ses_parent', 'fixer');
+
+    const output = {
+      output:
+        '<task_id>ses_child1</task_id>[ERROR] Invalid arguments: Must provide either category or subagent_type',
+    };
+    await hook['tool.execute.after']({ tool: 'task' }, output);
+
+    expect(output.output).not.toContain('[task partial state available]');
+  });
+
+  test('marks session as completed for successful results', async () => {
+    const { tracker, hook } = createHook();
+    tracker.register('ses_child1', 'ses_parent', 'fixer');
+
+    const output = {
+      output: '<task_id>ses_child1</task_id><task_result>Done.</task_result>',
+    };
+    await hook['tool.execute.after']({ tool: 'task' }, output);
+
+    expect(output.output).not.toContain('[task partial state available]');
+    expect(tracker.get('ses_child1')?.status).toBe('completed');
+  });
+
+  test('ignores non-task tools', async () => {
+    const { hook } = createHook();
+    const output = { output: '' };
+    await hook['tool.execute.after']({ tool: 'read' }, output);
+
+    expect(output.output).toBe('');
+  });
+
+  test('ignores non-string output', async () => {
+    const { hook } = createHook();
+    const output = { output: { some: 'object' } as unknown };
+    await hook['tool.execute.after']({ tool: 'task' }, output);
+
+    expect(output.output).toEqual({ some: 'object' });
+  });
+});

+ 4 - 0
src/hooks/delegate-task-resume/index.ts

@@ -0,0 +1,4 @@
+export { detectInterruptedTask, parseTaskId } from './detector';
+export type { ResumeGuidanceOptions } from './guidance';
+export { buildResumeGuidance } from './guidance';
+export { createDelegateTaskResumeHook } from './hook';

+ 1 - 0
src/hooks/index.ts

@@ -2,6 +2,7 @@ export { createApplyPatchHook } from './apply-patch';
 export type { AutoUpdateCheckerOptions } from './auto-update-checker';
 export { createAutoUpdateCheckerHook } from './auto-update-checker';
 export { createChatHeadersHook } from './chat-headers';
+export { createDelegateTaskResumeHook } from './delegate-task-resume';
 export { createDelegateTaskRetryHook } from './delegate-task-retry';
 export { createFilterAvailableSkillsHook } from './filter-available-skills';
 export {

+ 27 - 0
src/index.ts

@@ -8,6 +8,7 @@ import {
   createApplyPatchHook,
   createAutoUpdateCheckerHook,
   createChatHeadersHook,
+  createDelegateTaskResumeHook,
   createDelegateTaskRetryHook,
   createFilterAvailableSkillsHook,
   createJsonErrorRecoveryHook,
@@ -35,6 +36,7 @@ import { resolveRuntimeAgentName, rewriteDisplayNameMentions } from './utils';
 import { initLogger, log } from './utils/logger';
 import { SubagentDepthTracker } from './utils/subagent-depth';
 import { collapseSystemInPlace } from './utils/system-collapse';
+import { TaskSessionTracker } from './utils/task-session-tracker';
 
 /**
  * Best-effort log to opencode's app logger.
@@ -106,6 +108,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let postFileToolNudgeHook: ReturnType<typeof createPostFileToolNudgeHook>;
   let chatHeadersHook: ReturnType<typeof createChatHeadersHook>;
   let delegateTaskRetryHook: ReturnType<typeof createDelegateTaskRetryHook>;
+  let delegateTaskResumeHook: ReturnType<typeof createDelegateTaskResumeHook>;
+  let taskSessionTracker: TaskSessionTracker;
   let applyPatchHook: ReturnType<typeof createApplyPatchHook>;
   let jsonErrorRecoveryHook: ReturnType<typeof createJsonErrorRecoveryHook>;
   let foregroundFallback: ForegroundFallbackManager;
@@ -231,6 +235,13 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     // Initialize delegate-task retry guidance hook
     delegateTaskRetryHook = createDelegateTaskRetryHook(ctx);
 
+    // Initialize task session tracker and resume hook for session recovery
+    taskSessionTracker = new TaskSessionTracker();
+    delegateTaskResumeHook = createDelegateTaskResumeHook(
+      ctx,
+      taskSessionTracker,
+    );
+
     applyPatchHook = createApplyPatchHook(ctx);
     // Initialize JSON parse error recovery hook
     jsonErrorRecoveryHook = createJsonErrorRecoveryHook(ctx);
@@ -543,6 +554,12 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         if (depthTracker && childSessionId && parentSessionId) {
           depthTracker.registerChild(parentSessionId, childSessionId);
         }
+        // Track subagent task sessions for potential resumption.
+        // Only register child sessions (parentID set) since those are
+        // subagent sessions created by the task tool.
+        if (taskSessionTracker && childSessionId && parentSessionId) {
+          taskSessionTracker.register(childSessionId, parentSessionId);
+        }
       }
 
       // Runtime model fallback for foreground agents (rate-limit detection)
@@ -590,6 +607,9 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         if (depthTracker && sessionID) {
           depthTracker.cleanup(sessionID);
         }
+        if (taskSessionTracker && sessionID) {
+          taskSessionTracker.cleanup(sessionID);
+        }
         if (sessionID) {
           sessionAgentMap.delete(sessionID);
         }
@@ -789,6 +809,13 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         output as { output: unknown },
       );
 
+      // Session resumption: detect empty/interrupted task results and
+      // annotate with recovery note containing task_id
+      await delegateTaskResumeHook['tool.execute.after'](
+        input as { tool: string; sessionID?: string },
+        output as { output: unknown },
+      );
+
       await jsonErrorRecoveryHook['tool.execute.after'](
         input as {
           tool: string;

+ 3 - 1
src/interview/interview.test.ts

@@ -1618,7 +1618,9 @@ describe('renderInterviewPage', () => {
     expect(html).toContain(
       "window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });",
     );
-    expect(html).toContain('overlayText.textContent = "Submitting Answers...";');
+    expect(html).toContain(
+      'overlayText.textContent = "Submitting Answers...";',
+    );
     expect(html).toContain('scrollToTop();');
   });
 });

+ 1 - 1
src/utils/system-collapse.test.ts

@@ -1,4 +1,4 @@
-import { expect, describe, test } from 'bun:test';
+import { describe, expect, test } from 'bun:test';
 import { collapseSystemInPlace } from './system-collapse';
 
 /**

+ 91 - 0
src/utils/task-session-tracker.test.ts

@@ -0,0 +1,91 @@
+import { describe, expect, test } from 'bun:test';
+import { TaskSessionTracker } from './task-session-tracker';
+
+describe('TaskSessionTracker', () => {
+  test('register stores a session and get retrieves it', () => {
+    const tracker = new TaskSessionTracker();
+    tracker.register('ses_child1', 'ses_parent', 'fixer');
+
+    const session = tracker.get('ses_child1');
+    expect(session).toBeDefined();
+    expect(session?.sessionId).toBe('ses_child1');
+    expect(session?.parentSessionId).toBe('ses_parent');
+    expect(session?.agent).toBe('fixer');
+    expect(session?.status).toBe('active');
+  });
+
+  test('register without agent stores undefined agent', () => {
+    const tracker = new TaskSessionTracker();
+    tracker.register('ses_child1', 'ses_parent');
+
+    const session = tracker.get('ses_child1');
+    expect(session).toBeDefined();
+    expect(session?.agent).toBeUndefined();
+  });
+
+  test('register ignores duplicate session IDs', () => {
+    const tracker = new TaskSessionTracker();
+    tracker.register('ses_child1', 'ses_parent1', 'fixer');
+    tracker.register('ses_child1', 'ses_parent2', 'explorer');
+
+    const session = tracker.get('ses_child1');
+    expect(session?.parentSessionId).toBe('ses_parent1');
+    expect(session?.agent).toBe('fixer');
+  });
+
+  test('markInterrupted updates status', () => {
+    const tracker = new TaskSessionTracker();
+    tracker.register('ses_child1', 'ses_parent', 'fixer');
+    tracker.markInterrupted('ses_child1');
+
+    expect(tracker.get('ses_child1')?.status).toBe('interrupted');
+  });
+
+  test('markCompleted updates status', () => {
+    const tracker = new TaskSessionTracker();
+    tracker.register('ses_child1', 'ses_parent', 'fixer');
+    tracker.markCompleted('ses_child1');
+
+    expect(tracker.get('ses_child1')?.status).toBe('completed');
+  });
+
+  test('markInterrupted on unknown session is a no-op', () => {
+    const tracker = new TaskSessionTracker();
+    expect(() => tracker.markInterrupted('ses_unknown')).not.toThrow();
+  });
+
+  test('markCompleted on unknown session is a no-op', () => {
+    const tracker = new TaskSessionTracker();
+    expect(() => tracker.markCompleted('ses_unknown')).not.toThrow();
+  });
+
+  test('cleanup removes a session', () => {
+    const tracker = new TaskSessionTracker();
+    tracker.register('ses_child1', 'ses_parent', 'fixer');
+    tracker.cleanup('ses_child1');
+
+    expect(tracker.get('ses_child1')).toBeUndefined();
+  });
+
+  test('cleanup on unknown session is a no-op', () => {
+    const tracker = new TaskSessionTracker();
+    expect(() => tracker.cleanup('ses_unknown')).not.toThrow();
+  });
+
+  test('sweepStale removes old entries', () => {
+    const tracker = new TaskSessionTracker();
+    tracker.register('ses_old', 'ses_parent', 'fixer');
+
+    // Manually backdate the entry
+    const session = tracker.get('ses_old');
+    expect(session).toBeDefined();
+    // biome-ignore lint/style/noNonNullAssertion: guarded by toBeDefined above
+    session!.createdAt = Date.now() - 2 * 60 * 60 * 1000; // 2 hours ago
+
+    tracker.register('ses_new', 'ses_parent', 'explorer');
+    tracker.sweepStale();
+
+    expect(tracker.get('ses_old')).toBeUndefined();
+    expect(tracker.get('ses_new')).toBeDefined();
+  });
+});

+ 106 - 0
src/utils/task-session-tracker.ts

@@ -0,0 +1,106 @@
+/**
+ * TaskSessionTracker
+ *
+ * Tracks task tool sessions so that the plugin can annotate empty/interrupted
+ * results with the session's task_id, enabling the orchestrator to resume
+ * rather than spawn a fresh subagent.
+ *
+ * Lifecycle:
+ *   register()   — called when the `task` tool fires via tool.execute.after
+ *   markInterrupted() / markCompleted() — update session status
+ *   cleanup()    — called on session.deleted event
+ */
+
+import { log } from './logger';
+
+export interface TrackedTaskSession {
+  /** The session ID that can be passed as task_id to resume. */
+  sessionId: string;
+  /** Parent (orchestrator) session that spawned this task. */
+  parentSessionId: string;
+  /** Agent name if known (e.g. "fixer", "explorer"). */
+  agent: string | undefined;
+  /** Current lifecycle status of the task session. */
+  status: 'active' | 'interrupted' | 'completed';
+  /** Timestamp when the session was first registered. */
+  createdAt: number;
+}
+
+/** Sweep tracked sessions older than this. */
+const MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
+
+export class TaskSessionTracker {
+  private sessions = new Map<string, TrackedTaskSession>();
+
+  /**
+   * Register a task session for potential resumption.
+   * Called from `tool.execute.after` when the `task` tool fires.
+   */
+  register(sessionId: string, parentSessionId: string, agent?: string): void {
+    if (this.sessions.has(sessionId)) return;
+
+    this.sessions.set(sessionId, {
+      sessionId,
+      parentSessionId,
+      agent,
+      status: 'active',
+      createdAt: Date.now(),
+    });
+
+    log('[task-session-tracker] registered', {
+      sessionId,
+      parentSessionId,
+      agent,
+    });
+  }
+
+  /**
+   * Mark a session as interrupted (empty/error result, may be resumable).
+   */
+  markInterrupted(sessionId: string): void {
+    const entry = this.sessions.get(sessionId);
+    if (!entry) return;
+
+    entry.status = 'interrupted';
+    log('[task-session-tracker] marked interrupted', { sessionId });
+  }
+
+  /**
+   * Mark a session as completed normally (no resumption needed).
+   */
+  markCompleted(sessionId: string): void {
+    const entry = this.sessions.get(sessionId);
+    if (!entry) return;
+
+    entry.status = 'completed';
+    log('[task-session-tracker] marked completed', { sessionId });
+  }
+
+  /**
+   * Get a tracked session by its ID.
+   */
+  get(sessionId: string): TrackedTaskSession | undefined {
+    return this.sessions.get(sessionId);
+  }
+
+  /**
+   * Clean up tracking for a deleted session.
+   * Called from the `session.deleted` event handler.
+   */
+  cleanup(sessionId: string): void {
+    this.sessions.delete(sessionId);
+  }
+
+  /**
+   * Remove stale entries older than MAX_AGE_MS.
+   * Can be called periodically as a safety net.
+   */
+  sweepStale(): void {
+    const now = Date.now();
+    for (const [id, entry] of this.sessions) {
+      if (now - entry.createdAt > MAX_AGE_MS) {
+        this.sessions.delete(id);
+      }
+    }
+  }
+}