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.
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
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": {
|
package/src/server/index.mjs
CHANGED
|
@@ -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
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
-
//
|
|
1377
|
-
//
|
|
1378
|
-
//
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
//
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
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
|
-
|
|
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.
|