@xingjisoft/neutrino 0.5.1 → 0.6.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 +1 -0
- package/assets/fast.txt +18 -0
- package/assets/pro/common.txt +4 -0
- package/assets/pro/main_model.txt +2 -0
- package/assets/pro/plan_model.txt +3 -1
- package/assets/prompt.txt +9 -0
- package/assets/ptc.txt +27 -0
- package/assets/subagent.txt +22 -1
- package/dist/app.js +2 -0
- package/dist/config.js +30 -1
- package/dist/llm/client.js +61 -8
- package/dist/llm/codeExecutor.js +239 -0
- package/dist/llm/codemode.js +164 -0
- package/dist/llm/context.js +97 -0
- package/dist/llm/plan.js +97 -9
- package/dist/llm/risk.js +10 -0
- package/dist/llm/runtime.js +301 -0
- package/dist/llm/search.js +426 -0
- package/dist/llm/searchNetwork.js +129 -0
- package/dist/llm/subagent.js +236 -112
- package/dist/llm/tools.js +101 -4
- package/dist/pro/agents.js +49 -19
- package/dist/pro/preflight.js +24 -2
- package/dist/screens/chat.js +299 -27
- package/dist/update.js +8 -2
- package/dist/updateWatch.js +76 -0
- package/dist/web/hub.js +9 -0
- package/dist/web/server.js +183 -13
- package/dist/web/service.js +22 -0
- package/dist/web/session.js +327 -62
- package/dist/web/terminalBridge.js +10 -1
- package/package.json +42 -37
- package/web/app.js +347 -29
- package/web/i18n.js +27 -0
- package/web/index.html +465 -442
- package/web/settings.js +31 -1
- package/web/style.css +3581 -3473
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The periodic update check.
|
|
3
|
+
*
|
|
4
|
+
* A Web console can stay open for days, so the start-up lookup alone would
|
|
5
|
+
* never tell its windows about a release published afterwards. This module asks
|
|
6
|
+
* the registry again on a timer, in the process that serves those windows, and
|
|
7
|
+
* hands a release it has not seen before to whoever is listening: the console
|
|
8
|
+
* forwards it to every open browser (GET /api/update/events) and the first-open
|
|
9
|
+
* dialog reads the same answer.
|
|
10
|
+
*/
|
|
11
|
+
import { checkForUpdate, updateStatus } from "./update.js";
|
|
12
|
+
/** How often the background service asks the registry again. */
|
|
13
|
+
export const UPDATE_CHECK_INTERVAL_MS = 30 * 60 * 1000;
|
|
14
|
+
const listeners = new Set();
|
|
15
|
+
let running = null;
|
|
16
|
+
/**
|
|
17
|
+
* Listen for a release the periodic check finds. Returns the unsubscribe
|
|
18
|
+
* function.
|
|
19
|
+
*
|
|
20
|
+
* Only news is announced: a version this process already knows — from the
|
|
21
|
+
* start-up lookup, the settings button or an earlier tick — is not sent again,
|
|
22
|
+
* so the dialogs cannot reopen every half hour for a release the user has
|
|
23
|
+
* already declined.
|
|
24
|
+
*/
|
|
25
|
+
export function subscribeUpdateAnnouncements(listener) {
|
|
26
|
+
listeners.add(listener);
|
|
27
|
+
return () => {
|
|
28
|
+
listeners.delete(listener);
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Start asking the registry every `intervalMs` (30 minutes by default).
|
|
33
|
+
*
|
|
34
|
+
* Idempotent, and the returned function stops the timer and leaves the module
|
|
35
|
+
* ready to start again. Checks run off the timer rather than at start-up: both
|
|
36
|
+
* the console and the terminal UI already look once when they start, and a
|
|
37
|
+
* second immediate lookup would only spend npm calls on the same answer.
|
|
38
|
+
*/
|
|
39
|
+
export function startUpdateWatch(options = {}) {
|
|
40
|
+
if (running)
|
|
41
|
+
return running;
|
|
42
|
+
const timer = setInterval(() => {
|
|
43
|
+
// The remembered answer is read before the lookup, because the lookup itself
|
|
44
|
+
// updates it: what matters is whether this check is what discovered the
|
|
45
|
+
// release.
|
|
46
|
+
const known = updateStatus().latestVersion;
|
|
47
|
+
// `refresh` is what makes a timer useful: without it checkForUpdate keeps
|
|
48
|
+
// returning the answer this process already has and would never see a new
|
|
49
|
+
// release.
|
|
50
|
+
void checkForUpdate({ refresh: true }).then((check) => {
|
|
51
|
+
try {
|
|
52
|
+
options.onCheck?.(check);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// A failing log must not stop the check that follows it.
|
|
56
|
+
}
|
|
57
|
+
if (!check.hasUpdate || check.latestVersion === known)
|
|
58
|
+
return;
|
|
59
|
+
for (const listener of [...listeners]) {
|
|
60
|
+
try {
|
|
61
|
+
listener(check);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// One broken window must not starve the others.
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}, options.intervalMs ?? UPDATE_CHECK_INTERVAL_MS);
|
|
69
|
+
// A check in flight must never keep a shutting-down process alive.
|
|
70
|
+
timer.unref?.();
|
|
71
|
+
running = () => {
|
|
72
|
+
clearInterval(timer);
|
|
73
|
+
running = null;
|
|
74
|
+
};
|
|
75
|
+
return running;
|
|
76
|
+
}
|
package/dist/web/hub.js
CHANGED
|
@@ -189,6 +189,15 @@ class LiveHub {
|
|
|
189
189
|
this.notify(key);
|
|
190
190
|
return result;
|
|
191
191
|
}
|
|
192
|
+
setAgentMode(key, mode) {
|
|
193
|
+
const entry = this.entries.get(key);
|
|
194
|
+
if (!entry?.agent.liveSetAgentMode)
|
|
195
|
+
return { ok: false, error: "this session cannot change agent mode" };
|
|
196
|
+
const result = entry.agent.liveSetAgentMode(mode);
|
|
197
|
+
if (result.ok)
|
|
198
|
+
this.notify(key);
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
192
201
|
setApprovalMode(key, mode) {
|
|
193
202
|
const entry = this.entries.get(key);
|
|
194
203
|
if (!entry?.agent.liveSetApprovalMode)
|
package/dist/web/server.js
CHANGED
|
@@ -17,7 +17,7 @@ import os from "node:os";
|
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
import { timingSafeEqual } from "node:crypto";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
|
-
import { deleteSession, listSessions, loadConfig, normalizeApprovalMode, resolveModelSelection, saveConfig, sessionDir, } from "../config.js";
|
|
20
|
+
import { deleteSession, listSessions, loadConfig, normalizeApprovalMode, isAgentMode, resolveModelSelection, saveConfig, sessionDir, } from "../config.js";
|
|
21
21
|
import { getWebSettings, updateWebSettings } from "./settings.js";
|
|
22
22
|
import { isSeenIdentity, loadSeen, updateSeen, SEEN_LIMIT } from "./clientState.js";
|
|
23
23
|
import { WebAuth } from "./auth.js";
|
|
@@ -29,7 +29,8 @@ import { defaultProPreset, ensureProModels, requireProPreset } from "../pro/conf
|
|
|
29
29
|
import { listAgentSessions, loadAgentMessages, loadAgentSession, safeAgentSessionId } from "../agents.js";
|
|
30
30
|
import { migrateAgentSession, SessionMigrationError } from "../migration.js";
|
|
31
31
|
import { runningBackground } from "../background.js";
|
|
32
|
-
import { MANUAL_UPDATE_COMMAND, installUpdate, updateStatus } from "../update.js";
|
|
32
|
+
import { MANUAL_UPDATE_COMMAND, checkForUpdate, installUpdate, updateStatus } from "../update.js";
|
|
33
|
+
import { subscribeUpdateAnnouncements } from "../updateWatch.js";
|
|
33
34
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
34
35
|
// dist/web -> package root (mirrors the same trick used in config.ts).
|
|
35
36
|
const PKG_ROOT = path.resolve(__dirname, "..", "..");
|
|
@@ -537,18 +538,20 @@ function serveStatic(res, pathname) {
|
|
|
537
538
|
sendText(res, 403, "Forbidden");
|
|
538
539
|
return;
|
|
539
540
|
}
|
|
540
|
-
|
|
541
|
-
|
|
541
|
+
// Send an immutable snapshot: the length and body must agree even while
|
|
542
|
+
// the development checkout changes or a filesystem stream adapter retries.
|
|
543
|
+
fs.readFile(filePath, (err, contents) => {
|
|
544
|
+
if (err) {
|
|
542
545
|
sendText(res, 404, "Not found");
|
|
543
546
|
return;
|
|
544
547
|
}
|
|
545
548
|
const type = MIME_TYPES[path.extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
546
549
|
res.writeHead(200, {
|
|
547
550
|
"Content-Type": type,
|
|
548
|
-
"Content-Length":
|
|
551
|
+
"Content-Length": contents.length,
|
|
549
552
|
"Cache-Control": "no-cache",
|
|
550
553
|
});
|
|
551
|
-
|
|
554
|
+
res.end(contents);
|
|
552
555
|
});
|
|
553
556
|
}
|
|
554
557
|
function streamLive(key, req, res) {
|
|
@@ -604,7 +607,88 @@ function streamLive(key, req, res) {
|
|
|
604
607
|
// Without this, a client reset during a write is an uncaught 'error' event.
|
|
605
608
|
res.on("error", cleanup);
|
|
606
609
|
}
|
|
607
|
-
|
|
610
|
+
/**
|
|
611
|
+
* The windows watching for update announcements. The console owns one of these
|
|
612
|
+
* so closing it can end those streams: a browser reconnects an EventSource by
|
|
613
|
+
* itself, so an open tab would otherwise keep `close` waiting for a socket that
|
|
614
|
+
* will never go quiet on its own.
|
|
615
|
+
*/
|
|
616
|
+
function createUpdateStreams() {
|
|
617
|
+
const open = new Set();
|
|
618
|
+
return {
|
|
619
|
+
add(res) {
|
|
620
|
+
open.add(res);
|
|
621
|
+
},
|
|
622
|
+
remove(res) {
|
|
623
|
+
open.delete(res);
|
|
624
|
+
},
|
|
625
|
+
closeAll() {
|
|
626
|
+
for (const res of [...open]) {
|
|
627
|
+
open.delete(res);
|
|
628
|
+
try {
|
|
629
|
+
res.end();
|
|
630
|
+
}
|
|
631
|
+
catch { /* the window is already gone */ }
|
|
632
|
+
}
|
|
633
|
+
},
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* The periodic check's findings, as an event stream. A window that opens after
|
|
638
|
+
* a release was already found still has to learn about it, so the answer this
|
|
639
|
+
* process holds goes out first: the same status /api/settings hands the
|
|
640
|
+
* first-open dialog, with the same fields the manual check returns.
|
|
641
|
+
*
|
|
642
|
+
* Both workspaces read it. An update belongs to the process, not to a
|
|
643
|
+
* conversation, so a Pro tab and a standard tab are told about the same release.
|
|
644
|
+
*/
|
|
645
|
+
function streamUpdateEvents(req, res, streams) {
|
|
646
|
+
res.writeHead(200, {
|
|
647
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
648
|
+
"Cache-Control": "no-cache, no-transform",
|
|
649
|
+
Connection: "keep-alive",
|
|
650
|
+
"X-Accel-Buffering": "no",
|
|
651
|
+
});
|
|
652
|
+
// Same guard as streamLive: a browser that closed or reloaded the tab leaves
|
|
653
|
+
// a socket writes fail on, and an unhandled 'error' event would stop the process.
|
|
654
|
+
const write = (chunk) => {
|
|
655
|
+
if (res.destroyed || res.writableEnded)
|
|
656
|
+
return false;
|
|
657
|
+
try {
|
|
658
|
+
res.write(chunk);
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
catch {
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
const send = (status, error = "") => {
|
|
666
|
+
write(`event: update\ndata: ${JSON.stringify({
|
|
667
|
+
has_update: status.hasUpdate,
|
|
668
|
+
current_version: status.currentVersion,
|
|
669
|
+
latest_version: status.latestVersion,
|
|
670
|
+
error,
|
|
671
|
+
})}\n\n`);
|
|
672
|
+
};
|
|
673
|
+
write("retry: 2000\n\n");
|
|
674
|
+
send(updateStatus());
|
|
675
|
+
streams.add(res);
|
|
676
|
+
const unsubscribe = subscribeUpdateAnnouncements((check) => send(check, check.error ?? ""));
|
|
677
|
+
const keepAlive = setInterval(() => {
|
|
678
|
+
if (!write(": ping\n\n"))
|
|
679
|
+
cleanup();
|
|
680
|
+
}, 25_000);
|
|
681
|
+
keepAlive.unref?.();
|
|
682
|
+
function cleanup() {
|
|
683
|
+
clearInterval(keepAlive);
|
|
684
|
+
unsubscribe();
|
|
685
|
+
streams.remove(res);
|
|
686
|
+
}
|
|
687
|
+
req.on("close", cleanup);
|
|
688
|
+
req.on("error", cleanup);
|
|
689
|
+
res.on("error", cleanup);
|
|
690
|
+
}
|
|
691
|
+
async function handleApi(req, res, url, auth, mode, pathname, binding, updates) {
|
|
608
692
|
if (!pathname.startsWith("/api/"))
|
|
609
693
|
return false;
|
|
610
694
|
// The Pro presets are not bundled either: the first Pro request makes sure they
|
|
@@ -748,6 +832,39 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
748
832
|
}
|
|
749
833
|
return true;
|
|
750
834
|
}
|
|
835
|
+
// The 30-minute background check announces a release here, so a window that
|
|
836
|
+
// stays open learns about it without reloading. Both workspaces open it.
|
|
837
|
+
if (req.method === "GET" && pathname === "/api/update/events") {
|
|
838
|
+
streamUpdateEvents(req, res, updates);
|
|
839
|
+
return true;
|
|
840
|
+
}
|
|
841
|
+
// The settings page's "Check for updates" button. Start-up reads the registry
|
|
842
|
+
// once and the Web console is served from that answer, so a manual check has
|
|
843
|
+
// to ask again; otherwise the button would keep showing whatever was true
|
|
844
|
+
// when the process started. Like the install below it runs npm on this
|
|
845
|
+
// machine, so it sits behind the login cookie and the same-origin check.
|
|
846
|
+
//
|
|
847
|
+
// The answer is a status, not an HTTP failure: an unreachable registry leaves
|
|
848
|
+
// `error` set on a 200 response, which is what the browser shows next to the
|
|
849
|
+
// button. A failure also changes nothing — the last good answer stays.
|
|
850
|
+
if (pathname === "/api/update/check") {
|
|
851
|
+
if (req.method !== "POST") {
|
|
852
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
853
|
+
return true;
|
|
854
|
+
}
|
|
855
|
+
if (!isSameOrigin(req)) {
|
|
856
|
+
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
857
|
+
return true;
|
|
858
|
+
}
|
|
859
|
+
const check = await checkForUpdate({ refresh: true });
|
|
860
|
+
sendJson(res, 200, {
|
|
861
|
+
has_update: check.hasUpdate,
|
|
862
|
+
current_version: check.currentVersion,
|
|
863
|
+
latest_version: check.latestVersion,
|
|
864
|
+
error: check.error ?? "",
|
|
865
|
+
});
|
|
866
|
+
return true;
|
|
867
|
+
}
|
|
751
868
|
// The update prompt's confirm button: npm runs on this machine, so the route
|
|
752
869
|
// sits behind the login cookie and the same-origin check like every other
|
|
753
870
|
// mutation, and only a release the registry announced can be installed.
|
|
@@ -924,7 +1041,10 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
924
1041
|
}
|
|
925
1042
|
approvalMode = normalizeApprovalMode(input.approval_mode);
|
|
926
1043
|
}
|
|
927
|
-
|
|
1044
|
+
if (input.agent_mode !== undefined && (!isAgentMode(input.agent_mode) || (mode === "pro" && input.agent_mode !== "standard"))) {
|
|
1045
|
+
throw new ApiError(400, "invalid agent mode", "invalid agent mode");
|
|
1046
|
+
}
|
|
1047
|
+
const session = createWebSession(cwd, selection, approvalMode, mode, preset, input.agent_mode);
|
|
928
1048
|
const key = session.start();
|
|
929
1049
|
webSessions.set(key, session);
|
|
930
1050
|
recordWorkspace(cwd, mode);
|
|
@@ -1064,6 +1184,7 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
1064
1184
|
}
|
|
1065
1185
|
let selection;
|
|
1066
1186
|
let preset;
|
|
1187
|
+
let agentMode;
|
|
1067
1188
|
try {
|
|
1068
1189
|
const body = await readJsonBody(req);
|
|
1069
1190
|
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
@@ -1074,6 +1195,12 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
1074
1195
|
const requestedPreset = body.pro_preset;
|
|
1075
1196
|
if (requestedPreset !== undefined && mode === "pro")
|
|
1076
1197
|
preset = requireProPreset(requestedPreset);
|
|
1198
|
+
const requestedMode = body.agent_mode;
|
|
1199
|
+
if (requestedMode !== undefined) {
|
|
1200
|
+
if (!isAgentMode(requestedMode) || (mode === "pro" && requestedMode !== "standard"))
|
|
1201
|
+
throw new Error("invalid agent mode");
|
|
1202
|
+
agentMode = requestedMode;
|
|
1203
|
+
}
|
|
1077
1204
|
}
|
|
1078
1205
|
catch (err) {
|
|
1079
1206
|
sendJson(res, 400, { error: err instanceof Error ? err.message : "invalid model selection" });
|
|
@@ -1083,6 +1210,22 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
1083
1210
|
const existing = hub.list(mode).find((entry) => entry.sessionId === id);
|
|
1084
1211
|
if (existing) {
|
|
1085
1212
|
const remote = hub.remoteAction(existing.key);
|
|
1213
|
+
if (agentMode !== undefined && mode !== "pro") {
|
|
1214
|
+
if (remote) {
|
|
1215
|
+
const result = await remote("agent-mode", { mode: agentMode });
|
|
1216
|
+
if (result.status !== 200) {
|
|
1217
|
+
sendJson(res, result.status, result.data);
|
|
1218
|
+
return true;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
else {
|
|
1222
|
+
const changed = hub.setAgentMode(existing.key, agentMode);
|
|
1223
|
+
if (!changed.ok) {
|
|
1224
|
+
sendJson(res, 409, { error: changed.error });
|
|
1225
|
+
return true;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1086
1229
|
if (remote && preset !== undefined) {
|
|
1087
1230
|
const result = await remote("pro-preset", { preset });
|
|
1088
1231
|
if (result.status !== 200) {
|
|
@@ -1121,6 +1264,8 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
1121
1264
|
}
|
|
1122
1265
|
if (preset !== undefined)
|
|
1123
1266
|
data.pro_preset = preset;
|
|
1267
|
+
if (agentMode !== undefined)
|
|
1268
|
+
data.agent_mode = agentMode;
|
|
1124
1269
|
const requested = selection ? {
|
|
1125
1270
|
provider: selection.provider, model: selection.model.name, intensity: selection.intensity,
|
|
1126
1271
|
} : undefined;
|
|
@@ -1146,7 +1291,7 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
1146
1291
|
streamLive(key, req, res);
|
|
1147
1292
|
return true;
|
|
1148
1293
|
}
|
|
1149
|
-
const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|pro-preset|approval-mode|clear))?$/.exec(pathname);
|
|
1294
|
+
const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|pro-preset|approval-mode|agent-mode|clear))?$/.exec(pathname);
|
|
1150
1295
|
if (liveMatch) {
|
|
1151
1296
|
const key = decodeURIComponent(liveMatch[1]);
|
|
1152
1297
|
const action = liveMatch[2];
|
|
@@ -1175,7 +1320,7 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
1175
1320
|
return true;
|
|
1176
1321
|
}
|
|
1177
1322
|
try {
|
|
1178
|
-
const body = ["approve", "approval-mode", "message", "model", "pro-preset"].includes(action)
|
|
1323
|
+
const body = ["approve", "approval-mode", "agent-mode", "message", "model", "pro-preset"].includes(action)
|
|
1179
1324
|
? await readJsonBody(req) : {};
|
|
1180
1325
|
const result = await remote(action, body);
|
|
1181
1326
|
sendJson(res, result.status, result.data);
|
|
@@ -1204,6 +1349,25 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
1204
1349
|
}
|
|
1205
1350
|
return true;
|
|
1206
1351
|
}
|
|
1352
|
+
if (action === "agent-mode") {
|
|
1353
|
+
try {
|
|
1354
|
+
const body = await readJsonBody(req);
|
|
1355
|
+
const value = body?.mode;
|
|
1356
|
+
if (!isAgentMode(value) || mode === "pro") {
|
|
1357
|
+
sendJson(res, 400, { error: "invalid agent mode" });
|
|
1358
|
+
return true;
|
|
1359
|
+
}
|
|
1360
|
+
const changed = hub.setAgentMode(key, value);
|
|
1361
|
+
if (!changed.ok)
|
|
1362
|
+
sendJson(res, 409, { error: changed.error });
|
|
1363
|
+
else
|
|
1364
|
+
sendJson(res, 200, { ok: true, snapshot: hub.snapshot(key) });
|
|
1365
|
+
}
|
|
1366
|
+
catch (error) {
|
|
1367
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : "invalid agent mode" });
|
|
1368
|
+
}
|
|
1369
|
+
return true;
|
|
1370
|
+
}
|
|
1207
1371
|
if (action === "approval-mode") {
|
|
1208
1372
|
let body;
|
|
1209
1373
|
try {
|
|
@@ -1308,7 +1472,7 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
|
1308
1472
|
sendJson(res, 404, { error: "not found" });
|
|
1309
1473
|
return true;
|
|
1310
1474
|
}
|
|
1311
|
-
async function handleRequest(req, res, auth, binding) {
|
|
1475
|
+
async function handleRequest(req, res, auth, binding, updates) {
|
|
1312
1476
|
let url;
|
|
1313
1477
|
try {
|
|
1314
1478
|
url = new URL(req.url ?? "/", "http://localhost");
|
|
@@ -1341,7 +1505,7 @@ async function handleRequest(req, res, auth, binding) {
|
|
|
1341
1505
|
mode = "pro";
|
|
1342
1506
|
apiPathname = pathname.slice("/pro".length);
|
|
1343
1507
|
}
|
|
1344
|
-
if (await handleApi(req, res, url, auth, mode, apiPathname, binding))
|
|
1508
|
+
if (await handleApi(req, res, url, auth, mode, apiPathname, binding, updates))
|
|
1345
1509
|
return;
|
|
1346
1510
|
if (mode === "pro") {
|
|
1347
1511
|
// Nothing else lives under /pro but the page and its API.
|
|
@@ -1371,8 +1535,11 @@ export async function startWebServer(options) {
|
|
|
1371
1535
|
service: options.backgroundService,
|
|
1372
1536
|
updateSettings: (body, mode) => updateSettings(body, mode),
|
|
1373
1537
|
};
|
|
1538
|
+
// Every window watching for an update belongs to this console, not the module,
|
|
1539
|
+
// so a second embedded server (a test, an embedding) keeps its own set.
|
|
1540
|
+
const updates = createUpdateStreams();
|
|
1374
1541
|
const server = http.createServer((req, res) => {
|
|
1375
|
-
handleRequest(req, res, auth, binding).catch((err) => {
|
|
1542
|
+
handleRequest(req, res, auth, binding, updates).catch((err) => {
|
|
1376
1543
|
if (!res.headersSent) {
|
|
1377
1544
|
sendJson(res, 500, { error: err instanceof Error ? err.message : "internal error" });
|
|
1378
1545
|
}
|
|
@@ -1513,6 +1680,9 @@ export async function startWebServer(options) {
|
|
|
1513
1680
|
setHost: (next) => rebind(next),
|
|
1514
1681
|
close: () => new Promise((resolve) => {
|
|
1515
1682
|
closed = true;
|
|
1683
|
+
// These streams never end on their own, so they are closed explicitly;
|
|
1684
|
+
// otherwise server.close() would wait for a window that stays open.
|
|
1685
|
+
updates.closeAll();
|
|
1516
1686
|
for (const key of [...webSessions.keys()])
|
|
1517
1687
|
closeWebSession(key);
|
|
1518
1688
|
if (!server.listening) {
|
package/dist/web/service.js
CHANGED
|
@@ -5,6 +5,7 @@ import { loadConfig, saveConfig } from "../config.js";
|
|
|
5
5
|
import { startBackgroundTray } from "../tray.js";
|
|
6
6
|
import { PRO_MODELS_URL, syncProModels } from "../pro/config.js";
|
|
7
7
|
import { checkForUpdate, UPDATE_PACKAGE } from "../update.js";
|
|
8
|
+
import { startUpdateWatch } from "../updateWatch.js";
|
|
8
9
|
import { hub } from "./hub.js";
|
|
9
10
|
import { startWebServer } from "./server.js";
|
|
10
11
|
import { TerminalBridges } from "./terminalBridge.js";
|
|
@@ -18,6 +19,7 @@ export async function runWebService(options) {
|
|
|
18
19
|
let handle = null;
|
|
19
20
|
let tray = null;
|
|
20
21
|
let monitor = null;
|
|
22
|
+
let stopUpdateWatch = null;
|
|
21
23
|
let stopping = false;
|
|
22
24
|
const bridges = new TerminalBridges();
|
|
23
25
|
const status = () => {
|
|
@@ -44,6 +46,8 @@ export async function runWebService(options) {
|
|
|
44
46
|
stopping = true;
|
|
45
47
|
if (monitor)
|
|
46
48
|
clearInterval(monitor);
|
|
49
|
+
stopUpdateWatch?.();
|
|
50
|
+
stopUpdateWatch = null;
|
|
47
51
|
tray?.close();
|
|
48
52
|
bridges.close();
|
|
49
53
|
backgroundLog(`stopping pid=${process.pid}`);
|
|
@@ -131,6 +135,24 @@ export async function runWebService(options) {
|
|
|
131
135
|
}
|
|
132
136
|
backgroundLog(`started pid=${process.pid} on ${handle.url} (residency ${resident ? "on" : "off"})`);
|
|
133
137
|
syncTray();
|
|
138
|
+
// The start-up lookup above is one answer; a console that stays open needs
|
|
139
|
+
// more than that. Every 30 minutes the registry is asked again and a release
|
|
140
|
+
// it has not seen before is announced to every open window
|
|
141
|
+
// (GET /api/update/events), which is the same status the first-open dialog
|
|
142
|
+
// reads, so both ways of telling the user come from one source.
|
|
143
|
+
let loggedUpdate = "";
|
|
144
|
+
stopUpdateWatch = startUpdateWatch({
|
|
145
|
+
onCheck: (result) => {
|
|
146
|
+
// Log a change, not the same answer every half hour.
|
|
147
|
+
const line = result.hasUpdate
|
|
148
|
+
? `${UPDATE_PACKAGE} ${result.latestVersion} is available (running ${result.currentVersion})`
|
|
149
|
+
: result.error ? `update check: ${result.error}` : "";
|
|
150
|
+
if (!line || line === loggedUpdate)
|
|
151
|
+
return;
|
|
152
|
+
loggedUpdate = line;
|
|
153
|
+
backgroundLog(line);
|
|
154
|
+
},
|
|
155
|
+
});
|
|
134
156
|
// No helper processes: polling pids must remain invisible on Windows. An
|
|
135
157
|
// orphaned, non-resident console lets an in-flight browser turn finish before
|
|
136
158
|
// exiting, so disabling residency also cannot throw away running work.
|