@aaa-sentinel/ai-usage 0.1.2 → 0.2.1
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 +23 -8
- package/dist/cli.js +550 -148
- package/dist/hook.js +51 -33
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -39,7 +39,12 @@ path of a transcript. The CLI reads only the tool its connection is for: a conne
|
|
|
39
39
|
Cursor never opens a Claude Code transcript, and `gh` is not called unless the connection is
|
|
40
40
|
Copilot.
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
For quota headroom, a Claude Code connection also sends the day's peak share of the 5-hour and
|
|
43
|
+
weekly limits and the end time of any 5-hour window that reached 100 %; a Copilot connection sends
|
|
44
|
+
the month's total of AI credits, without amounts.
|
|
45
|
+
|
|
46
|
+
The CLI sends no money figures. AAA Sentinel shows what the usage would cost at API list prices,
|
|
47
|
+
always apart from billed or estimated spend and never added to them.
|
|
43
48
|
|
|
44
49
|
## Setup, step by step
|
|
45
50
|
|
|
@@ -215,17 +220,25 @@ systemd=true
|
|
|
215
220
|
a dev container or a remote machine over SSH, install the agent there. A cost figure that
|
|
216
221
|
sometimes comes with the session is not uploaded. A turn here is one call to the model, so a
|
|
217
222
|
reply that uses tools counts several.
|
|
223
|
+
Claude Code passes its 5-hour and weekly limits only to the status line, so the first `report`
|
|
224
|
+
sets `sentinel-usage statusline` as the status line in Claude Code's `settings.json`. If you
|
|
225
|
+
already had one, it keeps running and is what you see; if not, you see the 5-hour and weekly
|
|
226
|
+
usage. Remove or change it and the agent does not put it back; `logout` restores yours.
|
|
218
227
|
- **GitHub Copilot.** Needs the [GitHub CLI](https://cli.github.com) signed in to the account that
|
|
219
228
|
holds the Copilot plan: `gh auth login`, then `gh auth status` must show that account
|
|
220
229
|
(`winget install GitHub.cli` on Windows, `brew install gh` on macOS). Premium requests by model
|
|
221
230
|
are read through `gh`; GitHub does not report tokens. This is account usage, not device usage, so
|
|
222
|
-
two laptops do not double it. If `gh` answers 401 or 403, an empty row is not written.
|
|
231
|
+
two laptops do not double it. If `gh` answers 401 or 403, an empty row is not written. The
|
|
232
|
+
month's AI credits are read the same way and only their total is sent, to compare with the
|
|
233
|
+
credits the plan includes.
|
|
223
234
|
- **Codex.** Session counters, only when Codex is signed in with the ChatGPT subscription
|
|
224
235
|
(`codex login` → sign in with ChatGPT). If its auth file shows an API key, nothing is attributed
|
|
225
236
|
to the individual account: that spend belongs to the organization connection.
|
|
226
237
|
|
|
227
|
-
Each user on a computer
|
|
228
|
-
the
|
|
238
|
+
Each user on a computer can report **one connection per tool**: run `login` once with each token,
|
|
239
|
+
for example the Claude Code one and the Cursor one, and both report. A second token for the same
|
|
240
|
+
tool replaces the first, because the local usage of a tool does not say which account it came from.
|
|
241
|
+
Usage from before `login` is never uploaded.
|
|
229
242
|
|
|
230
243
|
## Reading the figures
|
|
231
244
|
|
|
@@ -296,10 +309,11 @@ start uses the Node that `login` ran with.
|
|
|
296
309
|
|
|
297
310
|
| Command | What it does |
|
|
298
311
|
| --- | --- |
|
|
299
|
-
| `sentinel-usage login` |
|
|
300
|
-
| `sentinel-usage report` | Uploads the usage that is new since
|
|
301
|
-
| `sentinel-usage status` | Says whether the agent and the startup task are installed, which tool
|
|
302
|
-
| `sentinel-usage logout` | Removes the startup task, the Cursor hook and the
|
|
312
|
+
| `sentinel-usage login` | Adds the token read from standard input, installs the Cursor hook and registers the startup task. A token for a tool already reported replaces it. If any step fails, nothing is left installed |
|
|
313
|
+
| `sentinel-usage report` | Uploads, for each connection, the usage that is new since its last accepted day. Run by the startup task; safe to run by hand |
|
|
314
|
+
| `sentinel-usage status` | Says whether the agent and the startup task are installed and, for each connection, which tool it reports and its last accepted day. It does not print tokens |
|
|
315
|
+
| `sentinel-usage logout` | Removes every connection, the startup task, the Cursor hook and the status line from this machine |
|
|
316
|
+
| `sentinel-usage logout cursor` | Removes only that tool (`cursor`, `claude_code`, `github_copilot` or `codex`); the others keep reporting |
|
|
303
317
|
|
|
304
318
|
## Environment variables
|
|
305
319
|
|
|
@@ -313,6 +327,7 @@ macOS and Linux it does not read the variables of your shell.
|
|
|
313
327
|
| `SENTINEL_USAGE_CURSOR_HOOKS` | Path of Cursor's hooks file. Defaults to `~/.cursor/hooks.json` |
|
|
314
328
|
| `SENTINEL_USAGE_CLAUDE_DIR` | Claude Code projects folder. Defaults to `projects` under `CLAUDE_CONFIG_DIR` or `~/.claude` |
|
|
315
329
|
| `SENTINEL_USAGE_CODEX_HOME` | Codex folder. Defaults to `CODEX_HOME` or `~/.codex` |
|
|
330
|
+
| `SENTINEL_USAGE_NO_STATUSLINE` | With `1`, the Claude Code status line is not set and its quota headroom is not measured |
|
|
316
331
|
|
|
317
332
|
The token is stored in `usage.json` inside `SENTINEL_USAGE_HOME`. On macOS and Linux the file is
|
|
318
333
|
readable only by your user (mode 600); on Windows it inherits the permissions of your profile
|
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { execFile } from "node:child_process";
|
|
5
5
|
import { homedir, userInfo } from "node:os";
|
|
6
|
-
import { dirname as
|
|
6
|
+
import { dirname as dirname3, join as join5 } from "node:path";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { promisify } from "node:util";
|
|
9
9
|
|
|
@@ -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(
|
|
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 {
|
|
@@ -188,10 +188,144 @@ WantedBy=timers.target
|
|
|
188
188
|
};
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
// src/quota.ts
|
|
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
|
|
200
|
+
import { dirname } from "node:path";
|
|
201
|
+
var MAX_RESETS_PER_DAY = 48;
|
|
202
|
+
function num(value) {
|
|
203
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : void 0;
|
|
204
|
+
}
|
|
205
|
+
function asRecord(value) {
|
|
206
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
207
|
+
}
|
|
208
|
+
function readQuotaState(path) {
|
|
209
|
+
if (!existsSync2(path)) return { days: {} };
|
|
210
|
+
try {
|
|
211
|
+
const root = asRecord(JSON.parse(readFileSync2(path, "utf8")));
|
|
212
|
+
const days = {};
|
|
213
|
+
for (const [day, raw] of Object.entries(asRecord(root?.["days"]) ?? {})) {
|
|
214
|
+
const entry = asRecord(raw);
|
|
215
|
+
if (!entry || !/^\d{4}-\d{2}-\d{2}$/.test(day)) continue;
|
|
216
|
+
const parsed = {};
|
|
217
|
+
const five = num(entry["fiveHourPeak"]);
|
|
218
|
+
const seven = num(entry["sevenDayPeak"]);
|
|
219
|
+
if (five !== void 0) parsed.fiveHourPeak = five;
|
|
220
|
+
if (seven !== void 0) parsed.sevenDayPeak = seven;
|
|
221
|
+
const resets = entry["fiveHourLimitResets"];
|
|
222
|
+
if (Array.isArray(resets)) {
|
|
223
|
+
const valid = resets.filter((end) => typeof end === "number" && Number.isInteger(end) && end > 0);
|
|
224
|
+
if (valid.length > 0) parsed.fiveHourLimitResets = valid.slice(-MAX_RESETS_PER_DAY);
|
|
225
|
+
}
|
|
226
|
+
days[day] = parsed;
|
|
227
|
+
}
|
|
228
|
+
const lastRaw = asRecord(root?.["last"]);
|
|
229
|
+
const state = { days };
|
|
230
|
+
if (lastRaw) {
|
|
231
|
+
const last = {};
|
|
232
|
+
const five = num(lastRaw["fiveHour"]);
|
|
233
|
+
const seven = num(lastRaw["sevenDay"]);
|
|
234
|
+
if (five !== void 0) last.fiveHour = five;
|
|
235
|
+
if (seven !== void 0) last.sevenDay = seven;
|
|
236
|
+
state.last = last;
|
|
237
|
+
}
|
|
238
|
+
return state;
|
|
239
|
+
} catch {
|
|
240
|
+
return { days: {} };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function writeQuotaState(path, state) {
|
|
244
|
+
mkdirSync2(dirname(path), { recursive: true, mode: 448 });
|
|
245
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
246
|
+
writeFileSync2(temp, `${JSON.stringify(state)}
|
|
247
|
+
`, { mode: 384 });
|
|
248
|
+
renameSync(temp, path);
|
|
249
|
+
}
|
|
250
|
+
function recordStatusSample(state, input, now) {
|
|
251
|
+
const limits = asRecord(asRecord(input)?.["rate_limits"]);
|
|
252
|
+
if (!limits) return false;
|
|
253
|
+
const fiveHour = asRecord(limits["five_hour"]);
|
|
254
|
+
const sevenDay = asRecord(limits["seven_day"]);
|
|
255
|
+
const five = num(fiveHour?.["used_percentage"]);
|
|
256
|
+
const seven = num(sevenDay?.["used_percentage"]);
|
|
257
|
+
if (five === void 0 && seven === void 0) return false;
|
|
258
|
+
const day = now.toISOString().slice(0, 10);
|
|
259
|
+
const entry = state.days[day] ?? {};
|
|
260
|
+
const before = JSON.stringify(entry);
|
|
261
|
+
if (five !== void 0) entry.fiveHourPeak = Math.max(entry.fiveHourPeak ?? 0, five);
|
|
262
|
+
if (seven !== void 0) entry.sevenDayPeak = Math.max(entry.sevenDayPeak ?? 0, seven);
|
|
263
|
+
const resetsAt = num(fiveHour?.["resets_at"]);
|
|
264
|
+
if (five !== void 0 && five >= 100 && resetsAt !== void 0 && Number.isInteger(resetsAt)) {
|
|
265
|
+
const resets = new Set(entry.fiveHourLimitResets ?? []);
|
|
266
|
+
resets.add(resetsAt);
|
|
267
|
+
entry.fiveHourLimitResets = [...resets].slice(-MAX_RESETS_PER_DAY);
|
|
268
|
+
}
|
|
269
|
+
state.days[day] = entry;
|
|
270
|
+
const lastBefore = JSON.stringify(state.last ?? {});
|
|
271
|
+
state.last = {
|
|
272
|
+
...five !== void 0 ? { fiveHour: five } : state.last?.fiveHour !== void 0 ? { fiveHour: state.last.fiveHour } : {},
|
|
273
|
+
...seven !== void 0 ? { sevenDay: seven } : state.last?.sevenDay !== void 0 ? { sevenDay: state.last.sevenDay } : {}
|
|
274
|
+
};
|
|
275
|
+
return JSON.stringify(entry) !== before || JSON.stringify(state.last) !== lastBefore;
|
|
276
|
+
}
|
|
277
|
+
function defaultStatusText(state) {
|
|
278
|
+
const parts = [];
|
|
279
|
+
if (state.last?.fiveHour !== void 0) parts.push(`5 h ${Math.round(state.last.fiveHour)} %`);
|
|
280
|
+
if (state.last?.sevenDay !== void 0) parts.push(`semana ${Math.round(state.last.sevenDay)} %`);
|
|
281
|
+
return parts.join(" \xB7 ");
|
|
282
|
+
}
|
|
283
|
+
function statusLineCommand(args, platform) {
|
|
284
|
+
return args.map((arg) => platform === "win32" ? arg.replaceAll("\\", "/") : arg).map((arg) => `"${arg.replaceAll('"', '\\"')}"`).join(" ");
|
|
285
|
+
}
|
|
286
|
+
function parseSettings(text) {
|
|
287
|
+
if (!text.trim()) return {};
|
|
288
|
+
try {
|
|
289
|
+
return asRecord(JSON.parse(stripBom(text)));
|
|
290
|
+
} catch {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function statusLineOf(root) {
|
|
295
|
+
const raw = asRecord(root["statusLine"]);
|
|
296
|
+
if (!raw || typeof raw["command"] !== "string") return void 0;
|
|
297
|
+
const setting = { type: typeof raw["type"] === "string" ? raw["type"] : "command", command: raw["command"] };
|
|
298
|
+
if (typeof raw["padding"] === "number") setting.padding = raw["padding"];
|
|
299
|
+
return setting;
|
|
300
|
+
}
|
|
301
|
+
function mergeClaudeStatusLine(existing, command) {
|
|
302
|
+
const root = parseSettings(existing);
|
|
303
|
+
if (!root) return { status: "unreadable" };
|
|
304
|
+
const previous = statusLineOf(root);
|
|
305
|
+
if (previous?.command === command) return { status: "already" };
|
|
306
|
+
const next = { ...root, statusLine: { type: "command", command, ...previous?.padding !== void 0 ? { padding: previous.padding } : {} } };
|
|
307
|
+
return { status: "installed", text: `${JSON.stringify(next, null, 2)}
|
|
308
|
+
`, ...previous ? { previous } : {} };
|
|
309
|
+
}
|
|
310
|
+
function hasClaudeStatusLine(existing, command) {
|
|
311
|
+
const root = parseSettings(existing);
|
|
312
|
+
return root ? statusLineOf(root)?.command === command : false;
|
|
313
|
+
}
|
|
314
|
+
function removeClaudeStatusLine(existing, command, previous) {
|
|
315
|
+
const root = parseSettings(existing);
|
|
316
|
+
if (!root || statusLineOf(root)?.command !== command) return existing;
|
|
317
|
+
const next = { ...root };
|
|
318
|
+
if (previous) next["statusLine"] = { ...previous };
|
|
319
|
+
else delete next["statusLine"];
|
|
320
|
+
return `${JSON.stringify(next, null, 2)}
|
|
321
|
+
`;
|
|
322
|
+
}
|
|
323
|
+
|
|
191
324
|
// src/session.ts
|
|
325
|
+
import { spawnSync } from "node:child_process";
|
|
192
326
|
import { randomBytes } from "node:crypto";
|
|
193
|
-
import { chmodSync, existsSync as
|
|
194
|
-
import { dirname, join as join4 } from "node:path";
|
|
327
|
+
import { chmodSync, existsSync as existsSync3, mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync4, rmSync as rmSync2, unlinkSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
328
|
+
import { dirname as dirname2, join as join4 } from "node:path";
|
|
195
329
|
|
|
196
330
|
// src/cursor.ts
|
|
197
331
|
var MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
@@ -254,19 +388,19 @@ ${event.model ?? ""}`;
|
|
|
254
388
|
}
|
|
255
389
|
return byDay;
|
|
256
390
|
}
|
|
257
|
-
function
|
|
391
|
+
function asRecord2(value) {
|
|
258
392
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
259
393
|
return value;
|
|
260
394
|
}
|
|
261
395
|
function mergeCursorHook(existing, command) {
|
|
262
396
|
let parsed = {};
|
|
263
|
-
if (existing.trim()) parsed = JSON.parse(existing);
|
|
264
|
-
const root =
|
|
265
|
-
const hooks =
|
|
397
|
+
if (existing.trim()) parsed = JSON.parse(stripBom(existing));
|
|
398
|
+
const root = asRecord2(parsed) ?? {};
|
|
399
|
+
const hooks = asRecord2(root["hooks"]) ?? {};
|
|
266
400
|
const stopRaw = hooks["stop"];
|
|
267
|
-
const stop = Array.isArray(stopRaw) ? stopRaw.filter((entry) =>
|
|
401
|
+
const stop = Array.isArray(stopRaw) ? stopRaw.filter((entry) => asRecord2(entry)?.["command"] !== command) : [];
|
|
268
402
|
const kept = stop.flatMap((entry) => {
|
|
269
|
-
const record =
|
|
403
|
+
const record = asRecord2(entry);
|
|
270
404
|
if (!record || typeof record["command"] !== "string") return [];
|
|
271
405
|
return [record];
|
|
272
406
|
});
|
|
@@ -275,11 +409,11 @@ function mergeCursorHook(existing, command) {
|
|
|
275
409
|
}
|
|
276
410
|
function removeCursorHook(existing, command) {
|
|
277
411
|
if (!existing.trim()) return existing;
|
|
278
|
-
const root =
|
|
412
|
+
const root = asRecord2(JSON.parse(stripBom(existing)));
|
|
279
413
|
if (!root) return existing;
|
|
280
|
-
const hooks =
|
|
414
|
+
const hooks = asRecord2(root["hooks"]);
|
|
281
415
|
if (!hooks || !Array.isArray(hooks["stop"])) return existing;
|
|
282
|
-
const stop = hooks["stop"].filter((entry) =>
|
|
416
|
+
const stop = hooks["stop"].filter((entry) => asRecord2(entry)?.["command"] !== command);
|
|
283
417
|
if (stop.length === 0) {
|
|
284
418
|
const rest = {};
|
|
285
419
|
for (const [key, value] of Object.entries(hooks)) {
|
|
@@ -574,7 +708,7 @@ function readClaudeIncrement(dir, previous) {
|
|
|
574
708
|
}
|
|
575
709
|
|
|
576
710
|
// src/codex.ts
|
|
577
|
-
import { closeSync as closeSync2, openSync as openSync2, readdirSync as readdirSync2, readFileSync as
|
|
711
|
+
import { closeSync as closeSync2, openSync as openSync2, readdirSync as readdirSync2, readFileSync as readFileSync3, readSync as readSync2, statSync as statSync2 } from "node:fs";
|
|
578
712
|
import { join as join3, relative as relative2 } from "node:path";
|
|
579
713
|
var MODEL_PATTERN3 = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
580
714
|
var TOKEN_MAX3 = 1e12;
|
|
@@ -772,7 +906,7 @@ function readChunk2(path, offset) {
|
|
|
772
906
|
}
|
|
773
907
|
function readCodexAuth(home) {
|
|
774
908
|
try {
|
|
775
|
-
return classifyCodexAuth(
|
|
909
|
+
return classifyCodexAuth(readFileSync3(join3(home, "auth.json"), "utf8"));
|
|
776
910
|
} catch {
|
|
777
911
|
return "unknown";
|
|
778
912
|
}
|
|
@@ -906,12 +1040,39 @@ async function ghJson(gh, path) {
|
|
|
906
1040
|
return { auth: false, payload: null };
|
|
907
1041
|
}
|
|
908
1042
|
}
|
|
909
|
-
|
|
1043
|
+
function creditsFromCopilotUsage(payload) {
|
|
1044
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return void 0;
|
|
1045
|
+
const items = payload["usageItems"];
|
|
1046
|
+
if (!Array.isArray(items)) return void 0;
|
|
1047
|
+
let total = 0;
|
|
1048
|
+
for (const item of items) {
|
|
1049
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
1050
|
+
const record = item;
|
|
1051
|
+
const unit = typeof record["unitType"] === "string" ? record["unitType"].toLowerCase() : "";
|
|
1052
|
+
const quantity = typeof record["grossQuantity"] === "number" && Number.isFinite(record["grossQuantity"]) && record["grossQuantity"] >= 0 ? record["grossQuantity"] : void 0;
|
|
1053
|
+
if (unit.includes("credit") && quantity !== void 0) total += quantity;
|
|
1054
|
+
}
|
|
1055
|
+
return total;
|
|
1056
|
+
}
|
|
1057
|
+
async function copilotLogin(gh) {
|
|
910
1058
|
const user = await ghJson(gh, "user");
|
|
911
1059
|
if (user.auth) return { status: "auth" };
|
|
912
1060
|
if (!user.payload) return { status: "unavailable" };
|
|
913
1061
|
const login2 = user.payload && typeof user.payload === "object" && !Array.isArray(user.payload) ? user.payload["login"] : void 0;
|
|
914
1062
|
if (typeof login2 !== "string" || !/^[A-Za-z0-9-]{1,39}$/.test(login2)) return { status: "empty" };
|
|
1063
|
+
return { status: "ok", login: login2 };
|
|
1064
|
+
}
|
|
1065
|
+
async function readCopilotMonthCredits(gh, day) {
|
|
1066
|
+
const user = await copilotLogin(gh);
|
|
1067
|
+
if (user.status !== "ok") return void 0;
|
|
1068
|
+
const [year, month] = day.split("-");
|
|
1069
|
+
const usage = await ghJson(gh, `/users/${user.login}/settings/billing/ai_credit/usage?year=${year}&month=${Number(month)}`);
|
|
1070
|
+
return usage.payload ? creditsFromCopilotUsage(usage.payload) : void 0;
|
|
1071
|
+
}
|
|
1072
|
+
async function readCopilotDay(gh, day) {
|
|
1073
|
+
const user = await copilotLogin(gh);
|
|
1074
|
+
if (user.status !== "ok") return { status: user.status };
|
|
1075
|
+
const login2 = user.login;
|
|
915
1076
|
const [year, month, date] = day.split("-");
|
|
916
1077
|
const query = `year=${year}&month=${Number(month)}&day=${Number(date)}`;
|
|
917
1078
|
const base = `/users/${login2}/settings/billing`;
|
|
@@ -975,7 +1136,8 @@ async function postTelemetry(options) {
|
|
|
975
1136
|
turns: row.turns,
|
|
976
1137
|
tokensKnown: row.tokensKnown,
|
|
977
1138
|
...row.model ? { model: row.model } : {}
|
|
978
|
-
}))
|
|
1139
|
+
})),
|
|
1140
|
+
...options.quota ? { quota: options.quota } : {}
|
|
979
1141
|
})
|
|
980
1142
|
});
|
|
981
1143
|
let code;
|
|
@@ -995,47 +1157,82 @@ var EXIT_REPORT = 4;
|
|
|
995
1157
|
var EXIT_COPILOT = 5;
|
|
996
1158
|
var MAX_ROWS = 40;
|
|
997
1159
|
var TOKEN_PATTERN = /^sau_[A-Za-z0-9_-]{20,200}$/;
|
|
1160
|
+
function parseStatusLineConfig(value) {
|
|
1161
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
|
|
1162
|
+
const record = value;
|
|
1163
|
+
if (record["state"] !== "installed" && record["state"] !== "declined") return void 0;
|
|
1164
|
+
const config = { state: record["state"] };
|
|
1165
|
+
const previous = record["previous"];
|
|
1166
|
+
if (previous && typeof previous === "object" && !Array.isArray(previous)) {
|
|
1167
|
+
const raw = previous;
|
|
1168
|
+
if (typeof raw["command"] === "string") {
|
|
1169
|
+
config.previous = {
|
|
1170
|
+
type: typeof raw["type"] === "string" ? raw["type"] : "command",
|
|
1171
|
+
command: raw["command"],
|
|
1172
|
+
...typeof raw["padding"] === "number" ? { padding: raw["padding"] } : {}
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
return config;
|
|
1177
|
+
}
|
|
998
1178
|
var DEVICE_PATTERN = /^[a-f0-9]{16}$/;
|
|
999
1179
|
function newDevice() {
|
|
1000
1180
|
return randomBytes(8).toString("hex");
|
|
1001
1181
|
}
|
|
1002
|
-
function
|
|
1003
|
-
if (
|
|
1182
|
+
function parseConnection(record) {
|
|
1183
|
+
if (typeof record["token"] !== "string") return null;
|
|
1184
|
+
const connection = { token: record["token"] };
|
|
1185
|
+
if (isUsageSource(record["source"])) connection.source = record["source"];
|
|
1186
|
+
if (typeof record["addedAt"] === "string") connection.addedAt = record["addedAt"];
|
|
1187
|
+
if (typeof record["lastAcceptedDay"] === "string") connection.lastAcceptedDay = record["lastAcceptedDay"];
|
|
1188
|
+
if (record["claude"]) connection.claude = parseClaudeState(record["claude"]);
|
|
1189
|
+
if (record["codex"]) connection.codex = parseCodexState(record["codex"]);
|
|
1190
|
+
return connection;
|
|
1191
|
+
}
|
|
1192
|
+
function readConfig(path) {
|
|
1193
|
+
if (!existsSync3(path)) return { connections: [] };
|
|
1004
1194
|
try {
|
|
1005
|
-
const parsed = JSON.parse(
|
|
1006
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
1195
|
+
const parsed = JSON.parse(readFileSync4(path, "utf8"));
|
|
1196
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return { connections: [] };
|
|
1007
1197
|
const record = parsed;
|
|
1008
|
-
const config = {};
|
|
1009
|
-
if (typeof record["token"] === "string") config.token = record["token"];
|
|
1198
|
+
const config = { connections: [] };
|
|
1010
1199
|
if (typeof record["device"] === "string" && DEVICE_PATTERN.test(record["device"])) config.device = record["device"];
|
|
1011
|
-
if (
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1200
|
+
if (Array.isArray(record["connections"])) {
|
|
1201
|
+
for (const entry of record["connections"]) {
|
|
1202
|
+
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
|
|
1203
|
+
const connection = parseConnection(entry);
|
|
1204
|
+
if (connection) config.connections.push(connection);
|
|
1205
|
+
}
|
|
1206
|
+
} else {
|
|
1207
|
+
const legacy = parseConnection(record);
|
|
1208
|
+
if (legacy) config.connections.push(legacy);
|
|
1209
|
+
}
|
|
1210
|
+
const statusLine2 = parseStatusLineConfig(record["claudeStatusLine"]);
|
|
1211
|
+
if (statusLine2) config.claudeStatusLine = statusLine2;
|
|
1015
1212
|
return config;
|
|
1016
1213
|
} catch {
|
|
1017
|
-
return {};
|
|
1214
|
+
return { connections: [] };
|
|
1018
1215
|
}
|
|
1019
1216
|
}
|
|
1020
|
-
function
|
|
1021
|
-
|
|
1022
|
-
|
|
1217
|
+
function writeConfig(path, value) {
|
|
1218
|
+
mkdirSync3(dirname2(path), { recursive: true, mode: 448 });
|
|
1219
|
+
writeFileSync3(path, `${JSON.stringify({ version: 2, ...value })}
|
|
1023
1220
|
`, { mode: 384 });
|
|
1024
1221
|
chmodSync(path, 384);
|
|
1025
1222
|
}
|
|
1026
1223
|
function restoreFile(path, previous) {
|
|
1027
1224
|
if (previous === null) {
|
|
1028
|
-
if (
|
|
1225
|
+
if (existsSync3(path)) unlinkSync(path);
|
|
1029
1226
|
return;
|
|
1030
1227
|
}
|
|
1031
|
-
|
|
1228
|
+
writeFileSync3(path, previous);
|
|
1032
1229
|
}
|
|
1033
1230
|
function readSpool(dir) {
|
|
1034
|
-
if (!
|
|
1231
|
+
if (!existsSync3(dir)) return [];
|
|
1035
1232
|
return readdirSync3(dir).filter((name) => name.endsWith(".json")).map((name) => {
|
|
1036
1233
|
const path = join4(dir, name);
|
|
1037
1234
|
try {
|
|
1038
|
-
return { path, event: readSpoolEvent(JSON.parse(
|
|
1235
|
+
return { path, event: readSpoolEvent(JSON.parse(readFileSync4(path, "utf8"))) };
|
|
1039
1236
|
} catch {
|
|
1040
1237
|
return { path, event: null };
|
|
1041
1238
|
}
|
|
@@ -1047,27 +1244,46 @@ function utcDay(date) {
|
|
|
1047
1244
|
function clearSpool(dir) {
|
|
1048
1245
|
rmSync2(dir, { recursive: true, force: true });
|
|
1049
1246
|
}
|
|
1247
|
+
function forgetLocalState(paths, source) {
|
|
1248
|
+
if (source === "cursor") clearSpool(paths.spoolPath);
|
|
1249
|
+
if (source === "claude_code" && paths.quotaPath) rmSync2(paths.quotaPath, { force: true });
|
|
1250
|
+
}
|
|
1050
1251
|
async function login(options) {
|
|
1051
|
-
const token = options.token
|
|
1252
|
+
const token = stripBom(options.token).trim();
|
|
1052
1253
|
if (!TOKEN_PATTERN.test(token)) {
|
|
1053
1254
|
return { code: EXIT_USAGE, message: "El token no tiene el formato de un alta de uso." };
|
|
1054
1255
|
}
|
|
1055
|
-
const previousConfig =
|
|
1056
|
-
const previousHooks =
|
|
1057
|
-
const config =
|
|
1058
|
-
const
|
|
1059
|
-
const
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1256
|
+
const previousConfig = existsSync3(options.paths.configPath) ? readFileSync4(options.paths.configPath, "utf8") : null;
|
|
1257
|
+
const previousHooks = existsSync3(options.paths.hooksPath) ? readFileSync4(options.paths.hooksPath, "utf8") : null;
|
|
1258
|
+
const config = readConfig(options.paths.configPath);
|
|
1259
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
1260
|
+
const existing = config.connections.find((connection) => connection.token === token);
|
|
1261
|
+
let source = existing?.source;
|
|
1262
|
+
let replaced = [];
|
|
1263
|
+
if (!existing) {
|
|
1264
|
+
try {
|
|
1265
|
+
source = await options.probe?.(token);
|
|
1266
|
+
} catch {
|
|
1267
|
+
source = void 0;
|
|
1268
|
+
}
|
|
1269
|
+
const since = utcDay(now);
|
|
1270
|
+
const added = {
|
|
1271
|
+
token,
|
|
1272
|
+
addedAt: now.toISOString(),
|
|
1273
|
+
claude: emptyClaudeState(since),
|
|
1274
|
+
codex: emptyCodexState(since),
|
|
1275
|
+
...source ? { source } : {}
|
|
1276
|
+
};
|
|
1277
|
+
replaced = source ? config.connections.filter((connection) => connection.source === source) : [];
|
|
1278
|
+
config.connections = [...config.connections.filter((connection) => !replaced.includes(connection)), added];
|
|
1279
|
+
}
|
|
1280
|
+
config.device = config.device ?? newDevice();
|
|
1281
|
+
writeConfig(options.paths.configPath, config);
|
|
1066
1282
|
try {
|
|
1067
|
-
|
|
1068
|
-
|
|
1283
|
+
mkdirSync3(dirname2(options.paths.hooksPath), { recursive: true });
|
|
1284
|
+
writeFileSync3(options.paths.hooksPath, mergeCursorHook(previousHooks ?? "", options.hookCommand));
|
|
1069
1285
|
await options.launcher.install();
|
|
1070
|
-
if (
|
|
1286
|
+
if (replaced.length > 0) forgetLocalState(options.paths, source);
|
|
1071
1287
|
} catch (err) {
|
|
1072
1288
|
restoreFile(options.paths.configPath, previousConfig);
|
|
1073
1289
|
restoreFile(options.paths.hooksPath, previousHooks);
|
|
@@ -1077,72 +1293,108 @@ async function login(options) {
|
|
|
1077
1293
|
message: launcherFailed ? `No se pudo registrar el arranque. No se ha dejado el token instalado. ${err.message}` : `No se pudo instalar el hook de Cursor. No se ha dejado el token instalado. ${err.message}`
|
|
1078
1294
|
};
|
|
1079
1295
|
}
|
|
1080
|
-
|
|
1296
|
+
const tools = config.connections.map((connection) => connection.source ?? "sin comprobar").join(", ");
|
|
1297
|
+
const replacedNote = replaced.length > 0 ? ` Sustituye a la conexi\xF3n anterior de ${source}.` : "";
|
|
1298
|
+
return {
|
|
1299
|
+
code: 0,
|
|
1300
|
+
message: `Agente instalado. El arranque subir\xE1 el uso; no hace falta lanzarlo en cada sesi\xF3n.${replacedNote} Este ordenador informa de: ${tools}.`
|
|
1301
|
+
};
|
|
1081
1302
|
}
|
|
1082
1303
|
async function logout(options) {
|
|
1304
|
+
const config = readConfig(options.paths.configPath);
|
|
1305
|
+
const leaving = options.source ? config.connections.filter((connection) => connection.source === options.source) : config.connections;
|
|
1306
|
+
if (options.source && leaving.length === 0) {
|
|
1307
|
+
return { code: EXIT_USAGE, message: `Este ordenador no informa de ${options.source}.` };
|
|
1308
|
+
}
|
|
1309
|
+
const staying = config.connections.filter((connection) => !leaving.includes(connection));
|
|
1083
1310
|
let launcherError;
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1311
|
+
if (staying.length === 0) {
|
|
1312
|
+
try {
|
|
1313
|
+
await options.launcher.uninstall();
|
|
1314
|
+
} catch (err) {
|
|
1315
|
+
launcherError = err;
|
|
1316
|
+
}
|
|
1088
1317
|
}
|
|
1089
1318
|
let hookError = false;
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1319
|
+
const keepsCursor = staying.some((connection) => connection.source === "cursor" || !connection.source);
|
|
1320
|
+
if (!keepsCursor) {
|
|
1321
|
+
if (existsSync3(options.paths.hooksPath)) {
|
|
1322
|
+
try {
|
|
1323
|
+
const current = readFileSync4(options.paths.hooksPath, "utf8");
|
|
1324
|
+
writeFileSync3(options.paths.hooksPath, removeCursorHook(current, options.hookCommand));
|
|
1325
|
+
} catch {
|
|
1326
|
+
hookError = true;
|
|
1327
|
+
}
|
|
1096
1328
|
}
|
|
1329
|
+
clearSpool(options.paths.spoolPath);
|
|
1097
1330
|
}
|
|
1098
|
-
|
|
1099
|
-
const
|
|
1100
|
-
if (
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1331
|
+
const next = { ...config.device ? { device: config.device } : {}, connections: staying };
|
|
1332
|
+
const keepsClaude = staying.some((connection) => connection.source === "claude_code" || !connection.source);
|
|
1333
|
+
if (keepsClaude) {
|
|
1334
|
+
if (config.claudeStatusLine) next.claudeStatusLine = config.claudeStatusLine;
|
|
1335
|
+
} else {
|
|
1336
|
+
if (config.claudeStatusLine?.state === "installed") {
|
|
1337
|
+
restoreClaudeStatusLine(options.paths, options.statusLineCommand, config.claudeStatusLine.previous);
|
|
1338
|
+
}
|
|
1339
|
+
if (options.paths.quotaPath) rmSync2(options.paths.quotaPath, { force: true });
|
|
1104
1340
|
}
|
|
1341
|
+
if (existsSync3(options.paths.configPath) || staying.length > 0) writeConfig(options.paths.configPath, next);
|
|
1105
1342
|
if (launcherError) {
|
|
1106
1343
|
return { code: EXIT_LAUNCHER, message: `No se pudo quitar el arranque. ${launcherError.message}` };
|
|
1107
1344
|
}
|
|
1108
1345
|
if (hookError) {
|
|
1109
1346
|
return { code: EXIT_USAGE, message: "El arranque se quit\xF3, pero el hook de Cursor no: su fichero no es JSON." };
|
|
1110
1347
|
}
|
|
1348
|
+
if (staying.length > 0) {
|
|
1349
|
+
return { code: 0, message: `Conexi\xF3n retirada. Este ordenador sigue informando de: ${staying.map((connection) => connection.source ?? "sin comprobar").join(", ")}.` };
|
|
1350
|
+
}
|
|
1111
1351
|
return { code: 0, message: "Agente retirado." };
|
|
1112
1352
|
}
|
|
1113
1353
|
function dropCursorHook(paths, hookCommand) {
|
|
1114
|
-
if (!hookCommand || !
|
|
1354
|
+
if (!hookCommand || !existsSync3(paths.hooksPath)) return;
|
|
1115
1355
|
try {
|
|
1116
|
-
const current =
|
|
1356
|
+
const current = readFileSync4(paths.hooksPath, "utf8");
|
|
1117
1357
|
const next = removeCursorHook(current, hookCommand);
|
|
1118
|
-
if (next !== current)
|
|
1358
|
+
if (next !== current) writeFileSync3(paths.hooksPath, next);
|
|
1119
1359
|
} catch {
|
|
1120
1360
|
}
|
|
1121
1361
|
}
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
const today = utcDay(options.now ?? /* @__PURE__ */ new Date());
|
|
1130
|
-
const probe = await fetchUsageSource({ baseUrl, token, fetchImpl: options.fetchImpl });
|
|
1131
|
-
if (probe.status === 401) {
|
|
1132
|
-
return { code: EXIT_REPORT, message: "El servidor no reconoce el token. Hay que repetir el login con un token nuevo." };
|
|
1362
|
+
function restoreClaudeStatusLine(paths, command, previous) {
|
|
1363
|
+
if (!command || !paths.claudeSettingsPath || !existsSync3(paths.claudeSettingsPath)) return;
|
|
1364
|
+
try {
|
|
1365
|
+
const current = readFileSync4(paths.claudeSettingsPath, "utf8");
|
|
1366
|
+
const next = removeClaudeStatusLine(current, command, previous);
|
|
1367
|
+
if (next !== current) writeFileSync3(paths.claudeSettingsPath, next);
|
|
1368
|
+
} catch {
|
|
1133
1369
|
}
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1370
|
+
}
|
|
1371
|
+
function ensureClaudeStatusLine(paths, command, current) {
|
|
1372
|
+
const settingsPath = paths.claudeSettingsPath;
|
|
1373
|
+
if (!command || !settingsPath || current?.state === "declined") return current;
|
|
1374
|
+
try {
|
|
1375
|
+
const text = existsSync3(settingsPath) ? readFileSync4(settingsPath, "utf8") : "";
|
|
1376
|
+
if (current?.state === "installed") {
|
|
1377
|
+
return hasClaudeStatusLine(text, command) ? current : { state: "declined", ...current.previous ? { previous: current.previous } : {} };
|
|
1378
|
+
}
|
|
1379
|
+
const merged = mergeClaudeStatusLine(text, command);
|
|
1380
|
+
if (merged.status === "unreadable") return current;
|
|
1381
|
+
if (merged.status === "already") return { state: "installed" };
|
|
1382
|
+
mkdirSync3(dirname2(settingsPath), { recursive: true });
|
|
1383
|
+
writeFileSync3(settingsPath, merged.text);
|
|
1384
|
+
return { state: "installed", ...merged.previous ? { previous: merged.previous } : {} };
|
|
1385
|
+
} catch {
|
|
1386
|
+
return current;
|
|
1137
1387
|
}
|
|
1138
|
-
|
|
1388
|
+
}
|
|
1389
|
+
async function reportConnection(options, connection, source, context) {
|
|
1390
|
+
const { baseUrl, device, today } = context;
|
|
1391
|
+
const token = connection.token;
|
|
1139
1392
|
const accepted = /* @__PURE__ */ new Set();
|
|
1140
1393
|
const outcome = { error: void 0, mismatch: false, acceptedAny: false };
|
|
1141
|
-
let lastAccepted =
|
|
1142
|
-
let
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
if (rows.length === 0) return "skip";
|
|
1394
|
+
let lastAccepted = connection.lastAcceptedDay;
|
|
1395
|
+
let statusLine2 = context.statusLine;
|
|
1396
|
+
const send = async (day, rows, kept, quota) => {
|
|
1397
|
+
if (rows.length === 0 && !quota) return "skip";
|
|
1146
1398
|
if (rows.length > MAX_ROWS) {
|
|
1147
1399
|
outcome.error = `El d\xEDa ${day} tiene m\xE1s de ${MAX_ROWS} modelos y no se ha subido.`;
|
|
1148
1400
|
return "stop";
|
|
@@ -1154,7 +1406,8 @@ async function report(options) {
|
|
|
1154
1406
|
rows,
|
|
1155
1407
|
fetchImpl: options.fetchImpl,
|
|
1156
1408
|
source,
|
|
1157
|
-
...source === "github_copilot" ? {} : { device }
|
|
1409
|
+
...source === "github_copilot" ? {} : { device },
|
|
1410
|
+
...quota ? { quota } : {}
|
|
1158
1411
|
});
|
|
1159
1412
|
if (result.ok) {
|
|
1160
1413
|
outcome.acceptedAny = true;
|
|
@@ -1174,24 +1427,32 @@ async function report(options) {
|
|
|
1174
1427
|
let copilotUnavailable = false;
|
|
1175
1428
|
let codexAuth;
|
|
1176
1429
|
if (source === "cursor") {
|
|
1177
|
-
const byDay = aggregateByDay(
|
|
1430
|
+
const byDay = aggregateByDay(context.spool.flatMap((file) => file.event ? [file.event] : []));
|
|
1178
1431
|
for (const day of [...byDay.keys()].sort()) {
|
|
1179
1432
|
if (await send(day, byDay.get(day) ?? [], " El spool de ese d\xEDa sigue en el port\xE1til.") === "stop") break;
|
|
1180
1433
|
}
|
|
1181
1434
|
} else if (source === "claude_code") {
|
|
1435
|
+
statusLine2 = ensureClaudeStatusLine(options.paths, options.statusLineCommand, statusLine2);
|
|
1182
1436
|
const claudeDir = options.claudeProjectsDir;
|
|
1183
|
-
const ready = claudeDir &&
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
if (
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1437
|
+
const ready = claudeDir && existsSync3(claudeDir) ? readClaudeIncrement(claudeDir, connection.claude ?? emptyClaudeState(today)) : null;
|
|
1438
|
+
const quotaDays = options.paths.quotaPath ? readQuotaState(options.paths.quotaPath).days : {};
|
|
1439
|
+
const days = [.../* @__PURE__ */ new Set([...ready?.dirtyDays ?? [], ...Object.keys(quotaDays)])].sort();
|
|
1440
|
+
for (const day of days) {
|
|
1441
|
+
const rows = ready ? rowsForDay(ready.state.days, day) : [];
|
|
1442
|
+
if (await send(day, rows, " La transcripci\xF3n sigue en el port\xE1til.", quotaDays[day]) === "stop") break;
|
|
1443
|
+
}
|
|
1444
|
+
if (ready && !outcome.error) {
|
|
1445
|
+
connection.claude = ready.state;
|
|
1446
|
+
if (ready.caughtUp) pruneClaudeDays(connection.claude, previousUtcDay(today));
|
|
1447
|
+
}
|
|
1448
|
+
if (options.paths.quotaPath && accepted.size > 0) {
|
|
1449
|
+
const fresh = readQuotaState(options.paths.quotaPath);
|
|
1450
|
+
for (const day of Object.keys(fresh.days)) if (day < today && accepted.has(day)) delete fresh.days[day];
|
|
1451
|
+
writeQuotaState(options.paths.quotaPath, fresh);
|
|
1192
1452
|
}
|
|
1193
1453
|
} else if (source === "github_copilot") {
|
|
1194
1454
|
if (options.gh) {
|
|
1455
|
+
const credits = await readCopilotMonthCredits(options.gh, today);
|
|
1195
1456
|
for (const day of [previousUtcDay(today), today]) {
|
|
1196
1457
|
const read = await readCopilotDay(options.gh, day);
|
|
1197
1458
|
if (read.status === "auth") {
|
|
@@ -1202,78 +1463,183 @@ async function report(options) {
|
|
|
1202
1463
|
copilotUnavailable = true;
|
|
1203
1464
|
break;
|
|
1204
1465
|
}
|
|
1205
|
-
|
|
1206
|
-
if (
|
|
1466
|
+
const quota = day === today && credits !== void 0 ? { monthAiCredits: credits } : void 0;
|
|
1467
|
+
if (read.status !== "rows" && !quota) continue;
|
|
1468
|
+
if (await send(day, read.status === "rows" ? read.rows : [], "", quota) === "stop") break;
|
|
1207
1469
|
}
|
|
1208
1470
|
}
|
|
1209
1471
|
} else {
|
|
1210
1472
|
const codexHome2 = options.codexHome;
|
|
1211
|
-
if (codexHome2 &&
|
|
1473
|
+
if (codexHome2 && existsSync3(codexHome2)) {
|
|
1212
1474
|
const auth = readCodexAuth(codexHome2);
|
|
1213
1475
|
const sessions = join4(codexHome2, "sessions");
|
|
1214
1476
|
if (auth !== "chatgpt") codexAuth = auth;
|
|
1215
|
-
else if (
|
|
1216
|
-
const ready = readCodexIncrement(sessions,
|
|
1477
|
+
else if (existsSync3(sessions)) {
|
|
1478
|
+
const ready = readCodexIncrement(sessions, connection.codex ?? emptyCodexState(today));
|
|
1217
1479
|
for (const day of ready.dirtyDays) {
|
|
1218
1480
|
if (await send(day, rowsForCodexDay(ready.state.days, day), " La sesi\xF3n sigue en el port\xE1til.") === "stop") break;
|
|
1219
1481
|
}
|
|
1220
1482
|
if (!outcome.error) {
|
|
1221
|
-
|
|
1483
|
+
connection.codex = ready.state;
|
|
1222
1484
|
if (ready.caughtUp) {
|
|
1223
1485
|
const before = previousUtcDay(today);
|
|
1224
|
-
for (const day of Object.keys(
|
|
1225
|
-
if (day < before) delete
|
|
1486
|
+
for (const day of Object.keys(connection.codex.days)) {
|
|
1487
|
+
if (day < before) delete connection.codex.days[day];
|
|
1226
1488
|
}
|
|
1227
1489
|
}
|
|
1228
1490
|
}
|
|
1229
1491
|
}
|
|
1230
1492
|
}
|
|
1231
1493
|
}
|
|
1232
|
-
|
|
1233
|
-
if (
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
if (
|
|
1243
|
-
|
|
1244
|
-
if (outcome.error) return { code: EXIT_REPORT, message: outcome.error };
|
|
1245
|
-
if (outcome.mismatch) {
|
|
1246
|
-
return { code: 0, message: "El uso de esta herramienta no corresponde a esta conexi\xF3n y no se ha subido." };
|
|
1247
|
-
}
|
|
1494
|
+
if (lastAccepted) connection.lastAcceptedDay = lastAccepted;
|
|
1495
|
+
if (!outcome.mismatch) connection.source = source;
|
|
1496
|
+
const run2 = (result) => ({
|
|
1497
|
+
connection,
|
|
1498
|
+
source,
|
|
1499
|
+
result,
|
|
1500
|
+
mismatch: outcome.mismatch,
|
|
1501
|
+
accepted,
|
|
1502
|
+
...statusLine2 ? { statusLine: statusLine2 } : {}
|
|
1503
|
+
});
|
|
1504
|
+
if (outcome.error) return run2({ code: EXIT_REPORT, message: outcome.error });
|
|
1505
|
+
if (outcome.mismatch) return run2({ code: 0, message: "El uso de esta herramienta no corresponde a esta conexi\xF3n y no se ha subido." });
|
|
1248
1506
|
if (copilotAuth && !outcome.acceptedAny) {
|
|
1249
|
-
return { code: EXIT_COPILOT, message: "gh no tiene permiso para leer el uso de Copilot. No se ha escrito una fila vac\xEDa." };
|
|
1507
|
+
return run2({ code: EXIT_COPILOT, message: "gh no tiene permiso para leer el uso de Copilot. No se ha escrito una fila vac\xEDa." });
|
|
1250
1508
|
}
|
|
1251
1509
|
if (copilotUnavailable) {
|
|
1252
|
-
return { code: EXIT_COPILOT, message: "No se pudo usar gh. Hace falta GitHub CLI con gh auth login en la cuenta de Copilot." };
|
|
1510
|
+
return run2({ code: EXIT_COPILOT, message: "No se pudo usar gh. Hace falta GitHub CLI con gh auth login en la cuenta de Copilot." });
|
|
1253
1511
|
}
|
|
1254
1512
|
if (!outcome.acceptedAny) {
|
|
1255
|
-
if (codexAuth === "api_key") {
|
|
1256
|
-
|
|
1513
|
+
if (codexAuth === "api_key") return run2({ code: 0, message: "Codex usa una API key. Ese gasto no se atribuye a la cuenta individual." });
|
|
1514
|
+
if (codexAuth === "unknown") return run2({ code: 0, message: "No se pudo confirmar que Codex use la suscripci\xF3n de ChatGPT. No se ha subido nada." });
|
|
1515
|
+
return run2({ code: 0, message: "No hay uso nuevo que subir." });
|
|
1516
|
+
}
|
|
1517
|
+
return run2({ code: 0, message: `Uso aceptado hasta ${lastAccepted ?? "hoy"}.` });
|
|
1518
|
+
}
|
|
1519
|
+
async function report(options) {
|
|
1520
|
+
const config = readConfig(options.paths.configPath);
|
|
1521
|
+
if (config.connections.length === 0) return { code: EXIT_USAGE, message: "No hay token. Hay que hacer login en esta conexi\xF3n." };
|
|
1522
|
+
const device = config.device ?? newDevice();
|
|
1523
|
+
const baseUrl = resolveUsageBaseUrl(options.baseUrl);
|
|
1524
|
+
if (!baseUrl) return { code: EXIT_USAGE, message: "La URL del API tiene que ser https." };
|
|
1525
|
+
const today = utcDay(options.now ?? /* @__PURE__ */ new Date());
|
|
1526
|
+
const resolved = [];
|
|
1527
|
+
for (const connection of config.connections) {
|
|
1528
|
+
const probe = await fetchUsageSource({ baseUrl, token: connection.token, fetchImpl: options.fetchImpl });
|
|
1529
|
+
if (probe.status === 401) {
|
|
1530
|
+
resolved.push({
|
|
1531
|
+
connection,
|
|
1532
|
+
...connection.source ? { source: connection.source } : {},
|
|
1533
|
+
failure: { code: EXIT_REPORT, message: "El servidor no reconoce el token. Hay que repetir el login con un token nuevo." }
|
|
1534
|
+
});
|
|
1535
|
+
continue;
|
|
1536
|
+
}
|
|
1537
|
+
const source = probe.source ?? connection.source;
|
|
1538
|
+
resolved.push(
|
|
1539
|
+
source ? { connection, source } : { connection, failure: { code: EXIT_REPORT, message: `No se pudo saber qu\xE9 herramienta informa esta conexi\xF3n (${probe.status}). Se reintentar\xE1.` } }
|
|
1540
|
+
);
|
|
1541
|
+
}
|
|
1542
|
+
const kept = [];
|
|
1543
|
+
const dropped = [];
|
|
1544
|
+
for (let index = resolved.length - 1; index >= 0; index--) {
|
|
1545
|
+
const entry = resolved[index];
|
|
1546
|
+
if (entry.source && kept.some((other) => !other.failure && other.source === entry.source)) dropped.push(entry);
|
|
1547
|
+
else kept.unshift(entry);
|
|
1548
|
+
}
|
|
1549
|
+
let spool = readSpool(options.paths.spoolPath);
|
|
1550
|
+
for (const entry of dropped) {
|
|
1551
|
+
if (entry.source !== "cursor") continue;
|
|
1552
|
+
const winner = kept.find((other) => other.source === "cursor")?.connection.addedAt;
|
|
1553
|
+
for (const file of spool) {
|
|
1554
|
+
if (!winner || !file.event || file.event.at < winner) unlinkSync(file.path);
|
|
1257
1555
|
}
|
|
1258
|
-
|
|
1259
|
-
|
|
1556
|
+
spool = readSpool(options.paths.spoolPath);
|
|
1557
|
+
}
|
|
1558
|
+
const runs = [];
|
|
1559
|
+
const failures = [];
|
|
1560
|
+
let statusLine2 = config.claudeStatusLine;
|
|
1561
|
+
for (const entry of kept) {
|
|
1562
|
+
if (entry.failure || !entry.source) {
|
|
1563
|
+
failures.push({ ...entry.source ? { source: entry.source } : {}, result: entry.failure });
|
|
1564
|
+
continue;
|
|
1260
1565
|
}
|
|
1261
|
-
|
|
1566
|
+
const run2 = await reportConnection(options, entry.connection, entry.source, {
|
|
1567
|
+
baseUrl,
|
|
1568
|
+
device,
|
|
1569
|
+
today,
|
|
1570
|
+
spool,
|
|
1571
|
+
...statusLine2 ? { statusLine: statusLine2 } : {}
|
|
1572
|
+
});
|
|
1573
|
+
if (entry.source === "claude_code") statusLine2 = run2.statusLine;
|
|
1574
|
+
runs.push(run2);
|
|
1262
1575
|
}
|
|
1263
|
-
|
|
1576
|
+
const pending = kept.some((entry) => !entry.source);
|
|
1577
|
+
const cursorRun = runs.find((run2) => run2.source === "cursor" && !run2.mismatch);
|
|
1578
|
+
const keepsCursor = pending || kept.some((entry) => entry.source === "cursor" && (entry.failure || !runs.find((run2) => run2.connection === entry.connection)?.mismatch));
|
|
1579
|
+
if (!keepsCursor) dropCursorHook(options.paths, options.hookCommand);
|
|
1580
|
+
for (const file of spool) {
|
|
1581
|
+
const day = file.event?.at.slice(0, 10);
|
|
1582
|
+
if (!keepsCursor || cursorRun && day && day < today && cursorRun.accepted.has(day)) unlinkSync(file.path);
|
|
1583
|
+
}
|
|
1584
|
+
const keepsClaude = pending || kept.some((entry) => entry.source === "claude_code" && (entry.failure || !runs.find((run2) => run2.connection === entry.connection)?.mismatch));
|
|
1585
|
+
if (!keepsClaude && statusLine2?.state === "installed") {
|
|
1586
|
+
restoreClaudeStatusLine(options.paths, options.statusLineCommand, statusLine2.previous);
|
|
1587
|
+
statusLine2 = void 0;
|
|
1588
|
+
}
|
|
1589
|
+
const next = { device, connections: kept.map((entry) => entry.connection) };
|
|
1590
|
+
if (statusLine2) next.claudeStatusLine = statusLine2;
|
|
1591
|
+
writeConfig(options.paths.configPath, next);
|
|
1592
|
+
const all = [
|
|
1593
|
+
...dropped.map((entry) => ({ source: entry.source, result: { code: 0, message: "Sustituida por un login m\xE1s reciente de la misma herramienta." } })),
|
|
1594
|
+
...failures,
|
|
1595
|
+
...runs.map((run2) => ({ source: run2.source, result: run2.result }))
|
|
1596
|
+
];
|
|
1597
|
+
if (all.length === 1) return all[0].result;
|
|
1598
|
+
return {
|
|
1599
|
+
code: all.find((entry) => entry.result.code !== 0)?.result.code ?? 0,
|
|
1600
|
+
message: all.map((entry) => `${entry.source ?? "sin comprobar"}: ${entry.result.message}`).join("\n")
|
|
1601
|
+
};
|
|
1264
1602
|
}
|
|
1265
1603
|
function previousUtcDay(day) {
|
|
1266
1604
|
return new Date(Date.parse(`${day}T00:00:00Z`) - 864e5).toISOString().slice(0, 10);
|
|
1267
1605
|
}
|
|
1606
|
+
var runPreviousWithShell = (command, input) => {
|
|
1607
|
+
const result = spawnSync(command, { shell: process.env["SHELL"] || true, input, encoding: "utf8", timeout: 5e3, windowsHide: true });
|
|
1608
|
+
return result.status === 0 && typeof result.stdout === "string" ? result.stdout : null;
|
|
1609
|
+
};
|
|
1610
|
+
function statusLine(options) {
|
|
1611
|
+
let parsed = null;
|
|
1612
|
+
try {
|
|
1613
|
+
parsed = JSON.parse(stripBom(options.input));
|
|
1614
|
+
} catch {
|
|
1615
|
+
parsed = null;
|
|
1616
|
+
}
|
|
1617
|
+
let state = { days: {} };
|
|
1618
|
+
if (options.paths.quotaPath) {
|
|
1619
|
+
try {
|
|
1620
|
+
state = readQuotaState(options.paths.quotaPath);
|
|
1621
|
+
if (parsed && recordStatusSample(state, parsed, options.now)) writeQuotaState(options.paths.quotaPath, state);
|
|
1622
|
+
} catch {
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
const previous = readConfig(options.paths.configPath).claudeStatusLine?.previous;
|
|
1626
|
+
if (previous) {
|
|
1627
|
+
const output = (options.runPrevious ?? runPreviousWithShell)(previous.command, options.input);
|
|
1628
|
+
if (output !== null) return output;
|
|
1629
|
+
}
|
|
1630
|
+
return defaultStatusText(state);
|
|
1631
|
+
}
|
|
1268
1632
|
async function status(options) {
|
|
1269
|
-
const config =
|
|
1633
|
+
const config = readConfig(options.paths.configPath);
|
|
1270
1634
|
const installed = await options.launcher.installed();
|
|
1271
|
-
const lines = [
|
|
1272
|
-
|
|
1273
|
-
`
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1635
|
+
const lines = [`agente: ${config.connections.length > 0 ? "instalado" : "ausente"}`, `arranque: ${installed ? "s\xED" : "no"}`];
|
|
1636
|
+
for (const connection of config.connections) {
|
|
1637
|
+
lines.push(`herramienta: ${connection.source ?? "sin comprobar"}`, `\xFAltimo d\xEDa aceptado: ${connection.lastAcceptedDay ?? "ninguno"}`);
|
|
1638
|
+
if (connection.source === "claude_code") {
|
|
1639
|
+
const state = config.claudeStatusLine?.state;
|
|
1640
|
+
lines.push(`l\xEDnea de estado de Claude Code: ${state === "installed" ? "puesta" : state === "declined" ? "retirada por ti, no se vuelve a poner" : "no"}`);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1277
1643
|
return { code: 0, message: lines.join("\n") };
|
|
1278
1644
|
}
|
|
1279
1645
|
|
|
@@ -1289,14 +1655,24 @@ function nodeExec(file, args) {
|
|
|
1289
1655
|
})
|
|
1290
1656
|
);
|
|
1291
1657
|
}
|
|
1658
|
+
function claudeConfigDir() {
|
|
1659
|
+
return process.env["CLAUDE_CONFIG_DIR"] ?? join5(homedir(), ".claude");
|
|
1660
|
+
}
|
|
1292
1661
|
function pathsFromEnv() {
|
|
1293
1662
|
const home = process.env["SENTINEL_USAGE_HOME"] ?? join5(homedir(), ".config", "aaa-sentinel");
|
|
1294
1663
|
return {
|
|
1295
1664
|
configPath: join5(home, "usage.json"),
|
|
1296
1665
|
spoolPath: join5(home, "spool"),
|
|
1297
|
-
hooksPath: process.env["SENTINEL_USAGE_CURSOR_HOOKS"] ?? join5(homedir(), ".cursor", "hooks.json")
|
|
1666
|
+
hooksPath: process.env["SENTINEL_USAGE_CURSOR_HOOKS"] ?? join5(homedir(), ".cursor", "hooks.json"),
|
|
1667
|
+
quotaPath: join5(home, "claude-quota.json"),
|
|
1668
|
+
claudeSettingsPath: process.env["SENTINEL_USAGE_CLAUDE_SETTINGS"] ?? join5(claudeConfigDir(), "settings.json")
|
|
1298
1669
|
};
|
|
1299
1670
|
}
|
|
1671
|
+
function statusLineArgs() {
|
|
1672
|
+
if (process.env["SENTINEL_USAGE_NO_STATUSLINE"] === "1") return null;
|
|
1673
|
+
const cliJs = fileURLToPath(new URL("./cli.js", import.meta.url));
|
|
1674
|
+
return [process.execPath, cliJs, "statusline"];
|
|
1675
|
+
}
|
|
1300
1676
|
function codexHome() {
|
|
1301
1677
|
return process.env["SENTINEL_USAGE_CODEX_HOME"] || process.env["CODEX_HOME"] || join5(homedir(), ".codex");
|
|
1302
1678
|
}
|
|
@@ -1312,7 +1688,7 @@ function ghRun(args) {
|
|
|
1312
1688
|
}
|
|
1313
1689
|
function claudeProjectsDir() {
|
|
1314
1690
|
if (process.env["SENTINEL_USAGE_CLAUDE_DIR"]) return process.env["SENTINEL_USAGE_CLAUDE_DIR"];
|
|
1315
|
-
return join5(
|
|
1691
|
+
return join5(claudeConfigDir(), "projects");
|
|
1316
1692
|
}
|
|
1317
1693
|
function hookArgs(spoolPath) {
|
|
1318
1694
|
const hookJs = fileURLToPath(new URL("./hook.js", import.meta.url));
|
|
@@ -1325,7 +1701,13 @@ function reportArgs() {
|
|
|
1325
1701
|
async function main() {
|
|
1326
1702
|
const [command, tokenArg] = [process.argv[2], process.argv[3]];
|
|
1327
1703
|
const paths = pathsFromEnv();
|
|
1704
|
+
if (command === "statusline") {
|
|
1705
|
+
process.stdout.write(statusLine({ input: await readStdin(), paths, now: /* @__PURE__ */ new Date() }));
|
|
1706
|
+
return 0;
|
|
1707
|
+
}
|
|
1328
1708
|
const hookCommand = formatCommand(hookArgs(paths.spoolPath));
|
|
1709
|
+
const statusArgs = statusLineArgs();
|
|
1710
|
+
const statusLineCommand2 = statusArgs ? statusLineCommand(statusArgs, process.platform) : void 0;
|
|
1329
1711
|
const uid = process.getuid?.();
|
|
1330
1712
|
const launcher = createLauncher({
|
|
1331
1713
|
platform: process.platform,
|
|
@@ -1333,7 +1715,7 @@ async function main() {
|
|
|
1333
1715
|
// El hook solo escribe el spool. Si la tarea lo relanza, el informe no sale del portátil.
|
|
1334
1716
|
args: reportArgs(),
|
|
1335
1717
|
homeDir: homedir(),
|
|
1336
|
-
scriptDir:
|
|
1718
|
+
scriptDir: dirname3(paths.configPath),
|
|
1337
1719
|
...uid !== void 0 ? { uid } : {},
|
|
1338
1720
|
windowsUser: process.env.USERDOMAIN ? `${process.env.USERDOMAIN}\\${userInfo().username}` : userInfo().username
|
|
1339
1721
|
});
|
|
@@ -1347,17 +1729,28 @@ async function main() {
|
|
|
1347
1729
|
return 2;
|
|
1348
1730
|
}
|
|
1349
1731
|
const token = (await readStdin()).trim();
|
|
1350
|
-
const
|
|
1732
|
+
const probe = sourceProbe();
|
|
1733
|
+
const result = await login({ token, paths, launcher, hookCommand, ...probe ? { probe } : {} });
|
|
1351
1734
|
console.log(result.message);
|
|
1352
1735
|
return result.code;
|
|
1353
1736
|
}
|
|
1354
1737
|
if (command === "logout") {
|
|
1355
|
-
|
|
1738
|
+
if (tokenArg && !isUsageSource(tokenArg)) {
|
|
1739
|
+
console.log("Herramienta desconocida. Usa cursor, claude_code, github_copilot o codex, o nada para retirarlas todas.");
|
|
1740
|
+
return 2;
|
|
1741
|
+
}
|
|
1742
|
+
const result = await logout({
|
|
1743
|
+
paths,
|
|
1744
|
+
launcher,
|
|
1745
|
+
hookCommand,
|
|
1746
|
+
...statusLineCommand2 ? { statusLineCommand: statusLineCommand2 } : {},
|
|
1747
|
+
...tokenArg && isUsageSource(tokenArg) ? { source: tokenArg } : {}
|
|
1748
|
+
});
|
|
1356
1749
|
console.log(result.message);
|
|
1357
1750
|
return result.code;
|
|
1358
1751
|
}
|
|
1359
1752
|
if (command === "report") {
|
|
1360
|
-
const baseUrl =
|
|
1753
|
+
const baseUrl = usageBaseUrl();
|
|
1361
1754
|
const result = await report({
|
|
1362
1755
|
paths,
|
|
1363
1756
|
baseUrl,
|
|
@@ -1365,7 +1758,8 @@ async function main() {
|
|
|
1365
1758
|
claudeProjectsDir: claudeProjectsDir(),
|
|
1366
1759
|
codexHome: codexHome(),
|
|
1367
1760
|
gh: ghRun,
|
|
1368
|
-
hookCommand
|
|
1761
|
+
hookCommand,
|
|
1762
|
+
...statusLineCommand2 ? { statusLineCommand: statusLineCommand2 } : {}
|
|
1369
1763
|
});
|
|
1370
1764
|
console.log(result.message);
|
|
1371
1765
|
return result.code;
|
|
@@ -1375,9 +1769,17 @@ async function main() {
|
|
|
1375
1769
|
console.log(result.message);
|
|
1376
1770
|
return result.code;
|
|
1377
1771
|
}
|
|
1378
|
-
console.log("Uso: sentinel-usage login|logout|report|status");
|
|
1772
|
+
console.log("Uso: sentinel-usage login|logout [herramienta]|report|status");
|
|
1379
1773
|
return 2;
|
|
1380
1774
|
}
|
|
1775
|
+
function usageBaseUrl() {
|
|
1776
|
+
return process.env["SENTINEL_USAGE_BASE_URL"] ?? DEFAULT_BASE_URL;
|
|
1777
|
+
}
|
|
1778
|
+
function sourceProbe() {
|
|
1779
|
+
const baseUrl = resolveUsageBaseUrl(usageBaseUrl());
|
|
1780
|
+
if (!baseUrl) return void 0;
|
|
1781
|
+
return async (token) => (await fetchUsageSource({ baseUrl, token, fetchImpl: fetch })).source;
|
|
1782
|
+
}
|
|
1381
1783
|
function readStdin() {
|
|
1382
1784
|
return new Promise((resolve, reject) => {
|
|
1383
1785
|
const chunks = [];
|
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,
|
|
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(
|
|
16
|
-
if (!
|
|
17
|
-
const record =
|
|
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
|
|
26
|
-
if (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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
42
|
+
return event;
|
|
35
43
|
}
|
|
36
|
-
function
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
-
|
|
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