claude-garage 0.3.4 → 0.4.1

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
@@ -3,16 +3,18 @@
3
3
  <img src="docs/banner.png" alt="claude-garage: a pit wall for your Claude Code agents" width="100%" />
4
4
 
5
5
  **Multiple Claude agents driving you crazy? Park them all in one
6
- garage.** A needs-input queue that follows you across every project (`a`
7
- jumps to whoever's waiting), file-by-file diff review, and tmux-owned
8
- sessions that outlive the tool — reboot the Mac, `tmux attach` still works.
6
+ garage.** A full-screen terminal wall that shows every session at once, a
7
+ needs-input queue that follows you across every project (`a` jumps to
8
+ whoever's waiting), and tmux-owned sessions that outlive the tool — reboot
9
+ the Mac, `tmux attach` still works. A browser wall with file-by-file diff
10
+ review comes along for the ride.
9
11
 
10
12
  [![npm](https://img.shields.io/npm/v/claude-garage?color=e2a75e&label=npm)](https://www.npmjs.com/package/claude-garage)
11
13
  [![license](https://img.shields.io/badge/license-MIT-79b26e)](LICENSE)
12
14
  [![node](https://img.shields.io/badge/node-%E2%89%A5%2020-6e9ecc)](package.json)
13
15
  [![local](https://img.shields.io/badge/100%25-local-c6cfdb)](#local-only-by-design)
14
16
 
15
- <img src="docs/hero.png" alt="The web wall: two workspaces, a session asking for permission (amber), a finished worktree session, and the diff pane" width="100%" />
17
+ <img src="docs/hero-tui.png" alt="The terminal wall: two workspaces in the rail, four live Claude sessions, one asking for permission (amber), and the status strip with the blocked count" width="100%" />
16
18
 
17
19
  </div>
18
20
 
@@ -70,7 +72,7 @@ and every tmux session running. It's also light: ~5ms input latency, ~2%
70
72
  CPU, and a 2.4MB binary (measured on the ratatui port — see
71
73
  [`openspec/changes/archive/2026-08-31-p9-ratatui-port/verification.md`](openspec/changes/archive/2026-08-31-p9-ratatui-port/verification.md)).
72
74
 
73
- Since p10–p12, the TUI also groups sessions into views (`d` detaches the
75
+ The TUI also groups sessions into views (`d` detaches the
74
76
  focused one into its own view or rejoins it to the default, `D` moves it to
75
77
  a chosen group, `Tab` cycles views), shows a dim auto-subtitle under each
76
78
  session's label straight from Claude Code's own terminal-title updates (zero
@@ -78,7 +80,9 @@ config), and tracks context pressure: a per-tile meter plus a `5h`/`7-day`
78
80
  usage chip in the strip, fed by a one-keypress `I` install of a chaining
79
81
  statusline wrapper (any statusline you already have keeps running). Closing
80
82
  a plain session tears it down; closing a worktree session keeps its branch
81
- and points you at the web wall to merge or discard it.
83
+ and points you at the web wall to merge or discard it. `w` adds a project:
84
+ type a path, paste one, drag a folder in from Finder, or press `^O` for the
85
+ macOS folder picker.
82
86
 
83
87
  **Prefer a browser?**
84
88
 
@@ -189,6 +193,39 @@ gets a line like *"water. now. i'm not asking."* (Arthur), *"acceptable."*
189
193
  nights, and a usage window running hot, and they never interrupt a session
190
194
  that needs you. Set `GARAGE_PET_ASCII=1` if your font lacks `ᴥ`.
191
195
 
196
+ ## Restarting
197
+
198
+ Two things go stale under a running wall: the garage daemon itself (after
199
+ a `npm` upgrade) and the `claude` binary inside every session (after
200
+ Anthropic ships a release — Claude Code shows "update available", but a
201
+ running session keeps the old process until it's restarted).
202
+
203
+ ```bash
204
+ claude-garage restart
205
+ ```
206
+
207
+ Restarts the daemon only. Sessions are untouched; the TUI and web wall
208
+ reconnect on their own through the existing SSE reconnect.
209
+
210
+ ```bash
211
+ claude-garage restart --sessions
212
+ ```
213
+
214
+ Restarts the daemon, then respawns every idle/done session in place —
215
+ `tmux respawn-pane -k` running `claude --resume <id>` in the same tmux
216
+ session, so the tile, its title and the conversation all survive. Sessions
217
+ that are `working` or `needs-input` are skipped and listed rather than
218
+ losing an in-flight turn; `--all` restarts those too.
219
+
220
+ **In the TUI:** `r r` restarts the focused session (press twice — the
221
+ first press warns if it's busy), `r a` restarts every idle/done session
222
+ in the focused workspace, `r d` restarts the daemon. `?` lists all three.
223
+
224
+ **In the web wall:** the `↻` in a cell's hover controls restarts that
225
+ session, with the same two-click confirmation.
226
+
227
+ Upgrading the garage package itself is still `npx claude-garage@latest tui`.
228
+
192
229
  ## Keybindings
193
230
 
194
231
  The TUI's full keymap (`claude-garage tui`, also shown in-app with `?`).
@@ -216,11 +253,15 @@ key mapping (Settings → Profiles → Keyboard) for Shift+Return that sends
216
253
  | `R` | restore all restorable sessions in workspace |
217
254
  | `I` | install statusline feed for context meters |
218
255
  | `x x` | close focused session (press twice) |
256
+ | `r r` | restart focused session — resumes on the current claude binary |
257
+ | `r a` | restart idle/done sessions in workspace |
258
+ | `r d` | restart the daemon (sessions untouched) |
219
259
  | `X X` | remove focused workspace (sessions keep running) |
220
260
  | `X K` | remove focused workspace AND kill its sessions |
221
- | `w` | add workspace |
261
+ | `w` | add workspace — type, paste or drag a path in, `^O` opens the folder picker |
222
262
  | `d` | detach focused session to its own view, or rejoin main |
223
263
  | `D` | move focused session to another view / new group |
264
+ | `P` | cycle the pit pet: Arthur / Papito / Segan / off |
224
265
  | `Tab` | cycle the focused workspace's views |
225
266
  | `?` | toggle this help |
226
267
  | `q` | quit (tmux sessions keep running) |
@@ -258,7 +299,9 @@ npm run build:tui # cargo build --release; copies the binary into wall/dist
258
299
  ```
259
300
 
260
301
  `wall/` is a separate Rust workspace with its own unit and e2e suites
261
- (`cd wall && cargo test`; e2e harnesses under `wall/test/e2e`).
302
+ (`cd wall && cargo test --lib --bins` for the unit tests; e2e harnesses under
303
+ `wall/test/e2e`). Plain `cargo test` also runs `tests/injection.rs`, which
304
+ creates and kills scratch sessions on your real tmux server.
262
305
 
263
306
  Built through spec-driven phases ([`openspec/`](openspec/)), each verified
264
307
  end-to-end on a real system.
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["*"]);