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 +51 -8
- package/bin/garage.js +115 -2
- package/daemon/src/daemon-restart.js +87 -0
- package/daemon/src/index.js +33 -8
- package/daemon/src/poller.js +18 -2
- package/daemon/src/sessions.js +183 -22
- package/daemon/src/status.js +11 -0
- package/daemon/src/tmux.js +18 -0
- package/package.json +1 -1
- package/ui/dist/assets/{index-bVL5DcKG.js → index-CK8OO0D_.js} +18 -18
- package/ui/dist/index.html +1 -1
- package/wall/dist/garage-wall-darwin-arm64 +0 -0
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
|
|
7
|
-
|
|
8
|
-
sessions that outlive the tool — reboot
|
|
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
|
[](https://www.npmjs.com/package/claude-garage)
|
|
11
13
|
[](LICENSE)
|
|
12
14
|
[](package.json)
|
|
13
15
|
[](#local-only-by-design)
|
|
14
16
|
|
|
15
|
-
<img src="docs/hero.png" alt="The
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/daemon/src/index.js
CHANGED
|
@@ -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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 };
|
package/daemon/src/poller.js
CHANGED
|
@@ -127,7 +127,15 @@ export function diffSessionState(sessions, panePids, hostname, prevIds, prevTitl
|
|
|
127
127
|
return { changed, ids: currentIds, titles };
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
|
|
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
|
-
|
|
213
|
+
pollOnce(app);
|
|
198
214
|
}, POLL_MS);
|
|
199
215
|
timer.unref?.();
|
|
200
216
|
return () => clearInterval(timer);
|
package/daemon/src/sessions.js
CHANGED
|
@@ -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
|
-
|
|
251
|
-
|
|
252
|
-
meta.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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["*"]);
|