agent-dag 1.33.78 → 1.33.79

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-Ffz1bNVI.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-BCX5R6_z.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-CF2yG9oG.css">
10
10
  </head>
11
11
  <body>
package/hook/hook.js CHANGED
@@ -91,6 +91,51 @@ function isAlive(pid) {
91
91
  catch (e) { return e && e.code === "EPERM"; }
92
92
  }
93
93
 
94
+ /**
95
+ * Of the decks about to be posted this event, which ones should also write it
96
+ * to disk? Returns the subset that should; every other target is asked to
97
+ * display the event and keep no record of it.
98
+ *
99
+ * The fan-out itself is deliberate — several decks can match one session and
100
+ * they should all draw it. Persisting is not: they all default to the same
101
+ * <claude config dir>/agent-dag/events.jsonl, so each of them appending its own
102
+ * copy wrote every event once per running deck. The file then grew N times as
103
+ * fast, rotated N times as often, and every replay of it ingested each tool
104
+ * call N times, which is what put duplicate tools and duplicate bubbles on the
105
+ * canvas after a restart.
106
+ *
107
+ * Decks are therefore grouped by the log file each one names in its discovery
108
+ * record, and one deck per group is elected. Grouping by the file rather than
109
+ * counting decks is what keeps the overrides honest: a deck run with
110
+ * `--history` sits alone in its own group and always writes, a deck run with
111
+ * `--no-persist` reports no file and can never be elected to write for one that
112
+ * does, and a deck too old to report either keeps the behaviour it had before
113
+ * this rule existed. Within a group the lowest port wins — a fixed rule, so the
114
+ * same deck holds the file for as long as it is up and the next one inherits it
115
+ * as soon as that deck is gone.
116
+ *
117
+ * The platform is a parameter, like cwdInWorkspace's, so the case-folding half
118
+ * is testable from any machine.
119
+ */
120
+ function electWriters(decks, platform = process.platform) {
121
+ const byLog = new Map();
122
+ for (const d of decks) {
123
+ const log = typeof d.persist === "string" ? d.persist : "";
124
+ // Two namespaces, so a deck with no log to share — and a deck too old to
125
+ // report one — is alone in its group and cannot collide with a real path.
126
+ const key = log
127
+ ? `log:${foldsCase(platform) ? log.toLowerCase() : log}`
128
+ : `deck:${d.pid}:${d.port}`;
129
+ const held = byLog.get(key);
130
+ // Ports are unique among live decks; pid only breaks a tie a stale
131
+ // discovery file could invent, so the answer stays deterministic.
132
+ if (!held || d.port < held.port || (d.port === held.port && d.pid < held.pid)) {
133
+ byLog.set(key, d);
134
+ }
135
+ }
136
+ return new Set(byLog.values());
137
+ }
138
+
94
139
  /**
95
140
  * The answer a deck must give to be handed a session payload.
96
141
  *
@@ -168,12 +213,15 @@ const POST_TIMEOUT_MS = 1000;
168
213
  * this target is finished.
169
214
  *
170
215
  * A deck that advertised no token is posted to directly: see requiresProof.
216
+ *
217
+ * `persists` is this deck's answer from electWriters: true for the one deck
218
+ * that logs the event, false for every other one it is also drawn on.
171
219
  */
172
- function deliver(d, body, done) {
220
+ function deliver(d, body, persists, done) {
173
221
  let settled = false;
174
222
  const finish = () => { if (settled) return; settled = true; done(); };
175
223
 
176
- if (!requiresProof(d)) return post(d, body, finish);
224
+ if (!requiresProof(d)) return post(d, body, persists, finish);
177
225
 
178
226
  const nonce = crypto.randomBytes(16).toString("hex");
179
227
  const want = challengeProof(d.token, nonce);
@@ -201,7 +249,7 @@ function deliver(d, body, done) {
201
249
  let proof;
202
250
  try { proof = JSON.parse(answer).proof; } catch { return finish(); }
203
251
  if (!sameProof(proof, want)) return finish();
204
- post(d, body, finish);
252
+ post(d, body, persists, finish);
205
253
  });
206
254
  });
207
255
  req.on("error", finish);
@@ -209,11 +257,13 @@ function deliver(d, body, done) {
209
257
  req.end();
210
258
  }
211
259
 
212
- function post(d, body, finish) {
260
+ function post(d, body, persists, finish) {
213
261
  const req = http.request({
214
262
  hostname: "127.0.0.1",
215
263
  port: d.port,
216
- path: "/api/event",
264
+ // Only the elected deck records the event; the rest are asked to draw it
265
+ // and keep no copy, so one log file ends up with one copy of it.
266
+ path: persists ? "/api/event" : "/api/event?persist=0",
217
267
  method: "POST",
218
268
  headers: { "Content-Type": "application/json" },
219
269
  timeout: POST_TIMEOUT_MS,
@@ -289,16 +339,20 @@ function main() {
289
339
  const bestLen = matches[0].wsLen;
290
340
  const targets = matches.filter(m => m.wsLen === bestLen);
291
341
 
342
+ // One deck per events log records this event; the others only draw it.
343
+ const writers = electWriters(targets.map(m => m.d));
344
+
292
345
  let pending = targets.length;
293
346
  const done = () => { if (--pending <= 0) process.exit(0); };
294
347
 
295
- for (const { d } of targets) deliver(d, taggedInput, done);
348
+ for (const { d } of targets) deliver(d, taggedInput, writers.has(d), done);
296
349
  });
297
350
  }
298
351
 
299
352
  // The host CLI always runs this file as the process entry point — the command
300
353
  // the installer writes is `"<node>" "<...>/hook.js" --provider <name>`. Under a
301
- // require() it exports the matching rule and starts nothing, which is what lets
302
- // the rule be tested without a 1.5s exit timer in the test runner.
303
- module.exports = { cwdInWorkspace, foldsCase, challengeProof, requiresProof };
354
+ // require() it exports the rules it decides by — matching, election, the
355
+ // handshake — and starts nothing, which is what lets them be tested without a
356
+ // 1.5s exit timer in the test runner.
357
+ module.exports = { cwdInWorkspace, foldsCase, electWriters, challengeProof, requiresProof };
304
358
  if (require.main === module) main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.33.78",
3
+ "version": "1.33.79",
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": {
@@ -50,6 +50,45 @@ const sseClients = new Set(); // res handles
50
50
 
51
51
  let persistPath = null; // absolute path to events.jsonl, or null
52
52
 
53
+ // ─── Which deck records which session ─────────────────────────────────────
54
+ // The hook posts an event to every deck whose workspace matches, and by
55
+ // default all of them append to one events.jsonl — so each event landed in
56
+ // that file once per running deck, and every later replay of it ingested each
57
+ // tool call that many times. The hook now elects one writer per log file and
58
+ // marks the request to the rest `?persist=0`. That flag covers the hook event
59
+ // itself; this map carries it to the ModelObserved/UsageObserved/
60
+ // ContextObserved events a deck derives from the same session's transcript,
61
+ // which every deck reads for itself and would otherwise duplicate exactly the
62
+ // same way. A session is recorded unless we have been told someone else owns
63
+ // it, so a lone deck — and a deck the hook is too old to flag — still writes
64
+ // everything.
65
+ const foreignSessions = new Set(); // session ids another deck is logging
66
+ const MAX_FOREIGN_SESSIONS = 512; // bound: insertion order = oldest first
67
+
68
+ function noteLogWriter(payload, mine) {
69
+ const sid = payload && typeof payload === "object" ? payload.session_id : null;
70
+ if (typeof sid !== "string" || sid === "") return;
71
+ // Delete either way: on re-add it moves the id back to the young end, so the
72
+ // eviction below drops sessions that stopped being posted, not busy ones.
73
+ foreignSessions.delete(sid);
74
+ if (mine) return;
75
+ foreignSessions.add(sid);
76
+ if (foreignSessions.size > MAX_FOREIGN_SESSIONS) {
77
+ foreignSessions.delete(foreignSessions.values().next().value);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Is this deck the one that writes this payload's session to the log? Every
83
+ * event goes through here on its way to disk, including the ones this deck
84
+ * derives from a transcript on its own — that is the point of remembering the
85
+ * session rather than only honouring the flag on the event that carried it.
86
+ */
87
+ export function writesLogFor(payload) {
88
+ const sid = payload && typeof payload === "object" ? payload.session_id : null;
89
+ return typeof sid !== "string" || sid === "" || !foreignSessions.has(sid);
90
+ }
91
+
53
92
  // ─── Persistence rotation ─────────────────────────────────────────────────
54
93
  // 24/7 dev servers used to grow events.jsonl unbounded — saw it hit GBs
55
94
  // across weeks. We rotate when the file passes ROTATE_AT_BYTES, archiving
@@ -949,7 +988,7 @@ function pushEvent(raw, source, opts = {}) {
949
988
  try { res.write(line); } catch {}
950
989
  }
951
990
 
952
- if (persistPath && !opts.replay) {
991
+ if (persistPath && !opts.replay && opts.persist !== false && writesLogFor(raw)) {
953
992
  // Fire-and-forget append. JSONL = newline-delimited JSON.
954
993
  appendFile(persistPath, JSON.stringify(evt) + "\n", "utf8").catch(() => {});
955
994
  // Cheap throttled check (every 30s) — only rotates if file > 50MB.
@@ -1048,7 +1087,11 @@ function readBody(req, limit = 64_000) {
1048
1087
  });
1049
1088
  }
1050
1089
 
1051
- function handleEventIngest(req, res) {
1090
+ // `persist` is false when the hook posted this event to another deck as well
1091
+ // and elected that one to write it to the log they share. The event is still
1092
+ // buffered and broadcast here — every matching deck draws it — it is only the
1093
+ // second copy on disk that is dropped.
1094
+ function handleEventIngest(req, res, persist = true) {
1052
1095
  let body = "";
1053
1096
  req.setEncoding("utf8");
1054
1097
  req.on("data", c => {
@@ -1061,7 +1104,8 @@ function handleEventIngest(req, res) {
1061
1104
  let parsed;
1062
1105
  try { parsed = JSON.parse(body); }
1063
1106
  catch { return send(res, 400, { error: "invalid json" }); }
1064
- const evt = pushEvent(parsed, "hook");
1107
+ noteLogWriter(parsed, persist);
1108
+ const evt = pushEvent(parsed, "hook", { persist });
1065
1109
  send(res, 200, { ok: true, seq: evt.seq });
1066
1110
  });
1067
1111
  req.on("error", () => send(res, 400, { error: "bad request" }));
@@ -1556,7 +1600,9 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
1556
1600
  return send(res, 403, { error: "cross-site request blocked" });
1557
1601
  }
1558
1602
 
1559
- if (req.method === "POST" && url.pathname === "/api/event") return guard(handleEventIngest(req, res), res);
1603
+ // `?persist=0` — another deck was elected to write this event to the log
1604
+ // the two of them share. Absent, this deck writes it.
1605
+ if (req.method === "POST" && url.pathname === "/api/event") return guard(handleEventIngest(req, res, url.searchParams.get("persist") !== "0"), res);
1560
1606
  if (req.method === "GET" && url.pathname === "/api/health") return handleHealth(req, res);
1561
1607
  if (req.method === "GET" && url.pathname === "/api/hook-challenge") return handleHookChallenge(req, res, url);
1562
1608
  if (req.method === "GET" && url.pathname === "/events") return handleSse(req, res);
@@ -300,12 +300,21 @@ export function hasCodexInstalled() {
300
300
  return existsSync(CODEX_DIR);
301
301
  }
302
302
 
303
+ /**
304
+ * The events log as the record spells it: an absolute path, or null when this
305
+ * deck writes none. Shared by the writer and by ensureDiscovery's comparison,
306
+ * so a file this process wrote can never read back as somebody else's.
307
+ */
308
+ function persistField(persist) {
309
+ return typeof persist === "string" && persist !== "" ? persist : null;
310
+ }
311
+
303
312
  // The token is the deck's proof of identity, so the file holding it is the
304
313
  // deck's key material: readable and writable by its owner, nobody else. Windows
305
314
  // ignores the mode (NTFS ACLs inherit from the profile directory, which is
306
315
  // already per-user), and a file left over from an earlier run under a recycled
307
316
  // pid keeps its old mode through writeFile, hence the explicit chmod.
308
- export async function writeDiscovery({ port, workspace, token }) {
317
+ export async function writeDiscovery({ port, workspace, token, persist = null }) {
309
318
  await ensureDir(AGENT_DAG_DIR);
310
319
  const file = discoveryPath();
311
320
  const data = {
@@ -315,6 +324,12 @@ export async function writeDiscovery({ port, workspace, token }) {
315
324
  // Without this the hooks refuse to post: a file naming a port it cannot
316
325
  // authenticate is exactly the stale-file case they now decline to trust.
317
326
  token: token ?? "",
327
+ // Absolute path of the events log this deck appends to, or null under
328
+ // --no-persist. The hook reads it to elect a single writer per file:
329
+ // several decks receive the same event by design, and without this they
330
+ // each appended their own copy to the one log they share. See
331
+ // electWriters in hook/hook.js.
332
+ persist: persistField(persist),
318
333
  startedAt: new Date().toISOString(),
319
334
  };
320
335
  await writeFile(file, JSON.stringify(data, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
@@ -342,9 +357,13 @@ export function discoveryPath() {
342
357
  * is cheap (one small read), so the deck checks rather than assumes.
343
358
  *
344
359
  * A file this process wrote is left alone, mode included. Anything else — no
345
- * file, unreadable, another pid, a stale port or token — is replaced.
360
+ * file, unreadable, another pid, a stale port, token or events log — is
361
+ * replaced. Every field the hooks read is compared, the log path included:
362
+ * leave one out and a record missing it would pass as ours forever, which for
363
+ * the log path means the hook cannot tell which decks share a file and they all
364
+ * write their own copy of every event again.
346
365
  */
347
- export async function ensureDiscovery({ port, workspace, token }) {
366
+ export async function ensureDiscovery({ port, workspace, token, persist = null }) {
348
367
  const file = discoveryPath();
349
368
  try {
350
369
  const d = JSON.parse(stripBom(await readFile(file, "utf8")));
@@ -352,11 +371,12 @@ export async function ensureDiscovery({ port, workspace, token }) {
352
371
  && d.pid === process.pid
353
372
  && d.port === port
354
373
  && (d.workspace ?? "") === (workspace ?? "")
355
- && (d.token ?? "") === (token ?? "")) {
374
+ && (d.token ?? "") === (token ?? "")
375
+ && (d.persist ?? null) === persistField(persist)) {
356
376
  return { file, rewritten: false };
357
377
  }
358
378
  } catch { /* missing, unreadable or corrupt — rewritten below */ }
359
- await writeDiscovery({ port, workspace, token });
379
+ await writeDiscovery({ port, workspace, token, persist });
360
380
  return { file, rewritten: true };
361
381
  }
362
382
 
@@ -373,7 +393,7 @@ export async function ensureDiscovery({ port, workspace, token }) {
373
393
  * `stop()` must be called before the file is removed on shutdown — otherwise
374
394
  * the next tick would put it straight back.
375
395
  */
376
- export function keepDiscovery({ port, workspace, token, intervalMs = 5000, onState = null } = {}) {
396
+ export function keepDiscovery({ port, workspace, token, persist = null, intervalMs = 5000, onState = null } = {}) {
377
397
  // null until the first outcome, which therefore always differs and is always
378
398
  // reported — the caller learns where it stands before anything else happens.
379
399
  let healthy = null;
@@ -381,7 +401,7 @@ export function keepDiscovery({ port, workspace, token, intervalMs = 5000, onSta
381
401
  const check = async () => {
382
402
  let state;
383
403
  try {
384
- const { rewritten } = await ensureDiscovery({ port, workspace, token });
404
+ const { rewritten } = await ensureDiscovery({ port, workspace, token, persist });
385
405
  state = { ok: true, rewritten, file: discoveryPath(), error: null };
386
406
  } catch (err) {
387
407
  state = { ok: false, rewritten: false, file: discoveryPath(), error: err };