@lioneltay/worker-manager
v0.0.5
Published
Claude Code plugin for spawning and managing worker agents in isolated worktrees
Readme
@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:
npm install -g @lioneltay/worker-managerQuick 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:
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
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 pendingState 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:
{
"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:
{
"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:
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_BHow it works:
- 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.
- Workers receive the orchestrator ID via the
ORCHESTRATOR_IDenv var and write mail tomail/<orchestratorId>/. - Hooks run as children of the same Claude Code process, so
process.ppidmatches the MCP server'sprocess.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.
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:
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. TheWORKER_IDenv var causes it to start as a worker MCP server instead of an orchestrator. - Environment variables in tmux:
WORKER_IDandSTATE_DIRare 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
completewhen done oraskif 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:
- All workers with
status: "running"have their tmux sessions killed - Their status is updated to
"stopped"in the registry - 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 definitionsPlugin 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 creationzod— Input validation for MCP tool schemastmux— Required system dependency for worker session management
