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,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
+ }