agent-dag 1.34.6 → 1.35.0

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.
@@ -40,7 +40,7 @@
40
40
  document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
41
41
  })();
42
42
  </script>
43
- <script type="module" crossorigin src="/assets/index-G1Xt6iC6.js"></script>
43
+ <script type="module" crossorigin src="/assets/index-CMZYhML1.js"></script>
44
44
  <link rel="stylesheet" crossorigin href="/assets/index-CRPobBZf.css">
45
45
  </head>
46
46
  <body>
package/hook/hook.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  // agent-dag hook forwarder. Invoked by Claude Code or Codex CLI as a command
3
3
  // hook. Reads stdin (event JSON), tags it with the provider passed via
4
- // `--provider <name>`, finds the matching agent-dag server via per-workspace
5
- // discovery files in <claude config dir>/agent-dag/, makes that server prove it
6
- // is the deck the file describes, and POSTs the payload. Dead instances are
7
- // cleaned up.
4
+ // `--provider <name>`, finds every agent-dag server whose workspace contains the
5
+ // session — via the discovery files in <claude config dir>/agent-dag/ makes
6
+ // each one prove it is the deck its file describes, and POSTs the payload. Dead
7
+ // instances are cleaned up.
8
8
  "use strict";
9
9
 
10
10
  const fs = require("fs");
@@ -86,6 +86,36 @@ function cwdInWorkspace(cwd, workspace, platform = process.platform) {
86
86
  return a.startsWith(b.endsWith(p.sep) ? b : b + p.sep);
87
87
  }
88
88
 
89
+ /**
90
+ * Does a deck scoped to `workspace` capture a session running in `cwd`? This is
91
+ * the whole of what `--workspace` means, and it is a question about ONE deck: it
92
+ * asks nothing about the others that may also be up, so a deck's answer never
93
+ * depends on who else is running.
94
+ *
95
+ * An empty workspace is the default — machine-wide — and captures everything.
96
+ * It is answered before cwdInWorkspace rather than passed to it because
97
+ * p.resolve("") is the resolving process's own cwd, which here is the agent's,
98
+ * so an unscoped deck would be silently scoped to whatever directory the user
99
+ * happened to run their agent in.
100
+ *
101
+ * A session that never said where it runs is inside no workspace, so only an
102
+ * unscoped deck sees it. Unreachable from main(), which exits before this on a
103
+ * payload with no cwd — it is here because the rule has to be stated the same
104
+ * way on both sides to be pinned against the other one.
105
+ *
106
+ * src/server/log-writer.mjs answers this same question, for the sessions the
107
+ * server builds itself out of Codex's rollout files, under the name
108
+ * codexCwdInWorkspace — this script is copied out of the package and run
109
+ * standalone, so it cannot import that copy. A test walks one table of paths
110
+ * through both: a disagreement between them is `--workspace` meaning two
111
+ * different things depending on which CLI produced the session.
112
+ */
113
+ function capturesSession(cwd, workspace, platform = process.platform) {
114
+ if (!workspace || typeof workspace !== "string") return true;
115
+ if (!cwd || typeof cwd !== "string") return false;
116
+ return cwdInWorkspace(cwd, workspace, platform);
117
+ }
118
+
89
119
  function isAlive(pid) {
90
120
  try { process.kill(pid, 0); return true; }
91
121
  catch (e) { return e && e.code === "EPERM"; }
@@ -315,7 +345,24 @@ function main() {
315
345
  } catch { return process.exit(0); }
316
346
  if (!files.length) return process.exit(0);
317
347
 
318
- const matches = [];
348
+ // Every deck whose workspace contains this cwd, and nothing else decides it.
349
+ //
350
+ // This used to sort the matches by how long each deck's workspace path was
351
+ // and deliver only to the longest — so a deck scoped to /Users/x/proj TOOK
352
+ // that tree's sessions away from a machine-wide deck, which then sat there
353
+ // showing nothing while `--all` promised it captured every session on this
354
+ // machine. Nothing documented that, and the server's own Codex capture never
355
+ // did it: each deck tails the rollout files itself and evaluates its own
356
+ // workspace, so a Codex session inside a scoped tree appeared on both decks
357
+ // while the Claude session beside it appeared on one. One flag, one path,
358
+ // two answers.
359
+ //
360
+ // The fan-out is the documented meaning and the one kept: `--workspace` says
361
+ // which sessions a deck captures, not which sessions it takes from the decks
362
+ // around it. It is also what electWriters below already assumes — several
363
+ // decks drawing one event is the case it exists to keep from being written
364
+ // to one log several times.
365
+ const targets = [];
319
366
  for (const file of files) {
320
367
  let d;
321
368
  try { d = JSON.parse(fs.readFileSync(path.join(DIR, file), "utf8")); } catch { continue; }
@@ -329,29 +376,25 @@ function main() {
329
376
  continue;
330
377
  }
331
378
 
332
- if (d.workspace === "") {
333
- matches.push({ d, wsLen: 0 });
334
- continue;
335
- }
336
- const ws = normPath(d.workspace);
337
- if (cwdInWorkspace(resolvedCwd, ws)) {
338
- matches.push({ d, wsLen: ws.length });
339
- }
379
+ // "" is machine-wide and must never reach normPath: resolving it would
380
+ // produce this hook's own cwd — the agent's — and scope a deck that asked
381
+ // for no scope at all. Any other spelling is canonicalized here, which is
382
+ // now a second pass over a path bin/deck.js already canonicalized before
383
+ // publishing it — kept because a deck old enough to have published a
384
+ // relative one is still entitled to its events.
385
+ const ws = d.workspace === "" ? "" : normPath(d.workspace);
386
+ if (capturesSession(resolvedCwd, ws)) targets.push(d);
340
387
  }
341
388
 
342
- if (!matches.length) return process.exit(0);
343
-
344
- matches.sort((a, b) => b.wsLen - a.wsLen);
345
- const bestLen = matches[0].wsLen;
346
- const targets = matches.filter(m => m.wsLen === bestLen);
389
+ if (!targets.length) return process.exit(0);
347
390
 
348
391
  // One deck per events log records this event; the others only draw it.
349
- const writers = electWriters(targets.map(m => m.d));
392
+ const writers = electWriters(targets);
350
393
 
351
394
  let pending = targets.length;
352
395
  const done = () => { if (--pending <= 0) process.exit(0); };
353
396
 
354
- for (const { d } of targets) deliver(d, taggedInput, writers.has(d), done);
397
+ for (const d of targets) deliver(d, taggedInput, writers.has(d), done);
355
398
  });
356
399
  }
357
400
 
@@ -360,5 +403,5 @@ function main() {
360
403
  // require() it exports the rules it decides by — matching, election, the
361
404
  // handshake — and starts nothing, which is what lets them be tested without a
362
405
  // 1.5s exit timer in the test runner.
363
- module.exports = { cwdInWorkspace, foldsCase, electWriters, challengeProof, requiresProof };
406
+ module.exports = { capturesSession, cwdInWorkspace, foldsCase, electWriters, challengeProof, requiresProof };
364
407
  if (require.main === module) main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.34.6",
3
+ "version": "1.35.0",
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": {
@@ -2,7 +2,7 @@
2
2
  // Single-file pure Node HTTP server, zero deps.
3
3
  import { createServer } from "node:http";
4
4
  import { readFile, stat, mkdir, appendFile, open, truncate, readdir, unlink } from "node:fs/promises";
5
- import { createReadStream, existsSync, readFileSync } from "node:fs";
5
+ import { createReadStream, existsSync, readFileSync, realpathSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { extname, join, resolve, dirname as pdirname } from "node:path";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -1918,6 +1918,37 @@ async function handleSoundHookSet(req, res) {
1918
1918
  // can read before a single event has arrived.
1919
1919
  let _workspace = "";
1920
1920
 
1921
+ /**
1922
+ * The one spelling of `--workspace` everything downstream compares against.
1923
+ * Empty — including a value that is nothing but spaces — stays empty, which is
1924
+ * how every reader of it says "machine-wide".
1925
+ *
1926
+ * Called once, in bin/deck.js, where the flag arrives: that process's cwd is the
1927
+ * shell the user typed the command in, and it is the only process on either
1928
+ * capture path whose cwd is the one a relative `--workspace ./sub` was written
1929
+ * against. The raw string used to go straight into the discovery file, and
1930
+ * hook.js resolved it inside its own process — which the host CLI runs with the
1931
+ * AGENT's cwd — so `--workspace ./sub` scoped Claude sessions to a `sub`
1932
+ * directory under whatever the agent happened to be working in, a different
1933
+ * directory per agent and none of them the one asked for. The Codex path
1934
+ * resolved the same string in the server process and was right. Resolving here
1935
+ * makes both of them right, and for the same reason bin/deck.js already resolves
1936
+ * the events log before publishing it: what goes in the discovery file is read
1937
+ * by other processes that cannot reconstruct the context it was written in.
1938
+ *
1939
+ * Symlinks are resolved too, because a process's cwd — which is what both
1940
+ * providers report — comes from getcwd() and has none left in it. Without this,
1941
+ * `--workspace /tmp/proj` on a Mac is scoped to /tmp/proj while every session
1942
+ * inside it reports /private/tmp/proj, and the deck stays empty. A path that
1943
+ * does not exist yet keeps its resolved form rather than failing: scoping a deck
1944
+ * to a directory you are about to create is not an error.
1945
+ */
1946
+ export function canonicalWorkspace(raw) {
1947
+ if (typeof raw !== "string" || raw.trim() === "") return "";
1948
+ const abs = resolve(raw);
1949
+ try { return realpathSync(abs); } catch { return abs; }
1950
+ }
1951
+
1921
1952
  function handleHealth(_req, res) {
1922
1953
  send(res, 200, {
1923
1954
  ok: true,
@@ -73,17 +73,29 @@ export function electWriters(decks, platform = process.platform) {
73
73
 
74
74
  /**
75
75
  * Would a deck scoped to `workspace` capture a rollout running in `cwd`? An
76
- * empty workspace is unscoped and captures every session.
76
+ * empty workspace is unscoped and captures every session; a rollout that never
77
+ * said where it runs is inside no workspace, so only an unscoped deck draws it.
77
78
  *
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.
79
+ * This answers two questions with one function whether THIS deck tails a
80
+ * rollout, and whether another deck tails it too and that is only sound while
81
+ * the rule below is the rule every deck actually runs. Model another deck's
82
+ * capture with anything else and the election covers the wrong set: a deck that
83
+ * writes without being elected, or an elected deck that never opened the file.
84
+ *
85
+ * It is also the rule hook/hook.js runs for the sessions it delivers, under the
86
+ * name capturesSession that script is copied out of the package and run
87
+ * standalone, so the two are written twice and pinned equal by a test walking
88
+ * one table of paths through both. They were not equal: case was folded here on
89
+ * every platform, so on Linux a deck scoped to /srv/proj captured Codex sessions
90
+ * from /srv/Proj and Claude sessions from neither. Those are two real
91
+ * directories there, and the hook's own comment says what folding them together
92
+ * costs — a deck handed the events of a tree it was not scoped to. So the fold
93
+ * is per-platform on both sides now, and `--workspace` means one thing.
94
+ *
95
+ * (The narrow window that opens: two decks on Linux whose workspaces differ only
96
+ * in case, one of them old enough to still fold, both containing one rollout's
97
+ * cwd. Each models the other as tailing the file; one of them is wrong, and the
98
+ * cost is a single log line written twice.)
87
99
  *
88
100
  * The platform is a parameter, following the hook's cwdInWorkspace and
89
101
  * spawnSpec in src/server/exec.mjs, so the Windows separator is testable from a
@@ -93,8 +105,9 @@ export function codexCwdInWorkspace(cwd, workspace, platform = process.platform)
93
105
  if (!workspace || typeof workspace !== "string") return true;
94
106
  if (!cwd || typeof cwd !== "string") return false;
95
107
  const p = platform === "win32" ? win32 : posix;
96
- const a = p.resolve(cwd).toLowerCase();
97
- const b = p.resolve(workspace).toLowerCase();
108
+ const fold = s => (foldsCase(platform) ? s.toLowerCase() : s);
109
+ const a = fold(p.resolve(cwd));
110
+ const b = fold(p.resolve(workspace));
98
111
  if (a === b) return true;
99
112
  // A root ("C:\", "/") already ends in the separator; appending a second one
100
113
  // would match nothing.
@@ -106,13 +119,15 @@ export function codexCwdInWorkspace(cwd, workspace, platform = process.platform)
106
119
  * it? `decks` is every deck registered right now, `pid` identifies this one
107
120
  * among them, and `cwd` is the workspace the rollout is running in.
108
121
  *
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.
122
+ * The group is every deck that tails this same rollout: each deck decides for
123
+ * itself, so all of them whose workspace contains the cwd read the file and all
124
+ * of them would write it. The hook builds the same group the same way for the
125
+ * events it delivers it used to narrow them to the longest workspace match
126
+ * first, which is the asymmetry the predicate above describes the end of. A deck
127
+ * started with `--no-codex` tails nothing and is left out; electing it would
128
+ * mean the rollout's events reach no log at all. A deck too old to say either
129
+ * way is assumed to be tailing, which is what it was doing before this field
130
+ * existed.
116
131
  */
117
132
  export function writesCodexLog({ decks, pid, cwd, platform = process.platform }) {
118
133
  const live = Array.isArray(decks) ? decks : [];