npm package: @lioneltay/worker-manager
Description: Claude Code plugin for spawning and managing worker agents in isolated worktrees
Latest version: 0.0.5

# @lioneltay/worker-manager

A Claude Code plugin that spawns autonomous worker agents in isolated git worktrees. Workers run in tmux sessions with their own Claude Code instance and communicate back to the orchestrator via a file-based mail system.

## Installation

Install via the Claude Code plugin marketplace, or manually:

```bash
npm install -g @lioneltay/worker-manager
```

## Quick start

Once the plugin is installed, the orchestrator tools are available in your Claude Code session:

```
> Spawn a worker to refactor the auth module into separate files

# Claude calls start_worker({ title: "refactor-auth", task: "..." })
# Worker is created in a new worktree on branch worker/refactor-auth-a3f1b2c0
# Worker runs autonomously in tmux session worker-a3f1b2c0

> [next prompt — hook fires automatically]
# "Worker refactor-auth (a3f1b2c0) COMPLETED: Refactored auth module into..."
```

## How it works

A single Node.js binary (`dist/index.js`) serves four different roles depending on how it's invoked:

```mermaid
flowchart TD
    Entry["node dist/index.js"]
    Entry --> CheckHook{"--hook flag?"}
    CheckHook -->|Yes| Hook["UserPromptSubmit Hook
    Read & display pending mail"]
    CheckHook -->|No| CheckStop{"--stop-hook flag?"}
    CheckStop -->|Yes| StopHook["Stop Hook
    Block premature stops"]
    CheckStop -->|No| CheckWorker{"WORKER_ID env var?"}
    CheckWorker -->|Yes| Worker["Worker MCP Server
    Tools: complete, ask"]
    CheckWorker -->|No| Orchestrator["Orchestrator MCP Server
    Tools: start_worker, list_workers,
    nudge_worker, stop_worker, read_mail"]
```

The orchestrator runs as an MCP server in the lead agent's Claude Code session. When it spawns a worker, it launches a new Claude Code instance in a tmux session, configured with a worker MCP server that provides `complete` and `ask` tools. Workers communicate back via the filesystem — no HTTP servers, no daemons.

## Tools

### Orchestrator tools

| Tool           | Input                                         | Description                                                                       |
| -------------- | --------------------------------------------- | --------------------------------------------------------------------------------- |
| `start_worker` | `title`, `task`, `useWorktree`, `baseBranch?` | Spawn a worker agent in an isolated worktree or the current directory.            |
| `list_workers` | —                                             | List all workers with status. Cross-references tmux to detect crashed workers.    |
| `nudge_worker` | `id`, `message`                               | Send a message to a worker's tmux session (answers questions, provides guidance). |
| `stop_worker`  | `id`                                          | Stop a running worker by killing its tmux session. Worktrees are preserved.       |
| `read_mail`    | —                                             | Read and clear all pending messages from this orchestrator's workers.             |

### Worker tools

| Tool       | Input      | Description                                                                         |
| ---------- | ---------- | ----------------------------------------------------------------------------------- |
| `complete` | `summary`  | Signal task completion. Writes a mail message and updates the registry.             |
| `ask`      | `question` | Ask the orchestrator a question. Sets status to "asking" and writes a mail message. |

## Worker lifecycle

```mermaid
sequenceDiagram
    participant User
    participant O as Orchestrator
    participant FS as State (filesystem)
    participant T as tmux
    participant W as Worker (Claude Code)
    participant WT as Git Worktree

    User->>O: "spawn a worker to do X"
    O->>WT: Create worktree (new branch)
    O->>FS: Register worker in state.json
    O->>T: Create tmux session
    O->>T: Launch: claude --dangerously-skip-permissions<br/>with worker MCP + task prompt
    T->>W: Claude Code starts

    Note over W: Works autonomously<br/>in isolated worktree

    alt Task completed
        W->>FS: complete(summary)<br/>Write mail + update status
    else Needs clarification
        W->>FS: ask(question)<br/>Write mail + update status
    end

    Note over User,O: On next prompt submission...
    FS-->>O: UserPromptSubmit hook<br/>reads and prints mail

    alt Worker asked a question
        O->>T: nudge_worker(id, message)<br/>tmux send-keys
        T->>W: Message appears as user input
    end

    Note over User,O: When orchestrator tries to stop...
    FS-->>O: Stop hook checks for unread mail<br/>blocks if messages are pending
```

## State management

All state is stored in a temp directory derived from the git root, keeping the repository clean:

```
$TMPDIR/worker-manager/<hash>/
├── state.json                    # Worker registry
├── orchestrators/
│   └── <claude-code-pid>         # Maps PID → orchestrator ID
└── mail/
    ├── <orchestrator-id>/        # Per-orchestrator mailbox
    │   ├── 2025-01-15T...-uuid.json
    │   └── 2025-01-15T...-uuid.json
    └── <orchestrator-id>/
        └── ...
```

The `<hash>` is the first 12 characters of the SHA-256 of the git root path. This means different repositories get isolated state, and worktrees of the same repository share state with the main checkout.

### Worker registry (`state.json`)

Tracks all workers spawned from this git root:

```json
{
  "workers": {
    "a3f1b2c0": {
      "id": "a3f1b2c0",
      "name": "refactor-auth",
      "task": "Refactor the auth module...",
      "status": "running",
      "branch": "worker/refactor-auth-a3f1b2c0",
      "worktreePath": "/repo/.worktrees/worker--refactor-auth-a3f1b2c0",
      "tmuxSession": "worker-a3f1b2c0",
      "createdAt": "2025-01-15T10:30:00.000Z",
      "useWorktree": true
    }
  }
}
```

Registry writes are atomic — data is written to a temp file first, then renamed into place via `fs.renameSync`.

### Worker statuses

| Status      | Meaning                                                       |
| ----------- | ------------------------------------------------------------- |
| `running`   | Worker is actively processing its task                        |
| `completed` | Worker called `complete` — task is done                       |
| `asking`    | Worker called `ask` — waiting for orchestrator response       |
| `failed`    | Detected by `list_workers` when tmux session no longer exists |
| `stopped`   | Worker was explicitly stopped or cleaned up on shutdown       |

### Mail messages

Each message is a separate JSON file named `<timestamp>-<uuid>.json`:

```json
{
  "id": "550e8400-e29b-41d4-a716-446655440000",
  "workerId": "a3f1b2c0",
  "workerName": "refactor-auth",
  "type": "completion",
  "content": "Refactored auth module into login.ts, register.ts, and middleware.ts",
  "timestamp": "2025-01-15T10:35:00.000Z"
}
```

Message types: `completion`, `question`.

Reading mail is destructive — files are deleted after being read. This ensures each message is delivered exactly once.

### Multi-session isolation

Multiple Claude Code sessions in the same directory each get their own mailbox:

```mermaid
flowchart TB
    subgraph "Claude Code Session A (PID 1234)"
        MCP_A["Orchestrator MCP<br/>id: abc12345"]
        Hook_A["Hooks<br/>ppid: 1234"]
    end

    subgraph "Claude Code Session B (PID 5678)"
        MCP_B["Orchestrator MCP<br/>id: def67890"]
        Hook_B["Hooks<br/>ppid: 5678"]
    end

    subgraph "State Directory"
        Mapping["orchestrators/<br/>1234 → abc12345<br/>5678 → def67890"]
        Mail_A["mail/abc12345/<br/>messages..."]
        Mail_B["mail/def67890/<br/>messages..."]
    end

    MCP_A -->|"writes mapping"| Mapping
    MCP_B -->|"writes mapping"| Mapping
    Hook_A -->|"reads ppid 1234<br/>→ abc12345"| Mapping
    Hook_B -->|"reads ppid 5678<br/>→ def67890"| Mapping
    Hook_A -->|"reads only"| Mail_A
    Hook_B -->|"reads only"| Mail_B
```

**How it works:**

1. When the orchestrator MCP server starts, it generates a unique ID and writes a mapping from its parent PID (the Claude Code process) to that ID.
2. Workers receive the orchestrator ID via the `ORCHESTRATOR_ID` env var and write mail to `mail/<orchestratorId>/`.
3. Hooks run as children of the same Claude Code process, so `process.ppid` matches the MCP server's `process.ppid`. They look up the mapping to find the right mailbox.

This ensures each session only reads its own workers' mail, even when multiple sessions share the same git root.

## Hooks

The plugin registers two Claude Code hooks:

### UserPromptSubmit hook

Fires before every prompt. Reads pending mail from this orchestrator's mailbox and prints it to stdout, where Claude Code displays it as context for the next turn.

```
[Worker refactor-auth (a3f1b2c0)] COMPLETED: Refactored auth module into...
```

### Stop hook

Fires when Claude Code is about to stop. Has two modes depending on context:

**Orchestrator mode** (no `WORKER_ID` env var): Checks for unread mail. If mail is pending, blocks the stop with a JSON decision so the orchestrator processes worker results before going idle.

**Worker mode** (`WORKER_ID` env var set): Checks whether the worker called `complete` or `ask`. If not, blocks the stop and reminds the worker to call one of those tools. This prevents workers from silently exiting without reporting results.

```mermaid
flowchart TD
    StopHook["Stop Hook Fires"]
    StopHook --> IsWorker{"WORKER_ID set?"}

    IsWorker -->|No| OrchestratorPath["Orchestrator Mode"]
    OrchestratorPath --> HasMapping{"Orchestrator ID<br/>mapping exists?"}
    HasMapping -->|No| AllowStop0["Allow stop<br/>(no workers spawned)"]
    HasMapping -->|Yes| HasMail{"Pending mail?"}
    HasMail -->|No| AllowStop1["Allow stop"]
    HasMail -->|Yes| BlockOrch["Block stop<br/>Show mail content"]

    IsWorker -->|Yes| WorkerPath["Worker Mode"]
    WorkerPath --> CheckStatus{"Worker status?"}
    CheckStatus -->|"completed / asking"| AllowStop2["Allow stop"]
    CheckStatus -->|running| CheckRetries{"Retries >= 2?"}
    CheckRetries -->|Yes| AllowStop3["Allow stop<br/>(give up)"]
    CheckRetries -->|No| BlockWorker["Block stop<br/>'Call complete or ask'"]
```

The worker stop hook has a retry limit of 2 to prevent infinite loops — if the worker ignores the reminder twice, it's allowed to exit.

## Worker spawning

When `start_worker` is called, the following happens:

```mermaid
sequenceDiagram
    participant O as Orchestrator
    participant WT as Worktree Manager
    participant FS as Filesystem
    participant T as tmux

    O->>WT: create("worker/name-id", { newBranch: true })
    WT-->>O: { path: "/repo/.worktrees/worker--name-id" }

    O->>FS: Write worker to state.json
    O->>FS: Write MCP config to $TMPDIR/mcp-config-{id}.json

    Note over FS: MCP config points worker at same<br/>binary with env vars: WORKER_ID,<br/>WORKER_NAME, STATE_DIR, ORCHESTRATOR_ID

    O->>T: tmux new-session -d -s worker-{id}
    O->>T: tmux send-keys:<br/>export WORKER_ID=... STATE_DIR=...<br/>claude --dangerously-skip-permissions<br/>--mcp-config {path} --session-id {uuid}<br/>--append-system-prompt "..." "task"
```

Key details:

- **Same binary, different mode**: The worker's MCP config points at the same `dist/index.js`. The `WORKER_ID` env var causes it to start as a worker MCP server instead of an orchestrator.
- **Environment variables in tmux**: `WORKER_ID` and `STATE_DIR` are exported as shell variables in the tmux session before launching Claude. This allows the Stop hook (which inherits the shell environment) to detect that it's running in a worker context.
- **`--dangerously-skip-permissions`**: Workers run without permission prompts since they're autonomous.
- **System prompt injection**: Workers are told to call `complete` when done or `ask` if blocked.

## Cleanup

### Explicit stop

Use `stop_worker` to kill a specific worker's tmux session. The worker's status is updated to `"stopped"` and its worktree is preserved on disk for merging.

### Shutdown cleanup

When the orchestrator MCP server exits (Claude Code session ends, SIGTERM, or SIGINT), all running workers are automatically cleaned up:

1. All workers with `status: "running"` have their tmux sessions killed
2. Their status is updated to `"stopped"` in the registry
3. The orchestrator's PID mapping file is removed

Worktrees are intentionally preserved in both cases — they contain code changes the user may want to merge.

### Worktree vs shared directory

`useWorktree: true` creates an isolated git worktree via `@lioneltay/worktree-manager`. `useWorktree: false` runs the worker in the current directory on the current branch — useful for parallel tasks that don't need branch isolation (e.g., research, testing).

## Source files

```
src/
├── index.ts          # Entry point — mode detection + hook implementations
├── orchestrator.ts   # Orchestrator MCP server (start_worker, list_workers, etc.)
├── worker.ts         # Worker MCP server (complete, ask)
├── spawn.ts          # Worker spawning logic (tmux + Claude CLI)
├── state.ts          # File-based state (registry, mail, orchestrator mapping)
└── types.ts          # Shared type definitions
```

## Plugin structure

```
.claude-plugin/
  plugin.json         # Plugin manifest
.mcp.json             # MCP server configuration
hooks/
  hooks.json          # Hook definitions (UserPromptSubmit + Stop)
```

## Dependencies

- `@modelcontextprotocol/sdk` — MCP server implementation
- `@lioneltay/worktree-manager` — Git worktree creation
- `zod` — Input validation for MCP tool schemas
- `tmux` — Required system dependency for worker session management