neuvikon 0.2.7 → 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.
- package/dist/index.mjs +293 -241
- package/package.json +1 -1
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.
|
|
25
|
-
*
|
|
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
|
-
|
|
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 });
|
|
@@ -212,126 +499,6 @@ function isInstalled(root) {
|
|
|
212
499
|
return false;
|
|
213
500
|
}
|
|
214
501
|
//#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
502
|
//#region src/commands/init.ts
|
|
336
503
|
/**
|
|
337
504
|
* Separate from `login` because instructions are per-project and credentials
|
|
@@ -345,49 +512,6 @@ function init() {
|
|
|
345
512
|
if (!readCredentials()) warn("Not logged in yet — run `npx neuvikon login`.");
|
|
346
513
|
}
|
|
347
514
|
//#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);
|
|
381
|
-
}
|
|
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;
|
|
386
|
-
}
|
|
387
|
-
function normalizeUrl(url) {
|
|
388
|
-
return url.trim().replace(/\/+$/, "");
|
|
389
|
-
}
|
|
390
|
-
//#endregion
|
|
391
515
|
//#region src/browser.ts
|
|
392
516
|
/**
|
|
393
517
|
* Best-effort. The URL is always printed too, so a headless box, an SSH session
|
|
@@ -465,54 +589,6 @@ async function login(flags) {
|
|
|
465
589
|
}
|
|
466
590
|
}
|
|
467
591
|
//#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
592
|
//#region src/commands/logout.ts
|
|
517
593
|
/**
|
|
518
594
|
* Deleting the local file is not enough: the token stays valid until the server
|
|
@@ -549,31 +625,6 @@ async function logout() {
|
|
|
549
625
|
info(dim(" Could not reach Neuvikon to revoke the token — revoke it in settings if it leaked."));
|
|
550
626
|
}
|
|
551
627
|
//#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
628
|
//#region src/usage.ts
|
|
578
629
|
/**
|
|
579
630
|
* Sums token usage out of a JSONL agent transcript, starting from `offset`.
|
|
@@ -890,6 +941,7 @@ async function main() {
|
|
|
890
941
|
case "logout": return logout();
|
|
891
942
|
case "init": return init();
|
|
892
943
|
case "start": return start(flags, positional);
|
|
944
|
+
case "hook-start": return hookStart();
|
|
893
945
|
case "report": return report(flags);
|
|
894
946
|
case "stop": return stop();
|
|
895
947
|
case "status": return status();
|