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.
- package/README.md +12 -4
- package/dist/web/assets/{BrowserWatchModal-DTjbVOvo.js → BrowserWatchModal-Pdx1qMyq.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-BcXa5ICK.js → UsageHistoryModal-DdeJQWA7.js} +1 -1
- package/dist/web/assets/{index-ClJbck-M.js → index-FnWpX0X5.js} +53 -53
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/server/account-notify.mjs +558 -0
- package/src/server/account-projects.mjs +24 -18
- package/src/server/account-watch.mjs +169 -0
- package/src/server/codex-quota.mjs +13 -0
- package/src/server/cswap-auto-loop.mjs +10 -2
- package/src/server/deck-prefs.mjs +26 -1
- package/src/server/event-pipeline.mjs +27 -5
- package/src/server/index.mjs +7 -0
- package/src/server/quota.mjs +18 -0
- package/src/server/sse-clients.mjs +5 -2
- package/src/server/transcript-scan.mjs +114 -28
|
@@ -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,
|
|
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 =
|
|
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
|
|
246
|
-
*
|
|
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
|
|
254
|
-
//
|
|
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
|
|
257
|
-
const billing = requestBilling(obj,
|
|
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
|
-
|
|
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)
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/src/server/index.mjs
CHANGED
|
@@ -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
|
package/src/server/quota.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|