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.
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
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": {
|
package/src/server/index.mjs
CHANGED
|
@@ -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
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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 (
|
|
1121
|
+
if (persisting) {
|
|
1016
1122
|
// Fire-and-forget append. JSONL = newline-delimited JSON.
|
|
1017
|
-
appendFile(persistPath,
|
|
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
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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);
|