claude-garage 0.3.4 → 0.4.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 CHANGED
@@ -189,6 +189,39 @@ gets a line like *"water. now. i'm not asking."* (Arthur), *"acceptable."*
189
189
  nights, and a usage window running hot, and they never interrupt a session
190
190
  that needs you. Set `GARAGE_PET_ASCII=1` if your font lacks `ᴥ`.
191
191
 
192
+ ## Restarting
193
+
194
+ Two things go stale under a running wall: the garage daemon itself (after
195
+ a `npm` upgrade) and the `claude` binary inside every session (after
196
+ Anthropic ships a release — Claude Code shows "update available", but a
197
+ running session keeps the old process until it's restarted).
198
+
199
+ ```bash
200
+ claude-garage restart
201
+ ```
202
+
203
+ Restarts the daemon only. Sessions are untouched; the TUI and web wall
204
+ reconnect on their own through the existing SSE reconnect.
205
+
206
+ ```bash
207
+ claude-garage restart --sessions
208
+ ```
209
+
210
+ Restarts the daemon, then respawns every idle/done session in place —
211
+ `tmux respawn-pane -k` running `claude --resume <id>` in the same tmux
212
+ session, so the tile, its title and the conversation all survive. Sessions
213
+ that are `working` or `needs-input` are skipped and listed rather than
214
+ losing an in-flight turn; `--all` restarts those too.
215
+
216
+ **In the TUI:** `r r` restarts the focused session (press twice — the
217
+ first press warns if it's busy), `r a` restarts every idle/done session
218
+ in the focused workspace, `r d` restarts the daemon. `?` lists all three.
219
+
220
+ **In the web wall:** the `↻` in a cell's hover controls restarts that
221
+ session, with the same two-click confirmation.
222
+
223
+ Upgrading the garage package itself is still `npx claude-garage@latest tui`.
224
+
192
225
  ## Keybindings
193
226
 
194
227
  The TUI's full keymap (`claude-garage tui`, also shown in-app with `?`).
@@ -216,6 +249,9 @@ key mapping (Settings → Profiles → Keyboard) for Shift+Return that sends
216
249
  | `R` | restore all restorable sessions in workspace |
217
250
  | `I` | install statusline feed for context meters |
218
251
  | `x x` | close focused session (press twice) |
252
+ | `r r` | restart focused session — resumes on the current claude binary |
253
+ | `r a` | restart idle/done sessions in workspace |
254
+ | `r d` | restart the daemon (sessions untouched) |
219
255
  | `X X` | remove focused workspace (sessions keep running) |
220
256
  | `X K` | remove focused workspace AND kill its sessions |
221
257
  | `w` | add workspace |
package/bin/garage.js CHANGED
@@ -491,8 +491,121 @@ async function tuiMain() {
491
491
  });
492
492
  }
493
493
 
494
- if (process.argv[2] === "tui") {
494
+ // ---------------------------------------------------------------------------
495
+ // `claude-garage restart` (p16-restart)
496
+ // ---------------------------------------------------------------------------
497
+
498
+ const JSON_HEADERS = { "content-type": "application/json" };
499
+
500
+ // D4: same 15s-cap/250ms-poll shape as waitForHealth above, but also
501
+ // requires the answering pid to differ from `previousPid` (undefined counts
502
+ // as "no prior daemon", so any live match immediately satisfies it) AND the
503
+ // version to match this launcher's own — a daemon that hasn't actually
504
+ // swapped yet (still the old process, mid-handoff) must not read as done.
505
+ async function waitForHealthChanged(previousPid, deadlineMs = 15000) {
506
+ const start = Date.now();
507
+ while (Date.now() - start < deadlineMs) {
508
+ const health = await fetchHealth(500);
509
+ if (health && health.version === VERSION && health.pid !== previousPid) return health;
510
+ await new Promise((r) => setTimeout(r, 250));
511
+ }
512
+ return null;
513
+ }
514
+
515
+ // D4/D5: `claude-garage restart --sessions` — POSTs /api/sessions/restart
516
+ // with `{all: true, force: <--all>}` and prints one line per restarted,
517
+ // skipped and failed session, then a summary line.
518
+ async function restartSessionsCLI(force) {
519
+ const res = await fetch(`http://127.0.0.1:${PORT}/api/sessions/restart`, {
520
+ method: "POST",
521
+ headers: JSON_HEADERS,
522
+ body: JSON.stringify({ all: true, force }),
523
+ });
524
+ const body = await res.json().catch(() => ({}));
525
+ if (!res.ok) {
526
+ console.error(body.error || `session restart failed (${res.status})`);
527
+ process.exit(1);
528
+ }
529
+
530
+ for (const r of body.restarted ?? []) {
531
+ console.log(
532
+ r.resumed
533
+ ? `restarted ${r.id} (resumed)`
534
+ : `restarted ${r.id} (fresh — no conversation to resume)`
535
+ );
536
+ }
537
+ for (const s of body.skipped ?? []) {
538
+ console.log(`skipped ${s.id} — ${s.status} (use --all to include)`);
539
+ }
540
+ for (const f of body.failed ?? []) {
541
+ console.log(`failed ${f.id} — ${f.error}`);
542
+ }
543
+
544
+ const restartedN = body.restarted?.length ?? 0;
545
+ const skippedN = body.skipped?.length ?? 0;
546
+ const failedN = body.failed?.length ?? 0;
547
+ console.log(`${restartedN} restarted, ${skippedN} skipped, ${failedN} failed`);
548
+ }
549
+
550
+ // D3/D4: restarts the daemon — via its own self-restart endpoint when one
551
+ // is already healthy (so the in-process successor logic is exercised the
552
+ // same way the TUI's stale-daemon gate exercises stopStaleDaemon), else
553
+ // there's nothing to hand off from, so just start one detached the same way
554
+ // `tui` does when no daemon answers. `--sessions` then restarts every live
555
+ // Claude Code session in place; `--all` also includes busy ones.
556
+ async function restartMain() {
557
+ const args = process.argv.slice(3);
558
+ const sessions = args.includes("--sessions");
559
+ const all = args.includes("--all");
560
+
561
+ const before = await fetchHealth();
562
+ if (before) {
563
+ const res = await fetch(`http://127.0.0.1:${PORT}/api/daemon/restart`, { method: "POST" });
564
+ if (!res.ok) {
565
+ console.error(
566
+ `daemon restart request failed (${res.status}) — the running daemon ` +
567
+ `(v${before.version ?? "?"}, pid ${before.pid ?? "?"}) may predate this endpoint; ` +
568
+ `stop it yourself (lsof -ti tcp:${PORT} | xargs kill) and re-run "claude-garage"`
569
+ );
570
+ process.exit(1);
571
+ }
572
+ if (!(await waitForHealthChanged(before.pid))) {
573
+ console.error(
574
+ `garage daemon did not come back up on port ${PORT} within 15s of restarting`
575
+ );
576
+ process.exit(1);
577
+ }
578
+ } else {
579
+ console.log(`no daemon on port ${PORT} — starting one`);
580
+ startDetachedDaemon();
581
+ if (!(await waitForHealth())) {
582
+ console.error(`claude-garage daemon did not come up on port ${PORT}`);
583
+ process.exit(1);
584
+ }
585
+ }
586
+
587
+ const after = await fetchHealth();
588
+ console.log(`garage daemon restarted → v${after.version} (pid ${after.pid})`);
589
+
590
+ if (sessions) {
591
+ // p16-restart follow-up: the daemon now awaits one fresh poll before
592
+ // planning restart targets whenever any of them has no status entry
593
+ // yet (exactly the case right after this handoff) — say why there's a
594
+ // short pause instead of leaving the user staring at silence.
595
+ console.log("daemon restarted — refreshing session status before restarting sessions");
596
+ await restartSessionsCLI(all);
597
+ }
598
+ }
599
+
600
+ const subcommand = process.argv[2];
601
+ if (subcommand === undefined) {
602
+ main();
603
+ } else if (subcommand === "tui") {
495
604
  tuiMain();
605
+ } else if (subcommand === "restart") {
606
+ restartMain();
496
607
  } else {
497
- main();
608
+ console.error(`unknown subcommand: ${subcommand}`);
609
+ console.error(`usage: claude-garage [tui|restart [--sessions] [--all]]`);
610
+ process.exit(1);
498
611
  }
@@ -0,0 +1,87 @@
1
+ // p16-restart D3: daemon self-restart. POST /api/daemon/restart spawns a
2
+ // detached successor running this same daemon/src/index.js, replies 202
3
+ // with the successor's pid, then closes this daemon and exits — the
4
+ // successor takes the port once this process is gone (see index.js's
5
+ // boot-time waitForPredecessor call and D6's port-handoff-without-a-race
6
+ // requirement).
7
+ import { spawn } from "node:child_process";
8
+ import { mkdirSync, openSync } from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+
13
+ // Same daemon.log the launcher's startDetachedDaemon uses (bin/garage.js) —
14
+ // honors GARAGE_DIR (the p8.1 scratch-dir override) so a scratch-port test
15
+ // run never writes into the real ~/.garage.
16
+ function daemonLogPath() {
17
+ const logDir = process.env.GARAGE_DIR ?? path.join(os.homedir(), ".garage");
18
+ mkdirSync(logDir, { recursive: true });
19
+ return path.join(logDir, "daemon.log");
20
+ }
21
+
22
+ // probe() answers true while the predecessor is still reachable on `port`,
23
+ // false once its /api/health stops answering (port free). A short per-probe
24
+ // timeout keeps a single failed connect from stretching the 200ms poll
25
+ // interval a caller passes below.
26
+ async function defaultProbe(port) {
27
+ try {
28
+ const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
29
+ signal: AbortSignal.timeout(500),
30
+ });
31
+ return res.ok;
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ // D3/D6: called at daemon boot when GARAGE_PREDECESSOR_PID is set (see
38
+ // index.js) — polls until the predecessor's health stops answering (the
39
+ // port is free, safe to bind) or `deadlineMs` passes, in which case it
40
+ // throws so the successor fails loud and exits non-zero rather than racing
41
+ // the predecessor for the port. `probe`/`now`/`sleep` are injectable so this
42
+ // is unit-testable without a real daemon or real time (daemon/test/).
43
+ export async function waitForPredecessor({
44
+ port,
45
+ pid,
46
+ probe = () => defaultProbe(port),
47
+ now = Date.now,
48
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
49
+ deadlineMs = 10000,
50
+ } = {}) {
51
+ const start = now();
52
+ while (now() - start < deadlineMs) {
53
+ if (!(await probe())) return;
54
+ await sleep(200);
55
+ }
56
+ throw new Error(
57
+ `predecessor daemon (pid ${pid}) did not release port ${port} within ${deadlineMs}ms`
58
+ );
59
+ }
60
+
61
+ export default async function daemonRestartRoutes(app) {
62
+ app.post("/api/daemon/restart", async (req, reply) => {
63
+ // daemon/src/daemon-restart.js -> daemon/src/index.js, same directory.
64
+ const daemonEntry = path.resolve(
65
+ path.dirname(fileURLToPath(import.meta.url)),
66
+ "index.js"
67
+ );
68
+ const out = openSync(daemonLogPath(), "a");
69
+ const child = spawn(process.execPath, [daemonEntry], {
70
+ detached: true,
71
+ stdio: ["ignore", out, out],
72
+ // GARAGE_PREDECESSOR_PID tells the successor which pid to wait out
73
+ // before it binds the port (see waitForPredecessor / index.js).
74
+ env: { ...process.env, GARAGE_PREDECESSOR_PID: String(process.pid) },
75
+ });
76
+ child.unref();
77
+
78
+ await reply.code(202).send({ pid: child.pid });
79
+ // The reply must flush to the socket before this process tears itself
80
+ // down — deferred to the next tick so Fastify's write actually reaches
81
+ // the client first (D3).
82
+ setImmediate(async () => {
83
+ await app.close();
84
+ process.exit(0);
85
+ });
86
+ });
87
+ }
@@ -13,6 +13,7 @@ import notifyRoutes from "./notify.js";
13
13
  import diffRoutes from "./diff.js";
14
14
  import editorRoutes from "./editor.js";
15
15
  import worktreeRoutes from "./worktrees.js";
16
+ import daemonRestartRoutes, { waitForPredecessor } from "./daemon-restart.js";
16
17
  import { attachTermServer } from "./term.js";
17
18
  import { rejectForeignOrigins } from "./security.js";
18
19
  import { startPoller } from "./poller.js";
@@ -45,6 +46,7 @@ app.register(notifyRoutes);
45
46
  app.register(diffRoutes);
46
47
  app.register(editorRoutes);
47
48
  app.register(worktreeRoutes);
49
+ app.register(daemonRestartRoutes);
48
50
  attachTermServer(app);
49
51
 
50
52
  // D-packaging: flag-gated so dev mode (Vite on :5173 proxying to this
@@ -71,15 +73,38 @@ app.addHook("onClose", async () => {
71
73
  stopPoller();
72
74
  });
73
75
 
74
- app.listen({ host: HOST, port: PORT }).catch((err) => {
75
- if (err.code === "EADDRINUSE") {
76
- app.log.error(
77
- `port ${PORT} is already in use — set GARAGE_PORT to choose a different port`
78
- );
79
- } else {
76
+ // p16-restart D3/D6: a successor spawned by POST /api/daemon/restart carries
77
+ // GARAGE_PREDECESSOR_PID — wait for that pid's /api/health to stop answering
78
+ // (port free) before binding it, so the handoff never races two daemons for
79
+ // one port. Fails loud (non-zero exit, listen() never called) if the
80
+ // predecessor doesn't let go within 10s, naming the port and pid — see
81
+ // daemon-restart.js. `readyToListen` (rather than exiting straight from the
82
+ // catch below) keeps this a single, unambiguous control-flow path down to
83
+ // exactly one of listen() or process.exit(1) — never both.
84
+ let readyToListen = true;
85
+ if (process.env.GARAGE_PREDECESSOR_PID) {
86
+ await waitForPredecessor({
87
+ port: PORT,
88
+ pid: Number(process.env.GARAGE_PREDECESSOR_PID),
89
+ }).catch((err) => {
80
90
  app.log.error(err);
81
- }
91
+ readyToListen = false;
92
+ });
93
+ }
94
+
95
+ if (readyToListen) {
96
+ app.listen({ host: HOST, port: PORT }).catch((err) => {
97
+ if (err.code === "EADDRINUSE") {
98
+ app.log.error(
99
+ `port ${PORT} is already in use — set GARAGE_PORT to choose a different port`
100
+ );
101
+ } else {
102
+ app.log.error(err);
103
+ }
104
+ process.exit(1);
105
+ });
106
+ } else {
82
107
  process.exit(1);
83
- });
108
+ }
84
109
 
85
110
  export { app };
@@ -127,7 +127,15 @@ export function diffSessionState(sessions, panePids, hostname, prevIds, prevTitl
127
127
  return { changed, ids: currentIds, titles };
128
128
  }
129
129
 
130
- async function tick() {
130
+ // p16-restart follow-up: renamed from `tick` (body unchanged) and split so
131
+ // a route (POST /api/sessions/restart) can `await pollOnce(app)` for one
132
+ // fresh poll — e.g. right after a restarted daemon boots with an empty
133
+ // status store, planning off getStatus's "idle" default would be wrong
134
+ // (status.js's hasStatus). `pollOnce` catches internally and logs via
135
+ // `app` exactly like startPoller's interval always has, so it never
136
+ // rejects — a caller awaiting it never needs its own try/catch, and
137
+ // startPoller's own error-logging shape is preserved unchanged below.
138
+ async function pollBody() {
131
139
  const sessions = await listSessions().catch(() => []);
132
140
 
133
141
  if (sessions.length === 0) {
@@ -192,9 +200,17 @@ async function tick() {
192
200
  }
193
201
  }
194
202
 
203
+ export async function pollOnce(app) {
204
+ try {
205
+ await pollBody();
206
+ } catch (err) {
207
+ app?.log?.warn?.({ err }, "poller tick failed");
208
+ }
209
+ }
210
+
195
211
  export function startPoller(app) {
196
212
  const timer = setInterval(() => {
197
- tick().catch((err) => app?.log?.warn?.({ err }, "poller tick failed"));
213
+ pollOnce(app);
198
214
  }, POLL_MS);
199
215
  timer.unref?.();
200
216
  return () => clearInterval(timer);
@@ -11,6 +11,7 @@ import {
11
11
  hasSession,
12
12
  createSession,
13
13
  killSession,
14
+ respawnPane,
14
15
  } from "./tmux.js";
15
16
  import {
16
17
  getWorkspace,
@@ -19,13 +20,91 @@ import {
19
20
  upsertSessionMeta,
20
21
  removeSessionMeta,
21
22
  } from "./registry.js";
22
- import { getStatusEntry } from "./status.js";
23
+ import { getStatusEntry, getStatus, hasStatus, dropSession as dropStatus } from "./status.js";
24
+ import { dropSession as dropStatuslineContext } from "./statusline.js";
25
+ import { pollerEvents, pollOnce } from "./poller.js";
23
26
  import { createWorktree } from "./worktrees.js";
24
27
  import { getStatuslineContext, getRateLimits } from "./statusline.js";
25
28
  import { getCachedContext, refreshContext } from "./transcript.js";
26
29
 
27
30
  const CLAUDE_CMD = process.env.GARAGE_CLAUDE_CMD ?? "claude";
28
31
 
32
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
33
+
34
+ // D-restore-flow / p16-restart D1: shared by restore (spawn === createSession,
35
+ // a fresh tmux session) and restart (spawn === respawnPane, the SAME tmux
36
+ // session) — both need "start `claude --resume <claudeSessionId>`, then
37
+ // confirm it didn't immediately die". `claude --resume` exits (after
38
+ // printing "No conversation found") when the conversation was never
39
+ // persisted — a session with no submitted exchange — killing the pane/
40
+ // session it just started. The exit isn't instant, so confirm twice before
41
+ // trusting the resume; if it died, or there was no claudeSessionId to resume
42
+ // in the first place (p16-restart out-of-scope note: a fresh session that
43
+ // never wrote a transcript), fall back to a plain `claude` and report
44
+ // `resumed: false` rather than silently defaulting a required value.
45
+ // `spawn` takes the same (id, dir, command, extraArgs) shape createSession
46
+ // and respawnPane already share.
47
+ // Exported alongside planRestartTargets so the missing-claudeSessionId
48
+ // branch (no tmux call at all — it never reaches hasSession) is
49
+ // unit-testable with a fake `spawn`, same discipline as the target-
50
+ // selection tests below (daemon/test/restart.test.js).
51
+ export async function spawnClaudeResumed(spawn, id, dir, claudeSessionId) {
52
+ if (!claudeSessionId) {
53
+ await spawn(id, dir, CLAUDE_CMD);
54
+ return false;
55
+ }
56
+
57
+ await spawn(id, dir, CLAUDE_CMD, ["--resume", claudeSessionId]);
58
+
59
+ await sleep(1500);
60
+ if (await hasSession(id)) {
61
+ await sleep(2000);
62
+ }
63
+ if (await hasSession(id)) {
64
+ return true;
65
+ }
66
+
67
+ await spawn(id, dir, CLAUDE_CMD);
68
+ return false;
69
+ }
70
+
71
+ // p16-restart D2: pure target-selection step for POST /api/sessions/restart
72
+ // — no tmux/IO, so it's exhaustively unit-testable without a live tmux
73
+ // server (daemon/test/restart.test.js). `sessions` is the already-scoped
74
+ // candidate list ({id, status} — either the one requested id or every live
75
+ // session for {all:true}); busy sessions (`working`/`needs-input`) are
76
+ // skipped unless `force`. p16-restart follow-up: a status of "unknown"
77
+ // (the route's stand-in for "the poller has never observed this id" — see
78
+ // hasStatus/needsPollBeforePlan below) is treated the same way — never
79
+ // silently planned as if it were idle (D6: never default a required
80
+ // value). Exported for the test suite.
81
+ export function planRestartTargets(sessions, force) {
82
+ const targets = [];
83
+ const skipped = [];
84
+ for (const s of sessions) {
85
+ const busy = s.status === "working" || s.status === "needs-input" || s.status === "unknown";
86
+ if (!force && busy) {
87
+ skipped.push({ id: s.id, status: s.status });
88
+ } else {
89
+ targets.push(s.id);
90
+ }
91
+ }
92
+ return { targets, skipped };
93
+ }
94
+
95
+ // p16-restart follow-up: pure decision — should the restart route await one
96
+ // fresh poll (pollOnce) before planning? Yes whenever ANY id in `ids` has no
97
+ // status entry yet (`hasStatusFn` is status.js's `hasStatus`, injected so
98
+ // this is unit-testable without the real store). This is exactly the
99
+ // window right after `claude-garage restart` hands off to a fresh successor
100
+ // daemon: its status store is empty, so getStatus(id) would silently read
101
+ // "idle" for a session that is actually mid-turn (status.js docs this
102
+ // default deliberately) — planning off that default would restart a
103
+ // `working` session without `force`.
104
+ export function needsPollBeforePlan(ids, hasStatusFn) {
105
+ return ids.some((id) => !hasStatusFn(id));
106
+ }
107
+
29
108
  export default async function sessionRoutes(app) {
30
109
  app.get("/api/sessions", async () => {
31
110
  const sessions = await listSessions();
@@ -220,8 +299,6 @@ export default async function sessionRoutes(app) {
220
299
  targets = [meta];
221
300
  }
222
301
 
223
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
224
-
225
302
  // Targets are independent — restore them concurrently so the
226
303
  // died-on-resume confirmation waits below don't serialize restore-all.
227
304
  const results = await Promise.all(
@@ -247,25 +324,12 @@ export default async function sessionRoutes(app) {
247
324
  return { failed: { id: meta.id, reason: "session already running" } };
248
325
  }
249
326
 
250
- await createSession(meta.id, spawnDir, CLAUDE_CMD, [
251
- "--resume",
252
- meta.claudeSessionId,
253
- ]);
254
-
255
- // `claude --resume` exits (after printing "No conversation found")
256
- // when the conversation was never persisted — a session with no
257
- // submitted exchange — killing the new tmux session and leaving a
258
- // restore loop. The exit isn't instant, so confirm twice before
259
- // trusting the resume; if it died, fall back to a fresh claude.
260
- let resumed = true;
261
- await sleep(1500);
262
- if (await hasSession(meta.id)) {
263
- await sleep(2000);
264
- }
265
- if (!(await hasSession(meta.id))) {
266
- resumed = false;
267
- await createSession(meta.id, spawnDir, CLAUDE_CMD);
268
- }
327
+ const resumed = await spawnClaudeResumed(
328
+ createSession,
329
+ meta.id,
330
+ spawnDir,
331
+ meta.claudeSessionId
332
+ );
269
333
 
270
334
  return {
271
335
  restored: {
@@ -287,6 +351,103 @@ export default async function sessionRoutes(app) {
287
351
  return reply.code(status).send({ restored, failed });
288
352
  });
289
353
 
354
+ // p16-restart: restart every LIVE session matching {id}, or every live
355
+ // session for {all:true}, in place — tmux respawn-pane (D1), not
356
+ // kill+restore, so the tmux session identity (and everything keyed off
357
+ // it: the wall tile, dockview panel, title) survives. Restorable
358
+ // (non-live) sessions are never targets — they have no pane to respawn;
359
+ // restore them via /api/sessions/restore instead. `force` restarts a
360
+ // working/needs-input session anyway; without it those land in `skipped`
361
+ // (D2) rather than losing an in-flight turn.
362
+ app.post("/api/sessions/restart", async (req, reply) => {
363
+ const { id, all, force } = req.body ?? {};
364
+ if (!id && !all) {
365
+ return reply.code(400).send({ error: "must provide id or all:true" });
366
+ }
367
+
368
+ const liveSessions = await listSessions();
369
+ if (id && !liveSessions.some((s) => s.id === id)) {
370
+ return reply.code(404).send({ error: `no live session: ${id}` });
371
+ }
372
+ const scoped = id ? liveSessions.filter((s) => s.id === id) : liveSessions;
373
+
374
+ // p16-restart follow-up: getStatus defaults an unobserved id to "idle"
375
+ // (by design — every other caller wants that safe default), which is
376
+ // wrong here right after a fresh successor daemon boots with an empty
377
+ // status store (claude-garage restart, D3) — every session would look
378
+ // idle and a `working` one would restart without `force`. Await one
379
+ // real poll first whenever that's the situation, so planning below
380
+ // reads real signal instead of the default.
381
+ if (needsPollBeforePlan(scoped.map((s) => s.id), hasStatus)) {
382
+ await pollOnce(app);
383
+ }
384
+
385
+ const candidates = scoped.map((s) => ({
386
+ id: s.id,
387
+ // Still unobserved even after the poll (claude CLI missing, pid join
388
+ // failed, ...) — "unknown", never silently defaulted to idle; see
389
+ // planRestartTargets.
390
+ status: hasStatus(s.id) ? getStatus(s.id) : "unknown",
391
+ }));
392
+
393
+ const { targets, skipped } = planRestartTargets(candidates, Boolean(force));
394
+
395
+ const liveById = new Map(liveSessions.map((s) => [s.id, s]));
396
+ const metas = await listSessionMetas();
397
+ const metaById = new Map(metas.map((m) => [m.id, m]));
398
+
399
+ // Targets are independent — restart them concurrently, same discipline
400
+ // as restore-all above. A tmux failure (respawnPane rejects with tmux's
401
+ // stderr) is caught here and reported per-target in `failed`, rather
402
+ // than letting one bad target take down the rest of the batch.
403
+ const results = await Promise.all(
404
+ targets.map(async (targetId) => {
405
+ const live = liveById.get(targetId);
406
+ const meta = metaById.get(targetId);
407
+ try {
408
+ // Same dir resolution restore uses (D-wt-meta): the worktree
409
+ // path when this is a worktree session, else the registered
410
+ // workspace dir — falling back to the live tmux dir only when
411
+ // the session predates any registry meta (a fresh session the
412
+ // poller hasn't observed yet).
413
+ const registered = await getWorkspace(live.workspace);
414
+ const dir = meta?.worktree?.path ?? registered?.dir ?? live.dir;
415
+
416
+ const resumed = await spawnClaudeResumed(
417
+ respawnPane,
418
+ targetId,
419
+ dir,
420
+ meta?.claudeSessionId ?? null
421
+ );
422
+
423
+ // The pane now runs a fresh claude process (a new pid) — the
424
+ // status the poller had for the OLD process (working/done/
425
+ // needs-input) no longer describes anything. Clear it the same
426
+ // way the poller's own dropSession does for a dead session, so
427
+ // the next tick's pid-join re-derives the real state instead of
428
+ // carrying stale signal forward.
429
+ dropStatus(targetId);
430
+ dropStatuslineContext(targetId);
431
+
432
+ return { restarted: { id: targetId, resumed } };
433
+ } catch (err) {
434
+ return { failed: { id: targetId, error: err.message } };
435
+ }
436
+ })
437
+ );
438
+
439
+ const restarted = results.filter((r) => r.restarted).map((r) => r.restarted);
440
+ const failed = results.filter((r) => r.failed).map((r) => r.failed);
441
+
442
+ // respawn-pane never changes the tmux session-id set the poller diffs
443
+ // on (same session, new process), so the poller's own "sessions-changed"
444
+ // detection never fires for a restart — push it explicitly so the wall
445
+ // refetches promptly instead of waiting on an unrelated event.
446
+ if (restarted.length > 0) pollerEvents.emit("sessions-changed");
447
+
448
+ return reply.code(200).send({ restarted, skipped, failed });
449
+ });
450
+
290
451
  // id contains slashes — capture the whole tail as a wildcard.
291
452
  app.delete("/api/sessions/*", async (req, reply) => {
292
453
  const id = decodeURIComponent(req.params["*"]);
@@ -26,6 +26,17 @@ export function getStatus(id) {
26
26
  return store.get(id)?.state ?? "idle";
27
27
  }
28
28
 
29
+ // p16-restart follow-up: true only when the store has a real entry for
30
+ // `id` — unlike getStatus (which reads "idle" for an unobserved id, by
31
+ // design, so every other caller gets a safe default), callers that must
32
+ // tell "genuinely idle" apart from "the poller hasn't looked at this one
33
+ // yet" (e.g. planning a session restart right after a fresh successor
34
+ // daemon boots with an empty store) need this instead. getStatus itself is
35
+ // unchanged.
36
+ export function hasStatus(id) {
37
+ return store.has(id);
38
+ }
39
+
29
40
  // Same lookup as getStatus but also exposes `since` (epoch ms the current
30
41
  // state began, or null for a never-signaled id) so callers can compute
31
42
  // elapsed time without a second store.
@@ -175,6 +175,24 @@ export async function createSession(id, dir, command, extraArgs = []) {
175
175
  await run("tmux", ["set-option", "-t", id, "status", "off"]).catch(() => {});
176
176
  }
177
177
 
178
+ // p16-restart D1: in-place restart — kills the pane's running process (tmux
179
+ // sends SIGHUP) and starts `command` in the SAME pane/session/tmux identity
180
+ // in one call, so the tmux session (and everything the daemon/UI key off
181
+ // its name) never dies. `-c <dir>` re-roots the new command exactly the way
182
+ // `createSession`'s `-c` does; extraArgs join the same way createSession's
183
+ // do — see the comment above it. No try/catch: a tmux failure (unknown
184
+ // target, bad dir) rejects with tmux's stderr in the message, same as every
185
+ // other call through `run`.
186
+ //
187
+ // Target: `-t <id>` targets a PANE, not a session (unlike every other
188
+ // single-target call here, which targets a session) — bare `=<id>` exact-
189
+ // matches nothing (tmux looks for a pane literally named that), so a
190
+ // trailing `:` is required to say "this session, exactly; its active
191
+ // window's active pane" (garage sessions only ever have the one pane).
192
+ export async function respawnPane(id, dir, command, extraArgs = []) {
193
+ await run("tmux", ["respawn-pane", "-k", "-c", dir, "-t", `${exact(id)}:`, command, ...extraArgs]);
194
+ }
195
+
178
196
  // p14: Claude Code's Shift+Enter (kitty protocol, ESC[13;2u) only reaches a
179
197
  // pane if the tmux server has extended keys on and advertises them (and,
180
198
  // since 0.3.4, re-encodes in csi-u form so Option+Enter survives too) —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-garage",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "Multiple Claude agents driving you crazy? Park them all in one garage: a needs-input queue that follows you across projects, a full-screen terminal TUI, and a browser wall with diff review. tmux owns the sessions; close the app and nothing dies.",
5
5
  "license": "MIT",
6
6
  "author": "Saiful Islam <saifulislamadar@gmail.com>",