agent-dag 3.19.0 → 3.20.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.
@@ -40,8 +40,8 @@
40
40
  document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
41
41
  })();
42
42
  </script>
43
- <script type="module" crossorigin src="/assets/index-DTbVC1d9.js"></script>
44
- <link rel="stylesheet" crossorigin href="/assets/index-CE-8EKi6.css">
43
+ <script type="module" crossorigin src="/assets/index-Dr-bVaHM.js"></script>
44
+ <link rel="stylesheet" crossorigin href="/assets/index-DVj_M279.css">
45
45
  </head>
46
46
  <body>
47
47
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-dag",
3
- "version": "3.19.0",
3
+ "version": "3.20.0",
4
4
  "description": "Live deck of Claude Code and Codex agents — watch tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31,6 +31,12 @@
31
31
  "about a defect in the type, and the suite refuses both it and no space at",
32
32
  "all."
33
33
  ],
34
+ "3.20.0": [
35
+ {
36
+ "title": "🟢 The deck keeps running after you close the terminal",
37
+ "body": "`ccdeck` prints the same boot report and gives you the prompt back. The deck stays up, and starts again when you log in.\n\nšŸ›‘ **`ccdeck --stop` is the off switch now** — Ctrl+C only cancels a start that is still printing. `--status` says what is running, `--logs` shows what the deck wrote while you were away, and `--foreground` holds the terminal the way every version before this one did.\n\nšŸ” Typing `ccdeck` beside a deck that is already up opens **that deck's tab** instead of building a second one. `ccdeck --stop --all` clears any older deck still running from before this version. An `npx` run cannot start at login — `npx ccdeck --install` puts it on your PATH and sets that up.\n\nšŸ”‘ An account that will not collect now says which of three things is wrong: **no stored login**, a **login that expired**, or a **keychain the deck cannot open** — with the fix beside it. One with no stored login is filled over the local network by a paired deck that has it.\n\nšŸ“” And decks find each other again on a machine with a VPN up: the beacon was going to an address that never left the machine, so this deck could hear colleagues and none of them could hear it."
38
+ }
39
+ ],
34
40
  "3.19.0": [
35
41
  {
36
42
  "title": "🟢 Local network: fixes and improvements",
@@ -110,6 +110,25 @@ export function isPortValue(raw) {
110
110
  * C:\Users\John Smith\proj` reaches the parser as two arguments, and the second
111
111
  * one used to be dropped in silence (see launchNpx in bin/agent-dag.js).
112
112
  */
113
+ /**
114
+ * The flags that do a thing and exit, rather than starting a deck.
115
+ *
116
+ * bin/agent-dag.js reads this before it decides whether to detach, and that is
117
+ * the whole reason it exists as a list rather than as a condition written out
118
+ * at the call site: a one-shot that detached would print its answer into a log
119
+ * file and hand the terminal back empty. `ccdeck --version` detaching itself is
120
+ * the shape of the bug this prevents.
121
+ */
122
+ export const ONE_SHOT = Object.freeze([
123
+ "help", "version", "uninstall", "stop", "status", "logs",
124
+ "install", "installService", "uninstallService",
125
+ ]);
126
+
127
+ /** Is this a command line that answers and leaves? */
128
+ export function isOneShot(flags = {}) {
129
+ return ONE_SHOT.some((k) => flags[k] === true);
130
+ }
131
+
113
132
  export function parseArgs(args) {
114
133
  const out = { unknown: [], incomplete: [] };
115
134
  for (let i = 0; i < args.length; i++) {
@@ -140,6 +159,14 @@ export function parseArgs(args) {
140
159
  else if (a === "-v" || a === "--version") out.version = true;
141
160
  else if (a === "-p" || a === "--port") set("port", "a port number");
142
161
  else if (a === "--no-open") out.noOpen = true;
162
+ else if (a === "--new") out.new = true;
163
+ else if (a === "--foreground") out.foreground = true;
164
+ else if (a === "--stop") out.stop = true;
165
+ else if (a === "--status") out.status = true;
166
+ else if (a === "--logs") out.logs = true;
167
+ else if (a === "--install") out.install = true;
168
+ else if (a === "--install-service") out.installService = true;
169
+ else if (a === "--uninstall-service") out.uninstallService = true;
143
170
  else if (a === "--uninstall") out.uninstall = true;
144
171
  else if (a === "--workspace") set("workspace", "a path");
145
172
  else if (a === "--scope") out.scope = true;
@@ -151,6 +151,27 @@ function heldReading(now) {
151
151
  // without a marker would misrepresent them (its own trust ceiling is 3600s).
152
152
  const STALE_AFTER_MS = 15 * 60_000;
153
153
 
154
+ /**
155
+ * How long a collector may produce nothing before that is a fault rather than a
156
+ * cadence.
157
+ *
158
+ * TWELVE HOURS, and the number is chosen from what the failure actually looks
159
+ * like rather than from taste. Measured on the machine that reported this: three
160
+ * accounts last collected 21 hours, 40 hours and 28 days ago, every one of them
161
+ * with `consecutiveFailures: 0` — because the thing stopping them was
162
+ * `keychain_unavailable`, which is claude-swap failing to OPEN the credential
163
+ * rather than having it rejected, and which never touches that counter.
164
+ *
165
+ * Long enough that a laptop closed overnight does not trip it. Short enough that
166
+ * an account nobody can read stops being advertised to the group as one they
167
+ * can. And the cost of being wrong is deliberately lopsided: a false "not
168
+ * collecting" asks a peer for a blob that `cswap import` then declines, because
169
+ * a plain import skips an account that is present and healthy — while the false
170
+ * negative this replaces is an account that is never repaired by anything, ever,
171
+ * and is published to every paired deck as good.
172
+ */
173
+ const COLLECTION_STOPPED_AFTER_MS = 12 * 60 * 60_000;
174
+
154
175
  // Nudging the collector.
155
176
  //
156
177
  // Two throttles, because the cost of asking is not the cost of fetching. When
@@ -289,6 +310,94 @@ export function nextReadAt(row, matches, fetchedAtMs, isActive, now) {
289
310
  * Either way claude-swap decides whether a network call actually happens, and
290
311
  * this is throttled on top of that.
291
312
  */
313
+ /**
314
+ * What claude-swap says about each slot, in its own words.
315
+ *
316
+ * `usage.json` records numbers and a failure COUNTER; `cswap list --json`
317
+ * records a per-slot VERDICT, and the two answer different questions. Measured
318
+ * on the machine this was written for, at one instant, for the same account:
319
+ *
320
+ * usage.json -> consecutiveFailures: 0, lastError: null
321
+ * cswap list -> usageStatus: "no_credentials"
322
+ *
323
+ * Three verdicts, three different things for a person to do — `no_credentials`
324
+ * is an account to receive or re-add, `relogin_required` is one to sign into,
325
+ * `keychain_unavailable` is not about the account at all but about the process
326
+ * asking. The counter can tell none of them apart, and for two of the three it
327
+ * reads zero.
328
+ *
329
+ * WHERE IT COMES FROM COSTS NOTHING EXTRA. nudgeCollector already spawns
330
+ * `cswap list` when a collection is due, and threw the output away. It now asks
331
+ * for `--json` and keeps the verdicts. Still not awaited by anyone — the nudge
332
+ * stays synchronous for its callers — and still one child at a time.
333
+ */
334
+ let _verdicts = { at: 0, byNum: {} };
335
+ /** Stale after this, because a verdict that outlives its cause is worse than no
336
+ * verdict: "no credentials" under an account somebody has since signed into is
337
+ * a sentence that sends them to fix what is already fixed. */
338
+ const VERDICT_TTL_MS = 10 * 60_000;
339
+ /** One `cswap list --json` at a time. A collection can take a while on a cold
340
+ * network, and a nudge landing inside one must not start a second. */
341
+ let _verdictInFlight = false;
342
+
343
+ /**
344
+ * Ask claude-swap about one account RIGHT NOW, rather than reading the cache.
345
+ *
346
+ * The cached verdicts are ten minutes old at worst, which is right for a label
347
+ * and wrong for a decision that writes a credential: somebody who signed in two
348
+ * minutes ago still reads as `no_credentials` there, and acting on that would
349
+ * replace the login they just created with a peer's.
350
+ *
351
+ * Matched on IDENTITY rather than on the slot number, because a number is a
352
+ * position in one machine's store and the caller is holding an account.
353
+ *
354
+ * Returns null when the question cannot be answered — no claude-swap, a refusal,
355
+ * an account it does not know. Null is not `no_credentials`, and the one caller
356
+ * treats it as "do not act".
357
+ */
358
+ export async function verdictNow(email, org, { runner = run, bin = cswapBin } = {}) {
359
+ const want = String(email ?? "").trim().toLowerCase();
360
+ if (!want) return null;
361
+ try {
362
+ const out = await runner(await bin(), ["list", "--json"], { timeout: VERDICT_TIMEOUT_MS });
363
+ if (!out?.ok) return null;
364
+ const d = JSON.parse(out.stdout);
365
+ // The same freshly-read answer feeds the cache the panel draws from, since
366
+ // it cost a subprocess either way.
367
+ const byNum = readVerdicts(out.stdout);
368
+ if (Object.keys(byNum).length) _verdicts = { at: Date.now(), byNum };
369
+ for (const a of Array.isArray(d?.accounts) ? d.accounts : []) {
370
+ if (String(a?.email ?? "").trim().toLowerCase() !== want) continue;
371
+ if ((a?.organizationUuid ?? "") !== (org ?? "")) continue;
372
+ return typeof a?.usageStatus === "string" ? a.usageStatus : null;
373
+ }
374
+ return null;
375
+ } catch { return null; }
376
+ }
377
+
378
+ /** claude-swap's verdict for a slot, or null when there is none fresh enough. */
379
+ function verdictFor(num, now) {
380
+ if (now - _verdicts.at > VERDICT_TTL_MS) return null;
381
+ const v = _verdicts.byNum[String(num)];
382
+ return typeof v === "string" && v !== "" ? v : null;
383
+ }
384
+
385
+ /** Read the verdicts out of a `cswap list --json` payload. Tolerant by
386
+ * construction: this is another tool's output, and a shape we do not recognise
387
+ * means no verdicts rather than a thrown boot. */
388
+ export function readVerdicts(stdout) {
389
+ try {
390
+ const d = JSON.parse(stdout);
391
+ const out = {};
392
+ for (const a of Array.isArray(d?.accounts) ? d.accounts : []) {
393
+ if (typeof a?.number === "number" && typeof a?.usageStatus === "string") {
394
+ out[String(a.number)] = a.usageStatus;
395
+ }
396
+ }
397
+ return out;
398
+ } catch { return {}; }
399
+ }
400
+
292
401
  function nudgeCollector(rows, slots, now, activeNum) {
293
402
  // Did the last ask accomplish anything? Cheap proxy: the newest collection
294
403
  // timestamp in the store.
@@ -307,12 +416,35 @@ function nudgeCollector(rows, slots, now, activeNum) {
307
416
  if (!due && !freshen) return;
308
417
 
309
418
  _lastNudge = now;
310
- const args = due ? ["list"] : ["auto", "--once", "--dry-run", "--json"];
419
+ // The due path asks for JSON and KEEPS it — see _verdicts. The dry-run path
420
+ // stays detached: it is the engine's own collect pass, its JSON is a different
421
+ // shape, and nothing here reads it.
422
+ if (!due) {
423
+ cswapBin().then(bin => runDetached(bin, ["auto", "--once", "--dry-run", "--json"])).catch(() => {});
424
+ return;
425
+ }
426
+ if (_verdictInFlight) return;
427
+ _verdictInFlight = true;
311
428
  // Fire-and-forget: this function is deliberately synchronous so callers never
312
- // wait on it, and resolving the binary is the only async part.
313
- cswapBin().then(bin => runDetached(bin, args)).catch(() => {});
429
+ // wait on it, and resolving the binary is the only async part. `run` rather
430
+ // than `runDetached` only so the output can be read; the caller is no more
431
+ // aware of it than before.
432
+ cswapBin()
433
+ .then(bin => run(bin, ["list", "--json"], { timeout: VERDICT_TIMEOUT_MS }))
434
+ .then(out => {
435
+ const byNum = out?.ok ? readVerdicts(out.stdout) : {};
436
+ // Replaced whole rather than merged. A slot that has gone away must not
437
+ // keep the verdict it had when it was last seen.
438
+ if (Object.keys(byNum).length) _verdicts = { at: Date.now(), byNum };
439
+ })
440
+ .catch(() => {})
441
+ .finally(() => { _verdictInFlight = false; });
314
442
  }
315
443
 
444
+ /** Long enough for a cold collection over a slow network, short enough that a
445
+ * wedged claude-swap does not hold a child for the rest of the day. */
446
+ const VERDICT_TIMEOUT_MS = 90_000;
447
+
316
448
  async function readJson(path) {
317
449
  try {
318
450
  const parsed = JSON.parse(await readFile(path, "utf8"));
@@ -449,7 +581,9 @@ async function readRoster(now, gen) {
449
581
 
450
582
  const fetchedAtMs = matches && typeof row.fetchedAt === "number" ? row.fetchedAt * 1000 : null;
451
583
  const isActive = String(seq.activeAccountNumber) === num;
452
- const trouble = authTrouble(row, { matches, isActive, identity, email: acct.email });
584
+ const trouble = authTrouble(row, {
585
+ matches, isActive, identity, email: acct.email, fetchedAt: fetchedAtMs, now,
586
+ });
453
587
 
454
588
  const lanes = [
455
589
  lane("five_hour", "5h", good?.five_hour),
@@ -502,6 +636,17 @@ async function readRoster(now, gen) {
502
636
  // in as it anyway. The panel says so quietly instead of offering to log
503
637
  // them in again.
504
638
  staleCopy: trouble?.kind === "stale-copy",
639
+ // Nothing has been collected for this account in half a day, and nothing
640
+ // says why. Its own word because the two existing ones would both be
641
+ // wrong: `error` claims a rejection that was never reported, and
642
+ // `staleCopy` promises the user is signed in as it, which is only
643
+ // knowable for the active account.
644
+ stopped: trouble?.kind === "stopped",
645
+ // claude-swap's own verdict for this slot, when there is a fresh one:
646
+ // "no_credentials", "relogin_required", "keychain_unavailable", … It is
647
+ // what turns "not collecting" into a sentence with a next step in it, and
648
+ // it is null on every machine where the collector has not been asked yet.
649
+ collector: verdictFor(num, now),
505
650
  });
506
651
  }
507
652
 
@@ -541,8 +686,24 @@ async function readRoster(now, gen) {
541
686
  * evidence of anything, so the stored verdict stands: refusing to show a real
542
687
  * expiry because a subprocess failed is the opposite mistake.
543
688
  */
544
- export function authTrouble(row, { matches, isActive, identity, email } = {}) {
545
- if (!matches || !((row?.consecutiveFailures ?? 0) > 0)) return null;
689
+ export function authTrouble(row, {
690
+ matches, isActive, identity, email,
691
+ fetchedAt = null, now = Date.now(), stoppedAfterMs = COLLECTION_STOPPED_AFTER_MS,
692
+ } = {}) {
693
+ if (!matches) return null;
694
+ const failing = (row?.consecutiveFailures ?? 0) > 0;
695
+ // A COLLECTOR THAT HAS PRODUCED NOTHING IN HALF A DAY IS FAILING, whatever
696
+ // its counter says — see COLLECTION_STOPPED_AFTER_MS. `consecutiveFailures`
697
+ // counts rejections, and the failure found on the reporting machine was not a
698
+ // rejection: `cswap list` answered `usageStatus: keychain_unavailable` for
699
+ // three accounts whose counters all read zero.
700
+ //
701
+ // `fetchedAt == null` is deliberately NOT this. That is an account nobody has
702
+ // ever collected — usually one added a minute ago — and the panel already has
703
+ // a word for it. Calling a new account broken is a worse first impression than
704
+ // saying nothing.
705
+ const stopped = !failing && fetchedAt != null && now - fetchedAt > stoppedAfterMs;
706
+ if (!failing && !stopped) return null;
546
707
 
547
708
  const signedInHere = isActive
548
709
  && identity
@@ -555,6 +716,12 @@ export function authTrouble(row, { matches, isActive, identity, email } = {}) {
555
716
  // fix under a red badge, and it must not offer to sign them in again.
556
717
  return { kind: "stale-copy", error: null };
557
718
  }
719
+ // A SILENCE, NOT A DIAGNOSIS. All that is known is that nothing has been
720
+ // collected for half a day; the reason lives in claude-swap and may be a dead
721
+ // login, a keychain it cannot open, or a machine that was off. Putting
722
+ // `invalid_grant` on it would be inventing evidence, and the panel would then
723
+ // offer "sign in again" for a problem that may not be a sign-in at all.
724
+ if (stopped) return { kind: "stopped", error: null };
558
725
  return { kind: "auth", error: row.lastError ?? "error" };
559
726
  }
560
727
 
@@ -987,6 +987,23 @@ export async function shareAccounts(nums) {
987
987
  }
988
988
 
989
989
 
990
+ /**
991
+ * Did anything actually arrive in the store?
992
+ *
993
+ * THREE STATES MEAN YES AND THEY ARE NOT INTERCHANGEABLE. `imported` is a slot
994
+ * that did not exist; `healed` and `updated` are one that did and was rewritten,
995
+ * which is what a forced import produces and what `added` — counting `imported`
996
+ * alone — reports as nothing having happened. A caller that asked for a replace
997
+ * and read `added` would conclude its own success had failed.
998
+ *
999
+ * `present` is the decline: claude-swap kept what it had. `failed` is the
1000
+ * refusal. Neither is an arrival.
1001
+ */
1002
+ export function landed(results) {
1003
+ return (Array.isArray(results) ? results : [])
1004
+ .some(r => r?.state === "imported" || r?.state === "healed" || r?.state === "updated");
1005
+ }
1006
+
990
1007
  /**
991
1008
  * The identities a bundle carries, or `[]` when it cannot be read.
992
1009
  *
@@ -0,0 +1,243 @@
1
+ // Where the deck keeps its OWN things, as opposed to where Claude Code keeps
2
+ // its things.
3
+ //
4
+ // For a year those were one directory. `~/.claude/agent-dag/` held the hook
5
+ // Claude Code reads, the deck's private key, its pairings, its port registry
6
+ // and a hundred megabytes of event log — and the only reason was that the deck
7
+ // already knew that path, because it has to read `~/.claude` to do its job at
8
+ // all.
9
+ //
10
+ // THREE THINGS THAT COST, and none of them is tidiness:
11
+ //
12
+ // A person who deletes ~/.claude to fix Claude Code deletes this deck's
13
+ // identity. The key is what every paired machine has pinned, so it is not a
14
+ // setting that comes back — every colleague has to accept this deck again.
15
+ //
16
+ // CLAUDE_CONFIG_DIR is Claude Code's knob for keeping a second profile, and
17
+ // pointing it somewhere gave the deck a brand new identity and no pairings,
18
+ // silently. That is a footgun with a fifty-machine blast radius and no error
19
+ // message anywhere.
20
+ //
21
+ // The event log is a hundred megabytes of the deck's data inside another
22
+ // application's configuration directory. Nothing about a rotating log is
23
+ // configuration.
24
+ //
25
+ // So the deck's own state moves to where this platform keeps application data,
26
+ // and the two things that CANNOT move stay where they are: `hook.js`, because
27
+ // Claude Code reads it from there, and the per-pid port registry, because a
28
+ // deck of any version has to be able to list the others — see
29
+ // registeredDeckPorts, which reads that directory by name.
30
+ //
31
+ // The layout is env-paths', which is what Node command-line tools converge on,
32
+ // and its rule is the one worth repeating: XDG is a Linux specification, so it
33
+ // applies on Linux and nowhere else. macOS has Application Support and Logs;
34
+ // Windows has LocalAppData. A tool that puts ~/.config on a Mac is a tool that
35
+ // read half the page.
36
+ //
37
+ // NOTHING IS DELETED BY THE MOVE. Every file is COPIED to its new home and the
38
+ // original left where it was, so a deck downgraded to the version before this
39
+ // one finds exactly what it had. See migrateDeckFiles.
40
+ import { homedir } from "node:os";
41
+ import { join, posix as posixPath, win32 as winPath } from "node:path";
42
+ import { claudeConfigDir } from "./claude-dir.mjs";
43
+ // The Windows ladder, not `rename` (#786). MoveFileExW refuses while any other
44
+ // process holds the destination open — a scanner, the indexer, a backup agent —
45
+ // and the file this one renames into place is a private key. Losing that rename
46
+ // to a virus scanner would be a deck with no identity, which is the one outcome
47
+ // this whole module is written to prevent.
48
+ import { renameWithRetry } from "./installer.mjs";
49
+
50
+ /** The name this deck files itself under. No `-nodejs` suffix, which env-paths
51
+ * adds to keep a script from colliding with a native app of the same name —
52
+ * there is no other ccdeck on any of these platforms. */
53
+ const APP = "ccdeck";
54
+
55
+ /** Point the deck's whole home somewhere else in one variable. The escape
56
+ * hatch for a portable install, a test, or anybody who simply wants it
57
+ * elsewhere; it wins over every rule below. */
58
+ export const HOME_ENV = "CCDECK_HOME";
59
+
60
+ /** Where the deck kept everything before this, and where two of its files still
61
+ * live. Also the place a migration reads from. */
62
+ export function legacyDeckDir(env = process.env, home = homedir(), platform = process.platform) {
63
+ const { join } = platform === "win32" ? winPath : posixPath;
64
+ return join(claudeConfigDir(env, home), "agent-dag");
65
+ }
66
+
67
+ /**
68
+ * The deck's own durable state: its key, its pairings, the accounts it offers.
69
+ *
70
+ * CLAUDE_CONFIG_DIR IS HONOURED RATHER THAN OVERRIDDEN, and that is deliberate.
71
+ * Somebody who set it has asked for a separate Claude profile and has been
72
+ * getting a separate deck along with it for a year — separate identity,
73
+ * separate pairings, separate share list. Moving those people to one
74
+ * machine-wide deck would collapse two profiles into one on upgrade, which is
75
+ * exactly the kind of silent change this file exists to stop. They keep what
76
+ * they have; everybody else moves.
77
+ */
78
+ export function deckDataDir(platform = process.platform, env = process.env, home = homedir()) {
79
+ // THE TARGET PLATFORM'S SEPARATOR, NOT THE HOST'S — the same reason
80
+ // claudeCliCandidates does it, which is that the only way a Windows answer
81
+ // stays right is if it can be checked from a Mac. `node:path` joins with
82
+ // whatever the machine running the code uses, so a bare `join` here answers
83
+ // `\home\u\Library\...` on Windows for a question about macOS.
84
+ // `resolve` for the same reason as `join` just below it: it is host-bound
85
+ // too, so a posix path resolved on a Windows runner comes back with the
86
+ // runner's drive letter glued to the front of it.
87
+ const { join, resolve } = platform === "win32" ? winPath : posixPath;
88
+ const forced = env[HOME_ENV]?.trim();
89
+ if (forced) return resolve(forced);
90
+ if (env.CLAUDE_CONFIG_DIR?.trim()) return legacyDeckDir(env, home, platform);
91
+ if (platform === "darwin") return join(home, "Library", "Application Support", APP);
92
+ if (platform === "win32") return join(localAppData(env, home, platform), APP, "Data");
93
+ return join(env.XDG_DATA_HOME?.trim() || join(home, ".local", "share"), APP);
94
+ }
95
+
96
+ /**
97
+ * Where a log belongs, which is not where a preference belongs.
98
+ *
99
+ * On Linux that is the state directory rather than the data one: XDG separates
100
+ * "data the user would miss" from "state the program can rebuild", and an event
101
+ * log that rotates at 50 MB is the second. macOS and Windows have a named place
102
+ * for logs and this uses it.
103
+ */
104
+ export function deckLogDir(platform = process.platform, env = process.env, home = homedir()) {
105
+ const { join, resolve } = platform === "win32" ? winPath : posixPath;
106
+ const forced = env[HOME_ENV]?.trim();
107
+ if (forced) return resolve(forced);
108
+ if (env.CLAUDE_CONFIG_DIR?.trim()) return legacyDeckDir(env, home, platform);
109
+ if (platform === "darwin") return join(home, "Library", "Logs", APP);
110
+ if (platform === "win32") return join(localAppData(env, home, platform), APP, "Log");
111
+ return join(env.XDG_STATE_HOME?.trim() || join(home, ".local", "state"), APP);
112
+ }
113
+
114
+ /** %LOCALAPPDATA%, or the path it points at on every Windows since Vista. The
115
+ * fallback is not decoration: a service account or a stripped environment can
116
+ * reach this code with the variable unset, and a deck that throws there is a
117
+ * deck that will not start. */
118
+ function localAppData(env = process.env, home = homedir(), platform = process.platform) {
119
+ const { join } = platform === "win32" ? winPath : posixPath;
120
+ return env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local");
121
+ }
122
+
123
+ /**
124
+ * WHAT MOVES, and the two things that do not.
125
+ *
126
+ * `hook.js` stays: Claude Code reads it out of ~/.claude/agent-dag and would
127
+ * find nothing if it moved. The per-pid `NNNNN.json` files stay: a deck lists
128
+ * that directory to find the other decks on this machine, and a deck of the
129
+ * previous version reads it by name — move them and two versions running side
130
+ * by side stop seeing each other.
131
+ *
132
+ * `mode` is the file's, not the directory's, and 0600 on the preferences is
133
+ * load-bearing: it is the one file with a private key in it.
134
+ */
135
+ export const MOVED = Object.freeze([
136
+ Object.freeze({ name: "prefs.json", where: "data", mode: 0o600 }),
137
+ Object.freeze({ name: "events.jsonl", where: "log", mode: 0o644 }),
138
+ Object.freeze({ name: "events.jsonl.1", where: "log", mode: 0o644 }),
139
+ ]);
140
+
141
+ /**
142
+ * Bring a deck's files to their new home, once, and never at the cost of one.
143
+ *
144
+ * FOUR RULES, and each one is a way this could have lost somebody's key:
145
+ *
146
+ * A destination that already exists is never touched. That is what makes
147
+ * this safe to run at every boot, and it is also what stops a stale copy in
148
+ * the old directory from overwriting live state later.
149
+ *
150
+ * The copy lands on a temp file in the destination directory and is renamed
151
+ * into place. A copy interrupted half-way is a truncated prefs.json, and a
152
+ * truncated prefs.json is a deck with no key — which is the whole thing this
153
+ * is trying not to do.
154
+ *
155
+ * The original is LEFT WHERE IT IS. Somebody who downgrades finds what they
156
+ * had. It costs four kilobytes and a hundred megabytes of log that was
157
+ * already there, and it buys a move nobody can be hurt by.
158
+ *
159
+ * A failure is reported and swallowed. A read-only home, a full disk, a
160
+ * permission the installer never had — none of those is a reason for the
161
+ * deck not to start, and the caller falls back to reading the old path.
162
+ *
163
+ * Returns what it did, so the caller can say so once rather than every boot.
164
+ */
165
+ export async function migrateDeckFiles({
166
+ from,
167
+ data,
168
+ log,
169
+ fs,
170
+ /** Separate from `fs` because it is not `fs.rename`: see the import above. */
171
+ rename = renameWithRetry,
172
+ onError,
173
+ } = {}) {
174
+ const moved = [];
175
+ const failed = [];
176
+ for (const file of MOVED) {
177
+ const dir = file.where === "log" ? log : data;
178
+ const src = join(from, file.name);
179
+ const dst = join(dir, file.name);
180
+ if (src === dst) continue;
181
+ try {
182
+ // Both questions asked before anything is written: there is something to
183
+ // move, and there is nothing already there.
184
+ if (!(await exists(fs, src))) continue;
185
+ if (await exists(fs, dst)) continue;
186
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
187
+ const tmp = `${dst}.${process.pid}.migrating`;
188
+ await fs.copyFile(src, tmp);
189
+ await fs.chmod(tmp, file.mode).catch(() => {});
190
+ await rename(tmp, dst);
191
+ moved.push(file.name);
192
+ } catch (err) {
193
+ failed.push(file.name);
194
+ onError?.(file.name, err);
195
+ }
196
+ }
197
+ return { moved, failed };
198
+ }
199
+
200
+ async function exists(fs, path) {
201
+ try { await fs.stat(path); return true; } catch { return false; }
202
+ }
203
+
204
+ /** An hour. A temp file younger than this may belong to a write happening in
205
+ * another deck right now, and deleting one of those turns an atomic write into
206
+ * a lost one. Nothing legitimate is an hour old: the rename that retires a
207
+ * temp file happens within milliseconds of its creation. */
208
+ export const TEMP_STALE_MS = 60 * 60 * 1000;
209
+
210
+ /**
211
+ * The litter an atomic write leaves when the process is killed between the
212
+ * write and the rename.
213
+ *
214
+ * Ninety-seven of them were found in one directory, the oldest six days old:
215
+ * two waves, one from decks that crashed and one from decks killed by hand
216
+ * mid-write. Nothing has ever swept them, because the code that makes them is
217
+ * the code that is not running any more when they are made.
218
+ *
219
+ * So the sweep belongs at START, where a process that is alive can clean up
220
+ * after the ones that are not — and it is deliberately blind to WHICH deck made
221
+ * a file, because it cannot know: it goes by age instead, which is a fact about
222
+ * the file rather than a guess about a pid.
223
+ */
224
+ export async function sweepTempFiles({ dirs, fs, now = Date.now(), onError } = {}) {
225
+ let removed = 0;
226
+ for (const dir of dirs ?? []) {
227
+ let names;
228
+ try { names = await fs.readdir(dir); } catch { continue; }
229
+ for (const name of names) {
230
+ if (!name.endsWith(".tmp") && !name.endsWith(".migrating")) continue;
231
+ const path = join(dir, name);
232
+ try {
233
+ const st = await fs.stat(path);
234
+ if (now - st.mtimeMs < TEMP_STALE_MS) continue;
235
+ await fs.unlink(path);
236
+ removed++;
237
+ } catch (err) {
238
+ onError?.(path, err);
239
+ }
240
+ }
241
+ }
242
+ return removed;
243
+ }
@@ -31,15 +31,21 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
31
31
  // this call. See the note over the write below (#786).
32
32
  import { renameWithRetry } from "./installer.mjs";
33
33
  import { join } from "node:path";
34
- import { claudeConfigDir } from "./claude-dir.mjs";
34
+ import { deckDataDir } from "./deck-home.mjs";
35
35
 
36
36
  /** Set to "1" to keep the deck off the desktop whatever the stored preference
37
37
  * says. Same sheet of switches as AGENTS_DECK_NO_DOWNLOAD and
38
38
  * AGENTS_DECK_NO_INSTALL, and unchanged in meaning by this file. */
39
39
  export const OFF_ENV = "AGENTS_DECK_NO_NOTIFY";
40
40
 
41
- const prefsDir = (home = claudeConfigDir()) => join(home, "agent-dag");
42
- export const prefsPath = (home = claudeConfigDir()) => join(prefsDir(home), "prefs.json");
41
+ /* WHERE THIS FILE LIVES, AND WHY IT MOVED. It sat in ~/.claude/agent-dag — the
42
+ directory Claude Code owns — which meant a person clearing Claude Code's
43
+ configuration cleared this deck's private key, and every machine that had
44
+ pinned it had to be told to trust this one again. deck-home.mjs owns the new
45
+ answer and the reasons; what matters here is that the parameter is still a
46
+ DIRECTORY, so every caller that passes one is unchanged. */
47
+ const prefsDir = (home = deckDataDir()) => home;
48
+ export const prefsPath = (home = deckDataDir()) => join(prefsDir(home), "prefs.json");
43
49
 
44
50
  /**
45
51
  * Every preference the deck keeps, with the answer it gives when there is no
@@ -54,7 +60,21 @@ export const DEFAULTS = Object.freeze({
54
60
  notifications: true,
55
61
  // LAN sync, off until somebody turns it on. `passphrase` is the only secret
56
62
  // this file has ever held, which is why the write below now names a mode.
57
- lan: Object.freeze({ enabled: false, name: "", secret: "", shared: [], manual: [], trusted: [], port: 0 }),
63
+ lan: Object.freeze({
64
+ enabled: false, name: "", secret: "", shared: [], manual: [], trusted: [], port: 0,
65
+ // WHO PAIRS WITH WHOM, WITHOUT ANYBODY PRESSING ANYTHING. Both on, so two
66
+ // decks on one network find each other and pair themselves — which is what
67
+ // a person with three of their own machines wants and had to do by hand
68
+ // six times.
69
+ //
70
+ // Read this next to the two switches that gate it. `enabled` above is off,
71
+ // so nothing here happens until somebody deliberately puts this deck on the
72
+ // network; `shared` is empty, so a deck that pairs is offered nothing until
73
+ // somebody ticks a login. These say what happens AFTER both of those, and
74
+ // the dialog that turns the feature on prints them.
75
+ autoAsk: true,
76
+ autoAccept: true,
77
+ }),
58
78
  });
59
79
 
60
80
  /** The mode prefs.json is created with.
@@ -102,6 +122,16 @@ function normaliseLan(raw) {
102
122
  // A PREFERENCE, never a requirement: a port already taken falls through to
103
123
  // an OS-chosen one and this is rewritten. 0 means "none yet".
104
124
  port: Number.isInteger(src.port) && src.port > 0 && src.port < 65_536 ? src.port : 0,
125
+ // ASK FIRST. A deck heard on the broadcast is sent a pairing request without
126
+ // anybody pressing `ask` — the outbound half, which gives nothing away: the
127
+ // machine on the other end still answers it, by hand or by the switch below.
128
+ autoAsk: typeof src.autoAsk === "boolean" ? src.autoAsk : true,
129
+ // AND SAY YES. Every deck that finishes a handshake and is not already
130
+ // trusted is pinned without anybody being asked — the accept button pressed
131
+ // in advance, and it hands whoever asks a copy of every login this deck
132
+ // shares. Absent means the default above; only a real boolean overrides it,
133
+ // because a truthy string from a hand-edited file is not an answer.
134
+ autoAccept: typeof src.autoAccept === "boolean" ? src.autoAccept : true,
105
135
  };
106
136
  }
107
137
 
@@ -121,7 +151,7 @@ export function normalise(raw) {
121
151
 
122
152
  /** What is on disk, or the defaults. A corrupt or absent file is not an error
123
153
  * the user can act on mid-session, so it reads as "nothing chosen yet". */
124
- export async function readPrefs(home = claudeConfigDir(), deps = {}) {
154
+ export async function readPrefs(home = deckDataDir(), deps = {}) {
125
155
  const read = deps.readFile ?? readFile;
126
156
  try { return normalise(JSON.parse(await read(prefsPath(home), "utf8"))); }
127
157
  catch { return { ...DEFAULTS }; }
@@ -142,7 +172,7 @@ let _chain = Promise.resolve();
142
172
  * Serialized for the same reason the other store is: two pages toggling two
143
173
  * different switches in the same second must not lose one of them.
144
174
  */
145
- export async function writePrefs(patch, home = claudeConfigDir(), deps = {}) {
175
+ export async function writePrefs(patch, home = deckDataDir(), deps = {}) {
146
176
  const job = async () => {
147
177
  const mk = deps.mkdir ?? mkdir;
148
178
  const write = deps.writeFile ?? writeFile;
@@ -159,7 +189,7 @@ export async function writePrefs(patch, home = claudeConfigDir(), deps = {}) {
159
189
  // nothing has to send a secret it was never given.
160
190
  const merged = { ...prev, ...patch, lan: { ...prev.lan, ...(patch?.lan ?? {}) } };
161
191
  const next = normalise(merged);
162
- await mk(prefsDir(home), { recursive: true });
192
+ await mk(prefsDir(home), { recursive: true, mode: 0o700 });
163
193
  const tmp = `${prefsPath(home)}.${process.pid}.tmp`;
164
194
  await write(tmp, JSON.stringify(next, null, 2) + "\n", { encoding: "utf8", mode: PREFS_MODE });
165
195
  await mv(tmp, prefsPath(home));