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.
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/bin/garage.js +143 -0
- package/daemon/package.json +13 -0
- package/daemon/src/diff.js +465 -0
- package/daemon/src/editor.js +63 -0
- package/daemon/src/events.js +54 -0
- package/daemon/src/hooks.js +241 -0
- package/daemon/src/index.js +76 -0
- package/daemon/src/notify.js +80 -0
- package/daemon/src/picker.js +48 -0
- package/daemon/src/poller.js +134 -0
- package/daemon/src/registry.js +189 -0
- package/daemon/src/security.js +39 -0
- package/daemon/src/sessions.js +255 -0
- package/daemon/src/status.js +59 -0
- package/daemon/src/term.js +94 -0
- package/daemon/src/tmux.js +124 -0
- package/daemon/src/workspaces.js +139 -0
- package/daemon/src/worktrees.js +180 -0
- package/package.json +57 -0
- package/ui/dist/apple-touch-icon.png +0 -0
- package/ui/dist/assets/google-sans-code-latin-400-normal-C-TpydLV.woff +0 -0
- package/ui/dist/assets/google-sans-code-latin-400-normal-CqaqKeEp.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-latin-600-normal-BUCrGHE5.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-latin-600-normal-CKee-OZj.woff +0 -0
- package/ui/dist/assets/google-sans-code-latin-700-normal-0vT4k3S3.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-latin-700-normal-BvnkA064.woff +0 -0
- package/ui/dist/assets/google-sans-code-latin-ext-400-normal-CALCk2lB.woff +0 -0
- package/ui/dist/assets/google-sans-code-latin-ext-400-normal-xZllot2U.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-latin-ext-600-normal-CNNK3O9-.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-latin-ext-600-normal-DDkmmTp6.woff +0 -0
- package/ui/dist/assets/google-sans-code-latin-ext-700-normal-CxgnixdG.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-latin-ext-700-normal-bxgt_uDG.woff +0 -0
- package/ui/dist/assets/google-sans-code-math-400-normal-C2EgcUvD.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-math-400-normal-CgFzjhah.woff +0 -0
- package/ui/dist/assets/google-sans-code-math-600-normal-B5PK-qTM.woff +0 -0
- package/ui/dist/assets/google-sans-code-math-600-normal-DT_RT86i.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-math-700-normal-C65M3EbO.woff +0 -0
- package/ui/dist/assets/google-sans-code-math-700-normal-CgTZgjvl.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-symbols-400-normal-BtBR4e7S.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-symbols-400-normal-NSSmFXdP.woff +0 -0
- package/ui/dist/assets/google-sans-code-symbols-600-normal-DdWLfSlP.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-symbols-600-normal-QtnQvwms.woff +0 -0
- package/ui/dist/assets/google-sans-code-symbols-700-normal-Ceb9rWtn.woff +0 -0
- package/ui/dist/assets/google-sans-code-symbols-700-normal-DDa4ndCu.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-symbols2-400-normal-CicwJrzf.woff +0 -0
- package/ui/dist/assets/google-sans-code-symbols2-600-normal-BtzXZASM.woff +0 -0
- package/ui/dist/assets/google-sans-code-symbols2-700-normal-Cci1DrKv.woff +0 -0
- package/ui/dist/assets/google-sans-code-vietnamese-400-normal-CWRUr5CO.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-vietnamese-400-normal-DhSdRbI2.woff +0 -0
- package/ui/dist/assets/google-sans-code-vietnamese-600-normal-DBBK0KXU.woff +0 -0
- package/ui/dist/assets/google-sans-code-vietnamese-600-normal-zsb-D4cZ.woff2 +0 -0
- package/ui/dist/assets/google-sans-code-vietnamese-700-normal-DDJUOtHZ.woff +0 -0
- package/ui/dist/assets/google-sans-code-vietnamese-700-normal-jZ3jhCNl.woff2 +0 -0
- package/ui/dist/assets/index-B9ScE6Rx.css +32 -0
- package/ui/dist/assets/index-BjoMvOWt.js +70 -0
- package/ui/dist/favicon-32.png +0 -0
- 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
|
+
}
|