ccdeck 3.38.2 → 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.
@@ -33,7 +33,9 @@ import { readSwapLog, accountAtTime, trackedSince, seedActive, markGap } from ".
33
33
  // and a session's card cannot disagree about which lines billed anything, nor
34
34
  // about the speed they were billed at — see transcript-scan.mjs.
35
35
  import {
36
- billedSpeed, hasSpend, MAX_SPEEDS_PER_MODEL, repeatsRequestUsage, UNRECOGNISED_SPEED,
36
+ billedSpeed, hasSpend, MAX_SPEEDS_PER_MODEL, UNRECOGNISED_SPEED,
37
+ requestBilling, newRequestLedger, noteRequestRecord, streamingRequestId,
38
+ BILL_LINE, REBILL_REQUEST, SKIP_LINE,
37
39
  } from "./transcript-scan.mjs";
38
40
 
39
41
  /** The pseudo-account for messages the swap log cannot place — everything
@@ -41,7 +43,7 @@ import {
41
43
  * a per-account total is never quietly inflated by work that is not that
42
44
  * account's. A NUL keeps it from ever colliding with a real `email@@org` key. */
43
45
  export const UNATTRIBUTED = "\u0000unattributed";
44
- const STATE_VERSION = 4;
46
+ const STATE_VERSION = 6;
45
47
 
46
48
  /** Where the tally and cursors live, beside cswap-auto's own state. */
47
49
  export function statePath(home = homedir()) {
@@ -104,6 +106,16 @@ function addInto(dst, src) {
104
106
  return dst;
105
107
  }
106
108
 
109
+ /** Take `src`'s six counters back out of `dst`. */
110
+ function takeFrom(dst, src) {
111
+ dst.i -= src.i; dst.o -= src.o; dst.cr -= src.cr;
112
+ dst.cc -= src.cc; dst.c1h -= src.c1h; dst.c5m -= src.c5m;
113
+ }
114
+
115
+ function isZero(c) {
116
+ return !c.i && !c.o && !c.cr && !c.cc && !c.c1h && !c.c5m;
117
+ }
118
+
107
119
  /** The share of `counters` billed at `speed` (#754), created on first sight:
108
120
  * `s[speed]`, a SUBSET of the counters it sits on, the way the 1h/5m split is
109
121
  * of `cc`, so the totals read exactly as they did and the client prices the
@@ -112,11 +124,18 @@ function addInto(dst, src) {
112
124
  * past the cap is kept as UNRECOGNISED_SPEED, which no rate table names, so it
113
125
  * is counted and left unpriced rather than priced at the standard rate. */
114
126
  function speedShareOf(counters, speed) {
127
+ const key = speedShareKey(counters, speed);
128
+ return counters.s[key];
129
+ }
130
+
131
+ /** The key `speed`'s share is kept under in `counters`, the share created on
132
+ * first sight — see speedShareOf. */
133
+ function speedShareKey(counters, speed) {
115
134
  const shares = counters.s ??= {};
116
- if (Object.hasOwn(shares, speed)) return shares[speed];
135
+ if (Object.hasOwn(shares, speed)) return speed;
117
136
  const key = Object.keys(shares).length < MAX_SPEEDS_PER_MODEL ? speed : UNRECOGNISED_SPEED;
118
137
  if (!Object.hasOwn(shares, key)) shares[key] = zero();
119
- return shares[key];
138
+ return key;
120
139
  }
121
140
 
122
141
  function zero() { return { i: 0, o: 0, cr: 0, cc: 0, c1h: 0, c5m: 0 }; }
@@ -223,22 +242,32 @@ export function projectPath(cwd) {
223
242
  *
224
243
  * `timeline` is the sorted swap log; the account is whoever it says was active
225
244
  * at this line's timestamp, or UNATTRIBUTED when the timestamp precedes it.
226
- * `from` names the transcript's project directory — see folderOf.
245
+ * `from` names the transcript's project directory — see folderOf — and, as
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).
227
250
  */
228
251
  export function foldLine(tally, line, timeline, from) {
229
252
  if (!line || !line.includes('"usage"')) return;
230
253
  let obj = null;
231
254
  try { obj = JSON.parse(line); } catch { return; }
232
- // Claude writes one assistant record for each content block in a request,
233
- // and every block repeats the request's usage, so only block 0 is billable
234
- // — see repeatsRequestUsage.
235
- if (repeatsRequestUsage(obj)) return;
255
+ // A request is billed once, from its final record, which in a subagent's
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.
259
+ const requests = from?.requests ?? 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);
263
+ if (billing === SKIP_LINE) return;
236
264
  const usage = obj?.message?.usage;
237
265
  if (!usage || typeof usage !== "object") return;
238
266
  const ts = Date.parse(obj?.timestamp);
239
267
  if (!Number.isFinite(ts)) return;
240
268
  const c = countersFrom(usage);
241
269
  if (!hasSpend(asUsageTotals(c))) return; // a usage block that billed nothing
270
+ if (billing === REBILL_REQUEST) refund(tally, ledger.streaming);
242
271
  const model = typeof obj?.message?.model === "string" ? obj.message.model : "";
243
272
  const lineCwd = (typeof obj?.cwd === "string" && obj.cwd) ? obj.cwd : "";
244
273
  const cwd = projectPath(folderOf(lineCwd, from)); // a worktree counts under the repo it checks out
@@ -251,7 +280,40 @@ export function foldLine(tally, line, timeline, from) {
251
280
  // board prices it so (#754). Kept as a share rather than a model of its own,
252
281
  // so a row's tokens and its model read exactly as before.
253
282
  const speed = billedSpeed(obj);
254
- if (speed) addInto(speedShareOf(counters, speed), c);
283
+ const share = speed ? speedShareKey(counters, speed) : null;
284
+ if (share) addInto(counters.s[share], c);
285
+ if (billing === REBILL_REQUEST) dropIfEmpty(tally, ledger.streaming);
286
+ if (billing === BILL_LINE || !requests) return;
287
+ // What a request still streaming was charged, and where, so its final
288
+ // record can take that back, in this pass or a later one.
289
+ const id = streamingRequestId(obj);
290
+ requests[from.path].streaming = id === null ? null : { id, at: [key, cwd, day, model], s: share, c };
291
+ }
292
+
293
+ /** Take a streaming request's charge back out of the row it was charged to,
294
+ * for its later record to charge in its place. A row pruned since has
295
+ * nothing left to take back. */
296
+ function refund(tally, { at: [key, cwd, day, model], s, c }) {
297
+ const counters = tally[key]?.[cwd]?.[day]?.[model];
298
+ if (!counters) return;
299
+ takeFrom(counters, c);
300
+ if (s && counters.s?.[s]) takeFrom(counters.s[s], c);
301
+ }
302
+
303
+ /** Remove what a refund left at zero — a speed share, or a whole row whose
304
+ * request's final record landed on another day or account — so the report
305
+ * never lists an empty row. */
306
+ function dropIfEmpty(tally, { at: [key, cwd, day, model], s }) {
307
+ const days = tally[key]?.[cwd];
308
+ const counters = days?.[day]?.[model];
309
+ if (!counters) return;
310
+ if (s && counters.s?.[s] && isZero(counters.s[s])) delete counters.s[s];
311
+ if (counters.s && !Object.keys(counters.s).length) delete counters.s;
312
+ if (!isZero(counters)) return;
313
+ delete days[day][model];
314
+ if (!Object.keys(days[day]).length) delete days[day];
315
+ if (!Object.keys(days).length) delete tally[key][cwd];
316
+ if (!Object.keys(tally[key]).length) delete tally[key];
255
317
  }
256
318
 
257
319
  /** Read the bytes of `path` from `start` to `size`, folding each COMPLETE line,
@@ -423,7 +485,7 @@ export function createProjectRollup({
423
485
  setInterval: setIv = setInterval,
424
486
  clearInterval: clearIv = clearInterval,
425
487
  } = {}) {
426
- let state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {} };
488
+ let state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {}, requests: {} };
427
489
  let timer = null;
428
490
  let running = false;
429
491
  let dirty = false;
@@ -437,14 +499,19 @@ export function createProjectRollup({
437
499
  if (disk && disk.version === STATE_VERSION && disk.tally && disk.cursors) {
438
500
  state = disk;
439
501
  state.folders ??= {};
440
- } else if (disk && (disk.version === 1 || disk.version === 2 || disk.version === 3)) {
502
+ state.requests ??= {};
503
+ } else if (disk && [1, 2, 3, 4, 5].includes(disk.version)) {
441
504
  // Version 1 counted every API content block; version 2 keyed each row
442
505
  // by the line's own cwd, a row per folder the agent cd'd into (#1278);
443
506
  // version 3 kept no speed, so every fast turn read at the standard
444
- // rate. None of them can be re-keyed, since it no longer knows which
445
- // transcript a count came from. Keep the heartbeat so a restart gap
446
- // remains fenced, but rebuild the tally from transcripts.
447
- state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {}, lastAlive: disk.lastAlive };
507
+ // rate; version 4 billed a subagent's request from its streaming
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.
514
+ state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {}, requests: {}, lastAlive: disk.lastAlive };
448
515
  dirty = true;
449
516
  }
450
517
  } catch { /* first run: empty state */ }
@@ -474,9 +541,9 @@ export function createProjectRollup({
474
541
  // resync the cursor to the current end: the replaced content is lost, never
475
542
  // counted twice. Transcripts append and do not shrink in normal use, so this
476
543
  // is the rare, safe side to err on.
477
- if (st.size < offset) { state.cursors[path] = st.size; dirty = true; return; }
544
+ if (st.size < offset) { state.cursors[path] = st.size; delete state.requests[path]; dirty = true; return; }
478
545
  if (st.size <= offset) return;
479
- const from = { slug, folders: state.folders };
546
+ const from = { slug, folders: state.folders, requests: state.requests, path };
480
547
  let fh = null;
481
548
  try {
482
549
  fh = await open(path, "r");
@@ -504,9 +571,11 @@ export function createProjectRollup({
504
571
  const timeline = await readSwapLog(swapLog);
505
572
  const files = await listTranscripts(roots);
506
573
  for (const [path, slug] of files) await foldFile(path, slug, timeline);
507
- // Forget cursors for files that are gone, and the folders of project
508
- // directories with no transcript left, so neither map grows forever.
574
+ // Forget the cursors and request ledgers of files that are gone, and
575
+ // the folders of project directories with no transcript left, so none of
576
+ // the three maps grows forever.
509
577
  for (const p of Object.keys(state.cursors)) if (!files.has(p)) { delete state.cursors[p]; dirty = true; }
578
+ for (const p of Object.keys(state.requests)) if (!files.has(p)) { delete state.requests[p]; dirty = true; }
510
579
  const slugs = new Set(files.values());
511
580
  for (const s of Object.keys(state.folders)) if (!slugs.has(s)) { delete state.folders[s]; dirty = true; }
512
581
  // Heartbeat: record that the deck was alive now, so a later boot can see
@@ -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
@@ -41,7 +41,7 @@
41
41
  // anything.
42
42
  import {
43
43
  addTrusted, ANNOUNCE_MS, credentialAad, dropTrusted, identityFrom, open, peerWhy, plan, PRESENT_MS,
44
- stillListed, transferChallenge, trustedPeer,
44
+ sharedWith, stillListed, transferChallenge, trustedPeer,
45
45
  } from "./lan-sync.mjs";
46
46
  import { mintInvite, readInvite } from "./lan-invite.mjs";
47
47
  import { createInviteOffer } from "./lan-invite-offer.mjs";
@@ -1028,13 +1028,25 @@ export function createEngine({
1028
1028
  had.met = had.met || row.met;
1029
1029
  if (row.name && !had.name) had.name = row.name;
1030
1030
  };
1031
+ // WHAT IS TICKED HERE AND NOT OFFERED THAT DECK, so its dialog draws no
1032
+ // lane going out for it: what an arrival ticked, when the accept switch
1033
+ // paired it — see sharedWith in lan-sync.mjs. The page never sees `onward`
1034
+ // or which pin a switch made, so the engine says the difference itself,
1035
+ // and only where there is one, which on most decks is nowhere.
1036
+ const notOfferedTo = id => {
1037
+ const offered = sharedWith(cfg, id);
1038
+ const held = cfg.shared.filter(key => !offered.includes(key));
1039
+ return held.length ? { notOffered: held } : {};
1040
+ };
1031
1041
  // WHAT THE DECK'S OWN DIALOG DRAWS, by identity: the card it sent,
1032
- // the logins it offered last, and when somebody here said yes. All
1033
- // three are keyed by the fingerprint that proved itself, so both
1034
- // halves of a merged row read the same answer.
1042
+ // the logins it offered last, when somebody here said yes, and what
1043
+ // this deck does not offer it. All four are keyed by the fingerprint
1044
+ // that proved itself, so both halves of a merged row read the same
1045
+ // answer.
1035
1046
  const card = id => ({
1036
1047
  ...heardOf(id),
1037
1048
  pairedAt: trustedPeer(cfg.trusted, id)?.at ?? null,
1049
+ ...notOfferedTo(id),
1038
1050
  });
1039
1051
  for (const p of [...beacon.peers.values(), ...dials.rows()]) {
1040
1052
  if (!stillListed(p, now())) continue;
@@ -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
  }