agent-dag 1.0.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.
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>ccgraph</title>
7
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='84' font-size='84'%3E%E2%97%89%3C/text%3E%3C/svg%3E" />
8
+ <script type="module" crossorigin src="/assets/index-ChObhKsa.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-CbRJ5PCq.css">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
package/hook/hook.js ADDED
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env node
2
+ // ccgraph hook forwarder. Claude Code invokes this as a command hook.
3
+ // It reads stdin (CC event JSON), finds the matching ccgraph server via
4
+ // per-workspace discovery files in ~/.claude/ccgraph/, and forwards the
5
+ // payload to it via HTTP POST. Dead instances are cleaned up.
6
+ "use strict";
7
+
8
+ const fs = require("fs");
9
+ const path = require("path");
10
+ const http = require("http");
11
+ const os = require("os");
12
+
13
+ // Hard cap so a stuck server can never wedge Claude Code.
14
+ setTimeout(() => process.exit(0), 1500);
15
+
16
+ const DIR = path.join(os.homedir(), ".claude", "ccgraph");
17
+ const IS_WIN = process.platform === "win32";
18
+
19
+ function normPath(p) {
20
+ let r = path.resolve(p);
21
+ try { r = fs.realpathSync(r); } catch {}
22
+ return r;
23
+ }
24
+
25
+ function isAlive(pid) {
26
+ // process.kill(pid, 0) works on Windows in Node 18+:
27
+ // ESRCH => no such process, EPERM => exists but not ours (still alive).
28
+ try { process.kill(pid, 0); return true; }
29
+ catch (e) { return e && e.code === "EPERM"; }
30
+ }
31
+
32
+ let input = "";
33
+ process.stdin.setEncoding("utf8");
34
+ process.stdin.on("data", c => { input += c; });
35
+ process.stdin.on("end", () => {
36
+ let cwd;
37
+ try { cwd = JSON.parse(input).cwd; } catch { return process.exit(0); }
38
+ if (!cwd) return process.exit(0);
39
+
40
+ const resolvedCwd = normPath(cwd);
41
+
42
+ let files;
43
+ try {
44
+ files = fs.readdirSync(DIR).filter(f => f.endsWith(".json"));
45
+ } catch { return process.exit(0); }
46
+ if (!files.length) return process.exit(0);
47
+
48
+ const matches = [];
49
+ for (const file of files) {
50
+ let d;
51
+ try { d = JSON.parse(fs.readFileSync(path.join(DIR, file), "utf8")); } catch { continue; }
52
+ if (typeof d.workspace !== "string" || !d.pid || !d.port) continue;
53
+
54
+ if (!isAlive(d.pid)) {
55
+ try { fs.unlinkSync(path.join(DIR, file)); } catch {}
56
+ continue;
57
+ }
58
+
59
+ // Empty workspace = match-all (used by `ccgraph --all`).
60
+ if (d.workspace === "") {
61
+ matches.push({ d, wsLen: 0 });
62
+ continue;
63
+ }
64
+ const ws = normPath(d.workspace);
65
+ if (resolvedCwd === ws || resolvedCwd.startsWith(ws + path.sep)) {
66
+ matches.push({ d, wsLen: ws.length });
67
+ }
68
+ }
69
+
70
+ if (!matches.length) return process.exit(0);
71
+
72
+ // Most specific workspace wins.
73
+ matches.sort((a, b) => b.wsLen - a.wsLen);
74
+ const bestLen = matches[0].wsLen;
75
+ const targets = matches.filter(m => m.wsLen === bestLen);
76
+
77
+ let pending = targets.length;
78
+ const done = () => { if (--pending <= 0) process.exit(0); };
79
+
80
+ for (const { d } of targets) {
81
+ let settled = false;
82
+ const finish = () => { if (settled) return; settled = true; done(); };
83
+ const req = http.request({
84
+ hostname: "127.0.0.1",
85
+ port: d.port,
86
+ path: "/api/event",
87
+ method: "POST",
88
+ headers: { "Content-Type": "application/json" },
89
+ timeout: 1000,
90
+ }, res => { res.resume(); res.on("end", finish); });
91
+ req.on("error", finish);
92
+ req.on("timeout", () => req.destroy());
93
+ req.write(input);
94
+ req.end();
95
+ }
96
+ });
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "agent-dag",
3
+ "version": "1.0.0",
4
+ "description": "Live DAG of Claude Code agents — watch parallel subagents fork, call tools, and return on one calm canvas.",
5
+ "type": "module",
6
+ "bin": {
7
+ "agent-dag": "bin/ccgraph.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "hook",
12
+ "src/server",
13
+ "dist/web",
14
+ "LICENSE",
15
+ "README.md"
16
+ ],
17
+ "scripts": {
18
+ "dev:web": "vite",
19
+ "build:web": "vite build",
20
+ "dev:server": "node src/server/index.mjs",
21
+ "start": "node bin/ccgraph.js",
22
+ "build": "vite build",
23
+ "prepublishOnly": "vite build"
24
+ },
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "keywords": [
29
+ "claude-code",
30
+ "claude",
31
+ "anthropic",
32
+ "agents",
33
+ "subagents",
34
+ "visualization",
35
+ "dashboard",
36
+ "hooks",
37
+ "observability",
38
+ "dag",
39
+ "graph",
40
+ "live"
41
+ ],
42
+ "author": "Bargan Constantin",
43
+ "license": "MIT",
44
+ "dependencies": {
45
+ "open": "^10.1.0"
46
+ },
47
+ "devDependencies": {
48
+ "@types/react": "^18.3.12",
49
+ "@types/react-dom": "^18.3.1",
50
+ "@vitejs/plugin-react": "^4.3.4",
51
+ "dagre": "^0.8.5",
52
+ "react": "^18.3.1",
53
+ "react-dom": "^18.3.1",
54
+ "reactflow": "^11.11.4",
55
+ "typescript": "^5.6.3",
56
+ "vite": "^6.0.0"
57
+ }
58
+ }
@@ -0,0 +1,250 @@
1
+ // ccgraph server: HTTP ingest + SSE broadcast + static file serving.
2
+ // Single-file pure Node HTTP server, zero deps.
3
+ import { createServer } from "node:http";
4
+ import { readFile, stat, mkdir, appendFile, open, truncate, readdir, unlink } from "node:fs/promises";
5
+ import { createReadStream, existsSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { extname, join, resolve, dirname as pdirname } from "node:path";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
+ import { dirname } from "node:path";
10
+ import { createInterface } from "node:readline";
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const PKG_ROOT = resolve(__dirname, "..", "..");
14
+ const WEB_DIST = resolve(PKG_ROOT, "dist", "web");
15
+
16
+ const MIME = {
17
+ ".html": "text/html; charset=utf-8",
18
+ ".js": "application/javascript; charset=utf-8",
19
+ ".mjs": "application/javascript; charset=utf-8",
20
+ ".css": "text/css; charset=utf-8",
21
+ ".json": "application/json; charset=utf-8",
22
+ ".svg": "image/svg+xml",
23
+ ".png": "image/png",
24
+ ".jpg": "image/jpeg",
25
+ ".woff2": "font/woff2",
26
+ ".map": "application/json",
27
+ };
28
+
29
+ const MAX_BUFFER = 2000; // recent events kept for late SSE subscribers
30
+ const events = []; // ring buffer
31
+ let nextSeq = 1;
32
+ const sseClients = new Set(); // res handles
33
+
34
+ let persistPath = null; // absolute path to events.jsonl, or null
35
+
36
+ function pushEvent(raw, source, opts = {}) {
37
+ const seq = nextSeq++;
38
+ const evt = {
39
+ seq,
40
+ receivedAt: opts.receivedAt ?? Date.now(),
41
+ source,
42
+ payload: raw,
43
+ };
44
+ events.push(evt);
45
+ if (events.length > MAX_BUFFER) events.splice(0, events.length - MAX_BUFFER);
46
+
47
+ const line = `id: ${seq}\nevent: hook\ndata: ${JSON.stringify(evt)}\n\n`;
48
+ for (const res of sseClients) {
49
+ try { res.write(line); } catch {}
50
+ }
51
+
52
+ if (persistPath && !opts.replay) {
53
+ // Fire-and-forget append. JSONL = newline-delimited JSON.
54
+ appendFile(persistPath, JSON.stringify(evt) + "\n", "utf8").catch(() => {});
55
+ }
56
+
57
+ return evt;
58
+ }
59
+
60
+ async function replayLog(filePath) {
61
+ if (!existsSync(filePath)) return 0;
62
+ let count = 0;
63
+ const rl = createInterface({ input: createReadStream(filePath, { encoding: "utf8" }) });
64
+ for await (const line of rl) {
65
+ if (!line) continue;
66
+ try {
67
+ const evt = JSON.parse(line);
68
+ if (evt && typeof evt === "object" && evt.payload) {
69
+ pushEvent(evt.payload, evt.source ?? "replay", { receivedAt: evt.receivedAt, replay: true });
70
+ count++;
71
+ }
72
+ } catch { /* skip corrupt line */ }
73
+ }
74
+ return count;
75
+ }
76
+
77
+ function send(res, status, body, headers = {}) {
78
+ res.writeHead(status, {
79
+ "Content-Type": "application/json; charset=utf-8",
80
+ "Cache-Control": "no-store",
81
+ ...headers,
82
+ });
83
+ res.end(typeof body === "string" ? body : JSON.stringify(body));
84
+ }
85
+
86
+ async function serveStatic(req, res, url) {
87
+ // Strip leading slash, default to index.html
88
+ let rel = url.pathname.replace(/^\/+/, "");
89
+ if (rel === "" || rel.endsWith("/")) rel = `${rel}index.html`;
90
+ const filePath = join(WEB_DIST, rel);
91
+ if (!filePath.startsWith(WEB_DIST)) return send(res, 403, { error: "forbidden" });
92
+
93
+ try {
94
+ const s = await stat(filePath);
95
+ if (s.isDirectory()) return send(res, 404, { error: "not found" });
96
+ const buf = await readFile(filePath);
97
+ res.writeHead(200, {
98
+ "Content-Type": MIME[extname(filePath).toLowerCase()] ?? "application/octet-stream",
99
+ "Cache-Control": "no-cache",
100
+ });
101
+ res.end(buf);
102
+ } catch {
103
+ // SPA fallback to index.html for client-side routes
104
+ try {
105
+ const idx = await readFile(join(WEB_DIST, "index.html"));
106
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
107
+ res.end(idx);
108
+ } catch {
109
+ send(res, 404, { error: "ui not built. run `pnpm build` or `npm run build`." });
110
+ }
111
+ }
112
+ }
113
+
114
+ function handleEventIngest(req, res) {
115
+ let body = "";
116
+ req.setEncoding("utf8");
117
+ req.on("data", c => {
118
+ body += c;
119
+ if (body.length > 5_000_000) {
120
+ req.destroy();
121
+ }
122
+ });
123
+ req.on("end", () => {
124
+ let parsed;
125
+ try { parsed = JSON.parse(body); }
126
+ catch { return send(res, 400, { error: "invalid json" }); }
127
+ const evt = pushEvent(parsed, "hook");
128
+ send(res, 200, { ok: true, seq: evt.seq });
129
+ });
130
+ req.on("error", () => send(res, 400, { error: "bad request" }));
131
+ }
132
+
133
+ function handleSse(req, res) {
134
+ res.writeHead(200, {
135
+ "Content-Type": "text/event-stream",
136
+ "Cache-Control": "no-cache, no-transform",
137
+ "Connection": "keep-alive",
138
+ "X-Accel-Buffering": "no",
139
+ });
140
+ res.write(`retry: 1500\n\n`);
141
+
142
+ // Resume: replay events after Last-Event-ID
143
+ const lastId = Number(req.headers["last-event-id"] ?? 0);
144
+ for (const e of events) {
145
+ if (e.seq <= lastId) continue;
146
+ res.write(`id: ${e.seq}\nevent: hook\ndata: ${JSON.stringify(e)}\n\n`);
147
+ }
148
+
149
+ sseClients.add(res);
150
+ const ping = setInterval(() => {
151
+ try { res.write(`: ping\n\n`); } catch {}
152
+ }, 15000);
153
+
154
+ req.on("close", () => {
155
+ clearInterval(ping);
156
+ sseClients.delete(res);
157
+ });
158
+ }
159
+
160
+ function handleHealth(_req, res) {
161
+ send(res, 200, {
162
+ ok: true,
163
+ name: "ccgraph",
164
+ seq: nextSeq - 1,
165
+ clients: sseClients.size,
166
+ uptimeMs: Math.round(process.uptime() * 1000),
167
+ });
168
+ }
169
+
170
+ function isProcessAlive(pid) {
171
+ try { process.kill(pid, 0); return true; }
172
+ catch (e) { return e && e.code === "EPERM"; }
173
+ }
174
+
175
+ async function sweepStaleDiscovery() {
176
+ const dir = join(homedir(), ".claude", "ccgraph");
177
+ let files;
178
+ try { files = await readdir(dir); } catch { return 0; }
179
+ let removed = 0;
180
+ for (const f of files) {
181
+ if (!f.endsWith(".json")) continue;
182
+ const p = join(dir, f);
183
+ try {
184
+ const d = JSON.parse(await readFile(p, "utf8"));
185
+ if (d && typeof d.pid === "number" && !isProcessAlive(d.pid)) {
186
+ await unlink(p).catch(() => {});
187
+ removed++;
188
+ }
189
+ } catch { /* corrupt — leave it */ }
190
+ }
191
+ return removed;
192
+ }
193
+
194
+ export async function startServer({ port = 4317, host = "127.0.0.1", persist = null } = {}) {
195
+ const removed = await sweepStaleDiscovery();
196
+ if (removed > 0) console.log(` swept ${removed} stale discovery file(s)`);
197
+ if (persist) {
198
+ persistPath = resolve(persist);
199
+ try { await mkdir(pdirname(persistPath), { recursive: true }); } catch {}
200
+ const replayed = await replayLog(persistPath);
201
+ if (replayed > 0) {
202
+ // Don't broadcast replays as live; SSE clients catch up via Last-Event-ID
203
+ // already. Just keep the buffer + seq counter primed.
204
+ }
205
+ }
206
+ const server = createServer((req, res) => {
207
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? host}`);
208
+
209
+ if (req.method === "POST" && url.pathname === "/api/event") return handleEventIngest(req, res);
210
+ if (req.method === "GET" && url.pathname === "/api/health") return handleHealth(req, res);
211
+ if (req.method === "GET" && url.pathname === "/events") return handleSse(req, res);
212
+
213
+ if (req.method === "GET" && url.pathname === "/api/events") {
214
+ const since = Number(url.searchParams.get("since") ?? 0);
215
+ return send(res, 200, events.filter(e => e.seq > since));
216
+ }
217
+
218
+ // POST /api/clear — wipe in-memory buffer + persistence file (UI reset)
219
+ if (req.method === "POST" && url.pathname === "/api/clear") {
220
+ events.length = 0;
221
+ if (persistPath) truncate(persistPath, 0).catch(() => {});
222
+ pushEvent({ hook_event_name: "__clear", cwd: "" }, "internal");
223
+ return send(res, 200, { ok: true });
224
+ }
225
+
226
+ if (req.method === "GET") return serveStatic(req, res, url);
227
+ send(res, 405, { error: "method not allowed" });
228
+ });
229
+
230
+ return new Promise((resolveStart, rejectStart) => {
231
+ server.once("error", rejectStart);
232
+ server.listen(port, host, () => {
233
+ server.removeListener("error", rejectStart);
234
+ resolveStart(server);
235
+ });
236
+ });
237
+ }
238
+
239
+ // Allow running this file directly for dev.
240
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
241
+ const port = Number(process.env.CCGRAPH_PORT ?? 4317);
242
+ startServer({ port }).then(s => {
243
+ const addr = s.address();
244
+ const p = typeof addr === "object" && addr ? addr.port : port;
245
+ console.log(`ccgraph server: http://127.0.0.1:${p}`);
246
+ }).catch(e => {
247
+ console.error("ccgraph server failed:", e.message);
248
+ process.exit(1);
249
+ });
250
+ }
@@ -0,0 +1,116 @@
1
+ // Idempotent hook installer for ~/.claude/settings.json.
2
+ // Adds a single command-hook entry per CC hook event pointing at our forwarder.
3
+ // Re-runs are safe (entries are tagged with __ccgraph and de-duped).
4
+ import { readFile, writeFile, mkdir, copyFile, unlink } from "node:fs/promises";
5
+ import { existsSync } from "node:fs";
6
+ import { homedir, platform } from "node:os";
7
+ import { join, resolve, dirname } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const PKG_ROOT = resolve(__dirname, "..", "..");
12
+
13
+ const HOME = homedir();
14
+ const CLAUDE_DIR = join(HOME, ".claude");
15
+ const SETTINGS = join(CLAUDE_DIR, "settings.json");
16
+ const CCGRAPH_DIR = join(CLAUDE_DIR, "ccgraph");
17
+
18
+ export const HOOK_EVENTS = [
19
+ "SessionStart",
20
+ "UserPromptSubmit",
21
+ "PreToolUse",
22
+ "PostToolUse",
23
+ "PostToolUseFailure",
24
+ "SubagentStart",
25
+ "SubagentStop",
26
+ "Stop",
27
+ "SessionEnd",
28
+ "Notification",
29
+ ];
30
+
31
+ const MARK_KEY = "__ccgraph";
32
+
33
+ function hookCommand(installedHookPath) {
34
+ // process.execPath = absolute path to current node (works on Win + *nix).
35
+ const node = process.execPath;
36
+ return `"${node}" "${installedHookPath}"`;
37
+ }
38
+
39
+ async function ensureDir(p) {
40
+ if (!existsSync(p)) await mkdir(p, { recursive: true });
41
+ }
42
+
43
+ async function readJsonSafe(p) {
44
+ try { return JSON.parse(await readFile(p, "utf8")); } catch { return null; }
45
+ }
46
+
47
+ async function installHookScript() {
48
+ await ensureDir(CCGRAPH_DIR);
49
+ const src = join(PKG_ROOT, "hook", "hook.js");
50
+ const dst = join(CCGRAPH_DIR, "hook.js");
51
+ await copyFile(src, dst);
52
+ return dst;
53
+ }
54
+
55
+ function buildHookEntry(command) {
56
+ return {
57
+ [MARK_KEY]: true,
58
+ hooks: [{ type: "command", command, timeout: 2 }],
59
+ };
60
+ }
61
+
62
+ function dedupeOurEntries(group) {
63
+ if (!Array.isArray(group)) return [];
64
+ return group.filter(g => !(g && typeof g === "object" && g[MARK_KEY] === true));
65
+ }
66
+
67
+ export async function installHooks() {
68
+ const hookPath = await installHookScript();
69
+ const command = hookCommand(hookPath);
70
+ await ensureDir(CLAUDE_DIR);
71
+
72
+ const current = (await readJsonSafe(SETTINGS)) ?? {};
73
+ current.hooks = current.hooks ?? {};
74
+
75
+ for (const evt of HOOK_EVENTS) {
76
+ const cleaned = dedupeOurEntries(current.hooks[evt]);
77
+ cleaned.push(buildHookEntry(command));
78
+ current.hooks[evt] = cleaned;
79
+ }
80
+
81
+ await writeFile(SETTINGS, JSON.stringify(current, null, 2) + "\n", "utf8");
82
+ return { settingsPath: SETTINGS, hookPath, events: HOOK_EVENTS };
83
+ }
84
+
85
+ export async function uninstallHooks() {
86
+ const current = await readJsonSafe(SETTINGS);
87
+ if (!current?.hooks) return { changed: false };
88
+ let changed = false;
89
+ for (const evt of Object.keys(current.hooks)) {
90
+ const cleaned = dedupeOurEntries(current.hooks[evt]);
91
+ if (cleaned.length !== (current.hooks[evt]?.length ?? 0)) changed = true;
92
+ if (cleaned.length === 0) delete current.hooks[evt];
93
+ else current.hooks[evt] = cleaned;
94
+ }
95
+ if (changed) await writeFile(SETTINGS, JSON.stringify(current, null, 2) + "\n", "utf8");
96
+ return { changed };
97
+ }
98
+
99
+ export async function writeDiscovery({ port, workspace }) {
100
+ await ensureDir(CCGRAPH_DIR);
101
+ const file = join(CCGRAPH_DIR, `${process.pid}.json`);
102
+ const data = {
103
+ pid: process.pid,
104
+ port,
105
+ workspace: workspace ?? "",
106
+ startedAt: new Date().toISOString(),
107
+ };
108
+ await writeFile(file, JSON.stringify(data, null, 2) + "\n", "utf8");
109
+ return file;
110
+ }
111
+
112
+ export async function removeDiscovery(file) {
113
+ try { await unlink(file); } catch {}
114
+ }
115
+
116
+ export { CCGRAPH_DIR, SETTINGS };