claude-garage 0.1.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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +184 -0
  3. package/bin/garage.js +143 -0
  4. package/daemon/package.json +13 -0
  5. package/daemon/src/diff.js +465 -0
  6. package/daemon/src/editor.js +63 -0
  7. package/daemon/src/events.js +54 -0
  8. package/daemon/src/hooks.js +241 -0
  9. package/daemon/src/index.js +76 -0
  10. package/daemon/src/notify.js +80 -0
  11. package/daemon/src/picker.js +48 -0
  12. package/daemon/src/poller.js +134 -0
  13. package/daemon/src/registry.js +189 -0
  14. package/daemon/src/security.js +39 -0
  15. package/daemon/src/sessions.js +255 -0
  16. package/daemon/src/status.js +59 -0
  17. package/daemon/src/term.js +94 -0
  18. package/daemon/src/tmux.js +124 -0
  19. package/daemon/src/workspaces.js +139 -0
  20. package/daemon/src/worktrees.js +180 -0
  21. package/package.json +57 -0
  22. package/ui/dist/apple-touch-icon.png +0 -0
  23. package/ui/dist/assets/google-sans-code-latin-400-normal-C-TpydLV.woff +0 -0
  24. package/ui/dist/assets/google-sans-code-latin-400-normal-CqaqKeEp.woff2 +0 -0
  25. package/ui/dist/assets/google-sans-code-latin-600-normal-BUCrGHE5.woff2 +0 -0
  26. package/ui/dist/assets/google-sans-code-latin-600-normal-CKee-OZj.woff +0 -0
  27. package/ui/dist/assets/google-sans-code-latin-700-normal-0vT4k3S3.woff2 +0 -0
  28. package/ui/dist/assets/google-sans-code-latin-700-normal-BvnkA064.woff +0 -0
  29. package/ui/dist/assets/google-sans-code-latin-ext-400-normal-CALCk2lB.woff +0 -0
  30. package/ui/dist/assets/google-sans-code-latin-ext-400-normal-xZllot2U.woff2 +0 -0
  31. package/ui/dist/assets/google-sans-code-latin-ext-600-normal-CNNK3O9-.woff2 +0 -0
  32. package/ui/dist/assets/google-sans-code-latin-ext-600-normal-DDkmmTp6.woff +0 -0
  33. package/ui/dist/assets/google-sans-code-latin-ext-700-normal-CxgnixdG.woff2 +0 -0
  34. package/ui/dist/assets/google-sans-code-latin-ext-700-normal-bxgt_uDG.woff +0 -0
  35. package/ui/dist/assets/google-sans-code-math-400-normal-C2EgcUvD.woff2 +0 -0
  36. package/ui/dist/assets/google-sans-code-math-400-normal-CgFzjhah.woff +0 -0
  37. package/ui/dist/assets/google-sans-code-math-600-normal-B5PK-qTM.woff +0 -0
  38. package/ui/dist/assets/google-sans-code-math-600-normal-DT_RT86i.woff2 +0 -0
  39. package/ui/dist/assets/google-sans-code-math-700-normal-C65M3EbO.woff +0 -0
  40. package/ui/dist/assets/google-sans-code-math-700-normal-CgTZgjvl.woff2 +0 -0
  41. package/ui/dist/assets/google-sans-code-symbols-400-normal-BtBR4e7S.woff2 +0 -0
  42. package/ui/dist/assets/google-sans-code-symbols-400-normal-NSSmFXdP.woff +0 -0
  43. package/ui/dist/assets/google-sans-code-symbols-600-normal-DdWLfSlP.woff2 +0 -0
  44. package/ui/dist/assets/google-sans-code-symbols-600-normal-QtnQvwms.woff +0 -0
  45. package/ui/dist/assets/google-sans-code-symbols-700-normal-Ceb9rWtn.woff +0 -0
  46. package/ui/dist/assets/google-sans-code-symbols-700-normal-DDa4ndCu.woff2 +0 -0
  47. package/ui/dist/assets/google-sans-code-symbols2-400-normal-CicwJrzf.woff +0 -0
  48. package/ui/dist/assets/google-sans-code-symbols2-600-normal-BtzXZASM.woff +0 -0
  49. package/ui/dist/assets/google-sans-code-symbols2-700-normal-Cci1DrKv.woff +0 -0
  50. package/ui/dist/assets/google-sans-code-vietnamese-400-normal-CWRUr5CO.woff2 +0 -0
  51. package/ui/dist/assets/google-sans-code-vietnamese-400-normal-DhSdRbI2.woff +0 -0
  52. package/ui/dist/assets/google-sans-code-vietnamese-600-normal-DBBK0KXU.woff +0 -0
  53. package/ui/dist/assets/google-sans-code-vietnamese-600-normal-zsb-D4cZ.woff2 +0 -0
  54. package/ui/dist/assets/google-sans-code-vietnamese-700-normal-DDJUOtHZ.woff +0 -0
  55. package/ui/dist/assets/google-sans-code-vietnamese-700-normal-jZ3jhCNl.woff2 +0 -0
  56. package/ui/dist/assets/index-B9ScE6Rx.css +32 -0
  57. package/ui/dist/assets/index-BjoMvOWt.js +70 -0
  58. package/ui/dist/favicon-32.png +0 -0
  59. package/ui/dist/index.html +23 -0
@@ -0,0 +1,189 @@
1
+ import { readFile, writeFile, rename, mkdir, unlink } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { GARAGE_PREFIX } from "./tmux.js";
6
+
7
+ // D-registry: ~/.garage/state.json {workspaces:{name:{dir}}}.
8
+ // Created lazily on first write — a user who registers nothing has no file
9
+ // to migrate or corrupt. tmux ls remains the sole source of truth for
10
+ // sessions; this file is a directory mapping only, never a session list.
11
+ const GARAGE_DIR = path.join(homedir(), ".garage");
12
+ const STATE_PATH = path.join(GARAGE_DIR, "state.json");
13
+
14
+ async function readState() {
15
+ try {
16
+ const raw = await readFile(STATE_PATH, "utf8");
17
+ const parsed = JSON.parse(raw);
18
+ const workspaces =
19
+ parsed && typeof parsed.workspaces === "object" && parsed.workspaces !== null
20
+ ? parsed.workspaces
21
+ : {};
22
+ // D-resume-meta: sessions map defaulted the same way workspaces always
23
+ // has been — a pre-P3 state.json (no `sessions` key) loads exactly as it
24
+ // did before, and silently gains the key on the first write post-upgrade.
25
+ // Spreading `parsed` preserves hookToken and any other existing keys.
26
+ const sessions =
27
+ parsed && typeof parsed.sessions === "object" && parsed.sessions !== null
28
+ ? parsed.sessions
29
+ : {};
30
+ return { ...parsed, workspaces, sessions };
31
+ } catch {
32
+ // Missing file, unreadable, or malformed JSON — treat as empty registry.
33
+ return { workspaces: {}, sessions: {} };
34
+ }
35
+ }
36
+
37
+ // Atomic write: temp file in the same directory, then rename(). rename() is
38
+ // atomic on the same filesystem, so a crash mid-write leaves the previous
39
+ // complete file or the new complete file — never a truncated one.
40
+ async function writeState(state) {
41
+ await mkdir(GARAGE_DIR, { recursive: true });
42
+ const tmpPath = path.join(GARAGE_DIR, `.state.json.${process.pid}.${randomUUID()}.tmp`);
43
+ try {
44
+ await writeFile(tmpPath, JSON.stringify(state, null, 2));
45
+ await rename(tmpPath, STATE_PATH);
46
+ } catch (err) {
47
+ await unlink(tmpPath).catch(() => {});
48
+ throw err;
49
+ }
50
+ }
51
+
52
+ export async function listWorkspaces() {
53
+ const state = await readState();
54
+ return Object.entries(state.workspaces).map(([name, entry]) => ({
55
+ name,
56
+ dir: entry.dir,
57
+ }));
58
+ }
59
+
60
+ export async function getWorkspace(name) {
61
+ const state = await readState();
62
+ return state.workspaces[name] ?? null;
63
+ }
64
+
65
+ export async function upsertWorkspace(name, dir) {
66
+ const state = await readState();
67
+ state.workspaces[name] = { dir };
68
+ await writeState(state);
69
+ return { name, dir };
70
+ }
71
+
72
+ // D-rename: moves the workspace's registry key and rewrites every
73
+ // resume-metadata key that embeds the old name (`garage/<old>/<label>` ->
74
+ // `garage/<new>/<label>`, plus the meta's own `workspace` field) in one
75
+ // atomic write — mirrors the live tmux renames the caller performs
76
+ // separately. Callers are expected to have already validated oldName exists
77
+ // and newName is free; this function trusts its inputs.
78
+ export async function renameWorkspace(oldName, newName) {
79
+ const state = await readState();
80
+ const entry = state.workspaces[oldName];
81
+ if (!entry) return null;
82
+
83
+ delete state.workspaces[oldName];
84
+ state.workspaces[newName] = entry;
85
+
86
+ const oldPrefix = `${GARAGE_PREFIX}${oldName}/`;
87
+ const newPrefix = `${GARAGE_PREFIX}${newName}/`;
88
+ const nextSessions = {};
89
+ for (const [id, meta] of Object.entries(state.sessions)) {
90
+ if (id.startsWith(oldPrefix)) {
91
+ const newId = newPrefix + id.slice(oldPrefix.length);
92
+ nextSessions[newId] = { ...meta, workspace: newName };
93
+ } else {
94
+ nextSessions[id] = meta;
95
+ }
96
+ }
97
+ state.sessions = nextSessions;
98
+
99
+ await writeState(state);
100
+ return { name: newName, dir: entry.dir };
101
+ }
102
+
103
+ // D-resume-meta / D-wt-meta: sessions map, keyed by garage session id,
104
+ // {claudeSessionId, workspace, label, worktree}. `claudeSessionId`/
105
+ // `workspace`/`label` are written opportunistically by the poller whenever
106
+ // it observes a session's Claude Code sessionId (see poller.js); `worktree`
107
+ // (`{path, branch, repoDir}` or `null`) is written once, at spawn, by
108
+ // sessions.js and never touched by the poller — see upsertSessionMeta's
109
+ // field-level merge below. Deleted only on a deliberate DELETE
110
+ // /api/sessions/:id (see sessions.js) — a session vanishing from tmux any
111
+ // other way (reboot, `tmux kill-server`, an out-of-band `tmux kill-session`)
112
+ // leaves its meta in place so it can be offered as restorable.
113
+ export async function getSessionMeta(id) {
114
+ const state = await readState();
115
+ return state.sessions[id] ?? null;
116
+ }
117
+
118
+ export async function listSessionMetas() {
119
+ const state = await readState();
120
+ return Object.entries(state.sessions).map(([id, meta]) => ({ id, ...meta }));
121
+ }
122
+
123
+ // Write only if changed: a session's claudeSessionId is stable for the life
124
+ // of one conversation, so after the first tick following spawn this is a
125
+ // no-op comparison on every subsequent poll — the file is touched once per
126
+ // session's lifetime in the common case, mirroring getHookToken()'s
127
+ // generate-and-persist-once discipline and avoiding the write-amplification
128
+ // D-registry (P0) was careful to avoid.
129
+ //
130
+ // D-wt-meta: field-level merge, not a blind overwrite. Two independent
131
+ // writers share this record — sessions.js writes `worktree` once, at spawn,
132
+ // and never touches `claudeSessionId`/`label`/`workspace` again; poller.js
133
+ // writes `claudeSessionId`/`workspace`/`label` on every tick once it learns
134
+ // the Claude Code sessionId, and never mentions `worktree`. A field absent
135
+ // (`undefined`) from the incoming `meta` falls back to whatever's already on
136
+ // disk, so neither writer can clobber the other's field — only a caller that
137
+ // explicitly passes a field (including `null`) can change or clear it.
138
+ export async function upsertSessionMeta(id, meta) {
139
+ const state = await readState();
140
+ const existing = state.sessions[id];
141
+ const next = {
142
+ claudeSessionId:
143
+ meta.claudeSessionId !== undefined ? meta.claudeSessionId : (existing?.claudeSessionId ?? null),
144
+ workspace: meta.workspace !== undefined ? meta.workspace : existing?.workspace,
145
+ label: meta.label !== undefined ? meta.label : existing?.label,
146
+ worktree: meta.worktree !== undefined ? meta.worktree : (existing?.worktree ?? null),
147
+ };
148
+ const unchanged = existing && JSON.stringify(existing) === JSON.stringify(next);
149
+ if (unchanged) return existing;
150
+
151
+ state.sessions[id] = next;
152
+ await writeState(state);
153
+ return next;
154
+ }
155
+
156
+ export async function removeSessionMeta(id) {
157
+ const state = await readState();
158
+ if (!(id in state.sessions)) return;
159
+ delete state.sessions[id];
160
+ await writeState(state);
161
+ }
162
+
163
+ // Deleting a workspace drops its registry entry AND its sessions' resume
164
+ // metadata (they could never restore without a registered dir — a 409 loop),
165
+ // but by design never touches tmux; see workspaces.js DELETE.
166
+ export async function removeWorkspace(name) {
167
+ const state = await readState();
168
+ if (!(name in state.workspaces)) return;
169
+ delete state.workspaces[name];
170
+ const prefix = `garage/${name}/`;
171
+ for (const id of Object.keys(state.sessions)) {
172
+ if (id.startsWith(prefix)) delete state.sessions[id];
173
+ }
174
+ await writeState(state);
175
+ }
176
+
177
+ // Per-install shared secret for the hook endpoint (CSRF defense-in-depth):
178
+ // generated once, embedded in the hook snippet URL, persisted so daemon
179
+ // restarts don't invalidate hooks already installed in ~/.claude/settings.json.
180
+ export async function getHookToken() {
181
+ const state = await readState();
182
+ if (typeof state.hookToken === "string" && state.hookToken.length >= 32) {
183
+ return state.hookToken;
184
+ }
185
+ const { randomBytes } = await import("node:crypto");
186
+ state.hookToken = randomBytes(32).toString("hex");
187
+ await writeState(state);
188
+ return state.hookToken;
189
+ }
@@ -0,0 +1,39 @@
1
+ // Browser pages are confused deputies: any website can fire requests at
2
+ // 127.0.0.1, and WebSockets are exempt from CORS. Browsers always send an
3
+ // Origin header, so we allowlist the UI's origin. Requests WITHOUT an Origin
4
+ // (curl, scripts) are allowed — a local process needs no browser to reach
5
+ // tmux and already runs with the user's privileges.
6
+ //
7
+ // D-packaging: in same-process serving mode (GARAGE_SERVE_UI=1) the UI is
8
+ // served from the daemon's own origin instead of Vite's :5173, so that
9
+ // origin (following GARAGE_PORT, same as index.js) must be allowlisted too —
10
+ // otherwise the served UI's own fetch/EventSource calls would be rejected as
11
+ // foreign.
12
+ const PORT = Number(process.env.GARAGE_PORT ?? 4747);
13
+ const DEFAULT_ORIGINS = [
14
+ "http://127.0.0.1:5173",
15
+ "http://localhost:5173",
16
+ `http://127.0.0.1:${PORT}`,
17
+ `http://localhost:${PORT}`,
18
+ ];
19
+
20
+ export const ALLOWED_ORIGINS = new Set(
21
+ process.env.GARAGE_UI_ORIGINS?.split(",").map((s) => s.trim()) ??
22
+ DEFAULT_ORIGINS
23
+ );
24
+
25
+ export function originAllowed(origin) {
26
+ return origin === undefined || ALLOWED_ORIGINS.has(origin);
27
+ }
28
+
29
+ const STATE_CHANGING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
30
+
31
+ // No exemptions: claude's hook posts carry no Origin header, so they pass
32
+ // the allowlist naturally (header-less = ordinary local process). The hook
33
+ // endpoint additionally requires a per-install token — see hooks.js.
34
+ export async function rejectForeignOrigins(req, reply) {
35
+ if (!STATE_CHANGING.has(req.method)) return;
36
+ if (!originAllowed(req.headers.origin)) {
37
+ return reply.code(403).send({ error: "origin not allowed" });
38
+ }
39
+ }
@@ -0,0 +1,255 @@
1
+ import { stat } from "node:fs/promises";
2
+ import {
3
+ GARAGE_PREFIX,
4
+ NAME_RE,
5
+ sessionId,
6
+ listSessions,
7
+ listPanePaths,
8
+ resolveBranch,
9
+ hasSession,
10
+ createSession,
11
+ killSession,
12
+ } from "./tmux.js";
13
+ import {
14
+ getWorkspace,
15
+ listSessionMetas,
16
+ getSessionMeta,
17
+ upsertSessionMeta,
18
+ removeSessionMeta,
19
+ } from "./registry.js";
20
+ import { getStatus } from "./status.js";
21
+ import { createWorktree } from "./worktrees.js";
22
+
23
+ const CLAUDE_CMD = process.env.GARAGE_CLAUDE_CMD ?? "claude";
24
+
25
+ export default async function sessionRoutes(app) {
26
+ app.get("/api/sessions", async () => {
27
+ const sessions = await listSessions();
28
+ const panePaths = await listPanePaths();
29
+
30
+ // D-branch: this route is re-hit on every SSE-driven refetch, so the
31
+ // git calls it triggers must stay cheap — one execFile per unique
32
+ // directory per request, not per session. `getBranch` memoizes on
33
+ // that dir for the lifetime of this single request only (a fresh Map
34
+ // per call, no cross-request caching); resolveBranch itself already
35
+ // swallows errors and returns null, so a bad/non-git dir never surfaces
36
+ // here as a rejection.
37
+ const branchCache = new Map();
38
+ const getBranch = (dir) => {
39
+ if (!dir) return Promise.resolve(null);
40
+ if (!branchCache.has(dir)) branchCache.set(dir, resolveBranch(dir));
41
+ return branchCache.get(dir);
42
+ };
43
+
44
+ const live = await Promise.all(
45
+ sessions.map(async (s) => ({
46
+ ...s,
47
+ status: getStatus(s.id),
48
+ // Live pane cwd, not session_path (see listPanePaths) — falls back
49
+ // to session_path only if the pane vanished between the two tmux
50
+ // calls above (a session that died mid-request).
51
+ branch: await getBranch(panePaths.get(s.id) ?? s.dir),
52
+ }))
53
+ );
54
+ const liveIds = new Set(live.map((s) => s.id));
55
+
56
+ // D-restore-flow: append one entry per resume-metadata record whose id
57
+ // has no matching live tmux session. dir is re-resolved from the
58
+ // registry (not a cached copy in the meta record) so a workspace
59
+ // re-registered to a new path since the crash is reflected correctly.
60
+ const metas = await listSessionMetas();
61
+ const restorable = [];
62
+ for (const meta of metas) {
63
+ if (liveIds.has(meta.id)) continue;
64
+ const registered = await getWorkspace(meta.workspace);
65
+ const dir = registered?.dir ?? null;
66
+ restorable.push({
67
+ id: meta.id,
68
+ workspace: meta.workspace,
69
+ label: meta.label,
70
+ dir,
71
+ attached: false,
72
+ status: "restorable",
73
+ restorable: true,
74
+ // No live pane to read a cwd from — resolve against the
75
+ // registered workspace dir instead; null if that's gone too.
76
+ branch: await getBranch(dir),
77
+ });
78
+ }
79
+
80
+ return [...live, ...restorable];
81
+ });
82
+
83
+ app.post("/api/sessions", async (req, reply) => {
84
+ const { workspace, label, worktree: wantWorktree } = req.body ?? {};
85
+
86
+ if (!NAME_RE.test(workspace ?? "") || !NAME_RE.test(label ?? "")) {
87
+ return reply
88
+ .code(400)
89
+ .send({ error: "workspace and label must match [a-z0-9-]+" });
90
+ }
91
+
92
+ const registered = await getWorkspace(workspace);
93
+ if (!registered) {
94
+ return reply.code(404).send({ error: `unknown workspace: ${workspace}` });
95
+ }
96
+
97
+ const dirStat = await stat(registered.dir).catch(() => null);
98
+ if (!dirStat?.isDirectory()) {
99
+ return reply
100
+ .code(400)
101
+ .send({ error: `registered dir no longer exists: ${registered.dir}` });
102
+ }
103
+
104
+ const id = sessionId(workspace, label);
105
+ if (await hasSession(id)) {
106
+ return reply.code(409).send({ error: `session already exists: ${id}` });
107
+ }
108
+
109
+ // D-wt-meta: worktree creation happens before any tmux state exists —
110
+ // createWorktree throws (400, with git's stderr) on a non-git repoDir or
111
+ // a failed `git worktree add`, and nothing has been spawned yet to clean
112
+ // up in that case.
113
+ let worktree = null;
114
+ if (wantWorktree) {
115
+ try {
116
+ worktree = await createWorktree({ repoDir: registered.dir, workspace, label });
117
+ } catch (err) {
118
+ return reply.code(err.statusCode ?? 400).send({ error: err.message });
119
+ }
120
+ }
121
+
122
+ const spawnDir = worktree?.path ?? registered.dir;
123
+ await createSession(id, spawnDir, CLAUDE_CMD);
124
+
125
+ if (worktree) {
126
+ // Written immediately, at spawn — not by the poller. The poller's own
127
+ // upserts (claudeSessionId/workspace/label, once it observes the
128
+ // agent) merge alongside this without clobbering it; see registry.js.
129
+ await upsertSessionMeta(id, {
130
+ workspace,
131
+ label,
132
+ worktree: { ...worktree, repoDir: registered.dir },
133
+ });
134
+ }
135
+
136
+ return reply.code(201).send({ id, workspace, label, dir: spawnDir, worktree });
137
+ });
138
+
139
+ // D-restore-flow: {id} restores one restorable session; {all:true}
140
+ // restores every session whose metadata has no live tmux match. Each
141
+ // target is independent — one failure (stale workspace, missing dir, name
142
+ // collision) never blocks the rest, and the meta record is retained on
143
+ // failure so a later retry can still succeed.
144
+ app.post("/api/sessions/restore", async (req, reply) => {
145
+ const { id, all } = req.body ?? {};
146
+ if (!id && !all) {
147
+ return reply.code(400).send({ error: "must provide id or all:true" });
148
+ }
149
+
150
+ const metas = await listSessionMetas();
151
+ let targets;
152
+ if (all) {
153
+ const liveIds = new Set((await listSessions()).map((s) => s.id));
154
+ targets = metas.filter((m) => !liveIds.has(m.id));
155
+ } else {
156
+ const meta = metas.find((m) => m.id === id);
157
+ if (!meta) {
158
+ return reply.code(404).send({ error: `no resume metadata for: ${id}` });
159
+ }
160
+ targets = [meta];
161
+ }
162
+
163
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
164
+
165
+ // Targets are independent — restore them concurrently so the
166
+ // died-on-resume confirmation waits below don't serialize restore-all.
167
+ const results = await Promise.all(
168
+ targets.map(async (meta) => {
169
+ const registered = await getWorkspace(meta.workspace);
170
+ if (!registered) {
171
+ return { failed: { id: meta.id, reason: "workspace no longer registered" } };
172
+ }
173
+
174
+ // D-wt-meta: a worktree session restores INTO its worktree, not the
175
+ // registered workspace dir. The stat-gate keeps the metadata
176
+ // (neither the session nor the worktree record is removed on
177
+ // failure) so a later retry — or a manual `finish` with the
178
+ // recorded path/branch — can still recover.
179
+ const spawnDir = meta.worktree?.path ?? registered.dir;
180
+ const dirStat = await stat(spawnDir).catch(() => null);
181
+ if (!dirStat?.isDirectory()) {
182
+ const reason = meta.worktree ? "worktree missing" : "registered dir no longer exists";
183
+ return { failed: { id: meta.id, reason } };
184
+ }
185
+
186
+ if (await hasSession(meta.id)) {
187
+ return { failed: { id: meta.id, reason: "session already running" } };
188
+ }
189
+
190
+ await createSession(meta.id, spawnDir, CLAUDE_CMD, [
191
+ "--resume",
192
+ meta.claudeSessionId,
193
+ ]);
194
+
195
+ // `claude --resume` exits (after printing "No conversation found")
196
+ // when the conversation was never persisted — a session with no
197
+ // submitted exchange — killing the new tmux session and leaving a
198
+ // restore loop. The exit isn't instant, so confirm twice before
199
+ // trusting the resume; if it died, fall back to a fresh claude.
200
+ let resumed = true;
201
+ await sleep(1500);
202
+ if (await hasSession(meta.id)) {
203
+ await sleep(2000);
204
+ }
205
+ if (!(await hasSession(meta.id))) {
206
+ resumed = false;
207
+ await createSession(meta.id, spawnDir, CLAUDE_CMD);
208
+ }
209
+
210
+ return {
211
+ restored: {
212
+ id: meta.id,
213
+ workspace: meta.workspace,
214
+ label: meta.label,
215
+ dir: spawnDir,
216
+ worktree: meta.worktree ?? null,
217
+ resumed,
218
+ },
219
+ };
220
+ })
221
+ );
222
+
223
+ const restored = results.filter((r) => r.restored).map((r) => r.restored);
224
+ const failed = results.filter((r) => r.failed).map((r) => r.failed);
225
+
226
+ const status = restored.length > 0 ? (all ? 200 : 201) : 409;
227
+ return reply.code(status).send({ restored, failed });
228
+ });
229
+
230
+ // id contains slashes — capture the whole tail as a wildcard.
231
+ app.delete("/api/sessions/*", async (req, reply) => {
232
+ const id = decodeURIComponent(req.params["*"]);
233
+
234
+ if (!id.startsWith(GARAGE_PREFIX)) {
235
+ return reply
236
+ .code(403)
237
+ .send({ error: "refusing to touch non-garage sessions" });
238
+ }
239
+ if (!(await hasSession(id))) {
240
+ return reply.code(404).send({ error: `no such session: ${id}` });
241
+ }
242
+
243
+ // D-wt-meta: capture the meta BEFORE removing it — the worktree record
244
+ // (if any) rides the response body so the caller can drive
245
+ // POST /api/worktrees/finish afterward, since by then the session (and
246
+ // its metadata) are already gone.
247
+ const meta = await getSessionMeta(id).catch(() => null);
248
+
249
+ await killSession(id);
250
+ // Deliberate kill: the user is done with this conversation, so there's
251
+ // nothing to offer restoring later.
252
+ await removeSessionMeta(id).catch(() => {});
253
+ return reply.code(200).send({ deleted: true, worktree: meta?.worktree ?? null });
254
+ });
255
+ }
@@ -0,0 +1,59 @@
1
+ import { EventEmitter } from "node:events";
2
+
3
+ // D-status: StatusStore. Four states: needs-input | working | done | idle.
4
+ // setStatus is the single write path — API, SSE, and notifications only
5
+ // ever read (getStatus) or subscribe (statusEvents). `done` decays to `idle`
6
+ // after DECAY_MS with no further transition; `needs-input` never
7
+ // auto-decays — it only clears via a new setStatus call (poller 'busy' or a
8
+ // hook Stop/new activity).
9
+ const DECAY_MS = 2 * 60 * 1000;
10
+
11
+ const store = new Map(); // id -> { state, since }
12
+ const decayTimers = new Map(); // id -> Timeout
13
+
14
+ export const statusEvents = new EventEmitter();
15
+ statusEvents.setMaxListeners(0);
16
+
17
+ function clearDecay(id) {
18
+ const timer = decayTimers.get(id);
19
+ if (timer) {
20
+ clearTimeout(timer);
21
+ decayTimers.delete(id);
22
+ }
23
+ }
24
+
25
+ // Unknown/never-signaled sessions read as idle.
26
+ export function getStatus(id) {
27
+ return store.get(id)?.state ?? "idle";
28
+ }
29
+
30
+ export function getAllStatuses() {
31
+ return new Map(store);
32
+ }
33
+
34
+ export function setStatus(id, state) {
35
+ const existing = store.get(id);
36
+ const prev = existing?.state ?? "idle";
37
+ const changed = prev !== state;
38
+ const since = changed ? Date.now() : existing?.since ?? Date.now();
39
+
40
+ clearDecay(id);
41
+ store.set(id, { state, since });
42
+
43
+ if (state === "done") {
44
+ const timer = setTimeout(() => setStatus(id, "idle"), DECAY_MS);
45
+ timer.unref?.();
46
+ decayTimers.set(id, timer);
47
+ }
48
+
49
+ if (changed) {
50
+ statusEvents.emit("transition", { id, from: prev, to: state });
51
+ }
52
+ }
53
+
54
+ // Called when a session disappears from tmux (poller-detected death) so
55
+ // timers don't leak and a future reuse of the same id starts clean.
56
+ export function dropSession(id) {
57
+ clearDecay(id);
58
+ store.delete(id);
59
+ }
@@ -0,0 +1,94 @@
1
+ import { WebSocketServer } from "ws";
2
+ import pty from "node-pty";
3
+ import { GARAGE_PREFIX } from "./tmux.js";
4
+ import { originAllowed } from "./security.js";
5
+
6
+ const SWEEP_MS = 30_000;
7
+
8
+ // ws → pty. The socket owns the pty: socket dies → pty dies (detach).
9
+ // The tmux session itself is never touched from here.
10
+ const bridges = new Map();
11
+
12
+ export function attachTermServer(app) {
13
+ const wss = new WebSocketServer({ noServer: true });
14
+
15
+ app.server.on("upgrade", (req, socket, head) => {
16
+ // WebSockets bypass CORS — block browser pages from foreign origins.
17
+ if (!originAllowed(req.headers.origin)) {
18
+ socket.destroy();
19
+ return;
20
+ }
21
+ const url = new URL(req.url, "http://127.0.0.1");
22
+ if (!url.pathname.startsWith("/term/")) {
23
+ socket.destroy();
24
+ return;
25
+ }
26
+ const id = decodeURIComponent(url.pathname.slice("/term/".length));
27
+ if (!id.startsWith(GARAGE_PREFIX)) {
28
+ socket.destroy();
29
+ return;
30
+ }
31
+ wss.handleUpgrade(req, socket, head, (ws) => bridge(app, ws, id));
32
+ });
33
+
34
+ const sweep = setInterval(() => {
35
+ for (const [ws, term] of bridges) {
36
+ if (ws.readyState === ws.CLOSED || ws.readyState === ws.CLOSING) {
37
+ term.kill();
38
+ bridges.delete(ws);
39
+ }
40
+ }
41
+ }, SWEEP_MS);
42
+ app.addHook("onClose", async () => {
43
+ clearInterval(sweep);
44
+ for (const term of bridges.values()) term.kill();
45
+ bridges.clear();
46
+ });
47
+ }
48
+
49
+ function bridge(app, ws, id) {
50
+ let term;
51
+ try {
52
+ term = pty.spawn("tmux", ["attach", "-t", `=${id}`], {
53
+ name: "xterm-256color",
54
+ cols: 80,
55
+ rows: 24,
56
+ cwd: process.env.HOME,
57
+ env: process.env,
58
+ });
59
+ } catch (err) {
60
+ app.log.error({ err, id }, "pty spawn failed");
61
+ ws.close(1011, "pty spawn failed");
62
+ return;
63
+ }
64
+ bridges.set(ws, term);
65
+ app.log.info({ id, pid: term.pid }, "term attached");
66
+
67
+ term.onData((data) => {
68
+ if (ws.readyState === ws.OPEN) ws.send(Buffer.from(data));
69
+ });
70
+ term.onExit(({ exitCode }) => {
71
+ bridges.delete(ws);
72
+ app.log.info({ id, exitCode }, "term detached");
73
+ if (ws.readyState === ws.OPEN) ws.close(1000, "detached");
74
+ });
75
+
76
+ ws.on("message", (data, isBinary) => {
77
+ if (isBinary) {
78
+ term.write(data.toString("utf8"));
79
+ return;
80
+ }
81
+ try {
82
+ const msg = JSON.parse(data.toString("utf8"));
83
+ if (msg.type === "resize" && msg.cols > 0 && msg.rows > 0) {
84
+ term.resize(msg.cols, msg.rows);
85
+ }
86
+ } catch {
87
+ app.log.warn({ id }, "ignoring malformed control frame");
88
+ }
89
+ });
90
+ ws.on("close", () => {
91
+ bridges.delete(ws);
92
+ term.kill();
93
+ });
94
+ }