agent-dag 3.22.1 → 3.22.4

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.
Files changed (70) hide show
  1. package/README.md +6 -477
  2. package/package.json +14 -48
  3. package/shim.js +107 -0
  4. package/LICENSE +0 -661
  5. package/LICENSING.md +0 -82
  6. package/THIRD_PARTY_NOTICES.md +0 -395
  7. package/bin/agent-dag.js +0 -626
  8. package/bin/deck.js +0 -1805
  9. package/dist/web/assets/index-CJYsv0lr.css +0 -1
  10. package/dist/web/assets/index-Ifm23DDC.js +0 -270
  11. package/dist/web/index.html +0 -49
  12. package/hook/hook.js +0 -542
  13. package/release-notes.json +0 -398
  14. package/src/server/activity.mjs +0 -52
  15. package/src/server/agent-activity.mjs +0 -522
  16. package/src/server/args.mjs +0 -183
  17. package/src/server/auto-update.mjs +0 -79
  18. package/src/server/block-notify.mjs +0 -173
  19. package/src/server/boot-deadline.mjs +0 -127
  20. package/src/server/brand.mjs +0 -16
  21. package/src/server/browser-history.mjs +0 -497
  22. package/src/server/browser-presence.mjs +0 -211
  23. package/src/server/browser-profiles.mjs +0 -279
  24. package/src/server/browser-react.mjs +0 -284
  25. package/src/server/browser-watch-store.mjs +0 -350
  26. package/src/server/browser-watch.mjs +0 -905
  27. package/src/server/ccusage.mjs +0 -1168
  28. package/src/server/claude-accounts.mjs +0 -951
  29. package/src/server/claude-dir.mjs +0 -213
  30. package/src/server/codex-auth.mjs +0 -388
  31. package/src/server/codex-dir.mjs +0 -171
  32. package/src/server/codex-quota.mjs +0 -449
  33. package/src/server/codex-usage.mjs +0 -512
  34. package/src/server/cswap-admin.mjs +0 -1562
  35. package/src/server/cswap-auto.mjs +0 -658
  36. package/src/server/cswap-install.mjs +0 -641
  37. package/src/server/deck-home.mjs +0 -243
  38. package/src/server/deck-prefs.mjs +0 -301
  39. package/src/server/deck-probe.mjs +0 -111
  40. package/src/server/detach.mjs +0 -244
  41. package/src/server/exec.mjs +0 -996
  42. package/src/server/global-install.mjs +0 -67
  43. package/src/server/hwmonitor.mjs +0 -56
  44. package/src/server/index.mjs +0 -6043
  45. package/src/server/installer.mjs +0 -912
  46. package/src/server/invoked-as.mjs +0 -144
  47. package/src/server/lan-about.mjs +0 -119
  48. package/src/server/lan-engine.mjs +0 -952
  49. package/src/server/lan-reach.mjs +0 -256
  50. package/src/server/lan-socket.mjs +0 -682
  51. package/src/server/lan-sync.mjs +0 -941
  52. package/src/server/lhm-parse.mjs +0 -91
  53. package/src/server/log-tail.mjs +0 -139
  54. package/src/server/log-writer.mjs +0 -322
  55. package/src/server/login-service.mjs +0 -473
  56. package/src/server/macmon.mjs +0 -310
  57. package/src/server/npx.mjs +0 -264
  58. package/src/server/open-url.mjs +0 -242
  59. package/src/server/presence.mjs +0 -40
  60. package/src/server/quota.mjs +0 -792
  61. package/src/server/relay-guard.mjs +0 -507
  62. package/src/server/reset-label.mjs +0 -78
  63. package/src/server/retire-sound-hook.mjs +0 -349
  64. package/src/server/running-deck.mjs +0 -234
  65. package/src/server/self-update.mjs +0 -1380
  66. package/src/server/stop-deck.mjs +0 -171
  67. package/src/server/supervisor.mjs +0 -392
  68. package/src/server/system-metrics.mjs +0 -1825
  69. package/src/server/term.mjs +0 -686
  70. package/src/server/uv-bootstrap.mjs +0 -337
@@ -1,49 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>ccdeck</title>
7
- <!-- The resting pair, and only the resting pair. Both are parsed before any
8
- module runs, so this is the deck at boot — nothing waiting, nothing
9
- known to be running — and App.tsx's ambient effect corrects both within
10
- the first frame it has state for. The title is the one copy of the name
11
- that no constant can reach (display-name.test.ts pins it to PRODUCT);
12
- the href is ambient.ts's own idle mark, generated by FAVICON_HREF and
13
- pinned to it by ambient-signal.test.ts, which is why it is an
14
- unreadable percent-encoded run rather than something to hand-edit. It
15
- was a `<text>◉</text>` glyph until #338 — a font-dependent shape that
16
- renders at three different weights on the three platforms and as tofu
17
- where the codepoint is missing, which stopped being acceptable the
18
- moment the icon started carrying state. -->
19
- <link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Ccircle%20cx%3D%2216%22%20cy%3D%2216%22%20r%3D%2212%22%20fill%3D%22none%22%20stroke%3D%22%237e828c%22%20stroke-width%3D%225%22%2F%3E%3C%2Fsvg%3E" />
20
- <!-- The theme, before the first frame rather than after it.
21
- styles.css keys every colour off :root[data-theme=…] and reads a
22
- missing attribute as dark, so a light-theme user whose preference is
23
- applied by a React effect watches a fully painted dark deck first —
24
- native scrollbars and form controls included, because that block also
25
- declares color-scheme: dark. The bundle cannot fix this: it is a module
26
- script, so it is deferred and the paint can happen while it is still
27
- being fetched. This is a classic inline script, which means the parser
28
- stops here, inside <head>, before any frame exists — and it is what
29
- Vite leaves untouched in the built HTML (it rewrites only scripts with
30
- a src or type="module"). Deliberately dependency-free, and kept honest
31
- against resolveTheme() in theme.ts by theme-first-paint.test.ts, which
32
- runs this exact text. -->
33
- <script>
34
- (function () {
35
- var stored = null;
36
- // `window.localStorage` is a GETTER that throws where site data is
37
- // blocked, so the property read is inside the try, not just getItem.
38
- // A refused store must cost a preference and never the boot.
39
- try { stored = window.localStorage.getItem("agent-dag.theme"); } catch (e) {}
40
- document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
41
- })();
42
- </script>
43
- <script type="module" crossorigin src="/assets/index-Ifm23DDC.js"></script>
44
- <link rel="stylesheet" crossorigin href="/assets/index-CJYsv0lr.css">
45
- </head>
46
- <body>
47
- <div id="root"></div>
48
- </body>
49
- </html>
package/hook/hook.js DELETED
@@ -1,542 +0,0 @@
1
- #!/usr/bin/env node
2
- // agent-dag hook forwarder. Invoked by Claude Code or Codex CLI as a command
3
- // hook. Reads stdin (event JSON), tags it with the provider passed via
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
- "use strict";
9
-
10
- const fs = require("fs");
11
- const path = require("path");
12
- const http = require("http");
13
- const os = require("os");
14
- const crypto = require("crypto");
15
-
16
- // Single shared discovery dir — Claude Code and Codex CLI both register here
17
- // via the installer. Lets one running agent-dag server receive both providers.
18
- //
19
- // This has to name the same directory src/server/claude-dir.mjs does, because
20
- // the installer writes the files read below. It is duplicated rather than
21
- // imported because this script is copied out of the package and run standalone
22
- // by the host CLI, with no path back to the module it came from.
23
- const configOverride = (process.env.CLAUDE_CONFIG_DIR || "").trim();
24
- const CLAUDE_DIR = configOverride
25
- ? path.resolve(configOverride)
26
- : path.join(os.homedir(), ".claude");
27
- const DIR = path.join(CLAUDE_DIR, "agent-dag");
28
-
29
- function parseProvider(argv) {
30
- for (let i = 0; i < argv.length; i++) {
31
- if (argv[i] === "--provider" && i + 1 < argv.length) return argv[i + 1];
32
- }
33
- return "claude";
34
- }
35
- const PROVIDER = parseProvider(process.argv.slice(2));
36
-
37
- /**
38
- * The one spelling of a directory, so that a path this process reports and a
39
- * path bin/deck.js published can be compared as strings.
40
- *
41
- * Resolving symlinks is the half that is easy to think you can skip, because on
42
- * POSIX a cwd comes from getcwd(3) and has none left in it. Windows has no such
43
- * guarantee — GetCurrentDirectoryW returns the string the directory was set
44
- * with, junction, `subst` drive and all — so a workspace reached that way only
45
- * matches if BOTH sides go through here. The server's rollout watcher keeps its
46
- * own copy of this rule under the name canonicalCwd, for the Codex sessions that
47
- * never reach this file; a test walks one path through both. A path that does
48
- * not resolve keeps its resolved form, which is also what canonicalWorkspace
49
- * does with a directory the user has not created yet.
50
- *
51
- * `.native` IS THE RULE, not a detail. fs.realpathSync is a JavaScript
52
- * lstat-and-readlink walk that resolves symlinks and junctions and nothing else;
53
- * fs.realpathSync.native is GetFinalPathNameByHandleW, which also expands a DOS
54
- * 8.3 short component to its long form. This used to call the plain one while
55
- * the server's canonicalCwd went through the native one, so the moment a path
56
- * arrived short — `%TEMP%` under a shortened profile directory, which is what
57
- * every GitHub Windows runner has — the two canonicalisers that exist to agree
58
- * disagreed by a whole path: C:\Users\RUNNER~1\… against C:\Users\runneradmin\….
59
- * canonicalWorkspace in src/server/index.mjs says the rest of it, including why
60
- * the long form is the canonical one; all three sites name `.native` out loud.
61
- *
62
- * Exported for that test: it is half of what `--workspace` means, and a
63
- * predicate handed an already-canonical path cannot show that the caller
64
- * canonicalises.
65
- */
66
- function normPath(p) {
67
- let r = path.resolve(p);
68
- try { r = fs.realpathSync.native(r); } catch {}
69
- return r;
70
- }
71
-
72
- /**
73
- * Does this platform's filesystem treat two spellings that differ only in case
74
- * as the same directory? Exported for tests: the platform is a parameter so
75
- * both answers can be checked from either kind of machine.
76
- *
77
- * Windows always does, and macOS does by default (APFS and HFS+ are formatted
78
- * case-insensitive unless the user deliberately chose otherwise). Linux does
79
- * not, and folding case there would be a bug of its own: /srv/Proj and
80
- * /srv/proj are two real directories, and a deck scoped to one must not be
81
- * handed the other's events.
82
- *
83
- * A case-sensitive macOS volume is therefore over-matched. That is the safe
84
- * direction to be wrong in — the cost is a deck that also sees a sibling tree
85
- * it was not scoped to, against the cost of the default configuration seeing
86
- * nothing at all.
87
- */
88
- const foldsCase = (platform = process.platform) =>
89
- platform === "win32" || platform === "darwin";
90
-
91
- /**
92
- * Is `cwd` the workspace directory or somewhere inside it?
93
- *
94
- * Both sides arrive already resolved, but resolved is not the same as
95
- * comparable. Neither path.resolve nor the JS fs.realpathSync canonicalizes
96
- * character case, so the drive letter and every component keep whatever case
97
- * the process that reported them happened to use — `c:\proj` from one shell,
98
- * `C:\Proj` from another, for one directory. A raw === / startsWith then says
99
- * "not in the workspace", the hook posts to nobody, and a scoped deck stays
100
- * empty with no error printed anywhere. Re-resolving through the platform's
101
- * own path flavour also settles separators and a trailing one, so
102
- * `C:/proj/` and `C:\proj` compare equal too.
103
- *
104
- * The platform is a parameter, following spawnSpec/isBatch in
105
- * src/server/exec.mjs, so the Windows rule is testable from a POSIX machine.
106
- */
107
- function cwdInWorkspace(cwd, workspace, platform = process.platform) {
108
- const p = platform === "win32" ? path.win32 : path.posix;
109
- const fold = s => (foldsCase(platform) ? s.toLowerCase() : s);
110
- const a = fold(p.resolve(cwd));
111
- const b = fold(p.resolve(workspace));
112
- if (a === b) return true;
113
- // A root ("C:\", "/") already ends in the separator; appending a second one
114
- // would match nothing.
115
- return a.startsWith(b.endsWith(p.sep) ? b : b + p.sep);
116
- }
117
-
118
- /**
119
- * Does a deck scoped to `workspace` capture a session running in `cwd`? This is
120
- * the whole of what `--workspace` means, and it is a question about ONE deck: it
121
- * asks nothing about the others that may also be up, so a deck's answer never
122
- * depends on who else is running.
123
- *
124
- * An empty workspace is the default — machine-wide — and captures everything.
125
- * It is answered before cwdInWorkspace rather than passed to it because
126
- * p.resolve("") is the resolving process's own cwd, which here is the agent's,
127
- * so an unscoped deck would be silently scoped to whatever directory the user
128
- * happened to run their agent in.
129
- *
130
- * A session that never said where it runs is inside no workspace, so only an
131
- * unscoped deck sees it. Unreachable from main(), which exits before this on a
132
- * payload with no cwd — it is here because the rule has to be stated the same
133
- * way on both sides to be pinned against the other one.
134
- *
135
- * src/server/log-writer.mjs answers this same question, for the sessions the
136
- * server builds itself out of Codex's rollout files, under the name
137
- * codexCwdInWorkspace — this script is copied out of the package and run
138
- * standalone, so it cannot import that copy. A test walks one table of paths
139
- * through both: a disagreement between them is `--workspace` meaning two
140
- * different things depending on which CLI produced the session.
141
- */
142
- function capturesSession(cwd, workspace, platform = process.platform) {
143
- if (!workspace || typeof workspace !== "string") return true;
144
- if (!cwd || typeof cwd !== "string") return false;
145
- return cwdInWorkspace(cwd, workspace, platform);
146
- }
147
-
148
- // Signal 0 delivers nothing; it asks whether the pid could be signalled.
149
- //
150
- // BOTH ERRNOS, and the second one is the Windows spelling. POSIX `kill(2)`
151
- // answers EPERM for a process this account may not signal. On Windows
152
- // `uv_kill` calls `OpenProcess`, a denial is ERROR_ACCESS_DENIED, and libuv
153
- // maps that to EACCES — so a deck started from an elevated terminal, or under
154
- // another account, read as DEAD to every probe in this repo. What followed was
155
- // silent: the live deck's discovery file was unlinked on the next hook fire,
156
- // rewritten five seconds later by keepDiscovery, and its banner went on
157
- // claiming it was receiving events it had stopped receiving.
158
- function isAlive(pid) {
159
- try { process.kill(pid, 0); return true; }
160
- catch (e) { return !!e && (e.code === "EPERM" || e.code === "EACCES"); }
161
- }
162
-
163
- /**
164
- * Of the decks about to be posted this event, which ones should also write it
165
- * to disk? Returns the subset that should; every other target is asked to
166
- * display the event and keep no record of it.
167
- *
168
- * The fan-out itself is deliberate — several decks can match one session and
169
- * they should all draw it. Persisting is not: they all default to the same
170
- * <claude config dir>/agent-dag/events.jsonl, so each of them appending its own
171
- * copy wrote every event once per running deck. The file then grew N times as
172
- * fast, rotated N times as often, and every replay of it ingested each tool
173
- * call N times, which is what put duplicate tools and duplicate bubbles on the
174
- * canvas after a restart.
175
- *
176
- * Decks are therefore grouped by the log file each one names in its discovery
177
- * record, and one deck per group is elected. Grouping by the file rather than
178
- * counting decks is what keeps the overrides honest: a deck run with
179
- * `--history` sits alone in its own group and always writes, a deck run with
180
- * `--no-persist` reports no file and can never be elected to write for one that
181
- * does, and a deck too old to report either keeps the behaviour it had before
182
- * this rule existed. Within a group the lowest port wins — a fixed rule, so the
183
- * same deck holds the file for as long as it is up and the next one inherits it
184
- * as soon as that deck is gone.
185
- *
186
- * The platform is a parameter, like cwdInWorkspace's, so the case-folding half
187
- * is testable from any machine.
188
- *
189
- * src/server/log-writer.mjs repeats this rule for the events no hook delivers —
190
- * the ones the server builds itself from Codex's rollout files — because this
191
- * script is copied out of the package and cannot import it. A test compares the
192
- * two directly: they decide for the same decks, and a disagreement is a line
193
- * written twice or not at all.
194
- */
195
- function electWriters(decks, platform = process.platform) {
196
- const byLog = new Map();
197
- for (const d of decks) {
198
- const log = typeof d.persist === "string" ? d.persist : "";
199
- // Two namespaces, so a deck with no log to share — and a deck too old to
200
- // report one — is alone in its group and cannot collide with a real path.
201
- const key = log
202
- ? `log:${foldsCase(platform) ? log.toLowerCase() : log}`
203
- : `deck:${d.pid}:${d.port}`;
204
- const held = byLog.get(key);
205
- // Ports are unique among live decks; pid only breaks a tie a stale
206
- // discovery file could invent, so the answer stays deterministic.
207
- if (!held || d.port < held.port || (d.port === held.port && d.pid < held.pid)) {
208
- byLog.set(key, d);
209
- }
210
- }
211
- return new Set(byLog.values());
212
- }
213
-
214
- /**
215
- * The answer a deck must give to be handed a session payload.
216
- *
217
- * Liveness of the recorded pid is not evidence that the thing listening on the
218
- * recorded port is a deck. A deck killed with SIGKILL or lost to a power cut
219
- * leaves its discovery file behind — nothing unlinks it — and every cleanup
220
- * path here and in the server probes the same pid. Once the OS hands that
221
- * number to some other long-lived process the file passes forever, and the
222
- * port it names may by then belong to anything at all (4317, the deck's own
223
- * default, is also the standard OTLP collector port). What was POSTed there is
224
- * the whole hook event: prompt text, tool inputs, tool results, cwd.
225
- *
226
- * So the port has to prove itself before it is told anything. The deck writes a
227
- * fresh random token into its discovery file at startup; this hook asks the
228
- * listener to hash that token against a nonce it has never seen, and sends the
229
- * payload only if the answer matches. A stranger on the port cannot answer
230
- * without the token, and the nonce is new every time, so an answer overheard
231
- * earlier is worth nothing. Note the direction: the hook never transmits the
232
- * token itself, only a challenge, so a wrong listener learns nothing it could
233
- * replay against the next event.
234
- *
235
- * Both sides must derive the proof identically — src/server/index.mjs exports
236
- * the same function under the same name, and the pair is pinned by a test.
237
- */
238
- function challengeProof(token, nonce) {
239
- return crypto.createHash("sha256").update(`${token}:${nonce}`).digest("hex");
240
- }
241
-
242
- /**
243
- * Must this target answer the challenge before it is handed a payload?
244
- *
245
- * Only a deck that advertises a token can be asked to prove it holds one. And
246
- * hook.js is a single shared file — <claude config dir>/agent-dag/hook.js,
247
- * installed by whichever deck booted most recently — while running several
248
- * decks at once is ordinary use. So a hook that knows about the handshake
249
- * routinely reads discovery files written by decks that predate it, which serve
250
- * no /api/hook-challenge route at all. Refusing those outright leaves every one
251
- * of them listening and permanently empty, with its banner still saying it is
252
- * receiving events.
253
- *
254
- * THE FALLBACK IS GONE, on the condition this comment set for itself: "drop it
255
- * once no deck older than 1.33.71 is plausibly still running". That release is
256
- * two majors back — this package is on 3.x — so the window has closed.
257
- *
258
- * What it did while it stood: a tokenless discovery file was handed the payload
259
- * on pid liveness alone, which is a control an adversary switches off by
260
- * leaving a key out of a JSON file. It cost that adversary nothing to write
261
- * one, since writing into the discovery directory at all is the capability in
262
- * question — but a stale file from an old deck, or a port another program has
263
- * since taken, is the ordinary case it also covered, and both are better served
264
- * by refusing.
265
- *
266
- * The cost of refusing is stated plainly: a deck older than 1.33.71 running
267
- * beside a current one receives nothing, while its banner still says it is
268
- * connected. That was the reason to keep the fallback in the first place, and
269
- * it is now a machine nobody has.
270
- */
271
- function requiresProof(d) {
272
- return true;
273
- }
274
-
275
- // Constant-time compare, purely so a hostile listener cannot walk the expected
276
- // proof out of us one byte at a time by timing how long we take to hang up. The
277
- // lengths are public (64 hex chars) and a mismatched one is rejected outright,
278
- // which is what timingSafeEqual requires of its arguments anyway.
279
- function sameProof(got, want) {
280
- if (typeof got !== "string") return false;
281
- const a = Buffer.from(got, "utf8");
282
- const b = Buffer.from(want, "utf8");
283
- return a.length === b.length && crypto.timingSafeEqual(a, b);
284
- }
285
-
286
- // Two round trips happen per target, and main()'s hard cap is 1500ms, so the
287
- // pair has to fit inside it with room to spare. The challenge is a bodyless GET
288
- // to a loopback port — sub-millisecond when a deck is there, and instant
289
- // ECONNREFUSED when nothing is.
290
- //
291
- // They are now separated by a barrier: every target is challenged, then the
292
- // election is decided, then the payload goes out (#695). The worst case is
293
- // unchanged — the challenges run in parallel, so it is still one 400ms deadline
294
- // followed by one 1000ms deadline. What the barrier does cost is that an honest
295
- // deck's POST waits for the slowest challenge in the set, which only matters
296
- // when some OTHER record's port accepts a connection and then says nothing. A
297
- // ghost port with nothing behind it refuses instantly and delays no one.
298
- const CHALLENGE_TIMEOUT_MS = 400;
299
- const POST_TIMEOUT_MS = 1000;
300
-
301
- /**
302
- * Ask the listener to prove it is the deck that wrote `d`. `cb` is called
303
- * exactly once with true or false — a refused connection, a silent port and a
304
- * wrong answer are all just "not the deck this record describes".
305
- *
306
- * A deck that advertised no token cannot be asked and passes: see requiresProof.
307
- */
308
- function prove(d, cb, attempt = 0) {
309
- let settled = false;
310
- // A DEADLINE IS NOT AN ANSWER, and the difference is worth one retry.
311
- //
312
- // A wrong proof, a refused connection and a 404 are all verdicts: that port
313
- // is not the deck this record describes, and asking again would get the same
314
- // answer. A TIMEOUT is not — it is a machine too busy to reply in 400ms, and
315
- // the deck on the other side is fine. Measured on the Windows box: the full
316
- // test suite (335 files in parallel) is enough load to make a healthy deck
317
- // miss that window, and the event is then dropped with nothing on screen to
318
- // say so. A big build or a machine running several agents is the same shape.
319
- //
320
- // One retry, only on the deadline, and the budget still fits: 400 + 400 for
321
- // the challenge and 1000 for the POST, under the 1900ms cap main() sets —
322
- // which is itself under the two-second timeout the installed hook entry
323
- // carries, so Claude Code never has to kill this process.
324
- const retryOnTimeout = () => {
325
- if (settled) return;
326
- if (attempt >= 1) return finish(false);
327
- settled = true; // this attempt is over; the next owns `cb`
328
- prove(d, cb, attempt + 1);
329
- };
330
- const finish = ok => { if (settled) return; settled = true; cb(ok); };
331
-
332
- if (!requiresProof(d)) return finish(true);
333
-
334
- const nonce = crypto.randomBytes(16).toString("hex");
335
- const want = challengeProof(d.token, nonce);
336
-
337
- const req = http.request({
338
- hostname: "127.0.0.1",
339
- port: d.port,
340
- path: `/api/hook-challenge?nonce=${nonce}`,
341
- method: "GET",
342
- timeout: CHALLENGE_TIMEOUT_MS,
343
- }, res => {
344
- if (res.statusCode !== 200) { res.resume(); return res.on("end", () => finish(false)); }
345
- let answer = "";
346
- res.setEncoding("utf8");
347
- res.on("data", c => {
348
- answer += c;
349
- // A deck answers in ~100 bytes. Anything pouring data at us is not one,
350
- // and must not be allowed to grow this buffer without bound.
351
- if (answer.length > 4096) { req.destroy(); finish(false); }
352
- });
353
- res.on("end", () => {
354
- // Already given up on this target — a flood we cut off above. Whatever
355
- // arrived before that is not an answer we are going to act on.
356
- if (settled) return;
357
- let proof;
358
- try { proof = JSON.parse(answer).proof; } catch { return finish(false); }
359
- finish(sameProof(proof, want));
360
- });
361
- });
362
- // `destroy()` on a timeout makes 'error' fire with ECONNRESET, so the two
363
- // handlers have to agree on which of them is speaking: `timedOut` is what
364
- // tells a deadline apart from a refusal.
365
- let timedOut = false;
366
- req.on("error", () => { if (timedOut) retryOnTimeout(); else finish(false); });
367
- req.on("timeout", () => { timedOut = true; req.destroy(); });
368
- req.end();
369
- }
370
-
371
- /**
372
- * Challenge every target, then hand back the ones that answered — in the order
373
- * they were given, so the election below is a function of the records alone.
374
- *
375
- * WHY THIS RUNS BEFORE THE ELECTION AND NOT AFTER IT (#695). The two round trips
376
- * per target have always both happened; they used to happen in the wrong order.
377
- * electWriters ran over every record whose pid was merely alive, and only then
378
- * did deliver() challenge each target and drop the ones that could not answer.
379
- * So a record left behind by a deck that is gone — SIGKILL, an OOM kill, a power
380
- * cut, a console window closed on Windows, none of which run the shutdown that
381
- * unlinks it — kept passing the one staleness test there is the moment the OS
382
- * handed its pid to some other long-lived process. If it also named a port below
383
- * every real deck's, it WON the election, was never posted to because it could
384
- * not answer, and no other deck was posted to with the flag either: every deck
385
- * drew the event, all of them were told `?persist=0`, and events.jsonl stopped
386
- * growing. Silently, for as long as that file sat in the directory.
387
- *
388
- * The election has to be decided over the decks that are actually going to be
389
- * handed the payload, and the only thing that establishes that is the handshake.
390
- * So: prove, then elect, then post. It costs no extra round trip, only this
391
- * ordering, and it is the same reordering src/server/index.mjs makes in
392
- * readLiveDecks for the Codex rollouts no hook ever sees.
393
- *
394
- * The record is NOT unlinked when a target fails. A dead pid is proof the deck
395
- * is gone and is swept above; a failed challenge is not — a deck restarting
396
- * under its supervisor refuses connections for a moment while its record still
397
- * stands, and a merely busy one can miss the 400ms deadline. Deleting another
398
- * deck's registration on that evidence trades a bug that loses log lines for one
399
- * that loses a whole deck's events, and it buys nothing now that the election no
400
- * longer believes the record: a ghost that survives on disk costs one instant
401
- * ECONNREFUSED per hook run and decides nothing.
402
- */
403
- function proveTargets(targets, cb) {
404
- const ok = new Array(targets.length).fill(false);
405
- let pending = targets.length;
406
- const settle = () => { if (--pending <= 0) cb(targets.filter((_, i) => ok[i])); };
407
- targets.forEach((d, i) => prove(d, answered => { ok[i] = answered; settle(); }));
408
- }
409
-
410
- /**
411
- * Hand this deck the payload. `done` runs exactly once, whatever the outcome —
412
- * a delivered event, a refused connection and a socket that errors after the
413
- * response are all just "this target is finished".
414
- *
415
- * `persists` is this deck's answer from electWriters: true for the one deck that
416
- * logs the event, false for every other one it is also drawn on.
417
- */
418
- function post(d, body, persists, done) {
419
- let settled = false;
420
- const finish = () => { if (settled) return; settled = true; done(); };
421
- const req = http.request({
422
- hostname: "127.0.0.1",
423
- port: d.port,
424
- // Only the elected deck records the event; the rest are asked to draw it
425
- // and keep no copy, so one log file ends up with one copy of it.
426
- path: persists ? "/api/event" : "/api/event?persist=0",
427
- method: "POST",
428
- headers: { "Content-Type": "application/json" },
429
- timeout: POST_TIMEOUT_MS,
430
- }, res => { res.resume(); res.on("end", finish); });
431
- req.on("error", finish);
432
- req.on("timeout", () => req.destroy());
433
- req.write(body);
434
- req.end();
435
- }
436
-
437
- function main() {
438
- // Hard cap so a stuck server can never wedge the host CLI. 1900ms, which is
439
- // the challenge's two attempts (400 + 400) plus the POST's 1000 with a little
440
- // room — and still under the two-second timeout the installed hook entry
441
- // carries, so this process ends itself rather than being killed.
442
- setTimeout(() => process.exit(0), 1900);
443
-
444
- // The deck reads the Claude quota by running `claude --print /usage`, which is
445
- // a full Claude Code invocation and therefore fires these hooks. Reporting it
446
- // drew a session onto the canvas for every quota poll — no prompt, no tools,
447
- // a few seconds long — so the deck filled up with its own measurements. The
448
- // probe sets this in the environment and hooks inherit it.
449
- if (process.env.AGENTS_DECK_INTERNAL === "1") process.exit(0);
450
-
451
- let input = "";
452
- process.stdin.setEncoding("utf8");
453
- process.stdin.on("data", c => { input += c; });
454
- process.stdin.on("end", () => {
455
- let parsed;
456
- try { parsed = JSON.parse(input); } catch { return process.exit(0); }
457
- const cwd = parsed && parsed.cwd;
458
- if (!cwd) return process.exit(0);
459
-
460
- // Stamp provider so the server / reducer can branch on it without
461
- // re-sniffing payload shape.
462
- if (parsed && typeof parsed === "object" && !parsed.provider) {
463
- parsed.provider = PROVIDER;
464
- }
465
- const taggedInput = JSON.stringify(parsed);
466
-
467
- const resolvedCwd = normPath(cwd);
468
-
469
- let files;
470
- try {
471
- files = fs.readdirSync(DIR).filter(f => f.endsWith(".json"));
472
- } catch { return process.exit(0); }
473
- if (!files.length) return process.exit(0);
474
-
475
- // Every deck whose workspace contains this cwd, and nothing else decides it.
476
- //
477
- // This used to sort the matches by how long each deck's workspace path was
478
- // and deliver only to the longest — so a deck scoped to /Users/x/proj TOOK
479
- // that tree's sessions away from a machine-wide deck, which then sat there
480
- // showing nothing while `--all` promised it captured every session on this
481
- // machine. Nothing documented that, and the server's own Codex capture never
482
- // did it: each deck tails the rollout files itself and evaluates its own
483
- // workspace, so a Codex session inside a scoped tree appeared on both decks
484
- // while the Claude session beside it appeared on one. One flag, one path,
485
- // two answers.
486
- //
487
- // The fan-out is the documented meaning and the one kept: `--workspace` says
488
- // which sessions a deck captures, not which sessions it takes from the decks
489
- // around it. It is also what electWriters below already assumes — several
490
- // decks drawing one event is the case it exists to keep from being written
491
- // to one log several times.
492
- const targets = [];
493
- for (const file of files) {
494
- let d;
495
- try { d = JSON.parse(fs.readFileSync(path.join(DIR, file), "utf8")); } catch { continue; }
496
- if (typeof d.workspace !== "string" || !d.pid || !d.port) continue;
497
- // A missing token is not a reason to drop the file here — prove() decides
498
- // what a target has to prove, and a deck older than the handshake can
499
- // prove nothing. See requiresProof.
500
-
501
- if (!isAlive(d.pid)) {
502
- try { fs.unlinkSync(path.join(DIR, file)); } catch {}
503
- continue;
504
- }
505
-
506
- // "" is machine-wide and must never reach normPath: resolving it would
507
- // produce this hook's own cwd — the agent's — and scope a deck that asked
508
- // for no scope at all. Any other spelling is canonicalized here, which is
509
- // now a second pass over a path bin/deck.js already canonicalized before
510
- // publishing it — kept because a deck old enough to have published a
511
- // relative one is still entitled to its events.
512
- const ws = d.workspace === "" ? "" : normPath(d.workspace);
513
- if (capturesSession(resolvedCwd, ws)) targets.push(d);
514
- }
515
-
516
- if (!targets.length) return process.exit(0);
517
-
518
- // Prove, elect, post — in that order, and see proveTargets for what the
519
- // other order cost. A record whose pid is merely alive has established
520
- // nothing: it may be a deck that died and had its pid recycled, and electing
521
- // one of those to write the log meant nobody wrote it (#695).
522
- proveTargets(targets, proven => {
523
- if (!proven.length) return process.exit(0);
524
-
525
- // One deck per events log records this event; the others only draw it.
526
- const writers = electWriters(proven);
527
-
528
- let pending = proven.length;
529
- const done = () => { if (--pending <= 0) process.exit(0); };
530
-
531
- for (const d of proven) post(d, taggedInput, writers.has(d), done);
532
- });
533
- });
534
- }
535
-
536
- // The host CLI always runs this file as the process entry point — the command
537
- // the installer writes is `"<node>" "<...>/hook.js" --provider <name>`. Under a
538
- // require() it exports the rules it decides by — matching, election, the
539
- // handshake — and starts nothing, which is what lets them be tested without a
540
- // 1.5s exit timer in the test runner.
541
- module.exports = { capturesSession, cwdInWorkspace, foldsCase, normPath, electWriters, challengeProof, requiresProof };
542
- if (require.main === module) main();