agent-dag 1.13.0 → 1.15.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.
@@ -5,8 +5,8 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>agent-dag</title>
7
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-Cd7zyxkL.js"></script>
9
- <link rel="stylesheet" crossorigin href="/assets/index-Ch4BS-lV.css">
8
+ <script type="module" crossorigin src="/assets/index-DZeh3MHD.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-Bwj0xSAo.css">
10
10
  </head>
11
11
  <body>
12
12
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.13.0",
3
+ "version": "1.15.0",
4
4
  "description": "Live DAG of Claude Code agents — watch parallel subagents fork, call tools, and return on one calm canvas.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -73,32 +73,52 @@ async function maybeRotatePersistFile() {
73
73
  // payloads for that session before broadcasting, (b) emit a synthetic
74
74
  // `ModelObserved` event so the client backfills agents created before
75
75
  // the model was resolved.
76
- const modelBySession = new Map(); // sessionId -> "claude-…"
76
+ const modelBySession = new Map(); // sessionId -> { rootModel, subsSig }
77
77
  const pendingTranscriptReads = new Set(); // sessionId currently being read
78
+ const modelLastReadAt = new Map(); // sessionId -> ms timestamp (re-read throttle)
79
+ const MODEL_READ_THROTTLE_MS = 2500;
78
80
 
79
81
  async function readModelFromTranscript(path) {
82
+ // Returns { rootModel, subagentModels } — root is the most recent
83
+ // non-sidechain assistant model, subagents are attributed by the Task
84
+ // tool_use id that owns them. CC transcripts mark subagent messages
85
+ // with `isSidechain:true` and (for current schemas) a `parentToolUseID`
86
+ // or `parent_tool_use_id` referencing the Task invocation.
80
87
  try {
81
88
  const s = await stat(path);
82
89
  if (s.size === 0) return null;
83
- // Read up to last 128 KB — plenty for the most-recent model
84
- // declaration. Reading from the tail handles sessions that switched
85
- // model mid-conversation (we want the current one).
86
- const TAIL = 128 * 1024;
87
- const start = Math.max(0, s.size - TAIL);
88
90
  const fh = await open(path, "r");
91
+ let text;
89
92
  try {
90
- const len = s.size - start;
91
- const buf = Buffer.alloc(len);
92
- await fh.read(buf, 0, len, start);
93
- const text = buf.toString("utf8");
94
- // Scan all matches and return the LAST one — most recent model used.
95
- const re = /"model"\s*:\s*"(claude[-_][^"]+)"/gi;
96
- let last = null;
97
- for (const m of text.matchAll(re)) last = m[1];
98
- return last;
93
+ const buf = Buffer.alloc(s.size);
94
+ await fh.read(buf, 0, s.size, 0);
95
+ text = buf.toString("utf8");
99
96
  } finally {
100
97
  await fh.close();
101
98
  }
99
+ let rootModel = null;
100
+ const subagentModels = {};
101
+ for (const line of text.split("\n")) {
102
+ if (!line) continue;
103
+ let obj;
104
+ try { obj = JSON.parse(line); } catch { continue; }
105
+ const msg = obj && obj.message;
106
+ // Assistant model entries live on either obj.model or obj.message.model
107
+ // depending on CC schema version. Accept either.
108
+ const model = (msg && typeof msg.model === "string" && /^claude[-_]/i.test(msg.model)) ? msg.model
109
+ : (typeof obj.model === "string" && /^claude[-_]/i.test(obj.model)) ? obj.model
110
+ : null;
111
+ if (!model) continue;
112
+ const isSide = obj.isSidechain === true || obj.is_sidechain === true;
113
+ const ptid = obj.parentToolUseID || obj.parent_tool_use_id || obj.parentToolUseId || null;
114
+ if (isSide && ptid) {
115
+ subagentModels[ptid] = model;
116
+ } else if (!isSide) {
117
+ rootModel = model;
118
+ }
119
+ }
120
+ if (!rootModel && Object.keys(subagentModels).length === 0) return null;
121
+ return { rootModel, subagentModels };
102
122
  } catch {
103
123
  return null;
104
124
  }
@@ -109,16 +129,29 @@ function maybeResolveModel(payload) {
109
129
  const sid = payload.session_id;
110
130
  const tp = payload.transcript_path;
111
131
  if (!sid || !tp) return;
112
- if (modelBySession.has(sid)) return;
132
+ // Re-read on every event for this session — the cache was preventing us
133
+ // from picking up subagent models that arrive after the root is known.
134
+ // Throttle so we don't thrash the filesystem.
113
135
  if (pendingTranscriptReads.has(sid)) return;
136
+ const now = Date.now();
137
+ const last = modelLastReadAt.get(sid) ?? 0;
138
+ if (now - last < MODEL_READ_THROTTLE_MS) return;
139
+ modelLastReadAt.set(sid, now);
114
140
  pendingTranscriptReads.add(sid);
115
141
  readModelFromTranscript(tp)
116
- .then(model => {
117
- if (!model) return;
118
- modelBySession.set(sid, model);
119
- // Synthetic enrichment event — reducer applies to every agent in
120
- // this session, including ones created before we resolved.
121
- pushEvent({ hook_event_name: "ModelObserved", session_id: sid, model }, "internal");
142
+ .then(result => {
143
+ if (!result) return;
144
+ const { rootModel, subagentModels } = result;
145
+ const prev = modelBySession.get(sid);
146
+ const subsSig = JSON.stringify(subagentModels);
147
+ if (prev && prev.rootModel === rootModel && prev.subsSig === subsSig) return;
148
+ modelBySession.set(sid, { rootModel, subsSig });
149
+ pushEvent({
150
+ hook_event_name: "ModelObserved",
151
+ session_id: sid,
152
+ model: rootModel,
153
+ subagentModels,
154
+ }, "internal");
122
155
  })
123
156
  .catch(() => {})
124
157
  .finally(() => pendingTranscriptReads.delete(sid));
@@ -198,6 +231,145 @@ function maybeResolveUsage(payload) {
198
231
  .finally(() => pendingUsageReads.delete(sid));
199
232
  }
200
233
 
234
+ // ─── Context enrichment ──────────────────────────────────────────────────
235
+ // Approximation of `/context` since CC doesn't expose its breakdown via
236
+ // hooks. We scan the transcript JSONL for message counts (user / assistant
237
+ // / tool_use / tool_result / system-reminders) and walk up from cwd for
238
+ // any CLAUDE.md files in scope. Token totals come from UsageObserved; this
239
+ // scan is purely structural ("what does the context contain").
240
+ const lastContextReadAt = new Map();
241
+ const pendingContextReads = new Set();
242
+ const CONTEXT_READ_THROTTLE_MS = 4000;
243
+
244
+ async function readContextFromTranscript(path) {
245
+ try {
246
+ const s = await stat(path);
247
+ if (s.size === 0) return null;
248
+ const fh = await open(path, "r");
249
+ let buf;
250
+ try { buf = Buffer.alloc(s.size); await fh.read(buf, 0, s.size, 0); }
251
+ finally { await fh.close(); }
252
+ const text = buf.toString("utf8");
253
+ const breakdown = {
254
+ msgsUser: 0,
255
+ msgsAssistant: 0,
256
+ toolUses: 0,
257
+ toolResults: 0,
258
+ systemReminders: 0,
259
+ currentContextTokens: 0,
260
+ };
261
+ breakdown.msgsUser = (text.match(/"type"\s*:\s*"user"/g) ?? []).length;
262
+ breakdown.msgsAssistant = (text.match(/"type"\s*:\s*"assistant"/g) ?? []).length;
263
+ breakdown.toolUses = (text.match(/"type"\s*:\s*"tool_use"/g) ?? []).length;
264
+ breakdown.toolResults = (text.match(/"type"\s*:\s*"tool_result"/g) ?? []).length;
265
+ breakdown.systemReminders = (text.match(/<system-reminder>/g) ?? []).length;
266
+ // Current context size = input + cache_read + cache_create on the LAST
267
+ // usage block. Each assistant message's usage describes the context
268
+ // window for THAT call; summing across calls double-counts cached
269
+ // prefixes and explodes past the real ceiling. Take the most recent.
270
+ const re = /"usage"\s*:\s*\{([^}]+)\}/g;
271
+ const grab = (blob, key) => {
272
+ const km = blob.match(new RegExp(`"${key}"\\s*:\\s*(\\d+)`));
273
+ return km ? Number(km[1]) : 0;
274
+ };
275
+ let lastBlob = null;
276
+ for (const m of text.matchAll(re)) lastBlob = m[1];
277
+ if (lastBlob) {
278
+ breakdown.currentContextTokens =
279
+ grab(lastBlob, "input_tokens") +
280
+ grab(lastBlob, "cache_read_input_tokens") +
281
+ grab(lastBlob, "cache_creation_input_tokens");
282
+ }
283
+ return breakdown;
284
+ } catch { return null; }
285
+ }
286
+
287
+ /** Encode an absolute path the way CC stores it under
288
+ * ~/.claude/projects/<slug>/. Drive letters, colons, and path separators
289
+ * are flattened to "-" so the slug survives as a single directory name. */
290
+ function ccProjectSlug(cwd) {
291
+ if (!cwd) return "";
292
+ // Replace path separators and the Windows drive colon. Match CC's own
293
+ // encoding: every \\ / : → - (no collapsing of adjacent dashes).
294
+ return resolve(cwd).replace(/[\\/:]/g, "-");
295
+ }
296
+
297
+ async function scanClaudeMdFiles(cwd) {
298
+ if (!cwd || typeof cwd !== "string") return [];
299
+ const found = [];
300
+ const seen = new Set();
301
+ const home = homedir();
302
+ const push = async (p) => {
303
+ if (seen.has(p)) return;
304
+ seen.add(p);
305
+ try {
306
+ const s = await stat(p);
307
+ if (s.isFile() && s.size > 0) found.push({ path: p, bytes: s.size });
308
+ } catch {}
309
+ };
310
+ // Walk up from cwd to filesystem root. At each dir, check for the
311
+ // canonical CC memory filenames plus CLAUDE.local.md (user-private).
312
+ let dir = resolve(cwd);
313
+ for (let depth = 0; depth < 16; depth++) {
314
+ for (const rel of [
315
+ "CLAUDE.md",
316
+ "CLAUDE.local.md",
317
+ join(".claude", "CLAUDE.md"),
318
+ join(".claude", "CLAUDE.local.md"),
319
+ ]) {
320
+ await push(join(dir, rel));
321
+ }
322
+ const parent = pdirname(dir);
323
+ if (parent === dir) break;
324
+ dir = parent;
325
+ }
326
+ // User-global memory.
327
+ await push(join(home, ".claude", "CLAUDE.md"));
328
+ await push(join(home, ".claude", "CLAUDE.local.md"));
329
+ // Per-project auto-memory: ~/.claude/projects/<slug>/memory/*.md
330
+ // (plus MEMORY.md index). CC injects these into context for sessions
331
+ // whose cwd matches the slug.
332
+ const slug = ccProjectSlug(cwd);
333
+ if (slug) {
334
+ const memDir = join(home, ".claude", "projects", slug, "memory");
335
+ try {
336
+ const entries = await readdir(memDir);
337
+ for (const f of entries) {
338
+ if (f.toLowerCase().endsWith(".md")) await push(join(memDir, f));
339
+ }
340
+ } catch {}
341
+ }
342
+ return found;
343
+ }
344
+
345
+ function maybeResolveContext(payload) {
346
+ if (!payload || typeof payload !== "object") return;
347
+ const sid = payload.session_id;
348
+ const tp = payload.transcript_path;
349
+ const cwd = payload.cwd;
350
+ if (!sid || !tp) return;
351
+ if (pendingContextReads.has(sid)) return;
352
+ const now = Date.now();
353
+ const last = lastContextReadAt.get(sid) ?? 0;
354
+ if (now - last < CONTEXT_READ_THROTTLE_MS) return;
355
+ lastContextReadAt.set(sid, now);
356
+ pendingContextReads.add(sid);
357
+ Promise.all([readContextFromTranscript(tp), scanClaudeMdFiles(cwd)])
358
+ .then(([breakdown, claudeMdFiles]) => {
359
+ if (!breakdown && (!claudeMdFiles || claudeMdFiles.length === 0)) return;
360
+ pushEvent({
361
+ hook_event_name: "ContextObserved",
362
+ session_id: sid,
363
+ context: {
364
+ ...(breakdown ?? {}),
365
+ claudeMdFiles: claudeMdFiles ?? [],
366
+ },
367
+ }, "internal");
368
+ })
369
+ .catch(() => {})
370
+ .finally(() => pendingContextReads.delete(sid));
371
+ }
372
+
201
373
  function pushEvent(raw, source, opts = {}) {
202
374
  // Synchronous enrichment: if we already know this session's model, stamp
203
375
  // it on the payload so the client's recursive scanner picks it up.
@@ -235,6 +407,7 @@ function pushEvent(raw, source, opts = {}) {
235
407
  if (source === "hook" && !opts.replay) {
236
408
  maybeResolveModel(raw);
237
409
  maybeResolveUsage(raw);
410
+ maybeResolveContext(raw);
238
411
  }
239
412
 
240
413
  return evt;