agent-dag 1.34.4 → 1.34.5

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,7 +40,7 @@
40
40
  document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
41
41
  })();
42
42
  </script>
43
- <script type="module" crossorigin src="/assets/index-CCqhdmvG.js"></script>
43
+ <script type="module" crossorigin src="/assets/index-CDgBtXXg.js"></script>
44
44
  <link rel="stylesheet" crossorigin href="/assets/index-CRPobBZf.css">
45
45
  </head>
46
46
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.34.4",
3
+ "version": "1.34.5",
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": {
@@ -1138,17 +1138,89 @@ function queuedBytes(res) {
1138
1138
  return own + sock;
1139
1139
  }
1140
1140
 
1141
+ /** Hang up on a client we have decided not to keep. `delete` on a response
1142
+ * that never made it into the set — the resume path below hangs up on clients
1143
+ * before they are subscribed — is a harmless no-op. */
1144
+ function dropSse(res) {
1145
+ sseClients.delete(res);
1146
+ // Destroying the socket is what makes the request emit 'close', which is
1147
+ // where the ping interval is cleared.
1148
+ try { res.destroy(); } catch {}
1149
+ try { res.socket?.destroy(); } catch {}
1150
+ }
1151
+
1141
1152
  /** Write one SSE frame, hanging up on a client too far behind to keep. */
1142
1153
  function writeSse(res, frame) {
1143
1154
  try {
1144
1155
  res.write(frame);
1145
1156
  if (queuedBytes(res) <= MAX_CLIENT_BUFFER_BYTES) return;
1146
1157
  } catch { /* already dead — drop it below */ }
1147
- sseClients.delete(res);
1148
- // Destroying the socket is what makes the request emit 'close', which is
1149
- // where the ping interval is cleared.
1150
- try { res.destroy(); } catch {}
1151
- try { res.socket?.destroy(); } catch {}
1158
+ dropSse(res);
1159
+ }
1160
+
1161
+ // How long a resuming client is given to accept the bytes already queued for
1162
+ // it before the deck concludes it is not reading at all. Generous on purpose:
1163
+ // what it has to work through is a full MAX_CLIENT_BUFFER_BYTES, the link may
1164
+ // be an `ssh -L` tunnel rather than loopback, and dropping a client that is
1165
+ // merely slow costs it the whole replay. A tab that is genuinely frozen will
1166
+ // not accept a byte in any budget, so the only thing a long one buys it is a
1167
+ // few more seconds of holding its own buffer. The environment override exists
1168
+ // so the tests can pin the drop without sitting through the real budget.
1169
+ const REPLAY_DRAIN_MS = Number(process.env.AGENTS_DECK_REPLAY_DRAIN_MS) > 0
1170
+ ? Number(process.env.AGENTS_DECK_REPLAY_DRAIN_MS)
1171
+ : 30_000;
1172
+
1173
+ /**
1174
+ * Write one frame of the resume stream under the same ceiling the live path
1175
+ * obeys, waiting rather than dropping when the client is at it. Resolves true
1176
+ * while the client is worth keeping, false once it is not.
1177
+ *
1178
+ * Waiting is the whole difference from writeSse, and the replay is why. The
1179
+ * live path writes one frame per event, so a full buffer there means the
1180
+ * client stopped reading and the only answer is to hang up. Here the burst is
1181
+ * ours: the loop below hands the socket the entire ring buffer in one turn of
1182
+ * the event loop, so even a client reading at full speed sees its buffer fill
1183
+ * — nothing has drained it yet, because nothing could. Dropping on that would
1184
+ * hang up on healthy clients, and hang up on them again every time they came
1185
+ * back: EventSource reconnects with the same Last-Event-ID, meets the same
1186
+ * oversized replay, and is dropped again 1.5 seconds later, forever. So we
1187
+ * stop writing until the socket has taken what it already has, and only give
1188
+ * up on a client that takes nothing at all for REPLAY_DRAIN_MS.
1189
+ *
1190
+ * The wait is on write()'s completion callback rather than on a 'drain' event:
1191
+ * 'drain' only follows a write that was answered false, and a frame can push
1192
+ * queuedBytes past the cap while still being answered true, the socket's own
1193
+ * pending bytes being one of the two terms in that sum. The callback fires
1194
+ * once this chunk —
1195
+ * and therefore everything queued ahead of it — has reached the OS, which is
1196
+ * exactly the condition being waited for. It also fires, with an error we do
1197
+ * not need to read, if the response is destroyed underneath us, so this cannot
1198
+ * hang on a client that goes away.
1199
+ */
1200
+ async function writeResume(res, frame) {
1201
+ // Below the cap this is the plain write it has always been. The `await` in
1202
+ // the caller costs a microtask and nothing else: the checkpoint drains
1203
+ // before the loop can accept I/O, so no live event can slip between two
1204
+ // replay frames the way it could across a real wait.
1205
+ if (queuedBytes(res) <= MAX_CLIENT_BUFFER_BYTES) {
1206
+ try { res.write(frame); return true; } catch { return false; }
1207
+ }
1208
+ return new Promise(resolve => {
1209
+ let settled = false;
1210
+ const done = ok => {
1211
+ if (settled) return;
1212
+ settled = true;
1213
+ clearTimeout(timer);
1214
+ resolve(ok);
1215
+ };
1216
+ // Unref'd so a client that stopped reading can never be the reason the
1217
+ // process stays alive, and cleared on every path out so a completed replay
1218
+ // leaves no timer behind.
1219
+ const timer = setTimeout(() => done(false), REPLAY_DRAIN_MS);
1220
+ timer.unref?.();
1221
+ try { res.write(frame, () => done(!res.destroyed)); }
1222
+ catch { done(false); }
1223
+ });
1152
1224
  }
1153
1225
 
1154
1226
  function pushEvent(raw, source, opts = {}) {
@@ -1373,32 +1445,91 @@ function handleSse(req, res) {
1373
1445
  });
1374
1446
  res.write(`retry: 1500\n\n`);
1375
1447
 
1376
- // Resume: replay events after Last-Event-ID. Marked with `replay:true`
1377
- // on the envelope so the client can suppress turn-cleanup side effects
1378
- // (exitAt stamping, autofit churn) until the live stream takes over.
1379
- // Without this the reducer's UserPromptSubmit handler treats replayed
1380
- // events as a real new turn — hiding prior-turn subagents using the
1381
- // event's stale receivedAt, which collides with wall-clock visibility
1382
- // gates and yields the "nodes appear then vanish" symptom on refresh.
1383
- const lastId = Number(req.headers["last-event-id"] ?? 0);
1384
- for (const e of events) {
1385
- if (e.seq <= lastId) continue;
1386
- const tagged = { ...e, replay: true };
1387
- res.write(`id: ${e.seq}\nevent: hook\ndata: ${JSON.stringify(tagged)}\n\n`);
1448
+ // A stale or absent id replays the whole ring, and so does a malformed one:
1449
+ // Number("nonsense") is NaN, every `seq <= NaN` is false, and the catch-up
1450
+ // loop below would rather compare against a number.
1451
+ const asked = Number(req.headers["last-event-id"] ?? 0);
1452
+ const lastId = Number.isFinite(asked) ? asked : 0;
1453
+
1454
+ // The replay waits on the socket now, so it can no longer be part of this
1455
+ // synchronous handler. Nothing is waiting on the result here — the response
1456
+ // is already committed to a 200 and its own failure path is to hang up — so
1457
+ // start it, keep the router's contract of returning nothing, and make sure a
1458
+ // rejection ends the stream rather than the process.
1459
+ resumeSse(req, res, lastId).catch(() => dropSse(res));
1460
+ }
1461
+
1462
+ /**
1463
+ * Drain the ring buffer into a newly connected client, then subscribe it.
1464
+ *
1465
+ * Two things had to change when this stopped being one synchronous burst.
1466
+ * `close` is registered before the first frame, because a tab closed mid-replay
1467
+ * has to stop it. And the replay repeats until it reaches the live tail: an
1468
+ * actual wait lets pushEvent run, and an event that lands after we have walked
1469
+ * past its place but before the client is in `sseClients` would otherwise be in
1470
+ * neither stream — a hole the client cannot even ask for again, its last id
1471
+ * having moved past it.
1472
+ */
1473
+ async function resumeSse(req, res, lastId) {
1474
+ let sentThrough = lastId;
1475
+ let ping = null;
1476
+ let closed = false;
1477
+ req.on("close", () => {
1478
+ closed = true;
1479
+ if (ping) clearInterval(ping);
1480
+ sseClients.delete(res);
1481
+ });
1482
+
1483
+ for (;;) {
1484
+ // A snapshot per pass, because a wait lets pushEvent splice the head of
1485
+ // `events` off, and iterating an array being spliced from the front skips
1486
+ // entries. Events evicted that way are gone for this client, which is the
1487
+ // same bargain every resume against a rotated ring already makes.
1488
+ const batch = events.slice();
1489
+ for (const e of batch) {
1490
+ if (e.seq <= sentThrough) continue;
1491
+ if (closed || res.destroyed) return;
1492
+ // Marked with `replay:true` on the envelope so the client can suppress
1493
+ // turn-cleanup side effects (exitAt stamping, autofit churn) until the
1494
+ // live stream takes over. Without this the reducer's UserPromptSubmit
1495
+ // handler treats replayed events as a real new turn — hiding prior-turn
1496
+ // subagents using the event's stale receivedAt, which collides with
1497
+ // wall-clock visibility gates and yields the "nodes appear then vanish"
1498
+ // symptom on refresh.
1499
+ const tagged = { ...e, replay: true };
1500
+ if (!await writeResume(res, `id: ${e.seq}\nevent: hook\ndata: ${JSON.stringify(tagged)}\n\n`)) {
1501
+ return dropSse(res);
1502
+ }
1503
+ sentThrough = e.seq;
1504
+ }
1505
+ // Caught up with the tail as it stands right now. Reached in one pass
1506
+ // unless a wait let new events in, and it terminates for the same reason
1507
+ // the client is still here at all: either it is taking bytes, in which case
1508
+ // loopback outruns any hook, or it is not, in which case writeResume gives
1509
+ // up on it.
1510
+ if (events.length === 0 || events[events.length - 1].seq <= sentThrough) break;
1388
1511
  }
1389
- // Sentinel: tells client "ring buffer drained, live stream starts now".
1390
- res.write(`event: replay-end\ndata: {}\n\n`);
1391
1512
 
1513
+ if (closed || res.destroyed) return;
1514
+
1515
+ // Sentinel: tells client "ring buffer drained, live stream starts now". It
1516
+ // goes out under the same rule as the frames before it, so a client that has
1517
+ // just been handed a large replay is not hung up on over the last 30 bytes of
1518
+ // it before it has had the chance to read any.
1519
+ //
1520
+ // Subscribing before waiting on that write, rather than after, is what closes
1521
+ // the last hole: writeResume puts the bytes on the socket before it returns,
1522
+ // so the sentinel still precedes every live frame, and an event pushed while
1523
+ // we wait reaches this client through the live fan-out instead of falling
1524
+ // into the gap between the two. If the wait then ends in a drop, dropSse
1525
+ // takes it back out of the set — the same exit writeSse uses.
1526
+ const flushed = writeResume(res, `event: replay-end\ndata: {}\n\n`);
1392
1527
  sseClients.add(res);
1393
1528
  // Through writeSse like every other frame: on a client that has stopped
1394
1529
  // reading, the ping is the one thing still being written between events, and
1395
1530
  // it is what eventually reveals the socket as unrecoverable.
1396
- const ping = setInterval(() => writeSse(res, `: ping\n\n`), 15000);
1397
-
1398
- req.on("close", () => {
1399
- clearInterval(ping);
1400
- sseClients.delete(res);
1401
- });
1531
+ ping = setInterval(() => writeSse(res, `: ping\n\n`), 15000);
1532
+ if (!await flushed) dropSse(res);
1402
1533
  }
1403
1534
 
1404
1535
  // True only when a supervisor is listening AND the event log is being written.