ccdeck 3.22.14 → 3.23.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 (51) hide show
  1. package/README.md +53 -10
  2. package/THIRD_PARTY_NOTICES.md +123 -15
  3. package/bin/deck.js +264 -21
  4. package/dist/web/assets/BrowserWatchModal-DD6srgsk.js +1 -0
  5. package/dist/web/assets/UsageHistoryModal-zExZpRWU.js +1 -0
  6. package/dist/web/assets/index-Bf-qcZ6x.css +1 -0
  7. package/dist/web/assets/index-Dsca2TwS.js +296 -0
  8. package/dist/web/index.html +9 -5
  9. package/hook/hook.js +631 -99
  10. package/package.json +1 -1
  11. package/release-notes.json +62 -1
  12. package/src/server/activity.mjs +42 -4
  13. package/src/server/args.mjs +30 -4
  14. package/src/server/browser-history.mjs +164 -38
  15. package/src/server/browser-presence.mjs +60 -0
  16. package/src/server/browser-react.mjs +160 -7
  17. package/src/server/browser-watch-store.mjs +319 -17
  18. package/src/server/browser-watch.mjs +304 -60
  19. package/src/server/ccusage.mjs +20 -4
  20. package/src/server/claude-accounts.mjs +79 -34
  21. package/src/server/claude-identity.mjs +121 -0
  22. package/src/server/cswap-admin.mjs +160 -133
  23. package/src/server/cswap-auto.mjs +86 -7
  24. package/src/server/cswap-install.mjs +280 -43
  25. package/src/server/deck-home.mjs +1 -1
  26. package/src/server/deck-prefs.mjs +252 -36
  27. package/src/server/deck-probe.mjs +1 -1
  28. package/src/server/exec.mjs +84 -3
  29. package/src/server/global-install.mjs +13 -0
  30. package/src/server/index.mjs +1454 -182
  31. package/src/server/installer.mjs +136 -8
  32. package/src/server/lan-about.mjs +8 -3
  33. package/src/server/lan-engine.mjs +385 -51
  34. package/src/server/lan-socket.mjs +384 -57
  35. package/src/server/lan-sync.mjs +495 -22
  36. package/src/server/log-writer.mjs +575 -2
  37. package/src/server/login-service.mjs +31 -4
  38. package/src/server/open-url.mjs +22 -2
  39. package/src/server/output-watch.mjs +59 -8
  40. package/src/server/purge-key.mjs +135 -0
  41. package/src/server/quota.mjs +112 -7
  42. package/src/server/running-deck.mjs +15 -1
  43. package/src/server/self-update.mjs +98 -4
  44. package/src/server/session-recap.mjs +89 -0
  45. package/src/server/static-cache.mjs +67 -0
  46. package/src/server/stop-deck.mjs +2 -2
  47. package/src/server/store-lock.mjs +51 -0
  48. package/src/server/supervisor.mjs +1 -1
  49. package/src/server/system-metrics.mjs +143 -17
  50. package/dist/web/assets/index-BdYsAtN8.js +0 -274
  51. package/dist/web/assets/index-CgwmcBHv.css +0 -1
@@ -23,15 +23,26 @@
23
23
  // each would be a switch nobody could reason about.
24
24
  //
25
25
  // The write is the atomic one browser-watch-store.mjs argues for at length —
26
- // temp file, rename — because the alternative is a truncated JSON document as
27
- // the only record of what the user chose, and a corrupt file here silently
28
- // turns the notifications back on.
29
- import { mkdir, readFile, writeFile } from "node:fs/promises";
26
+ // temp file, fsync, rename — because the alternative is a truncated JSON
27
+ // document as the only record of what the user chose.
28
+ //
29
+ // And the READ is the other half of that sentence, which took #1002 to notice.
30
+ // An atomic write makes a truncated prefs.json rare; it does not make it
31
+ // impossible — a full disk, a power cut, an editor saving garbage — and what
32
+ // the deck did when it met one was read it as "nothing chosen yet" and then
33
+ // write defaults over it, destroying the LAN key every paired machine had
34
+ // pinned. A file that cannot be parsed is now moved aside and said out loud;
35
+ // only a genuinely ABSENT file starts clean. See loadPrefs.
36
+ import { chmod, mkdir, readFile, unlink } from "node:fs/promises";
30
37
  // The rename, with the Windows retry ladder installer.mjs wrote for exactly
31
- // this call. See the note over the write below (#786).
32
- import { renameWithRetry } from "./installer.mjs";
38
+ // this call. See the note over the write below (#786). `stripBom` and
39
+ // `createTemp` come from the same module for the reason its export block gives:
40
+ // a rule spelled twice is a rule that drifts, and both of these are rules
41
+ // settings.json and auth.json already follow on files with the same stakes.
42
+ import { createTemp, renameWithRetry, stripBom } from "./installer.mjs";
33
43
  import { join } from "node:path";
34
44
  import { deckDataDir } from "./deck-home.mjs";
45
+ import { PRODUCT } from "./brand.mjs";
35
46
 
36
47
  /** Set to "1" to keep the deck off the desktop whatever the stored preference
37
48
  * says. Same sheet of switches as AGENTS_DECK_NO_DOWNLOAD and
@@ -89,6 +100,11 @@ export const DEFAULTS = Object.freeze({
89
100
  // until somebody ticks a login: the gate that did not change.
90
101
  autoAsk: true,
91
102
  autoAccept: false,
103
+ // WHICH ACCOUNT THIS DECK IS ON, told to the decks it is paired with — and
104
+ // only ever one it shares, so an unticked account is never named. On, so two
105
+ // of one person's machines show each other where they are working; off, and
106
+ // paired decks read "current account hidden" instead.
107
+ shareActive: true,
92
108
  // What somebody HERE calls another deck, keyed by its fingerprint. The name
93
109
  // a deck gives itself is its owner's to choose; this is the other half.
94
110
  aliases: Object.freeze({}),
@@ -102,10 +118,12 @@ export const DEFAULTS = Object.freeze({
102
118
  * machine the default mode hands it to every other account on the box, and
103
119
  * from it they can decrypt any credential that crosses the network.
104
120
  *
105
- * Passed to `writeFile` rather than applied with a follow-up chmod, and the
121
+ * Named on the CREATE rather than applied with a follow-up chmod, and the
106
122
  * difference is the whole point — claude-swap's transfer.py makes the same
107
- * argument at length: a write-then-chmod leaves the file readable for the
108
- * window between the two, which is exactly when a secret is in it. */
123
+ * argument at length: a create-then-chmod leaves the file readable for the
124
+ * window between the two, which is exactly when a secret is in it. The chmod
125
+ * that does follow the create is not that: it only pins a file the umask may
126
+ * have made narrower, and cannot widen one past what the create allowed. */
109
127
  export const PREFS_MODE = 0o600;
110
128
 
111
129
  /** The longest name somebody here may give another deck. The same order as a
@@ -192,6 +210,9 @@ function normaliseLan(raw) {
192
210
  // overrides it, because a truthy string from a hand-edited file is not an
193
211
  // answer.
194
212
  autoAccept: typeof src.autoAccept === "boolean" ? src.autoAccept : DEFAULTS.lan.autoAccept,
213
+ // Whether paired decks are told which shared account this one is on. Absent
214
+ // is on — the default above — and only a real boolean turns it off.
215
+ shareActive: typeof src.shareActive === "boolean" ? src.shareActive : DEFAULTS.lan.shareActive,
195
216
  };
196
217
  }
197
218
 
@@ -210,12 +231,102 @@ export function normalise(raw) {
210
231
  };
211
232
  }
212
233
 
213
- /** What is on disk, or the defaults. A corrupt or absent file is not an error
214
- * the user can act on mid-session, so it reads as "nothing chosen yet". */
215
- export async function readPrefs(home = deckDataDir(), deps = {}) {
234
+ /** Where the bytes of a prefs.json nothing could parse are put.
235
+ *
236
+ * `Date.now()` rather than an ISO timestamp because a colon is not a legal
237
+ * filename character on Windows, and a quarantine that cannot be created on
238
+ * the platform it is protecting is not a quarantine. */
239
+ export const quarantinePath = (home = deckDataDir(), at = Date.now()) =>
240
+ `${prefsPath(home)}.corrupt-${at}`;
241
+
242
+ /** The refusal `writePrefs` throws rather than merge onto a base it knows is
243
+ * not the user's. Shaped like installer.mjs's SETTINGS_UNREADABLE, which is
244
+ * the same policy on the other file this deck rewrites: a file we cannot
245
+ * reproduce is never treated as an empty one. */
246
+ function unreadablePrefs(path, why) {
247
+ const err = new Error(
248
+ `${path} could not be read (${why}). Refusing to overwrite it — this deck's ` +
249
+ `LAN key and its pairings are in there and cannot be re-derived. Fix the ` +
250
+ `file or move it aside, then restart ${PRODUCT}.`,
251
+ );
252
+ err.code = "PREFS_UNREADABLE";
253
+ err.prefsPath = path;
254
+ err.why = why;
255
+ return err;
256
+ }
257
+
258
+ /**
259
+ * Read prefs.json, and say WHICH of four things happened — because three of
260
+ * them hand back the same object and only one of them means it.
261
+ *
262
+ * WHY THE SOURCE IS PART OF THE ANSWER. The old read collapsed "no file yet"
263
+ * and "a file nothing could parse" into one silent `{ ...DEFAULTS }`, which is
264
+ * a defensible answer to a question about VALUES and a catastrophic one as the
265
+ * merge base of a write. A power cut or an OOM kill between the write and the
266
+ * rename leaves a truncated prefs.json; the next boot read it as "nothing
267
+ * chosen yet", found an empty `lan.secret`, generated a new identity and wrote
268
+ * it — over the file that still had the old key, the pairings, the shared
269
+ * accounts and the dialled addresses legibly in it. Every peer that had pinned
270
+ * this deck had to accept it again, and nothing anywhere said why.
271
+ *
272
+ * "file" parsed. These are the user's own settings.
273
+ * "missing" ENOENT, and only ENOENT. Nothing has been chosen yet;
274
+ * defaults, silently, which is what a first start is.
275
+ * "corrupt" bytes that are not JSON. Moved aside to `quarantined` BEFORE
276
+ * this returns, so nothing can merge over them.
277
+ * "unreadable" the read itself failed for some reason other than absence — a
278
+ * permission, a directory in the way. The file is still there
279
+ * and still unread, which is exactly when a write must not land.
280
+ *
281
+ * A BYTE-ORDER MARK IS NOT DAMAGE. Notepad and `Set-Content` write one, and
282
+ * `JSON.parse` throws on it — so stripping it here is what keeps a perfectly
283
+ * good hand-edited file out of quarantine. Same call installer.mjs makes on
284
+ * settings.json for the same reason.
285
+ */
286
+ export async function loadPrefs(home = deckDataDir(), deps = {}) {
216
287
  const read = deps.readFile ?? readFile;
217
- try { return normalise(JSON.parse(await read(prefsPath(home), "utf8"))); }
218
- catch { return { ...DEFAULTS }; }
288
+ const warn = deps.warn ?? console.error;
289
+ const path = prefsPath(home);
290
+ const defaults = () => ({ ...DEFAULTS });
291
+
292
+ let raw;
293
+ try {
294
+ raw = await read(path, "utf8");
295
+ } catch (err) {
296
+ if (err?.code === "ENOENT") return { prefs: defaults(), source: "missing", quarantined: "" };
297
+ warn(`${PRODUCT}: could not read ${path}: ${err?.message ?? err}. Leaving it alone — settings will not be saved until it can be read.`);
298
+ return { prefs: defaults(), source: "unreadable", quarantined: "" };
299
+ }
300
+
301
+ try {
302
+ return { prefs: normalise(JSON.parse(stripBom(raw))), source: "file", quarantined: "" };
303
+ } catch (err) {
304
+ const why = err?.message ?? String(err);
305
+ const mv = deps.rename ?? renameWithRetry;
306
+ const to = quarantinePath(home);
307
+ try {
308
+ await mv(path, to);
309
+ } catch (moveErr) {
310
+ // ENOENT means somebody else moved it between the read and the rename —
311
+ // a second deck booting on the same machine. The bytes are safe, just not
312
+ // under a name this read chose, and what is at `path` now is nothing.
313
+ if (moveErr?.code === "ENOENT") return { prefs: defaults(), source: "missing", quarantined: "" };
314
+ // Anything else and the damaged file is STILL THERE, unread and
315
+ // unprotected. Saying so is the whole point: `writePrefs` refuses on it.
316
+ warn(`${PRODUCT}: ${path} could not be read as JSON (${why}), and could not be moved aside either: ${moveErr?.message ?? moveErr}. Settings will not be saved until it is fixed or moved.`);
317
+ return { prefs: defaults(), source: "corrupt", quarantined: "" };
318
+ }
319
+ warn(`${PRODUCT}: ${path} could not be read as JSON (${why}). It has been kept as ${to} and this deck is starting with fresh settings — its LAN key and its pairings are in that file, so do not delete it if you want them back.`);
320
+ return { prefs: defaults(), source: "corrupt", quarantined: to };
321
+ }
322
+ }
323
+
324
+ /** What is on disk, or the defaults — the VALUES alone, for the callers that
325
+ * want nothing else. `loadPrefs` is the same read with the answer to "and was
326
+ * that really the user's file?" still attached; anything about to write must
327
+ * ask that question, and does. */
328
+ export async function readPrefs(home = deckDataDir(), deps = {}) {
329
+ return (await loadPrefs(home, deps)).prefs;
219
330
  }
220
331
 
221
332
  let _chain = Promise.resolve();
@@ -232,35 +343,140 @@ let _chain = Promise.resolve();
232
343
  *
233
344
  * Serialized for the same reason the other store is: two pages toggling two
234
345
  * different switches in the same second must not lose one of them.
346
+ *
347
+ * AND IT REFUSES RATHER THAN MERGE ONTO A BASE THAT IS NOT THE USER'S. The
348
+ * merge above exists to PRESERVE what the caller did not send; handed the
349
+ * defaults because `loadPrefs` could not read the file, it preserves nothing
350
+ * and the write becomes the thing that destroys the key. So the two failures
351
+ * that read as defaults are separated: a file that was moved aside is safe to
352
+ * start clean over, and one still sitting there unread is not. See
353
+ * installer.mjs's readSettingsForWrite, which is this policy on settings.json.
235
354
  */
236
355
  export async function writePrefs(patch, home = deckDataDir(), deps = {}) {
237
- const job = async () => {
238
- const mk = deps.mkdir ?? mkdir;
239
- const write = deps.writeFile ?? writeFile;
240
- // `renameWithRetry`, not `rename` (#786). MoveFileExW refuses while any
241
- // handle without FILE_SHARE_DELETE is open on either side, and Defender and
242
- // the search indexer open a file the instant it is written — so on Windows
243
- // a bare rename fails on a perfectly healthy machine, `POST /api/prefs`
244
- // 500s through `guard`, and the notifications switch silently does not
245
- // stick. POSIX rename(2) has no such rule, which is why this shipped green.
246
- const mv = deps.rename ?? renameWithRetry;
247
- const prev = await readPrefs(home, deps);
248
- // The LAN section merges rather than replaces, so a page toggling the
249
- // switch does not have to send the passphrase back to keep it — and so
250
- // nothing has to send a secret it was never given.
251
- const merged = { ...prev, ...patch, lan: { ...prev.lan, ...(patch?.lan ?? {}) } };
252
- const next = normalise(merged);
253
- await mk(prefsDir(home), { recursive: true, mode: 0o700 });
254
- const tmp = `${prefsPath(home)}.${process.pid}.tmp`;
255
- await write(tmp, JSON.stringify(next, null, 2) + "\n", { encoding: "utf8", mode: PREFS_MODE });
256
- await mv(tmp, prefsPath(home));
257
- return next;
258
- };
356
+ return queued(() => save(() => patch, home, deps));
357
+ }
358
+
359
+ /**
360
+ * Change some of the preferences from what the write is about to READ.
361
+ *
362
+ * The twin of `updateStore` in browser-watch-store.mjs, made for the same
363
+ * reason and shaped the same way: `mutate(prev)` runs INSIDE the queued job,
364
+ * after that job's own `loadPrefs` and after the two guards that decide whether
365
+ * this file may be written at all, so the patch is a function of the file
366
+ * rather than of a copy somebody took earlier — and a caller that computes one
367
+ * never sees the defaults `loadPrefs` returns for a file it refused to write
368
+ * over.
369
+ *
370
+ * WHICH CALLERS NEED IT, AND WHY A PATCH IS NOT ENOUGH ON ITS OWN. `writePrefs`
371
+ * already merges field by field, so two writes naming two different fields
372
+ * cannot lose each other. What it cannot do is merge two writes of the SAME
373
+ * field, and three callers compute one whole field — `lan.manual` twice and
374
+ * `lan.aliases` once — out of `index.mjs`'s module-level `_prefs`, which is
375
+ * refreshed only when a previous write resolves. Two of them in one turn both
376
+ * read before either job runs, and the second patch is a whole array or a whole
377
+ * map without the first's entry in it: both answer 200, both panels redraw, one
378
+ * change is never written.
379
+ *
380
+ * Measured against this file in a temp directory, with those exact call shapes,
381
+ * starting from `manual: ["10.0.0.1:5000"]`:
382
+ *
383
+ * memory manual = ["10.0.0.1:5000","10.0.0.3:5002"] # the onDial entry is gone
384
+ * disk manual = ["10.0.0.1:5000","10.0.0.3:5002"]
385
+ *
386
+ * The alias route carried a comment claiming the opposite — "the whole map is
387
+ * rebuilt from the one on disk rather than sent by the page, so two tabs
388
+ * renaming two decks cannot undo each other" — which is the sentence this makes
389
+ * true.
390
+ *
391
+ * `mutate` returns a PATCH, not a whole state, so the merge doctrine above is
392
+ * unchanged: a field nobody mentions keeps its value. It may return nothing,
393
+ * which means "no change" and still rewrites the file with what it read.
394
+ */
395
+ export async function updatePrefs(mutate, home = deckDataDir(), deps = {}) {
396
+ return queued(() => save(mutate, home, deps));
397
+ }
398
+
399
+ /** One read-modify-write, behind every other one. Both entry points go through
400
+ * here so there is a single queue and a single merge. */
401
+ function queued(job) {
259
402
  const started = _chain.then(job, job);
260
403
  _chain = started.then(() => {}, () => {});
261
404
  return started;
262
405
  }
263
406
 
407
+ async function save(mutate, home, deps) {
408
+ const mk = deps.mkdir ?? mkdir;
409
+ const temp = deps.createTemp ?? createTemp;
410
+ const setMode = deps.chmod ?? chmod;
411
+ const drop = deps.unlink ?? unlink;
412
+ // `renameWithRetry`, not `rename` (#786). MoveFileExW refuses while any
413
+ // handle without FILE_SHARE_DELETE is open on either side, and Defender and
414
+ // the search indexer open a file the instant it is written — so on Windows
415
+ // a bare rename fails on a perfectly healthy machine, `POST /api/prefs`
416
+ // 500s through `guard`, and the notifications switch silently does not
417
+ // stick. POSIX rename(2) has no such rule, which is why this shipped green.
418
+ const mv = deps.rename ?? renameWithRetry;
419
+ const target = prefsPath(home);
420
+ const { prefs: prev, source, quarantined } = await loadPrefs(home, deps);
421
+ if (source === "unreadable") throw unreadablePrefs(target, "the read failed");
422
+ if (source === "corrupt" && !quarantined) {
423
+ throw unreadablePrefs(target, "it is not JSON and could not be moved aside");
424
+ }
425
+ // INSIDE THE JOB, after the read above and after the two guards that
426
+ // decide whether this file may be written at all — see `updatePrefs`.
427
+ // `writePrefs` hands over a constant here, which is what makes the two
428
+ // one function.
429
+ const patch = await mutate(prev);
430
+ // The LAN section merges rather than replaces, so a page toggling the
431
+ // switch does not have to send the passphrase back to keep it — and so
432
+ // nothing has to send a secret it was never given.
433
+ const merged = { ...prev, ...patch, lan: { ...prev.lan, ...(patch?.lan ?? {}) } };
434
+ const next = normalise(merged);
435
+ await mk(prefsDir(home), { recursive: true, mode: 0o700 });
436
+ // `createTemp`, not a name built out of the pid alone.
437
+ //
438
+ // The splice browser-watch-store.mjs met is NOT the hazard here: `_chain`
439
+ // above serializes every write in this process, and two decks on one home
440
+ // have two pids. The hazard is the LEFTOVER. A deck killed between the
441
+ // create and the rename strands a temp file with this deck's private key
442
+ // in it under a name derived from its pid, the old code never unlinked one
443
+ // on failure either — and `writeFile`'s `mode` applies only when the call
444
+ // CREATES the file, so a later deck the OS hands that pid back adopted the
445
+ // stranded file whole, keeping whatever permissions it had, and wrote the
446
+ // key into it. O_EXCL is what turns a taken name into an error the caller
447
+ // handles instead, and it is also what makes PREFS_MODE binding from the
448
+ // first byte rather than a hope about what was there before. codex-auth.mjs
449
+ // argues all of this at length over the one other secret this deck stages
450
+ // through a temp file.
451
+ const { tmp, handle } = await temp(target, { mode: PREFS_MODE });
452
+ let landed = false;
453
+ try {
454
+ try {
455
+ await handle.writeFile(JSON.stringify(next, null, 2) + "\n", "utf8");
456
+ // The fsync is the half of "atomic" a rename alone does not buy. A
457
+ // rename orders the directory entry; it does not order the BYTES, so a
458
+ // machine that loses power just after it can come up with the entry
459
+ // pointing at a file that was never flushed — which is the truncated
460
+ // prefs.json this whole read path now exists to survive.
461
+ await handle.sync();
462
+ } finally {
463
+ await handle.close();
464
+ }
465
+ // The umask only ever clears bits off the creation mode, so the key is
466
+ // never wider than 0600 — but it can land narrower, and a prefs.json at
467
+ // 0400 is one this writer cannot replace next time. On Windows chmod's
468
+ // only effect is the read-only bit, and a read-only target is one no
469
+ // rename can replace. Pin it either way.
470
+ await setMode(tmp, PREFS_MODE);
471
+ await mv(tmp, target);
472
+ landed = true;
473
+ } finally {
474
+ // A temp left behind holds this deck's private key in cleartext.
475
+ if (!landed) await drop(tmp).catch(() => {});
476
+ }
477
+ return next;
478
+ }
479
+
264
480
  /**
265
481
  * May the deck raise a desktop notification right now?
266
482
  *
@@ -30,7 +30,7 @@ import { request as httpRequest } from "node:http";
30
30
  // The same deadline hook.js gives a challenge, and for the same reason: a
31
31
  // bodyless GET to a loopback port is sub-millisecond when a deck is there and an
32
32
  // instant ECONNREFUSED when nothing is.
33
- export const DECK_CHALLENGE_TIMEOUT_MS = 400;
33
+ const DECK_CHALLENGE_TIMEOUT_MS = 400;
34
34
 
35
35
  // Signal 0 delivers nothing; it asks whether the pid could be signalled.
36
36
  //
@@ -402,6 +402,81 @@ export function killTree(child, signal) {
402
402
  }
403
403
  }
404
404
 
405
+ /**
406
+ * Every child this module started and has not yet seen the end of.
407
+ *
408
+ * A deadline is not a promise unless something is alive to enforce it, and the
409
+ * deadlines in this file all live in the parent: `run` states the outcome on a
410
+ * timer and only then kills, `runInteractive` does the same. Kill the parent
411
+ * and the timer dies with it, so the very case a deadline exists for — a tool
412
+ * that has hung — is the one case where nothing is left to stop it.
413
+ *
414
+ * Observed (#1012). A sandboxed deck, a quota poll in flight, SIGINT to the
415
+ * deck at 05:26:22.706. The deck was gone within 200ms; its `claude --print
416
+ * /usage` child, spawned at 05:26:18.078 under a 15-second deadline, was still
417
+ * running at 05:26:35.204 — reparented to init, past the deadline the dead
418
+ * parent would have enforced, and with nothing anywhere that would ever kill
419
+ * it. A whole Claude Code process, hundreds of MB resident, orphaned by a
420
+ * Ctrl+C.
421
+ *
422
+ * REMOVED ON 'close', NOT ON 'exit', and the difference is Windows'. A batch
423
+ * candidate runs THROUGH cmd.exe and the tool is a grandchild (see viaCmd and
424
+ * killTree): the wrapper can exit while the tool underneath goes on holding
425
+ * the inherited stdio, which is exactly the shape killTree's `taskkill /T`
426
+ * exists for. 'exit' there means "the wrapper is gone" and 'close' means "and
427
+ * so is everything it started" — forgetting on the first would drop a tree
428
+ * that is still running and still reachable. 'error' is the third way a child
429
+ * ends, and the only one after which 'close' may never come at all.
430
+ *
431
+ * `runDetached` is deliberately absent. It is the one function here named for
432
+ * outliving its call — a sound, or a collection whose result lands in a file
433
+ * the next poll reads — it holds no deadline, and it unrefs its child on
434
+ * purpose. Killing those on the way out would be this set overreaching.
435
+ */
436
+ const live = new Set();
437
+
438
+ /** Track one child until it ends. Returns it, so it can wrap a spawn inline. */
439
+ function watchChild(cp) {
440
+ if (!cp) return cp;
441
+ live.add(cp);
442
+ const forget = () => { live.delete(cp); };
443
+ cp.once?.("close", forget);
444
+ cp.once?.("error", forget);
445
+ return cp;
446
+ }
447
+
448
+ /**
449
+ * The pids of the children this deck still has running.
450
+ *
451
+ * Exported for the test below this, and because it is the honest answer to
452
+ * "what is this deck still doing" that a future `--status` wants. It may
453
+ * over-report by one in the Windows case above — a wrapper whose 'close' never
454
+ * arrives stays here — which is the right direction to be wrong in: that entry
455
+ * names a process that really is still running.
456
+ */
457
+ export const liveChildPids = () => [...live].map(c => c?.pid).filter(Boolean);
458
+
459
+ /**
460
+ * Stop every child this module started, and everything they started.
461
+ *
462
+ * Called from shutdown(), where the alternative is the orphan above. It does
463
+ * not WAIT for the corpses, on purpose: a signal is delivered synchronously on
464
+ * POSIX, and on Windows killTree's taskkill is a process of its own that
465
+ * CreateProcess has already started by the time spawn() returns and that
466
+ * outlives this one. Waiting would buy nothing and would spend the ~200ms exit
467
+ * that is the rest of shutdown's good behaviour.
468
+ *
469
+ * The set is cleared first so a 'close' arriving mid-loop cannot mutate what
470
+ * is being iterated, and so a second call — shutdown can be reached twice —
471
+ * has nothing left to do.
472
+ */
473
+ export function killLiveChildren(signal) {
474
+ const doomed = [...live];
475
+ live.clear();
476
+ for (const cp of doomed) killTree(cp, signal);
477
+ return doomed.length;
478
+ }
479
+
405
480
  // Reasons to try the next candidate spelling rather than give up. EINVAL and
406
481
  // UNKNOWN show up on Windows for a file that exists but cannot be executed the
407
482
  // way it was asked for; both mean "not this one", not "no such tool".
@@ -695,8 +770,11 @@ export function run(cmd, args, { timeout = 20_000, maxBuffer = 4 << 20, env } =
695
770
  };
696
771
 
697
772
  try {
698
- const cp = execFile(file, argv,
699
- { timeout: 0, shell: false, windowsHide: true, maxBuffer, ...(env ? { env } : {}), ...opts }, done);
773
+ // watchChild: this run's deadline lives in the timer below, in THIS
774
+ // process, so a shutdown that does not reap it leaves the child with
775
+ // no deadline at all. See `live` above, and #1012.
776
+ const cp = watchChild(execFile(file, argv,
777
+ { timeout: 0, shell: false, windowsHide: true, maxBuffer, ...(env ? { env } : {}), ...opts }, done));
700
778
  // Give the child EOF on stdin straight away, which is what this
701
779
  // function's contract has always claimed ("run closes stdin", says
702
780
  // runInteractive's header) and what execFile does not do: it leaves the
@@ -853,7 +931,10 @@ export function runInteractive(cmd, args, { timeout = 300_000, maxOutput = 256 <
853
931
  const { file, args: argv, opts, launch } = candidateSpec(raw, args);
854
932
  let proc;
855
933
  try {
856
- proc = spawn(file, argv, { stdio: ["pipe", "pipe", "pipe"], shell: false, windowsHide: true, ...opts });
934
+ // Tracked for the same reason `run`'s child is, and with more at stake:
935
+ // this one's deadline is five minutes and it is a `claude auth login`
936
+ // blocked on a stdin pipe only this process holds. See `live` above.
937
+ proc = watchChild(spawn(file, argv, { stdio: ["pipe", "pipe", "pipe"], shell: false, windowsHide: true, ...opts }));
857
938
  } catch (err) {
858
939
  return tryNext(err) ? attempt(i + 1) : finish(-1, err);
859
940
  }
@@ -54,6 +54,16 @@ export function readGlobalRoot(out) {
54
54
  * is not obvious: a global prefix owned by root needs either sudo or a prefix
55
55
  * of the user's own, and "permission denied" alone sends people to sudo when
56
56
  * the second is the better answer.
57
+ *
58
+ * EEXIST is the other, and it is the one this deck's own history produced. The
59
+ * retired `agents-deck` and `agent-dag` packages shipped all three commands in
60
+ * their `bin` map, so a machine that installed either of them owns
61
+ * `<prefix>/bin/ccdeck` under that package's name. `npm i -g ccdeck` then has a
62
+ * command file to link and something already sitting on it, and npm refuses
63
+ * rather than taking a file another package still claims. The raw first stderr
64
+ * line says `EEXIST` and names a path, which tells a user nothing about the
65
+ * package that has to come off first — and it is exactly the users of #975,
66
+ * reaching for the one command that would have got them out of it.
57
67
  */
58
68
  export function installFailure(out, { pkg = "ccdeck" } = {}) {
59
69
  const said = `${out?.stderr ?? ""}\n${out?.stdout ?? ""}`;
@@ -62,6 +72,9 @@ export function installFailure(out, { pkg = "ccdeck" } = {}) {
62
72
  if (/EACCES|permission denied/i.test(said)) {
63
73
  return `npm cannot write to the global prefix. Either \`sudo npm i -g ${pkg}\`, or point npm at a prefix you own (\`npm config set prefix ~/.npm-global\`) and add its \`bin\` to PATH`;
64
74
  }
75
+ if (/EEXIST/i.test(said)) {
76
+ return `npm will not overwrite a command another global package owns. This deck's retired names ship the same commands, so remove them first: \`npm rm -g agents-deck agent-dag\`, then \`npm i -g ${pkg}\``;
77
+ }
65
78
  const first = said.split(/\r?\n/).map(s => s.trim()).filter(Boolean)[0];
66
79
  return first || `npm exited ${out?.status ?? "non-zero"}`;
67
80
  }