# Async Jobs Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to
> implement this plan task-by-task.

**Goal:** Add `run_async` parameter to bash/delegate tools and create job
management tools for agent self-service.

**Architecture:** Extend existing job infrastructure (shell/subagent jobs
already work via `job:` prefix) to be accessible through standard tool
invocations. Job management tools are runtime-handled stubs (like delegate) that
call into the existing job registry functions.

**Tech Stack:** TypeScript, Zod schemas, ENT protocol, Vitest

---

## Current State Summary

- **Job infrastructure exists**: `startShellJob()`, `startSubagentJob()`,
  `finalizeJob()` in server.ts
- **ENT protocol methods exist**: `ent/job/list`, `ent/job/output`,
  `ent/job/kill` in server.ts
- **Bash tool**: Full foreground implementation, no `run_async` parameter
- **Delegate tool**: Stub class, runtime-handled at server.ts:4324-4387
- **Testing pattern**: E2E tests in `agent-process.jobs.e2e.test.ts` using
  `job:` prefix

---

## Task 1: Add `run_async` Parameter to Bash Tool Schema

**Files:**

- Modify: `packages/agent/src/tools/implementations/bash.ts:35-37`
- Test: `packages/agent/src/tools/implementations/bash.test.ts`

**Step 1: Write the failing test**

Create test file at
`packages/agent/src/tools/implementations/bash-async.test.ts`:

```typescript
import { describe, expect, it } from 'vitest';
import { BashTool } from './bash';

describe('BashTool schema', () => {
  it('accepts run_async parameter', () => {
    const tool = new BashTool();
    const schema = tool.schema;

    // Should parse successfully with run_async
    const result = schema.safeParse({
      command: 'echo hi',
      run_async: true,
    });

    expect(result.success).toBe(true);
    if (result.success) {
      expect(result.data.run_async).toBe(true);
    }
  });

  it('defaults run_async to false', () => {
    const tool = new BashTool();
    const schema = tool.schema;

    const result = schema.safeParse({
      command: 'echo hi',
    });

    expect(result.success).toBe(true);
    if (result.success) {
      expect(result.data.run_async).toBe(false);
    }
  });
});
```

**Step 2: Run test to verify it fails**

Run: `npm test -- packages/agent/src/tools/implementations/bash-async.test.ts`
Expected: FAIL - `run_async` property not recognized in schema

**Step 3: Update bash schema to include run_async**

In `packages/agent/src/tools/implementations/bash.ts`, update the schema at line
35:

```typescript
const bashSchema = z.object({
  command: NonEmptyString,
  run_async: z.boolean().default(false),
});
```

**Step 4: Run test to verify it passes**

Run: `npm test -- packages/agent/src/tools/implementations/bash-async.test.ts`
Expected: PASS

**Step 5: Commit**

```bash
git add packages/agent/src/tools/implementations/bash.ts packages/agent/src/tools/implementations/bash-async.test.ts
git commit -m "$(cat <<'EOF'
feat(agent): add run_async parameter to bash tool schema

Prepares bash tool for async job execution. The actual async
behavior will be handled by the server runtime, similar to delegate.
EOF
)"
```

---

## Task 2: Add Runtime Handling for Async Bash

**Files:**

- Modify: `packages/agent/src/server.ts` (near tool execution, ~line 4388)
- Test: `packages/agent/src/__tests__/agent-process.jobs.e2e.test.ts`

**Step 1: Write the failing E2E test**

Add to `packages/agent/src/__tests__/agent-process.jobs.e2e.test.ts`:

```typescript
it(
  'spawns async bash job via run_async parameter',
  { timeout: 20_000 },
  async () => {
    agent = spawnAgentProcess({ laceDir });

    const updates: Array<Record<string, unknown>> = [];
    let jobId: string | undefined;

    agent.peer.onRequest('session/update', async (params) => {
      const p = params as Record<string, unknown>;
      updates.push(p);
      if (p.type === 'job_started' && typeof p.jobId === 'string')
        jobId = p.jobId;
      return undefined;
    });

    agent.peer.onRequest('session/request_permission', async () => {
      return { decision: 'allow' };
    });

    await withTimeout(
      agent.peer.request(
        'initialize',
        defaultInitializeParams({ config: { approvalMode: 'allow' } })
      ),
      2_000,
      'initialize'
    );

    await withTimeout(
      agent.peer.request('session/new', { workDir }),
      2_000,
      'session/new'
    );

    // Use a multi-turn prompt that will invoke bash with run_async
    // We need the model to actually call the tool, so we use a simple echo
    await withTimeout(
      agent.peer.request('session/prompt', {
        content: [{ type: 'text', text: 'run: echo test-async-job' }],
      }),
      5_000,
      'session/prompt'
    );

    // For now, verify the run: shortcut still works
    // The actual run_async tool parameter test needs model interaction
    // which is tested in integration tests
  }
);
```

**Note:** Full E2E testing of `run_async` requires model interaction. For TDD,
we'll add a unit test that verifies the runtime branching logic.

**Step 2: Create unit test for runtime async detection**

Create `packages/agent/src/__tests__/bash-async-detection.test.ts`:

```typescript
import { describe, expect, it } from 'vitest';

// Test the logic that will be used in server.ts to detect async mode
describe('bash async detection', () => {
  it('detects run_async=true in tool input', () => {
    const input = { command: 'echo hi', run_async: true };
    const isAsync = input.run_async === true;
    expect(isAsync).toBe(true);
  });

  it('defaults to sync when run_async not specified', () => {
    const input = { command: 'echo hi' };
    const isAsync = (input as any).run_async === true;
    expect(isAsync).toBe(false);
  });
});
```

**Step 3: Run tests**

Run: `npm test -- packages/agent/src/__tests__/bash-async-detection.test.ts`
Expected: PASS (logic test, not integration)

**Step 4: Add async branch in server.ts tool execution**

In `packages/agent/src/server.ts`, find the tool execution block around line
4388 (after the delegate handling). Add before the standard
`toolExecutor.execute()` call:

```typescript
// Handle bash with run_async=true
if (toolName === 'bash' && (finalInput as any).run_async === true) {
  const command = (finalInput as any).command as string;
  if (!command) {
    coreResult = {
      status: 'failed',
      content: [{ type: 'text', text: 'bash.command is required' }],
    };
  } else {
    const { jobId } = await startShellJob({
      command,
      description: command.substring(0, 50),
      turnContext: { turnId, turnSeq: toolTurnSeq },
    });

    coreResult = {
      status: 'completed',
      content: [
        {
          type: 'text',
          text: `Async job started: ${jobId}\nUse job_output to check status.`,
        },
      ],
    };
  }
} else if (toolName === 'delegate') {
  // ... existing delegate handling
```

**Step 5: Run E2E tests**

Run: `npm test -- packages/agent/src/__tests__/agent-process.jobs.e2e.test.ts`
Expected: PASS

**Step 6: Commit**

```bash
git add packages/agent/src/server.ts packages/agent/src/__tests__/bash-async-detection.test.ts
git commit -m "$(cat <<'EOF'
feat(agent): handle bash run_async=true in runtime

When bash tool is called with run_async=true, spawn a background
shell job instead of executing synchronously. Returns jobId for
tracking via job_output tool.
EOF
)"
```

---

## Task 3: Add `run_async` Parameter to Delegate Tool Schema

**Files:**

- Modify: `packages/agent/src/tools/implementations/delegate.ts:9-13`
- Test: `packages/agent/src/tools/implementations/delegate.test.ts` (new)

**Step 1: Write the failing test**

Create `packages/agent/src/tools/implementations/delegate.test.ts`:

```typescript
import { describe, expect, it } from 'vitest';
import { DelegateTool } from './delegate';

describe('DelegateTool schema', () => {
  it('accepts run_async parameter', () => {
    const tool = new DelegateTool();
    const schema = tool.schema;

    const result = schema.safeParse({
      prompt: 'find all typescript files',
      run_async: true,
    });

    expect(result.success).toBe(true);
    if (result.success) {
      expect(result.data.run_async).toBe(true);
    }
  });

  it('defaults run_async to false', () => {
    const tool = new DelegateTool();
    const schema = tool.schema;

    const result = schema.safeParse({
      prompt: 'search the codebase',
    });

    expect(result.success).toBe(true);
    if (result.success) {
      expect(result.data.run_async).toBe(false);
    }
  });

  it('accepts description parameter', () => {
    const tool = new DelegateTool();
    const schema = tool.schema;

    const result = schema.safeParse({
      prompt: 'explore the API',
      description: 'API exploration',
    });

    expect(result.success).toBe(true);
    if (result.success) {
      expect(result.data.description).toBe('API exploration');
    }
  });
});
```

**Step 2: Run test to verify it fails**

Run: `npm test -- packages/agent/src/tools/implementations/delegate.test.ts`
Expected: FAIL - properties not in schema

**Step 3: Update delegate schema**

In `packages/agent/src/tools/implementations/delegate.ts`:

```typescript
const delegateSchema = z
  .object({
    prompt: NonEmptyString,
    description: z.string().optional(),
    run_async: z.boolean().default(false),
  })
  .strict();
```

**Step 4: Run test to verify it passes**

Run: `npm test -- packages/agent/src/tools/implementations/delegate.test.ts`
Expected: PASS

**Step 5: Commit**

```bash
git add packages/agent/src/tools/implementations/delegate.ts packages/agent/src/tools/implementations/delegate.test.ts
git commit -m "$(cat <<'EOF'
feat(agent): add run_async and description to delegate schema

Extends delegate tool to support async subagent execution.
When run_async=true, returns immediately with jobId.
EOF
)"
```

---

## Task 4: Add Runtime Handling for Async Delegate

**Files:**

- Modify: `packages/agent/src/server.ts` (~line 4324-4387)
- Test: `packages/agent/src/__tests__/agent-process.jobs.e2e.test.ts`

**Step 1: Write the failing E2E test**

Add to `packages/agent/src/__tests__/agent-process.jobs.e2e.test.ts`:

```typescript
it(
  'spawns async subagent job via delegate run_async',
  { timeout: 20_000 },
  async () => {
    agent = spawnAgentProcess({ laceDir });

    const updates: Array<Record<string, unknown>> = [];
    let subagentJobId: string | undefined;

    agent.peer.onRequest('session/update', async (params) => {
      const p = params as Record<string, unknown>;
      updates.push(p);
      if (
        p.type === 'job_started' &&
        p.jobType === 'subagent' &&
        typeof p.jobId === 'string'
      ) {
        subagentJobId = p.jobId;
      }
      return undefined;
    });

    agent.peer.onRequest('session/request_permission', async () => {
      return { decision: 'allow' };
    });

    await withTimeout(
      agent.peer.request(
        'initialize',
        defaultInitializeParams({ config: { approvalMode: 'allow' } })
      ),
      2_000,
      'initialize'
    );

    await withTimeout(
      agent.peer.request('session/new', { workDir }),
      2_000,
      'session/new'
    );

    // Use subagent: prefix to trigger delegate (existing pattern)
    await withTimeout(
      agent.peer.request('session/prompt', {
        content: [{ type: 'text', text: 'subagent: say hello' }],
      }),
      10_000,
      'session/prompt'
    );

    // Wait for job to finish
    await withTimeout(
      new Promise<void>((resolve) => {
        const interval = setInterval(() => {
          if (!subagentJobId) return;
          const finished = updates.find(
            (u) => u.type === 'job_finished' && u.jobId === subagentJobId
          );
          if (finished) {
            clearInterval(interval);
            resolve();
          }
        }, 10);
      }),
      10_000,
      'subagent job_finished'
    );

    expect(subagentJobId).toMatch(/^job_/);
  }
);
```

**Step 2: Run test**

Run: `npm test -- packages/agent/src/__tests__/agent-process.jobs.e2e.test.ts`
Expected: Should pass with existing infrastructure

**Step 3: Modify delegate runtime handling for run_async**

In `packages/agent/src/server.ts`, update the delegate handling block around
line 4324:

```typescript
if (toolName === 'delegate') {
  const prompt = toNonEmptyString((finalInput as any).prompt);
  const description = (finalInput as any).description as string | undefined;
  const runAsync = (finalInput as any).run_async === true;

  if (!prompt) {
    coreResult = {
      status: 'failed',
      content: [{ type: 'text', text: 'delegate.prompt is required' }],
    };
  } else {
    const { jobId } = await startSubagentJob({
      prompt,
      description: description ?? 'Delegate',
      turnContext: { turnId, turnSeq: toolTurnSeq },
    });

    if (runAsync) {
      // Async mode: return immediately with jobId
      coreResult = {
        status: 'completed',
        content: [
          {
            type: 'text',
            text: `Async subagent started: ${jobId}\nUse job_output to check status.`,
          },
        ],
      };
    } else {
      // Sync mode: wait for completion (existing behavior)
      const job = state.jobs.get(jobId);
      if (job) {
        const abortPromise = new Promise<never>((_, reject) => {
          abortController.signal.addEventListener(
            'abort',
            () => reject(new Error('cancelled')),
            { once: true }
          );
        });

        try {
          await Promise.race([job.completion, abortPromise]);
        } catch {
          job.status = 'cancelled';
          await finalizeJob(job);
        }
      }

      let output = '';
      try {
        output = readFileSync(
          getJobOutputPath(state.activeSession.dir, jobId),
          'utf8'
        );
      } catch {
        output = '';
      }

      const tailLimit = 64 * 1024;
      const truncated = output.length > tailLimit;
      const reportText = truncated ? output.slice(-tailLimit) : output;

      const status = job?.status ?? 'failed';
      coreResult = {
        status:
          status === 'completed'
            ? 'completed'
            : status === 'cancelled'
              ? 'aborted'
              : 'failed',
        content: [
          {
            type: 'text',
            text:
              `delegate jobId=${jobId}\n\n` +
              (reportText.trim().length > 0
                ? reportText.trim()
                : '(no output)') +
              (truncated ? '\n\n(truncated)' : ''),
          },
        ],
      };
    }
  }
}
```

**Step 4: Run E2E tests**

Run: `npm test -- packages/agent/src/__tests__/agent-process.jobs.e2e.test.ts`
Expected: PASS

**Step 5: Commit**

```bash
git add packages/agent/src/server.ts
git commit -m "$(cat <<'EOF'
feat(agent): support run_async in delegate tool

When delegate is called with run_async=true, return immediately
with jobId instead of blocking until subagent completes.
EOF
)"
```

---

## Task 5: Create `job_output` Tool Schema (Stub)

**Files:**

- Create: `packages/agent/src/tools/implementations/job_output.ts`
- Test: `packages/agent/src/tools/implementations/job_output.test.ts`

**Step 1: Write the failing test**

Create `packages/agent/src/tools/implementations/job_output.test.ts`:

```typescript
import { describe, expect, it } from 'vitest';
import { JobOutputTool } from './job_output';

describe('JobOutputTool', () => {
  it('has correct name and schema', () => {
    const tool = new JobOutputTool();

    expect(tool.name).toBe('job_output');

    const result = tool.schema.safeParse({
      jobId: 'job_abc123',
    });
    expect(result.success).toBe(true);
  });

  it('accepts block parameter', () => {
    const tool = new JobOutputTool();

    const result = tool.schema.safeParse({
      jobId: 'job_abc123',
      block: false,
    });

    expect(result.success).toBe(true);
    if (result.success) {
      expect(result.data.block).toBe(false);
    }
  });

  it('defaults block to true', () => {
    const tool = new JobOutputTool();

    const result = tool.schema.safeParse({
      jobId: 'job_abc123',
    });

    expect(result.success).toBe(true);
    if (result.success) {
      expect(result.data.block).toBe(true);
    }
  });

  it('accepts timeoutMs and cursor parameters', () => {
    const tool = new JobOutputTool();

    const result = tool.schema.safeParse({
      jobId: 'job_abc123',
      timeoutMs: 5000,
      cursor: 100,
    });

    expect(result.success).toBe(true);
    if (result.success) {
      expect(result.data.timeoutMs).toBe(5000);
      expect(result.data.cursor).toBe(100);
    }
  });
});
```

**Step 2: Run test to verify it fails**

Run: `npm test -- packages/agent/src/tools/implementations/job_output.test.ts`
Expected: FAIL - module not found

**Step 3: Implement job_output tool stub**

Create `packages/agent/src/tools/implementations/job_output.ts`:

```typescript
// ABOUTME: Job output retrieval tool schema stub
// ABOUTME: Executed by lace-agent runtime (not via ToolExecutor)

import { z } from 'zod';
import { Tool } from '../tool';
import { NonEmptyString } from '../schemas/common';
import type { ToolAnnotations, ToolContext, ToolResult } from '../types';

const jobOutputSchema = z.object({
  jobId: NonEmptyString,
  block: z.boolean().default(true),
  timeoutMs: z.number().int().min(0).max(600_000).default(30_000),
  cursor: z.number().int().min(0).default(0),
});

export class JobOutputTool extends Tool {
  name = 'job_output';
  description = `Retrieve status and output from a background job. Use block=true to wait for completion, block=false for immediate status check. Cursor enables incremental output reads.`;
  schema = jobOutputSchema;
  annotations: ToolAnnotations = {
    title: 'Get Job Output',
    destructiveHint: false,
    openWorldHint: false,
    readOnlySafe: true,
  };

  protected executeValidated(
    _args: z.infer<typeof jobOutputSchema>,
    _context: ToolContext
  ): Promise<ToolResult> {
    return Promise.resolve({
      status: 'failed',
      content: [
        {
          type: 'text',
          text: 'job_output is executed by the lace-agent runtime (should not be executed via ToolExecutor).',
        },
      ],
    });
  }
}
```

**Step 4: Run test to verify it passes**

Run: `npm test -- packages/agent/src/tools/implementations/job_output.test.ts`
Expected: PASS

**Step 5: Commit**

```bash
git add packages/agent/src/tools/implementations/job_output.ts packages/agent/src/tools/implementations/job_output.test.ts
git commit -m "$(cat <<'EOF'
feat(agent): add job_output tool schema stub

Runtime-handled tool for retrieving job status and output.
Supports blocking wait, timeout, and cursor-based pagination.
EOF
)"
```

---

## Task 6: Create `jobs_list` Tool Schema (Stub)

**Files:**

- Create: `packages/agent/src/tools/implementations/jobs_list.ts`
- Test: `packages/agent/src/tools/implementations/jobs_list.test.ts`

**Step 1: Write the failing test**

Create `packages/agent/src/tools/implementations/jobs_list.test.ts`:

```typescript
import { describe, expect, it } from 'vitest';
import { JobsListTool } from './jobs_list';

describe('JobsListTool', () => {
  it('has correct name', () => {
    const tool = new JobsListTool();
    expect(tool.name).toBe('jobs_list');
  });

  it('accepts no parameters', () => {
    const tool = new JobsListTool();

    const result = tool.schema.safeParse({});
    expect(result.success).toBe(true);
  });

  it('accepts status filter', () => {
    const tool = new JobsListTool();

    const result = tool.schema.safeParse({
      status: ['running', 'completed'],
    });

    expect(result.success).toBe(true);
    if (result.success) {
      expect(result.data.status).toEqual(['running', 'completed']);
    }
  });

  it('accepts type filter', () => {
    const tool = new JobsListTool();

    const result = tool.schema.safeParse({
      type: ['shell'],
    });

    expect(result.success).toBe(true);
    if (result.success) {
      expect(result.data.type).toEqual(['shell']);
    }
  });
});
```

**Step 2: Run test to verify it fails**

Run: `npm test -- packages/agent/src/tools/implementations/jobs_list.test.ts`
Expected: FAIL - module not found

**Step 3: Implement jobs_list tool stub**

Create `packages/agent/src/tools/implementations/jobs_list.ts`:

```typescript
// ABOUTME: Job listing tool schema stub
// ABOUTME: Executed by lace-agent runtime (not via ToolExecutor)

import { z } from 'zod';
import { Tool } from '../tool';
import type { ToolAnnotations, ToolContext, ToolResult } from '../types';

const jobsListSchema = z.object({
  status: z
    .array(z.enum(['pending', 'running', 'completed', 'failed', 'cancelled']))
    .optional(),
  type: z.array(z.enum(['shell', 'subagent'])).optional(),
  limit: z.number().int().min(1).max(100).default(50),
});

export class JobsListTool extends Tool {
  name = 'jobs_list';
  description = `List current and recent background jobs. Filter by status or type. Returns job IDs, descriptions, and status.`;
  schema = jobsListSchema;
  annotations: ToolAnnotations = {
    title: 'List Jobs',
    destructiveHint: false,
    openWorldHint: false,
    readOnlySafe: true,
  };

  protected executeValidated(
    _args: z.infer<typeof jobsListSchema>,
    _context: ToolContext
  ): Promise<ToolResult> {
    return Promise.resolve({
      status: 'failed',
      content: [
        {
          type: 'text',
          text: 'jobs_list is executed by the lace-agent runtime (should not be executed via ToolExecutor).',
        },
      ],
    });
  }
}
```

**Step 4: Run test to verify it passes**

Run: `npm test -- packages/agent/src/tools/implementations/jobs_list.test.ts`
Expected: PASS

**Step 5: Commit**

```bash
git add packages/agent/src/tools/implementations/jobs_list.ts packages/agent/src/tools/implementations/jobs_list.test.ts
git commit -m "$(cat <<'EOF'
feat(agent): add jobs_list tool schema stub

Runtime-handled tool for listing background jobs.
Supports filtering by status and type.
EOF
)"
```

---

## Task 7: Create `job_kill` Tool Schema (Stub)

**Files:**

- Create: `packages/agent/src/tools/implementations/job_kill.ts`
- Test: `packages/agent/src/tools/implementations/job_kill.test.ts`

**Step 1: Write the failing test**

Create `packages/agent/src/tools/implementations/job_kill.test.ts`:

```typescript
import { describe, expect, it } from 'vitest';
import { JobKillTool } from './job_kill';

describe('JobKillTool', () => {
  it('has correct name', () => {
    const tool = new JobKillTool();
    expect(tool.name).toBe('job_kill');
  });

  it('requires jobId', () => {
    const tool = new JobKillTool();

    const result = tool.schema.safeParse({});
    expect(result.success).toBe(false);
  });

  it('accepts jobId', () => {
    const tool = new JobKillTool();

    const result = tool.schema.safeParse({
      jobId: 'job_abc123',
    });

    expect(result.success).toBe(true);
    if (result.success) {
      expect(result.data.jobId).toBe('job_abc123');
    }
  });
});
```

**Step 2: Run test to verify it fails**

Run: `npm test -- packages/agent/src/tools/implementations/job_kill.test.ts`
Expected: FAIL - module not found

**Step 3: Implement job_kill tool stub**

Create `packages/agent/src/tools/implementations/job_kill.ts`:

```typescript
// ABOUTME: Job cancellation tool schema stub
// ABOUTME: Executed by lace-agent runtime (not via ToolExecutor)

import { z } from 'zod';
import { Tool } from '../tool';
import { NonEmptyString } from '../schemas/common';
import type { ToolAnnotations, ToolContext, ToolResult } from '../types';

const jobKillSchema = z.object({
  jobId: NonEmptyString,
});

export class JobKillTool extends Tool {
  name = 'job_kill';
  description = `Cancel a running background job. Only running jobs can be cancelled.`;
  schema = jobKillSchema;
  annotations: ToolAnnotations = {
    title: 'Kill Job',
    destructiveHint: true,
    openWorldHint: false,
    readOnlySafe: false,
  };

  protected executeValidated(
    _args: z.infer<typeof jobKillSchema>,
    _context: ToolContext
  ): Promise<ToolResult> {
    return Promise.resolve({
      status: 'failed',
      content: [
        {
          type: 'text',
          text: 'job_kill is executed by the lace-agent runtime (should not be executed via ToolExecutor).',
        },
      ],
    });
  }
}
```

**Step 4: Run test to verify it passes**

Run: `npm test -- packages/agent/src/tools/implementations/job_kill.test.ts`
Expected: PASS

**Step 5: Commit**

```bash
git add packages/agent/src/tools/implementations/job_kill.ts packages/agent/src/tools/implementations/job_kill.test.ts
git commit -m "$(cat <<'EOF'
feat(agent): add job_kill tool schema stub

Runtime-handled tool for cancelling running jobs.
EOF
)"
```

---

## Task 8: Register Job Tools in Executor

**Files:**

- Modify: `packages/agent/src/tools/executor.ts` (~line 225-232)
- Modify: `packages/agent/src/tools/implementations/index.ts`

**Step 1: Write the failing test**

Add to existing executor tests or create
`packages/agent/src/tools/job-tools-registration.test.ts`:

```typescript
import { describe, expect, it } from 'vitest';
import { ToolExecutor } from './executor';

describe('ToolExecutor job tools registration', () => {
  it('registers job management tools', () => {
    const executor = new ToolExecutor({ workDir: '/tmp' });
    const schemas = executor.getToolSchemas();
    const names = schemas.map((s) => s.name);

    expect(names).toContain('job_output');
    expect(names).toContain('jobs_list');
    expect(names).toContain('job_kill');
  });
});
```

**Step 2: Run test to verify it fails**

Run: `npm test -- packages/agent/src/tools/job-tools-registration.test.ts`
Expected: FAIL - tools not registered

**Step 3: Export job tools from implementations/index.ts**

In `packages/agent/src/tools/implementations/index.ts`, add:

```typescript
export { JobOutputTool } from './job_output';
export { JobsListTool } from './jobs_list';
export { JobKillTool } from './job_kill';
```

**Step 4: Register job tools in executor.ts**

In `packages/agent/src/tools/executor.ts`, update the default tools list around
line 225:

```typescript
import { JobOutputTool, JobsListTool, JobKillTool } from './implementations';

// In the constructor or default tools array:
new JobOutputTool(),
new JobsListTool(),
new JobKillTool(),
```

**Step 5: Run test to verify it passes**

Run: `npm test -- packages/agent/src/tools/job-tools-registration.test.ts`
Expected: PASS

**Step 6: Commit**

```bash
git add packages/agent/src/tools/executor.ts packages/agent/src/tools/implementations/index.ts packages/agent/src/tools/job-tools-registration.test.ts
git commit -m "$(cat <<'EOF'
feat(agent): register job management tools in executor

Adds job_output, jobs_list, job_kill to default tool set.
These are runtime-handled stubs like delegate.
EOF
)"
```

---

## Task 9: Implement Runtime Handling for Job Tools

**Files:**

- Modify: `packages/agent/src/server.ts` (tool execution section)
- Test: `packages/agent/src/__tests__/agent-process.jobs.e2e.test.ts`

**Step 1: Write the failing E2E test**

Add to `packages/agent/src/__tests__/agent-process.jobs.e2e.test.ts`:

```typescript
it(
  'job_output tool returns job status and output',
  { timeout: 20_000 },
  async () => {
    agent = spawnAgentProcess({ laceDir });

    let jobId: string | undefined;

    agent.peer.onRequest('session/update', async (params) => {
      const p = params as Record<string, unknown>;
      if (p.type === 'job_started' && typeof p.jobId === 'string')
        jobId = p.jobId;
      return undefined;
    });

    agent.peer.onRequest('session/request_permission', async () => {
      return { decision: 'allow' };
    });

    await withTimeout(
      agent.peer.request(
        'initialize',
        defaultInitializeParams({ config: { approvalMode: 'allow' } })
      ),
      2_000,
      'initialize'
    );

    await withTimeout(
      agent.peer.request('session/new', { workDir }),
      2_000,
      'session/new'
    );

    // Start a job via the shortcut
    await withTimeout(
      agent.peer.request('session/prompt', {
        content: [{ type: 'text', text: 'job: echo hello-from-job' }],
      }),
      5_000,
      'session/prompt'
    );

    // Wait for job to complete
    await withTimeout(
      new Promise<void>((resolve) => {
        const interval = setInterval(async () => {
          if (!jobId) return;
          const list = (await agent!.peer.request('ent/job/list')) as {
            jobs: Array<{ jobId: string; status: string }>;
          };
          const job = list.jobs.find((j) => j.jobId === jobId);
          if (job?.status === 'completed') {
            clearInterval(interval);
            resolve();
          }
        }, 50);
      }),
      5_000,
      'job completion'
    );

    expect(jobId).toBeDefined();
  }
);
```

**Step 2: Add runtime handling for job_output, jobs_list, job_kill**

In `packages/agent/src/server.ts`, add handling before the standard
`toolExecutor.execute()`:

```typescript
// Handle job_output tool
if (toolName === 'job_output') {
  const jobId = (finalInput as any).jobId as string;
  const block = (finalInput as any).block !== false;
  const timeoutMs = (finalInput as any).timeoutMs ?? 30_000;
  const cursor = (finalInput as any).cursor ?? 0;

  if (!jobId) {
    coreResult = {
      status: 'failed',
      content: [{ type: 'text', text: 'job_output.jobId is required' }],
    };
  } else {
    // Reuse the ent/job/output implementation
    const result = await handleJobOutput({ jobId, block, timeout: timeoutMs, afterOffset: cursor });
    coreResult = {
      status: 'completed',
      content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
    };
  }
} else if (toolName === 'jobs_list') {
  const statusFilter = (finalInput as any).status as string[] | undefined;
  const typeFilter = (finalInput as any).type as string[] | undefined;
  const limit = (finalInput as any).limit ?? 50;

  // Reuse the ent/job/list implementation
  const result = await handleJobList();
  let jobs = result.jobs;

  // Apply filters
  if (statusFilter) {
    jobs = jobs.filter((j) => statusFilter.includes(j.status));
  }
  if (typeFilter) {
    const typeMap: Record<string, string> = { shell: 'shell', subagent: 'subagent' };
    jobs = jobs.filter((j) => typeFilter.includes(typeMap[j.type] ?? j.type));
  }
  jobs = jobs.slice(0, limit);

  coreResult = {
    status: 'completed',
    content: [{ type: 'text', text: JSON.stringify({ jobs }, null, 2) }],
  };
} else if (toolName === 'job_kill') {
  const jobId = (finalInput as any).jobId as string;

  if (!jobId) {
    coreResult = {
      status: 'failed',
      content: [{ type: 'text', text: 'job_kill.jobId is required' }],
    };
  } else {
    // Reuse the ent/job/kill implementation
    const result = await handleJobKill({ jobId });
    coreResult = {
      status: result.success ? 'completed' : 'failed',
      content: [{ type: 'text', text: result.success ? `Killed job ${jobId}` : `Failed to kill job ${jobId}` }],
    };
  }
} else if (toolName === 'delegate') {
  // ... existing delegate handling
```

**Note:** The actual implementation will need to extract the helper functions
for `ent/job/output`, `ent/job/list`, `ent/job/kill` from the RPC handlers so
they can be reused by the tool handlers.

**Step 3: Run E2E tests**

Run: `npm test -- packages/agent/src/__tests__/agent-process.jobs.e2e.test.ts`
Expected: PASS

**Step 4: Commit**

```bash
git add packages/agent/src/server.ts
git commit -m "$(cat <<'EOF'
feat(agent): implement runtime handling for job tools

job_output, jobs_list, job_kill tools now execute via runtime.
Reuses existing ent/job/* handler implementations.
EOF
)"
```

---

## Task 10: Integration Test - Full Async Job Workflow

**Files:**

- Create:
  `packages/agent/src/__tests__/agent-process.async-workflow.e2e.test.ts`

**Step 1: Write comprehensive E2E test**

```typescript
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
  spawnAgentProcess,
  withTimeout,
  type SpawnedAgent,
} from './helpers/agent-process';
import { defaultInitializeParams } from './helpers/initialize';

describe('async job workflow (E2E)', () => {
  let laceDir: string;
  let workDir: string;
  let agent: SpawnedAgent | undefined;

  beforeEach(() => {
    laceDir = mkdtempSync(join(tmpdir(), 'lace-async-workflow-'));
    workDir = mkdtempSync(join(tmpdir(), 'lace-async-wd-'));
  });

  afterEach(async () => {
    if (agent) {
      await agent.shutdown();
      agent = undefined;
    }
    rmSync(laceDir, { recursive: true, force: true });
    rmSync(workDir, { recursive: true, force: true });
  });

  it(
    'complete async bash workflow: spawn, list, check output, kill',
    { timeout: 30_000 },
    async () => {
      agent = spawnAgentProcess({ laceDir });

      const updates: Array<Record<string, unknown>> = [];
      let shellJobId: string | undefined;

      agent.peer.onRequest('session/update', async (params) => {
        const p = params as Record<string, unknown>;
        updates.push(p);
        if (
          p.type === 'job_started' &&
          p.jobType === 'shell' &&
          typeof p.jobId === 'string'
        ) {
          shellJobId = p.jobId;
        }
        return undefined;
      });

      agent.peer.onRequest('session/request_permission', async () => {
        return { decision: 'allow' };
      });

      await withTimeout(
        agent.peer.request(
          'initialize',
          defaultInitializeParams({ config: { approvalMode: 'allow' } })
        ),
        2_000,
        'initialize'
      );

      await withTimeout(
        agent.peer.request('session/new', { workDir }),
        2_000,
        'session/new'
      );

      // Spawn a long-running job that we can kill
      await withTimeout(
        agent.peer.request('session/prompt', {
          content: [{ type: 'text', text: 'job: sleep 60' }],
        }),
        5_000,
        'session/prompt (spawn job)'
      );

      // Wait for job_started
      await withTimeout(
        new Promise<void>((resolve) => {
          const interval = setInterval(() => {
            if (shellJobId) {
              clearInterval(interval);
              resolve();
            }
          }, 10);
        }),
        3_000,
        'job_started'
      );

      expect(shellJobId).toMatch(/^job_/);

      // List jobs - should show running
      const list = (await withTimeout(
        agent.peer.request('ent/job/list'),
        2_000,
        'ent/job/list'
      )) as { jobs: Array<{ jobId: string; status: string }> };

      const runningJob = list.jobs.find((j) => j.jobId === shellJobId);
      expect(runningJob?.status).toBe('running');

      // Kill the job
      const killed = (await withTimeout(
        agent.peer.request('ent/job/kill', { jobId: shellJobId }),
        2_000,
        'ent/job/kill'
      )) as { success: boolean };

      expect(killed.success).toBe(true);

      // Check final status
      const output = (await withTimeout(
        agent.peer.request('ent/job/output', { jobId: shellJobId }),
        2_000,
        'ent/job/output'
      )) as { status: string };

      expect(output.status).toBe('cancelled');
    }
  );
});
```

**Step 2: Run test**

Run:
`npm test -- packages/agent/src/__tests__/agent-process.async-workflow.e2e.test.ts`
Expected: PASS

**Step 3: Commit**

```bash
git add packages/agent/src/__tests__/agent-process.async-workflow.e2e.test.ts
git commit -m "$(cat <<'EOF'
test(agent): add comprehensive async job workflow E2E test

Tests full lifecycle: spawn job, list, check status, kill.
EOF
)"
```

---

## Task 11: Store subagentSessionId in Job Record

**Files:**

- Modify: `packages/agent/src/server.ts` (JobState type and startSubagentJob)

**Step 1: Write the failing test**

Add to `packages/agent/src/__tests__/agent-process.jobs.e2e.test.ts`:

```typescript
it('job record includes subagentSessionId', { timeout: 20_000 }, async () => {
  agent = spawnAgentProcess({ laceDir });

  let jobId: string | undefined;

  agent.peer.onRequest('session/update', async (params) => {
    const p = params as Record<string, unknown>;
    if (
      p.type === 'job_started' &&
      p.jobType === 'subagent' &&
      typeof p.jobId === 'string'
    ) {
      jobId = p.jobId;
    }
    return undefined;
  });

  agent.peer.onRequest('session/request_permission', async () => {
    return { decision: 'allow' };
  });

  await withTimeout(
    agent.peer.request(
      'initialize',
      defaultInitializeParams({ config: { approvalMode: 'allow' } })
    ),
    2_000,
    'initialize'
  );

  await withTimeout(
    agent.peer.request('session/new', { workDir }),
    2_000,
    'session/new'
  );

  await withTimeout(
    agent.peer.request('session/prompt', {
      content: [{ type: 'text', text: 'subagent: say hello' }],
    }),
    10_000,
    'session/prompt'
  );

  // Wait for job to finish
  await withTimeout(
    new Promise<void>((resolve) => {
      const interval = setInterval(async () => {
        if (!jobId) return;
        const list = (await agent!.peer.request('ent/job/list')) as {
          jobs: Array<{
            jobId: string;
            status: string;
            subagentSessionId?: string;
          }>;
        };
        const job = list.jobs.find((j) => j.jobId === jobId);
        if (job?.status === 'completed') {
          // Verify subagentSessionId is present
          expect(job.subagentSessionId).toBeDefined();
          expect(job.subagentSessionId).toMatch(/^[a-z0-9-]+$/);
          clearInterval(interval);
          resolve();
        }
      }, 50);
    }),
    10_000,
    'job completion with subagentSessionId'
  );
});
```

**Step 2: Run test to verify it fails**

Run: `npm test -- packages/agent/src/__tests__/agent-process.jobs.e2e.test.ts`
Expected: FAIL - subagentSessionId not in job list response

**Step 3: Update JobState type and startSubagentJob**

In `packages/agent/src/server.ts`, add `subagentSessionId` to JobState and
populate it when creating subagent session:

```typescript
// In JobState type definition
subagentSessionId?: string;  // the subagent's own durable session

// In startSubagentJob, after session/new response:
const sessionResult = await childPeer.request('session/new', { workDir });
job.subagentSessionId = sessionResult.sessionId;
```

**Step 4: Update ent/job/list to include subagentSessionId**

```typescript
// In the job/list handler, include subagentSessionId in response
jobs: Array.from(state.jobs.values()).map((j) => ({
  jobId: j.jobId,
  // ... other fields
  subagentSessionId: j.subagentSessionId,
}));
```

**Step 5: Run test to verify it passes**

Run: `npm test -- packages/agent/src/__tests__/agent-process.jobs.e2e.test.ts`
Expected: PASS

**Step 6: Commit**

```bash
git add packages/agent/src/server.ts packages/agent/src/__tests__/agent-process.jobs.e2e.test.ts
git commit -m "$(cat <<'EOF'
feat(agent): store subagentSessionId in job record

Subagents are full agents with their own durable sessions.
The subagentSessionId enables job resume capability.
EOF
)"
```

---

## Task 12: Implement Job Resume for Delegate Tool

**Files:**

- Modify: `packages/agent/src/server.ts` (delegate runtime handling)
- Test: `packages/agent/src/__tests__/agent-process.jobs.e2e.test.ts`

**Step 1: Write the failing E2E test**

Add to `packages/agent/src/__tests__/agent-process.jobs.e2e.test.ts`:

```typescript
it(
  'can resume a failed/cancelled subagent job',
  { timeout: 30_000 },
  async () => {
    agent = spawnAgentProcess({ laceDir });

    let firstJobId: string | undefined;
    let firstJobSessionId: string | undefined;

    agent.peer.onRequest('session/update', async (params) => {
      const p = params as Record<string, unknown>;
      if (
        p.type === 'job_started' &&
        p.jobType === 'subagent' &&
        typeof p.jobId === 'string'
      ) {
        firstJobId = p.jobId;
      }
      return undefined;
    });

    agent.peer.onRequest('session/request_permission', async () => {
      return { decision: 'allow' };
    });

    await withTimeout(
      agent.peer.request(
        'initialize',
        defaultInitializeParams({ config: { approvalMode: 'allow' } })
      ),
      2_000,
      'initialize'
    );

    const session = (await withTimeout(
      agent.peer.request('session/new', { workDir }),
      2_000,
      'session/new'
    )) as { sessionId: string };

    // Start a subagent job that will take a while
    await withTimeout(
      agent.peer.request('session/prompt', {
        content: [
          { type: 'text', text: 'subagent: think for a moment then say hello' },
        ],
      }),
      5_000,
      'session/prompt (start job)'
    );

    // Wait for job to start
    await withTimeout(
      new Promise<void>((resolve) => {
        const interval = setInterval(async () => {
          if (firstJobId) {
            clearInterval(interval);
            resolve();
          }
        }, 10);
      }),
      3_000,
      'job_started'
    );

    // Get the subagentSessionId before killing
    const listBefore = (await agent.peer.request('ent/job/list')) as {
      jobs: Array<{ jobId: string; subagentSessionId?: string }>;
    };
    firstJobSessionId = listBefore.jobs.find(
      (j) => j.jobId === firstJobId
    )?.subagentSessionId;

    // Kill the job
    await agent.peer.request('ent/job/kill', { jobId: firstJobId });

    // Now test resume - this would be done via delegate with resume parameter
    // For now, verify the subagentSessionId is available for resume
    expect(firstJobSessionId).toBeDefined();

    // The actual resume test would spawn a new subagent with session/load
    // This is tested by verifying the infrastructure is in place
  }
);
```

**Step 2: Update delegate runtime to support resume parameter**

In `packages/agent/src/server.ts`, update delegate handling:

```typescript
if (toolName === 'delegate') {
  const prompt = toNonEmptyString((finalInput as any).prompt);
  const description = (finalInput as any).description as string | undefined;
  const runAsync = (finalInput as any).run_async === true;
  const resumeJobId = (finalInput as any).resume as string | undefined;

  if (!prompt) {
    coreResult = {
      status: 'failed',
      content: [{ type: 'text', text: 'delegate.prompt is required' }],
    };
  } else {
    // If resuming, look up the previous job's subagentSessionId
    let resumeSessionId: string | undefined;
    if (resumeJobId) {
      const previousJob = state.jobs.get(resumeJobId);
      if (!previousJob?.subagentSessionId) {
        coreResult = {
          status: 'failed',
          content: [
            {
              type: 'text',
              text: `Cannot resume job ${resumeJobId}: no subagentSessionId found`,
            },
          ],
        };
        // skip rest of delegate handling
      } else {
        resumeSessionId = previousJob.subagentSessionId;
      }
    }

    if (!coreResult) {
      const { jobId } = await startSubagentJob({
        prompt,
        description: description ?? 'Delegate',
        turnContext: { turnId, turnSeq: toolTurnSeq },
        resumeSessionId, // pass to subagent runner
      });
      // ... rest of handling
    }
  }
}
```

**Step 3: Update startSubagentJob to handle resumeSessionId**

```typescript
const startSubagentJob = async (options: {
  prompt: string;
  description?: string;
  parentJobId?: string;
  turnContext?: { turnId: string; turnSeq: number };
  resumeSessionId?: string; // if provided, resume this session instead of creating new
}): Promise<{ jobId: string }> => {
  // ... existing setup ...

  // In runSubagentJobProcess, handle resume:
  if (options.resumeSessionId) {
    await childPeer.request('session/load', {
      sessionId: options.resumeSessionId,
    });
    job.subagentSessionId = options.resumeSessionId;
  } else {
    const sessionResult = await childPeer.request('session/new', { workDir });
    job.subagentSessionId = sessionResult.sessionId;
  }
};
```

**Step 4: Run tests**

Run: `npm test -- packages/agent/src/__tests__/agent-process.jobs.e2e.test.ts`
Expected: PASS

**Step 5: Commit**

```bash
git add packages/agent/src/server.ts
git commit -m "$(cat <<'EOF'
feat(agent): implement job resume for delegate tool

When delegate is called with resume=jobId, load the previous
job's subagentSessionId via session/load instead of creating
a new session. Enables resuming failed/cancelled jobs.
EOF
)"
```

---

## Summary

| Task | Description                           | Files                      |
| ---- | ------------------------------------- | -------------------------- |
| 1    | Add `run_async` to bash schema        | bash.ts                    |
| 2    | Runtime handling for async bash       | server.ts                  |
| 3    | Add `run_async` to delegate schema    | delegate.ts                |
| 4    | Runtime handling for async delegate   | server.ts                  |
| 5    | Create job_output tool stub           | job_output.ts              |
| 6    | Create jobs_list tool stub            | jobs_list.ts               |
| 7    | Create job_kill tool stub             | job_kill.ts                |
| 8    | Register job tools in executor        | executor.ts, index.ts      |
| 9    | Runtime handling for job tools        | server.ts                  |
| 10   | Full workflow E2E test                | async-workflow.e2e.test.ts |
| 11   | Store subagentSessionId in job record | server.ts                  |
| 12   | Implement job resume for delegate     | server.ts                  |

**Total: 12 tasks with ~35 commits**
