@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
|
@@ -0,0 +1,160 @@
|
|
|
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 { PRO_MODELS_URL, syncProModels } from "../pro/config.js";
|
|
7
|
+
import { checkForUpdate, UPDATE_PACKAGE } from "../update.js";
|
|
8
|
+
import { hub } from "./hub.js";
|
|
9
|
+
import { startWebServer } from "./server.js";
|
|
10
|
+
import { TerminalBridges } from "./terminalBridge.js";
|
|
11
|
+
/** The detached process owns browser conversations for their entire lifetime. */
|
|
12
|
+
export async function runWebService(options) {
|
|
13
|
+
const token = process.env.NEUTRINO_BACKGROUND_TOKEN?.trim() || randomBytes(32).toString("hex");
|
|
14
|
+
let resident = process.env.NEUTRINO_BACKGROUND_RESIDENT !== "0";
|
|
15
|
+
const owner = Number(process.env.NEUTRINO_BACKGROUND_OWNER);
|
|
16
|
+
let owners = new Set(Number.isSafeInteger(owner) && owner > 0 ? [owner] : []);
|
|
17
|
+
const startedAt = new Date().toISOString();
|
|
18
|
+
let handle = null;
|
|
19
|
+
let tray = null;
|
|
20
|
+
let monitor = null;
|
|
21
|
+
let stopping = false;
|
|
22
|
+
const bridges = new TerminalBridges();
|
|
23
|
+
const status = () => {
|
|
24
|
+
if (!handle)
|
|
25
|
+
throw new Error("the Web console is starting");
|
|
26
|
+
return {
|
|
27
|
+
pid: process.pid, port: handle.port, host: handle.host, url: handle.url,
|
|
28
|
+
auth_required: options.auth, token, started_at: startedAt,
|
|
29
|
+
resident, owner_pids: [...owners],
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
const syncTray = () => {
|
|
33
|
+
if (!resident) {
|
|
34
|
+
tray?.close();
|
|
35
|
+
tray = null;
|
|
36
|
+
}
|
|
37
|
+
else if (!tray && options.tray && handle) {
|
|
38
|
+
tray = startBackgroundTray({ url: handle.url, stopUrl: `${handle.url}/api/background/stop`, token, pid: process.pid });
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
const shutdown = () => {
|
|
42
|
+
if (stopping)
|
|
43
|
+
return;
|
|
44
|
+
stopping = true;
|
|
45
|
+
if (monitor)
|
|
46
|
+
clearInterval(monitor);
|
|
47
|
+
tray?.close();
|
|
48
|
+
bridges.close();
|
|
49
|
+
backgroundLog(`stopping pid=${process.pid}`);
|
|
50
|
+
clearBackgroundStatus(process.pid);
|
|
51
|
+
const finish = () => process.exit(0);
|
|
52
|
+
if (handle)
|
|
53
|
+
handle.close().finally(finish).catch(finish);
|
|
54
|
+
else
|
|
55
|
+
finish();
|
|
56
|
+
setTimeout(finish, 3000).unref();
|
|
57
|
+
};
|
|
58
|
+
const configure = (update) => {
|
|
59
|
+
if (stopping)
|
|
60
|
+
throw new Error("the Web console is shutting down");
|
|
61
|
+
if (update.ownerPid !== undefined && !isProcessAlive(update.ownerPid)) {
|
|
62
|
+
throw new Error("the terminal that owns this console has already exited");
|
|
63
|
+
}
|
|
64
|
+
const previousResident = resident;
|
|
65
|
+
const previousOwners = owners;
|
|
66
|
+
const config = loadConfig();
|
|
67
|
+
const changed = update.resident !== undefined;
|
|
68
|
+
if (changed)
|
|
69
|
+
saveConfig({ ...config, background: update.resident });
|
|
70
|
+
resident = update.resident ?? resident;
|
|
71
|
+
owners = new Set(owners);
|
|
72
|
+
if (update.ownerPid !== undefined)
|
|
73
|
+
owners.add(update.ownerPid);
|
|
74
|
+
try {
|
|
75
|
+
writeBackgroundStatus(status());
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
resident = previousResident;
|
|
79
|
+
owners = previousOwners;
|
|
80
|
+
if (changed) {
|
|
81
|
+
try {
|
|
82
|
+
saveConfig(config);
|
|
83
|
+
}
|
|
84
|
+
catch { /* Report the original failure. */ }
|
|
85
|
+
}
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
if (update.bridge && update.ownerPid)
|
|
89
|
+
bridges.add(update.ownerPid, update.bridge);
|
|
90
|
+
syncTray();
|
|
91
|
+
return status();
|
|
92
|
+
};
|
|
93
|
+
// Nothing ships with the package any more: reconcile the presets with the
|
|
94
|
+
// official copy now, so the first browser to open /pro already finds them and
|
|
95
|
+
// an outdated local set is replaced. A failure is logged and reported there,
|
|
96
|
+
// so this never holds up the console itself.
|
|
97
|
+
void syncProModels().then((result) => {
|
|
98
|
+
if (result.status === "updated")
|
|
99
|
+
backgroundLog(`pro presets: updated from ${PRO_MODELS_URL}`);
|
|
100
|
+
else if (result.status === "kept")
|
|
101
|
+
backgroundLog(`pro presets: kept the local copy (${result.detail})`);
|
|
102
|
+
}).catch((error) => {
|
|
103
|
+
backgroundLog(`pro presets: ${error instanceof Error ? error.message : String(error)}`);
|
|
104
|
+
});
|
|
105
|
+
// The registry lookup runs alongside the listener so a slow or unreachable
|
|
106
|
+
// registry never delays the console; the pages read the answer from the
|
|
107
|
+
// settings payload. Failures are logged, as with the presets above.
|
|
108
|
+
void checkForUpdate().then((result) => {
|
|
109
|
+
if (result.hasUpdate) {
|
|
110
|
+
backgroundLog(`${UPDATE_PACKAGE} ${result.latestVersion} is available (running ${result.currentVersion})`);
|
|
111
|
+
}
|
|
112
|
+
else if (result.error) {
|
|
113
|
+
backgroundLog(`update check: ${result.error}`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
try {
|
|
117
|
+
handle = await startWebServer({
|
|
118
|
+
port: options.port, host: options.host, auth: options.auth,
|
|
119
|
+
backgroundPrompt: !resident && process.env.NEUTRINO_BACKGROUND_PROMPT === "1",
|
|
120
|
+
backgroundService: { token, onStop: shutdown, status, configure },
|
|
121
|
+
onAddressChange: () => { if (handle)
|
|
122
|
+
writeBackgroundStatus(status()); },
|
|
123
|
+
});
|
|
124
|
+
writeBackgroundStatus(status());
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
backgroundLog(`failed to start pid=${process.pid}: ${error instanceof Error ? error.message : String(error)}`);
|
|
128
|
+
if (handle)
|
|
129
|
+
await handle.close();
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
backgroundLog(`started pid=${process.pid} on ${handle.url} (residency ${resident ? "on" : "off"})`);
|
|
133
|
+
syncTray();
|
|
134
|
+
// No helper processes: polling pids must remain invisible on Windows. An
|
|
135
|
+
// orphaned, non-resident console lets an in-flight browser turn finish before
|
|
136
|
+
// exiting, so disabling residency also cannot throw away running work.
|
|
137
|
+
monitor = setInterval(() => {
|
|
138
|
+
try {
|
|
139
|
+
const live = new Set([...owners].filter(isProcessAlive));
|
|
140
|
+
if (live.size !== owners.size) {
|
|
141
|
+
for (const owner of owners)
|
|
142
|
+
if (!live.has(owner))
|
|
143
|
+
bridges.remove(owner);
|
|
144
|
+
owners = live;
|
|
145
|
+
writeBackgroundStatus(status());
|
|
146
|
+
}
|
|
147
|
+
if (!resident && owners.size === 0 && !hub.list().some(entry => entry.busy || entry.paused || entry.queued || entry.compressing || entry.approval || entry.reviewing)) {
|
|
148
|
+
shutdown();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
backgroundLog(`lifetime check failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
153
|
+
}
|
|
154
|
+
}, 500);
|
|
155
|
+
monitor.unref();
|
|
156
|
+
process.on("SIGINT", shutdown);
|
|
157
|
+
process.on("SIGTERM", shutdown);
|
|
158
|
+
if (process.platform !== "win32")
|
|
159
|
+
process.on("SIGHUP", () => { });
|
|
160
|
+
}
|
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. */
|
|
@@ -264,6 +262,7 @@ export class WebSession {
|
|
|
264
262
|
} } : {}),
|
|
265
263
|
sessionId: this.sessionId,
|
|
266
264
|
title: this.title(),
|
|
265
|
+
empty: this.messages.length === 0 && !this.streamingContent,
|
|
267
266
|
provider: this.provider,
|
|
268
267
|
model: this.model.name,
|
|
269
268
|
intensity: this.intensity,
|
|
@@ -710,9 +709,12 @@ export class WebSession {
|
|
|
710
709
|
const tools = this.mode === "pro" ? getProToolDefinitions() : getToolDefinitions();
|
|
711
710
|
const team = this.proTeam;
|
|
712
711
|
try {
|
|
713
|
-
|
|
712
|
+
while (true) {
|
|
714
713
|
if (token !== this.turnToken || this.paused)
|
|
715
714
|
return false;
|
|
715
|
+
// Match the terminal: long tasks can keep using tools until the model
|
|
716
|
+
// finishes, while older tool results are trimmed near the context limit.
|
|
717
|
+
apiMessages = this.trimApiMessages(apiMessages);
|
|
716
718
|
const client = team ? team.client("main_model") : new LLMClient(this.model, this.provider, this.intensity);
|
|
717
719
|
let text = "";
|
|
718
720
|
this.streamingContent = "";
|
|
@@ -825,9 +827,6 @@ export class WebSession {
|
|
|
825
827
|
}
|
|
826
828
|
this.save();
|
|
827
829
|
}
|
|
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
830
|
}
|
|
832
831
|
catch (err) {
|
|
833
832
|
if (token !== this.turnToken)
|
|
@@ -976,6 +975,25 @@ export class WebSession {
|
|
|
976
975
|
}
|
|
977
976
|
return contextCompressionThreshold(this.provider, this.model, this.mode);
|
|
978
977
|
}
|
|
978
|
+
/** Trim old tool results in a long turn without changing the saved history. */
|
|
979
|
+
trimApiMessages(apiMessages) {
|
|
980
|
+
const threshold = this.compressThreshold();
|
|
981
|
+
if (threshold <= 0 || this.lastPromptTokens < threshold)
|
|
982
|
+
return apiMessages;
|
|
983
|
+
const toolIdx = [];
|
|
984
|
+
for (let i = 0; i < apiMessages.length; i++) {
|
|
985
|
+
if (apiMessages[i].role === "tool")
|
|
986
|
+
toolIdx.push(i);
|
|
987
|
+
}
|
|
988
|
+
const dropCount = toolIdx.length - COMPACT_KEEP;
|
|
989
|
+
if (dropCount <= 0)
|
|
990
|
+
return apiMessages;
|
|
991
|
+
const copy = apiMessages.slice();
|
|
992
|
+
for (let k = 0; k < dropCount; k++) {
|
|
993
|
+
copy[toolIdx[k]] = { ...copy[toolIdx[k]], content: "[tool output omitted to save context]" };
|
|
994
|
+
}
|
|
995
|
+
return copy;
|
|
996
|
+
}
|
|
979
997
|
async autoCompress(token) {
|
|
980
998
|
const threshold = this.compressThreshold();
|
|
981
999
|
if (threshold <= 0 || this.lastPromptTokens < threshold)
|
package/dist/web/settings.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { DEFAULT_CONFIG, INTENSITY_LEVELS, WEB_LANGUAGES, contextWindow, intensityForModel, isContextWindow, loadConfig, loadModels, modelContextWindow, modelContextWindowKey, modelIntensities, normalizeApprovalMode, normalizeIntensity, normalizeWebLanguage, resolveModelSelection, saveConfig, saveModels, } from "../config.js";
|
|
2
2
|
import { hashWebPassword, validateWebPassword } from "./auth.js";
|
|
3
3
|
import { defaultProPreset, loadProModels, proMaxRecursionDepth, requireProPreset } from "../pro/config.js";
|
|
4
|
+
import { updateStatus } from "../update.js";
|
|
4
5
|
/**
|
|
5
6
|
* Public settings use a whitelist: API keys and cached credentials stay on disk.
|
|
6
7
|
*
|
|
@@ -31,7 +32,15 @@ export function getWebSettings(mode = "standard") {
|
|
|
31
32
|
? catalog.providers.find((p) => p.name === provider.name)?.models.find((m) => m.name === model.name) ?? null
|
|
32
33
|
: null;
|
|
33
34
|
const intensity = intensityForModel(configuredModel, config.selected_intensity ?? config.default_intensity, config.default_intensity);
|
|
35
|
+
// Read-only runtime state for the update notice: which version runs, which
|
|
36
|
+
// one is published, and whether the two differ. Never persisted.
|
|
37
|
+
const update = updateStatus();
|
|
34
38
|
return {
|
|
39
|
+
update: {
|
|
40
|
+
current_version: update.currentVersion,
|
|
41
|
+
latest_version: update.latestVersion,
|
|
42
|
+
has_update: update.hasUpdate,
|
|
43
|
+
},
|
|
35
44
|
...(mode === "pro" ? { pro: {
|
|
36
45
|
presets: loadProModels().tier.map((preset) => ({
|
|
37
46
|
name: preset.name, chinese_name: preset.chinese_name,
|
|
@@ -62,6 +71,7 @@ export function getWebSettings(mode = "standard") {
|
|
|
62
71
|
approval_mode: normalizeApprovalMode(config.approval_mode),
|
|
63
72
|
web_console: config.web_console !== false,
|
|
64
73
|
web_lan: config.web_lan === true,
|
|
74
|
+
background: config.background === true,
|
|
65
75
|
},
|
|
66
76
|
};
|
|
67
77
|
}
|
|
@@ -150,6 +160,11 @@ export function updateWebSettings(value, mode = "standard") {
|
|
|
150
160
|
throw new Error("LAN access must be a boolean");
|
|
151
161
|
config.web_lan = val;
|
|
152
162
|
}
|
|
163
|
+
else if (key === "background") {
|
|
164
|
+
if (typeof val !== "boolean")
|
|
165
|
+
throw new Error("background residency must be a boolean");
|
|
166
|
+
config.background = val;
|
|
167
|
+
}
|
|
153
168
|
else if (key === "web_password") {
|
|
154
169
|
validateWebPassword(val);
|
|
155
170
|
}
|
|
@@ -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.
|
|
3
|
+
"version": "0.5.0",
|
|
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-web-mobile-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-update.mjs && node scripts/test-update-ui.mjs && node scripts/test-update-web.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-web-sidebar-status.mjs && node scripts/test-web-seen.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
|
},
|