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.
- package/README.md +9 -0
- package/dist/index.mjs +237 -140
- 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
|
|
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
|
-
*
|
|
748
|
-
*
|
|
749
|
-
*
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
|
|
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") ??
|
|
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)
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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":
|