claude-garage 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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +184 -0
  3. package/bin/garage.js +143 -0
  4. package/daemon/package.json +13 -0
  5. package/daemon/src/diff.js +465 -0
  6. package/daemon/src/editor.js +63 -0
  7. package/daemon/src/events.js +54 -0
  8. package/daemon/src/hooks.js +241 -0
  9. package/daemon/src/index.js +76 -0
  10. package/daemon/src/notify.js +80 -0
  11. package/daemon/src/picker.js +48 -0
  12. package/daemon/src/poller.js +134 -0
  13. package/daemon/src/registry.js +189 -0
  14. package/daemon/src/security.js +39 -0
  15. package/daemon/src/sessions.js +255 -0
  16. package/daemon/src/status.js +59 -0
  17. package/daemon/src/term.js +94 -0
  18. package/daemon/src/tmux.js +124 -0
  19. package/daemon/src/workspaces.js +139 -0
  20. package/daemon/src/worktrees.js +180 -0
  21. package/package.json +57 -0
  22. package/ui/dist/apple-touch-icon.png +0 -0
  23. package/ui/dist/assets/google-sans-code-latin-400-normal-C-TpydLV.woff +0 -0
  24. package/ui/dist/assets/google-sans-code-latin-400-normal-CqaqKeEp.woff2 +0 -0
  25. package/ui/dist/assets/google-sans-code-latin-600-normal-BUCrGHE5.woff2 +0 -0
  26. package/ui/dist/assets/google-sans-code-latin-600-normal-CKee-OZj.woff +0 -0
  27. package/ui/dist/assets/google-sans-code-latin-700-normal-0vT4k3S3.woff2 +0 -0
  28. package/ui/dist/assets/google-sans-code-latin-700-normal-BvnkA064.woff +0 -0
  29. package/ui/dist/assets/google-sans-code-latin-ext-400-normal-CALCk2lB.woff +0 -0
  30. package/ui/dist/assets/google-sans-code-latin-ext-400-normal-xZllot2U.woff2 +0 -0
  31. package/ui/dist/assets/google-sans-code-latin-ext-600-normal-CNNK3O9-.woff2 +0 -0
  32. package/ui/dist/assets/google-sans-code-latin-ext-600-normal-DDkmmTp6.woff +0 -0
  33. package/ui/dist/assets/google-sans-code-latin-ext-700-normal-CxgnixdG.woff2 +0 -0
  34. package/ui/dist/assets/google-sans-code-latin-ext-700-normal-bxgt_uDG.woff +0 -0
  35. package/ui/dist/assets/google-sans-code-math-400-normal-C2EgcUvD.woff2 +0 -0
  36. package/ui/dist/assets/google-sans-code-math-400-normal-CgFzjhah.woff +0 -0
  37. package/ui/dist/assets/google-sans-code-math-600-normal-B5PK-qTM.woff +0 -0
  38. package/ui/dist/assets/google-sans-code-math-600-normal-DT_RT86i.woff2 +0 -0
  39. package/ui/dist/assets/google-sans-code-math-700-normal-C65M3EbO.woff +0 -0
  40. package/ui/dist/assets/google-sans-code-math-700-normal-CgTZgjvl.woff2 +0 -0
  41. package/ui/dist/assets/google-sans-code-symbols-400-normal-BtBR4e7S.woff2 +0 -0
  42. package/ui/dist/assets/google-sans-code-symbols-400-normal-NSSmFXdP.woff +0 -0
  43. package/ui/dist/assets/google-sans-code-symbols-600-normal-DdWLfSlP.woff2 +0 -0
  44. package/ui/dist/assets/google-sans-code-symbols-600-normal-QtnQvwms.woff +0 -0
  45. package/ui/dist/assets/google-sans-code-symbols-700-normal-Ceb9rWtn.woff +0 -0
  46. package/ui/dist/assets/google-sans-code-symbols-700-normal-DDa4ndCu.woff2 +0 -0
  47. package/ui/dist/assets/google-sans-code-symbols2-400-normal-CicwJrzf.woff +0 -0
  48. package/ui/dist/assets/google-sans-code-symbols2-600-normal-BtzXZASM.woff +0 -0
  49. package/ui/dist/assets/google-sans-code-symbols2-700-normal-Cci1DrKv.woff +0 -0
  50. package/ui/dist/assets/google-sans-code-vietnamese-400-normal-CWRUr5CO.woff2 +0 -0
  51. package/ui/dist/assets/google-sans-code-vietnamese-400-normal-DhSdRbI2.woff +0 -0
  52. package/ui/dist/assets/google-sans-code-vietnamese-600-normal-DBBK0KXU.woff +0 -0
  53. package/ui/dist/assets/google-sans-code-vietnamese-600-normal-zsb-D4cZ.woff2 +0 -0
  54. package/ui/dist/assets/google-sans-code-vietnamese-700-normal-DDJUOtHZ.woff +0 -0
  55. package/ui/dist/assets/google-sans-code-vietnamese-700-normal-jZ3jhCNl.woff2 +0 -0
  56. package/ui/dist/assets/index-B9ScE6Rx.css +32 -0
  57. package/ui/dist/assets/index-BjoMvOWt.js +70 -0
  58. package/ui/dist/favicon-32.png +0 -0
  59. package/ui/dist/index.html +23 -0
@@ -0,0 +1,465 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { getWorkspace, getSessionMeta } from "./registry.js";
6
+ import { listPanePaths, resolveBranch } from "./tmux.js";
7
+
8
+ // D-diff-cmd (openspec/changes/p2-diff-review/design.md): every git
9
+ // invocation here is read-only — status/diff/rev-parse only, never `add -N`
10
+ // or anything else that would touch the index. Verified by the gate-1.3
11
+ // "status identical before/after repeated calls" check.
12
+ const run = promisify(execFile);
13
+
14
+ // Diffs can legitimately be larger than Node's 1MB default execFile
15
+ // maxBuffer; we apply our own byte budgets below, so let git hand back
16
+ // whatever it has and truncate ourselves rather than erroring on big output.
17
+ const MAX_BUFFER = 64 * 1024 * 1024;
18
+
19
+ const FILE_BUDGET = 30 * 1024; // ~30KB per file, cut at a hunk boundary when possible
20
+ const TOTAL_BUDGET = 300 * 1024; // ~300KB total diff bytes attached to one response
21
+
22
+ const BINARY_RE = /^Binary files .* differ$/m;
23
+
24
+ async function git(dir, args) {
25
+ return run("git", ["-C", dir, ...args], { maxBuffer: MAX_BUFFER });
26
+ }
27
+
28
+ // Parses `git status --porcelain=v2 -z` output. -z NUL-terminates each
29
+ // record and, for type "2" (rename/copy) records, also uses a NUL (instead
30
+ // of the usual tab) to separate the new path from the original path — so a
31
+ // rename record consumes an extra token from the split.
32
+ function parseStatus(raw) {
33
+ const tokens = raw.split("\0").filter((t) => t.length > 0);
34
+ const entries = [];
35
+ let i = 0;
36
+ while (i < tokens.length) {
37
+ const token = tokens[i];
38
+ const type = token[0];
39
+ if (type === "1") {
40
+ const parts = token.split(" ");
41
+ entries.push({ kind: "ordinary", xy: parts[1], path: parts.slice(8).join(" ") });
42
+ i += 1;
43
+ } else if (type === "2") {
44
+ const parts = token.split(" ");
45
+ const path = parts.slice(9).join(" ");
46
+ const origPath = tokens[i + 1];
47
+ entries.push({ kind: "rename", xy: parts[1], path, origPath });
48
+ i += 2;
49
+ } else if (type === "u") {
50
+ const parts = token.split(" ");
51
+ entries.push({ kind: "ordinary", xy: "MM", path: parts.slice(10).join(" ") });
52
+ i += 1;
53
+ } else if (type === "?") {
54
+ entries.push({ kind: "untracked", path: token.slice(2) });
55
+ i += 1;
56
+ } else {
57
+ // "!" (ignored) or anything unrecognized — nothing to report.
58
+ i += 1;
59
+ }
60
+ }
61
+ return entries;
62
+ }
63
+
64
+ function changeTypeFor(entry) {
65
+ if (entry.kind === "untracked") return "untracked";
66
+ if (entry.kind === "rename") return "renamed";
67
+ const [x, y] = entry.xy;
68
+ if (x === "A" || y === "A") return "added";
69
+ if (x === "D" || y === "D") return "deleted";
70
+ return "modified";
71
+ }
72
+
73
+ // Splits one `git diff` blob into per-file chunks keyed by the file's
74
+ // current ("b/") path. Note: git's own -M similarity detection inside this
75
+ // single bulk diff can occasionally disagree with `git status`'s rename
76
+ // pairing (e.g. a rename with a big enough content change can show up here
77
+ // as a plain delete+add instead of one "rename from/rename to" chunk) — in
78
+ // that case this map still has a chunk keyed by the new path (the "add"
79
+ // half), just without the old-path pairing in its text. Known, accepted gap
80
+ // per design's Risks section on rename-detection nuance.
81
+ function splitDiffByFile(diffText) {
82
+ const map = new Map();
83
+ if (!diffText) return map;
84
+ const pieces = diffText.split(/(?=^diff --git )/m).filter(Boolean);
85
+ for (const chunk of pieces) {
86
+ const nl = chunk.indexOf("\n");
87
+ const headerLine = nl === -1 ? chunk : chunk.slice(0, nl);
88
+ const m = headerLine.match(/^diff --git a\/(.*) b\/(.*)$/);
89
+ if (m) map.set(m[2], chunk.replace(/\n$/, ""));
90
+ }
91
+ return map;
92
+ }
93
+
94
+ function countStats(diffText) {
95
+ let additions = 0;
96
+ let deletions = 0;
97
+ for (const line of diffText.split("\n")) {
98
+ if (line.startsWith("+++ ") || line.startsWith("--- ")) continue;
99
+ if (line.startsWith("+")) additions++;
100
+ else if (line.startsWith("-")) deletions++;
101
+ }
102
+ return { additions, deletions };
103
+ }
104
+
105
+ function splitHeaderAndHunks(diffText) {
106
+ const lines = diffText.split("\n");
107
+ const firstHunk = lines.findIndex((l) => l.startsWith("@@ "));
108
+ if (firstHunk === -1) return { header: diffText, hunks: [] };
109
+ const header = lines.slice(0, firstHunk).join("\n") + "\n";
110
+ const hunks = [];
111
+ let start = firstHunk;
112
+ for (let idx = firstHunk + 1; idx <= lines.length; idx++) {
113
+ if (idx === lines.length || lines[idx].startsWith("@@ ")) {
114
+ hunks.push(lines.slice(start, idx).join("\n") + "\n");
115
+ start = idx;
116
+ }
117
+ }
118
+ return { header, hunks };
119
+ }
120
+
121
+ function hardCut(text, budget) {
122
+ return Buffer.from(text, "utf8").subarray(0, budget).toString("utf8");
123
+ }
124
+
125
+ // Caps a single file's diff text at FILE_BUDGET, cutting at a hunk boundary
126
+ // when one exists so we never emit a half-hunk; falls back to a hard byte
127
+ // cut only when no hunk boundary helps (e.g. one gigantic hunk).
128
+ function truncateToBudget(diffText, budget) {
129
+ if (Buffer.byteLength(diffText, "utf8") <= budget) {
130
+ return { diff: diffText, truncated: false };
131
+ }
132
+ const { header, hunks } = splitHeaderAndHunks(diffText);
133
+ if (hunks.length === 0) {
134
+ return { diff: hardCut(diffText, budget), truncated: true };
135
+ }
136
+ let acc = header;
137
+ for (const hunk of hunks) {
138
+ const next = acc + hunk;
139
+ if (Buffer.byteLength(next, "utf8") > budget) {
140
+ if (acc === header) return { diff: hardCut(next, budget), truncated: true };
141
+ return { diff: acc, truncated: true };
142
+ }
143
+ acc = next;
144
+ }
145
+ return { diff: acc, truncated: true };
146
+ }
147
+
148
+ async function countLines(absPath) {
149
+ try {
150
+ const content = await readFile(absPath, "utf8");
151
+ if (content.length === 0) return 0;
152
+ return content.split("\n").length - (content.endsWith("\n") ? 1 : 0);
153
+ } catch {
154
+ return 0;
155
+ }
156
+ }
157
+
158
+ // Untracked (or, in a headless repo, "everything") content comes from a
159
+ // read-only `git diff --no-index -- /dev/null <path>` — deliberately not
160
+ // `git add -N`, which would write an index entry (a mutation, however
161
+ // reversible). --no-index exits 1 when there IS a difference (the normal
162
+ // case here); that's not an error, it's the expected signal.
163
+ async function noIndexDiff(dir, relPath) {
164
+ try {
165
+ const { stdout } = await git(dir, ["diff", "--no-color", "--no-index", "--", "/dev/null", relPath]);
166
+ return stdout;
167
+ } catch (err) {
168
+ if (typeof err.stdout === "string") return err.stdout;
169
+ throw err;
170
+ }
171
+ }
172
+
173
+ // D-wt-diff: `?sessionId=` overrides the diff root when the named session's
174
+ // live pane cwd (or, failing that, its worktree meta) sits outside the
175
+ // registry dir. Live pane cwd is preferred — it reflects wherever the user's
176
+ // shell/claude actually is right now, same source as the sessions list's
177
+ // branch chip — falling back to the recorded worktree path only if the pane
178
+ // is gone (session died between kill and finish, or was already dead when
179
+ // this request landed). Returns registeredDir unchanged if no override
180
+ // applies (no sessionId, unknown session, override path vanished, or the
181
+ // resolved path IS the registry dir).
182
+ // Returns { dir, isWorktree }: isWorktree marks that the override target
183
+ // is a worktree session (has worktree metadata), which switches the diff
184
+ // base to the fork point (see the route) so committed-but-unmerged branch
185
+ // work still shows.
186
+ async function resolveDiffRoot(registeredDir, sessionId) {
187
+ if (!sessionId) return { dir: registeredDir, isWorktree: false };
188
+
189
+ const meta = await getSessionMeta(sessionId).catch(() => null);
190
+ const wtPath = meta?.worktree?.path;
191
+
192
+ const panePaths = await listPanePaths();
193
+ const paneCwd = panePaths.get(sessionId);
194
+ if (paneCwd && paneCwd !== registeredDir) {
195
+ const s = await stat(paneCwd).catch(() => null);
196
+ if (s?.isDirectory()) return { dir: paneCwd, isWorktree: !!wtPath };
197
+ }
198
+
199
+ if (wtPath && wtPath !== registeredDir) {
200
+ const s = await stat(wtPath).catch(() => null);
201
+ if (s?.isDirectory()) return { dir: wtPath, isWorktree: true };
202
+ }
203
+
204
+ return { dir: registeredDir, isWorktree: false };
205
+ }
206
+
207
+ // `git diff --name-status -z` records: STATUS\0PATH\0, renames/copies:
208
+ // Rnnn\0OLD\0NEW\0. Emitted in parseStatus's entry shape so the same
209
+ // changeTypeFor/rendering path applies.
210
+ function parseNameStatusZ(raw) {
211
+ const tokens = raw.split("\0").filter((t) => t.length > 0);
212
+ const entries = [];
213
+ let i = 0;
214
+ while (i < tokens.length) {
215
+ const status = tokens[i];
216
+ if (status.startsWith("R") || status.startsWith("C")) {
217
+ entries.push({ kind: "rename", xy: "R.", path: tokens[i + 2], origPath: tokens[i + 1] });
218
+ i += 3;
219
+ } else {
220
+ entries.push({ kind: "ordinary", xy: status[0], path: tokens[i + 1] });
221
+ i += 2;
222
+ }
223
+ }
224
+ return entries;
225
+ }
226
+
227
+ export default async function diffRoutes(app) {
228
+ app.get("/api/diff/:workspace", async (req, reply) => {
229
+ const { workspace } = req.params;
230
+ const { sessionId } = req.query ?? {};
231
+ const registered = await getWorkspace(workspace);
232
+ if (!registered) {
233
+ return reply.code(404).send({ error: `unknown workspace: ${workspace}` });
234
+ }
235
+ const { dir, isWorktree } = await resolveDiffRoot(registered.dir, sessionId);
236
+ const root = dir;
237
+
238
+ // One gate covers both "not a git repo" and "registered dir no longer
239
+ // exists" — both mean the same observable thing: nothing to diff.
240
+ try {
241
+ await git(dir, ["rev-parse", "--is-inside-work-tree"]);
242
+ } catch {
243
+ return { files: [], truncated: false, root, branch: null };
244
+ }
245
+
246
+ const branch = await resolveBranch(dir);
247
+
248
+ let statusRaw;
249
+ try {
250
+ ({ stdout: statusRaw } = await git(dir, ["status", "--porcelain=v2", "-z"]));
251
+ } catch {
252
+ return { files: [], truncated: false, root, branch };
253
+ }
254
+
255
+ const statusEntries = parseStatus(statusRaw);
256
+
257
+ // Repos with zero commits have no HEAD to diff against — every
258
+ // status-reported path is treated the same as an untracked file.
259
+ let hasHead = true;
260
+ try {
261
+ await git(dir, ["rev-parse", "HEAD"]);
262
+ } catch {
263
+ hasHead = false;
264
+ }
265
+
266
+ // Fork-point diff for worktree sessions: "what changed" means
267
+ // everything the branch would bring relative to where it forked from
268
+ // the source repo — committed AND uncommitted — because that is the
269
+ // delta the finish (merge/discard) decision acts on. Base = merge-base
270
+ // of the worktree HEAD and the source repo's current HEAD. Tracked
271
+ // entries are re-derived against that base (git status alone goes
272
+ // blank the moment the agent commits); untracked files still come from
273
+ // status (diff never lists them). Any failure falls back to the plain
274
+ // uncommitted-only behavior.
275
+ let baseRef = "HEAD";
276
+ let entries = statusEntries;
277
+ if (isWorktree && hasHead) {
278
+ try {
279
+ const { stdout: repoHead } = await git(registered.dir, ["rev-parse", "HEAD"]);
280
+ const { stdout: mergeBase } = await git(dir, ["merge-base", "HEAD", repoHead.trim()]);
281
+ if (mergeBase.trim()) baseRef = mergeBase.trim();
282
+ } catch {
283
+ // fork point unknown (repo dir gone, unrelated histories) — keep HEAD
284
+ }
285
+ if (baseRef !== "HEAD") {
286
+ try {
287
+ const { stdout } = await git(dir, [
288
+ "diff",
289
+ "-M",
290
+ "--name-status",
291
+ "-z",
292
+ baseRef,
293
+ "--",
294
+ ".",
295
+ ]);
296
+ entries = [
297
+ ...parseNameStatusZ(stdout),
298
+ ...statusEntries.filter((e) => e.kind === "untracked"),
299
+ ];
300
+ } catch {
301
+ entries = statusEntries;
302
+ baseRef = "HEAD";
303
+ }
304
+ }
305
+ }
306
+
307
+ if (entries.length === 0) return { files: [], truncated: false, root, branch };
308
+
309
+ let diffMap = new Map();
310
+ if (hasHead) {
311
+ const hasTracked = entries.some((e) => e.kind !== "untracked");
312
+ if (hasTracked) {
313
+ try {
314
+ const { stdout } = await git(dir, ["diff", "-M", "--no-color", baseRef, "--", "."]);
315
+ diffMap = splitDiffByFile(stdout);
316
+ } catch {
317
+ diffMap = new Map();
318
+ }
319
+ }
320
+ }
321
+
322
+ const files = [];
323
+ let totalBytes = 0;
324
+ let truncated = false;
325
+
326
+ for (const entry of entries) {
327
+ const relPath = entry.path;
328
+ const oldPath = entry.kind === "rename" ? entry.origPath : undefined;
329
+ const changeType = hasHead
330
+ ? changeTypeFor(entry)
331
+ : entry.kind === "untracked"
332
+ ? "untracked"
333
+ : "added";
334
+
335
+ if (entry.kind === "untracked" || !hasHead) {
336
+ if (totalBytes >= TOTAL_BUDGET) {
337
+ const additions = await countLines(join(dir, relPath));
338
+ files.push({
339
+ path: relPath,
340
+ oldPath,
341
+ changeType,
342
+ additions,
343
+ deletions: 0,
344
+ binary: false,
345
+ diff: "",
346
+ truncated: true,
347
+ });
348
+ truncated = true;
349
+ continue;
350
+ }
351
+
352
+ let raw;
353
+ try {
354
+ raw = await noIndexDiff(dir, relPath);
355
+ } catch {
356
+ // Real failure (permission denied, etc.) — degrade to stat-only
357
+ // rather than failing the whole request.
358
+ files.push({
359
+ path: relPath,
360
+ oldPath,
361
+ changeType,
362
+ additions: 0,
363
+ deletions: 0,
364
+ binary: false,
365
+ diff: "",
366
+ truncated: false,
367
+ });
368
+ continue;
369
+ }
370
+
371
+ if (BINARY_RE.test(raw)) {
372
+ files.push({
373
+ path: relPath,
374
+ oldPath,
375
+ changeType,
376
+ additions: 0,
377
+ deletions: 0,
378
+ binary: true,
379
+ diff: "",
380
+ truncated: false,
381
+ });
382
+ continue;
383
+ }
384
+
385
+ const { additions, deletions } = countStats(raw);
386
+ const { diff, truncated: fileTruncated } = truncateToBudget(raw, FILE_BUDGET);
387
+ totalBytes += Buffer.byteLength(diff, "utf8");
388
+ if (fileTruncated) truncated = true;
389
+ files.push({
390
+ path: relPath,
391
+ oldPath,
392
+ changeType,
393
+ additions,
394
+ deletions,
395
+ binary: false,
396
+ diff,
397
+ truncated: fileTruncated,
398
+ });
399
+ continue;
400
+ }
401
+
402
+ // Tracked (ordinary or rename) with a HEAD to diff against.
403
+ const raw = diffMap.get(relPath);
404
+ if (!raw) {
405
+ files.push({
406
+ path: relPath,
407
+ oldPath,
408
+ changeType,
409
+ additions: 0,
410
+ deletions: 0,
411
+ binary: false,
412
+ diff: "",
413
+ truncated: false,
414
+ });
415
+ continue;
416
+ }
417
+
418
+ if (BINARY_RE.test(raw)) {
419
+ files.push({
420
+ path: relPath,
421
+ oldPath,
422
+ changeType,
423
+ additions: 0,
424
+ deletions: 0,
425
+ binary: true,
426
+ diff: "",
427
+ truncated: false,
428
+ });
429
+ continue;
430
+ }
431
+
432
+ const { additions, deletions } = countStats(raw);
433
+ if (totalBytes >= TOTAL_BUDGET) {
434
+ files.push({
435
+ path: relPath,
436
+ oldPath,
437
+ changeType,
438
+ additions,
439
+ deletions,
440
+ binary: false,
441
+ diff: "",
442
+ truncated: true,
443
+ });
444
+ truncated = true;
445
+ continue;
446
+ }
447
+
448
+ const { diff, truncated: fileTruncated } = truncateToBudget(raw, FILE_BUDGET);
449
+ totalBytes += Buffer.byteLength(diff, "utf8");
450
+ if (fileTruncated) truncated = true;
451
+ files.push({
452
+ path: relPath,
453
+ oldPath,
454
+ changeType,
455
+ additions,
456
+ deletions,
457
+ binary: false,
458
+ diff,
459
+ truncated: fileTruncated,
460
+ });
461
+ }
462
+
463
+ return { files, truncated, root, branch };
464
+ });
465
+ }
@@ -0,0 +1,63 @@
1
+ import { spawn } from "node:child_process";
2
+ import path from "node:path";
3
+ import { getWorkspace } from "./registry.js";
4
+
5
+ // D-editor (openspec/changes/p2-diff-review/design.md): mirrors the
6
+ // GARAGE_CLAUDE_CMD override pattern already used by sessions.js.
7
+ const EDITOR_CMD = process.env.GARAGE_EDITOR_CMD ?? "code";
8
+
9
+ // Resolves once the child has actually spawned (so a caller can trust a
10
+ // resolved promise means the CLI was found), rejects with the spawn error
11
+ // (notably ENOENT when the editor CLI isn't on PATH) otherwise. The child is
12
+ // detached and its stdio ignored — it's a GUI app we fire-and-forget, not a
13
+ // process whose output or lifetime the daemon should own.
14
+ function spawnEditor(args) {
15
+ return new Promise((resolve, reject) => {
16
+ const child = spawn(EDITOR_CMD, args, { stdio: "ignore", detached: true });
17
+ child.once("error", reject);
18
+ child.once("spawn", () => {
19
+ child.unref();
20
+ resolve();
21
+ });
22
+ });
23
+ }
24
+
25
+ export default async function editorRoutes(app) {
26
+ app.post("/api/open-editor", async (req, reply) => {
27
+ const { workspace, file, line } = req.body ?? {};
28
+
29
+ const registered = await getWorkspace(workspace);
30
+ if (!registered) {
31
+ return reply.code(404).send({ error: `unknown workspace: ${workspace}` });
32
+ }
33
+ const dir = registered.dir;
34
+
35
+ let args;
36
+ if (file !== undefined && file !== null && file !== "") {
37
+ if (typeof file !== "string") {
38
+ return reply.code(400).send({ error: "file must be a string" });
39
+ }
40
+ // Prefix-check the RESOLVED path, not the raw string, so both
41
+ // "../"-style traversal and absolute-path overrides of `file` are
42
+ // caught regardless of how they're spelled.
43
+ const abs = path.resolve(dir, file);
44
+ if (abs !== dir && !abs.startsWith(dir + path.sep)) {
45
+ return reply.code(400).send({ error: "file escapes workspace directory" });
46
+ }
47
+ args = ["--goto", `${abs}:${line ?? 1}`];
48
+ } else {
49
+ args = [dir];
50
+ }
51
+
52
+ try {
53
+ await spawnEditor(args);
54
+ } catch (err) {
55
+ if (err.code === "ENOENT") {
56
+ return reply.code(501).send({ error: `editor CLI not found (${EDITOR_CMD})` });
57
+ }
58
+ throw err;
59
+ }
60
+
61
+ return reply.code(200).send({ ok: true });
62
+ });
63
+ }
@@ -0,0 +1,54 @@
1
+ import { statusEvents } from "./status.js";
2
+ import { pollerEvents } from "./poller.js";
3
+
4
+ const KEEPALIVE_MS = 15_000;
5
+
6
+ // D-push: GET /api/events as SSE. One-directional daemon -> UI, so SSE over
7
+ // a plain reply.raw loop — no new dependency, EventSource auto-reconnects.
8
+ const activeStreams = new Set();
9
+
10
+ export default async function eventRoutes(app) {
11
+ app.get("/api/events", async (req, reply) => {
12
+ reply.hijack();
13
+ const res = reply.raw;
14
+ res.writeHead(200, {
15
+ "Content-Type": "text/event-stream",
16
+ "Cache-Control": "no-cache",
17
+ Connection: "keep-alive",
18
+ });
19
+ res.write(": connected\n\n");
20
+ activeStreams.add(res);
21
+
22
+ const send = (event, data) => {
23
+ res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
24
+ };
25
+
26
+ const onTransition = ({ id, to }) => send("status", { id, status: to });
27
+ const onSessionsChanged = () => send("sessions", {});
28
+
29
+ statusEvents.on("transition", onTransition);
30
+ pollerEvents.on("sessions-changed", onSessionsChanged);
31
+
32
+ const keepalive = setInterval(() => {
33
+ res.write(": keepalive\n\n");
34
+ }, KEEPALIVE_MS);
35
+ keepalive.unref?.();
36
+
37
+ const cleanup = () => {
38
+ clearInterval(keepalive);
39
+ statusEvents.off("transition", onTransition);
40
+ pollerEvents.off("sessions-changed", onSessionsChanged);
41
+ activeStreams.delete(res);
42
+ };
43
+
44
+ req.raw.on("close", cleanup);
45
+ res.on("error", cleanup);
46
+ });
47
+
48
+ app.addHook("onClose", async () => {
49
+ for (const res of activeStreams) {
50
+ res.end();
51
+ }
52
+ activeStreams.clear();
53
+ });
54
+ }