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.
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-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
|
-
|
|
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
|
|
302
|
-
//
|
|
303
|
-
|
|
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.
|
|
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": {
|
package/src/server/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/server/installer.mjs
CHANGED
|
@@ -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
|
|
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 };
|