ccdeck 3.38.3 → 3.38.4

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.
@@ -34,7 +34,8 @@ import { readSwapLog, accountAtTime, trackedSince, seedActive, markGap } from ".
34
34
  // about the speed they were billed at — see transcript-scan.mjs.
35
35
  import {
36
36
  billedSpeed, hasSpend, MAX_SPEEDS_PER_MODEL, UNRECOGNISED_SPEED,
37
- requestBilling, streamingRequestId, BILL_LINE, REBILL_REQUEST, SKIP_LINE,
37
+ requestBilling, newRequestLedger, noteRequestRecord, streamingRequestId,
38
+ BILL_LINE, REBILL_REQUEST, SKIP_LINE,
38
39
  } from "./transcript-scan.mjs";
39
40
 
40
41
  /** The pseudo-account for messages the swap log cannot place — everything
@@ -42,7 +43,7 @@ import {
42
43
  * a per-account total is never quietly inflated by work that is not that
43
44
  * account's. A NUL keeps it from ever colliding with a real `email@@org` key. */
44
45
  export const UNATTRIBUTED = "\u0000unattributed";
45
- const STATE_VERSION = 5;
46
+ const STATE_VERSION = 6;
46
47
 
47
48
  /** Where the tally and cursors live, beside cswap-auto's own state. */
48
49
  export function statePath(home = homedir()) {
@@ -242,19 +243,23 @@ export function projectPath(cwd) {
242
243
  * `timeline` is the sorted swap log; the account is whoever it says was active
243
244
  * at this line's timestamp, or UNATTRIBUTED when the timestamp precedes it.
244
245
  * `from` names the transcript's project directory — see folderOf — and, as
245
- * `requests` and `path`, where the request this file is still waiting on a
246
- * final record for is kept between passes.
246
+ * `requests` and `path`, where what this file has read of its requests is kept
247
+ * between passes: the newest request record, which a resumed subagent's replay
248
+ * of its history is told apart by, and the request still waiting on a final
249
+ * record (newRequestLedger in transcript-scan.mjs).
247
250
  */
248
251
  export function foldLine(tally, line, timeline, from) {
249
252
  if (!line || !line.includes('"usage"')) return;
250
253
  let obj = null;
251
254
  try { obj = JSON.parse(line); } catch { return; }
252
255
  // A request is billed once, from its final record, which in a subagent's
253
- // file follows a streaming snapshot — see requestBilling. A call that keeps
254
- // no requests for the file never sees one replaced, and bills block 0.
256
+ // file follows a streaming snapshot, and not again when a resumed subagent
257
+ // restates it — see requestBilling. A call that keeps no requests for the
258
+ // file never sees one replaced or restated, and bills block 0.
255
259
  const requests = from?.requests ?? null;
256
- const streaming = requests?.[from.path] ?? null;
257
- const billing = requestBilling(obj, streaming?.id ?? null);
260
+ const ledger = requests?.[from.path] ?? null;
261
+ const billing = requestBilling(obj, ledger);
262
+ if (requests && billing !== BILL_LINE) noteRequestRecord(requests[from.path] ??= newRequestLedger(), obj);
258
263
  if (billing === SKIP_LINE) return;
259
264
  const usage = obj?.message?.usage;
260
265
  if (!usage || typeof usage !== "object") return;
@@ -262,7 +267,7 @@ export function foldLine(tally, line, timeline, from) {
262
267
  if (!Number.isFinite(ts)) return;
263
268
  const c = countersFrom(usage);
264
269
  if (!hasSpend(asUsageTotals(c))) return; // a usage block that billed nothing
265
- if (billing === REBILL_REQUEST) refund(tally, streaming);
270
+ if (billing === REBILL_REQUEST) refund(tally, ledger.streaming);
266
271
  const model = typeof obj?.message?.model === "string" ? obj.message.model : "";
267
272
  const lineCwd = (typeof obj?.cwd === "string" && obj.cwd) ? obj.cwd : "";
268
273
  const cwd = projectPath(folderOf(lineCwd, from)); // a worktree counts under the repo it checks out
@@ -277,13 +282,12 @@ export function foldLine(tally, line, timeline, from) {
277
282
  const speed = billedSpeed(obj);
278
283
  const share = speed ? speedShareKey(counters, speed) : null;
279
284
  if (share) addInto(counters.s[share], c);
280
- if (billing === REBILL_REQUEST) dropIfEmpty(tally, streaming);
285
+ if (billing === REBILL_REQUEST) dropIfEmpty(tally, ledger.streaming);
281
286
  if (billing === BILL_LINE || !requests) return;
282
287
  // What a request still streaming was charged, and where, so its final
283
288
  // record can take that back, in this pass or a later one.
284
289
  const id = streamingRequestId(obj);
285
- if (id === null) delete requests[from.path];
286
- else requests[from.path] = { id, at: [key, cwd, day, model], s: share, c };
290
+ requests[from.path].streaming = id === null ? null : { id, at: [key, cwd, day, model], s: share, c };
287
291
  }
288
292
 
289
293
  /** Take a streaming request's charge back out of the row it was charged to,
@@ -496,15 +500,17 @@ export function createProjectRollup({
496
500
  state = disk;
497
501
  state.folders ??= {};
498
502
  state.requests ??= {};
499
- } else if (disk && [1, 2, 3, 4].includes(disk.version)) {
503
+ } else if (disk && [1, 2, 3, 4, 5].includes(disk.version)) {
500
504
  // Version 1 counted every API content block; version 2 keyed each row
501
505
  // by the line's own cwd, a row per folder the agent cd'd into (#1278);
502
506
  // version 3 kept no speed, so every fast turn read at the standard
503
507
  // rate; version 4 billed a subagent's request from its streaming
504
- // snapshot, most of its output and its speed missing (#1883). None of
505
- // them can be re-keyed, since it no longer knows which transcript a
506
- // count came from. Keep the heartbeat so a restart gap remains fenced,
507
- // but rebuild the tally from transcripts.
508
+ // snapshot, most of its output and its speed missing (#1883); version
509
+ // 5 billed a resumed subagent's request again each time its file
510
+ // restated its history. None of them can be re-keyed, since it no
511
+ // longer knows which transcript a count came from. Keep the heartbeat
512
+ // so a restart gap remains fenced, but rebuild the tally from
513
+ // transcripts.
508
514
  state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {}, requests: {}, lastAlive: disk.lastAlive };
509
515
  dirty = true;
510
516
  }
@@ -565,7 +571,7 @@ export function createProjectRollup({
565
571
  const timeline = await readSwapLog(swapLog);
566
572
  const files = await listTranscripts(roots);
567
573
  for (const [path, slug] of files) await foldFile(path, slug, timeline);
568
- // Forget the cursors and streaming requests of files that are gone, and
574
+ // Forget the cursors and request ledgers of files that are gone, and
569
575
  // the folders of project directories with no transcript left, so none of
570
576
  // the three maps grows forever.
571
577
  for (const p of Object.keys(state.cursors)) if (!files.has(p)) { delete state.cursors[p]; dirty = true; }
@@ -0,0 +1,169 @@
1
+ // The account notifier this process runs: where its records live, what reads
2
+ // quota for it while no page does, and the doors the rest of the server reaches
3
+ // it through.
4
+ //
5
+ // The decisions are account-notify.mjs's. This file is the wiring around them,
6
+ // kept light on purpose: event-pipeline.mjs connects it as the server module
7
+ // loads, and the auto-switch loop reports every tick to it, so everything heavy
8
+ // it uses — the two quota modules and the accounts store — is reached by
9
+ // `import()` and only when a reading is wanted. Those three are pinned with the
10
+ // rest (pinned-build.mjs).
11
+ //
12
+ // NOTHING HERE MAY COST A POLL OR A TICK. Every door returns at once and does
13
+ // its work on a promise that is caught; a notification that cannot be raised,
14
+ // a record that cannot be written and a store that cannot be read all end as
15
+ // a line on stderr at most.
16
+ import { readFile, mkdir } from "node:fs/promises";
17
+ import { dirname, join, resolve } from "node:path";
18
+ import { fileURLToPath, pathToFileURL } from "node:url";
19
+ import {
20
+ claudeAccountKey, claudeWindows, codexAccountKey, codexWindows, createAccountNotifier,
21
+ } from "./account-notify.mjs";
22
+ import { deckDataDir } from "./deck-home.mjs";
23
+ import { writeFileAtomic } from "./atomic-write.mjs";
24
+
25
+ const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
26
+
27
+ /** Where the records are kept: beside prefs.json, in the deck's own data. */
28
+ export function recordsPath(home = deckDataDir()) {
29
+ return join(home, "account-notify.json");
30
+ }
31
+
32
+ /**
33
+ * How often quota is read for the notifier while it wants readings.
34
+ *
35
+ * Only while the threshold or the reset switch is on — both start off — and
36
+ * every read goes through the same caches and floors as the Usage panel's, so
37
+ * with a page open this adds nothing and with none it costs half of what an
38
+ * open page does.
39
+ */
40
+ export const WATCH_MS = 2 * 60_000;
41
+
42
+ let _deps = null;
43
+ let _notifier = null;
44
+ let _autoOn = false;
45
+ let _timer = null;
46
+ let _watching = null;
47
+
48
+ /**
49
+ * Hand the notifier its OS call, its product name and its switches. Called
50
+ * once, as the server module loads; until it is, every door below is a no-op —
51
+ * which is what keeps a test that drives the auto-switch loop on its own from
52
+ * writing records or raising anything.
53
+ */
54
+ export function connectAccountNotify({ notify, settings, product, onError }) {
55
+ _deps = { notify, settings, product, onError };
56
+ }
57
+
58
+ function notifier() {
59
+ if (_notifier || !_deps) return _notifier;
60
+ const path = recordsPath();
61
+ _notifier = createAccountNotifier({
62
+ notify: _deps.notify,
63
+ settings: _deps.settings,
64
+ product: _deps.product,
65
+ onError: _deps.onError,
66
+ load: async () => {
67
+ try { return JSON.parse(await readFile(path, "utf8")); } catch { return null; }
68
+ },
69
+ save: async records => {
70
+ await mkdir(dirname(path), { recursive: true });
71
+ await writeFileAtomic(path, `${JSON.stringify(records)}\n`);
72
+ },
73
+ });
74
+ _notifier.autoSwitch(_autoOn);
75
+ return _notifier;
76
+ }
77
+
78
+ /** The deck's auto-switch loop was switched on or off (cswap-auto-loop.mjs). */
79
+ export function noteAutoSwitch(enabled) {
80
+ _autoOn = enabled === true;
81
+ try { _notifier?.autoSwitch(_autoOn); } catch { /* never the loop's problem */ }
82
+ }
83
+
84
+ /**
85
+ * A finished tick of the deck's own auto-switch loop. The only way a swap
86
+ * notification is raised: a switch somebody pressed never comes through here.
87
+ * After a switch, claude-swap's roster is read for the two accounts' names and
88
+ * keys — the store, which the deck already reads, rather than anything more out
89
+ * of the tick's own output.
90
+ */
91
+ export function noteAutoTick(result) {
92
+ const n = notifier();
93
+ if (!n || !result) return Promise.resolve();
94
+ return (result.switched ? storeAccounts() : Promise.resolve(null))
95
+ .then(accounts => n.tick(result, accounts))
96
+ .catch(() => {});
97
+ }
98
+
99
+ /** claude-swap's roster, number → `{ email, organizationUuid, alias }`, or null. */
100
+ async function storeAccounts() {
101
+ try {
102
+ const { backupRoot } = await import(pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href);
103
+ const seq = JSON.parse(await readFile(join(backupRoot(), "sequence.json"), "utf8"));
104
+ return seq?.accounts && typeof seq.accounts === "object" ? seq.accounts : null;
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /** What the deck calls a Claude account: its alias in claude-swap, else its
111
+ * address. */
112
+ function claudeName(account, accounts) {
113
+ const entry = Object.values(accounts ?? {}).find(a =>
114
+ typeof a?.email === "string" && a.email.toLowerCase() === account.email.toLowerCase()
115
+ && a.organizationUuid === account.organizationUuid);
116
+ const alias = typeof entry?.alias === "string" ? entry.alias.trim() : "";
117
+ return alias || account.email;
118
+ }
119
+
120
+ async function observeClaude(n) {
121
+ const quota = await import(pathToFileURL(join(PKG_ROOT, "src/server/quota.mjs")).href);
122
+ const reading = await quota.fetchClaudeQuota();
123
+ const who = quota.quotaAccount(reading);
124
+ const key = who ? claudeAccountKey(who.email, who.organizationUuid) : null;
125
+ if (!key) return;
126
+ await n.observe("claude", { key, name: claudeName(who, await storeAccounts()) }, claudeWindows(reading));
127
+ }
128
+
129
+ async function observeCodex(n) {
130
+ const quota = await import(pathToFileURL(join(PKG_ROOT, "src/server/codex-quota.mjs")).href);
131
+ const reading = await quota.fetchCodexQuota();
132
+ const who = quota.codexQuotaAccount(reading);
133
+ const key = who ? codexAccountKey(who.accountId, who.email) : null;
134
+ if (!key) return;
135
+ await n.observe("codex", { key, name: who.email || null }, codexWindows(reading));
136
+ }
137
+
138
+ /**
139
+ * One look at the live accounts' quota, when a switch wants it. One at a time:
140
+ * a Claude read can take a while, and the next interval joins it rather than
141
+ * stacking a second behind it.
142
+ */
143
+ export function watchOnce(providers) {
144
+ if (_watching) return _watching;
145
+ const s = _deps?.settings?.();
146
+ const n = notifier();
147
+ if (!n || !s || (!s.quota && !s.reset)) return Promise.resolve();
148
+ const { claude = true, codex = true } = providers?.() ?? {};
149
+ _watching = (async () => {
150
+ // A failed read is the quota modules' to report, and the Usage panel says
151
+ // it; here it only means there is nothing to compare this time.
152
+ if (claude) await observeClaude(n).catch(() => {});
153
+ if (codex) await observeCodex(n).catch(() => {});
154
+ })().finally(() => { _watching = null; });
155
+ return _watching;
156
+ }
157
+
158
+ /**
159
+ * Start reading quota on a timer, from startServer once the port is bound —
160
+ * never at import, because a launcher imports the server module only to ask
161
+ * whether a deck is already up. Unref'd like every other timer the deck keeps.
162
+ */
163
+ export function startAccountWatch({ providers } = {}) {
164
+ if (_timer) return;
165
+ _timer = setInterval(() => { watchOnce(providers).catch(() => {}); }, WATCH_MS);
166
+ _timer.unref?.();
167
+ const first = setTimeout(() => { watchOnce(providers).catch(() => {}); }, 15_000);
168
+ first.unref?.();
169
+ }
@@ -498,6 +498,19 @@ async function doFetchCodexQuota() {
498
498
  };
499
499
 
500
500
  result.resetCredits = await fetchResetCredits(base, auth);
501
+ _accountOfQuota.set(result, { accountId: auth.accountId ?? null, email: result.email });
501
502
 
502
503
  return finish(result);
503
504
  }
505
+
506
+ // Which account a reading was taken for — the ChatGPT account id and the
507
+ // address — kept beside it rather than on it, as quota.mjs keeps Claude's, so
508
+ // the panel is sent nothing new. A reading held over and re-served as stale is
509
+ // a copy and is not tagged.
510
+ const _accountOfQuota = new WeakMap();
511
+
512
+ /** The account a fetchCodexQuota reading was taken for, or null — for
513
+ * account-watch.mjs, whose notifications are about one account at a time. */
514
+ export function codexQuotaAccount(reading) {
515
+ return (reading && typeof reading === "object" && _accountOfQuota.get(reading)) || null;
516
+ }
@@ -16,6 +16,10 @@ import { invalidateQuotaCache } from "./quota.mjs";
16
16
  // The one store mutex. A tick is the only thing in the deck that moves the live
17
17
  // account with nobody watching, which is why it of all writers must queue.
18
18
  import { withStoreLock } from "./store-lock.mjs";
19
+ // Told about every finished tick and about the switch itself, so a tick that
20
+ // moved the account is said once on the desktop (account-notify.mjs). Both
21
+ // doors return at once and never throw.
22
+ import { noteAutoSwitch, noteAutoTick } from "./account-watch.mjs";
19
23
  import { readFile, writeFile, mkdir } from "node:fs/promises";
20
24
  import { join } from "node:path";
21
25
  import { homedir } from "node:os";
@@ -239,7 +243,10 @@ async function runTick() {
239
243
  */
240
244
  function tick() {
241
245
  if (_ticking) return _ticking;
242
- _ticking = runTick().finally(() => { _ticking = null; });
246
+ // The notifier hears what every tick did, the ones that switched nothing
247
+ // included: a threshold notice it is holding waits for exactly that answer.
248
+ // Not awaited, and caught inside: a notification never delays a tick.
249
+ _ticking = runTick().then(() => { noteAutoTick(_lastTick); }).finally(() => { _ticking = null; });
243
250
  return _ticking;
244
251
  }
245
252
 
@@ -332,6 +339,7 @@ export function lastTick() {
332
339
  /** Turn the deck-managed loop on or off, persisting the choice. */
333
340
  export async function setAutoEnabled(enabled) {
334
341
  _enabled = Boolean(enabled);
342
+ noteAutoSwitch(_enabled);
335
343
  await saveState({ ...(await loadState()), enabled: _enabled });
336
344
  if (_enabled) await startLoop(); else stopLoop();
337
345
  return { ok: true, enabled: _enabled };
@@ -351,5 +359,5 @@ export async function setAutoEnabled(enabled) {
351
359
  export async function initCswapAuto({ after = null } = {}) {
352
360
  _toolQuiet = Promise.resolve(after).then(() => {}, () => {});
353
361
  const state = await loadState();
354
- if (state.enabled) { _enabled = true; await startLoop(); }
362
+ if (state.enabled) { _enabled = true; noteAutoSwitch(true); await startLoop(); }
355
363
  }
@@ -72,6 +72,16 @@ export { prefsPath };
72
72
  */
73
73
  export const DEFAULTS = Object.freeze({
74
74
  notifications: false,
75
+ // ACCOUNT NOTIFICATIONS, one switch each (account-notify.mjs). Not part of
76
+ // `notifications` above, which is about a deck nobody is looking at: these
77
+ // are said whether a page is open or not, because no page says them. The
78
+ // swap is ON — it fires only when the deck itself moved the live Claude
79
+ // account, which is rare and which nothing else announces; the quota two are
80
+ // OFF, because turning either on is also what starts the deck reading quota
81
+ // with no page open (account-watch.mjs). The owner's choice, 2026-10-07.
82
+ notifySwap: true,
83
+ notifyQuota: false,
84
+ notifyReset: false,
75
85
  // The Electron window can open on a new localhost port after an update.
76
86
  // Its tour marker must outlive that origin; browser tabs still use their
77
87
  // own localStorage marker and never read this preference.
@@ -304,6 +314,9 @@ export function normalise(raw) {
304
314
  const src = raw && typeof raw === "object" ? raw : {};
305
315
  return {
306
316
  notifications: flagOr(src.notifications, DEFAULTS.notifications),
317
+ notifySwap: flagOr(src.notifySwap, DEFAULTS.notifySwap),
318
+ notifyQuota: flagOr(src.notifyQuota, DEFAULTS.notifyQuota),
319
+ notifyReset: flagOr(src.notifyReset, DEFAULTS.notifyReset),
307
320
  tourSeen: flagOr(src.tourSeen, DEFAULTS.tourSeen),
308
321
  autoUpdate: flagOr(src.autoUpdate, DEFAULTS.autoUpdate),
309
322
  reports: flagOr(src.reports, DEFAULTS.reports),
@@ -688,7 +701,7 @@ export function publicPrefs(prefs) {
688
701
  * here — prefs-route-fields.test.ts lists them.
689
702
  */
690
703
  export const PAGE_FIELDS = Object.freeze({
691
- top: Object.freeze(["notifications", "tourSeen", "autoUpdate"]),
704
+ top: Object.freeze(["notifications", "notifySwap", "notifyQuota", "notifyReset", "tourSeen", "autoUpdate"]),
692
705
  lan: Object.freeze([
693
706
  "enabled", "name", "shared", "manual", "shareActive", "pairingMode",
694
707
  "autoAsk", "autoAccept", "tailscale", "tailscaleAsk", "tailscaleAccept",
@@ -721,6 +734,18 @@ export function notificationsOn(prefs, env = process.env) {
721
734
  return normalise(prefs).notifications;
722
735
  }
723
736
 
737
+ /**
738
+ * Which account notifications may go out right now: the three switches, each
739
+ * on its own, and all three off when the machine vetoed notifications at
740
+ * launch — AGENTS_DECK_NO_NOTIFY=1 keeps the deck off the desktop whatever
741
+ * else it was told, and these are desktop notifications too.
742
+ */
743
+ export function accountNotifySettings(prefs, env = process.env) {
744
+ if (notificationsVetoed(env)) return { swap: false, quota: false, reset: false };
745
+ const p = normalise(prefs);
746
+ return { swap: p.notifySwap, quota: p.notifyQuota, reset: p.notifyReset };
747
+ }
748
+
724
749
  /**
725
750
  * Should the LAN engine run? The file's answer, unless the machine said no.
726
751
  *
@@ -12,7 +12,10 @@
12
12
  import { PRODUCT } from "./brand.mjs";
13
13
  import { createBlockNotifier } from "./block-notify.mjs";
14
14
  import { notify as osNotify } from "./browser-react.mjs";
15
- import { notificationsOn } from "./deck-prefs.mjs";
15
+ import { accountNotifySettings, notificationsOn } from "./deck-prefs.mjs";
16
+ // The account notifier — a switch, a quota threshold, a reset — connected below
17
+ // to the same OS call the block notifier uses.
18
+ import { connectAccountNotify } from "./account-watch.mjs";
16
19
  // The settings as this process holds them — see prefs-state.mjs.
17
20
  import { heldPrefs } from "./prefs-state.mjs";
18
21
  // The deck's own token, taken back out of every event before the ring, the
@@ -49,6 +52,13 @@ import { connectEventSink } from "./event-sink.mjs";
49
52
  // emits until a request or a timer startServer arms asks it to.
50
53
  connectEventSink(pushEvent);
51
54
 
55
+ /**
56
+ * Where a notification goes. The desktop app, while it is connected, raises it
57
+ * itself — under its own name, icon and permission, with a click that opens its
58
+ * window. Only when no app is listening does the OS helper speak.
59
+ */
60
+ const deskNotify = (title, body, meta) => (trayClients.size > 0 ? notifyTrays(title, body, meta) : osNotify(title, body));
61
+
52
62
  /**
53
63
  * The desktop notifier, built once per process.
54
64
  *
@@ -65,10 +75,7 @@ connectEventSink(pushEvent);
65
75
  * every in-page surface still says everything it said before.
66
76
  */
67
77
  const blockNotifier = createBlockNotifier({
68
- // The desktop app, while it is connected, raises the notification itself —
69
- // under its own name, icon and permission, with a click that opens its
70
- // window. Only when no app is listening does the OS helper speak.
71
- notify: (title, body, meta) => (trayClients.size > 0 ? notifyTrays(title, body, meta) : osNotify(title, body)),
78
+ notify: deskNotify,
72
79
  product: PRODUCT,
73
80
  // A function, not a boolean: this is a switch a person flips from the sound
74
81
  // menu while the deck is running, and a mute that waited for a restart would
@@ -80,6 +87,21 @@ const blockNotifier = createBlockNotifier({
80
87
  onError: err => console.error(`${PRODUCT}: could not raise a desktop notification:`, err?.message ?? err),
81
88
  });
82
89
 
90
+ /**
91
+ * The account notifier's half of the same channel. Not gated on a page being
92
+ * open, as the block notifier is: an open page plays a tone for a blocked
93
+ * session, so the desktop stays out of its way, but no page says that the deck
94
+ * moved your account or that a quota window filled — so these go out either
95
+ * way. Each of its three switches is asked per notification, and the launch
96
+ * veto still wins inside `accountNotifySettings`.
97
+ */
98
+ connectAccountNotify({
99
+ notify: deskNotify,
100
+ product: PRODUCT,
101
+ settings: () => accountNotifySettings(heldPrefs.current()),
102
+ onError: err => console.error(`${PRODUCT}: could not raise an account notification:`, err?.message ?? err),
103
+ });
104
+
83
105
  export function pushEvent(raw, source, opts = {}) {
84
106
  // First, before anything below can see it: the deck's own credential does not
85
107
  // belong in a store that is served without one. Every entry point to the
@@ -129,6 +129,9 @@ export { markDeckReady, releaseRestart } from "./lifecycle.mjs";
129
129
  // The settings as this process holds them, and every write that changes them —
130
130
  // see prefs-state.mjs.
131
131
  import { prefsRead } from "./prefs-state.mjs";
132
+ // Quota read for the account notifications while no page reads it — only while
133
+ // one of their quota switches is on (account-watch.mjs).
134
+ import { startAccountWatch } from "./account-watch.mjs";
132
135
  // This deck's LAN engine, what the settings may tell it, and the probe that
133
136
  // asks whether other decks can reach it — see lan-deck.mjs. startServer hands
134
137
  // it the prefs at boot; the settings route does after every write.
@@ -513,6 +516,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
513
516
  // being installed or upgraded underneath them (#1043) — null from every
514
517
  // caller with nothing to wait for.
515
518
  cswapAutoModule().then(m => m.initCswapAuto({ after: cswapQuiet })).catch(() => {});
519
+ // The account notifications' own quota reads, for the providers this deck
520
+ // watches. Idle — a check of two switches every couple of minutes — unless
521
+ // the threshold or the reset notification is on.
522
+ startAccountWatch({ providers: deckProviders });
516
523
  // The account-projects rollup: fold Claude transcripts into a per-account,
517
524
  // per-project token tally, incrementally. Claude only — it reads Claude
518
525
  // transcripts — best effort, and its timer is unref'd so it never holds
@@ -247,6 +247,10 @@ function notYet(now) {
247
247
  * long as the store has nothing to say about the new account.
248
248
  */
249
249
  function publish(gen, result, at, { good = false, who = null } = {}) {
250
+ // Tagged before the generation check: a read that finished after a switch is
251
+ // still a true reading of the account it was taken for, and whoever asked is
252
+ // still handed it.
253
+ if (who?.account) _accountOfQuota.set(result, who.account);
250
254
  if (gen !== _generation) return result;
251
255
  _cache = result;
252
256
  _cacheAt = at;
@@ -303,6 +307,20 @@ async function storeQuota() {
303
307
  }
304
308
  }
305
309
 
310
+ // Which account a published reading was taken for, as `{ email,
311
+ // organizationUuid }` — beside the reading, like `_accountOfReading` below, so
312
+ // the panel is not sent an identity it has no use for. Only readings taken this
313
+ // time are tagged: one held over and re-served as `stale` is a copy, and says
314
+ // nothing new about anybody.
315
+ const _accountOfQuota = new WeakMap();
316
+
317
+ /** The account a reading fetchClaudeQuota returned was taken for, or null when
318
+ * the deck could not tell — for account-watch.mjs, whose notifications are
319
+ * about one account and must never land on another. */
320
+ export function quotaAccount(reading) {
321
+ return (reading && typeof reading === "object" && _accountOfQuota.get(reading)) || null;
322
+ }
323
+
306
324
  // Which account a store reading is about, for the inventory read it starts.
307
325
  // Beside the reading rather than on it: the reading is sent to the browser as
308
326
  // it is, and an identity is not something the panel needs. Weak, so a reading
@@ -25,9 +25,12 @@ function pageCount() {
25
25
 
26
26
  /** A notification for the desktop app to raise as itself, instead of the OS
27
27
  * helper raising it as Script Editor (osascript) or PowerShell. */
28
- function notifyTrays(title, body, { chime = null, who = null } = {}) {
28
+ function notifyTrays(title, body, { chime = null, who = null, silent = false } = {}) {
29
29
  // The app names itself above the notification, so the title is the session.
30
- const line = `event: notify\ndata: ${JSON.stringify({ title: who ?? title, body, chime })}\n\n`;
30
+ // `silent` only when asked for: the account notifications make no sound, and
31
+ // an app that did not send it would get the system's default one.
32
+ const frame = silent === true ? { title: who ?? title, body, chime, silent: true } : { title: who ?? title, body, chime };
33
+ const line = `event: notify\ndata: ${JSON.stringify(frame)}\n\n`;
31
34
  for (const res of trayClients) writeSse(res, line);
32
35
  return true;
33
36
  }