@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.
- package/README.md +33 -549
- package/assets/neutrino.ico +0 -0
- package/assets/neutrino.svg +7 -0
- package/assets/pro/backend_output_model.txt +5 -0
- package/assets/pro/claude-module-prompt.txt +65 -0
- package/assets/pro/common.txt +9 -0
- package/assets/pro/confirm_model.txt +5 -0
- package/assets/pro/front_output_model.txt +9 -0
- package/assets/pro/genimage_model.txt +1 -0
- package/assets/pro/main_model.txt +5 -0
- package/assets/pro/model-template.json +376 -0
- package/assets/pro/modeling_model.txt +5 -0
- package/assets/pro/plan_model.txt +8 -0
- package/assets/pro/thinking_model.txt +3 -0
- package/dist/agents.js +22 -3
- package/dist/app.js +199 -26
- package/dist/background.js +358 -0
- package/dist/bin.js +184 -44
- package/dist/browser.js +57 -0
- package/dist/config.js +174 -26
- package/dist/header.js +2 -1
- package/dist/index.js +1 -1
- package/dist/llm/approval.js +6 -2
- package/dist/llm/client.js +197 -28
- package/dist/llm/subagent.js +37 -1
- package/dist/llm/tools.js +3 -1
- package/dist/migration.js +33 -0
- package/dist/pro/agents.js +104 -0
- package/dist/pro/config.js +88 -0
- package/dist/pro/preflight.js +172 -0
- package/dist/screens/agentConversation.js +48 -5
- package/dist/screens/agentHistory.js +4 -2
- package/dist/screens/chat.js +14 -8
- package/dist/screens/modelSelect.js +15 -4
- package/dist/screens/settings.js +52 -9
- package/dist/screens/start.js +48 -15
- package/dist/state.js +1 -0
- package/dist/terminal/restore.js +3 -12
- package/dist/tray.js +138 -0
- package/dist/ui.js +58 -0
- package/dist/web/hub.js +26 -4
- package/dist/web/server.js +495 -74
- package/dist/web/service.js +135 -0
- package/dist/web/session.js +278 -25
- package/dist/web/settings.js +97 -24
- package/dist/web/terminalBridge.js +250 -0
- package/package.json +37 -37
- package/web/README.md +150 -16
- package/web/app.js +490 -25
- package/web/i18n.js +396 -345
- package/web/index.html +120 -15
- package/web/neutrino-pro.gif +0 -0
- package/web/neutrino.png +0 -0
- package/web/pro.js +184 -0
- package/web/settings.js +373 -247
- 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 {
|
|
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
|
-
|
|
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.
|
|
137
|
-
return this.
|
|
164
|
+
if (this.webService)
|
|
165
|
+
return this.webService;
|
|
166
|
+
if (this.webStarting)
|
|
167
|
+
return this.webStarting;
|
|
138
168
|
this.webOptions = { port, host, auth };
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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.
|
|
155
|
-
this.showToast(`Web console: ${
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
+
}
|