@poulles/worktree-dashboard 0.1.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/src/server.mjs ADDED
@@ -0,0 +1,609 @@
1
+ import http from 'http';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import { execSync, execFile, spawn } from 'child_process';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+
10
+ // ── SSE clients ────────────────────────────────────────────────────────────────
11
+
12
+ const clients = new Set();
13
+
14
+ // ── Running dev-server processes ─────────────────────────────────────────────────
15
+ // Keyed by worktree name → { child, pid, port, script, status, logs, exitCode }
16
+ const processes = new Map();
17
+
18
+ function broadcast(event, data) {
19
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
20
+ for (const res of clients) {
21
+ try { res.write(payload); } catch { clients.delete(res); }
22
+ }
23
+ }
24
+
25
+ // ── Helpers ────────────────────────────────────────────────────────────────────
26
+
27
+ function readBody(req) {
28
+ return new Promise((resolve, reject) => {
29
+ let body = '';
30
+ req.on('data', chunk => (body += chunk));
31
+ req.on('end', () => {
32
+ try { resolve(JSON.parse(body || '{}')); } catch { resolve({}); }
33
+ });
34
+ req.on('error', reject);
35
+ });
36
+ }
37
+
38
+ function json(res, data, status = 200) {
39
+ res.writeHead(status, { 'Content-Type': 'application/json' });
40
+ res.end(JSON.stringify(data));
41
+ }
42
+
43
+ function formatRelativeTime(ts) {
44
+ const seconds = Math.floor((Date.now() - ts) / 1000);
45
+ if (seconds < 10) return 'just now';
46
+ if (seconds < 60) return `${seconds}s ago`;
47
+ const minutes = Math.floor(seconds / 60);
48
+ if (minutes < 60) return `${minutes} min ago`;
49
+ const hours = Math.floor(minutes / 60);
50
+ return `${hours} h ago`;
51
+ }
52
+
53
+ function formatDuration(ms) {
54
+ const minutes = Math.floor(ms / 60000);
55
+ if (minutes < 60) return `${minutes} min`;
56
+ const hours = Math.floor(minutes / 60);
57
+ const rem = minutes % 60;
58
+ return rem > 0 ? `${hours} h ${rem} min` : `${hours} h`;
59
+ }
60
+
61
+ // ── Git worktrees ──────────────────────────────────────────────────────────────
62
+
63
+ function getGitWorktrees(cwd) {
64
+ try {
65
+ const out = execSync('git worktree list --porcelain', { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
66
+ const worktrees = [];
67
+ for (const block of out.trim().split(/\n\n+/)) {
68
+ const wt = {};
69
+ for (const line of block.trim().split('\n')) {
70
+ if (line.startsWith('worktree ')) wt.path = line.slice(9);
71
+ else if (line.startsWith('branch ')) wt.branch = line.slice(7).replace('refs/heads/', '');
72
+ else if (line === 'bare') wt.bare = true;
73
+ else if (line === 'detached') wt.branch = 'HEAD (detached)';
74
+ }
75
+ if (wt.path && !wt.bare) worktrees.push(wt);
76
+ }
77
+ return worktrees;
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
83
+ // Recent commits for a worktree's checked-out branch. Newest first.
84
+ function getRecentCommits(worktreePath, limit = 5) {
85
+ try {
86
+ const out = execSync(
87
+ `git log -n ${limit} --no-color --format=%h%x00%s%x00%cr%x00%an`,
88
+ { cwd: worktreePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] },
89
+ );
90
+ return out
91
+ .split('\n')
92
+ .filter(Boolean)
93
+ .map((line) => {
94
+ const [hash, subject, relativeDate, author] = line.split('\0');
95
+ return { hash, subject, relativeDate, author };
96
+ });
97
+ } catch {
98
+ return [];
99
+ }
100
+ }
101
+
102
+ // ── Dev-server processes ─────────────────────────────────────────────────────────
103
+
104
+ function readScripts(worktreePath, allowlist) {
105
+ try {
106
+ const pkg = JSON.parse(fs.readFileSync(path.join(worktreePath, 'package.json'), 'utf8'));
107
+ const available = Object.keys(pkg.scripts || {});
108
+ return allowlist.filter((s) => available.includes(s));
109
+ } catch {
110
+ return [];
111
+ }
112
+ }
113
+
114
+ // Default port = basePort + worktree index, bumped past any port already in use.
115
+ function assignPort(config, index) {
116
+ const base = config.run?.basePort ?? 4200;
117
+ const used = new Set([...processes.values()].filter((p) => p.status === 'running').map((p) => p.port));
118
+ let port = base + index;
119
+ while (used.has(port)) port++;
120
+ return port;
121
+ }
122
+
123
+ function runState(name) {
124
+ const p = processes.get(name);
125
+ if (!p) return null;
126
+ return { script: p.script, port: p.port, status: p.status, exitCode: p.exitCode ?? null, url: `http://localhost:${p.port}` };
127
+ }
128
+
129
+ function startProcess(config, wt, index, script) {
130
+ const port = assignPort(config, index);
131
+ const cmd = config.run.command.replaceAll('{script}', script).replaceAll('{port}', String(port));
132
+
133
+ // detached so we can kill the whole process group (npm → ng serve) on stop.
134
+ const child = spawn(cmd, {
135
+ cwd: wt.path,
136
+ shell: true,
137
+ detached: true,
138
+ env: { ...process.env, PORT: String(port) },
139
+ });
140
+
141
+ const entry = { child, pid: child.pid, port, script, status: 'running', logs: [], exitCode: null };
142
+ processes.set(wt.name, entry);
143
+
144
+ const append = (buf) => {
145
+ for (const line of buf.toString().split('\n')) {
146
+ if (!line.trim()) continue;
147
+ entry.logs.push(line);
148
+ if (entry.logs.length > 200) entry.logs.shift();
149
+ }
150
+ };
151
+ child.stdout?.on('data', append);
152
+ child.stderr?.on('data', append);
153
+ child.on('exit', (code) => { entry.status = 'exited'; entry.exitCode = code; });
154
+ child.on('error', (err) => { entry.status = 'exited'; entry.logs.push(`[spawn error] ${err.message}`); });
155
+
156
+ return entry;
157
+ }
158
+
159
+ function stopProcess(name) {
160
+ const entry = processes.get(name);
161
+ if (!entry || entry.status !== 'running') return false;
162
+ try {
163
+ // negative pid → kill the entire process group started with detached: true
164
+ process.kill(-entry.child.pid, 'SIGTERM');
165
+ } catch {
166
+ try { entry.child.kill('SIGTERM'); } catch {}
167
+ }
168
+ entry.status = 'exited';
169
+ return true;
170
+ }
171
+
172
+ function stopAllProcesses() {
173
+ for (const name of processes.keys()) stopProcess(name);
174
+ }
175
+
176
+ // ── Claude session JSONL ───────────────────────────────────────────────────────
177
+
178
+ function findProjectDir(worktreePath) {
179
+ const projectsDir = path.join(os.homedir(), '.claude', 'projects');
180
+ if (!fs.existsSync(projectsDir)) return null;
181
+
182
+ // Claude Code names a project's session folder by replacing EVERY
183
+ // non-alphanumeric character in the absolute path with '-' (slashes, dots,
184
+ // and spaces all become '-', including the leading slash).
185
+ const encoded = worktreePath.replace(/[^a-zA-Z0-9]/g, '-');
186
+ const direct = path.join(projectsDir, encoded);
187
+ if (fs.existsSync(direct)) return direct;
188
+
189
+ // Fallback: scan for the closest match by basename. Normalize the basename
190
+ // the same way so worktree folder names with spaces/dots still match.
191
+ let best = null;
192
+ let bestScore = 0;
193
+ const basename = path.basename(worktreePath).replace(/[^a-zA-Z0-9]/g, '-');
194
+ try {
195
+ for (const dir of fs.readdirSync(projectsDir)) {
196
+ if (dir.endsWith(basename) || dir.endsWith(`-${basename}`)) {
197
+ const score = dir.length;
198
+ if (score > bestScore) {
199
+ bestScore = score;
200
+ best = path.join(projectsDir, dir);
201
+ }
202
+ }
203
+ }
204
+ } catch {}
205
+ return best;
206
+ }
207
+
208
+ function latestJsonl(dir) {
209
+ if (!dir || !fs.existsSync(dir)) return null;
210
+ let latestPath = null, latestMtime = 0;
211
+ try {
212
+ for (const file of fs.readdirSync(dir)) {
213
+ if (!file.endsWith('.jsonl')) continue;
214
+ const full = path.join(dir, file);
215
+ const mtime = fs.statSync(full).mtimeMs;
216
+ if (mtime > latestMtime) { latestMtime = mtime; latestPath = full; }
217
+ }
218
+ } catch {}
219
+ return latestPath ? { path: latestPath, mtime: latestMtime } : null;
220
+ }
221
+
222
+ function parseSession(projectDir) {
223
+ const latest = latestJsonl(projectDir);
224
+ if (!latest) return null;
225
+
226
+ let lines;
227
+ try {
228
+ lines = fs.readFileSync(latest.path, 'utf8').split('\n').filter(l => l.trim());
229
+ } catch {
230
+ return null;
231
+ }
232
+
233
+ const events = [];
234
+ for (const line of lines) {
235
+ try { events.push(JSON.parse(line)); } catch {}
236
+ }
237
+ if (events.length === 0) return null;
238
+
239
+ const result = {
240
+ lastTool: null,
241
+ lastFile: null,
242
+ lastLine: null,
243
+ lastMessage: null,
244
+ changedFiles: new Set(),
245
+ tokenCount: 0,
246
+ firstTs: null,
247
+ lastTs: null,
248
+ lastEventType: null,
249
+ lastHasToolCall: false,
250
+ };
251
+
252
+ for (const ev of events) {
253
+ const ts = ev.timestamp ? new Date(ev.timestamp).getTime() : null;
254
+ if (ts && !isNaN(ts)) {
255
+ if (!result.firstTs) result.firstTs = ts;
256
+ result.lastTs = ts;
257
+ }
258
+
259
+ const type = ev.type;
260
+
261
+ if (type === 'assistant') {
262
+ result.lastEventType = 'assistant';
263
+ result.lastHasToolCall = false;
264
+ const content = ev.message?.content ?? [];
265
+ for (const item of Array.isArray(content) ? content : []) {
266
+ if (item.type === 'text' && item.text) {
267
+ result.lastMessage = item.text.trim();
268
+ }
269
+ if (item.type === 'tool_use') {
270
+ result.lastHasToolCall = true;
271
+ result.lastTool = item.name;
272
+ const inp = item.input ?? {};
273
+ const filePath = inp.file_path ?? inp.path ?? null;
274
+ if (filePath) {
275
+ result.lastFile = filePath;
276
+ if (['Write', 'Edit', 'NotebookEdit'].includes(item.name)) {
277
+ result.changedFiles.add(filePath);
278
+ }
279
+ }
280
+ if (inp.line != null) result.lastLine = inp.line;
281
+ else if (inp.old_string != null && result.lastFile) {
282
+ // Edit tool — no line number in spec but keep file
283
+ }
284
+ }
285
+ }
286
+ if (ev.message?.usage?.output_tokens) {
287
+ result.tokenCount = ev.message.usage.output_tokens;
288
+ }
289
+ } else if (type === 'user') {
290
+ result.lastEventType = 'user';
291
+ }
292
+ }
293
+
294
+ // Infer status
295
+ const now = Date.now();
296
+ const idleMs = result.lastTs ? now - result.lastTs : Infinity;
297
+
298
+ let status;
299
+ if (!result.lastTs) {
300
+ status = 'no session';
301
+ } else if (idleMs > 5 * 60 * 1000) {
302
+ status = 'idle';
303
+ } else if (idleMs < 30 * 1000) {
304
+ status = result.lastHasToolCall ? 'working' : 'thinking';
305
+ } else {
306
+ const msg = result.lastMessage ?? '';
307
+ const hasQuestion = msg.includes('?') || msg.endsWith(':');
308
+ status = hasQuestion ? 'waiting' : 'done';
309
+ }
310
+
311
+ return {
312
+ status,
313
+ lastTool: result.lastTool,
314
+ lastFile: result.lastFile,
315
+ lastLine: result.lastLine,
316
+ lastMessage: result.lastMessage,
317
+ lastActivity: result.lastTs ? formatRelativeTime(result.lastTs) : null,
318
+ changedFiles: [...result.changedFiles].slice(-10),
319
+ tokenCount: result.tokenCount,
320
+ sessionDuration: (result.firstTs && result.lastTs) ? formatDuration(result.lastTs - result.firstTs) : null,
321
+ };
322
+ }
323
+
324
+ // ── Worktree prompt log ────────────────────────────────────────────────────────
325
+
326
+ // Latest entry from the central, per-branch prompt log written by the Stop hook
327
+ // (.claude/hooks/log-prompt.sh). Logs live in the MAIN checkout so all worktrees
328
+ // share one gitignored location; the dashboard reads them from there.
329
+ function readWorktreeLog(mainPath, branch) {
330
+ if (!mainPath || !branch) return null;
331
+ const safe = branch.replace(/\//g, '-');
332
+ const file = path.join(mainPath, '.worktree-logs', `${safe}.jsonl`);
333
+ try {
334
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter(l => l.trim());
335
+ if (lines.length === 0) return null;
336
+ const last = JSON.parse(lines[lines.length - 1]);
337
+ // A "start" entry with no following "done" means the turn is still in flight
338
+ // → show it as the current task. Otherwise show the completed summary.
339
+ if (last.kind === 'start') {
340
+ return {
341
+ working: true,
342
+ text: last.prompt ?? null,
343
+ files: [],
344
+ at: last.ts ? formatRelativeTime(last.ts) : null,
345
+ };
346
+ }
347
+ return {
348
+ working: false,
349
+ text: last.summary ?? null,
350
+ files: Array.isArray(last.files) ? last.files : [],
351
+ at: last.ts ? formatRelativeTime(last.ts) : null,
352
+ };
353
+ } catch {
354
+ return null;
355
+ }
356
+ }
357
+
358
+ // ── Worktree data assembly ─────────────────────────────────────────────────────
359
+
360
+ function buildWorktreeData(config) {
361
+ const worktrees = getGitWorktrees(config.cwd);
362
+ const mainPath = worktrees[0]?.path ?? config.cwd;
363
+ return worktrees.map((wt, i) => {
364
+ const name = path.basename(wt.path);
365
+ const isMain = i === 0;
366
+ const projectDir = findProjectDir(wt.path);
367
+ const session = parseSession(projectDir);
368
+ const scripts = config.run ? readScripts(wt.path, config.run.scripts) : [];
369
+ const commits = getRecentCommits(wt.path);
370
+ const lastPrompt = readWorktreeLog(mainPath, wt.branch);
371
+ return {
372
+ name,
373
+ path: wt.path,
374
+ branch: wt.branch ?? '',
375
+ isMain,
376
+ scripts,
377
+ commits,
378
+ lastPrompt,
379
+ defaultPort: config.run ? (config.run.basePort ?? 4200) + i : null,
380
+ running: runState(name),
381
+ status: session?.status ?? 'no session',
382
+ lastTool: session?.lastTool ?? null,
383
+ lastFile: session?.lastFile ?? null,
384
+ lastLine: session?.lastLine ?? null,
385
+ lastMessage: session?.lastMessage ?? null,
386
+ lastActivity: session?.lastActivity ?? null,
387
+ changedFiles: session?.changedFiles ?? [],
388
+ tokenCount: session?.tokenCount ?? 0,
389
+ sessionDuration: session?.sessionDuration ?? null,
390
+ };
391
+ });
392
+ }
393
+
394
+ // ── Route handlers ─────────────────────────────────────────────────────────────
395
+
396
+ function serveHTML(res, config) {
397
+ const htmlPath = path.join(__dirname, 'dashboard.html');
398
+ let html;
399
+ try {
400
+ html = fs.readFileSync(htmlPath, 'utf8');
401
+ } catch {
402
+ res.writeHead(500);
403
+ res.end('dashboard.html not found');
404
+ return;
405
+ }
406
+ const configScript = `<script>window.__CONFIG__ = ${JSON.stringify(config)};</script>`;
407
+ html = html.replace('</head>', configScript + '\n</head>');
408
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
409
+ res.end(html);
410
+ }
411
+
412
+ function handleSSE(req, res) {
413
+ res.writeHead(200, {
414
+ 'Content-Type': 'text/event-stream',
415
+ 'Cache-Control': 'no-cache',
416
+ Connection: 'keep-alive',
417
+ });
418
+ res.write(':\n\n'); // comment to flush
419
+ clients.add(res);
420
+ req.on('close', () => clients.delete(res));
421
+ }
422
+
423
+ function handleGetWorktrees(req, res, config) {
424
+ json(res, buildWorktreeData(config));
425
+ }
426
+
427
+ const VAR_PATTERN = /^[A-Za-z0-9._/-]+$/;
428
+
429
+ // Replace {key} tokens in a string with sanitized variable values.
430
+ function interpolate(template, vars) {
431
+ return String(template).replace(/\{(\w+)\}/g, (match, key) =>
432
+ Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : match
433
+ );
434
+ }
435
+
436
+ // Write the predefined prompt + a folder-open task that auto-launches Claude.
437
+ function writeTemplateFiles(worktreePath, prompt) {
438
+ fs.writeFileSync(path.join(worktreePath, 'CLAUDE_TASK.md'), prompt + '\n');
439
+ const vscodeDir = path.join(worktreePath, '.vscode');
440
+ fs.mkdirSync(vscodeDir, { recursive: true });
441
+ const tasks = {
442
+ version: '2.0.0',
443
+ tasks: [
444
+ {
445
+ label: 'Start Claude',
446
+ type: 'shell',
447
+ command: 'claude',
448
+ args: ['Read CLAUDE_TASK.md and follow the instructions in it.'],
449
+ presentation: { reveal: 'always', panel: 'dedicated', focus: true },
450
+ runOptions: { runOn: 'folderOpen' },
451
+ problemMatcher: [],
452
+ },
453
+ ],
454
+ };
455
+ fs.writeFileSync(path.join(vscodeDir, 'tasks.json'), JSON.stringify(tasks, null, 2) + '\n');
456
+ }
457
+
458
+ function handleCreateWorktree(req, res, config) {
459
+ readBody(req).then(({ name, branch, template, vars }) => {
460
+ let prompt = null;
461
+
462
+ if (template) {
463
+ const def = (config.templates || []).find((t) => t.id === template);
464
+ if (!def) return json(res, { error: `unknown template '${template}'` }, 400);
465
+ vars = vars || {};
466
+ for (const field of def.fields || []) {
467
+ const value = (vars[field.key] ?? '').trim();
468
+ if (!value) return json(res, { error: `${field.label || field.key} is required` }, 400);
469
+ if (!VAR_PATTERN.test(value) || value.includes('..')) {
470
+ return json(res, { error: `${field.label || field.key} contains invalid characters` }, 400);
471
+ }
472
+ vars[field.key] = value;
473
+ }
474
+ name = interpolate(def.name, vars);
475
+ branch = interpolate(def.branch, vars);
476
+ prompt = interpolate(def.prompt, vars);
477
+ }
478
+
479
+ if (!name || !branch) return json(res, { error: 'name and branch required' }, 400);
480
+ const script = path.join(__dirname, 'worktree-add.sh');
481
+ const worktreesPath = path.resolve(config.cwd, config.worktrees);
482
+ execFile('bash', [script, name, branch, worktreesPath], { cwd: config.cwd }, (err, stdout, stderr) => {
483
+ if (err) return json(res, { error: stderr.trim() || err.message }, 500);
484
+ const worktreePath = path.join(worktreesPath, name);
485
+ if (prompt) {
486
+ try {
487
+ writeTemplateFiles(worktreePath, prompt);
488
+ } catch (e) {
489
+ return json(res, { error: `worktree created but setup failed: ${e.message}` }, 500);
490
+ }
491
+ }
492
+ json(res, { ok: true, path: worktreePath });
493
+ });
494
+ });
495
+ }
496
+
497
+ function handleRemoveWorktree(req, res, config) {
498
+ readBody(req).then(({ name }) => {
499
+ if (!name) return json(res, { error: 'name required' }, 400);
500
+ const target = buildWorktreeData(config).find((wt) => wt.name === name);
501
+ if (target?.isMain) return json(res, { error: 'cannot remove the main worktree' }, 400);
502
+ const script = path.join(__dirname, 'worktree-remove.sh');
503
+ const worktreesPath = path.resolve(config.cwd, config.worktrees);
504
+ execFile('bash', [script, name, worktreesPath], { cwd: config.cwd }, (err, stdout, stderr) => {
505
+ if (err) return json(res, { error: stderr.trim() || err.message }, 500);
506
+ json(res, { ok: true });
507
+ });
508
+ });
509
+ }
510
+
511
+ function handleStartScript(req, res, config) {
512
+ readBody(req).then(({ name, script }) => {
513
+ if (!config.run) return json(res, { error: 'script running is not configured' }, 400);
514
+ if (!name || !script) return json(res, { error: 'name and script required' }, 400);
515
+ const existing = processes.get(name);
516
+ if (existing?.status === 'running') return json(res, { error: 'a script is already running' }, 400);
517
+
518
+ const data = buildWorktreeData(config);
519
+ const index = data.findIndex((wt) => wt.name === name);
520
+ const wt = data[index];
521
+ if (!wt) return json(res, { error: 'worktree not found' }, 404);
522
+ if (!wt.scripts.includes(script)) return json(res, { error: `script "${script}" not allowed` }, 400);
523
+
524
+ try {
525
+ const entry = startProcess(config, wt, index, script);
526
+ json(res, { ok: true, port: entry.port, url: `http://localhost:${entry.port}` });
527
+ } catch (err) {
528
+ json(res, { error: err.message }, 500);
529
+ }
530
+ });
531
+ }
532
+
533
+ function handleStopScript(req, res) {
534
+ readBody(req).then(({ name }) => {
535
+ if (!name) return json(res, { error: 'name required' }, 400);
536
+ if (!stopProcess(name)) return json(res, { error: 'no running script' }, 400);
537
+ json(res, { ok: true });
538
+ });
539
+ }
540
+
541
+ function handleLogs(req, res) {
542
+ const name = new URL(req.url, 'http://localhost').searchParams.get('name');
543
+ const entry = name ? processes.get(name) : null;
544
+ json(res, { logs: entry?.logs ?? [], status: entry?.status ?? null });
545
+ }
546
+
547
+ function handleOpen(req, res) {
548
+ readBody(req).then(({ path: p }) => {
549
+ if (!p) return json(res, { error: 'path required' }, 400);
550
+ execFile('code', [p], (err) => {
551
+ if (err) return json(res, { error: err.message }, 500);
552
+ json(res, { ok: true });
553
+ });
554
+ });
555
+ }
556
+
557
+ function handleOpenFile(req, res) {
558
+ readBody(req).then(({ path: p, line }) => {
559
+ if (!p) return json(res, { error: 'path required' }, 400);
560
+ const target = line ? `${p}:${line}` : p;
561
+ execFile('code', ['--goto', target], (err) => {
562
+ if (err) return json(res, { error: err.message }, 500);
563
+ json(res, { ok: true });
564
+ });
565
+ });
566
+ }
567
+
568
+ // ── Server factory ─────────────────────────────────────────────────────────────
569
+
570
+ export function createServer(config) {
571
+ const server = http.createServer((req, res) => {
572
+ res.setHeader('Access-Control-Allow-Origin', 'http://localhost:' + config.port);
573
+
574
+ const { method } = req;
575
+ const pathname = new URL(req.url, `http://localhost`).pathname;
576
+
577
+ if (method === 'GET' && pathname === '/') return serveHTML(res, config);
578
+ if (method === 'GET' && pathname === '/events') return handleSSE(req, res);
579
+ if (method === 'GET' && pathname === '/api/worktrees') return handleGetWorktrees(req, res, config);
580
+ if (method === 'POST' && pathname === '/api/worktree/create') return handleCreateWorktree(req, res, config);
581
+ if (method === 'POST' && pathname === '/api/worktree/remove') return handleRemoveWorktree(req, res, config);
582
+ if (method === 'POST' && pathname === '/api/worktree/start') return handleStartScript(req, res, config);
583
+ if (method === 'POST' && pathname === '/api/worktree/stop') return handleStopScript(req, res);
584
+ if (method === 'GET' && pathname === '/api/worktree/logs') return handleLogs(req, res);
585
+ if (method === 'POST' && pathname === '/api/open') return handleOpen(req, res);
586
+ if (method === 'POST' && pathname === '/api/open-file') return handleOpenFile(req, res);
587
+ if (method === 'OPTIONS') { res.writeHead(204); return res.end(); }
588
+
589
+ res.writeHead(404);
590
+ res.end('Not found');
591
+ });
592
+
593
+ // SSE broadcast loop — every 3 s
594
+ const interval = setInterval(() => {
595
+ const data = buildWorktreeData(config);
596
+ broadcast('worktrees', data);
597
+ }, 3000);
598
+
599
+ server.on('close', () => {
600
+ clearInterval(interval);
601
+ stopAllProcesses();
602
+ });
603
+
604
+ // Detached dev-server groups outlive the parent, so kill them synchronously on
605
+ // exit too — the server 'close' event may not fire before process.exit().
606
+ process.on('exit', stopAllProcesses);
607
+
608
+ return server;
609
+ }
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ # Usage: worktree-add.sh <name> <branch> <worktrees-path>
3
+ set -euo pipefail
4
+
5
+ NAME="$1"
6
+ BRANCH="$2"
7
+ WORKTREES_PATH="${3:-.claude/worktrees}"
8
+
9
+ WORKTREE_PATH="$WORKTREES_PATH/$NAME"
10
+
11
+ if [[ -d "$WORKTREE_PATH" ]]; then
12
+ echo "Error: worktree '$NAME' already exists at $WORKTREE_PATH" >&2
13
+ exit 1
14
+ fi
15
+
16
+ mkdir -p "$WORKTREES_PATH"
17
+
18
+ if git show-ref --verify --quiet "refs/heads/$BRANCH"; then
19
+ git worktree add "$WORKTREE_PATH" "$BRANCH"
20
+ else
21
+ git worktree add -b "$BRANCH" "$WORKTREE_PATH"
22
+ fi
23
+
24
+ echo "Created worktree '$NAME' at $WORKTREE_PATH (branch: $BRANCH)"
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env bash
2
+ # Usage: worktree-remove.sh <name> <worktrees-path>
3
+ set -euo pipefail
4
+
5
+ NAME="$1"
6
+ WORKTREES_PATH="${2:-.claude/worktrees}"
7
+
8
+ WORKTREE_PATH="$WORKTREES_PATH/$NAME"
9
+
10
+ if [[ ! -d "$WORKTREE_PATH" ]]; then
11
+ echo "Error: worktree '$NAME' not found at $WORKTREE_PATH" >&2
12
+ exit 1
13
+ fi
14
+
15
+ git worktree remove --force "$WORKTREE_PATH"
16
+ echo "Removed worktree '$NAME'"