dispatch-agents 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,6 +49,41 @@ npm link
49
49
  - `git` — for worktree management
50
50
  - iTerm2 (recommended) — for native tab integration via `tmux -CC`
51
51
 
52
+ ## MCP Server
53
+
54
+ Dispatch includes an MCP server so Claude Code can orchestrate agents directly — no shell commands needed.
55
+
56
+ ### Setup
57
+
58
+ After installing dispatch, register the MCP server with Claude Code:
59
+
60
+ ```bash
61
+ claude mcp add --scope user dispatch node $(which dispatch-mcp)
62
+ ```
63
+
64
+ This exposes 6 tools to Claude Code:
65
+
66
+ | Tool | Description |
67
+ |------|-------------|
68
+ | `dispatch_run` | Launch an agent with a prompt (inline text, not a file) |
69
+ | `dispatch_list` | List all running agents with status |
70
+ | `dispatch_stop` | Stop a running agent |
71
+ | `dispatch_resume` | Resume a stopped agent |
72
+ | `dispatch_cleanup` | Remove worktrees and optionally branches |
73
+ | `dispatch_logs` | Get recent output from an agent |
74
+
75
+ ### How it works
76
+
77
+ The MCP server wraps the dispatch CLI over stdio using the [Model Context Protocol](https://modelcontextprotocol.io). When Claude Code calls `dispatch_run`, the server writes the prompt to a temp file, runs `dispatch run --prompt-file <file>`, and cleans up. Interactive agents open iTerm tabs; headless agents run in the background.
78
+
79
+ ### Working directory
80
+
81
+ By default the MCP server uses the directory Claude Code is running in. To override, set `DISPATCH_CWD`:
82
+
83
+ ```bash
84
+ claude mcp add --scope user dispatch -e DISPATCH_CWD=/path/to/repo node $(which dispatch-mcp)
85
+ ```
86
+
52
87
  ## Usage
53
88
 
54
89
  ### Launch an agent
package/dist/cli.js CHANGED
@@ -211,8 +211,10 @@ function ensureSession() {
211
211
  execSync(
212
212
  `tmux new-session -d -s "${DISPATCH_SESSION}" -n "dispatch"`
213
213
  );
214
- execSync(`tmux set -t "${DISPATCH_SESSION}" -g mouse on`);
215
- execSync(`tmux set -t "${DISPATCH_SESSION}" -g history-limit 50000`);
214
+ execSync(`tmux set -t "${DISPATCH_SESSION}" mouse on`);
215
+ execSync(`tmux set -t "${DISPATCH_SESSION}" history-limit 50000`);
216
+ execQuiet(`tmux set -t "${DISPATCH_SESSION}" set-titles on`);
217
+ execQuiet(`tmux set -t "${DISPATCH_SESSION}" set-titles-string "#W"`);
216
218
  execSync(
217
219
  `tmux send-keys -t "${DISPATCH_SESSION}:dispatch" "# Dispatch control window" Enter`
218
220
  );
@@ -241,6 +243,9 @@ function createWindow(id, cwd) {
241
243
  log.error(`Failed to create tmux window: ${err}`);
242
244
  process.exit(1);
243
245
  }
246
+ const target = `${DISPATCH_SESSION}:${id}`;
247
+ execQuiet(`tmux setw -t "${target}" allow-passthrough on`);
248
+ execQuiet(`tmux setw -t "${target}" automatic-rename off`);
244
249
  const countStr = execQuiet(
245
250
  `tmux list-windows -t "${DISPATCH_SESSION}" | wc -l`
246
251
  );
@@ -249,19 +254,9 @@ function createWindow(id, cwd) {
249
254
  const red = parseInt(hex.slice(0, 2), 16);
250
255
  const green = parseInt(hex.slice(2, 4), 16);
251
256
  const blue = parseInt(hex.slice(4, 6), 16);
252
- const target = `${DISPATCH_SESSION}:${id}`;
253
- execSync(
254
- `tmux send-keys -t "${target}" "printf '\\\\033]6;1;bg;red;brightness;${red}\\\\007'" Enter`
255
- );
256
- execSync(
257
- `tmux send-keys -t "${target}" "printf '\\\\033]6;1;bg;green;brightness;${green}\\\\007'" Enter`
258
- );
259
- execSync(
260
- `tmux send-keys -t "${target}" "printf '\\\\033]6;1;bg;blue;brightness;${blue}\\\\007'" Enter`
261
- );
262
257
  const badge = Buffer.from(id).toString("base64");
263
258
  execSync(
264
- `tmux send-keys -t "${target}" "printf '\\\\033]1337;SetBadgeFormat=${badge}\\\\007'" Enter`
259
+ `tmux send-keys -t "${target}" "printf '\\\\033]0;${id}\\\\007\\\\033]6;1;bg;red;brightness;${red}\\\\007\\\\033]6;1;bg;green;brightness;${green}\\\\007\\\\033]6;1;bg;blue;brightness;${blue}\\\\007\\\\033]1337;SetBadgeFormat=${badge}\\\\007' && clear" Enter`
265
260
  );
266
261
  return true;
267
262
  }
@@ -294,19 +289,12 @@ function tmuxAttach(window) {
294
289
  const target = window ? `${DISPATCH_SESSION}:${window}` : DISPATCH_SESSION;
295
290
  const hasTTY = process.stdin.isTTY;
296
291
  if (hasTTY) {
297
- const isIterm = process.env.TERM_PROGRAM === "iTerm.app";
298
- if (isIterm) {
299
- spawnSync("tmux", ["-CC", "attach", "-t", target], {
300
- stdio: "inherit"
301
- });
302
- } else {
303
- spawnSync("tmux", ["attach", "-t", target], {
304
- stdio: "inherit"
305
- });
306
- }
292
+ spawnSync("tmux", ["attach", "-t", target], {
293
+ stdio: "inherit",
294
+ env: { ...process.env, TERM_PROGRAM: "dumb" }
295
+ });
307
296
  } else if (process.platform === "darwin") {
308
- const cmd = `tmux attach -t ${target}`;
309
- const script = openTerminalTabAppleScript(cmd);
297
+ const script = openTerminalTabAppleScript(target);
310
298
  if (script) {
311
299
  spawnSync("osascript", ["-e", script], { stdio: "pipe" });
312
300
  } else {
@@ -316,7 +304,8 @@ function tmuxAttach(window) {
316
304
  log.warn(`No TTY available. Run manually: tmux attach -t ${target}`);
317
305
  }
318
306
  }
319
- function openTerminalTabAppleScript(command) {
307
+ function openTerminalTabAppleScript(target) {
308
+ const agentName = target.includes(":") ? target.split(":").pop() : target;
320
309
  const terminals = [
321
310
  {
322
311
  name: "iTerm2",
@@ -326,7 +315,8 @@ function openTerminalTabAppleScript(command) {
326
315
  tell current window
327
316
  create tab with default profile
328
317
  tell current session
329
- write text "${command}"
318
+ set name to "${agentName}"
319
+ write text "TERM_PROGRAM=dumb tmux attach -t ${target}"
330
320
  end tell
331
321
  end tell
332
322
  end tell`
@@ -339,7 +329,7 @@ function openTerminalTabAppleScript(command) {
339
329
  tell application "System Events" to tell process "Warp"
340
330
  keystroke "t" using command down
341
331
  delay 0.3
342
- keystroke "${command}"
332
+ keystroke "tmux attach -t ${target}"
343
333
  key code 36
344
334
  end tell
345
335
  end tell`
@@ -349,7 +339,7 @@ function openTerminalTabAppleScript(command) {
349
339
  bundleId: "com.apple.Terminal",
350
340
  script: `tell application "Terminal"
351
341
  activate
352
- do script "${command}"
342
+ do script "tmux attach -t ${target}"
353
343
  end tell`
354
344
  }
355
345
  ];
@@ -539,6 +529,7 @@ async function cmdRun(args, config) {
539
529
  let extraArgs = "";
540
530
  let skipWorktree = false;
541
531
  let nameOverride = "";
532
+ let noAttach = false;
542
533
  let i = 0;
543
534
  while (i < args.length) {
544
535
  const arg = args[i];
@@ -575,6 +566,10 @@ async function cmdRun(args, config) {
575
566
  skipWorktree = true;
576
567
  i++;
577
568
  break;
569
+ case "--no-attach":
570
+ noAttach = true;
571
+ i++;
572
+ break;
578
573
  case "--name":
579
574
  case "-n":
580
575
  nameOverride = args[++i];
@@ -614,7 +609,7 @@ async function cmdRun(args, config) {
614
609
  await launchAgent(input, headless, extraArgs, skipWorktree, promptFile, nameOverride, config);
615
610
  }
616
611
  console.log();
617
- if (!headless && inputs.length === 1) {
612
+ if (!headless && inputs.length === 1 && !noAttach) {
618
613
  log.info("Attaching to tmux session...");
619
614
  log.dim(" Detach with: Ctrl-B then D");
620
615
  console.log();
@@ -703,10 +698,11 @@ function cmdStop(args) {
703
698
  function cmdResume(args, config) {
704
699
  const id = args[0];
705
700
  if (!id) {
706
- log.error("Usage: dispatch resume <agent-id> [--headless]");
701
+ log.error("Usage: dispatch resume <agent-id> [--headless] [--no-attach]");
707
702
  process.exit(1);
708
703
  }
709
704
  const headless = args.includes("--headless") || args.includes("-H");
705
+ const noAttach = args.includes("--no-attach");
710
706
  ensureTmux();
711
707
  const wtPath = worktreePath(id, config);
712
708
  if (!existsSync2(wtPath)) {
@@ -725,7 +721,7 @@ function cmdResume(args, config) {
725
721
  `tmux send-keys -t "${tmuxTarget(id)}" "unset CLAUDECODE && claude --continue ${modelFlag}" Enter`
726
722
  );
727
723
  log.ok(`Resumed agent: ${id} (interactive)`);
728
- tmuxAttach();
724
+ if (!noAttach) tmuxAttach();
729
725
  } else {
730
726
  const resumePrompt = "Continue working on the task.";
731
727
  const claudeCmd = buildClaudeCmd(
package/dist/mcp.js ADDED
@@ -0,0 +1,263 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/mcp.ts
4
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import {
7
+ CallToolRequestSchema,
8
+ ListToolsRequestSchema
9
+ } from "@modelcontextprotocol/sdk/types.js";
10
+ import { execSync } from "child_process";
11
+ import {
12
+ writeFileSync,
13
+ unlinkSync,
14
+ readFileSync,
15
+ existsSync
16
+ } from "fs";
17
+ import { tmpdir } from "os";
18
+ import { join } from "path";
19
+ import { randomBytes } from "crypto";
20
+ function stripAnsi(str) {
21
+ return str.replace(/\x1b\[[0-9;]*m/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
22
+ }
23
+ function dispatch(args) {
24
+ const cwd = process.env.DISPATCH_CWD || process.cwd();
25
+ try {
26
+ const output = execSync(`dispatch ${args}`, {
27
+ encoding: "utf-8",
28
+ cwd,
29
+ timeout: 12e4,
30
+ stdio: ["pipe", "pipe", "pipe"]
31
+ });
32
+ return stripAnsi(output).trim();
33
+ } catch (e) {
34
+ const out = [e.stdout, e.stderr, e.message].filter(Boolean).join("\n");
35
+ return stripAnsi(out).trim();
36
+ }
37
+ }
38
+ function makeTempPrompt(prompt) {
39
+ const name = `dispatch-mcp-${randomBytes(4).toString("hex")}.md`;
40
+ const path = join(tmpdir(), name);
41
+ writeFileSync(path, prompt);
42
+ return path;
43
+ }
44
+ var server = new Server(
45
+ { name: "dispatch", version: "0.1.0" },
46
+ { capabilities: { tools: {} } }
47
+ );
48
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
49
+ tools: [
50
+ {
51
+ name: "dispatch_run",
52
+ description: "Launch a Claude Code agent in an isolated git worktree. Pass the full task prompt inline. Returns agent ID and branch name.",
53
+ inputSchema: {
54
+ type: "object",
55
+ properties: {
56
+ prompt: {
57
+ type: "string",
58
+ description: "Full task description/prompt for the agent"
59
+ },
60
+ ticket: {
61
+ type: "string",
62
+ description: "Linear ticket ID (e.g. HEY-907). Fetches title + description if LINEAR_API_KEY is set."
63
+ },
64
+ name: {
65
+ type: "string",
66
+ description: "Agent name and branch name (kebab-case). Defaults to ticket ID or derived from prompt."
67
+ },
68
+ headless: {
69
+ type: "boolean",
70
+ description: "Run in background (fire-and-forget). Default: false (interactive)."
71
+ },
72
+ model: {
73
+ type: "string",
74
+ description: "Claude model: sonnet, opus, haiku"
75
+ },
76
+ base_branch: {
77
+ type: "string",
78
+ description: "Branch to create worktree from. Default: dev."
79
+ },
80
+ max_turns: {
81
+ type: "number",
82
+ description: "Max agentic turns (headless only)"
83
+ }
84
+ },
85
+ required: ["prompt"]
86
+ }
87
+ },
88
+ {
89
+ name: "dispatch_list",
90
+ description: "List all running dispatch agents with their status (running/idle/exited).",
91
+ inputSchema: { type: "object", properties: {} }
92
+ },
93
+ {
94
+ name: "dispatch_stop",
95
+ description: "Stop a running dispatch agent. Worktree and branch are preserved.",
96
+ inputSchema: {
97
+ type: "object",
98
+ properties: {
99
+ agent_id: { type: "string", description: "Agent ID to stop" }
100
+ },
101
+ required: ["agent_id"]
102
+ }
103
+ },
104
+ {
105
+ name: "dispatch_resume",
106
+ description: "Resume a previously stopped agent. Picks up where it left off.",
107
+ inputSchema: {
108
+ type: "object",
109
+ properties: {
110
+ agent_id: {
111
+ type: "string",
112
+ description: "Agent ID to resume"
113
+ },
114
+ headless: {
115
+ type: "boolean",
116
+ description: "Resume in headless mode"
117
+ }
118
+ },
119
+ required: ["agent_id"]
120
+ }
121
+ },
122
+ {
123
+ name: "dispatch_cleanup",
124
+ description: "Remove an agent's worktree and optionally its branch.",
125
+ inputSchema: {
126
+ type: "object",
127
+ properties: {
128
+ agent_id: {
129
+ type: "string",
130
+ description: "Agent ID to clean up. Omit and set all=true for all agents."
131
+ },
132
+ all: {
133
+ type: "boolean",
134
+ description: "Clean up all worktrees"
135
+ },
136
+ delete_branch: {
137
+ type: "boolean",
138
+ description: "Also delete the git branch"
139
+ }
140
+ }
141
+ }
142
+ },
143
+ {
144
+ name: "dispatch_logs",
145
+ description: "Get recent output from a dispatch agent (log file or tmux capture).",
146
+ inputSchema: {
147
+ type: "object",
148
+ properties: {
149
+ agent_id: { type: "string", description: "Agent ID" },
150
+ lines: {
151
+ type: "number",
152
+ description: "Number of lines to return. Default: 50."
153
+ }
154
+ },
155
+ required: ["agent_id"]
156
+ }
157
+ }
158
+ ]
159
+ }));
160
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
161
+ const { name, arguments: args = {} } = request.params;
162
+ switch (name) {
163
+ case "dispatch_run": {
164
+ const {
165
+ prompt,
166
+ ticket,
167
+ name: agentName,
168
+ headless,
169
+ model,
170
+ base_branch,
171
+ max_turns
172
+ } = args;
173
+ const tmpFile = makeTempPrompt(prompt);
174
+ try {
175
+ const parts = ["run"];
176
+ parts.push(ticket || "prompt-file");
177
+ parts.push(`--prompt-file "${tmpFile}"`);
178
+ if (agentName) parts.push(`--name "${agentName}"`);
179
+ if (headless) parts.push("--headless");
180
+ if (model) parts.push(`--model ${model}`);
181
+ if (base_branch) parts.push(`--base ${base_branch}`);
182
+ if (max_turns) parts.push(`--max-turns ${max_turns}`);
183
+ if (headless) parts.push("--no-attach");
184
+ const output = dispatch(parts.join(" "));
185
+ return { content: [{ type: "text", text: output }] };
186
+ } finally {
187
+ try {
188
+ unlinkSync(tmpFile);
189
+ } catch {
190
+ }
191
+ }
192
+ }
193
+ case "dispatch_list": {
194
+ const output = dispatch("list");
195
+ return {
196
+ content: [{ type: "text", text: output || "No agents running." }]
197
+ };
198
+ }
199
+ case "dispatch_stop": {
200
+ const { agent_id } = args;
201
+ const output = dispatch(`stop ${agent_id}`);
202
+ return { content: [{ type: "text", text: output }] };
203
+ }
204
+ case "dispatch_resume": {
205
+ const { agent_id, headless } = args;
206
+ const flags = headless ? "--headless" : "";
207
+ const output = dispatch(
208
+ `resume ${agent_id} ${flags} --no-attach`.trim()
209
+ );
210
+ return { content: [{ type: "text", text: output }] };
211
+ }
212
+ case "dispatch_cleanup": {
213
+ const { agent_id, all, delete_branch } = args;
214
+ const parts = ["cleanup"];
215
+ if (all) parts.push("--all");
216
+ else if (agent_id) parts.push(agent_id);
217
+ if (delete_branch) parts.push("--delete-branch");
218
+ const output = dispatch(parts.join(" "));
219
+ return { content: [{ type: "text", text: output }] };
220
+ }
221
+ case "dispatch_logs": {
222
+ const { agent_id, lines = 50 } = args;
223
+ const cwd = process.env.DISPATCH_CWD || process.cwd();
224
+ const logPath = join(cwd, ".worktrees", agent_id, ".dispatch.log");
225
+ if (existsSync(logPath)) {
226
+ const content = readFileSync(logPath, "utf-8");
227
+ const logLines = content.split("\n");
228
+ const lastLines = logLines.slice(-lines).join("\n");
229
+ return {
230
+ content: [{ type: "text", text: stripAnsi(lastLines) }]
231
+ };
232
+ }
233
+ try {
234
+ const output = execSync(
235
+ `tmux capture-pane -t "dispatch:${agent_id}" -p -S -${lines}`,
236
+ { encoding: "utf-8", timeout: 5e3 }
237
+ );
238
+ return {
239
+ content: [{ type: "text", text: stripAnsi(output).trim() }]
240
+ };
241
+ } catch {
242
+ return {
243
+ content: [
244
+ {
245
+ type: "text",
246
+ text: `Agent '${agent_id}' not found or no output available.`
247
+ }
248
+ ]
249
+ };
250
+ }
251
+ }
252
+ default:
253
+ return {
254
+ content: [{ type: "text", text: `Unknown tool: ${name}` }],
255
+ isError: true
256
+ };
257
+ }
258
+ });
259
+ async function main() {
260
+ const transport = new StdioServerTransport();
261
+ await server.connect(transport);
262
+ }
263
+ main().catch(console.error);
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "dispatch-agents",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "description": "Orchestrate Claude Code agents in git worktrees",
5
5
  "type": "module",
6
6
  "bin": {
7
- "dispatch": "./dist/cli.js"
7
+ "dispatch": "./dist/cli.js",
8
+ "dispatch-mcp": "./dist/mcp.js"
8
9
  },
9
10
  "scripts": {
10
11
  "build": "tsup",
@@ -26,6 +27,9 @@
26
27
  "type": "git",
27
28
  "url": "https://github.com/paperMoose/dispatch.git"
28
29
  },
30
+ "dependencies": {
31
+ "@modelcontextprotocol/sdk": "^1.12.1"
32
+ },
29
33
  "devDependencies": {
30
34
  "tsup": "^8.0.0",
31
35
  "tsx": "^4.0.0",