@aaa-sentinel/ai-usage 0.2.0 → 0.2.2

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 CHANGED
@@ -226,9 +226,11 @@ systemd=true
226
226
  usage. Remove or change it and the agent does not put it back; `logout` restores yours.
227
227
  - **GitHub Copilot.** Needs the [GitHub CLI](https://cli.github.com) signed in to the account that
228
228
  holds the Copilot plan: `gh auth login`, then `gh auth status` must show that account
229
- (`winget install GitHub.cli` on Windows, `brew install gh` on macOS). Premium requests by model
229
+ (`winget install GitHub.cli` on Windows, `brew install gh` on macOS). The default `gh` login
230
+ cannot read billing usage: add the `user` scope once with `gh auth refresh -h github.com -s user`.
231
+ Premium requests by model
230
232
  are read through `gh`; GitHub does not report tokens. This is account usage, not device usage, so
231
- two laptops do not double it. If `gh` answers 401 or 403, an empty row is not written. The
233
+ two laptops do not double it. If `gh` answers 401 or 403, or asks for the `user` scope, an empty row is not written. The
232
234
  month's AI credits are read the same way and only their total is sent, to compare with the
233
235
  credits the plan includes.
234
236
  - **Codex.** Session counters, only when Codex is signed in with the ChatGPT subscription
@@ -276,7 +278,7 @@ measures Codex, not the ChatGPT chat.
276
278
  | Exit code `3` — *No se pudo registrar el arranque* | Windows: a company policy may block Task Scheduler, ask IT. macOS: run `login` in the Terminal of your session, not over SSH. Linux/WSL: no systemd user instance, see [Automatic start](#automatic-start). Nothing is left installed |
277
279
  | Exit code `4` — *El servidor no reconoce el token* | The token was rotated or the connection was revoked or removed. Get a new token and repeat step 4 |
278
280
  | *El uso de esta herramienta no corresponde a esta conexión…* | The token was enrolled for a different tool. Use the token of the connection that matches this machine's tool |
279
- | Exit code `5` | Copilot: install `gh` and `gh auth login` to the account that holds Copilot |
281
+ | Exit code `5` | Copilot: install `gh`, `gh auth login` to the account that holds Copilot, then `gh auth refresh -h github.com -s user` |
280
282
  | `sentinel-usage: fetch failed` | No route to `https://api.aaa-sentinel.com` on port 443: proxy or firewall. Ask IT. For npm behind a proxy: `npm config set proxy …` and `npm config set https-proxy …` |
281
283
  | The card stays on *Silent agent* | No usage this period yet. Use the tool, run `sentinel-usage report` and read its message |
282
284
 
package/dist/cli.js CHANGED
@@ -63,7 +63,7 @@ ${args.map(cmdArg).join(" ")}\r
63
63
  <Actions Context="Author"><Exec><Command>${xmlEscape(windowsCmd)}</Command></Exec></Actions>\r
64
64
  </Task>\r
65
65
  `;
66
- writeFileSync(logonXml, Buffer.from(`\uFEFF${xml}`, "utf16le"));
66
+ writeFileSync(logonXml, Buffer.from(`${String.fromCharCode(65279)}${xml}`, "utf16le"));
67
67
  try {
68
68
  await run(exec, "schtasks", ["/Create", "/F", "/TN", TASK_LOGON, "/XML", logonXml]);
69
69
  } finally {
@@ -190,6 +190,13 @@ WantedBy=timers.target
190
190
 
191
191
  // src/quota.ts
192
192
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, renameSync, writeFileSync as writeFileSync2 } from "node:fs";
193
+
194
+ // src/bom.ts
195
+ function stripBom(text) {
196
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
197
+ }
198
+
199
+ // src/quota.ts
193
200
  import { dirname } from "node:path";
194
201
  var MAX_RESETS_PER_DAY = 48;
195
202
  function num(value) {
@@ -279,7 +286,7 @@ function statusLineCommand(args, platform) {
279
286
  function parseSettings(text) {
280
287
  if (!text.trim()) return {};
281
288
  try {
282
- return asRecord(JSON.parse(text));
289
+ return asRecord(JSON.parse(stripBom(text)));
283
290
  } catch {
284
291
  return null;
285
292
  }
@@ -387,7 +394,7 @@ function asRecord2(value) {
387
394
  }
388
395
  function mergeCursorHook(existing, command) {
389
396
  let parsed = {};
390
- if (existing.trim()) parsed = JSON.parse(existing);
397
+ if (existing.trim()) parsed = JSON.parse(stripBom(existing));
391
398
  const root = asRecord2(parsed) ?? {};
392
399
  const hooks = asRecord2(root["hooks"]) ?? {};
393
400
  const stopRaw = hooks["stop"];
@@ -402,7 +409,7 @@ function mergeCursorHook(existing, command) {
402
409
  }
403
410
  function removeCursorHook(existing, command) {
404
411
  if (!existing.trim()) return existing;
405
- const root = asRecord2(JSON.parse(existing));
412
+ const root = asRecord2(JSON.parse(stripBom(existing)));
406
413
  if (!root) return existing;
407
414
  const hooks = asRecord2(root["hooks"]);
408
415
  if (!hooks || !Array.isArray(hooks["stop"])) return existing;
@@ -952,6 +959,10 @@ function readCodexIncrement(dir, previous) {
952
959
  // src/copilot.ts
953
960
  var MODEL_PATTERN4 = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
954
961
  var API_VERSION = "2026-03-10";
962
+ function missingUserScope(result) {
963
+ return result.code !== 0 && /needs the "user" scope/i.test(`${result.stdout}
964
+ ${result.stderr}`);
965
+ }
955
966
  function authFailure(result) {
956
967
  if (result.code === 0) return false;
957
968
  const text = `${result.stdout}
@@ -1025,6 +1036,7 @@ async function ghJson(gh, path) {
1025
1036
  `X-GitHub-Api-Version: ${API_VERSION}`,
1026
1037
  path
1027
1038
  ]);
1039
+ if (missingUserScope(result)) return { auth: true, scope: true, payload: null };
1028
1040
  if (authFailure(result)) return { auth: true, payload: null };
1029
1041
  if (result.code !== 0) return { auth: false, payload: null };
1030
1042
  try {
@@ -1070,6 +1082,7 @@ async function readCopilotDay(gh, day) {
1070
1082
  const query = `year=${year}&month=${Number(month)}&day=${Number(date)}`;
1071
1083
  const base = `/users/${login2}/settings/billing`;
1072
1084
  const premium = await ghJson(gh, `${base}/premium_request/usage?${query}`);
1085
+ if (premium.scope) return { status: "scope" };
1073
1086
  if (premium.auth) return { status: "auth" };
1074
1087
  const rows = rowsFromCopilotUsage(premium.payload, day);
1075
1088
  return rows.length > 0 ? { status: "rows", rows } : { status: "empty" };
@@ -1242,7 +1255,7 @@ function forgetLocalState(paths, source) {
1242
1255
  if (source === "claude_code" && paths.quotaPath) rmSync2(paths.quotaPath, { force: true });
1243
1256
  }
1244
1257
  async function login(options) {
1245
- const token = options.token.replace(/^/, "").trim();
1258
+ const token = stripBom(options.token).trim();
1246
1259
  if (!TOKEN_PATTERN.test(token)) {
1247
1260
  return { code: EXIT_USAGE, message: "El token no tiene el formato de un alta de uso." };
1248
1261
  }
@@ -1448,8 +1461,8 @@ async function reportConnection(options, connection, source, context) {
1448
1461
  const credits = await readCopilotMonthCredits(options.gh, today);
1449
1462
  for (const day of [previousUtcDay(today), today]) {
1450
1463
  const read = await readCopilotDay(options.gh, day);
1451
- if (read.status === "auth") {
1452
- copilotAuth = true;
1464
+ if (read.status === "auth" || read.status === "scope") {
1465
+ copilotAuth = read.status;
1453
1466
  break;
1454
1467
  }
1455
1468
  if (read.status === "unavailable") {
@@ -1497,7 +1510,8 @@ async function reportConnection(options, connection, source, context) {
1497
1510
  if (outcome.error) return run2({ code: EXIT_REPORT, message: outcome.error });
1498
1511
  if (outcome.mismatch) return run2({ code: 0, message: "El uso de esta herramienta no corresponde a esta conexi\xF3n y no se ha subido." });
1499
1512
  if (copilotAuth && !outcome.acceptedAny) {
1500
- return run2({ code: EXIT_COPILOT, message: "gh no tiene permiso para leer el uso de Copilot. No se ha escrito una fila vac\xEDa." });
1513
+ const fix = copilotAuth === "scope" ? "ejecuta gh auth refresh -h github.com -s user" : "inicia sesi\xF3n con gh auth login en la cuenta de Copilot";
1514
+ return run2({ code: EXIT_COPILOT, message: `gh no tiene permiso para leer el uso de Copilot: ${fix}. No se ha escrito una fila vac\xEDa.` });
1501
1515
  }
1502
1516
  if (copilotUnavailable) {
1503
1517
  return run2({ code: EXIT_COPILOT, message: "No se pudo usar gh. Hace falta GitHub CLI con gh auth login en la cuenta de Copilot." });
@@ -1603,7 +1617,7 @@ var runPreviousWithShell = (command, input) => {
1603
1617
  function statusLine(options) {
1604
1618
  let parsed = null;
1605
1619
  try {
1606
- parsed = JSON.parse(options.input);
1620
+ parsed = JSON.parse(stripBom(options.input));
1607
1621
  } catch {
1608
1622
  parsed = null;
1609
1623
  }
package/dist/hook.js CHANGED
@@ -1,8 +1,16 @@
1
1
  // src/hook.ts
2
+ import { readFileSync } from "node:fs";
3
+
4
+ // src/cursor.ts
2
5
  import { randomBytes } from "node:crypto";
3
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { mkdirSync, writeFileSync } from "node:fs";
4
7
  import { join } from "node:path";
5
8
 
9
+ // src/bom.ts
10
+ function stripBom(text) {
11
+ return text.charCodeAt(0) === 65279 ? text.slice(1) : text;
12
+ }
13
+
6
14
  // src/cursor.ts
7
15
  var MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
8
16
  var TOKEN_MAX = 1e12;
@@ -12,9 +20,9 @@ function finiteInt(value) {
12
20
  if (rounded > TOKEN_MAX) return void 0;
13
21
  return rounded;
14
22
  }
15
- function parseStopPayload(payload2, at) {
16
- if (!payload2 || typeof payload2 !== "object" || Array.isArray(payload2)) return null;
17
- const record = payload2;
23
+ function parseStopPayload(payload, at) {
24
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
25
+ const record = payload;
18
26
  if (record["hook_event_name"] !== "stop") return null;
19
27
  const modelRaw = record["model"];
20
28
  const model = typeof modelRaw === "string" && MODEL_PATTERN.test(modelRaw) ? modelRaw : void 0;
@@ -22,25 +30,45 @@ function parseStopPayload(payload2, at) {
22
30
  const output = finiteInt(record["output_tokens"]);
23
31
  const cacheRead = finiteInt(record["cache_read_tokens"]);
24
32
  const cacheWrite = finiteInt(record["cache_write_tokens"]);
25
- const event2 = { at, tokensKnown: false };
26
- if (model) event2.model = model;
33
+ const event = { at, tokensKnown: false };
34
+ if (model) event.model = model;
27
35
  if (input !== void 0 && output !== void 0 && cacheRead !== void 0 && cacheWrite !== void 0) {
28
- event2.tokensKnown = true;
29
- event2.inputTokens = Math.max(0, input - cacheRead - cacheWrite);
30
- event2.outputTokens = output;
31
- event2.cacheReadTokens = cacheRead;
32
- event2.cacheWriteTokens = cacheWrite;
36
+ event.tokensKnown = true;
37
+ event.inputTokens = Math.max(0, input - cacheRead - cacheWrite);
38
+ event.outputTokens = output;
39
+ event.cacheReadTokens = cacheRead;
40
+ event.cacheWriteTokens = cacheWrite;
33
41
  }
34
- return event2;
42
+ return event;
35
43
  }
36
- function serializeSpoolEvent(event2) {
37
- const line = { at: event2.at, tokensKnown: event2.tokensKnown };
38
- if (event2.model) line["model"] = event2.model;
39
- if (event2.tokensKnown) {
40
- line["inputTokens"] = event2.inputTokens ?? 0;
41
- line["outputTokens"] = event2.outputTokens ?? 0;
42
- line["cacheReadTokens"] = event2.cacheReadTokens ?? 0;
43
- line["cacheWriteTokens"] = event2.cacheWriteTokens ?? 0;
44
+ function recordStop(raw2, spool2, at) {
45
+ let payload;
46
+ try {
47
+ payload = JSON.parse(stripBom(raw2));
48
+ } catch {
49
+ return false;
50
+ }
51
+ const event = parseStopPayload(payload, at);
52
+ if (!event) return false;
53
+ try {
54
+ mkdirSync(spool2, { recursive: true, mode: 448 });
55
+ writeFileSync(join(spool2, `${Date.now()}-${randomBytes(4).toString("hex")}.json`), serializeSpoolEvent(event), {
56
+ flag: "wx",
57
+ mode: 384
58
+ });
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+ function serializeSpoolEvent(event) {
65
+ const line = { at: event.at, tokensKnown: event.tokensKnown };
66
+ if (event.model) line["model"] = event.model;
67
+ if (event.tokensKnown) {
68
+ line["inputTokens"] = event.inputTokens ?? 0;
69
+ line["outputTokens"] = event.outputTokens ?? 0;
70
+ line["cacheReadTokens"] = event.cacheReadTokens ?? 0;
71
+ line["cacheWriteTokens"] = event.cacheWriteTokens ?? 0;
44
72
  }
45
73
  return JSON.stringify(line);
46
74
  }
@@ -48,20 +76,10 @@ function serializeSpoolEvent(event2) {
48
76
  // src/hook.ts
49
77
  var spool = process.argv[2];
50
78
  if (!spool) process.exit(0);
51
- var payload;
52
- try {
53
- payload = JSON.parse(readFileSync(0, "utf8"));
54
- } catch {
55
- process.exit(0);
56
- }
57
- var event = parseStopPayload(payload, (/* @__PURE__ */ new Date()).toISOString());
58
- if (!event) process.exit(0);
79
+ var raw;
59
80
  try {
60
- mkdirSync(spool, { recursive: true, mode: 448 });
61
- writeFileSync(join(spool, `${Date.now()}-${randomBytes(4).toString("hex")}.json`), serializeSpoolEvent(event), {
62
- flag: "wx",
63
- mode: 384
64
- });
81
+ raw = readFileSync(0, "utf8");
65
82
  } catch {
66
83
  process.exit(0);
67
84
  }
85
+ recordStop(raw, spool, (/* @__PURE__ */ new Date()).toISOString());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaa-sentinel/ai-usage",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Report individual Cursor, Claude Code, Copilot and Codex usage to AAA Sentinel; prompts and tokens stay on the laptop",
5
5
  "license": "MIT",
6
6
  "type": "module",