agent-dag 1.33.1 → 1.33.2

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/bin/agent-dag.js CHANGED
@@ -23,13 +23,22 @@
23
23
  // Everything else the deck does still lives in bin/deck.js. This file must stay
24
24
  // boring: it is the one process that is never replaced.
25
25
  import { spawn } from "node:child_process";
26
+ import { connect } from "node:net";
26
27
  import { dirname, join } from "node:path";
27
28
  import { fileURLToPath } from "node:url";
29
+ import { installedVersion, npxRestartSpec } from "../src/server/self-update.mjs";
28
30
 
29
- // Chosen because it means nothing else here: the worker exits 0 normally and
31
+ // Chosen because they mean nothing else here: the worker exits 0 normally and
30
32
  // 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
+ const RESTART_CODE = 75; // come back running the files on disk
34
+ const UPGRADE_CODE = 76; // come back through npx, which fetches newer files
35
+ const BIN_DIR = dirname(fileURLToPath(import.meta.url));
36
+ const WORKER = join(BIN_DIR, "deck.js");
37
+ const PKG_ROOT = dirname(BIN_DIR);
38
+
39
+ // npx is a .cmd shim on Windows, which spawn can only launch through a shell.
40
+ const NPX = process.platform === "win32" ? "npx.cmd" : "npx";
41
+ const VERSION = installedVersion(PKG_ROOT) ?? "?";
33
42
 
34
43
  // The port the worker actually bound, which is not necessarily the one it was
35
44
  // asked for — the first launch falls back to a random port when 4317 is taken.
@@ -38,6 +47,10 @@ const WORKER = join(dirname(fileURLToPath(import.meta.url)), "deck.js");
38
47
  let boundPort = null;
39
48
  let restarts = 0;
40
49
  let child = null;
50
+ // Set by the signal handlers. Without it, Ctrl+C during an npx fetch looks
51
+ // exactly like a failed fetch, and the fallback below would resurrect the deck
52
+ // the user just stopped.
53
+ let stopping = false;
41
54
 
42
55
  function launch(respawn) {
43
56
  const args = [WORKER, ...process.argv.slice(2)];
@@ -71,6 +84,11 @@ function launch(respawn) {
71
84
  launch(true);
72
85
  return;
73
86
  }
87
+ if (code === UPGRADE_CODE) {
88
+ restarts++;
89
+ launchNpx();
90
+ return;
91
+ }
74
92
  // Anything else is the worker's own verdict and belongs to whoever started
75
93
  // us — including the ccdeck wrapper, which exits with our code in turn.
76
94
  if (signal) process.kill(process.pid, signal);
@@ -83,6 +101,85 @@ function launch(respawn) {
83
101
  });
84
102
  }
85
103
 
104
+ /** Drop the two flags launchNpx sets itself. `--port` takes a value, and both
105
+ * spellings npm's parser accepts (`--port 4317`, `--port=4317`) have to go. */
106
+ function withoutPortAndOpen(args) {
107
+ const out = [];
108
+ for (let i = 0; i < args.length; i++) {
109
+ const a = args[i];
110
+ if (a === "--no-open") continue;
111
+ if (a === "--port") { i++; continue; } // and its value
112
+ if (a.startsWith("--port=")) continue;
113
+ out.push(a);
114
+ }
115
+ return out;
116
+ }
117
+
118
+ /**
119
+ * Come back on a newer version, for a deck that npx started.
120
+ *
121
+ * There is nothing to install here: npx unpacks each spec into its own
122
+ * content-addressed directory under _npx, so `npm i -g` would upgrade something
123
+ * this process could never reach. `npx -y <spec>@latest` resolves fresh, gets a
124
+ * DIFFERENT directory, and starts the deck there — on the port we hand it, so
125
+ * the tab that asked for the update reconnects to the same URL.
126
+ *
127
+ * This process stays as the parent rather than exec-ing, for the same reasons
128
+ * the file's header gives, plus one more: a fetch can fail (offline, registry
129
+ * down), and someone has to bring the working copy back when it does.
130
+ */
131
+ function launchNpx() {
132
+ const spec = npxRestartSpec(PKG_ROOT);
133
+ if (!spec) { launch(true); return; } // not an npx run after all
134
+ // Our two are appended, so the originals are dropped rather than left to be
135
+ // overridden — `--port 4317 --no-open --port 4317 --no-open` works, but it is
136
+ // what the next person reads in `ps`.
137
+ const args = ["-y", spec, ...withoutPortAndOpen(process.argv.slice(2))];
138
+ if (boundPort != null) args.push("--port", String(boundPort));
139
+ // The tab that asked for this is open and reconnecting; a second one would be
140
+ // the deck talking over itself.
141
+ args.push("--no-open");
142
+
143
+ process.stdout.write(`\n ↻ fetching ${spec}…\n`);
144
+ const started = spawn(NPX, args, { stdio: "inherit", shell: process.platform === "win32" });
145
+ child = started;
146
+
147
+ // Whether the replacement ever got as far as serving. An npx that cannot
148
+ // resolve exits in seconds having bound nothing; a deck the user stops with
149
+ // Ctrl+C exits non-zero too, and only this tells the two apart.
150
+ let served = false;
151
+ const probe = setInterval(() => {
152
+ if (boundPort == null || served) return;
153
+ const sock = connect({ port: boundPort, host: "127.0.0.1" });
154
+ sock.setTimeout(1000);
155
+ sock.on("connect", () => { served = true; sock.destroy(); });
156
+ sock.on("timeout", () => sock.destroy());
157
+ sock.on("error", () => { /* not up yet */ });
158
+ }, 1000);
159
+ probe.unref?.();
160
+
161
+ const giveUp = (why) => {
162
+ clearInterval(probe);
163
+ child = null;
164
+ if (stopping || served) return; // the user stopped it, or it ran and ended
165
+ console.error(`agents-deck: ${why} — staying on v${VERSION}`);
166
+ restarts++;
167
+ launch(true);
168
+ };
169
+
170
+ started.on("exit", (code, signal) => {
171
+ clearInterval(probe);
172
+ child = null;
173
+ if (stopping || served) {
174
+ if (signal) process.kill(process.pid, signal);
175
+ else process.exit(code ?? 0);
176
+ return;
177
+ }
178
+ giveUp(`${NPX} ${spec} exited ${code ?? signal}`);
179
+ });
180
+ started.on("error", (err) => giveUp(`could not run ${NPX}: ${err.message}`));
181
+ }
182
+
86
183
  // Ctrl+C already reaches the child directly — it shares this process group — so
87
184
  // forwarding would deliver it twice. These handlers exist only to keep the
88
185
  // supervisor alive long enough for the child's own graceful shutdown to run and
@@ -92,8 +189,18 @@ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
92
189
  // On Windows none of these are delivered to a Node process, which is fine:
93
190
  // there the console kills the whole tree and sweepStaleDiscovery cleans up
94
191
  // on the next boot.
95
- if (child) { try { child.kill(sig); } catch { /* already gone */ } }
96
- else process.exit(0);
192
+ const second = stopping; // an impatient user pressing Ctrl+C again
193
+ stopping = true;
194
+ if (!child) { process.exit(0); return; }
195
+ try { child.kill(second ? "SIGKILL" : sig); } catch { /* already gone */ }
196
+ // The npx step is a shell that exec's the new deck, and a signal can land in
197
+ // the gap: the shell dies, the process replacing it never saw it. One more
198
+ // attempt a moment later catches that; it is a no-op when the first worked.
199
+ if (!second) {
200
+ setTimeout(() => {
201
+ if (stopping && child) { try { child.kill("SIGTERM"); } catch { /* gone */ } }
202
+ }, 2500).unref();
203
+ }
97
204
  });
98
205
  }
99
206
 
package/bin/deck.js CHANGED
@@ -19,8 +19,12 @@ const PKG_VERSION = (() => {
19
19
  const argv = process.argv.slice(2);
20
20
  const flags = parseArgs(argv);
21
21
 
22
- // Exit code the supervisor reads as "bring me back". Anything else it forwards.
22
+ // Exit codes the supervisor reads as "bring me back": 75 from the files on
23
+ // disk, 76 through npx — which is the only way an npx run reaches a newer
24
+ // version, since its directory is never upgraded in place. Anything else it
25
+ // forwards.
23
26
  const RESTART_CODE = 75;
27
+ const UPGRADE_CODE = 76;
24
28
  const RESPAWN = process.env.AGENTS_DECK_RESPAWN === "1";
25
29
  const SUPERVISED = typeof process.send === "function";
26
30
 
@@ -241,14 +245,19 @@ if (upgrade) {
241
245
  // only after this process is gone — which is precisely what keeps the
242
246
  // replacement from racing this listener onto a random fallback port.
243
247
  let restarting = false;
244
- const requestRestart = () => {
248
+ const requestRestart = (mode) => {
245
249
  if (restarting) return;
246
250
  restarting = true;
247
- const to = restartTarget();
251
+ // "npx" means the newer code is not on this disk at all — the supervisor has
252
+ // to fetch it — so there is no target version to name yet.
253
+ const viaNpx = mode === "npx";
254
+ const to = viaNpx ? null : restartTarget();
248
255
  process.stdout.write(
249
- `\n ${C.yellow}↻${C.reset} ${C.dim}restarting${to ? ` → v${to}` : ""}…${C.reset}\n`,
256
+ viaNpx
257
+ ? `\n ${C.yellow}↻${C.reset} ${C.dim}updating via npx…${C.reset}\n`
258
+ : `\n ${C.yellow}↻${C.reset} ${C.dim}restarting${to ? ` → v${to}` : ""}…${C.reset}\n`,
250
259
  );
251
- shutdown(RESTART_CODE);
260
+ shutdown(viaNpx ? UPGRADE_CODE : RESTART_CODE);
252
261
  };
253
262
  // What a restart would land on. Read from disk now rather than remembered from
254
263
  // boot, because the whole point is that the two differ.
@@ -283,7 +292,11 @@ if (RESPAWN) {
283
292
  } else {
284
293
  sp.stop(true, `server ready ${C.dim}→ ${C.reset}${C.bCyan}${C.bold}${url}${C.reset}`);
285
294
  if (persist) process.stdout.write(` ${C.dim}log : ${persist}${C.reset}\n`);
286
- process.stdout.write(`\n ${C.green}${C.bold}▶ opening browser…${C.reset}\n\n`);
295
+ // Only when one is actually being opened. Under --no-open — which is how an
296
+ // npx update relaunches, with a tab already waiting — this was announcing
297
+ // something that never happened.
298
+ if (openBrowser) process.stdout.write(`\n ${C.green}${C.bold}▶ opening browser…${C.reset}\n\n`);
299
+ else process.stdout.write("\n");
287
300
  }
288
301
 
289
302
  const discoveryFile = await writeDiscovery({ port: realPort, workspace });
@@ -311,7 +324,9 @@ const shutdown = async (code = 0) => {
311
324
  // on its own before either timer runs, Node would otherwise exit 0 and the
312
325
  // supervisor would take that as "done" instead of "bring me back".
313
326
  process.exitCode = code;
314
- if (tty && code !== RESTART_CODE) process.stdout.write(`\n\n ${C.yellow}◉ shutting down…${C.reset}\n`);
327
+ if (tty && code !== RESTART_CODE && code !== UPGRADE_CODE) {
328
+ process.stdout.write(`\n\n ${C.yellow}◉ shutting down…${C.reset}\n`);
329
+ }
315
330
  await removeDiscovery(discoveryFile);
316
331
  server.close(() => process.exit(code));
317
332
  // SSE connections never end by themselves, so close() alone would sit out the