claude-garage 0.2.1 → 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.
package/bin/garage.js CHANGED
@@ -4,13 +4,34 @@
4
4
  // UI+API serving — see daemon/src/index.js), then prints and best-effort
5
5
  // opens the URL. Never touches tmux on shutdown — SIGINT/SIGTERM close the
6
6
  // HTTP server only, so live garage sessions survive the process exiting.
7
- import { execFile, spawn } from "node:child_process";
7
+ //
8
+ // p8-packaging: `claude-garage tui` runs the same prerequisite checks, then
9
+ // attaches the compiled TUI to a running daemon — starting one detached
10
+ // first (so it outlives the TUI) when /api/health is unreachable. Quitting
11
+ // the TUI leaves the daemon and every tmux session running.
12
+ import { execFile, spawn, spawnSync } from "node:child_process";
13
+ import { accessSync, chmodSync, constants as fsConstants, copyFileSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, statSync } from "node:fs";
14
+ import os from "node:os";
15
+ import path from "node:path";
8
16
  import readline from "node:readline/promises";
17
+ import { fileURLToPath } from "node:url";
9
18
 
10
19
  const PORT = Number(process.env.GARAGE_PORT ?? 4747);
11
20
  const HEALTH_URL = `http://127.0.0.1:${PORT}/api/health`;
12
21
  const UI_URL = `http://127.0.0.1:${PORT}`;
13
22
 
23
+ // bin/garage.js -> repo/package root is one level up. Resolves correctly
24
+ // whether run from a checkout or an installed package.
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
26
+
27
+ // The launcher's own package version — compared against the version the
28
+ // daemon reports on /api/health to detect a stale (pre-upgrade) daemon
29
+ // still serving old code (p8.2; symptom: 404s on routes the new UI/TUI
30
+ // call, e.g. `?meta=1`).
31
+ const VERSION = JSON.parse(
32
+ readFileSync(path.join(ROOT, "package.json"), "utf8")
33
+ ).version;
34
+
14
35
  function execFileP(cmd, args) {
15
36
  return new Promise((resolve, reject) => {
16
37
  execFile(cmd, args, (err, stdout, stderr) => {
@@ -74,7 +95,8 @@ async function offerTmuxInstall() {
74
95
  }
75
96
  }
76
97
 
77
- async function main() {
98
+ // Shared by the web and TUI paths — identical checks, identical errors.
99
+ async function checkPrerequisites() {
78
100
  try {
79
101
  await execFileP("tmux", ["-V"]);
80
102
  } catch (err) {
@@ -93,6 +115,18 @@ async function main() {
93
115
  ["--version"],
94
116
  "see https://docs.claude.com/en/docs/claude-code for install instructions"
95
117
  );
118
+ }
119
+
120
+ async function main() {
121
+ await checkPrerequisites();
122
+
123
+ // Stale-daemon gate (p8.2), same as the tui path: a healthy daemon that
124
+ // predates this launcher is stopped by its reported pid so the
125
+ // in-process daemon below can bind the freed port and serve current
126
+ // code. A daemon at the launcher's own version is left alone (the
127
+ // import below will then report EADDRINUSE as before).
128
+ const health = await fetchHealth();
129
+ if (daemonIsStale(health)) await stopStaleDaemon(health);
96
130
 
97
131
  process.env.GARAGE_SERVE_UI = "1";
98
132
 
@@ -140,4 +174,325 @@ async function main() {
140
174
  }, 600);
141
175
  }
142
176
 
143
- main();
177
+ // ---------------------------------------------------------------------------
178
+ // `claude-garage tui` (p8-packaging)
179
+ // ---------------------------------------------------------------------------
180
+
181
+ async function healthOk(timeoutMs = 750) {
182
+ return (await fetchHealth(timeoutMs)) !== null;
183
+ }
184
+
185
+ // The parsed /api/health body, or null when no healthy daemon answers.
186
+ // A daemon that answers 200 with an unparseable body still counts as
187
+ // present ({}), so the version gate below treats it as stale rather than
188
+ // racing a second daemon onto an occupied port.
189
+ async function fetchHealth(timeoutMs = 750) {
190
+ try {
191
+ const res = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(timeoutMs) });
192
+ if (!res.ok) return null;
193
+ return await res.json().catch(() => ({}));
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+
199
+ // ── Stale-daemon gate (p8.2) ────────────────────────────────────────────
200
+ // A daemon left running across an upgrade keeps serving pre-upgrade code
201
+ // (observed as `?meta=1` 404s from a daemon older than the launcher). When
202
+ // the version /api/health reports differs from the launcher's — or the
203
+ // field is missing entirely (pre-upgrade daemons) — stop it by the pid it
204
+ // reports (never pkill by name) and let the caller start a fresh one the
205
+ // same way it would have with no daemon at all. Restarting is safe by
206
+ // design: tmux owns the sessions and state.json is on disk.
207
+
208
+ function daemonIsStale(health) {
209
+ return health !== null && health.version !== VERSION;
210
+ }
211
+
212
+ async function waitForPortFree(deadlineMs = 10000) {
213
+ const start = Date.now();
214
+ while (Date.now() - start < deadlineMs) {
215
+ if (!(await healthOk(500))) return true;
216
+ await new Promise((r) => setTimeout(r, 250));
217
+ }
218
+ return false;
219
+ }
220
+
221
+ // The pid of the process LISTENING on the daemon port — the fallback for
222
+ // pre-upgrade daemons whose /api/health reports no pid field. Port-based
223
+ // and exact (never process-name matching); null when it cannot be
224
+ // determined unambiguously.
225
+ async function pidListeningOnPort() {
226
+ try {
227
+ const { stdout } = await execFileP("lsof", [
228
+ "-nP", `-iTCP:${PORT}`, "-sTCP:LISTEN", "-t",
229
+ ]);
230
+ const pids = [...new Set(stdout.split("\n").map((s) => s.trim()).filter(Boolean))];
231
+ if (pids.length !== 1) return null;
232
+ const pid = Number(pids[0]);
233
+ return Number.isInteger(pid) && pid > 1 ? pid : null;
234
+ } catch {
235
+ return null; // lsof missing or nothing listening
236
+ }
237
+ }
238
+
239
+ async function stopStaleDaemon(health) {
240
+ const label = health.version ? `v${health.version}` : "with no version (pre-upgrade)";
241
+ console.log(
242
+ `garage daemon ${label} is stale (launcher v${VERSION}) — restarting it; ` +
243
+ `sessions are untouched (tmux owns them)`
244
+ );
245
+ // Prefer the pid health reports (current daemons); fall back to the
246
+ // port's listener for pre-upgrade daemons that report none.
247
+ let pid = Number(health.pid);
248
+ if (!Number.isInteger(pid) || pid <= 1) {
249
+ pid = await pidListeningOnPort();
250
+ }
251
+ if (pid === null) {
252
+ console.error(
253
+ `could not determine the stale daemon's pid — stop it yourself ` +
254
+ `(lsof -ti tcp:${PORT} | xargs kill) and re-run`
255
+ );
256
+ process.exit(1);
257
+ }
258
+ try {
259
+ process.kill(pid, "SIGTERM");
260
+ } catch (err) {
261
+ if (!(err && err.code === "ESRCH")) {
262
+ console.error(
263
+ `could not stop the stale daemon (pid ${pid}): ${err.message} — ` +
264
+ `stop it yourself and re-run`
265
+ );
266
+ process.exit(1);
267
+ }
268
+ // ESRCH: already gone — the port check below settles it either way.
269
+ }
270
+ if (!(await waitForPortFree())) {
271
+ console.error(
272
+ `port ${PORT} did not free up after stopping daemon pid ${pid} — ` +
273
+ `something else may be serving /api/health there; stop it ` +
274
+ `(lsof -ti tcp:${PORT}) and re-run`
275
+ );
276
+ process.exit(1);
277
+ }
278
+ }
279
+
280
+ // Start the daemon as a detached child so it outlives the TUI — same daemon
281
+ // module the web entrypoint imports in-process, same GARAGE_SERVE_UI=1 flag.
282
+ // daemon/src/index.js degrades gracefully when ui/dist is absent (logs and
283
+ // serves API-only), so the TUI path never requires a built UI, while the web
284
+ // wall keeps working alongside whenever ui/dist exists.
285
+ function startDetachedDaemon() {
286
+ const daemonEntry = path.join(ROOT, "daemon", "src", "index.js");
287
+ // daemon.log lives beside the daemon's state: honor GARAGE_DIR (the
288
+ // p8.1 scratch-dir override the daemon itself uses) so a scratch-port
289
+ // launcher run never writes into the real ~/.garage.
290
+ const logDir = process.env.GARAGE_DIR ?? path.join(os.homedir(), ".garage");
291
+ mkdirSync(logDir, { recursive: true });
292
+ const logPath = path.join(logDir, "daemon.log");
293
+ const out = openSync(logPath, "a");
294
+ const child = spawn(process.execPath, [daemonEntry], {
295
+ detached: true,
296
+ stdio: ["ignore", out, out],
297
+ env: { ...process.env, GARAGE_SERVE_UI: "1" },
298
+ });
299
+ child.unref();
300
+ return logPath;
301
+ }
302
+
303
+ async function waitForHealth(deadlineMs = 15000) {
304
+ const start = Date.now();
305
+ while (Date.now() - start < deadlineMs) {
306
+ if (await healthOk(500)) return true;
307
+ await new Promise((r) => setTimeout(r, 250));
308
+ }
309
+ return false;
310
+ }
311
+
312
+ // cargo build --release, then copy the result into wall/dist/<target-name>
313
+ // (creating the dist dir if needed) and mark it executable. Shared by the
314
+ // "no prebuilt binary yet" path and the p11 staleness-guard rebuild path
315
+ // below — one cargo invocation, one copy-into-dist step, used both places.
316
+ function cargoBuildTuiBinary(rustBinary) {
317
+ const build = spawnSync("cargo", ["build", "--release"], {
318
+ cwd: path.join(ROOT, "wall"),
319
+ stdio: "inherit",
320
+ });
321
+ const built = path.join(ROOT, "wall", "target", "release", "garage-wall");
322
+ if (build.status !== 0 || !existsSync(built)) {
323
+ console.error("cargo build failed — see the output above");
324
+ process.exit(1);
325
+ }
326
+ mkdirSync(path.dirname(rustBinary), { recursive: true });
327
+ copyFileSync(built, rustBinary);
328
+ chmodSync(rustBinary, 0o755);
329
+ }
330
+
331
+ // The latest mtime (ms) of any file under `dir`, recursively; 0 if the
332
+ // directory doesn't exist or is empty. Used by the p11 staleness guard to
333
+ // compare wall/src against the built binary — a plain recursive walk since
334
+ // wall/src is small and this only runs once per launch.
335
+ function newestMtimeUnder(dir) {
336
+ let newest = 0;
337
+ const stack = [dir];
338
+ while (stack.length > 0) {
339
+ const current = stack.pop();
340
+ let entries;
341
+ try {
342
+ entries = readdirSync(current, { withFileTypes: true });
343
+ } catch {
344
+ continue;
345
+ }
346
+ for (const entry of entries) {
347
+ const full = path.join(current, entry.name);
348
+ if (entry.isDirectory()) {
349
+ stack.push(full);
350
+ } else {
351
+ const mtime = statSync(full).mtimeMs;
352
+ if (mtime > newest) newest = mtime;
353
+ }
354
+ }
355
+ }
356
+ return newest;
357
+ }
358
+
359
+ // p11 staleness guard: true when anything under wall/src, or wall/Cargo.toml
360
+ // / wall/Cargo.lock, has an mtime newer than the built binary — i.e. the
361
+ // checkout has source changes the binary predates (the "invisible picker"
362
+ // report's root cause: a stale binary silently missing a fix). Source-file
363
+ // mtimes only; never compares against GARAGE_TUI_BIN (that path never calls
364
+ // this — see resolveTuiBinary's override branch, which returns first).
365
+ function wallSourcesNewerThan(rustBinary) {
366
+ const binaryMtime = statSync(rustBinary).mtimeMs;
367
+ const wallDir = path.join(ROOT, "wall");
368
+ for (const f of ["Cargo.toml", "Cargo.lock"]) {
369
+ const full = path.join(wallDir, f);
370
+ if (existsSync(full) && statSync(full).mtimeMs > binaryMtime) return true;
371
+ }
372
+ return newestMtimeUnder(path.join(wallDir, "src")) > binaryMtime;
373
+ }
374
+
375
+ // Binary lookup order (p9-ratatui-port "TUI binary availability"):
376
+ // 0. GARAGE_TUI_BIN — test hook for the e2e harnesses (parity gate): an
377
+ // explicit binary path that wins over everything else. Used only when
378
+ // set and executable; a set-but-unusable value is a hard error (a test
379
+ // hook must never silently fall through to a different binary). Honored
380
+ // verbatim — the p11 staleness guard below never runs for it.
381
+ // 1. prebuilt Rust binary at wall/dist/garage-wall-<platform>-<arch> — in a
382
+ // source checkout (wall/Cargo.toml present), p11 additionally checks it
383
+ // isn't stale against wall/src before returning it (see below).
384
+ // 2. build once with cargo (checkout only — needs wall/ sources), announced
385
+ // 3. actionable error naming what is missing (Rust toolchain first),
386
+ // exit non-zero.
387
+ function resolveTuiBinary() {
388
+ const override = process.env.GARAGE_TUI_BIN;
389
+ if (override) {
390
+ try {
391
+ accessSync(override, fsConstants.X_OK);
392
+ return override;
393
+ } catch {
394
+ console.error(
395
+ `GARAGE_TUI_BIN is set but not an executable file: ${override}`
396
+ );
397
+ process.exit(1);
398
+ }
399
+ }
400
+
401
+ const target = `${process.platform}-${process.arch}`;
402
+ const distDir = path.join(ROOT, "wall", "dist");
403
+ const rustBinary = path.join(distDir, `garage-wall-${target}`);
404
+ const wallSrc = path.join(ROOT, "wall", "Cargo.toml");
405
+
406
+ if (existsSync(rustBinary)) {
407
+ // p11 launcher staleness guard: only in a source checkout (wall/
408
+ // present at all — an installed package ships no wall/ sources, so this
409
+ // never triggers there) AND only when wall/src actually outdates the
410
+ // binary. Mirrors the stale-daemon gate's shape: detect, announce,
411
+ // self-heal — reusing the exact same cargo-build path task 2 above (and
412
+ // "no prebuilt binary" below) already uses.
413
+ if (existsSync(wallSrc) && wallSourcesNewerThan(rustBinary)) {
414
+ const haveCargo = spawnSync("cargo", ["--version"], { stdio: "ignore" }).status === 0;
415
+ if (haveCargo) {
416
+ console.log("wall sources newer than the built binary — rebuilding");
417
+ cargoBuildTuiBinary(rustBinary);
418
+ } else {
419
+ console.error(
420
+ `warning: wall/src is newer than the built TUI binary (${rustBinary}) ` +
421
+ `and no cargo on PATH to rebuild it — it may be stale`
422
+ );
423
+ }
424
+ }
425
+ return rustBinary;
426
+ }
427
+
428
+ const haveCargo = spawnSync("cargo", ["--version"], { stdio: "ignore" }).status === 0;
429
+ if (haveCargo && existsSync(wallSrc)) {
430
+ console.log(
431
+ `no prebuilt TUI binary for ${target} — building once with cargo ` +
432
+ `(release build; lands at wall/dist/garage-wall-${target})`
433
+ );
434
+ cargoBuildTuiBinary(rustBinary);
435
+ return rustBinary;
436
+ }
437
+
438
+ if (existsSync(wallSrc)) {
439
+ console.error(
440
+ `no prebuilt TUI binary for ${target} and no Rust toolchain (cargo) on PATH.\n` +
441
+ `Install Rust (https://rustup.rs) and re-run — the TUI builds itself once ` +
442
+ `from wall/ — or use a platform with a shipped binary (macOS arm64).`
443
+ );
444
+ } else {
445
+ console.error(
446
+ `no prebuilt TUI binary for ${target} in this package, and no wall/ sources ` +
447
+ `to build from.\nUse a platform with a shipped binary (macOS arm64), or run ` +
448
+ `from a git checkout with the Rust toolchain installed (npm run build:tui).`
449
+ );
450
+ }
451
+ process.exit(1);
452
+ }
453
+
454
+ async function tuiMain() {
455
+ await checkPrerequisites();
456
+
457
+ // Resolve (and if needed build) the binary BEFORE starting a daemon: a
458
+ // missing binary should fail fast without a side-effect daemon appearing.
459
+ const tuiBinary = resolveTuiBinary();
460
+
461
+ // Stale-daemon gate (p8.2): a healthy daemon that predates this launcher
462
+ // is stopped (by its reported pid) and replaced exactly like the
463
+ // daemon-absent path below.
464
+ const health = await fetchHealth();
465
+ const stale = daemonIsStale(health);
466
+ if (stale) await stopStaleDaemon(health);
467
+ if (health === null || stale) {
468
+ if (!stale) {
469
+ console.log(`no daemon on port ${PORT} — starting one (it outlives the TUI)`);
470
+ }
471
+ const logPath = startDetachedDaemon();
472
+ if (!(await waitForHealth())) {
473
+ console.error(
474
+ `claude-garage daemon did not come up on port ${PORT} — see ${logPath} ` +
475
+ `(port already in use? set GARAGE_PORT to pick a different one)`
476
+ );
477
+ process.exit(1);
478
+ }
479
+ }
480
+
481
+ // Foreground, inherited stdio: the TUI owns the terminal until it exits.
482
+ // The daemon (in-process elsewhere or the detached child above) is left
483
+ // running — tmux sessions and the web wall stay live.
484
+ const child = spawn(tuiBinary, [], { stdio: "inherit", env: process.env });
485
+ child.on("error", (err) => {
486
+ console.error(`failed to start TUI binary at ${tuiBinary}: ${err.message}`);
487
+ process.exit(1);
488
+ });
489
+ child.on("exit", (code, signal) => {
490
+ process.exit(signal ? 1 : (code ?? 0));
491
+ });
492
+ }
493
+
494
+ if (process.argv[2] === "tui") {
495
+ tuiMain();
496
+ } else {
497
+ main();
498
+ }
@@ -0,0 +1,16 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ // p11: GARAGE_CLAUDE_HOME overrides the Claude Code config root this daemon
5
+ // reads/writes (~/.claude by default) — same spirit as GARAGE_DIR overriding
6
+ // ~/.garage for the registry (registry.js). Production always uses the real
7
+ // ~/.claude; the override exists purely for testability, so hook/statusline
8
+ // install tests and transcript fixture tests never touch the user's real
9
+ // settings.json or ~/.claude/projects transcripts. Read lazily (not cached
10
+ // in a module-level constant) so a caller that sets the env var right before
11
+ // a dynamic import — the pattern every test in this repo uses — always sees
12
+ // it, and callers that DO want a load-time constant (mirroring GARAGE_DIR)
13
+ // simply call this once at their own module's top level.
14
+ export function claudeHome() {
15
+ return process.env.GARAGE_CLAUDE_HOME ?? join(homedir(), ".claude");
16
+ }
@@ -190,7 +190,7 @@ async function resolveDiffRoot(registeredDir, sessionId) {
190
190
  const wtPath = meta?.worktree?.path;
191
191
 
192
192
  const panePaths = await listPanePaths();
193
- const paneCwd = panePaths.get(sessionId);
193
+ const paneCwd = panePaths.get(sessionId)?.path;
194
194
  if (paneCwd && paneCwd !== registeredDir) {
195
195
  const s = await stat(paneCwd).catch(() => null);
196
196
  if (s?.isDirectory()) return { dir: paneCwd, isWorktree: !!wtPath };
@@ -1,4 +1,4 @@
1
- import { statusEvents } from "./status.js";
1
+ import { statusEvents, getStatusEntry } from "./status.js";
2
2
  import { pollerEvents } from "./poller.js";
3
3
 
4
4
  const KEEPALIVE_MS = 15_000;
@@ -23,7 +23,11 @@ export default async function eventRoutes(app) {
23
23
  res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
24
24
  };
25
25
 
26
- const onTransition = ({ id, to }) => send("status", { id, status: to });
26
+ // `since` is re-read fresh from the store rather than threaded through
27
+ // the transition payload — setStatus already wrote it before emitting,
28
+ // so this is always the timestamp of this exact transition.
29
+ const onTransition = ({ id, to }) =>
30
+ send("status", { id, status: to, since: getStatusEntry(id).since });
27
31
  const onSessionsChanged = () => send("sessions", {});
28
32
 
29
33
  statusEvents.on("transition", onTransition);
@@ -0,0 +1,28 @@
1
+ // p8.2 stale-daemon detection: /api/health reports the daemon's package
2
+ // version and pid so the launcher (bin/garage.js) can spot a daemon left
3
+ // over from before an upgrade (it keeps serving old code — symptom:
4
+ // 404s on routes the new UI/TUI call) and restart it by pid — never by
5
+ // process-name matching. Restarting is safe by design: tmux owns the
6
+ // sessions and state.json is on disk.
7
+ import { readFileSync } from "node:fs";
8
+ import path from "node:path";
9
+ import { fileURLToPath } from "node:url";
10
+
11
+ // daemon/src/health.js -> repo/package root is two levels up.
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+
14
+ // Read once at daemon start. A daemon that cannot read its own
15
+ // package.json still serves health — the launcher treats the missing
16
+ // version as stale, which errs toward a (harmless) restart.
17
+ let version = null;
18
+ try {
19
+ version = JSON.parse(
20
+ readFileSync(path.resolve(__dirname, "..", "..", "package.json"), "utf8")
21
+ ).version ?? null;
22
+ } catch {
23
+ // leave version null
24
+ }
25
+
26
+ export function healthPayload() {
27
+ return { status: "ok", version, pid: process.pid };
28
+ }
@@ -1,24 +1,14 @@
1
- import { execFile } from "node:child_process";
2
- import { promisify } from "node:util";
3
- import { timingSafeEqual } from "node:crypto";
4
- import { copyFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
5
- import { homedir } from "node:os";
6
- import { dirname, join } from "node:path";
7
- import { listSessions } from "./tmux.js";
1
+ import { join } from "node:path";
8
2
  import { setStatus } from "./status.js";
9
3
  import { getHookToken } from "./registry.js";
4
+ import { tokenMatches } from "./token-auth.js";
5
+ import { resolveSessionIds } from "./session-resolve.js";
6
+ import { readSettingsOrRefuse, writeSettingsAtomic } from "./settings-install.js";
7
+ import { claudeHome } from "./claude-home.js";
10
8
 
11
- const run = promisify(execFile);
12
9
  const PORT = Number(process.env.GARAGE_PORT ?? 4747);
13
10
  const HOOK_URL = `http://127.0.0.1:${PORT}/api/hooks/claude`;
14
11
 
15
- function tokenMatches(given, expected) {
16
- if (typeof given !== "string") return false;
17
- const a = Buffer.from(given);
18
- const b = Buffer.from(expected);
19
- return a.length === b.length && timingSafeEqual(a, b);
20
- }
21
-
22
12
  const EVENT_TO_STATE = {
23
13
  Notification: "needs-input",
24
14
  Stop: "done",
@@ -38,57 +28,6 @@ export function isIdleReminder(message) {
38
28
  return IDLE_REMINDER_RE.test(message ?? "");
39
29
  }
40
30
 
41
- async function getAgents() {
42
- try {
43
- const { stdout } = await run("claude", ["agents", "--json"]);
44
- return JSON.parse(stdout);
45
- } catch {
46
- return [];
47
- }
48
- }
49
-
50
- async function getPanePids() {
51
- try {
52
- const { stdout } = await run("tmux", [
53
- "list-panes",
54
- "-a",
55
- "-F",
56
- "#{session_name}\t#{pane_pid}",
57
- ]);
58
- const map = new Map(); // pid -> session name
59
- for (const line of stdout.split("\n")) {
60
- if (!line.trim()) continue;
61
- const [name, pid] = line.split("\t");
62
- map.set(Number(pid), name);
63
- }
64
- return map;
65
- } catch {
66
- return new Map();
67
- }
68
- }
69
-
70
- // Resolve a hook payload to the garage session id(s) it applies to.
71
- // Precise path: payload session_id -> agents-json sessionId -> its pid ->
72
- // pane_pid join. Fallback: cwd fail-open, applied to every garage session
73
- // sharing that dir (over-notify rather than silently drop).
74
- async function resolveSessionIds({ session_id, cwd }) {
75
- const [agents, pidToSession] = await Promise.all([getAgents(), getPanePids()]);
76
-
77
- if (session_id) {
78
- const agent = agents.find((a) => a.sessionId === session_id);
79
- const sessionName = agent && pidToSession.get(agent.pid);
80
- if (sessionName) return [sessionName];
81
- }
82
-
83
- if (cwd) {
84
- const sessions = await listSessions();
85
- const matches = sessions.filter((s) => s.dir === cwd).map((s) => s.id);
86
- if (matches.length > 0) return matches;
87
- }
88
-
89
- return [];
90
- }
91
-
92
31
  export async function hookSnippet() {
93
32
  const token = await getHookToken();
94
33
  const url = `${HOOK_URL}?token=${token}`;
@@ -108,11 +47,12 @@ export async function hookSnippet() {
108
47
  // Safety order: parse-or-refuse (a corrupt file is returned as an error,
109
48
  // byte-for-byte untouched) -> timestamped backup of the pre-install file
110
49
  // -> atomic write (tmp + rename, same directory, so a crash mid-write can
111
- // never leave a half-written settings.json). Idempotency comes from
112
- // entry-level dedupe: a hook group is only appended when no existing
113
- // group for that event already carries one of its hook URLs (the URL
114
- // embeds the per-install token, which is stable — see getHookToken).
115
- const SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
50
+ // never leave a half-written settings.json) — see settings-install.js.
51
+ // Idempotency comes from entry-level dedupe: a hook group is only appended
52
+ // when no existing group for that event already carries one of its hook
53
+ // URLs (the URL embeds the per-install token, which is stable — see
54
+ // getHookToken).
55
+ const SETTINGS_PATH = join(claudeHome(), "settings.json");
116
56
 
117
57
  function groupHasAnyUrl(group, urls) {
118
58
  return (group?.hooks ?? []).some((h) => urls.has(h.url));
@@ -148,32 +88,7 @@ export function mergeHookSnippet(settings, snippet) {
148
88
  }
149
89
 
150
90
  async function installHooks() {
151
- let raw = null;
152
- try {
153
- raw = await readFile(SETTINGS_PATH, "utf8");
154
- } catch (err) {
155
- if (err.code !== "ENOENT") throw err;
156
- }
157
-
158
- let settings = {};
159
- if (raw !== null && raw.trim() !== "") {
160
- try {
161
- settings = JSON.parse(raw);
162
- } catch {
163
- const error = new Error(
164
- `~/.claude/settings.json is not valid JSON — fix it (or remove it) and retry; the file was left untouched`
165
- );
166
- error.statusCode = 422;
167
- throw error;
168
- }
169
- if (typeof settings !== "object" || settings === null || Array.isArray(settings)) {
170
- const error = new Error(
171
- `~/.claude/settings.json is not a JSON object — the file was left untouched`
172
- );
173
- error.statusCode = 422;
174
- throw error;
175
- }
176
- }
91
+ const { settings, raw } = await readSettingsOrRefuse(SETTINGS_PATH);
177
92
 
178
93
  const snippet = await hookSnippet();
179
94
  const { merged, changed } = mergeHookSnippet(settings, snippet);
@@ -181,18 +96,7 @@ async function installHooks() {
181
96
  return { ok: true, installed: true, alreadyInstalled: true, backup: null };
182
97
  }
183
98
 
184
- await mkdir(dirname(SETTINGS_PATH), { recursive: true });
185
-
186
- let backup = null;
187
- if (raw !== null) {
188
- backup = `${SETTINGS_PATH}.garage-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
189
- await copyFile(SETTINGS_PATH, backup);
190
- }
191
-
192
- const tmp = `${SETTINGS_PATH}.garage-tmp-${process.pid}`;
193
- await writeFile(tmp, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
194
- await rename(tmp, SETTINGS_PATH);
195
-
99
+ const backup = await writeSettingsAtomic(SETTINGS_PATH, raw, merged);
196
100
  return { ok: true, installed: true, alreadyInstalled: false, backup };
197
101
  }
198
102
 
@@ -220,7 +124,11 @@ export default async function hookRoutes(app) {
220
124
  }
221
125
 
222
126
  const ids = await resolveSessionIds(payload);
223
- for (const id of ids) setStatus(id, state);
127
+ // p8 message capture: a Notification's text rides into the status store
128
+ // alongside needs-input; other events pass no message (setStatus clears
129
+ // it on any transition away from needs-input).
130
+ const message = state === "needs-input" ? payload.message ?? null : undefined;
131
+ for (const id of ids) setStatus(id, state, message);
224
132
 
225
133
  return reply.code(200).send({ ok: true, applied: ids });
226
134
  });
@@ -7,6 +7,7 @@ import sessionRoutes from "./sessions.js";
7
7
  import workspaceRoutes from "./workspaces.js";
8
8
  import pickerRoutes from "./picker.js";
9
9
  import hookRoutes from "./hooks.js";
10
+ import statuslineRoutes from "./statusline.js";
10
11
  import eventRoutes from "./events.js";
11
12
  import notifyRoutes from "./notify.js";
12
13
  import diffRoutes from "./diff.js";
@@ -15,6 +16,7 @@ import worktreeRoutes from "./worktrees.js";
15
16
  import { attachTermServer } from "./term.js";
16
17
  import { rejectForeignOrigins } from "./security.js";
17
18
  import { startPoller } from "./poller.js";
19
+ import { healthPayload } from "./health.js";
18
20
 
19
21
  const HOST = "127.0.0.1";
20
22
  const PORT = Number(process.env.GARAGE_PORT ?? 4747);
@@ -29,11 +31,14 @@ const REPO_ROOT = path.resolve(__dirname, "..", "..");
29
31
  const app = Fastify({ logger: { level: "info" }, forceCloseConnections: true });
30
32
 
31
33
  app.addHook("onRequest", rejectForeignOrigins);
32
- app.get("/api/health", async () => ({ status: "ok" }));
34
+ // version + pid ride along for the launcher's stale-daemon gate
35
+ // (bin/garage.js, p8.2) — see health.js.
36
+ app.get("/api/health", async () => healthPayload());
33
37
  app.register(sessionRoutes);
34
38
  app.register(workspaceRoutes);
35
39
  app.register(pickerRoutes);
36
40
  app.register(hookRoutes);
41
+ app.register(statuslineRoutes);
37
42
  app.register(eventRoutes);
38
43
  app.register(notifyRoutes);
39
44
  app.register(diffRoutes);