@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/terminal/restore.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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)
|