agent-dag 1.30.9 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,6 +28,7 @@ No config. No install step. Ctrl+C to stop.
28
28
  - **Workspace filter** — `--scope` limits capture to the current directory; `--workspace <path>` for any subtree
29
29
  - **Zero trust step for Codex** — no hook install, no `/hooks` trust prompt; the server tails `~/.codex/sessions/` directly
30
30
  - **Version drift warning** — Node caches modules at startup, so a deck upgraded while running keeps executing the old code. The topbar says so, and points at the restart or the upgrade command
31
+ - **One-click update** — when a newer release is on npm, `Update now` installs it in the background and the deck restarts itself as soon as nothing is running. Never runs behind your back, and declines outright where it could do harm
31
32
 
32
33
  ## How it works
33
34
 
@@ -74,10 +75,44 @@ AGENTS_DECK_NO_UPDATE_CHECK=1 Don't ask npm about releases, but keep everything
74
75
  AGENTS_DECK_NO_FRESHEN=1 Never nudge claude-swap to collect usage early
75
76
  ```
76
77
 
77
- The update check is one ~20-byte GET to `registry.npmjs.org`, at most once a day,
78
- and it never installs anything. Being told to restart after an upgrade is local
79
- only — no network involved — and cannot be turned off, because a deck running
80
- superseded code is a bug you cannot see any other way.
78
+ The update check is one ~20-byte GET to `registry.npmjs.org`, at most once a day.
79
+ Being told to restart after an upgrade is local only — no network involved — and
80
+ cannot be turned off, because a deck running superseded code is a bug you cannot
81
+ see any other way.
82
+
83
+ ### Updating
84
+
85
+ `Update now` runs `npm install -g agents-deck@latest` and nothing else — the
86
+ argument vector is fixed in the server, not taken from the request. When it
87
+ finishes, the newer files on disk trigger the ordinary restart path below.
88
+
89
+ Nothing is ever installed unless you click. The button is replaced by the reason
90
+ when installing would be wrong:
91
+
92
+ | | |
93
+ |---|---|
94
+ | git checkout | your working copy leads npm; pull instead |
95
+ | npx | its cache directory is never upgraded in place |
96
+ | directory not writable | a root-owned global prefix — declined up front rather than failing inside npm |
97
+ | `AGENTS_DECK_NO_INSTALL=1` | you asked for no installs |
98
+
99
+ If npm fails anyway, the banner shows npm's own last line and the command to run
100
+ by hand. The command is always on screen, button or no button.
101
+
102
+ ### Restarting
103
+
104
+ `agents-deck` runs as a two-process pair: a supervisor that owns nothing but the
105
+ lifecycle, and the deck itself. When newer code is found on disk, the deck exits
106
+ with code 75 and the supervisor brings it back **on the port it actually bound**,
107
+ which is not always the one it asked for. Ctrl+C, stdout and exit codes behave
108
+ exactly as before — same terminal, same process group.
109
+
110
+ It restarts on its own only once nothing has been running for 30 seconds: hook
111
+ events are fire-and-forget, so anything fired during the gap is lost. Turn that
112
+ off with the toggle in the banner and use the button instead; the preference is
113
+ per-browser. Under `--no-persist` restarting is refused outright, by the server
114
+ and not just by the UI — with no event log there is nothing to replay, and the
115
+ canvas would be gone.
81
116
 
82
117
  ## Uninstall
83
118
 
package/bin/agent-dag.js CHANGED
@@ -1,304 +1,100 @@
1
1
  #!/usr/bin/env node
2
- // agent-dag CLI entrypoint. Registers hooks, starts server, opens browser.
3
- import { resolve, dirname, join } from "node:path";
4
- import { homedir } from "node:os";
5
- import { fileURLToPath, pathToFileURL } from "node:url";
6
- import { existsSync, readFileSync } from "node:fs";
7
-
8
- const __dirname = dirname(fileURLToPath(import.meta.url));
9
- const PKG_ROOT = resolve(__dirname, "..");
10
- const PKG_VERSION = (() => {
11
- try { return JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version ?? "0.0.0"; }
12
- catch { return "0.0.0"; }
13
- })();
14
-
15
- const argv = process.argv.slice(2);
16
- const flags = parseArgs(argv);
17
-
18
- if (flags.help) {
19
- printHelp();
20
- process.exit(0);
21
- }
22
-
23
- if (flags.uninstall) {
24
- const { uninstallHooks, hasCodexInstalled } = await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
25
- const claude = await uninstallHooks({ provider: "claude" });
26
- console.log(claude.changed
27
- ? `agents-deck: hooks removed from ${claude.settingsPath}`
28
- : "agents-deck: no Claude hooks to remove");
29
- if (hasCodexInstalled()) {
30
- const codex = await uninstallHooks({ provider: "codex" });
31
- console.log(codex.changed
32
- ? `agents-deck: hooks removed from ${codex.settingsPath}`
33
- : "agents-deck: no Codex hooks to remove");
34
- }
35
- process.exit(0);
36
- }
37
-
38
- const port = Number(flags.port ?? process.env.AGENT_DAG_PORT ?? 4317);
39
- // Default = machine-wide (capture every CC session on this box). Pass
40
- // `--workspace <path>` (or `--scope`) to restrict to a single tree.
41
- const workspace = flags.workspace != null
42
- ? flags.workspace
43
- : (flags.scope ? process.cwd() : "");
44
- const openBrowser = flags.noOpen !== true;
45
- const persist = flags.noPersist
46
- ? null
47
- : (flags.history ?? join(homedir(), ".claude", "agent-dag", "events.jsonl"));
48
-
49
- const { installHooks, writeDiscovery, removeDiscovery, hasCodexInstalled } =
50
- await import(pathToFileURL(join(PKG_ROOT, "src/server/installer.mjs")).href);
51
- const { startServer } =
52
- await import(pathToFileURL(join(PKG_ROOT, "src/server/index.mjs")).href);
53
-
54
- // Codex hooks install when ~/.codex/ exists, unless --no-codex was passed.
55
- // --codex forces install even if the dir is missing (creates it).
56
- const wantCodex = flags.noCodex
57
- ? false
58
- : (flags.codex === true || hasCodexInstalled());
59
-
60
- const WEB_DIST = join(PKG_ROOT, "dist", "web", "index.html");
61
- if (!existsSync(WEB_DIST)) {
62
- console.error("agents-deck: ui not built. run `npm run build` (or `pnpm build`) first.");
63
- process.exit(1);
64
- }
65
-
66
- // ── ANSI helpers ──────────────────────────────────────────────────────────────
67
- const tty = process.stdout.isTTY;
68
- const C = {
69
- reset: tty ? "\x1b[0m" : "",
70
- bold: tty ? "\x1b[1m" : "",
71
- dim: tty ? "\x1b[2m" : "",
72
- cyan: tty ? "\x1b[36m" : "",
73
- blue: tty ? "\x1b[34m" : "",
74
- magenta: tty ? "\x1b[35m" : "",
75
- yellow: tty ? "\x1b[33m" : "",
76
- green: tty ? "\x1b[32m" : "",
77
- white: tty ? "\x1b[97m" : "",
78
- bCyan: tty ? "\x1b[96m" : "",
79
- bMag: tty ? "\x1b[95m" : "",
80
- };
81
- const sleep = ms => new Promise(r => setTimeout(r, ms));
82
-
83
- // ── Animated banner ───────────────────────────────────────────────────────────
84
- async function printBanner() {
85
- // figlet slant font — hardcoded, no runtime dep
86
- const ART = [
87
- ' __ __ __ ',
88
- ' ____ _____ ____ ____ / /______ ____/ /__ _____/ /__',
89
- ' / __ `/ __ `/ _ \\/ __ \\/ __/ ___/_____/ __ / _ \\/ ___/ //_/',
90
- '/ /_/ / /_/ / __/ / / / /_(__ )_____/ /_/ / __/ /__/ ,< ',
91
- '\\__,_/\\__, /\\___/_/ /_/\\__/____/ \\__,_/\\___/\\___/_/|_| ',
92
- ' /____/ ',
93
- ];
94
- const COLORS = [C.dim, C.blue, C.cyan, C.bCyan, C.magenta, C.dim];
95
-
96
- process.stdout.write('\n');
97
-
98
- if (tty) {
99
- const frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
100
- for (let i = 0; i < 8; i++) {
101
- process.stdout.write(`\r ${C.bCyan}${frames[i % frames.length]}${C.reset} ${C.dim}loading…${C.reset}`);
102
- await sleep(70);
103
- }
104
- process.stdout.write('\r' + ' '.repeat(28) + '\n');
105
- await sleep(40);
106
- }
107
-
108
- for (let i = 0; i < ART.length; i++) {
109
- process.stdout.write(` ${COLORS[i]}${ART[i]}${C.reset}\n`);
110
- if (tty) await sleep(38);
111
- }
112
-
113
- process.stdout.write(`\n ${C.dim}v${PKG_VERSION} · live agent DAG · Claude Code + Codex${C.reset}\n\n`);
114
- }
115
-
116
- // ── Spinner ───────────────────────────────────────────────────────────────────
117
- function spinner(label) {
118
- if (!tty) { process.stdout.write(` … ${label}\n`); return { stop: (ok, msg) => process.stdout.write(` ${ok ? "✓" : "✗"} ${msg}\n`) }; }
119
- const frames = ["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"];
120
- let i = 0;
121
- const iv = setInterval(() => {
122
- process.stdout.write(`\r ${C.cyan}${frames[i++ % frames.length]}${C.reset} ${label}`);
123
- }, 80);
124
- return {
125
- stop(ok, msg) {
126
- clearInterval(iv);
127
- const icon = ok ? `${C.green}✓${C.reset}` : `${C.yellow}✗${C.reset}`;
128
- process.stdout.write(`\r ${icon} ${msg}\n`);
129
- }
130
- };
131
- }
132
-
133
- await printBanner();
134
-
135
- // ── Startup steps ─────────────────────────────────────────────────────────────
136
- process.stdout.write(` ${C.dim}workspace :${C.reset} ${workspace === "" ? C.yellow + "(all)" + C.reset : workspace}\n`);
137
-
138
- let sp = spinner("installing Claude hooks…");
139
- const claudeInstall = await installHooks({ provider: "claude" });
140
- sp.stop(true, `Claude hooks ${C.dim}→ ${claudeInstall.hookPath}${C.reset}`);
141
-
142
- // Codex CLI hooks never fire on Windows (sandbox refuses to spawn the hook
143
- // command). Instead the server tails Codex's rollout JSONL files directly, so
144
- // there's nothing to install and no /hooks trust step. We just confirm Codex
145
- // is present and let the watcher pick up sessions.
146
- if (wantCodex) {
147
- process.stdout.write(` ${C.green}✓${C.reset} Codex sessions ${C.dim}→ watching ${join(homedir(), ".codex", "sessions")}${C.reset}\n`);
148
- } else {
149
- process.stdout.write(` ${C.dim}Codex watch skipped (no ~/.codex/, or --no-codex)${C.reset}\n`);
150
- }
151
-
152
- // claude-swap backs the multi-account panel. Installing it touches the user's
153
- // global tool path, so unlike the ccusage install this one announces itself.
154
- {
155
- const { ensureCswap } = await import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-install.mjs")).href);
156
- const csp = spinner("checking claude-swap…");
157
- const cs = await ensureCswap();
158
- if (cs.state === "present") {
159
- csp.stop(true, `claude-swap ${C.dim}→ v${cs.version} (accounts panel enabled)${C.reset}`);
160
- } else if (cs.state === "installed") {
161
- csp.stop(true, `claude-swap ${C.dim}→ installed v${cs.version} via ${cs.via}${C.reset}`);
162
- } else if (cs.state === "upgrading") {
163
- csp.stop(true, `claude-swap ${C.dim}→ v${cs.version}, upgrading to v${cs.latest} in background${C.reset}`);
164
- } else if (cs.state === "skipped") {
165
- csp.stop(true, `claude-swap ${C.dim}not installed (AGENTS_DECK_NO_INSTALL=1)${C.reset}`);
166
- } else {
167
- const how = cs.reason === "no_installer"
168
- ? "not installed — the accounts panel needs it"
169
- : cs.reason === "not_on_path"
170
- ? `installed via ${cs.via} but not on PATH — add ${
171
- process.platform === "win32" ? "%USERPROFILE%\\.local\\bin" : "~/.local/bin"
172
- }`
173
- : `install failed via ${cs.via}`;
174
- csp.stop(false, `claude-swap ${C.dim}${how}${C.reset}`);
175
- // A URL is not an answer when someone just wants the panel to work. Print
176
- // the command for THIS machine, picked from what is already on it.
177
- if (cs.hint) process.stdout.write(` ${C.dim}${cs.hint}${C.reset}\n`);
178
- }
179
-
180
- // A working claude-swap with an empty store still leaves the panel useless,
181
- // so the account already signed in is registered once. Bounded inside
182
- // seedFirstAccount: empty store only, once ever, never with NO_INSTALL set.
183
- if (cs.state === "present" || cs.state === "installed" || cs.state === "upgrading") {
184
- const { seedFirstAccount } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
185
- const seed = await seedFirstAccount().catch(() => ({ state: "failed" }));
186
- if (seed.state === "added") {
187
- process.stdout.write(` ${C.green}✓${C.reset} accounts ${C.dim}registered the signed-in account (cswap add)${C.reset}\n`);
188
- } else if (seed.state === "failed" || seed.state === "nothing-to-add") {
189
- process.stdout.write(` ${C.dim} accounts panel empty — sign in to Claude Code, then run cswap add${C.reset}\n`);
2
+ // Supervisor. Owns one thing: the worker's lifecycle.
3
+ //
4
+ // Why this exists at all: Node caches every module at import, so a deck that is
5
+ // running when an upgrade lands keeps executing the old code until the process
6
+ // is replaced. The deck can now see that (GET /api/version) — this is the half
7
+ // that can act on it.
8
+ //
9
+ // The tempting shortcut is to have the server respawn itself and exit. Every
10
+ // version of that is worse than it looks:
11
+ // • the replacement races the dying listener, and startServer answers
12
+ // EADDRINUSE by binding one of ten RANDOM ports in 4318–4400 — so the tab
13
+ // you are looking at reconnects forever next to a healthy invisible server;
14
+ // • an orphan spawned from a dying parent leaves the shell's foreground
15
+ // process group, so Ctrl+C stops reaching it;
16
+ // • with stdio ignored it also loses the banner, the URL line and every
17
+ // console.error the server writes.
18
+ // A parent that stays alive avoids all three: the child is dead — and its
19
+ // listening socket released — before the next one is spawned, stdio is
20
+ // inherited so the terminal is unchanged, and Ctrl+C keeps working because the
21
+ // process group never changes.
22
+ //
23
+ // Everything else the deck does still lives in bin/deck.js. This file must stay
24
+ // boring: it is the one process that is never replaced.
25
+ import { spawn } from "node:child_process";
26
+ import { dirname, join } from "node:path";
27
+ import { fileURLToPath } from "node:url";
28
+
29
+ // Chosen because it means nothing else here: the worker exits 0 normally and
30
+ // non-zero on failure, both of which must pass straight through.
31
+ const RESTART_CODE = 75;
32
+ const WORKER = join(dirname(fileURLToPath(import.meta.url)), "deck.js");
33
+
34
+ // The port the worker actually bound, which is not necessarily the one it was
35
+ // asked for — the first launch falls back to a random port when 4317 is taken.
36
+ // Re-launching without this is how a restart silently moves the deck out from
37
+ // under an open tab.
38
+ let boundPort = null;
39
+ let restarts = 0;
40
+ let child = null;
41
+
42
+ function launch(respawn) {
43
+ const args = [WORKER, ...process.argv.slice(2)];
44
+ // Appended last so it wins: the worker's parser keeps the final --port.
45
+ if (respawn && boundPort != null) args.push("--port", String(boundPort));
46
+
47
+ child = spawn(process.execPath, args, {
48
+ // stdio inherited so the child owns the same terminal the user started:
49
+ // same banner, same colours, same Ctrl+C. The fourth slot adds an IPC
50
+ // channel — the only way the worker can tell us which port it got, since
51
+ // parsing its stdout would be guesswork.
52
+ stdio: ["inherit", "inherit", "inherit", "ipc"],
53
+ env: {
54
+ ...process.env,
55
+ // Boot did the slow, once-per-session work already (hook install, the
56
+ // claude-swap probe with its 8s timeout, the ccusage prime). Repeating it
57
+ // is what would make a restart feel like a restart.
58
+ AGENTS_DECK_RESPAWN: respawn ? "1" : "",
59
+ AGENTS_DECK_RESTARTS: String(restarts),
60
+ },
61
+ });
62
+
63
+ child.on("message", (m) => {
64
+ if (m && m.type === "listening" && typeof m.port === "number") boundPort = m.port;
65
+ });
66
+
67
+ child.on("exit", (code, signal) => {
68
+ child = null;
69
+ if (code === RESTART_CODE) {
70
+ restarts++;
71
+ launch(true);
72
+ return;
190
73
  }
191
- }
74
+ // Anything else is the worker's own verdict and belongs to whoever started
75
+ // us — including the ccdeck wrapper, which exits with our code in turn.
76
+ if (signal) process.kill(process.pid, signal);
77
+ else process.exit(code ?? 0);
78
+ });
79
+
80
+ child.on("error", (err) => {
81
+ console.error(`agents-deck: could not start ${WORKER}: ${err.message}`);
82
+ process.exit(1);
83
+ });
192
84
  }
193
85
 
194
- // ccusage backs the usage-history modal. Primed here rather than on first
195
- // open so a cold machine pays the install while the deck is still booting.
196
- if (process.env.AGENTS_DECK_NO_INSTALL !== "1") {
197
- const { primeCcusage } = await import(pathToFileURL(join(PKG_ROOT, "src/server/ccusage.mjs")).href);
198
- const cu = primeCcusage();
199
- if (cu.state === "present") process.stdout.write(` ${C.green}✓${C.reset} ccusage ${C.dim}→ v${cu.version}${C.reset}\n`);
200
- else if (cu.state === "updating") process.stdout.write(` ${C.green}✓${C.reset} ccusage ${C.dim}→ v${cu.version}, checking for update${C.reset}\n`);
201
- else if (cu.state === "installing") process.stdout.write(` ${C.green}✓${C.reset} ccusage ${C.dim}installing in background${C.reset}\n`);
86
+ // Ctrl+C already reaches the child directly it shares this process group — so
87
+ // forwarding would deliver it twice. These handlers exist only to keep the
88
+ // supervisor alive long enough for the child's own graceful shutdown to run and
89
+ // for its exit code to arrive.
90
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
91
+ process.on(sig, () => {
92
+ // On Windows none of these are delivered to a Node process, which is fine:
93
+ // there the console kills the whole tree and sweepStaleDiscovery cleans up
94
+ // on the next boot.
95
+ if (child) { try { child.kill(sig); } catch { /* already gone */ } }
96
+ else process.exit(0);
97
+ });
202
98
  }
203
99
 
204
- // A newer release on npm, said once, in the place the upgrade gets typed.
205
- // Started here and collected below so the lookup overlaps the rest of boot, and
206
- // hard-capped so a slow registry cannot delay the server — the answer is
207
- // usually already cached in ~/.agents-deck/.self-update-check anyway. It has to
208
- // resolve BEFORE the pulse indicator starts writing over the last line.
209
- const selfCheck = import(pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href)
210
- .then(m => m.versionReport({ running: PKG_VERSION, pkgRoot: PKG_ROOT }))
211
- .catch(() => null);
212
- const upgrade = await Promise.race([
213
- selfCheck.then(r => r?.notice?.kind === "upgrade" ? r : null),
214
- new Promise(r => setTimeout(() => r(null), 1200)),
215
- ]);
216
- if (upgrade) {
217
- process.stdout.write(
218
- ` ${C.yellow}↑${C.reset} update ${C.dim}v${upgrade.notice.to} available — ${C.reset}${C.yellow}${upgrade.command}${C.reset}\n`,
219
- );
220
- }
221
-
222
- sp = spinner("starting server…");
223
- const server = await startServer({ port, persist, workspace, codex: wantCodex }).catch(err => {
224
- sp.stop(false, `server failed: ${err.message}`);
225
- process.exit(1);
226
- });
227
- const addr = server.address();
228
- const realPort = typeof addr === "object" && addr ? addr.port : port;
229
- const url = `http://127.0.0.1:${realPort}`;
230
- sp.stop(true, `server ready ${C.dim}→ ${C.reset}${C.bCyan}${C.bold}${url}${C.reset}`);
231
-
232
- if (persist) process.stdout.write(` ${C.dim}log : ${persist}${C.reset}\n`);
233
-
234
- process.stdout.write(`\n ${C.green}${C.bold}▶ opening browser…${C.reset}\n\n`);
235
-
236
- const discoveryFile = await writeDiscovery({ port: realPort, workspace });
237
-
238
- if (openBrowser) {
239
- try {
240
- const { default: open } = await import("open");
241
- await open(url);
242
- } catch {}
243
- }
244
-
245
- // ── Pulse indicator ───────────────────────────────────────────────────────────
246
- if (tty) {
247
- const pulseFrames = [`${C.green}●${C.reset}`, `${C.dim}●${C.reset}`];
248
- let pi = 0;
249
- setInterval(() => {
250
- process.stdout.write(`\r ${pulseFrames[pi++ % 2]} ${C.dim}listening — Ctrl+C to stop${C.reset} `);
251
- }, 800).unref();
252
- }
253
-
254
- const shutdown = async () => {
255
- if (tty) process.stdout.write(`\n\n ${C.yellow}◉ shutting down…${C.reset}\n`);
256
- await removeDiscovery(discoveryFile);
257
- server.close(() => process.exit(0));
258
- setTimeout(() => process.exit(0), 1500).unref();
259
- };
260
- process.on("SIGINT", shutdown);
261
- process.on("SIGTERM", shutdown);
262
- process.on("beforeExit", () => removeDiscovery(discoveryFile));
263
-
264
- // ── helpers ───────────────────────────────────────────────────────────────────
265
-
266
- function parseArgs(args) {
267
- const out = {};
268
- for (let i = 0; i < args.length; i++) {
269
- const a = args[i];
270
- if (a === "-h" || a === "--help") out.help = true;
271
- else if (a === "-p" || a === "--port") out.port = args[++i];
272
- else if (a === "--no-open") out.noOpen = true;
273
- else if (a === "--uninstall") out.uninstall = true;
274
- else if (a === "--workspace") out.workspace = args[++i];
275
- else if (a === "--scope") out.scope = true;
276
- else if (a === "--all") out.all = true; // legacy no-op (now default)
277
- else if (a === "--no-persist") out.noPersist = true;
278
- else if (a === "--history") out.history = args[++i];
279
- else if (a === "--codex") out.codex = true;
280
- else if (a === "--no-codex") out.noCodex = true;
281
- }
282
- return out;
283
- }
284
-
285
- function printHelp() {
286
- process.stdout.write(`agents-deck — live deck of Claude Code + Codex agents
287
-
288
- Usage:
289
- agents-deck [options]
290
-
291
- Options:
292
- -p, --port <number> Preferred port (default: 4317; falls back to random 4318–4400)
293
- --no-open Don't open the browser automatically
294
- --workspace <path> Only capture sessions whose cwd is inside <path>
295
- --scope Restrict to current working directory
296
- --all Capture every session (default)
297
- --history <path> Override events log file (default: ~/.claude/agent-dag/events.jsonl)
298
- --no-persist Don't write or replay events log (RAM-only)
299
- --codex Force-enable Codex capture even if ~/.codex/ missing
300
- --no-codex Skip Codex capture (Claude only)
301
- --uninstall Remove agents-deck Claude hook entries
302
- -h, --help Show this help
303
- `);
304
- }
100
+ launch(false);