@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
@@ -89,17 +89,7 @@ export function writeCrashLog(kind, err) {
89
89
  return null;
90
90
  }
91
91
  }
92
- /**
93
- * Installs process-wide handlers that restore the terminal before the process
94
- * dies.
95
- *
96
- * Without this, any throw that escapes a timer, a stream callback or a floating
97
- * promise kills the process with raw mode still on and the alternate screen
98
- * still active. The visible result is a terminal that works but spews
99
- * `[<b>;<x>;<y>M` mouse reports at the shell prompt, because the shell now
100
- * receives the mouse events the TUI asked the terminal to send.
101
- */
102
- export function installCrashGuard() {
92
+ export function installCrashGuard(options = {}) {
103
93
  if (installed)
104
94
  return;
105
95
  installed = true;
@@ -123,7 +113,8 @@ export function installCrashGuard() {
123
113
  });
124
114
  // These signals otherwise bypass Node's exit hook, including Ctrl+Break on
125
115
  // Windows. The app handles SIGINT as an interrupt and SIGTERM as a quit.
126
- process.on("SIGHUP", () => process.exit(129));
116
+ if (!options.surviveHangup)
117
+ process.on("SIGHUP", () => process.exit(129));
127
118
  process.on("SIGBREAK", () => process.exit(149));
128
119
  const fatal = (kind, err) => {
129
120
  // Restore first: the terminal must be usable even if logging itself fails.
package/dist/tray.js ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Windows notification-area icon for the background Web console.
3
+ *
4
+ * Node cannot create a tray icon by itself, and the project deliberately has
5
+ * no native dependencies, so a tiny Windows PowerShell process hosts a
6
+ * `System.Windows.Forms.NotifyIcon`. It uses the same orbit badge as the
7
+ * regular console's top-left mark. Everything is best-effort: an unavailable
8
+ * icon must never stop the service.
9
+ */
10
+ import { spawn } from "node:child_process";
11
+ import { randomBytes } from "node:crypto";
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import process from "node:process";
15
+ import { fileURLToPath } from "node:url";
16
+ import { BACKGROUND_LOG_FILE, backgroundLog } from "./background.js";
17
+ import { NEUTRINO_DIR } from "./config.js";
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+ const PKG_ROOT = path.resolve(__dirname, "..");
20
+ const ICON_FILE = path.join(PKG_ROOT, "assets", "neutrino.ico");
21
+ /** Quote a value as a PowerShell single-quoted literal. */
22
+ function psString(value) {
23
+ return `'${value.replace(/'/g, "''")}'`;
24
+ }
25
+ /** The icon itself. Only Windows has a notification area, and only it can run this. */
26
+ export function startBackgroundTray(options) {
27
+ if (process.platform !== "win32")
28
+ return null;
29
+ if (process.env.NEUTRINO_NO_TRAY === "1")
30
+ return null;
31
+ if (!fs.existsSync(ICON_FILE)) {
32
+ backgroundLog(`tray icon not found at ${ICON_FILE}`);
33
+ return null;
34
+ }
35
+ // Separate files let an old icon close cleanly even if residency is turned
36
+ // back on before its PowerShell message loop has processed the shutdown.
37
+ const suffix = `${process.pid}-${randomBytes(8).toString("hex")}`;
38
+ const scriptFile = path.join(NEUTRINO_DIR, `tray-${suffix}.ps1`);
39
+ const closeFile = path.join(NEUTRINO_DIR, `tray-${suffix}.stop`);
40
+ const script = `
41
+ $ErrorActionPreference = 'Continue'
42
+ $scriptFile = ${psString(scriptFile)}
43
+ $closeFile = ${psString(closeFile)}
44
+ try {
45
+ Add-Type -AssemblyName System.Windows.Forms
46
+ Add-Type -AssemblyName System.Drawing
47
+ $iconPath = ${psString(ICON_FILE)}
48
+ $url = ${psString(options.url)}
49
+ $stopUrl = ${psString(options.stopUrl)}
50
+ $token = ${psString(options.token)}
51
+ $parent = ${options.pid}
52
+ $notify = New-Object System.Windows.Forms.NotifyIcon
53
+ $notify.Icon = New-Object System.Drawing.Icon($iconPath)
54
+ $notify.Text = 'Neutrino - background console'
55
+ $notify.Visible = $true
56
+ $menu = New-Object System.Windows.Forms.ContextMenuStrip
57
+ $open = $menu.Items.Add('Open Neutrino console')
58
+ $open.add_Click({ Start-Process -FilePath $url })
59
+ $stop = $menu.Items.Add('Stop background service')
60
+ $stop.add_Click({
61
+ try {
62
+ Invoke-RestMethod -Method Post -Uri $stopUrl -Headers @{ Authorization = "Bearer $token" } -TimeoutSec 5 | Out-Null
63
+ } catch {
64
+ Stop-Process -Id $parent -Force
65
+ }
66
+ $notify.Visible = $false
67
+ [System.Windows.Forms.Application]::Exit()
68
+ })
69
+ $menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) | Out-Null
70
+ $notify.ContextMenuStrip = $menu
71
+ $notify.add_MouseClick({ param($sender, $eventArgs) if ($eventArgs.Button -eq [System.Windows.Forms.MouseButtons]::Left) { Start-Process -FilePath $url } })
72
+ $notify.add_DoubleClick({ Start-Process -FilePath $url })
73
+ $timer = New-Object System.Windows.Forms.Timer
74
+ $timer.Interval = 250
75
+ $timer.add_Tick({
76
+ if ((Test-Path -LiteralPath $closeFile) -or -not (Get-Process -Id $parent -ErrorAction SilentlyContinue)) {
77
+ $notify.Visible = $false
78
+ [System.Windows.Forms.Application]::Exit()
79
+ }
80
+ })
81
+ $timer.Start()
82
+ [System.Windows.Forms.Application]::Run()
83
+ } finally {
84
+ if ($notify) { $notify.Visible = $false; $notify.Dispose() }
85
+ Remove-Item -LiteralPath $closeFile, $scriptFile -Force -ErrorAction SilentlyContinue
86
+ }
87
+ `;
88
+ const cleanup = () => {
89
+ for (const file of [scriptFile, closeFile]) {
90
+ try {
91
+ fs.rmSync(file, { force: true });
92
+ }
93
+ catch { /* The helper also cleans up. */ }
94
+ }
95
+ };
96
+ try {
97
+ fs.mkdirSync(NEUTRINO_DIR, { recursive: true });
98
+ fs.writeFileSync(scriptFile, `\ufeff${script}`, { encoding: "utf-8", mode: 0o600 });
99
+ const logFd = fs.openSync(BACKGROUND_LOG_FILE, "a");
100
+ let child;
101
+ try {
102
+ child = spawn("powershell.exe", [
103
+ "-NoProfile", "-NonInteractive", "-STA", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden",
104
+ "-File", scriptFile,
105
+ ],
106
+ // Not detached: on Windows a detached PowerShell exits before its
107
+ // message loop starts. The icon outlives the service on its own; its
108
+ // timer notices the parent pid disappearing and shuts it down.
109
+ { stdio: ["ignore", "ignore", logFd], windowsHide: true });
110
+ }
111
+ finally {
112
+ fs.closeSync(logFd);
113
+ }
114
+ let active = true;
115
+ child.once("error", (error) => { active = false; cleanup(); backgroundLog(`tray failed: ${error.message}`); });
116
+ child.once("exit", (code) => { active = false; cleanup(); backgroundLog(`tray exited (${code ?? "signal"})`); });
117
+ child.unref();
118
+ backgroundLog(`tray icon started (pid ${child.pid ?? "unknown"})`);
119
+ return { close: () => {
120
+ if (!active)
121
+ return;
122
+ try {
123
+ fs.writeFileSync(closeFile, "", { mode: 0o600 });
124
+ }
125
+ catch {
126
+ try {
127
+ child.kill();
128
+ }
129
+ catch { /* Already gone. */ }
130
+ }
131
+ } };
132
+ }
133
+ catch (error) {
134
+ cleanup();
135
+ backgroundLog(`tray failed: ${error instanceof Error ? error.message : String(error)}`);
136
+ return null;
137
+ }
138
+ }
package/dist/ui.js CHANGED
@@ -293,6 +293,64 @@ export function drawTabButton(buf, x, y, w, label, selected, align = "center") {
293
293
  buf.writeText(lx, y + 1, label, { fg: C.dim });
294
294
  }
295
295
  }
296
+ /** Label of the CLI entry point that opens the browser console's Pro workspace. */
297
+ export const PRO_MODE_LABEL = "启用Pro模式";
298
+ /**
299
+ * Geometry of the Pro-mode button: a small box in the top-left corner, directly
300
+ * below the status bar (row 0). Rendering and hit testing share it so a click
301
+ * always lands on the box that is drawn.
302
+ *
303
+ * Returns null when the terminal is too small to fit the box with a little
304
+ * breathing room around it. The height floor also keeps the start screen's
305
+ * centered block (which is pushed below the box) clear of the approval toggle
306
+ * on very short terminals, where the original layout is preserved instead.
307
+ */
308
+ export function proModeButton(width, height) {
309
+ // One blank column at the very edge, then the border: "flush to the edge
310
+ // with a single space", as the start screen expects.
311
+ const x = 1;
312
+ const y = 1;
313
+ const h = 3;
314
+ // Two cells of padding, one inside each border.
315
+ const w = displayWidth(PRO_MODE_LABEL) + 4;
316
+ if (width < w + 4 || height < 22)
317
+ return null;
318
+ return { x, y, w, h, label: PRO_MODE_LABEL };
319
+ }
320
+ /**
321
+ * Draw the Pro-mode button. It is painted as opaque chrome so content behind it
322
+ * can never bleed through; the label row turns into the highlight background
323
+ * while the pointer is over it.
324
+ */
325
+ export function drawProModeButton(buf, hovered = false) {
326
+ const button = proModeButton(buf.width, buf.height);
327
+ if (!button)
328
+ return;
329
+ const { x, y, w, h, label } = button;
330
+ if (w < 2 || h < 2)
331
+ return;
332
+ // Clear the footprint first: the button acts as an overlay over decorative
333
+ // content (the logo), which must not show through its interior.
334
+ buf.fillRect(x, y, w, h, { bg: C.black });
335
+ buf.drawBox(x, y, w, h, { fg: C.dim });
336
+ const innerX = x + 1;
337
+ const innerW = w - 2;
338
+ const labelY = y + Math.floor(h / 2);
339
+ if (hovered)
340
+ buf.fillRect(innerX, labelY, innerW, 1, { bg: C.highlightBg });
341
+ const text = truncateByWidth(label, innerW);
342
+ const textX = innerX + Math.max(0, Math.floor((innerW - displayWidth(text)) / 2));
343
+ const style = hovered
344
+ ? { fg: C.accent, bg: C.highlightBg, bold: true }
345
+ : { fg: C.accent, bold: true };
346
+ buf.writeText(textX, labelY, text, style);
347
+ // The button is chrome, not content: copying a selection across it must not
348
+ // drag its border or label into the clipboard.
349
+ for (let j = y; j < y + h; j++) {
350
+ for (let i = x; i < x + w; i++)
351
+ buf.markChrome(i, j);
352
+ }
353
+ }
296
354
  export function centerVertically(totalHeight, contentHeight) {
297
355
  const top = Math.floor((totalHeight - contentHeight) / 2);
298
356
  return Math.max(1, top);
package/dist/web/hub.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * {@link LiveHub.notify}, which coalesces rapid-fire updates (one per streamed
13
13
  * token) and pushes a snapshot to any connected browser.
14
14
  */
15
+ import { normalizeMode } from "../config.js";
15
16
  export function liveApprovalDetails(tool, args, risk, cwd) {
16
17
  return { tool, level: risk.level, arguments: args, cwd, summary: risk.summary, reasons: risk.reasons };
17
18
  }
@@ -32,6 +33,7 @@ class LiveHub {
32
33
  key,
33
34
  agent,
34
35
  cwd: agent.liveCwd(),
36
+ mode: normalizeMode(agent.mode),
35
37
  listeners: new Set(),
36
38
  timer: null,
37
39
  lastSent: "",
@@ -48,13 +50,24 @@ class LiveHub {
48
50
  clearTimeout(entry.timer);
49
51
  this.entries.delete(key);
50
52
  }
51
- has(key) {
52
- return this.entries.has(key);
53
+ /**
54
+ * Whether a key is live. With a mode, keys from the other workspace are
55
+ * treated as unknown: the Pro page must never reach a terminal session.
56
+ */
57
+ has(key, mode) {
58
+ const entry = this.entries.get(key);
59
+ return Boolean(entry) && (!mode || entry.mode === mode);
53
60
  }
54
- /** All live sessions, newest registration last. */
55
- list() {
61
+ remoteAction(key) {
62
+ const agent = this.entries.get(key)?.agent;
63
+ return agent?.remoteAction ? agent.remoteAction.bind(agent) : null;
64
+ }
65
+ /** All live sessions, newest registration last. Optionally one workspace only. */
66
+ list(mode) {
56
67
  const out = [];
57
68
  for (const entry of this.entries.values()) {
69
+ if (mode && entry.mode !== mode)
70
+ continue;
58
71
  out.push({ ...entry.agent.liveMeta(), key: entry.key, cwd: entry.cwd });
59
72
  }
60
73
  return out;
@@ -167,6 +180,15 @@ class LiveHub {
167
180
  this.notify(key);
168
181
  return result;
169
182
  }
183
+ setProPreset(key, preset) {
184
+ const entry = this.entries.get(key);
185
+ if (!entry?.agent.liveSetProPreset)
186
+ return { ok: false, error: "this session cannot change Pro presets" };
187
+ const result = entry.agent.liveSetProPreset(preset);
188
+ if (result.ok)
189
+ this.notify(key);
190
+ return result;
191
+ }
170
192
  setApprovalMode(key, mode) {
171
193
  const entry = this.entries.get(key);
172
194
  if (!entry?.agent.liveSetApprovalMode)