agent-dag 1.33.36 → 1.33.37

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,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>agents-deck</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-m26PmDnF.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-BOQgzj5w.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-Cpi89XJ8.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.33.36",
3
+ "version": "1.33.37",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch parallel subagents fork, call tools, and return on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -81,6 +81,197 @@ async function maybeRotatePersistFile() {
81
81
  }
82
82
  }
83
83
 
84
+ // ─── Incremental transcript scanning ─────────────────────────────────────
85
+ // Model, usage and context enrichment all derive from the same append-only
86
+ // transcript JSONL, and each one used to re-read and re-parse the whole file
87
+ // on every throttled pass. A session's transcript grows to tens of MB, so
88
+ // that cost O(n) per pass, O(n²) over the session, and — because the parse
89
+ // loop is one synchronous block — it stalled SSE broadcasts and /api/event
90
+ // ingest for as long as it ran.
91
+ //
92
+ // Instead we keep one cursor per file plus the running totals derived so
93
+ // far, read only the bytes appended since the last pass, and fold them into
94
+ // that state. The three scanners share it, so the first one to run in a
95
+ // cycle pays for the read and the other two reuse the result. This mirrors
96
+ // the offset tailing the Codex rollout watcher already does further down.
97
+ const transcriptScans = new Map(); // path -> scan state
98
+ const transcriptScanInFlight = new Map(); // path -> in-progress scan promise
99
+ const MAX_TRANSCRIPT_SCANS = 256; // bound the per-path state
100
+
101
+ const MODEL_ID_RE = /^claude[-_]/i;
102
+ const USAGE_BLOCK_RE = /"usage"\s*:\s*\{([^}]+)\}/g;
103
+ // CC `/clear` and `/compact` write a marker into the transcript and reset the
104
+ // context window to ~0 while the JSONL keeps growing — everything before the
105
+ // last marker is stale.
106
+ const CONTEXT_RESET_RE = /<command-name>\s*\/(?:clear|compact)\s*<\/command-name>/g;
107
+ const TYPE_USER_RE = /"type"\s*:\s*"user"/g;
108
+ const TYPE_ASSISTANT_RE = /"type"\s*:\s*"assistant"/g;
109
+ const TYPE_TOOL_USE_RE = /"type"\s*:\s*"tool_use"/g;
110
+ const TYPE_TOOL_RESULT_RE = /"type"\s*:\s*"tool_result"/g;
111
+ const SYSTEM_REMINDER_RE = /<system-reminder>/g;
112
+ const USAGE_FIELD_RE = {
113
+ input_tokens: /"input_tokens"\s*:\s*(\d+)/,
114
+ output_tokens: /"output_tokens"\s*:\s*(\d+)/,
115
+ cache_read_input_tokens: /"cache_read_input_tokens"\s*:\s*(\d+)/,
116
+ cache_creation_input_tokens: /"cache_creation_input_tokens"\s*:\s*(\d+)/,
117
+ };
118
+
119
+ function grabUsageField(blob, key) {
120
+ const m = blob.match(USAGE_FIELD_RE[key]);
121
+ return m ? Number(m[1]) : 0;
122
+ }
123
+
124
+ function newContextBreakdown() {
125
+ return {
126
+ msgsUser: 0,
127
+ msgsAssistant: 0,
128
+ toolUses: 0,
129
+ toolResults: 0,
130
+ systemReminders: 0,
131
+ currentContextTokens: 0,
132
+ };
133
+ }
134
+
135
+ function newTranscriptState() {
136
+ return {
137
+ offset: 0, // bytes already folded in
138
+ touchedAt: 0,
139
+ rootModel: null,
140
+ lastModel: null, // last claude-* model on any line, sidechain included
141
+ subagentModels: {},
142
+ usage: {
143
+ input_tokens: 0, output_tokens: 0,
144
+ cache_read_input_tokens: 0, cache_creation_input_tokens: 0,
145
+ },
146
+ ctx: newContextBreakdown(),
147
+ };
148
+ }
149
+
150
+ async function readByteRange(path, from, to) {
151
+ const fh = await open(path, "r");
152
+ try {
153
+ const len = to - from;
154
+ if (len <= 0) return "";
155
+ const buf = Buffer.alloc(len);
156
+ await fh.read(buf, 0, len, from);
157
+ return buf.toString("utf8");
158
+ } finally {
159
+ await fh.close();
160
+ }
161
+ }
162
+
163
+ /** Fold one transcript line into the running state. Every fact the three
164
+ * scanners need lives on a single line, so line-at-a-time folding sees
165
+ * exactly what a whole-file pass would. */
166
+ function foldTranscriptLine(state, line) {
167
+ if (!line) return;
168
+
169
+ // Model. Only a line that mentions a model can change it, and parsing the
170
+ // rest is what made the full rescan expensive.
171
+ if (line.includes('"model"')) {
172
+ let obj = null;
173
+ try { obj = JSON.parse(line); } catch {}
174
+ const msg = obj && obj.message;
175
+ const model = (msg && typeof msg.model === "string" && MODEL_ID_RE.test(msg.model)) ? msg.model
176
+ : (obj && typeof obj.model === "string" && MODEL_ID_RE.test(obj.model)) ? obj.model
177
+ : null;
178
+ if (model) {
179
+ state.lastModel = model;
180
+ const isSide = obj.isSidechain === true || obj.is_sidechain === true;
181
+ const ptid = obj.parentToolUseID || obj.parent_tool_use_id || obj.parentToolUseId || null;
182
+ if (isSide && ptid) state.subagentModels[ptid] = model;
183
+ else if (!isSide) state.rootModel = model;
184
+ }
185
+ }
186
+
187
+ // Usage totals sum every block in the file, resets included.
188
+ for (const m of line.matchAll(USAGE_BLOCK_RE)) {
189
+ const blob = m[1];
190
+ state.usage.input_tokens += grabUsageField(blob, "input_tokens");
191
+ state.usage.output_tokens += grabUsageField(blob, "output_tokens");
192
+ state.usage.cache_read_input_tokens += grabUsageField(blob, "cache_read_input_tokens");
193
+ state.usage.cache_creation_input_tokens += grabUsageField(blob, "cache_creation_input_tokens");
194
+ }
195
+
196
+ // Context counts only what follows the most recent /clear or /compact.
197
+ let ctxText = line;
198
+ let resetEnd = -1;
199
+ for (const m of line.matchAll(CONTEXT_RESET_RE)) resetEnd = (m.index ?? -1) + m[0].length;
200
+ if (resetEnd >= 0) {
201
+ state.ctx = newContextBreakdown();
202
+ ctxText = line.slice(resetEnd);
203
+ }
204
+ const ctx = state.ctx;
205
+ ctx.msgsUser += (ctxText.match(TYPE_USER_RE) ?? []).length;
206
+ ctx.msgsAssistant += (ctxText.match(TYPE_ASSISTANT_RE) ?? []).length;
207
+ ctx.toolUses += (ctxText.match(TYPE_TOOL_USE_RE) ?? []).length;
208
+ ctx.toolResults += (ctxText.match(TYPE_TOOL_RESULT_RE) ?? []).length;
209
+ ctx.systemReminders += (ctxText.match(SYSTEM_REMINDER_RE) ?? []).length;
210
+ // Current context size = the LAST usage block after the reset. Stays 0
211
+ // right after a /clear, which is what CC's own /context reports.
212
+ let lastBlob = null;
213
+ for (const m of ctxText.matchAll(USAGE_BLOCK_RE)) lastBlob = m[1];
214
+ if (lastBlob) {
215
+ ctx.currentContextTokens =
216
+ grabUsageField(lastBlob, "input_tokens") +
217
+ grabUsageField(lastBlob, "cache_read_input_tokens") +
218
+ grabUsageField(lastBlob, "cache_creation_input_tokens");
219
+ }
220
+ }
221
+
222
+ function pruneTranscriptScans() {
223
+ if (transcriptScans.size <= MAX_TRANSCRIPT_SCANS) return;
224
+ let oldestPath = null;
225
+ let oldestAt = Infinity;
226
+ for (const [p, st] of transcriptScans) {
227
+ if (st.touchedAt < oldestAt) { oldestAt = st.touchedAt; oldestPath = p; }
228
+ }
229
+ if (oldestPath !== null) transcriptScans.delete(oldestPath);
230
+ }
231
+
232
+ /** Bring a transcript's scan state up to date and return it. Concurrent
233
+ * callers share one read — folding the same appended bytes twice would
234
+ * double the usage totals. Never throws; an unreadable file just leaves
235
+ * the state where it was. */
236
+ function scanTranscript(path) {
237
+ if (!path || typeof path !== "string") return Promise.resolve(null);
238
+ const inFlight = transcriptScanInFlight.get(path);
239
+ if (inFlight) return inFlight;
240
+ const run = (async () => {
241
+ let state = transcriptScans.get(path);
242
+ if (!state) {
243
+ state = newTranscriptState();
244
+ transcriptScans.set(path, state);
245
+ pruneTranscriptScans();
246
+ }
247
+ state.touchedAt = Date.now();
248
+ try {
249
+ const s = await stat(path);
250
+ // Shorter than the cursor means the file was truncated, rotated or
251
+ // replaced — the offset now points at unrelated bytes, so start over.
252
+ if (s.size < state.offset) {
253
+ state = newTranscriptState();
254
+ state.touchedAt = Date.now();
255
+ transcriptScans.set(path, state);
256
+ }
257
+ if (s.size <= state.offset) return state;
258
+ const text = await readByteRange(path, state.offset, s.size);
259
+ const lastNl = text.lastIndexOf("\n");
260
+ if (lastNl < 0) return state; // no complete line appended yet
261
+ const consumed = text.slice(0, lastNl);
262
+ // Advance before folding: a fold that throws half-way must not leave
263
+ // the cursor where the next pass would count those lines again.
264
+ state.offset += Buffer.byteLength(consumed, "utf8") + 1; // +1 for the \n
265
+ for (const line of consumed.split("\n")) foldTranscriptLine(state, line);
266
+ } catch { /* keep whatever we already folded */ }
267
+ return state;
268
+ })();
269
+ transcriptScanInFlight.set(path, run);
270
+ return run.finally(() => {
271
+ if (transcriptScanInFlight.get(path) === run) transcriptScanInFlight.delete(path);
272
+ });
273
+ }
274
+
84
275
  // ─── Model enrichment ────────────────────────────────────────────────────
85
276
  // CC's hook payloads never carry the `model` field — but every hook
86
277
  // references a `transcript_path` JSONL that contains lines like
@@ -108,46 +299,13 @@ export function cachedModelId(cached) {
108
299
  * inline with `isSidechain:true` + `parentToolUseID`). Current CC versions
109
300
  * store subagents in `<sessionDir>/subagents/agent-<id>.jsonl` — those are
110
301
  * handled by `readSubagentModelsFromDir` below. */
111
- async function readModelFromTranscript(path) {
112
- try {
113
- const s = await stat(path);
114
- if (s.size === 0) return null;
115
- const fh = await open(path, "r");
116
- let text;
117
- try {
118
- const buf = Buffer.alloc(s.size);
119
- await fh.read(buf, 0, s.size, 0);
120
- text = buf.toString("utf8");
121
- } finally {
122
- await fh.close();
123
- }
124
- let rootModel = null;
125
- const subagentModels = {};
126
- let anyModelSeen = null;
127
- for (const line of text.split("\n")) {
128
- if (!line) continue;
129
- let obj;
130
- try { obj = JSON.parse(line); } catch { continue; }
131
- const msg = obj && obj.message;
132
- const model = (msg && typeof msg.model === "string" && /^claude[-_]/i.test(msg.model)) ? msg.model
133
- : (typeof obj.model === "string" && /^claude[-_]/i.test(obj.model)) ? obj.model
134
- : null;
135
- if (!model) continue;
136
- anyModelSeen = model;
137
- const isSide = obj.isSidechain === true || obj.is_sidechain === true;
138
- const ptid = obj.parentToolUseID || obj.parent_tool_use_id || obj.parentToolUseId || null;
139
- if (isSide && ptid) {
140
- subagentModels[ptid] = model;
141
- } else if (!isSide) {
142
- rootModel = model;
143
- }
144
- }
145
- if (!rootModel) rootModel = anyModelSeen;
146
- if (!rootModel && Object.keys(subagentModels).length === 0) return null;
147
- return { rootModel, subagentModels };
148
- } catch {
149
- return null;
150
- }
302
+ export async function readModelFromTranscript(path) {
303
+ const state = await scanTranscript(path);
304
+ if (!state) return null;
305
+ const rootModel = state.rootModel ?? state.lastModel;
306
+ const subagentModels = { ...state.subagentModels };
307
+ if (!rootModel && Object.keys(subagentModels).length === 0) return null;
308
+ return { rootModel, subagentModels };
151
309
  }
152
310
 
153
311
  /** Newer CC schema (~2026-06): each subagent turn writes its OWN file at
@@ -173,30 +331,11 @@ async function readSubagentModelsFromDir(transcriptPath) {
173
331
  const agentId = f.replace(/^agent-/, "").replace(/\.jsonl$/i, "");
174
332
  const full = join(subDir, f);
175
333
  try {
176
- const s = await stat(full);
177
- if (s.size === 0) continue;
178
- const fh = await open(full, "r");
179
- let text;
180
- try {
181
- const buf = Buffer.alloc(s.size);
182
- await fh.read(buf, 0, s.size, 0);
183
- text = buf.toString("utf8");
184
- } finally {
185
- await fh.close();
186
- }
187
- // Last-seen claude-* model wins — subagents may switch model mid-turn
188
- // (Sonnet → Haiku for tool-call fallback etc.).
189
- let last = null;
190
- for (const line of text.split("\n")) {
191
- if (!line) continue;
192
- let obj;
193
- try { obj = JSON.parse(line); } catch { continue; }
194
- const msg = obj && obj.message;
195
- const m = (msg && typeof msg.model === "string" && /^claude[-_]/i.test(msg.model)) ? msg.model
196
- : (typeof obj.model === "string" && /^claude[-_]/i.test(obj.model)) ? obj.model
197
- : null;
198
- if (m) last = m;
199
- }
334
+ // Same incremental cursor as the main transcript. Last-seen claude-*
335
+ // model wins — subagents may switch model mid-turn (Sonnet → Haiku for
336
+ // tool-call fallback etc.).
337
+ const state = await scanTranscript(full);
338
+ const last = state ? state.lastModel : null;
200
339
  if (last) models[agentId] = last;
201
340
  } catch { /* skip unreadable file */ }
202
341
  }
@@ -250,47 +389,16 @@ const lastUsageReadAt = new Map(); // sid -> ms timestamp
250
389
  const pendingUsageReads = new Set(); // sid currently being read
251
390
  const USAGE_READ_THROTTLE_MS = 2500;
252
391
 
253
- async function readUsageFromTranscript(path) {
254
- try {
255
- const s = await stat(path);
256
- if (s.size === 0) return null;
257
- // Transcripts can grow large (thinking blocks, tool inputs) — read the
258
- // whole file. Each entry has its own usage object and we sum every
259
- // occurrence, so missing earlier bytes would undercount. Files are
260
- // usually < 1MB; tens-of-MB sessions cost a few ms to scan.
261
- const fh = await open(path, "r");
262
- let buf;
263
- try {
264
- buf = Buffer.alloc(s.size);
265
- await fh.read(buf, 0, s.size, 0);
266
- } finally {
267
- await fh.close();
268
- }
269
- const text = buf.toString("utf8");
270
- const totals = {
271
- input_tokens: 0, output_tokens: 0,
272
- cache_read_input_tokens: 0, cache_creation_input_tokens: 0,
273
- };
274
- // Match each `"usage":{...}` block and sum the four numeric fields.
275
- // Regex is good enough — these blocks are flat single-level JSON.
276
- const re = /"usage"\s*:\s*\{([^}]+)\}/g;
277
- const grab = (blob, key) => {
278
- const km = blob.match(new RegExp(`"${key}"\\s*:\\s*(\\d+)`));
279
- return km ? Number(km[1]) : 0;
280
- };
281
- for (const m of text.matchAll(re)) {
282
- const blob = m[1];
283
- totals.input_tokens += grab(blob, "input_tokens");
284
- totals.output_tokens += grab(blob, "output_tokens");
285
- totals.cache_read_input_tokens += grab(blob, "cache_read_input_tokens");
286
- totals.cache_creation_input_tokens += grab(blob, "cache_creation_input_tokens");
287
- }
288
- if (totals.input_tokens === 0 && totals.output_tokens === 0
289
- && totals.cache_read_input_tokens === 0 && totals.cache_creation_input_tokens === 0) return null;
290
- return totals;
291
- } catch {
292
- return null;
293
- }
392
+ // Every entry carries its own usage object and we sum every occurrence, so
393
+ // the totals are cumulative over the whole transcript — the running state
394
+ // keeps them across passes and each pass only adds the newly appended blocks.
395
+ export async function readUsageFromTranscript(path) {
396
+ const state = await scanTranscript(path);
397
+ if (!state) return null;
398
+ const totals = { ...state.usage };
399
+ if (totals.input_tokens === 0 && totals.output_tokens === 0
400
+ && totals.cache_read_input_tokens === 0 && totals.cache_creation_input_tokens === 0) return null;
401
+ return totals;
294
402
  }
295
403
 
296
404
  function maybeResolveUsage(payload) {
@@ -323,61 +431,16 @@ const lastContextReadAt = new Map();
323
431
  const pendingContextReads = new Set();
324
432
  const CONTEXT_READ_THROTTLE_MS = 4000;
325
433
 
326
- async function readContextFromTranscript(path) {
327
- try {
328
- const s = await stat(path);
329
- if (s.size === 0) return null;
330
- const fh = await open(path, "r");
331
- let buf;
332
- try { buf = Buffer.alloc(s.size); await fh.read(buf, 0, s.size, 0); }
333
- finally { await fh.close(); }
334
- const fullText = buf.toString("utf8");
335
- // CC `/clear` writes a user message `<command-name>/clear</command-name>`
336
- // into the same transcript file and resets its in-memory context window
337
- // to ~0, but the JSONL keeps growing — every usage block before the
338
- // clear marker is stale (pre-reset) and reading the LAST one made the
339
- // donut report ~100% even though CC's actual context was empty. Same
340
- // applies to `/compact`: it writes a summary and starts a fresh context.
341
- // Slice the transcript to the segment AFTER the most recent reset so
342
- // counts/usage reflect what CC is actually carrying forward.
343
- const resetRe = /<command-name>\s*\/(?:clear|compact)\s*<\/command-name>/g;
344
- let lastResetIdx = -1;
345
- for (const m of fullText.matchAll(resetRe)) {
346
- lastResetIdx = (m.index ?? -1) + m[0].length;
347
- }
348
- const text = lastResetIdx >= 0 ? fullText.slice(lastResetIdx) : fullText;
349
- const breakdown = {
350
- msgsUser: 0,
351
- msgsAssistant: 0,
352
- toolUses: 0,
353
- toolResults: 0,
354
- systemReminders: 0,
355
- currentContextTokens: 0,
356
- };
357
- breakdown.msgsUser = (text.match(/"type"\s*:\s*"user"/g) ?? []).length;
358
- breakdown.msgsAssistant = (text.match(/"type"\s*:\s*"assistant"/g) ?? []).length;
359
- breakdown.toolUses = (text.match(/"type"\s*:\s*"tool_use"/g) ?? []).length;
360
- breakdown.toolResults = (text.match(/"type"\s*:\s*"tool_result"/g) ?? []).length;
361
- breakdown.systemReminders = (text.match(/<system-reminder>/g) ?? []).length;
362
- // Current context size = input + cache_read + cache_create on the LAST
363
- // usage block in the post-reset slice. If the user just ran /clear and
364
- // hasn't sent a new prompt yet, this stays 0 (no usage blocks yet) —
365
- // matches what CC's own `/context` would report.
366
- const re = /"usage"\s*:\s*\{([^}]+)\}/g;
367
- const grab = (blob, key) => {
368
- const km = blob.match(new RegExp(`"${key}"\\s*:\\s*(\\d+)`));
369
- return km ? Number(km[1]) : 0;
370
- };
371
- let lastBlob = null;
372
- for (const m of text.matchAll(re)) lastBlob = m[1];
373
- if (lastBlob) {
374
- breakdown.currentContextTokens =
375
- grab(lastBlob, "input_tokens") +
376
- grab(lastBlob, "cache_read_input_tokens") +
377
- grab(lastBlob, "cache_creation_input_tokens");
378
- }
379
- return breakdown;
380
- } catch { return null; }
434
+ // The counts reset at every `/clear` or `/compact` marker (see
435
+ // foldTranscriptLine): CC resets its in-memory window there while the JSONL
436
+ // keeps growing, and reading the pre-reset blocks made the donut report ~100%
437
+ // on an empty context.
438
+ export async function readContextFromTranscript(path) {
439
+ const state = await scanTranscript(path);
440
+ // Nothing folded yet — the file is empty, unreadable, or has no complete
441
+ // line. Callers treat that as "no breakdown", same as before.
442
+ if (!state || state.offset === 0) return null;
443
+ return { ...state.ctx };
381
444
  }
382
445
 
383
446
  /** Encode an absolute path the way CC stores it under
@@ -671,19 +734,6 @@ async function listRecentCodexRollouts() {
671
734
  return out;
672
735
  }
673
736
 
674
- async function readByteRange(path, from, to) {
675
- const fh = await open(path, "r");
676
- try {
677
- const len = to - from;
678
- if (len <= 0) return "";
679
- const buf = Buffer.alloc(len);
680
- await fh.read(buf, 0, len, from);
681
- return buf.toString("utf8");
682
- } finally {
683
- await fh.close();
684
- }
685
- }
686
-
687
737
  // Read the first complete JSON line of a rollout (the session_meta header)
688
738
  // to learn sid + cwd before we start streaming. The header line can be large
689
739
  // (base_instructions text runs tens of KB), so we read in growing chunks until