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.
Files changed (3) hide show
  1. package/README.md +9 -0
  2. package/dist/index.mjs +554 -405
  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,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. Without this, `start`/`stop` are the only events an
25
- * agent ever sends and token usage never leaves zero.
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
- SessionEnd: withCommand(hooks.SessionEnd, STOP_COMMAND)
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
- const payload = await response.json().catch(() => ({}));
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
- function normalizeUrl(url) {
388
- return url.trim().replace(/\/+$/, "");
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
- * Called from an agent hook on every turn, so it is deliberately silent and
697
- * never exits non-zero: a token count is not worth interrupting someone's work
698
- * 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.
699
802
  */
700
- async function report(flags) {
701
- const credentials = readCredentials();
702
- const session = readSession();
703
- if (!credentials || !session) {
704
- if (flags.has("verbose")) info(dim(credentials ? "No ticket is being tracked here." : "Not logged in."));
705
- return;
706
- }
707
- let tokensIn = toCount(flags.get("tokens-in"));
708
- let tokensOut = toCount(flags.get("tokens-out"));
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
- 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`));
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") ?? "other";
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) await post(credentials.url, "/api/agent/events", {
831
- token: credentials.token,
832
- event: "stop",
833
- sessionKey: session.sessionKey
834
- }).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
+ }
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":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuvikon",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Tell Neuvikon which ticket your coding agent is working on.",
5
5
  "keywords": [
6
6
  "neuvikon",