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
@@ -14,7 +14,7 @@ import { readFile } from "node:fs/promises";
14
14
  import { cooldownFromHeader } from "./quota-oauth.mjs";
15
15
  import { join } from "node:path";
16
16
  import { CODEX_HOME } from "./codex-dir.mjs";
17
- import { getCodexAuth, forceCodexRefresh, isCredentialHost } from "./codex-auth.mjs";
17
+ import { codexCredentialFingerprint, getCodexAuth, forceCodexRefresh, isCredentialHost } from "./codex-auth.mjs";
18
18
  import { PRODUCT } from "./brand.mjs";
19
19
  import { resetLabel } from "./reset-label.mjs";
20
20
 
@@ -28,6 +28,11 @@ const DEFAULT_BASE = "https://chatgpt.com/backend-api";
28
28
  let _cache = null;
29
29
  let _cacheAt = 0;
30
30
  const CACHE_MS = 60_000;
31
+ // The last reading that had lanes in it, apart from `_cache`, which also holds
32
+ // failures. A 429 is the backend asking for a pause, not a statement that the
33
+ // lanes read a minute ago have stopped being true, so they are what is held
34
+ // through it — as quota.mjs keeps its own `_lastGood` for the same case.
35
+ let _lastGood = null;
31
36
 
32
37
  // The last base URL we refused, so the refusal is said once rather than once a
33
38
  // minute for as long as the config stays that way.
@@ -280,6 +285,12 @@ const FORCE_POLL_MS = 60_000;
280
285
  // _rateLimitedUntil, which is likewise never beaten by force.
281
286
  let _rateLimitedUntil = 0;
282
287
  const COOLDOWN_MS = 5 * 60_000;
288
+ // Set when the cooldown above is for a refused login rather than a 429: the
289
+ // fingerprint of the credential auth.json held when it was refused. That wait
290
+ // is about the credential, and `codex login` — the very thing the panel tells
291
+ // the user to run — replaces it; held to the clock alone, ↻ was answered with
292
+ // the old refusal for five minutes after the login that fixed it.
293
+ let _refusedWith = null;
283
294
 
284
295
  // Stamped when a fetch STARTS rather than when it lands: what the floor is
285
296
  // rationing is the round trip, and one that is still in flight has already been
@@ -323,10 +334,27 @@ export function fetchCodexQuota({ force = false } = {}) {
323
334
  // that has not landed yet is one.
324
335
  if (_inflight) return _inflight;
325
336
  if (!mayFetchQuota({ now, lastFetchAt: _lastFetchAt, rateLimitedUntil: _rateLimitedUntil })) {
337
+ // Past the floor and inside a refused login's cooldown, the one question
338
+ // worth asking is whether auth.json still holds what was refused — a local
339
+ // read, and the wire stays quiet unless the answer is no.
340
+ if (_refusedWith && mayFetchQuota({ now, lastFetchAt: _lastFetchAt, rateLimitedUntil: 0 })) {
341
+ return track((async () => {
342
+ if (await codexCredentialFingerprint() === _refusedWith) return heldReading(Date.now());
343
+ _rateLimitedUntil = 0;
344
+ _refusedWith = null;
345
+ _lastFetchAt = Date.now();
346
+ return doFetchCodexQuota();
347
+ })());
348
+ }
326
349
  return Promise.resolve(heldReading(now));
327
350
  }
328
351
  _lastFetchAt = now;
329
- _inflight = doFetchCodexQuota().finally(() => { _inflight = null; });
352
+ return track(doFetchCodexQuota());
353
+ }
354
+
355
+ /** `run`, as the one outstanding fetch every caller joins until it settles. */
356
+ function track(run) {
357
+ _inflight = run.finally(() => { _inflight = null; });
330
358
  return _inflight;
331
359
  }
332
360
 
@@ -335,7 +363,12 @@ async function doFetchCodexQuota() {
335
363
  // Stamped at completion, not at entry: the two calls below can take up to
336
364
  // 17s between them, and a cache entry that is already stale on arrival
337
365
  // shortens the effective TTL for no reason.
338
- const finish = (r) => { _cache = r; _cacheAt = Date.now(); return r; };
366
+ const finish = (r) => {
367
+ _cache = r;
368
+ _cacheAt = Date.now();
369
+ if (r.ok && !r.stale) _lastGood = r;
370
+ return r;
371
+ };
339
372
  const fail = (reason) => finish({ ok: false, reason, fetchedAt: started });
340
373
  // A refusal we were told about, rather than one we inferred: back off further
341
374
  // than the ordinary floor before asking again. `retry-after` is honoured when
@@ -345,6 +378,13 @@ async function doFetchCodexQuota() {
345
378
  // defeats the cooldown a 429 exists to impose, and a day freezes this
346
379
  // poller for the life of the process.
347
380
  _rateLimitedUntil = Date.now() + cooldownFromHeader(res?.headers?.get?.("retry-after"), COOLDOWN_MS);
381
+ _refusedWith = null;
382
+ };
383
+ // The same wait for a login that was refused, stamped with the credential it
384
+ // was refused for, so a new one ends it (see fetchCodexQuota).
385
+ const refused = async (res) => {
386
+ cooldown(res);
387
+ _refusedWith = await codexCredentialFingerprint();
348
388
  };
349
389
 
350
390
  let auth, base, res;
@@ -388,7 +428,7 @@ async function doFetchCodexQuota() {
388
428
  // The credential is gone and only `codex login` brings it back, so
389
429
  // rotating another single-use token at the next request would burn the
390
430
  // one the CLI is still holding. Wait.
391
- if (refreshed.reason === "refresh_rejected") cooldown(null);
431
+ if (refreshed.reason === "refresh_rejected") await refused(null);
392
432
  return fail(refreshed.reason);
393
433
  }
394
434
  auth = refreshed;
@@ -398,9 +438,19 @@ async function doFetchCodexQuota() {
398
438
  if (!res.ok) {
399
439
  // A second 401 means the token we just rotated to was rejected as well —
400
440
  // the case that turned into one rotation per request. 429 is the backend
401
- // saying the same thing in the ordinary way.
402
- if (res.status === 401 || res.status === 429) cooldown(res);
403
- return fail(res.status === 401 ? "refresh_rejected" : `http_${res.status}`);
441
+ // saying the same thing in the ordinary way, and it says nothing against
442
+ // the lanes already read: they are held, stale, through the wait, and
443
+ // without any the panel is told the deck is waiting rather than to press
444
+ // a ↻ the cooldown refuses.
445
+ if (res.status === 401) {
446
+ await refused(res);
447
+ return fail("refresh_rejected");
448
+ }
449
+ if (res.status === 429) {
450
+ cooldown(res);
451
+ return _lastGood ? finish({ ..._lastGood, stale: true }) : fail("rate_limited");
452
+ }
453
+ return fail(`http_${res.status}`);
404
454
  }
405
455
  } catch (err) {
406
456
  console.error(`${PRODUCT} codex-quota: fetch failed:`, err?.message ?? err);
@@ -220,6 +220,11 @@ export const TEMP_STALE_MS = 60 * 60 * 1000;
220
220
  * after the ones that are not — and it is deliberately blind to WHICH deck made
221
221
  * a file, because it cannot know: it goes by age instead, which is a fact about
222
222
  * the file rather than a guess about a pid.
223
+ *
224
+ * BUT ONLY THE DECK'S OWN NAMES. CCDECK_HOME is used as it is, so these can be
225
+ * somebody's own folder — a USB stick's root, a synced tools folder — and a
226
+ * `report.tmp` or a downloader's staging file in it is theirs. Every shape the
227
+ * deck's writers have ever used is in deckTemp, and nothing else is touched.
223
228
  */
224
229
  export async function sweepTempFiles({ dirs, fs, now = Date.now(), onError } = {}) {
225
230
  let removed = 0;
@@ -227,7 +232,7 @@ export async function sweepTempFiles({ dirs, fs, now = Date.now(), onError } = {
227
232
  let names;
228
233
  try { names = await fs.readdir(dir); } catch { continue; }
229
234
  for (const name of names) {
230
- if (!name.endsWith(".tmp") && !name.endsWith(".migrating")) continue;
235
+ if (!deckTemp(name)) continue;
231
236
  const path = join(dir, name);
232
237
  try {
233
238
  const st = await fs.stat(path);
@@ -241,3 +246,20 @@ export async function sweepTempFiles({ dirs, fs, now = Date.now(), onError } = {
241
246
  }
242
247
  return removed;
243
248
  }
249
+
250
+ const escaped = (name) => name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
251
+ const DECK_TEMP = [
252
+ // atomic-write.mjs: `<target>.agent-dag-<pid>-<n>.tmp`, and before the
253
+ // sequence number `<target>.agent-dag-<pid>.tmp`.
254
+ /\.agent-dag-\d+(?:-\d+)?\.tmp$/,
255
+ // The preferences' and the Browser Watch archive's own writers, from before
256
+ // atomic-write.mjs and since: `prefs.json.<pid>.tmp`, `state.json.<pid>.<n>.tmp`.
257
+ /^(?:prefs|state)\.json\.\d+(?:\.\d+)?\.tmp$/,
258
+ // migrateDeckFiles: `<moved file>.<pid>.migrating`.
259
+ new RegExp(`^(?:${MOVED.map(m => escaped(m.name)).join("|")})\\.\\d+\\.migrating$`),
260
+ ];
261
+
262
+ /** Is `name` a temp file the deck itself makes? */
263
+ export function deckTemp(name) {
264
+ return DECK_TEMP.some(re => re.test(name));
265
+ }
@@ -47,6 +47,8 @@ import { deckDataDir } from "./deck-home.mjs";
47
47
  import { PRODUCT } from "./brand.mjs";
48
48
  import { normaliseOrigins } from "./account-origins.mjs";
49
49
  import { normaliseUsage } from "./usage-day.mjs";
50
+ import { refSlug } from "./install-ref.mjs";
51
+ import { normaliseRating } from "./rating.mjs";
50
52
 
51
53
  /** Set to "1" to keep the deck off the desktop whatever the stored preference
52
54
  * says. Same sheet of switches as AGENTS_DECK_NO_DOWNLOAD and
@@ -93,13 +95,16 @@ export const DEFAULTS = Object.freeze({
93
95
  // not yet acknowledged, retried on the next start so switching off while
94
96
  // offline still ends in a deletion; `usage` is the day's tally of sessions,
95
97
  // subagents and projects, counts and days only, so a restart carries it on
96
- // (usage-day.mjs); `installedAt` is when the id was made, and
98
+ // (usage-day.mjs); `installedAt` is when the id was made, on a first run
99
+ // only (an id made over an older deck's settings has none), and
97
100
  // `firstSessionAt`/`firstProvider`/`activationSent` the install's first
98
101
  // session and whether the "activated" event saying so got through — the times
99
- // stay here, only a bucket leaves (activation.mjs).
102
+ // stay here, only a bucket leaves (activation.mjs); `rating` is the one
103
+ // question the deck asks about itself, answered or put off (rating.mjs).
100
104
  report: Object.freeze({
101
105
  installId: "", lastVersion: "", lastActiveDay: "", forget: "", usage: null,
102
106
  installedAt: "", firstSessionAt: "", firstProvider: "", activationSent: false,
107
+ rating: Object.freeze(normaliseRating(null)),
103
108
  }),
104
109
  // The accounts this deck signed in itself, and the re-sign-in prompts
105
110
  // somebody put off (#1893) — keyed by account identity, written by the deck
@@ -110,7 +115,7 @@ export const DEFAULTS = Object.freeze({
110
115
  // write below names a mode. AGENTS_DECK_NO_LAN=1 keeps a deck off the network
111
116
  // whatever this file says — see lanEnabled.
112
117
  lan: Object.freeze({
113
- enabled: true, name: "", secret: "", shared: [], manual: [], trusted: [], unpaired: [], port: 0,
118
+ enabled: true, name: "", secret: "", shared: [], onward: [], manual: [], trusted: [], unpaired: [], port: 0,
114
119
  // WHO PAIRS WITH WHOM, WITHOUT ANYBODY PRESSING ANYTHING. Asking is on, so
115
120
  // two decks on one network find each other and send each other a request —
116
121
  // which is what a person with three of their own machines wants and had to
@@ -227,6 +232,11 @@ function normaliseLan(raw) {
227
232
  // anybody can act on and refusing to start would take the feature away.
228
233
  secret: typeof src.secret === "string" ? src.secret : "",
229
234
  shared: strings(src.shared),
235
+ // WHICH OF THOSE AN ARRIVAL TICKED rather than a person (#1188), so a deck
236
+ // the accept switch paired is not offered them — see sharedWith in
237
+ // lan-sync.mjs. Only ever a subset of `shared`: an untick takes the mark
238
+ // with it, so ticking the account again is a person's tick.
239
+ onward: strings(src.onward).filter(key => strings(src.shared).includes(key)),
230
240
  manual: strings(src.manual),
231
241
  // THE DECKS SOMEBODY PRESSED ACCEPT ON. The public key is the load-bearing
232
242
  // half: a fingerprint is a hash of it, so an entry without one cannot be
@@ -301,7 +311,7 @@ export function normalise(raw) {
301
311
  }
302
312
 
303
313
  /** The reporter's own state, coerced: strings empty when absent, the day's
304
- * tally null until there is one, and one flag. */
314
+ * tally null until there is one, one flag, and a ref only if it is one. */
305
315
  function normaliseReport(raw) {
306
316
  const src = raw && typeof raw === "object" ? raw : {};
307
317
  const text = v => (typeof v === "string" ? v : "");
@@ -315,9 +325,21 @@ function normaliseReport(raw) {
315
325
  firstSessionAt: text(src.firstSessionAt),
316
326
  firstProvider: text(src.firstProvider),
317
327
  activationSent: src.activationSent === true,
328
+ ref: refSlug(src.ref) ?? "",
329
+ selfUpdate: selfUpdateMarker(src.selfUpdate),
330
+ rating: normaliseRating(src.rating),
318
331
  };
319
332
  }
320
333
 
334
+ /** The deck's note that it started an update itself (reports.mjs
335
+ * noteSelfUpdate): the version it left and when, or null. */
336
+ function selfUpdateMarker(raw) {
337
+ if (!raw || typeof raw !== "object") return null;
338
+ const from = typeof raw.from === "string" && /^[0-9A-Za-z.+_-]{1,32}$/.test(raw.from) ? raw.from : "";
339
+ const at = typeof raw.at === "string" && Number.isFinite(Date.parse(raw.at)) ? raw.at : "";
340
+ return from && at ? { from, at } : null;
341
+ }
342
+
321
343
  // How a refusal to write is told — the error writePrefs throws, and the reason
322
344
  // and the detail the route hands the panel: prefs-refusal.mjs.
323
345
  export { prefsRefusalDetail, prefsWriteRefusal };
@@ -506,11 +528,15 @@ export function withManualEntry(entry) {
506
528
  * to the next machine as the deck that gave it. Only on arrival — an untick
507
529
  * afterwards is the person's answer and nothing re-ticks it, because an account
508
530
  * this deck already holds is never added again.
531
+ *
532
+ * AND MARKED AS THE ARRIVAL'S in `lan.onward`, in the same write, so a restart
533
+ * cannot turn it into a tick somebody made — see sharedWith in lan-sync.mjs.
509
534
  */
510
535
  export function withShared(key) {
511
536
  return prev => {
512
537
  const shared = Array.isArray(prev?.lan?.shared) ? prev.lan.shared : [];
513
- return !key || shared.includes(key) ? null : { lan: { shared: [...shared, key] } };
538
+ const onward = Array.isArray(prev?.lan?.onward) ? prev.lan.onward : [];
539
+ return !key || shared.includes(key) ? null : { lan: { shared: [...shared, key], onward: [...onward, key] } };
514
540
  };
515
541
  }
516
542
 
@@ -633,14 +659,51 @@ export function publicPrefs(prefs) {
633
659
  const { report: _report, accounts: _accounts, ...p } = normalise(prefs);
634
660
  // unpaired is engine-authored state too. No page draws or edits it; keeping
635
661
  // it out also means a future whole-prefs form cannot replay a stale unpair
636
- // list over a decision the engine made after the form loaded.
637
- const { secret, trusted, unpaired: _unpaired, ...lan } = p.lan;
662
+ // list over a decision the engine made after the form loaded. `onward` is
663
+ // the deck's own bookkeeping of which ticks an arrival made, for the same
664
+ // reason.
665
+ const { secret, trusted, unpaired: _unpaired, onward: _onward, ...lan } = p.lan;
638
666
  return {
639
667
  ...p,
640
668
  lan: { ...lan, trusted: trusted.map(t => ({ fp: t.fp, name: t.name })) },
641
669
  };
642
670
  }
643
671
 
672
+ /**
673
+ * The preferences a PAGE may write — publicPrefs's other half, and the fields
674
+ * `POST /api/prefs` takes. Exactly what the page and the desktop app send:
675
+ * the three switches, and the LAN dialog's fields.
676
+ *
677
+ * EVERYTHING ELSE IS THE DECK'S OWN, and kept as it is on disk whatever a body
678
+ * names. The LAN key, the decks somebody accepted or unpaired, which ticks an
679
+ * arrival made and the port are written by the engine and read back at the
680
+ * next start (see lanApplyFields in lan-deck.mjs); a name for another deck
681
+ * goes through its own route, which cleans it; the reports switch only through
682
+ * /api/reports, because turning it off asks for a deletion (#1853); the
683
+ * reporter's state and the accounts the deck signed in (#1893) are the deck's
684
+ * to record. A new preference is not writable from a page until it is named
685
+ * here — prefs-route-fields.test.ts lists them.
686
+ */
687
+ export const PAGE_FIELDS = Object.freeze({
688
+ top: Object.freeze(["notifications", "tourSeen", "autoUpdate"]),
689
+ lan: Object.freeze([
690
+ "enabled", "name", "shared", "manual", "shareActive", "pairingMode",
691
+ "autoAsk", "autoAccept", "tailscale", "tailscaleAsk", "tailscaleAccept",
692
+ ]),
693
+ });
694
+
695
+ /** A page's body cut down to PAGE_FIELDS: the patch writePrefs is handed. A
696
+ * field the body does not name is not in it, so it keeps its value. */
697
+ export function pagePatch(body) {
698
+ const pick = (src, keys) => {
699
+ const from = src && typeof src === "object" && !Array.isArray(src) ? src : {};
700
+ return Object.fromEntries(keys.filter(k => Object.hasOwn(from, k)).map(k => [k, from[k]]));
701
+ };
702
+ const patch = pick(body, PAGE_FIELDS.top);
703
+ const lan = pick(body?.lan, PAGE_FIELDS.lan);
704
+ return Object.keys(lan).length ? { ...patch, lan } : patch;
705
+ }
706
+
644
707
  /**
645
708
  * May the deck raise a desktop notification right now?
646
709
  *
@@ -0,0 +1,178 @@
1
+ // How the deck holds up and who runs it, for the daily "active" report
2
+ // (reports.mjs), and how an update arrived, for the "update" one.
3
+ //
4
+ // EVERY VALUE HERE IS A BUCKET, A CATEGORY OR A SMALL COUNT. How long this run
5
+ // took to start, the usage day's peak memory and how many events the deck took
6
+ // in that day leave as the bucket they fall in ("2s", "512m", "1k"); the Claude
7
+ // and Codex plans as the plan's category ("max-20x", "plus") and nothing else
8
+ // from the credentials they are read from; the Claude accounts and the paired
9
+ // machines as counts, capped. The lists below are the only words that can
10
+ // leave, and the API keeps the same ones (ccdeck-api AppEventRequest): a value
11
+ // off a list is not sent.
12
+ //
13
+ // Each bucket is named by its upper edge: "2s" is over one second and up to
14
+ // two, "512m" over 256 MB and up to 512 MB, "1k" a day of 100 to 999 events.
15
+
16
+ export const LAUNCH_BUCKETS = Object.freeze(["1s", "2s", "5s", "10s", "30s", "later"]);
17
+ const LAUNCH_EDGES_MS = [1000, 2000, 5000, 10_000, 30_000];
18
+
19
+ export const MEMORY_BUCKETS = Object.freeze(["128m", "256m", "512m", "1g", "2g", "more"]);
20
+ const MEMORY_EDGES_MB = [128, 256, 512, 1024, 2048];
21
+
22
+ export const EVENT_BUCKETS = Object.freeze(["0", "100", "1k", "10k", "100k", "more"]);
23
+ const EVENT_EDGES = [0, 99, 999, 9999, 99_999];
24
+
25
+ export const CLAUDE_PLANS = Object.freeze(["free", "pro", "max", "max-5x", "max-20x", "team", "enterprise", "api"]);
26
+ export const CODEX_PLANS = Object.freeze(["free", "go", "plus", "pro", "pro-lite", "team", "business", "enterprise", "edu", "api"]);
27
+
28
+ /** How a new version arrived: the deck's own update (a press, or the update it
29
+ * runs while nobody is looking), the desktop app's updater, npx fetching the
30
+ * latest, npm run by hand, or a source checkout pulled. */
31
+ export const UPDATE_VIAS = Object.freeze(["self-update", "desktop-updater", "npx", "npm", "checkout"]);
32
+
33
+ /** The most accounts or paired machines a report says; past it, it says this. */
34
+ export const COUNT_CAP = 50;
35
+
36
+ /** How long the deck's own update stays the answer: a press that failed is not
37
+ * credited with an update somebody made by hand days later. */
38
+ export const SELF_UPDATE_FRESH_MS = 2 * 24 * 60 * 60 * 1000;
39
+
40
+ function bucketOf(value, edges, names) {
41
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return undefined;
42
+ const i = edges.findIndex(edge => value <= edge);
43
+ return names[i < 0 ? names.length - 1 : i];
44
+ }
45
+
46
+ /** A run's start, in ms from its process starting to the deck listening. */
47
+ export function launchBucket(ms) {
48
+ return bucketOf(ms, LAUNCH_EDGES_MS, LAUNCH_BUCKETS);
49
+ }
50
+
51
+ /** The deck's peak resident memory on a day, in MB. */
52
+ export function memoryBucket(mb) {
53
+ return bucketOf(mb, MEMORY_EDGES_MB, MEMORY_BUCKETS);
54
+ }
55
+
56
+ /** The live events a day took in. */
57
+ export function eventsBucket(n) {
58
+ return Number.isInteger(n) ? bucketOf(n, EVENT_EDGES, EVENT_BUCKETS) : undefined;
59
+ }
60
+
61
+ /** A whole count of zero or more, capped at COUNT_CAP; anything else, nothing. */
62
+ export function cappedCount(n) {
63
+ return Number.isInteger(n) && n >= 0 ? Math.min(n, COUNT_CAP) : undefined;
64
+ }
65
+
66
+ /**
67
+ * The Claude plan as a category, from the two fields Claude Code keeps beside
68
+ * its OAuth token: `subscriptionType` ("pro", "max", "team"…) and, for Max, the
69
+ * `rateLimitTier` that says which of the two ("…max_5x", "…max_20x"). Nothing
70
+ * else in that file is looked at, and a type off the list says nothing.
71
+ */
72
+ export function claudePlanToken({ subscriptionType, rateLimitTier } = {}) {
73
+ const type = typeof subscriptionType === "string" ? subscriptionType.trim().toLowerCase() : "";
74
+ if (type === "max") {
75
+ const tier = typeof rateLimitTier === "string" ? rateLimitTier.toLowerCase() : "";
76
+ return tier.includes("20x") ? "max-20x" : tier.includes("5x") ? "max-5x" : "max";
77
+ }
78
+ return ["free", "pro", "team", "enterprise"].includes(type) ? type : undefined;
79
+ }
80
+
81
+ /** The Codex plan as a category, from the `chatgpt_plan_type` claim codex-auth
82
+ * reads; OpenAI writes Pro Lite three ways. */
83
+ export function codexPlanToken(planType) {
84
+ const type = typeof planType === "string" ? planType.trim().toLowerCase().replace(/_/g, "-") : "";
85
+ const plan = type === "prolite" ? "pro-lite" : type;
86
+ return CODEX_PLANS.includes(plan) && plan !== "api" ? plan : undefined;
87
+ }
88
+
89
+ /**
90
+ * How the version this run is on arrived, for the "update" that says it.
91
+ *
92
+ * `marker` is what the deck wrote when it started an update itself
93
+ * (`{ from, at }`, reports.mjs noteSelfUpdate): it counts only for an update
94
+ * away from the version it was written on, and only while fresh. Otherwise the
95
+ * channel says it: the desktop app updates through its own updater, a source
96
+ * checkout through git, and an npm install either because npx fetched the
97
+ * latest or because somebody ran npm.
98
+ */
99
+ export function updateVia({ marker, lastVersion, channel, npx = false, now = new Date() } = {}) {
100
+ const at = marker && typeof marker.at === "string" ? Date.parse(marker.at) : NaN;
101
+ if (marker?.from && marker.from === lastVersion && Number.isFinite(at)
102
+ && now.getTime() - at >= 0 && now.getTime() - at <= SELF_UPDATE_FRESH_MS) {
103
+ return "self-update";
104
+ }
105
+ if (channel === "desktop") return "desktop-updater";
106
+ if (channel === "checkout") return "checkout";
107
+ if (channel === "npm") return npx ? "npx" : "npm";
108
+ return undefined;
109
+ }
110
+
111
+ // ── the deck's own readings ──────────────────────────────────────────────────
112
+ //
113
+ // Read only when an "active" is about to go, never on a hot path, and each by
114
+ // lazy import, so the reporter's module graph pulls in none of it. Each one is
115
+ // on its own: a reading that fails or does not apply is left out, and none of
116
+ // them can throw.
117
+
118
+ /** The Claude plan, unless the boot left Claude out. Bedrock, Vertex and an
119
+ * API key are "api"; a machine that keeps its login in the macOS Keychain has
120
+ * no file to read, and says nothing. Bedrock, Vertex and the key are read
121
+ * where the quota reader reads them — Claude Code's settings as well as this
122
+ * deck's environment, "true", "yes" and "on" as well as "1" — so the plan
123
+ * reported and the quota panel agree about one install. */
124
+ async function claudePlan(env) {
125
+ const [{ readFile }, { claudeSignIn, credentialsPath }] = await Promise.all([import("node:fs/promises"), import("./quota-oauth.mjs")]);
126
+ const { cloud, apiKey } = await claudeSignIn(env);
127
+ if (cloud) return "api";
128
+ let oauth = null;
129
+ try {
130
+ oauth = JSON.parse(await readFile(credentialsPath(), "utf8"))?.claudeAiOauth ?? null;
131
+ } catch { /* no file, or not one we can read */ }
132
+ if (oauth) return claudePlanToken(oauth);
133
+ return apiKey ? "api" : undefined;
134
+ }
135
+
136
+ /** The Codex plan, never refreshing a token to learn it. */
137
+ async function codexPlan() {
138
+ const { getCodexAuth } = await import("./codex-auth.mjs");
139
+ const auth = await getCodexAuth({ allowRefresh: false });
140
+ if (auth?.apiKeyMode) return "api";
141
+ return auth?.ok ? codexPlanToken(auth.planType) : undefined;
142
+ }
143
+
144
+ /** The accounts the last roster read held: no read is started for this. */
145
+ async function accountCount() {
146
+ const { knownAccountCount } = await import("./claude-accounts.mjs");
147
+ return knownAccountCount();
148
+ }
149
+
150
+ async function quietly(read) {
151
+ try {
152
+ return await read();
153
+ } catch {
154
+ return undefined;
155
+ }
156
+ }
157
+
158
+ /**
159
+ * What the deck says about itself on its "active": the plans, the accounts and
160
+ * the paired machines. `setup` is what the boot set up (activation.mjs) — the
161
+ * Claude plan is read unless the Claude hooks are off, the Codex plan only when
162
+ * the deck watches Codex — and `prefs` the held prefs, whose paired decks are
163
+ * the machines.
164
+ */
165
+ export async function deckDepth({ setup = {}, prefs = null, env = process.env } = {}) {
166
+ const [claude, codex, accounts] = await Promise.all([
167
+ setup.claudeHooks === "off" ? undefined : quietly(() => claudePlan(env)),
168
+ setup.codexWatch === "on" ? quietly(codexPlan) : undefined,
169
+ quietly(accountCount),
170
+ ]);
171
+ const trusted = prefs?.lan?.trusted;
172
+ return {
173
+ claudePlan: claude,
174
+ codexPlan: codex,
175
+ accounts,
176
+ machines: Array.isArray(trusted) ? trusted.length : undefined,
177
+ };
178
+ }
@@ -20,11 +20,13 @@
20
20
  // the terminal is fed by TAILING that file rather than by holding a pipe open.
21
21
  // One sink, no handover, nothing to break when the launcher goes.
22
22
  //
23
- // That file is `deck.log`, and it is truncated only when no deck is registered
24
- // on this machine. A running deck holds an open descriptor into it, so
25
- // truncating underneath one would punch a hole in the log of a deck nobody
26
- // asked to disturb — and the case that reaches here with a deck already up is
27
- // the attach, which has six lines to say and no business erasing anything.
23
+ // That file is `deck.log`, every writer appends to it, and it is started afresh
24
+ // only when no deck is registered or booting on this machine — by moving the
25
+ // old one aside, never by truncating it. A running deck holds an open
26
+ // descriptor into it, so truncating underneath one would punch a hole in the
27
+ // log of a deck nobody asked to disturb — and the case that reaches here with a
28
+ // deck already up is the attach, which has six lines to say and no business
29
+ // erasing anything.
28
30
  //
29
31
  // ONE PLATFORM WHERE THIS IS NOT ENOUGH, and it is worth knowing before someone
30
32
  // reports it as a bug. Windows OpenSSH puts a session's processes in a job
@@ -45,7 +47,7 @@
45
47
  // deck was started from, which is what makes `--logs` worth reading, and the
46
48
  // launcher passes nothing at all when it is not itself a terminal.
47
49
  import { spawn } from "node:child_process";
48
- import { closeSync, mkdirSync, openSync, readSync, statSync } from "node:fs";
50
+ import { closeSync, mkdirSync, openSync, readSync, renameSync, statSync } from "node:fs";
49
51
  import { join } from "node:path";
50
52
  import { isOneShot } from "./args.mjs";
51
53
 
@@ -60,16 +62,23 @@ export const DETACHED_ENV = "AGENTS_DECK_DETACHED";
60
62
  export const DECK_LOG = "deck.log";
61
63
 
62
64
  /**
63
- * Truncate this log, or append to it?
65
+ * Start this log afresh, or carry on in it?
64
66
  *
65
- * Truncated when nothing is registered, which is the ordinary start and the
66
- * only moment the previous contents are certainly nobody's. Appended otherwise,
67
- * because a registered deck is holding an open descriptor at some offset into
68
- * this very file: truncating under it does not make it start again at zero, it
69
- * makes its next write land past the end and leave a hole.
67
+ * Afresh when nothing is registered and no deck holds the boot lock, which is
68
+ * the ordinary start and the only moment the previous contents are certainly
69
+ * nobody's. A deck in its boot window is not registered yet — it registers up
70
+ * to eight seconds later (boot-lock.mjs) — so two starts inside one window both
71
+ * read an empty registry; the lock is what the second one can see.
72
+ *
73
+ * AFRESH IS NEVER A TRUNCATION. A deck holds an open descriptor into this file,
74
+ * and truncating under it does not make it start again at zero: its next write
75
+ * lands past the end and leaves a hole. So the old log is moved aside to
76
+ * `deck.log.1` — a deck still writing to it keeps writing there — and a new
77
+ * file is opened. And every writer appends (see detachAndWatch), so nobody's
78
+ * offset can fall behind anybody else's.
70
79
  */
71
- export function logMode(liveCount) {
72
- return liveCount > 0 ? "a" : "w";
80
+ export function startsFresh(liveCount, booting = false) {
81
+ return liveCount === 0 && !booting;
73
82
  }
74
83
 
75
84
  /**
@@ -152,31 +161,43 @@ export function noConsoleOptions({ detached = false, platform = process.platform
152
161
  * An npx run has no `ccdeck` on PATH — that is the whole point of npx — so
153
162
  * telling one to run `ccdeck --stop` is telling them to run something that does
154
163
  * not exist on their machine. `invokedAs` already answers which of the three
155
- * published names was typed; this only decides whether `npx ` goes in front.
164
+ * published names was typed; this only decides which runner goes in front.
165
+ * `runner` is install-layout's oneOffRunner — "npx", "pnpm dlx", "bunx" or
166
+ * "yarn dlx", none of which leaves the command on PATH either — and `npx` the
167
+ * older spelling of the first.
156
168
  */
157
- export function stopCommand({ npx = false, invokedAs = null, product = "ccdeck" } = {}) {
169
+ export function stopCommand({ runner = null, npx = false, invokedAs = null, product = "ccdeck" } = {}) {
170
+ return command("--stop", { runner, npx, invokedAs, product });
171
+ }
172
+
173
+ /** `<runner> <name> <flag>`, or `<name> <flag>` for a copy that stays. */
174
+ function command(flag, { runner = null, npx = false, invokedAs = null, product = "ccdeck" }) {
175
+ const via = runner || (npx ? "npx" : null);
158
176
  const name = invokedAs || product;
159
- return npx ? `npx ${name} --stop` : `${name} --stop`;
177
+ return via ? `${via} ${name} ${flag}` : `${name} ${flag}`;
160
178
  }
161
179
 
162
180
  /**
163
181
  * 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.
182
+ * command that ends it, and — for a one-off run — the one thing it cannot have.
165
183
  *
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.
184
+ * THE ONE THING A ONE-OFF RUN CANNOT HAVE, said where it is missing. A login
185
+ * item must name a path that will still be there tomorrow, and npx, pnpm dlx,
186
+ * bunx and yarn dlx all run out of a cache or a temp folder that is deleted
187
+ * whenever its owner likes — so such a deck runs in the background and cannot
188
+ * come back after a reboot. One line, no disk written, offered rather than
189
+ * done: `npx` means "run without installing", and a tool that installs itself
190
+ * anyway is the one people uninstall. Through the same runner, like the stop
191
+ * command: neither is on PATH after a one-off run.
172
192
  *
173
193
  * `tone` is a palette and `g` a glyph tier, both from term.mjs, so a pipe gets
174
194
  * no escapes and a console without the em dash gets its stand-in.
175
195
  */
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`
196
+ export function backgroundNote({ runner = null, npx = false, invokedAs = null, product = "ccdeck", tone, g }) {
197
+ const how = { runner, npx, invokedAs, product };
198
+ const stop = stopCommand(how);
199
+ const offer = runner || npx
200
+ ? ` ${tone.muted}\`${command("--install", how)}\` also starts it at login${tone.reset}\n`
180
201
  : "";
181
202
  return ` ${tone.muted}${g.dash} running in the background ${g.bullet} \`${stop}\` ends it${tone.reset}\n${offer}\n`;
182
203
  }
@@ -241,6 +262,7 @@ export async function detachAndWatch({
241
262
  argv = [],
242
263
  logDir,
243
264
  liveCount = 0,
265
+ booting = false,
244
266
  env = process.env,
245
267
  out = process.stdout,
246
268
  isTTY = false,
@@ -254,14 +276,20 @@ export async function detachAndWatch({
254
276
  const path = join(logDir, DECK_LOG);
255
277
  try { mkdirSync(logDir, { recursive: true }); } catch { /* reported by the open below */ }
256
278
 
257
- const mode = logMode(liveCount);
258
- // Where the terminal starts reading. An append leaves the previous deck's log
259
- // alone and shows only what this child writes.
260
- const from = mode === "a" ? (() => { try { return statSync(path).size; } catch { return 0; } })() : 0;
279
+ if (startsFresh(liveCount, booting)) {
280
+ try { renameSync(path, `${path}.1`); } catch { /* no log yet, or held where it cannot move: carry on in it */ }
281
+ }
282
+ // Where the terminal starts reading: the end of what is there. An attach
283
+ // leaves the running deck's log alone and shows only what this child writes.
284
+ const from = (() => { try { return statSync(path).size; } catch { return 0; } })();
261
285
 
262
286
  let fd;
263
287
  try {
264
- fd = openSync(path, mode, 0o600);
288
+ // "a", O_APPEND, for every start. The supervisor and worker share this
289
+ // descriptor, an attach appends its own lines to the same file, and a login
290
+ // item's service manager appends to it too; opened at an offset of its own,
291
+ // the running deck's next write landed over whatever came after it.
292
+ fd = openSync(path, "a", 0o600);
265
293
  } catch (err) {
266
294
  // A log we cannot open is not a reason to refuse to start. Say so once and
267
295
  // run in the foreground, which is exactly what every version before this