neuvikon 0.2.6 → 0.2.8

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 +1 -1
  2. package/dist/index.mjs +311 -230
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -74,7 +74,7 @@ being used, add the block above by hand.
74
74
  | | |
75
75
  |---|---|
76
76
  | `~/.neuvikon/config.json` | Token and deployment URL, `0600` |
77
- | `.neuvikon-session` | Which ticket this directory is tracking. Git-ignored by `init` |
77
+ | `.neuvikon-session[.<id>]` | Which ticket this directory (or, under Claude Code, this agent) is tracking. Git-ignored by `init` |
78
78
  | `--dev` | Target the development deployment instead of production |
79
79
  | `NEUVIKON_URL` | Point at a self-hosted deployment. `--url` does the same per command |
80
80
  | `NEUVIKON_TOKEN` | Use this token instead of logging in — for CI and anywhere a browser cannot open |
package/dist/index.mjs CHANGED
@@ -1,11 +1,284 @@
1
1
  #!/usr/bin/env node
2
2
  import { chmodSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
3
- import { dirname, join } from "node:path";
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/ui.ts
213
+ /**
214
+ * Output helpers. Everything the agent shouldn't parse goes to stderr, so a
215
+ * command's stdout stays a clean, pipeable value.
216
+ */
217
+ const useColor = process.env.NO_COLOR === void 0 && process.stderr.isTTY === true;
218
+ function paint(code, text) {
219
+ return useColor ? `\x1b[${code}m${text}\x1b[0m` : text;
220
+ }
221
+ const dim = (text) => paint("2", text);
222
+ const bold = (text) => paint("1", text);
223
+ const green = (text) => paint("32", text);
224
+ const red = (text) => paint("31", text);
225
+ const cyan = (text) => paint("36", text);
226
+ function info(message) {
227
+ process.stderr.write(`${message}\n`);
228
+ }
229
+ function success(message) {
230
+ process.stderr.write(`${green("✓")} ${message}\n`);
231
+ }
232
+ function warn(message) {
233
+ process.stderr.write(`${red("!")} ${message}\n`);
234
+ }
235
+ /** Ends the process. Every user-facing failure funnels through here. */
236
+ function fail(message, hint) {
237
+ process.stderr.write(`${red("✗")} ${message}\n`);
238
+ if (hint) process.stderr.write(` ${dim(hint)}\n`);
239
+ process.exit(1);
240
+ }
241
+ //#endregion
242
+ //#region src/commands/hook-start.ts
243
+ /**
244
+ * The `UserPromptSubmit` counterpart to `start`. Unlike `start`, this runs on
245
+ * *every* prompt regardless of whether it mentions a ticket, so it must never
246
+ * fail the hook and block the user's message — no credentials, no ticket ref,
247
+ * or a dead network are all just "nothing to do here", not errors.
248
+ */
249
+ async function hookStart() {
250
+ try {
251
+ const credentials = readCredentials();
252
+ if (!credentials) return;
253
+ const [ref] = extractTicketRefs(await readStdin());
254
+ if (!ref) return;
255
+ const existing = readSession();
256
+ if (existing?.ticket === ref) return;
257
+ const result = await post(credentials.url, "/api/agent/events", {
258
+ token: credentials.token,
259
+ event: "start",
260
+ ticketRef: ref,
261
+ tool: "claude-code"
262
+ });
263
+ if (existing) await post(credentials.url, "/api/agent/events", {
264
+ token: credentials.token,
265
+ event: "stop",
266
+ sessionKey: existing.sessionKey
267
+ }).catch(() => void 0);
268
+ writeSession({
269
+ sessionKey: result.sessionKey,
270
+ ticket: ref,
271
+ startedAt: Date.now()
272
+ });
273
+ success(`Tracking work on ${bold(ref)}`);
274
+ } catch {}
275
+ }
276
+ //#endregion
6
277
  //#region src/agents/hooks.ts
7
278
  const REPORT_COMMAND = "npx neuvikon report";
279
+ const HEARTBEAT_COMMAND = "npx neuvikon report --heartbeat";
8
280
  const STOP_COMMAND = "npx neuvikon stop";
281
+ const HOOK_START_COMMAND = "npx neuvikon hook-start";
9
282
  function hasCommand(groups, command) {
10
283
  return (groups ?? []).some((group) => group.hooks.some((hook) => hook.command === command));
11
284
  }
@@ -21,8 +294,20 @@ function withCommand(groups, command) {
21
294
  * whose transcript exposes usage, per `usage.ts`. `Stop` fires after every
22
295
  * turn, exactly when `report` should read what the transcript gained since
23
296
  * 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.
297
+ * to run it by hand.
298
+ *
299
+ * `UserPromptSubmit` covers `start`: relying on the agent to notice a ticket
300
+ * ref in the prompt and remember to run `start` itself is exactly the failure
301
+ * mode reported in NS-31 — an LLM that's mid-instruction-following skips it
302
+ * often enough to be unreliable bookkeeping. The hook makes it deterministic
303
+ * for Claude Code; other tools still fall back to the AGENTS.md instruction.
304
+ *
305
+ * `Notification` fires when Claude Code is blocked mid-turn — a permission
306
+ * prompt, a question, waiting on the user — which is exactly when `Stop`
307
+ * does *not* fire, since the turn hasn't finished. Without a heartbeat there
308
+ * too, a session that's genuinely just waiting on a person reads as stale on
309
+ * the board after `AGENT_SESSION_STALE_MS`, indistinguishable from one that
310
+ * actually stopped (NS-32).
26
311
  */
27
312
  function installClaudeHooks(root) {
28
313
  const relativePath = join(".claude", "settings.json");
@@ -39,7 +324,7 @@ function installClaudeHooks(root) {
39
324
  };
40
325
  }
41
326
  const hooks = settings.hooks ?? {};
42
- if (hasCommand(hooks.Stop, REPORT_COMMAND) && hasCommand(hooks.SessionEnd, STOP_COMMAND)) return {
327
+ if (hasCommand(hooks.Stop, REPORT_COMMAND) && hasCommand(hooks.Notification, HEARTBEAT_COMMAND) && hasCommand(hooks.SessionEnd, STOP_COMMAND) && hasCommand(hooks.UserPromptSubmit, HOOK_START_COMMAND)) return {
43
328
  path: relativePath,
44
329
  action: "unchanged"
45
330
  };
@@ -48,7 +333,9 @@ function installClaudeHooks(root) {
48
333
  hooks: {
49
334
  ...hooks,
50
335
  Stop: withCommand(hooks.Stop, REPORT_COMMAND),
51
- SessionEnd: withCommand(hooks.SessionEnd, STOP_COMMAND)
336
+ Notification: withCommand(hooks.Notification, HEARTBEAT_COMMAND),
337
+ SessionEnd: withCommand(hooks.SessionEnd, STOP_COMMAND),
338
+ UserPromptSubmit: withCommand(hooks.UserPromptSubmit, HOOK_START_COMMAND)
52
339
  }
53
340
  };
54
341
  mkdirSync(dirname(path), { recursive: true });
@@ -156,17 +443,32 @@ function writeCursorRule(root) {
156
443
  action: existing ? "updated" : "created"
157
444
  };
158
445
  }
159
- /** Keeps the per-directory session file out of the user's commits. */
446
+ /**
447
+ * Keeps the per-directory session file(s) out of the user's commits. Globbed
448
+ * because each concurrent agent gets its own `.neuvikon-session.<id>` file
449
+ * now (see `session.ts`) — an exact-match `.neuvikon-session` line no longer
450
+ * covers them, so an older, already-ignored project is migrated in place.
451
+ */
160
452
  function ignoreSessionFile(root) {
161
453
  const path = join(root, ".gitignore");
162
454
  if (!existsSync(join(root, ".git")) && !existsSync(path)) return null;
163
455
  const existing = existsSync(path) ? readFileSync(path, "utf8") : "";
164
- if (existing.split("\n").some((line) => line.trim() === ".neuvikon-session")) return {
456
+ const lines = existing.split("\n");
457
+ if (lines.some((line) => line.trim() === ".neuvikon-session*")) return {
165
458
  path: ".gitignore",
166
459
  action: "unchanged"
167
460
  };
461
+ const legacyIndex = lines.findIndex((line) => line.trim() === ".neuvikon-session");
462
+ if (legacyIndex !== -1) {
463
+ lines[legacyIndex] = ".neuvikon-session*";
464
+ writeFileSync(path, lines.join("\n"));
465
+ return {
466
+ path: ".gitignore",
467
+ action: "updated"
468
+ };
469
+ }
168
470
  const prefix = existing.length === 0 ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
169
- writeFileSync(path, `${existing}${prefix}# neuvikon CLI session state\n.neuvikon-session\n`);
471
+ writeFileSync(path, `${existing}${prefix}# neuvikon CLI session state\n.neuvikon-session*\n`);
170
472
  return {
171
473
  path: ".gitignore",
172
474
  action: existing ? "updated" : "created"
@@ -197,126 +499,6 @@ function isInstalled(root) {
197
499
  return false;
198
500
  }
199
501
  //#endregion
200
- //#region src/config.ts
201
- /**
202
- * The two deployments this CLI knows about. The browser origin is kept here
203
- * rather than read back from the server, because the API answers on a Convex
204
- * domain while approval happens on the web app's — one is not derivable from
205
- * the other, and guessing wrong sends people to a page that does not exist.
206
- */
207
- const DEPLOYMENTS = {
208
- production: {
209
- api: "https://flexible-bird-801.eu-west-1.convex.site",
210
- web: "https://www.neuvikon.space"
211
- },
212
- development: {
213
- api: "https://calm-sturgeon-831.eu-west-1.convex.site",
214
- web: "http://localhost:3000"
215
- }
216
- };
217
- /**
218
- * `--dev` targets the development deployment; NEUVIKON_URL overrides the API
219
- * for a self-hosted install, in which case the web origin is unknowable and
220
- * falls back to the matching default.
221
- */
222
- function resolveDeployment(flags) {
223
- const base = flags.has("dev") ? DEPLOYMENTS.development : DEPLOYMENTS.production;
224
- const override = flags.get("url") || process.env.NEUVIKON_URL;
225
- return override ? {
226
- api: override.trim().replace(/\/+$/, ""),
227
- web: base.web
228
- } : base;
229
- }
230
- const DEFAULT_SITE_URL = process.env.NEUVIKON_URL ?? DEPLOYMENTS.production.api;
231
- const CONFIG_DIR = join(homedir(), ".neuvikon");
232
- const CONFIG_PATH = join(CONFIG_DIR, "config.json");
233
- /**
234
- * Somewhere a browser cannot open — CI, a container, a remote box — there is no
235
- * way to complete a device login, so a token handed in through the environment
236
- * stands in for one. It wins over the config file: an explicit env var is the
237
- * more specific instruction.
238
- */
239
- function credentialsFromEnv() {
240
- const token = process.env.NEUVIKON_TOKEN;
241
- if (!token) return null;
242
- return {
243
- token,
244
- url: DEFAULT_SITE_URL
245
- };
246
- }
247
- function readCredentials() {
248
- const fromEnv = credentialsFromEnv();
249
- if (fromEnv) return fromEnv;
250
- let raw;
251
- try {
252
- raw = readFileSync(CONFIG_PATH, "utf8");
253
- } catch {
254
- return null;
255
- }
256
- try {
257
- const parsed = JSON.parse(raw);
258
- if (typeof parsed !== "object" || parsed === null) return null;
259
- const { token, url } = parsed;
260
- if (typeof token !== "string" || !token) return null;
261
- if (typeof url !== "string" || !url) return null;
262
- return parsed;
263
- } catch {
264
- return null;
265
- }
266
- }
267
- /**
268
- * A token on disk is only as safe as its permissions, so directory and file are
269
- * locked to the owner on every write, not just on first create — an earlier
270
- * version may have left them readable.
271
- */
272
- function writeCredentials(credentials) {
273
- mkdirSync(CONFIG_DIR, {
274
- recursive: true,
275
- mode: 448
276
- });
277
- chmodSync(CONFIG_DIR, 448);
278
- writeFileSync(CONFIG_PATH, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 384 });
279
- chmodSync(CONFIG_PATH, 384);
280
- }
281
- function clearCredentials() {
282
- try {
283
- rmSync(CONFIG_PATH);
284
- return true;
285
- } catch {
286
- return false;
287
- }
288
- }
289
- //#endregion
290
- //#region src/ui.ts
291
- /**
292
- * Output helpers. Everything the agent shouldn't parse goes to stderr, so a
293
- * command's stdout stays a clean, pipeable value.
294
- */
295
- const useColor = process.env.NO_COLOR === void 0 && process.stderr.isTTY === true;
296
- function paint(code, text) {
297
- return useColor ? `\x1b[${code}m${text}\x1b[0m` : text;
298
- }
299
- const dim = (text) => paint("2", text);
300
- const bold = (text) => paint("1", text);
301
- const green = (text) => paint("32", text);
302
- const red = (text) => paint("31", text);
303
- const cyan = (text) => paint("36", text);
304
- function info(message) {
305
- process.stderr.write(`${message}\n`);
306
- }
307
- function success(message) {
308
- process.stderr.write(`${green("✓")} ${message}\n`);
309
- }
310
- function warn(message) {
311
- process.stderr.write(`${red("!")} ${message}\n`);
312
- }
313
- /** Ends the process. Every user-facing failure funnels through here. */
314
- function fail(message, hint) {
315
- process.stderr.write(`${red("✗")} ${message}\n`);
316
- if (hint) process.stderr.write(` ${dim(hint)}\n`);
317
- process.exit(1);
318
- }
319
- //#endregion
320
502
  //#region src/commands/init.ts
321
503
  /**
322
504
  * Separate from `login` because instructions are per-project and credentials
@@ -330,49 +512,6 @@ function init() {
330
512
  if (!readCredentials()) warn("Not logged in yet — run `npx neuvikon login`.");
331
513
  }
332
514
  //#endregion
333
- //#region src/api.ts
334
- var ApiError = class extends Error {
335
- status;
336
- constructor(message, status) {
337
- super(message);
338
- this.status = status;
339
- this.name = "ApiError";
340
- }
341
- };
342
- /** A request that never reached the server: offline, DNS, TLS, timeout. */
343
- var NetworkError = class extends Error {
344
- constructor(cause) {
345
- super("Could not reach Neuvikon", { cause });
346
- this.name = "NetworkError";
347
- }
348
- };
349
- const TIMEOUT_MS = 15e3;
350
- /**
351
- * The one place an HTTP failure becomes a typed error. Callers decide what a
352
- * failure means — a hook wrapping the agent's turn wants to swallow it, `login`
353
- * wants to shout about it.
354
- */
355
- async function post(url, path, body) {
356
- let response;
357
- try {
358
- response = await fetch(`${url}${path}`, {
359
- method: "POST",
360
- headers: { "content-type": "application/json" },
361
- body: JSON.stringify(body),
362
- signal: AbortSignal.timeout(TIMEOUT_MS)
363
- });
364
- } catch (error) {
365
- throw new NetworkError(error);
366
- }
367
- const payload = await response.json().catch(() => ({}));
368
- const error = typeof payload === "object" && payload !== null ? payload.error : void 0;
369
- if (!response.ok) throw new ApiError(typeof error === "string" ? error : `Request failed with status ${response.status}`, response.status);
370
- return payload;
371
- }
372
- function normalizeUrl(url) {
373
- return url.trim().replace(/\/+$/, "");
374
- }
375
- //#endregion
376
515
  //#region src/browser.ts
377
516
  /**
378
517
  * Best-effort. The URL is always printed too, so a headless box, an SSH session
@@ -450,40 +589,6 @@ async function login(flags) {
450
589
  }
451
590
  }
452
591
  //#endregion
453
- //#region src/session.ts
454
- /**
455
- * Scoped to the working directory rather than the machine: two checkouts driven
456
- * in parallel are two sessions, which is what running two agents means.
457
- */
458
- function sessionPath() {
459
- return join(process.cwd(), ".neuvikon-session");
460
- }
461
- function readSession() {
462
- let raw;
463
- try {
464
- raw = readFileSync(sessionPath(), "utf8");
465
- } catch {
466
- return null;
467
- }
468
- try {
469
- const parsed = JSON.parse(raw);
470
- if (typeof parsed !== "object" || parsed === null) return null;
471
- const { sessionKey, ticket } = parsed;
472
- if (typeof sessionKey !== "string" || typeof ticket !== "string") return null;
473
- return parsed;
474
- } catch {
475
- return null;
476
- }
477
- }
478
- function writeSession(session) {
479
- writeFileSync(sessionPath(), `${JSON.stringify(session, null, 2)}\n`);
480
- }
481
- function clearSession() {
482
- try {
483
- rmSync(sessionPath());
484
- } catch {}
485
- }
486
- //#endregion
487
592
  //#region src/commands/logout.ts
488
593
  /**
489
594
  * Deleting the local file is not enough: the token stays valid until the server
@@ -520,31 +625,6 @@ async function logout() {
520
625
  info(dim(" Could not reach Neuvikon to revoke the token — revoke it in settings if it leaked."));
521
626
  }
522
627
  //#endregion
523
- //#region src/ticket.ts
524
- /**
525
- * Ticket refs look like `AWKN-14`. This pattern is deliberately looser than the
526
- * server's — it pulls *candidates* out of arbitrary pasted prose and lets the
527
- * server decide which one is a real ticket in the caller's organization, so the
528
- * authoritative rule lives in exactly one place.
529
- */
530
- const CANDIDATE = /\b([A-Z0-9]{2,8})-(\d{1,9})\b/g;
531
- /** Every distinct ref-shaped token in `text`, in the order it appears. */
532
- function extractTicketRefs(text) {
533
- const seen = /* @__PURE__ */ new Set();
534
- for (const match of text.toUpperCase().matchAll(CANDIDATE)) {
535
- if (/^\d+$/.test(match[1])) continue;
536
- seen.add(`${match[1]}-${match[2]}`);
537
- }
538
- return [...seen];
539
- }
540
- /** Reads piped input, if any. Returns "" when stdin is a terminal. */
541
- async function readStdin() {
542
- if (process.stdin.isTTY) return "";
543
- const chunks = [];
544
- for await (const chunk of process.stdin) chunks.push(chunk);
545
- return Buffer.concat(chunks).toString("utf8");
546
- }
547
- //#endregion
548
628
  //#region src/usage.ts
549
629
  /**
550
630
  * Sums token usage out of a JSONL agent transcript, starting from `offset`.
@@ -861,6 +941,7 @@ async function main() {
861
941
  case "logout": return logout();
862
942
  case "init": return init();
863
943
  case "start": return start(flags, positional);
944
+ case "hook-start": return hookStart();
864
945
  case "report": return report(flags);
865
946
  case "stop": return stop();
866
947
  case "status": return status();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neuvikon",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Tell Neuvikon which ticket your coding agent is working on.",
5
5
  "keywords": [
6
6
  "neuvikon",