agent-dag 1.33.47 → 1.33.49

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.
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>agents-deck</title>
7
7
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='84' font-size='84'%3E%E2%97%89%3C/text%3E%3C/svg%3E" />
8
- <script type="module" crossorigin src="/assets/index-Cyd_2XUH.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-DXPYK_Sr.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-CF2yG9oG.css">
10
10
  </head>
11
11
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "1.33.47",
3
+ "version": "1.33.49",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch parallel subagents fork, call tools, and return on one calm canvas. Also available as npx ccdeck and npx agent-dag.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,7 +15,7 @@ import { spawn, spawnSync } from "node:child_process";
15
15
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
16
16
  import path from "node:path";
17
17
  import os from "node:os";
18
- import { spawnSpec } from "./exec.mjs";
18
+ import { killTree, spawnSpec } from "./exec.mjs";
19
19
 
20
20
  const CACHE_MS = 120_000; // 2 min — modal is manual-open; cheap to keep warm
21
21
  const TIMEOUT_MS = 90_000;
@@ -188,7 +188,9 @@ async function runCcusage(args) {
188
188
  });
189
189
  let out = "", err = "";
190
190
  const timer = setTimeout(() => {
191
- child.kill();
191
+ // The npx fallback runs through a shell, so on Windows `child` is cmd.exe
192
+ // and npx is a grandchild that a plain kill would leave downloading.
193
+ killTree(child);
192
194
  reject(new Error("ccusage timed out"));
193
195
  }, TIMEOUT_MS);
194
196
  child.stdout.on("data", d => { out += d; });
@@ -158,7 +158,27 @@ export function extractLoginUrl(text) {
158
158
 
159
159
  // The prompt the CLI blocks on. Matched rather than assumed: writing a code
160
160
  // into a child that is not asking for one would send it somewhere unknown.
161
- const CODE_PROMPT = /paste code here/i;
161
+ const CODE_PROMPT = /paste code here/gi;
162
+
163
+ /**
164
+ * How many times one line of output asks for the code.
165
+ *
166
+ * Per LINE, not per delivery. The prompt ends without a newline, so exec.mjs
167
+ * re-offers the same unterminated line on every chunk as it grows — and both
168
+ * streams share that buffer, so a progress dot, a `\r` spinner frame on stderr,
169
+ * or simply the prompt split across two writes all arrive as the ask again,
170
+ * a few characters longer. Counting an ask whenever the text merely CHANGED, as
171
+ * this used to, turned any of those into a phantom second prompt, and a second
172
+ * prompt is exactly how submitLoginCode hears "that code was rejected": a
173
+ * login the CLI was busy completing came back as `code_rejected`, with the
174
+ * account unregistered and the live credentials left switched.
175
+ *
176
+ * A real re-ask writes the prompt again — on a fresh line, or after a `\r`
177
+ * redraw of this one — so it shows up as another match, not as a longer tail.
178
+ */
179
+ export function countCodePrompts(line) {
180
+ return (stripTerminalEscapes(line).match(CODE_PROMPT) ?? []).length;
181
+ }
162
182
 
163
183
  /**
164
184
  * The whole login, as one object, because the browser talks to it twice: once
@@ -219,23 +239,27 @@ export async function startLogin({ email } = {}) {
219
239
  // exit, it just asks again, so waiting for exit would hang for the whole
220
240
  // five-minute window on a typo.
221
241
  prompts: 0,
222
- lastPromptText: "",
223
242
  };
224
243
  const flow = _login;
225
244
 
226
- child.onLine((line) => {
245
+ // How many asks the line currently being written has already contributed.
246
+ // The prompt is an unterminated line, re-delivered as it grows, so the same
247
+ // ask arrives over and over — and once more, whole, when something else
248
+ // finally ends the line.
249
+ let countedOnLine = 0;
250
+
251
+ child.onLine((line, partial) => {
227
252
  if (!flow.url) {
228
253
  const url = extractLoginUrl(line);
229
254
  if (url) { flow.url = url; flow.state = "awaiting_code"; }
230
255
  }
231
- // The prompt is an unterminated line, re-delivered as it grows, so the
232
- // same ask must not count twice.
233
- const clean = stripTerminalEscapes(line);
234
- if (CODE_PROMPT.test(clean)) {
235
- if (clean !== flow.lastPromptText) { flow.prompts += 1; flow.lastPromptText = clean; }
236
- } else if (clean.trim()) {
237
- flow.lastPromptText = "";
256
+ const asks = countCodePrompts(line);
257
+ if (asks > countedOnLine) {
258
+ flow.prompts += asks - countedOnLine;
259
+ countedOnLine = asks;
238
260
  }
261
+ // A newline ended that line; whatever comes next is a new one.
262
+ if (!partial) countedOnLine = 0;
239
263
  });
240
264
  // The child dying before the code was accepted is a failure of the login, not
241
265
  // of the deck; say so rather than leaving the dialog spinning.
@@ -78,6 +78,47 @@ export function viaCmd(file, args) {
78
78
  export const spawnSpec = (file, args, platform = process.platform) =>
79
79
  isBatch(file, platform) ? viaCmd(file, args) : { file, args, opts: {} };
80
80
 
81
+ /**
82
+ * Stop a child AND everything it started.
83
+ *
84
+ * On POSIX the child is the tool, so a signal to it is the whole job and this
85
+ * is exactly the `child.kill()` it replaces. On Windows a .cmd or .bat runs
86
+ * THROUGH cmd.exe (see viaCmd), so the tool — node, for the claude and npm
87
+ * shims — is a grandchild, and a kill is one TerminateProcess against the
88
+ * wrapper. Windows terminates no descendants and libuv's job object lets them
89
+ * break away, so the wrapper vanished and the work carried on: every cancelled
90
+ * or expired `claude auth login` left a node.exe blocked forever on the stdin
91
+ * pipe this process holds, and because that node.exe kept the inherited stdout
92
+ * handle open, the wrapper's 'close' never arrived either — the run that was
93
+ * reported dead never settled.
94
+ *
95
+ * `taskkill /T` is the descendant walk Windows does have, and `/F` is what
96
+ * stops a console app that is not pumping its message queue. Taking it from
97
+ * System32 rather than from PATH matters here: PATH is the user's, and this is
98
+ * the program we hand a pid to kill. If it cannot run at all, the plain kill
99
+ * still happens, which is no worse than before.
100
+ *
101
+ * A process group would be the tidier answer and is the wrong one on Windows:
102
+ * `detached` there only means CREATE_NEW_PROCESS_GROUP, and Node cannot signal
103
+ * a group — `process.kill(-pid)` is POSIX-only.
104
+ */
105
+ export function killTree(child, signal) {
106
+ const plain = () => { try { child?.kill(signal); } catch { /* already gone */ } };
107
+ if (process.platform !== "win32" || !child?.pid) return plain();
108
+ try {
109
+ const root = process.env.SystemRoot || process.env.systemroot;
110
+ const exe = root ? `${root}\\System32\\taskkill.exe` : "taskkill";
111
+ const killer = spawn(exe, ["/pid", String(child.pid), "/T", "/F"], {
112
+ stdio: "ignore", windowsHide: true,
113
+ });
114
+ killer.on("error", plain);
115
+ killer.on("exit", (code) => { if (code !== 0) plain(); });
116
+ killer.unref?.();
117
+ } catch {
118
+ plain();
119
+ }
120
+ }
121
+
81
122
  // Reasons to try the next candidate spelling rather than give up. EINVAL and
82
123
  // UNKNOWN show up on Windows for a file that exists but cannot be executed the
83
124
  // way it was asked for; both mean "not this one", not "no such tool".
@@ -124,7 +165,15 @@ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20 } = {}) {
124
165
  ? viaCmd(raw, args)
125
166
  : { file: raw, args, opts: {} };
126
167
 
168
+ // execFile's own `timeout` ends with one signal to the process it
169
+ // started, which for a batch candidate is the cmd.exe wrapper: the
170
+ // deadline was reported as enforced while the tool underneath went on
171
+ // running. A batch candidate is timed here instead, on the whole tree.
172
+ const tree = isBatch(raw);
173
+ let timer = null, timedOut = false;
174
+
127
175
  const done = (err, stdout, stderr) => {
176
+ clearTimeout(timer);
128
177
  // cmd.exe's "is not recognized" counts as "not this spelling" too, and
129
178
  // it arrives as a normal non-zero exit rather than a spawn error.
130
179
  const missing = Boolean(err) && looksMissing(`${stderr ?? ""}\n${stdout ?? ""}`);
@@ -135,14 +184,21 @@ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20 } = {}) {
135
184
  // A tool cmd.exe could not find is missing, not "exited 1" — callers
136
185
  // key their message off this.
137
186
  code: missing ? "ENOENT" : (err?.code ?? 0),
138
- killed: Boolean(err?.killed),
187
+ // Our own tree kill is not `killed` as far as execFile can tell, so
188
+ // a timeout reports the same either way.
189
+ killed: Boolean(err?.killed) || timedOut,
139
190
  stdout: String(stdout ?? ""),
140
191
  stderr: String(stderr ?? ""),
141
192
  });
142
193
  };
143
194
 
144
195
  try {
145
- execFile(file, argv, { timeout, shell: false, windowsHide: true, maxBuffer, ...opts }, done);
196
+ const cp = execFile(file, argv,
197
+ { timeout: tree ? 0 : timeout, shell: false, windowsHide: true, maxBuffer, ...opts }, done);
198
+ if (tree) {
199
+ timer = setTimeout(() => { timedOut = true; killTree(cp); }, timeout);
200
+ timer.unref?.();
201
+ }
146
202
  } catch (err) {
147
203
  // Synchronous throw — the EINVAL case. Same handling as a callback
148
204
  // error; letting it propagate here is what crashed the server, because
@@ -210,7 +266,7 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
210
266
 
211
267
  const timer = setTimeout(() => {
212
268
  timedOut = true;
213
- try { child?.kill(); } catch { /* already gone */ }
269
+ killTree(child);
214
270
  }, timeout);
215
271
  timer.unref?.();
216
272
 
@@ -287,9 +343,11 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
287
343
  end() {
288
344
  try { child?.stdin?.end(); } catch { /* already closed */ }
289
345
  },
346
+ /** Stop the run. On Windows that means the tool under the cmd.exe wrapper
347
+ * too — see killTree; a cancelled sign-in used to leave it running. */
290
348
  kill() {
291
349
  killed = true;
292
- try { child?.kill(); } catch { /* already gone */ }
350
+ killTree(child);
293
351
  },
294
352
  onLine(cb) { lineSubs.push(cb); },
295
353
  done,
@@ -25,6 +25,7 @@ import { accessSync, constants as FS, existsSync, mkdirSync, readFileSync, write
25
25
  import { spawn } from "node:child_process";
26
26
  import { homedir } from "node:os";
27
27
  import { dirname, join, resolve } from "node:path";
28
+ import { killTree } from "./exec.mjs";
28
29
 
29
30
  // Once an hour, not once a day.
30
31
  //
@@ -395,7 +396,10 @@ export function startUpgrade({ pkgRoot, name = "agents-deck" }) {
395
396
  child.stdout.on("data", d => keepTail(String(d)));
396
397
  child.stderr.on("data", d => keepTail(String(d)));
397
398
 
398
- const timer = setTimeout(() => { try { child.kill(); } catch { /* gone */ } }, INSTALL_TIMEOUT_MS);
399
+ // npm is a .cmd shim on Windows and is therefore spawned through a shell, so
400
+ // `child` is cmd.exe and npm itself is a grandchild — a plain kill would
401
+ // report the install as timed out while it carried on writing to node_modules.
402
+ const timer = setTimeout(() => killTree(child), INSTALL_TIMEOUT_MS);
399
403
  timer.unref?.();
400
404
 
401
405
  child.on("error", (e) => {