ccdeck 3.29.11 → 3.31.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 (178) hide show
  1. package/README.md +4 -3
  2. package/bin/agent-dag.js +67 -118
  3. package/bin/cli/help.js +67 -0
  4. package/bin/cli/login-item.js +182 -0
  5. package/bin/cli/one-shot.js +254 -0
  6. package/bin/cli/package.js +39 -0
  7. package/bin/cli/pulse.js +98 -0
  8. package/bin/cli/restart.js +179 -0
  9. package/bin/cli/screen.js +166 -0
  10. package/bin/cli/second-start.js +120 -0
  11. package/bin/cli/startup.js +459 -0
  12. package/bin/cli/uninstall.js +169 -0
  13. package/bin/deck.js +112 -1559
  14. package/dist/web/assets/BrowserWatchModal-n63GjLhv.js +1 -0
  15. package/dist/web/assets/UsageHistoryModal-DEwBX4Fk.js +1 -0
  16. package/dist/web/assets/index-CZJf1Cpp.js +299 -0
  17. package/dist/web/assets/index-D4q1ElXU.css +1 -0
  18. package/dist/web/index.html +2 -2
  19. package/hook/hook.js +15 -14
  20. package/package.json +1 -1
  21. package/release-notes.json +40 -0
  22. package/src/server/account-projects.mjs +25 -11
  23. package/src/server/account-routes.mjs +254 -0
  24. package/src/server/activity.mjs +3 -3
  25. package/src/server/append-budget.mjs +185 -0
  26. package/src/server/append-failures.mjs +147 -0
  27. package/src/server/args.mjs +37 -5
  28. package/src/server/atomic-write.mjs +300 -0
  29. package/src/server/block-notify.mjs +0 -5
  30. package/src/server/boot-lock.mjs +1 -1
  31. package/src/server/brand.mjs +7 -6
  32. package/src/server/browser-react.mjs +10 -2
  33. package/src/server/browser-watch-decks.mjs +146 -0
  34. package/src/server/browser-watch-feed.mjs +69 -0
  35. package/src/server/browser-watch-log.mjs +173 -0
  36. package/src/server/browser-watch-mtime.mjs +18 -0
  37. package/src/server/browser-watch-relay.mjs +112 -0
  38. package/src/server/browser-watch-routes.mjs +172 -0
  39. package/src/server/browser-watch-seen.mjs +127 -0
  40. package/src/server/browser-watch-store.mjs +14 -143
  41. package/src/server/browser-watch.mjs +278 -626
  42. package/src/server/canonical-path.mjs +150 -0
  43. package/src/server/ccusage-failure.mjs +64 -0
  44. package/src/server/ccusage-install.mjs +459 -0
  45. package/src/server/ccusage-runner.mjs +271 -0
  46. package/src/server/ccusage.mjs +21 -756
  47. package/src/server/claude-accounts.mjs +220 -464
  48. package/src/server/claude-collector.mjs +189 -0
  49. package/src/server/claude-dir.mjs +7 -3
  50. package/src/server/claude-identity.mjs +5 -4
  51. package/src/server/claude-reset-credits.mjs +130 -0
  52. package/src/server/claude-verdicts.mjs +174 -0
  53. package/src/server/clear-route.mjs +191 -0
  54. package/src/server/codex-auth.mjs +4 -4
  55. package/src/server/codex-dir.mjs +52 -8
  56. package/src/server/codex-enrichment.mjs +310 -0
  57. package/src/server/codex-quota.mjs +5 -3
  58. package/src/server/codex-translate.mjs +407 -0
  59. package/src/server/codex-usage.mjs +8 -5
  60. package/src/server/codex-watch.mjs +496 -0
  61. package/src/server/cswap-auto-loop.mjs +342 -0
  62. package/src/server/cswap-auto-readers.mjs +303 -0
  63. package/src/server/cswap-auto.mjs +51 -609
  64. package/src/server/cswap-install.mjs +69 -265
  65. package/src/server/cswap-layout.mjs +242 -0
  66. package/src/server/deck-home.mjs +2 -2
  67. package/src/server/deck-prefs.mjs +58 -184
  68. package/src/server/deck-scope.mjs +52 -0
  69. package/src/server/desktop-update-routes.mjs +75 -0
  70. package/src/server/detach.mjs +78 -0
  71. package/src/server/discovery.mjs +298 -0
  72. package/src/server/event-log.mjs +328 -0
  73. package/src/server/event-pipeline.mjs +235 -0
  74. package/src/server/event-ring.mjs +214 -0
  75. package/src/server/event-routes.mjs +352 -0
  76. package/src/server/event-sink.mjs +26 -0
  77. package/src/server/exec-children.mjs +128 -0
  78. package/src/server/exec-not-found.mjs +213 -0
  79. package/src/server/exec-output.mjs +23 -0
  80. package/src/server/exec-spec.mjs +341 -0
  81. package/src/server/exec.mjs +74 -661
  82. package/src/server/health-route.mjs +42 -0
  83. package/src/server/hook-challenge.mjs +37 -0
  84. package/src/server/http-io.mjs +104 -0
  85. package/src/server/index.mjs +174 -7620
  86. package/src/server/install-layout.mjs +506 -0
  87. package/src/server/installer.mjs +79 -608
  88. package/src/server/jsonl-chunks.mjs +139 -0
  89. package/src/server/lan-accounts.mjs +27 -0
  90. package/src/server/lan-beacon.mjs +457 -0
  91. package/src/server/lan-call.mjs +239 -0
  92. package/src/server/lan-copies.mjs +270 -0
  93. package/src/server/lan-deck.mjs +583 -0
  94. package/src/server/lan-dials.mjs +161 -0
  95. package/src/server/lan-engine.mjs +475 -1024
  96. package/src/server/lan-hearing.mjs +49 -0
  97. package/src/server/lan-inbound.mjs +100 -0
  98. package/src/server/lan-invite-offer.mjs +77 -0
  99. package/src/server/lan-invite.mjs +207 -0
  100. package/src/server/lan-lines.mjs +73 -0
  101. package/src/server/lan-manifest.mjs +74 -0
  102. package/src/server/lan-reach.mjs +82 -69
  103. package/src/server/lan-requests.mjs +249 -0
  104. package/src/server/lan-round-record.mjs +45 -0
  105. package/src/server/lan-round-timer.mjs +62 -0
  106. package/src/server/lan-routes.mjs +143 -0
  107. package/src/server/lan-serve.mjs +112 -0
  108. package/src/server/lan-socket.mjs +243 -925
  109. package/src/server/lan-sync.mjs +58 -1026
  110. package/src/server/lan-tailnet-poll.mjs +57 -0
  111. package/src/server/lan-turns.mjs +80 -0
  112. package/src/server/lan-wire.mjs +620 -0
  113. package/src/server/lifecycle.mjs +421 -0
  114. package/src/server/listen.mjs +110 -0
  115. package/src/server/live-decks.mjs +138 -0
  116. package/src/server/load-average.mjs +32 -0
  117. package/src/server/log-election.mjs +235 -0
  118. package/src/server/log-replay.mjs +374 -0
  119. package/src/server/log-writer.mjs +72 -557
  120. package/src/server/login-service.mjs +3 -2
  121. package/src/server/macmon.mjs +1 -1
  122. package/src/server/memory-files.mjs +163 -0
  123. package/src/server/memory-metrics.mjs +178 -0
  124. package/src/server/metrics-history.mjs +94 -0
  125. package/src/server/metrics-run.mjs +100 -0
  126. package/src/server/music-routes.mjs +134 -0
  127. package/src/server/network-metrics.mjs +5 -3
  128. package/src/server/network-sampler.mjs +256 -0
  129. package/src/server/npm-latest.mjs +385 -0
  130. package/src/server/npm-upgrade.mjs +352 -0
  131. package/src/server/npx.mjs +41 -7
  132. package/src/server/output-watch.mjs +5 -4
  133. package/src/server/pinned-build.mjs +110 -0
  134. package/src/server/prefs-give-back.mjs +206 -0
  135. package/src/server/prefs-path.mjs +14 -0
  136. package/src/server/prefs-refusal.mjs +86 -0
  137. package/src/server/prefs-routes.mjs +99 -0
  138. package/src/server/prefs-set-aside.mjs +86 -0
  139. package/src/server/prefs-state.mjs +70 -0
  140. package/src/server/process-command.mjs +104 -0
  141. package/src/server/process-list.mjs +547 -0
  142. package/src/server/provider-status.mjs +307 -0
  143. package/src/server/pulse-line.mjs +132 -0
  144. package/src/server/purge-key.mjs +7 -7
  145. package/src/server/quota-cli.mjs +174 -0
  146. package/src/server/quota-oauth.mjs +190 -0
  147. package/src/server/quota-shape.mjs +229 -0
  148. package/src/server/quota-store-resets.mjs +206 -0
  149. package/src/server/quota.mjs +123 -509
  150. package/src/server/relay-guard.mjs +3 -3
  151. package/src/server/request-gates.mjs +441 -0
  152. package/src/server/restart-note.mjs +180 -0
  153. package/src/server/retire-sound-hook.mjs +3 -3
  154. package/src/server/ring-bounds.mjs +215 -0
  155. package/src/server/running-deck.mjs +1 -1
  156. package/src/server/running-version.mjs +24 -0
  157. package/src/server/self-update.mjs +39 -1371
  158. package/src/server/session-enrichment.mjs +622 -0
  159. package/src/server/session-read-gate.mjs +49 -0
  160. package/src/server/session-tracking.mjs +242 -0
  161. package/src/server/sse-clients.mjs +222 -0
  162. package/src/server/stale-discovery.mjs +30 -0
  163. package/src/server/static-serve.mjs +81 -0
  164. package/src/server/supervisor.mjs +73 -12
  165. package/src/server/system-metrics.mjs +169 -1935
  166. package/src/server/term.mjs +11 -249
  167. package/src/server/thermal-metrics.mjs +520 -0
  168. package/src/server/thermal-sampler.mjs +204 -0
  169. package/src/server/token-redact.mjs +123 -0
  170. package/src/server/transcript-gate.mjs +107 -0
  171. package/src/server/transcript-scan.mjs +563 -0
  172. package/src/server/usage-routes.mjs +108 -0
  173. package/src/server/uv-bootstrap.mjs +1 -1
  174. package/src/server/wordmark.mjs +127 -0
  175. package/dist/web/assets/BrowserWatchModal-CyAFTvAY.js +0 -1
  176. package/dist/web/assets/UsageHistoryModal-KJfli0oa.js +0 -1
  177. package/dist/web/assets/index-Ck2RAla3.css +0 -1
  178. package/dist/web/assets/index-jmrcEsCj.js +0 -298
@@ -45,7 +45,7 @@ import { claudeConfigDir } from "./claude-dir.mjs";
45
45
  // and the file this one renames into place is a private key. Losing that rename
46
46
  // to a virus scanner would be a deck with no identity, which is the one outcome
47
47
  // this whole module is written to prevent.
48
- import { renameWithRetry } from "./installer.mjs";
48
+ import { renameWithRetry } from "./atomic-write.mjs";
49
49
 
50
50
  /** The name this deck files itself under. No `-nodejs` suffix, which env-paths
51
51
  * adds to keep a script from colliding with a native app of the same name —
@@ -61,7 +61,7 @@ const HOME_ENV = "CCDECK_HOME";
61
61
  * live. Also the place a migration reads from. */
62
62
  export function legacyDeckDir(env = process.env, home = homedir(), platform = process.platform) {
63
63
  const { join } = platform === "win32" ? winPath : posixPath;
64
- return join(claudeConfigDir(env, home), "agent-dag");
64
+ return join(claudeConfigDir(env, home, platform), "agent-dag");
65
65
  }
66
66
 
67
67
  /**
@@ -33,14 +33,16 @@
33
33
  // write defaults over it, destroying the LAN key every paired machine had
34
34
  // pinned. A file that cannot be parsed is now moved aside and said out loud;
35
35
  // only a genuinely ABSENT file starts clean. See loadPrefs.
36
- import { chmod, mkdir, readFile, stat, unlink } from "node:fs/promises";
37
- // The rename, with the Windows retry ladder installer.mjs wrote for exactly
36
+ import { chmod, mkdir, readFile, unlink } from "node:fs/promises";
37
+ // The rename, with the Windows retry ladder atomic-write.mjs keeps for exactly
38
38
  // this call. See the note over the write below (#786). `stripBom` and
39
39
  // `createTemp` come from the same module for the reason its export block gives:
40
40
  // a rule spelled twice is a rule that drifts, and both of these are rules
41
41
  // settings.json and auth.json already follow on files with the same stakes.
42
- import { createTemp, renameWithRetry, stripBom } from "./installer.mjs";
43
- import { join } from "node:path";
42
+ import { createTemp, renameWithRetry, stripBom } from "./atomic-write.mjs";
43
+ import { prefsDir, prefsPath } from "./prefs-path.mjs";
44
+ import { NOT_JSON, prefsRefusalDetail, prefsWriteRefusal, unreadablePrefs } from "./prefs-refusal.mjs";
45
+ import { DENIED, foreignPath, quarantinePath, setAsideForeign } from "./prefs-set-aside.mjs";
44
46
  import { deckDataDir } from "./deck-home.mjs";
45
47
  import { PRODUCT } from "./brand.mjs";
46
48
 
@@ -49,14 +51,8 @@ import { PRODUCT } from "./brand.mjs";
49
51
  * AGENTS_DECK_NO_INSTALL, and unchanged in meaning by this file. */
50
52
  export const OFF_ENV = "AGENTS_DECK_NO_NOTIFY";
51
53
 
52
- /* WHERE THIS FILE LIVES, AND WHY IT MOVED. It sat in ~/.claude/agent-dag — the
53
- directory Claude Code owns — which meant a person clearing Claude Code's
54
- configuration cleared this deck's private key, and every machine that had
55
- pinned it had to be told to trust this one again. deck-home.mjs owns the new
56
- answer and the reasons; what matters here is that the parameter is still a
57
- DIRECTORY, so every caller that passes one is unchanged. */
58
- const prefsDir = (home = deckDataDir()) => home;
59
- export const prefsPath = (home = deckDataDir()) => join(prefsDir(home), "prefs.json");
54
+ // Where prefs.json lives, and why it moved: prefs-path.mjs.
55
+ export { prefsPath };
60
56
 
61
57
  /**
62
58
  * Every preference the deck keeps, with the answer it gives when there is no
@@ -179,6 +175,15 @@ function aliasesFrom(raw) {
179
175
  return out;
180
176
  }
181
177
 
178
+ /** A stored switch, or its default when the file does not hold a real boolean.
179
+ * Only `true` and `false` override a default: a truthy string or a 1 from a
180
+ * hand-edited file is not an answer anybody gave, and reading it as one would
181
+ * turn on a switch nobody pressed. One function, because the rule was spelled
182
+ * out at every switch and a copy is where the next one would have differed. */
183
+ export function flagOr(value, fallback) {
184
+ return typeof value === "boolean" ? value : fallback;
185
+ }
186
+
182
187
  /** One LAN section, coerced. Unknown keys dropped like everything else here,
183
188
  * and string lists forced to arrays of strings before anything compares them
184
189
  * with account keys, addresses or fingerprints. */
@@ -186,7 +191,7 @@ function normaliseLan(raw) {
186
191
  const src = raw && typeof raw === "object" ? raw : {};
187
192
  const strings = v => (Array.isArray(v) ? v.filter(x => typeof x === "string") : []);
188
193
  return {
189
- enabled: typeof src.enabled === "boolean" ? src.enabled : DEFAULTS.lan.enabled,
194
+ enabled: flagOr(src.enabled, DEFAULTS.lan.enabled),
190
195
  name: typeof src.name === "string" ? src.name : "",
191
196
  // THIS DECK'S PRIVATE KEY, and the only secret this file has ever held —
192
197
  // which is why the write below names a mode rather than taking the umask's.
@@ -224,7 +229,7 @@ function normaliseLan(raw) {
224
229
  // ASK FIRST. A deck heard on the broadcast is sent a pairing request without
225
230
  // anybody pressing `ask` — the outbound half, which gives nothing away: the
226
231
  // machine on the other end still answers it, by hand or by the switch below.
227
- autoAsk: typeof src.autoAsk === "boolean" ? src.autoAsk : true,
232
+ autoAsk: flagOr(src.autoAsk, DEFAULTS.lan.autoAsk),
228
233
  // AND SAY YES, once somebody has turned this on. Every deck that finishes a
229
234
  // handshake and is not already trusted is then pinned without anybody being
230
235
  // asked — the accept button pressed in advance, and it hands whoever asks a
@@ -232,16 +237,16 @@ function normaliseLan(raw) {
232
237
  // which is off since the feature itself started on; only a real boolean
233
238
  // overrides it, because a truthy string from a hand-edited file is not an
234
239
  // answer.
235
- autoAccept: typeof src.autoAccept === "boolean" ? src.autoAccept : DEFAULTS.lan.autoAccept,
240
+ autoAccept: flagOr(src.autoAccept, DEFAULTS.lan.autoAccept),
236
241
  pairingMode: src.pairingMode === "invite" ? "invite" : "automatic",
237
242
  // Whether paired decks are told which shared account this one is on. Absent
238
243
  // is on — the default above — and only a real boolean turns it off.
239
- shareActive: typeof src.shareActive === "boolean" ? src.shareActive : DEFAULTS.lan.shareActive,
244
+ shareActive: flagOr(src.shareActive, DEFAULTS.lan.shareActive),
240
245
  // The Tailscale switch and its two permissions. Only a real boolean
241
246
  // overrides a default, as above.
242
- tailscale: typeof src.tailscale === "boolean" ? src.tailscale : DEFAULTS.lan.tailscale,
243
- tailscaleAsk: typeof src.tailscaleAsk === "boolean" ? src.tailscaleAsk : DEFAULTS.lan.tailscaleAsk,
244
- tailscaleAccept: typeof src.tailscaleAccept === "boolean" ? src.tailscaleAccept : DEFAULTS.lan.tailscaleAccept,
247
+ tailscale: flagOr(src.tailscale, DEFAULTS.lan.tailscale),
248
+ tailscaleAsk: flagOr(src.tailscaleAsk, DEFAULTS.lan.tailscaleAsk),
249
+ tailscaleAccept: flagOr(src.tailscaleAccept, DEFAULTS.lan.tailscaleAccept),
245
250
  };
246
251
  }
247
252
 
@@ -254,159 +259,21 @@ function normaliseLan(raw) {
254
259
  export function normalise(raw) {
255
260
  const src = raw && typeof raw === "object" ? raw : {};
256
261
  return {
257
- notifications: typeof src.notifications === "boolean" ? src.notifications : DEFAULTS.notifications,
258
- tourSeen: typeof src.tourSeen === "boolean" ? src.tourSeen : DEFAULTS.tourSeen,
259
- autoUpdate: typeof src.autoUpdate === "boolean" ? src.autoUpdate : DEFAULTS.autoUpdate,
262
+ notifications: flagOr(src.notifications, DEFAULTS.notifications),
263
+ tourSeen: flagOr(src.tourSeen, DEFAULTS.tourSeen),
264
+ autoUpdate: flagOr(src.autoUpdate, DEFAULTS.autoUpdate),
260
265
  lan: normaliseLan(src.lan),
261
266
  };
262
267
  }
263
268
 
264
- /** Where the bytes of a prefs.json nothing could parse are put.
265
- *
266
- * `Date.now()` rather than an ISO timestamp because a colon is not a legal
267
- * filename character on Windows, and a quarantine that cannot be created on
268
- * the platform it is protecting is not a quarantine. */
269
- export const quarantinePath = (home = deckDataDir(), at = Date.now()) =>
270
- `${prefsPath(home)}.corrupt-${at}`;
271
-
272
- /** The refusal `writePrefs` throws rather than merge onto a base it knows is
273
- * not the user's. Shaped like installer.mjs's SETTINGS_UNREADABLE, which is
274
- * the same policy on the other file this deck rewrites: a file we cannot
275
- * reproduce is never treated as an empty one. */
276
- function unreadablePrefs(path, why, blocked) {
277
- const err = new Error(
278
- `${path} could not be read (${why}). Refusing to overwrite it — this deck's ` +
279
- `LAN key and its pairings are in there and cannot be re-derived. Fix the ` +
280
- `file or move it aside, then restart ${PRODUCT}.`,
281
- );
282
- err.code = "PREFS_UNREADABLE";
283
- err.prefsPath = path;
284
- err.why = why;
285
- err.blocked = blocked;
286
- return err;
287
- }
288
-
289
- /** The errors a filesystem raises for a place this user may not write — most
290
- * often a settings folder a `sudo` run left owned by root (#1335). */
291
- const NOT_WRITABLE = new Set(["EACCES", "EPERM", "EROFS"]);
292
-
293
- /**
294
- * Why a settings write failed, as a reason the panel can name — or null when
295
- * the failure is not one of the two a person can fix from outside the deck.
296
- *
297
- * A code, never the message: the message carries the absolute path, and a
298
- * route's body is readable by a DNS-rebound page (see sendInternalError). The
299
- * deck's log keeps the path for the person who has to go and look.
300
- */
301
- export function prefsWriteRefusal(err) {
302
- if (err?.code === "PREFS_UNREADABLE") return "prefs_unreadable";
303
- if (NOT_WRITABLE.has(err?.code)) return "prefs_not_writable";
304
- return null;
305
- }
306
-
307
- /** What blocked a prefs.json that is not JSON and could not be moved aside —
308
- * no errno for it, so a name of the deck's own in the same shape. */
309
- const NOT_JSON = Object.freeze({ code: "BADJSON", owner: "unknown", on: "file" });
310
-
311
- const OWNERS = new Set(["you", "other", "unknown"]);
312
- /** An errno as Node spells one, or the deck's BADJSON. Anything else — which
313
- * nothing here produces — is dropped rather than echoed to the page. */
314
- const ERRNO = /^E?[A-Z][A-Z0-9]{1,15}$/;
315
-
316
- /**
317
- * The same refusal, told precisely enough to act on from a screenshot (#1335).
318
- *
319
- * 3.29.3's panel could only guess — "the file may belong to another user" — and
320
- * the one Mac it was written for turned out not to be the case 3.29.4 repairs,
321
- * with nothing on the screen to say which case it was. So the page now gets the
322
- * errno, who owns what blocked the read (`you`, `other`, `unknown`), and whether
323
- * that is the `file` or its `folder`.
324
- *
325
- * Every field is picked from a closed set. Still no path and no uid: the route's
326
- * body is readable by a DNS-rebound page (see prefsWriteRefusal), and a path
327
- * names the user. Null when there is nothing precise to add.
328
- */
329
- export function prefsRefusalDetail(err) {
330
- const blocked = err?.code === "PREFS_UNREADABLE" ? err.blocked
331
- : NOT_WRITABLE.has(err?.code) ? { code: err.code, owner: "unknown", on: "folder" }
332
- : null;
333
- if (!blocked || typeof blocked !== "object") return null;
334
- return {
335
- code: ERRNO.test(blocked.code ?? "") ? blocked.code : "",
336
- owner: OWNERS.has(blocked.owner) ? blocked.owner : "unknown",
337
- on: blocked.on === "folder" ? "folder" : "file",
338
- };
339
- }
340
-
341
- /** The read errors that can mean "not yours" rather than "broken". Only these
342
- * send a read to setAsideForeign; EISDIR, EIO and EMFILE never do. */
343
- const DENIED = new Set(["EACCES", "EPERM"]);
344
-
345
- /** Where a prefs.json another user owns is put. Beside the file, like
346
- * quarantinePath, and stamped in milliseconds for the same Windows reason —
347
- * but a name of its own, because nothing is wrong with these bytes. */
348
- export const foreignPath = (home = deckDataDir(), at = Date.now()) =>
349
- `${prefsPath(home)}.foreign-${at}`;
269
+ // How a refusal to write is told — the error writePrefs throws, and the reason
270
+ // and the detail the route hands the panel: prefs-refusal.mjs.
271
+ export { prefsRefusalDetail, prefsWriteRefusal };
350
272
 
351
- /**
352
- * Move a prefs.json that ANOTHER USER OWNS out of the way, or say why not.
353
- *
354
- * #1335. A `sudo ccdeck` that kept the user's HOME writes a root-owned 0600
355
- * prefs.json into the user's own folder. From then on every
356
- * deck that user starts is refused the read, refuses every settings write on
357
- * top of it — rightly — and so can never keep anything: a new LAN identity on
358
- * every start, a share tick that says "Could not share that account", and no
359
- * way out from inside the deck.
360
- *
361
- * WHY MOVING IT IS SAFE HERE WHEN IT IS NOT FOR "unreadable". The refusal
362
- * exists so the deck never loses a key it could still have read. A file owned
363
- * by somebody else is not one this user's deck will ever read — waiting does
364
- * not help, and the deck is already running on defaults. A rename reads and
365
- * changes nothing: owner and mode travel with the file, so the key in it stays
366
- * private and whole, and `sudo chown` plus a move puts it back.
367
- *
368
- * Only when the owner is KNOWN to be somebody else. A file this user owns and
369
- * still cannot read (a mode bit, a lock, a sandbox) is left exactly where it
370
- * is, and so is anything on Windows, where there is no uid to compare and
371
- * EPERM is usually another program holding the file.
372
- *
373
- * Answers `{ moved, uid }`, `{ gone }` when the file vanished under it, or,
374
- * when it stayed, `{ why, owner, on }`: a sentence for the log, possibly empty,
375
- * and who owns what blocked the read — "you", "other" or "unknown" — and
376
- * whether that is the "file" or its "folder". The last two are what the panel
377
- * is told (see prefsRefusalDetail); the sentence, which has the path, is not.
378
- */
379
- async function setAsideForeign(path, home, deps) {
380
- const uid = deps.getuid ? deps.getuid() : process.getuid?.();
381
- if (uid === undefined) return { why: "", owner: "unknown", on: "file" };
382
- const look = deps.stat ?? stat;
383
- let st;
384
- try {
385
- st = await look(path);
386
- } catch (err) {
387
- if (err?.code === "ENOENT") return { gone: true };
388
- // The folder itself is out of reach, so nothing in it can be looked at, let
389
- // alone moved. Name the folder and its owner, which is what a person needs.
390
- const folder = prefsDir(home);
391
- const owner = await look(folder).then(s => s.uid, () => undefined);
392
- if (owner === undefined) return { why: "", owner: "unknown", on: "folder" };
393
- if (owner === uid) return { why: "", owner: "you", on: "folder" };
394
- return {
395
- why: ` The folder ${folder} belongs to another user (uid ${owner}), most likely from a run with sudo; sudo chown -R "$(id -un)" "${folder}" gives it back.`,
396
- owner: "other",
397
- on: "folder",
398
- };
399
- }
400
- if (st.uid === uid) return { why: "", owner: "you", on: "file" };
401
- const to = foreignPath(home);
402
- try {
403
- await (deps.rename ?? renameWithRetry)(path, to);
404
- } catch (err) {
405
- if (err?.code === "ENOENT") return { gone: true };
406
- return { why: ` It belongs to another user (uid ${st.uid}) and could not be moved aside either: ${err?.message ?? err}.`, owner: "other", on: "file" };
407
- }
408
- return { moved: to, uid: st.uid };
409
- }
273
+ // Moving a prefs.json nobody can use out of the way — the quarantine for bytes
274
+ // that are not JSON, and setAsideForeign for a file another user owns (#1335):
275
+ // prefs-set-aside.mjs.
276
+ export { foreignPath, quarantinePath };
410
277
 
411
278
  /**
412
279
  * Read prefs.json, and say WHICH of five things happened — because four of
@@ -517,7 +384,7 @@ let _chain = Promise.resolve();
517
384
  * and the write becomes the thing that destroys the key. So the two failures
518
385
  * that read as defaults are separated: a file that was moved aside is safe to
519
386
  * start clean over, and one still sitting there unread is not. See
520
- * installer.mjs's readSettingsForWrite, which is this policy on settings.json.
387
+ * atomic-write.mjs's readSettingsForWrite, which is this policy on settings.json.
521
388
  */
522
389
  export async function writePrefs(patch, home = deckDataDir(), deps = {}) {
523
390
  return queued(() => save(() => patch, home, deps));
@@ -538,11 +405,11 @@ export async function writePrefs(patch, home = deckDataDir(), deps = {}) {
538
405
  * already merges field by field, so two writes naming two different fields
539
406
  * cannot lose each other. What it cannot do is merge two writes of the SAME
540
407
  * field, and three callers compute one whole field — `lan.manual` twice and
541
- * `lan.aliases` once — out of `index.mjs`'s module-level `_prefs`, which is
542
- * refreshed only when a previous write resolves. Two of them in one turn both
543
- * read before either job runs, and the second patch is a whole array or a whole
544
- * map without the first's entry in it: both answer 200, both panels redraw, one
545
- * change is never written.
408
+ * `lan.aliases` once. Computed out of the in-memory copy prefs-state.mjs holds,
409
+ * which is refreshed only when a previous write resolves, two of them in one
410
+ * turn both read before either job runs, and the second patch is a whole array
411
+ * or a whole map without the first's entry in it: both answer 200, both panels
412
+ * redraw, one change is never written.
546
413
  *
547
414
  * Measured against this file in a temp directory, with those exact call shapes,
548
415
  * starting from `manual: ["10.0.0.1:5000"]`:
@@ -567,7 +434,7 @@ export async function updatePrefs(mutate, home = deckDataDir(), deps = {}) {
567
434
  * The two `mutate`s the three callers above hand updatePrefs, named so that the
568
435
  * callers and prefs-update-1041.test.ts run the same function. The suite used to
569
436
  * carry its own copy of each closure and test the copy (#1168), which stays
570
- * green however the one in index.mjs is edited.
437
+ * green however the one the server runs is edited.
571
438
  */
572
439
 
573
440
  /** `lan.manual` with `entry` on the end, or no change when it is there already.
@@ -605,6 +472,13 @@ export function withAlias(fp, name) {
605
472
  };
606
473
  }
607
474
 
475
+ /** loadPrefs, behind every write already queued (#1711): a re-read that lands
476
+ * between a write's read and its rename, or that answers with a copy older
477
+ * than a write that finished first, is a copy the next write merges onto. */
478
+ export function loadPrefsInTurn(home = deckDataDir(), deps = {}) {
479
+ return queued(() => loadPrefs(home, deps));
480
+ }
481
+
608
482
  /** One read-modify-write, behind every other one. Both entry points go through
609
483
  * here so there is a single queue and a single merge. */
610
484
  function queued(job) {
@@ -686,15 +560,6 @@ async function save(mutate, home, deps) {
686
560
  return next;
687
561
  }
688
562
 
689
- /**
690
- * May the deck raise a desktop notification right now?
691
- *
692
- * The env var wins. A machine launched with AGENTS_DECK_NO_NOTIFY=1 has been
693
- * told by whoever started it to stay off the desktop, and a page posting to
694
- * /api/prefs must not be able to overrule that — the person at the keyboard and
695
- * the person who wrote the launch script are not always the same person, and
696
- * only one of them is making a claim about the machine.
697
- */
698
563
  /**
699
564
  * The preferences as a PAGE may see them.
700
565
  *
@@ -720,8 +585,17 @@ export function publicPrefs(prefs) {
720
585
  };
721
586
  }
722
587
 
588
+ /**
589
+ * May the deck raise a desktop notification right now?
590
+ *
591
+ * The env var wins. A machine launched with AGENTS_DECK_NO_NOTIFY=1 has been
592
+ * told by whoever started it to stay off the desktop, and a page posting to
593
+ * /api/prefs must not be able to overrule that — the person at the keyboard and
594
+ * the person who wrote the launch script are not always the same person, and
595
+ * only one of them is making a claim about the machine.
596
+ */
723
597
  export function notificationsOn(prefs, env = process.env) {
724
- if (env[OFF_ENV] === "1") return false;
598
+ if (notificationsVetoed(env)) return false;
725
599
  return normalise(prefs).notifications;
726
600
  }
727
601
 
@@ -0,0 +1,52 @@
1
+ // Which tree this deck captures and which CLIs it watches — the two facts
2
+ // startServer is told at boot, and that the browser can learn only from
3
+ // /api/health.
4
+ //
5
+ // These lived in src/server/index.mjs as `_workspace` and `_providers`, set by
6
+ // startServer and read by it, by the boot replay it starts and by the health
7
+ // route. They are private here: set once through setDeckScope, read through
8
+ // deckWorkspace and deckProviders. The values are unchanged.
9
+
10
+ // The tree this deck was told to capture, "" when it captures the whole
11
+ // machine. Set by startServer and never changed afterwards.
12
+ //
13
+ // The browser had no way to learn it: the launcher prints the scope on stdout
14
+ // and the hook reads it out of the discovery file, but nothing put it in an
15
+ // HTTP response — so the deck's own empty state guessed, and guessed wrong for
16
+ // everyone who passed --workspace or --scope. Health is where it belongs: it
17
+ // already answers "which deck am I talking to", and it is the one route the UI
18
+ // can read before a single event has arrived.
19
+ let _workspace = "";
20
+
21
+ // Which CLIs this deck is actually watching. Decided in bin/deck.js — from
22
+ // whether each one is on the machine, and from --claude/--no-claude and
23
+ // --codex/--no-codex — and passed in here, because the browser had no way to
24
+ // learn it and so drew both sides of the UI on every machine.
25
+ //
26
+ // That is the whole of #402 and its mirror. A Codex-only machine got the
27
+ // accounts panel open on first run, telling it to sign into a CLI it does not
28
+ // have; a Claude-only machine permanently carried "Quota unavailable. / Run
29
+ // codex login to authenticate." Same missing fact, two directions.
30
+ //
31
+ // Defaults are both true, which is what an older deck effectively reported by
32
+ // saying nothing — and the browser reads a missing field as "could not say" and
33
+ // shows both, so the two agree.
34
+ let _providers = { claude: true, codex: true };
35
+
36
+ /** Both facts, set once at boot by startServer, normalised on the way in. */
37
+ export function setDeckScope({ workspace, claude, codex }) {
38
+ _workspace = typeof workspace === "string" ? workspace : "";
39
+ // `!== false` rather than a cast: a caller that omits the field means "yes",
40
+ // which is how every embedder that predates this option keeps working.
41
+ _providers = { claude: claude !== false, codex: codex !== false };
42
+ }
43
+
44
+ /** The tree this deck captures, "" when it captures the whole machine. */
45
+ export function deckWorkspace() {
46
+ return _workspace;
47
+ }
48
+
49
+ /** Which CLIs this deck watches, as `{ claude, codex }`. */
50
+ export function deckProviders() {
51
+ return _providers;
52
+ }
@@ -0,0 +1,75 @@
1
+ // The desktop app's update, as the window sees it: the state the app's updater
2
+ // reports, and the window's two answers to it relayed back to the app.
3
+ //
4
+ // These lived in src/server/index.mjs, after the tray notifier. The state is
5
+ // this module's; the frames go out through the subscriber sets and writeSse in
6
+ // sse-clients.mjs — to the pages for the state, and to the app's own tray
7
+ // connections for the window's answers. The handlers are unchanged.
8
+ import { readBody, send } from "./http-io.mjs";
9
+ import { presentsDeckToken } from "./request-gates.mjs";
10
+ import { sseClients, trayClients, writeSse } from "./sse-clients.mjs";
11
+
12
+ // The desktop app's updater lives in Electron, while the window is a page
13
+ // served by this deck. Keep only the small piece of state the page needs. A
14
+ // report is accepted only with the deck token; a browser may request an
15
+ // install, but Electron verifies the exact ready version again before acting.
16
+ const DESKTOP_UPDATE_STATUSES = new Set(["idle", "checking", "current", "downloading", "ready", "error"]);
17
+ let desktopUpdateState = { status: "idle", version: null };
18
+
19
+ function cleanDesktopUpdate(value) {
20
+ if (!value || typeof value !== "object" || !DESKTOP_UPDATE_STATUSES.has(value.status)) return null;
21
+ const version = typeof value.version === "string" && value.version.trim()
22
+ ? value.version.trim().slice(0, 80)
23
+ : null;
24
+ if (value.status === "ready" && !version) return null;
25
+ return { status: value.status, version };
26
+ }
27
+
28
+ function broadcastDesktopUpdate() {
29
+ const frame = `event: desktop-update\ndata: ${JSON.stringify(desktopUpdateState)}\n\n`;
30
+ for (const client of sseClients) {
31
+ if (!trayClients.has(client)) writeSse(client, frame);
32
+ }
33
+ }
34
+
35
+ function handleDesktopUpdateRead(_req, res) {
36
+ send(res, 200, desktopUpdateState);
37
+ }
38
+
39
+ async function handleDesktopUpdateReport(req, res) {
40
+ // Same-origin pages pass the generic mutation gate, but only the native app
41
+ // may claim what its updater has verified.
42
+ if (!presentsDeckToken(req.headers ?? {})) {
43
+ return send(res, 401, { ok: false, reason: "app_token_required" });
44
+ }
45
+ const body = await readBody(req, res).catch(() => null);
46
+ let value = null;
47
+ try { value = cleanDesktopUpdate(JSON.parse(body ?? "")); } catch { /* bad JSON */ }
48
+ if (!value) return send(res, 400, { ok: false, reason: "bad_update_state" });
49
+ desktopUpdateState = value;
50
+ broadcastDesktopUpdate();
51
+ send(res, 200, { ok: true });
52
+ }
53
+
54
+ // The window's two messages about that update, relayed to the app as frames
55
+ // on its tray stream: apply it (`desktop-update-restart`), or it has been shown
56
+ // (`desktop-update-seen`, so the app's own ready notice stands down, #1182).
57
+ // Both behind the same gates as /api/restart, and neither decides anything:
58
+ // this refuses only what cannot be current, and Electron checks the exact
59
+ // ready version again before acting on either.
60
+ async function handleDesktopUpdateRequest(req, res, event) {
61
+ const body = await readBody(req, res).catch(() => null);
62
+ let version = "";
63
+ try { version = String(JSON.parse(body ?? "")?.version ?? "").trim(); } catch { /* bad JSON */ }
64
+ if (!version || desktopUpdateState.status !== "ready" || desktopUpdateState.version !== version) {
65
+ return send(res, 409, { ok: false, reason: "update_not_ready" });
66
+ }
67
+ if (trayClients.size === 0) return send(res, 409, { ok: false, reason: "app_disconnected" });
68
+ const frame = `event: ${event}\ndata: ${JSON.stringify({ version })}\n\n`;
69
+ for (const client of trayClients) writeSse(client, frame);
70
+ send(res, 202, { ok: true });
71
+ }
72
+
73
+ // The four routes index.mjs dispatches to these. Listed rather than marked at
74
+ // each declaration, so the declarations read as they did where they came from.
75
+ export { handleDesktopUpdateRead, handleDesktopUpdateReport, handleDesktopUpdateRequest };
@@ -47,6 +47,7 @@
47
47
  import { spawn } from "node:child_process";
48
48
  import { closeSync, mkdirSync, openSync, readSync, statSync } from "node:fs";
49
49
  import { join } from "node:path";
50
+ import { isOneShot } from "./args.mjs";
50
51
 
51
52
  /** The marker that stops the child doing this again. A fork bomb is the only
52
53
  * way this file can fail catastrophically, so the guard is one variable with
@@ -90,6 +91,61 @@ export function detachEnv({ isTTY = false, profile = "none", columns = 0 } = {})
90
91
  return out;
91
92
  }
92
93
 
94
+ /**
95
+ * Does this start go to the background?
96
+ *
97
+ * bin/agent-dag.js asks, and a yes sends it to detachAndWatch. Every no is a
98
+ * way of staying put:
99
+ *
100
+ * ALREADY DETACHED — we ARE the background copy. Carry on as the supervisor
101
+ * always has: spawn the worker, supervise it, never come back here.
102
+ *
103
+ * LEASHED — a parent is already holding our lifecycle, over an IPC channel,
104
+ * and is waiting on our exit code. Running away from it into our own process
105
+ * group is precisely the wrong answer to being supervised.
106
+ *
107
+ * `--foreground` — the way to ask for the old behaviour out loud. Every
108
+ * version before this one held the terminal, and something out there depends
109
+ * on that: a wrapper script, a CI step, a supervisor of somebody else's that
110
+ * starts `ccdeck` and waits on it, a `ccdeck && open …`. Handing all of those
111
+ * an immediate exit and no way to say otherwise would be a breaking change
112
+ * with no escape hatch — and DETACHED_ENV is an internal marker, not
113
+ * something to tell a user to export.
114
+ *
115
+ * A ONE-SHOT — `--version`, `--stop`, `--status`, `--help`, `--uninstall`.
116
+ * Those answer and leave, and a one-shot that detached would print its answer
117
+ * into a log file and hand the terminal back empty. They run in the
118
+ * foreground exactly as they always have.
119
+ *
120
+ * Anything else is a start, and a start goes to the background.
121
+ */
122
+ export function shouldDetach({ detached = false, leashed = false, flags = {} } = {}) {
123
+ return !detached && !leashed && flags.foreground !== true && !isOneShot(flags);
124
+ }
125
+
126
+ /**
127
+ * How the worker (and an upgrade's replacement) is started when this
128
+ * supervisor has no console of its own — detached from a terminal, or run by
129
+ * the desktop app.
130
+ *
131
+ * ON WINDOWS, A CONSOLE PROGRAM STARTED BY A PROCESS WITH NO CONSOLE IS GIVEN A
132
+ * NEW ONE, and with Windows Terminal as the default terminal that new console
133
+ * is a window. Measured on a Windows 10 box: `npx ccdeck` put a second
134
+ * Windows Terminal window on screen, hosting the deck's own node.exe, after the
135
+ * one the user typed into. `windowsHide` alone does not stop it: with stdio
136
+ * inherited, libuv only asks for a hidden window, which Windows Terminal's
137
+ * default-terminal handoff does not honour. DETACHED_PROCESS gives the child
138
+ * no console at all, so there is nothing to show.
139
+ *
140
+ * Only when detached: a supervisor in the user's own terminal shares that
141
+ * console with its worker, and Ctrl+C has to reach both.
142
+ */
143
+ export function noConsoleOptions({ detached = false, platform = process.platform } = {}) {
144
+ return detached && platform === "win32"
145
+ ? { detached: true, windowsHide: true }
146
+ : {};
147
+ }
148
+
93
149
  /**
94
150
  * The command that ends it, spelled the way this user would have to type it.
95
151
  *
@@ -103,6 +159,28 @@ export function stopCommand({ npx = false, invokedAs = null, product = "ccdeck"
103
159
  return npx ? `npx ${name} --stop` : `${name} --stop`;
104
160
  }
105
161
 
162
+ /**
163
+ * What the terminal is left with once the deck has gone to the background: the
164
+ * command that ends it, and — for an npx run — the one thing it cannot have.
165
+ *
166
+ * THE ONE THING AN NPX RUN CANNOT HAVE, said where it is missing. A login item
167
+ * must name a path that will still be there tomorrow, and npx runs out of a
168
+ * cache npm deletes whenever it likes — so an npx deck runs in the background
169
+ * and cannot come back after a reboot. One line, no disk written, offered
170
+ * rather than done: `npx` means "run without installing", and a tool that
171
+ * installs itself anyway is the one people uninstall.
172
+ *
173
+ * `tone` is a palette and `g` a glyph tier, both from term.mjs, so a pipe gets
174
+ * no escapes and a console without the em dash gets its stand-in.
175
+ */
176
+ export function backgroundNote({ npx = false, invokedAs = null, product = "ccdeck", tone, g }) {
177
+ const stop = stopCommand({ npx, invokedAs, product });
178
+ const offer = npx
179
+ ? ` ${tone.muted}\`${invokedAs ?? product} --install\` also starts it at login${tone.reset}\n`
180
+ : "";
181
+ return ` ${tone.muted}${g.dash} running in the background ${g.bullet} \`${stop}\` ends it${tone.reset}\n${offer}\n`;
182
+ }
183
+
106
184
  /**
107
185
  * Copy a growing file to a stream, from an offset, until told to stop.
108
186
  *