agent-dag 1.44.1 → 1.45.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
@@ -135,7 +135,9 @@ them, the other one's hooks, installs and panels are skipped rather than shown
135
135
  empty — the boot banner says which way it went, and `--claude` / `--codex`
136
136
  override it if the guess is wrong.
137
137
 
138
- `--workspace` is a filter this deck applies to itself, not a claim on the sessions it matches: **every** running deck whose workspace contains a session's directory draws that session, so a machine-wide deck and one scoped to `~/proj` both show the agents working inside `~/proj`. It reads the same way on both capture paths — Claude Code's hook and Codex's rollout files — and the events log still gets exactly one copy of each event, whichever decks are up. A relative path is resolved against the directory you start the deck in, and once, so both paths scope to the same tree.
138
+ `--workspace` is a filter this deck applies to itself, not a claim on the sessions it matches: **every** running deck whose workspace contains a session's directory draws that session, so a machine-wide deck and one scoped to `~/proj` both show the agents working inside `~/proj`. It reads the same way on all three paths a session can reach the canvas by — Claude Code's hook, Codex's rollout files, and the boot replay of the events log — and the events log still gets exactly one copy of each event, whichever decks are up. A relative path is resolved against the directory you start the deck in, and once, so every path scopes to the same tree. The log is machine-wide by default and shared by every deck on the box, so a scoped deck replays only the part of it that is inside its own workspace: it comes up showing what it will go on to capture, and nothing else.
139
+
140
+ That one events log is also the reason Clear is not quite the per-deck button it looks like. The decks elect a single writer for each log file, and only that deck may empty it: Clear on any other deck wipes its own canvas and leaves the file to the deck that writes it. The confirmation says which of the two you are about to do, and how many decks share the log when it is yours to empty — so `--history` or `--no-persist` gives a deck a log of its own if you want Clear to answer to nobody else.
139
141
 
140
142
  Environment:
141
143
 
package/bin/agent-dag.js CHANGED
@@ -42,7 +42,7 @@ import {
42
42
  bareSpecName, claimRestartFailureKey, clearRestartFailure, installedName, installedVersion,
43
43
  lastKnownLatest, npxRestartSpec, readRestartFailure, recordRestartFailure, successorRoot,
44
44
  } from "../src/server/self-update.mjs";
45
- import { dieOfSignal, replacedNote, upgradeAttempt, upgradeRefusalText, workerExitAction } from "../src/server/supervisor.mjs";
45
+ import { dieOfSignal, dieWithParent, replacedNote, upgradeAttempt, upgradeRefusalText, workerExitAction } from "../src/server/supervisor.mjs";
46
46
  import { colorProfile, glyphs, palette, unicodeOK } from "../src/server/term.mjs";
47
47
  import { PRODUCT } from "../src/server/brand.mjs";
48
48
 
@@ -478,4 +478,28 @@ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
478
478
  });
479
479
  }
480
480
 
481
+ // And the same leash the worker is on, one link up (#702).
482
+ //
483
+ // Inert for every published way of starting the deck: `ccdeck`, `npx ccdeck` and
484
+ // the npx relaunch in launchNpx all spawn this file with no IPC channel, so
485
+ // dieWithParent declines to arm and this line costs nothing. It is armed by a
486
+ // harness that DOES hand one over — the suite's spawnSupervised — and what it
487
+ // buys there is the case no teardown can cover, because the teardown is not
488
+ // running: a vitest worker killed outright leaves this supervisor orphaned, and
489
+ // an orphaned supervisor keeps a worker alive under it.
490
+ //
491
+ // Taking the worker with us is the whole of the stop. `stopping` first, so the
492
+ // worker's exit is not read as a request to bring it back; the kill is the
493
+ // signal on POSIX and, through killTree, `taskkill /T /F` on Windows, which has
494
+ // neither SIGTERM nor a process group to aim at.
495
+ dieWithParent(() => {
496
+ stopping = true;
497
+ if (fetching) { killTree(fetching, "SIGTERM"); fetching = null; }
498
+ if (child) killTree(child, "SIGTERM");
499
+ // Long enough for the worker to unregister its discovery file and let the
500
+ // port go, short enough that nothing is waiting on us. Unref'd: if the worker
501
+ // leaves first its exit handler ends this process and the timer never fires.
502
+ setTimeout(() => process.exit(0), 3000).unref();
503
+ });
504
+
481
505
  launch(false);
package/bin/deck.js CHANGED
@@ -7,8 +7,8 @@
7
7
  import { resolve, dirname, join } from "node:path";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { existsSync, readFileSync } from "node:fs";
10
- import { dieOfSignal } from "../src/server/supervisor.mjs";
11
- import { parseArgs } from "../src/server/args.mjs";
10
+ import { dieOfSignal, dieWithParent } from "../src/server/supervisor.mjs";
11
+ import { isPortValue, parseArgs } from "../src/server/args.mjs";
12
12
  import {
13
13
  CURSOR_HIDE, CURSOR_SHOW, colorProfile, fit, glyphs, labelColumn, link, motionOK, oneLine,
14
14
  palette, pulseText, spinnerFrames, statusLine, supportsHyperlinks, termColumns, unicodeOK,
@@ -89,22 +89,34 @@ if (flags.uninstall) {
89
89
  : `${PRODUCT}: no ${label} hooks to remove`);
90
90
  };
91
91
  report(await uninstallHooks({ provider: "claude" }), "Claude");
92
- // The sound toggle is a second entry in the same file, marked
92
+ // The old finish sound was a second entry in the same file, marked
93
93
  // __agent-dag-sound rather than __agent-dag, and uninstallHooks does not know
94
94
  // that mark — so it used to be left behind, playing on every turn after the
95
- // deck was supposedly gone. It also parks the user's own afplay/PowerShell
96
- // Stop hooks while it is on, and once the deck is uninstalled nothing else can
97
- // put them back, so this restores them too.
98
- const { uninstallSoundHook } = await import(pathToFileURL(join(PKG_ROOT, "src/server/sound-hook.mjs")).href);
99
- const sound = await uninstallSoundHook();
95
+ // deck was supposedly gone. #704 retired the mechanism outright, but the entry
96
+ // is still on every machine that had it, so removing it is still this
97
+ // command's job. So is the other half: turning the sound on parked the user's
98
+ // own afplay/PowerShell Stop hooks, and once the deck is uninstalled nothing
99
+ // else on the machine knows where they went.
100
+ const { retireSoundHook } = await import(pathToFileURL(join(PKG_ROOT, "src/server/retire-sound-hook.mjs")).href);
101
+ const sound = await retireSoundHook();
102
+ if (sound.removed) console.log(`${PRODUCT}: sound hook removed`);
103
+ if (sound.restored) console.log(`${PRODUCT}: restored ${sound.restored} of your own sound hook(s)`);
100
104
  if (sound.ok === false) {
101
105
  refused = true;
102
- console.error(named.has(sound.settingsPath)
103
- ? `${PRODUCT}: the sound hook is still in that file too.`
104
- : `${PRODUCT}: sound hook left in place ${sound.message}`);
105
- } else {
106
- if (sound.removed) console.log(`${PRODUCT}: sound hook removed`);
107
- if (sound.restored) console.log(`${PRODUCT}: restored ${sound.restored} of your own sound hook(s)`);
106
+ // Two different refusals, and saying the wrong one sends the user to the
107
+ // wrong file. `settings_unreadable` means nothing was touched at all — and
108
+ // when the forwarders already named that same file, the whole path and
109
+ // parser error would only bury the one line that differs. `parked_unreadable`
110
+ // is the other file: our entry IS out (the lines above said so), and what is
111
+ // still owed is the user's own hooks, which stay parked until they repair it.
112
+ if (sound.reason === "settings_unreadable") {
113
+ console.error(named.has(sound.settingsPath)
114
+ ? `${PRODUCT}: the sound hook is still in that file too.`
115
+ : `${PRODUCT}: sound hook left in place — ${sound.message}`);
116
+ named.add(sound.settingsPath);
117
+ } else {
118
+ console.error(`${PRODUCT}: your own sound hooks were NOT restored — ${sound.message}`);
119
+ }
108
120
  }
109
121
  if (hasCodexInstalled()) {
110
122
  report(await uninstallHooks({ provider: "codex" }), "Codex");
@@ -119,7 +131,27 @@ if (flags.uninstall) {
119
131
  process.exit(refused ? 1 : 0);
120
132
  }
121
133
 
122
- const port = Number(flags.port ?? process.env.AGENT_DAG_PORT ?? 4317);
134
+ // The port, and the one piece of argv the deck really does refuse to boot over.
135
+ //
136
+ // It refused before too — `--port banana` and `--port --no-open` both became
137
+ // `Number(…)` → `NaN`, which survived the whole startup (hooks installed,
138
+ // claude-swap installed, ccusage probed) and then killed the process from inside
139
+ // `listen` with Node's own wording: "options.port should be >= 0 and < 65536.
140
+ // Received type number (NaN)." That names neither the flag nor the value the
141
+ // user typed, and arrives after a page of green ticks. Same outcome, said here:
142
+ // early, in the deck's own voice, quoting the flag and the value back.
143
+ //
144
+ // An empty `AGENT_DAG_PORT` is an unset one — a variable that did not expand is
145
+ // not a request for port zero. `--port ""` never reaches this, because the
146
+ // parser records an empty value as `incomplete` and leaves the flag unset.
147
+ const envPort = process.env.AGENT_DAG_PORT?.trim();
148
+ const rawPort = flags.port ?? (envPort ? envPort : null);
149
+ if (rawPort != null && !isPortValue(rawPort)) {
150
+ const named = flags.port != null ? "--port" : "AGENT_DAG_PORT";
151
+ console.error(`${PRODUCT}: ${named} ${rawPort}: not a port number — expected 0–65535.`);
152
+ process.exit(1);
153
+ }
154
+ const port = rawPort == null ? 4317 : Number(rawPort);
123
155
  // Default = machine-wide (capture every CC session on this box). Pass
124
156
  // `--workspace <path>` (or `--scope`) to restrict to a single tree. Canonicalized
125
157
  // just below, once the module that owns that rule is loaded.
@@ -241,6 +273,7 @@ process.on("SIGHUP", () => { showCursor(); dieOfSignal("SIGHUP"); });
241
273
  const LABELS = [
242
274
  "workspace", "Claude hooks", "Codex sessions", "claude-swap", "accounts",
243
275
  "ccusage", "update", "name", "server ready", "log", "unknown option",
276
+ "missing value",
244
277
  ];
245
278
  const LABEL_W = labelColumn(LABELS);
246
279
 
@@ -645,6 +678,22 @@ let server = null;
645
678
  let discovery = null;
646
679
  let discoveryFile = null;
647
680
 
681
+ // This worker does not outlive the supervisor that started it (#702).
682
+ //
683
+ // Armed HERE rather than beside the signal handlers at the bottom of the file,
684
+ // for the reason the three `let`s above are where they are: this is the first
685
+ // line from which `shutdown` can be called without dying of a temporal dead
686
+ // zone, and the window that matters is the boot — the supervisor can be killed
687
+ // while the report is still printing, and a worker orphaned in there is a
688
+ // worker that binds its port a moment later and then holds it for a day.
689
+ //
690
+ // A no-op with nothing supervising us, which is `node bin/deck.js` run by hand:
691
+ // dieWithParent arms only when there is a channel, the same question SUPERVISED
692
+ // asks above. Shutdown code 0 because there is nobody left to read it — the
693
+ // point is only that discovery is unregistered and the port let go of on the
694
+ // way out, rather than left for the next boot's stale sweep.
695
+ dieWithParent(() => shutdown(0));
696
+
648
697
  // The listen, begun HERE and awaited below the startup report rather than after
649
698
  // it. The report is a narration; the port is the product, and it was queued
650
699
  // behind three tool probes for no reason but the order these two statements
@@ -725,6 +774,7 @@ if (RESPAWN) {
725
774
  // and then went quiet for every restart afterwards is back to hiding it from
726
775
  // anyone who was not watching the first boot.
727
776
  reportUnknownFlags(flags.unknown);
777
+ reportIncompleteFlags(flags.incomplete);
728
778
  } else {
729
779
  // The URL is the one detail an ellipsis would destroy — half an address is
730
780
  // not a shorter address — so it keeps its own line when the terminal is too
@@ -736,6 +786,7 @@ if (RESPAWN) {
736
786
  if (persist) write(row({ label: "log", detail: fileLink(persist) }));
737
787
  // Last of the rows, on purpose — see reportUnknownFlags.
738
788
  reportUnknownFlags(flags.unknown);
789
+ reportIncompleteFlags(flags.incomplete);
739
790
  // Only when one is actually being opened. Under --no-open — which is how an
740
791
  // npx update relaunches, with a tab already waiting — this was announcing
741
792
  // something that never happened.
@@ -937,6 +988,33 @@ function reportUnknownFlags(unknown) {
937
988
  }
938
989
  }
939
990
 
991
+ /**
992
+ * Every value-taking flag that was given no value it could use, named, one row
993
+ * each — and printed beside the unknown ones because it is the same failure
994
+ * wearing a different hat.
995
+ *
996
+ * #697: `--workspace`, `--history` and `--port` used to consume the following
997
+ * token whatever it was, so `ccdeck --workspace $PROJ --no-persist` with `PROJ`
998
+ * unset scoped the deck to a directory called `--no-persist`, kept persisting to
999
+ * the shared log, and reported neither. Nothing landed in `unknown`, because the
1000
+ * token that belonged there had been eaten. The parser refuses that value now
1001
+ * and lists the flag here instead.
1002
+ *
1003
+ * Said rather than acted on, under exactly the argument reportUnknownFlags makes
1004
+ * above: the flag falls back to its documented default and the deck still boots.
1005
+ * The row is what makes the fallback a decision the user can see, and the rows
1006
+ * around it show its consequence — `workspace (all)` and the `log` line are
1007
+ * printed by the same report.
1008
+ */
1009
+ function reportIncompleteFlags(incomplete) {
1010
+ for (const { flag, expects } of incomplete ?? []) {
1011
+ write(row({
1012
+ mark: G.warn, tone: P.warn, label: "missing value",
1013
+ detail: `${flag} ${G.dash} expected ${expects}; using the default`,
1014
+ }));
1015
+ }
1016
+ }
1017
+
940
1018
  function printHelp() {
941
1019
  process.stdout.write(`${PRODUCT} — live deck of Claude Code + Codex agents
942
1020
 
@@ -964,5 +1042,10 @@ Options:
964
1042
 
965
1043
  Anything else on the command line is reported as an unknown option and then
966
1044
  ignored: the deck still starts.
1045
+
1046
+ A flag that takes a value never swallows the next flag. If the value is missing,
1047
+ empty, or itself looks like a flag — \`${PRODUCT} --workspace \$UNSET --no-persist\`
1048
+ after the shell has dropped an unset variable — the flag is reported, left on its
1049
+ default, and the token it would have eaten is parsed as the flag it is.
967
1050
  `);
968
1051
  }