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,124 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const run = promisify(execFile);
5
+
6
+ export const GARAGE_PREFIX = "garage/";
7
+ export const NAME_RE = /^[a-z0-9-]+$/;
8
+
9
+ export function sessionId(workspace, label) {
10
+ return `${GARAGE_PREFIX}${workspace}/${label}`;
11
+ }
12
+
13
+ // `=` forces exact-name matching; bare -t does prefix matching.
14
+ const exact = (id) => `=${id}`;
15
+
16
+ export async function listSessions() {
17
+ let stdout;
18
+ try {
19
+ ({ stdout } = await run("tmux", [
20
+ "ls",
21
+ "-F",
22
+ "#{session_name}\t#{session_path}\t#{session_attached}",
23
+ ]));
24
+ } catch {
25
+ // tmux exits non-zero when no server is running — that means "no sessions".
26
+ return [];
27
+ }
28
+ return stdout
29
+ .split("\n")
30
+ .filter((line) => line.startsWith(GARAGE_PREFIX))
31
+ .map((line) => {
32
+ const [name, dir, attached] = line.split("\t");
33
+ const [, workspace, label] = name.split("/");
34
+ return { id: name, workspace, label, dir, attached: attached !== "0" };
35
+ });
36
+ }
37
+
38
+ // D-branch: live pane cwd per garage session, keyed by session name — NOT
39
+ // `session_path` (that's the session's *starting* dir, frozen at creation;
40
+ // a user who `cd`s into a worktree inside the pane changes pane_current_path
41
+ // but not session_path). `-a` lists every pane on the server across every
42
+ // session, so results are filtered to the garage prefix same as
43
+ // listSessions(). A session can have multiple panes/windows if the user
44
+ // split it manually — only the first pane encountered per session is kept,
45
+ // there's no principled way to pick "the" cwd of a multi-pane session.
46
+ export async function listPanePaths() {
47
+ let stdout;
48
+ try {
49
+ ({ stdout } = await run("tmux", [
50
+ "list-panes",
51
+ "-a",
52
+ "-F",
53
+ "#{session_name}\t#{pane_current_path}",
54
+ ]));
55
+ } catch {
56
+ // No tmux server running — no panes.
57
+ return new Map();
58
+ }
59
+ const paths = new Map();
60
+ for (const line of stdout.split("\n")) {
61
+ if (!line.startsWith(GARAGE_PREFIX)) continue;
62
+ const [name, panePath] = line.split("\t");
63
+ if (!paths.has(name)) paths.set(name, panePath);
64
+ }
65
+ return paths;
66
+ }
67
+
68
+ // D-branch: shared by sessions.js (per-session, keyed off live pane cwd)
69
+ // and workspaces.js (per-workspace, keyed off the registered dir) — both
70
+ // just need "what branch is HEAD at in this directory", so the git call
71
+ // lives here once. `null` for anything that isn't a usable git working
72
+ // dir (no dir, not a repo, dir vanished, etc.) rather than throwing —
73
+ // callers render it as "no chip" and must never let a git failure break
74
+ // the sessions/workspaces list response.
75
+ export async function resolveBranch(dir) {
76
+ if (!dir) return null;
77
+ try {
78
+ const { stdout } = await run("git", ["-C", dir, "rev-parse", "--abbrev-ref", "HEAD"]);
79
+ const branch = stdout.trim();
80
+ if (branch && branch !== "HEAD") return branch;
81
+ // Detached HEAD — abbrev-ref returns the literal string "HEAD" — fall
82
+ // back to a short sha, prefixed so it's visually distinct from a real
83
+ // branch name in the UI chip.
84
+ const { stdout: sha } = await run("git", ["-C", dir, "rev-parse", "--short", "HEAD"]);
85
+ const short = sha.trim();
86
+ return short ? `@${short}` : null;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ export async function hasSession(id) {
93
+ try {
94
+ await run("tmux", ["has-session", "-t", exact(id)]);
95
+ return true;
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ // extraArgs are appended as separate argv entries after `command` — tmux
102
+ // joins the trailing shell-command words itself (e.g. `command, "--resume",
103
+ // sessionId` becomes the pane's `claude --resume <sessionId>`), so callers
104
+ // must not pre-join them into a single string.
105
+ export async function createSession(id, dir, command, extraArgs = []) {
106
+ await run("tmux", ["new-session", "-d", "-s", id, "-c", dir, command, ...extraArgs]);
107
+ // The pit wall renders its own session title bars, so tmux's status line
108
+ // is visual noise in every grid cell. Per-session option — a plain
109
+ // `tmux attach` escape-hatch user can restore it with `set status on`.
110
+ // set-option does not accept the `=` exact-match target prefix; the full
111
+ // freshly-created name is unambiguous here.
112
+ await run("tmux", ["set-option", "-t", id, "status", "off"]).catch(() => {});
113
+ }
114
+
115
+ export async function killSession(id) {
116
+ await run("tmux", ["kill-session", "-t", exact(id)]);
117
+ }
118
+
119
+ // D-rename: renames the tmux session entity itself. Attached clients (grid
120
+ // ptys, iTerm) stay attached across a rename — they're bound to the session
121
+ // entity, not its name — so this is safe to call on a live, attached session.
122
+ export async function renameSession(oldId, newId) {
123
+ await run("tmux", ["rename-session", "-t", exact(oldId), newId]);
124
+ }
@@ -0,0 +1,139 @@
1
+ import { stat } from "node:fs/promises";
2
+ import {
3
+ GARAGE_PREFIX,
4
+ NAME_RE,
5
+ listSessions,
6
+ renameSession,
7
+ resolveBranch,
8
+ killSession,
9
+ } from "./tmux.js";
10
+ import {
11
+ listWorkspaces,
12
+ upsertWorkspace,
13
+ getWorkspace,
14
+ renameWorkspace,
15
+ removeWorkspace,
16
+ } from "./registry.js";
17
+
18
+ export default async function workspaceRoutes(app) {
19
+ // D-branch: same helper as sessions.js's per-session branch (tmux.js's
20
+ // resolveBranch), keyed off the registered dir instead of a live pane
21
+ // cwd — a registered workspace has no pane of its own. This route isn't
22
+ // hit on every SSE tick the way GET /api/sessions is, so no per-request
23
+ // dedup here; a plain per-workspace resolve is cheap enough at this
24
+ // scale (one execFile pair per registered workspace).
25
+ app.get("/api/workspaces", async () => {
26
+ const workspaces = await listWorkspaces();
27
+ return Promise.all(
28
+ workspaces.map(async (w) => ({ ...w, branch: await resolveBranch(w.dir) }))
29
+ );
30
+ });
31
+
32
+ app.put("/api/workspaces", async (req, reply) => {
33
+ const { name, dir } = req.body ?? {};
34
+
35
+ if (!NAME_RE.test(name ?? "")) {
36
+ return reply.code(400).send({ error: "name must match [a-z0-9-]+" });
37
+ }
38
+ const dirStat = await stat(dir ?? "").catch(() => null);
39
+ if (!dirStat?.isDirectory()) {
40
+ return reply.code(400).send({ error: `dir is not a directory: ${dir}` });
41
+ }
42
+
43
+ const workspace = await upsertWorkspace(name, dir);
44
+ return reply.code(200).send(workspace);
45
+ });
46
+
47
+ // D-rename: renames everything that embeds the workspace name — the
48
+ // registry key, every live tmux session under it, and their
49
+ // resume-metadata keys. Live-session renames happen first (best-effort,
50
+ // no rollback on partial failure per design) so a mid-flight failure
51
+ // still leaves the registry consistent with whichever sessions actually
52
+ // got renamed.
53
+ app.patch("/api/workspaces/:name", async (req, reply) => {
54
+ const oldName = req.params.name;
55
+ const { name: newName } = req.body ?? {};
56
+
57
+ const existing = await getWorkspace(oldName);
58
+ if (!existing) {
59
+ return reply.code(404).send({ error: `unknown workspace: ${oldName}` });
60
+ }
61
+ if (!NAME_RE.test(newName ?? "")) {
62
+ return reply.code(400).send({ error: "name must match [a-z0-9-]+" });
63
+ }
64
+ if (newName !== oldName && (await getWorkspace(newName))) {
65
+ return reply
66
+ .code(409)
67
+ .send({ error: `workspace already registered: ${newName}` });
68
+ }
69
+
70
+ const oldPrefix = `${GARAGE_PREFIX}${oldName}/`;
71
+ const newPrefix = `${GARAGE_PREFIX}${newName}/`;
72
+ const live = (await listSessions()).filter((s) => s.id.startsWith(oldPrefix));
73
+
74
+ const renamedSessions = [];
75
+ const failedSessions = [];
76
+ for (const s of live) {
77
+ const newId = newPrefix + s.id.slice(oldPrefix.length);
78
+ try {
79
+ await renameSession(s.id, newId);
80
+ renamedSessions.push({ from: s.id, to: newId });
81
+ } catch (err) {
82
+ failedSessions.push({ id: s.id, error: err.message ?? String(err) });
83
+ }
84
+ }
85
+
86
+ const workspace = await renameWorkspace(oldName, newName);
87
+
88
+ const response = { ...workspace, renamedSessions };
89
+ if (failedSessions.length > 0) {
90
+ response.failedSessions = failedSessions;
91
+ }
92
+ return reply.code(200).send(response);
93
+ });
94
+
95
+ // Removing a workspace by default NEVER touches tmux: the registry is a
96
+ // directory mapping, not a session store. Live sessions keep running and
97
+ // reappear in the rail as an unregistered group; the workspace's resume
98
+ // metadata is dropped (removeWorkspace handles that) so no
99
+ // permanently-unrestorable ghosts linger.
100
+ //
101
+ // p7 follow-up: `?sessions=kill` opts into shutting the workspace down
102
+ // for real — every live garage/<name>/* tmux session is killed first,
103
+ // then the registry entry (and, via removeWorkspace, the resume
104
+ // metadata) is removed. Per-session kill failures are reported without
105
+ // aborting the rest. Worktree directories/branches are deliberately left
106
+ // untouched — bulk removal must never silently discard branch work; they
107
+ // remain recoverable via plain git.
108
+ app.delete("/api/workspaces/:name", async (req, reply) => {
109
+ const name = req.params.name;
110
+ if (!(await getWorkspace(name))) {
111
+ return reply.code(404).send({ error: `unknown workspace: ${name}` });
112
+ }
113
+
114
+ const killMode = req.query?.sessions === "kill";
115
+ if (!killMode) {
116
+ await removeWorkspace(name);
117
+ return reply.code(204).send();
118
+ }
119
+
120
+ const prefix = `${GARAGE_PREFIX}${name}/`;
121
+ const live = (await listSessions()).filter((s) => s.id.startsWith(prefix));
122
+ const killedSessions = [];
123
+ const failedSessions = [];
124
+ for (const s of live) {
125
+ try {
126
+ await killSession(s.id);
127
+ killedSessions.push(s.id);
128
+ } catch (err) {
129
+ failedSessions.push({ id: s.id, error: err.message ?? String(err) });
130
+ }
131
+ }
132
+
133
+ await removeWorkspace(name);
134
+
135
+ const response = { removed: name, killedSessions };
136
+ if (failedSessions.length > 0) response.failedSessions = failedSessions;
137
+ return reply.code(200).send(response);
138
+ });
139
+ }
@@ -0,0 +1,180 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import { existsSync } from "node:fs";
4
+ import { mkdir } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import path from "node:path";
7
+
8
+ const run = promisify(execFile);
9
+
10
+ // D-wt-location: outside any repo, so a worktree never pollutes `git status`
11
+ // for the repo itself. Keyed by workspace so `~/.garage/worktrees/<ws>/` is
12
+ // one place to `ls` per workspace.
13
+ const WORKTREES_ROOT = path.join(homedir(), ".garage", "worktrees");
14
+
15
+ // D-wt-branch: namespaced so a repo's real branches never collide with ours,
16
+ // and so the discard/merge cleanup guard below can refuse to touch anything
17
+ // that isn't ours.
18
+ const GARAGE_BRANCH_PREFIX = "garage/";
19
+
20
+ async function git(dir, args) {
21
+ try {
22
+ return await run("git", ["-C", dir, ...args]);
23
+ } catch (err) {
24
+ // execFile's rejection already carries .stderr/.stdout when the process
25
+ // ran and exited non-zero; wrap so callers get a clean .message (git's
26
+ // stderr, trimmed) while still exposing the raw stderr for surfacing to
27
+ // the API caller (D-wt-finish: merge conflicts return git's stderr).
28
+ const wrapped = new Error(err.stderr?.trim() || err.message);
29
+ wrapped.stderr = err.stderr;
30
+ wrapped.cause = err;
31
+ throw wrapped;
32
+ }
33
+ }
34
+
35
+ function assertGarageBranch(branch) {
36
+ if (typeof branch !== "string" || !branch.startsWith(GARAGE_BRANCH_PREFIX)) {
37
+ const err = new Error(`refusing to touch non-garage branch: ${branch}`);
38
+ err.statusCode = 400;
39
+ throw err;
40
+ }
41
+ }
42
+
43
+ async function branchExists(repoDir, branch) {
44
+ try {
45
+ await run("git", ["-C", repoDir, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ // D-wt-location / D-wt-branch: both the directory and the branch name are
53
+ // derived from the same candidate label, suffixed together (-2, -3, ...)
54
+ // until a candidate is free on both axes — keeps `garage/<label>` and its
55
+ // worktree dir visually paired even after a collision.
56
+ async function pickCandidate(repoDir, workspace, label) {
57
+ const baseDir = path.join(WORKTREES_ROOT, workspace);
58
+ let suffix = 1;
59
+ let candidate = label;
60
+ for (;;) {
61
+ const dirPath = path.join(baseDir, candidate);
62
+ const branch = `${GARAGE_BRANCH_PREFIX}${candidate}`;
63
+ const dirTaken = existsSync(dirPath);
64
+ const branchTaken = dirTaken || (await branchExists(repoDir, branch));
65
+ if (!dirTaken && !branchTaken) return { dirPath, branch };
66
+ suffix += 1;
67
+ candidate = `${label}-${suffix}`;
68
+ }
69
+ }
70
+
71
+ // Creates `~/.garage/worktrees/<workspace>/<label>` (suffixed on collision)
72
+ // as a new git worktree on a fresh `garage/<label>` branch cut from repoDir's
73
+ // current HEAD. Throws a 400 error (with git's stderr as .message) if repoDir
74
+ // isn't a git repo or `git worktree add` fails for any reason (dirty/locked
75
+ // repo state, etc.) — callers should surface err.statusCode / err.message
76
+ // directly, no session or tmux state is created before this succeeds.
77
+ export async function createWorktree({ repoDir, workspace, label }) {
78
+ try {
79
+ await run("git", ["-C", repoDir, "rev-parse", "--is-inside-work-tree"]);
80
+ } catch {
81
+ const err = new Error(`not a git repository: ${repoDir}`);
82
+ err.statusCode = 400;
83
+ throw err;
84
+ }
85
+
86
+ const { dirPath, branch } = await pickCandidate(repoDir, workspace, label);
87
+ await mkdir(path.dirname(dirPath), { recursive: true });
88
+
89
+ try {
90
+ await run("git", ["-C", repoDir, "worktree", "add", "-b", branch, dirPath]);
91
+ } catch (err) {
92
+ const wrapped = new Error(err.stderr?.trim() || err.message);
93
+ wrapped.statusCode = 400;
94
+ throw wrapped;
95
+ }
96
+
97
+ return { path: dirPath, branch };
98
+ }
99
+
100
+ // D-wt-finish: runs after the session is dead — repoDir/path/branch come
101
+ // from the worktree record the caller already holds (DELETE's response
102
+ // body), never re-derived. `merge` and `discard` are guarded to garage/*
103
+ // branches so this can never touch a repo's real branches even if a caller
104
+ // passes a bogus record. `keep` is a deliberate no-op so the UI's three-way
105
+ // finish prompt is uniform (dismissing it is equivalent).
106
+ export async function finishWorktree({ repoDir, path: wtPath, branch, action }) {
107
+ if (action === "keep") {
108
+ return { action: "keep", path: wtPath, branch };
109
+ }
110
+
111
+ if (action !== "merge" && action !== "discard") {
112
+ const err = new Error(`unknown action: ${action}`);
113
+ err.statusCode = 400;
114
+ throw err;
115
+ }
116
+
117
+ assertGarageBranch(branch);
118
+
119
+ if (action === "merge") {
120
+ // The most common failure isn't a conflict — it's an agent that edited
121
+ // but never committed: the branch has nothing to merge and `worktree
122
+ // remove` would refuse anyway. Catch it up front with a human message.
123
+ const dirty = await git(wtPath, ["status", "--porcelain"]).catch(() => null);
124
+ if (dirty?.stdout && dirty.stdout.trim() !== "") {
125
+ const err = new Error(
126
+ "worktree has uncommitted changes — commit them in the session first, or discard"
127
+ );
128
+ err.statusCode = 409;
129
+ throw err;
130
+ }
131
+ try {
132
+ await git(repoDir, ["merge", "--no-ff", branch]);
133
+ } catch (err) {
134
+ // D-wt-finish: non-zero exit -> 409, worktree and branch kept, the
135
+ // user resolves manually (no conflict UI in v1).
136
+ const conflictErr = new Error(err.message);
137
+ conflictErr.statusCode = 409;
138
+ conflictErr.stderr = err.stderr;
139
+ throw conflictErr;
140
+ }
141
+ await git(repoDir, ["worktree", "remove", wtPath]);
142
+ await git(repoDir, ["branch", "-d", branch]);
143
+ return { action: "merge", path: wtPath, branch, merged: true };
144
+ }
145
+
146
+ // discard
147
+ await git(repoDir, ["worktree", "remove", "--force", wtPath]);
148
+ await git(repoDir, ["branch", "-D", branch]);
149
+ return { action: "discard", path: wtPath, branch, discarded: true };
150
+ }
151
+
152
+ export default async function worktreeRoutes(app) {
153
+ app.post("/api/worktrees/finish", async (req, reply) => {
154
+ const { worktree, action } = req.body ?? {};
155
+
156
+ if (!worktree || typeof worktree !== "object") {
157
+ return reply.code(400).send({ error: "worktree record required" });
158
+ }
159
+ const { path: wtPath, branch, repoDir } = worktree;
160
+ if (!wtPath || !branch || !repoDir) {
161
+ return reply
162
+ .code(400)
163
+ .send({ error: "worktree record requires path, branch, and repoDir" });
164
+ }
165
+ if (!["merge", "discard", "keep"].includes(action)) {
166
+ return reply
167
+ .code(400)
168
+ .send({ error: "action must be one of merge, discard, keep" });
169
+ }
170
+
171
+ try {
172
+ const result = await finishWorktree({ repoDir, path: wtPath, branch, action });
173
+ return reply.code(200).send(result);
174
+ } catch (err) {
175
+ const body = { error: err.message };
176
+ if (err.stderr) body.stderr = err.stderr;
177
+ return reply.code(err.statusCode ?? 500).send(body);
178
+ }
179
+ });
180
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "claude-garage",
3
+ "version": "0.1.0",
4
+ "description": "Multiple Claude agents driving you crazy? Park them all in one garage: every session on one live terminal wall, no window juggling. Needs-input triage, worktree isolation, diff review. tmux owns the sessions; close the app and nothing dies.",
5
+ "license": "MIT",
6
+ "author": "Saiful Islam <saifulislamadar@gmail.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/adar2378/claude-garage.git"
10
+ },
11
+ "homepage": "https://github.com/adar2378/claude-garage#readme",
12
+ "bugs": "https://github.com/adar2378/claude-garage/issues",
13
+ "keywords": [
14
+ "claude",
15
+ "claude-code",
16
+ "tmux",
17
+ "terminal",
18
+ "agents",
19
+ "session-manager",
20
+ "dashboard",
21
+ "xterm",
22
+ "git-worktree",
23
+ "developer-tools"
24
+ ],
25
+ "type": "module",
26
+ "engines": {
27
+ "node": ">=20"
28
+ },
29
+ "bin": {
30
+ "claude-garage": "bin/garage.js"
31
+ },
32
+ "files": [
33
+ "bin",
34
+ "daemon/src",
35
+ "daemon/package.json",
36
+ "ui/dist"
37
+ ],
38
+ "workspaces": [
39
+ "daemon",
40
+ "ui"
41
+ ],
42
+ "scripts": {
43
+ "dev": "concurrently -n daemon,ui -c blue,magenta \"npm:dev --workspace daemon\" \"npm:dev --workspace ui\"",
44
+ "test": "node --test \"daemon/test/*.test.js\" \"ui/test/*.test.js\"",
45
+ "postinstall": "chmod +x node_modules/node-pty/prebuilds/darwin-*/spawn-helper 2>/dev/null || true",
46
+ "prepack": "npm run build --workspace ui"
47
+ },
48
+ "dependencies": {
49
+ "@fastify/static": "^10.1.0",
50
+ "fastify": "^5.3.0",
51
+ "node-pty": "^1.1.0",
52
+ "ws": "^8.18.0"
53
+ },
54
+ "devDependencies": {
55
+ "concurrently": "^10.0.3"
56
+ }
57
+ }
Binary file