agent-dag 1.34.4 → 1.34.6
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-G1Xt6iC6.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.6",
|
|
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
|
@@ -785,12 +785,17 @@ function maybeResolveCodex(payload) {
|
|
|
785
785
|
// object {timestamp, type, payload}; we map the relevant ones to the same
|
|
786
786
|
// synthetic hook payloads the reducer already understands:
|
|
787
787
|
// session_meta → SessionStart
|
|
788
|
-
// event_msg/user_message → UserPromptSubmit
|
|
788
|
+
// event_msg/user_message → UserPromptSubmit (Codex ≤ 0.144)
|
|
789
|
+
// event_msg/item_completed/UserMessage → UserPromptSubmit (Codex ≥ 0.147)
|
|
789
790
|
// response_item/function_call → PreToolUse
|
|
790
791
|
// response_item/function_call_output → PostToolUse
|
|
791
792
|
// event_msg/token_count → UsageObserved
|
|
792
793
|
// event_msg/task_started (+window) → ModelObserved (context window)
|
|
794
|
+
// event_msg/task_complete → Stop (the turn finished)
|
|
795
|
+
// event_msg/turn_aborted → Stop (the turn was interrupted)
|
|
793
796
|
// turn_context / response_item.model → model snapshot (ModelObserved on change)
|
|
797
|
+
// There is deliberately no SessionEnd here: Codex writes no session-close
|
|
798
|
+
// record, so the end of a SESSION is still inferred by sweepStaleSessions.
|
|
794
799
|
// Events are emitted with source "codex" so pushEvent skips the Claude-only
|
|
795
800
|
// transcript enrichment (which needs transcript_path / hook events) but still
|
|
796
801
|
// broadcasts them exactly like a hook event, and persists them when this deck is
|
|
@@ -890,10 +895,27 @@ async function readCodexHeader(path) {
|
|
|
890
895
|
return null;
|
|
891
896
|
}
|
|
892
897
|
|
|
898
|
+
/**
|
|
899
|
+
* The human's prompt out of a 0.147-era `item_completed` item.
|
|
900
|
+
*
|
|
901
|
+
* `item.content` is an array of parts — every UserMessage observed carries a
|
|
902
|
+
* single `{ type: "text", text, text_elements }` — so the parts are joined
|
|
903
|
+
* rather than indexed, and a part with no string `text` contributes nothing
|
|
904
|
+
* instead of printing "undefined" into the prompt the card shows.
|
|
905
|
+
*/
|
|
906
|
+
function codexItemText(item) {
|
|
907
|
+
const parts = Array.isArray(item && item.content) ? item.content : [];
|
|
908
|
+
return parts.map(p => (p && typeof p.text === "string" ? p.text : "")).join("");
|
|
909
|
+
}
|
|
910
|
+
|
|
893
911
|
// Map one parsed rollout object to a synthetic hook payload (or null to skip).
|
|
894
912
|
// Mutates codexSessionModel and returns { payload, modelEvent } where
|
|
895
913
|
// modelEvent is an optional ModelObserved to emit first when the model changed.
|
|
896
|
-
|
|
914
|
+
//
|
|
915
|
+
// Exported for the tests: this is the whole of the Codex translation, and the
|
|
916
|
+
// lifecycle it produces is worth pinning against the real reducer without
|
|
917
|
+
// standing up a watcher, a temp home and a 1.5s poll to get at it.
|
|
918
|
+
export function codexObjToPayload(obj, sid, cwd) {
|
|
897
919
|
const type = obj && obj.type;
|
|
898
920
|
const pl = (obj && obj.payload) || {};
|
|
899
921
|
const base = { session_id: sid, cwd, provider: "codex" };
|
|
@@ -913,12 +935,49 @@ function codexObjToPayload(obj, sid, cwd) {
|
|
|
913
935
|
const prompt = typeof pl.message === "string" ? pl.message : "";
|
|
914
936
|
return { ...base, hook_event_name: "UserPromptSubmit", prompt, model };
|
|
915
937
|
}
|
|
938
|
+
// Codex 0.147 stopped writing `user_message` and writes the same submission
|
|
939
|
+
// as an `item_completed` carrying a `UserMessage` item instead. The two are
|
|
940
|
+
// mutually exclusive per CLI version — across the rollouts sampled here the
|
|
941
|
+
// 0.144 files have `user_message` and no `item_completed` at all, and the
|
|
942
|
+
// 0.147 files have exactly one `UserMessage` item per turn and no
|
|
943
|
+
// `user_message` — so handling both names emits one prompt per turn either
|
|
944
|
+
// way rather than two on either version. The item is the human's typed text
|
|
945
|
+
// only: the AGENTS.md preamble Codex prepends is written as a bare
|
|
946
|
+
// `response_item` with role "user" and never gets an item of its own.
|
|
947
|
+
//
|
|
948
|
+
// This matters far beyond the prompt text. `UserPromptSubmit` is what puts
|
|
949
|
+
// a settled root back to `active` (reducer.ts), so on 0.147 it is the ONLY
|
|
950
|
+
// thing that reopens a session for its second turn — without it the `Stop`
|
|
951
|
+
// below would trade "live forever" for "done forever", which is not better.
|
|
952
|
+
if (pl.type === "item_completed" && pl.item && pl.item.type === "UserMessage") {
|
|
953
|
+
return { ...base, hook_event_name: "UserPromptSubmit", prompt: codexItemText(pl.item), model };
|
|
954
|
+
}
|
|
916
955
|
if (pl.type === "token_count" && pl.info && pl.info.total_token_usage) {
|
|
917
956
|
return { ...base, hook_event_name: "UsageObserved", usage: pl.info.total_token_usage, model };
|
|
918
957
|
}
|
|
919
958
|
if (pl.type === "task_started" && typeof pl.model_context_window === "number") {
|
|
920
959
|
return { ...base, hook_event_name: "ModelObserved", model, model_context_window: pl.model_context_window };
|
|
921
960
|
}
|
|
961
|
+
// The end of a turn, which is the only end Codex ever announces. Both names
|
|
962
|
+
// are one outcome as far as the deck is concerned — the turn is over and
|
|
963
|
+
// nothing is running — so both settle the root the way Claude's own Stop
|
|
964
|
+
// hook does. They are also exhaustive: across the rollouts sampled here
|
|
965
|
+
// every `task_started` is answered by exactly one of the two (54 completes
|
|
966
|
+
// + 1 abort for 55 starts), so no turn is left open by this mapping and
|
|
967
|
+
// none is closed twice. `turn_aborted` is the Esc key, and it is the case
|
|
968
|
+
// that matters most on the deck: pressing Esc is precisely when the user is
|
|
969
|
+
// watching to confirm the thing stopped.
|
|
970
|
+
//
|
|
971
|
+
// Per TURN, not per session, and that is correct: Codex writes no
|
|
972
|
+
// session-close record at all — a rollout simply stops growing when the
|
|
973
|
+
// terminal goes away — so `sweepStaleSessions` remains the only thing that
|
|
974
|
+
// ends a Codex *session*, and it must. What changes is that it now only
|
|
975
|
+
// ever sees the sessions it was written for: the ones that died without
|
|
976
|
+
// finishing. A turn that really ended is settled here, at the moment it
|
|
977
|
+
// ended, with no `reaped` flag, because it was a finish and not a guess.
|
|
978
|
+
if (pl.type === "task_complete" || pl.type === "turn_aborted") {
|
|
979
|
+
return { ...base, hook_event_name: "Stop", model };
|
|
980
|
+
}
|
|
922
981
|
return null;
|
|
923
982
|
}
|
|
924
983
|
if (type === "response_item") {
|
|
@@ -1138,17 +1197,89 @@ function queuedBytes(res) {
|
|
|
1138
1197
|
return own + sock;
|
|
1139
1198
|
}
|
|
1140
1199
|
|
|
1200
|
+
/** Hang up on a client we have decided not to keep. `delete` on a response
|
|
1201
|
+
* that never made it into the set — the resume path below hangs up on clients
|
|
1202
|
+
* before they are subscribed — is a harmless no-op. */
|
|
1203
|
+
function dropSse(res) {
|
|
1204
|
+
sseClients.delete(res);
|
|
1205
|
+
// Destroying the socket is what makes the request emit 'close', which is
|
|
1206
|
+
// where the ping interval is cleared.
|
|
1207
|
+
try { res.destroy(); } catch {}
|
|
1208
|
+
try { res.socket?.destroy(); } catch {}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1141
1211
|
/** Write one SSE frame, hanging up on a client too far behind to keep. */
|
|
1142
1212
|
function writeSse(res, frame) {
|
|
1143
1213
|
try {
|
|
1144
1214
|
res.write(frame);
|
|
1145
1215
|
if (queuedBytes(res) <= MAX_CLIENT_BUFFER_BYTES) return;
|
|
1146
1216
|
} catch { /* already dead — drop it below */ }
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1217
|
+
dropSse(res);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// How long a resuming client is given to accept the bytes already queued for
|
|
1221
|
+
// it before the deck concludes it is not reading at all. Generous on purpose:
|
|
1222
|
+
// what it has to work through is a full MAX_CLIENT_BUFFER_BYTES, the link may
|
|
1223
|
+
// be an `ssh -L` tunnel rather than loopback, and dropping a client that is
|
|
1224
|
+
// merely slow costs it the whole replay. A tab that is genuinely frozen will
|
|
1225
|
+
// not accept a byte in any budget, so the only thing a long one buys it is a
|
|
1226
|
+
// few more seconds of holding its own buffer. The environment override exists
|
|
1227
|
+
// so the tests can pin the drop without sitting through the real budget.
|
|
1228
|
+
const REPLAY_DRAIN_MS = Number(process.env.AGENTS_DECK_REPLAY_DRAIN_MS) > 0
|
|
1229
|
+
? Number(process.env.AGENTS_DECK_REPLAY_DRAIN_MS)
|
|
1230
|
+
: 30_000;
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Write one frame of the resume stream under the same ceiling the live path
|
|
1234
|
+
* obeys, waiting rather than dropping when the client is at it. Resolves true
|
|
1235
|
+
* while the client is worth keeping, false once it is not.
|
|
1236
|
+
*
|
|
1237
|
+
* Waiting is the whole difference from writeSse, and the replay is why. The
|
|
1238
|
+
* live path writes one frame per event, so a full buffer there means the
|
|
1239
|
+
* client stopped reading and the only answer is to hang up. Here the burst is
|
|
1240
|
+
* ours: the loop below hands the socket the entire ring buffer in one turn of
|
|
1241
|
+
* the event loop, so even a client reading at full speed sees its buffer fill
|
|
1242
|
+
* — nothing has drained it yet, because nothing could. Dropping on that would
|
|
1243
|
+
* hang up on healthy clients, and hang up on them again every time they came
|
|
1244
|
+
* back: EventSource reconnects with the same Last-Event-ID, meets the same
|
|
1245
|
+
* oversized replay, and is dropped again 1.5 seconds later, forever. So we
|
|
1246
|
+
* stop writing until the socket has taken what it already has, and only give
|
|
1247
|
+
* up on a client that takes nothing at all for REPLAY_DRAIN_MS.
|
|
1248
|
+
*
|
|
1249
|
+
* The wait is on write()'s completion callback rather than on a 'drain' event:
|
|
1250
|
+
* 'drain' only follows a write that was answered false, and a frame can push
|
|
1251
|
+
* queuedBytes past the cap while still being answered true, the socket's own
|
|
1252
|
+
* pending bytes being one of the two terms in that sum. The callback fires
|
|
1253
|
+
* once this chunk —
|
|
1254
|
+
* and therefore everything queued ahead of it — has reached the OS, which is
|
|
1255
|
+
* exactly the condition being waited for. It also fires, with an error we do
|
|
1256
|
+
* not need to read, if the response is destroyed underneath us, so this cannot
|
|
1257
|
+
* hang on a client that goes away.
|
|
1258
|
+
*/
|
|
1259
|
+
async function writeResume(res, frame) {
|
|
1260
|
+
// Below the cap this is the plain write it has always been. The `await` in
|
|
1261
|
+
// the caller costs a microtask and nothing else: the checkpoint drains
|
|
1262
|
+
// before the loop can accept I/O, so no live event can slip between two
|
|
1263
|
+
// replay frames the way it could across a real wait.
|
|
1264
|
+
if (queuedBytes(res) <= MAX_CLIENT_BUFFER_BYTES) {
|
|
1265
|
+
try { res.write(frame); return true; } catch { return false; }
|
|
1266
|
+
}
|
|
1267
|
+
return new Promise(resolve => {
|
|
1268
|
+
let settled = false;
|
|
1269
|
+
const done = ok => {
|
|
1270
|
+
if (settled) return;
|
|
1271
|
+
settled = true;
|
|
1272
|
+
clearTimeout(timer);
|
|
1273
|
+
resolve(ok);
|
|
1274
|
+
};
|
|
1275
|
+
// Unref'd so a client that stopped reading can never be the reason the
|
|
1276
|
+
// process stays alive, and cleared on every path out so a completed replay
|
|
1277
|
+
// leaves no timer behind.
|
|
1278
|
+
const timer = setTimeout(() => done(false), REPLAY_DRAIN_MS);
|
|
1279
|
+
timer.unref?.();
|
|
1280
|
+
try { res.write(frame, () => done(!res.destroyed)); }
|
|
1281
|
+
catch { done(false); }
|
|
1282
|
+
});
|
|
1152
1283
|
}
|
|
1153
1284
|
|
|
1154
1285
|
function pushEvent(raw, source, opts = {}) {
|
|
@@ -1373,32 +1504,91 @@ function handleSse(req, res) {
|
|
|
1373
1504
|
});
|
|
1374
1505
|
res.write(`retry: 1500\n\n`);
|
|
1375
1506
|
|
|
1376
|
-
//
|
|
1377
|
-
//
|
|
1378
|
-
//
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
//
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1507
|
+
// A stale or absent id replays the whole ring, and so does a malformed one:
|
|
1508
|
+
// Number("nonsense") is NaN, every `seq <= NaN` is false, and the catch-up
|
|
1509
|
+
// loop below would rather compare against a number.
|
|
1510
|
+
const asked = Number(req.headers["last-event-id"] ?? 0);
|
|
1511
|
+
const lastId = Number.isFinite(asked) ? asked : 0;
|
|
1512
|
+
|
|
1513
|
+
// The replay waits on the socket now, so it can no longer be part of this
|
|
1514
|
+
// synchronous handler. Nothing is waiting on the result here — the response
|
|
1515
|
+
// is already committed to a 200 and its own failure path is to hang up — so
|
|
1516
|
+
// start it, keep the router's contract of returning nothing, and make sure a
|
|
1517
|
+
// rejection ends the stream rather than the process.
|
|
1518
|
+
resumeSse(req, res, lastId).catch(() => dropSse(res));
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
/**
|
|
1522
|
+
* Drain the ring buffer into a newly connected client, then subscribe it.
|
|
1523
|
+
*
|
|
1524
|
+
* Two things had to change when this stopped being one synchronous burst.
|
|
1525
|
+
* `close` is registered before the first frame, because a tab closed mid-replay
|
|
1526
|
+
* has to stop it. And the replay repeats until it reaches the live tail: an
|
|
1527
|
+
* actual wait lets pushEvent run, and an event that lands after we have walked
|
|
1528
|
+
* past its place but before the client is in `sseClients` would otherwise be in
|
|
1529
|
+
* neither stream — a hole the client cannot even ask for again, its last id
|
|
1530
|
+
* having moved past it.
|
|
1531
|
+
*/
|
|
1532
|
+
async function resumeSse(req, res, lastId) {
|
|
1533
|
+
let sentThrough = lastId;
|
|
1534
|
+
let ping = null;
|
|
1535
|
+
let closed = false;
|
|
1536
|
+
req.on("close", () => {
|
|
1537
|
+
closed = true;
|
|
1538
|
+
if (ping) clearInterval(ping);
|
|
1539
|
+
sseClients.delete(res);
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
for (;;) {
|
|
1543
|
+
// A snapshot per pass, because a wait lets pushEvent splice the head of
|
|
1544
|
+
// `events` off, and iterating an array being spliced from the front skips
|
|
1545
|
+
// entries. Events evicted that way are gone for this client, which is the
|
|
1546
|
+
// same bargain every resume against a rotated ring already makes.
|
|
1547
|
+
const batch = events.slice();
|
|
1548
|
+
for (const e of batch) {
|
|
1549
|
+
if (e.seq <= sentThrough) continue;
|
|
1550
|
+
if (closed || res.destroyed) return;
|
|
1551
|
+
// Marked with `replay:true` on the envelope so the client can suppress
|
|
1552
|
+
// turn-cleanup side effects (exitAt stamping, autofit churn) until the
|
|
1553
|
+
// live stream takes over. Without this the reducer's UserPromptSubmit
|
|
1554
|
+
// handler treats replayed events as a real new turn — hiding prior-turn
|
|
1555
|
+
// subagents using the event's stale receivedAt, which collides with
|
|
1556
|
+
// wall-clock visibility gates and yields the "nodes appear then vanish"
|
|
1557
|
+
// symptom on refresh.
|
|
1558
|
+
const tagged = { ...e, replay: true };
|
|
1559
|
+
if (!await writeResume(res, `id: ${e.seq}\nevent: hook\ndata: ${JSON.stringify(tagged)}\n\n`)) {
|
|
1560
|
+
return dropSse(res);
|
|
1561
|
+
}
|
|
1562
|
+
sentThrough = e.seq;
|
|
1563
|
+
}
|
|
1564
|
+
// Caught up with the tail as it stands right now. Reached in one pass
|
|
1565
|
+
// unless a wait let new events in, and it terminates for the same reason
|
|
1566
|
+
// the client is still here at all: either it is taking bytes, in which case
|
|
1567
|
+
// loopback outruns any hook, or it is not, in which case writeResume gives
|
|
1568
|
+
// up on it.
|
|
1569
|
+
if (events.length === 0 || events[events.length - 1].seq <= sentThrough) break;
|
|
1388
1570
|
}
|
|
1389
|
-
// Sentinel: tells client "ring buffer drained, live stream starts now".
|
|
1390
|
-
res.write(`event: replay-end\ndata: {}\n\n`);
|
|
1391
1571
|
|
|
1572
|
+
if (closed || res.destroyed) return;
|
|
1573
|
+
|
|
1574
|
+
// Sentinel: tells client "ring buffer drained, live stream starts now". It
|
|
1575
|
+
// goes out under the same rule as the frames before it, so a client that has
|
|
1576
|
+
// just been handed a large replay is not hung up on over the last 30 bytes of
|
|
1577
|
+
// it before it has had the chance to read any.
|
|
1578
|
+
//
|
|
1579
|
+
// Subscribing before waiting on that write, rather than after, is what closes
|
|
1580
|
+
// the last hole: writeResume puts the bytes on the socket before it returns,
|
|
1581
|
+
// so the sentinel still precedes every live frame, and an event pushed while
|
|
1582
|
+
// we wait reaches this client through the live fan-out instead of falling
|
|
1583
|
+
// into the gap between the two. If the wait then ends in a drop, dropSse
|
|
1584
|
+
// takes it back out of the set — the same exit writeSse uses.
|
|
1585
|
+
const flushed = writeResume(res, `event: replay-end\ndata: {}\n\n`);
|
|
1392
1586
|
sseClients.add(res);
|
|
1393
1587
|
// Through writeSse like every other frame: on a client that has stopped
|
|
1394
1588
|
// reading, the ping is the one thing still being written between events, and
|
|
1395
1589
|
// it is what eventually reveals the socket as unrecoverable.
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
req.on("close", () => {
|
|
1399
|
-
clearInterval(ping);
|
|
1400
|
-
sseClients.delete(res);
|
|
1401
|
-
});
|
|
1590
|
+
ping = setInterval(() => writeSse(res, `: ping\n\n`), 15000);
|
|
1591
|
+
if (!await flushed) dropSse(res);
|
|
1402
1592
|
}
|
|
1403
1593
|
|
|
1404
1594
|
// True only when a supervisor is listening AND the event log is being written.
|