agent-dag 1.33.80 → 1.33.81

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.
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>agents-deck</title>
7
7
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='84' font-size='84'%3E%E2%97%89%3C/text%3E%3C/svg%3E" />
8
- <script type="module" crossorigin src="/assets/index-5_r9cSQl.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-C-dhwbw-.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-dwRx-2y0.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.33.80",
3
+ "version": "1.33.81",
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": {
@@ -757,8 +757,12 @@ function maybeResolveCodex(payload) {
757
757
  // transcript enrichment (which needs transcript_path / hook events) but still
758
758
  // persists + broadcasts them exactly like a hook event. This path is entirely
759
759
  // additive — the Claude hook flow is untouched.
760
- const codexFileState = new Map(); // path -> { offset, sid, cwd, skip }
760
+ const codexFileState = new Map(); // path -> { offset, sid, cwd, skip, seenAt }
761
761
  const codexSessionModel = new Map(); // sid -> last model string
762
+ // How long a rollout's tail cursor is kept after it stops showing up in the
763
+ // listing. The listing covers two day-directories, so anything missing from it
764
+ // is at least a day old and will never be appended to again.
765
+ const CODEX_STATE_TTL_MS = 10 * 60 * 1000;
762
766
  let codexScanRunning = false;
763
767
  let codexWatchTimer = null;
764
768
  let codexWorkspace = "";
@@ -902,11 +906,13 @@ async function codexScanOnce(firstRun) {
902
906
  if (codexScanRunning) return;
903
907
  codexScanRunning = true;
904
908
  try {
909
+ const now = Date.now();
905
910
  const files = await listRecentCodexRollouts();
906
911
  for (const path of files) {
907
912
  let st;
908
913
  try { st = await stat(path); } catch { continue; }
909
914
  let state = codexFileState.get(path);
915
+ if (state) state.seenAt = now;
910
916
 
911
917
  if (!state) {
912
918
  // New file — read the header for sid + cwd, then decide whether to
@@ -914,10 +920,10 @@ async function codexScanOnce(firstRun) {
914
920
  const header = await readCodexHeader(path);
915
921
  if (!header || !header.sid) continue; // not ready yet — retry next tick
916
922
  if (!codexCwdInWorkspace(header.cwd)) {
917
- codexFileState.set(path, { offset: st.size, sid: header.sid, cwd: header.cwd, skip: true, rootEmitted: false });
923
+ codexFileState.set(path, { offset: st.size, sid: header.sid, cwd: header.cwd, skip: true, rootEmitted: false, seenAt: now });
918
924
  continue;
919
925
  }
920
- state = { offset: 0, sid: header.sid, cwd: header.cwd, skip: false, rootEmitted: false };
926
+ state = { offset: 0, sid: header.sid, cwd: header.cwd, skip: false, rootEmitted: false, seenAt: now };
921
927
  codexFileState.set(path, state);
922
928
  if (firstRun) {
923
929
  // On startup, skip a pre-existing session's history entirely — no
@@ -955,6 +961,15 @@ async function codexScanOnce(firstRun) {
955
961
  }
956
962
  }
957
963
  }
964
+
965
+ // Rollout files fall out of the newest-2-days listing and never come back,
966
+ // but their tail cursors used to live as long as the process did. Expire by
967
+ // "not seen for a while" rather than "absent from this listing": a single
968
+ // unreadable directory mid-scan would otherwise drop a live file's cursor,
969
+ // and re-adding it at offset 0 replays that entire rollout as fresh events.
970
+ for (const [p, s] of codexFileState) {
971
+ if (now - (s.seenAt ?? 0) > CODEX_STATE_TTL_MS) codexFileState.delete(p);
972
+ }
958
973
  } catch {
959
974
  /* swallow — watcher must never crash the server */
960
975
  } finally {
@@ -988,6 +1003,79 @@ export function eventsSince(seq) {
988
1003
  return events.filter(e => e.seq > after);
989
1004
  }
990
1005
 
1006
+ // ─── Per-session cache expiry ────────────────────────────────────────────
1007
+ // Every enrichment cache above is keyed by session id and nothing ever
1008
+ // removed an entry: a deck left up for weeks — the 24/7 use this thing is
1009
+ // built for — kept a model string, a subagent signature and four read-throttle
1010
+ // stamps for every session it had ever seen, plus the Codex rollout path and
1011
+ // model of each. SessionEnd is not a usable eviction signal (a killed CLI
1012
+ // never sends one, and Codex has no such hook at all), so entries expire by
1013
+ // least-recent use against a cap instead — the same shape pruneTranscriptScans
1014
+ // already uses for its per-path state. The cap sits far above any plausible
1015
+ // number of concurrent sessions, so a live session is never evicted; and an
1016
+ // evicted one that speaks again simply re-reads its transcript.
1017
+ const sessionTouchedAt = new Map(); // sid -> ms of the last event seen
1018
+ const MAX_TRACKED_SESSIONS = 256;
1019
+
1020
+ function forgetSession(sid) {
1021
+ modelBySession.delete(sid);
1022
+ modelLastReadAt.delete(sid);
1023
+ lastUsageReadAt.delete(sid);
1024
+ lastContextReadAt.delete(sid);
1025
+ codexRolloutPathBySid.delete(sid);
1026
+ lastCodexUsageReadAt.delete(sid);
1027
+ codexSessionModel.delete(sid);
1028
+ }
1029
+
1030
+ function touchSession(sid) {
1031
+ if (!sid || typeof sid !== "string") return;
1032
+ // Re-insert so the Map's own insertion order *is* the LRU order and eviction
1033
+ // below is one key read rather than a scan of every session ever seen.
1034
+ sessionTouchedAt.delete(sid);
1035
+ sessionTouchedAt.set(sid, Date.now());
1036
+ while (sessionTouchedAt.size > MAX_TRACKED_SESSIONS) {
1037
+ const oldest = sessionTouchedAt.keys().next().value;
1038
+ sessionTouchedAt.delete(oldest);
1039
+ forgetSession(oldest);
1040
+ }
1041
+ }
1042
+
1043
+ // ─── SSE backpressure ────────────────────────────────────────────────────
1044
+ // A client that stops reading without closing its socket — a frozen tab, a
1045
+ // suspended machine, a stalled `ssh -L` tunnel — never fires 'close' and never
1046
+ // makes write() throw, so the old `try { res.write(line) } catch {}` had no
1047
+ // way to notice it. On loopback nothing times the connection out either, so
1048
+ // every event (tool responses run to megabytes) queued in that socket's write
1049
+ // buffer for as long as the process lived.
1050
+ //
1051
+ // We drop the client rather than the events. EventSource reconnects after the
1052
+ // `retry: 1500` we send on connect and resumes from Last-Event-ID, so the ring
1053
+ // buffer replays whatever it missed. Dropping individual events instead would
1054
+ // leave a hole the resume path cannot even see, the client's last id having
1055
+ // moved past it.
1056
+ const MAX_CLIENT_BUFFER_BYTES = 8 * 1024 * 1024;
1057
+
1058
+ /** Bytes queued for a client: what the response has not handed to the socket
1059
+ * yet, plus what the socket has not handed to the kernel. */
1060
+ function queuedBytes(res) {
1061
+ const own = typeof res.writableLength === "number" ? res.writableLength : 0;
1062
+ const sock = res.socket && typeof res.socket.writableLength === "number" ? res.socket.writableLength : 0;
1063
+ return own + sock;
1064
+ }
1065
+
1066
+ /** Write one SSE frame, hanging up on a client too far behind to keep. */
1067
+ function writeSse(res, frame) {
1068
+ try {
1069
+ res.write(frame);
1070
+ if (queuedBytes(res) <= MAX_CLIENT_BUFFER_BYTES) return;
1071
+ } catch { /* already dead — drop it below */ }
1072
+ sseClients.delete(res);
1073
+ // Destroying the socket is what makes the request emit 'close', which is
1074
+ // where the ping interval is cleared.
1075
+ try { res.destroy(); } catch {}
1076
+ try { res.socket?.destroy(); } catch {}
1077
+ }
1078
+
991
1079
  function pushEvent(raw, source, opts = {}) {
992
1080
  // Synchronous enrichment: if we already know this session's model, stamp
993
1081
  // it on the payload so the client's recursive scanner picks it up.
@@ -1007,18 +1095,42 @@ function pushEvent(raw, source, opts = {}) {
1007
1095
  events.push(evt);
1008
1096
  if (events.length > MAX_BUFFER) events.splice(0, events.length - MAX_BUFFER);
1009
1097
 
1010
- const line = `id: ${seq}\nevent: hook\ndata: ${JSON.stringify(evt)}\n\n`;
1011
- for (const res of sseClients) {
1012
- try { res.write(line); } catch {}
1098
+ // Does this event reach the log at all? Not on a replay (it came from
1099
+ // there), not when the hook told us another deck owns this session's log,
1100
+ // and not when we have no log. Decided before serializing because it is half
1101
+ // of the answer to whether serializing is worth doing.
1102
+ const persisting = persistPath && !opts.replay && opts.persist !== false && writesLogFor(raw);
1103
+
1104
+ // One serialization, shared by both consumers — and skipped entirely when
1105
+ // neither wants it. This used to stringify the whole envelope twice on the
1106
+ // hottest path in the process (once for the SSE frame, once for the persist
1107
+ // line), and built the frame even with nobody subscribed: a headless deck
1108
+ // paid a full stringify per event for a string no one read, and boot replay
1109
+ // — which runs before the listener exists and never broadcasts — paid one
1110
+ // for every line of a log that rotates at 50MB. An event this deck is not
1111
+ // logging is still broadcast, so a subscriber alone is reason enough.
1112
+ const json = (sseClients.size > 0 || persisting) ? JSON.stringify(evt) : null;
1113
+
1114
+ if (sseClients.size > 0) {
1115
+ const line = `id: ${seq}\nevent: hook\ndata: ${json}\n\n`;
1116
+ // writeSse may drop a client mid-loop; deleting from a Set while iterating
1117
+ // it is well defined and skips only the entry removed.
1118
+ for (const res of sseClients) writeSse(res, line);
1013
1119
  }
1014
1120
 
1015
- if (persistPath && !opts.replay && opts.persist !== false && writesLogFor(raw)) {
1121
+ if (persisting) {
1016
1122
  // Fire-and-forget append. JSONL = newline-delimited JSON.
1017
- appendFile(persistPath, JSON.stringify(evt) + "\n", "utf8").catch(() => {});
1123
+ appendFile(persistPath, json + "\n", "utf8").catch(() => {});
1018
1124
  // Cheap throttled check (every 30s) — only rotates if file > 50MB.
1019
1125
  maybeRotatePersistFile();
1020
1126
  }
1021
1127
 
1128
+ // Note the session so the caches the scanners below fill can expire by
1129
+ // least-recent use. Replays are excluded: they fill nothing, and a boot
1130
+ // replay of a log spanning weeks would otherwise churn the whole LRU through
1131
+ // dead session ids before the first live event even arrives.
1132
+ if (!opts.replay && raw && typeof raw === "object") touchSession(raw.session_id);
1133
+
1022
1134
  // Kick off async transcript scans. Model arrives as a one-shot
1023
1135
  // ModelObserved; usage is re-read periodically (throttled to 2.5s per
1024
1136
  // session) so the cost columns track running totals as the session
@@ -1161,9 +1273,10 @@ function handleSse(req, res) {
1161
1273
  res.write(`event: replay-end\ndata: {}\n\n`);
1162
1274
 
1163
1275
  sseClients.add(res);
1164
- const ping = setInterval(() => {
1165
- try { res.write(`: ping\n\n`); } catch {}
1166
- }, 15000);
1276
+ // Through writeSse like every other frame: on a client that has stopped
1277
+ // reading, the ping is the one thing still being written between events, and
1278
+ // it is what eventually reveals the socket as unrecoverable.
1279
+ const ping = setInterval(() => writeSse(res, `: ping\n\n`), 15000);
1167
1280
 
1168
1281
  req.on("close", () => {
1169
1282
  clearInterval(ping);