agent-dag 1.35.43 → 1.36.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.
@@ -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-OvdPf_n3.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.0",
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,143 @@ 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 the Windows performance counter.
291
+ *
292
+ * `Get-Process` is the obvious call and the wrong one: its `CPU` property is
293
+ * total processor SECONDS consumed since the process started, so sorting by it
294
+ * ranks whatever has been running longest rather than whatever is busy now — a
295
+ * different question, and not the one the panel asks. The formatted performance
296
+ * counter publishes `PercentProcessorTime` directly, already a rate, so one
297
+ * query gives the same thing `ps -r` gives on Unix without a second sample.
298
+ *
299
+ * `_Total` and `Idle` are pseudo-processes in that class and are dropped.
300
+ */
301
+ export function parseWindowsProcesses(json, totalMem, limit = TOP_N) {
302
+ let rows;
303
+ try { rows = typeof json === "string" ? JSON.parse(json) : json; }
304
+ catch { return []; }
305
+ if (!rows) return [];
306
+ if (!Array.isArray(rows)) rows = [rows];
307
+ const cores = os.cpus().length || 1;
308
+ return rows
309
+ .filter(r => r && r.Name && r.Name !== "_Total" && r.Name !== "Idle")
310
+ .map(r => ({
311
+ pid: Number(r.IDProcess) || 0,
312
+ // The counter is per-core-summed, exactly like macOS's 0-to-N00 scale, so
313
+ // it is normalised here to the 0-100 the Unix branch already reports.
314
+ cpu: Math.round((Number(r.PercentProcessorTime) || 0) / cores * 10) / 10,
315
+ mem: totalMem > 0
316
+ ? Math.round((Number(r.WorkingSetPrivate) || 0) / totalMem * 1000) / 10
317
+ : 0,
318
+ name: String(r.Name),
319
+ }))
320
+ .sort((a, b) => b.cpu - a.cpu)
321
+ .slice(0, limit);
322
+ }
323
+
324
+ /** The process list, on demand only — never on the ambient timer. */
325
+ export async function readProcesses(platform = process.platform) {
326
+ if (platform === "win32") {
327
+ const out = await run("powershell.exe", [
328
+ "-NoProfile", "-NonInteractive", "-Command",
329
+ "Get-CimInstance Win32_PerfFormattedData_PerfProc_Process | Select-Object Name,IDProcess,PercentProcessorTime,WorkingSetPrivate | ConvertTo-Json -Compress",
330
+ ], 6_000);
331
+ return out ? parseWindowsProcesses(out.trim(), os.totalmem()) : [];
332
+ }
333
+ const out = await run("ps", ["-Aceo", "pid,pcpu,pmem,comm", "-r"], 4_000);
334
+ return out ? parsePsProcesses(out) : [];
335
+ }
336
+
163
337
  async function sampleMemory() {
164
338
  if (memInFlight) return;
165
339
  memInFlight = true;
@@ -171,13 +345,18 @@ async function sampleMemory() {
171
345
  available,
172
346
  usedPct: Math.max(0, Math.min(100, Math.round(((total - available) / total) * 1000) / 10)),
173
347
  };
348
+ // Same 30s cadence as memory, and for the same reason: it moves in minutes
349
+ // and costs a subprocess on two of the three platforms.
350
+ swap = await readSwap();
174
351
  } catch { /* keep the previous reading rather than blanking the meter */ }
175
352
  finally { memInFlight = false; }
176
353
  }
177
354
 
178
355
  function sampleCpu() {
356
+ const per = corePercents();
179
357
  const pct = cpuPercent();
180
358
  if (pct == null) return;
359
+ cores = per;
181
360
  cpuHistory.push(pct);
182
361
  while (cpuHistory.length > HISTORY) cpuHistory.shift();
183
362
  }
@@ -189,6 +368,7 @@ function sampleCpu() {
189
368
  export function startSystemMetrics() {
190
369
  if (cpuTimer) return;
191
370
  prevTicks = readTicks(); // baseline, so the first tick has a delta
371
+ prevCoreTicks = readCoreTicks();
192
372
  sampleMemory();
193
373
  cpuTimer = setInterval(sampleCpu, CPU_INTERVAL_MS);
194
374
  memTimer = setInterval(sampleMemory, MEM_INTERVAL_MS);
@@ -201,8 +381,11 @@ export function stopSystemMetrics() {
201
381
  if (memTimer) clearInterval(memTimer);
202
382
  cpuTimer = memTimer = null;
203
383
  prevTicks = null;
384
+ prevCoreTicks = null;
204
385
  cpuHistory.length = 0;
205
386
  memory = null;
387
+ cores = null;
388
+ swap = null;
206
389
  }
207
390
 
208
391
  /**
@@ -223,6 +406,10 @@ export function systemSnapshot() {
223
406
  cpuHistory: [...cpuHistory],
224
407
  cores: os.cpus().length,
225
408
  memory,
409
+ swap,
410
+ perCore: cores,
411
+ uptimeSec: Math.round(os.uptime()),
412
+ platform: process.platform,
226
413
  loadavg: hasLoad ? load.map(n => Math.round(n * 100) / 100) : null,
227
414
  intervalMs: CPU_INTERVAL_MS,
228
415
  sampledAt: Date.now(),