Browse Source

refactor(background): implement event-driven fire-and-forget tasks (#89)

* refactor(background): implement event-driven fire-and-forget tasks

- BackgroundTaskManager now launches tasks in ~1ms (fire-and-forget)
- Uses session.status events instead of deprecated session.idle
- Adds start queue with configurable concurrency limit (default: 10)
- Optional notification to parent session on completion
- Removes sync mode and legacy polling-based approach
- Simplified tool API: background_task, background_output, background_cancel

BREAKING CHANGE: Removed sync/async mode, session.create, sendPrompt,
pollSession, resolveSessionId, extractResponseText exports

* fix(PR#89): address review comments

- TmuxSessionManager: call startPolling() for fallback reliability
- Remove redundant assignments before completeTask() in cancel()
- Remove unused model parameter from LaunchOptions
- Remove unused POLL_INTERVAL_BACKGROUND_MS import
- Remove unused createSessionCreatedHandler/createSessionStatusHandler methods
- Fix misplaced comment in schema.ts

* fix(PR#89): add cancelled status checks

- completeTask: guard now checks cancelled status
- background.ts: waitForCompletion skips cancelled tasks

* fix(PR#89): address remaining review comments
- Fix activeStarts leak in startTask early return
- Add resolver cleanup to prevent memory leak
- Fix race condition in cancel() by marking cancelled first

* Update src/background/background-manager.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: “nghyane” <“hoangvananhnghia99@gmail.com”>
Co-authored-by: Alvin <alvin@cmngoal.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Hoàng Văn Anh Nghĩa 2 months ago
parent
commit
d2c326f08c

+ 95 - 0
AGENTS.md

@@ -0,0 +1,95 @@
+# Agent Coding Guidelines
+
+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.
+
+## 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"
+```
+
+## Code Style
+
+### 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
+
+## Project Structure
+
+```
+oh-my-opencode-slim/
+├── src/              # TypeScript source files
+├── dist/             # Built JavaScript and declarations
+├── node_modules/     # Dependencies
+├── biome.json        # Biome configuration
+├── tsconfig.json     # TypeScript configuration
+└── package.json      # Project manifest and scripts
+```
+
+## Key Dependencies
+
+- `@modelcontextprotocol/sdk` - MCP protocol implementation
+- `@opencode-ai/sdk` - OpenCode AI SDK
+- `zod` - Runtime validation
+- `vscode-jsonrpc` / `vscode-languageserver-protocol` - LSP support
+
+## Development Workflow
+
+1. Make code changes
+2. Run `bun run check:ci` to verify linting and formatting
+3. Run `bun run typecheck` to verify types
+4. Run `bun test` to verify tests pass
+5. Commit changes
+
+## 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`
+- Skills are located in `src/skills/` (included in package publish)

+ 421 - 129
src/background/background-manager.test.ts

@@ -12,15 +12,18 @@ function createMockContext(overrides?: {
     }>;
   };
 }) {
+  let callCount = 0;
   return {
     client: {
       session: {
-        create: mock(
-          async () =>
+        create: mock(async () => {
+          callCount++;
+          return (
             overrides?.sessionCreateResult ?? {
-              data: { id: 'test-session-id' },
-            },
-        ),
+              data: { id: `test-session-${callCount}` },
+            }
+          );
+        }),
         status: mock(
           async () => overrides?.sessionStatusResult ?? { data: {} },
         ),
@@ -36,10 +39,9 @@ function createMockContext(overrides?: {
 
 describe('BackgroundTaskManager', () => {
   describe('constructor', () => {
-    test('creates manager with tmux disabled by default', () => {
+    test('creates manager with defaults', () => {
       const ctx = createMockContext();
       const manager = new BackgroundTaskManager(ctx);
-      // Manager should be created without errors
       expect(manager).toBeDefined();
     });
 
@@ -52,14 +54,25 @@ describe('BackgroundTaskManager', () => {
       });
       expect(manager).toBeDefined();
     });
+
+    test('creates manager with background config', () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx, undefined, {
+        background: {
+          notifyOnComplete: true,
+          maxConcurrentStarts: 5,
+        },
+      });
+      expect(manager).toBeDefined();
+    });
   });
 
-  describe('launch', () => {
-    test('creates new session and task', async () => {
+  describe('launch (fire-and-forget)', () => {
+    test('returns task immediately with pending or starting status', async () => {
       const ctx = createMockContext();
       const manager = new BackgroundTaskManager(ctx);
 
-      const task = await manager.launch({
+      const task = manager.launch({
         agent: 'explorer',
         prompt: 'Find all test files',
         description: 'Test file search',
@@ -67,101 +80,297 @@ describe('BackgroundTaskManager', () => {
       });
 
       expect(task.id).toMatch(/^bg_/);
-      expect(task.sessionId).toBe('test-session-id');
+      // Task may be pending (in queue) or starting (already started)
+      expect(['pending', 'starting']).toContain(task.status);
+      expect(task.sessionId).toBeUndefined();
       expect(task.agent).toBe('explorer');
       expect(task.description).toBe('Test file search');
-      expect(task.status).toBe('running');
       expect(task.startedAt).toBeDefined();
     });
 
-    test('throws when session creation fails', async () => {
+    test('sessionId is set asynchronously when task starts', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      const task = manager.launch({
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'parent-123',
+      });
+
+      // Immediately after launch, no sessionId
+      expect(task.sessionId).toBeUndefined();
+
+      // Wait for microtask queue to process
+      await Promise.resolve();
+      await Promise.resolve();
+
+      // After background start, sessionId should be set
+      expect(task.sessionId).toBeDefined();
+      expect(task.status).toBe('running');
+    });
+
+    test('task fails when session creation fails', async () => {
       const ctx = createMockContext({ sessionCreateResult: { data: {} } });
       const manager = new BackgroundTaskManager(ctx);
 
-      await expect(
-        manager.launch({
-          agent: 'explorer',
-          prompt: 'test',
-          description: 'test',
-          parentSessionId: 'parent-123',
-        }),
-      ).rejects.toThrow('Failed to create background session');
+      const task = manager.launch({
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'parent-123',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      expect(task.status).toBe('failed');
+      expect(task.error).toBe('Failed to create background session');
     });
 
-    test('passes model to prompt when provided', async () => {
+    test('multiple launches return immediately', async () => {
       const ctx = createMockContext();
       const manager = new BackgroundTaskManager(ctx);
 
-      await manager.launch({
+      const task1 = manager.launch({
+        agent: 'explorer',
+        prompt: 'test1',
+        description: 'test1',
+        parentSessionId: 'parent-123',
+      });
+
+      const task2 = manager.launch({
+        agent: 'oracle',
+        prompt: 'test2',
+        description: 'test2',
+        parentSessionId: 'parent-123',
+      });
+
+      const task3 = manager.launch({
+        agent: 'fixer',
+        prompt: 'test3',
+        description: 'test3',
+        parentSessionId: 'parent-123',
+      });
+
+      // All return immediately with pending or starting status
+      expect(['pending', 'starting']).toContain(task1.status);
+      expect(['pending', 'starting']).toContain(task2.status);
+      expect(['pending', 'starting']).toContain(task3.status);
+    });
+  });
+
+  describe('handleSessionStatus', () => {
+    test('completes task when session becomes idle', async () => {
+      const ctx = createMockContext({
+        sessionMessagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Result text' }],
+            },
+          ],
+        },
+      });
+      const manager = new BackgroundTaskManager(ctx);
+
+      const task = manager.launch({
         agent: 'explorer',
         prompt: 'test',
         description: 'test',
         parentSessionId: 'parent-123',
-        model: 'custom/model',
       });
 
-      expect(ctx.client.session.prompt).toHaveBeenCalled();
+      // Wait for task to start
+      await Promise.resolve();
+      await Promise.resolve();
+
+      // Simulate session.idle event
+      await manager.handleSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: task.sessionId,
+          status: { type: 'idle' },
+        },
+      });
+
+      expect(task.status).toBe('completed');
+      expect(task.result).toBe('Result text');
+    });
+
+    test('ignores non-idle status', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      const task = manager.launch({
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'parent-123',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      // Simulate session.busy event
+      await manager.handleSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: task.sessionId,
+          status: { type: 'busy' },
+        },
+      });
+
+      expect(task.status).toBe('running');
+    });
+
+    test('ignores non-matching session ID', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      const task = manager.launch({
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'parent-123',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      // Simulate event for different session
+      await manager.handleSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: 'other-session-id',
+          status: { type: 'idle' },
+        },
+      });
+
+      expect(task.status).toBe('running');
     });
   });
 
   describe('getResult', () => {
-    test('returns null for unknown task', async () => {
+    test('returns null for unknown task', () => {
       const ctx = createMockContext();
       const manager = new BackgroundTaskManager(ctx);
 
-      const result = await manager.getResult('unknown-task-id');
+      const result = manager.getResult('unknown-task-id');
       expect(result).toBeNull();
     });
 
-    test('returns task immediately when not blocking', async () => {
+    test('returns task immediately (no blocking)', () => {
       const ctx = createMockContext();
       const manager = new BackgroundTaskManager(ctx);
 
-      const task = await manager.launch({
+      const task = manager.launch({
         agent: 'explorer',
         prompt: 'test',
         description: 'test',
         parentSessionId: 'parent-123',
       });
 
-      const result = await manager.getResult(task.id, false);
+      const result = manager.getResult(task.id);
       expect(result).toBeDefined();
       expect(result?.id).toBe(task.id);
     });
+  });
 
-    test('returns completed task immediately even when blocking', async () => {
+  describe('waitForCompletion', () => {
+    test('waits for task to complete', async () => {
       const ctx = createMockContext({
-        sessionStatusResult: { data: { 'test-session-id': { type: 'idle' } } },
         sessionMessagesResult: {
           data: [
             {
               info: { role: 'assistant' },
-              parts: [{ type: 'text', text: 'Result text' }],
+              parts: [{ type: 'text', text: 'Done' }],
+            },
+          ],
+        },
+      });
+      const manager = new BackgroundTaskManager(ctx);
+
+      const task = manager.launch({
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'parent-123',
+      });
+
+      // Wait for task to start
+      await Promise.resolve();
+      await Promise.resolve();
+
+      // Trigger completion via session.status event
+      await manager.handleSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: task.sessionId,
+          status: { type: 'idle' },
+        },
+      });
+
+      // Now waitForCompletion should return immediately
+      const result = await manager.waitForCompletion(task.id, 5000);
+      expect(result?.status).toBe('completed');
+      expect(result?.result).toBe('Done');
+    });
+
+    test('returns immediately if already completed', async () => {
+      const ctx = createMockContext({
+        sessionMessagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Done' }],
             },
           ],
         },
       });
       const manager = new BackgroundTaskManager(ctx);
 
-      const task = await manager.launch({
+      const task = manager.launch({
         agent: 'explorer',
         prompt: 'test',
         description: 'test',
         parentSessionId: 'parent-123',
       });
 
-      const result = await manager.getResult(task.id, true);
+      // Wait for task to start
+      await Promise.resolve();
+      await Promise.resolve();
+
+      // Trigger completion
+      await manager.handleSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: task.sessionId,
+          status: { type: 'idle' },
+        },
+      });
+
+      // Now wait should return immediately
+      const result = await manager.waitForCompletion(task.id, 5000);
       expect(result?.status).toBe('completed');
-      expect(result?.result).toBe('Result text');
+    });
+
+    test('returns null for unknown task', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      const result = await manager.waitForCompletion('unknown-task-id', 5000);
+      expect(result).toBeNull();
     });
   });
 
   describe('cancel', () => {
-    test('cancels specific running task', async () => {
+    test('cancels pending task before it starts', () => {
       const ctx = createMockContext();
       const manager = new BackgroundTaskManager(ctx);
 
-      const task = await manager.launch({
+      const task = manager.launch({
         agent: 'explorer',
         prompt: 'test',
         description: 'test',
@@ -171,9 +380,30 @@ describe('BackgroundTaskManager', () => {
       const count = manager.cancel(task.id);
       expect(count).toBe(1);
 
-      const result = await manager.getResult(task.id);
-      expect(result?.status).toBe('failed');
-      expect(result?.error).toBe('Cancelled by user');
+      const result = manager.getResult(task.id);
+      expect(result?.status).toBe('cancelled');
+    });
+
+    test('cancels running task', async () => {
+      const ctx = createMockContext();
+      const manager = new BackgroundTaskManager(ctx);
+
+      const task = manager.launch({
+        agent: 'explorer',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'parent-123',
+      });
+
+      // Wait for task to start
+      await Promise.resolve();
+      await Promise.resolve();
+
+      const count = manager.cancel(task.id);
+      expect(count).toBe(1);
+
+      const result = manager.getResult(task.id);
+      expect(result?.status).toBe('cancelled');
     });
 
     test('returns 0 when cancelling unknown task', () => {
@@ -184,24 +414,18 @@ describe('BackgroundTaskManager', () => {
       expect(count).toBe(0);
     });
 
-    test('cancels all running tasks when no ID provided', async () => {
+    test('cancels all pending/running tasks when no ID provided', () => {
       const ctx = createMockContext();
-      // Make each call return a different session ID
-      let callCount = 0;
-      ctx.client.session.create = mock(async () => {
-        callCount++;
-        return { data: { id: `session-${callCount}` } };
-      });
       const manager = new BackgroundTaskManager(ctx);
 
-      await manager.launch({
+      manager.launch({
         agent: 'explorer',
         prompt: 'test1',
         description: 'test1',
         parentSessionId: 'parent-123',
       });
 
-      await manager.launch({
+      manager.launch({
         agent: 'oracle',
         prompt: 'test2',
         description: 'test2',
@@ -214,7 +438,6 @@ describe('BackgroundTaskManager', () => {
 
     test('does not cancel already completed tasks', async () => {
       const ctx = createMockContext({
-        sessionStatusResult: { data: { 'test-session-id': { type: 'idle' } } },
         sessionMessagesResult: {
           data: [
             {
@@ -226,100 +449,169 @@ describe('BackgroundTaskManager', () => {
       });
       const manager = new BackgroundTaskManager(ctx);
 
-      const task = await manager.launch({
+      const task = manager.launch({
         agent: 'explorer',
         prompt: 'test',
         description: 'test',
         parentSessionId: 'parent-123',
       });
 
-      // Use getResult with block=true to wait for completion
-      // This triggers polling immediately rather than relying on interval
-      const result = await manager.getResult(task.id, true, 5000);
-      expect(result?.status).toBe('completed');
+      // Wait for task to start
+      await Promise.resolve();
+      await Promise.resolve();
+
+      // Trigger completion
+      await manager.handleSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: task.sessionId,
+          status: { type: 'idle' },
+        },
+      });
 
       // Now try to cancel - should fail since already completed
       const count = manager.cancel(task.id);
-      expect(count).toBe(0); // Already completed, so not cancelled
+      expect(count).toBe(0);
     });
   });
-});
 
-describe('BackgroundTask logic', () => {
-  test('extracts content from multiple types and messages', async () => {
-    const ctx = createMockContext({
-      sessionStatusResult: { data: { 'test-session-id': { type: 'idle' } } },
-      sessionMessagesResult: {
-        data: [
-          {
-            info: { role: 'assistant' },
-            parts: [
-              { type: 'reasoning', text: 'I am thinking...' },
-              { type: 'text', text: 'First part.' },
-            ],
-          },
-          {
-            info: { role: 'assistant' },
-            parts: [
-              { type: 'text', text: 'Second part.' },
-              { type: 'text', text: '' }, // Should be ignored
-            ],
-          },
-        ],
-      },
-    });
-    const manager = new BackgroundTaskManager(ctx);
-    const task = await manager.launch({
-      agent: 'test',
-      prompt: 'test',
-      description: 'test',
-      parentSessionId: 'p1',
-    });
+  describe('BackgroundTask logic', () => {
+    test('extracts content from multiple types and messages', async () => {
+      const ctx = createMockContext({
+        sessionMessagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [
+                { type: 'reasoning', text: 'I am thinking...' },
+                { type: 'text', text: 'First part.' },
+              ],
+            },
+            {
+              info: { role: 'assistant' },
+              parts: [
+                { type: 'text', text: 'Second part.' },
+                { type: 'text', text: '' }, // Should be ignored
+              ],
+            },
+          ],
+        },
+      });
+      const manager = new BackgroundTaskManager(ctx);
 
-    const result = await manager.getResult(task.id, true);
-    expect(result?.status).toBe('completed');
-    expect(result?.result).toContain('I am thinking...');
-    expect(result?.result).toContain('First part.');
-    expect(result?.result).toContain('Second part.');
-    // Check for double newline join
-    expect(result?.result).toBe(
-      'I am thinking...\n\nFirst part.\n\nSecond part.',
-    );
-  });
+      const task = manager.launch({
+        agent: 'test',
+        prompt: 'test',
+        description: 'test',
+        parentSessionId: 'p1',
+      });
 
-  test('task has completedAt timestamp on success or failure', async () => {
-    const ctx = createMockContext({
-      sessionStatusResult: { data: { 'test-session-id': { type: 'idle' } } },
-      sessionMessagesResult: {
-        data: [
-          {
-            info: { role: 'assistant' },
-            parts: [{ type: 'text', text: 'done' }],
-          },
-        ],
-      },
+      // Wait for task to start
+      await Promise.resolve();
+      await Promise.resolve();
+
+      // Trigger completion
+      await manager.handleSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: task.sessionId,
+          status: { type: 'idle' },
+        },
+      });
+
+      expect(task.status).toBe('completed');
+      expect(task.result).toContain('I am thinking...');
+      expect(task.result).toContain('First part.');
+      expect(task.result).toContain('Second part.');
+      // Check for double newline join
+      expect(task.result).toBe(
+        'I am thinking...\n\nFirst part.\n\nSecond part.',
+      );
     });
-    const manager = new BackgroundTaskManager(ctx);
-
-    // Test success timestamp
-    const task1 = await manager.launch({
-      agent: 'test',
-      prompt: 't1',
-      description: 'd1',
-      parentSessionId: 'p1',
+
+    test('task has completedAt timestamp on completion or cancellation', async () => {
+      const ctx = createMockContext({
+        sessionMessagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'done' }],
+            },
+          ],
+        },
+      });
+      const manager = new BackgroundTaskManager(ctx);
+
+      // Test completion timestamp
+      const task1 = manager.launch({
+        agent: 'test',
+        prompt: 't1',
+        description: 'd1',
+        parentSessionId: 'p1',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      await manager.handleSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: task1.sessionId,
+          status: { type: 'idle' },
+        },
+      });
+
+      expect(task1.completedAt).toBeInstanceOf(Date);
+      expect(task1.status).toBe('completed');
+
+      // Test cancellation timestamp
+      const task2 = manager.launch({
+        agent: 'test',
+        prompt: 't2',
+        description: 'd2',
+        parentSessionId: 'p2',
+      });
+
+      manager.cancel(task2.id);
+      expect(task2.completedAt).toBeInstanceOf(Date);
+      expect(task2.status).toBe('cancelled');
     });
-    await manager.getResult(task1.id, true);
-    expect(task1.completedAt).toBeInstanceOf(Date);
-
-    // Test cancellation timestamp
-    const task2 = await manager.launch({
-      agent: 'test',
-      prompt: 't2',
-      description: 'd2',
-      parentSessionId: 'p2',
+
+    test('notifyOnComplete sends notification to parent session', async () => {
+      const ctx = createMockContext({
+        sessionMessagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'done' }],
+            },
+          ],
+        },
+      });
+      const manager = new BackgroundTaskManager(ctx, undefined, {
+        background: { notifyOnComplete: true, maxConcurrentStarts: 10 },
+      });
+
+      const task = manager.launch({
+        agent: 'test',
+        prompt: 't',
+        description: 'd',
+        parentSessionId: 'parent-session',
+      });
+
+      await Promise.resolve();
+      await Promise.resolve();
+
+      await manager.handleSessionStatus({
+        type: 'session.status',
+        properties: {
+          sessionID: task.sessionId,
+          status: { type: 'idle' },
+        },
+      });
+
+      // Should have called prompt.append for notification
+      expect(ctx.client.session.prompt).toHaveBeenCalled();
     });
-    manager.cancel(task2.id);
-    expect(task2.completedAt).toBeInstanceOf(Date);
-    expect(task2.status).toBe('failed');
   });
 });

+ 346 - 164
src/background/background-manager.ts

@@ -6,15 +6,15 @@
  * the user to continue working while tasks complete asynchronously.
  *
  * Key features:
+ * - Fire-and-forget launch (returns task_id immediately)
  * - Creates isolated sessions for background work
- * - Polls task status until completion
- * - Integrates with tmux for visual feedback (when enabled)
+ * - Event-driven completion detection via session.status
+ * - Start queue with configurable concurrency limit
  * - Supports task cancellation and result retrieval
  */
 
 import type { PluginInput } from '@opencode-ai/plugin';
-import type { PluginConfig } from '../config';
-import { POLL_INTERVAL_BACKGROUND_MS, POLL_INTERVAL_SLOW_MS } from '../config';
+import type { BackgroundTaskConfig, PluginConfig } from '../config';
 import type { TmuxConfig } from '../config/schema';
 import { applyAgentVariant, resolveAgentVariant } from '../utils';
 import { log } from '../utils/logger';
@@ -38,14 +38,23 @@ type OpencodeClient = PluginInput['client'];
  */
 export interface BackgroundTask {
   id: string; // Unique task identifier (e.g., "bg_abc123")
-  sessionId: string; // OpenCode session ID where the task runs
+  sessionId?: string; // OpenCode session ID (set when starting)
   description: string; // Human-readable task description
   agent: string; // Agent name handling the task
-  status: 'pending' | 'running' | 'completed' | 'failed';
+  status:
+    | 'pending'
+    | 'starting'
+    | 'running'
+    | 'completed'
+    | 'failed'
+    | 'cancelled';
   result?: string; // Final output from the agent (when completed)
   error?: string; // Error message (when failed)
+  config: BackgroundTaskConfig; // Task configuration
+  parentSessionId: string; // Parent session ID for notifications
   startedAt: Date; // Task creation timestamp
   completedAt?: Date; // Task completion/failure timestamp
+  prompt: string; // Initial prompt
 }
 
 /**
@@ -56,7 +65,7 @@ export interface LaunchOptions {
   prompt: string; // Initial prompt to send to the agent
   description: string; // Human-readable task description
   parentSessionId: string; // Parent session ID for task hierarchy
-  model?: string; // Optional model override
+  notifyOnComplete?: boolean; // Whether to notify parent session on completion
 }
 
 function generateTaskId(): string {
@@ -65,11 +74,23 @@ function generateTaskId(): string {
 
 export class BackgroundTaskManager {
   private tasks = new Map<string, BackgroundTask>();
+  private tasksBySessionId = new Map<string, string>();
   private client: OpencodeClient;
   private directory: string;
-  private pollInterval?: ReturnType<typeof setInterval>;
   private tmuxEnabled: boolean;
   private config?: PluginConfig;
+  private backgroundConfig: BackgroundTaskConfig;
+
+  // Start queue
+  private startQueue: BackgroundTask[] = [];
+  private activeStarts = 0;
+  private maxConcurrentStarts: number;
+
+  // Completion waiting
+  private completionResolvers = new Map<
+    string,
+    (task: BackgroundTask) => void
+  >();
 
   constructor(
     ctx: PluginInput,
@@ -80,196 +101,170 @@ export class BackgroundTaskManager {
     this.directory = ctx.directory;
     this.tmuxEnabled = tmuxConfig?.enabled ?? false;
     this.config = config;
+    this.backgroundConfig = config?.background ?? {
+      notifyOnComplete: false,
+      maxConcurrentStarts: 10,
+    };
+    this.maxConcurrentStarts = this.backgroundConfig.maxConcurrentStarts;
   }
 
   /**
-   * Launch a new background task in an isolated session.
+   * Launch a new background task (fire-and-forget).
    *
-   * Creates a new session, registers the task, starts polling for completion,
-   * and sends the initial prompt to the specified agent.
+   * Phase A (sync): Creates task record and returns immediately.
+   * Phase B (async): Session creation and prompt sending happen in background.
    *
    * @param opts - Task configuration options
-   * @returns The created background task object
-   * @throws Error if session creation fails
+   * @returns The created background task with pending status
    */
-  async launch(opts: LaunchOptions): Promise<BackgroundTask> {
-    const session = await this.client.session.create({
-      body: {
-        parentID: opts.parentSessionId,
-        title: `Background: ${opts.description}`,
-      },
-      query: { directory: this.directory },
-    });
-
-    if (!session.data?.id) {
-      throw new Error('Failed to create background session');
-    }
-
+  launch(opts: LaunchOptions): BackgroundTask {
     const task: BackgroundTask = {
       id: generateTaskId(),
-      sessionId: session.data.id,
+      sessionId: undefined,
       description: opts.description,
       agent: opts.agent,
-      status: 'running',
+      status: 'pending',
       startedAt: new Date(),
+      config: {
+        notifyOnComplete:
+          opts.notifyOnComplete ?? this.backgroundConfig.notifyOnComplete,
+        maxConcurrentStarts: this.maxConcurrentStarts,
+      },
+      parentSessionId: opts.parentSessionId,
+      prompt: opts.prompt,
     };
 
     this.tasks.set(task.id, task);
-    this.startPolling();
-
-    // Give TmuxSessionManager time to spawn the pane via event hook
-    // before we send the prompt (so the TUI can receive streaming updates)
-    if (this.tmuxEnabled) {
-      await new Promise((r) => setTimeout(r, 500));
-    }
 
-    const promptQuery: Record<string, string> = {
-      directory: this.directory,
-    };
-    if (opts.model) {
-      promptQuery.model = opts.model;
-    }
+    // Queue task for background start
+    this.enqueueStart(task);
 
-    log(`[background-manager] launching task for agent="${opts.agent}"`, {
-      description: opts.description,
-    });
-    const resolvedVariant = resolveAgentVariant(this.config, opts.agent);
-    const promptBody = applyAgentVariant(resolvedVariant, {
+    log(`[background-manager] task launched: ${task.id}`, {
       agent: opts.agent,
-      tools: { background_task: false, task: false },
-      parts: [{ type: 'text' as const, text: opts.prompt }],
-    } as PromptBody) as unknown as PromptBody;
-
-    await this.client.session.prompt({
-      path: { id: session.data.id },
-      body: promptBody,
-      query: promptQuery,
+      description: opts.description,
     });
 
     return task;
   }
 
   /**
-   * Retrieve the current state of a background task.
-   *
-   * @param taskId - The task ID to retrieve
-   * @param block - If true, wait for task completion before returning
-   * @param timeout - Maximum time to wait in milliseconds (default: 2 minutes)
-   * @returns The task object, or null if not found
+   * Enqueue task for background start.
    */
-  async getResult(
-    taskId: string,
-    block = false,
-    timeout = 120000,
-  ): Promise<BackgroundTask | null> {
-    const task = this.tasks.get(taskId);
-    if (!task) return null;
-
-    if (!block || task.status === 'completed' || task.status === 'failed') {
-      return task;
-    }
+  private enqueueStart(task: BackgroundTask): void {
+    this.startQueue.push(task);
+    this.processQueue();
+  }
 
-    const deadline = Date.now() + timeout;
-    while (Date.now() < deadline) {
-      await this.pollTask(task);
-      if (
-        (task.status as string) === 'completed' ||
-        (task.status as string) === 'failed'
-      ) {
-        return task;
-      }
-      await new Promise((r) => setTimeout(r, POLL_INTERVAL_SLOW_MS));
+  /**
+   * Process start queue with concurrency limit.
+   */
+  private processQueue(): void {
+    while (
+      this.activeStarts < this.maxConcurrentStarts &&
+      this.startQueue.length > 0
+    ) {
+      const task = this.startQueue.shift()!;
+      this.startTask(task);
     }
-
-    return task;
   }
 
   /**
-   * Cancel one or all running background tasks.
-   *
-   * @param taskId - Optional task ID to cancel. If omitted, cancels all running tasks.
-   * @returns Number of tasks cancelled
+   * Start a task in the background (Phase B).
    */
-  cancel(taskId?: string): number {
-    if (taskId) {
-      const task = this.tasks.get(taskId);
-      if (task && task.status === 'running') {
-        task.status = 'failed';
-        task.error = 'Cancelled by user';
-        task.completedAt = new Date();
-        return 1;
-      }
-      return 0;
+  private async startTask(task: BackgroundTask): Promise<void> {
+    task.status = 'starting';
+    this.activeStarts++;
+
+    // Check if cancelled after incrementing activeStarts (to catch race)
+    // Use type assertion since cancel() can change status during race condition
+    if ((task as BackgroundTask & { status: string }).status === 'cancelled') {
+      this.completeTask(task, 'cancelled', 'Task cancelled before start');
+      return;
     }
 
-    let count = 0;
-    for (const task of this.tasks.values()) {
-      if (task.status === 'running') {
-        task.status = 'failed';
-        task.error = 'Cancelled by user';
-        task.completedAt = new Date();
-        count++;
+    try {
+      // Create session
+      const session = await this.client.session.create({
+        body: {
+          parentID: task.parentSessionId,
+          title: `Background: ${task.description}`,
+        },
+        query: { directory: this.directory },
+      });
+
+      if (!session.data?.id) {
+        throw new Error('Failed to create background session');
       }
+
+      task.sessionId = session.data.id;
+      this.tasksBySessionId.set(session.data.id, task.id);
+      task.status = 'running';
+
+      // Give TmuxSessionManager time to spawn the pane
+      if (this.tmuxEnabled) {
+        await new Promise((r) => setTimeout(r, 500));
+      }
+
+      // Send prompt
+      const promptQuery: Record<string, string> = { directory: this.directory };
+      const resolvedVariant = resolveAgentVariant(this.config, task.agent);
+      const promptBody = applyAgentVariant(resolvedVariant, {
+        agent: task.agent,
+        tools: { background_task: false, task: false },
+        parts: [{ type: 'text' as const, text: task.prompt }],
+      } as PromptBody) as unknown as PromptBody;
+
+      await this.client.session.prompt({
+        path: { id: session.data.id },
+        body: promptBody,
+        query: promptQuery,
+      });
+
+      log(`[background-manager] task started: ${task.id}`, {
+        sessionId: session.data.id,
+      });
+    } catch (error) {
+      const errorMessage =
+        error instanceof Error ? error.message : String(error);
+      this.completeTask(task, 'failed', errorMessage);
+    } finally {
+      this.activeStarts--;
+      this.processQueue();
     }
-    return count;
   }
 
   /**
-   * Start the polling interval to check task status.
-   * Only starts if not already polling.
+   * Handle session.status events for completion detection.
+   * Uses session.status instead of deprecated session.idle.
    */
-  private startPolling() {
-    if (this.pollInterval) return;
-    this.pollInterval = setInterval(
-      () => this.pollAllTasks(),
-      POLL_INTERVAL_BACKGROUND_MS,
-    );
-  }
+  async handleSessionStatus(event: {
+    type: string;
+    properties?: { sessionID?: string; status?: { type: string } };
+  }): Promise<void> {
+    if (event.type !== 'session.status') return;
 
-  /**
-   * Poll all running tasks for status updates.
-   * Stops polling automatically when no tasks are running.
-   */
-  private async pollAllTasks() {
-    const runningTasks = [...this.tasks.values()].filter(
-      (t) => t.status === 'running',
-    );
-    if (runningTasks.length === 0 && this.pollInterval) {
-      clearInterval(this.pollInterval);
-      this.pollInterval = undefined;
-      return;
-    }
+    const sessionId = event.properties?.sessionID;
+    if (!sessionId) return;
+
+    const taskId = this.tasksBySessionId.get(sessionId);
+    if (!taskId) return;
+
+    const task = this.tasks.get(taskId);
+    if (!task || task.status !== 'running') return;
 
-    for (const task of runningTasks) {
-      await this.pollTask(task);
+    // Check if session is idle (completed)
+    if (event.properties?.status?.type === 'idle') {
+      await this.extractAndCompleteTask(task);
     }
   }
 
   /**
-   * Poll a single task for completion.
-   *
-   * Checks if the session is idle, then retrieves assistant messages.
-   * Updates task status to completed/failed based on the response.
+   * Extract task result and mark complete.
    */
-  private async pollTask(task: BackgroundTask) {
-    try {
-      // Check session status first
-      const statusResult = await this.client.session.status();
-      const allStatuses = (statusResult.data ?? {}) as Record<
-        string,
-        { type: string }
-      >;
-      const sessionStatus = allStatuses[task.sessionId];
-
-      // If session is still active (not idle), don't try to read messages yet
-      if (
-        task.status !== 'running' ||
-        (sessionStatus && sessionStatus.type !== 'idle')
-      ) {
-        return;
-      }
+  private async extractAndCompleteTask(task: BackgroundTask): Promise<void> {
+    if (!task.sessionId) return;
 
-      // Get messages using correct API
+    try {
       const messagesResult = await this.client.session.messages({
         path: { id: task.sessionId },
       });
@@ -281,11 +276,6 @@ export class BackgroundTaskManager {
         (m) => m.info?.role === 'assistant',
       );
 
-      if (assistantMessages.length === 0) {
-        return; // No response yet
-      }
-
-      // Extract text from all assistant messages
       const extractedContent: string[] = [];
       for (const message of assistantMessages) {
         for (const part of message.parts ?? []) {
@@ -301,17 +291,209 @@ export class BackgroundTaskManager {
       const responseText = extractedContent
         .filter((t) => t.length > 0)
         .join('\n\n');
+
       if (responseText) {
-        task.result = responseText;
-        task.status = 'completed';
-        task.completedAt = new Date();
-        // Pane closing is handled by TmuxSessionManager via polling
+        this.completeTask(task, 'completed', responseText);
+      } else {
+        this.completeTask(task, 'completed', '(No output)');
       }
     } catch (error) {
-      task.status = 'failed';
-      task.error = error instanceof Error ? error.message : String(error);
-      task.completedAt = new Date();
-      // Pane closing is handled by TmuxSessionManager via polling
+      this.completeTask(
+        task,
+        'failed',
+        error instanceof Error ? error.message : String(error),
+      );
+    }
+  }
+
+  /**
+   * Complete a task and notify waiting callers.
+   */
+  private completeTask(
+    task: BackgroundTask,
+    status: 'completed' | 'failed' | 'cancelled',
+    resultOrError: string,
+  ): void {
+    // Don't check for 'cancelled' here - cancel() may set status before calling
+    if (task.status === 'completed' || task.status === 'failed') {
+      return; // Already completed
     }
+
+    task.status = status;
+    task.completedAt = new Date();
+
+    if (status === 'completed') {
+      task.result = resultOrError;
+    } else {
+      task.error = resultOrError;
+    }
+
+    // Clean up tasksBySessionId map to prevent memory leak
+    if (task.sessionId) {
+      this.tasksBySessionId.delete(task.sessionId);
+    }
+
+    // Send notification if configured
+    if (task.config.notifyOnComplete && task.parentSessionId) {
+      this.sendCompletionNotification(task).catch((err) => {
+        log(`[background-manager] notification failed: ${err}`);
+      });
+    }
+
+    // Resolve waiting callers
+    const resolver = this.completionResolvers.get(task.id);
+    if (resolver) {
+      resolver(task);
+      this.completionResolvers.delete(task.id);
+    }
+
+    log(`[background-manager] task ${status}: ${task.id}`, {
+      description: task.description,
+    });
+  }
+
+  /**
+   * Send completion notification to parent session.
+   */
+  private async sendCompletionNotification(
+    task: BackgroundTask,
+  ): Promise<void> {
+    const message =
+      task.status === 'completed'
+        ? `[Background task "${task.description}" completed]`
+        : `[Background task "${task.description}" failed: ${task.error}]`;
+
+    await this.client.session.prompt({
+      path: { id: task.parentSessionId },
+      body: {
+        parts: [{ type: 'text' as const, text: message }],
+      },
+    });
+  }
+
+  /**
+   * Retrieve the current state of a background task.
+   *
+   * @param taskId - The task ID to retrieve
+   * @returns The task object, or null if not found
+   */
+  getResult(taskId: string): BackgroundTask | null {
+    return this.tasks.get(taskId) ?? null;
+  }
+
+  /**
+   * Wait for a task to complete.
+   *
+   * @param taskId - The task ID to wait for
+   * @param timeout - Maximum time to wait in milliseconds (0 = no timeout)
+   * @returns The completed task, or null if not found/timeout
+   */
+  async waitForCompletion(
+    taskId: string,
+    timeout = 0,
+  ): Promise<BackgroundTask | null> {
+    const task = this.tasks.get(taskId);
+    if (!task) return null;
+
+    if (
+      task.status === 'completed' ||
+      task.status === 'failed' ||
+      task.status === 'cancelled'
+    ) {
+      return task;
+    }
+
+    return new Promise((resolve) => {
+      const resolver = (t: BackgroundTask) => resolve(t);
+      this.completionResolvers.set(taskId, resolver);
+
+      if (timeout > 0) {
+        setTimeout(() => {
+          this.completionResolvers.delete(taskId);
+          resolve(this.tasks.get(taskId) ?? null);
+        }, timeout);
+      }
+    });
+  }
+
+  /**
+   * Cancel one or all running background tasks.
+   *
+   * @param taskId - Optional task ID to cancel. If omitted, cancels all pending/running tasks.
+   * @returns Number of tasks cancelled
+   */
+  cancel(taskId?: string): number {
+    if (taskId) {
+      const task = this.tasks.get(taskId);
+      if (
+        task &&
+        (task.status === 'pending' ||
+          task.status === 'starting' ||
+          task.status === 'running')
+      ) {
+        // Clean up any waiting resolver
+        this.completionResolvers.delete(taskId);
+
+        // Check if in start queue (must check before marking cancelled)
+        const inStartQueue = task.status === 'pending';
+
+        // Mark as cancelled FIRST to prevent race with startTask
+        // Use type assertion since we're deliberately changing status before completeTask
+        (task as BackgroundTask & { status: string }).status = 'cancelled';
+
+        // Remove from start queue if pending
+        if (inStartQueue) {
+          const idx = this.startQueue.findIndex((t) => t.id === taskId);
+          if (idx >= 0) {
+            this.startQueue.splice(idx, 1);
+          }
+        }
+
+        this.completeTask(task, 'cancelled', 'Cancelled by user');
+        return 1;
+      }
+      return 0;
+    }
+
+    let count = 0;
+    for (const task of this.tasks.values()) {
+      if (
+        task.status === 'pending' ||
+        task.status === 'starting' ||
+        task.status === 'running'
+      ) {
+        // Clean up any waiting resolver
+        this.completionResolvers.delete(task.id);
+
+        // Check if in start queue (must check before marking cancelled)
+        const inStartQueue = task.status === 'pending';
+
+        // Mark as cancelled FIRST to prevent race with startTask
+        // Use type assertion since we're deliberately changing status before completeTask
+        (task as BackgroundTask & { status: string }).status = 'cancelled';
+
+        // Remove from start queue if pending
+        if (inStartQueue) {
+          const idx = this.startQueue.findIndex((t) => t.id === task.id);
+          if (idx >= 0) {
+            this.startQueue.splice(idx, 1);
+          }
+        }
+
+        this.completeTask(task, 'cancelled', 'Cancelled by user');
+        count++;
+      }
+    }
+    return count;
+  }
+
+  /**
+   * Clean up all tasks.
+   */
+  cleanup(): void {
+    this.startQueue = [];
+    this.completionResolvers.clear();
+    this.tasks.clear();
+    this.tasksBySessionId.clear();
   }
 }

+ 35 - 20
src/background/tmux-session-manager.ts

@@ -17,19 +17,24 @@ interface TrackedSession {
 }
 
 /**
- * Event shape for session creation hooks
+ * Event shape for session events
  */
-interface SessionCreatedEvent {
+interface SessionEvent {
   type: string;
-  properties?: { info?: { id?: string; parentID?: string; title?: string } };
+  properties?: {
+    info?: { id?: string; parentID?: string; title?: string };
+    sessionID?: string;
+    status?: { type: string };
+  };
 }
 
 const SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
 const SESSION_MISSING_GRACE_MS = POLL_INTERVAL_BACKGROUND_MS * 3;
 
 /**
- * TmuxSessionManager tracks child sessions (created by OpenCode's Task tool)
- * and spawns/closes tmux panes for them.
+ * TmuxSessionManager tracks child sessions and spawns/closes tmux panes for them.
+ *
+ * Uses session.status events for completion detection instead of polling.
  */
 export class TmuxSessionManager {
   private client: OpencodeClient;
@@ -58,10 +63,7 @@ export class TmuxSessionManager {
    * Handle session.created events.
    * Spawns a tmux pane for child sessions (those with parentID).
    */
-  async onSessionCreated(event: {
-    type: string;
-    properties?: { info?: { id?: string; parentID?: string; title?: string } };
-  }): Promise<void> {
+  async onSessionCreated(event: SessionEvent): Promise<void> {
     if (!this.enabled) return;
     if (event.type !== 'session.created') return;
 
@@ -115,10 +117,30 @@ export class TmuxSessionManager {
         paneId: paneResult.paneId,
       });
 
+      // Start polling for fallback reliability
       this.startPolling();
     }
   }
 
+  /**
+   * Handle session.status events for completion detection.
+   * Uses session.status instead of deprecated session.idle.
+   *
+   * When a session becomes idle (completed), close its pane.
+   */
+  async onSessionStatus(event: SessionEvent): Promise<void> {
+    if (!this.enabled) return;
+    if (event.type !== 'session.status') return;
+
+    const sessionId = event.properties?.sessionID;
+    if (!sessionId) return;
+
+    // Check if session is idle (completed)
+    if (event.properties?.status?.type === 'idle') {
+      await this.closeSession(sessionId);
+    }
+  }
+
   private startPolling(): void {
     if (this.pollInterval) return;
 
@@ -137,6 +159,10 @@ export class TmuxSessionManager {
     }
   }
 
+  /**
+   * Poll sessions for status updates (fallback for reliability).
+   * Also handles timeout and missing session detection.
+   */
   private async pollSessions(): Promise<void> {
     if (this.sessions.size === 0) {
       this.stopPolling();
@@ -204,17 +230,6 @@ export class TmuxSessionManager {
   }
 
   /**
-   * Create the event handler for the plugin's event hook.
-   */
-  createEventHandler(): (input: {
-    event: { type: string; properties?: unknown };
-  }) => Promise<void> {
-    return async (input) => {
-      await this.onSessionCreated(input.event as SessionCreatedEvent);
-    };
-  }
-
-  /**
    * Clean up all tracked sessions.
    */
   async cleanup(): Promise<void> {

+ 9 - 0
src/config/schema.ts

@@ -39,6 +39,14 @@ export type Preset = z.infer<typeof PresetSchema>;
 export const McpNameSchema = z.enum(['websearch', 'context7', 'grep_app']);
 export type McpName = z.infer<typeof McpNameSchema>;
 
+// Background task configuration
+export const BackgroundTaskConfigSchema = z.object({
+  notifyOnComplete: z.boolean().default(false),
+  maxConcurrentStarts: z.number().min(1).max(50).default(10),
+});
+
+export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>;
+
 // Main plugin config
 export const PluginConfigSchema = z.object({
   preset: z.string().optional(),
@@ -46,6 +54,7 @@ export const PluginConfigSchema = z.object({
   agents: z.record(z.string(), AgentOverrideConfigSchema).optional(),
   disabled_mcps: z.array(z.string()).optional(),
   tmux: TmuxConfigSchema.optional(),
+  background: BackgroundTaskConfigSchema.optional(),
 });
 
 export type PluginConfig = z.infer<typeof PluginConfigSchema>;

+ 16 - 0
src/index.ts

@@ -165,6 +165,22 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
           };
         },
       );
+
+      // Handle session.status events for:
+      // 1. BackgroundTaskManager: completion detection
+      // 2. TmuxSessionManager: pane cleanup
+      await backgroundManager.handleSessionStatus(
+        input.event as {
+          type: string;
+          properties?: { sessionID?: string; status?: { type: string } };
+        },
+      );
+      await tmuxSessionManager.onSessionStatus(
+        input.event as {
+          type: string;
+          properties?: { sessionID?: string; status?: { type: string } };
+        },
+      );
     },
 
     // Inject phase reminder before sending to API (doesn't show in UI)

+ 0 - 591
src/tools/background.test.ts

@@ -1,591 +0,0 @@
-import { beforeEach, describe, expect, mock, spyOn, test } from 'bun:test';
-import type { PluginInput } from '@opencode-ai/plugin';
-import type { BackgroundTaskManager } from '../background/background-manager';
-import { MAX_POLL_TIME_MS, STABLE_POLLS_THRESHOLD } from '../config';
-import {
-  createBackgroundTools,
-  createSession,
-  extractResponseText,
-  pollSession,
-  resolveSessionId,
-  sendPrompt,
-} from './background.ts';
-
-// Mock the PluginInput context
-function createMockContext(overrides: any = {}) {
-  return {
-    client: {
-      session: {
-        create: mock(async () => ({ data: { id: 'new-session-id' } })),
-        get: mock(async () => ({
-          data: { id: 'existing-session-id', directory: '/parent/dir' },
-        })),
-        status: mock(async () => ({
-          data: { 'new-session-id': { type: 'idle' } },
-        })),
-        messages: mock(async () => ({ data: [] })),
-        prompt: mock(async () => ({})),
-      },
-    },
-    directory: '/current/dir',
-    ...overrides,
-  } as unknown as PluginInput;
-}
-
-// Mock BackgroundTaskManager
-function createMockManager() {
-  const tasks = new Map<string, any>();
-  return {
-    launch: mock(async (opts: any) => {
-      const task = {
-        id: 'bg_123',
-        agent: opts.agent,
-        prompt: opts.prompt,
-        description: opts.description,
-        status: 'running',
-        startedAt: new Date(),
-      };
-      tasks.set(task.id, task);
-      return task;
-    }),
-    getResult: mock(async (id: string, _block?: boolean, _timeout?: number) => {
-      return tasks.get(id) || null;
-    }),
-    cancel: mock((id?: string) => {
-      if (id) {
-        if (tasks.has(id)) {
-          tasks.delete(id);
-          return 1;
-        }
-        return 0;
-      }
-      const count = tasks.size;
-      tasks.clear();
-      return count;
-    }),
-  } as unknown as BackgroundTaskManager;
-}
-
-describe('Background Tools', () => {
-  let ctx: PluginInput;
-  let manager: BackgroundTaskManager;
-  let tools: any;
-
-  beforeEach(() => {
-    ctx = createMockContext();
-    manager = createMockManager();
-    tools = createBackgroundTools(ctx, manager);
-  });
-
-  describe('background_task', () => {
-    test('launches a background task in async mode', async () => {
-      const result = await tools.background_task.execute(
-        {
-          agent: 'explorer',
-          prompt: 'find files',
-          description: 'finding files',
-          sync: false,
-        },
-        { sessionID: 'parent-session-id' },
-      );
-
-      expect(manager.launch).toHaveBeenCalledWith({
-        agent: 'explorer',
-        prompt: 'find files',
-        description: 'finding files',
-        parentSessionId: 'parent-session-id',
-      });
-      expect(result).toContain('Background task launched');
-      expect(result).toContain('Task ID: bg_123');
-    });
-
-    test('executes a task in sync mode', async () => {
-      // Setup mock responses for sync execution
-      (ctx.client.session.messages as any).mockImplementation(async () => ({
-        data: [
-          {
-            info: { role: 'assistant' },
-            parts: [{ type: 'text', text: 'Task result' }],
-          },
-        ],
-      }));
-
-      const result = await tools.background_task.execute(
-        {
-          agent: 'explorer',
-          prompt: 'find files',
-          description: 'finding files',
-          sync: true,
-        },
-        { sessionID: 'parent-session-id', abort: new AbortController().signal },
-      );
-
-      expect(ctx.client.session.create).toHaveBeenCalled();
-      expect(ctx.client.session.prompt).toHaveBeenCalled();
-      expect(result).toContain('Task result');
-      expect(result).toContain('session_id: new-session-id');
-    });
-
-    test('returns error message if session resolution fails', async () => {
-      (ctx.client.session.get as any).mockResolvedValue({
-        error: 'Get failed',
-      });
-      const result = await tools.background_task.execute(
-        {
-          agent: 'explorer',
-          prompt: 'test',
-          description: 'test',
-          sync: true,
-          session_id: 'invalid',
-        },
-        { sessionID: 'p1' } as any,
-      );
-      expect(result).toContain('Error: Failed to get session: Get failed');
-    });
-
-    test('returns error message if prompt sending fails', async () => {
-      (ctx.client.session.prompt as any).mockRejectedValue(
-        new Error('Prompt failed'),
-      );
-      const result = await tools.background_task.execute(
-        { agent: 'explorer', prompt: 'test', description: 'test', sync: true },
-        { sessionID: 'p1', abort: new AbortController().signal } as any,
-      );
-      expect(result).toContain('Error: Failed to send prompt: Prompt failed');
-      expect(result).toContain('<task_metadata>');
-    });
-
-    test('handles task abort in sync mode', async () => {
-      (ctx.client.session.status as any).mockImplementation(async () => {
-        return { data: { 'new-session-id': { type: 'busy' } } };
-      });
-      const controller = new AbortController();
-
-      // Trigger abort after a short delay
-      setTimeout(() => controller.abort(), 100);
-
-      const result = await tools.background_task.execute(
-        { agent: 'explorer', prompt: 'test', description: 'test', sync: true },
-        { sessionID: 'p1', abort: controller.signal } as any,
-      );
-      expect(result).toContain('Task aborted.');
-    });
-
-    test('handles timeout in sync mode', async () => {
-      // Mock pollSession to return timeout
-      // We can't easily mock pollSession if we are testing through background_task.execute
-      // because it's an internal function.
-      // But since we exported it, we could try to mock it if we use a different approach,
-      // or just mock the dependencies of pollSession to force a timeout.
-
-      // Actually, we can just mock Date.now inside the test.
-      const originalNow = Date.now;
-      let calls = 0;
-      Date.now = () => {
-        calls++;
-        if (calls > 5) return originalNow() + MAX_POLL_TIME_MS + 1000;
-        return originalNow();
-      };
-
-      try {
-        const result = await tools.background_task.execute(
-          {
-            agent: 'explorer',
-            prompt: 'test',
-            description: 'test',
-            sync: true,
-          },
-          { sessionID: 'p1', abort: new AbortController().signal } as any,
-        );
-        expect(result).toContain('Error: Agent timed out');
-      } finally {
-        Date.now = originalNow;
-      }
-    });
-
-    test('returns error if pollSession fails', async () => {
-      // Force pollSession to return error by mocking status to fail
-      (ctx.client.session.status as any).mockResolvedValue({
-        error: 'Poll failed',
-      });
-      const result = await tools.background_task.execute(
-        { agent: 'explorer', prompt: 'test', description: 'test', sync: true },
-        { sessionID: 'p1', abort: new AbortController().signal } as any,
-      );
-      expect(result).toContain(
-        'Error: Failed to get session status: Poll failed',
-      );
-    });
-
-    test('returns error if messages retrieval fails after polling', async () => {
-      (ctx.client.session.messages as any).mockResolvedValue({
-        error: 'Messages failed',
-      });
-      // First few calls to status/messages in pollSession need to succeed
-      let calls = 0;
-      (ctx.client.session.messages as any).mockImplementation(async () => {
-        calls++;
-        if (calls <= STABLE_POLLS_THRESHOLD + 1) return { data: [{}] }; // Stable count for polling
-        return { error: 'Messages failed' }; // Fail after polling
-      });
-
-      const result = await tools.background_task.execute(
-        { agent: 'explorer', prompt: 'test', description: 'test', sync: true },
-        { sessionID: 'p1', abort: new AbortController().signal } as any,
-      );
-      expect(result).toContain(
-        'Error: Failed to get messages: Messages failed',
-      );
-    });
-
-    test('returns error if no response text extracted', async () => {
-      // Return only user messages so extractResponseText returns empty
-      (ctx.client.session.messages as any).mockResolvedValue({
-        data: [
-          { info: { role: 'user' }, parts: [{ type: 'text', text: 'hi' }] },
-        ],
-      });
-      const result = await tools.background_task.execute(
-        { agent: 'explorer', prompt: 'test', description: 'test', sync: true },
-        { sessionID: 'p1', abort: new AbortController().signal } as any,
-      );
-      expect(result).toContain('Error: No response from agent.');
-    });
-
-    test('throws error if sessionID is missing in toolContext', async () => {
-      await expect(
-        tools.background_task.execute(
-          { agent: 'explorer', prompt: 'test', description: 'test' },
-          {} as any,
-        ),
-      ).rejects.toThrow('Invalid toolContext: missing sessionID');
-    });
-  });
-
-  describe('background_output', () => {
-    test('returns task output', async () => {
-      const task = {
-        id: 'bg_123',
-        description: 'test task',
-        status: 'completed',
-        startedAt: new Date(Date.now() - 5000),
-        completedAt: new Date(),
-        result: 'Success!',
-      };
-      (manager.getResult as any).mockResolvedValue(task);
-
-      const result = await tools.background_output.execute({
-        task_id: 'bg_123',
-      });
-
-      expect(result).toContain('Task: bg_123');
-      expect(result).toContain('Status: completed');
-      expect(result).toContain('Success!');
-    });
-
-    test('returns error if task not found', async () => {
-      (manager.getResult as any).mockResolvedValue(null);
-      const result = await tools.background_output.execute({
-        task_id: 'non-existent',
-      });
-      expect(result).toBe('Task not found: non-existent');
-    });
-
-    test('shows running status if not completed', async () => {
-      const task = {
-        id: 'bg_123',
-        description: 'test task',
-        status: 'running',
-        startedAt: new Date(),
-      };
-      (manager.getResult as any).mockResolvedValue(task);
-
-      const result = await tools.background_output.execute({
-        task_id: 'bg_123',
-      });
-      expect(result).toContain('Status: running');
-      expect(result).toContain('(Task still running)');
-    });
-
-    test('shows error if task failed', async () => {
-      const task = {
-        id: 'bg_123',
-        description: 'test task',
-        status: 'failed',
-        startedAt: new Date(),
-        error: 'Something went wrong',
-      };
-      (manager.getResult as any).mockResolvedValue(task);
-
-      const result = await tools.background_output.execute({
-        task_id: 'bg_123',
-      });
-      expect(result).toContain('Status: failed');
-      expect(result).toContain('Error: Something went wrong');
-    });
-  });
-
-  describe('background_cancel', () => {
-    test('cancels all tasks', async () => {
-      (manager.cancel as any).mockReturnValue(5);
-      const result = await tools.background_cancel.execute({ all: true });
-      expect(result).toBe('Cancelled 5 running task(s).');
-      expect(manager.cancel).toHaveBeenCalledWith();
-    });
-
-    test('cancels specific task', async () => {
-      (manager.cancel as any).mockReturnValue(1);
-      const result = await tools.background_cancel.execute({
-        task_id: 'bg_123',
-      });
-      expect(result).toBe('Cancelled task bg_123.');
-      expect(manager.cancel).toHaveBeenCalledWith('bg_123');
-    });
-
-    test('returns not found for specific task', async () => {
-      (manager.cancel as any).mockReturnValue(0);
-      const result = await tools.background_cancel.execute({
-        task_id: 'bg_123',
-      });
-      expect(result).toBe('Task bg_123 not found or not running.');
-    });
-
-    test('requires task_id or all', async () => {
-      const result = await tools.background_cancel.execute({});
-      expect(result).toBe('Specify task_id or use all=true.');
-    });
-  });
-
-  describe('resolveSessionId', () => {
-    test('validates and returns existing session ID', async () => {
-      const result = await resolveSessionId(
-        ctx,
-        { sessionID: 'p1' } as any,
-        'desc',
-        'agent',
-        undefined,
-        'existing-id',
-      );
-      expect(ctx.client.session.get).toHaveBeenCalledWith({
-        path: { id: 'existing-id' },
-      });
-      expect(result.sessionID).toBe('existing-id');
-    });
-
-    test('returns error if existing session not found', async () => {
-      (ctx.client.session.get as any).mockResolvedValue({ error: 'Not found' });
-      const result = await resolveSessionId(
-        ctx,
-        { sessionID: 'p1' } as any,
-        'desc',
-        'agent',
-        undefined,
-        'invalid-id',
-      );
-      expect(result.error).toContain('Failed to get session');
-    });
-
-    test('creates new session if no existing ID provided', async () => {
-      const result = await resolveSessionId(
-        ctx,
-        { sessionID: 'p1' } as any,
-        'desc',
-        'agent',
-      );
-      expect(ctx.client.session.create).toHaveBeenCalled();
-      expect(result.sessionID).toBe('new-session-id');
-    });
-  });
-
-  describe('createSession', () => {
-    test('inherits parent directory', async () => {
-      (ctx.client.session.get as any).mockResolvedValue({
-        data: { directory: '/inherited/dir' },
-      });
-      const result = await createSession(
-        ctx,
-        { sessionID: 'parent-id' } as any,
-        'desc',
-        'agent',
-      );
-
-      expect(ctx.client.session.create).toHaveBeenCalledWith(
-        expect.objectContaining({
-          query: { directory: '/inherited/dir' },
-        }),
-      );
-      expect(result.sessionID).toBe('new-session-id');
-    });
-
-    test('uses default directory if parent lookup fails', async () => {
-      (ctx.client.session.get as any).mockRejectedValue(new Error('Fail'));
-      const _result = await createSession(
-        ctx,
-        { sessionID: 'parent-id' } as any,
-        'desc',
-        'agent',
-      );
-
-      expect(ctx.client.session.create).toHaveBeenCalledWith(
-        expect.objectContaining({
-          query: { directory: '/current/dir' },
-        }),
-      );
-    });
-
-    test('respects tmux enabled delay', async () => {
-      const setTimeoutSpy = spyOn(global, 'setTimeout');
-      await createSession(ctx, { sessionID: 'p1' } as any, 'desc', 'agent', {
-        enabled: true,
-      } as any);
-      expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 500);
-    });
-  });
-
-  describe('sendPrompt', () => {
-    test('sends prompt with variant resolution', async () => {
-      const pluginConfig = {
-        agents: {
-          agent: { variant: 'pro' },
-        },
-      } as any;
-      const result = await sendPrompt(
-        ctx,
-        's1',
-        'my prompt',
-        'agent',
-        pluginConfig,
-      );
-      expect(ctx.client.session.prompt).toHaveBeenCalledWith(
-        expect.objectContaining({
-          body: expect.objectContaining({
-            agent: 'agent',
-            variant: 'pro',
-          }),
-        }),
-      );
-      expect(result.error).toBeUndefined();
-    });
-
-    test('handles prompt errors', async () => {
-      (ctx.client.session.prompt as any).mockRejectedValue(
-        new Error('Prompt failed'),
-      );
-      const result = await sendPrompt(ctx, 's1', 'prompt', 'agent');
-      expect(result.error).toContain('Failed to send prompt: Prompt failed');
-    });
-  });
-
-  describe('pollSession', () => {
-    test('completes when message count is stable', async () => {
-      let _calls = 0;
-      (ctx.client.session.status as any).mockResolvedValue({
-        data: { s1: { type: 'idle' } },
-      });
-      (ctx.client.session.messages as any).mockImplementation(async () => {
-        _calls++;
-        // First 2 calls return 1 message, next calls return 1 message (stable)
-        return { data: new Array(1).fill({}) };
-      });
-
-      const result = await pollSession(ctx, 's1', new AbortController().signal);
-      expect(result.error).toBeUndefined();
-      expect(result.timeout).toBeUndefined();
-    });
-
-    test('resets stability when status is not idle', async () => {
-      let statusCalls = 0;
-      (ctx.client.session.status as any).mockImplementation(async () => {
-        statusCalls++;
-        return { data: { s1: { type: statusCalls === 1 ? 'busy' : 'idle' } } };
-      });
-      (ctx.client.session.messages as any).mockResolvedValue({
-        data: [{}, {}],
-      });
-
-      // This will take a few more polls because of the busy status
-      const result = await pollSession(ctx, 's1', new AbortController().signal);
-      expect(result.error).toBeUndefined();
-    });
-
-    test('handles abort signal', async () => {
-      const controller = new AbortController();
-      controller.abort();
-      const result = await pollSession(ctx, 's1', controller.signal);
-      expect(result.aborted).toBe(true);
-    });
-
-    test('handles error getting status', async () => {
-      (ctx.client.session.status as any).mockResolvedValue({
-        error: 'Status failed',
-      });
-      const result = await pollSession(ctx, 's1', new AbortController().signal);
-      expect(result.error).toContain(
-        'Failed to get session status: Status failed',
-      );
-    });
-
-    test('handles error getting messages', async () => {
-      (ctx.client.session.status as any).mockResolvedValue({
-        data: { s1: { type: 'idle' } },
-      });
-      (ctx.client.session.messages as any).mockResolvedValue({
-        error: 'Messages failed',
-      });
-      const result = await pollSession(ctx, 's1', new AbortController().signal);
-      expect(result.error).toContain(
-        'Failed to check messages: Messages failed',
-      );
-    });
-
-    test('times out', async () => {
-      // Mock Date.now to simulate timeout
-      const originalNow = Date.now;
-      let now = 1000;
-      Date.now = () => {
-        now += MAX_POLL_TIME_MS + 1000;
-        return now;
-      };
-
-      try {
-        const result = await pollSession(
-          ctx,
-          's1',
-          new AbortController().signal,
-        );
-        expect(result.timeout).toBe(true);
-      } finally {
-        Date.now = originalNow;
-      }
-    });
-  });
-
-  describe('extractResponseText', () => {
-    test('filters assistant messages and extracts content', () => {
-      const messages = [
-        { info: { role: 'user' }, parts: [{ type: 'text', text: 'hi' }] },
-        {
-          info: { role: 'assistant' },
-          parts: [
-            { type: 'reasoning', text: 'thought' },
-            { type: 'text', text: 'hello' },
-          ],
-        },
-        {
-          info: { role: 'assistant' },
-          parts: [
-            { type: 'text', text: 'world' },
-            { type: 'text', text: '' },
-          ],
-        },
-      ];
-      const result = extractResponseText(messages as any);
-      expect(result).toBe('thought\n\nhello\n\nworld');
-    });
-
-    test('returns empty string if no assistant messages', () => {
-      const result = extractResponseText([{ info: { role: 'user' } }] as any);
-      expect(result).toBe('');
-    });
-  });
-});

+ 39 - 361
src/tools/background.ts

@@ -5,35 +5,18 @@ import {
 } from '@opencode-ai/plugin';
 import type { BackgroundTaskManager } from '../background';
 import type { PluginConfig } from '../config';
-import {
-  DEFAULT_TIMEOUT_MS,
-  MAX_POLL_TIME_MS,
-  POLL_INTERVAL_MS,
-  STABLE_POLLS_THRESHOLD,
-  SUBAGENT_NAMES,
-} from '../config';
+import { SUBAGENT_NAMES } from '../config';
 import type { TmuxConfig } from '../config/schema';
 import { applyAgentVariant, resolveAgentVariant } from '../utils';
 import { log } from '../utils/logger';
 
 const z = tool.schema;
 
-type ToolContext = {
-  sessionID: string;
-  messageID: string;
-  agent: string;
-  abort: AbortSignal;
-};
-
 interface SessionMessage {
   info?: { role: string };
   parts?: Array<{ type: string; text?: string }>;
 }
 
-interface SessionStatus {
-  type: string;
-}
-
 /**
  * Creates background task management tools for the plugin.
  * @param ctx - Plugin input context
@@ -50,31 +33,30 @@ export function createBackgroundTools(
 ): Record<string, ToolDefinition> {
   const agentNames = SUBAGENT_NAMES.join(', ');
 
-  // Tool for launching agent tasks (async or sync mode)
+  // Tool for launching agent tasks (fire-and-forget)
   const background_task = tool({
-    description: `Run agent task. Use sync=true to wait for result, sync=false (default) to run in background.
+    description: `Run agent task in background. Returns task_id immediately - use \`background_output\` to get results.
 
 Agents: ${agentNames}.
 
-Async mode returns task_id immediately - use \`background_output\` to get results.
-Sync mode blocks until completion and returns the result directly.`,
+Key behaviors:
+- Fire-and-forget: Returns task_id in ~1ms without waiting for session creation
+- Multiple tasks launch in parallel (up to 10 concurrent)
+- Completion detection via session.status events (no polling)
+- Optional: Set notifyOnComplete=true to get notification when task completes`,
+
     args: {
       description: z
         .string()
         .describe('Short description of the task (5-10 words)'),
       prompt: z.string().describe('The task prompt for the agent'),
       agent: z.string().describe(`Agent to use: ${agentNames}`),
-      sync: z
+      notifyOnComplete: z
         .boolean()
         .optional()
-        .describe('Wait for completion (default: false = async)'),
-      session_id: z
-        .string()
-        .optional()
-        .describe('Continue existing session (sync mode only)'),
+        .describe('Notify parent session when task completes (default: false)'),
     },
     async execute(args, toolContext) {
-      // Validate tool context has required sessionID
       if (
         !toolContext ||
         typeof toolContext !== 'object' ||
@@ -82,38 +64,26 @@ Sync mode blocks until completion and returns the result directly.`,
       ) {
         throw new Error('Invalid toolContext: missing sessionID');
       }
+
       const agent = String(args.agent);
       const prompt = String(args.prompt);
       const description = String(args.description);
-      const isSync = args.sync === true;
-
-      // Sync mode: execute task and wait for completion
-      if (isSync) {
-        return await executeSync(
-          description,
-          prompt,
-          agent,
-          toolContext as ToolContext,
-          ctx,
-          tmuxConfig,
-          pluginConfig,
-          args.session_id as string | undefined,
-        );
-      }
+      const notifyOnComplete = args.notifyOnComplete === true;
 
-      // Async mode: launch task and return immediately with task ID
-      const task = await manager.launch({
+      // Fire-and-forget launch
+      const task = manager.launch({
         agent,
         prompt,
         description,
-        parentSessionId: (toolContext as ToolContext).sessionID,
+        parentSessionId: (toolContext as { sessionID: string }).sessionID,
+        notifyOnComplete,
       });
 
       return `Background task launched.
 
 Task ID: ${task.id}
 Agent: ${agent}
-Status: running
+Status: ${task.status}
 
 Use \`background_output\` with task_id="${task.id}" to get results.`;
     },
@@ -121,26 +91,33 @@ Use \`background_output\` with task_id="${task.id}" to get results.`;
 
   // Tool for retrieving output from background tasks
   const background_output = tool({
-    description: 'Get output from background task.',
+    description:
+      'Get output from background task. Returns current state immediately (no blocking).',
     args: {
       task_id: z.string().describe('Task ID from background_task'),
-      block: z
-        .boolean()
-        .optional()
-        .describe('Wait for completion (default: false)'),
       timeout: z
         .number()
         .optional()
-        .describe('Timeout in ms (default: 120000)'),
+        .describe('Wait for completion (in ms, 0=no wait, default: 0)'),
     },
     async execute(args) {
       const taskId = String(args.task_id);
-      const block = args.block === true;
       const timeout =
-        typeof args.timeout === 'number' ? args.timeout : DEFAULT_TIMEOUT_MS;
+        typeof args.timeout === 'number' && args.timeout > 0 ? args.timeout : 0;
+
+      let task = manager.getResult(taskId);
+
+      // Wait for completion if timeout specified
+      if (
+        task &&
+        timeout > 0 &&
+        task.status !== 'completed' &&
+        task.status !== 'failed' &&
+        task.status !== 'cancelled'
+      ) {
+        task = await manager.waitForCompletion(taskId, timeout);
+      }
 
-      // Retrieve task result (optionally blocking until completion)
-      const task = await manager.getResult(taskId, block, timeout);
       if (!task) {
         return `Task not found: ${taskId}`;
       }
@@ -148,7 +125,7 @@ Use \`background_output\` with task_id="${task.id}" to get results.`;
       // Calculate task duration
       const duration = task.completedAt
         ? `${Math.floor((task.completedAt.getTime() - task.startedAt.getTime()) / 1000)}s`
-        : 'running';
+        : `${Math.floor((Date.now() - task.startedAt.getTime()) / 1000)}s`;
 
       let output = `Task: ${task.id}
  Description: ${task.description}
@@ -164,6 +141,8 @@ Use \`background_output\` with task_id="${task.id}" to get results.`;
         output += task.result;
       } else if (task.status === 'failed') {
         output += `Error: ${task.error}`;
+      } else if (task.status === 'cancelled') {
+        output += '(Task cancelled)';
       } else {
         output += '(Task still running)';
       }
@@ -184,7 +163,7 @@ Use \`background_output\` with task_id="${task.id}" to get results.`;
       // Cancel all running tasks if requested
       if (args.all === true) {
         const count = manager.cancel();
-        return `Cancelled ${count} running task(s).`;
+        return `Cancelled ${count} task(s).`;
       }
 
       // Cancel specific task if task_id provided
@@ -201,304 +180,3 @@ Use \`background_output\` with task_id="${task.id}" to get results.`;
 
   return { background_task, background_output, background_cancel };
 }
-
-/**
- * Executes a task synchronously by creating/resuming a session, sending a prompt,
- * polling for completion, and extracting the response.
- * @param description - Short description of the task
- * @param prompt - The task prompt for the agent
- * @param agent - The agent to use for the task
- * @param toolContext - Tool context containing session ID and abort signal
- * @param ctx - Plugin input context
- * @param tmuxConfig - Optional tmux configuration for session management
- * @param pluginConfig - Optional plugin configuration for agent variants
- * @param existingSessionId - Optional existing session ID to resume
- * @returns The agent's response text with task metadata
- */
-async function executeSync(
-  description: string,
-  prompt: string,
-  agent: string,
-  toolContext: ToolContext,
-  ctx: PluginInput,
-  tmuxConfig?: TmuxConfig,
-  pluginConfig?: PluginConfig,
-  existingSessionId?: string,
-): Promise<string> {
-  // Resolve or create session for the task
-  const { sessionID, error: sessionError } = await resolveSessionId(
-    ctx,
-    toolContext,
-    description,
-    agent,
-    tmuxConfig,
-    existingSessionId,
-  );
-
-  if (sessionError) {
-    return `Error: ${sessionError}`;
-  }
-
-  // Disable recursive delegation tools to prevent infinite loops
-  log(`[background-sync] launching sync task for agent="${agent}"`, {
-    description,
-  });
-
-  // Send prompt to the session
-  const { error: promptError } = await sendPrompt(
-    ctx,
-    sessionID,
-    prompt,
-    agent,
-    pluginConfig,
-  );
-  if (promptError) {
-    return withTaskMetadata(`Error: ${promptError}`, sessionID);
-  }
-
-  // Poll session until completion, abort, or timeout
-  const pollResult = await pollSession(ctx, sessionID, toolContext.abort);
-  if (pollResult.aborted) {
-    return withTaskMetadata('Task aborted.', sessionID);
-  }
-  if (pollResult.timeout) {
-    const minutes = Math.floor(MAX_POLL_TIME_MS / 60000);
-    return withTaskMetadata(
-      `Error: Agent timed out after ${minutes} minutes.`,
-      sessionID,
-    );
-  }
-  if (pollResult.error) {
-    return withTaskMetadata(`Error: ${pollResult.error}`, sessionID);
-  }
-
-  // Retrieve and extract the agent's response
-  const messagesResult = await ctx.client.session.messages({
-    path: { id: sessionID },
-  });
-  if (messagesResult.error) {
-    return `Error: Failed to get messages: ${messagesResult.error}`;
-  }
-
-  const messages = messagesResult.data as SessionMessage[];
-  const responseText = extractResponseText(messages);
-
-  if (!responseText) {
-    return withTaskMetadata('Error: No response from agent.', sessionID);
-  }
-
-  // Pane closing is handled by TmuxSessionManager via polling
-  return formatResponse(responseText, sessionID);
-}
-
-/**
- * Resolves an existing session or creates a new one.
- */
-export async function resolveSessionId(
-  ctx: PluginInput,
-  toolContext: ToolContext,
-  description: string,
-  agent: string,
-  tmuxConfig?: TmuxConfig,
-  existingSessionId?: string,
-): Promise<{ sessionID: string; error?: string }> {
-  // If existing session ID provided, validate and return it
-  if (existingSessionId) {
-    const sessionResult = await ctx.client.session.get({
-      path: { id: existingSessionId },
-    });
-    if (sessionResult.error) {
-      return {
-        sessionID: '',
-        error: `Failed to get session: ${sessionResult.error}`,
-      };
-    }
-    return { sessionID: existingSessionId };
-  }
-  // Otherwise, create a new session
-  return createSession(ctx, toolContext, description, agent, tmuxConfig);
-}
-
-/**
- * Creates a new session with proper configuration.
- */
-export async function createSession(
-  ctx: PluginInput,
-  toolContext: ToolContext,
-  description: string,
-  agent: string,
-  tmuxConfig?: TmuxConfig,
-): Promise<{ sessionID: string; error?: string }> {
-  // Get parent session to inherit directory context
-  const parentSession = await ctx.client.session
-    .get({ path: { id: toolContext.sessionID } })
-    .catch(() => null);
-  const parentDirectory = parentSession?.data?.directory ?? ctx.directory;
-
-  // Create new session with parent relationship
-  const createResult = await ctx.client.session.create({
-    body: {
-      parentID: toolContext.sessionID,
-      title: `${description} (@${agent})`,
-    },
-    query: { directory: parentDirectory },
-  });
-
-  if (createResult.error) {
-    return {
-      sessionID: '',
-      error: `Failed to create session: ${createResult.error}`,
-    };
-  }
-
-  // Give TmuxSessionManager time to spawn the pane via event hook
-  // before we send the prompt (so the TUI can receive streaming updates)
-  if (tmuxConfig?.enabled) {
-    await new Promise((r) => setTimeout(r, 500));
-  }
-
-  return { sessionID: createResult.data.id };
-}
-
-/**
- * Sends a prompt to the specified session.
- */
-export async function sendPrompt(
-  ctx: PluginInput,
-  sessionID: string,
-  prompt: string,
-  agent: string,
-  pluginConfig?: PluginConfig,
-): Promise<{ error?: string }> {
-  // Resolve agent variant configuration
-  const resolvedVariant = resolveAgentVariant(pluginConfig, agent);
-
-  type PromptBody = {
-    agent: string;
-    tools: { background_task: boolean; task: boolean };
-    parts: Array<{ type: 'text'; text: string }>;
-    variant?: string;
-  };
-
-  // Build prompt body with recursive tools disabled to prevent infinite loops
-  const baseBody: PromptBody = {
-    agent,
-    tools: { background_task: false, task: false },
-    parts: [{ type: 'text' as const, text: prompt }],
-  };
-  const promptBody = applyAgentVariant(resolvedVariant, baseBody);
-
-  // Send prompt to the session
-  try {
-    await ctx.client.session.prompt({
-      path: { id: sessionID },
-      body: promptBody,
-    });
-    return {};
-  } catch (error) {
-    return {
-      error: `Failed to send prompt: ${error instanceof Error ? error.message : String(error)}`,
-    };
-  }
-}
-
-/**
- * Polls the session until it becomes idle and has messages.
- */
-export async function pollSession(
-  ctx: PluginInput,
-  sessionID: string,
-  abortSignal: AbortSignal,
-): Promise<{ error?: string; timeout?: boolean; aborted?: boolean }> {
-  const pollStart = Date.now();
-  let lastMsgCount = 0;
-  let stablePolls = 0;
-
-  // Poll until timeout, abort, or stable message count detected
-  while (Date.now() - pollStart < MAX_POLL_TIME_MS) {
-    // Check for abort signal
-    if (abortSignal.aborted) {
-      return { aborted: true };
-    }
-
-    await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
-
-    // Check session status - if not idle, reset stability counters
-    const statusResult = await ctx.client.session.status();
-    if (statusResult.error) {
-      return { error: `Failed to get session status: ${statusResult.error}` };
-    }
-    const allStatuses = (statusResult.data ?? {}) as Record<
-      string,
-      SessionStatus
-    >;
-    const sessionStatus = allStatuses[sessionID];
-
-    if (sessionStatus && sessionStatus.type !== 'idle') {
-      stablePolls = 0;
-      lastMsgCount = 0;
-      continue;
-    }
-
-    // Check message count - if stable for threshold, task is complete
-    const messagesCheck = await ctx.client.session.messages({
-      path: { id: sessionID },
-    });
-    if (messagesCheck.error) {
-      return { error: `Failed to check messages: ${messagesCheck.error}` };
-    }
-    const msgs = messagesCheck.data as SessionMessage[];
-    const currentMsgCount = msgs.length;
-
-    if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) {
-      stablePolls++;
-      if (stablePolls >= STABLE_POLLS_THRESHOLD) return {};
-    } else {
-      stablePolls = 0;
-      lastMsgCount = currentMsgCount;
-    }
-  }
-
-  return { timeout: true };
-}
-
-/**
- * Extracts the assistant's response text from session messages.
- */
-export function extractResponseText(messages: SessionMessage[]): string {
-  // Filter for assistant messages only
-  const assistantMessages = messages.filter(
-    (m) => m.info?.role === 'assistant',
-  );
-  const extractedContent: string[] = [];
-
-  // Extract text and reasoning content from message parts
-  for (const message of assistantMessages) {
-    for (const part of message.parts ?? []) {
-      if ((part.type === 'text' || part.type === 'reasoning') && part.text) {
-        extractedContent.push(part.text);
-      }
-    }
-  }
-
-  // Join non-empty content with double newlines
-  return extractedContent.filter((t) => t.length > 0).join('\n\n');
-}
-
-/**
- * Formats the final response with metadata.
- */
-function formatResponse(responseText: string, sessionID: string): string {
-  return withTaskMetadata(responseText, sessionID);
-}
-
-/**
- * Wraps content with task metadata footer.
- */
-function withTaskMetadata(content: string, sessionID: string): string {
-  return `${content}
-
-<task_metadata>
-session_id: ${sessionID}
-</task_metadata>`;
-}