@xingjisoft/neutrino 0.3.0 → 0.4.1

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 (56) hide show
  1. package/README.md +33 -549
  2. package/assets/neutrino.ico +0 -0
  3. package/assets/neutrino.svg +7 -0
  4. package/assets/pro/backend_output_model.txt +5 -0
  5. package/assets/pro/claude-module-prompt.txt +65 -0
  6. package/assets/pro/common.txt +9 -0
  7. package/assets/pro/confirm_model.txt +5 -0
  8. package/assets/pro/front_output_model.txt +9 -0
  9. package/assets/pro/genimage_model.txt +1 -0
  10. package/assets/pro/main_model.txt +5 -0
  11. package/assets/pro/model-template.json +376 -0
  12. package/assets/pro/modeling_model.txt +5 -0
  13. package/assets/pro/plan_model.txt +8 -0
  14. package/assets/pro/thinking_model.txt +3 -0
  15. package/dist/agents.js +22 -3
  16. package/dist/app.js +199 -26
  17. package/dist/background.js +358 -0
  18. package/dist/bin.js +184 -44
  19. package/dist/browser.js +57 -0
  20. package/dist/config.js +174 -26
  21. package/dist/header.js +2 -1
  22. package/dist/index.js +1 -1
  23. package/dist/llm/approval.js +6 -2
  24. package/dist/llm/client.js +197 -28
  25. package/dist/llm/subagent.js +37 -1
  26. package/dist/llm/tools.js +3 -1
  27. package/dist/migration.js +33 -0
  28. package/dist/pro/agents.js +104 -0
  29. package/dist/pro/config.js +88 -0
  30. package/dist/pro/preflight.js +172 -0
  31. package/dist/screens/agentConversation.js +48 -5
  32. package/dist/screens/agentHistory.js +4 -2
  33. package/dist/screens/chat.js +14 -8
  34. package/dist/screens/modelSelect.js +15 -4
  35. package/dist/screens/settings.js +52 -9
  36. package/dist/screens/start.js +48 -15
  37. package/dist/state.js +1 -0
  38. package/dist/terminal/restore.js +3 -12
  39. package/dist/tray.js +138 -0
  40. package/dist/ui.js +58 -0
  41. package/dist/web/hub.js +26 -4
  42. package/dist/web/server.js +495 -74
  43. package/dist/web/service.js +135 -0
  44. package/dist/web/session.js +278 -25
  45. package/dist/web/settings.js +97 -24
  46. package/dist/web/terminalBridge.js +250 -0
  47. package/package.json +37 -37
  48. package/web/README.md +150 -16
  49. package/web/app.js +490 -25
  50. package/web/i18n.js +396 -345
  51. package/web/index.html +120 -15
  52. package/web/neutrino-pro.gif +0 -0
  53. package/web/neutrino.png +0 -0
  54. package/web/pro.js +184 -0
  55. package/web/settings.js +373 -247
  56. package/web/style.css +398 -0
package/dist/app.js CHANGED
@@ -4,18 +4,20 @@ import { InputReader } from "./terminal/input.js";
4
4
  import { Renderer } from "./terminal/renderer.js";
5
5
  import { installCrashGuard, writeCrashLog } from "./terminal/restore.js";
6
6
  import { defaultState } from "./state.js";
7
- import { loadConfig, saveConfig } from "./config.js";
7
+ import { findConfiguredModel, intensityForModel, loadConfig, saveConfig } from "./config.js";
8
8
  import { C } from "./colors.js";
9
9
  import { applySelection, orderLinePoints, rowCells, rowSelectionText, selectionText, } from "./selection.js";
10
10
  import { offsetFromThumbTop, scrollbarGeometry } from "./ui.js";
11
11
  import { copyToClipboard } from "./clipboard.js";
12
+ import { openInBrowser, proModeUrl } from "./browser.js";
12
13
  import { ChatScreen } from "./screens/chat.js";
13
14
  import { HistoryScreen } from "./screens/history.js";
14
15
  import { AgentSelectScreen } from "./screens/agentSelect.js";
15
16
  import { ModelSelectScreen } from "./screens/modelSelect.js";
16
17
  import { SettingsScreen } from "./screens/settings.js";
17
18
  import { StartScreen } from "./screens/start.js";
18
- import { startWebServer } from "./web/server.js";
19
+ import { configureBackgroundService, readBackgroundStatus, isProcessAlive, runningBackground, startBackgroundService, stopBackgroundService, } from "./background.js";
20
+ import { startTerminalBridge } from "./web/terminalBridge.js";
19
21
  const CURSOR_BLINK_MS = 530;
20
22
  const TOAST_MS = 1600;
21
23
  const INTERRUPT_WINDOW_MS = 1500;
@@ -89,9 +91,17 @@ export class App {
89
91
  // its own (streamed output, a resize) can still carry the anchor along.
90
92
  selOffset = null;
91
93
  resizeHandler = () => this.onResize();
92
- webHandle = null;
94
+ webService = null;
95
+ webStarting = null;
96
+ terminalBridge = null;
97
+ backgroundChanges = Promise.resolve();
98
+ webTray = true;
93
99
  /** Where the web UI lives; kept even while it is switched off. */
94
100
  webOptions = { port: 8765, auth: true };
101
+ /** Offer the background-residency prompt when this process mounts the UI. */
102
+ webPromptBackground = true;
103
+ /** True while a click is bringing the Web console up, so double clicks are safe. */
104
+ proOpening = false;
95
105
  constructor() {
96
106
  this.width = process.stdout.columns || 80;
97
107
  this.height = process.stdout.rows || 24;
@@ -116,30 +126,159 @@ export class App {
116
126
  const config = loadConfig();
117
127
  this.state.selectedProvider = String(config.selected_provider ?? this.state.selectedProvider);
118
128
  this.state.selectedModelName = String(config.selected_model ?? this.state.selectedModelName);
119
- this.state.selectedIntensity = String(config.selected_intensity ?? this.state.selectedIntensity);
120
129
  this.state.selectedModelUrl = String(config.selected_model_url ?? this.state.selectedModelUrl);
121
130
  this.state.selectedModelApiKey = String(config.selected_model_api_key ?? this.state.selectedModelApiKey);
122
131
  this.state.selectedModelStreaming = Boolean(config.selected_model_streaming ?? true);
132
+ // The saved intensity can predate a model's `intensity` limits (or the
133
+ // selected model itself can have been hand-edited since); start from a
134
+ // level this exact model accepts.
135
+ const model = findConfiguredModel(this.state.selectedProvider, this.state.selectedModelName);
136
+ this.state.selectedIntensity = intensityForModel(model, config.selected_intensity ?? config.default_intensity ?? this.state.selectedIntensity, config.default_intensity);
123
137
  }
124
138
  /**
125
139
  * Remember where the browser UI should listen. Called at startup even when
126
140
  * the console is disabled, so Settings can mount it later without guessing.
141
+ * `backgroundPrompt` is false when the console is meant to run detached.
127
142
  */
128
- configureWeb(port, host, auth) {
143
+ configureWeb(port, host, auth, backgroundPrompt = true, tray = true) {
129
144
  this.webOptions = { port, host, auth };
145
+ this.webPromptBackground = backgroundPrompt;
146
+ this.webTray = tray;
147
+ }
148
+ /**
149
+ * Adopt a Web console that another process owns. No socket is opened here:
150
+ * the terminal only records the address, so Settings and the Pro button can
151
+ * reach it later.
152
+ */
153
+ attachBackground(status) {
154
+ this.webService = status;
155
+ this.webOptions = { port: status.port, host: status.host, auth: status.auth_required };
156
+ this.state.webUrl = status.url;
157
+ this.state.webBackground = status.resident;
130
158
  }
131
159
  /**
132
160
  * Mount the browser UI on the given port. Safe to call once; subsequent calls
133
161
  * return the already-running server.
134
162
  */
135
163
  async enableWeb(port, host, auth = true) {
136
- if (this.webHandle)
137
- return this.webHandle;
164
+ if (this.webService)
165
+ return this.webService;
166
+ if (this.webStarting)
167
+ return this.webStarting;
138
168
  this.webOptions = { port, host, auth };
139
- const handle = await startWebServer({ port, host, auth });
140
- this.webHandle = handle;
141
- this.state.webUrl = handle.url;
142
- return handle;
169
+ this.webStarting = (async () => {
170
+ const bridge = await startTerminalBridge();
171
+ let started = null;
172
+ try {
173
+ const result = await startBackgroundService({
174
+ port, host, auth, tray: this.webTray, resident: loadConfig().background === true,
175
+ ownerPid: process.pid, prompt: this.webPromptBackground,
176
+ });
177
+ if (!result.ok)
178
+ throw new Error(result.error);
179
+ if (result.started)
180
+ started = result.status;
181
+ if (result.status.lifetime_supported === false) {
182
+ // Keep an older resident service reachable while its current tasks
183
+ // finish; it cannot accept live lifetime changes until restarted.
184
+ bridge.close();
185
+ this.attachBackground(result.status);
186
+ return result.status;
187
+ }
188
+ const status = await configureBackgroundService(result.status, {
189
+ ownerPid: process.pid, bridge: { url: bridge.url, token: bridge.token },
190
+ });
191
+ this.terminalBridge = bridge;
192
+ this.attachBackground(status);
193
+ return status;
194
+ }
195
+ catch (error) {
196
+ bridge.close();
197
+ if (started) {
198
+ const current = readBackgroundStatus();
199
+ if (current && current.owner_pids.every(owner => owner === process.pid)) {
200
+ await stopBackgroundService(started);
201
+ }
202
+ }
203
+ throw error;
204
+ }
205
+ })();
206
+ try {
207
+ return await this.webStarting;
208
+ }
209
+ finally {
210
+ this.webStarting = null;
211
+ }
212
+ }
213
+ /**
214
+ * The address the console is reachable at right now: the local socket, the
215
+ * detached service, or nothing.
216
+ */
217
+ currentWebUrl() {
218
+ return this.webService ? this.state.webUrl : null;
219
+ }
220
+ syncWebStatus() {
221
+ if (!this.webService)
222
+ return false;
223
+ const status = readBackgroundStatus();
224
+ if (!status && isProcessAlive(this.webService.pid))
225
+ return false;
226
+ if (!status || status.pid !== this.webService.pid || !isProcessAlive(status.pid)) {
227
+ this.terminalBridge?.close();
228
+ this.terminalBridge = null;
229
+ this.webService = null;
230
+ this.state.webUrl = null;
231
+ this.state.webBackground = false;
232
+ return true;
233
+ }
234
+ const changed = this.state.webUrl !== status.url || this.state.webBackground !== status.resident;
235
+ this.attachBackground(status);
236
+ return changed;
237
+ }
238
+ /**
239
+ * Start the Web console when it is not up yet. Returns the URL to open.
240
+ */
241
+ async ensureWebConsole() {
242
+ this.syncWebStatus();
243
+ const existing = this.currentWebUrl();
244
+ if (existing)
245
+ return existing;
246
+ const status = await this.enableWeb(this.webOptions.port, this.webOptions.host, this.webOptions.auth);
247
+ return status.url;
248
+ }
249
+ /** Change the existing worker's lifetime without moving its conversations. */
250
+ setBackgroundResidency(enabled) {
251
+ const task = this.backgroundChanges.then(async () => {
252
+ try {
253
+ this.syncWebStatus();
254
+ const existing = this.webService ? null : runningBackground();
255
+ if (existing)
256
+ await this.enableWeb(existing.port, existing.host, existing.auth_required);
257
+ if (!enabled && !this.webService) {
258
+ saveConfig({ ...loadConfig(), background: false });
259
+ }
260
+ else {
261
+ await this.ensureWebConsole();
262
+ if (this.webService?.lifetime_supported === false) {
263
+ throw new Error("Restart the console to update background residency.");
264
+ }
265
+ const status = await configureBackgroundService(this.webService, { resident: enabled, ownerPid: process.pid });
266
+ this.attachBackground(status);
267
+ }
268
+ this.showToast(enabled
269
+ ? "Background residency on: browser tasks keep running after this terminal closes"
270
+ : "Background residency off: the console follows this terminal");
271
+ this.render();
272
+ return { ok: true };
273
+ }
274
+ catch (err) {
275
+ const error = err instanceof Error ? err.message : String(err);
276
+ this.showToast(`Background residency failed: ${error}`, "top");
277
+ return { ok: false, error };
278
+ }
279
+ });
280
+ this.backgroundChanges = task.then(() => undefined, () => undefined);
281
+ return task;
143
282
  }
144
283
  /**
145
284
  * Start or stop the browser UI while the terminal keeps running. Returns a
@@ -148,28 +287,59 @@ export class App {
148
287
  */
149
288
  async setWebConsole(enabled) {
150
289
  if (enabled) {
151
- if (this.webHandle)
152
- return { ok: true };
153
290
  try {
154
- await this.enableWeb(this.webOptions.port, this.webOptions.host, this.webOptions.auth);
155
- this.showToast(`Web console: ${this.state.webUrl}`);
291
+ const url = await this.ensureWebConsole();
292
+ this.showToast(`Web console: ${url}`);
156
293
  return { ok: true };
157
294
  }
158
295
  catch (err) {
159
296
  const error = err instanceof Error ? err.message : String(err);
160
- this.state.webUrl = null;
161
297
  this.showToast(`Web console failed: ${error}`, "top");
162
298
  return { ok: false, error };
163
299
  }
164
300
  }
165
- const handle = this.webHandle;
166
- this.webHandle = null;
301
+ if (this.webStarting)
302
+ await this.webStarting.catch(() => { });
303
+ if (this.webService) {
304
+ const stopped = await stopBackgroundService(this.webService);
305
+ if (!stopped.ok) {
306
+ this.showToast(`Web console could not be stopped: ${stopped.error}`, "top");
307
+ return { ok: false, error: stopped.error };
308
+ }
309
+ }
310
+ this.terminalBridge?.close();
311
+ this.terminalBridge = null;
312
+ this.webService = null;
167
313
  this.state.webUrl = null;
314
+ this.state.webBackground = false;
168
315
  this.render();
169
- await handle?.close();
170
316
  this.showToast("Web console stopped");
171
317
  return { ok: true };
172
318
  }
319
+ /**
320
+ * Open the browser console's Pro workspace, bringing the console up first
321
+ * when it is not running yet. This runs from a mouse handler, so it never
322
+ * throws and never waits on the browser: failures become a toast and the TUI
323
+ * keeps rendering.
324
+ */
325
+ async openProMode() {
326
+ if (this.proOpening)
327
+ return;
328
+ this.proOpening = true;
329
+ try {
330
+ const url = await this.ensureWebConsole();
331
+ const target = proModeUrl(url);
332
+ openInBrowser(target);
333
+ this.showToast(`Opening Pro mode: ${target}`);
334
+ }
335
+ catch (err) {
336
+ const message = err instanceof Error ? err.message : String(err);
337
+ this.showToast(`Pro mode failed: ${message}`, "top");
338
+ }
339
+ finally {
340
+ this.proOpening = false;
341
+ }
342
+ }
173
343
  async start() {
174
344
  // Installed before anything touches the terminal: from here on, a crash
175
345
  // restores mouse tracking, raw mode and the alternate screen instead of
@@ -181,7 +351,8 @@ export class App {
181
351
  process.on("SIGINT", () => this.handleInterrupt());
182
352
  process.on("SIGTERM", () => this.quit());
183
353
  this.sizeTimer = setInterval(() => {
184
- if (this.syncSize())
354
+ const webChanged = this.syncWebStatus();
355
+ if (this.syncSize() || webChanged)
185
356
  this.render();
186
357
  }, 200);
187
358
  this.repaintTimer = setInterval(() => this.renderer.repaint(), REPAINT_MS);
@@ -763,16 +934,17 @@ export class App {
763
934
  this.render();
764
935
  }
765
936
  setModelConfig(providerName, model, intensity) {
937
+ const config = loadConfig();
938
+ const level = intensityForModel(model, intensity, config.default_intensity);
766
939
  this.state.selectedProvider = providerName;
767
940
  this.state.selectedModelName = model.name;
768
941
  this.state.selectedModelUrl = model.url;
769
942
  this.state.selectedModelApiKey = model.api_key ?? "";
770
943
  this.state.selectedModelStreaming = model.supports_streaming ?? true;
771
- this.state.selectedIntensity = intensity;
772
- const config = loadConfig();
944
+ this.state.selectedIntensity = level;
773
945
  config.selected_provider = providerName;
774
946
  config.selected_model = model.name;
775
- config.selected_intensity = intensity;
947
+ config.selected_intensity = level;
776
948
  config.selected_model_url = model.url;
777
949
  config.selected_model_api_key = model.api_key ?? "";
778
950
  config.selected_model_streaming = model.supports_streaming ?? true;
@@ -798,8 +970,9 @@ export class App {
798
970
  this.state.selectedProvider = data.provider;
799
971
  if (data.model)
800
972
  this.state.selectedModelName = data.model;
801
- if (data.intensity)
802
- this.state.selectedIntensity = data.intensity;
973
+ if (data.intensity) {
974
+ this.state.selectedIntensity = intensityForModel(findConfiguredModel(this.state.selectedProvider, this.state.selectedModelName), data.intensity, loadConfig().default_intensity);
975
+ }
803
976
  this.pushScreen(screen);
804
977
  }
805
978
  openModelConfig() {
@@ -859,7 +1032,7 @@ export class App {
859
1032
  catch {
860
1033
  // Terminal restoration has already run, even if stdin is unusable.
861
1034
  }
862
- void this.webHandle?.close();
1035
+ this.terminalBridge?.close();
863
1036
  process.exit(exitCode);
864
1037
  }
865
1038
  }
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Background residency.
3
+ *
4
+ * A background Neutrino is the ordinary Web console running in a detached
5
+ * process, with no terminal attached. Conversations started from the browser
6
+ * are owned by that process. With residency off, it follows the terminals that
7
+ * opened it; with residency on, it survives them. Changing that lifetime never
8
+ * moves or restarts a conversation. Terminal conversations stay local.
9
+ *
10
+ * The service is discovered through a small status file under `~/.neutrino`
11
+ * (`background.json`). It carries the process id, the address it bound and a
12
+ * random management token; the token protects the stop/ping endpoints from
13
+ * other local processes. The file is short-lived and removed on every clean
14
+ * shutdown, so a stale file is detected by checking the pid.
15
+ */
16
+ import { spawn } from "node:child_process";
17
+ import { randomBytes } from "node:crypto";
18
+ import fs from "node:fs";
19
+ import net from "node:net";
20
+ import path from "node:path";
21
+ import process from "node:process";
22
+ import { fileURLToPath } from "node:url";
23
+ import { NEUTRINO_DIR } from "./config.js";
24
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
+ export const BACKGROUND_STATUS_FILE = path.join(NEUTRINO_DIR, "background.json");
26
+ export const BACKGROUND_LOG_FILE = path.join(NEUTRINO_DIR, "background.log");
27
+ const START_TIMEOUT_MS = 12_000;
28
+ const STOP_TIMEOUT_MS = 5_000;
29
+ const LOG_LIMIT_BYTES = 256 * 1024;
30
+ function ensureDir() {
31
+ fs.mkdirSync(NEUTRINO_DIR, { recursive: true });
32
+ }
33
+ /** True for a positive, ordinary process id. */
34
+ function isPid(value) {
35
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
36
+ }
37
+ /** Validate a status file (or an HTTP response) before trusting it. */
38
+ export function parseBackgroundStatus(value) {
39
+ if (!value || typeof value !== "object" || Array.isArray(value))
40
+ return null;
41
+ const data = value;
42
+ if (!isPid(data.pid))
43
+ return null;
44
+ if (typeof data.port !== "number" || !Number.isSafeInteger(data.port) || data.port < 1 || data.port > 65535)
45
+ return null;
46
+ if (typeof data.host !== "string" || data.host.trim() === "")
47
+ return null;
48
+ if (typeof data.url !== "string" || !/^https?:\/\//i.test(data.url))
49
+ return null;
50
+ if (data.auth_required !== undefined && typeof data.auth_required !== "boolean")
51
+ return null;
52
+ if (typeof data.token !== "string" || data.token.length < 16)
53
+ return null;
54
+ if (typeof data.started_at !== "string" || data.started_at === "")
55
+ return null;
56
+ if (data.resident !== undefined && typeof data.resident !== "boolean")
57
+ return null;
58
+ if (data.owner_pids !== undefined && (!Array.isArray(data.owner_pids) || !data.owner_pids.every(isPid)))
59
+ return null;
60
+ return {
61
+ pid: data.pid,
62
+ port: data.port,
63
+ host: data.host,
64
+ url: data.url.replace(/\/+$/, ""),
65
+ // A status written by an older build predates the flag; the console asked
66
+ // for a password then, which is still the safe reading.
67
+ auth_required: data.auth_required !== false,
68
+ token: data.token,
69
+ started_at: data.started_at,
70
+ // Older services were always resident and had no terminal owners.
71
+ resident: data.resident !== false,
72
+ owner_pids: Array.isArray(data.owner_pids) ? data.owner_pids : [],
73
+ lifetime_supported: typeof data.resident === "boolean" && Array.isArray(data.owner_pids),
74
+ };
75
+ }
76
+ /** The saved status, including a stale file from a process that no longer runs. */
77
+ export function readBackgroundStatus() {
78
+ try {
79
+ return parseBackgroundStatus(JSON.parse(fs.readFileSync(BACKGROUND_STATUS_FILE, "utf-8")));
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ /** Whether `pid` is a live process. Permission errors still mean "alive". */
86
+ export function isProcessAlive(pid) {
87
+ if (!isPid(pid))
88
+ return false;
89
+ try {
90
+ process.kill(pid, 0);
91
+ return true;
92
+ }
93
+ catch (error) {
94
+ return error.code === "EPERM";
95
+ }
96
+ }
97
+ /** The running background service, or null when there is none. */
98
+ export function runningBackground() {
99
+ const status = readBackgroundStatus();
100
+ if (!status)
101
+ return null;
102
+ if (isProcessAlive(status.pid))
103
+ return status;
104
+ clearBackgroundStatus(status.pid);
105
+ return null;
106
+ }
107
+ /** Write the status file atomically so another process never reads half of it. */
108
+ export function writeBackgroundStatus(status) {
109
+ ensureDir();
110
+ const temp = `${BACKGROUND_STATUS_FILE}.${process.pid}.tmp`;
111
+ fs.writeFileSync(temp, JSON.stringify(status, null, 2), { encoding: "utf-8", mode: 0o600 });
112
+ fs.renameSync(temp, BACKGROUND_STATUS_FILE);
113
+ }
114
+ /**
115
+ * Remove the status file. With `pid`, only remove it when it still belongs to
116
+ * that process, so one service can never delete another's registration.
117
+ */
118
+ export function clearBackgroundStatus(pid) {
119
+ try {
120
+ if (pid !== undefined) {
121
+ const current = readBackgroundStatus();
122
+ if (current && current.pid !== pid)
123
+ return;
124
+ }
125
+ fs.rmSync(BACKGROUND_STATUS_FILE, { force: true });
126
+ }
127
+ catch {
128
+ // A status file we cannot remove is retried by the next liveness check.
129
+ }
130
+ }
131
+ /** Append a diagnostic line, rotating the file instead of letting it grow. */
132
+ export function backgroundLog(message) {
133
+ try {
134
+ ensureDir();
135
+ const line = `[${new Date().toISOString()}] ${message}\n`;
136
+ try {
137
+ if (fs.statSync(BACKGROUND_LOG_FILE).size > LOG_LIMIT_BYTES) {
138
+ fs.writeFileSync(BACKGROUND_LOG_FILE, line, "utf-8");
139
+ return;
140
+ }
141
+ }
142
+ catch {
143
+ // No log yet.
144
+ }
145
+ fs.appendFileSync(BACKGROUND_LOG_FILE, line, "utf-8");
146
+ }
147
+ catch {
148
+ // Diagnostics must never break the service.
149
+ }
150
+ }
151
+ /** The last few log lines, for a failed start message. */
152
+ export function backgroundLogTail(lines = 6) {
153
+ try {
154
+ return fs.readFileSync(BACKGROUND_LOG_FILE, "utf-8").trimEnd().split(/\r?\n/).slice(-lines).join("\n");
155
+ }
156
+ catch {
157
+ return "";
158
+ }
159
+ }
160
+ function sleep(ms) {
161
+ return new Promise((resolve) => setTimeout(resolve, ms));
162
+ }
163
+ /** Can a client connect to the listening socket yet? */
164
+ function canConnect(port, host) {
165
+ return new Promise((resolve) => {
166
+ const target = host === "0.0.0.0" || host === "" ? "127.0.0.1"
167
+ : host === "::" || host === "[::]" ? "::1"
168
+ : host;
169
+ const socket = net.connect({ port, host: target });
170
+ let settled = false;
171
+ const done = (ok) => {
172
+ if (settled)
173
+ return;
174
+ settled = true;
175
+ socket.destroy();
176
+ resolve(ok);
177
+ };
178
+ socket.setTimeout(500);
179
+ socket.once("connect", () => done(true));
180
+ socket.once("timeout", () => done(false));
181
+ socket.once("error", () => done(false));
182
+ });
183
+ }
184
+ /** The CLI entry point, used by the detached child. */
185
+ function cliEntry() {
186
+ // Always the built CLI next to this module. `process.argv[1]` belongs to the
187
+ // caller (a test, the terminal UI, a wrapper) and must not be re-executed.
188
+ const bundled = path.join(__dirname, "bin.js");
189
+ if (fs.existsSync(bundled))
190
+ return bundled;
191
+ const entry = process.argv[1];
192
+ if (entry && fs.existsSync(entry))
193
+ return path.resolve(entry);
194
+ return bundled;
195
+ }
196
+ /**
197
+ * Start the detached console, or reuse the one already running.
198
+ *
199
+ * The child writes the same status file we poll here, so "the address is
200
+ * reachable" and "the file names this exact process" both have to hold before
201
+ * the start counts as successful. The random token in the env is what proves
202
+ * the file was written by *our* child rather than an older instance.
203
+ */
204
+ export async function startBackgroundService(options) {
205
+ const existing = runningBackground();
206
+ if (existing) {
207
+ if (existing.auth_required !== options.auth) {
208
+ return { ok: false, error: "the running Web console uses a different password setting" };
209
+ }
210
+ if (options.host && existing.host !== options.host) {
211
+ return { ok: false, error: `the running Web console is bound to ${existing.host}, not ${options.host}` };
212
+ }
213
+ if (options.strictPort && options.port !== null && existing.port !== options.port) {
214
+ return { ok: false, error: `the running Web console uses port ${existing.port}, not ${options.port}` };
215
+ }
216
+ // An older detached service is already resident. Reuse it without trying
217
+ // to register an owner through an endpoint that does not exist yet.
218
+ if (existing.lifetime_supported === false)
219
+ return { ok: true, status: existing, started: false };
220
+ const update = {};
221
+ if (options.ownerPid && !existing.owner_pids.includes(options.ownerPid))
222
+ update.ownerPid = options.ownerPid;
223
+ if (options.resident !== false && !existing.resident)
224
+ update.resident = true;
225
+ try {
226
+ const status = Object.keys(update).length ? await configureBackgroundService(existing, update) : existing;
227
+ return { ok: true, status, started: false };
228
+ }
229
+ catch (error) {
230
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
231
+ }
232
+ }
233
+ if (options.resident === false && (!options.ownerPid || !isProcessAlive(options.ownerPid))) {
234
+ return { ok: false, error: "a non-resident Web console needs a running terminal" };
235
+ }
236
+ clearBackgroundStatus();
237
+ const token = randomBytes(32).toString("hex");
238
+ const port = options.port ?? 8765;
239
+ const args = [cliEntry(), "background", "run", "--port", String(port)];
240
+ if (options.host)
241
+ args.push("--host", options.host);
242
+ if (!options.auth)
243
+ args.push("--no-auth");
244
+ if (options.tray === false)
245
+ args.push("--no-tray");
246
+ let child;
247
+ try {
248
+ child = spawn(process.execPath, args, {
249
+ detached: true,
250
+ stdio: "ignore",
251
+ windowsHide: true,
252
+ env: {
253
+ ...process.env,
254
+ NEUTRINO_BACKGROUND_TOKEN: token,
255
+ NEUTRINO_BACKGROUND_RESIDENT: options.resident === false ? "0" : "1",
256
+ NEUTRINO_BACKGROUND_OWNER: options.ownerPid ? String(options.ownerPid) : "",
257
+ NEUTRINO_BACKGROUND_PROMPT: options.prompt ? "1" : "0",
258
+ },
259
+ });
260
+ }
261
+ catch (error) {
262
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
263
+ }
264
+ child.once("error", (error) => backgroundLog(`spawn failed: ${error.message}`));
265
+ child.unref();
266
+ const deadline = Date.now() + START_TIMEOUT_MS;
267
+ while (Date.now() < deadline) {
268
+ if (child.exitCode !== null) {
269
+ const tail = backgroundLogTail();
270
+ return {
271
+ ok: false,
272
+ error: `the background service exited with code ${child.exitCode}${tail ? `: ${tail}` : ""}`,
273
+ };
274
+ }
275
+ const status = readBackgroundStatus();
276
+ if (status && status.token === token && isProcessAlive(status.pid) && await canConnect(status.port, status.host)) {
277
+ return { ok: true, status, started: true };
278
+ }
279
+ await sleep(100);
280
+ }
281
+ // Don't leave a half-started service behind; it has no registration yet.
282
+ if (child.pid && isProcessAlive(child.pid)) {
283
+ try {
284
+ process.kill(child.pid, "SIGTERM");
285
+ }
286
+ catch { /* Already gone. */ }
287
+ }
288
+ const tail = backgroundLogTail();
289
+ return {
290
+ ok: false,
291
+ error: `the background service did not become reachable${tail ? `: ${tail}` : `; see ${BACKGROUND_LOG_FILE}`}`,
292
+ };
293
+ }
294
+ /** Apply a lifetime change to the process that already owns the conversations. */
295
+ export async function configureBackgroundService(status, update) {
296
+ const response = await fetch(`${status.url}/api/background/configure`, {
297
+ method: "POST",
298
+ headers: { Authorization: `Bearer ${status.token}`, "Content-Type": "application/json" },
299
+ body: JSON.stringify(update),
300
+ signal: AbortSignal.timeout(5000),
301
+ });
302
+ const data = await response.json();
303
+ if (!response.ok)
304
+ throw new Error(data.error || "could not update background residency");
305
+ const updated = parseBackgroundStatus(data);
306
+ if (!updated || updated.pid !== status.pid || updated.token !== status.token) {
307
+ throw new Error("the Web console returned an invalid background status");
308
+ }
309
+ return updated;
310
+ }
311
+ /** Stop the detached service, politely first and by signal if it is stuck. */
312
+ export async function stopBackgroundService(expected) {
313
+ const status = runningBackground();
314
+ if (!status) {
315
+ if (!expected)
316
+ clearBackgroundStatus();
317
+ return { ok: true, stopped: false };
318
+ }
319
+ if (expected && (status.pid !== expected.pid || status.token !== expected.token)) {
320
+ return { ok: false, stopped: false, error: "the Web console changed before it could be stopped" };
321
+ }
322
+ let requested = false;
323
+ try {
324
+ const response = await fetch(`${status.url}/api/background/stop`, {
325
+ method: "POST",
326
+ headers: { Authorization: `Bearer ${status.token}` },
327
+ signal: AbortSignal.timeout(2500),
328
+ });
329
+ requested = response.ok;
330
+ }
331
+ catch {
332
+ // The process may already be gone, or its socket may be wedged.
333
+ }
334
+ if (!requested) {
335
+ try {
336
+ process.kill(status.pid, "SIGTERM");
337
+ }
338
+ catch { /* Already gone. */ }
339
+ }
340
+ const deadline = Date.now() + STOP_TIMEOUT_MS;
341
+ while (Date.now() < deadline && isProcessAlive(status.pid))
342
+ await sleep(100);
343
+ if (isProcessAlive(status.pid)) {
344
+ try {
345
+ process.kill(status.pid, "SIGKILL");
346
+ }
347
+ catch { /* Already gone. */ }
348
+ const hardDeadline = Date.now() + 1500;
349
+ while (Date.now() < hardDeadline && isProcessAlive(status.pid))
350
+ await sleep(100);
351
+ }
352
+ const alive = isProcessAlive(status.pid);
353
+ if (!alive)
354
+ clearBackgroundStatus(status.pid);
355
+ return alive
356
+ ? { ok: false, stopped: false, error: `background service ${status.pid} did not stop` }
357
+ : { ok: true, stopped: true };
358
+ }