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/README.md +134 -62
- package/bin/garage.js +358 -3
- package/daemon/src/claude-home.js +16 -0
- package/daemon/src/diff.js +1 -1
- package/daemon/src/events.js +6 -2
- package/daemon/src/health.js +28 -0
- package/daemon/src/hooks.js +18 -110
- package/daemon/src/index.js +6 -1
- package/daemon/src/poller.js +90 -23
- package/daemon/src/registry.js +3 -1
- package/daemon/src/session-resolve.js +58 -0
- package/daemon/src/sessions.js +93 -10
- package/daemon/src/settings-install.js +62 -0
- package/daemon/src/status.js +36 -26
- package/daemon/src/statusline.js +200 -0
- package/daemon/src/tmux.js +76 -16
- package/daemon/src/token-auth.js +13 -0
- package/daemon/src/transcript.js +142 -0
- package/daemon/src/worktrees.js +5 -1
- package/package.json +6 -3
- package/ui/dist/assets/index-bVL5DcKG.js +81 -0
- package/ui/dist/assets/{index-B9ScE6Rx.css → index-u3ZEnG0l.css} +2 -2
- package/ui/dist/index.html +2 -2
- package/wall/dist/garage-wall-darwin-arm64 +0 -0
- package/ui/dist/assets/index-D2yHw1H6.js +0 -81
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/daemon/src/diff.js
CHANGED
|
@@ -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 };
|
package/daemon/src/events.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/daemon/src/hooks.js
CHANGED
|
@@ -1,24 +1,14 @@
|
|
|
1
|
-
import {
|
|
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)
|
|
112
|
-
// entry-level dedupe: a hook group is only appended
|
|
113
|
-
// group for that event already carries one of its hook
|
|
114
|
-
// embeds the per-install token, which is stable — see
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
});
|
package/daemon/src/index.js
CHANGED
|
@@ -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
|
-
|
|
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);
|