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/README.md +208 -12
- package/bin/agent-dag.js +481 -0
- package/bin/deck.js +968 -0
- package/dist/web/assets/index-DBsxIfdM.js +78 -0
- package/dist/web/assets/index-XtT5NdJI.css +1 -0
- package/dist/web/index.html +49 -0
- package/hook/hook.js +407 -0
- package/hook/notify.mjs +104 -0
- package/package.json +42 -9
- package/src/server/args.mjs +58 -0
- package/src/server/brand.mjs +16 -0
- package/src/server/ccusage.mjs +1052 -0
- package/src/server/claude-accounts.mjs +653 -0
- package/src/server/claude-dir.mjs +213 -0
- package/src/server/codex-auth.mjs +383 -0
- package/src/server/codex-dir.mjs +171 -0
- package/src/server/codex-quota.mjs +445 -0
- package/src/server/codex-usage.mjs +353 -0
- package/src/server/cswap-admin.mjs +902 -0
- package/src/server/cswap-auto.mjs +441 -0
- package/src/server/cswap-install.mjs +583 -0
- package/src/server/exec.mjs +850 -0
- package/src/server/index.mjs +3479 -0
- package/src/server/installer.mjs +674 -0
- package/src/server/invoked-as.mjs +144 -0
- package/src/server/log-writer.mjs +283 -0
- package/src/server/npx.mjs +259 -0
- package/src/server/quota.mjs +684 -0
- package/src/server/reset-label.mjs +78 -0
- package/src/server/self-update.mjs +1331 -0
- package/src/server/sound-hook.mjs +518 -0
- package/src/server/supervisor.mjs +235 -0
- package/src/server/system-metrics.mjs +619 -0
- package/src/server/term.mjs +570 -0
- package/src/server/uv-bootstrap.mjs +301 -0
- package/bin/ccdeck.js +0 -194
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();
|
package/hook/notify.mjs
ADDED
|
@@ -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.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
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
|
-
"
|
|
12
|
-
"
|
|
13
|
+
"hook",
|
|
14
|
+
"src/server",
|
|
15
|
+
"dist/web",
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"README.md"
|
|
13
18
|
],
|
|
14
|
-
"
|
|
15
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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";
|