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.
- package/README.md +12 -4
- package/dist/web/assets/{BrowserWatchModal-CwkyKvpa.js → BrowserWatchModal-Pdx1qMyq.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-D8YK0c4C.js → UsageHistoryModal-DdeJQWA7.js} +1 -1
- package/dist/web/assets/index-FnWpX0X5.js +309 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/server/account-notify.mjs +558 -0
- package/src/server/account-projects.mjs +89 -20
- 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/lan-engine.mjs +16 -4
- package/src/server/quota.mjs +18 -0
- package/src/server/sse-clients.mjs +5 -2
- package/src/server/transcript-scan.mjs +206 -40
- package/dist/web/assets/index-5Bss6_uX.js +0 -309
|
@@ -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,
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
//
|
|
233
|
-
//
|
|
234
|
-
// — see
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
|
|
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
|
|
508
|
-
// directories with no transcript left, so
|
|
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
|
-
|
|
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
|
|
@@ -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,
|
|
1033
|
-
//
|
|
1034
|
-
// halves of a merged row read the same
|
|
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;
|
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
|
}
|