agent-dag 1.35.43 → 1.36.1

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.
@@ -40,8 +40,8 @@
40
40
  document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
41
41
  })();
42
42
  </script>
43
- <script type="module" crossorigin src="/assets/index-CbEDZOIk.js"></script>
44
- <link rel="stylesheet" crossorigin href="/assets/index-rOAqrum-.css">
43
+ <script type="module" crossorigin src="/assets/index-DWHO_6dI.js"></script>
44
+ <link rel="stylesheet" crossorigin href="/assets/index-CaAP5Ufx.css">
45
45
  </head>
46
46
  <body>
47
47
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.35.43",
3
+ "version": "1.36.1",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -14,7 +14,7 @@ import { CODEX_HOME, CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-d
14
14
  import { PRODUCT } from "./brand.mjs";
15
15
  import { invokedName, renameNotice } from "./invoked-as.mjs";
16
16
  import { appendLogLine, codexCwdInWorkspace, writesCodexLog } from "./log-writer.mjs";
17
- import { startSystemMetrics, systemSnapshot } from "./system-metrics.mjs";
17
+ import { readProcesses, startSystemMetrics, systemSnapshot } from "./system-metrics.mjs";
18
18
 
19
19
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
20
  const PKG_ROOT = resolve(__dirname, "..", "..");
@@ -3054,6 +3054,11 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
3054
3054
  // Machine state, not session state: sampled on the server's own timer and
3055
3055
  // deliberately kept out of the event stream. See src/server/system-metrics.mjs.
3056
3056
  if (req.method === "GET" && url.pathname === "/api/system") return send(res, 200, systemSnapshot());
3057
+ // On demand only — the process list costs a subprocess on every platform,
3058
+ // so it is fetched while the detail panel is open and never on the timer.
3059
+ if (req.method === "GET" && url.pathname === "/api/system/processes") {
3060
+ return guard(readProcesses().then(procs => send(res, 200, { ok: true, procs })), res);
3061
+ }
3057
3062
  if (req.method === "GET" && url.pathname === "/api/codex-quota") return guard(handleCodexQuota(req, res), res);
3058
3063
  if (req.method === "GET" && url.pathname === "/api/ccusage") return guard(handleCcusage(req, res), res);
3059
3064
  if (req.method === "GET" && url.pathname === "/api/claude-accounts") return guard(handleClaudeAccounts(req, res), res);
@@ -33,6 +33,9 @@ const HISTORY = 20;
33
33
  let cpuTimer = null;
34
34
  let memTimer = null;
35
35
  let prevTicks = null;
36
+ let prevCoreTicks = null;
37
+ let cores = null;
38
+ let swap = null;
36
39
  /** Newest last. Seeded empty; the first tick produces no percentage because a
37
40
  * delta needs two readings. */
38
41
  const cpuHistory = [];
@@ -52,6 +55,40 @@ function readTicks() {
52
55
  return { idle, total };
53
56
  }
54
57
 
58
+ /** The same pair, per core, in `os.cpus()` order. */
59
+ function readCoreTicks() {
60
+ return os.cpus().map(cpu => {
61
+ let idle = 0;
62
+ let total = 0;
63
+ for (const [kind, ms] of Object.entries(cpu.times)) {
64
+ total += ms;
65
+ if (kind === "idle") idle += ms;
66
+ }
67
+ return { idle, total };
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Busy percentage per core since the previous reading.
73
+ *
74
+ * The aggregate figure the topbar draws hides the shape of the load, and the
75
+ * shape is what tells a saturated machine from a machine running one hot
76
+ * single-threaded job. Same delta arithmetic as `cpuPercent`, one row per core,
77
+ * and the same refusal to invent a number before there are two readings.
78
+ */
79
+ function corePercents() {
80
+ const now = readCoreTicks();
81
+ const prev = prevCoreTicks;
82
+ prevCoreTicks = now;
83
+ if (!prev || prev.length !== now.length) return null;
84
+ return now.map((c, i) => {
85
+ const dTotal = c.total - prev[i].total;
86
+ const dIdle = c.idle - prev[i].idle;
87
+ if (dTotal <= 0) return 0;
88
+ return Math.max(0, Math.min(100, Math.round((1 - dIdle / dTotal) * 1000) / 10));
89
+ });
90
+ }
91
+
55
92
  /**
56
93
  * Busy percentage across all cores since the previous reading, 0-100.
57
94
  *
@@ -160,6 +197,192 @@ async function readAvailable(platform = process.platform) {
160
197
  return os.freemem();
161
198
  }
162
199
 
200
+ /**
201
+ * Swap out of macOS `sysctl -n vm.swapusage`, which prints
202
+ * `total = 14336.00M used = 12876.00M free = 1460.00M (encrypted)`.
203
+ *
204
+ * Swap is the reading a percentage cannot give you. A machine at "64% memory
205
+ * used" that is quietly paging 12 GB to disk is not the same machine as one at
206
+ * 64% with an empty swap file, and the difference is the one you can feel.
207
+ */
208
+ export function swapFromSysctl(text) {
209
+ const unit = s => {
210
+ const m = /^([\d.]+)([KMG])?$/i.exec(s);
211
+ if (!m) return null;
212
+ const mult = { k: 1024, m: 1024 ** 2, g: 1024 ** 3 }[(m[2] || "M").toLowerCase()] ?? 1;
213
+ return Math.round(Number(m[1]) * mult);
214
+ };
215
+ const total = unit(/total\s*=\s*(\S+)/i.exec(String(text ?? ""))?.[1] ?? "");
216
+ const used = unit(/used\s*=\s*(\S+)/i.exec(String(text ?? ""))?.[1] ?? "");
217
+ if (total == null || used == null) return null;
218
+ return { total, used };
219
+ }
220
+
221
+ /** Swap out of `/proc/meminfo`, where it is two fields rather than one line. */
222
+ export function swapFromMeminfo(text) {
223
+ const s = String(text ?? "");
224
+ const total = /^SwapTotal:\s+(\d+)\s*kB/m.exec(s);
225
+ const free = /^SwapFree:\s+(\d+)\s*kB/m.exec(s);
226
+ if (!total || !free) return null;
227
+ const t = Number(total[1]) * 1024;
228
+ return { total: t, used: Math.max(0, t - Number(free[1]) * 1024) };
229
+ }
230
+
231
+ /**
232
+ * Windows has no swap file in the Unix sense; the comparable pressure signal is
233
+ * commit charge, which `Win32_OperatingSystem` reports as total and free
234
+ * virtual memory in KB. Labelled "commit" in the UI rather than "swap", because
235
+ * calling it swap would be borrowing a word for a different mechanism.
236
+ */
237
+ export function swapFromWmicJson(json) {
238
+ try {
239
+ const o = typeof json === "string" ? JSON.parse(json) : json;
240
+ const total = Number(o?.TotalVirtualMemorySize) * 1024;
241
+ const free = Number(o?.FreeVirtualMemory) * 1024;
242
+ if (!Number.isFinite(total) || !Number.isFinite(free) || total <= 0) return null;
243
+ return { total, used: Math.max(0, total - free) };
244
+ } catch { return null; }
245
+ }
246
+
247
+ async function readSwap(platform = process.platform) {
248
+ if (platform === "darwin") {
249
+ const out = await run("sysctl", ["-n", "vm.swapusage"]);
250
+ return out ? swapFromSysctl(out) : null;
251
+ }
252
+ if (platform === "linux") {
253
+ try { return swapFromMeminfo(await readFile("/proc/meminfo", "utf8")); }
254
+ catch { return null; }
255
+ }
256
+ if (platform === "win32") {
257
+ const out = await run("powershell.exe", [
258
+ "-NoProfile", "-NonInteractive", "-Command",
259
+ "Get-CimInstance Win32_OperatingSystem | Select-Object TotalVirtualMemorySize,FreeVirtualMemory | ConvertTo-Json -Compress",
260
+ ], 4_000);
261
+ return out ? swapFromWmicJson(out.trim()) : null;
262
+ }
263
+ return null;
264
+ }
265
+
266
+ /** How many rows the process list keeps. Enough to see what is eating the
267
+ * machine, few enough that the panel never becomes a scroll. */
268
+ const TOP_N = 8;
269
+
270
+ /**
271
+ * Rows out of `ps -Aceo pid,pcpu,pmem,comm -r`.
272
+ *
273
+ * `-c` gives the executable name without its full path and without the argv
274
+ * that would leak a prompt or a token into the UI; `-r` sorts by current CPU,
275
+ * which is the ordering that answers "what is eating this machine right now".
276
+ */
277
+ export function parsePsProcesses(text, limit = TOP_N) {
278
+ const lines = String(text ?? "").trim().split("\n");
279
+ const out = [];
280
+ for (const line of lines.slice(1)) { // drop the header row
281
+ const m = /^\s*(\d+)\s+([\d.]+)\s+([\d.]+)\s+(.+?)\s*$/.exec(line);
282
+ if (!m) continue;
283
+ out.push({ pid: Number(m[1]), cpu: Number(m[2]), mem: Number(m[3]), name: m[4] });
284
+ if (out.length >= limit) break;
285
+ }
286
+ return out;
287
+ }
288
+
289
+ /**
290
+ * Rows out of `Get-Process`.
291
+ *
292
+ * NOT `Win32_PerfFormattedData_PerfProc_Process`, which is what this used and
293
+ * which is not a class you may assume exists. It is published by perflib, and
294
+ * perflib is deregistered often enough to matter — a corporate image, a bad
295
+ * in-place upgrade, a half-run `lodctr`. On a machine reported from the field
296
+ * the class was simply absent (`Get-CimInstance: Invalid class`), and `typeperf`
297
+ * failed identically, which places the fault below WMI rather than in it. WMI
298
+ * was only mirroring what perflib had stopped publishing.
299
+ *
300
+ * `Get-Process` reads through NtQuerySystemInformation instead, so it depends on
301
+ * nothing that can be unregistered. The cost is that its `CPU` is total
302
+ * processor SECONDS since the process started, not a rate — so a percentage has
303
+ * to be derived from two readings, exactly the way the machine-wide figure is
304
+ * already derived from two tick samples. Reliability is worth one extra poll:
305
+ * an instant number from a class that may not exist is worth nothing at all.
306
+ */
307
+ export function parseGetProcessJson(json, totalMem) {
308
+ let rows;
309
+ try { rows = typeof json === "string" ? JSON.parse(json) : json; }
310
+ catch { return []; }
311
+ if (!rows) return [];
312
+ if (!Array.isArray(rows)) rows = [rows];
313
+ return rows
314
+ .filter(r => r && r.ProcessName)
315
+ .map(r => ({
316
+ pid: Number(r.Id) || 0,
317
+ name: String(r.ProcessName),
318
+ // Null rather than 0 when the process denies the read: a system process
319
+ // we cannot query has an unknown CPU time, and calling that zero would
320
+ // rank it as idle.
321
+ cpuSec: typeof r.CPU === "number" ? r.CPU : null,
322
+ mem: totalMem > 0
323
+ ? Math.round((Number(r.WorkingSetPrivate) || 0) / totalMem * 1000) / 10
324
+ : 0,
325
+ }));
326
+ }
327
+
328
+ /**
329
+ * Turn two `Get-Process` readings into a percentage per process.
330
+ *
331
+ * `prev` maps pid to the cpuSec of the previous reading. A pid absent from it —
332
+ * a process that started since — has no delta and reports null rather than a
333
+ * number invented from its whole lifetime, which would rank a freshly spawned
334
+ * compiler as though it had been burning a core since boot.
335
+ *
336
+ * Normalised by core count so a Windows row means the same thing as the Unix
337
+ * one: 100 is one machine, not one core.
338
+ */
339
+ export function cpuFromDeltas(rows, prev, elapsedMs, cores, limit = TOP_N) {
340
+ const secs = elapsedMs / 1000;
341
+ const out = rows.map(r => {
342
+ const before = prev instanceof Map ? prev.get(r.pid) : undefined;
343
+ let cpu = null;
344
+ if (r.cpuSec != null && before != null && secs > 0) {
345
+ const d = r.cpuSec - before;
346
+ // A counter that went backwards means the pid was reused by a different
347
+ // process; report nothing rather than a negative or a wild number.
348
+ if (d >= 0) cpu = Math.max(0, Math.min(100, Math.round((d / secs / Math.max(1, cores)) * 1000) / 10));
349
+ }
350
+ return { pid: r.pid, cpu, mem: r.mem, name: r.name };
351
+ });
352
+ // Until the second reading lands there is no CPU to sort on, so the list is
353
+ // ordered by memory — which is a real answer to "what is this machine doing",
354
+ // not a placeholder.
355
+ const haveCpu = out.some(r => r.cpu != null);
356
+ out.sort(haveCpu
357
+ ? (a, b) => (b.cpu ?? -1) - (a.cpu ?? -1)
358
+ : (a, b) => b.mem - a.mem);
359
+ return out.slice(0, limit);
360
+ }
361
+
362
+ /** The process list, on demand only — never on the ambient timer. */
363
+ /** Previous Windows reading, so the next one can be a rate. Cleared with the
364
+ * rest of the sampler state. */
365
+ let prevProcCpu = null;
366
+ let prevProcAt = 0;
367
+
368
+ export async function readProcesses(platform = process.platform) {
369
+ if (platform === "win32") {
370
+ const out = await run("powershell.exe", [
371
+ "-NoProfile", "-NonInteractive", "-Command",
372
+ "Get-Process | Select-Object Id,ProcessName,CPU,@{n='WorkingSetPrivate';e={$_.PrivateMemorySize64}} | ConvertTo-Json -Compress",
373
+ ], 6_000);
374
+ if (!out) return [];
375
+ const rows = parseGetProcessJson(out.trim(), os.totalmem());
376
+ const now = Date.now();
377
+ const result = cpuFromDeltas(rows, prevProcCpu, now - prevProcAt, os.cpus().length);
378
+ prevProcCpu = new Map(rows.filter(r => r.cpuSec != null).map(r => [r.pid, r.cpuSec]));
379
+ prevProcAt = now;
380
+ return result;
381
+ }
382
+ const out = await run("ps", ["-Aceo", "pid,pcpu,pmem,comm", "-r"], 4_000);
383
+ return out ? parsePsProcesses(out) : [];
384
+ }
385
+
163
386
  async function sampleMemory() {
164
387
  if (memInFlight) return;
165
388
  memInFlight = true;
@@ -171,13 +394,18 @@ async function sampleMemory() {
171
394
  available,
172
395
  usedPct: Math.max(0, Math.min(100, Math.round(((total - available) / total) * 1000) / 10)),
173
396
  };
397
+ // Same 30s cadence as memory, and for the same reason: it moves in minutes
398
+ // and costs a subprocess on two of the three platforms.
399
+ swap = await readSwap();
174
400
  } catch { /* keep the previous reading rather than blanking the meter */ }
175
401
  finally { memInFlight = false; }
176
402
  }
177
403
 
178
404
  function sampleCpu() {
405
+ const per = corePercents();
179
406
  const pct = cpuPercent();
180
407
  if (pct == null) return;
408
+ cores = per;
181
409
  cpuHistory.push(pct);
182
410
  while (cpuHistory.length > HISTORY) cpuHistory.shift();
183
411
  }
@@ -189,6 +417,7 @@ function sampleCpu() {
189
417
  export function startSystemMetrics() {
190
418
  if (cpuTimer) return;
191
419
  prevTicks = readTicks(); // baseline, so the first tick has a delta
420
+ prevCoreTicks = readCoreTicks();
192
421
  sampleMemory();
193
422
  cpuTimer = setInterval(sampleCpu, CPU_INTERVAL_MS);
194
423
  memTimer = setInterval(sampleMemory, MEM_INTERVAL_MS);
@@ -201,8 +430,13 @@ export function stopSystemMetrics() {
201
430
  if (memTimer) clearInterval(memTimer);
202
431
  cpuTimer = memTimer = null;
203
432
  prevTicks = null;
433
+ prevCoreTicks = null;
204
434
  cpuHistory.length = 0;
205
435
  memory = null;
436
+ cores = null;
437
+ swap = null;
438
+ prevProcCpu = null;
439
+ prevProcAt = 0;
206
440
  }
207
441
 
208
442
  /**
@@ -223,6 +457,10 @@ export function systemSnapshot() {
223
457
  cpuHistory: [...cpuHistory],
224
458
  cores: os.cpus().length,
225
459
  memory,
460
+ swap,
461
+ perCore: cores,
462
+ uptimeSec: Math.round(os.uptime()),
463
+ platform: process.platform,
226
464
  loadavg: hasLoad ? load.map(n => Math.round(n * 100) / 100) : null,
227
465
  intervalMs: CPU_INTERVAL_MS,
228
466
  sampledAt: Date.now(),