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.
@@ -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-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.4",
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": {
@@ -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
- function codexObjToPayload(obj, sid, cwd) {
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
- 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 {}
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
- // 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`);
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
- const ping = setInterval(() => writeSse(res, `: ping\n\n`), 15000);
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.