neuvikon 0.2.7 → 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 +554 -405
- 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,11 +1,429 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
2
|
+
import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
3
|
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
+
//#region src/config.ts
|
|
7
|
+
/**
|
|
8
|
+
* The two deployments this CLI knows about. The browser origin is kept here
|
|
9
|
+
* rather than read back from the server, because the API answers on a Convex
|
|
10
|
+
* domain while approval happens on the web app's — one is not derivable from
|
|
11
|
+
* the other, and guessing wrong sends people to a page that does not exist.
|
|
12
|
+
*/
|
|
13
|
+
const DEPLOYMENTS = {
|
|
14
|
+
production: {
|
|
15
|
+
api: "https://flexible-bird-801.eu-west-1.convex.site",
|
|
16
|
+
web: "https://www.neuvikon.space"
|
|
17
|
+
},
|
|
18
|
+
development: {
|
|
19
|
+
api: "https://calm-sturgeon-831.eu-west-1.convex.site",
|
|
20
|
+
web: "http://localhost:3000"
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* `--dev` targets the development deployment; NEUVIKON_URL overrides the API
|
|
25
|
+
* for a self-hosted install, in which case the web origin is unknowable and
|
|
26
|
+
* falls back to the matching default.
|
|
27
|
+
*/
|
|
28
|
+
function resolveDeployment(flags) {
|
|
29
|
+
const base = flags.has("dev") ? DEPLOYMENTS.development : DEPLOYMENTS.production;
|
|
30
|
+
const override = flags.get("url") || process.env.NEUVIKON_URL;
|
|
31
|
+
return override ? {
|
|
32
|
+
api: override.trim().replace(/\/+$/, ""),
|
|
33
|
+
web: base.web
|
|
34
|
+
} : base;
|
|
35
|
+
}
|
|
36
|
+
const DEFAULT_SITE_URL = process.env.NEUVIKON_URL ?? DEPLOYMENTS.production.api;
|
|
37
|
+
const CONFIG_DIR = join(homedir(), ".neuvikon");
|
|
38
|
+
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
39
|
+
/**
|
|
40
|
+
* Somewhere a browser cannot open — CI, a container, a remote box — there is no
|
|
41
|
+
* way to complete a device login, so a token handed in through the environment
|
|
42
|
+
* stands in for one. It wins over the config file: an explicit env var is the
|
|
43
|
+
* more specific instruction.
|
|
44
|
+
*/
|
|
45
|
+
function credentialsFromEnv() {
|
|
46
|
+
const token = process.env.NEUVIKON_TOKEN;
|
|
47
|
+
if (!token) return null;
|
|
48
|
+
return {
|
|
49
|
+
token,
|
|
50
|
+
url: DEFAULT_SITE_URL
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function readCredentials() {
|
|
54
|
+
const fromEnv = credentialsFromEnv();
|
|
55
|
+
if (fromEnv) return fromEnv;
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = readFileSync(CONFIG_PATH, "utf8");
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(raw);
|
|
64
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
65
|
+
const { token, url } = parsed;
|
|
66
|
+
if (typeof token !== "string" || !token) return null;
|
|
67
|
+
if (typeof url !== "string" || !url) return null;
|
|
68
|
+
return parsed;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A token on disk is only as safe as its permissions, so directory and file are
|
|
75
|
+
* locked to the owner on every write, not just on first create — an earlier
|
|
76
|
+
* version may have left them readable.
|
|
77
|
+
*/
|
|
78
|
+
function writeCredentials(credentials) {
|
|
79
|
+
mkdirSync(CONFIG_DIR, {
|
|
80
|
+
recursive: true,
|
|
81
|
+
mode: 448
|
|
82
|
+
});
|
|
83
|
+
chmodSync(CONFIG_DIR, 448);
|
|
84
|
+
writeFileSync(CONFIG_PATH, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 384 });
|
|
85
|
+
chmodSync(CONFIG_PATH, 384);
|
|
86
|
+
}
|
|
87
|
+
function clearCredentials() {
|
|
88
|
+
try {
|
|
89
|
+
rmSync(CONFIG_PATH);
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
96
|
+
//#region src/api.ts
|
|
97
|
+
var ApiError = class extends Error {
|
|
98
|
+
status;
|
|
99
|
+
constructor(message, status) {
|
|
100
|
+
super(message);
|
|
101
|
+
this.status = status;
|
|
102
|
+
this.name = "ApiError";
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
/** A request that never reached the server: offline, DNS, TLS, timeout. */
|
|
106
|
+
var NetworkError = class extends Error {
|
|
107
|
+
constructor(cause) {
|
|
108
|
+
super("Could not reach Neuvikon", { cause });
|
|
109
|
+
this.name = "NetworkError";
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
const TIMEOUT_MS = 15e3;
|
|
113
|
+
/**
|
|
114
|
+
* The one place an HTTP failure becomes a typed error. Callers decide what a
|
|
115
|
+
* failure means — a hook wrapping the agent's turn wants to swallow it, `login`
|
|
116
|
+
* wants to shout about it.
|
|
117
|
+
*/
|
|
118
|
+
async function post(url, path, body) {
|
|
119
|
+
let response;
|
|
120
|
+
try {
|
|
121
|
+
response = await fetch(`${url}${path}`, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "content-type": "application/json" },
|
|
124
|
+
body: JSON.stringify(body),
|
|
125
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
126
|
+
});
|
|
127
|
+
} catch (error) {
|
|
128
|
+
throw new NetworkError(error);
|
|
129
|
+
}
|
|
130
|
+
const payload = await response.json().catch(() => ({}));
|
|
131
|
+
const error = typeof payload === "object" && payload !== null ? payload.error : void 0;
|
|
132
|
+
if (!response.ok) throw new ApiError(typeof error === "string" ? error : `Request failed with status ${response.status}`, response.status);
|
|
133
|
+
return payload;
|
|
134
|
+
}
|
|
135
|
+
function normalizeUrl(url) {
|
|
136
|
+
return url.trim().replace(/\/+$/, "");
|
|
137
|
+
}
|
|
138
|
+
//#endregion
|
|
139
|
+
//#region src/session.ts
|
|
140
|
+
/**
|
|
141
|
+
* Distinguishes concurrent agents working in the *same* checkout — two Claude
|
|
142
|
+
* Code panes both running `claude` in this directory get two session files,
|
|
143
|
+
* because each one's process tree carries its own CLAUDE_CODE_SESSION_ID. A
|
|
144
|
+
* tool that doesn't set it (or a person typing the command by hand) falls
|
|
145
|
+
* back to the pre-multi-agent behavior of one session per directory.
|
|
146
|
+
*/
|
|
147
|
+
function instanceKey() {
|
|
148
|
+
return process.env.CLAUDE_CODE_SESSION_ID?.trim() || null;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Scoped to the working directory rather than the machine: two checkouts driven
|
|
152
|
+
* in parallel are two sessions, which is what running two agents means. Within
|
|
153
|
+
* one checkout, `instanceKey()` further scopes it per agent so two panes in the
|
|
154
|
+
* same directory don't stop each other's session out from under them.
|
|
155
|
+
*/
|
|
156
|
+
function sessionPath() {
|
|
157
|
+
const key = instanceKey();
|
|
158
|
+
const filename = key ? `.neuvikon-session.${key}` : ".neuvikon-session";
|
|
159
|
+
return join(process.cwd(), filename);
|
|
160
|
+
}
|
|
161
|
+
function readSession() {
|
|
162
|
+
let raw;
|
|
163
|
+
try {
|
|
164
|
+
raw = readFileSync(sessionPath(), "utf8");
|
|
165
|
+
} catch {
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const parsed = JSON.parse(raw);
|
|
170
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
171
|
+
const { sessionKey, ticket } = parsed;
|
|
172
|
+
if (typeof sessionKey !== "string" || typeof ticket !== "string") return null;
|
|
173
|
+
return parsed;
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function writeSession(session) {
|
|
179
|
+
writeFileSync(sessionPath(), `${JSON.stringify(session, null, 2)}\n`);
|
|
180
|
+
}
|
|
181
|
+
function clearSession() {
|
|
182
|
+
try {
|
|
183
|
+
rmSync(sessionPath());
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
//#endregion
|
|
187
|
+
//#region src/ticket.ts
|
|
188
|
+
/**
|
|
189
|
+
* Ticket refs look like `AWKN-14`. This pattern is deliberately looser than the
|
|
190
|
+
* server's — it pulls *candidates* out of arbitrary pasted prose and lets the
|
|
191
|
+
* server decide which one is a real ticket in the caller's organization, so the
|
|
192
|
+
* authoritative rule lives in exactly one place.
|
|
193
|
+
*/
|
|
194
|
+
const CANDIDATE = /\b([A-Z0-9]{2,8})-(\d{1,9})\b/g;
|
|
195
|
+
/** Every distinct ref-shaped token in `text`, in the order it appears. */
|
|
196
|
+
function extractTicketRefs(text) {
|
|
197
|
+
const seen = /* @__PURE__ */ new Set();
|
|
198
|
+
for (const match of text.toUpperCase().matchAll(CANDIDATE)) {
|
|
199
|
+
if (/^\d+$/.test(match[1])) continue;
|
|
200
|
+
seen.add(`${match[1]}-${match[2]}`);
|
|
201
|
+
}
|
|
202
|
+
return [...seen];
|
|
203
|
+
}
|
|
204
|
+
/** Reads piped input, if any. Returns "" when stdin is a terminal. */
|
|
205
|
+
async function readStdin() {
|
|
206
|
+
if (process.stdin.isTTY) return "";
|
|
207
|
+
const chunks = [];
|
|
208
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
209
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
210
|
+
}
|
|
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
|
|
331
|
+
//#region src/ui.ts
|
|
332
|
+
/**
|
|
333
|
+
* Output helpers. Everything the agent shouldn't parse goes to stderr, so a
|
|
334
|
+
* command's stdout stays a clean, pipeable value.
|
|
335
|
+
*/
|
|
336
|
+
const useColor = process.env.NO_COLOR === void 0 && process.stderr.isTTY === true;
|
|
337
|
+
function paint(code, text) {
|
|
338
|
+
return useColor ? `\x1b[${code}m${text}\x1b[0m` : text;
|
|
339
|
+
}
|
|
340
|
+
const dim = (text) => paint("2", text);
|
|
341
|
+
const bold = (text) => paint("1", text);
|
|
342
|
+
const green = (text) => paint("32", text);
|
|
343
|
+
const red = (text) => paint("31", text);
|
|
344
|
+
const cyan = (text) => paint("36", text);
|
|
345
|
+
function info(message) {
|
|
346
|
+
process.stderr.write(`${message}\n`);
|
|
347
|
+
}
|
|
348
|
+
function success(message) {
|
|
349
|
+
process.stderr.write(`${green("✓")} ${message}\n`);
|
|
350
|
+
}
|
|
351
|
+
function warn(message) {
|
|
352
|
+
process.stderr.write(`${red("!")} ${message}\n`);
|
|
353
|
+
}
|
|
354
|
+
/** Ends the process. Every user-facing failure funnels through here. */
|
|
355
|
+
function fail(message, hint) {
|
|
356
|
+
process.stderr.write(`${red("✗")} ${message}\n`);
|
|
357
|
+
if (hint) process.stderr.write(` ${dim(hint)}\n`);
|
|
358
|
+
process.exit(1);
|
|
359
|
+
}
|
|
360
|
+
//#endregion
|
|
361
|
+
//#region src/commands/hook-start.ts
|
|
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
|
+
/**
|
|
385
|
+
* The `UserPromptSubmit` counterpart to `start`. Unlike `start`, this runs on
|
|
386
|
+
* *every* prompt regardless of whether it mentions a ticket, so it must never
|
|
387
|
+
* fail the hook and block the user's message — no credentials, no ticket ref,
|
|
388
|
+
* or a dead network are all just "nothing to do here", not errors.
|
|
389
|
+
*/
|
|
390
|
+
async function hookStart() {
|
|
391
|
+
try {
|
|
392
|
+
const credentials = readCredentials();
|
|
393
|
+
if (!credentials) return;
|
|
394
|
+
const { prompt, transcriptPath } = parsePayload(await readStdin());
|
|
395
|
+
const [ref] = extractTicketRefs(prompt ?? "");
|
|
396
|
+
if (!ref) return;
|
|
397
|
+
const existing = readSession();
|
|
398
|
+
if (existing?.ticket === ref) return;
|
|
399
|
+
const result = await post(credentials.url, "/api/agent/events", {
|
|
400
|
+
token: credentials.token,
|
|
401
|
+
event: "start",
|
|
402
|
+
ticketRef: ref,
|
|
403
|
+
tool: "claude-code"
|
|
404
|
+
});
|
|
405
|
+
if (existing) await post(credentials.url, "/api/agent/events", {
|
|
406
|
+
token: credentials.token,
|
|
407
|
+
event: "stop",
|
|
408
|
+
sessionKey: existing.sessionKey
|
|
409
|
+
}).catch(() => void 0);
|
|
410
|
+
const transcript = transcriptPath ?? claudeTranscriptPath();
|
|
411
|
+
const transcriptOffset = transcript ? transcriptSize(transcript) ?? void 0 : void 0;
|
|
412
|
+
writeSession({
|
|
413
|
+
sessionKey: result.sessionKey,
|
|
414
|
+
ticket: ref,
|
|
415
|
+
startedAt: Date.now(),
|
|
416
|
+
transcriptOffset
|
|
417
|
+
});
|
|
418
|
+
success(`Tracking work on ${bold(ref)}`);
|
|
419
|
+
} catch {}
|
|
420
|
+
}
|
|
421
|
+
//#endregion
|
|
6
422
|
//#region src/agents/hooks.ts
|
|
7
423
|
const REPORT_COMMAND = "npx neuvikon report";
|
|
424
|
+
const HEARTBEAT_COMMAND = "npx neuvikon report --heartbeat";
|
|
8
425
|
const STOP_COMMAND = "npx neuvikon stop";
|
|
426
|
+
const HOOK_START_COMMAND = "npx neuvikon hook-start";
|
|
9
427
|
function hasCommand(groups, command) {
|
|
10
428
|
return (groups ?? []).some((group) => group.hooks.some((hook) => hook.command === command));
|
|
11
429
|
}
|
|
@@ -21,8 +439,20 @@ function withCommand(groups, command) {
|
|
|
21
439
|
* whose transcript exposes usage, per `usage.ts`. `Stop` fires after every
|
|
22
440
|
* turn, exactly when `report` should read what the transcript gained since
|
|
23
441
|
* last time; `SessionEnd` is the backstop for `stop` when nobody remembered
|
|
24
|
-
* to run it by hand.
|
|
25
|
-
*
|
|
442
|
+
* to run it by hand.
|
|
443
|
+
*
|
|
444
|
+
* `UserPromptSubmit` covers `start`: relying on the agent to notice a ticket
|
|
445
|
+
* ref in the prompt and remember to run `start` itself is exactly the failure
|
|
446
|
+
* mode reported in NS-31 — an LLM that's mid-instruction-following skips it
|
|
447
|
+
* often enough to be unreliable bookkeeping. The hook makes it deterministic
|
|
448
|
+
* for Claude Code; other tools still fall back to the AGENTS.md instruction.
|
|
449
|
+
*
|
|
450
|
+
* `Notification` fires when Claude Code is blocked mid-turn — a permission
|
|
451
|
+
* prompt, a question, waiting on the user — which is exactly when `Stop`
|
|
452
|
+
* does *not* fire, since the turn hasn't finished. Without a heartbeat there
|
|
453
|
+
* too, a session that's genuinely just waiting on a person reads as stale on
|
|
454
|
+
* the board after `AGENT_SESSION_STALE_MS`, indistinguishable from one that
|
|
455
|
+
* actually stopped (NS-32).
|
|
26
456
|
*/
|
|
27
457
|
function installClaudeHooks(root) {
|
|
28
458
|
const relativePath = join(".claude", "settings.json");
|
|
@@ -39,7 +469,7 @@ function installClaudeHooks(root) {
|
|
|
39
469
|
};
|
|
40
470
|
}
|
|
41
471
|
const hooks = settings.hooks ?? {};
|
|
42
|
-
if (hasCommand(hooks.Stop, REPORT_COMMAND) && hasCommand(hooks.SessionEnd, STOP_COMMAND)) return {
|
|
472
|
+
if (hasCommand(hooks.Stop, REPORT_COMMAND) && hasCommand(hooks.Notification, HEARTBEAT_COMMAND) && hasCommand(hooks.SessionEnd, STOP_COMMAND) && hasCommand(hooks.UserPromptSubmit, HOOK_START_COMMAND)) return {
|
|
43
473
|
path: relativePath,
|
|
44
474
|
action: "unchanged"
|
|
45
475
|
};
|
|
@@ -48,7 +478,9 @@ function installClaudeHooks(root) {
|
|
|
48
478
|
hooks: {
|
|
49
479
|
...hooks,
|
|
50
480
|
Stop: withCommand(hooks.Stop, REPORT_COMMAND),
|
|
51
|
-
|
|
481
|
+
Notification: withCommand(hooks.Notification, HEARTBEAT_COMMAND),
|
|
482
|
+
SessionEnd: withCommand(hooks.SessionEnd, STOP_COMMAND),
|
|
483
|
+
UserPromptSubmit: withCommand(hooks.UserPromptSubmit, HOOK_START_COMMAND)
|
|
52
484
|
}
|
|
53
485
|
};
|
|
54
486
|
mkdirSync(dirname(path), { recursive: true });
|
|
@@ -195,197 +627,34 @@ function installAgentInstructions(root) {
|
|
|
195
627
|
}
|
|
196
628
|
if (existsSync(join(root, ".cursor"))) results.push(writeCursorRule(root));
|
|
197
629
|
if (existsSync(join(root, ".claude"))) results.push(installClaudeHooks(root));
|
|
198
|
-
const gitignore = ignoreSessionFile(root);
|
|
199
|
-
if (gitignore) results.push(gitignore);
|
|
200
|
-
return results;
|
|
201
|
-
}
|
|
202
|
-
/** Whether this project already carries the instruction, for `status`. */
|
|
203
|
-
function isInstalled(root) {
|
|
204
|
-
for (const target of [
|
|
205
|
-
"AGENTS.md",
|
|
206
|
-
"CLAUDE.md",
|
|
207
|
-
join(".cursor", "rules", "neuvikon.mdc")
|
|
208
|
-
]) {
|
|
209
|
-
const path = join(root, target);
|
|
210
|
-
if (existsSync(path) && hasInstructions(readFileSync(path, "utf8"))) return true;
|
|
211
|
-
}
|
|
212
|
-
return false;
|
|
213
|
-
}
|
|
214
|
-
//#endregion
|
|
215
|
-
//#region src/config.ts
|
|
216
|
-
/**
|
|
217
|
-
* The two deployments this CLI knows about. The browser origin is kept here
|
|
218
|
-
* rather than read back from the server, because the API answers on a Convex
|
|
219
|
-
* domain while approval happens on the web app's — one is not derivable from
|
|
220
|
-
* the other, and guessing wrong sends people to a page that does not exist.
|
|
221
|
-
*/
|
|
222
|
-
const DEPLOYMENTS = {
|
|
223
|
-
production: {
|
|
224
|
-
api: "https://flexible-bird-801.eu-west-1.convex.site",
|
|
225
|
-
web: "https://www.neuvikon.space"
|
|
226
|
-
},
|
|
227
|
-
development: {
|
|
228
|
-
api: "https://calm-sturgeon-831.eu-west-1.convex.site",
|
|
229
|
-
web: "http://localhost:3000"
|
|
230
|
-
}
|
|
231
|
-
};
|
|
232
|
-
/**
|
|
233
|
-
* `--dev` targets the development deployment; NEUVIKON_URL overrides the API
|
|
234
|
-
* for a self-hosted install, in which case the web origin is unknowable and
|
|
235
|
-
* falls back to the matching default.
|
|
236
|
-
*/
|
|
237
|
-
function resolveDeployment(flags) {
|
|
238
|
-
const base = flags.has("dev") ? DEPLOYMENTS.development : DEPLOYMENTS.production;
|
|
239
|
-
const override = flags.get("url") || process.env.NEUVIKON_URL;
|
|
240
|
-
return override ? {
|
|
241
|
-
api: override.trim().replace(/\/+$/, ""),
|
|
242
|
-
web: base.web
|
|
243
|
-
} : base;
|
|
244
|
-
}
|
|
245
|
-
const DEFAULT_SITE_URL = process.env.NEUVIKON_URL ?? DEPLOYMENTS.production.api;
|
|
246
|
-
const CONFIG_DIR = join(homedir(), ".neuvikon");
|
|
247
|
-
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
248
|
-
/**
|
|
249
|
-
* Somewhere a browser cannot open — CI, a container, a remote box — there is no
|
|
250
|
-
* way to complete a device login, so a token handed in through the environment
|
|
251
|
-
* stands in for one. It wins over the config file: an explicit env var is the
|
|
252
|
-
* more specific instruction.
|
|
253
|
-
*/
|
|
254
|
-
function credentialsFromEnv() {
|
|
255
|
-
const token = process.env.NEUVIKON_TOKEN;
|
|
256
|
-
if (!token) return null;
|
|
257
|
-
return {
|
|
258
|
-
token,
|
|
259
|
-
url: DEFAULT_SITE_URL
|
|
260
|
-
};
|
|
261
|
-
}
|
|
262
|
-
function readCredentials() {
|
|
263
|
-
const fromEnv = credentialsFromEnv();
|
|
264
|
-
if (fromEnv) return fromEnv;
|
|
265
|
-
let raw;
|
|
266
|
-
try {
|
|
267
|
-
raw = readFileSync(CONFIG_PATH, "utf8");
|
|
268
|
-
} catch {
|
|
269
|
-
return null;
|
|
270
|
-
}
|
|
271
|
-
try {
|
|
272
|
-
const parsed = JSON.parse(raw);
|
|
273
|
-
if (typeof parsed !== "object" || parsed === null) return null;
|
|
274
|
-
const { token, url } = parsed;
|
|
275
|
-
if (typeof token !== "string" || !token) return null;
|
|
276
|
-
if (typeof url !== "string" || !url) return null;
|
|
277
|
-
return parsed;
|
|
278
|
-
} catch {
|
|
279
|
-
return null;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
/**
|
|
283
|
-
* A token on disk is only as safe as its permissions, so directory and file are
|
|
284
|
-
* locked to the owner on every write, not just on first create — an earlier
|
|
285
|
-
* version may have left them readable.
|
|
286
|
-
*/
|
|
287
|
-
function writeCredentials(credentials) {
|
|
288
|
-
mkdirSync(CONFIG_DIR, {
|
|
289
|
-
recursive: true,
|
|
290
|
-
mode: 448
|
|
291
|
-
});
|
|
292
|
-
chmodSync(CONFIG_DIR, 448);
|
|
293
|
-
writeFileSync(CONFIG_PATH, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 384 });
|
|
294
|
-
chmodSync(CONFIG_PATH, 384);
|
|
295
|
-
}
|
|
296
|
-
function clearCredentials() {
|
|
297
|
-
try {
|
|
298
|
-
rmSync(CONFIG_PATH);
|
|
299
|
-
return true;
|
|
300
|
-
} catch {
|
|
301
|
-
return false;
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
//#endregion
|
|
305
|
-
//#region src/ui.ts
|
|
306
|
-
/**
|
|
307
|
-
* Output helpers. Everything the agent shouldn't parse goes to stderr, so a
|
|
308
|
-
* command's stdout stays a clean, pipeable value.
|
|
309
|
-
*/
|
|
310
|
-
const useColor = process.env.NO_COLOR === void 0 && process.stderr.isTTY === true;
|
|
311
|
-
function paint(code, text) {
|
|
312
|
-
return useColor ? `\x1b[${code}m${text}\x1b[0m` : text;
|
|
313
|
-
}
|
|
314
|
-
const dim = (text) => paint("2", text);
|
|
315
|
-
const bold = (text) => paint("1", text);
|
|
316
|
-
const green = (text) => paint("32", text);
|
|
317
|
-
const red = (text) => paint("31", text);
|
|
318
|
-
const cyan = (text) => paint("36", text);
|
|
319
|
-
function info(message) {
|
|
320
|
-
process.stderr.write(`${message}\n`);
|
|
321
|
-
}
|
|
322
|
-
function success(message) {
|
|
323
|
-
process.stderr.write(`${green("✓")} ${message}\n`);
|
|
324
|
-
}
|
|
325
|
-
function warn(message) {
|
|
326
|
-
process.stderr.write(`${red("!")} ${message}\n`);
|
|
327
|
-
}
|
|
328
|
-
/** Ends the process. Every user-facing failure funnels through here. */
|
|
329
|
-
function fail(message, hint) {
|
|
330
|
-
process.stderr.write(`${red("✗")} ${message}\n`);
|
|
331
|
-
if (hint) process.stderr.write(` ${dim(hint)}\n`);
|
|
332
|
-
process.exit(1);
|
|
333
|
-
}
|
|
334
|
-
//#endregion
|
|
335
|
-
//#region src/commands/init.ts
|
|
336
|
-
/**
|
|
337
|
-
* Separate from `login` because instructions are per-project and credentials
|
|
338
|
-
* are per-machine: a second checkout needs this, not another login.
|
|
339
|
-
*/
|
|
340
|
-
function init() {
|
|
341
|
-
const results = installAgentInstructions(process.cwd());
|
|
342
|
-
for (const result of results) if (result.action === "unchanged") info(dim(` ${result.path} — already up to date`));
|
|
343
|
-
else if (result.action === "skipped") warn(`${result.path} — skipped (${result.reason})`);
|
|
344
|
-
else success(`${result.path} — ${result.action}`);
|
|
345
|
-
if (!readCredentials()) warn("Not logged in yet — run `npx neuvikon login`.");
|
|
346
|
-
}
|
|
347
|
-
//#endregion
|
|
348
|
-
//#region src/api.ts
|
|
349
|
-
var ApiError = class extends Error {
|
|
350
|
-
status;
|
|
351
|
-
constructor(message, status) {
|
|
352
|
-
super(message);
|
|
353
|
-
this.status = status;
|
|
354
|
-
this.name = "ApiError";
|
|
355
|
-
}
|
|
356
|
-
};
|
|
357
|
-
/** A request that never reached the server: offline, DNS, TLS, timeout. */
|
|
358
|
-
var NetworkError = class extends Error {
|
|
359
|
-
constructor(cause) {
|
|
360
|
-
super("Could not reach Neuvikon", { cause });
|
|
361
|
-
this.name = "NetworkError";
|
|
362
|
-
}
|
|
363
|
-
};
|
|
364
|
-
const TIMEOUT_MS = 15e3;
|
|
365
|
-
/**
|
|
366
|
-
* The one place an HTTP failure becomes a typed error. Callers decide what a
|
|
367
|
-
* failure means — a hook wrapping the agent's turn wants to swallow it, `login`
|
|
368
|
-
* wants to shout about it.
|
|
369
|
-
*/
|
|
370
|
-
async function post(url, path, body) {
|
|
371
|
-
let response;
|
|
372
|
-
try {
|
|
373
|
-
response = await fetch(`${url}${path}`, {
|
|
374
|
-
method: "POST",
|
|
375
|
-
headers: { "content-type": "application/json" },
|
|
376
|
-
body: JSON.stringify(body),
|
|
377
|
-
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
378
|
-
});
|
|
379
|
-
} catch (error) {
|
|
380
|
-
throw new NetworkError(error);
|
|
630
|
+
const gitignore = ignoreSessionFile(root);
|
|
631
|
+
if (gitignore) results.push(gitignore);
|
|
632
|
+
return results;
|
|
633
|
+
}
|
|
634
|
+
/** Whether this project already carries the instruction, for `status`. */
|
|
635
|
+
function isInstalled(root) {
|
|
636
|
+
for (const target of [
|
|
637
|
+
"AGENTS.md",
|
|
638
|
+
"CLAUDE.md",
|
|
639
|
+
join(".cursor", "rules", "neuvikon.mdc")
|
|
640
|
+
]) {
|
|
641
|
+
const path = join(root, target);
|
|
642
|
+
if (existsSync(path) && hasInstructions(readFileSync(path, "utf8"))) return true;
|
|
381
643
|
}
|
|
382
|
-
|
|
383
|
-
const error = typeof payload === "object" && payload !== null ? payload.error : void 0;
|
|
384
|
-
if (!response.ok) throw new ApiError(typeof error === "string" ? error : `Request failed with status ${response.status}`, response.status);
|
|
385
|
-
return payload;
|
|
644
|
+
return false;
|
|
386
645
|
}
|
|
387
|
-
|
|
388
|
-
|
|
646
|
+
//#endregion
|
|
647
|
+
//#region src/commands/init.ts
|
|
648
|
+
/**
|
|
649
|
+
* Separate from `login` because instructions are per-project and credentials
|
|
650
|
+
* are per-machine: a second checkout needs this, not another login.
|
|
651
|
+
*/
|
|
652
|
+
function init() {
|
|
653
|
+
const results = installAgentInstructions(process.cwd());
|
|
654
|
+
for (const result of results) if (result.action === "unchanged") info(dim(` ${result.path} — already up to date`));
|
|
655
|
+
else if (result.action === "skipped") warn(`${result.path} — skipped (${result.reason})`);
|
|
656
|
+
else success(`${result.path} — ${result.action}`);
|
|
657
|
+
if (!readCredentials()) warn("Not logged in yet — run `npx neuvikon login`.");
|
|
389
658
|
}
|
|
390
659
|
//#endregion
|
|
391
660
|
//#region src/browser.ts
|
|
@@ -465,54 +734,6 @@ async function login(flags) {
|
|
|
465
734
|
}
|
|
466
735
|
}
|
|
467
736
|
//#endregion
|
|
468
|
-
//#region src/session.ts
|
|
469
|
-
/**
|
|
470
|
-
* Distinguishes concurrent agents working in the *same* checkout — two Claude
|
|
471
|
-
* Code panes both running `claude` in this directory get two session files,
|
|
472
|
-
* because each one's process tree carries its own CLAUDE_CODE_SESSION_ID. A
|
|
473
|
-
* tool that doesn't set it (or a person typing the command by hand) falls
|
|
474
|
-
* back to the pre-multi-agent behavior of one session per directory.
|
|
475
|
-
*/
|
|
476
|
-
function instanceKey() {
|
|
477
|
-
return process.env.CLAUDE_CODE_SESSION_ID?.trim() || null;
|
|
478
|
-
}
|
|
479
|
-
/**
|
|
480
|
-
* Scoped to the working directory rather than the machine: two checkouts driven
|
|
481
|
-
* in parallel are two sessions, which is what running two agents means. Within
|
|
482
|
-
* one checkout, `instanceKey()` further scopes it per agent so two panes in the
|
|
483
|
-
* same directory don't stop each other's session out from under them.
|
|
484
|
-
*/
|
|
485
|
-
function sessionPath() {
|
|
486
|
-
const key = instanceKey();
|
|
487
|
-
const filename = key ? `.neuvikon-session.${key}` : ".neuvikon-session";
|
|
488
|
-
return join(process.cwd(), filename);
|
|
489
|
-
}
|
|
490
|
-
function readSession() {
|
|
491
|
-
let raw;
|
|
492
|
-
try {
|
|
493
|
-
raw = readFileSync(sessionPath(), "utf8");
|
|
494
|
-
} catch {
|
|
495
|
-
return null;
|
|
496
|
-
}
|
|
497
|
-
try {
|
|
498
|
-
const parsed = JSON.parse(raw);
|
|
499
|
-
if (typeof parsed !== "object" || parsed === null) return null;
|
|
500
|
-
const { sessionKey, ticket } = parsed;
|
|
501
|
-
if (typeof sessionKey !== "string" || typeof ticket !== "string") return null;
|
|
502
|
-
return parsed;
|
|
503
|
-
} catch {
|
|
504
|
-
return null;
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
function writeSession(session) {
|
|
508
|
-
writeFileSync(sessionPath(), `${JSON.stringify(session, null, 2)}\n`);
|
|
509
|
-
}
|
|
510
|
-
function clearSession() {
|
|
511
|
-
try {
|
|
512
|
-
rmSync(sessionPath());
|
|
513
|
-
} catch {}
|
|
514
|
-
}
|
|
515
|
-
//#endregion
|
|
516
737
|
//#region src/commands/logout.ts
|
|
517
738
|
/**
|
|
518
739
|
* Deleting the local file is not enough: the token stays valid until the server
|
|
@@ -549,126 +770,6 @@ async function logout() {
|
|
|
549
770
|
info(dim(" Could not reach Neuvikon to revoke the token — revoke it in settings if it leaked."));
|
|
550
771
|
}
|
|
551
772
|
//#endregion
|
|
552
|
-
//#region src/ticket.ts
|
|
553
|
-
/**
|
|
554
|
-
* Ticket refs look like `AWKN-14`. This pattern is deliberately looser than the
|
|
555
|
-
* server's — it pulls *candidates* out of arbitrary pasted prose and lets the
|
|
556
|
-
* server decide which one is a real ticket in the caller's organization, so the
|
|
557
|
-
* authoritative rule lives in exactly one place.
|
|
558
|
-
*/
|
|
559
|
-
const CANDIDATE = /\b([A-Z0-9]{2,8})-(\d{1,9})\b/g;
|
|
560
|
-
/** Every distinct ref-shaped token in `text`, in the order it appears. */
|
|
561
|
-
function extractTicketRefs(text) {
|
|
562
|
-
const seen = /* @__PURE__ */ new Set();
|
|
563
|
-
for (const match of text.toUpperCase().matchAll(CANDIDATE)) {
|
|
564
|
-
if (/^\d+$/.test(match[1])) continue;
|
|
565
|
-
seen.add(`${match[1]}-${match[2]}`);
|
|
566
|
-
}
|
|
567
|
-
return [...seen];
|
|
568
|
-
}
|
|
569
|
-
/** Reads piped input, if any. Returns "" when stdin is a terminal. */
|
|
570
|
-
async function readStdin() {
|
|
571
|
-
if (process.stdin.isTTY) return "";
|
|
572
|
-
const chunks = [];
|
|
573
|
-
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
574
|
-
return Buffer.concat(chunks).toString("utf8");
|
|
575
|
-
}
|
|
576
|
-
//#endregion
|
|
577
|
-
//#region src/usage.ts
|
|
578
|
-
/**
|
|
579
|
-
* Sums token usage out of a JSONL agent transcript, starting from `offset`.
|
|
580
|
-
*
|
|
581
|
-
* Reading incrementally is what makes a per-turn hook safe to call repeatedly:
|
|
582
|
-
* each call reports only what was appended since the last one. Anything that
|
|
583
|
-
* doesn't parse is skipped rather than fatal — a transcript being appended to
|
|
584
|
-
* while we read it will have a torn final line, and that is normal, not an
|
|
585
|
-
* error worth failing a user's turn over.
|
|
586
|
-
*/
|
|
587
|
-
/**
|
|
588
|
-
* Where the transcript currently ends, so a session that starts mid-conversation
|
|
589
|
-
* can mark its starting point instead of counting what came before it.
|
|
590
|
-
*/
|
|
591
|
-
function transcriptSize(path) {
|
|
592
|
-
let fd;
|
|
593
|
-
try {
|
|
594
|
-
fd = openSync(path, "r");
|
|
595
|
-
} catch {
|
|
596
|
-
return null;
|
|
597
|
-
}
|
|
598
|
-
try {
|
|
599
|
-
return fstatSync(fd).size;
|
|
600
|
-
} catch {
|
|
601
|
-
return null;
|
|
602
|
-
} finally {
|
|
603
|
-
closeSync(fd);
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
function readUsageSince(path, offset) {
|
|
607
|
-
let fd;
|
|
608
|
-
try {
|
|
609
|
-
fd = openSync(path, "r");
|
|
610
|
-
} catch {
|
|
611
|
-
return null;
|
|
612
|
-
}
|
|
613
|
-
try {
|
|
614
|
-
const size = fstatSync(fd).size;
|
|
615
|
-
const from = offset > size ? 0 : offset;
|
|
616
|
-
if (size === from) return {
|
|
617
|
-
tokensIn: 0,
|
|
618
|
-
tokensOut: 0,
|
|
619
|
-
offset: size
|
|
620
|
-
};
|
|
621
|
-
const buffer = Buffer.alloc(size - from);
|
|
622
|
-
readSync(fd, buffer, 0, buffer.length, from);
|
|
623
|
-
const text = buffer.toString("utf8");
|
|
624
|
-
const lastNewline = text.lastIndexOf("\n");
|
|
625
|
-
if (lastNewline === -1) return {
|
|
626
|
-
tokensIn: 0,
|
|
627
|
-
tokensOut: 0,
|
|
628
|
-
offset: from
|
|
629
|
-
};
|
|
630
|
-
let tokensIn = 0;
|
|
631
|
-
let tokensOut = 0;
|
|
632
|
-
for (const line of text.slice(0, lastNewline).split("\n")) {
|
|
633
|
-
if (!line.trim()) continue;
|
|
634
|
-
let row;
|
|
635
|
-
try {
|
|
636
|
-
row = JSON.parse(line);
|
|
637
|
-
} catch {
|
|
638
|
-
continue;
|
|
639
|
-
}
|
|
640
|
-
const usage = extractUsage(row);
|
|
641
|
-
if (!usage) continue;
|
|
642
|
-
tokensIn += usage.tokensIn;
|
|
643
|
-
tokensOut += usage.tokensOut;
|
|
644
|
-
}
|
|
645
|
-
return {
|
|
646
|
-
tokensIn,
|
|
647
|
-
tokensOut,
|
|
648
|
-
offset: from + Buffer.byteLength(text.slice(0, lastNewline + 1))
|
|
649
|
-
};
|
|
650
|
-
} catch {
|
|
651
|
-
return null;
|
|
652
|
-
} finally {
|
|
653
|
-
closeSync(fd);
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
function toCount$1(value) {
|
|
657
|
-
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
658
|
-
}
|
|
659
|
-
function extractUsage(row) {
|
|
660
|
-
if (typeof row !== "object" || row === null) return null;
|
|
661
|
-
const message = row.message;
|
|
662
|
-
if (typeof message !== "object" || message === null) return null;
|
|
663
|
-
const usage = message.usage;
|
|
664
|
-
if (typeof usage !== "object" || usage === null) return null;
|
|
665
|
-
const fields = usage;
|
|
666
|
-
return {
|
|
667
|
-
tokensIn: toCount$1(fields.input_tokens) + toCount$1(fields.cache_read_input_tokens) + toCount$1(fields.cache_creation_input_tokens),
|
|
668
|
-
tokensOut: toCount$1(fields.output_tokens)
|
|
669
|
-
};
|
|
670
|
-
}
|
|
671
|
-
//#endregion
|
|
672
773
|
//#region src/commands/report.ts
|
|
673
774
|
/**
|
|
674
775
|
* Agent hooks hand their context to the command on stdin as JSON. Reading the
|
|
@@ -693,54 +794,87 @@ function toCount(raw) {
|
|
|
693
794
|
return Number.isFinite(value) && value >= 0 ? value : void 0;
|
|
694
795
|
}
|
|
695
796
|
/**
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
*
|
|
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.
|
|
699
802
|
*/
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
let nextOffset;
|
|
710
|
-
const transcript = flags.get("transcript") || await transcriptFromHookPayload();
|
|
711
|
-
if (transcript) {
|
|
712
|
-
const baseline = session.transcriptOffset ?? transcriptSize(transcript);
|
|
713
|
-
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({
|
|
714
812
|
...session,
|
|
715
813
|
transcriptOffset: baseline
|
|
716
814
|
});
|
|
717
|
-
const usage = baseline === null ? null : readUsageSince(transcript, baseline);
|
|
718
|
-
if (usage) {
|
|
719
|
-
tokensIn = (tokensIn ?? 0) + usage.tokensIn;
|
|
720
|
-
tokensOut = (tokensOut ?? 0) + usage.tokensOut;
|
|
721
|
-
nextOffset = usage.offset;
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
if (!tokensIn && !tokensOut) {
|
|
725
|
-
if (flags.has("verbose")) info(dim("No new usage to report."));
|
|
726
|
-
return;
|
|
727
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) {
|
|
728
837
|
try {
|
|
729
838
|
await post(credentials.url, "/api/agent/events", {
|
|
730
839
|
token: credentials.token,
|
|
731
840
|
event: "report",
|
|
732
841
|
sessionKey: session.sessionKey,
|
|
733
|
-
tokensIn,
|
|
734
|
-
tokensOut
|
|
842
|
+
tokensIn: delta.tokensIn,
|
|
843
|
+
tokensOut: delta.tokensOut
|
|
735
844
|
});
|
|
736
845
|
} catch {
|
|
737
|
-
return;
|
|
846
|
+
return false;
|
|
738
847
|
}
|
|
739
|
-
if (nextOffset !== void 0) writeSession({
|
|
848
|
+
if (delta.nextOffset !== void 0) writeSession({
|
|
740
849
|
...session,
|
|
741
|
-
transcriptOffset: nextOffset
|
|
850
|
+
transcriptOffset: delta.nextOffset
|
|
742
851
|
});
|
|
743
|
-
|
|
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`));
|
|
744
878
|
}
|
|
745
879
|
//#endregion
|
|
746
880
|
//#region src/commands/start.ts
|
|
@@ -761,11 +895,18 @@ async function resolveRef(flags, positional) {
|
|
|
761
895
|
if (refs.length === 0) fail("No ticket reference found.", "Pass one with --ticket EWWQ-1, or pipe the ticket text in.");
|
|
762
896
|
return refs[0];
|
|
763
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
|
+
}
|
|
764
905
|
async function start(flags, positional) {
|
|
765
906
|
const credentials = readCredentials();
|
|
766
907
|
if (!credentials) fail("Not logged in.", "Run `npx neuvikon login` first.");
|
|
767
908
|
const ref = await resolveRef(flags, positional);
|
|
768
|
-
const tool = flags.get("tool") ??
|
|
909
|
+
const tool = flags.get("tool") ?? detectTool();
|
|
769
910
|
if (!TOOLS.has(tool)) fail(`Unknown tool "${tool}".`, `Expected one of: ${[...TOOLS].join(", ")}.`);
|
|
770
911
|
const existing = readSession();
|
|
771
912
|
if (existing?.ticket === ref) {
|
|
@@ -793,10 +934,12 @@ async function start(flags, positional) {
|
|
|
793
934
|
event: "stop",
|
|
794
935
|
sessionKey: existing.sessionKey
|
|
795
936
|
}).catch(() => void 0);
|
|
937
|
+
const transcript = flags.get("transcript") || claudeTranscriptPath();
|
|
796
938
|
writeSession({
|
|
797
939
|
sessionKey: result.sessionKey,
|
|
798
940
|
ticket: ref,
|
|
799
|
-
startedAt: Date.now()
|
|
941
|
+
startedAt: Date.now(),
|
|
942
|
+
transcriptOffset: transcript ? transcriptSize(transcript) ?? void 0 : void 0
|
|
800
943
|
});
|
|
801
944
|
success(`Tracking work on ${bold(ref)}`);
|
|
802
945
|
}
|
|
@@ -820,18 +963,23 @@ function status() {
|
|
|
820
963
|
}
|
|
821
964
|
//#endregion
|
|
822
965
|
//#region src/commands/stop.ts
|
|
823
|
-
async function stop() {
|
|
966
|
+
async function stop(flags) {
|
|
824
967
|
const session = readSession();
|
|
825
968
|
if (!session) {
|
|
826
969
|
info(dim("No ticket is being tracked here."));
|
|
827
970
|
return;
|
|
828
971
|
}
|
|
829
972
|
const credentials = readCredentials();
|
|
830
|
-
if (credentials)
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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
|
+
}
|
|
835
983
|
clearSession();
|
|
836
984
|
success(`Stopped tracking ${session.ticket}`);
|
|
837
985
|
}
|
|
@@ -890,8 +1038,9 @@ async function main() {
|
|
|
890
1038
|
case "logout": return logout();
|
|
891
1039
|
case "init": return init();
|
|
892
1040
|
case "start": return start(flags, positional);
|
|
1041
|
+
case "hook-start": return hookStart();
|
|
893
1042
|
case "report": return report(flags);
|
|
894
|
-
case "stop": return stop();
|
|
1043
|
+
case "stop": return stop(flags);
|
|
895
1044
|
case "status": return status();
|
|
896
1045
|
case void 0:
|
|
897
1046
|
case "help":
|