agent-dag 1.33.93 → 1.33.94

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-CaEUrDxR.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-DEsEm5QP.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-dwRx-2y0.css">
10
10
  </head>
11
11
  <body>
package/hook/hook.js CHANGED
@@ -116,6 +116,12 @@ function isAlive(pid) {
116
116
  *
117
117
  * The platform is a parameter, like cwdInWorkspace's, so the case-folding half
118
118
  * is testable from any machine.
119
+ *
120
+ * src/server/log-writer.mjs repeats this rule for the events no hook delivers —
121
+ * the ones the server builds itself from Codex's rollout files — because this
122
+ * script is copied out of the package and cannot import it. A test compares the
123
+ * two directly: they decide for the same decks, and a disagreement is a line
124
+ * written twice or not at all.
119
125
  */
120
126
  function electWriters(decks, platform = process.platform) {
121
127
  const byLog = new Map();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.33.93",
3
+ "version": "1.33.94",
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": {
@@ -4,12 +4,13 @@ import { createServer } from "node:http";
4
4
  import { readFile, stat, mkdir, appendFile, open, truncate, readdir, unlink } from "node:fs/promises";
5
5
  import { createReadStream, existsSync, readFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
- import { extname, join, resolve, dirname as pdirname, sep } from "node:path";
7
+ import { extname, join, resolve, dirname as pdirname } from "node:path";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { dirname } from "node:path";
10
10
  import { createInterface } from "node:readline";
11
11
  import { createHash, randomBytes } from "node:crypto";
12
12
  import { claudeConfigDir } from "./claude-dir.mjs";
13
+ import { codexCwdInWorkspace, writesCodexLog } from "./log-writer.mjs";
13
14
 
14
15
  const __dirname = dirname(fileURLToPath(import.meta.url));
15
16
  const PKG_ROOT = resolve(__dirname, "..", "..");
@@ -778,8 +779,9 @@ function maybeResolveCodex(payload) {
778
779
  // turn_context / response_item.model → model snapshot (ModelObserved on change)
779
780
  // Events are emitted with source "codex" so pushEvent skips the Claude-only
780
781
  // transcript enrichment (which needs transcript_path / hook events) but still
781
- // persists + broadcasts them exactly like a hook event. This path is entirely
782
- // additive — the Claude hook flow is untouched.
782
+ // broadcasts them exactly like a hook event, and persists them when this deck is
783
+ // the one elected to log this rollout — see writesCodexLog. This path is
784
+ // entirely additive — the Claude hook flow is untouched.
783
785
  const codexFileState = new Map(); // path -> { offset, sid, cwd, skip, seenAt }
784
786
  const codexSessionModel = new Map(); // sid -> last model string
785
787
  // How long a rollout's tail cursor is kept after it stops showing up in the
@@ -790,12 +792,32 @@ let codexScanRunning = false;
790
792
  let codexWatchTimer = null;
791
793
  let codexWorkspace = "";
792
794
 
793
- function codexCwdInWorkspace(cwd) {
794
- if (!codexWorkspace) return true;
795
- if (!cwd || typeof cwd !== "string") return false;
796
- const a = resolve(cwd).toLowerCase();
797
- const b = resolve(codexWorkspace).toLowerCase();
798
- return a === b || a.startsWith(b + sep.toLowerCase());
795
+ /**
796
+ * Every deck registered right now, as its own discovery record spells it.
797
+ *
798
+ * These are the files hook.js enumerates; this is the server reading them for
799
+ * itself, because the rollout watcher has no hook to do it and still has to know
800
+ * which other decks are tailing the same file into the same log. Dead pids are
801
+ * ignored rather than unlinked — sweepStaleDiscovery owns that, and a poll
802
+ * running every 1.5s is no place to be deleting other decks' registrations.
803
+ */
804
+ async function readLiveDecks() {
805
+ const dir = join(claudeConfigDir(), "agent-dag");
806
+ let files;
807
+ try { files = await readdir(dir); } catch { return []; }
808
+ const decks = [];
809
+ for (const f of files) {
810
+ if (!f.endsWith(".json")) continue;
811
+ try {
812
+ const d = JSON.parse(await readFile(join(dir, f), "utf8"));
813
+ // Without both numbers there is nothing to group or tie-break on, so the
814
+ // record cannot take part in an election either way.
815
+ if (!d || typeof d.pid !== "number" || typeof d.port !== "number") continue;
816
+ if (!isProcessAlive(d.pid)) continue;
817
+ decks.push(d);
818
+ } catch { /* corrupt, or gone between listing and read */ }
819
+ }
820
+ return decks;
799
821
  }
800
822
 
801
823
  // List rollout files from the newest 2 day-directories. New sessions always
@@ -912,17 +934,21 @@ function parseCodexOutput(raw) {
912
934
  }
913
935
  }
914
936
 
915
- function emitCodexEvent(payload) {
916
- pushEvent(payload, "codex");
937
+ // `persist` is false when another deck tailing this same rollout was elected to
938
+ // write it to the log they share. The event is still buffered and broadcast —
939
+ // every deck watching the session draws it — it is only the second copy on disk
940
+ // that is dropped. See writesCodexLog in log-writer.mjs.
941
+ function emitCodexEvent(payload, persist) {
942
+ pushEvent(payload, "codex", { persist });
917
943
  }
918
944
 
919
945
  // Emit the SessionStart root exactly once per file, lazily — only when the
920
946
  // session actually produces an event. This keeps long-dead sessions that were
921
947
  // merely on disk at startup from cluttering the canvas with empty roots.
922
- function ensureCodexRoot(state) {
948
+ function ensureCodexRoot(state, persist) {
923
949
  if (state.rootEmitted) return;
924
950
  state.rootEmitted = true;
925
- emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "SessionStart" });
951
+ emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "SessionStart" }, persist);
926
952
  }
927
953
 
928
954
  async function codexScanOnce(firstRun) {
@@ -930,6 +956,11 @@ async function codexScanOnce(firstRun) {
930
956
  codexScanRunning = true;
931
957
  try {
932
958
  const now = Date.now();
959
+ // Read at most once per scan, and only from the first rollout that actually
960
+ // has new bytes: most ticks read nothing and must not pay a directory
961
+ // listing for the answer to a question nothing is asking.
962
+ let decksRead = null;
963
+ const liveDecks = () => (decksRead ??= readLiveDecks());
933
964
  const files = await listRecentCodexRollouts();
934
965
  for (const path of files) {
935
966
  let st;
@@ -942,7 +973,7 @@ async function codexScanOnce(firstRun) {
942
973
  // capture it. Skip files outside our workspace.
943
974
  const header = await readCodexHeader(path);
944
975
  if (!header || !header.sid) continue; // not ready yet — retry next tick
945
- if (!codexCwdInWorkspace(header.cwd)) {
976
+ if (!codexCwdInWorkspace(header.cwd, codexWorkspace)) {
946
977
  codexFileState.set(path, { offset: st.size, sid: header.sid, cwd: header.cwd, skip: true, rootEmitted: false, seenAt: now });
947
978
  continue;
948
979
  }
@@ -966,6 +997,13 @@ async function codexScanOnce(firstRun) {
966
997
  const consume = text.slice(0, lastNl);
967
998
  state.offset += Buffer.byteLength(consume, "utf8") + 1; // +1 for the \n
968
999
 
1000
+ // Decided per file rather than per line: which decks are up, and which of
1001
+ // them tail this rollout, cannot change inside one batch of appended
1002
+ // lines, and the answer must be the same for every event in it — a root
1003
+ // written by one deck and its tool calls by another is worse than either.
1004
+ const persist = !persistPath
1005
+ || writesCodexLog({ decks: await liveDecks(), pid: process.pid, cwd: state.cwd });
1006
+
969
1007
  for (const line of consume.split("\n")) {
970
1008
  if (!line) continue;
971
1009
  let obj;
@@ -975,12 +1013,12 @@ async function codexScanOnce(firstRun) {
975
1013
  // If the model changed (turn_context/response_item), surface it.
976
1014
  const nowModel = codexSessionModel.get(state.sid);
977
1015
  if (nowModel && nowModel !== prevModel) {
978
- ensureCodexRoot(state);
979
- emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "ModelObserved", model: nowModel });
1016
+ ensureCodexRoot(state, persist);
1017
+ emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "ModelObserved", model: nowModel }, persist);
980
1018
  }
981
1019
  if (payload) {
982
- ensureCodexRoot(state);
983
- emitCodexEvent(payload);
1020
+ ensureCodexRoot(state, persist);
1021
+ emitCodexEvent(payload, persist);
984
1022
  }
985
1023
  }
986
1024
  }
@@ -314,7 +314,7 @@ function persistField(persist) {
314
314
  // ignores the mode (NTFS ACLs inherit from the profile directory, which is
315
315
  // already per-user), and a file left over from an earlier run under a recycled
316
316
  // pid keeps its old mode through writeFile, hence the explicit chmod.
317
- export async function writeDiscovery({ port, workspace, token, persist = null }) {
317
+ export async function writeDiscovery({ port, workspace, token, persist = null, codex = true }) {
318
318
  await ensureDir(AGENT_DAG_DIR);
319
319
  const file = discoveryPath();
320
320
  const data = {
@@ -330,6 +330,12 @@ export async function writeDiscovery({ port, workspace, token, persist = null })
330
330
  // each appended their own copy to the one log they share. See
331
331
  // electWriters in hook/hook.js.
332
332
  persist: persistField(persist),
333
+ // Is this deck tailing Codex's rollout files? Those events never pass
334
+ // through a hook, so the decks elect a writer for them among themselves —
335
+ // and a deck running --no-codex must be left out of that election rather
336
+ // than win it and record a rollout it is not even reading. See
337
+ // writesCodexLog in src/server/log-writer.mjs.
338
+ codex: codex !== false,
333
339
  startedAt: new Date().toISOString(),
334
340
  };
335
341
  await writeFile(file, JSON.stringify(data, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
@@ -357,13 +363,13 @@ export function discoveryPath() {
357
363
  * is cheap (one small read), so the deck checks rather than assumes.
358
364
  *
359
365
  * A file this process wrote is left alone, mode included. Anything else — no
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.
366
+ * file, unreadable, another pid, a stale port, token, events log or Codex
367
+ * setting — is replaced. Every field another deck decides by is compared, the
368
+ * log path included: leave one out and a record missing it would pass as ours
369
+ * forever, which for the log path means no deck can tell which of them share a
370
+ * file and they all write their own copy of every event again.
365
371
  */
366
- export async function ensureDiscovery({ port, workspace, token, persist = null }) {
372
+ export async function ensureDiscovery({ port, workspace, token, persist = null, codex = true }) {
367
373
  const file = discoveryPath();
368
374
  try {
369
375
  const d = JSON.parse(stripBom(await readFile(file, "utf8")));
@@ -372,11 +378,12 @@ export async function ensureDiscovery({ port, workspace, token, persist = null }
372
378
  && d.port === port
373
379
  && (d.workspace ?? "") === (workspace ?? "")
374
380
  && (d.token ?? "") === (token ?? "")
375
- && (d.persist ?? null) === persistField(persist)) {
381
+ && (d.persist ?? null) === persistField(persist)
382
+ && d.codex === (codex !== false)) {
376
383
  return { file, rewritten: false };
377
384
  }
378
385
  } catch { /* missing, unreadable or corrupt — rewritten below */ }
379
- await writeDiscovery({ port, workspace, token, persist });
386
+ await writeDiscovery({ port, workspace, token, persist, codex });
380
387
  return { file, rewritten: true };
381
388
  }
382
389
 
@@ -393,7 +400,7 @@ export async function ensureDiscovery({ port, workspace, token, persist = null }
393
400
  * `stop()` must be called before the file is removed on shutdown — otherwise
394
401
  * the next tick would put it straight back.
395
402
  */
396
- export function keepDiscovery({ port, workspace, token, persist = null, intervalMs = 5000, onState = null } = {}) {
403
+ export function keepDiscovery({ port, workspace, token, persist = null, codex = true, intervalMs = 5000, onState = null } = {}) {
397
404
  // null until the first outcome, which therefore always differs and is always
398
405
  // reported — the caller learns where it stands before anything else happens.
399
406
  let healthy = null;
@@ -401,7 +408,7 @@ export function keepDiscovery({ port, workspace, token, persist = null, interval
401
408
  const check = async () => {
402
409
  let state;
403
410
  try {
404
- const { rewritten } = await ensureDiscovery({ port, workspace, token, persist });
411
+ const { rewritten } = await ensureDiscovery({ port, workspace, token, persist, codex });
405
412
  state = { ok: true, rewritten, file: discoveryPath(), error: null };
406
413
  } catch (err) {
407
414
  state = { ok: false, rewritten: false, file: discoveryPath(), error: err };
@@ -0,0 +1,135 @@
1
+ // Which of the running decks writes an event to the log they share?
2
+ //
3
+ // The hook already answers that for the events it delivers: it groups the decks
4
+ // it is about to post to by the log file each one names in its discovery record,
5
+ // elects one per file, and marks the request to every other one `?persist=0`.
6
+ // See electWriters in hook/hook.js.
7
+ //
8
+ // The Codex rollout watcher never goes through the hook — it builds its events
9
+ // inside the server by tailing ~/.codex/sessions/**/rollout-*.jsonl — so nothing
10
+ // suppressed the copies on that path: every deck tailing the same rollout
11
+ // appended its own line to the one events.jsonl they all default to, so each
12
+ // Codex tool call, prompt and session start landed there once per running deck.
13
+ // That is the duplication the hook election was added to end, still open on the
14
+ // path that is the only Codex capture there is on Windows, where Codex hooks
15
+ // never fire at all.
16
+ //
17
+ // So the server runs the same election, over the same discovery records, with
18
+ // the same tie-break. The rule is repeated here rather than imported from
19
+ // hook/hook.js because that file is copied out of the package and run standalone
20
+ // by the host CLI, with no path back to the module it came from — the same
21
+ // reason it re-derives the Claude config dir inline. The two copies are pinned
22
+ // equal by a test, as challengeProof's pair already is.
23
+ import { win32, posix } from "node:path";
24
+
25
+ /**
26
+ * Does this platform's filesystem treat two spellings that differ only in case
27
+ * as the same file? The platform is a parameter so both answers can be checked
28
+ * from either kind of machine.
29
+ *
30
+ * Windows always does, and macOS does by default (APFS and HFS+ are formatted
31
+ * case-insensitive unless the user deliberately chose otherwise). Linux does
32
+ * not, and folding case there would be a bug of its own: /srv/a/events.jsonl and
33
+ * /srv/A/events.jsonl are two real files, each of which needs a writer.
34
+ */
35
+ export const foldsCase = (platform = process.platform) =>
36
+ platform === "win32" || platform === "darwin";
37
+
38
+ /**
39
+ * Of these decks, which ones write to disk? Returns the subset that should;
40
+ * every other one is expected to draw the event and keep no record of it.
41
+ *
42
+ * Decks are grouped by the log file each one names in its discovery record and
43
+ * one deck per group is elected. Grouping by the file rather than counting decks
44
+ * is what keeps the overrides honest: a deck run with `--history` sits alone in
45
+ * its own group and always writes, a deck run with `--no-persist` reports no
46
+ * file and can never be elected to write for one that does, and a deck too old
47
+ * to report either keeps the behaviour it had before this rule existed. Within a
48
+ * group the lowest port wins — a fixed rule, so the same deck holds the file for
49
+ * as long as it is up and the next one inherits it as soon as that deck is gone.
50
+ *
51
+ * Kept byte-for-byte equivalent to electWriters in hook/hook.js: the two decide
52
+ * for the same decks over the same records, and a disagreement between them
53
+ * means one log line written twice or none at all.
54
+ */
55
+ export function electWriters(decks, platform = process.platform) {
56
+ const byLog = new Map();
57
+ for (const d of decks) {
58
+ const log = typeof d.persist === "string" ? d.persist : "";
59
+ // Two namespaces, so a deck with no log to share — and a deck too old to
60
+ // report one — is alone in its group and cannot collide with a real path.
61
+ const key = log
62
+ ? `log:${foldsCase(platform) ? log.toLowerCase() : log}`
63
+ : `deck:${d.pid}:${d.port}`;
64
+ const held = byLog.get(key);
65
+ // Ports are unique among live decks; pid only breaks a tie a stale
66
+ // discovery file could invent, so the answer stays deterministic.
67
+ if (!held || d.port < held.port || (d.port === held.port && d.pid < held.pid)) {
68
+ byLog.set(key, d);
69
+ }
70
+ }
71
+ return new Set(byLog.values());
72
+ }
73
+
74
+ /**
75
+ * Would a deck scoped to `workspace` capture a rollout running in `cwd`? An
76
+ * empty workspace is unscoped and captures every session.
77
+ *
78
+ * Case is folded on every platform here, which the hook's own cwdInWorkspace
79
+ * deliberately does not do. That stays as it was, because this predicate now
80
+ * answers two questions with one function: whether THIS deck tails a rollout,
81
+ * and whether another deck tails it too. Model the other deck's capture with a
82
+ * rule stricter than the one it actually runs and the election covers the wrong
83
+ * set — a deck that writes without being elected, or an elected deck that never
84
+ * saw the file. Being wrong in the over-matching direction on Linux costs a deck
85
+ * a sibling tree it was not scoped to; being wrong in either direction here
86
+ * costs a duplicated log line or a lost one.
87
+ *
88
+ * The platform is a parameter, following the hook's cwdInWorkspace and
89
+ * spawnSpec in src/server/exec.mjs, so the Windows separator is testable from a
90
+ * POSIX machine.
91
+ */
92
+ export function codexCwdInWorkspace(cwd, workspace, platform = process.platform) {
93
+ if (!workspace || typeof workspace !== "string") return true;
94
+ if (!cwd || typeof cwd !== "string") return false;
95
+ const p = platform === "win32" ? win32 : posix;
96
+ const a = p.resolve(cwd).toLowerCase();
97
+ const b = p.resolve(workspace).toLowerCase();
98
+ if (a === b) return true;
99
+ // A root ("C:\", "/") already ends in the separator; appending a second one
100
+ // would match nothing.
101
+ return a.startsWith(b.endsWith(p.sep) ? b : b + p.sep);
102
+ }
103
+
104
+ /**
105
+ * Does this deck append a rollout's events to its log, or is another deck doing
106
+ * it? `decks` is every deck registered right now, `pid` identifies this one
107
+ * among them, and `cwd` is the workspace the rollout is running in.
108
+ *
109
+ * The group is every deck that tails this same rollout — the hook's targets are
110
+ * narrowed to the longest workspace match, but nothing narrows these: each deck
111
+ * decides for itself, so all of them whose workspace contains the cwd read the
112
+ * file and all of them would write it. A deck started with `--no-codex` tails
113
+ * nothing and is left out; electing it would mean the rollout's events reach no
114
+ * log at all. A deck too old to say either way is assumed to be tailing, which
115
+ * is what it was doing before this field existed.
116
+ */
117
+ export function writesCodexLog({ decks, pid, cwd, platform = process.platform }) {
118
+ const live = Array.isArray(decks) ? decks : [];
119
+ const self = live.find(d => d && d.pid === pid) ?? null;
120
+ // No record of our own on disk — the window before the first heartbeat writes
121
+ // it, or a deck that cannot write one at all. Nobody can elect us and we
122
+ // cannot see who else is here, so keep what this deck did before the election
123
+ // existed and write. A line written twice is recoverable; a deck that quietly
124
+ // stops recording anything is not.
125
+ if (!self || typeof self.persist !== "string" || self.persist === "") return true;
126
+
127
+ const group = [self];
128
+ for (const d of live) {
129
+ if (!d || d.pid === self.pid) continue;
130
+ if (d.codex === false) continue;
131
+ if (!codexCwdInWorkspace(cwd, d.workspace ?? "", platform)) continue;
132
+ group.push(d);
133
+ }
134
+ return electWriters(group, platform).has(self);
135
+ }