ccdeck 3.36.9 → 3.37.1

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 (75) hide show
  1. package/README.md +15 -7
  2. package/bin/agent-dag.js +19 -6
  3. package/bin/cli/help.js +1 -0
  4. package/bin/cli/login-item.js +44 -20
  5. package/bin/cli/one-shot.js +15 -5
  6. package/bin/cli/screen.js +10 -5
  7. package/bin/cli/startup.js +6 -2
  8. package/bin/cli/uninstall.js +41 -5
  9. package/bin/deck.js +50 -8
  10. package/dist/web/assets/BrowserWatchModal-njN0zg93.js +1 -0
  11. package/dist/web/assets/UsageHistoryModal-DYSZZZEz.js +1 -0
  12. package/dist/web/assets/index-B0SOa-XB.css +1 -0
  13. package/dist/web/assets/index-DDxnWaTa.js +306 -0
  14. package/dist/web/index.html +2 -2
  15. package/hook/hook.js +80 -9
  16. package/package.json +1 -1
  17. package/release-notes.json +8 -1
  18. package/src/server/account-origins.mjs +8 -1
  19. package/src/server/account-projects.mjs +34 -9
  20. package/src/server/activation.mjs +39 -10
  21. package/src/server/app-host.mjs +7 -2
  22. package/src/server/args.mjs +3 -0
  23. package/src/server/browser-watch-routes.mjs +23 -16
  24. package/src/server/browser-watch.mjs +9 -3
  25. package/src/server/claude-accounts.mjs +27 -7
  26. package/src/server/claude-verdicts.mjs +12 -1
  27. package/src/server/cli-versions.mjs +6 -4
  28. package/src/server/codex-auth.mjs +29 -0
  29. package/src/server/codex-quota.mjs +57 -7
  30. package/src/server/deck-home.mjs +23 -1
  31. package/src/server/deck-prefs.mjs +70 -7
  32. package/src/server/depth-facts.mjs +178 -0
  33. package/src/server/detach.mjs +61 -33
  34. package/src/server/event-log.mjs +15 -0
  35. package/src/server/event-pipeline.mjs +5 -2
  36. package/src/server/event-routes.mjs +22 -0
  37. package/src/server/feedback-form.mjs +36 -13
  38. package/src/server/index.mjs +6 -2
  39. package/src/server/install-layout.mjs +35 -0
  40. package/src/server/install-ref.mjs +39 -0
  41. package/src/server/installer.mjs +20 -2
  42. package/src/server/lan-beacon.mjs +18 -3
  43. package/src/server/lan-deck.mjs +7 -1
  44. package/src/server/lan-engine.mjs +40 -21
  45. package/src/server/lan-manifest.mjs +9 -6
  46. package/src/server/lan-requests.mjs +18 -2
  47. package/src/server/lan-serve.mjs +4 -2
  48. package/src/server/lan-sync.mjs +97 -6
  49. package/src/server/lifecycle.mjs +18 -2
  50. package/src/server/log-replay.mjs +9 -3
  51. package/src/server/login-service.mjs +95 -12
  52. package/src/server/npm-upgrade.mjs +12 -4
  53. package/src/server/pinned-build.mjs +3 -0
  54. package/src/server/prefs-routes.mjs +5 -8
  55. package/src/server/prefs-state.mjs +9 -1
  56. package/src/server/quota-oauth.mjs +88 -13
  57. package/src/server/quota-shape.mjs +28 -0
  58. package/src/server/quota.mjs +68 -17
  59. package/src/server/rating.mjs +87 -0
  60. package/src/server/reports-routes.mjs +21 -2
  61. package/src/server/reports.mjs +672 -64
  62. package/src/server/ring-bounds.mjs +11 -0
  63. package/src/server/running-deck.mjs +48 -0
  64. package/src/server/self-update.mjs +1 -1
  65. package/src/server/session-enrichment.mjs +14 -6
  66. package/src/server/stable-node.mjs +68 -0
  67. package/src/server/static-serve.mjs +5 -0
  68. package/src/server/supervisor.mjs +21 -15
  69. package/src/server/tailscale.mjs +8 -4
  70. package/src/server/transcript-scan.mjs +7 -4
  71. package/src/server/usage-day.mjs +76 -11
  72. package/dist/web/assets/BrowserWatchModal-BCrDvuug.js +0 -1
  73. package/dist/web/assets/UsageHistoryModal-BtRCuo33.js +0 -1
  74. package/dist/web/assets/index-BkXTN4dZ.css +0 -1
  75. package/dist/web/assets/index-tVBMXvDv.js +0 -306
@@ -101,8 +101,11 @@
101
101
  // downloads a week and four dependencies of its own; running unmaintained
102
102
  // cryptography to protect live credentials is worse than the plain construction
103
103
  // in lan-wire.mjs.
104
- import { createHash } from "node:crypto";
104
+ import { execFileSync } from "node:child_process";
105
+ import { createHash, createHmac } from "node:crypto";
106
+ import { readFileSync } from "node:fs";
105
107
  import os from "node:os";
108
+ import { win32 as winPath } from "node:path";
106
109
  // What two decks say once one has dialled the other — the keys, the proofs and
107
110
  // the seals — is lan-wire.mjs's, and so is PROTOCOL, which the beacon below
108
111
  // carries: every label there is derived under it, and that file cannot import
@@ -211,9 +214,74 @@ export function cleanName(raw, fallback = "unnamed deck") {
211
214
  * The home directory is in it so that a copied `~/.claude` — which is how two
212
215
  * real machines end up holding one key, and the reason `id-clash` exists —
213
216
  * still reads as two machines when the hostnames differ, which they do.
217
+ *
218
+ * AND KEYED, because a plain hash of those two hid neither. The hostname rides
219
+ * in the same packet as the deck's name on a deck nobody renamed, which left
220
+ * only the home directory to guess — and /Users/<name>, /home/<name> and
221
+ * C:\Users\<name> over a list of first names is a guess that lands. The key is
222
+ * a secret this machine already has and never sends (see machineSecret): the
223
+ * same for every deck on it, so they still agree without writing anything, and
224
+ * another machine's own, so a copied config reads as another machine even under
225
+ * the same hostname. A machine with no such secret to read keys with nothing
226
+ * and sends the plain hash every deck sent before (see formerHostId).
227
+ */
228
+ export function hostId({ hostname = os.hostname(), home = os.homedir(), key = machineKey() } = {}) {
229
+ const input = `${machineName(hostname)}\u0000${home}`;
230
+ if (!key) return createHash("sha256").update(input).digest("hex").slice(0, 12);
231
+ return createHmac("sha256", key).update(`ccdeck host id\u0000${input}`).digest("hex").slice(0, 12);
232
+ }
233
+
234
+ /**
235
+ * This machine's id as a deck from before the key sends it — the plain hash.
236
+ *
237
+ * Never sent by this one. Read so that a beacon carrying it is known for what
238
+ * it is: an older deck on this same computer, which a newer one must not take
239
+ * for a stranger to pair with or for a copy of its key (see beaconVerdict). It
240
+ * names this machine and no other, because its inputs are this machine's.
241
+ */
242
+ export function formerHostId({ hostname = os.hostname(), home = os.homedir() } = {}) {
243
+ return hostId({ hostname, home, key: "" });
244
+ }
245
+
246
+ /**
247
+ * The secret hostId is keyed with: an id the operating system keeps for this
248
+ * machine, readable by any user on it and never sent anywhere by this deck —
249
+ * systemd's (or D-Bus's) machine id on Linux, the platform UUID on a Mac, the
250
+ * machine GUID on Windows. Null where none can be read: a container without a
251
+ * machine id, a tool that would not run.
252
+ *
253
+ * Only ever the key of an HMAC, never the message and never on the wire, which
254
+ * is how systemd asks for its machine id to be used by an application.
214
255
  */
215
- export function hostId({ hostname = os.hostname(), home = os.homedir() } = {}) {
216
- return createHash("sha256").update(`${machineName(hostname)}\u0000${home}`).digest("hex").slice(0, 12);
256
+ export function machineSecret({ platform = process.platform, env = process.env, readFile = readFileSync, run = execFileSync } = {}) {
257
+ const tool = (file, args, pattern) => {
258
+ try {
259
+ const out = run(file, args, { encoding: "utf8", timeout: 3_000, windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
260
+ return pattern.exec(String(out ?? ""))?.[1] ?? null;
261
+ } catch { return null; }
262
+ };
263
+ if (platform === "darwin") {
264
+ return tool("/usr/sbin/ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], /"IOPlatformUUID"\s*=\s*"([0-9A-Fa-f-]{16,})"/);
265
+ }
266
+ if (platform === "win32") {
267
+ const reg = winPath.join(env.SystemRoot || env.windir || "C:\\Windows", "System32", "reg.exe");
268
+ return tool(reg, ["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"], /MachineGuid\s+REG_SZ\s+([0-9A-Fa-f-]{16,})/);
269
+ }
270
+ for (const file of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
271
+ try {
272
+ const id = String(readFile(file, "utf8") ?? "").trim();
273
+ if (/^[0-9A-Fa-f-]{16,}$/.test(id)) return id;
274
+ } catch { /* the next place, or none */ }
275
+ }
276
+ return null;
277
+ }
278
+
279
+ /** machineSecret, read once per process: it cannot change while one runs, and
280
+ * on a Mac or Windows it is a process to start. Empty when there is none. */
281
+ let machineKeyRead = null;
282
+ function machineKey() {
283
+ machineKeyRead ??= machineSecret() ?? "";
284
+ return machineKeyRead;
217
285
  }
218
286
 
219
287
  /**
@@ -325,15 +393,18 @@ export function readBeacon(buf, { maxBytes = MAX_BEACON_BYTES } = {}) {
325
393
  * broadcast on every interface it owns, and filtering by address would need a
326
394
  * list of them that changes when a VPN comes up.
327
395
  */
328
- export function beaconVerdict(beacon, { selfFp, selfInstance, selfHost, trusted } = {}) {
396
+ export function beaconVerdict(beacon, { selfFp, selfInstance, selfHost, formerHost, trusted } = {}) {
329
397
  if (!beacon) return "unreadable";
398
+ // THIS COMPUTER'S ID, as this deck sends it or as a deck from before the key
399
+ // did (see formerHostId) — both name this machine and no other.
400
+ const here = !!selfHost && !!beacon.host && (beacon.host === selfHost || (!!formerHost && beacon.host === formerHost));
330
401
  // ANOTHER DECK ON THIS COMPUTER. Not this process — a different key, honestly
331
402
  // its own — and still nothing to pair with: both read one claude-swap store,
332
403
  // so neither holds a login the other could heal. It used to be filtered by
333
404
  // the address the packet came from, which is true and needs the list of this
334
405
  // machine's addresses to be current; the machine's own id needs nothing and
335
406
  // is the same answer.
336
- if (selfHost && beacon.host && beacon.host === selfHost && beacon.fp !== selfFp) return "self";
407
+ if (here && beacon.fp !== selfFp) return "self";
337
408
  if (beacon.fp === selfFp) {
338
409
  // OUR OWN NAME, FROM SOMEBODY ELSE'S PROCESS. Two decks sharing a config
339
410
  // directory hold the same key — and so does the second machine when
@@ -363,7 +434,7 @@ export function beaconVerdict(beacon, { selfFp, selfInstance, selfHost, trusted
363
434
  // the OTHER one: a `~/.claude` copied to a second machine, where two real
364
435
  // decks would otherwise be permanently invisible to each other.
365
436
  if (selfInstance && beacon.instance !== selfInstance) {
366
- return selfHost && beacon.host === selfHost ? "self" : "id-clash";
437
+ return here ? "self" : "id-clash";
367
438
  }
368
439
  return "self";
369
440
  }
@@ -430,6 +501,26 @@ export function dropTrusted(trusted, fp) {
430
501
  return (Array.isArray(trusted) ? trusted : []).filter(t => t && t.fp !== fp);
431
502
  }
432
503
 
504
+ /**
505
+ * The accounts this deck offers the paired deck `fp`: everything ticked here,
506
+ * less what an arrival ticked when that deck is one the accept switch paired.
507
+ *
508
+ * An account that arrives from a deck somebody here chose is ticked on arrival
509
+ * (#1188), and `onward` keeps which ticks those were. The reasoning for that
510
+ * tick is that the group already has the login, and the group is the decks a
511
+ * person here chose. A deck the switch paired (`auto`, see addTrusted) is
512
+ * offered what a person ticked and nothing else, which is what keeps the
513
+ * switch's promise: a deck that pairs is offered nothing until somebody ticks
514
+ * a login here. A person who unticks and ticks the account again takes the
515
+ * mark off, and from then on it is offered like any other tick.
516
+ */
517
+ export function sharedWith(cfg, fp) {
518
+ const shared = Array.isArray(cfg?.shared) ? cfg.shared : [];
519
+ if (trustedPeer(cfg?.trusted, fp)?.auto !== true) return shared;
520
+ const onward = Array.isArray(cfg?.onward) ? cfg.onward : [];
521
+ return shared.filter(key => !onward.includes(key));
522
+ }
523
+
433
524
  /**
434
525
  * The decks worth offering to pair with, out of everything that has ever been
435
526
  * heard.
@@ -47,6 +47,11 @@ let _onRestart = null;
47
47
  // directly, or an embedder — and /api/shutdown then answers 501 rather than
48
48
  // pretending.
49
49
  let _onStop = null;
50
+ // Told when the deck starts updating itself — the Upgrade press, the npx
51
+ // relaunch onto the latest, the away-update — so the reporter can write down
52
+ // that the next version came from the deck (index.mjs hands it in). Absent,
53
+ // nothing is told.
54
+ let _onSelfUpdate = null;
50
55
  // A stop is in flight. Two sockets asking at once must not both start one, and
51
56
  // the response's 'finish' and 'close' can both fire for the same request.
52
57
  let _stopping = false;
@@ -66,9 +71,10 @@ let _deckReady = false;
66
71
  * and the stop it may hand to, whether a restart would have a log to replay,
67
72
  * and the two in-flight flags and the ready flag, all back to a fresh boot's.
68
73
  */
69
- function armLifecycle({ onRestart, onStop, persist }) {
74
+ function armLifecycle({ onRestart, onStop, persist, onSelfUpdate = null }) {
70
75
  _onRestart = typeof onRestart === "function" ? onRestart : null;
71
76
  _onStop = typeof onStop === "function" ? onStop : null;
77
+ _onSelfUpdate = typeof onSelfUpdate === "function" ? onSelfUpdate : null;
72
78
  _stopping = false;
73
79
  _canRestart = _onRestart != null && persist != null;
74
80
  // A new listener is a new boot, whatever a previous one had got as far as
@@ -155,9 +161,16 @@ async function handleUpgrade(_req, res) {
155
161
  // lands in the first moments of a boot, or on a server nothing marked ready.
156
162
  await pinRunningBuild();
157
163
  const out = startUpgrade({ pkgRoot: PKG_ROOT });
164
+ if (out.ok) selfUpdateStarted();
158
165
  send(res, out.ok ? 200 : 409, out);
159
166
  }
160
167
 
168
+ /** The deck is updating itself: say so to whoever armed it (the reporter's
169
+ * note, so the next version's "update" can tell). Never throws. */
170
+ function selfUpdateStarted() {
171
+ try { void Promise.resolve(_onSelfUpdate?.()).catch(() => {}); } catch { /* a note, nothing more */ }
172
+ }
173
+
161
174
  // Restart is a two-party act: this half answers before it stops listening, so
162
175
  // the caller learns it was accepted rather than losing the socket mid-reply.
163
176
  //
@@ -214,6 +227,7 @@ async function handleRestart(req, res) {
214
227
  }
215
228
  }
216
229
  _restarting = true;
230
+ if (mode === "npx") selfUpdateStarted();
217
231
  // Accepted either way — the ask is good and the launcher holds on to it — but
218
232
  // the two are not the same event and answering 200 to both would be this
219
233
  // window's second untruth rather than its first. Between this listener
@@ -394,13 +408,15 @@ async function awayUpdateTick() {
394
408
  // Pinned before npm is spawned, never after it — see pinned-build.mjs. On a
395
409
  // deck bin/deck.js has marked ready this is a promise that settled long ago.
396
410
  await pinRunningBuild();
397
- su.startUpgrade({ pkgRoot: PKG_ROOT });
411
+ const started = su.startUpgrade({ pkgRoot: PKG_ROOT });
412
+ if (started?.ok) selfUpdateStarted();
398
413
  return step.act;
399
414
  }
400
415
  // handleRestart's own check for the npx path, for the same reason: the mode
401
416
  // comes from the install on disk, and a spec it cannot name is no relaunch.
402
417
  if (step.act === "npx" && !su.npxRestartSpec(PKG_ROOT)) return null;
403
418
  _restarting = true;
419
+ if (step.act === "npx") selfUpdateStarted();
404
420
  handOffRestart(step.act === "npx" ? "npx" : null);
405
421
  return step.act;
406
422
  }
@@ -11,7 +11,7 @@ import { existsSync } from "node:fs";
11
11
  import { PRODUCT } from "./brand.mjs";
12
12
  import { codexCwdInWorkspace } from "./log-election.mjs";
13
13
  import { linesFromEnd, linesFromStart } from "./log-tail.mjs";
14
- import { ENVELOPE_CHARS, MAX_BUFFER, MAX_BUFFER_CHARS, MAX_RING_ENTRIES, isEnrichment, payloadChars } from "./ring-bounds.mjs";
14
+ import { ENVELOPE_CHARS, MAX_BUFFER, MAX_BUFFER_CHARS, MAX_RING_ENTRIES, isEnrichment, isReservedEventName, payloadChars } from "./ring-bounds.mjs";
15
15
  // event-pipeline.mjs's pushEvent, reached without importing it — see
16
16
  // event-sink.mjs.
17
17
  import { pushEvent } from "./event-sink.mjs";
@@ -60,7 +60,8 @@ import { pushEvent } from "./event-sink.mjs";
60
60
  * everything before it. It has not been written to the log since #698, but
61
61
  * logs the decks before that wrote still carry it, and dropping it on a
62
62
  * scoped deck would replay the state a user had explicitly cleared, so it
63
- * is always admitted.
63
+ * is always admitted — when the server recorded it, which replayLog
64
+ * checks before asking this (see `usable` there).
64
65
  * * a payload with no cwd and no session the map has seen — refused on a
65
66
  * scoped deck, which is exactly what `capturesSession` decides live for a
66
67
  * session that never said where it runs.
@@ -324,7 +325,12 @@ export async function replayLog(filePath, workspace = "", {
324
325
  return null;
325
326
  }
326
327
  };
327
- const usable = (evt) => evt && typeof evt === "object" && evt.payload;
328
+ // A control marker counts only when the server itself recorded it: the one
329
+ // writer of `__clear` has always stamped it `internal`, and since #698 it is
330
+ // not written at all. A line under a reserved name from anywhere else is not
331
+ // the deck's instruction, and replaying it would hide everything before it.
332
+ const usable = (evt) => evt && typeof evt === "object" && evt.payload
333
+ && (evt.source === "internal" || !isReservedEventName(evt.payload.hook_event_name));
328
334
 
329
335
  let count = 0;
330
336
  if (admits.orderDependent) {
@@ -32,6 +32,7 @@
32
32
  // tested. Two policies over one process is how `ccdeck --stop` becomes a
33
33
  // suggestion the machine overrules a second later.
34
34
  import { inApp } from "./app-host.mjs";
35
+ import { stableNodePath } from "./stable-node.mjs";
35
36
  import { spawnSync } from "node:child_process";
36
37
  import { homedir } from "node:os";
37
38
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -123,6 +124,10 @@ ${vars}
123
124
  `;
124
125
  }
125
126
 
127
+ /** A value as systemd will read it back: `%` starts a specifier anywhere in a
128
+ * unit, and `%%` is the literal. */
129
+ const sdEscape = (s) => String(s).replace(/%/g, "%%");
130
+
126
131
  /**
127
132
  * The systemd --user unit.
128
133
  *
@@ -130,7 +135,19 @@ ${vars}
130
135
  * still receiving updates. No Restart=, for the reason in the header.
131
136
  */
132
137
  export function unitFor({ execPath, script, logPath, args = [], env = {}, product = "ccdeck" } = {}) {
133
- const cmd = [execPath, script, ...args].map(a => (/\s/.test(a) ? `"${a}"` : a)).join(" ");
138
+ // EVERY VALUE, not only the Environment= lines below. systemd expands `%`
139
+ // in StandardOutput=/StandardError= and in ExecStart as well, and `$NAME` in
140
+ // ExecStart; those were written raw, so a deck home like the
141
+ // `/home/ana/100%backup` below had its log path rewritten — systemd 259
142
+ // drops the setting with "Failed to resolve unit specifiers … ignoring" for
143
+ // an unknown one and substitutes a known one (`%b`, the boot ID) silently —
144
+ // and the same `%` in an install path made ExecStart a fatal error.
145
+ // An argument with whitespace, a quote or a backslash is quoted, with the
146
+ // C escapes systemd's word splitting reads inside the quotes.
147
+ const cmd = [execPath, script, ...args].map(a => {
148
+ const v = sdEscape(a).replace(/\$/g, "$$$$");
149
+ return /[\s"'\\]/.test(v) ? `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"` : v;
150
+ }).join(" ");
134
151
  // QUOTED, AND `%` ESCAPED. systemd.exec(5) splits an Environment= assignment
135
152
  // on whitespace unless the whole thing is double-quoted, and it expands `%`
136
153
  // specifiers in the value. ExecStart above quotes its own arguments; this
@@ -146,10 +163,15 @@ export function unitFor({ execPath, script, logPath, args = [], env = {}, produc
146
163
  // decks and two LAN keys on one machine". `%b` is the boot-ID specifier, so
147
164
  // `/home/ana/100%backup` was rewritten just as quietly.
148
165
  //
166
+ // AND `"` AND `\` ESCAPED INSIDE THOSE QUOTES, which systemd reads with C
167
+ // escapes: a `"` in the value closed them early and a `\` began an escape,
168
+ // so `/home/ana/a "quote` and a value ending in `\` were dropped as
169
+ // "Invalid syntax, ignoring" and `back\slash` came back with a space in it.
170
+ //
149
171
  // The macOS branch was always safe: plistFor puts each value in its own
150
172
  // <string> and XML-escapes it. Windows carries none of them.
151
173
  const vars = Object.entries({ AGENTS_DECK_DETACHED: "1", ...env })
152
- .map(([k, v]) => `Environment="${k}=${String(v).replace(/%/g, "%%")}"`).join("\n");
174
+ .map(([k, v]) => `Environment="${sdEscape(`${k}=${v}`).replace(/[\\"]/g, "\\$&")}"`).join("\n");
153
175
  return `[Unit]
154
176
  Description=${product} — live deck of Claude Code + Codex agents
155
177
  After=default.target
@@ -158,8 +180,8 @@ After=default.target
158
180
  Type=simple
159
181
  ${vars}
160
182
  ExecStart=${cmd}
161
- StandardOutput=append:${logPath}
162
- StandardError=append:${logPath}
183
+ StandardOutput=append:${sdEscape(logPath)}
184
+ StandardError=append:${sdEscape(logPath)}
163
185
 
164
186
  [Install]
165
187
  WantedBy=default.target
@@ -250,7 +272,9 @@ export function taskXmlFor({ execPath, script, args = [], product = "ccdeck" } =
250
272
  *
251
273
  * `npx` runs out of ~/.npm/_npx/<hash>/, which npm deletes whenever it feels
252
274
  * like it. A login item pointing at nothing, forever, on a machine where the
253
- * user never installed anything.
275
+ * user never installed anything. pnpm dlx, bunx and yarn dlx are the same
276
+ * case in another cache or temp folder, and callers pass `npx` for all four
277
+ * (isOneOffRun).
254
278
  *
255
279
  * A CHECKOUT is somebody's working tree. Found by running this: a test boot
256
280
  * from the repo on the author's own machine wrote a LaunchAgent naming
@@ -354,8 +378,40 @@ export function lingerState({ platform = process.platform, run = spawnSync, user
354
378
  export const SCOPE_VARS = Object.freeze(["CLAUDE_CONFIG_DIR", "CCDECK_HOME", "CODEX_HOME"]);
355
379
 
356
380
  export function scopeEnv(env = process.env) {
381
+ return setOnes(SCOPE_VARS, env);
382
+ }
383
+
384
+ /**
385
+ * …and the variables that decide what that deck DOES: every other row of
386
+ * README's environment table, by name.
387
+ *
388
+ * Lost the same way the scope directories were, and with a cost the user had
389
+ * already said no to. `AGENTS_DECK_NO_REPORTS=1 ccdeck`, or the variable in
390
+ * ~/.zshrc, installed an item on the first start whose deck ran at the next
391
+ * login with no veto: an install event with the device fingerprint, a daily
392
+ * "active", and — AGENTS_DECK_NO_LAN gone too — the beacon on the local
393
+ * network. A terminal exporting the variable afterwards attached to that deck
394
+ * rather than starting one of its own, so not even it was spared.
395
+ *
396
+ * BY NAME, NOT BY PREFIX. A shell holds tokens and keys a login item has no
397
+ * business copying into a file in ~/Library/LaunchAgents; the list is what the
398
+ * deck documents and reads, and a test holds it to README's table. Only the
399
+ * ones that are set, for the reason scopeEnv gives.
400
+ */
401
+ export const SETTING_VARS = Object.freeze([
402
+ "AGENT_DAG_PORT",
403
+ "AGENTS_DECK_NO_INSTALL", "AGENTS_DECK_NO_DOWNLOAD", "AGENTS_DECK_NO_UPDATE_CHECK", "AGENTS_DECK_NO_STATUS",
404
+ "AGENTS_DECK_NO_FRESHEN", "AGENTS_DECK_NO_NOTIFY", "AGENTS_DECK_NO_LAN", "AGENTS_DECK_NO_REPORTS",
405
+ "AGENTS_DECK_CSWAP", "AGENTS_DECK_CLAUDE", "AGENTS_DECK_CCUSAGE", "CLAUDE_SWAP_BACKUP", "AGENTS_DECK_LHM_PORT",
406
+ ]);
407
+
408
+ export function settingsEnv(env = process.env) {
409
+ return setOnes(SETTING_VARS, env);
410
+ }
411
+
412
+ function setOnes(names, env) {
357
413
  return Object.fromEntries(
358
- SCOPE_VARS.filter(k => typeof env?.[k] === "string" && env[k].trim() !== "").map(k => [k, env[k]]),
414
+ names.filter(k => typeof env?.[k] === "string" && env[k].trim() !== "").map(k => [k, env[k]]),
359
415
  );
360
416
  }
361
417
 
@@ -394,7 +450,11 @@ export function installService({
394
450
  platform = process.platform,
395
451
  home = homedir(),
396
452
  env = process.env,
397
- execPath = process.execPath,
453
+ // The node an upgrade of the same install does not delete, which is not
454
+ // `process.execPath` on Homebrew or a linked tarball — see stable-node.mjs.
455
+ // Looked up on THIS machine whatever `platform` asks about: it is this
456
+ // machine's node that the job will run.
457
+ execPath = stableNodePath({ env }),
398
458
  script,
399
459
  logPath,
400
460
  // `--at-login` because registering the job also STARTS it, on Linux and
@@ -422,12 +482,29 @@ export function installService({
422
482
  // genuinely wants a different job environment — the sandboxed end-to-end
423
483
  // test, which passes HOME and the deck's own directories — still says so,
424
484
  // and replaces this outright rather than adding to it.
425
- serviceEnv = scopeEnv(env),
485
+ //
486
+ // The opt-outs ride along for the same reason (settingsEnv): an item that
487
+ // starts a deck without the shell's AGENTS_DECK_NO_REPORTS is a deck that
488
+ // reports from the next login on.
489
+ serviceEnv = { ...scopeEnv(env), ...settingsEnv(env) },
426
490
  product = "ccdeck",
427
491
  fs = { mkdirSync, writeFileSync },
428
492
  run = spawnSync,
493
+ // Whether systemd is this machine's init — sd_booted(3)'s own test. Only
494
+ // this machine can be asked, so a Linux job written from anywhere else is
495
+ // taken to have one.
496
+ systemd = () => process.platform !== "linux" || existsSync("/run/systemd/system"),
429
497
  } = {}) {
430
498
  const path = servicePath(platform, home, env);
499
+ // NO SYSTEMD, NO LOGIN ITEM — Alpine and Gentoo on OpenRC, Void, Devuan, a
500
+ // container, WSL with systemd off. A unit written there is read by nothing,
501
+ // and `systemctl` is missing or refuses; the refusal used to read as
502
+ // "file-only", so the first start said the deck would start at login, saved
503
+ // the item as installed and never offered again. Asked before anything is
504
+ // written, so nothing is left behind in a directory nobody reads.
505
+ if (platform === "linux" && !systemd()) {
506
+ return { ok: false, path, reason: "this machine does not run systemd" };
507
+ }
431
508
  try {
432
509
  // BEFORE THE SERVICE MANAGER IS TOLD ANYTHING. launchd opens
433
510
  // StandardOutPath when it starts the job and systemd opens
@@ -464,11 +541,17 @@ export function installService({
464
541
  fs.writeFileSync(path, body, { mode: 0o644 });
465
542
  const cmd = registerCommand(platform, path);
466
543
  const out = run(cmd.file, cmd.args, { encoding: "utf8" });
467
- // A registration that failed still leaves the file, and both launchd and
468
- // systemd read their directories at the next login — so the service works
469
- // from then on either way. Said, not hidden, and not treated as fatal.
544
+ // A registration that failed still leaves the file. launchd loads every
545
+ // plist in ~/Library/LaunchAgents at the next login, so on a Mac the item
546
+ // works from then on. systemd does not: it starts at login only a user
547
+ // unit that is ENABLED, which is the link the failed `enable` never made,
548
+ // so the verdict carries the command that makes it. Said, not hidden, and
549
+ // not treated as fatal.
470
550
  if (out?.status !== 0) {
471
- return { ok: true, path, how: "file-only", reason: oneLine(out?.stderr || out?.stdout) || `${cmd.file} refused` };
551
+ const reason = oneLine(out?.stderr || out?.stdout) || `${cmd.file} refused`;
552
+ return platform === "darwin"
553
+ ? { ok: true, path, how: "file-only", reason }
554
+ : { ok: true, path, how: "file-only", reason, enable: `systemctl --user enable ${SERVICE_LABEL}.service` };
472
555
  }
473
556
  return { ok: true, path, how: cmd.file };
474
557
  } catch (err) {
@@ -16,7 +16,7 @@ import { join, resolve } from "node:path";
16
16
  import { killTree, shimPath, spawnSpec } from "./exec.mjs";
17
17
  import { npmCliLaunch } from "./npx.mjs";
18
18
  import {
19
- frozenNameInstall, hostPackage, installedName, isGitCheckout, isNpxInstall,
19
+ frozenNameInstall, hostPackage, installedName, isGitCheckout, isNpxInstall, isOneOffRun,
20
20
  PUBLISHED_NAME, successorRoot, upgradeCommand, upgradeName,
21
21
  } from "./install-layout.mjs";
22
22
 
@@ -78,7 +78,7 @@ export function upgradeSpec(target, platform = process.platform, deps) {
78
78
  * Pure so the policy can be read and tested on its own — it is the part that
79
79
  * decides whether we are allowed to write to the user's machine.
80
80
  */
81
- export function upgradeBlockedReason({ git, npx, writable, optedOut, frozen }) {
81
+ export function upgradeBlockedReason({ git, npx, oneOff, writable, optedOut, frozen }) {
82
82
  if (optedOut) return "opted_out";
83
83
  // The maintainer's own tree. Its version leads npm's, and installing over it
84
84
  // would replace a working copy with a published tarball.
@@ -87,6 +87,12 @@ export function upgradeBlockedReason({ git, npx, writable, optedOut, frozen }) {
87
87
  // place — `npx agents-deck@latest` fetches a DIFFERENT directory, which this
88
88
  // process could not switch to even after restarting.
89
89
  if (npx) return "npx";
90
+ // pnpm dlx, bunx and yarn dlx: a cache or temp folder as well, but with no
91
+ // spec recorded to re-run the way npx's is. Writable, so it looked like a
92
+ // global install, and `npm i -g` from here installs a copy this process
93
+ // never runs — the running version never moves, and the away-update tried
94
+ // again every thirty minutes. The command is shown instead.
95
+ if (oneOff) return "one_off";
90
96
  // A flat install of a retired name: the one shape where `npm i -g <us>@latest`
91
97
  // runs cleanly, reports success, and leaves no deck behind — the registry
92
98
  // serves a 5 KB pointer for those names now, and npm's reify removes bin/,
@@ -108,8 +114,9 @@ export function upgradeBlockedReason({ git, npx, writable, optedOut, frozen }) {
108
114
  * "install" — `npm i -g` here and restart into the new files.
109
115
  * "npx" — nothing to install: the supervisor re-runs `npx -y <spec>`,
110
116
  * which fetches a NEW cache directory and hands the port to it.
111
- * null — a checkout, an unwritable prefix, or an explicit opt-out; the
112
- * user gets the command and does it themselves.
117
+ * null — a checkout, a one-off runner's cache, an unwritable prefix, or
118
+ * an explicit opt-out; the user gets the command and does it
119
+ * themselves.
113
120
  *
114
121
  * Pure, so the policy is one readable expression rather than three conditions
115
122
  * spread across the server and the UI.
@@ -182,6 +189,7 @@ export function upgradeBlock(pkgRoot) {
182
189
  return upgradeBlockedReason({
183
190
  git: isGitCheckout(pkgRoot),
184
191
  npx: isNpxInstall(pkgRoot),
192
+ oneOff: isOneOffRun(pkgRoot),
185
193
  // npm -g rewrites the package directory and its parent (the global
186
194
  // node_modules), so both have to be ours to write.
187
195
  writable: dirWritable(target) && dirWritable(resolve(target, "..")),
@@ -89,6 +89,9 @@ const PINNED_MODULES = [
89
89
  "cli-versions.mjs",
90
90
  "session-tracking.mjs",
91
91
  "session-enrichment.mjs",
92
+ // The plans the daily report says, read through import() by depth-facts.mjs.
93
+ "quota-oauth.mjs",
94
+ "codex-auth.mjs",
92
95
  ];
93
96
 
94
97
  let _pinned = null;
@@ -7,7 +7,7 @@
7
7
  // engine had (lan-deck.mjs): what is left of them is a body read, a write
8
8
  // through the one, a push to the other, and an answer. The notifier stays in
9
9
  // index.mjs, beside the desktop-app connections it notifies through.
10
- import { notificationsOn, notificationsVetoed, prefsRefusalDetail, prefsWriteRefusal, publicPrefs, reportsVetoed } from "./deck-prefs.mjs";
10
+ import { notificationsOn, notificationsVetoed, pagePatch, prefsRefusalDetail, prefsWriteRefusal, publicPrefs, reportsVetoed } from "./deck-prefs.mjs";
11
11
  import { PRODUCT } from "./brand.mjs";
12
12
  import { heldPrefs } from "./prefs-state.mjs";
13
13
  import { applyLanPrefs, forgetReach, resetLanLoaded } from "./lan-deck.mjs";
@@ -52,13 +52,10 @@ export async function handlePrefsWrite(req, res) {
52
52
  let body = null;
53
53
  try { body = JSON.parse(raw ?? ""); } catch { /* handled below */ }
54
54
  if (!body || typeof body !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
55
- // The reports switch has consequences a plain write would skip — a deletion
56
- // asked for — so it is changed only through /api/reports, and its state never
57
- // by the page at all (#1853).
58
- // Which accounts the deck signed in is the deck's to record, on a sign-in it
59
- // ran itself (#1893); a page that could write it could give any account a
60
- // provenance it never had.
61
- const { reports: _reports, report: _report, accounts: _accounts, ...patch } = body;
55
+ // Only the fields the page edits. The rest — the LAN key and pairings, the
56
+ // reports switch (#1853), the reporter's state, the accounts the deck signed
57
+ // in (#1893) — are the deck's own and keep what is on disk; see pagePatch.
58
+ const patch = pagePatch(body);
62
59
  try {
63
60
  await heldPrefs.write(patch);
64
61
  } catch (err) {
@@ -35,6 +35,10 @@ let _prefs = { ...DEFAULTS };
35
35
  * it. A write that lands first merges onto the file, but leaves the engine on
36
36
  * what it made up, so it does not count. */
37
37
  let _unread = false;
38
+ /** Whether the boot read found no file there at all. A file it could not read
39
+ * or make sense of was still a deck that ran here, so only "missing" counts.
40
+ * False until the read lands. */
41
+ let _noFile = false;
38
42
 
39
43
  // Read at import, so `consider` has its answer from the first event. LAN sync
40
44
  // used to start from this same read, and it must not: bin/deck.js imports the
@@ -43,7 +47,11 @@ let _unread = false;
43
47
  // in — which is the `lan sync (listen): EADDRINUSE` line a second `ccdeck`
44
48
  // printed above `deck already running`. The engine starts from the listen that
45
49
  // succeeds, in startServer, which is the only process that may hold a port.
46
- export const prefsRead = loadPrefs().then(r => { _prefs = r.prefs; _unread = r.source === "unreadable"; }).catch(() => {});
50
+ export const prefsRead = loadPrefs().then(r => { _prefs = r.prefs; _unread = r.source === "unreadable"; _noFile = r.source === "missing"; }).catch(() => {});
51
+
52
+ /** Whether the boot read found no settings file at all: the first run on this
53
+ * machine, as far as the deck can tell (reports.mjs times only that one). */
54
+ export const bootFoundNoPrefs = () => _noFile;
47
55
 
48
56
  export const heldPrefs = Object.freeze({
49
57
  /** What the last read or write left here. Never a disk read — see above. */