ccdeck 1.43.0 → 1.44.1

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/hook/hook.js ADDED
@@ -0,0 +1,407 @@
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
+ function normPath(p) {
38
+ let r = path.resolve(p);
39
+ try { r = fs.realpathSync(r); } catch {}
40
+ return r;
41
+ }
42
+
43
+ /**
44
+ * Does this platform's filesystem treat two spellings that differ only in case
45
+ * as the same directory? Exported for tests: the platform is a parameter so
46
+ * both answers can be checked from either kind of machine.
47
+ *
48
+ * Windows always does, and macOS does by default (APFS and HFS+ are formatted
49
+ * case-insensitive unless the user deliberately chose otherwise). Linux does
50
+ * not, and folding case there would be a bug of its own: /srv/Proj and
51
+ * /srv/proj are two real directories, and a deck scoped to one must not be
52
+ * handed the other's events.
53
+ *
54
+ * A case-sensitive macOS volume is therefore over-matched. That is the safe
55
+ * direction to be wrong in — the cost is a deck that also sees a sibling tree
56
+ * it was not scoped to, against the cost of the default configuration seeing
57
+ * nothing at all.
58
+ */
59
+ const foldsCase = (platform = process.platform) =>
60
+ platform === "win32" || platform === "darwin";
61
+
62
+ /**
63
+ * Is `cwd` the workspace directory or somewhere inside it?
64
+ *
65
+ * Both sides arrive already resolved, but resolved is not the same as
66
+ * comparable. Neither path.resolve nor the JS fs.realpathSync canonicalizes
67
+ * character case, so the drive letter and every component keep whatever case
68
+ * the process that reported them happened to use — `c:\proj` from one shell,
69
+ * `C:\Proj` from another, for one directory. A raw === / startsWith then says
70
+ * "not in the workspace", the hook posts to nobody, and a scoped deck stays
71
+ * empty with no error printed anywhere. Re-resolving through the platform's
72
+ * own path flavour also settles separators and a trailing one, so
73
+ * `C:/proj/` and `C:\proj` compare equal too.
74
+ *
75
+ * The platform is a parameter, following spawnSpec/isBatch in
76
+ * src/server/exec.mjs, so the Windows rule is testable from a POSIX machine.
77
+ */
78
+ function cwdInWorkspace(cwd, workspace, platform = process.platform) {
79
+ const p = platform === "win32" ? path.win32 : path.posix;
80
+ const fold = s => (foldsCase(platform) ? s.toLowerCase() : s);
81
+ const a = fold(p.resolve(cwd));
82
+ const b = fold(p.resolve(workspace));
83
+ if (a === b) return true;
84
+ // A root ("C:\", "/") already ends in the separator; appending a second one
85
+ // would match nothing.
86
+ return a.startsWith(b.endsWith(p.sep) ? b : b + p.sep);
87
+ }
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
+
119
+ function isAlive(pid) {
120
+ try { process.kill(pid, 0); return true; }
121
+ catch (e) { return e && e.code === "EPERM"; }
122
+ }
123
+
124
+ /**
125
+ * Of the decks about to be posted this event, which ones should also write it
126
+ * to disk? Returns the subset that should; every other target is asked to
127
+ * display the event and keep no record of it.
128
+ *
129
+ * The fan-out itself is deliberate — several decks can match one session and
130
+ * they should all draw it. Persisting is not: they all default to the same
131
+ * <claude config dir>/agent-dag/events.jsonl, so each of them appending its own
132
+ * copy wrote every event once per running deck. The file then grew N times as
133
+ * fast, rotated N times as often, and every replay of it ingested each tool
134
+ * call N times, which is what put duplicate tools and duplicate bubbles on the
135
+ * canvas after a restart.
136
+ *
137
+ * Decks are therefore grouped by the log file each one names in its discovery
138
+ * record, and one deck per group is elected. Grouping by the file rather than
139
+ * counting decks is what keeps the overrides honest: a deck run with
140
+ * `--history` sits alone in its own group and always writes, a deck run with
141
+ * `--no-persist` reports no file and can never be elected to write for one that
142
+ * does, and a deck too old to report either keeps the behaviour it had before
143
+ * this rule existed. Within a group the lowest port wins — a fixed rule, so the
144
+ * same deck holds the file for as long as it is up and the next one inherits it
145
+ * as soon as that deck is gone.
146
+ *
147
+ * The platform is a parameter, like cwdInWorkspace's, so the case-folding half
148
+ * is testable from any machine.
149
+ *
150
+ * src/server/log-writer.mjs repeats this rule for the events no hook delivers —
151
+ * the ones the server builds itself from Codex's rollout files — because this
152
+ * script is copied out of the package and cannot import it. A test compares the
153
+ * two directly: they decide for the same decks, and a disagreement is a line
154
+ * written twice or not at all.
155
+ */
156
+ function electWriters(decks, platform = process.platform) {
157
+ const byLog = new Map();
158
+ for (const d of decks) {
159
+ const log = typeof d.persist === "string" ? d.persist : "";
160
+ // Two namespaces, so a deck with no log to share — and a deck too old to
161
+ // report one — is alone in its group and cannot collide with a real path.
162
+ const key = log
163
+ ? `log:${foldsCase(platform) ? log.toLowerCase() : log}`
164
+ : `deck:${d.pid}:${d.port}`;
165
+ const held = byLog.get(key);
166
+ // Ports are unique among live decks; pid only breaks a tie a stale
167
+ // discovery file could invent, so the answer stays deterministic.
168
+ if (!held || d.port < held.port || (d.port === held.port && d.pid < held.pid)) {
169
+ byLog.set(key, d);
170
+ }
171
+ }
172
+ return new Set(byLog.values());
173
+ }
174
+
175
+ /**
176
+ * The answer a deck must give to be handed a session payload.
177
+ *
178
+ * Liveness of the recorded pid is not evidence that the thing listening on the
179
+ * recorded port is a deck. A deck killed with SIGKILL or lost to a power cut
180
+ * leaves its discovery file behind — nothing unlinks it — and every cleanup
181
+ * path here and in the server probes the same pid. Once the OS hands that
182
+ * number to some other long-lived process the file passes forever, and the
183
+ * port it names may by then belong to anything at all (4317, the deck's own
184
+ * default, is also the standard OTLP collector port). What was POSTed there is
185
+ * the whole hook event: prompt text, tool inputs, tool results, cwd.
186
+ *
187
+ * So the port has to prove itself before it is told anything. The deck writes a
188
+ * fresh random token into its discovery file at startup; this hook asks the
189
+ * listener to hash that token against a nonce it has never seen, and sends the
190
+ * payload only if the answer matches. A stranger on the port cannot answer
191
+ * without the token, and the nonce is new every time, so an answer overheard
192
+ * earlier is worth nothing. Note the direction: the hook never transmits the
193
+ * token itself, only a challenge, so a wrong listener learns nothing it could
194
+ * replay against the next event.
195
+ *
196
+ * Both sides must derive the proof identically — src/server/index.mjs exports
197
+ * the same function under the same name, and the pair is pinned by a test.
198
+ */
199
+ function challengeProof(token, nonce) {
200
+ return crypto.createHash("sha256").update(`${token}:${nonce}`).digest("hex");
201
+ }
202
+
203
+ /**
204
+ * Must this target answer the challenge before it is handed a payload?
205
+ *
206
+ * Only a deck that advertises a token can be asked to prove it holds one. And
207
+ * hook.js is a single shared file — <claude config dir>/agent-dag/hook.js,
208
+ * installed by whichever deck booted most recently — while running several
209
+ * decks at once is ordinary use. So a hook that knows about the handshake
210
+ * routinely reads discovery files written by decks that predate it, which serve
211
+ * no /api/hook-challenge route at all. Refusing those outright leaves every one
212
+ * of them listening and permanently empty, with its banner still saying it is
213
+ * receiving events.
214
+ *
215
+ * A tokenless file therefore falls back to what shipped before the handshake:
216
+ * pid liveness and nothing else. That is not a weakening of anything — it is
217
+ * the exact risk every release up to 1.33.70 already carried, unchanged — and
218
+ * it costs the hardening nothing, because a file that does carry a token still
219
+ * gets no payload until the port answers correctly. Drop this fallback, and
220
+ * refuse tokenless files again, once no deck older than 1.33.71 is plausibly
221
+ * still running.
222
+ */
223
+ function requiresProof(d) {
224
+ return typeof d.token === "string" && d.token !== "";
225
+ }
226
+
227
+ // Constant-time compare, purely so a hostile listener cannot walk the expected
228
+ // proof out of us one byte at a time by timing how long we take to hang up. The
229
+ // lengths are public (64 hex chars) and a mismatched one is rejected outright,
230
+ // which is what timingSafeEqual requires of its arguments anyway.
231
+ function sameProof(got, want) {
232
+ if (typeof got !== "string") return false;
233
+ const a = Buffer.from(got, "utf8");
234
+ const b = Buffer.from(want, "utf8");
235
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
236
+ }
237
+
238
+ // Two round trips now happen per target, and main()'s hard cap is 1500ms, so
239
+ // the pair has to fit inside it with room to spare. The challenge is a bodyless
240
+ // GET to a loopback port — sub-millisecond when a deck is there, and instant
241
+ // ECONNREFUSED when nothing is.
242
+ const CHALLENGE_TIMEOUT_MS = 400;
243
+ const POST_TIMEOUT_MS = 1000;
244
+
245
+ /**
246
+ * Ask the listener to prove it is the deck that wrote `d`, and POST the payload
247
+ * only once it has. `done` runs exactly once, whatever the outcome — a refused
248
+ * connection, a silent port, a wrong answer and a delivered event all just mean
249
+ * this target is finished.
250
+ *
251
+ * A deck that advertised no token is posted to directly: see requiresProof.
252
+ *
253
+ * `persists` is this deck's answer from electWriters: true for the one deck
254
+ * that logs the event, false for every other one it is also drawn on.
255
+ */
256
+ function deliver(d, body, persists, done) {
257
+ let settled = false;
258
+ const finish = () => { if (settled) return; settled = true; done(); };
259
+
260
+ if (!requiresProof(d)) return post(d, body, persists, finish);
261
+
262
+ const nonce = crypto.randomBytes(16).toString("hex");
263
+ const want = challengeProof(d.token, nonce);
264
+
265
+ const req = http.request({
266
+ hostname: "127.0.0.1",
267
+ port: d.port,
268
+ path: `/api/hook-challenge?nonce=${nonce}`,
269
+ method: "GET",
270
+ timeout: CHALLENGE_TIMEOUT_MS,
271
+ }, res => {
272
+ if (res.statusCode !== 200) { res.resume(); return res.on("end", finish); }
273
+ let answer = "";
274
+ res.setEncoding("utf8");
275
+ res.on("data", c => {
276
+ answer += c;
277
+ // A deck answers in ~100 bytes. Anything pouring data at us is not one,
278
+ // and must not be allowed to grow this buffer without bound.
279
+ if (answer.length > 4096) { req.destroy(); finish(); }
280
+ });
281
+ res.on("end", () => {
282
+ // Already given up on this target — a flood we cut off above. Whatever
283
+ // arrived before that is not an answer we are going to act on.
284
+ if (settled) return;
285
+ let proof;
286
+ try { proof = JSON.parse(answer).proof; } catch { return finish(); }
287
+ if (!sameProof(proof, want)) return finish();
288
+ post(d, body, persists, finish);
289
+ });
290
+ });
291
+ req.on("error", finish);
292
+ req.on("timeout", () => req.destroy());
293
+ req.end();
294
+ }
295
+
296
+ function post(d, body, persists, finish) {
297
+ const req = http.request({
298
+ hostname: "127.0.0.1",
299
+ port: d.port,
300
+ // Only the elected deck records the event; the rest are asked to draw it
301
+ // and keep no copy, so one log file ends up with one copy of it.
302
+ path: persists ? "/api/event" : "/api/event?persist=0",
303
+ method: "POST",
304
+ headers: { "Content-Type": "application/json" },
305
+ timeout: POST_TIMEOUT_MS,
306
+ }, res => { res.resume(); res.on("end", finish); });
307
+ req.on("error", finish);
308
+ req.on("timeout", () => req.destroy());
309
+ req.write(body);
310
+ req.end();
311
+ }
312
+
313
+ function main() {
314
+ // Hard cap so a stuck server can never wedge the host CLI.
315
+ setTimeout(() => process.exit(0), 1500);
316
+
317
+ // The deck reads the Claude quota by running `claude --print /usage`, which is
318
+ // a full Claude Code invocation and therefore fires these hooks. Reporting it
319
+ // drew a session onto the canvas for every quota poll — no prompt, no tools,
320
+ // a few seconds long — so the deck filled up with its own measurements. The
321
+ // probe sets this in the environment and hooks inherit it.
322
+ if (process.env.AGENTS_DECK_INTERNAL === "1") process.exit(0);
323
+
324
+ let input = "";
325
+ process.stdin.setEncoding("utf8");
326
+ process.stdin.on("data", c => { input += c; });
327
+ process.stdin.on("end", () => {
328
+ let parsed;
329
+ try { parsed = JSON.parse(input); } catch { return process.exit(0); }
330
+ const cwd = parsed && parsed.cwd;
331
+ if (!cwd) return process.exit(0);
332
+
333
+ // Stamp provider so the server / reducer can branch on it without
334
+ // re-sniffing payload shape.
335
+ if (parsed && typeof parsed === "object" && !parsed.provider) {
336
+ parsed.provider = PROVIDER;
337
+ }
338
+ const taggedInput = JSON.stringify(parsed);
339
+
340
+ const resolvedCwd = normPath(cwd);
341
+
342
+ let files;
343
+ try {
344
+ files = fs.readdirSync(DIR).filter(f => f.endsWith(".json"));
345
+ } catch { return process.exit(0); }
346
+ if (!files.length) return process.exit(0);
347
+
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 = [];
366
+ for (const file of files) {
367
+ let d;
368
+ try { d = JSON.parse(fs.readFileSync(path.join(DIR, file), "utf8")); } catch { continue; }
369
+ if (typeof d.workspace !== "string" || !d.pid || !d.port) continue;
370
+ // A missing token is not a reason to drop the file here — deliver()
371
+ // decides what a target has to prove, and a deck older than the handshake
372
+ // can prove nothing. See requiresProof.
373
+
374
+ if (!isAlive(d.pid)) {
375
+ try { fs.unlinkSync(path.join(DIR, file)); } catch {}
376
+ continue;
377
+ }
378
+
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);
387
+ }
388
+
389
+ if (!targets.length) return process.exit(0);
390
+
391
+ // One deck per events log records this event; the others only draw it.
392
+ const writers = electWriters(targets);
393
+
394
+ let pending = targets.length;
395
+ const done = () => { if (--pending <= 0) process.exit(0); };
396
+
397
+ for (const d of targets) deliver(d, taggedInput, writers.has(d), done);
398
+ });
399
+ }
400
+
401
+ // The host CLI always runs this file as the process entry point — the command
402
+ // the installer writes is `"<node>" "<...>/hook.js" --provider <name>`. Under a
403
+ // require() it exports the rules it decides by — matching, election, the
404
+ // handshake — and starts nothing, which is what lets them be tested without a
405
+ // 1.5s exit timer in the test runner.
406
+ module.exports = { capturesSession, cwdInWorkspace, foldsCase, electWriters, challengeProof, requiresProof };
407
+ if (require.main === module) main();
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ // Plays a short sound when Claude Code finishes a turn. Installed by
3
+ // agents-deck as a Stop hook and toggled from the deck's topbar.
4
+ //
5
+ // The `.mjs` is load-bearing and this file must not be renamed back. It is
6
+ // copied to <claude config dir>/agent-dag/, where no package.json sits above it
7
+ // to declare a format, so a `.js` there is CommonJS and the `import` below is a
8
+ // SyntaxError on every Node that does not detect module syntax by default —
9
+ // which is every Node before v20.19.0 and v22.7.0, well inside the package's own
10
+ // `engines: ">=18"`. See the note above NOTIFY_NAME in src/server/sound-hook.mjs.
11
+ // Its neighbour hook.js is CommonJS for the same reason read the other way.
12
+ //
13
+ // The platform check happens HERE, at run time, rather than in the settings
14
+ // entry that invokes it. Hand-written sound hooks are almost always a single
15
+ // OS-specific command — `afplay` on a Mac, a PowerShell one-liner on Windows —
16
+ // which does nothing on any other machine, and typically ends in `|| true`, so
17
+ // the failure is silent. One script that picks its own player means the same
18
+ // settings.json works on every machine the user syncs it to.
19
+ import { spawn } from "node:child_process";
20
+ import { existsSync } from "node:fs";
21
+
22
+ // First entry whose file exists wins. Each is a [command, args] pair.
23
+ function players() {
24
+ if (process.platform === "darwin") {
25
+ const sound = ["/System/Library/Sounds/Glass.aiff", "/System/Library/Sounds/Ping.aiff"]
26
+ .find(existsSync);
27
+ return sound ? [["afplay", [sound]]] : [];
28
+ }
29
+
30
+ if (process.platform === "win32") {
31
+ // PlaySync inside the hook process keeps the sound from being cut off when
32
+ // the shell exits, which is what a bare Media.SoundPlayer call would do.
33
+ const ps = "(New-Object Media.SoundPlayer 'C:\\Windows\\Media\\tada.wav').PlaySync()";
34
+ return [["powershell.exe", ["-NoProfile", "-Command", ps]]];
35
+ }
36
+
37
+ // Linux and the BSDs: no single player is guaranteed, so try the common ones
38
+ // in order of how likely they are to be present on a desktop install. The
39
+ // freedesktop sound theme ships with most of them.
40
+ const wav = [
41
+ "/usr/share/sounds/freedesktop/stereo/complete.oga",
42
+ "/usr/share/sounds/freedesktop/stereo/bell.oga",
43
+ ].find(existsSync);
44
+ return [
45
+ ["canberra-gtk-play", ["--id", "complete"]],
46
+ ...(wav ? [["paplay", [wav]], ["aplay", [wav]]] : []),
47
+ ];
48
+ }
49
+
50
+ /**
51
+ * The last resort, and the reason it is not in the list above: a terminal bell
52
+ * is one byte, and a byte is not something to spawn a process for.
53
+ *
54
+ * It used to be `["printf", ["\a"]]`, spawned like every other candidate — with
55
+ * `stdio: "ignore"`. So the BEL went to /dev/null, which is the one place a
56
+ * bell cannot ring. Worse than useless: `printf` exists on a headless Linux or
57
+ * SSH box, so the spawn SUCCEEDED, no `error` event fired, and on the exact
58
+ * machine the comment claimed it was for — no canberra-gtk-play, no freedesktop
59
+ * sounds, so a candidate list of one — the hook was silently inert, and a
60
+ * working install looked identical to no install at all. It could not run on
61
+ * Windows either: `printf` is a shell builtin there, not a program.
62
+ *
63
+ * Inheriting stdio would not have fixed it. This process is a Stop hook, so its
64
+ * stdout is Claude Code's pipe rather than the user's terminal, and a BEL
65
+ * written into a pipe is a stray byte in a log file — into THIS pipe it is a
66
+ * stray byte in the channel Claude Code reads a hook's answer from. So the bell
67
+ * is written only when our own stdout is a terminal, which is precisely when it
68
+ * IS the terminal the user is looking at: the hook run by hand, or by any
69
+ * runner that hands it the tty. Everywhere else the sound is simply the players
70
+ * above, and this function does nothing rather than claiming a capability this
71
+ * process does not have.
72
+ *
73
+ * Not tied to a platform, unlike the candidate it replaces: a console beeps on
74
+ * all three, and the branch that reaches here is whichever list ran out.
75
+ */
76
+ function bell() {
77
+ try {
78
+ // U+0007 BEL. One write, no child, nothing to fall back to after it.
79
+ if (process.stdout.isTTY) process.stdout.write("\u0007");
80
+ } catch { /* a closed stdout is not worth an exception at the end of a turn */ }
81
+ }
82
+
83
+ // Try each candidate until one starts without ENOENT. Detached and unref'd so
84
+ // the hook returns immediately — Claude Code waits on hook processes, and a
85
+ // two-second sound should not be two seconds of latency at the end of a turn.
86
+ //
87
+ // Running out of candidates is where the bell belongs, rather than being one:
88
+ // it is reached on every platform whose players are missing — a Mac with no
89
+ // system sounds, a Windows without PowerShell, the headless Linux box the old
90
+ // `printf` entry was written for — instead of only at the end of the one list
91
+ // it used to sit in.
92
+ function play(candidates, i = 0) {
93
+ if (i >= candidates.length) { bell(); return; }
94
+ const [cmd, args] = candidates[i];
95
+ try {
96
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true, shell: false });
97
+ child.on("error", () => play(candidates, i + 1)); // not installed — next
98
+ child.unref();
99
+ } catch {
100
+ play(candidates, i + 1);
101
+ }
102
+ }
103
+
104
+ play(players());
package/package.json CHANGED
@@ -1,18 +1,29 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "1.43.0",
4
- "description": "Alias for agents-deck — live deck of Claude Code and Codex agents. Run with: npx ccdeck",
3
+ "version": "1.44.1",
4
+ "description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "ccdeck": "bin/ccdeck.js"
7
+ "agents-deck": "bin/agent-dag.js",
8
+ "agent-dag": "bin/agent-dag.js",
9
+ "ccdeck": "bin/agent-dag.js"
8
10
  },
9
11
  "files": [
10
12
  "bin",
11
- "README.md",
12
- "LICENSE"
13
+ "hook",
14
+ "src/server",
15
+ "dist/web",
16
+ "LICENSE",
17
+ "README.md"
13
18
  ],
14
- "dependencies": {
15
- "agents-deck": "1.43.0"
19
+ "scripts": {
20
+ "dev:web": "vite",
21
+ "build:web": "vite build",
22
+ "dev:server": "node src/server/index.mjs",
23
+ "start": "node bin/agent-dag.js",
24
+ "build": "vite build",
25
+ "test": "vitest run",
26
+ "prepublishOnly": "vite build"
16
27
  },
17
28
  "engines": {
18
29
  "node": ">=18"
@@ -22,11 +33,15 @@
22
33
  "claude",
23
34
  "anthropic",
24
35
  "agents",
36
+ "subagents",
25
37
  "visualization",
26
38
  "dashboard",
39
+ "hooks",
40
+ "observability",
27
41
  "dag",
28
42
  "deck",
29
- "ccdeck"
43
+ "graph",
44
+ "live"
30
45
  ],
31
46
  "author": "Bargan Constantin",
32
47
  "license": "MIT",
@@ -34,5 +49,23 @@
34
49
  "type": "git",
35
50
  "url": "git+https://github.com/BarganConstantin/ccdeck.git"
36
51
  },
37
- "homepage": "https://github.com/BarganConstantin/ccdeck#readme"
52
+ "bugs": {
53
+ "url": "https://github.com/BarganConstantin/ccdeck/issues"
54
+ },
55
+ "homepage": "https://github.com/BarganConstantin/ccdeck#readme",
56
+ "dependencies": {
57
+ "open": "^10.1.0"
58
+ },
59
+ "devDependencies": {
60
+ "@types/react": "^18.3.12",
61
+ "@types/react-dom": "^18.3.1",
62
+ "@vitejs/plugin-react": "^4.3.4",
63
+ "dagre": "^0.8.5",
64
+ "react": "^18.3.1",
65
+ "react-dom": "^18.3.1",
66
+ "reactflow": "^11.11.4",
67
+ "typescript": "^5.6.3",
68
+ "vite": "^6.0.0",
69
+ "vitest": "^2.1.9"
70
+ }
38
71
  }
@@ -0,0 +1,58 @@
1
+ // The command line, parsed: everything the deck recognises, and everything it
2
+ // does not.
3
+ //
4
+ // Its own module rather than a function inside bin/deck.js, for one reason —
5
+ // importing bin/deck.js RUNS a deck. That file installs hooks, binds a port and
6
+ // opens a browser at module scope, so there was no way to ask what `--prot`
7
+ // parses to without starting a server to find out. Nothing here does any of
8
+ // that: strings in, a plain object out, no I/O, no process, no terminal.
9
+ //
10
+ // bin/deck.js is the only caller. The parser is the whole of the module's
11
+ // surface; the tables it matches against stay inside it.
12
+
13
+ /**
14
+ * Parse `process.argv.slice(2)`.
15
+ *
16
+ * Returns the flags that were set, plus `unknown` — every token the loop did
17
+ * not recognise, in the order it met them. That list is the point of this
18
+ * module: the loop used to have no `else`, so `ccdeck --prot 4500` booted on
19
+ * 4317 and said nothing, and a typo was indistinguishable from a flag that
20
+ * worked.
21
+ *
22
+ * The three flags that TAKE a value (`--port`, `--workspace`, `--history`)
23
+ * consume it with `args[++i]`, so the value is never re-examined as a token of
24
+ * its own and cannot land in `unknown` — `--port 4500` says nothing about
25
+ * `4500`, and `--workspace ~/some dir` nothing about the path. That is the way
26
+ * an unknown-flag warning usually goes wrong, and it is asserted rather than
27
+ * assumed; see src/web/__tests__/argv-480.test.ts.
28
+ *
29
+ * A bare word is `unknown` too, and deliberately: the deck takes no positional
30
+ * arguments at all, so `ccdeck ~/proj` is the same mistake as `ccdeck --workpace
31
+ * ~/proj` — something the deck read and then did nothing with. It is also how
32
+ * an unquoted path with a space in it becomes visible, which is a failure this
33
+ * repo already knew about and could not previously report: `--workspace
34
+ * C:\Users\John Smith\proj` reaches the parser as two arguments, and the second
35
+ * one used to be dropped in silence (see launchNpx in bin/agent-dag.js).
36
+ */
37
+ export function parseArgs(args) {
38
+ const out = { unknown: [] };
39
+ for (let i = 0; i < args.length; i++) {
40
+ const a = args[i];
41
+ if (a === "-h" || a === "--help") out.help = true;
42
+ else if (a === "-v" || a === "--version") out.version = true;
43
+ else if (a === "-p" || a === "--port") out.port = args[++i];
44
+ else if (a === "--no-open") out.noOpen = true;
45
+ else if (a === "--uninstall") out.uninstall = true;
46
+ else if (a === "--workspace") out.workspace = args[++i];
47
+ else if (a === "--scope") out.scope = true;
48
+ else if (a === "--all") out.all = true; // legacy no-op (now default)
49
+ else if (a === "--no-persist") out.noPersist = true;
50
+ else if (a === "--history") out.history = args[++i];
51
+ else if (a === "--codex") out.codex = true;
52
+ else if (a === "--no-codex") out.noCodex = true;
53
+ else if (a === "--claude") out.claude = true;
54
+ else if (a === "--no-claude") out.noClaude = true;
55
+ else out.unknown.push(a);
56
+ }
57
+ return out;
58
+ }
@@ -0,0 +1,16 @@
1
+ // The name the server prints, in one place — the console half of what
2
+ // src/web/brand.ts holds for the browser and src/server/term.mjs draws as the
3
+ // banner wordmark. Every `PRODUCT:` prefix below is a line a user reads when
4
+ // something has already gone wrong, which is the worst moment to introduce
5
+ // yourself by a name that appears nowhere else they have seen.
6
+ //
7
+ // Display name only. The package published to npm is still `agents-deck`, so
8
+ // self-update.mjs's `name` defaults stay as they are — that string is what the
9
+ // registry is queried for and what `npm i -g` installs, and an install that
10
+ // names the product instead of the package installs nothing. Same for the
11
+ // marker files under ~/.agents-deck, the event log under ~/.claude/agent-dag,
12
+ // the AGENTS_DECK_* variables and the `user-agent` headers: identifiers outlive
13
+ // a rename, and moving one orphans a running deck from its own state.
14
+
15
+ /** The prefix on every line the deck writes to a console or a log. */
16
+ export const PRODUCT = "ccdeck";