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