@xingjisoft/neutrino 0.4.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 -397
- package/assets/neutrino.ico +0 -0
- package/assets/neutrino.svg +7 -0
- package/dist/agents.js +22 -3
- package/dist/app.js +163 -26
- package/dist/background.js +358 -0
- package/dist/bin.js +179 -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/screens/agentConversation.js +48 -5
- package/dist/screens/agentHistory.js +4 -2
- package/dist/screens/settings.js +43 -2
- package/dist/state.js +1 -0
- package/dist/terminal/restore.js +3 -12
- package/dist/tray.js +138 -0
- package/dist/web/hub.js +4 -0
- package/dist/web/server.js +243 -35
- package/dist/web/service.js +135 -0
- package/dist/web/session.js +23 -6
- package/dist/web/settings.js +6 -0
- package/dist/web/terminalBridge.js +250 -0
- package/package.json +2 -2
- package/web/README.md +74 -14
- package/web/app.js +186 -11
- package/web/i18n.js +14 -0
- package/web/index.html +30 -1
- package/web/settings.js +53 -2
- package/web/style.css +101 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { backgroundLog, clearBackgroundStatus, isProcessAlive, writeBackgroundStatus, } from "../background.js";
|
|
4
|
+
import { loadConfig, saveConfig } from "../config.js";
|
|
5
|
+
import { startBackgroundTray } from "../tray.js";
|
|
6
|
+
import { hub } from "./hub.js";
|
|
7
|
+
import { startWebServer } from "./server.js";
|
|
8
|
+
import { TerminalBridges } from "./terminalBridge.js";
|
|
9
|
+
/** The detached process owns browser conversations for their entire lifetime. */
|
|
10
|
+
export async function runWebService(options) {
|
|
11
|
+
const token = process.env.NEUTRINO_BACKGROUND_TOKEN?.trim() || randomBytes(32).toString("hex");
|
|
12
|
+
let resident = process.env.NEUTRINO_BACKGROUND_RESIDENT !== "0";
|
|
13
|
+
const owner = Number(process.env.NEUTRINO_BACKGROUND_OWNER);
|
|
14
|
+
let owners = new Set(Number.isSafeInteger(owner) && owner > 0 ? [owner] : []);
|
|
15
|
+
const startedAt = new Date().toISOString();
|
|
16
|
+
let handle = null;
|
|
17
|
+
let tray = null;
|
|
18
|
+
let monitor = null;
|
|
19
|
+
let stopping = false;
|
|
20
|
+
const bridges = new TerminalBridges();
|
|
21
|
+
const status = () => {
|
|
22
|
+
if (!handle)
|
|
23
|
+
throw new Error("the Web console is starting");
|
|
24
|
+
return {
|
|
25
|
+
pid: process.pid, port: handle.port, host: handle.host, url: handle.url,
|
|
26
|
+
auth_required: options.auth, token, started_at: startedAt,
|
|
27
|
+
resident, owner_pids: [...owners],
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
const syncTray = () => {
|
|
31
|
+
if (!resident) {
|
|
32
|
+
tray?.close();
|
|
33
|
+
tray = null;
|
|
34
|
+
}
|
|
35
|
+
else if (!tray && options.tray && handle) {
|
|
36
|
+
tray = startBackgroundTray({ url: handle.url, stopUrl: `${handle.url}/api/background/stop`, token, pid: process.pid });
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
const shutdown = () => {
|
|
40
|
+
if (stopping)
|
|
41
|
+
return;
|
|
42
|
+
stopping = true;
|
|
43
|
+
if (monitor)
|
|
44
|
+
clearInterval(monitor);
|
|
45
|
+
tray?.close();
|
|
46
|
+
bridges.close();
|
|
47
|
+
backgroundLog(`stopping pid=${process.pid}`);
|
|
48
|
+
clearBackgroundStatus(process.pid);
|
|
49
|
+
const finish = () => process.exit(0);
|
|
50
|
+
if (handle)
|
|
51
|
+
handle.close().finally(finish).catch(finish);
|
|
52
|
+
else
|
|
53
|
+
finish();
|
|
54
|
+
setTimeout(finish, 3000).unref();
|
|
55
|
+
};
|
|
56
|
+
const configure = (update) => {
|
|
57
|
+
if (stopping)
|
|
58
|
+
throw new Error("the Web console is shutting down");
|
|
59
|
+
if (update.ownerPid !== undefined && !isProcessAlive(update.ownerPid)) {
|
|
60
|
+
throw new Error("the terminal that owns this console has already exited");
|
|
61
|
+
}
|
|
62
|
+
const previousResident = resident;
|
|
63
|
+
const previousOwners = owners;
|
|
64
|
+
const config = loadConfig();
|
|
65
|
+
const changed = update.resident !== undefined;
|
|
66
|
+
if (changed)
|
|
67
|
+
saveConfig({ ...config, background: update.resident });
|
|
68
|
+
resident = update.resident ?? resident;
|
|
69
|
+
owners = new Set(owners);
|
|
70
|
+
if (update.ownerPid !== undefined)
|
|
71
|
+
owners.add(update.ownerPid);
|
|
72
|
+
try {
|
|
73
|
+
writeBackgroundStatus(status());
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
resident = previousResident;
|
|
77
|
+
owners = previousOwners;
|
|
78
|
+
if (changed) {
|
|
79
|
+
try {
|
|
80
|
+
saveConfig(config);
|
|
81
|
+
}
|
|
82
|
+
catch { /* Report the original failure. */ }
|
|
83
|
+
}
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
if (update.bridge && update.ownerPid)
|
|
87
|
+
bridges.add(update.ownerPid, update.bridge);
|
|
88
|
+
syncTray();
|
|
89
|
+
return status();
|
|
90
|
+
};
|
|
91
|
+
try {
|
|
92
|
+
handle = await startWebServer({
|
|
93
|
+
port: options.port, host: options.host, auth: options.auth,
|
|
94
|
+
backgroundPrompt: !resident && process.env.NEUTRINO_BACKGROUND_PROMPT === "1",
|
|
95
|
+
backgroundService: { token, onStop: shutdown, status, configure },
|
|
96
|
+
onAddressChange: () => { if (handle)
|
|
97
|
+
writeBackgroundStatus(status()); },
|
|
98
|
+
});
|
|
99
|
+
writeBackgroundStatus(status());
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
backgroundLog(`failed to start pid=${process.pid}: ${error instanceof Error ? error.message : String(error)}`);
|
|
103
|
+
if (handle)
|
|
104
|
+
await handle.close();
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
backgroundLog(`started pid=${process.pid} on ${handle.url} (residency ${resident ? "on" : "off"})`);
|
|
108
|
+
syncTray();
|
|
109
|
+
// No helper processes: polling pids must remain invisible on Windows. An
|
|
110
|
+
// orphaned, non-resident console lets an in-flight browser turn finish before
|
|
111
|
+
// exiting, so disabling residency also cannot throw away running work.
|
|
112
|
+
monitor = setInterval(() => {
|
|
113
|
+
try {
|
|
114
|
+
const live = new Set([...owners].filter(isProcessAlive));
|
|
115
|
+
if (live.size !== owners.size) {
|
|
116
|
+
for (const owner of owners)
|
|
117
|
+
if (!live.has(owner))
|
|
118
|
+
bridges.remove(owner);
|
|
119
|
+
owners = live;
|
|
120
|
+
writeBackgroundStatus(status());
|
|
121
|
+
}
|
|
122
|
+
if (!resident && owners.size === 0 && !hub.list().some(entry => entry.busy || entry.paused || entry.queued || entry.compressing || entry.approval || entry.reviewing)) {
|
|
123
|
+
shutdown();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
backgroundLog(`lifetime check failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
128
|
+
}
|
|
129
|
+
}, 500);
|
|
130
|
+
monitor.unref();
|
|
131
|
+
process.on("SIGINT", shutdown);
|
|
132
|
+
process.on("SIGTERM", shutdown);
|
|
133
|
+
if (process.platform !== "win32")
|
|
134
|
+
process.on("SIGHUP", () => { });
|
|
135
|
+
}
|
package/dist/web/session.js
CHANGED
|
@@ -27,8 +27,6 @@ import { writeCrashLog } from "../terminal/restore.js";
|
|
|
27
27
|
import { hub, liveApprovalDetails, } from "./hub.js";
|
|
28
28
|
/** Drop a resumed conversation nobody touched for this long. */
|
|
29
29
|
const IDLE_DISPOSE_MS = 30 * 60 * 1000;
|
|
30
|
-
/** Upper bound on tool round-trips inside a single turn. */
|
|
31
|
-
const MAX_TOOL_ROUNDS = 24;
|
|
32
30
|
/** Placeholder shown while a tool is still running. */
|
|
33
31
|
const TOOL_PENDING = "…";
|
|
34
32
|
/** A sub-agent gets the same tools minus delegation: one level, never a tree. */
|
|
@@ -710,9 +708,12 @@ export class WebSession {
|
|
|
710
708
|
const tools = this.mode === "pro" ? getProToolDefinitions() : getToolDefinitions();
|
|
711
709
|
const team = this.proTeam;
|
|
712
710
|
try {
|
|
713
|
-
|
|
711
|
+
while (true) {
|
|
714
712
|
if (token !== this.turnToken || this.paused)
|
|
715
713
|
return false;
|
|
714
|
+
// Match the terminal: long tasks can keep using tools until the model
|
|
715
|
+
// finishes, while older tool results are trimmed near the context limit.
|
|
716
|
+
apiMessages = this.trimApiMessages(apiMessages);
|
|
716
717
|
const client = team ? team.client("main_model") : new LLMClient(this.model, this.provider, this.intensity);
|
|
717
718
|
let text = "";
|
|
718
719
|
this.streamingContent = "";
|
|
@@ -825,9 +826,6 @@ export class WebSession {
|
|
|
825
826
|
}
|
|
826
827
|
this.save();
|
|
827
828
|
}
|
|
828
|
-
if (this.mode === "pro")
|
|
829
|
-
this.messages.push({ role: "assistant", content: "[Error: Pro main agent reached its tool round limit before completion.]" });
|
|
830
|
-
return false;
|
|
831
829
|
}
|
|
832
830
|
catch (err) {
|
|
833
831
|
if (token !== this.turnToken)
|
|
@@ -976,6 +974,25 @@ export class WebSession {
|
|
|
976
974
|
}
|
|
977
975
|
return contextCompressionThreshold(this.provider, this.model, this.mode);
|
|
978
976
|
}
|
|
977
|
+
/** Trim old tool results in a long turn without changing the saved history. */
|
|
978
|
+
trimApiMessages(apiMessages) {
|
|
979
|
+
const threshold = this.compressThreshold();
|
|
980
|
+
if (threshold <= 0 || this.lastPromptTokens < threshold)
|
|
981
|
+
return apiMessages;
|
|
982
|
+
const toolIdx = [];
|
|
983
|
+
for (let i = 0; i < apiMessages.length; i++) {
|
|
984
|
+
if (apiMessages[i].role === "tool")
|
|
985
|
+
toolIdx.push(i);
|
|
986
|
+
}
|
|
987
|
+
const dropCount = toolIdx.length - COMPACT_KEEP;
|
|
988
|
+
if (dropCount <= 0)
|
|
989
|
+
return apiMessages;
|
|
990
|
+
const copy = apiMessages.slice();
|
|
991
|
+
for (let k = 0; k < dropCount; k++) {
|
|
992
|
+
copy[toolIdx[k]] = { ...copy[toolIdx[k]], content: "[tool output omitted to save context]" };
|
|
993
|
+
}
|
|
994
|
+
return copy;
|
|
995
|
+
}
|
|
979
996
|
async autoCompress(token) {
|
|
980
997
|
const threshold = this.compressThreshold();
|
|
981
998
|
if (threshold <= 0 || this.lastPromptTokens < threshold)
|
package/dist/web/settings.js
CHANGED
|
@@ -62,6 +62,7 @@ export function getWebSettings(mode = "standard") {
|
|
|
62
62
|
approval_mode: normalizeApprovalMode(config.approval_mode),
|
|
63
63
|
web_console: config.web_console !== false,
|
|
64
64
|
web_lan: config.web_lan === true,
|
|
65
|
+
background: config.background === true,
|
|
65
66
|
},
|
|
66
67
|
};
|
|
67
68
|
}
|
|
@@ -150,6 +151,11 @@ export function updateWebSettings(value, mode = "standard") {
|
|
|
150
151
|
throw new Error("LAN access must be a boolean");
|
|
151
152
|
config.web_lan = val;
|
|
152
153
|
}
|
|
154
|
+
else if (key === "background") {
|
|
155
|
+
if (typeof val !== "boolean")
|
|
156
|
+
throw new Error("background residency must be a boolean");
|
|
157
|
+
config.background = val;
|
|
158
|
+
}
|
|
153
159
|
else if (key === "web_password") {
|
|
154
160
|
validateWebPassword(val);
|
|
155
161
|
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { resolveModelSelection } from "../config.js";
|
|
4
|
+
import { hub } from "./hub.js";
|
|
5
|
+
function sendJson(res, status, data) {
|
|
6
|
+
res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
|
|
7
|
+
res.end(JSON.stringify(data));
|
|
8
|
+
}
|
|
9
|
+
function authorized(req, token) {
|
|
10
|
+
const provided = req.headers.authorization?.replace(/^Bearer /, "") ?? "";
|
|
11
|
+
const expected = Buffer.from(token);
|
|
12
|
+
const actual = Buffer.from(provided);
|
|
13
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
14
|
+
}
|
|
15
|
+
async function readBody(req) {
|
|
16
|
+
const chunks = [];
|
|
17
|
+
let size = 0;
|
|
18
|
+
for await (const chunk of req) {
|
|
19
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
20
|
+
size += bytes.length;
|
|
21
|
+
if (size > 1_000_000)
|
|
22
|
+
throw new Error("request too large");
|
|
23
|
+
chunks.push(bytes);
|
|
24
|
+
}
|
|
25
|
+
const value = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
|
|
26
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
27
|
+
throw new Error("invalid request");
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
/** A private loopback bridge exposes only the terminal's live hub. */
|
|
31
|
+
export async function startTerminalBridge() {
|
|
32
|
+
const token = randomBytes(32).toString("hex");
|
|
33
|
+
const server = http.createServer((req, res) => {
|
|
34
|
+
void (async () => {
|
|
35
|
+
if (!authorized(req, token)) {
|
|
36
|
+
sendJson(res, 401, { error: "terminal token required" });
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (req.method === "GET" && req.url === "/snapshots") {
|
|
40
|
+
const snapshots = [];
|
|
41
|
+
for (const entry of hub.list()) {
|
|
42
|
+
const snapshot = hub.snapshot(entry.key);
|
|
43
|
+
if (snapshot)
|
|
44
|
+
snapshots.push({ mode: hub.has(entry.key, "pro") ? "pro" : "standard", snapshot });
|
|
45
|
+
}
|
|
46
|
+
sendJson(res, 200, snapshots);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (req.method !== "POST" || req.url !== "/action") {
|
|
50
|
+
sendJson(res, 404, { error: "not found" });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const input = await readBody(req);
|
|
54
|
+
const key = typeof input.key === "string" ? input.key : "";
|
|
55
|
+
const action = input.action;
|
|
56
|
+
const body = input.body && typeof input.body === "object" && !Array.isArray(input.body)
|
|
57
|
+
? input.body : {};
|
|
58
|
+
if (!hub.has(key)) {
|
|
59
|
+
sendJson(res, 404, { error: "unknown live session" });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (action === "pause")
|
|
63
|
+
hub.pause(key);
|
|
64
|
+
else if (action === "resume")
|
|
65
|
+
hub.resume(key);
|
|
66
|
+
else if (action === "message") {
|
|
67
|
+
if (typeof body.text !== "string" || !body.text.trim())
|
|
68
|
+
throw new Error("empty message");
|
|
69
|
+
hub.send(key, body.text);
|
|
70
|
+
}
|
|
71
|
+
else if (action === "approve") {
|
|
72
|
+
if (!hub.approve(key, body.approved === true)) {
|
|
73
|
+
sendJson(res, 409, { error: "no approval is pending" });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else if (action === "approval-mode") {
|
|
78
|
+
if (body.mode !== "request" && body.mode !== "auto")
|
|
79
|
+
throw new Error("mode must be request or auto");
|
|
80
|
+
if (!hub.setApprovalMode(key, body.mode)) {
|
|
81
|
+
sendJson(res, 409, { error: "this session cannot change approval mode" });
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else if (action === "model") {
|
|
86
|
+
const result = hub.configure(key, resolveModelSelection(body));
|
|
87
|
+
if (!result.ok) {
|
|
88
|
+
sendJson(res, 409, { error: result.error });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
else if (action === "pro-preset") {
|
|
93
|
+
if (typeof body.preset !== "string")
|
|
94
|
+
throw new Error("invalid Pro preset");
|
|
95
|
+
const result = hub.setProPreset(key, body.preset);
|
|
96
|
+
if (!result.ok) {
|
|
97
|
+
sendJson(res, 409, { error: result.error });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else if (action === "clear") {
|
|
102
|
+
if (!hub.clear(key)) {
|
|
103
|
+
sendJson(res, 409, { error: "this session cannot be cleared" });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
sendJson(res, 400, { error: "this session is owned by the terminal UI" });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
sendJson(res, 200, {
|
|
112
|
+
ok: true, snapshot: hub.snapshot(key),
|
|
113
|
+
...(action === "pause" ? { paused: true } : action === "resume" ? { paused: false } : {}),
|
|
114
|
+
...(action === "approve" ? { approved: body.approved === true } : {}),
|
|
115
|
+
});
|
|
116
|
+
})().catch(error => {
|
|
117
|
+
if (!res.headersSent)
|
|
118
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : "invalid request" });
|
|
119
|
+
else
|
|
120
|
+
res.end();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
await new Promise((resolve, reject) => {
|
|
124
|
+
server.once("error", reject);
|
|
125
|
+
server.listen(0, "127.0.0.1", () => { server.removeListener("error", reject); resolve(); });
|
|
126
|
+
});
|
|
127
|
+
server.unref();
|
|
128
|
+
const address = server.address();
|
|
129
|
+
if (!address || typeof address === "string")
|
|
130
|
+
throw new Error("could not open the terminal bridge");
|
|
131
|
+
return { url: `http://127.0.0.1:${address.port}`, token, close: () => { server.close(); } };
|
|
132
|
+
}
|
|
133
|
+
class RemoteTerminal {
|
|
134
|
+
bridge;
|
|
135
|
+
remoteKey;
|
|
136
|
+
mode;
|
|
137
|
+
current;
|
|
138
|
+
key = "";
|
|
139
|
+
constructor(bridge, remoteKey, mode, initial) {
|
|
140
|
+
this.bridge = bridge;
|
|
141
|
+
this.remoteKey = remoteKey;
|
|
142
|
+
this.mode = mode;
|
|
143
|
+
this.current = initial;
|
|
144
|
+
this.key = hub.register(this);
|
|
145
|
+
}
|
|
146
|
+
liveCwd() { return this.current.cwd; }
|
|
147
|
+
liveMeta() {
|
|
148
|
+
const { key: _key, cwd: _cwd, messages: _messages, streamingContent: _streaming, context: _context, plan: _plan, ...meta } = this.current;
|
|
149
|
+
return meta;
|
|
150
|
+
}
|
|
151
|
+
liveSnapshot(key) { return { ...this.current, key }; }
|
|
152
|
+
// The public server forwards actions through remoteAction, where failures can
|
|
153
|
+
// be returned to the browser rather than silently lost.
|
|
154
|
+
livePause() { }
|
|
155
|
+
liveResume() { }
|
|
156
|
+
liveSend(_text) { }
|
|
157
|
+
update(snapshot) {
|
|
158
|
+
this.current = snapshot;
|
|
159
|
+
hub.notify(this.key);
|
|
160
|
+
}
|
|
161
|
+
async remoteAction(action, body) {
|
|
162
|
+
try {
|
|
163
|
+
const response = await fetch(`${this.bridge.url}/action`, {
|
|
164
|
+
method: "POST", headers: { Authorization: `Bearer ${this.bridge.token}`, "Content-Type": "application/json" },
|
|
165
|
+
body: JSON.stringify({ key: this.remoteKey, action, body }), signal: AbortSignal.timeout(5000),
|
|
166
|
+
});
|
|
167
|
+
const data = await response.json();
|
|
168
|
+
if (data.snapshot) {
|
|
169
|
+
this.update(data.snapshot);
|
|
170
|
+
data.snapshot = this.liveSnapshot(this.key);
|
|
171
|
+
}
|
|
172
|
+
return { status: response.status, data };
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
return { status: 503, data: { error: "the terminal session is unavailable" } };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
close() { hub.unregister(this.key); }
|
|
179
|
+
}
|
|
180
|
+
/** Mirror each attached terminal's live sessions into the Web service. */
|
|
181
|
+
export class TerminalBridges {
|
|
182
|
+
entries = new Map();
|
|
183
|
+
timer = setInterval(() => { for (const owner of this.entries.keys())
|
|
184
|
+
void this.poll(owner); }, 250);
|
|
185
|
+
constructor() { this.timer.unref(); }
|
|
186
|
+
add(owner, bridge) {
|
|
187
|
+
this.remove(owner);
|
|
188
|
+
this.entries.set(owner, { bridge, sessions: new Map(), polling: false, failures: 0 });
|
|
189
|
+
void this.poll(owner);
|
|
190
|
+
}
|
|
191
|
+
remove(owner) {
|
|
192
|
+
const entry = this.entries.get(owner);
|
|
193
|
+
if (!entry)
|
|
194
|
+
return;
|
|
195
|
+
this.entries.delete(owner);
|
|
196
|
+
for (const session of entry.sessions.values())
|
|
197
|
+
session.close();
|
|
198
|
+
}
|
|
199
|
+
close() {
|
|
200
|
+
clearInterval(this.timer);
|
|
201
|
+
for (const owner of this.entries.keys())
|
|
202
|
+
this.remove(owner);
|
|
203
|
+
}
|
|
204
|
+
async poll(owner) {
|
|
205
|
+
const entry = this.entries.get(owner);
|
|
206
|
+
if (!entry || entry.polling)
|
|
207
|
+
return;
|
|
208
|
+
entry.polling = true;
|
|
209
|
+
try {
|
|
210
|
+
const response = await fetch(`${entry.bridge.url}/snapshots`, {
|
|
211
|
+
headers: { Authorization: `Bearer ${entry.bridge.token}` }, signal: AbortSignal.timeout(1500),
|
|
212
|
+
});
|
|
213
|
+
if (!response.ok)
|
|
214
|
+
throw new Error("terminal bridge failed");
|
|
215
|
+
const snapshots = await response.json();
|
|
216
|
+
if (!Array.isArray(snapshots) || this.entries.get(owner) !== entry)
|
|
217
|
+
return;
|
|
218
|
+
entry.failures = 0;
|
|
219
|
+
const live = new Set();
|
|
220
|
+
for (const { mode, snapshot } of snapshots) {
|
|
221
|
+
if (!snapshot || typeof snapshot.key !== "string" || (mode !== "standard" && mode !== "pro"))
|
|
222
|
+
continue;
|
|
223
|
+
live.add(snapshot.key);
|
|
224
|
+
const session = entry.sessions.get(snapshot.key);
|
|
225
|
+
if (session)
|
|
226
|
+
session.update(snapshot);
|
|
227
|
+
else
|
|
228
|
+
entry.sessions.set(snapshot.key, new RemoteTerminal(entry.bridge, snapshot.key, mode, snapshot));
|
|
229
|
+
}
|
|
230
|
+
for (const [key, session] of entry.sessions) {
|
|
231
|
+
if (!live.has(key)) {
|
|
232
|
+
session.close();
|
|
233
|
+
entry.sessions.delete(key);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// Keep snapshots over brief disconnects, but never expose a dead terminal
|
|
239
|
+
// indefinitely if its process stays alive after the bridge fails.
|
|
240
|
+
if (++entry.failures >= 4) {
|
|
241
|
+
for (const session of entry.sessions.values())
|
|
242
|
+
session.close();
|
|
243
|
+
entry.sessions.clear();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
entry.polling = false;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xingjisoft/neutrino",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "XINGJI Neutrino - a CLI coding agent in the terminal.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
20
|
"build": "tsc -p tsconfig.json",
|
|
21
|
-
"test": "npm run build && node scripts/test-paste.mjs && node scripts/test-mouse.mjs && node scripts/test-cursor.mjs && node scripts/test-selection.mjs && node scripts/test-drag-scroll.mjs && node scripts/test-selection-performance.mjs && node scripts/test-copy.mjs && node scripts/test-scrollbar.mjs && node scripts/test-render.mjs && node scripts/test-drift.mjs && node scripts/test-sanitize.mjs && node scripts/test-markdown.mjs && node scripts/test-wrap.mjs && node scripts/test-fetch.mjs && node scripts/test-compact.mjs && node scripts/test-history-fold.mjs && node scripts/test-web-history.mjs && node scripts/test-web-default.mjs && node scripts/test-turn-fold.mjs && node scripts/test-plan.mjs && node scripts/test-subagent.mjs && node scripts/test-web-settings.mjs && node scripts/test-model-intensity.mjs && node scripts/test-web-markdown.mjs && node scripts/test-web-i18n.mjs && node scripts/test-web-workspaces.mjs && node scripts/test-tools.mjs && node scripts/test-approval.mjs && node scripts/test-approval-review.mjs && node scripts/test-approval-ui.mjs && node scripts/test-web-approval-details.mjs && node scripts/test-approval-mode.mjs && node scripts/test-approval-context.mjs && node scripts/test-settings-render.mjs && node scripts/test-approval-flow.mjs && node scripts/test-web.mjs && node scripts/test-web-settings-http.mjs && node scripts/test-web-lan.mjs && node scripts/test-web-auth.mjs && node scripts/test-web-auth-ui.mjs && node scripts/test-web-pro.mjs && node scripts/test-pro-preflight.mjs && node scripts/test-pro-runtime.mjs && node scripts/test-pro-team.mjs && node scripts/test-pro-ui.mjs && node scripts/test-interrupt.mjs && node scripts/test-terminal-lifecycle.mjs && node scripts/test-persistence.mjs && node scripts/test-resilience.mjs && node scripts/test-approval-timeout.mjs && node scripts/test-approval-hardening.mjs && node scripts/test-approval-extreme.mjs && node scripts/test-approval-reason.mjs",
|
|
21
|
+
"test": "npm run build && node scripts/test-paste.mjs && node scripts/test-mouse.mjs && node scripts/test-cursor.mjs && node scripts/test-selection.mjs && node scripts/test-drag-scroll.mjs && node scripts/test-selection-performance.mjs && node scripts/test-copy.mjs && node scripts/test-scrollbar.mjs && node scripts/test-render.mjs && node scripts/test-drift.mjs && node scripts/test-sanitize.mjs && node scripts/test-markdown.mjs && node scripts/test-wrap.mjs && node scripts/test-fetch.mjs && node scripts/test-compact.mjs && node scripts/test-history-fold.mjs && node scripts/test-web-history.mjs && node scripts/test-session-migration.mjs && node scripts/test-web-session-migration.mjs && node scripts/test-web-default.mjs && node scripts/test-turn-fold.mjs && node scripts/test-long-turn.mjs && node scripts/test-plan.mjs && node scripts/test-subagent.mjs && node scripts/test-web-settings.mjs && node scripts/test-model-intensity.mjs && node scripts/test-background.mjs && node scripts/test-background-lifetime.mjs && node scripts/test-web-markdown.mjs && node scripts/test-web-i18n.mjs && node scripts/test-web-workspaces.mjs && node scripts/test-tools.mjs && node scripts/test-approval.mjs && node scripts/test-approval-review.mjs && node scripts/test-approval-ui.mjs && node scripts/test-web-approval-details.mjs && node scripts/test-approval-mode.mjs && node scripts/test-approval-context.mjs && node scripts/test-settings-render.mjs && node scripts/test-approval-flow.mjs && node scripts/test-web.mjs && node scripts/test-web-settings-http.mjs && node scripts/test-web-lan.mjs && node scripts/test-web-auth.mjs && node scripts/test-web-auth-ui.mjs && node scripts/test-web-pro.mjs && node scripts/test-pro-preflight.mjs && node scripts/test-pro-runtime.mjs && node scripts/test-pro-team.mjs && node scripts/test-pro-ui.mjs && node scripts/test-interrupt.mjs && node scripts/test-terminal-lifecycle.mjs && node scripts/test-persistence.mjs && node scripts/test-resilience.mjs && node scripts/test-approval-timeout.mjs && node scripts/test-approval-hardening.mjs && node scripts/test-approval-extreme.mjs && node scripts/test-approval-reason.mjs",
|
|
22
22
|
"prepare": "npm run build",
|
|
23
23
|
"web": "node dist/bin.js web"
|
|
24
24
|
},
|
package/web/README.md
CHANGED
|
@@ -19,11 +19,13 @@ A small, dependency-free single page app that is served by the Neutrino CLI at
|
|
|
19
19
|
access;
|
|
20
20
|
- switch the interface between English and 简体中文, instantly and persistently;
|
|
21
21
|
- open the same plan / token card as the terminal's `ⓘ`, clear a conversation,
|
|
22
|
-
delete a saved transcript, and review other agents'
|
|
22
|
+
delete a saved transcript, and review or migrate other agents' history;
|
|
23
23
|
- drive the running conversation: pause / resume the current turn and send new
|
|
24
24
|
messages (they queue while the agent is busy);
|
|
25
25
|
- pick up a saved conversation in the browser and keep chatting, even when no
|
|
26
|
-
terminal is running
|
|
26
|
+
terminal is running;
|
|
27
|
+
- run the console as a detached background service, so conversations started in
|
|
28
|
+
the browser survive closing the terminal and the CLI process.
|
|
27
29
|
|
|
28
30
|
Nothing needs to be built — these are plain `.html`, `.css` and `.js` files.
|
|
29
31
|
|
|
@@ -40,6 +42,10 @@ permanently in the terminal's **Settings → Web console**. LAN access can be
|
|
|
40
42
|
switched on and off at any time in the browser's **Settings → Web access**
|
|
41
43
|
without restarting anything.
|
|
42
44
|
|
|
45
|
+
To keep the console alive after the terminal exits, turn on **Settings →
|
|
46
|
+
Background residency** in either workspace, or use `neutrino background` — see
|
|
47
|
+
[Background residency](#background-residency).
|
|
48
|
+
|
|
43
49
|
Or serve only the saved history, without a terminal:
|
|
44
50
|
|
|
45
51
|
```sh
|
|
@@ -50,6 +56,44 @@ npm run web
|
|
|
50
56
|
|
|
51
57
|
Then open <http://127.0.0.1:8765>.
|
|
52
58
|
|
|
59
|
+
## Background residency
|
|
60
|
+
|
|
61
|
+
Background residency is off by default. It is a property of the process, so the
|
|
62
|
+
regular and Pro workspaces share it; changing it in either page (or in the
|
|
63
|
+
terminal's **Settings → Background residency**) applies immediately, including
|
|
64
|
+
to browser tasks already in progress.
|
|
65
|
+
|
|
66
|
+
With it on, closing the terminal or exiting the CLI does not interrupt browser
|
|
67
|
+
conversations. With it off, the console follows its attached terminals and lets
|
|
68
|
+
current browser tasks finish before it shuts down. The terminal's own
|
|
69
|
+
conversations remain local to the terminal. Terminal and standalone launches
|
|
70
|
+
share one Web console; a standalone launch reports a conflict if its requested
|
|
71
|
+
port, interface or password setting differs from the running console. A ready
|
|
72
|
+
service can be managed without a terminal:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
neutrino background start # detach the web console
|
|
76
|
+
neutrino background status # show its pid and address
|
|
77
|
+
neutrino background open # open it in the default browser
|
|
78
|
+
neutrino background stop # stop it
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
On Windows the service adds a notification-area icon using the same orbit badge
|
|
82
|
+
as the regular console's top-left logo. Click it to open the console; its menu
|
|
83
|
+
opens or stops the service. `--no-tray` suppresses it, and `NEUTRINO_NO_TRAY=1`
|
|
84
|
+
does the same for automation. The service records its pid, address and a random
|
|
85
|
+
management token in `~/.neutrino/background.json` (mode `0600`) and appends
|
|
86
|
+
startup/stop diagnostics to `~/.neutrino/background.log`. The token protects
|
|
87
|
+
`/api/background/ping` and `/api/background/stop`; it is never sent to a
|
|
88
|
+
browser.
|
|
89
|
+
|
|
90
|
+
When a console is mounted by the terminal and the setting is off, the first
|
|
91
|
+
browser to open it is offered **建议开启后台常驻,避免误触中断任务** with a
|
|
92
|
+
highlighted **开启** and **取消**. Either answer consumes the invitation for
|
|
93
|
+
that CLI run — a reload does not ask again. **开启** applies immediately to the
|
|
94
|
+
current browser tasks. The invitation is never shown when residency is already
|
|
95
|
+
on or by a bare `neutrino web` server.
|
|
96
|
+
|
|
53
97
|
## Using it from another device
|
|
54
98
|
|
|
55
99
|
The server binds to loopback by default. To open it from your phone or another
|
|
@@ -193,15 +237,16 @@ removed can still be continued.
|
|
|
193
237
|
The **Settings** (gear) button in the sidebar opens the interface language,
|
|
194
238
|
plan-first turns, the default approval mode, defaults for new conversations, the
|
|
195
239
|
context compression threshold, the approval reviewer model, sound alerts, the
|
|
196
|
-
extreme-risk automatic approval option, LAN access, and
|
|
197
|
-
settings explicitly; cancelling leaves the saved values
|
|
198
|
-
model changes are separate from these defaults. Models come
|
|
199
|
-
catalog as the terminal, and API keys stay on the server. Configure
|
|
200
|
-
in the terminal model screen first. Intensity uses the same
|
|
201
|
-
settings as the terminal, and both only offer the levels the
|
|
202
|
-
declares in `model.json`. The regular and [Pro](#pro-workspace)
|
|
203
|
-
their own copy of every setting here except the approval policy,
|
|
204
|
-
|
|
240
|
+
extreme-risk automatic approval option, background residency, LAN access, and
|
|
241
|
+
the Web password. Save settings explicitly; cancelling leaves the saved values
|
|
242
|
+
intact. Conversation model changes are separate from these defaults. Models come
|
|
243
|
+
from the same catalog as the terminal, and API keys stay on the server. Configure
|
|
244
|
+
missing keys in the terminal model screen first. Intensity uses the same
|
|
245
|
+
Low–Ultra sampling settings as the terminal, and both only offer the levels the
|
|
246
|
+
selected model declares in `model.json`. The regular and [Pro](#pro-workspace)
|
|
247
|
+
workspaces keep their own copy of every setting here except the approval policy,
|
|
248
|
+
the Web password, LAN access and background residency, which belong to the whole
|
|
249
|
+
process.
|
|
205
250
|
|
|
206
251
|
## New conversations and working directories
|
|
207
252
|
|
|
@@ -272,6 +317,14 @@ the complete transcript in the conversation pane with a `read-only` badge. The
|
|
|
272
317
|
sources are the same as the terminal's history browser; OpenCode requires Node.js
|
|
273
318
|
22.13+ because it reads its SQLite database through `node:sqlite`.
|
|
274
319
|
|
|
320
|
+
Click **Migrate session** at the bottom right of an opened transcript to convert
|
|
321
|
+
its readable user/assistant text into a new, saved Neutrino conversation. The
|
|
322
|
+
copy opens immediately in the same working directory, using the current
|
|
323
|
+
Neutrino model (or the current Pro preset on `/pro`). The source is unchanged.
|
|
324
|
+
Foreign system prompts, tool records and non-text attachments are not imported.
|
|
325
|
+
The button is disabled while copying; a failed migration leaves the source open
|
|
326
|
+
with an error so you can retry.
|
|
327
|
+
|
|
275
328
|
## Plan-first turns
|
|
276
329
|
|
|
277
330
|
A browser-owned turn is planned before it runs, exactly like a turn in the
|
|
@@ -328,13 +381,17 @@ to know whether a conversation is driven by the terminal or by the server.
|
|
|
328
381
|
| GET | `/api/fs?path=…` | Folder listing for the picker (folders only, roots, parent) |
|
|
329
382
|
| POST | `/api/fs/mkdir` | Create `{ "path": "…" }` and return the new path |
|
|
330
383
|
| POST | `/api/conversations` | Start `{ cwd, selection?, approval_mode? }`, returns `{ key, snapshot }` |
|
|
331
|
-
| GET | `/api/settings` | Model catalog (each model lists its supported intensities), languages, public settings,
|
|
332
|
-
| POST | `/api/settings` | Save validated `{ selection?, settings? }`, including `language`, `plan_first`, `approval_mode`, `web_lan` and `web_password` |
|
|
384
|
+
| GET | `/api/settings` | Model catalog (each model lists its supported intensities), languages, public settings, the live bind address (`web_host`, `web_urls`) and the background state (`background`, `background_running`, `background_url`, one-time `background_prompt`); no credentials |
|
|
385
|
+
| POST | `/api/settings` | Save validated `{ selection?, settings? }`, including `language`, `plan_first`, `approval_mode`, `web_lan`, `background` and `web_password` |
|
|
386
|
+
| POST | `/api/background/dismiss` | Consume the one-time background-residency invitation for this console run |
|
|
387
|
+
| GET | `/api/background/ping` | Management probe of a detached service; requires `Authorization: Bearer <background.json token>` |
|
|
388
|
+
| POST | `/api/background/stop` | Ask a detached service to shut down; same bearer token |
|
|
333
389
|
| GET | `/api/session/:id` | One saved transcript |
|
|
334
390
|
| POST | `/api/session/:id/continue` | Resume a transcript with optional `{ selection }`, returns `{ key }` |
|
|
335
391
|
| POST | `/api/session/:id/delete` | Delete a saved, non-running transcript |
|
|
336
392
|
| GET | `/api/agents/:agent/sessions?cwd=…` | Read-only sessions from Claude Code / Codex / OpenCode |
|
|
337
393
|
| GET | `/api/agents/:agent/sessions/:id` | Read-only messages of one other-agent session |
|
|
394
|
+
| POST | `/api/agents/:agent/sessions/:id/migrate` | Copy a source in `cwd` into a saved native conversation and return its live key; accepts optional `selection` or Pro `pro_preset` |
|
|
338
395
|
| GET | `/api/live/:key` | Snapshot of a live session |
|
|
339
396
|
| GET | `/api/live/:key/stream` | SSE stream of a live session |
|
|
340
397
|
| POST | `/api/live/:key/pause` | Pause the running turn |
|
|
@@ -351,7 +408,10 @@ to know whether a conversation is driven by the terminal or by the server.
|
|
|
351
408
|
|
|
352
409
|
Conversation and settings routes require the login cookie; see
|
|
353
410
|
[Authentication](#authentication). `OPTIONS` is answered unauthenticated so
|
|
354
|
-
preflights are not blocked, and it returns no data.
|
|
411
|
+
preflights are not blocked, and it returns no data. The `/api/background/*`
|
|
412
|
+
management routes are the exception: they exist only in a detached service and
|
|
413
|
+
authenticate with the bearer token from `background.json`, because the CLI and
|
|
414
|
+
the tray icon have no browser cookie.
|
|
355
415
|
|
|
356
416
|
### Approval requests
|
|
357
417
|
|