ccdeck 1.33.155 → 1.34.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.
Files changed (2) hide show
  1. package/bin/ccdeck.js +152 -5
  2. package/package.json +2 -2
package/bin/ccdeck.js CHANGED
@@ -31,10 +31,56 @@ const __dir = dirname(fileURLToPath(import.meta.url));
31
31
  // keeps the guarantee in the paragraph above wherever a path does answer.
32
32
  const nested = resolve(__dir, '../node_modules/agents-deck');
33
33
  const sibling = resolve(__dir, '../../agents-deck');
34
- const pkg = existsSync(resolve(nested, 'package.json')) ? nested
35
- : existsSync(resolve(sibling, 'package.json')) ? sibling
36
- : dirname(createRequire(import.meta.url).resolve('agents-deck/package.json'));
37
- const { dieOfSignal } = await import(pathToFileURL(resolve(pkg, 'src/server/supervisor.mjs')).href);
34
+
35
+ // The three codes that mean "there is no deck here", as opposed to "the deck is
36
+ // broken". Both of the steps below can fail either way and they deserve opposite
37
+ // answers, so the distinction is made once, by code, and nothing else is caught.
38
+ //
39
+ // MODULE_NOT_FOUND is what the last resort above threw in the shape people
40
+ // actually hit it: a half-removed install, a linked workspace, Yarn PnP without
41
+ // agents-deck in scope. Unguarded it reached the user as an eight-frame stack
42
+ // out of node's CJS loader before a single line of the deck — the same failure
43
+ // shape 0075d57 was written to eliminate, arriving through a different API. A
44
+ // launcher that cannot find its own deck knows exactly what is wrong and can say
45
+ // it in a sentence.
46
+ //
47
+ // ERR_PACKAGE_PATH_NOT_EXPORTED is the case the header above worries about:
48
+ // `./package.json` is not exported by default, so an `exports` map added to
49
+ // agents-deck later would make that resolve throw rather than answer. Resolving
50
+ // `agents-deck/bin/agent-dag.js` instead, as #359 suggests, does not avoid it —
51
+ // every subpath is gated by an exports map, and that one resolves to the bin
52
+ // directory rather than to the package root. The path probes are what actually
53
+ // keep the header's promise; listing the code here only makes the failure legible
54
+ // if it ever arrives.
55
+ //
56
+ // ERR_MODULE_NOT_FOUND is the import below: a pkg that was found but has no
57
+ // supervisor.mjs under it — a truncated or half-written install — is just as
58
+ // much "there is no deck here", and it used to throw its own stack too.
59
+ const NO_DECK = new Set(['MODULE_NOT_FOUND', 'ERR_MODULE_NOT_FOUND', 'ERR_PACKAGE_PATH_NOT_EXPORTED']);
60
+
61
+ let pkg, dieOfSignal;
62
+ try {
63
+ pkg = existsSync(resolve(nested, 'package.json')) ? nested
64
+ : existsSync(resolve(sibling, 'package.json')) ? sibling
65
+ : dirname(createRequire(import.meta.url).resolve('agents-deck/package.json'));
66
+ ({ dieOfSignal } = await import(pathToFileURL(resolve(pkg, 'src/server/supervisor.mjs')).href));
67
+ } catch (err) {
68
+ // A supervisor.mjs that throws while it evaluates is a bug in the deck, not a
69
+ // missing install, and it keeps its stack — that stack is the only thing that
70
+ // would explain it.
71
+ if (!NO_DECK.has(err?.code)) throw err;
72
+ console.error([
73
+ 'ccdeck: could not find the agents-deck install it runs.',
74
+ '',
75
+ ` looked in ${nested}`,
76
+ ` and ${sibling}`,
77
+ '',
78
+ ' ccdeck ships only this launcher — the deck itself is the agents-deck package',
79
+ ' npm installs alongside it. Reinstall with `npm i -g ccdeck`, or skip the',
80
+ ' launcher and run the deck directly with `npx agents-deck`.',
81
+ ].join('\n'));
82
+ process.exit(1);
83
+ }
38
84
 
39
85
  const bin = resolve(pkg, 'bin/agent-dag.js');
40
86
  const child = spawn(process.execPath, [bin, ...process.argv.slice(2)], { stdio: 'inherit' });
@@ -43,5 +89,106 @@ const child = spawn(process.execPath, [bin, ...process.argv.slice(2)], { stdio:
43
89
  // `code ?? 0` turned that into 0: a `kill` or an OOM kill of a deck started as
44
90
  // `npx ccdeck` was reported to systemd — or to whatever script started it — as
45
91
  // a clean stop, while the same build under `npx agents-deck` reported 143.
46
- // Ctrl+C hides it, since that signals the whole group and kills us directly.
92
+ // Ctrl+C hid it: that signals the whole group, so the stub died of SIGINT
93
+ // before this line could run — and a deck that shut itself down cleanly comes
94
+ // back through here as a plain 0 anyway. The handlers below are what stop the
95
+ // stub from dying first, which is what makes this line reachable on that path
96
+ // too; they change nothing about the verdict it reports.
47
97
  child.on('exit', (code, signal) => (signal ? dieOfSignal(signal) : process.exit(code ?? 0)));
98
+
99
+ // ── a signal aimed at this process has to reach the deck, and must not reach it
100
+ // twice ─────────────────────────────────────────────────────────────────────
101
+ //
102
+ // Nothing here trapped a signal, so `kill <pid>` against the stub — a systemd
103
+ // unit without KillMode=control-group, any supervisor that signals only the main
104
+ // pid, a hand-typed kill — ended the launcher in about six milliseconds and left
105
+ // agent-dag.js and deck.js running: still serving on 4317, still advertising a
106
+ // live pid in the discovery file. Hooks keep posting to a deck the user believes
107
+ // is stopped, and the next `npx ccdeck` finds the port taken and quietly binds
108
+ // another one.
109
+ //
110
+ // Ctrl+C is why that survived: the terminal signals the whole foreground process
111
+ // group, and the child is in it — nothing here spawns detached — so the deck
112
+ // gets its own copy and tears itself down without us. Measured rather than
113
+ // assumed, by group-signalling a real stub over a real child: under a group-wide
114
+ // SIGINT the child receives exactly one signal and shuts down; under a targeted
115
+ // one it receives none and is still alive seconds later.
116
+ //
117
+ // Which is exactly why forwarding cannot be unconditional. Under Ctrl+C the
118
+ // child has already had the signal, and a forwarded copy lands about a
119
+ // millisecond behind it as a SECOND one — measured the same way, three runs out
120
+ // of three. agent-dag.js reads a second signal as an impatient user pressing
121
+ // Ctrl+C again and answers it by SIGKILLing deck.js, and a SIGKILLed deck is a
122
+ // deck denied its own shutdown: the discovery file it was partway through
123
+ // removing stays behind, naming a pid that is gone. That is this very bug
124
+ // arriving from the other direction, so the obvious one-liner trades an orphaned
125
+ // deck for a stale one.
126
+ //
127
+ // So the forward is delayed, and the child's own exit cancels it. If the child
128
+ // got the signal too it is gone long before the timer and nothing is sent; if it
129
+ // did not, the timer fires and the deck comes down. A second signal means
130
+ // somebody is waiting, so it goes straight through. Deciding this by clock
131
+ // rather than by asking agent-dag to tolerate a duplicate keeps the answer true
132
+ // against a deck this stub did not ship with — a workspace, a pnpm store, an
133
+ // older global agents-deck found by the resolution above — which is the whole
134
+ // reason those layouts are supported at all.
135
+ //
136
+ // Staying alive is the other half of the fix, and it is what the delay is built
137
+ // on. The default action for all three signals is to die, so the stub used to
138
+ // vanish while the deck was still shutting down, handing the shell a prompt and
139
+ // a status of its own before the deck had one. Now the child's status is what
140
+ // the caller gets, the way `npx agents-deck` has always answered — including the
141
+ // 0 deck.js chooses for a Ctrl+C, where the stub used to report 130. That is the
142
+ // parity the exit handler above exists for, extended to the signals that arrive
143
+ // here rather than there. dieOfSignal drops our handler before it re-raises, so
144
+ // a deck that really did die of a signal still comes back as 128+n instead of
145
+ // being swallowed by the handlers below.
146
+ //
147
+ // WINDOWS GETS THE OPPOSITE ANSWER, because it has no signals to forward. The
148
+ // only thing that runs these handlers there is a console event — Ctrl+C,
149
+ // Ctrl+Break, the window closing — and the console delivers those to every
150
+ // process attached to it, so the child always has its own copy already. A
151
+ // targeted kill there is TerminateProcess: no handler runs at all, and there is
152
+ // nothing this file can do about that. Meanwhile `child.kill()` on Windows IS
153
+ // TerminateProcess, so forwarding would take agent-dag.js out mid-shutdown and
154
+ // orphan deck.js underneath it — the failure we are here to fix, caused by the
155
+ // fix. So on Windows the handlers only keep us alive for the child's exit
156
+ // status, and never forward.
157
+ //
158
+ // The delay itself is chosen against what it has to outlast and what it makes
159
+ // wait: longer than a healthy teardown, since deck.js caps its entire shutdown
160
+ // at 1500ms, and shorter than every stop timeout that would be sitting on the
161
+ // other end of a `kill` — docker's 10s is the tightest, systemd's 90s the most
162
+ // common. Ctrl+C never waits it out, because the deck is long gone by then.
163
+ const FORWARD_AFTER_MS = 2000;
164
+
165
+ let pending = null; // a forward waiting to see whether the child needs one
166
+ let forwarded = false; // whether one has already gone out
167
+
168
+ const forward = (sig) => {
169
+ pending = null;
170
+ forwarded = true;
171
+ try { child.kill(sig); } catch { /* already gone */ }
172
+ };
173
+
174
+ for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
175
+ process.on(sig, () => {
176
+ // Node sets both of these in the same breath as it emits 'exit', so this is
177
+ // the child's real state and not a guess: there is nothing left to signal,
178
+ // and the exit handler above is already on its way to ending us.
179
+ if (child.exitCode !== null || child.signalCode !== null) return;
180
+ if (process.platform === 'win32') return;
181
+ if (!pending && !forwarded) {
182
+ // unref'd because the child's own handle is what keeps this process alive;
183
+ // a forward that is still waiting should never be the reason the stub
184
+ // outlives the deck.
185
+ pending = setTimeout(() => forward(sig), FORWARD_AFTER_MS).unref();
186
+ return;
187
+ }
188
+ // Anything after the first goes straight out, and `forwarded` keeps it that
189
+ // way: once somebody has asked twice, a later ask must not be slower than
190
+ // the one before it. The clear is a no-op when the delay has already run.
191
+ clearTimeout(pending);
192
+ forward(sig);
193
+ });
194
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "1.33.155",
3
+ "version": "1.34.0",
4
4
  "description": "Alias for agents-deck — live deck of Claude Code and Codex agents. Run with: npx ccdeck",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,7 @@
12
12
  "LICENSE"
13
13
  ],
14
14
  "dependencies": {
15
- "agents-deck": "1.33.155"
15
+ "agents-deck": "1.34.0"
16
16
  },
17
17
  "engines": {
18
18
  "node": ">=18"