@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/web/server.js
CHANGED
|
@@ -15,15 +15,19 @@ import fs from "node:fs";
|
|
|
15
15
|
import http from "node:http";
|
|
16
16
|
import os from "node:os";
|
|
17
17
|
import path from "node:path";
|
|
18
|
+
import { timingSafeEqual } from "node:crypto";
|
|
18
19
|
import { fileURLToPath } from "node:url";
|
|
19
|
-
import {
|
|
20
|
+
import { deleteSession, listSessions, loadConfig, normalizeApprovalMode, resolveModelSelection, saveConfig, sessionDir, } from "../config.js";
|
|
20
21
|
import { getWebSettings, updateWebSettings } from "./settings.js";
|
|
21
22
|
import { WebAuth } from "./auth.js";
|
|
22
23
|
import { normalizeCwd, sameCwd } from "../cwd.js";
|
|
23
24
|
import { hub } from "./hub.js";
|
|
24
25
|
import { writeCrashLog } from "../terminal/restore.js";
|
|
25
|
-
import { createWebSession, resumeSession } from "./session.js";
|
|
26
|
-
import {
|
|
26
|
+
import { createWebSession, proInitialSelection, resumeSession, WebSession } from "./session.js";
|
|
27
|
+
import { defaultProPreset, requireProPreset } from "../pro/config.js";
|
|
28
|
+
import { listAgentSessions, loadAgentMessages, loadAgentSession, safeAgentSessionId } from "../agents.js";
|
|
29
|
+
import { migrateAgentSession, SessionMigrationError } from "../migration.js";
|
|
30
|
+
import { runningBackground } from "../background.js";
|
|
27
31
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
28
32
|
// dist/web -> package root (mirrors the same trick used in config.ts).
|
|
29
33
|
const PKG_ROOT = path.resolve(__dirname, "..", "..");
|
|
@@ -43,6 +47,9 @@ function closeWebSession(key) {
|
|
|
43
47
|
}
|
|
44
48
|
/** Bind addresses that mean "every interface" rather than one host. */
|
|
45
49
|
const WILDCARD_HOSTS = new Set(["0.0.0.0", "::", "[::]", ""]);
|
|
50
|
+
/** The two interfaces the Web access switch moves between. */
|
|
51
|
+
const LOOPBACK_HOST = "127.0.0.1";
|
|
52
|
+
const WILDCARD_HOST = "0.0.0.0";
|
|
46
53
|
/** True when `host` only accepts connections from this machine. */
|
|
47
54
|
export function isLoopbackHost(host) {
|
|
48
55
|
const h = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
@@ -50,6 +57,17 @@ export function isLoopbackHost(host) {
|
|
|
50
57
|
return false;
|
|
51
58
|
return h === "localhost" || h === "::1" || h.startsWith("127.");
|
|
52
59
|
}
|
|
60
|
+
/** True when `host` listens on every interface, i.e. what --lan selects. */
|
|
61
|
+
export function isWildcardHost(host) {
|
|
62
|
+
return WILDCARD_HOSTS.has(host.trim().toLowerCase().replace(/^\[|\]$/g, ""));
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The interface a fresh listener uses when the command line did not pick one.
|
|
66
|
+
* The saved Web access switch behaves exactly like `--lan`.
|
|
67
|
+
*/
|
|
68
|
+
function defaultWebHost() {
|
|
69
|
+
return loadConfig().web_lan === true ? WILDCARD_HOST : LOOPBACK_HOST;
|
|
70
|
+
}
|
|
53
71
|
/**
|
|
54
72
|
* Adapters that only ever talk to this machine: hypervisor host-only switches,
|
|
55
73
|
* container bridges and loopback variants. Listing them would bury the address
|
|
@@ -99,6 +117,48 @@ export function webUrls(host, port) {
|
|
|
99
117
|
urls: hosts.map((h) => `http://${h}:${port}/`),
|
|
100
118
|
};
|
|
101
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Settings plus the live socket. The `web_lan` switch reports and changes what
|
|
122
|
+
* is listening right now, so a run started with --lan shows as on even though
|
|
123
|
+
* nothing was saved yet.
|
|
124
|
+
*/
|
|
125
|
+
function validTerminalBridge(bridge, ownerPid) {
|
|
126
|
+
if (!bridge || typeof bridge !== "object" || Array.isArray(bridge) || typeof ownerPid !== "number")
|
|
127
|
+
return false;
|
|
128
|
+
const info = bridge;
|
|
129
|
+
if (typeof info.url !== "string" || typeof info.token !== "string" || !/^[a-f0-9]{64}$/.test(info.token))
|
|
130
|
+
return false;
|
|
131
|
+
try {
|
|
132
|
+
const url = new URL(info.url);
|
|
133
|
+
return url.protocol === "http:" && url.hostname === "127.0.0.1" && Boolean(url.port)
|
|
134
|
+
&& url.pathname === "/" && !url.username && !url.password && !url.search && !url.hash;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function settingsPayload(mode, binding) {
|
|
141
|
+
const payload = getWebSettings(mode);
|
|
142
|
+
const lan = isWildcardHost(binding.host);
|
|
143
|
+
const { urls } = webUrls(binding.host, binding.port);
|
|
144
|
+
const background = binding.service?.status?.() ?? runningBackground();
|
|
145
|
+
return {
|
|
146
|
+
...payload,
|
|
147
|
+
settings: {
|
|
148
|
+
...payload.settings,
|
|
149
|
+
web_lan: lan,
|
|
150
|
+
web_host: binding.host,
|
|
151
|
+
// Addresses another device can open; empty unless every interface is bound.
|
|
152
|
+
web_urls: lan ? urls.filter((url) => !url.includes("//127.0.0.1:")) : [],
|
|
153
|
+
// The background service is a process, not a workspace: both pages report
|
|
154
|
+
// the same state. The prompt is never shown once the setting is on.
|
|
155
|
+
background: background?.pid === process.pid ? background.resident : payload.settings.background,
|
|
156
|
+
background_running: background?.resident === true,
|
|
157
|
+
background_url: background?.url ?? "",
|
|
158
|
+
background_prompt: binding.prompt.pending && payload.settings.background !== true,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
102
162
|
const MIME_TYPES = {
|
|
103
163
|
".html": "text/html; charset=utf-8",
|
|
104
164
|
".css": "text/css; charset=utf-8",
|
|
@@ -120,11 +180,12 @@ const SESSION_ID_RE = /^[A-Za-z0-9._-]{1,200}$/;
|
|
|
120
180
|
function isSafeSessionId(id) {
|
|
121
181
|
return SESSION_ID_RE.test(id) && id !== "." && id !== ".." && !id.includes("..");
|
|
122
182
|
}
|
|
123
|
-
function loadSessionSafe(id) {
|
|
183
|
+
function loadSessionSafe(id, mode = "standard") {
|
|
124
184
|
if (!isSafeSessionId(id))
|
|
125
185
|
return null;
|
|
126
|
-
const
|
|
127
|
-
|
|
186
|
+
const dir = sessionDir(mode);
|
|
187
|
+
const filePath = path.join(dir, `${id}.json`);
|
|
188
|
+
if (path.dirname(filePath) !== dir)
|
|
128
189
|
return null;
|
|
129
190
|
try {
|
|
130
191
|
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
@@ -158,7 +219,6 @@ function sendApiError(res, error) {
|
|
|
158
219
|
}
|
|
159
220
|
const MAX_RECENT_WORKSPACES = 12;
|
|
160
221
|
const AGENT_IDS = new Set(["claude", "codex", "opencode"]);
|
|
161
|
-
const AGENT_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,200}$/;
|
|
162
222
|
/** Expand `~` and require an absolute path; used by every folder route. */
|
|
163
223
|
function absolutePath(raw) {
|
|
164
224
|
if (typeof raw !== "string" || !raw.trim()) {
|
|
@@ -242,12 +302,12 @@ function readFolder(raw) {
|
|
|
242
302
|
};
|
|
243
303
|
}
|
|
244
304
|
/** Recent working directories: explicitly opened ones first, then saved-session folders. */
|
|
245
|
-
function recentWorkspacePaths() {
|
|
246
|
-
const config = loadConfig();
|
|
305
|
+
function recentWorkspacePaths(mode = "standard") {
|
|
306
|
+
const config = loadConfig(mode);
|
|
247
307
|
const stored = Array.isArray(config.web_workspaces)
|
|
248
308
|
? config.web_workspaces.filter((entry) => typeof entry === "string" && entry.trim() !== "")
|
|
249
309
|
: [];
|
|
250
|
-
const saved = listSessions().map((session) => session.cwd).filter(Boolean);
|
|
310
|
+
const saved = listSessions(mode).map((session) => session.cwd).filter(Boolean);
|
|
251
311
|
const seen = new Set();
|
|
252
312
|
const out = [];
|
|
253
313
|
for (const dir of [...stored, ...saved, process.cwd()]) {
|
|
@@ -261,9 +321,9 @@ function recentWorkspacePaths() {
|
|
|
261
321
|
}
|
|
262
322
|
return out;
|
|
263
323
|
}
|
|
264
|
-
function workspaceList() {
|
|
265
|
-
const sessions = listSessions();
|
|
266
|
-
return recentWorkspacePaths().map((dir) => {
|
|
324
|
+
function workspaceList(mode = "standard") {
|
|
325
|
+
const sessions = listSessions(mode);
|
|
326
|
+
return recentWorkspacePaths(mode).map((dir) => {
|
|
267
327
|
let exists = false;
|
|
268
328
|
try {
|
|
269
329
|
exists = fs.statSync(dir).isDirectory();
|
|
@@ -280,15 +340,15 @@ function workspaceList() {
|
|
|
280
340
|
});
|
|
281
341
|
}
|
|
282
342
|
/** Best-effort persistence: failing to remember a folder must not fail the conversation. */
|
|
283
|
-
function recordWorkspace(dir) {
|
|
343
|
+
function recordWorkspace(dir, mode = "standard") {
|
|
284
344
|
try {
|
|
285
|
-
const config = loadConfig();
|
|
345
|
+
const config = loadConfig(mode);
|
|
286
346
|
const stored = Array.isArray(config.web_workspaces)
|
|
287
347
|
? config.web_workspaces.filter((entry) => typeof entry === "string")
|
|
288
348
|
: [];
|
|
289
349
|
config.web_workspaces = [dir, ...stored.filter((entry) => !sameCwd(entry, dir))]
|
|
290
350
|
.slice(0, MAX_RECENT_WORKSPACES);
|
|
291
|
-
saveConfig(config);
|
|
351
|
+
saveConfig(config, mode);
|
|
292
352
|
}
|
|
293
353
|
catch {
|
|
294
354
|
// The picked folder still works for this conversation.
|
|
@@ -318,13 +378,10 @@ function createFolder(raw) {
|
|
|
318
378
|
function isAgentId(value) {
|
|
319
379
|
return AGENT_IDS.has(value);
|
|
320
380
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
function buildOverview() {
|
|
326
|
-
const sessions = listSessions();
|
|
327
|
-
const lives = hub.list();
|
|
381
|
+
/** Build the sidebar payload: the selected workspace's sessions grouped by directory. */
|
|
382
|
+
function buildOverview(mode = "standard") {
|
|
383
|
+
const sessions = listSessions(mode);
|
|
384
|
+
const lives = hub.list(mode);
|
|
328
385
|
const liveBySession = new Map();
|
|
329
386
|
for (const live of lives) {
|
|
330
387
|
if (live.sessionId)
|
|
@@ -448,6 +505,16 @@ function isSameOrigin(req) {
|
|
|
448
505
|
return false;
|
|
449
506
|
}
|
|
450
507
|
}
|
|
508
|
+
/** Constant-time comparison of the service's management token. */
|
|
509
|
+
function hasServiceToken(req, token) {
|
|
510
|
+
const header = String(req.headers.authorization ?? "");
|
|
511
|
+
const prefix = "Bearer ";
|
|
512
|
+
if (!header.startsWith(prefix))
|
|
513
|
+
return false;
|
|
514
|
+
const supplied = Buffer.from(header.slice(prefix.length));
|
|
515
|
+
const expected = Buffer.from(token);
|
|
516
|
+
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
|
|
517
|
+
}
|
|
451
518
|
function serveStatic(res, pathname) {
|
|
452
519
|
let rel = pathname === "/" ? "/index.html" : pathname;
|
|
453
520
|
try {
|
|
@@ -530,8 +597,7 @@ function streamLive(key, req, res) {
|
|
|
530
597
|
// Without this, a client reset during a write is an uncaught 'error' event.
|
|
531
598
|
res.on("error", cleanup);
|
|
532
599
|
}
|
|
533
|
-
async function handleApi(req, res, url, auth) {
|
|
534
|
-
const { pathname } = url;
|
|
600
|
+
async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
535
601
|
if (!pathname.startsWith("/api/"))
|
|
536
602
|
return false;
|
|
537
603
|
if (req.method === "OPTIONS") {
|
|
@@ -541,6 +607,59 @@ async function handleApi(req, res, url, auth) {
|
|
|
541
607
|
res.end();
|
|
542
608
|
return true;
|
|
543
609
|
}
|
|
610
|
+
// Management endpoints of the detached service. They are called by the CLI
|
|
611
|
+
// and the tray icon, which have the token from ~/.neutrino/background.json
|
|
612
|
+
// but not necessarily a browser login cookie.
|
|
613
|
+
if (["/api/background/ping", "/api/background/stop", "/api/background/configure"].includes(pathname)) {
|
|
614
|
+
if (req.method !== "GET" && req.method !== "POST") {
|
|
615
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
616
|
+
return true;
|
|
617
|
+
}
|
|
618
|
+
if (!binding.service) {
|
|
619
|
+
sendJson(res, 404, { error: "this console is not running in the background" });
|
|
620
|
+
return true;
|
|
621
|
+
}
|
|
622
|
+
if (!hasServiceToken(req, binding.service.token)) {
|
|
623
|
+
sendJson(res, 401, { error: "background service token required" });
|
|
624
|
+
return true;
|
|
625
|
+
}
|
|
626
|
+
if (pathname.endsWith("/ping")) {
|
|
627
|
+
sendJson(res, 200, { ok: true, pid: process.pid, url: webUrls(binding.host, binding.port).url });
|
|
628
|
+
return true;
|
|
629
|
+
}
|
|
630
|
+
if (pathname.endsWith("/configure")) {
|
|
631
|
+
if (req.method !== "POST") {
|
|
632
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
633
|
+
return true;
|
|
634
|
+
}
|
|
635
|
+
if (!binding.service.configure) {
|
|
636
|
+
sendJson(res, 409, { error: "Restart the console to update background residency." });
|
|
637
|
+
return true;
|
|
638
|
+
}
|
|
639
|
+
try {
|
|
640
|
+
const body = await readJsonBody(req, 4096);
|
|
641
|
+
if (!body || typeof body !== "object" || Array.isArray(body)
|
|
642
|
+
|| Object.keys(body).some(key => key !== "resident" && key !== "ownerPid" && key !== "bridge")
|
|
643
|
+
|| (body.resident !== undefined && typeof body.resident !== "boolean")
|
|
644
|
+
|| (body.ownerPid !== undefined && (!Number.isSafeInteger(body.ownerPid) || body.ownerPid <= 0))
|
|
645
|
+
|| (body.bridge !== undefined && !validTerminalBridge(body.bridge, body.ownerPid))) {
|
|
646
|
+
throw new Error("invalid background settings");
|
|
647
|
+
}
|
|
648
|
+
const updated = binding.service.configure(body);
|
|
649
|
+
if (updated.resident)
|
|
650
|
+
binding.prompt.pending = false;
|
|
651
|
+
sendJson(res, 200, updated);
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : "invalid background settings" });
|
|
655
|
+
}
|
|
656
|
+
return true;
|
|
657
|
+
}
|
|
658
|
+
sendJson(res, 200, { ok: true, stopping: true });
|
|
659
|
+
// Let the response reach the caller before the socket closes.
|
|
660
|
+
setImmediate(() => binding.service?.onStop());
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
544
663
|
if (pathname === "/api/auth/login" || pathname === "/api/auth/logout") {
|
|
545
664
|
if (req.method !== "POST") {
|
|
546
665
|
sendJson(res, 405, { error: "method not allowed" });
|
|
@@ -575,9 +694,24 @@ async function handleApi(req, res, url, auth) {
|
|
|
575
694
|
sendJson(res, 401, { error: "Password login required." });
|
|
576
695
|
return true;
|
|
577
696
|
}
|
|
697
|
+
// The first browser to see the background-residency prompt consumes it for
|
|
698
|
+
// this console run, whether it accepted or dismissed it.
|
|
699
|
+
if (pathname === "/api/background/dismiss") {
|
|
700
|
+
if (req.method !== "POST") {
|
|
701
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
702
|
+
}
|
|
703
|
+
else if (!isSameOrigin(req)) {
|
|
704
|
+
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
binding.prompt.pending = false;
|
|
708
|
+
sendJson(res, 200, { ok: true });
|
|
709
|
+
}
|
|
710
|
+
return true;
|
|
711
|
+
}
|
|
578
712
|
if (pathname === "/api/settings") {
|
|
579
713
|
if (req.method === "GET") {
|
|
580
|
-
sendJson(res, 200,
|
|
714
|
+
sendJson(res, 200, settingsPayload(mode, binding));
|
|
581
715
|
}
|
|
582
716
|
else if (req.method === "POST") {
|
|
583
717
|
if (!isSameOrigin(req)) {
|
|
@@ -586,11 +720,11 @@ async function handleApi(req, res, url, auth) {
|
|
|
586
720
|
}
|
|
587
721
|
try {
|
|
588
722
|
const body = await readJsonBody(req);
|
|
589
|
-
|
|
723
|
+
await binding.updateSettings(body, mode);
|
|
590
724
|
if (body.settings?.web_password !== undefined) {
|
|
591
725
|
auth.remember(res);
|
|
592
726
|
}
|
|
593
|
-
sendJson(res, 200,
|
|
727
|
+
sendJson(res, 200, settingsPayload(mode, binding));
|
|
594
728
|
}
|
|
595
729
|
catch (err) {
|
|
596
730
|
sendJson(res, err?.code ? 500 : 400, {
|
|
@@ -604,15 +738,15 @@ async function handleApi(req, res, url, auth) {
|
|
|
604
738
|
return true;
|
|
605
739
|
}
|
|
606
740
|
if (req.method === "GET" && pathname === "/api/health") {
|
|
607
|
-
sendJson(res, 200, { ok: true, live: hub.list().length });
|
|
741
|
+
sendJson(res, 200, { ok: true, live: hub.list(mode).length });
|
|
608
742
|
return true;
|
|
609
743
|
}
|
|
610
744
|
if (req.method === "GET" && pathname === "/api/overview") {
|
|
611
|
-
sendJson(res, 200, buildOverview());
|
|
745
|
+
sendJson(res, 200, buildOverview(mode));
|
|
612
746
|
return true;
|
|
613
747
|
}
|
|
614
748
|
if (req.method === "GET" && pathname === "/api/sessions") {
|
|
615
|
-
sendJson(res, 200, { sessions: listSessions() });
|
|
749
|
+
sendJson(res, 200, { sessions: listSessions(mode) });
|
|
616
750
|
return true;
|
|
617
751
|
}
|
|
618
752
|
if (pathname === "/api/workspaces") {
|
|
@@ -620,7 +754,7 @@ async function handleApi(req, res, url, auth) {
|
|
|
620
754
|
sendJson(res, 200, {
|
|
621
755
|
cwd: process.cwd(),
|
|
622
756
|
home: os.homedir(),
|
|
623
|
-
workspaces: workspaceList(),
|
|
757
|
+
workspaces: workspaceList(mode),
|
|
624
758
|
});
|
|
625
759
|
return true;
|
|
626
760
|
}
|
|
@@ -632,8 +766,8 @@ async function handleApi(req, res, url, auth) {
|
|
|
632
766
|
try {
|
|
633
767
|
const body = await readJsonBody(req);
|
|
634
768
|
const dir = existingDirectory(body?.path);
|
|
635
|
-
recordWorkspace(dir);
|
|
636
|
-
sendJson(res, 200, { ok: true, path: dir, workspaces: workspaceList() });
|
|
769
|
+
recordWorkspace(dir, mode);
|
|
770
|
+
sendJson(res, 200, { ok: true, path: dir, workspaces: workspaceList(mode) });
|
|
637
771
|
}
|
|
638
772
|
catch (err) {
|
|
639
773
|
sendApiError(res, err);
|
|
@@ -694,9 +828,10 @@ async function handleApi(req, res, url, auth) {
|
|
|
694
828
|
const input = body;
|
|
695
829
|
const cwd = existingDirectory(input.cwd ?? process.cwd());
|
|
696
830
|
const requested = input.selection === undefined
|
|
697
|
-
? getWebSettings().selection
|
|
831
|
+
? getWebSettings(mode).selection
|
|
698
832
|
: input.selection;
|
|
699
|
-
const
|
|
833
|
+
const preset = mode === "pro" ? requireProPreset(input.pro_preset ?? defaultProPreset()) : undefined;
|
|
834
|
+
const selection = mode === "pro" ? proInitialSelection(preset) : resolveModelSelection(requested, false);
|
|
700
835
|
let approvalMode;
|
|
701
836
|
if (input.approval_mode !== undefined) {
|
|
702
837
|
if (input.approval_mode !== "request" && input.approval_mode !== "auto") {
|
|
@@ -704,10 +839,10 @@ async function handleApi(req, res, url, auth) {
|
|
|
704
839
|
}
|
|
705
840
|
approvalMode = normalizeApprovalMode(input.approval_mode);
|
|
706
841
|
}
|
|
707
|
-
const session = createWebSession(cwd, selection, approvalMode);
|
|
842
|
+
const session = createWebSession(cwd, selection, approvalMode, mode, preset);
|
|
708
843
|
const key = session.start();
|
|
709
844
|
webSessions.set(key, session);
|
|
710
|
-
recordWorkspace(cwd);
|
|
845
|
+
recordWorkspace(cwd, mode);
|
|
711
846
|
sendJson(res, 200, { ok: true, key, cwd, snapshot: hub.snapshot(key) });
|
|
712
847
|
}
|
|
713
848
|
catch (err) {
|
|
@@ -726,18 +861,18 @@ async function handleApi(req, res, url, auth) {
|
|
|
726
861
|
sendJson(res, 400, { error: "invalid session id" });
|
|
727
862
|
return true;
|
|
728
863
|
}
|
|
729
|
-
if (hub.list().some((entry) => entry.sessionId === id)) {
|
|
864
|
+
if (hub.list(mode).some((entry) => entry.sessionId === id)) {
|
|
730
865
|
sendJson(res, 409, { error: "cannot delete a running conversation" });
|
|
731
866
|
return true;
|
|
732
867
|
}
|
|
733
|
-
if (!deleteSession(id)) {
|
|
868
|
+
if (!deleteSession(id, mode)) {
|
|
734
869
|
sendJson(res, 404, { error: "session not found" });
|
|
735
870
|
return true;
|
|
736
871
|
}
|
|
737
872
|
sendJson(res, 200, { ok: true, deleted: true });
|
|
738
873
|
return true;
|
|
739
874
|
}
|
|
740
|
-
//
|
|
875
|
+
// History recorded by other agents (Claude Code, Codex, OpenCode).
|
|
741
876
|
const agentSessionsMatch = /^\/api\/agents\/([^/]+)\/sessions$/.exec(pathname);
|
|
742
877
|
if (req.method === "GET" && agentSessionsMatch) {
|
|
743
878
|
const agent = decodeURIComponent(agentSessionsMatch[1]);
|
|
@@ -761,7 +896,59 @@ async function handleApi(req, res, url, auth) {
|
|
|
761
896
|
sendJson(res, 400, { error: "invalid session id" });
|
|
762
897
|
return true;
|
|
763
898
|
}
|
|
764
|
-
|
|
899
|
+
const cwd = url.searchParams.get("cwd");
|
|
900
|
+
if (cwd) {
|
|
901
|
+
const source = loadAgentSession(agent, id, cwd);
|
|
902
|
+
if (!source) {
|
|
903
|
+
sendJson(res, 404, { error: "session not found in this working directory" });
|
|
904
|
+
return true;
|
|
905
|
+
}
|
|
906
|
+
sendJson(res, 200, { ...source, agent });
|
|
907
|
+
}
|
|
908
|
+
else {
|
|
909
|
+
sendJson(res, 200, { agent, id, messages: loadAgentMessages(agent, id) });
|
|
910
|
+
}
|
|
911
|
+
return true;
|
|
912
|
+
}
|
|
913
|
+
const migrateMatch = /^\/api\/agents\/([^/]+)\/sessions\/([^/]+)\/migrate$/.exec(pathname);
|
|
914
|
+
if (migrateMatch) {
|
|
915
|
+
if (req.method !== "POST") {
|
|
916
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
917
|
+
return true;
|
|
918
|
+
}
|
|
919
|
+
if (!isSameOrigin(req)) {
|
|
920
|
+
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
921
|
+
return true;
|
|
922
|
+
}
|
|
923
|
+
const agent = decodeURIComponent(migrateMatch[1]);
|
|
924
|
+
const id = decodeURIComponent(migrateMatch[2]);
|
|
925
|
+
if (!isAgentId(agent) || !safeAgentSessionId(id)) {
|
|
926
|
+
sendJson(res, 400, { error: !isAgentId(agent) ? "unknown agent" : "invalid session id" });
|
|
927
|
+
return true;
|
|
928
|
+
}
|
|
929
|
+
try {
|
|
930
|
+
const body = await readJsonBody(req);
|
|
931
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
932
|
+
throw new Error("invalid JSON body");
|
|
933
|
+
const input = body;
|
|
934
|
+
const cwd = existingDirectory(input.cwd ?? process.cwd());
|
|
935
|
+
const preset = mode === "pro" ? requireProPreset(input.pro_preset ?? defaultProPreset()) : undefined;
|
|
936
|
+
const selection = mode === "pro" ? proInitialSelection(preset)
|
|
937
|
+
: resolveModelSelection(input.selection ?? getWebSettings(mode).selection, false);
|
|
938
|
+
const migrated = migrateAgentSession(agent, id, cwd, {
|
|
939
|
+
provider: selection.provider, model: selection.model.name, intensity: selection.intensity,
|
|
940
|
+
}, mode, preset);
|
|
941
|
+
const session = new WebSession(migrated, selection.model, mode);
|
|
942
|
+
const key = session.start();
|
|
943
|
+
webSessions.set(key, session);
|
|
944
|
+
sendJson(res, 201, { ok: true, id: migrated.id, key, cwd, snapshot: hub.snapshot(key) });
|
|
945
|
+
}
|
|
946
|
+
catch (err) {
|
|
947
|
+
if (err instanceof SessionMigrationError)
|
|
948
|
+
sendJson(res, err.status, { error: err.message });
|
|
949
|
+
else
|
|
950
|
+
sendApiError(res, err);
|
|
951
|
+
}
|
|
765
952
|
return true;
|
|
766
953
|
}
|
|
767
954
|
const sessionMatch = /^\/api\/session\/([^/]+)$/.exec(pathname);
|
|
@@ -771,7 +958,7 @@ async function handleApi(req, res, url, auth) {
|
|
|
771
958
|
sendJson(res, 400, { error: "invalid session id" });
|
|
772
959
|
return true;
|
|
773
960
|
}
|
|
774
|
-
const data = loadSessionSafe(id);
|
|
961
|
+
const data = loadSessionSafe(id, mode);
|
|
775
962
|
if (!data) {
|
|
776
963
|
sendJson(res, 404, { error: "session not found" });
|
|
777
964
|
return true;
|
|
@@ -791,22 +978,48 @@ async function handleApi(req, res, url, auth) {
|
|
|
791
978
|
return true;
|
|
792
979
|
}
|
|
793
980
|
let selection;
|
|
981
|
+
let preset;
|
|
794
982
|
try {
|
|
795
983
|
const body = await readJsonBody(req);
|
|
796
984
|
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
797
985
|
throw new Error("invalid JSON body");
|
|
798
986
|
const requested = body.selection;
|
|
799
|
-
if (requested !== undefined)
|
|
987
|
+
if (requested !== undefined && mode !== "pro")
|
|
800
988
|
selection = resolveModelSelection(requested);
|
|
989
|
+
const requestedPreset = body.pro_preset;
|
|
990
|
+
if (requestedPreset !== undefined && mode === "pro")
|
|
991
|
+
preset = requireProPreset(requestedPreset);
|
|
801
992
|
}
|
|
802
993
|
catch (err) {
|
|
803
994
|
sendJson(res, 400, { error: err instanceof Error ? err.message : "invalid model selection" });
|
|
804
995
|
return true;
|
|
805
996
|
}
|
|
806
997
|
// Already running (TUI or another tab)? Attach instead of duplicating.
|
|
807
|
-
const existing = hub.list().find((entry) => entry.sessionId === id);
|
|
998
|
+
const existing = hub.list(mode).find((entry) => entry.sessionId === id);
|
|
808
999
|
if (existing) {
|
|
809
|
-
|
|
1000
|
+
const remote = hub.remoteAction(existing.key);
|
|
1001
|
+
if (remote && preset !== undefined) {
|
|
1002
|
+
const result = await remote("pro-preset", { preset });
|
|
1003
|
+
if (result.status !== 200) {
|
|
1004
|
+
sendJson(res, result.status, result.data);
|
|
1005
|
+
return true;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
else if (remote && selection) {
|
|
1009
|
+
const result = await remote("model", { provider: selection.provider, model: selection.model.name, intensity: selection.intensity });
|
|
1010
|
+
if (result.status !== 200) {
|
|
1011
|
+
sendJson(res, result.status, result.data);
|
|
1012
|
+
return true;
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
if (!remote && preset !== undefined) {
|
|
1016
|
+
const changed = hub.setProPreset(existing.key, preset);
|
|
1017
|
+
if (!changed.ok) {
|
|
1018
|
+
sendJson(res, 409, { error: changed.error });
|
|
1019
|
+
return true;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
if (!remote && selection) {
|
|
810
1023
|
const changed = hub.configure(existing.key, selection);
|
|
811
1024
|
if (!changed.ok) {
|
|
812
1025
|
sendJson(res, 409, { error: changed.error });
|
|
@@ -816,15 +1029,17 @@ async function handleApi(req, res, url, auth) {
|
|
|
816
1029
|
sendJson(res, 200, { key: existing.key, attached: true });
|
|
817
1030
|
return true;
|
|
818
1031
|
}
|
|
819
|
-
const data = loadSessionSafe(id);
|
|
1032
|
+
const data = loadSessionSafe(id, mode);
|
|
820
1033
|
if (!data) {
|
|
821
1034
|
sendJson(res, 404, { error: "session not found" });
|
|
822
1035
|
return true;
|
|
823
1036
|
}
|
|
1037
|
+
if (preset !== undefined)
|
|
1038
|
+
data.pro_preset = preset;
|
|
824
1039
|
const requested = selection ? {
|
|
825
1040
|
provider: selection.provider, model: selection.model.name, intensity: selection.intensity,
|
|
826
1041
|
} : undefined;
|
|
827
|
-
const outcome = resumeSession(data, requested);
|
|
1042
|
+
const outcome = resumeSession(data, requested, mode);
|
|
828
1043
|
if (!outcome.ok) {
|
|
829
1044
|
sendJson(res, 400, { error: outcome.error });
|
|
830
1045
|
return true;
|
|
@@ -836,15 +1051,22 @@ async function handleApi(req, res, url, auth) {
|
|
|
836
1051
|
}
|
|
837
1052
|
const streamMatch = /^\/api\/live\/([^/]+)\/stream$/.exec(pathname);
|
|
838
1053
|
if (req.method === "GET" && streamMatch) {
|
|
839
|
-
|
|
1054
|
+
const key = decodeURIComponent(streamMatch[1]);
|
|
1055
|
+
// A Pro tab must never observe (or be handed) a session from the other
|
|
1056
|
+
// workspace just because it guessed the key.
|
|
1057
|
+
if (!hub.has(key, mode)) {
|
|
1058
|
+
sendJson(res, 404, { error: "unknown live session" });
|
|
1059
|
+
return true;
|
|
1060
|
+
}
|
|
1061
|
+
streamLive(key, req, res);
|
|
840
1062
|
return true;
|
|
841
1063
|
}
|
|
842
|
-
const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|approval-mode|clear))?$/.exec(pathname);
|
|
1064
|
+
const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|pro-preset|approval-mode|clear))?$/.exec(pathname);
|
|
843
1065
|
if (liveMatch) {
|
|
844
1066
|
const key = decodeURIComponent(liveMatch[1]);
|
|
845
1067
|
const action = liveMatch[2];
|
|
846
1068
|
if (req.method === "GET" && !action) {
|
|
847
|
-
const snapshot = hub.snapshot(key);
|
|
1069
|
+
const snapshot = hub.has(key, mode) ? hub.snapshot(key) : null;
|
|
848
1070
|
if (!snapshot) {
|
|
849
1071
|
sendJson(res, 404, { error: "unknown live session" });
|
|
850
1072
|
return true;
|
|
@@ -857,10 +1079,46 @@ async function handleApi(req, res, url, auth) {
|
|
|
857
1079
|
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
858
1080
|
return true;
|
|
859
1081
|
}
|
|
860
|
-
if (!hub.has(key)) {
|
|
1082
|
+
if (!hub.has(key, mode)) {
|
|
861
1083
|
sendJson(res, 404, { error: "unknown live session" });
|
|
862
1084
|
return true;
|
|
863
1085
|
}
|
|
1086
|
+
const remote = hub.remoteAction(key);
|
|
1087
|
+
if (remote) {
|
|
1088
|
+
if (action === "close") {
|
|
1089
|
+
sendJson(res, 400, { error: "this session is owned by the terminal UI" });
|
|
1090
|
+
return true;
|
|
1091
|
+
}
|
|
1092
|
+
try {
|
|
1093
|
+
const body = ["approve", "approval-mode", "message", "model", "pro-preset"].includes(action)
|
|
1094
|
+
? await readJsonBody(req) : {};
|
|
1095
|
+
const result = await remote(action, body);
|
|
1096
|
+
sendJson(res, result.status, result.data);
|
|
1097
|
+
}
|
|
1098
|
+
catch (error) {
|
|
1099
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : "invalid request" });
|
|
1100
|
+
}
|
|
1101
|
+
return true;
|
|
1102
|
+
}
|
|
1103
|
+
if (action === "pro-preset") {
|
|
1104
|
+
if (mode !== "pro") {
|
|
1105
|
+
sendJson(res, 404, { error: "Pro presets are only available in Pro mode" });
|
|
1106
|
+
return true;
|
|
1107
|
+
}
|
|
1108
|
+
try {
|
|
1109
|
+
const body = await readJsonBody(req);
|
|
1110
|
+
const preset = requireProPreset(body?.preset);
|
|
1111
|
+
const changed = hub.setProPreset(key, preset);
|
|
1112
|
+
if (!changed.ok)
|
|
1113
|
+
sendJson(res, 409, { error: changed.error });
|
|
1114
|
+
else
|
|
1115
|
+
sendJson(res, 200, { ok: true, snapshot: hub.snapshot(key) });
|
|
1116
|
+
}
|
|
1117
|
+
catch (error) {
|
|
1118
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : "invalid Pro preset" });
|
|
1119
|
+
}
|
|
1120
|
+
return true;
|
|
1121
|
+
}
|
|
864
1122
|
if (action === "approval-mode") {
|
|
865
1123
|
let body;
|
|
866
1124
|
try {
|
|
@@ -965,7 +1223,7 @@ async function handleApi(req, res, url, auth) {
|
|
|
965
1223
|
sendJson(res, 404, { error: "not found" });
|
|
966
1224
|
return true;
|
|
967
1225
|
}
|
|
968
|
-
async function handleRequest(req, res, auth) {
|
|
1226
|
+
async function handleRequest(req, res, auth, binding) {
|
|
969
1227
|
let url;
|
|
970
1228
|
try {
|
|
971
1229
|
url = new URL(req.url ?? "/", "http://localhost");
|
|
@@ -974,20 +1232,62 @@ async function handleRequest(req, res, auth) {
|
|
|
974
1232
|
sendText(res, 400, "Bad request");
|
|
975
1233
|
return;
|
|
976
1234
|
}
|
|
977
|
-
|
|
1235
|
+
// The Pro workspace is the same single-page app at /pro, talking to an
|
|
1236
|
+
// isolated API under /pro/api. Static assets stay at the root (the app uses
|
|
1237
|
+
// root-absolute URLs), so only the page route and the API prefix are special.
|
|
1238
|
+
const pathname = url.pathname;
|
|
1239
|
+
if (pathname === "/pro" || pathname === "/pro/") {
|
|
1240
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1241
|
+
sendText(res, 405, "Method not allowed");
|
|
1242
|
+
return;
|
|
1243
|
+
}
|
|
1244
|
+
if (pathname === "/pro/") {
|
|
1245
|
+
// Canonical URL without the trailing slash keeps relative links rooted.
|
|
1246
|
+
res.writeHead(308, { Location: `/pro${url.search}` });
|
|
1247
|
+
res.end();
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
serveStatic(res, "/index.html");
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
let mode = "standard";
|
|
1254
|
+
let apiPathname = pathname;
|
|
1255
|
+
if (pathname.startsWith("/pro/api/")) {
|
|
1256
|
+
mode = "pro";
|
|
1257
|
+
apiPathname = pathname.slice("/pro".length);
|
|
1258
|
+
}
|
|
1259
|
+
if (await handleApi(req, res, url, auth, mode, apiPathname, binding))
|
|
1260
|
+
return;
|
|
1261
|
+
if (mode === "pro") {
|
|
1262
|
+
// Nothing else lives under /pro but the page and its API.
|
|
1263
|
+
sendText(res, 404, "Not found");
|
|
978
1264
|
return;
|
|
1265
|
+
}
|
|
979
1266
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
980
1267
|
sendText(res, 405, "Method not allowed");
|
|
981
1268
|
return;
|
|
982
1269
|
}
|
|
983
|
-
serveStatic(res,
|
|
1270
|
+
serveStatic(res, pathname);
|
|
984
1271
|
}
|
|
985
1272
|
/** Start the web UI. Resolves once the socket is actually listening. */
|
|
986
1273
|
export async function startWebServer(options) {
|
|
987
|
-
const host = options.host ?? "127.0.0.1";
|
|
988
1274
|
const auth = new WebAuth(options.auth !== false);
|
|
1275
|
+
// An explicit --host/--lan wins for this run; otherwise the saved Web access
|
|
1276
|
+
// switch is applied exactly as if `--lan` had been passed.
|
|
1277
|
+
let host = options.host?.trim() || defaultWebHost();
|
|
1278
|
+
let port = options.port;
|
|
1279
|
+
let closed = false;
|
|
1280
|
+
// The settings page reads and changes this binding while the process runs.
|
|
1281
|
+
const binding = {
|
|
1282
|
+
host,
|
|
1283
|
+
port,
|
|
1284
|
+
setHost: (next) => rebind(next),
|
|
1285
|
+
prompt: { pending: options.backgroundPrompt === true },
|
|
1286
|
+
service: options.backgroundService,
|
|
1287
|
+
updateSettings: (body, mode) => updateSettings(body, mode),
|
|
1288
|
+
};
|
|
989
1289
|
const server = http.createServer((req, res) => {
|
|
990
|
-
handleRequest(req, res, auth).catch((err) => {
|
|
1290
|
+
handleRequest(req, res, auth, binding).catch((err) => {
|
|
991
1291
|
if (!res.headersSent) {
|
|
992
1292
|
sendJson(res, 500, { error: err instanceof Error ? err.message : "internal error" });
|
|
993
1293
|
}
|
|
@@ -996,41 +1296,162 @@ export async function startWebServer(options) {
|
|
|
996
1296
|
}
|
|
997
1297
|
});
|
|
998
1298
|
});
|
|
999
|
-
|
|
1299
|
+
/** Wait for the socket to actually listen, or for it to fail. */
|
|
1300
|
+
const listen = (onHost) => new Promise((resolve, reject) => {
|
|
1000
1301
|
const onError = (err) => {
|
|
1001
1302
|
server.removeListener("listening", onListening);
|
|
1002
1303
|
reject(err);
|
|
1003
1304
|
};
|
|
1004
1305
|
const onListening = () => {
|
|
1005
1306
|
server.removeListener("error", onError);
|
|
1006
|
-
// Once listening, the server must tolerate connection-level failures
|
|
1007
|
-
// (resets, EMFILE, half-open sockets) without the process dying. An
|
|
1008
|
-
// 'error' event with no listener is fatal in Node.
|
|
1009
|
-
server.on("error", (err) => {
|
|
1010
|
-
writeCrashLog("web server", err);
|
|
1011
|
-
});
|
|
1012
|
-
server.on("clientError", (_err, socket) => {
|
|
1013
|
-
if (socket.writable)
|
|
1014
|
-
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
1015
|
-
socket.destroy();
|
|
1016
|
-
});
|
|
1017
1307
|
resolve();
|
|
1018
1308
|
};
|
|
1019
1309
|
server.once("error", onError);
|
|
1020
1310
|
server.once("listening", onListening);
|
|
1021
|
-
server.listen(
|
|
1311
|
+
server.listen(port, onHost);
|
|
1022
1312
|
});
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1313
|
+
/** A just-closed port can need a moment to reappear: retry only that race. */
|
|
1314
|
+
async function listenRetry(onHost) {
|
|
1315
|
+
for (let attempt = 0;; attempt += 1) {
|
|
1316
|
+
try {
|
|
1317
|
+
await listen(onHost);
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
catch (err) {
|
|
1321
|
+
if (err.code !== "EADDRINUSE" || attempt >= 20)
|
|
1322
|
+
throw err;
|
|
1323
|
+
await new Promise((done) => setTimeout(done, 10));
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
const syncAddress = () => {
|
|
1328
|
+
const address = webUrls(host, port);
|
|
1329
|
+
binding.host = host;
|
|
1330
|
+
binding.port = port;
|
|
1331
|
+
handle.host = host;
|
|
1332
|
+
handle.port = port;
|
|
1333
|
+
handle.url = address.url;
|
|
1334
|
+
handle.urls = address.urls;
|
|
1335
|
+
options.onAddressChange?.();
|
|
1336
|
+
};
|
|
1337
|
+
// Two tabs saving at once must not interleave close/listen on one socket.
|
|
1338
|
+
let switches = Promise.resolve();
|
|
1339
|
+
function rebind(next) {
|
|
1340
|
+
const target = next.trim() || LOOPBACK_HOST;
|
|
1341
|
+
const task = switches.then(async () => {
|
|
1342
|
+
if (closed)
|
|
1343
|
+
throw new Error("the Web console is shutting down");
|
|
1344
|
+
if (target === host && server.listening)
|
|
1345
|
+
return;
|
|
1346
|
+
// `close` ends acceptance, not the connections already accepted. The
|
|
1347
|
+
// listener is released immediately, which is all the switch needs, so a
|
|
1348
|
+
// streaming turn and the tab that asked for the change stay connected.
|
|
1349
|
+
if (server.listening)
|
|
1350
|
+
server.close();
|
|
1351
|
+
try {
|
|
1352
|
+
await listenRetry(target);
|
|
1353
|
+
}
|
|
1354
|
+
catch (err) {
|
|
1355
|
+
// Put the console back where it was rather than leaving it dark.
|
|
1356
|
+
if (!closed) {
|
|
1357
|
+
try {
|
|
1358
|
+
await listenRetry(host);
|
|
1359
|
+
}
|
|
1360
|
+
catch {
|
|
1361
|
+
// The settings response still reports the original failure.
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
throw err;
|
|
1365
|
+
}
|
|
1366
|
+
// A concurrent shutdown wins: never resurrect a console being closed.
|
|
1367
|
+
if (closed) {
|
|
1368
|
+
server.close();
|
|
1369
|
+
throw new Error("the Web Console is shutting down");
|
|
1370
|
+
}
|
|
1371
|
+
host = target;
|
|
1372
|
+
syncAddress();
|
|
1373
|
+
});
|
|
1374
|
+
switches = task.catch(() => undefined);
|
|
1375
|
+
return task;
|
|
1376
|
+
}
|
|
1377
|
+
// Serialize saves from both workspaces so a failed switch cannot roll back
|
|
1378
|
+
// another tab's successful change.
|
|
1379
|
+
let settingsChanges = Promise.resolve();
|
|
1380
|
+
function updateSettings(body, mode) {
|
|
1381
|
+
const task = settingsChanges.then(async () => {
|
|
1382
|
+
const previous = loadConfig(mode);
|
|
1383
|
+
const wasHost = binding.host;
|
|
1384
|
+
try {
|
|
1385
|
+
updateWebSettings(body, mode);
|
|
1386
|
+
const patch = body?.settings;
|
|
1387
|
+
const desiredHost = patch && Object.prototype.hasOwnProperty.call(patch, "web_lan")
|
|
1388
|
+
? patch.web_lan === true ? WILDCARD_HOST : LOOPBACK_HOST
|
|
1389
|
+
: binding.host;
|
|
1390
|
+
if (desiredHost !== binding.host)
|
|
1391
|
+
await binding.setHost(desiredHost);
|
|
1392
|
+
if (patch && Object.prototype.hasOwnProperty.call(patch, "background")) {
|
|
1393
|
+
if (!binding.service?.configure) {
|
|
1394
|
+
throw new Error("Background residency could not be applied. Restart the console and try again.");
|
|
1395
|
+
}
|
|
1396
|
+
binding.service.configure({ resident: patch.background === true });
|
|
1397
|
+
if (patch.background === true)
|
|
1398
|
+
binding.prompt.pending = false;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
catch (error) {
|
|
1402
|
+
// A failed lifetime or address change must not look saved or active.
|
|
1403
|
+
if (binding.host !== wasHost) {
|
|
1404
|
+
try {
|
|
1405
|
+
await binding.setHost(wasHost);
|
|
1406
|
+
}
|
|
1407
|
+
catch { /* Report the original failure. */ }
|
|
1408
|
+
}
|
|
1409
|
+
const config = loadConfig(mode);
|
|
1410
|
+
config.web_lan = previous.web_lan;
|
|
1411
|
+
config.background = previous.background;
|
|
1412
|
+
try {
|
|
1413
|
+
saveConfig(config, mode);
|
|
1414
|
+
}
|
|
1415
|
+
catch { /* Report the original failure. */ }
|
|
1416
|
+
throw error;
|
|
1417
|
+
}
|
|
1418
|
+
});
|
|
1419
|
+
settingsChanges = task.catch(() => undefined);
|
|
1420
|
+
return task;
|
|
1421
|
+
}
|
|
1422
|
+
const handle = {
|
|
1026
1423
|
host,
|
|
1027
1424
|
port,
|
|
1028
1425
|
authRequired: auth.enabled,
|
|
1029
|
-
|
|
1426
|
+
url: "",
|
|
1427
|
+
urls: [],
|
|
1428
|
+
setHost: (next) => rebind(next),
|
|
1030
1429
|
close: () => new Promise((resolve) => {
|
|
1430
|
+
closed = true;
|
|
1031
1431
|
for (const key of [...webSessions.keys()])
|
|
1032
1432
|
closeWebSession(key);
|
|
1433
|
+
if (!server.listening) {
|
|
1434
|
+
resolve();
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1033
1437
|
server.close(() => resolve());
|
|
1034
1438
|
}),
|
|
1035
1439
|
};
|
|
1440
|
+
await listenRetry(host);
|
|
1441
|
+
// Port 0 means "let the OS pick": remember the real one for every URL.
|
|
1442
|
+
const address = server.address();
|
|
1443
|
+
port = typeof address === "object" && address ? address.port : options.port;
|
|
1444
|
+
syncAddress();
|
|
1445
|
+
// Once listening, the server must tolerate connection-level failures
|
|
1446
|
+
// (resets, EMFILE, half-open sockets) without the process dying: an 'error'
|
|
1447
|
+
// event with no listener is fatal in Node.
|
|
1448
|
+
server.on("error", (err) => {
|
|
1449
|
+
writeCrashLog("web server", err);
|
|
1450
|
+
});
|
|
1451
|
+
server.on("clientError", (_err, socket) => {
|
|
1452
|
+
if (socket.writable)
|
|
1453
|
+
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
1454
|
+
socket.destroy();
|
|
1455
|
+
});
|
|
1456
|
+
return handle;
|
|
1036
1457
|
}
|