claude-garage 0.3.3 โ 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 +56 -1
- 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 +27 -1
- 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
|
@@ -150,7 +150,7 @@ the features the TUI doesn't have yet:
|
|
|
150
150
|
notification when no page is open (clickable with
|
|
151
151
|
[`terminal-notifier`](https://github.com/julienXX/terminal-notifier)).
|
|
152
152
|
|
|
153
|
-
### The pit pet ๐
|
|
153
|
+
### The pit pet ๐
|
|
154
154
|
|
|
155
155
|
An optional ASCII companion on the key strip whose mood *is* the wall:
|
|
156
156
|
asleep when all is quiet, watching while agents run, **sprinting toward the
|
|
@@ -177,12 +177,64 @@ state in character:
|
|
|
177
177
|
| **Papito** (rubber duck) | deadpan: waddles, **never** bounces, and alerts with a single motionless stare; celebration is exactly one flap |
|
|
178
178
|
| **Segan** (pit pup) | maximum enthusiasm: fastest runner, biggest bounce, and celebrates with zoomies across the strip |
|
|
179
179
|
|
|
180
|
+
**In the TUI** the pet lives at the right end of the bottom strip, beside
|
|
181
|
+
the keys chip, as a one-row sprite (`=o.o=`, `<(o )___`, `(ยทแดฅยท)`), same
|
|
182
|
+
moods, same manners. It speaks right next to itself:
|
|
183
|
+
`you're doing great!! (ยทแดฅยท) keys โ garage`. Press **`P`** to
|
|
184
|
+
cycle Arthur โ Papito โ Segan โ off; the choice is remembered. Click the
|
|
185
|
+
pet while it's alert to jump to the blocked session, click it otherwise to
|
|
186
|
+
pet it. TUI pets also **talk**: every few minutes, in character, the strip
|
|
187
|
+
gets a line like *"water. now. i'm not asking."* (Arthur), *"acceptable."*
|
|
188
|
+
(Papito) or *"you're doing great!!"* (Segan). They notice long days, late
|
|
189
|
+
nights, and a usage window running hot, and they never interrupt a session
|
|
190
|
+
that needs you. Set `GARAGE_PET_ASCII=1` if your font lacks `แดฅ`.
|
|
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
|
+
|
|
180
225
|
## Keybindings
|
|
181
226
|
|
|
182
227
|
The TUI's full keymap (`claude-garage tui`, also shown in-app with `?`).
|
|
183
228
|
Bindings apply at the garage layer; `Enter` hands your keystrokes to the
|
|
184
229
|
focused session byte-exact until `Ctrl+G` takes them back.
|
|
185
230
|
|
|
231
|
+
**Terminal:** Ghostty, iTerm2, kitty and WezTerm pass every Claude Code
|
|
232
|
+
binding through, Shift+Enter included. macOS Terminal.app speaks neither
|
|
233
|
+
the kitty keyboard protocol nor modifyOtherKeys, so Shift+Enter can't be
|
|
234
|
+
told apart from Enter there; either switch terminals or add a Terminal.app
|
|
235
|
+
key mapping (Settings โ Profiles โ Keyboard) for Shift+Return that sends
|
|
236
|
+
`\033[13;2u`. Option+Enter and Ctrl+J work everywhere.
|
|
237
|
+
|
|
186
238
|
| Key | Action |
|
|
187
239
|
|---|---|
|
|
188
240
|
| `1`โ`9` | focus workspace |
|
|
@@ -197,6 +249,9 @@ focused session byte-exact until `Ctrl+G` takes them back.
|
|
|
197
249
|
| `R` | restore all restorable sessions in workspace |
|
|
198
250
|
| `I` | install statusline feed for context meters |
|
|
199
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) |
|
|
200
255
|
| `X X` | remove focused workspace (sessions keep running) |
|
|
201
256
|
| `X K` | remove focused workspace AND kill its sessions |
|
|
202
257
|
| `w` | add workspace |
|
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["*"]);
|
package/daemon/src/status.js
CHANGED
|
@@ -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.
|