@xingjisoft/neutrino 0.4.0 → 0.5.0

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/bin.js CHANGED
@@ -3,7 +3,12 @@ import process from "node:process";
3
3
  import { App } from "./app.js";
4
4
  import { loadConfig } from "./config.js";
5
5
  import { installCrashGuard } from "./terminal/restore.js";
6
- import { startWebServer } from "./web/server.js";
6
+ import { webUrls } from "./web/server.js";
7
+ import { runningBackground, startBackgroundService, stopBackgroundService, } from "./background.js";
8
+ import { runWebService } from "./web/service.js";
9
+ import { openInBrowser } from "./browser.js";
10
+ import { PRO_MODEL_FILE, PRO_MODELS_URL, syncProModels } from "./pro/config.js";
11
+ import { checkForUpdate } from "./update.js";
7
12
  const DEFAULT_WEB_PORT = 8765;
8
13
  /** Binding here makes the UI reachable from every interface. */
9
14
  const WILDCARD_HOST = "0.0.0.0";
@@ -35,6 +40,11 @@ Usage:
35
40
  neutrino Start the terminal UI (with the web UI).
36
41
  neutrino --lan Start both, reachable from other devices.
37
42
  neutrino web [--port <port>] Start only the web UI (serves saved history).
43
+ neutrino background start Run the web UI in the background; conversations
44
+ started there survive the terminal/CLI exiting.
45
+ neutrino background stop Stop the background service.
46
+ neutrino background status Show whether it is running and where.
47
+ neutrino background open Open its console in the default browser.
38
48
 
39
49
  Options:
40
50
  -p, --port <port> Web UI port. Default: ${DEFAULT_WEB_PORT}
@@ -43,12 +53,16 @@ Options:
43
53
  devices on your LAN or VPN.
44
54
  --no-web Do not start the web UI with the terminal UI.
45
55
  --no-auth Serve without password login.
56
+ --no-tray Do not show the Windows notification-area icon of the
57
+ background service.
46
58
  --web-port <n> Deprecated alias for --port while the TUI runs.
47
59
  -h, --help Show this help.
48
60
 
49
61
  Environment:
50
62
  NEUTRINO_WEB_PORT Same as --port in either mode.
51
63
  NEUTRINO_WEB_HOST Same as --host.
64
+ NEUTRINO_NO_TRAY Set to 1 to keep the background service out of the
65
+ Windows notification area.
52
66
 
53
67
  The web UI starts by default alongside the terminal UI on loopback. Turn it
54
68
  off for one run with --no-web, or permanently in Settings → Web console. It can
@@ -58,6 +72,13 @@ on, and off, in the browser's Settings → Web access; the choice is saved for t
58
72
  next launch, while --host/--lan win for the current one. Log in with the
59
73
  permanent password (default: 114514). Change it in Settings. --no-auth disables
60
74
  password login.
75
+
76
+ Background residency is off by default. Turn it on in Settings → Background
77
+ (shared by the regular and Pro workspaces). Switching it takes effect immediately,
78
+ including for browser tasks already running. The background service keeps
79
+ running when this terminal closes, so tasks started
80
+ from the browser are not interrupted. Stop it with 'neutrino background stop',
81
+ the tray icon, or Settings → Web console in the terminal.
61
82
  `);
62
83
  }
63
84
  /** Read the value that follows a flag, so `--host` alone is an error. */
@@ -68,13 +89,28 @@ function requireValue(argv, index, flag) {
68
89
  }
69
90
  return value;
70
91
  }
92
+ const BACKGROUND_ACTIONS = new Set(["run", "start", "stop", "status", "open"]);
71
93
  function parseArgs(argv) {
72
- const options = { mode: "tui", port: null, lan: false, noWeb: false, noAuth: false };
94
+ const options = {
95
+ mode: "tui", backgroundAction: null, port: null, lan: false, noWeb: false, noAuth: false, noTray: false,
96
+ };
73
97
  for (let i = 0; i < argv.length; i += 1) {
74
98
  const arg = argv[i];
75
99
  if (arg === "web") {
76
100
  options.mode = "web";
77
101
  }
102
+ else if (arg === "background") {
103
+ options.mode = "background";
104
+ options.backgroundAction = "status";
105
+ const next = argv[i + 1];
106
+ if (next && !next.startsWith("-")) {
107
+ if (!BACKGROUND_ACTIONS.has(next)) {
108
+ throw new Error(`unknown background action "${next}"`);
109
+ }
110
+ options.backgroundAction = next;
111
+ i += 1;
112
+ }
113
+ }
78
114
  else if (arg === "--port" || arg === "-p") {
79
115
  options.port = parsePort(argv[++i]);
80
116
  }
@@ -102,6 +138,25 @@ function parseArgs(argv) {
102
138
  else if (arg === "--no-auth") {
103
139
  options.noAuth = true;
104
140
  }
141
+ else if (arg === "--no-tray") {
142
+ options.noTray = true;
143
+ }
144
+ else if (arg === "--start") {
145
+ options.mode = "background";
146
+ options.backgroundAction = "start";
147
+ }
148
+ else if (arg === "--stop") {
149
+ options.mode = "background";
150
+ options.backgroundAction = "stop";
151
+ }
152
+ else if (arg === "--status") {
153
+ options.mode = "background";
154
+ options.backgroundAction = "status";
155
+ }
156
+ else if (arg === "--open") {
157
+ options.mode = "background";
158
+ options.backgroundAction = "open";
159
+ }
105
160
  else if (arg === "--help" || arg === "-h") {
106
161
  printHelp();
107
162
  process.exit(0);
@@ -116,31 +171,64 @@ function parseArgs(argv) {
116
171
  if (options.noWeb && options.mode === "web") {
117
172
  throw new Error("--no-web cannot be used with the 'web' command");
118
173
  }
174
+ if (options.noWeb && options.mode === "background") {
175
+ throw new Error("--no-web cannot be used with the 'background' command");
176
+ }
119
177
  if (options.noWeb && (options.port !== null || options.host !== undefined || options.lan)) {
120
178
  throw new Error("use either --no-web or web UI options, not both");
121
179
  }
122
180
  return options;
123
181
  }
124
- /** Print every address a client can open, plus what it takes to open one. */
125
- function reportAccess(handle) {
126
- process.stdout.write(`Neutrino web UI listening on ${handle.host}:${handle.port}\n`);
127
- const multiple = handle.urls.length > 1;
182
+ /** Print where an already-running detached console can be opened. */
183
+ function reportBackgroundAccess(status, started) {
184
+ process.stdout.write(`Neutrino ${status.resident ? "background " : ""}Web console ${started ? "started" : "already running"} `
185
+ + `(pid ${status.pid}) on ${status.host}:${status.port}\n`);
186
+ const { urls } = webUrls(status.host, status.port);
187
+ const multiple = urls.length > 1;
128
188
  process.stdout.write(multiple ? "Open the address that matches your device:\n" : "Open:\n");
129
- for (const url of handle.urls)
189
+ for (const url of urls)
130
190
  process.stdout.write(` ${url}\n`);
131
- const wildcard = handle.host === "0.0.0.0" || handle.host === "::";
132
- if (wildcard && !handle.urls.some((url) => !url.includes("//127.0.0.1:"))) {
133
- // Bound to every interface, yet nothing outside this machine is reachable:
134
- // usually Wi-Fi is off, the VPN is down, or the only adapters left are
135
- // virtual ones we deliberately hide.
191
+ if ((status.host === "0.0.0.0" || status.host === "::")
192
+ && !urls.some((url) => !url.includes("//127.0.0.1:"))) {
136
193
  process.stdout.write("note: no reachable network address found, so only this machine can connect.\n");
137
194
  }
138
- if (handle.authRequired) {
139
- process.stdout.write("Sign in with your permanent Web password (default: 114514). Change it in Settings.\n");
195
+ if (status.auth_required) {
196
+ process.stdout.write("Sign in with your permanent Web password (default: 114514).\n");
140
197
  }
141
198
  else {
142
199
  process.stdout.write("Password login is disabled.\n");
143
200
  }
201
+ if (status.resident) {
202
+ process.stdout.write("It keeps running after this terminal exits. Stop it with 'neutrino background stop'"
203
+ + (process.platform === "win32" ? " or the tray icon" : "") + ".\n");
204
+ }
205
+ }
206
+ /**
207
+ * The detached service itself (`neutrino background run`).
208
+ *
209
+ * It owns the Web console and every browser conversation for as long as the
210
+ * machine's user session keeps it alive. The token written into the status
211
+ * file is the only way another local process can ask it to stop.
212
+ */
213
+ async function runBackgroundService(host, auth) {
214
+ await runWebService({ port: options.port ?? portFromEnv ?? DEFAULT_WEB_PORT, host, auth, tray: !options.noTray });
215
+ }
216
+ function printBackgroundStatus() {
217
+ const status = runningBackground();
218
+ if (!status) {
219
+ process.stdout.write("No background service is running.\n");
220
+ process.stdout.write("Start one with 'neutrino background start'.\n");
221
+ return;
222
+ }
223
+ process.stdout.write(`Neutrino background service: running (pid ${status.pid})\n`);
224
+ process.stdout.write(`Residency: ${status.resident ? "on" : "off (follows the terminal)"}\n`);
225
+ process.stdout.write(`Started: ${status.started_at}\n`);
226
+ process.stdout.write(`Address: ${status.url}\n`);
227
+ const { urls } = webUrls(status.host, status.port);
228
+ for (const url of urls) {
229
+ if (url.replace(/\/$/, "") !== status.url)
230
+ process.stdout.write(`Also: ${url}\n`);
231
+ }
144
232
  }
145
233
  let options;
146
234
  try {
@@ -152,29 +240,82 @@ catch (err) {
152
240
  process.exit(2);
153
241
  }
154
242
  // Installed before either mode starts: a crash must leave a usable terminal
155
- // (TUI) and a log line (both), never a shell flooded with mouse reports.
156
- installCrashGuard();
243
+ // (TUI) and a log line (both), never a shell flooded with mouse reports. A
244
+ // detached service keeps its own SIGHUP handler so closing the terminal cannot
245
+ // take it down.
246
+ installCrashGuard({ surviveHangup: options.mode === "background" });
157
247
  const host = options.lan ? WILDCARD_HOST : options.host || process.env.NEUTRINO_WEB_HOST || undefined;
158
248
  const auth = !options.noAuth;
159
249
  const portFromEnv = envPort();
160
250
  if (options.mode === "web") {
161
- // Headless: serve the saved history (and any live sessions in this process,
162
- // which for a standalone server is none).
163
- const port = options.port ?? portFromEnv ?? DEFAULT_WEB_PORT;
164
- try {
165
- const handle = await startWebServer({ port, host, auth });
166
- reportAccess(handle);
167
- process.stdout.write("Serving saved conversations. Press Ctrl+C to stop.\n");
168
- const shutdown = () => {
169
- handle.close().finally(() => process.exit(0));
170
- };
171
- process.on("SIGINT", shutdown);
172
- process.on("SIGTERM", shutdown);
173
- }
174
- catch (err) {
175
- process.stderr.write(`neutrino: failed to start web server: ${err instanceof Error ? err.message : String(err)}\n`);
251
+ const result = await startBackgroundService({
252
+ port: options.port ?? portFromEnv ?? DEFAULT_WEB_PORT, host, auth,
253
+ tray: !options.noTray, resident: loadConfig().background === true, ownerPid: process.pid,
254
+ strictPort: options.port !== null,
255
+ });
256
+ if (!result.ok) {
257
+ process.stderr.write(`neutrino: failed to start web server: ${result.error}\n`);
176
258
  process.exit(1);
177
259
  }
260
+ reportBackgroundAccess(result.status, result.started);
261
+ process.stdout.write("Serving saved conversations. Press Ctrl+C to close this terminal.\n");
262
+ // The owner monitor in the service also handles an abrupt terminal close.
263
+ const keepAlive = setInterval(() => { }, 60_000);
264
+ const shutdown = () => { clearInterval(keepAlive); process.exit(0); };
265
+ process.on("SIGINT", shutdown);
266
+ process.on("SIGTERM", shutdown);
267
+ }
268
+ else if (options.mode === "background") {
269
+ const action = options.backgroundAction ?? "status";
270
+ if (action === "run") {
271
+ // The detached child. It does not own a terminal, so its stdio is ignored
272
+ // by the parent and every diagnostic goes to background.log.
273
+ await runBackgroundService(host, auth);
274
+ }
275
+ else if (action === "start") {
276
+ const result = await startBackgroundService({
277
+ port: options.port ?? portFromEnv ?? DEFAULT_WEB_PORT,
278
+ host,
279
+ auth,
280
+ tray: !options.noTray,
281
+ });
282
+ if (!result.ok) {
283
+ process.stderr.write(`neutrino: ${result.error}\n`);
284
+ process.exit(1);
285
+ }
286
+ reportBackgroundAccess(result.status, result.started);
287
+ process.exitCode = 0;
288
+ }
289
+ else if (action === "stop") {
290
+ const result = await stopBackgroundService();
291
+ if (!result.ok) {
292
+ process.stderr.write(`neutrino: ${result.error}\n`);
293
+ process.exit(1);
294
+ }
295
+ process.stdout.write(result.stopped ? "Background service stopped.\n" : "No background service is running.\n");
296
+ process.exitCode = 0;
297
+ }
298
+ else if (action === "open") {
299
+ const started = await startBackgroundService({
300
+ port: options.port ?? portFromEnv ?? DEFAULT_WEB_PORT,
301
+ host,
302
+ auth,
303
+ tray: !options.noTray,
304
+ });
305
+ if (!started.ok) {
306
+ process.stderr.write(`neutrino: ${started.error}\n`);
307
+ process.exit(1);
308
+ }
309
+ if (started.started)
310
+ reportBackgroundAccess(started.status, true);
311
+ openInBrowser(started.status.url);
312
+ process.stdout.write(`Opening ${started.status.url}\n`);
313
+ process.exitCode = 0;
314
+ }
315
+ else {
316
+ printBackgroundStatus();
317
+ process.exitCode = 0;
318
+ }
178
319
  }
179
320
  else {
180
321
  if (!process.stdout.isTTY || !process.stdin.isTTY) {
@@ -182,28 +323,45 @@ else {
182
323
  process.stderr.write("To serve the web UI without a terminal, run 'neutrino web --port <port>'.\n");
183
324
  process.exit(1);
184
325
  }
326
+ // The Pro presets are downloaded, not shipped, and kept in line with the
327
+ // official copy before any workspace can ask for a preset. A failure is not
328
+ // fatal: Pro mode reports the missing file (or keeps the local presets), and
329
+ // the next start tries again.
330
+ await syncProModels().then((result) => {
331
+ if (result.status === "updated") {
332
+ process.stderr.write(`neutrino: updated the Pro presets at ${PRO_MODEL_FILE} from ${PRO_MODELS_URL}\n`);
333
+ }
334
+ }).catch((error) => {
335
+ process.stderr.write(`neutrino: ${error instanceof Error ? error.message : String(error)}\n`);
336
+ });
337
+ // Ask the registry for the published version as early as possible, while the
338
+ // Web console is still starting: the first screen must not wait on the
339
+ // network. Offline is an ordinary state, hence no diagnostic here.
340
+ const updateCheck = checkForUpdate();
185
341
  const app = new App();
186
342
  const webPort = options.port ?? portFromEnv ?? DEFAULT_WEB_PORT;
187
- // Remember where the UI should live even when it is off right now, so the
188
- // Settings toggle can mount it without restarting the terminal.
189
- app.configureWeb(webPort, host, auth);
190
- // Explicit web settings win over the saved preference; otherwise the web UI
191
- // is part of a normal launch unless the user turned it off in Settings.
343
+ const saved = loadConfig();
344
+ app.configureWeb(webPort, host, auth, true, !options.noTray);
192
345
  const requested = options.port !== null || options.host !== undefined || options.lan
193
346
  || options.noAuth || portFromEnv !== null || Boolean(process.env.NEUTRINO_WEB_HOST);
194
- const enabledByDefault = loadConfig().web_console !== false;
195
- if (!options.noWeb && (requested || enabledByDefault)) {
347
+ if (!options.noWeb && (requested || saved.web_console !== false)) {
196
348
  try {
197
- const handle = await app.enableWeb(webPort, host, auth);
198
- reportAccess(handle);
349
+ const previous = runningBackground();
350
+ const status = await app.enableWeb(webPort, host, auth);
351
+ if (options.port !== null && options.port !== 0 && status.port !== webPort) {
352
+ process.stderr.write(`note: the Web console is already running on port ${status.port}; --port was not applied.\n`);
353
+ }
354
+ reportBackgroundAccess(status, previous?.pid !== status.pid);
199
355
  }
200
356
  catch (err) {
201
- // A console that cannot bind (usually a second instance on 8765) must
202
- // never take the terminal down with it.
203
357
  const detail = err instanceof Error ? err.message : String(err);
204
358
  process.stderr.write(`neutrino: web UI not started: ${detail}\n`);
205
359
  process.stderr.write("The terminal UI will keep running. Pass --port <port> to use another port, or --no-web to skip the web UI.\n");
206
360
  }
207
361
  }
362
+ void updateCheck.then((status) => {
363
+ if (status.hasUpdate)
364
+ app.promptUpdate(status);
365
+ });
208
366
  await app.start();
209
367
  }
package/dist/config.js CHANGED
@@ -24,10 +24,10 @@ export const PRO_CONFIG_FILE = path.join(PRO_DIR, "config.json");
24
24
  export const PRO_SESSION_DIR = path.join(PRO_DIR, "session");
25
25
  /**
26
26
  * Settings that intentionally apply to both workspaces. The Web password, the
27
- * approval policy and the console's interface are properties of this process,
28
- * not of a conversation workspace.
27
+ * approval policy, the console's interface/LAN access and background residency
28
+ * are properties of this process, not of a conversation workspace.
29
29
  */
30
- export const SHARED_CONFIG_KEYS = ["web_password_hash", "approval_mode", "auto_approve_extreme_risk", "web_lan"];
30
+ export const SHARED_CONFIG_KEYS = ["web_password_hash", "approval_mode", "auto_approve_extreme_risk", "web_lan", "background"];
31
31
  export function normalizeMode(value) {
32
32
  return typeof value === "string" && value.trim().toLowerCase() === "pro" ? "pro" : "standard";
33
33
  }
@@ -136,6 +136,10 @@ export const DEFAULT_CONFIG = {
136
136
  // Listen on every interface (the equivalent of --lan) instead of loopback.
137
137
  // The listener belongs to the process, so both workspaces share this value.
138
138
  web_lan: false,
139
+ // Keep the Web console and the conversations it runs alive after the
140
+ // terminal UI exits. This is a property of the machine, not of a workspace,
141
+ // so the regular site and Pro share it.
142
+ background: false,
139
143
  // Working directories opened from the browser, newest first. They remain
140
144
  // listed even when they hold no conversations yet.
141
145
  web_workspaces: [],
package/dist/header.js CHANGED
@@ -26,7 +26,8 @@ export function drawHeader(buf, state) {
26
26
  if (state.webUrl) {
27
27
  // A short reminder that the browser console is live; the startup message
28
28
  // prints the full addresses when more than one works.
29
- text += ` · web ${state.webUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")}`;
29
+ const label = state.webBackground ? "web (background)" : "web";
30
+ text += ` · ${label} ${state.webUrl.replace(/^https?:\/\//, "").replace(/\/$/, "")}`;
30
31
  }
31
32
  text = truncateByWidth(text, w);
32
33
  buf.centerText(0, text, { fg: C.accent, bg: C.highlightBg, bold: true });
package/dist/llm/tools.js CHANGED
@@ -157,7 +157,9 @@ function runCommand(command, cwd) {
157
157
  resolve("[Error: empty command]");
158
158
  return;
159
159
  }
160
- exec(command, { cwd, timeout: 120000, maxBuffer: MAX_OUTPUT * 4 }, (error, stdout, stderr) => {
160
+ exec(command,
161
+ // A detached Windows service has no console for its tool shells to inherit.
162
+ { cwd, timeout: 120000, maxBuffer: MAX_OUTPUT * 4, windowsHide: true }, (error, stdout, stderr) => {
161
163
  const parts = [];
162
164
  if (stdout)
163
165
  parts.push(stdout);
@@ -0,0 +1,33 @@
1
+ import { loadAgentSession, safeAgentSessionId } from "./agents.js";
2
+ import { loadConfig, loadSession, normalizeApprovalMode, saveSession, } from "./config.js";
3
+ export class SessionMigrationError extends Error {
4
+ status;
5
+ constructor(status, message) {
6
+ super(message);
7
+ this.status = status;
8
+ }
9
+ }
10
+ /** Copy the readable conversation into a fresh native transcript, ready to resume. */
11
+ export function migrateAgentSession(agent, sourceId, cwd, selection, mode = "standard", proPreset) {
12
+ if (!safeAgentSessionId(sourceId))
13
+ throw new SessionMigrationError(400, "invalid session id");
14
+ const source = loadAgentSession(agent, sourceId, cwd);
15
+ if (!source)
16
+ throw new SessionMigrationError(404, "session not found in this working directory");
17
+ // Preserve the roles and text exposed by the history viewer. Resumed native
18
+ // sessions supply their own system prompt and model/tool configuration.
19
+ const messages = source.messages.map(({ role, content }) => ({ role, content }));
20
+ if (!messages.length)
21
+ throw new SessionMigrationError(400, "this session has no messages to migrate");
22
+ let id;
23
+ try {
24
+ id = saveSession(messages, selection.model, selection.provider, selection.intensity, source.cwd, undefined, { prompt: 0, completion: 0 }, undefined, normalizeApprovalMode(loadConfig(mode).approval_mode), undefined, mode, proPreset);
25
+ }
26
+ catch (error) {
27
+ throw new SessionMigrationError(500, error instanceof Error ? error.message : String(error));
28
+ }
29
+ const saved = loadSession(id, mode);
30
+ if (!saved)
31
+ throw new SessionMigrationError(500, "could not read the migrated conversation");
32
+ return saved;
33
+ }
@@ -1,10 +1,16 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import process from "node:process";
3
4
  import { fileURLToPath } from "node:url";
4
5
  import { PRO_DIR, loadConfig } from "../config.js";
5
6
  /** Provider URLs and credentials stay in the shared, ordinary model catalogue. */
6
7
  export const PRO_MODEL_FILE = path.join(PRO_DIR, "model.json");
8
+ /** Presets the user adds or rewrites themselves; a same-named preset here wins. */
9
+ export const PRO_CUSTOM_MODEL_FILE = path.join(PRO_DIR, "model-custom.json");
10
+ /** The official presets, fetched on first use: nothing is bundled with the package. */
11
+ export const PRO_MODELS_URL = "https://ius.neutrino.xingjisoft.com/model.json";
7
12
  const ASSET_DIR = fileURLToPath(new URL("../../assets/pro/", import.meta.url));
13
+ const DOWNLOAD_TIMEOUT_MS = 10_000;
8
14
  export const PRO_ROLES = [
9
15
  "main_model", "thinking_model", "front_output_model", "backend_output_model",
10
16
  "confirm_model", "genimage_model", "plan_model", "modeling_model",
@@ -15,29 +21,125 @@ export const PRO_ROLE_LABELS = {
15
21
  backend_output_model: "Backend", confirm_model: "Approval", genimage_model: "Image generation",
16
22
  plan_model: "Planning", modeling_model: "Modeling",
17
23
  };
18
- /** Copy the bundled template byte-for-byte, exclusively on first use. */
24
+ let downloading = null;
25
+ /**
26
+ * Make sure the Pro presets are on disk, downloading the official set the first
27
+ * time. A file the user already has is never touched here; a failed download
28
+ * leaves nothing behind, so the next start simply tries again.
29
+ */
19
30
  export function ensureProModels() {
20
31
  fs.mkdirSync(PRO_DIR, { recursive: true });
32
+ if (fs.existsSync(PRO_MODEL_FILE))
33
+ return Promise.resolve();
34
+ downloading ??= downloadProModels().finally(() => { downloading = null; });
35
+ return downloading;
36
+ }
37
+ async function downloadProModels() {
38
+ const body = await fetchProModels();
39
+ // An error page or a truncated reply must never become the user's presets.
40
+ parseProModels(body, "model.json", PRO_MODELS_URL);
21
41
  try {
22
- fs.copyFileSync(path.join(ASSET_DIR, "model-template.json"), PRO_MODEL_FILE, fs.constants.COPYFILE_EXCL);
42
+ fs.writeFileSync(PRO_MODEL_FILE, body, { flag: "wx" });
23
43
  }
24
44
  catch (error) {
45
+ // Another process won the race and wrote the file first: that is fine.
25
46
  if (error.code !== "EEXIST")
26
47
  throw error;
27
48
  }
28
49
  }
29
- export function loadProModels() {
30
- ensureProModels();
50
+ /**
51
+ * Keep ~/.neutrino/pro/model.json in step with the official presets, which own
52
+ * that file: any difference is overwritten. Presets the user keeps for
53
+ * themselves belong in model-custom.json, so nothing personal is lost. When the
54
+ * official copy cannot be read, the local presets stay exactly as they are.
55
+ *
56
+ * Called once at start-up by both the terminal and the Web console.
57
+ */
58
+ export async function syncProModels() {
59
+ const failure = `could not download the Pro model presets from ${PRO_MODELS_URL}`;
60
+ let body;
61
+ try {
62
+ body = await fetchProModels();
63
+ }
64
+ catch (error) {
65
+ // Offline, or the server is down: the local presets are still usable.
66
+ if (!fs.existsSync(PRO_MODEL_FILE))
67
+ throw error;
68
+ return { status: "kept", detail: error instanceof Error ? error.message : String(error) };
69
+ }
70
+ const remote = parseProModels(body, "model.json", PRO_MODELS_URL);
71
+ const present = fs.existsSync(PRO_MODEL_FILE);
72
+ if (remote.tier.length === 0) {
73
+ if (!present)
74
+ throw new Error(`${failure}: it contains no presets`);
75
+ return { status: "kept", detail: `${failure}: it contains no presets` };
76
+ }
77
+ // Only formatting differences are not a difference: the file keeps its shape.
78
+ // A local file that does not parse is replaced, so a hand edit cannot block Pro.
79
+ if (present && samePresets(readProModelsQuietly(), remote))
80
+ return { status: "unchanged" };
81
+ writeProModels(body);
82
+ return { status: "updated" };
83
+ }
84
+ async function fetchProModels() {
85
+ try {
86
+ const response = await fetch(PRO_MODELS_URL, { signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
87
+ if (!response.ok)
88
+ throw new Error(`HTTP ${response.status}`);
89
+ return await response.text();
90
+ }
91
+ catch (error) {
92
+ throw new Error(`could not download the Pro model presets from ${PRO_MODELS_URL}: `
93
+ + `${error instanceof Error ? error.message : String(error)}`);
94
+ }
95
+ }
96
+ /** Write the official copy in one step, so no reader ever sees half a file. */
97
+ function writeProModels(body) {
98
+ fs.mkdirSync(PRO_DIR, { recursive: true });
99
+ const temp = `${PRO_MODEL_FILE}.${process.pid}.tmp`;
100
+ try {
101
+ fs.writeFileSync(temp, body);
102
+ fs.renameSync(temp, PRO_MODEL_FILE);
103
+ }
104
+ catch (error) {
105
+ fs.rmSync(temp, { force: true });
106
+ throw error;
107
+ }
108
+ }
109
+ function readProModelsQuietly() {
110
+ try {
111
+ return readProModels(PRO_MODEL_FILE, "model.json");
112
+ }
113
+ catch {
114
+ return null;
115
+ }
116
+ }
117
+ function samePresets(left, right) {
118
+ return left !== null && canonicalJson(left) === canonicalJson(right);
119
+ }
120
+ /** Compare documents by content, so reformatting alone is never a difference. */
121
+ function canonicalJson(value) {
122
+ if (Array.isArray(value))
123
+ return `[${value.map(canonicalJson).join(",")}]`;
124
+ if (value && typeof value === "object") {
125
+ const entries = Object.entries(value)
126
+ .filter(([, entry]) => entry !== undefined)
127
+ .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
128
+ return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(",")}}`;
129
+ }
130
+ return JSON.stringify(value) ?? "null";
131
+ }
132
+ /** Parse and validate one presets document; each file has to be valid on its own. */
133
+ function parseProModels(text, label, where) {
31
134
  let value;
32
135
  try {
33
- value = JSON.parse(fs.readFileSync(PRO_MODEL_FILE, "utf8"));
136
+ value = JSON.parse(text);
34
137
  }
35
138
  catch {
36
- throw new Error("Pro model.json is not valid JSON; fix ~/.neutrino/pro/model.json");
139
+ throw new Error(`Pro ${label} is not valid JSON; fix ${where}`);
37
140
  }
38
- if (!value || typeof value !== "object" || !Array.isArray(value.tier) ||
39
- value.tier.length === 0) {
40
- throw new Error("Pro model.json must contain a nonempty tier array");
141
+ if (!value || typeof value !== "object" || !Array.isArray(value.tier)) {
142
+ throw new Error(`Pro ${label} must contain a tier array`);
41
143
  }
42
144
  const names = new Set();
43
145
  for (const preset of value.tier) {
@@ -59,6 +161,30 @@ export function loadProModels() {
59
161
  }
60
162
  return value;
61
163
  }
164
+ function readProModels(file, label) {
165
+ return parseProModels(fs.readFileSync(file, "utf8"), label, `~/.neutrino/pro/${label}`);
166
+ }
167
+ export function loadProModels() {
168
+ if (!fs.existsSync(PRO_MODEL_FILE)) {
169
+ // The download is asynchronous, so it happens at start-up (see ensureProModels).
170
+ throw new Error(`Pro model.json is missing; it is downloaded from ${PRO_MODELS_URL} on first use`);
171
+ }
172
+ const { tier } = readProModels(PRO_MODEL_FILE, "model.json");
173
+ // Presets the user keeps in model-custom.json are merged in: a same-named entry
174
+ // replaces the one from model.json in place, new ones are appended.
175
+ if (fs.existsSync(PRO_CUSTOM_MODEL_FILE)) {
176
+ for (const preset of readProModels(PRO_CUSTOM_MODEL_FILE, "model-custom.json").tier) {
177
+ const index = tier.findIndex((entry) => entry.name === preset.name);
178
+ if (index === -1)
179
+ tier.push(preset);
180
+ else
181
+ tier[index] = preset;
182
+ }
183
+ }
184
+ if (tier.length === 0)
185
+ throw new Error("Pro model.json must contain a nonempty tier array");
186
+ return { tier };
187
+ }
62
188
  export function requireProPreset(value) {
63
189
  if (typeof value !== "string" || !loadProModels().tier.some((preset) => preset.name === value)) {
64
190
  throw new Error("Unknown Pro preset");