@xingjisoft/neutrino 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +33 -549
  2. package/assets/neutrino.ico +0 -0
  3. package/assets/neutrino.svg +7 -0
  4. package/assets/pro/backend_output_model.txt +5 -0
  5. package/assets/pro/claude-module-prompt.txt +65 -0
  6. package/assets/pro/common.txt +9 -0
  7. package/assets/pro/confirm_model.txt +5 -0
  8. package/assets/pro/front_output_model.txt +9 -0
  9. package/assets/pro/genimage_model.txt +1 -0
  10. package/assets/pro/main_model.txt +5 -0
  11. package/assets/pro/model-template.json +376 -0
  12. package/assets/pro/modeling_model.txt +5 -0
  13. package/assets/pro/plan_model.txt +8 -0
  14. package/assets/pro/thinking_model.txt +3 -0
  15. package/dist/agents.js +22 -3
  16. package/dist/app.js +199 -26
  17. package/dist/background.js +358 -0
  18. package/dist/bin.js +184 -44
  19. package/dist/browser.js +57 -0
  20. package/dist/config.js +174 -26
  21. package/dist/header.js +2 -1
  22. package/dist/index.js +1 -1
  23. package/dist/llm/approval.js +6 -2
  24. package/dist/llm/client.js +197 -28
  25. package/dist/llm/subagent.js +37 -1
  26. package/dist/llm/tools.js +3 -1
  27. package/dist/migration.js +33 -0
  28. package/dist/pro/agents.js +104 -0
  29. package/dist/pro/config.js +88 -0
  30. package/dist/pro/preflight.js +172 -0
  31. package/dist/screens/agentConversation.js +48 -5
  32. package/dist/screens/agentHistory.js +4 -2
  33. package/dist/screens/chat.js +14 -8
  34. package/dist/screens/modelSelect.js +15 -4
  35. package/dist/screens/settings.js +52 -9
  36. package/dist/screens/start.js +48 -15
  37. package/dist/state.js +1 -0
  38. package/dist/terminal/restore.js +3 -12
  39. package/dist/tray.js +138 -0
  40. package/dist/ui.js +58 -0
  41. package/dist/web/hub.js +26 -4
  42. package/dist/web/server.js +495 -74
  43. package/dist/web/service.js +135 -0
  44. package/dist/web/session.js +278 -25
  45. package/dist/web/settings.js +97 -24
  46. package/dist/web/terminalBridge.js +250 -0
  47. package/package.json +37 -37
  48. package/web/README.md +150 -16
  49. package/web/app.js +490 -25
  50. package/web/i18n.js +396 -345
  51. package/web/index.html +120 -15
  52. package/web/neutrino-pro.gif +0 -0
  53. package/web/neutrino.png +0 -0
  54. package/web/pro.js +184 -0
  55. package/web/settings.js +373 -247
  56. package/web/style.css +398 -0
package/dist/bin.js CHANGED
@@ -3,7 +3,10 @@ import process from "node:process";
3
3
  import { App } from "./app.js";
4
4
  import { loadConfig } from "./config.js";
5
5
  import { installCrashGuard } from "./terminal/restore.js";
6
- import { startWebServer } from "./web/server.js";
6
+ import { webUrls } from "./web/server.js";
7
+ import { runningBackground, startBackgroundService, stopBackgroundService, } from "./background.js";
8
+ import { runWebService } from "./web/service.js";
9
+ import { openInBrowser } from "./browser.js";
7
10
  const DEFAULT_WEB_PORT = 8765;
8
11
  /** Binding here makes the UI reachable from every interface. */
9
12
  const WILDCARD_HOST = "0.0.0.0";
@@ -35,6 +38,11 @@ Usage:
35
38
  neutrino Start the terminal UI (with the web UI).
36
39
  neutrino --lan Start both, reachable from other devices.
37
40
  neutrino web [--port <port>] Start only the web UI (serves saved history).
41
+ neutrino background start Run the web UI in the background; conversations
42
+ started there survive the terminal/CLI exiting.
43
+ neutrino background stop Stop the background service.
44
+ neutrino background status Show whether it is running and where.
45
+ neutrino background open Open its console in the default browser.
38
46
 
39
47
  Options:
40
48
  -p, --port <port> Web UI port. Default: ${DEFAULT_WEB_PORT}
@@ -43,18 +51,32 @@ Options:
43
51
  devices on your LAN or VPN.
44
52
  --no-web Do not start the web UI with the terminal UI.
45
53
  --no-auth Serve without password login.
54
+ --no-tray Do not show the Windows notification-area icon of the
55
+ background service.
46
56
  --web-port <n> Deprecated alias for --port while the TUI runs.
47
57
  -h, --help Show this help.
48
58
 
49
59
  Environment:
50
60
  NEUTRINO_WEB_PORT Same as --port in either mode.
51
61
  NEUTRINO_WEB_HOST Same as --host.
62
+ NEUTRINO_NO_TRAY Set to 1 to keep the background service out of the
63
+ Windows notification area.
52
64
 
53
65
  The web UI starts by default alongside the terminal UI on loopback. Turn it
54
66
  off for one run with --no-web, or permanently in Settings → Web console. It can
55
67
  read every saved conversation and drive an agent with shell access, so it binds
56
- to loopback unless you ask for --host/--lan. Log in with the permanent password
57
- (default: 114514). Change it in Settings. --no-auth disables password login.
68
+ to loopback unless you ask for --host/--lan. The same LAN access can be switched
69
+ on, and off, in the browser's Settings → Web access; the choice is saved for the
70
+ next launch, while --host/--lan win for the current one. Log in with the
71
+ permanent password (default: 114514). Change it in Settings. --no-auth disables
72
+ password login.
73
+
74
+ Background residency is off by default. Turn it on in Settings → Background
75
+ (shared by the regular and Pro workspaces). Switching it takes effect immediately,
76
+ including for browser tasks already running. The background service keeps
77
+ running when this terminal closes, so tasks started
78
+ from the browser are not interrupted. Stop it with 'neutrino background stop',
79
+ the tray icon, or Settings → Web console in the terminal.
58
80
  `);
59
81
  }
60
82
  /** Read the value that follows a flag, so `--host` alone is an error. */
@@ -65,13 +87,28 @@ function requireValue(argv, index, flag) {
65
87
  }
66
88
  return value;
67
89
  }
90
+ const BACKGROUND_ACTIONS = new Set(["run", "start", "stop", "status", "open"]);
68
91
  function parseArgs(argv) {
69
- const options = { mode: "tui", port: null, lan: false, noWeb: false, noAuth: false };
92
+ const options = {
93
+ mode: "tui", backgroundAction: null, port: null, lan: false, noWeb: false, noAuth: false, noTray: false,
94
+ };
70
95
  for (let i = 0; i < argv.length; i += 1) {
71
96
  const arg = argv[i];
72
97
  if (arg === "web") {
73
98
  options.mode = "web";
74
99
  }
100
+ else if (arg === "background") {
101
+ options.mode = "background";
102
+ options.backgroundAction = "status";
103
+ const next = argv[i + 1];
104
+ if (next && !next.startsWith("-")) {
105
+ if (!BACKGROUND_ACTIONS.has(next)) {
106
+ throw new Error(`unknown background action "${next}"`);
107
+ }
108
+ options.backgroundAction = next;
109
+ i += 1;
110
+ }
111
+ }
75
112
  else if (arg === "--port" || arg === "-p") {
76
113
  options.port = parsePort(argv[++i]);
77
114
  }
@@ -99,6 +136,25 @@ function parseArgs(argv) {
99
136
  else if (arg === "--no-auth") {
100
137
  options.noAuth = true;
101
138
  }
139
+ else if (arg === "--no-tray") {
140
+ options.noTray = true;
141
+ }
142
+ else if (arg === "--start") {
143
+ options.mode = "background";
144
+ options.backgroundAction = "start";
145
+ }
146
+ else if (arg === "--stop") {
147
+ options.mode = "background";
148
+ options.backgroundAction = "stop";
149
+ }
150
+ else if (arg === "--status") {
151
+ options.mode = "background";
152
+ options.backgroundAction = "status";
153
+ }
154
+ else if (arg === "--open") {
155
+ options.mode = "background";
156
+ options.backgroundAction = "open";
157
+ }
102
158
  else if (arg === "--help" || arg === "-h") {
103
159
  printHelp();
104
160
  process.exit(0);
@@ -113,31 +169,64 @@ function parseArgs(argv) {
113
169
  if (options.noWeb && options.mode === "web") {
114
170
  throw new Error("--no-web cannot be used with the 'web' command");
115
171
  }
172
+ if (options.noWeb && options.mode === "background") {
173
+ throw new Error("--no-web cannot be used with the 'background' command");
174
+ }
116
175
  if (options.noWeb && (options.port !== null || options.host !== undefined || options.lan)) {
117
176
  throw new Error("use either --no-web or web UI options, not both");
118
177
  }
119
178
  return options;
120
179
  }
121
- /** Print every address a client can open, plus what it takes to open one. */
122
- function reportAccess(handle) {
123
- process.stdout.write(`Neutrino web UI listening on ${handle.host}:${handle.port}\n`);
124
- const multiple = handle.urls.length > 1;
180
+ /** Print where an already-running detached console can be opened. */
181
+ function reportBackgroundAccess(status, started) {
182
+ process.stdout.write(`Neutrino ${status.resident ? "background " : ""}Web console ${started ? "started" : "already running"} `
183
+ + `(pid ${status.pid}) on ${status.host}:${status.port}\n`);
184
+ const { urls } = webUrls(status.host, status.port);
185
+ const multiple = urls.length > 1;
125
186
  process.stdout.write(multiple ? "Open the address that matches your device:\n" : "Open:\n");
126
- for (const url of handle.urls)
187
+ for (const url of urls)
127
188
  process.stdout.write(` ${url}\n`);
128
- const wildcard = handle.host === "0.0.0.0" || handle.host === "::";
129
- if (wildcard && !handle.urls.some((url) => !url.includes("//127.0.0.1:"))) {
130
- // Bound to every interface, yet nothing outside this machine is reachable:
131
- // usually Wi-Fi is off, the VPN is down, or the only adapters left are
132
- // virtual ones we deliberately hide.
189
+ if ((status.host === "0.0.0.0" || status.host === "::")
190
+ && !urls.some((url) => !url.includes("//127.0.0.1:"))) {
133
191
  process.stdout.write("note: no reachable network address found, so only this machine can connect.\n");
134
192
  }
135
- if (handle.authRequired) {
136
- process.stdout.write("Sign in with your permanent Web password (default: 114514). Change it in Settings.\n");
193
+ if (status.auth_required) {
194
+ process.stdout.write("Sign in with your permanent Web password (default: 114514).\n");
137
195
  }
138
196
  else {
139
197
  process.stdout.write("Password login is disabled.\n");
140
198
  }
199
+ if (status.resident) {
200
+ process.stdout.write("It keeps running after this terminal exits. Stop it with 'neutrino background stop'"
201
+ + (process.platform === "win32" ? " or the tray icon" : "") + ".\n");
202
+ }
203
+ }
204
+ /**
205
+ * The detached service itself (`neutrino background run`).
206
+ *
207
+ * It owns the Web console and every browser conversation for as long as the
208
+ * machine's user session keeps it alive. The token written into the status
209
+ * file is the only way another local process can ask it to stop.
210
+ */
211
+ async function runBackgroundService(host, auth) {
212
+ await runWebService({ port: options.port ?? portFromEnv ?? DEFAULT_WEB_PORT, host, auth, tray: !options.noTray });
213
+ }
214
+ function printBackgroundStatus() {
215
+ const status = runningBackground();
216
+ if (!status) {
217
+ process.stdout.write("No background service is running.\n");
218
+ process.stdout.write("Start one with 'neutrino background start'.\n");
219
+ return;
220
+ }
221
+ process.stdout.write(`Neutrino background service: running (pid ${status.pid})\n`);
222
+ process.stdout.write(`Residency: ${status.resident ? "on" : "off (follows the terminal)"}\n`);
223
+ process.stdout.write(`Started: ${status.started_at}\n`);
224
+ process.stdout.write(`Address: ${status.url}\n`);
225
+ const { urls } = webUrls(status.host, status.port);
226
+ for (const url of urls) {
227
+ if (url.replace(/\/$/, "") !== status.url)
228
+ process.stdout.write(`Also: ${url}\n`);
229
+ }
141
230
  }
142
231
  let options;
143
232
  try {
@@ -149,29 +238,82 @@ catch (err) {
149
238
  process.exit(2);
150
239
  }
151
240
  // Installed before either mode starts: a crash must leave a usable terminal
152
- // (TUI) and a log line (both), never a shell flooded with mouse reports.
153
- installCrashGuard();
241
+ // (TUI) and a log line (both), never a shell flooded with mouse reports. A
242
+ // detached service keeps its own SIGHUP handler so closing the terminal cannot
243
+ // take it down.
244
+ installCrashGuard({ surviveHangup: options.mode === "background" });
154
245
  const host = options.lan ? WILDCARD_HOST : options.host || process.env.NEUTRINO_WEB_HOST || undefined;
155
246
  const auth = !options.noAuth;
156
247
  const portFromEnv = envPort();
157
248
  if (options.mode === "web") {
158
- // Headless: serve the saved history (and any live sessions in this process,
159
- // which for a standalone server is none).
160
- const port = options.port ?? portFromEnv ?? DEFAULT_WEB_PORT;
161
- try {
162
- const handle = await startWebServer({ port, host, auth });
163
- reportAccess(handle);
164
- process.stdout.write("Serving saved conversations. Press Ctrl+C to stop.\n");
165
- const shutdown = () => {
166
- handle.close().finally(() => process.exit(0));
167
- };
168
- process.on("SIGINT", shutdown);
169
- process.on("SIGTERM", shutdown);
170
- }
171
- catch (err) {
172
- process.stderr.write(`neutrino: failed to start web server: ${err instanceof Error ? err.message : String(err)}\n`);
249
+ const result = await startBackgroundService({
250
+ port: options.port ?? portFromEnv ?? DEFAULT_WEB_PORT, host, auth,
251
+ tray: !options.noTray, resident: loadConfig().background === true, ownerPid: process.pid,
252
+ strictPort: options.port !== null,
253
+ });
254
+ if (!result.ok) {
255
+ process.stderr.write(`neutrino: failed to start web server: ${result.error}\n`);
173
256
  process.exit(1);
174
257
  }
258
+ reportBackgroundAccess(result.status, result.started);
259
+ process.stdout.write("Serving saved conversations. Press Ctrl+C to close this terminal.\n");
260
+ // The owner monitor in the service also handles an abrupt terminal close.
261
+ const keepAlive = setInterval(() => { }, 60_000);
262
+ const shutdown = () => { clearInterval(keepAlive); process.exit(0); };
263
+ process.on("SIGINT", shutdown);
264
+ process.on("SIGTERM", shutdown);
265
+ }
266
+ else if (options.mode === "background") {
267
+ const action = options.backgroundAction ?? "status";
268
+ if (action === "run") {
269
+ // The detached child. It does not own a terminal, so its stdio is ignored
270
+ // by the parent and every diagnostic goes to background.log.
271
+ await runBackgroundService(host, auth);
272
+ }
273
+ else if (action === "start") {
274
+ const result = await startBackgroundService({
275
+ port: options.port ?? portFromEnv ?? DEFAULT_WEB_PORT,
276
+ host,
277
+ auth,
278
+ tray: !options.noTray,
279
+ });
280
+ if (!result.ok) {
281
+ process.stderr.write(`neutrino: ${result.error}\n`);
282
+ process.exit(1);
283
+ }
284
+ reportBackgroundAccess(result.status, result.started);
285
+ process.exitCode = 0;
286
+ }
287
+ else if (action === "stop") {
288
+ const result = await stopBackgroundService();
289
+ if (!result.ok) {
290
+ process.stderr.write(`neutrino: ${result.error}\n`);
291
+ process.exit(1);
292
+ }
293
+ process.stdout.write(result.stopped ? "Background service stopped.\n" : "No background service is running.\n");
294
+ process.exitCode = 0;
295
+ }
296
+ else if (action === "open") {
297
+ const started = await startBackgroundService({
298
+ port: options.port ?? portFromEnv ?? DEFAULT_WEB_PORT,
299
+ host,
300
+ auth,
301
+ tray: !options.noTray,
302
+ });
303
+ if (!started.ok) {
304
+ process.stderr.write(`neutrino: ${started.error}\n`);
305
+ process.exit(1);
306
+ }
307
+ if (started.started)
308
+ reportBackgroundAccess(started.status, true);
309
+ openInBrowser(started.status.url);
310
+ process.stdout.write(`Opening ${started.status.url}\n`);
311
+ process.exitCode = 0;
312
+ }
313
+ else {
314
+ printBackgroundStatus();
315
+ process.exitCode = 0;
316
+ }
175
317
  }
176
318
  else {
177
319
  if (!process.stdout.isTTY || !process.stdin.isTTY) {
@@ -181,22 +323,20 @@ else {
181
323
  }
182
324
  const app = new App();
183
325
  const webPort = options.port ?? portFromEnv ?? DEFAULT_WEB_PORT;
184
- // Remember where the UI should live even when it is off right now, so the
185
- // Settings toggle can mount it without restarting the terminal.
186
- app.configureWeb(webPort, host, auth);
187
- // Explicit web settings win over the saved preference; otherwise the web UI
188
- // is part of a normal launch unless the user turned it off in Settings.
326
+ const saved = loadConfig();
327
+ app.configureWeb(webPort, host, auth, true, !options.noTray);
189
328
  const requested = options.port !== null || options.host !== undefined || options.lan
190
329
  || options.noAuth || portFromEnv !== null || Boolean(process.env.NEUTRINO_WEB_HOST);
191
- const enabledByDefault = loadConfig().web_console !== false;
192
- if (!options.noWeb && (requested || enabledByDefault)) {
330
+ if (!options.noWeb && (requested || saved.web_console !== false)) {
193
331
  try {
194
- const handle = await app.enableWeb(webPort, host, auth);
195
- reportAccess(handle);
332
+ const previous = runningBackground();
333
+ const status = await app.enableWeb(webPort, host, auth);
334
+ if (options.port !== null && options.port !== 0 && status.port !== webPort) {
335
+ process.stderr.write(`note: the Web console is already running on port ${status.port}; --port was not applied.\n`);
336
+ }
337
+ reportBackgroundAccess(status, previous?.pid !== status.pid);
196
338
  }
197
339
  catch (err) {
198
- // A console that cannot bind (usually a second instance on 8765) must
199
- // never take the terminal down with it.
200
340
  const detail = err instanceof Error ? err.message : String(err);
201
341
  process.stderr.write(`neutrino: web UI not started: ${detail}\n`);
202
342
  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");
@@ -0,0 +1,57 @@
1
+ import { spawn } from "node:child_process";
2
+ import process from "node:process";
3
+ /**
4
+ * Where the browser console's Pro workspace lives, given the console's base
5
+ * URL.
6
+ *
7
+ * The base comes straight from the running server, but it is normalised here
8
+ * anyway: a trailing slash must not produce `//pro`, and a URL that already
9
+ * points at `/pro` must not become `/pro/pro`. A missing/blank base yields the
10
+ * path alone instead of throwing, so a half-configured console can still be
11
+ * reported instead of crashing the click that asked for it.
12
+ */
13
+ export function proModeUrl(baseUrl) {
14
+ const base = String(baseUrl ?? "").trim().replace(/\/+$/, "");
15
+ if (/\/pro$/i.test(base))
16
+ return base;
17
+ return `${base}/pro`;
18
+ }
19
+ /**
20
+ * Open `url` in the user's default browser. Fire-and-forget: the child is
21
+ * detached, its stdio is ignored and it is unref'd so the terminal is never
22
+ * held open (or blocked) by the browser. Every failure is swallowed — opening
23
+ * a browser is best-effort and must never take the TUI down.
24
+ */
25
+ export function openInBrowser(url) {
26
+ try {
27
+ let command;
28
+ let args;
29
+ if (process.platform === "win32") {
30
+ // `start` is a cmd built-in. The empty "" is the (required) window title,
31
+ // so a quoted URL is never mistaken for one.
32
+ command = "cmd";
33
+ args = ["/c", "start", "", url];
34
+ }
35
+ else if (process.platform === "darwin") {
36
+ command = "open";
37
+ args = [url];
38
+ }
39
+ else {
40
+ command = "xdg-open";
41
+ args = [url];
42
+ }
43
+ const child = spawn(command, args, {
44
+ detached: true,
45
+ stdio: "ignore",
46
+ windowsHide: true,
47
+ });
48
+ // A missing opener (xdg-open is not installed everywhere) is reported
49
+ // asynchronously; without a listener that 'error' event would crash the
50
+ // whole process.
51
+ child.on("error", () => { });
52
+ child.unref();
53
+ }
54
+ catch {
55
+ // Best-effort only.
56
+ }
57
+ }