neuvikon 0.2.8 → 0.2.9

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.
Files changed (3) hide show
  1. package/README.md +9 -0
  2. package/dist/index.mjs +237 -140
  3. 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,125 @@ 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) {
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
+ };
256
+ const buffer = Buffer.alloc(size - from);
257
+ readSync(fd, buffer, 0, buffer.length, from);
258
+ const text = buffer.toString("utf8");
259
+ const lastNewline = text.lastIndexOf("\n");
260
+ if (lastNewline === -1) return {
261
+ tokensIn: 0,
262
+ tokensOut: 0,
263
+ offset: from
264
+ };
265
+ let tokensIn = 0;
266
+ let tokensOut = 0;
267
+ for (const line of text.slice(0, lastNewline).split("\n")) {
268
+ if (!line.trim()) continue;
269
+ let row;
270
+ try {
271
+ row = JSON.parse(line);
272
+ } catch {
273
+ continue;
274
+ }
275
+ const usage = extractUsage(row);
276
+ if (!usage) continue;
277
+ tokensIn += usage.tokensIn;
278
+ tokensOut += usage.tokensOut;
279
+ }
280
+ return {
281
+ tokensIn,
282
+ tokensOut,
283
+ offset: from + Buffer.byteLength(text.slice(0, lastNewline + 1))
284
+ };
285
+ } catch {
286
+ return null;
287
+ } finally {
288
+ closeSync(fd);
289
+ }
290
+ }
291
+ function toCount$1(value) {
292
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
293
+ }
294
+ function extractUsage(row) {
295
+ if (typeof row !== "object" || row === null) return null;
296
+ const message = row.message;
297
+ if (typeof message !== "object" || message === null) return null;
298
+ const usage = message.usage;
299
+ if (typeof usage !== "object" || usage === null) return null;
300
+ const fields = usage;
301
+ return {
302
+ tokensIn: toCount$1(fields.input_tokens) + toCount$1(fields.cache_read_input_tokens) + toCount$1(fields.cache_creation_input_tokens),
303
+ tokensOut: toCount$1(fields.output_tokens)
304
+ };
305
+ }
306
+ /**
307
+ * Claude Code's transcript for the session this command was spawned from.
308
+ *
309
+ * Hooks are handed a `transcript_path` on stdin, but `start` and `stop` are run
310
+ * by the agent's own shell, which only gets the session id in the environment.
311
+ * That id is a uuid, so finding the file by name is exact — and doesn't depend
312
+ * on how Claude Code derives a project directory name from the working
313
+ * directory, which is not a documented format.
314
+ */
315
+ function claudeTranscriptPath() {
316
+ const sessionId = process.env.CLAUDE_CODE_SESSION_ID?.trim();
317
+ if (!sessionId) return void 0;
318
+ const projects = join(homedir(), ".claude", "projects");
319
+ let directories;
320
+ try {
321
+ directories = readdirSync(projects);
322
+ } catch {
323
+ return;
324
+ }
325
+ for (const directory of directories) {
326
+ const candidate = join(projects, directory, `${sessionId}.jsonl`);
327
+ if (existsSync(candidate)) return candidate;
328
+ }
329
+ }
330
+ //#endregion
212
331
  //#region src/ui.ts
213
332
  /**
214
333
  * Output helpers. Everything the agent shouldn't parse goes to stderr, so a
@@ -241,6 +360,28 @@ function fail(message, hint) {
241
360
  //#endregion
242
361
  //#region src/commands/hook-start.ts
243
362
  /**
363
+ * `UserPromptSubmit` fires before the turn runs, which is what lets it capture
364
+ * a token baseline at the transcript's current size — anything read from that
365
+ * offset onward is this ticket's usage. Reading it here, instead of lazily on
366
+ * the first `Stop`, is what `report.ts` used to do: by the time `Stop` fires
367
+ * the first turn's usage is already appended, so that baseline came too late
368
+ * and silently discarded a whole turn's tokens (NS-33).
369
+ */
370
+ function parsePayload(raw) {
371
+ if (!raw.trim()) return {};
372
+ try {
373
+ const payload = JSON.parse(raw);
374
+ if (typeof payload !== "object" || payload === null) return {};
375
+ const record = payload;
376
+ return {
377
+ prompt: typeof record.prompt === "string" ? record.prompt : void 0,
378
+ transcriptPath: typeof record.transcript_path === "string" ? record.transcript_path : void 0
379
+ };
380
+ } catch {
381
+ return {};
382
+ }
383
+ }
384
+ /**
244
385
  * The `UserPromptSubmit` counterpart to `start`. Unlike `start`, this runs on
245
386
  * *every* prompt regardless of whether it mentions a ticket, so it must never
246
387
  * fail the hook and block the user's message — no credentials, no ticket ref,
@@ -250,7 +391,8 @@ async function hookStart() {
250
391
  try {
251
392
  const credentials = readCredentials();
252
393
  if (!credentials) return;
253
- const [ref] = extractTicketRefs(await readStdin());
394
+ const { prompt, transcriptPath } = parsePayload(await readStdin());
395
+ const [ref] = extractTicketRefs(prompt ?? "");
254
396
  if (!ref) return;
255
397
  const existing = readSession();
256
398
  if (existing?.ticket === ref) return;
@@ -265,10 +407,13 @@ async function hookStart() {
265
407
  event: "stop",
266
408
  sessionKey: existing.sessionKey
267
409
  }).catch(() => void 0);
410
+ const transcript = transcriptPath ?? claudeTranscriptPath();
411
+ const transcriptOffset = transcript ? transcriptSize(transcript) ?? void 0 : void 0;
268
412
  writeSession({
269
413
  sessionKey: result.sessionKey,
270
414
  ticket: ref,
271
- startedAt: Date.now()
415
+ startedAt: Date.now(),
416
+ transcriptOffset
272
417
  });
273
418
  success(`Tracking work on ${bold(ref)}`);
274
419
  } catch {}
@@ -625,101 +770,6 @@ async function logout() {
625
770
  info(dim(" Could not reach Neuvikon to revoke the token — revoke it in settings if it leaked."));
626
771
  }
627
772
  //#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
773
  //#region src/commands/report.ts
724
774
  /**
725
775
  * Agent hooks hand their context to the command on stdin as JSON. Reading the
@@ -744,54 +794,87 @@ function toCount(raw) {
744
794
  return Number.isFinite(value) && value >= 0 ? value : void 0;
745
795
  }
746
796
  /**
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.
797
+ * What the transcript has accumulated since this session's baseline.
798
+ *
799
+ * A session with no baseline yet establishes one here rather than counting
800
+ * from the top: everything before the ticket started belongs to whatever the
801
+ * agent was doing beforehand, not to this ticket.
750
802
  */
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({
803
+ function pendingUsage(session, transcript) {
804
+ if (!transcript) return {
805
+ tokensIn: 0,
806
+ tokensOut: 0
807
+ };
808
+ let baseline = session.transcriptOffset;
809
+ if (baseline === void 0) {
810
+ baseline = transcriptSize(transcript) ?? void 0;
811
+ if (baseline !== void 0) writeSession({
765
812
  ...session,
766
813
  transcriptOffset: baseline
767
814
  });
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
815
  }
816
+ if (baseline === void 0) return {
817
+ tokensIn: 0,
818
+ tokensOut: 0
819
+ };
820
+ const usage = readUsageSince(transcript, baseline);
821
+ if (!usage) return {
822
+ tokensIn: 0,
823
+ tokensOut: 0
824
+ };
825
+ return {
826
+ tokensIn: usage.tokensIn,
827
+ tokensOut: usage.tokensOut,
828
+ nextOffset: usage.offset
829
+ };
830
+ }
831
+ /**
832
+ * Hands a delta to the server, advancing the local offset only once it is
833
+ * accepted — a dropped request leaves the window to be retried by the next
834
+ * call instead of silently losing those tokens.
835
+ */
836
+ async function postUsage(credentials, session, delta) {
779
837
  try {
780
838
  await post(credentials.url, "/api/agent/events", {
781
839
  token: credentials.token,
782
840
  event: "report",
783
841
  sessionKey: session.sessionKey,
784
- tokensIn,
785
- tokensOut
842
+ tokensIn: delta.tokensIn,
843
+ tokensOut: delta.tokensOut
786
844
  });
787
845
  } catch {
788
- return;
846
+ return false;
789
847
  }
790
- if (nextOffset !== void 0) writeSession({
848
+ if (delta.nextOffset !== void 0) writeSession({
791
849
  ...session,
792
- transcriptOffset: nextOffset
850
+ transcriptOffset: delta.nextOffset
793
851
  });
794
- if (flags.has("verbose")) info(dim(`Reported ${tokensIn ?? 0} in / ${tokensOut ?? 0} out`));
852
+ return true;
853
+ }
854
+ /**
855
+ * Called from an agent hook on every turn, so it is deliberately silent and
856
+ * never exits non-zero: a token count is not worth interrupting someone's work
857
+ * over, and a hook that fails loudly is a hook people uninstall.
858
+ */
859
+ async function report(flags) {
860
+ const credentials = readCredentials();
861
+ const session = readSession();
862
+ if (!credentials || !session) {
863
+ if (flags.has("verbose")) info(dim(credentials ? "No ticket is being tracked here." : "Not logged in."));
864
+ return;
865
+ }
866
+ const fromTranscript = pendingUsage(session, flags.get("transcript") || await transcriptFromHookPayload() || claudeTranscriptPath());
867
+ const delta = {
868
+ tokensIn: (toCount(flags.get("tokens-in")) ?? 0) + fromTranscript.tokensIn,
869
+ tokensOut: (toCount(flags.get("tokens-out")) ?? 0) + fromTranscript.tokensOut,
870
+ nextOffset: fromTranscript.nextOffset
871
+ };
872
+ if (!delta.tokensIn && !delta.tokensOut && !flags.has("heartbeat")) {
873
+ if (flags.has("verbose")) info(dim("No new usage to report."));
874
+ return;
875
+ }
876
+ await postUsage(credentials, session, delta);
877
+ if (flags.has("verbose")) info(dim(`Reported ${delta.tokensIn} in / ${delta.tokensOut} out`));
795
878
  }
796
879
  //#endregion
797
880
  //#region src/commands/start.ts
@@ -812,11 +895,18 @@ async function resolveRef(flags, positional) {
812
895
  if (refs.length === 0) fail("No ticket reference found.", "Pass one with --ticket EWWQ-1, or pipe the ticket text in.");
813
896
  return refs[0];
814
897
  }
898
+ /**
899
+ * An agent shelling out to `start` rarely passes `--tool`, and "other" is a
900
+ * worse answer than the one the environment already gives us.
901
+ */
902
+ function detectTool() {
903
+ return process.env.CLAUDE_CODE_SESSION_ID ? "claude-code" : "other";
904
+ }
815
905
  async function start(flags, positional) {
816
906
  const credentials = readCredentials();
817
907
  if (!credentials) fail("Not logged in.", "Run `npx neuvikon login` first.");
818
908
  const ref = await resolveRef(flags, positional);
819
- const tool = flags.get("tool") ?? "other";
909
+ const tool = flags.get("tool") ?? detectTool();
820
910
  if (!TOOLS.has(tool)) fail(`Unknown tool "${tool}".`, `Expected one of: ${[...TOOLS].join(", ")}.`);
821
911
  const existing = readSession();
822
912
  if (existing?.ticket === ref) {
@@ -844,10 +934,12 @@ async function start(flags, positional) {
844
934
  event: "stop",
845
935
  sessionKey: existing.sessionKey
846
936
  }).catch(() => void 0);
937
+ const transcript = flags.get("transcript") || claudeTranscriptPath();
847
938
  writeSession({
848
939
  sessionKey: result.sessionKey,
849
940
  ticket: ref,
850
- startedAt: Date.now()
941
+ startedAt: Date.now(),
942
+ transcriptOffset: transcript ? transcriptSize(transcript) ?? void 0 : void 0
851
943
  });
852
944
  success(`Tracking work on ${bold(ref)}`);
853
945
  }
@@ -871,18 +963,23 @@ function status() {
871
963
  }
872
964
  //#endregion
873
965
  //#region src/commands/stop.ts
874
- async function stop() {
966
+ async function stop(flags) {
875
967
  const session = readSession();
876
968
  if (!session) {
877
969
  info(dim("No ticket is being tracked here."));
878
970
  return;
879
971
  }
880
972
  const credentials = readCredentials();
881
- if (credentials) await post(credentials.url, "/api/agent/events", {
882
- token: credentials.token,
883
- event: "stop",
884
- sessionKey: session.sessionKey
885
- }).catch(() => void 0);
973
+ if (credentials) {
974
+ const usage = pendingUsage(session, flags.get("transcript") || claudeTranscriptPath());
975
+ await post(credentials.url, "/api/agent/events", {
976
+ token: credentials.token,
977
+ event: "stop",
978
+ sessionKey: session.sessionKey,
979
+ tokensIn: usage.tokensIn,
980
+ tokensOut: usage.tokensOut
981
+ }).catch(() => void 0);
982
+ }
886
983
  clearSession();
887
984
  success(`Stopped tracking ${session.ticket}`);
888
985
  }
@@ -943,7 +1040,7 @@ async function main() {
943
1040
  case "start": return start(flags, positional);
944
1041
  case "hook-start": return hookStart();
945
1042
  case "report": return report(flags);
946
- case "stop": return stop();
1043
+ case "stop": return stop(flags);
947
1044
  case "status": return status();
948
1045
  case void 0:
949
1046
  case "help":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuvikon",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "Tell Neuvikon which ticket your coding agent is working on.",
5
5
  "keywords": [
6
6
  "neuvikon",