@xingjisoft/neutrino 0.4.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.
- package/README.md +33 -397
- package/assets/neutrino.ico +0 -0
- package/assets/neutrino.svg +7 -0
- package/dist/agents.js +22 -3
- package/dist/app.js +163 -26
- package/dist/background.js +358 -0
- package/dist/bin.js +179 -42
- package/dist/config.js +7 -3
- package/dist/header.js +2 -1
- package/dist/llm/tools.js +3 -1
- package/dist/migration.js +33 -0
- package/dist/screens/agentConversation.js +48 -5
- package/dist/screens/agentHistory.js +4 -2
- package/dist/screens/settings.js +43 -2
- package/dist/state.js +1 -0
- package/dist/terminal/restore.js +3 -12
- package/dist/tray.js +138 -0
- package/dist/web/hub.js +4 -0
- package/dist/web/server.js +243 -35
- package/dist/web/service.js +135 -0
- package/dist/web/session.js +23 -6
- package/dist/web/settings.js +6 -0
- package/dist/web/terminalBridge.js +250 -0
- package/package.json +2 -2
- package/web/README.md +74 -14
- package/web/app.js +186 -11
- package/web/i18n.js +14 -0
- package/web/index.html +30 -1
- package/web/settings.js +53 -2
- package/web/style.css +101 -0
package/dist/app.js
CHANGED
|
@@ -16,7 +16,8 @@ import { AgentSelectScreen } from "./screens/agentSelect.js";
|
|
|
16
16
|
import { ModelSelectScreen } from "./screens/modelSelect.js";
|
|
17
17
|
import { SettingsScreen } from "./screens/settings.js";
|
|
18
18
|
import { StartScreen } from "./screens/start.js";
|
|
19
|
-
import {
|
|
19
|
+
import { configureBackgroundService, readBackgroundStatus, isProcessAlive, runningBackground, startBackgroundService, stopBackgroundService, } from "./background.js";
|
|
20
|
+
import { startTerminalBridge } from "./web/terminalBridge.js";
|
|
20
21
|
const CURSOR_BLINK_MS = 530;
|
|
21
22
|
const TOAST_MS = 1600;
|
|
22
23
|
const INTERRUPT_WINDOW_MS = 1500;
|
|
@@ -90,9 +91,15 @@ export class App {
|
|
|
90
91
|
// its own (streamed output, a resize) can still carry the anchor along.
|
|
91
92
|
selOffset = null;
|
|
92
93
|
resizeHandler = () => this.onResize();
|
|
93
|
-
|
|
94
|
+
webService = null;
|
|
95
|
+
webStarting = null;
|
|
96
|
+
terminalBridge = null;
|
|
97
|
+
backgroundChanges = Promise.resolve();
|
|
98
|
+
webTray = true;
|
|
94
99
|
/** Where the web UI lives; kept even while it is switched off. */
|
|
95
100
|
webOptions = { port: 8765, auth: true };
|
|
101
|
+
/** Offer the background-residency prompt when this process mounts the UI. */
|
|
102
|
+
webPromptBackground = true;
|
|
96
103
|
/** True while a click is bringing the Web console up, so double clicks are safe. */
|
|
97
104
|
proOpening = false;
|
|
98
105
|
constructor() {
|
|
@@ -131,22 +138,147 @@ export class App {
|
|
|
131
138
|
/**
|
|
132
139
|
* Remember where the browser UI should listen. Called at startup even when
|
|
133
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.
|
|
134
142
|
*/
|
|
135
|
-
configureWeb(port, host, auth) {
|
|
143
|
+
configureWeb(port, host, auth, backgroundPrompt = true, tray = true) {
|
|
136
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;
|
|
137
158
|
}
|
|
138
159
|
/**
|
|
139
160
|
* Mount the browser UI on the given port. Safe to call once; subsequent calls
|
|
140
161
|
* return the already-running server.
|
|
141
162
|
*/
|
|
142
163
|
async enableWeb(port, host, auth = true) {
|
|
143
|
-
if (this.
|
|
144
|
-
return this.
|
|
164
|
+
if (this.webService)
|
|
165
|
+
return this.webService;
|
|
166
|
+
if (this.webStarting)
|
|
167
|
+
return this.webStarting;
|
|
145
168
|
this.webOptions = { port, host, auth };
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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;
|
|
150
282
|
}
|
|
151
283
|
/**
|
|
152
284
|
* Start or stop the browser UI while the terminal keeps running. Returns a
|
|
@@ -155,25 +287,32 @@ export class App {
|
|
|
155
287
|
*/
|
|
156
288
|
async setWebConsole(enabled) {
|
|
157
289
|
if (enabled) {
|
|
158
|
-
if (this.webHandle)
|
|
159
|
-
return { ok: true };
|
|
160
290
|
try {
|
|
161
|
-
await this.
|
|
162
|
-
this.showToast(`Web console: ${
|
|
291
|
+
const url = await this.ensureWebConsole();
|
|
292
|
+
this.showToast(`Web console: ${url}`);
|
|
163
293
|
return { ok: true };
|
|
164
294
|
}
|
|
165
295
|
catch (err) {
|
|
166
296
|
const error = err instanceof Error ? err.message : String(err);
|
|
167
|
-
this.state.webUrl = null;
|
|
168
297
|
this.showToast(`Web console failed: ${error}`, "top");
|
|
169
298
|
return { ok: false, error };
|
|
170
299
|
}
|
|
171
300
|
}
|
|
172
|
-
|
|
173
|
-
|
|
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;
|
|
174
313
|
this.state.webUrl = null;
|
|
314
|
+
this.state.webBackground = false;
|
|
175
315
|
this.render();
|
|
176
|
-
await handle?.close();
|
|
177
316
|
this.showToast("Web console stopped");
|
|
178
317
|
return { ok: true };
|
|
179
318
|
}
|
|
@@ -188,13 +327,10 @@ export class App {
|
|
|
188
327
|
return;
|
|
189
328
|
this.proOpening = true;
|
|
190
329
|
try {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
const url = proModeUrl(handle.url);
|
|
196
|
-
openInBrowser(url);
|
|
197
|
-
this.showToast(`Opening Pro mode: ${url}`);
|
|
330
|
+
const url = await this.ensureWebConsole();
|
|
331
|
+
const target = proModeUrl(url);
|
|
332
|
+
openInBrowser(target);
|
|
333
|
+
this.showToast(`Opening Pro mode: ${target}`);
|
|
198
334
|
}
|
|
199
335
|
catch (err) {
|
|
200
336
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -215,7 +351,8 @@ export class App {
|
|
|
215
351
|
process.on("SIGINT", () => this.handleInterrupt());
|
|
216
352
|
process.on("SIGTERM", () => this.quit());
|
|
217
353
|
this.sizeTimer = setInterval(() => {
|
|
218
|
-
|
|
354
|
+
const webChanged = this.syncWebStatus();
|
|
355
|
+
if (this.syncSize() || webChanged)
|
|
219
356
|
this.render();
|
|
220
357
|
}, 200);
|
|
221
358
|
this.repaintTimer = setInterval(() => this.renderer.repaint(), REPAINT_MS);
|
|
@@ -895,7 +1032,7 @@ export class App {
|
|
|
895
1032
|
catch {
|
|
896
1033
|
// Terminal restoration has already run, even if stdin is unusable.
|
|
897
1034
|
}
|
|
898
|
-
|
|
1035
|
+
this.terminalBridge?.close();
|
|
899
1036
|
process.exit(exitCode);
|
|
900
1037
|
}
|
|
901
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
|
+
}
|