dispatch-agents 0.4.0 → 0.5.0

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,6 +211,8 @@ 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
216
  execSync(
215
217
  `tmux send-keys -t "${DISPATCH_SESSION}:dispatch" "# Dispatch control window" Enter`
216
218
  );
@@ -419,6 +421,9 @@ function tailFile(path) {
419
421
 
420
422
  // src/commands.ts
421
423
  var TICKET_RE = /^[A-Z]+-[0-9]+$/;
424
+ function slugify(text) {
425
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "");
426
+ }
422
427
  function buildClaudeCmd(prompt, mode, wtPath, config, extraArgs) {
423
428
  let cmd = "claude";
424
429
  if (mode === "headless") cmd += " -p";
@@ -433,7 +438,7 @@ function buildClaudeCmd(prompt, mode, wtPath, config, extraArgs) {
433
438
  if (mode === "headless") {
434
439
  const promptFile = join3(wtPath, ".dispatch-prompt.txt");
435
440
  writeFileSync2(promptFile, prompt);
436
- cmd += ` "$(cat '${promptFile}')"`;
441
+ cmd += ` < '${promptFile}'`;
437
442
  }
438
443
  return cmd;
439
444
  }
@@ -442,9 +447,9 @@ async function launchAgent(input, headless, extraArgs, skipWorktree, promptFileA
442
447
  let prompt;
443
448
  let branch;
444
449
  if (TICKET_RE.test(input)) {
445
- id = input;
446
- branch = input.toLowerCase();
447
450
  const ticket = await fetchLinearTicket(input);
451
+ id = `${input.toLowerCase()}-${slugify(ticket.title)}`;
452
+ branch = id;
448
453
  if (ticket.description) {
449
454
  prompt = `Linear ticket ${input}: ${ticket.title}
450
455
 
@@ -455,14 +460,13 @@ Work on this ticket. Create commits as you go. When done, push the branch.`;
455
460
  prompt = `Work on ticket ${input}: ${ticket.title}. Create commits as you go. When done, push the branch.`;
456
461
  }
457
462
  } else {
458
- const suffix = String(Date.now()).slice(-6);
459
- id = `task-${suffix}`;
463
+ id = slugify(input) || `task-${String(Date.now()).slice(-6)}`;
460
464
  branch = id;
461
465
  prompt = input;
462
466
  }
463
467
  if (nameOverride) {
464
- id = nameOverride;
465
- branch = nameOverride.toLowerCase();
468
+ id = slugify(nameOverride) || nameOverride;
469
+ branch = id;
466
470
  }
467
471
  if (promptFileArg) {
468
472
  if (!existsSync2(promptFileArg)) {
@@ -474,6 +478,15 @@ Work on this ticket. Create commits as you go. When done, push the branch.`;
474
478
  }
475
479
  const { readFileSync: readFileSync4 } = await import("fs");
476
480
  prompt = readFileSync4(promptFileArg, "utf-8");
481
+ if (!nameOverride && !TICKET_RE.test(input)) {
482
+ const firstLine = prompt.split("\n").find((l) => l.trim().length > 0) || "";
483
+ const clean = firstLine.replace(/^#+\s*/, "");
484
+ const derived = slugify(clean);
485
+ if (derived) {
486
+ id = derived;
487
+ branch = id;
488
+ }
489
+ }
477
490
  }
478
491
  if (windowExists(id)) {
479
492
  log.error(`Agent '${id}' is already running. Use 'dispatch stop ${id}' first.`);
@@ -526,6 +539,7 @@ async function cmdRun(args, config) {
526
539
  let extraArgs = "";
527
540
  let skipWorktree = false;
528
541
  let nameOverride = "";
542
+ let noAttach = false;
529
543
  let i = 0;
530
544
  while (i < args.length) {
531
545
  const arg = args[i];
@@ -562,6 +576,10 @@ async function cmdRun(args, config) {
562
576
  skipWorktree = true;
563
577
  i++;
564
578
  break;
579
+ case "--no-attach":
580
+ noAttach = true;
581
+ i++;
582
+ break;
565
583
  case "--name":
566
584
  case "-n":
567
585
  nameOverride = args[++i];
@@ -589,6 +607,9 @@ async function cmdRun(args, config) {
589
607
  console.log(" dispatch run HEY-837 --base main # branch off main");
590
608
  process.exit(1);
591
609
  }
610
+ if (inputs.length === 0 && promptFile) {
611
+ inputs.push("prompt-file");
612
+ }
592
613
  ensureTmux();
593
614
  if (inputs.length > 1) {
594
615
  log.info(`Batch launching ${inputs.length} agents...`);
@@ -598,7 +619,7 @@ async function cmdRun(args, config) {
598
619
  await launchAgent(input, headless, extraArgs, skipWorktree, promptFile, nameOverride, config);
599
620
  }
600
621
  console.log();
601
- if (!headless && inputs.length === 1) {
622
+ if (!headless && inputs.length === 1 && !noAttach) {
602
623
  log.info("Attaching to tmux session...");
603
624
  log.dim(" Detach with: Ctrl-B then D");
604
625
  console.log();
@@ -687,10 +708,11 @@ function cmdStop(args) {
687
708
  function cmdResume(args, config) {
688
709
  const id = args[0];
689
710
  if (!id) {
690
- log.error("Usage: dispatch resume <agent-id> [--headless]");
711
+ log.error("Usage: dispatch resume <agent-id> [--headless] [--no-attach]");
691
712
  process.exit(1);
692
713
  }
693
714
  const headless = args.includes("--headless") || args.includes("-H");
715
+ const noAttach = args.includes("--no-attach");
694
716
  ensureTmux();
695
717
  const wtPath = worktreePath(id, config);
696
718
  if (!existsSync2(wtPath)) {
@@ -709,7 +731,7 @@ function cmdResume(args, config) {
709
731
  `tmux send-keys -t "${tmuxTarget(id)}" "unset CLAUDECODE && claude --continue ${modelFlag}" Enter`
710
732
  );
711
733
  log.ok(`Resumed agent: ${id} (interactive)`);
712
- tmuxAttach();
734
+ if (!noAttach) tmuxAttach();
713
735
  } else {
714
736
  const resumePrompt = "Continue working on the task.";
715
737
  const claudeCmd = buildClaudeCmd(
@@ -858,7 +880,7 @@ function cmdSetup() {
858
880
  }
859
881
 
860
882
  // src/cli.ts
861
- var VERSION = "0.4.0";
883
+ var VERSION = "0.4.1";
862
884
  function help() {
863
885
  console.log(`dispatch \u2014 Launch Claude Code agents in isolated git worktrees
864
886
 
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.0",
3
+ "version": "0.5.0",
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",