neuvikon 0.2.8 → 0.2.10
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 +9 -0
- package/dist/index.mjs +299 -191
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -57,6 +57,9 @@ into `.claude/settings.json` for you, whenever that project already has one:
|
|
|
57
57
|
{
|
|
58
58
|
"hooks": {
|
|
59
59
|
"Stop": [{ "hooks": [{ "type": "command", "command": "npx neuvikon report" }] }],
|
|
60
|
+
"Notification": [
|
|
61
|
+
{ "hooks": [{ "type": "command", "command": "npx neuvikon report --heartbeat" }] }
|
|
62
|
+
],
|
|
60
63
|
"SessionEnd": [{ "hooks": [{ "type": "command", "command": "npx neuvikon stop" }] }]
|
|
61
64
|
}
|
|
62
65
|
}
|
|
@@ -66,6 +69,12 @@ into `.claude/settings.json` for you, whenever that project already has one:
|
|
|
66
69
|
turn adds that turn rather than re-counting the conversation. It is silent and
|
|
67
70
|
always exits 0 — a token count is never worth interrupting someone's work.
|
|
68
71
|
|
|
72
|
+
`Notification` fires when Claude Code is blocked mid-turn — a permission
|
|
73
|
+
prompt, a question, waiting on you — exactly when `Stop` doesn't fire yet. The
|
|
74
|
+
`--heartbeat` flag tells `report` to still check in even when there's no new
|
|
75
|
+
token usage to send, so a session that's genuinely just waiting on you doesn't
|
|
76
|
+
read as stopped on the board.
|
|
77
|
+
|
|
69
78
|
If `.claude/settings.json` doesn't exist yet, or another agent is the one
|
|
70
79
|
being used, add the block above by hand.
|
|
71
80
|
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
@@ -209,6 +209,133 @@ async function readStdin() {
|
|
|
209
209
|
return Buffer.concat(chunks).toString("utf8");
|
|
210
210
|
}
|
|
211
211
|
//#endregion
|
|
212
|
+
//#region src/usage.ts
|
|
213
|
+
/**
|
|
214
|
+
* Sums token usage out of a JSONL agent transcript, starting from `offset`.
|
|
215
|
+
*
|
|
216
|
+
* Reading incrementally is what makes a per-turn hook safe to call repeatedly:
|
|
217
|
+
* each call reports only what was appended since the last one. Anything that
|
|
218
|
+
* doesn't parse is skipped rather than fatal — a transcript being appended to
|
|
219
|
+
* while we read it will have a torn final line, and that is normal, not an
|
|
220
|
+
* error worth failing a user's turn over.
|
|
221
|
+
*/
|
|
222
|
+
/**
|
|
223
|
+
* Where the transcript currently ends, so a session that starts mid-conversation
|
|
224
|
+
* can mark its starting point instead of counting what came before it.
|
|
225
|
+
*/
|
|
226
|
+
function transcriptSize(path) {
|
|
227
|
+
let fd;
|
|
228
|
+
try {
|
|
229
|
+
fd = openSync(path, "r");
|
|
230
|
+
} catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
return fstatSync(fd).size;
|
|
235
|
+
} catch {
|
|
236
|
+
return null;
|
|
237
|
+
} finally {
|
|
238
|
+
closeSync(fd);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function readUsageSince(path, offset, since) {
|
|
242
|
+
let fd;
|
|
243
|
+
try {
|
|
244
|
+
fd = openSync(path, "r");
|
|
245
|
+
} catch {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
const size = fstatSync(fd).size;
|
|
250
|
+
const from = offset > size ? 0 : offset;
|
|
251
|
+
if (size === from) return {
|
|
252
|
+
tokensIn: 0,
|
|
253
|
+
tokensOut: 0,
|
|
254
|
+
offset: size,
|
|
255
|
+
lastMessageId: since
|
|
256
|
+
};
|
|
257
|
+
const buffer = Buffer.alloc(size - from);
|
|
258
|
+
readSync(fd, buffer, 0, buffer.length, from);
|
|
259
|
+
const text = buffer.toString("utf8");
|
|
260
|
+
const lastNewline = text.lastIndexOf("\n");
|
|
261
|
+
if (lastNewline === -1) return {
|
|
262
|
+
tokensIn: 0,
|
|
263
|
+
tokensOut: 0,
|
|
264
|
+
offset: from,
|
|
265
|
+
lastMessageId: since
|
|
266
|
+
};
|
|
267
|
+
let tokensIn = 0;
|
|
268
|
+
let tokensOut = 0;
|
|
269
|
+
let lastMessageId = since;
|
|
270
|
+
for (const line of text.slice(0, lastNewline).split("\n")) {
|
|
271
|
+
if (!line.trim()) continue;
|
|
272
|
+
let row;
|
|
273
|
+
try {
|
|
274
|
+
row = JSON.parse(line);
|
|
275
|
+
} catch {
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const usage = extractUsage(row);
|
|
279
|
+
if (!usage) continue;
|
|
280
|
+
if (usage.messageId !== void 0 && usage.messageId === lastMessageId) continue;
|
|
281
|
+
lastMessageId = usage.messageId ?? lastMessageId;
|
|
282
|
+
tokensIn += usage.tokensIn;
|
|
283
|
+
tokensOut += usage.tokensOut;
|
|
284
|
+
}
|
|
285
|
+
return {
|
|
286
|
+
tokensIn,
|
|
287
|
+
tokensOut,
|
|
288
|
+
offset: from + Buffer.byteLength(text.slice(0, lastNewline + 1)),
|
|
289
|
+
lastMessageId
|
|
290
|
+
};
|
|
291
|
+
} catch {
|
|
292
|
+
return null;
|
|
293
|
+
} finally {
|
|
294
|
+
closeSync(fd);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function toCount$1(value) {
|
|
298
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
299
|
+
}
|
|
300
|
+
function extractUsage(row) {
|
|
301
|
+
if (typeof row !== "object" || row === null) return null;
|
|
302
|
+
const message = row.message;
|
|
303
|
+
if (typeof message !== "object" || message === null) return null;
|
|
304
|
+
const usage = message.usage;
|
|
305
|
+
if (typeof usage !== "object" || usage === null) return null;
|
|
306
|
+
const id = message.id;
|
|
307
|
+
const fields = usage;
|
|
308
|
+
return {
|
|
309
|
+
messageId: typeof id === "string" && id ? id : void 0,
|
|
310
|
+
tokensIn: toCount$1(fields.input_tokens) + toCount$1(fields.cache_creation_input_tokens),
|
|
311
|
+
tokensOut: toCount$1(fields.output_tokens)
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Claude Code's transcript for the session this command was spawned from.
|
|
316
|
+
*
|
|
317
|
+
* Hooks are handed a `transcript_path` on stdin, but `start` and `stop` are run
|
|
318
|
+
* by the agent's own shell, which only gets the session id in the environment.
|
|
319
|
+
* That id is a uuid, so finding the file by name is exact — and doesn't depend
|
|
320
|
+
* on how Claude Code derives a project directory name from the working
|
|
321
|
+
* directory, which is not a documented format.
|
|
322
|
+
*/
|
|
323
|
+
function claudeTranscriptPath() {
|
|
324
|
+
const sessionId = process.env.CLAUDE_CODE_SESSION_ID?.trim();
|
|
325
|
+
if (!sessionId) return void 0;
|
|
326
|
+
const projects = join(homedir(), ".claude", "projects");
|
|
327
|
+
let directories;
|
|
328
|
+
try {
|
|
329
|
+
directories = readdirSync(projects);
|
|
330
|
+
} catch {
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
for (const directory of directories) {
|
|
334
|
+
const candidate = join(projects, directory, `${sessionId}.jsonl`);
|
|
335
|
+
if (existsSync(candidate)) return candidate;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
//#endregion
|
|
212
339
|
//#region src/ui.ts
|
|
213
340
|
/**
|
|
214
341
|
* Output helpers. Everything the agent shouldn't parse goes to stderr, so a
|
|
@@ -239,8 +366,157 @@ function fail(message, hint) {
|
|
|
239
366
|
process.exit(1);
|
|
240
367
|
}
|
|
241
368
|
//#endregion
|
|
369
|
+
//#region src/commands/report.ts
|
|
370
|
+
/**
|
|
371
|
+
* Agent hooks hand their context to the command on stdin as JSON. Reading the
|
|
372
|
+
* transcript path from there means a hook is a bare `npx neuvikon report` with
|
|
373
|
+
* nothing to wire up, and nothing to keep in sync when a tool renames a flag.
|
|
374
|
+
*/
|
|
375
|
+
async function transcriptFromHookPayload() {
|
|
376
|
+
const raw = await readStdin();
|
|
377
|
+
if (!raw.trim()) return void 0;
|
|
378
|
+
try {
|
|
379
|
+
const payload = JSON.parse(raw);
|
|
380
|
+
if (typeof payload !== "object" || payload === null) return void 0;
|
|
381
|
+
const path = payload.transcript_path;
|
|
382
|
+
return typeof path === "string" && path ? path : void 0;
|
|
383
|
+
} catch {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
function toCount(raw) {
|
|
388
|
+
if (raw === void 0) return void 0;
|
|
389
|
+
const value = Number(raw);
|
|
390
|
+
return Number.isFinite(value) && value >= 0 ? value : void 0;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* What the transcript has accumulated since this session's baseline.
|
|
394
|
+
*
|
|
395
|
+
* A session with no baseline yet establishes one here rather than counting
|
|
396
|
+
* from the top: everything before the ticket started belongs to whatever the
|
|
397
|
+
* agent was doing beforehand, not to this ticket.
|
|
398
|
+
*/
|
|
399
|
+
function pendingUsage(session, transcript) {
|
|
400
|
+
if (!transcript) return {
|
|
401
|
+
tokensIn: 0,
|
|
402
|
+
tokensOut: 0
|
|
403
|
+
};
|
|
404
|
+
let baseline = session.transcriptOffset;
|
|
405
|
+
if (baseline === void 0) {
|
|
406
|
+
baseline = transcriptSize(transcript) ?? void 0;
|
|
407
|
+
if (baseline !== void 0) writeSession({
|
|
408
|
+
...session,
|
|
409
|
+
transcriptOffset: baseline
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
if (baseline === void 0) return {
|
|
413
|
+
tokensIn: 0,
|
|
414
|
+
tokensOut: 0
|
|
415
|
+
};
|
|
416
|
+
const usage = readUsageSince(transcript, baseline, session.lastMessageId);
|
|
417
|
+
if (!usage) return {
|
|
418
|
+
tokensIn: 0,
|
|
419
|
+
tokensOut: 0
|
|
420
|
+
};
|
|
421
|
+
return {
|
|
422
|
+
tokensIn: usage.tokensIn,
|
|
423
|
+
tokensOut: usage.tokensOut,
|
|
424
|
+
nextOffset: usage.offset,
|
|
425
|
+
nextMessageId: usage.lastMessageId
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Hands a delta to the server, advancing the local offset only once it is
|
|
430
|
+
* accepted — a dropped request leaves the window to be retried by the next
|
|
431
|
+
* call instead of silently losing those tokens.
|
|
432
|
+
*/
|
|
433
|
+
async function postUsage(credentials, session, delta) {
|
|
434
|
+
try {
|
|
435
|
+
await post(credentials.url, "/api/agent/events", {
|
|
436
|
+
token: credentials.token,
|
|
437
|
+
event: "report",
|
|
438
|
+
sessionKey: session.sessionKey,
|
|
439
|
+
tokensIn: delta.tokensIn,
|
|
440
|
+
tokensOut: delta.tokensOut
|
|
441
|
+
});
|
|
442
|
+
} catch {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
if (delta.nextOffset !== void 0) writeSession({
|
|
446
|
+
...session,
|
|
447
|
+
transcriptOffset: delta.nextOffset,
|
|
448
|
+
lastMessageId: delta.nextMessageId
|
|
449
|
+
});
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Closes a session, carrying everything it accumulated since its last report.
|
|
454
|
+
*
|
|
455
|
+
* Switching tickets used to stop the outgoing session bare, which dropped every
|
|
456
|
+
* token it had earned since its last turn ended — and a ticket that was started
|
|
457
|
+
* and left inside a single turn recorded exactly nothing.
|
|
458
|
+
*/
|
|
459
|
+
async function stopSession(credentials, session, transcript) {
|
|
460
|
+
const usage = pendingUsage(session, transcript);
|
|
461
|
+
await post(credentials.url, "/api/agent/events", {
|
|
462
|
+
token: credentials.token,
|
|
463
|
+
event: "stop",
|
|
464
|
+
sessionKey: session.sessionKey,
|
|
465
|
+
tokensIn: usage.tokensIn,
|
|
466
|
+
tokensOut: usage.tokensOut
|
|
467
|
+
}).catch(() => void 0);
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Called from an agent hook on every turn, so it is deliberately silent and
|
|
471
|
+
* never exits non-zero: a token count is not worth interrupting someone's work
|
|
472
|
+
* over, and a hook that fails loudly is a hook people uninstall.
|
|
473
|
+
*/
|
|
474
|
+
async function report(flags) {
|
|
475
|
+
const credentials = readCredentials();
|
|
476
|
+
const session = readSession();
|
|
477
|
+
if (!credentials || !session) {
|
|
478
|
+
if (flags.has("verbose")) info(dim(credentials ? "No ticket is being tracked here." : "Not logged in."));
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const fromTranscript = pendingUsage(session, flags.get("transcript") || await transcriptFromHookPayload() || claudeTranscriptPath());
|
|
482
|
+
const delta = {
|
|
483
|
+
tokensIn: (toCount(flags.get("tokens-in")) ?? 0) + fromTranscript.tokensIn,
|
|
484
|
+
tokensOut: (toCount(flags.get("tokens-out")) ?? 0) + fromTranscript.tokensOut,
|
|
485
|
+
nextOffset: fromTranscript.nextOffset,
|
|
486
|
+
nextMessageId: fromTranscript.nextMessageId
|
|
487
|
+
};
|
|
488
|
+
if (!delta.tokensIn && !delta.tokensOut && !flags.has("heartbeat")) {
|
|
489
|
+
if (flags.has("verbose")) info(dim("No new usage to report."));
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
await postUsage(credentials, session, delta);
|
|
493
|
+
if (flags.has("verbose")) info(dim(`Reported ${delta.tokensIn} in / ${delta.tokensOut} out`));
|
|
494
|
+
}
|
|
495
|
+
//#endregion
|
|
242
496
|
//#region src/commands/hook-start.ts
|
|
243
497
|
/**
|
|
498
|
+
* `UserPromptSubmit` fires before the turn runs, which is what lets it capture
|
|
499
|
+
* a token baseline at the transcript's current size — anything read from that
|
|
500
|
+
* offset onward is this ticket's usage. Reading it here, instead of lazily on
|
|
501
|
+
* the first `Stop`, is what `report.ts` used to do: by the time `Stop` fires
|
|
502
|
+
* the first turn's usage is already appended, so that baseline came too late
|
|
503
|
+
* and silently discarded a whole turn's tokens (NS-33).
|
|
504
|
+
*/
|
|
505
|
+
function parsePayload(raw) {
|
|
506
|
+
if (!raw.trim()) return {};
|
|
507
|
+
try {
|
|
508
|
+
const payload = JSON.parse(raw);
|
|
509
|
+
if (typeof payload !== "object" || payload === null) return {};
|
|
510
|
+
const record = payload;
|
|
511
|
+
return {
|
|
512
|
+
prompt: typeof record.prompt === "string" ? record.prompt : void 0,
|
|
513
|
+
transcriptPath: typeof record.transcript_path === "string" ? record.transcript_path : void 0
|
|
514
|
+
};
|
|
515
|
+
} catch {
|
|
516
|
+
return {};
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
244
520
|
* The `UserPromptSubmit` counterpart to `start`. Unlike `start`, this runs on
|
|
245
521
|
* *every* prompt regardless of whether it mentions a ticket, so it must never
|
|
246
522
|
* fail the hook and block the user's message — no credentials, no ticket ref,
|
|
@@ -250,25 +526,25 @@ async function hookStart() {
|
|
|
250
526
|
try {
|
|
251
527
|
const credentials = readCredentials();
|
|
252
528
|
if (!credentials) return;
|
|
253
|
-
const
|
|
529
|
+
const { prompt, transcriptPath } = parsePayload(await readStdin());
|
|
530
|
+
const [ref] = extractTicketRefs(prompt ?? "");
|
|
254
531
|
if (!ref) return;
|
|
255
532
|
const existing = readSession();
|
|
256
533
|
if (existing?.ticket === ref) return;
|
|
534
|
+
const transcript = transcriptPath ?? claudeTranscriptPath();
|
|
257
535
|
const result = await post(credentials.url, "/api/agent/events", {
|
|
258
536
|
token: credentials.token,
|
|
259
537
|
event: "start",
|
|
260
538
|
ticketRef: ref,
|
|
261
539
|
tool: "claude-code"
|
|
262
540
|
});
|
|
263
|
-
if (existing) await
|
|
264
|
-
|
|
265
|
-
event: "stop",
|
|
266
|
-
sessionKey: existing.sessionKey
|
|
267
|
-
}).catch(() => void 0);
|
|
541
|
+
if (existing) await stopSession(credentials, existing, transcript);
|
|
542
|
+
const transcriptOffset = transcript ? transcriptSize(transcript) ?? void 0 : void 0;
|
|
268
543
|
writeSession({
|
|
269
544
|
sessionKey: result.sessionKey,
|
|
270
545
|
ticket: ref,
|
|
271
|
-
startedAt: Date.now()
|
|
546
|
+
startedAt: Date.now(),
|
|
547
|
+
transcriptOffset
|
|
272
548
|
});
|
|
273
549
|
success(`Tracking work on ${bold(ref)}`);
|
|
274
550
|
} catch {}
|
|
@@ -625,175 +901,6 @@ async function logout() {
|
|
|
625
901
|
info(dim(" Could not reach Neuvikon to revoke the token — revoke it in settings if it leaked."));
|
|
626
902
|
}
|
|
627
903
|
//#endregion
|
|
628
|
-
//#region src/usage.ts
|
|
629
|
-
/**
|
|
630
|
-
* Sums token usage out of a JSONL agent transcript, starting from `offset`.
|
|
631
|
-
*
|
|
632
|
-
* Reading incrementally is what makes a per-turn hook safe to call repeatedly:
|
|
633
|
-
* each call reports only what was appended since the last one. Anything that
|
|
634
|
-
* doesn't parse is skipped rather than fatal — a transcript being appended to
|
|
635
|
-
* while we read it will have a torn final line, and that is normal, not an
|
|
636
|
-
* error worth failing a user's turn over.
|
|
637
|
-
*/
|
|
638
|
-
/**
|
|
639
|
-
* Where the transcript currently ends, so a session that starts mid-conversation
|
|
640
|
-
* can mark its starting point instead of counting what came before it.
|
|
641
|
-
*/
|
|
642
|
-
function transcriptSize(path) {
|
|
643
|
-
let fd;
|
|
644
|
-
try {
|
|
645
|
-
fd = openSync(path, "r");
|
|
646
|
-
} catch {
|
|
647
|
-
return null;
|
|
648
|
-
}
|
|
649
|
-
try {
|
|
650
|
-
return fstatSync(fd).size;
|
|
651
|
-
} catch {
|
|
652
|
-
return null;
|
|
653
|
-
} finally {
|
|
654
|
-
closeSync(fd);
|
|
655
|
-
}
|
|
656
|
-
}
|
|
657
|
-
function readUsageSince(path, offset) {
|
|
658
|
-
let fd;
|
|
659
|
-
try {
|
|
660
|
-
fd = openSync(path, "r");
|
|
661
|
-
} catch {
|
|
662
|
-
return null;
|
|
663
|
-
}
|
|
664
|
-
try {
|
|
665
|
-
const size = fstatSync(fd).size;
|
|
666
|
-
const from = offset > size ? 0 : offset;
|
|
667
|
-
if (size === from) return {
|
|
668
|
-
tokensIn: 0,
|
|
669
|
-
tokensOut: 0,
|
|
670
|
-
offset: size
|
|
671
|
-
};
|
|
672
|
-
const buffer = Buffer.alloc(size - from);
|
|
673
|
-
readSync(fd, buffer, 0, buffer.length, from);
|
|
674
|
-
const text = buffer.toString("utf8");
|
|
675
|
-
const lastNewline = text.lastIndexOf("\n");
|
|
676
|
-
if (lastNewline === -1) return {
|
|
677
|
-
tokensIn: 0,
|
|
678
|
-
tokensOut: 0,
|
|
679
|
-
offset: from
|
|
680
|
-
};
|
|
681
|
-
let tokensIn = 0;
|
|
682
|
-
let tokensOut = 0;
|
|
683
|
-
for (const line of text.slice(0, lastNewline).split("\n")) {
|
|
684
|
-
if (!line.trim()) continue;
|
|
685
|
-
let row;
|
|
686
|
-
try {
|
|
687
|
-
row = JSON.parse(line);
|
|
688
|
-
} catch {
|
|
689
|
-
continue;
|
|
690
|
-
}
|
|
691
|
-
const usage = extractUsage(row);
|
|
692
|
-
if (!usage) continue;
|
|
693
|
-
tokensIn += usage.tokensIn;
|
|
694
|
-
tokensOut += usage.tokensOut;
|
|
695
|
-
}
|
|
696
|
-
return {
|
|
697
|
-
tokensIn,
|
|
698
|
-
tokensOut,
|
|
699
|
-
offset: from + Buffer.byteLength(text.slice(0, lastNewline + 1))
|
|
700
|
-
};
|
|
701
|
-
} catch {
|
|
702
|
-
return null;
|
|
703
|
-
} finally {
|
|
704
|
-
closeSync(fd);
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
function toCount$1(value) {
|
|
708
|
-
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
709
|
-
}
|
|
710
|
-
function extractUsage(row) {
|
|
711
|
-
if (typeof row !== "object" || row === null) return null;
|
|
712
|
-
const message = row.message;
|
|
713
|
-
if (typeof message !== "object" || message === null) return null;
|
|
714
|
-
const usage = message.usage;
|
|
715
|
-
if (typeof usage !== "object" || usage === null) return null;
|
|
716
|
-
const fields = usage;
|
|
717
|
-
return {
|
|
718
|
-
tokensIn: toCount$1(fields.input_tokens) + toCount$1(fields.cache_read_input_tokens) + toCount$1(fields.cache_creation_input_tokens),
|
|
719
|
-
tokensOut: toCount$1(fields.output_tokens)
|
|
720
|
-
};
|
|
721
|
-
}
|
|
722
|
-
//#endregion
|
|
723
|
-
//#region src/commands/report.ts
|
|
724
|
-
/**
|
|
725
|
-
* Agent hooks hand their context to the command on stdin as JSON. Reading the
|
|
726
|
-
* transcript path from there means a hook is a bare `npx neuvikon report` with
|
|
727
|
-
* nothing to wire up, and nothing to keep in sync when a tool renames a flag.
|
|
728
|
-
*/
|
|
729
|
-
async function transcriptFromHookPayload() {
|
|
730
|
-
const raw = await readStdin();
|
|
731
|
-
if (!raw.trim()) return void 0;
|
|
732
|
-
try {
|
|
733
|
-
const payload = JSON.parse(raw);
|
|
734
|
-
if (typeof payload !== "object" || payload === null) return void 0;
|
|
735
|
-
const path = payload.transcript_path;
|
|
736
|
-
return typeof path === "string" && path ? path : void 0;
|
|
737
|
-
} catch {
|
|
738
|
-
return;
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
function toCount(raw) {
|
|
742
|
-
if (raw === void 0) return void 0;
|
|
743
|
-
const value = Number(raw);
|
|
744
|
-
return Number.isFinite(value) && value >= 0 ? value : void 0;
|
|
745
|
-
}
|
|
746
|
-
/**
|
|
747
|
-
* Called from an agent hook on every turn, so it is deliberately silent and
|
|
748
|
-
* never exits non-zero: a token count is not worth interrupting someone's work
|
|
749
|
-
* over, and a hook that fails loudly is a hook people uninstall.
|
|
750
|
-
*/
|
|
751
|
-
async function report(flags) {
|
|
752
|
-
const credentials = readCredentials();
|
|
753
|
-
const session = readSession();
|
|
754
|
-
if (!credentials || !session) {
|
|
755
|
-
if (flags.has("verbose")) info(dim(credentials ? "No ticket is being tracked here." : "Not logged in."));
|
|
756
|
-
return;
|
|
757
|
-
}
|
|
758
|
-
let tokensIn = toCount(flags.get("tokens-in"));
|
|
759
|
-
let tokensOut = toCount(flags.get("tokens-out"));
|
|
760
|
-
let nextOffset;
|
|
761
|
-
const transcript = flags.get("transcript") || await transcriptFromHookPayload();
|
|
762
|
-
if (transcript) {
|
|
763
|
-
const baseline = session.transcriptOffset ?? transcriptSize(transcript);
|
|
764
|
-
if (session.transcriptOffset === void 0 && baseline !== null) writeSession({
|
|
765
|
-
...session,
|
|
766
|
-
transcriptOffset: baseline
|
|
767
|
-
});
|
|
768
|
-
const usage = baseline === null ? null : readUsageSince(transcript, baseline);
|
|
769
|
-
if (usage) {
|
|
770
|
-
tokensIn = (tokensIn ?? 0) + usage.tokensIn;
|
|
771
|
-
tokensOut = (tokensOut ?? 0) + usage.tokensOut;
|
|
772
|
-
nextOffset = usage.offset;
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
if (!tokensIn && !tokensOut) {
|
|
776
|
-
if (flags.has("verbose")) info(dim("No new usage to report."));
|
|
777
|
-
return;
|
|
778
|
-
}
|
|
779
|
-
try {
|
|
780
|
-
await post(credentials.url, "/api/agent/events", {
|
|
781
|
-
token: credentials.token,
|
|
782
|
-
event: "report",
|
|
783
|
-
sessionKey: session.sessionKey,
|
|
784
|
-
tokensIn,
|
|
785
|
-
tokensOut
|
|
786
|
-
});
|
|
787
|
-
} catch {
|
|
788
|
-
return;
|
|
789
|
-
}
|
|
790
|
-
if (nextOffset !== void 0) writeSession({
|
|
791
|
-
...session,
|
|
792
|
-
transcriptOffset: nextOffset
|
|
793
|
-
});
|
|
794
|
-
if (flags.has("verbose")) info(dim(`Reported ${tokensIn ?? 0} in / ${tokensOut ?? 0} out`));
|
|
795
|
-
}
|
|
796
|
-
//#endregion
|
|
797
904
|
//#region src/commands/start.ts
|
|
798
905
|
const TOOLS = /* @__PURE__ */ new Set([
|
|
799
906
|
"claude-code",
|
|
@@ -812,17 +919,25 @@ async function resolveRef(flags, positional) {
|
|
|
812
919
|
if (refs.length === 0) fail("No ticket reference found.", "Pass one with --ticket EWWQ-1, or pipe the ticket text in.");
|
|
813
920
|
return refs[0];
|
|
814
921
|
}
|
|
922
|
+
/**
|
|
923
|
+
* An agent shelling out to `start` rarely passes `--tool`, and "other" is a
|
|
924
|
+
* worse answer than the one the environment already gives us.
|
|
925
|
+
*/
|
|
926
|
+
function detectTool() {
|
|
927
|
+
return process.env.CLAUDE_CODE_SESSION_ID ? "claude-code" : "other";
|
|
928
|
+
}
|
|
815
929
|
async function start(flags, positional) {
|
|
816
930
|
const credentials = readCredentials();
|
|
817
931
|
if (!credentials) fail("Not logged in.", "Run `npx neuvikon login` first.");
|
|
818
932
|
const ref = await resolveRef(flags, positional);
|
|
819
|
-
const tool = flags.get("tool") ??
|
|
933
|
+
const tool = flags.get("tool") ?? detectTool();
|
|
820
934
|
if (!TOOLS.has(tool)) fail(`Unknown tool "${tool}".`, `Expected one of: ${[...TOOLS].join(", ")}.`);
|
|
821
935
|
const existing = readSession();
|
|
822
936
|
if (existing?.ticket === ref) {
|
|
823
937
|
info(dim(`Already tracking ${ref}.`));
|
|
824
938
|
return;
|
|
825
939
|
}
|
|
940
|
+
const transcript = flags.get("transcript") || claudeTranscriptPath();
|
|
826
941
|
let result;
|
|
827
942
|
try {
|
|
828
943
|
result = await post(credentials.url, "/api/agent/events", {
|
|
@@ -839,15 +954,12 @@ async function start(flags, positional) {
|
|
|
839
954
|
}
|
|
840
955
|
throw error;
|
|
841
956
|
}
|
|
842
|
-
if (existing) await
|
|
843
|
-
token: credentials.token,
|
|
844
|
-
event: "stop",
|
|
845
|
-
sessionKey: existing.sessionKey
|
|
846
|
-
}).catch(() => void 0);
|
|
957
|
+
if (existing) await stopSession(credentials, existing, transcript);
|
|
847
958
|
writeSession({
|
|
848
959
|
sessionKey: result.sessionKey,
|
|
849
960
|
ticket: ref,
|
|
850
|
-
startedAt: Date.now()
|
|
961
|
+
startedAt: Date.now(),
|
|
962
|
+
transcriptOffset: transcript ? transcriptSize(transcript) ?? void 0 : void 0
|
|
851
963
|
});
|
|
852
964
|
success(`Tracking work on ${bold(ref)}`);
|
|
853
965
|
}
|
|
@@ -871,18 +983,14 @@ function status() {
|
|
|
871
983
|
}
|
|
872
984
|
//#endregion
|
|
873
985
|
//#region src/commands/stop.ts
|
|
874
|
-
async function stop() {
|
|
986
|
+
async function stop(flags) {
|
|
875
987
|
const session = readSession();
|
|
876
988
|
if (!session) {
|
|
877
989
|
info(dim("No ticket is being tracked here."));
|
|
878
990
|
return;
|
|
879
991
|
}
|
|
880
992
|
const credentials = readCredentials();
|
|
881
|
-
if (credentials) await
|
|
882
|
-
token: credentials.token,
|
|
883
|
-
event: "stop",
|
|
884
|
-
sessionKey: session.sessionKey
|
|
885
|
-
}).catch(() => void 0);
|
|
993
|
+
if (credentials) await stopSession(credentials, session, flags.get("transcript") || claudeTranscriptPath());
|
|
886
994
|
clearSession();
|
|
887
995
|
success(`Stopped tracking ${session.ticket}`);
|
|
888
996
|
}
|
|
@@ -943,7 +1051,7 @@ async function main() {
|
|
|
943
1051
|
case "start": return start(flags, positional);
|
|
944
1052
|
case "hook-start": return hookStart();
|
|
945
1053
|
case "report": return report(flags);
|
|
946
|
-
case "stop": return stop();
|
|
1054
|
+
case "stop": return stop(flags);
|
|
947
1055
|
case "status": return status();
|
|
948
1056
|
case void 0:
|
|
949
1057
|
case "help":
|