dispatch-agents 0.3.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +163 -0
  3. package/dist/cli.js +903 -0
  4. package/package.json +34 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 paperMoose
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,163 @@
1
+ # dispatch
2
+
3
+ CLI tool for orchestrating Claude Code agents in git worktrees. Dispatch work from Linear tickets or free text, run agents in named terminal tabs or headless.
4
+
5
+ ## The Problem
6
+
7
+ You're running 15 Claude Code sessions and your terminal looks like this:
8
+
9
+ ```
10
+ * Unit... | * App... | * Prod... | * DOI... | * Prof... | * Mov... | * Code...
11
+ ```
12
+
13
+ Which tab is doing what? No idea.
14
+
15
+ ## The Solution
16
+
17
+ ```bash
18
+ dispatch run HEY-837 # Opens named tab: "HEY-837: eval improvements"
19
+ dispatch run HEY-842 --headless # Runs in background
20
+ dispatch list # See all agents + status
21
+ ```
22
+
23
+ Each agent gets:
24
+ - Its own **git worktree** (isolated branch, no conflicts)
25
+ - A **named tmux window** that shows as an iTerm2 tab
26
+ - **Color-coded tabs** so you can tell them apart at a glance
27
+ - Optional **headless mode** for fire-and-forget tasks
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ npm install -g dispatch-agent
33
+ ```
34
+
35
+ Or from source:
36
+
37
+ ```bash
38
+ git clone https://github.com/paperMoose/dispatch.git
39
+ cd dispatch
40
+ npm install && npm run build
41
+ npm link
42
+ ```
43
+
44
+ ### Requirements
45
+
46
+ - Node.js 20+
47
+ - `tmux` — `brew install tmux`
48
+ - `claude` — [Claude Code CLI](https://code.claude.com)
49
+ - `git` — for worktree management
50
+ - iTerm2 (recommended) — for native tab integration via `tmux -CC`
51
+
52
+ ## Usage
53
+
54
+ ### Launch an agent
55
+
56
+ ```bash
57
+ # From a Linear ticket (fetches title + description as prompt)
58
+ dispatch run HEY-837
59
+
60
+ # Free text prompt
61
+ dispatch run "Fix the auth bug in login.py"
62
+
63
+ # Headless (background mode)
64
+ dispatch run HEY-837 --headless
65
+
66
+ # With options
67
+ dispatch run HEY-837 --model sonnet --max-turns 10 --base main
68
+ ```
69
+
70
+ ### Monitor agents
71
+
72
+ ```bash
73
+ # List all running agents with status
74
+ dispatch list
75
+
76
+ # Tail logs from a headless agent
77
+ dispatch logs HEY-837
78
+
79
+ # Attach to the tmux session (see all tabs)
80
+ dispatch attach
81
+ ```
82
+
83
+ ### Manage agents
84
+
85
+ ```bash
86
+ # Stop an agent (keeps worktree)
87
+ dispatch stop HEY-837
88
+
89
+ # Resume a stopped agent
90
+ dispatch resume HEY-837
91
+
92
+ # Clean up worktree + branch
93
+ dispatch cleanup HEY-837
94
+
95
+ # Clean up everything
96
+ dispatch cleanup --all
97
+ ```
98
+
99
+ ## How It Works
100
+
101
+ ```
102
+ dispatch run HEY-837
103
+ |
104
+ |-- 1. Fetch ticket from Linear (title + description)
105
+ |-- 2. git worktree add -b hey-837 .worktrees/hey-837 origin/dev
106
+ |-- 3. tmux new-window -n "HEY-837" (becomes iTerm2 tab)
107
+ |-- 4. Set tab color + badge
108
+ |-- 5. Launch Claude Code with ticket as prompt
109
+ |
110
+ v
111
+ Agent works in isolated worktree, commits, pushes
112
+ ```
113
+
114
+ ### Interactive vs Headless
115
+
116
+ | | Interactive | Headless |
117
+ |---|---|---|
118
+ | **Tab** | Named iTerm2 tab you can watch | Detached tmux window |
119
+ | **Interaction** | You can type into Claude Code | Fire and forget |
120
+ | **Output** | Live in the tab | `dispatch logs <id>` |
121
+ | **Safety** | Claude Code permission prompts | `--allowedTools` pre-approved |
122
+ | **Use case** | Complex tasks, review as you go | Simple/well-defined tasks |
123
+
124
+ ## Configuration
125
+
126
+ ### Environment variables
127
+
128
+ ```bash
129
+ export LINEAR_API_KEY="lin_api_..." # For ticket fetching
130
+ export DISPATCH_BASE_BRANCH="dev" # Default base branch
131
+ export DISPATCH_MODEL="opus" # Default model
132
+ ```
133
+
134
+ ### Config file (`~/.dispatch.yml`)
135
+
136
+ ```yaml
137
+ base_branch: dev
138
+ model: opus
139
+ max_turns: 20
140
+ worktree_dir: .worktrees
141
+ ```
142
+
143
+ ## iTerm2 Integration
144
+
145
+ Dispatch uses `tmux -CC` when it detects iTerm2, which maps tmux windows to native iTerm2 tabs. This means:
146
+
147
+ - Each agent gets a real iTerm2 tab with a clear name
148
+ - Tabs are color-coded to tell agents apart
149
+ - iTerm2 badges show the ticket ID as an overlay
150
+ - Sessions survive terminal crashes (tmux persistence)
151
+
152
+ ### Tab naming
153
+
154
+ Disable automatic title overrides in your shell:
155
+
156
+ ```bash
157
+ # Add to ~/.zshrc
158
+ DISABLE_AUTO_TITLE="true"
159
+ ```
160
+
161
+ ## License
162
+
163
+ MIT
package/dist/cli.js ADDED
@@ -0,0 +1,903 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/config.ts
4
+ import { readFileSync } from "fs";
5
+ import { join } from "path";
6
+ import { homedir } from "os";
7
+ var DEFAULTS = {
8
+ baseBranch: "dev",
9
+ model: "",
10
+ maxTurns: "",
11
+ maxBudget: "",
12
+ allowedTools: "Bash,Read,Write,Edit,Glob,Grep,Task,WebSearch,WebFetch",
13
+ worktreeDir: ".worktrees",
14
+ claudeTimeout: 30
15
+ };
16
+ var KEY_MAP = {
17
+ base_branch: "baseBranch",
18
+ model: "model",
19
+ max_turns: "maxTurns",
20
+ max_budget: "maxBudget",
21
+ allowed_tools: "allowedTools",
22
+ worktree_dir: "worktreeDir",
23
+ claude_timeout: "claudeTimeout"
24
+ };
25
+ function parseSimpleYaml(content) {
26
+ const result = {};
27
+ for (const line of content.split("\n")) {
28
+ const trimmed = line.trim();
29
+ if (!trimmed || trimmed.startsWith("#")) continue;
30
+ const idx = trimmed.indexOf(":");
31
+ if (idx === -1) continue;
32
+ const key = trimmed.slice(0, idx).trim();
33
+ let value = trimmed.slice(idx + 1).trim();
34
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
35
+ value = value.slice(1, -1);
36
+ }
37
+ result[key] = value;
38
+ }
39
+ return result;
40
+ }
41
+ function loadConfig(cliOverrides) {
42
+ const config = { ...DEFAULTS };
43
+ const configPath = process.env.DISPATCH_CONFIG || join(homedir(), ".dispatch.yml");
44
+ try {
45
+ const raw = readFileSync(configPath, "utf-8");
46
+ const parsed = parseSimpleYaml(raw);
47
+ for (const [yamlKey, value] of Object.entries(parsed)) {
48
+ const configKey = KEY_MAP[yamlKey];
49
+ if (configKey) {
50
+ config[configKey] = configKey === "claudeTimeout" ? Number(value) : value;
51
+ }
52
+ }
53
+ } catch {
54
+ }
55
+ const envMap = [
56
+ ["DISPATCH_BASE_BRANCH", "baseBranch"],
57
+ ["DISPATCH_MODEL", "model"],
58
+ ["DISPATCH_MAX_TURNS", "maxTurns"],
59
+ ["DISPATCH_MAX_BUDGET", "maxBudget"],
60
+ ["DISPATCH_ALLOWED_TOOLS", "allowedTools"],
61
+ ["DISPATCH_CLAUDE_TIMEOUT", "claudeTimeout"]
62
+ ];
63
+ for (const [envVar, key] of envMap) {
64
+ const val = process.env[envVar];
65
+ if (val) {
66
+ config[key] = key === "claudeTimeout" ? Number(val) : val;
67
+ }
68
+ }
69
+ if (cliOverrides) {
70
+ for (const [key, value] of Object.entries(cliOverrides)) {
71
+ if (value !== void 0 && value !== "") {
72
+ config[key] = value;
73
+ }
74
+ }
75
+ }
76
+ return config;
77
+ }
78
+
79
+ // src/commands.ts
80
+ import { existsSync as existsSync2, writeFileSync as writeFileSync2, readdirSync } from "fs";
81
+ import { join as join3 } from "path";
82
+ import { execSync as execSync2, spawnSync as spawnSync2 } from "child_process";
83
+
84
+ // src/shell.ts
85
+ import { execSync, spawnSync, spawn } from "child_process";
86
+ import { existsSync, readFileSync as readFileSync2, writeFileSync, appendFileSync, mkdirSync } from "fs";
87
+ import { join as join2 } from "path";
88
+ var RED = "\x1B[0;31m";
89
+ var GREEN = "\x1B[0;32m";
90
+ var YELLOW = "\x1B[0;33m";
91
+ var BLUE = "\x1B[0;34m";
92
+ var BOLD = "\x1B[1m";
93
+ var DIM = "\x1B[2m";
94
+ var NC = "\x1B[0m";
95
+ var fmt = { RED, GREEN, YELLOW, BLUE, BOLD, DIM, NC };
96
+ var log = {
97
+ info: (...args) => console.log(`${BLUE}\u25B8${NC}`, ...args),
98
+ ok: (...args) => console.log(`${GREEN}\u2713${NC}`, ...args),
99
+ warn: (...args) => console.log(`${YELLOW}\u26A0${NC}`, ...args),
100
+ error: (...args) => console.error(`${RED}\u2717${NC}`, ...args),
101
+ dim: (...args) => console.log(`${DIM}${args.join(" ")}${NC}`)
102
+ };
103
+ var TAB_COLORS = [
104
+ "2E86AB",
105
+ "A23B72",
106
+ "F18F01",
107
+ "C73E1D",
108
+ "3B1F2B",
109
+ "44BBA4",
110
+ "E94F37",
111
+ "393E41"
112
+ ];
113
+ var DISPATCH_SESSION = "dispatch";
114
+ function execQuiet(cmd) {
115
+ try {
116
+ return execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
117
+ } catch {
118
+ return null;
119
+ }
120
+ }
121
+ function gitRoot() {
122
+ const root = execQuiet("git rev-parse --show-toplevel");
123
+ if (!root) {
124
+ log.error("Not inside a git repository");
125
+ process.exit(1);
126
+ }
127
+ return root;
128
+ }
129
+ function ensureWorktreeDir(config) {
130
+ const root = gitRoot();
131
+ const dir = join2(root, config.worktreeDir);
132
+ mkdirSync(dir, { recursive: true });
133
+ const gitignore = join2(root, ".gitignore");
134
+ const entry = `${config.worktreeDir}/`;
135
+ if (existsSync(gitignore)) {
136
+ const content = readFileSync2(gitignore, "utf-8");
137
+ if (!content.split("\n").includes(entry)) {
138
+ appendFileSync(gitignore, `
139
+ ${entry}
140
+ `);
141
+ }
142
+ } else {
143
+ writeFileSync(gitignore, `${entry}
144
+ `);
145
+ }
146
+ return dir;
147
+ }
148
+ function worktreePath(id, config) {
149
+ return join2(gitRoot(), config.worktreeDir, id);
150
+ }
151
+ function createWorktree(id, branch, config) {
152
+ const wtPath = worktreePath(id, config);
153
+ if (existsSync(wtPath)) {
154
+ log.warn(`Worktree already exists: ${wtPath}`);
155
+ return;
156
+ }
157
+ ensureWorktreeDir(config);
158
+ log.info(
159
+ `Creating worktree: ${BOLD}${id}${NC} (branch: ${branch} off ${config.baseBranch})`
160
+ );
161
+ execQuiet(`git fetch origin "${config.baseBranch}"`);
162
+ const r1 = spawnSync(
163
+ "git",
164
+ ["worktree", "add", "-b", branch, wtPath, `origin/${config.baseBranch}`],
165
+ { stdio: "pipe" }
166
+ );
167
+ if (r1.status !== 0) {
168
+ const r2 = spawnSync("git", ["worktree", "add", wtPath, branch], {
169
+ stdio: "pipe"
170
+ });
171
+ if (r2.status !== 0) {
172
+ log.error("Failed to create worktree");
173
+ process.exit(1);
174
+ }
175
+ }
176
+ log.ok(`Worktree created at ${wtPath}`);
177
+ }
178
+ function removeWorktree(id, config) {
179
+ const wtPath = worktreePath(id, config);
180
+ if (!existsSync(wtPath)) {
181
+ log.warn(`Worktree not found: ${id}`);
182
+ return true;
183
+ }
184
+ log.info(`Removing worktree: ${id}`);
185
+ const r = spawnSync("git", ["worktree", "remove", "--force", wtPath], {
186
+ stdio: "pipe"
187
+ });
188
+ if (r.status !== 0) {
189
+ log.error(
190
+ `Failed to remove worktree. Try: git worktree remove --force ${wtPath}`
191
+ );
192
+ return false;
193
+ }
194
+ execQuiet("git worktree prune");
195
+ log.ok(`Worktree removed: ${id}`);
196
+ return true;
197
+ }
198
+ function ensureTmux() {
199
+ const r = spawnSync("command", ["-v", "tmux"], { shell: true, stdio: "pipe" });
200
+ if (r.status !== 0) {
201
+ log.error("tmux is required. Install with: brew install tmux");
202
+ process.exit(1);
203
+ }
204
+ }
205
+ function ensureSession() {
206
+ const r = spawnSync("tmux", ["has-session", "-t", DISPATCH_SESSION], {
207
+ stdio: "pipe"
208
+ });
209
+ if (r.status !== 0) {
210
+ execSync(
211
+ `tmux new-session -d -s "${DISPATCH_SESSION}" -n "dispatch"`
212
+ );
213
+ execSync(
214
+ `tmux send-keys -t "${DISPATCH_SESSION}:dispatch" "# Dispatch control window" Enter`
215
+ );
216
+ }
217
+ }
218
+ function windowExists(id) {
219
+ const out = execQuiet(
220
+ `tmux list-windows -t "${DISPATCH_SESSION}" -F "#{window_name}"`
221
+ );
222
+ if (!out) return false;
223
+ return out.split("\n").includes(id);
224
+ }
225
+ function createWindow(id, cwd) {
226
+ if (windowExists(id)) {
227
+ log.warn(`Window '${id}' already exists in tmux session`);
228
+ return false;
229
+ }
230
+ ensureSession();
231
+ const r = spawnSync(
232
+ "tmux",
233
+ ["new-window", "-a", "-t", DISPATCH_SESSION, "-n", id, "-c", cwd],
234
+ { stdio: "pipe" }
235
+ );
236
+ if (r.status !== 0) {
237
+ const err = r.stderr?.toString().trim();
238
+ log.error(`Failed to create tmux window: ${err}`);
239
+ process.exit(1);
240
+ }
241
+ const countStr = execQuiet(
242
+ `tmux list-windows -t "${DISPATCH_SESSION}" | wc -l`
243
+ );
244
+ const count = parseInt(countStr || "1", 10);
245
+ const hex = TAB_COLORS[(count - 1) % TAB_COLORS.length];
246
+ const red = parseInt(hex.slice(0, 2), 16);
247
+ const green = parseInt(hex.slice(2, 4), 16);
248
+ const blue = parseInt(hex.slice(4, 6), 16);
249
+ const target = `${DISPATCH_SESSION}:${id}`;
250
+ execSync(
251
+ `tmux send-keys -t "${target}" "printf '\\\\033]6;1;bg;red;brightness;${red}\\\\007'" Enter`
252
+ );
253
+ execSync(
254
+ `tmux send-keys -t "${target}" "printf '\\\\033]6;1;bg;green;brightness;${green}\\\\007'" Enter`
255
+ );
256
+ execSync(
257
+ `tmux send-keys -t "${target}" "printf '\\\\033]6;1;bg;blue;brightness;${blue}\\\\007'" Enter`
258
+ );
259
+ const badge = Buffer.from(id).toString("base64");
260
+ execSync(
261
+ `tmux send-keys -t "${target}" "printf '\\\\033]1337;SetBadgeFormat=${badge}\\\\007'" Enter`
262
+ );
263
+ return true;
264
+ }
265
+ function tmuxTarget(id) {
266
+ return `${DISPATCH_SESSION}:${id}`;
267
+ }
268
+ function tmuxSendKeys(id, keys) {
269
+ execSync(`tmux send-keys -t "${tmuxTarget(id)}" ${keys}`);
270
+ }
271
+ function tmuxCapture(id, lines) {
272
+ return execQuiet(
273
+ `tmux capture-pane -t "${tmuxTarget(id)}" -p -S -${lines}`
274
+ ) || "";
275
+ }
276
+ function tmuxKillWindow(id) {
277
+ execQuiet(`tmux kill-window -t "${tmuxTarget(id)}"`);
278
+ }
279
+ function tmuxListWindows() {
280
+ return execQuiet(
281
+ `tmux list-windows -t "${DISPATCH_SESSION}" -F "#{window_name}|#{pane_current_command}|#{pane_current_path}|#{pane_dead}"`
282
+ ) || "";
283
+ }
284
+ function tmuxHasSession() {
285
+ const r = spawnSync("tmux", ["has-session", "-t", DISPATCH_SESSION], {
286
+ stdio: "pipe"
287
+ });
288
+ return r.status === 0;
289
+ }
290
+ function tmuxAttach(window) {
291
+ const target = window ? `${DISPATCH_SESSION}:${window}` : DISPATCH_SESSION;
292
+ const hasTTY = process.stdin.isTTY;
293
+ if (hasTTY) {
294
+ const isIterm = process.env.TERM_PROGRAM === "iTerm.app";
295
+ if (isIterm) {
296
+ spawnSync("tmux", ["-CC", "attach", "-t", target], {
297
+ stdio: "inherit"
298
+ });
299
+ } else {
300
+ spawnSync("tmux", ["attach", "-t", target], {
301
+ stdio: "inherit"
302
+ });
303
+ }
304
+ } else if (process.platform === "darwin") {
305
+ const cmd = `tmux attach -t ${target}`;
306
+ const script = openTerminalTabAppleScript(cmd);
307
+ if (script) {
308
+ spawnSync("osascript", ["-e", script], { stdio: "pipe" });
309
+ } else {
310
+ log.warn(`No supported terminal detected. Run manually: tmux attach -t ${target}`);
311
+ }
312
+ } else {
313
+ log.warn(`No TTY available. Run manually: tmux attach -t ${target}`);
314
+ }
315
+ }
316
+ function openTerminalTabAppleScript(command) {
317
+ const terminals = [
318
+ {
319
+ name: "iTerm2",
320
+ bundleId: "com.googlecode.iterm2",
321
+ script: `tell application "iTerm2"
322
+ activate
323
+ tell current window
324
+ create tab with default profile
325
+ tell current session
326
+ write text "${command}"
327
+ end tell
328
+ end tell
329
+ end tell`
330
+ },
331
+ {
332
+ name: "Warp",
333
+ bundleId: "dev.warp.Warp-Stable",
334
+ script: `tell application "Warp"
335
+ activate
336
+ tell application "System Events" to tell process "Warp"
337
+ keystroke "t" using command down
338
+ delay 0.3
339
+ keystroke "${command}"
340
+ key code 36
341
+ end tell
342
+ end tell`
343
+ },
344
+ {
345
+ name: "Terminal",
346
+ bundleId: "com.apple.Terminal",
347
+ script: `tell application "Terminal"
348
+ activate
349
+ do script "${command}"
350
+ end tell`
351
+ }
352
+ ];
353
+ for (const t of terminals) {
354
+ const r = spawnSync("osascript", [
355
+ "-e",
356
+ `tell application "System Events" to return (name of processes) contains "${t.name}"`
357
+ ], { stdio: "pipe" });
358
+ if (r.stdout?.toString().trim() === "true") {
359
+ return t.script;
360
+ }
361
+ }
362
+ return null;
363
+ }
364
+ async function fetchLinearTicket(ticketId) {
365
+ const apiKey = process.env.LINEAR_API_KEY;
366
+ if (!apiKey) {
367
+ log.warn("Could not fetch ticket details (set LINEAR_API_KEY for auto-fetch)");
368
+ return { title: ticketId, description: "" };
369
+ }
370
+ const teamKey = ticketId.split("-")[0];
371
+ const issueNum = parseInt(ticketId.split("-")[1], 10);
372
+ log.info(`Fetching Linear ticket: ${ticketId}`);
373
+ try {
374
+ const response = await fetch("https://api.linear.app/graphql", {
375
+ method: "POST",
376
+ headers: {
377
+ "Content-Type": "application/json",
378
+ Authorization: apiKey
379
+ },
380
+ body: JSON.stringify({
381
+ query: `{ issueSearch(filter: { number: { eq: ${issueNum} }, team: { key: { eq: "${teamKey}" } } }) { nodes { title description identifier url branchName } } }`
382
+ })
383
+ });
384
+ const data = await response.json();
385
+ const nodes = data?.data?.issueSearch?.nodes;
386
+ if (nodes && nodes.length > 0) {
387
+ log.ok(`Ticket: ${nodes[0].title}`);
388
+ return {
389
+ title: nodes[0].title || ticketId,
390
+ description: nodes[0].description || ""
391
+ };
392
+ }
393
+ } catch {
394
+ }
395
+ log.warn("Could not fetch ticket details");
396
+ return { title: ticketId, description: "" };
397
+ }
398
+ function notify(title, message) {
399
+ execQuiet(
400
+ `osascript -e 'display notification "${message}" with title "${title}" sound name "Glass"'`
401
+ );
402
+ }
403
+ function waitForClaude(id, timeout) {
404
+ let waited = 0;
405
+ while (waited < timeout) {
406
+ const content = tmuxCapture(id, 5);
407
+ if (/^\s*[>?]\s*$/m.test(content) || /╭|Welcome|claude/i.test(content)) {
408
+ return;
409
+ }
410
+ spawnSync("sleep", ["1"]);
411
+ waited++;
412
+ }
413
+ log.warn(`Claude Code may not be fully initialized (waited ${timeout}s)`);
414
+ }
415
+ function tailFile(path) {
416
+ return spawn("tail", ["-f", path], { stdio: "inherit" });
417
+ }
418
+
419
+ // src/commands.ts
420
+ var TICKET_RE = /^[A-Z]+-[0-9]+$/;
421
+ function buildClaudeCmd(prompt, mode, wtPath, config, extraArgs) {
422
+ let cmd = "claude";
423
+ if (mode === "headless") cmd += " -p";
424
+ if (config.model) cmd += ` --model ${config.model}`;
425
+ if (mode === "headless") {
426
+ cmd += ` --allowedTools "${config.allowedTools}"`;
427
+ if (config.maxTurns) cmd += ` --max-turns ${config.maxTurns}`;
428
+ if (config.maxBudget) cmd += ` --max-budget-usd ${config.maxBudget}`;
429
+ cmd += " --output-format json";
430
+ }
431
+ if (extraArgs) cmd += ` ${extraArgs}`;
432
+ if (mode === "headless") {
433
+ const promptFile = join3(wtPath, ".dispatch-prompt.txt");
434
+ writeFileSync2(promptFile, prompt);
435
+ cmd += ` "$(cat '${promptFile}')"`;
436
+ }
437
+ return cmd;
438
+ }
439
+ async function launchAgent(input, headless, extraArgs, skipWorktree, promptFileArg, nameOverride, config) {
440
+ let id;
441
+ let prompt;
442
+ let branch;
443
+ if (TICKET_RE.test(input)) {
444
+ id = input;
445
+ branch = input.toLowerCase();
446
+ const ticket = await fetchLinearTicket(input);
447
+ if (ticket.description) {
448
+ prompt = `Linear ticket ${input}: ${ticket.title}
449
+
450
+ ${ticket.description}
451
+
452
+ Work on this ticket. Create commits as you go. When done, push the branch.`;
453
+ } else {
454
+ prompt = `Work on ticket ${input}: ${ticket.title}. Create commits as you go. When done, push the branch.`;
455
+ }
456
+ } else {
457
+ const suffix = String(Date.now()).slice(-6);
458
+ id = `task-${suffix}`;
459
+ branch = id;
460
+ prompt = input;
461
+ }
462
+ if (nameOverride) {
463
+ id = nameOverride;
464
+ branch = nameOverride.toLowerCase();
465
+ }
466
+ if (promptFileArg) {
467
+ if (!existsSync2(promptFileArg)) {
468
+ log.error(`Prompt file not found: ${promptFileArg}`);
469
+ return;
470
+ }
471
+ if (TICKET_RE.test(input)) {
472
+ log.warn(`Ticket prompt for ${input} overridden by --prompt-file`);
473
+ }
474
+ const { readFileSync: readFileSync3 } = await import("fs");
475
+ prompt = readFileSync3(promptFileArg, "utf-8");
476
+ }
477
+ if (windowExists(id)) {
478
+ log.error(`Agent '${id}' is already running. Use 'dispatch stop ${id}' first.`);
479
+ return;
480
+ }
481
+ let wtPath;
482
+ if (skipWorktree) {
483
+ wtPath = gitRoot();
484
+ } else {
485
+ createWorktree(id, branch, config);
486
+ wtPath = worktreePath(id, config);
487
+ }
488
+ createWindow(id, wtPath);
489
+ const mode = headless ? "headless" : "interactive";
490
+ const claudeCmd = buildClaudeCmd(prompt, mode, wtPath, config, extraArgs);
491
+ if (mode === "interactive") {
492
+ const modelFlag = config.model ? `--model ${config.model}` : "";
493
+ execSync2(
494
+ `tmux send-keys -t "${tmuxTarget(id)}" "unset CLAUDECODE && claude ${modelFlag}" Enter`
495
+ );
496
+ waitForClaude(id, config.claudeTimeout);
497
+ const pf = join3(wtPath, ".dispatch-prompt.txt");
498
+ writeFileSync2(pf, prompt);
499
+ const bufName = `dispatch-${id.replace(/[^a-zA-Z0-9]/g, "-")}`;
500
+ execSync2(`tmux load-buffer -b "${bufName}" "${pf}"`);
501
+ execSync2(
502
+ `tmux paste-buffer -b "${bufName}" -t "${tmuxTarget(id)}"`
503
+ );
504
+ execQuiet(`tmux delete-buffer -b "${bufName}"`);
505
+ execSync2(`tmux send-keys -t "${tmuxTarget(id)}" Enter`);
506
+ } else {
507
+ const logFile = join3(wtPath, ".dispatch.log");
508
+ execSync2(
509
+ `tmux send-keys -t "${tmuxTarget(id)}" "unset CLAUDECODE && ${claudeCmd} 2>&1 | tee -a ${logFile}; dispatch _notify-done ${id}" Enter`
510
+ );
511
+ }
512
+ console.log();
513
+ log.ok(`Agent ${fmt.BOLD}${id}${fmt.NC} launched (${mode})`);
514
+ log.dim(` Worktree: ${wtPath}`);
515
+ log.dim(` Branch: ${branch}`);
516
+ if (headless) {
517
+ log.dim(` Logs: dispatch logs ${id}`);
518
+ log.dim(` Stop: dispatch stop ${id}`);
519
+ }
520
+ }
521
+ async function cmdRun(args, config) {
522
+ const inputs = [];
523
+ let headless = false;
524
+ let promptFile = "";
525
+ let extraArgs = "";
526
+ let skipWorktree = false;
527
+ let nameOverride = "";
528
+ let i = 0;
529
+ while (i < args.length) {
530
+ const arg = args[i];
531
+ switch (arg) {
532
+ case "--headless":
533
+ case "-H":
534
+ headless = true;
535
+ i++;
536
+ break;
537
+ case "--model":
538
+ case "-m":
539
+ config.model = args[++i];
540
+ i++;
541
+ break;
542
+ case "--max-turns":
543
+ config.maxTurns = args[++i];
544
+ i++;
545
+ break;
546
+ case "--max-budget":
547
+ config.maxBudget = args[++i];
548
+ i++;
549
+ break;
550
+ case "--base":
551
+ case "-b":
552
+ config.baseBranch = args[++i];
553
+ i++;
554
+ break;
555
+ case "--prompt-file":
556
+ case "-f":
557
+ promptFile = args[++i];
558
+ i++;
559
+ break;
560
+ case "--no-worktree":
561
+ skipWorktree = true;
562
+ i++;
563
+ break;
564
+ case "--name":
565
+ case "-n":
566
+ nameOverride = args[++i];
567
+ i++;
568
+ break;
569
+ default:
570
+ if (arg.startsWith("--")) {
571
+ extraArgs += ` ${arg}`;
572
+ } else {
573
+ inputs.push(arg);
574
+ }
575
+ i++;
576
+ break;
577
+ }
578
+ }
579
+ if (inputs.length === 0 && !promptFile) {
580
+ log.error("Usage: dispatch run <ticket|prompt> [ticket2 ...] [options]");
581
+ console.log();
582
+ console.log(" dispatch run HEY-837 # from Linear ticket");
583
+ console.log(" dispatch run HEY-837 HEY-838 HEY-839 # batch launch");
584
+ console.log(" dispatch run HEY-837 --headless # run in background");
585
+ console.log(' dispatch run "Fix the auth bug" # free text prompt');
586
+ console.log(" dispatch run HEY-837 --model sonnet # specific model");
587
+ console.log(" dispatch run HEY-837 --max-turns 10 # limit turns");
588
+ console.log(" dispatch run HEY-837 --base main # branch off main");
589
+ process.exit(1);
590
+ }
591
+ ensureTmux();
592
+ if (inputs.length > 1) {
593
+ log.info(`Batch launching ${inputs.length} agents...`);
594
+ console.log();
595
+ }
596
+ for (const input of inputs) {
597
+ await launchAgent(input, headless, extraArgs, skipWorktree, promptFile, nameOverride, config);
598
+ }
599
+ console.log();
600
+ if (!headless && inputs.length === 1) {
601
+ log.info("Attaching to tmux session...");
602
+ log.dim(" Detach with: Ctrl-B then D");
603
+ console.log();
604
+ tmuxAttach();
605
+ } else if (inputs.length > 1) {
606
+ log.ok(`All agents launched. Use ${fmt.BOLD}dispatch attach${fmt.NC} to view tabs.`);
607
+ }
608
+ }
609
+ function cmdList(config) {
610
+ ensureTmux();
611
+ if (!tmuxHasSession()) {
612
+ log.info("No dispatch session running");
613
+ return;
614
+ }
615
+ console.log();
616
+ console.log(`${fmt.BOLD}Running Agents${fmt.NC}`);
617
+ console.log(
618
+ `${fmt.DIM}\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500${fmt.NC}`
619
+ );
620
+ const root = execQuiet("git rev-parse --show-toplevel") || "";
621
+ const lines = tmuxListWindows();
622
+ for (const line of lines.split("\n")) {
623
+ if (!line) continue;
624
+ const [name, cmd, path, dead] = line.split("|");
625
+ if (name === "dispatch") continue;
626
+ let statusIcon;
627
+ let statusText;
628
+ if (dead === "1") {
629
+ statusIcon = `${fmt.RED}\u25CF${fmt.NC}`;
630
+ statusText = "exited";
631
+ } else if (cmd === "claude" || cmd === "node") {
632
+ statusIcon = `${fmt.GREEN}\u25CF${fmt.NC}`;
633
+ statusText = "running";
634
+ } else {
635
+ statusIcon = `${fmt.YELLOW}\u25CF${fmt.NC}`;
636
+ statusText = "idle";
637
+ }
638
+ const shortPath = root && path.startsWith(root + "/") ? path.slice(root.length + 1) : path;
639
+ console.log(
640
+ ` ${statusIcon} ${fmt.BOLD}${name}${fmt.NC} ${fmt.DIM}(${statusText})${fmt.NC}`
641
+ );
642
+ console.log(` ${fmt.DIM}path: ${shortPath}${fmt.NC}`);
643
+ }
644
+ console.log();
645
+ }
646
+ function cmdLogs(args, config) {
647
+ const id = args[0];
648
+ if (!id) {
649
+ log.error("Usage: dispatch logs <agent-id>");
650
+ process.exit(1);
651
+ }
652
+ const wtPath = worktreePath(id, config);
653
+ const logFile = join3(wtPath, ".dispatch.log");
654
+ if (existsSync2(logFile)) {
655
+ log.info(`Tailing log: ${logFile}`);
656
+ const child = tailFile(logFile);
657
+ process.on("SIGINT", () => {
658
+ child.kill();
659
+ process.exit(0);
660
+ });
661
+ child.on("exit", () => process.exit(0));
662
+ } else if (windowExists(id)) {
663
+ log.info("Capturing output from tmux pane...");
664
+ console.log(tmuxCapture(id, 100));
665
+ } else {
666
+ log.error(`Agent '${id}' not found`);
667
+ process.exit(1);
668
+ }
669
+ }
670
+ function cmdStop(args) {
671
+ const id = args[0];
672
+ if (!id) {
673
+ log.error("Usage: dispatch stop <agent-id>");
674
+ process.exit(1);
675
+ }
676
+ if (!windowExists(id)) {
677
+ log.warn(`Agent '${id}' is not running`);
678
+ return;
679
+ }
680
+ log.info(`Stopping agent: ${id}`);
681
+ tmuxSendKeys(id, "C-c");
682
+ spawnSync2("sleep", ["1"]);
683
+ tmuxKillWindow(id);
684
+ log.ok(`Agent stopped: ${id}`);
685
+ }
686
+ function cmdResume(args, config) {
687
+ const id = args[0];
688
+ if (!id) {
689
+ log.error("Usage: dispatch resume <agent-id> [--headless]");
690
+ process.exit(1);
691
+ }
692
+ const headless = args.includes("--headless") || args.includes("-H");
693
+ ensureTmux();
694
+ const wtPath = worktreePath(id, config);
695
+ if (!existsSync2(wtPath)) {
696
+ log.error(`Worktree not found for '${id}'. Nothing to resume.`);
697
+ process.exit(1);
698
+ }
699
+ if (windowExists(id)) {
700
+ log.warn(`Agent '${id}' is already running. Attaching...`);
701
+ tmuxAttach();
702
+ return;
703
+ }
704
+ createWindow(id, wtPath);
705
+ if (!headless) {
706
+ const modelFlag = config.model ? `--model ${config.model}` : "";
707
+ execSync2(
708
+ `tmux send-keys -t "${tmuxTarget(id)}" "unset CLAUDECODE && claude --continue ${modelFlag}" Enter`
709
+ );
710
+ log.ok(`Resumed agent: ${id} (interactive)`);
711
+ tmuxAttach();
712
+ } else {
713
+ const resumePrompt = "Continue working on the task.";
714
+ const claudeCmd = buildClaudeCmd(
715
+ resumePrompt,
716
+ "headless",
717
+ wtPath,
718
+ config,
719
+ "--continue"
720
+ );
721
+ const logFile = join3(wtPath, ".dispatch.log");
722
+ execSync2(
723
+ `tmux send-keys -t "${tmuxTarget(id)}" "unset CLAUDECODE && ${claudeCmd} 2>&1 | tee -a ${logFile}; dispatch _notify-done ${id}" Enter`
724
+ );
725
+ log.ok(`Resumed agent: ${id} (headless)`);
726
+ }
727
+ }
728
+ function cmdCleanup(args, config) {
729
+ let id = "";
730
+ let all = false;
731
+ let deleteBranch = false;
732
+ for (const arg of args) {
733
+ switch (arg) {
734
+ case "--all":
735
+ all = true;
736
+ break;
737
+ case "--delete-branch":
738
+ deleteBranch = true;
739
+ break;
740
+ default:
741
+ id = arg;
742
+ break;
743
+ }
744
+ }
745
+ if (all) {
746
+ log.info("Cleaning up all worktrees...");
747
+ const root = gitRoot();
748
+ const wtDir = join3(root, config.worktreeDir);
749
+ if (!existsSync2(wtDir)) {
750
+ log.info("No worktrees to clean up");
751
+ return;
752
+ }
753
+ let entries;
754
+ try {
755
+ entries = readdirSync(wtDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
756
+ } catch {
757
+ log.info("No worktrees to clean up");
758
+ return;
759
+ }
760
+ for (const name of entries) {
761
+ if (windowExists(name)) {
762
+ cmdStop([name]);
763
+ }
764
+ removeWorktree(name, config);
765
+ if (deleteBranch) {
766
+ const r = spawnSync2("git", ["branch", "-D", name], { stdio: "pipe" });
767
+ if (r.status === 0) {
768
+ log.ok(`Deleted branch: ${name}`);
769
+ } else {
770
+ log.warn(`Branch not found: ${name}`);
771
+ }
772
+ }
773
+ }
774
+ } else if (id) {
775
+ if (windowExists(id)) {
776
+ cmdStop([id]);
777
+ }
778
+ removeWorktree(id, config);
779
+ if (deleteBranch) {
780
+ const r = spawnSync2("git", ["branch", "-D", id], { stdio: "pipe" });
781
+ if (r.status === 0) {
782
+ log.ok(`Deleted branch: ${id}`);
783
+ } else {
784
+ log.warn(`Branch not found: ${id}`);
785
+ }
786
+ }
787
+ } else {
788
+ log.error("Usage: dispatch cleanup <agent-id> | --all [--delete-branch]");
789
+ process.exit(1);
790
+ }
791
+ }
792
+ function cmdAttach(args) {
793
+ ensureTmux();
794
+ if (!tmuxHasSession()) {
795
+ log.error("No dispatch session running");
796
+ process.exit(1);
797
+ }
798
+ const window = args[0] || void 0;
799
+ tmuxAttach(window);
800
+ }
801
+ function cmdNotifyDone(args) {
802
+ const agentId = args[0] || "unknown";
803
+ notify("Dispatch", `Agent ${agentId} finished`);
804
+ log.ok(`Agent ${agentId} completed`);
805
+ }
806
+
807
+ // src/cli.ts
808
+ var VERSION = "0.3.0";
809
+ function help() {
810
+ console.log(`dispatch \u2014 Orchestrate Claude Code agents in git worktrees
811
+
812
+ Usage:
813
+ dispatch run <ticket|prompt> [more...] [options] Launch agent(s)
814
+ dispatch list Show running agents
815
+ dispatch logs <id> Tail agent output
816
+ dispatch stop <id> Stop an agent
817
+ dispatch resume <id> [--headless] Resume a stopped agent
818
+ dispatch cleanup <id> | --all [--delete-branch] Remove worktree(s)
819
+ dispatch attach Attach to tmux session
820
+
821
+ Run Options:
822
+ --headless, -H Run in background (no interactive tab)
823
+ --model, -m <model> Claude model (sonnet, opus, etc.)
824
+ --max-turns <n> Limit agent turns (headless only)
825
+ --max-budget <usd> Cap spending (headless only)
826
+ --base, -b <branch> Base branch for worktree (default: dev)
827
+ --prompt-file, -f <file> Load prompt from file
828
+ --name, -n <name> Override agent name and branch (e.g., HEY-879)
829
+ --no-worktree Run in current directory (no worktree)
830
+
831
+ Examples:
832
+ dispatch run HEY-837 Interactive, from Linear ticket
833
+ dispatch run HEY-837 --headless Background mode
834
+ dispatch run HEY-837 HEY-838 HEY-839 Batch launch (multiple agents)
835
+ dispatch run "Fix the auth bug" Free text prompt
836
+ dispatch run HEY-837 -m sonnet Use Sonnet model
837
+ dispatch run HEY-837 --max-turns 10 Limit to 10 turns
838
+
839
+ Environment:
840
+ LINEAR_API_KEY Linear API key for ticket fetching
841
+ DISPATCH_BASE_BRANCH Default base branch (default: dev)
842
+ DISPATCH_MODEL Default model
843
+ DISPATCH_CONFIG Config file path (default: ~/.dispatch.yml)
844
+
845
+ Config (~/.dispatch.yml):
846
+ base_branch: dev
847
+ model: opus
848
+ max_turns: 20
849
+ claude_timeout: 30
850
+ worktree_dir: .worktrees`);
851
+ }
852
+ async function main() {
853
+ const args = process.argv.slice(2);
854
+ const cmd = args[0] || "help";
855
+ const rest = args.slice(1);
856
+ const config = loadConfig();
857
+ switch (cmd) {
858
+ case "run":
859
+ await cmdRun(rest, config);
860
+ break;
861
+ case "list":
862
+ case "ls":
863
+ cmdList(config);
864
+ break;
865
+ case "logs":
866
+ cmdLogs(rest, config);
867
+ break;
868
+ case "stop":
869
+ cmdStop(rest);
870
+ break;
871
+ case "resume":
872
+ cmdResume(rest, config);
873
+ break;
874
+ case "cleanup":
875
+ cmdCleanup(rest, config);
876
+ break;
877
+ case "attach":
878
+ cmdAttach(rest);
879
+ break;
880
+ case "_notify-done":
881
+ cmdNotifyDone(rest);
882
+ break;
883
+ case "version":
884
+ case "-v":
885
+ case "--version":
886
+ console.log(`dispatch v${VERSION}`);
887
+ break;
888
+ case "help":
889
+ case "-h":
890
+ case "--help":
891
+ help();
892
+ break;
893
+ default:
894
+ console.error(`\x1B[0;31m\u2717\x1B[0m Unknown command: ${cmd}`);
895
+ console.log();
896
+ help();
897
+ process.exit(1);
898
+ }
899
+ }
900
+ main().catch((err) => {
901
+ console.error(`\x1B[0;31m\u2717\x1B[0m ${err.message}`);
902
+ process.exit(1);
903
+ });
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "dispatch-agents",
3
+ "version": "0.3.0",
4
+ "description": "Orchestrate Claude Code agents in git worktrees",
5
+ "type": "module",
6
+ "bin": {
7
+ "dispatch": "./dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsup",
11
+ "dev": "tsup --watch",
12
+ "test": "node --import tsx --test tests/*.test.ts"
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "keywords": [
18
+ "claude",
19
+ "agent",
20
+ "tmux",
21
+ "worktree",
22
+ "dispatch"
23
+ ],
24
+ "license": "MIT",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/paperMoose/dispatch.git"
28
+ },
29
+ "devDependencies": {
30
+ "tsup": "^8.0.0",
31
+ "tsx": "^4.0.0",
32
+ "typescript": "^5.4.0"
33
+ }
34
+ }