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,241 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { timingSafeEqual } from "node:crypto";
|
|
4
|
+
import { copyFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { listSessions } from "./tmux.js";
|
|
8
|
+
import { setStatus } from "./status.js";
|
|
9
|
+
import { getHookToken } from "./registry.js";
|
|
10
|
+
|
|
11
|
+
const run = promisify(execFile);
|
|
12
|
+
const PORT = Number(process.env.GARAGE_PORT ?? 4747);
|
|
13
|
+
const HOOK_URL = `http://127.0.0.1:${PORT}/api/hooks/claude`;
|
|
14
|
+
|
|
15
|
+
function tokenMatches(given, expected) {
|
|
16
|
+
if (typeof given !== "string") return false;
|
|
17
|
+
const a = Buffer.from(given);
|
|
18
|
+
const b = Buffer.from(expected);
|
|
19
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const EVENT_TO_STATE = {
|
|
23
|
+
Notification: "needs-input",
|
|
24
|
+
Stop: "done",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Claude Code's Notification hook fires for real blockers (permission
|
|
28
|
+
// prompts, questions, plan approvals) AND for a ~60s idle reminder
|
|
29
|
+
// ("Claude is waiting for your input") after a turn ends. The reminder is
|
|
30
|
+
// idle semantics — waiting for you to ASK, not to ANSWER — and mapping it
|
|
31
|
+
// to needs-input raises a sticky false alarm (needs-input never decays by
|
|
32
|
+
// design). Filter it by message; unknown messages stay needs-input
|
|
33
|
+
// (fail toward attention, never away from it).
|
|
34
|
+
const IDLE_REMINDER_RE = /waiting for your input/i;
|
|
35
|
+
|
|
36
|
+
// Exported for the test suite.
|
|
37
|
+
export function isIdleReminder(message) {
|
|
38
|
+
return IDLE_REMINDER_RE.test(message ?? "");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function getAgents() {
|
|
42
|
+
try {
|
|
43
|
+
const { stdout } = await run("claude", ["agents", "--json"]);
|
|
44
|
+
return JSON.parse(stdout);
|
|
45
|
+
} catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function getPanePids() {
|
|
51
|
+
try {
|
|
52
|
+
const { stdout } = await run("tmux", [
|
|
53
|
+
"list-panes",
|
|
54
|
+
"-a",
|
|
55
|
+
"-F",
|
|
56
|
+
"#{session_name}\t#{pane_pid}",
|
|
57
|
+
]);
|
|
58
|
+
const map = new Map(); // pid -> session name
|
|
59
|
+
for (const line of stdout.split("\n")) {
|
|
60
|
+
if (!line.trim()) continue;
|
|
61
|
+
const [name, pid] = line.split("\t");
|
|
62
|
+
map.set(Number(pid), name);
|
|
63
|
+
}
|
|
64
|
+
return map;
|
|
65
|
+
} catch {
|
|
66
|
+
return new Map();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Resolve a hook payload to the garage session id(s) it applies to.
|
|
71
|
+
// Precise path: payload session_id -> agents-json sessionId -> its pid ->
|
|
72
|
+
// pane_pid join. Fallback: cwd fail-open, applied to every garage session
|
|
73
|
+
// sharing that dir (over-notify rather than silently drop).
|
|
74
|
+
async function resolveSessionIds({ session_id, cwd }) {
|
|
75
|
+
const [agents, pidToSession] = await Promise.all([getAgents(), getPanePids()]);
|
|
76
|
+
|
|
77
|
+
if (session_id) {
|
|
78
|
+
const agent = agents.find((a) => a.sessionId === session_id);
|
|
79
|
+
const sessionName = agent && pidToSession.get(agent.pid);
|
|
80
|
+
if (sessionName) return [sessionName];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (cwd) {
|
|
84
|
+
const sessions = await listSessions();
|
|
85
|
+
const matches = sessions.filter((s) => s.dir === cwd).map((s) => s.id);
|
|
86
|
+
if (matches.length > 0) return matches;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function hookSnippet() {
|
|
93
|
+
const token = await getHookToken();
|
|
94
|
+
const url = `${HOOK_URL}?token=${token}`;
|
|
95
|
+
return {
|
|
96
|
+
hooks: {
|
|
97
|
+
Notification: [{ hooks: [{ type: "http", url }] }],
|
|
98
|
+
Stop: [{ hooks: [{ type: "http", url }] }],
|
|
99
|
+
},
|
|
100
|
+
allowedHttpHookUrls: [`http://127.0.0.1:${PORT}/*`],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// p7 hooks-install (design D-hooks-install): merge the snippet into
|
|
105
|
+
// ~/.claude/settings.json server-side so the UI can offer one-click
|
|
106
|
+
// installation instead of asking users to hand-merge JSON.
|
|
107
|
+
//
|
|
108
|
+
// Safety order: parse-or-refuse (a corrupt file is returned as an error,
|
|
109
|
+
// byte-for-byte untouched) -> timestamped backup of the pre-install file
|
|
110
|
+
// -> atomic write (tmp + rename, same directory, so a crash mid-write can
|
|
111
|
+
// never leave a half-written settings.json). Idempotency comes from
|
|
112
|
+
// entry-level dedupe: a hook group is only appended when no existing
|
|
113
|
+
// group for that event already carries one of its hook URLs (the URL
|
|
114
|
+
// embeds the per-install token, which is stable — see getHookToken).
|
|
115
|
+
const SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
|
|
116
|
+
|
|
117
|
+
function groupHasAnyUrl(group, urls) {
|
|
118
|
+
return (group?.hooks ?? []).some((h) => urls.has(h.url));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function mergeHookSnippet(settings, snippet) {
|
|
122
|
+
const merged = { ...settings };
|
|
123
|
+
let changed = false;
|
|
124
|
+
|
|
125
|
+
merged.hooks = { ...(merged.hooks ?? {}) };
|
|
126
|
+
for (const [event, snippetGroups] of Object.entries(snippet.hooks)) {
|
|
127
|
+
const existing = [...(merged.hooks[event] ?? [])];
|
|
128
|
+
for (const group of snippetGroups) {
|
|
129
|
+
const urls = new Set((group.hooks ?? []).map((h) => h.url));
|
|
130
|
+
if (!existing.some((g) => groupHasAnyUrl(g, urls))) {
|
|
131
|
+
existing.push(group);
|
|
132
|
+
changed = true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
merged.hooks[event] = existing;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const allowed = new Set(merged.allowedHttpHookUrls ?? []);
|
|
139
|
+
for (const url of snippet.allowedHttpHookUrls ?? []) {
|
|
140
|
+
if (!allowed.has(url)) {
|
|
141
|
+
allowed.add(url);
|
|
142
|
+
changed = true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
merged.allowedHttpHookUrls = [...allowed];
|
|
146
|
+
|
|
147
|
+
return { merged, changed };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function installHooks() {
|
|
151
|
+
let raw = null;
|
|
152
|
+
try {
|
|
153
|
+
raw = await readFile(SETTINGS_PATH, "utf8");
|
|
154
|
+
} catch (err) {
|
|
155
|
+
if (err.code !== "ENOENT") throw err;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let settings = {};
|
|
159
|
+
if (raw !== null && raw.trim() !== "") {
|
|
160
|
+
try {
|
|
161
|
+
settings = JSON.parse(raw);
|
|
162
|
+
} catch {
|
|
163
|
+
const error = new Error(
|
|
164
|
+
`~/.claude/settings.json is not valid JSON — fix it (or remove it) and retry; the file was left untouched`
|
|
165
|
+
);
|
|
166
|
+
error.statusCode = 422;
|
|
167
|
+
throw error;
|
|
168
|
+
}
|
|
169
|
+
if (typeof settings !== "object" || settings === null || Array.isArray(settings)) {
|
|
170
|
+
const error = new Error(
|
|
171
|
+
`~/.claude/settings.json is not a JSON object — the file was left untouched`
|
|
172
|
+
);
|
|
173
|
+
error.statusCode = 422;
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const snippet = await hookSnippet();
|
|
179
|
+
const { merged, changed } = mergeHookSnippet(settings, snippet);
|
|
180
|
+
if (!changed) {
|
|
181
|
+
return { ok: true, installed: true, alreadyInstalled: true, backup: null };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
await mkdir(dirname(SETTINGS_PATH), { recursive: true });
|
|
185
|
+
|
|
186
|
+
let backup = null;
|
|
187
|
+
if (raw !== null) {
|
|
188
|
+
backup = `${SETTINGS_PATH}.garage-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
189
|
+
await copyFile(SETTINGS_PATH, backup);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const tmp = `${SETTINGS_PATH}.garage-tmp-${process.pid}`;
|
|
193
|
+
await writeFile(tmp, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
|
|
194
|
+
await rename(tmp, SETTINGS_PATH);
|
|
195
|
+
|
|
196
|
+
return { ok: true, installed: true, alreadyInstalled: false, backup };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export default async function hookRoutes(app) {
|
|
200
|
+
// Auth: per-install token in the URL (claude's HTTP hooks are URL-only
|
|
201
|
+
// config, so the token rides as a query param). Combined with the normal
|
|
202
|
+
// Origin allowlist — no exemption — a foreign browser page is stopped by
|
|
203
|
+
// Origin, and anything else without the token is stopped here.
|
|
204
|
+
app.post("/api/hooks/claude", async (req, reply) => {
|
|
205
|
+
const expected = await getHookToken();
|
|
206
|
+
if (!tokenMatches(req.query?.token, expected)) {
|
|
207
|
+
return reply.code(401).send({ error: "missing or invalid hook token" });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const payload = req.body ?? {};
|
|
211
|
+
const state = EVENT_TO_STATE[payload.hook_event_name];
|
|
212
|
+
if (!state) {
|
|
213
|
+
return reply.code(200).send({ ok: true, ignored: true });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (payload.hook_event_name === "Notification" && isIdleReminder(payload.message)) {
|
|
217
|
+
// Idle reminder — leave the state alone (Stop already set done,
|
|
218
|
+
// which decays to idle on its own).
|
|
219
|
+
return reply.code(200).send({ ok: true, ignored: "idle-reminder" });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const ids = await resolveSessionIds(payload);
|
|
223
|
+
for (const id of ids) setStatus(id, state);
|
|
224
|
+
|
|
225
|
+
return reply.code(200).send({ ok: true, applied: ids });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
app.get("/api/hooks/snippet", async () => hookSnippet());
|
|
229
|
+
|
|
230
|
+
// p7 hooks-install: browser-called (rides the normal Origin allowlist,
|
|
231
|
+
// like every /api route except /api/hooks/claude's token scheme).
|
|
232
|
+
app.post("/api/hooks/install", async (req, reply) => {
|
|
233
|
+
try {
|
|
234
|
+
return await installHooks();
|
|
235
|
+
} catch (err) {
|
|
236
|
+
return reply
|
|
237
|
+
.code(err.statusCode ?? 500)
|
|
238
|
+
.send({ error: err.message ?? "hook install failed" });
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import Fastify from "fastify";
|
|
5
|
+
import fastifyStatic from "@fastify/static";
|
|
6
|
+
import sessionRoutes from "./sessions.js";
|
|
7
|
+
import workspaceRoutes from "./workspaces.js";
|
|
8
|
+
import pickerRoutes from "./picker.js";
|
|
9
|
+
import hookRoutes from "./hooks.js";
|
|
10
|
+
import eventRoutes from "./events.js";
|
|
11
|
+
import notifyRoutes from "./notify.js";
|
|
12
|
+
import diffRoutes from "./diff.js";
|
|
13
|
+
import editorRoutes from "./editor.js";
|
|
14
|
+
import worktreeRoutes from "./worktrees.js";
|
|
15
|
+
import { attachTermServer } from "./term.js";
|
|
16
|
+
import { rejectForeignOrigins } from "./security.js";
|
|
17
|
+
import { startPoller } from "./poller.js";
|
|
18
|
+
|
|
19
|
+
const HOST = "127.0.0.1";
|
|
20
|
+
const PORT = Number(process.env.GARAGE_PORT ?? 4747);
|
|
21
|
+
|
|
22
|
+
// daemon/src/index.js -> repo root is two levels up.
|
|
23
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const REPO_ROOT = path.resolve(__dirname, "..", "..");
|
|
25
|
+
|
|
26
|
+
// forceCloseConnections: close() actively terminates keep-alive and
|
|
27
|
+
// hijacked connections (SSE, upgraded sockets) instead of waiting for
|
|
28
|
+
// them to drain — they never would; see bin/garage.js shutdown.
|
|
29
|
+
const app = Fastify({ logger: { level: "info" }, forceCloseConnections: true });
|
|
30
|
+
|
|
31
|
+
app.addHook("onRequest", rejectForeignOrigins);
|
|
32
|
+
app.get("/api/health", async () => ({ status: "ok" }));
|
|
33
|
+
app.register(sessionRoutes);
|
|
34
|
+
app.register(workspaceRoutes);
|
|
35
|
+
app.register(pickerRoutes);
|
|
36
|
+
app.register(hookRoutes);
|
|
37
|
+
app.register(eventRoutes);
|
|
38
|
+
app.register(notifyRoutes);
|
|
39
|
+
app.register(diffRoutes);
|
|
40
|
+
app.register(editorRoutes);
|
|
41
|
+
app.register(worktreeRoutes);
|
|
42
|
+
attachTermServer(app);
|
|
43
|
+
|
|
44
|
+
// D-packaging: flag-gated so dev mode (Vite on :5173 proxying to this
|
|
45
|
+
// daemon) never double-registers a static root — registered last, after
|
|
46
|
+
// every /api/* route above, so those routes win over static's own wildcard.
|
|
47
|
+
if (process.env.GARAGE_SERVE_UI === "1") {
|
|
48
|
+
const uiDist = path.join(REPO_ROOT, "ui", "dist");
|
|
49
|
+
if (existsSync(uiDist)) {
|
|
50
|
+
app.register(fastifyStatic, { root: uiDist, prefix: "/" });
|
|
51
|
+
} else {
|
|
52
|
+
app.log.error(
|
|
53
|
+
`GARAGE_SERVE_UI is set but ${uiDist} does not exist — run "npm run build --workspace ui" first`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Poller feeds StatusStore (busy/idle baseline); hooks.js and notify.js
|
|
59
|
+
// subscribe to the same store — see status.js for the single write path.
|
|
60
|
+
const stopPoller = startPoller(app);
|
|
61
|
+
app.addHook("onClose", async () => {
|
|
62
|
+
stopPoller();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
app.listen({ host: HOST, port: PORT }).catch((err) => {
|
|
66
|
+
if (err.code === "EADDRINUSE") {
|
|
67
|
+
app.log.error(
|
|
68
|
+
`port ${PORT} is already in use — set GARAGE_PORT to choose a different port`
|
|
69
|
+
);
|
|
70
|
+
} else {
|
|
71
|
+
app.log.error(err);
|
|
72
|
+
}
|
|
73
|
+
process.exit(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export { app };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { statusEvents } from "./status.js";
|
|
3
|
+
|
|
4
|
+
const PORT = Number(process.env.GARAGE_PORT ?? 4747);
|
|
5
|
+
const UI_URL = `http://127.0.0.1:${PORT}`;
|
|
6
|
+
|
|
7
|
+
// D-notify: notification on transition INTO needs-input, edge-triggered
|
|
8
|
+
// (the store only emits "transition" on an actual state change, so this
|
|
9
|
+
// handler naturally fires once per transition, not once per poll tick).
|
|
10
|
+
// Darwin only; a no-op elsewhere. Never throws — a notification failure
|
|
11
|
+
// must not affect the daemon.
|
|
12
|
+
//
|
|
13
|
+
// Clickability: osascript's `display notification` cannot carry a click
|
|
14
|
+
// action — it is a dead-end toast. When terminal-notifier is installed
|
|
15
|
+
// (brew install terminal-notifier), use it instead: its -open flag makes
|
|
16
|
+
// clicking the notification open the pit wall. Checked once, lazily.
|
|
17
|
+
let hasTerminalNotifier = null; // null = not yet probed
|
|
18
|
+
function probeTerminalNotifier(cb) {
|
|
19
|
+
if (hasTerminalNotifier !== null) return cb(hasTerminalNotifier);
|
|
20
|
+
execFile("which", ["terminal-notifier"], (err) => {
|
|
21
|
+
hasTerminalNotifier = !err;
|
|
22
|
+
cb(hasTerminalNotifier);
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function notifyDarwin(id) {
|
|
27
|
+
if (process.platform !== "darwin") return;
|
|
28
|
+
const message = `${id} needs input`;
|
|
29
|
+
try {
|
|
30
|
+
probeTerminalNotifier((available) => {
|
|
31
|
+
if (available) {
|
|
32
|
+
execFile(
|
|
33
|
+
"terminal-notifier",
|
|
34
|
+
["-title", "claude-garage", "-message", message, "-open", UI_URL],
|
|
35
|
+
() => {}
|
|
36
|
+
);
|
|
37
|
+
} else {
|
|
38
|
+
const script = `display notification ${JSON.stringify(message)} with title "claude-garage"`;
|
|
39
|
+
execFile("osascript", ["-e", script], () => {});
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
} catch {
|
|
43
|
+
// never throw
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Visibility contract: the UI posts here periodically while a pit-wall
|
|
48
|
+
// page is in the foreground. "a visible page exists" = any client entry
|
|
49
|
+
// visible && seen within TTL_MS. Called from the browser, so it stays
|
|
50
|
+
// behind the normal Origin allowlist (unlike /api/hooks/claude).
|
|
51
|
+
const TTL_MS = 45_000;
|
|
52
|
+
const clients = new Map(); // clientId -> { visible, lastSeen }
|
|
53
|
+
|
|
54
|
+
function anyVisiblePage() {
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
for (const { visible, lastSeen } of clients.values()) {
|
|
57
|
+
if (visible && now - lastSeen <= TTL_MS) return true;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default async function notifyRoutes(app) {
|
|
63
|
+
app.post("/api/ui/visibility", async (req, reply) => {
|
|
64
|
+
const { clientId, visible } = req.body ?? {};
|
|
65
|
+
if (typeof clientId !== "string" || !clientId || typeof visible !== "boolean") {
|
|
66
|
+
return reply
|
|
67
|
+
.code(400)
|
|
68
|
+
.send({ error: "clientId (string) and visible (boolean) are required" });
|
|
69
|
+
}
|
|
70
|
+
clients.set(clientId, { visible, lastSeen: Date.now() });
|
|
71
|
+
return reply.code(200).send({ ok: true });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
statusEvents.on("transition", ({ id, to }) => {
|
|
75
|
+
if (to === "needs-input" && !anyVisiblePage()) {
|
|
76
|
+
app.log.info({ id }, "needs-input notification fired");
|
|
77
|
+
notifyDarwin(id);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const run = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
// D-picker: browsers deliberately never expose absolute filesystem paths,
|
|
7
|
+
// so the "add workspace" folder picker has to run daemon-side. macOS-only —
|
|
8
|
+
// non-darwin hosts keep the manual path-entry fallback in the UI.
|
|
9
|
+
const PICKER_SCRIPT =
|
|
10
|
+
'POSIX path of (choose folder with prompt "Add claude-garage workspace")';
|
|
11
|
+
|
|
12
|
+
// osascript blocks for as long as the dialog is open (the user may leave it
|
|
13
|
+
// sitting there) — give it a generous timeout rather than the execFile
|
|
14
|
+
// default, which would otherwise fail an interaction that's still in progress.
|
|
15
|
+
const PICKER_TIMEOUT_MS = 120_000;
|
|
16
|
+
|
|
17
|
+
export default async function pickerRoutes(app) {
|
|
18
|
+
app.post("/api/pick-directory", async (req, reply) => {
|
|
19
|
+
if (process.platform !== "darwin") {
|
|
20
|
+
return reply
|
|
21
|
+
.code(501)
|
|
22
|
+
.send({ error: "directory picker is only available on macOS" });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let stdout;
|
|
26
|
+
try {
|
|
27
|
+
({ stdout } = await run("osascript", ["-e", PICKER_SCRIPT], {
|
|
28
|
+
timeout: PICKER_TIMEOUT_MS,
|
|
29
|
+
}));
|
|
30
|
+
} catch (err) {
|
|
31
|
+
// osascript exits 1 with "User canceled" in stderr when the user
|
|
32
|
+
// dismisses the dialog — that's a normal outcome, not an error.
|
|
33
|
+
const stderr = err?.stderr ?? "";
|
|
34
|
+
if (stderr.includes("User canceled")) {
|
|
35
|
+
return reply.code(200).send({ cancelled: true });
|
|
36
|
+
}
|
|
37
|
+
return reply.code(500).send({ error: err?.message ?? "osascript failed" });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// osascript's `POSIX path of` always trails with a newline, and folder
|
|
41
|
+
// paths additionally trail with "/" (except the root "/" itself).
|
|
42
|
+
let dir = stdout.trimEnd();
|
|
43
|
+
if (dir.length > 1 && dir.endsWith("/")) {
|
|
44
|
+
dir = dir.slice(0, -1);
|
|
45
|
+
}
|
|
46
|
+
return reply.code(200).send({ dir });
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { EventEmitter } from "node:events";
|
|
4
|
+
import { listSessions } from "./tmux.js";
|
|
5
|
+
import { setStatus, getStatus, dropSession } from "./status.js";
|
|
6
|
+
import { upsertSessionMeta } from "./registry.js";
|
|
7
|
+
|
|
8
|
+
const run = promisify(execFile);
|
|
9
|
+
const POLL_MS = 2000;
|
|
10
|
+
|
|
11
|
+
// Emits "sessions-changed" whenever the poller notices the garage session
|
|
12
|
+
// list itself changed (spawn/death), so events.js can tell the UI to refetch.
|
|
13
|
+
export const pollerEvents = new EventEmitter();
|
|
14
|
+
pollerEvents.setMaxListeners(0);
|
|
15
|
+
|
|
16
|
+
let lastSessionIds = new Set();
|
|
17
|
+
|
|
18
|
+
// `claude agents --json` — missing CLI or any failure is a silent no-op,
|
|
19
|
+
// per D-status's graceful degradation to poller-absent behavior.
|
|
20
|
+
async function getAgents() {
|
|
21
|
+
try {
|
|
22
|
+
const { stdout } = await run("claude", ["agents", "--json"]);
|
|
23
|
+
return JSON.parse(stdout);
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// tmux pane pid per session name — pane_pid joins exactly to the `pid`
|
|
30
|
+
// reported by `claude agents --json` since garage spawns claude as the
|
|
31
|
+
// pane's root process (design D-status).
|
|
32
|
+
async function getPanePids() {
|
|
33
|
+
try {
|
|
34
|
+
const { stdout } = await run("tmux", [
|
|
35
|
+
"list-panes",
|
|
36
|
+
"-a",
|
|
37
|
+
"-F",
|
|
38
|
+
"#{session_name}\t#{pane_pid}",
|
|
39
|
+
]);
|
|
40
|
+
const map = new Map();
|
|
41
|
+
for (const line of stdout.split("\n")) {
|
|
42
|
+
if (!line.trim()) continue;
|
|
43
|
+
const [name, pid] = line.split("\t");
|
|
44
|
+
map.set(name, Number(pid));
|
|
45
|
+
}
|
|
46
|
+
return map;
|
|
47
|
+
} catch {
|
|
48
|
+
return new Map();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// `claude agents --json` self-reports busy | waiting | idle. `waiting`
|
|
53
|
+
// means blocked on the user (permission prompt, question) — that IS
|
|
54
|
+
// needs-input, just 2s-coarse. Mapping it to idle (the pre-publish bug)
|
|
55
|
+
// meant hookless installs never saw the product's core signal at all.
|
|
56
|
+
// Exported for the test suite.
|
|
57
|
+
export function applyAgentStatus(id, agentStatus) {
|
|
58
|
+
const mapped =
|
|
59
|
+
agentStatus === "busy" ? "working" : agentStatus === "waiting" ? "needs-input" : "idle";
|
|
60
|
+
// The poller's coarse 'idle' must not stomp the hook-sourced precision
|
|
61
|
+
// states: needs-input clears only on real activity, and done holds until
|
|
62
|
+
// its decay timer (status.js) or a 'busy' observation (-> working).
|
|
63
|
+
const current = getStatus(id);
|
|
64
|
+
if (mapped === "idle" && (current === "needs-input" || current === "done")) return;
|
|
65
|
+
setStatus(id, mapped);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function tick() {
|
|
69
|
+
const sessions = await listSessions().catch(() => []);
|
|
70
|
+
|
|
71
|
+
const currentIds = new Set(sessions.map((s) => s.id));
|
|
72
|
+
let changed = currentIds.size !== lastSessionIds.size;
|
|
73
|
+
for (const id of lastSessionIds) {
|
|
74
|
+
if (!currentIds.has(id)) {
|
|
75
|
+
dropSession(id);
|
|
76
|
+
changed = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!changed) {
|
|
80
|
+
for (const id of currentIds) {
|
|
81
|
+
if (!lastSessionIds.has(id)) {
|
|
82
|
+
changed = true;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
lastSessionIds = currentIds;
|
|
88
|
+
if (changed) pollerEvents.emit("sessions-changed");
|
|
89
|
+
|
|
90
|
+
if (sessions.length === 0) return;
|
|
91
|
+
|
|
92
|
+
const [agents, panePids] = await Promise.all([getAgents(), getPanePids()]);
|
|
93
|
+
if (agents.length === 0) return; // claude CLI missing/no live agents
|
|
94
|
+
|
|
95
|
+
const agentByPid = new Map(agents.map((a) => [a.pid, a]));
|
|
96
|
+
const unmatched = [];
|
|
97
|
+
|
|
98
|
+
for (const session of sessions) {
|
|
99
|
+
const panePid = panePids.get(session.id);
|
|
100
|
+
const agent = panePid !== undefined ? agentByPid.get(panePid) : undefined;
|
|
101
|
+
if (agent) {
|
|
102
|
+
applyAgentStatus(session.id, agent.status);
|
|
103
|
+
// D-resume-meta: only on the pid-join succeeding do we have a trusted
|
|
104
|
+
// sessionId for this garage session; upsertSessionMeta itself is a
|
|
105
|
+
// no-op write when nothing changed. Never let a meta-write failure
|
|
106
|
+
// interrupt status polling for the rest of the tick.
|
|
107
|
+
if (agent.sessionId) {
|
|
108
|
+
await upsertSessionMeta(session.id, {
|
|
109
|
+
claudeSessionId: agent.sessionId,
|
|
110
|
+
workspace: session.workspace,
|
|
111
|
+
label: session.label,
|
|
112
|
+
}).catch(() => {});
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
unmatched.push(session);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Fail-open fallback: pid join missed (e.g. claude exec'd through a
|
|
120
|
+
// wrapper) — apply the cwd-matching agent's status to ALL sessions in
|
|
121
|
+
// that dir. Over-notify rather than silently drop.
|
|
122
|
+
for (const session of unmatched) {
|
|
123
|
+
const cwdAgent = agents.find((a) => a.cwd === session.dir);
|
|
124
|
+
if (cwdAgent) applyAgentStatus(session.id, cwdAgent.status);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function startPoller(app) {
|
|
129
|
+
const timer = setInterval(() => {
|
|
130
|
+
tick().catch((err) => app?.log?.warn?.({ err }, "poller tick failed"));
|
|
131
|
+
}, POLL_MS);
|
|
132
|
+
timer.unref?.();
|
|
133
|
+
return () => clearInterval(timer);
|
|
134
|
+
}
|