ccdeck 3.22.7 → 3.22.9

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
@@ -96,7 +96,7 @@ The deck opens on these eight pictures the first time it runs — they are the w
96
96
  ## Quick start
97
97
 
98
98
  ```bash
99
- npx ccdeck # or: npx agents-deck · npx agent-dag — same deck
99
+ npx ccdeck
100
100
  ```
101
101
 
102
102
  Opens **http://127.0.0.1:4317**, shows an eight-picture tour the first time, and registers the Claude Code hook on first run. If something else already holds 4317, the deck takes a port between 4318 and 4400 instead and prints the address it ended up on — that line in the terminal is the one to trust. Start any Claude Code or Codex session and the graph fills in live.
@@ -241,9 +241,9 @@ ccdeck [options]
241
241
  --no-open Don't open the browser automatically
242
242
  --foreground Hold the terminal, the way every version before 3.20
243
243
  did (Ctrl+C stops the deck again)
244
- --new Start a second deck even if one is already running
245
- --stop Stop the deck a bare `ccdeck` would open
246
- (--port <n> stops that one; --all stops every deck)
244
+ --new Replace the running deck with a fresh one
245
+ --stop Stop the running deck
246
+ (--port <n> stops only the one on that port)
247
247
  --status What is running on this machine, and on which ports
248
248
  --logs What a backgrounded deck wrote where a terminal would
249
249
  have shown it
@@ -272,10 +272,8 @@ dashboard.
272
272
 
273
273
  `--foreground` is there for anything that was relying on the old behaviour — a
274
274
  wrapper script, a CI step, a supervisor of your own that starts the deck and
275
- waits on it. On the first run after upgrading you may also see a line about a
276
- deck from an older version still running: those publish nothing this one can
277
- check, so it starts beside them rather than attaching to something it cannot
278
- identify, and `ccdeck --stop --all` clears them.
275
+ waits on it. On the first run after upgrading, the deck from the older version
276
+ is stopped and the new one takes its place; the boot report says so.
279
277
 
280
278
  `ccdeck` runs in the background. The boot report prints in your terminal exactly
281
279
  as it always has — the hooks, the port, the URL — and then the prompt comes back
@@ -317,9 +315,16 @@ The port fallback is still there — 4317 is also the standard OTLP collector
317
315
  port, and Windows reserves whole TCP ranges for Hyper-V, WSL2 and Docker — but
318
316
  it now runs only when the thing holding the port is *not* one of your decks. The
319
317
  deck on the port has to prove it is yours, with the same token handshake the
320
- hooks use, before its tab is opened. Any flag that changes what the deck **is**
321
- — a port, a workspace, a log, either `--codex` or `--claude` pair — still starts
322
- its own, and `--new` starts one unconditionally.
318
+ hooks use, before its tab is opened.
319
+
320
+ **There is only ever one.** A start that asks for a different deck — a port, a
321
+ workspace, a log, either `--codex` or `--claude` pair — or that is a newer
322
+ version than the deck running, stops that deck and takes its place; `--new` does
323
+ so unconditionally. Two starts at the same moment, such as the login item and a
324
+ terminal opened at login, wait for each other instead of both starting. The rule
325
+ holds per Claude config directory: pointing `CLAUDE_CONFIG_DIR` somewhere else
326
+ is a second deck with its own identity, as it always was, and the login item is
327
+ given the same directory as the shell that installed it.
323
328
 
324
329
  ccdeck looks for each CLI before it does anything on that CLI's behalf. Claude
325
330
  Code counts as present when its binary is on `PATH` (or in one of the places its
@@ -380,7 +385,7 @@ What the banner offers depends on how this copy was installed:
380
385
 
381
386
  | Installed as | Offer |
382
387
  |---|---|
383
- | global npm install | **Update now** — runs `npm install -g` on the name you installed (`ccdeck`, `agents-deck` or `agent-dag`), then restarts once nothing is running |
388
+ | global npm install | **Update now** — runs `npm install -g` on the package you installed, then restarts once nothing is running |
384
389
  | `npx` | **Update & restart** — re-runs the spec through npx, which fetches a fresh copy and takes over the same port |
385
390
  | git checkout | the command, because your working copy leads npm: `git pull && npm run build` |
386
391
  | directory not writable | the command — a root-owned prefix is declined up front rather than failing inside npm |
@@ -403,24 +408,19 @@ It restarts on its own only after 30 seconds with nothing running, because hook
403
408
 
404
409
  ## Names
405
410
 
406
- **ccdeck** is the name — of this repo and of the command. On npm it goes out
407
- under three, and `npx` runs the same deck from any of them.
411
+ **ccdeck** is the name — of this repo, of the npm package and of the command.
412
+
413
+ The deck used to be published as `agents-deck` and `agent-dag` as well. Neither
414
+ is published any more; both stay on npm at their last version, 3.22.8. An
415
+ install under one of them keeps running, but it may never update itself again,
416
+ and the only command it puts on your `PATH` is the old one. To move to `ccdeck`:
408
417
 
409
418
  ```bash
410
- npx ccdeck # this repo's name — the short one
411
- npx agents-deck # the name it shipped under before the rename
412
- npx agent-dag # the original name; existing installs and scripts keep working
419
+ npm rm -g agents-deck agent-dag
420
+ npm i -g ccdeck
421
+ ccdeck --install-service # only if the deck started when you logged in
413
422
  ```
414
423
 
415
- The three are one build published three times, so they behave identically:
416
- `npx` on any of them fetches a single package, and `npm i -g` on any of them
417
- puts all three commands on your `PATH`.
418
-
419
- `ccdeck` was the exception until recently — a thin package that depended on
420
- `agents-deck` and spawned its binary. It worked, but `npx ccdeck` downloaded two
421
- packages instead of one and a global `ccdeck` ran under the old package's name.
422
- It is now the same build as the other two.
423
-
424
424
  The repository was previously named `agents-deck`; the old URL redirects here,
425
425
  so existing clones, links and bookmarks keep working.
426
426
 
package/bin/agent-dag.js CHANGED
@@ -40,7 +40,7 @@ import { invokedAs } from "../src/server/invoked-as.mjs";
40
40
  import { isOneShot, parseArgs } from "../src/server/args.mjs";
41
41
  import { npxFailureHint, npxFailureSummary, npxLaunch, npxPrefetch } from "../src/server/npx.mjs";
42
42
  import {
43
- bareSpecName, claimRestartFailureKey, clearRestartFailure, installedName, installedVersion,
43
+ bareSpecName, claimRestartFailureKey, clearRestartFailure, currentName, installedName, installedVersion,
44
44
  isNpxInstall, lastKnownLatest, npxRestartSpec, readRestartFailure, recordRestartFailure,
45
45
  successorRoot,
46
46
  } from "../src/server/self-update.mjs";
@@ -354,7 +354,13 @@ function withoutPortAndOpen(args) {
354
354
  function npxUpgrade() {
355
355
  const self = installedName(PKG_ROOT);
356
356
  const spec = npxRestartSpec(PKG_ROOT, self);
357
- return spec ? { spec, pkgName: bareSpecName(spec) ?? self } : null;
357
+ if (!spec) return null;
358
+ // A retired name relaunches as ccdeck, which is how upgradeName answers it on
359
+ // the worker's side: `npx -y agents-deck@latest` resolves to the same
360
+ // last-published package every time, so npx would reuse its cached copy and
361
+ // the deck would never move.
362
+ const pkgName = currentName(bareSpecName(spec) ?? self);
363
+ return { spec: `${pkgName}@latest`, pkgName };
358
364
  }
359
365
 
360
366
  /** The note the browser reads, written by the only process that knows why an
package/bin/deck.js CHANGED
@@ -21,7 +21,10 @@ import { budget, bootDeadlineMs } from "../src/server/boot-deadline.mjs";
21
21
  // with the rest rather than fetched later. Deliberately NOT the other way
22
22
  // round: it takes the handshake as a parameter precisely so that it never has
23
23
  // to import the server. See the note at the top of that file.
24
- import { asksForOwnDeck, runningDeck, versionNote } from "../src/server/running-deck.mjs";
24
+ import { deckRegistryDir, liveDecks, olderVersion, secondStart, versionNote } from "../src/server/running-deck.mjs";
25
+ // The same kind of leaf — fs, path, crypto and deck-probe.mjs — for the same
26
+ // reason: it is taken before anything else in the boot has run.
27
+ import { takeBootLock } from "../src/server/boot-lock.mjs";
25
28
 
26
29
  const __dirname = dirname(fileURLToPath(import.meta.url));
27
30
  const PKG_ROOT = resolve(__dirname, "..");
@@ -178,12 +181,11 @@ if (flags.uninstall) {
178
181
  // block below gives (#797): those are declared two hundred lines further down
179
182
  // and this runs at module top level.
180
183
  //
181
- // WHICH DECK. The shape a bare `ccdeck` would have built — same `sameShape` the
182
- // attach uses, so `ccdeck` and `ccdeck --stop` name the same deck and there is
183
- // one model to hold. `--port <n>` names one directly; `--all` takes every deck
184
- // on the machine. (`--all` is the legacy capture flag, a no-op since it became
185
- // the default; beside `--stop` it can only mean this, and it is the word a
186
- // person reaches for.)
184
+ // WHICH DECK. Every one, unless `--port <n>` names one. A start keeps at most
185
+ // one deck now, so a second is a leftover from before that rule, and an off
186
+ // switch that ended one of two would leave the machine running. (`--all` is the
187
+ // legacy capture flag, a no-op since it became the default; beside `--stop` it
188
+ // always meant "every deck", and that is what the bare command means now.)
187
189
  if (flags.stop || flags.status || flags.logs || flags.install || flags.installService || flags.uninstallService) {
188
190
  const { dash, ok: gOk, warn: gWarn, bullet, arrow, ellipsis: gEllipsis } = glyphs(unicodeOK());
189
191
  const tone = palette(colorProfile({ isTTY: Boolean(process.stdout.isTTY) }));
@@ -391,21 +393,18 @@ if (flags.stop || flags.status || flags.logs || flags.install || flags.installSe
391
393
  // default SHAPE, so the matcher finds it without help. It is also declared
392
394
  // below this block, which would make reading it here a ReferenceError (#797).
393
395
  const named = flags.port != null && isPortValue(flags.port) ? Number(flags.port) : null;
394
- const wanted = flags.all
395
- ? decks
396
- : named !== null
397
- ? decks.filter(d => d.port === named)
398
- : decks.filter(d => sameShape(d, mine)).slice(0, 1);
396
+ // EVERY DECK, unless one is named. There is meant to be one — a start keeps
397
+ // at most one now — so a second here is a leftover from before that rule, and
398
+ // an off switch that ended one of two would leave the machine running. `--all`
399
+ // is still accepted, and now means what the bare command means.
400
+ const wanted = named !== null ? decks.filter(d => d.port === named) : decks;
399
401
 
400
402
  if (!wanted.length) {
401
- // Three different silences, and saying the wrong one sends the reader
402
- // looking in the wrong place. A machine with no deck at all is not a
403
- // machine whose deck is scoped differently.
404
- const why = !decks.length
405
- ? `no deck is running`
406
- : named !== null
407
- ? `no deck is listening on ${named} ${dash} \`${INVOKED_AS ?? PRODUCT} --status\` lists them`
408
- : `no deck of this shape is running ${dash} \`${INVOKED_AS ?? PRODUCT} --status\` lists the ${decks.length} that ${decks.length === 1 ? "is" : "are"}`;
403
+ // Two different silences, and saying the wrong one sends the reader looking
404
+ // in the wrong place.
405
+ const why = named !== null && decks.length
406
+ ? `no deck is listening on ${named} ${dash} \`${INVOKED_AS ?? PRODUCT} --status\` lists them`
407
+ : `no deck is running`;
409
408
  say(`\n ${tone.muted}${dash} ${why}${tone.reset}\n`);
410
409
  process.exit(0);
411
410
  }
@@ -1180,6 +1179,10 @@ function restartTarget() {
1180
1179
  let server = null;
1181
1180
  let discovery = null;
1182
1181
  let discoveryFile = null;
1182
+ // The boot lock, for the same reason: taken at the gate below, given back once
1183
+ // this deck is registered, and given back by the exit handler on every way out
1184
+ // before that. See src/server/boot-lock.mjs.
1185
+ let bootLock = null;
1183
1186
 
1184
1187
  // This worker does not outlive the supervisor that started it (#702).
1185
1188
  //
@@ -1235,9 +1238,11 @@ dieWithParent(() => shutdown(0));
1235
1238
  // ── is one of ours already up? ────────────────────────────────────────────────
1236
1239
  // A bare `ccdeck` typed beside a deck that is already running used to build a
1237
1240
  // second everything on a random port, and neither half mentioned the other.
1238
- // src/server/running-deck.mjs carries the whole argument, the registry read and
1239
- // the token handshake that makes the answer trustworthy; this is only where it
1240
- // is asked.
1241
+ // Then it attached only to a deck of exactly its own shape and built a second
1242
+ // one beside anything else — a different flag, an older version, or a login
1243
+ // item whose environment decided `codex` or the log path differently from the
1244
+ // shell's. src/server/running-deck.mjs carries the whole argument, the registry
1245
+ // read, the handshake and the rule; this is only where it is asked and acted on.
1241
1246
  //
1242
1247
  // ASKED EXACTLY HERE, and the position is the point. Everything the answer
1243
1248
  // depends on is resolved above — the workspace, the canonical log path, which
@@ -1246,16 +1251,52 @@ dieWithParent(() => shutdown(0));
1246
1251
  // discovery file written. An attach therefore leaves the machine precisely as
1247
1252
  // it found it, which is what makes it safe to do without asking.
1248
1253
  //
1249
- // A respawn is excluded on its own line rather than left to the flag check.
1250
- // The supervisor relaunches us with `--port <bound>`, which is a shaping flag
1251
- // and would be excluded anyway — but a restart is THIS deck coming back, and
1252
- // having that read as "somebody typed ccdeck twice" is an accident waiting for
1253
- // the day the supervisor stops passing the port.
1254
- if (!RESPAWN && !asksForOwnDeck(flags)) {
1255
- const live = await runningDeck({
1254
+ // UNDER THE BOOT LOCK, held until this deck's own record is on disk — see the
1255
+ // release beside discovery.check below, and boot-lock.mjs for why a registry
1256
+ // read alone could not close the window two starts at login fell through.
1257
+ //
1258
+ // A RESPAWN IS ASKED TOO, and answers differently. A restart is THIS deck
1259
+ // coming back, so there is nothing to attach to — but in the gap a crash
1260
+ // leaves, a `ccdeck` typed by hand finds no deck and starts one, and the
1261
+ // respawn that followed used to take a random port beside it. Now it finds
1262
+ // that deck and exits 0, and the supervisor, reading a clean exit, ends too.
1263
+ bootLock = await takeBootLock({ dir: deckRegistryDir() }).catch(() => null);
1264
+ {
1265
+ // `--port` alone, not AGENT_DAG_PORT: the variable is how somebody RUNS a
1266
+ // deck rather than which one they mean — the line `--stop` draws above.
1267
+ const askedPort = flags.port != null && isPortValue(flags.port) ? Number(flags.port) : null;
1268
+ const plan = secondStart({
1269
+ live: await liveDecks().catch(() => []),
1256
1270
  want: { workspace, persist, codex: wantCodex, claude: wantClaude },
1257
- }).catch(() => null);
1258
- if (live) {
1271
+ port: askedPort,
1272
+ ours: PKG_VERSION,
1273
+ fresh: flags.new === true,
1274
+ respawn: RESPAWN,
1275
+ });
1276
+ if (plan.act === "yield") {
1277
+ console.error(`${PRODUCT}: a deck started on ${plan.deck.port} while this one was coming back ${G.dash} leaving it to that one.`);
1278
+ process.exit(0);
1279
+ }
1280
+ // THE NEWEST START WINS. What is stopped here is either the deck this start
1281
+ // replaces or, on an attach, a second deck left over from before this rule —
1282
+ // the duplicate the rule exists to end. Each one is said, with why, because a
1283
+ // deck that vanishes without a word is a mystery of its own.
1284
+ if (plan.stop.length) {
1285
+ const { stopDeck } = await import(pathToFileURL(join(PKG_ROOT, "src/server/stop-deck.mjs")).href);
1286
+ for (const d of plan.stop) {
1287
+ const why = plan.act === "replace" && flags.new === true
1288
+ ? "you asked for a fresh one"
1289
+ : olderVersion(d.version, PKG_VERSION)
1290
+ ? (d.version ? `it was v${d.version}` : "it was an older version")
1291
+ : plan.act === "attach" ? "it was a second deck" : "it was started with different settings";
1292
+ const out = await stopDeck(d).catch(() => ({ ok: false, reason: "unreachable" }));
1293
+ write(out.ok
1294
+ ? `\n ${P.ok}${G.ok}${P.reset} stopped the deck on ${d.port}${P.muted} ${G.bullet} pid ${d.pid} ${G.bullet} ${why}${P.reset}\n`
1295
+ : `\n ${P.warn}${G.warn} could not stop the deck on ${d.port} (pid ${d.pid}) ${G.dash} ${out.reason ?? out.how}${P.reset}\n`);
1296
+ }
1297
+ }
1298
+ if (plan.act === "attach") {
1299
+ const live = plan.deck;
1259
1300
  const liveUrl = `http://127.0.0.1:${live.port}`;
1260
1301
  const note = versionNote(live.version, PKG_VERSION);
1261
1302
  // Not the startup report's rows. That report has a label column because it
@@ -1275,7 +1316,7 @@ if (!RESPAWN && !asksForOwnDeck(flags)) {
1275
1316
  if (note) write(` ${P.warn}${G.warn} ${note}${P.reset}\n`);
1276
1317
  // THE TYPO'S WARNING, on the path that has no startup report to carry it.
1277
1318
  //
1278
- // asksForOwnDeck used to answer `true` for these two so the report would
1319
+ // The gate used to answer `true` for these two so the report would
1279
1320
  // run and print them, and that is how `ccdeck --stpo` — a misspelling of
1280
1321
  // the flag that STOPS a deck — came to build a second one. The warning was
1281
1322
  // the requirement; the extra process never was. Printed here, in the same
@@ -1284,8 +1325,8 @@ if (!RESPAWN && !asksForOwnDeck(flags)) {
1284
1325
  reportIncompleteFlags(flags.incomplete);
1285
1326
  // The line that says a second deck was NOT started. Without it the command
1286
1327
  // looks like it did nothing at all, which is the other way to be confusing
1287
- // about this — and it names the flag for the person who really did want two.
1288
- write(`\n ${P.muted}${G.dash} no second deck was started ${G.dash} \`${INVOKED_AS ?? PRODUCT} --new\` starts one${P.reset}\n`);
1328
+ // about this — and it names the flag for the person who wanted a fresh one.
1329
+ write(`\n ${P.muted}${G.dash} no second deck was started ${G.dash} \`${INVOKED_AS ?? PRODUCT} --new\` replaces it with a fresh one${P.reset}\n`);
1289
1330
  if (openBrowser) {
1290
1331
  write(`\n ${P.ok}${P.bold}${G.play} opening browser${G.ellipsis}${P.reset}\n\n`);
1291
1332
  try {
@@ -1420,6 +1461,10 @@ discoveryFile = discovery.file;
1420
1461
  // Now, not in five seconds: nothing should reach the pulse line below without
1421
1462
  // the deck knowing whether the hooks can see it.
1422
1463
  await discovery.check();
1464
+ // Registered, so the next start can find this deck for itself and the boot
1465
+ // lock has done its job. Given back here rather than at exit, or a second
1466
+ // `ccdeck` would wait out this deck's whole life before attaching to it.
1467
+ bootLock?.release();
1423
1468
 
1424
1469
  // Never on a respawn: the tab that asked for the restart is still open and
1425
1470
  // reconnecting on its own. A second one would be the deck talking over itself.
@@ -1435,31 +1480,6 @@ if (openBrowser && !RESPAWN) {
1435
1480
  } catch {}
1436
1481
  }
1437
1482
 
1438
- // ── the deck from before this version that is still running ──────────────────
1439
- //
1440
- // UPGRADE DAY, and without this it is a mystery. A deck older than the attach
1441
- // publishes no `claude` and no `version` in its discovery record, so sameShape
1442
- // cannot match it — deliberately, because a record that cannot be compared is
1443
- // not one to attach to. The consequence is that the first `ccdeck` after an
1444
- // upgrade starts a SECOND deck beside the one already running, opens a tab on
1445
- // it, and says nothing; and `ccdeck --stop` will not find the old one either,
1446
- // because it has the same shape problem.
1447
- //
1448
- // So it is named, once, with the command that actually clears it. Only for
1449
- // decks with no version: a deck started with `--new`, or one scoped to another
1450
- // workspace, is somebody's deliberate second deck and needs no explaining.
1451
- if (!RESPAWN) {
1452
- try {
1453
- const { liveDecks } = await import(pathToFileURL(join(PKG_ROOT, "src/server/running-deck.mjs")).href);
1454
- const older = (await liveDecks()).filter(d => !d.version);
1455
- if (older.length) {
1456
- const where = older.map(d => d.port).join(", ");
1457
- write(` ${P.warn}${G.warn}${P.reset} ${P.muted}${older.length === 1 ? "a deck" : `${older.length} decks`} from an older ${PRODUCT} ${older.length === 1 ? "is" : "are"} still running on ${where}${P.reset}\n`);
1458
- write(` ${P.muted}too old to be recognised, so this one started beside ${older.length === 1 ? "it" : "them"} ${G.dash} \`${INVOKED_AS ?? PRODUCT} --stop --all\` clears the lot${P.reset}\n\n`);
1459
- }
1460
- } catch { /* a question about other decks is never a reason to fail a boot */ }
1461
- }
1462
-
1463
1483
  // ── starting at login ─────────────────────────────────────────────────────────
1464
1484
  //
1465
1485
  // ONCE PER MACHINE, EVER. The record in the deck's own data directory is what
@@ -1487,6 +1507,9 @@ if (!RESPAWN) {
1487
1507
  script: join(PKG_ROOT, "bin", "agent-dag.js"),
1488
1508
  logPath: join(deckLogDir(), "deck.log"),
1489
1509
  product: PRODUCT,
1510
+ // The directories that decide WHICH deck this is, so the one started at
1511
+ // login is the one started from this shell — see scopeEnv.
1512
+ serviceEnv: svc.scopeEnv(process.env),
1490
1513
  });
1491
1514
  svc.writeServiceRecord(deckDataDir(), out.ok
1492
1515
  ? { installed: PKG_VERSION, at: new Date().toISOString(), path: out.path }
@@ -1673,6 +1696,11 @@ async function shutdown(code = 0) {
1673
1696
  process.on("SIGINT", () => shutdown(0));
1674
1697
  process.on("SIGTERM", () => shutdown(0));
1675
1698
  process.on("beforeExit", () => { discovery?.stop(); if (discoveryFile) removeDiscovery(discoveryFile); });
1699
+ // Every way out, including the attach and the boot that fails before it
1700
+ // registers: a lock left behind would hold the next start for thirty seconds
1701
+ // before it could judge the holder gone. Synchronous, because `exit` allows
1702
+ // nothing else, and a no-op once the lock has been given back.
1703
+ process.on("exit", () => { bootLock?.release(); });
1676
1704
 
1677
1705
  // ── helpers ───────────────────────────────────────────────────────────────────
1678
1706
 
@@ -1762,11 +1790,11 @@ Options:
1762
1790
  --no-open Don't open the browser automatically
1763
1791
  --foreground Hold the terminal, the way every version before 3.20
1764
1792
  did. Ctrl+C stops the deck again
1765
- --new Start a second deck even if one is already running.
1793
+ --new Replace the running deck with a fresh one.
1766
1794
  Without it, a bare \`${PRODUCT}\` beside a deck that is
1767
1795
  already up opens that deck's tab instead of building
1768
1796
  a rival on another port
1769
- --stop Stop the deck a bare \`${PRODUCT}\` would open.
1797
+ --stop Stop the running deck.
1770
1798
  With --port <n>, stop that one; with --all, stop every
1771
1799
  deck on this machine
1772
1800
  --status What is running on this machine, and on which ports