@xingjisoft/neutrino 0.2.1 → 0.4.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 +312 -436
- 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/app.js +43 -7
- package/dist/bin.js +5 -2
- package/dist/browser.js +57 -0
- package/dist/config.js +179 -26
- 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/pro/agents.js +104 -0
- package/dist/pro/config.js +88 -0
- package/dist/pro/preflight.js +172 -0
- package/dist/screens/chat.js +54 -38
- package/dist/screens/modelSelect.js +15 -4
- package/dist/screens/settings.js +9 -7
- package/dist/screens/start.js +48 -15
- package/dist/ui.js +58 -0
- package/dist/web/hub.js +31 -4
- package/dist/web/server.js +611 -50
- package/dist/web/session.js +320 -20
- package/dist/web/settings.js +117 -24
- package/package.json +37 -37
- package/web/README.md +158 -16
- package/web/app.js +1603 -128
- package/web/i18n.js +382 -0
- package/web/index.html +289 -37
- package/web/markdown.js +710 -0
- package/web/neutrino-pro.gif +0 -0
- package/web/neutrino.png +0 -0
- package/web/pro.js +184 -0
- package/web/settings.js +322 -193
- package/web/style.css +1378 -42
package/dist/web/server.js
CHANGED
|
@@ -16,13 +16,15 @@ import http from "node:http";
|
|
|
16
16
|
import os from "node:os";
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
|
-
import {
|
|
19
|
+
import { deleteSession, listSessions, loadConfig, normalizeApprovalMode, resolveModelSelection, saveConfig, sessionDir, } from "../config.js";
|
|
20
20
|
import { getWebSettings, updateWebSettings } from "./settings.js";
|
|
21
21
|
import { WebAuth } from "./auth.js";
|
|
22
|
-
import { normalizeCwd } from "../cwd.js";
|
|
22
|
+
import { normalizeCwd, sameCwd } from "../cwd.js";
|
|
23
23
|
import { hub } from "./hub.js";
|
|
24
24
|
import { writeCrashLog } from "../terminal/restore.js";
|
|
25
|
-
import { resumeSession } from "./session.js";
|
|
25
|
+
import { createWebSession, proInitialSelection, resumeSession } from "./session.js";
|
|
26
|
+
import { defaultProPreset, requireProPreset } from "../pro/config.js";
|
|
27
|
+
import { listAgentSessions, loadAgentMessages } from "../agents.js";
|
|
26
28
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
27
29
|
// dist/web -> package root (mirrors the same trick used in config.ts).
|
|
28
30
|
const PKG_ROOT = path.resolve(__dirname, "..", "..");
|
|
@@ -42,6 +44,9 @@ function closeWebSession(key) {
|
|
|
42
44
|
}
|
|
43
45
|
/** Bind addresses that mean "every interface" rather than one host. */
|
|
44
46
|
const WILDCARD_HOSTS = new Set(["0.0.0.0", "::", "[::]", ""]);
|
|
47
|
+
/** The two interfaces the Web access switch moves between. */
|
|
48
|
+
const LOOPBACK_HOST = "127.0.0.1";
|
|
49
|
+
const WILDCARD_HOST = "0.0.0.0";
|
|
45
50
|
/** True when `host` only accepts connections from this machine. */
|
|
46
51
|
export function isLoopbackHost(host) {
|
|
47
52
|
const h = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
@@ -49,6 +54,17 @@ export function isLoopbackHost(host) {
|
|
|
49
54
|
return false;
|
|
50
55
|
return h === "localhost" || h === "::1" || h.startsWith("127.");
|
|
51
56
|
}
|
|
57
|
+
/** True when `host` listens on every interface, i.e. what --lan selects. */
|
|
58
|
+
export function isWildcardHost(host) {
|
|
59
|
+
return WILDCARD_HOSTS.has(host.trim().toLowerCase().replace(/^\[|\]$/g, ""));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The interface a fresh listener uses when the command line did not pick one.
|
|
63
|
+
* The saved Web access switch behaves exactly like `--lan`.
|
|
64
|
+
*/
|
|
65
|
+
function defaultWebHost() {
|
|
66
|
+
return loadConfig().web_lan === true ? WILDCARD_HOST : LOOPBACK_HOST;
|
|
67
|
+
}
|
|
52
68
|
/**
|
|
53
69
|
* Adapters that only ever talk to this machine: hypervisor host-only switches,
|
|
54
70
|
* container bridges and loopback variants. Listing them would bury the address
|
|
@@ -98,6 +114,26 @@ export function webUrls(host, port) {
|
|
|
98
114
|
urls: hosts.map((h) => `http://${h}:${port}/`),
|
|
99
115
|
};
|
|
100
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Settings plus the live socket. The `web_lan` switch reports and changes what
|
|
119
|
+
* is listening right now, so a run started with --lan shows as on even though
|
|
120
|
+
* nothing was saved yet.
|
|
121
|
+
*/
|
|
122
|
+
function settingsPayload(mode, binding) {
|
|
123
|
+
const payload = getWebSettings(mode);
|
|
124
|
+
const lan = isWildcardHost(binding.host);
|
|
125
|
+
const { urls } = webUrls(binding.host, binding.port);
|
|
126
|
+
return {
|
|
127
|
+
...payload,
|
|
128
|
+
settings: {
|
|
129
|
+
...payload.settings,
|
|
130
|
+
web_lan: lan,
|
|
131
|
+
web_host: binding.host,
|
|
132
|
+
// Addresses another device can open; empty unless every interface is bound.
|
|
133
|
+
web_urls: lan ? urls.filter((url) => !url.includes("//127.0.0.1:")) : [],
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
101
137
|
const MIME_TYPES = {
|
|
102
138
|
".html": "text/html; charset=utf-8",
|
|
103
139
|
".css": "text/css; charset=utf-8",
|
|
@@ -119,11 +155,12 @@ const SESSION_ID_RE = /^[A-Za-z0-9._-]{1,200}$/;
|
|
|
119
155
|
function isSafeSessionId(id) {
|
|
120
156
|
return SESSION_ID_RE.test(id) && id !== "." && id !== ".." && !id.includes("..");
|
|
121
157
|
}
|
|
122
|
-
function loadSessionSafe(id) {
|
|
158
|
+
function loadSessionSafe(id, mode = "standard") {
|
|
123
159
|
if (!isSafeSessionId(id))
|
|
124
160
|
return null;
|
|
125
|
-
const
|
|
126
|
-
|
|
161
|
+
const dir = sessionDir(mode);
|
|
162
|
+
const filePath = path.join(dir, `${id}.json`);
|
|
163
|
+
if (path.dirname(filePath) !== dir)
|
|
127
164
|
return null;
|
|
128
165
|
try {
|
|
129
166
|
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
|
@@ -132,10 +169,198 @@ function loadSessionSafe(id) {
|
|
|
132
169
|
return null;
|
|
133
170
|
}
|
|
134
171
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
172
|
+
// ------------------------------------------------------- working directories
|
|
173
|
+
//
|
|
174
|
+
// The browser console can open any folder on this machine, which is exactly
|
|
175
|
+
// what the terminal can do by being launched from that folder. Every route
|
|
176
|
+
// below still sits behind the login cookie: browsing the filesystem is only as
|
|
177
|
+
// exposed as the authenticated console itself.
|
|
178
|
+
class ApiError extends Error {
|
|
179
|
+
status;
|
|
180
|
+
code;
|
|
181
|
+
constructor(status, code, message) {
|
|
182
|
+
super(message);
|
|
183
|
+
this.status = status;
|
|
184
|
+
this.code = code;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function sendApiError(res, error) {
|
|
188
|
+
if (error instanceof ApiError) {
|
|
189
|
+
sendJson(res, error.status, { error: error.message, code: error.code });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const message = error instanceof Error ? error.message : "invalid request";
|
|
193
|
+
sendJson(res, 400, { error: message });
|
|
194
|
+
}
|
|
195
|
+
const MAX_RECENT_WORKSPACES = 12;
|
|
196
|
+
const AGENT_IDS = new Set(["claude", "codex", "opencode"]);
|
|
197
|
+
const AGENT_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,200}$/;
|
|
198
|
+
/** Expand `~` and require an absolute path; used by every folder route. */
|
|
199
|
+
function absolutePath(raw) {
|
|
200
|
+
if (typeof raw !== "string" || !raw.trim()) {
|
|
201
|
+
throw new ApiError(400, "working directory is required", "working directory is required");
|
|
202
|
+
}
|
|
203
|
+
let value = raw.trim();
|
|
204
|
+
if (value === "~")
|
|
205
|
+
value = os.homedir();
|
|
206
|
+
else if (/^~[\\/]/.test(value))
|
|
207
|
+
value = path.join(os.homedir(), value.slice(2));
|
|
208
|
+
if (!path.isAbsolute(value)) {
|
|
209
|
+
throw new ApiError(400, "working directory must be an absolute path", "working directory must be an absolute path");
|
|
210
|
+
}
|
|
211
|
+
return path.resolve(value);
|
|
212
|
+
}
|
|
213
|
+
function existingDirectory(raw) {
|
|
214
|
+
const dir = absolutePath(raw);
|
|
215
|
+
let stat;
|
|
216
|
+
try {
|
|
217
|
+
stat = fs.statSync(dir);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
throw new ApiError(400, "working directory does not exist", "working directory does not exist");
|
|
221
|
+
}
|
|
222
|
+
if (!stat.isDirectory()) {
|
|
223
|
+
throw new ApiError(400, "working directory is not a folder", "working directory is not a folder");
|
|
224
|
+
}
|
|
225
|
+
return dir;
|
|
226
|
+
}
|
|
227
|
+
let cachedRoots = null;
|
|
228
|
+
/** Filesystem roots worth offering as shortcuts (every existing drive on Windows). */
|
|
229
|
+
function filesystemRoots() {
|
|
230
|
+
if (cachedRoots)
|
|
231
|
+
return cachedRoots;
|
|
232
|
+
if (process.platform !== "win32") {
|
|
233
|
+
cachedRoots = ["/"];
|
|
234
|
+
return cachedRoots;
|
|
235
|
+
}
|
|
236
|
+
const roots = [];
|
|
237
|
+
for (let code = 65; code <= 90; code += 1) {
|
|
238
|
+
const root = `${String.fromCharCode(code)}:\\`;
|
|
239
|
+
try {
|
|
240
|
+
if (fs.statSync(root).isDirectory())
|
|
241
|
+
roots.push(root);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
// A drive letter that does not exist is simply not offered.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
cachedRoots = roots.length ? roots : [path.parse(process.cwd()).root];
|
|
248
|
+
return cachedRoots;
|
|
249
|
+
}
|
|
250
|
+
/** One directory listing for the folder picker. Files are hidden: the picker chooses directories. */
|
|
251
|
+
function readFolder(raw) {
|
|
252
|
+
const dir = existingDirectory(raw);
|
|
253
|
+
let entries;
|
|
254
|
+
try {
|
|
255
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
throw new ApiError(403, "cannot read this folder", err instanceof Error ? err.message : "cannot read this folder");
|
|
259
|
+
}
|
|
260
|
+
const folders = entries
|
|
261
|
+
.filter((entry) => entry.isDirectory())
|
|
262
|
+
.map((entry) => ({
|
|
263
|
+
name: entry.name,
|
|
264
|
+
path: path.join(dir, entry.name),
|
|
265
|
+
hidden: entry.name.startsWith("."),
|
|
266
|
+
}))
|
|
267
|
+
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }));
|
|
268
|
+
const parent = path.dirname(dir);
|
|
269
|
+
return {
|
|
270
|
+
path: dir,
|
|
271
|
+
name: path.basename(dir) || dir,
|
|
272
|
+
parent: parent === dir ? null : parent,
|
|
273
|
+
separator: path.sep,
|
|
274
|
+
home: os.homedir(),
|
|
275
|
+
cwd: process.cwd(),
|
|
276
|
+
roots: filesystemRoots(),
|
|
277
|
+
entries: folders,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/** Recent working directories: explicitly opened ones first, then saved-session folders. */
|
|
281
|
+
function recentWorkspacePaths(mode = "standard") {
|
|
282
|
+
const config = loadConfig(mode);
|
|
283
|
+
const stored = Array.isArray(config.web_workspaces)
|
|
284
|
+
? config.web_workspaces.filter((entry) => typeof entry === "string" && entry.trim() !== "")
|
|
285
|
+
: [];
|
|
286
|
+
const saved = listSessions(mode).map((session) => session.cwd).filter(Boolean);
|
|
287
|
+
const seen = new Set();
|
|
288
|
+
const out = [];
|
|
289
|
+
for (const dir of [...stored, ...saved, process.cwd()]) {
|
|
290
|
+
const key = normalizeCwd(dir);
|
|
291
|
+
if (!key || seen.has(key))
|
|
292
|
+
continue;
|
|
293
|
+
seen.add(key);
|
|
294
|
+
out.push(dir);
|
|
295
|
+
if (out.length >= MAX_RECENT_WORKSPACES)
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
function workspaceList(mode = "standard") {
|
|
301
|
+
const sessions = listSessions(mode);
|
|
302
|
+
return recentWorkspacePaths(mode).map((dir) => {
|
|
303
|
+
let exists = false;
|
|
304
|
+
try {
|
|
305
|
+
exists = fs.statSync(dir).isDirectory();
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// A folder that disappeared stays listed, greyed out, so it can be removed by choosing another.
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
path: dir,
|
|
312
|
+
name: path.basename(dir) || dir,
|
|
313
|
+
sessions: sessions.filter((session) => sameCwd(session.cwd, dir)).length,
|
|
314
|
+
exists,
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
/** Best-effort persistence: failing to remember a folder must not fail the conversation. */
|
|
319
|
+
function recordWorkspace(dir, mode = "standard") {
|
|
320
|
+
try {
|
|
321
|
+
const config = loadConfig(mode);
|
|
322
|
+
const stored = Array.isArray(config.web_workspaces)
|
|
323
|
+
? config.web_workspaces.filter((entry) => typeof entry === "string")
|
|
324
|
+
: [];
|
|
325
|
+
config.web_workspaces = [dir, ...stored.filter((entry) => !sameCwd(entry, dir))]
|
|
326
|
+
.slice(0, MAX_RECENT_WORKSPACES);
|
|
327
|
+
saveConfig(config, mode);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// The picked folder still works for this conversation.
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
/** Create one folder inside an existing parent. */
|
|
334
|
+
function createFolder(raw) {
|
|
335
|
+
const target = absolutePath(raw);
|
|
336
|
+
if (target === path.parse(target).root) {
|
|
337
|
+
throw new ApiError(400, "cannot create a folder here", "cannot create a folder here");
|
|
338
|
+
}
|
|
339
|
+
const parent = path.dirname(target);
|
|
340
|
+
if (!fs.existsSync(parent) || !fs.statSync(parent).isDirectory()) {
|
|
341
|
+
throw new ApiError(400, "parent folder does not exist", "parent folder does not exist");
|
|
342
|
+
}
|
|
343
|
+
if (fs.existsSync(target)) {
|
|
344
|
+
throw new ApiError(409, "folder already exists", "folder already exists");
|
|
345
|
+
}
|
|
346
|
+
try {
|
|
347
|
+
fs.mkdirSync(target);
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
throw new ApiError(400, "cannot create a folder here", err instanceof Error ? err.message : "cannot create a folder here");
|
|
351
|
+
}
|
|
352
|
+
return target;
|
|
353
|
+
}
|
|
354
|
+
function isAgentId(value) {
|
|
355
|
+
return AGENT_IDS.has(value);
|
|
356
|
+
}
|
|
357
|
+
function safeAgentSessionId(id) {
|
|
358
|
+
return AGENT_SESSION_ID_RE.test(id) && !id.includes("..") && id !== "." && id !== "..";
|
|
359
|
+
}
|
|
360
|
+
/** Build the sidebar payload: the selected workspace's sessions grouped by directory. */
|
|
361
|
+
function buildOverview(mode = "standard") {
|
|
362
|
+
const sessions = listSessions(mode);
|
|
363
|
+
const lives = hub.list(mode);
|
|
139
364
|
const liveBySession = new Map();
|
|
140
365
|
for (const live of lives) {
|
|
141
366
|
if (live.sessionId)
|
|
@@ -178,7 +403,9 @@ function buildOverview() {
|
|
|
178
403
|
provider: live.provider,
|
|
179
404
|
intensity: live.intensity,
|
|
180
405
|
cwd: live.cwd,
|
|
181
|
-
title:
|
|
406
|
+
// An unnamed conversation keeps an empty title: the browser translates
|
|
407
|
+
// its own fallback, and the terminal/server never impose one language.
|
|
408
|
+
title: live.title || "",
|
|
182
409
|
message_count: 0,
|
|
183
410
|
live: true,
|
|
184
411
|
liveKey: live.key,
|
|
@@ -339,8 +566,7 @@ function streamLive(key, req, res) {
|
|
|
339
566
|
// Without this, a client reset during a write is an uncaught 'error' event.
|
|
340
567
|
res.on("error", cleanup);
|
|
341
568
|
}
|
|
342
|
-
async function handleApi(req, res, url, auth) {
|
|
343
|
-
const { pathname } = url;
|
|
569
|
+
async function handleApi(req, res, url, auth, mode, pathname, binding) {
|
|
344
570
|
if (!pathname.startsWith("/api/"))
|
|
345
571
|
return false;
|
|
346
572
|
if (req.method === "OPTIONS") {
|
|
@@ -386,22 +612,46 @@ async function handleApi(req, res, url, auth) {
|
|
|
386
612
|
}
|
|
387
613
|
if (pathname === "/api/settings") {
|
|
388
614
|
if (req.method === "GET") {
|
|
389
|
-
sendJson(res, 200,
|
|
615
|
+
sendJson(res, 200, settingsPayload(mode, binding));
|
|
390
616
|
}
|
|
391
617
|
else if (req.method === "POST") {
|
|
392
618
|
if (!isSameOrigin(req)) {
|
|
393
619
|
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
394
620
|
return true;
|
|
395
621
|
}
|
|
622
|
+
const wasLan = loadConfig(mode).web_lan === true;
|
|
396
623
|
try {
|
|
397
624
|
const body = await readJsonBody(req);
|
|
398
|
-
updateWebSettings(body);
|
|
625
|
+
updateWebSettings(body, mode);
|
|
626
|
+
// `web_lan` is both a saved preference and a live switch: apply it to
|
|
627
|
+
// the socket now so the console starts or stops answering on the LAN
|
|
628
|
+
// without a restart. Other settings never move the listener, and an
|
|
629
|
+
// explicit --host still decides the interface until this is changed.
|
|
630
|
+
const patch = body?.settings;
|
|
631
|
+
const desired = patch && Object.prototype.hasOwnProperty.call(patch, "web_lan")
|
|
632
|
+
? patch.web_lan === true
|
|
633
|
+
: isWildcardHost(binding.host);
|
|
634
|
+
if (desired !== isWildcardHost(binding.host)) {
|
|
635
|
+
await binding.setHost(desired ? WILDCARD_HOST : LOOPBACK_HOST);
|
|
636
|
+
}
|
|
399
637
|
if (body.settings?.web_password !== undefined) {
|
|
400
638
|
auth.remember(res);
|
|
401
639
|
}
|
|
402
|
-
sendJson(res, 200,
|
|
640
|
+
sendJson(res, 200, settingsPayload(mode, binding));
|
|
403
641
|
}
|
|
404
642
|
catch (err) {
|
|
643
|
+
// A switch that could not bind must not pretend the socket moved:
|
|
644
|
+
// restore the saved preference so the file matches what is running.
|
|
645
|
+
if ((loadConfig(mode).web_lan === true) !== wasLan) {
|
|
646
|
+
try {
|
|
647
|
+
const config = loadConfig(mode);
|
|
648
|
+
config.web_lan = wasLan;
|
|
649
|
+
saveConfig(config, mode);
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
// Reporting the original failure matters more than the rollback.
|
|
653
|
+
}
|
|
654
|
+
}
|
|
405
655
|
sendJson(res, err?.code ? 500 : 400, {
|
|
406
656
|
error: err instanceof Error ? err.message : "invalid settings",
|
|
407
657
|
});
|
|
@@ -413,15 +663,165 @@ async function handleApi(req, res, url, auth) {
|
|
|
413
663
|
return true;
|
|
414
664
|
}
|
|
415
665
|
if (req.method === "GET" && pathname === "/api/health") {
|
|
416
|
-
sendJson(res, 200, { ok: true, live: hub.list().length });
|
|
666
|
+
sendJson(res, 200, { ok: true, live: hub.list(mode).length });
|
|
417
667
|
return true;
|
|
418
668
|
}
|
|
419
669
|
if (req.method === "GET" && pathname === "/api/overview") {
|
|
420
|
-
sendJson(res, 200, buildOverview());
|
|
670
|
+
sendJson(res, 200, buildOverview(mode));
|
|
421
671
|
return true;
|
|
422
672
|
}
|
|
423
673
|
if (req.method === "GET" && pathname === "/api/sessions") {
|
|
424
|
-
sendJson(res, 200, { sessions: listSessions() });
|
|
674
|
+
sendJson(res, 200, { sessions: listSessions(mode) });
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
677
|
+
if (pathname === "/api/workspaces") {
|
|
678
|
+
if (req.method === "GET") {
|
|
679
|
+
sendJson(res, 200, {
|
|
680
|
+
cwd: process.cwd(),
|
|
681
|
+
home: os.homedir(),
|
|
682
|
+
workspaces: workspaceList(mode),
|
|
683
|
+
});
|
|
684
|
+
return true;
|
|
685
|
+
}
|
|
686
|
+
if (req.method === "POST") {
|
|
687
|
+
if (!isSameOrigin(req)) {
|
|
688
|
+
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
try {
|
|
692
|
+
const body = await readJsonBody(req);
|
|
693
|
+
const dir = existingDirectory(body?.path);
|
|
694
|
+
recordWorkspace(dir, mode);
|
|
695
|
+
sendJson(res, 200, { ok: true, path: dir, workspaces: workspaceList(mode) });
|
|
696
|
+
}
|
|
697
|
+
catch (err) {
|
|
698
|
+
sendApiError(res, err);
|
|
699
|
+
}
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
703
|
+
return true;
|
|
704
|
+
}
|
|
705
|
+
if (pathname === "/api/fs") {
|
|
706
|
+
if (req.method !== "GET") {
|
|
707
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
708
|
+
return true;
|
|
709
|
+
}
|
|
710
|
+
try {
|
|
711
|
+
sendJson(res, 200, readFolder(url.searchParams.get("path") ?? process.cwd()));
|
|
712
|
+
}
|
|
713
|
+
catch (err) {
|
|
714
|
+
sendApiError(res, err);
|
|
715
|
+
}
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
if (pathname === "/api/fs/mkdir") {
|
|
719
|
+
if (req.method !== "POST") {
|
|
720
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
if (!isSameOrigin(req)) {
|
|
724
|
+
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
725
|
+
return true;
|
|
726
|
+
}
|
|
727
|
+
try {
|
|
728
|
+
const body = await readJsonBody(req);
|
|
729
|
+
const created = createFolder(body?.path);
|
|
730
|
+
sendJson(res, 200, { ok: true, path: created });
|
|
731
|
+
}
|
|
732
|
+
catch (err) {
|
|
733
|
+
sendApiError(res, err);
|
|
734
|
+
}
|
|
735
|
+
return true;
|
|
736
|
+
}
|
|
737
|
+
// Start a brand-new conversation in a working directory chosen by the
|
|
738
|
+
// browser. Unlike `/continue`, there is no transcript to load first.
|
|
739
|
+
if (pathname === "/api/conversations") {
|
|
740
|
+
if (req.method !== "POST") {
|
|
741
|
+
sendJson(res, 405, { error: "method not allowed" });
|
|
742
|
+
return true;
|
|
743
|
+
}
|
|
744
|
+
if (!isSameOrigin(req)) {
|
|
745
|
+
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
748
|
+
try {
|
|
749
|
+
const body = await readJsonBody(req);
|
|
750
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
751
|
+
throw new ApiError(400, "invalid request", "invalid request");
|
|
752
|
+
}
|
|
753
|
+
const input = body;
|
|
754
|
+
const cwd = existingDirectory(input.cwd ?? process.cwd());
|
|
755
|
+
const requested = input.selection === undefined
|
|
756
|
+
? getWebSettings(mode).selection
|
|
757
|
+
: input.selection;
|
|
758
|
+
const preset = mode === "pro" ? requireProPreset(input.pro_preset ?? defaultProPreset()) : undefined;
|
|
759
|
+
const selection = mode === "pro" ? proInitialSelection(preset) : resolveModelSelection(requested, false);
|
|
760
|
+
let approvalMode;
|
|
761
|
+
if (input.approval_mode !== undefined) {
|
|
762
|
+
if (input.approval_mode !== "request" && input.approval_mode !== "auto") {
|
|
763
|
+
throw new ApiError(400, "invalid approval mode", "invalid approval mode");
|
|
764
|
+
}
|
|
765
|
+
approvalMode = normalizeApprovalMode(input.approval_mode);
|
|
766
|
+
}
|
|
767
|
+
const session = createWebSession(cwd, selection, approvalMode, mode, preset);
|
|
768
|
+
const key = session.start();
|
|
769
|
+
webSessions.set(key, session);
|
|
770
|
+
recordWorkspace(cwd, mode);
|
|
771
|
+
sendJson(res, 200, { ok: true, key, cwd, snapshot: hub.snapshot(key) });
|
|
772
|
+
}
|
|
773
|
+
catch (err) {
|
|
774
|
+
sendApiError(res, err);
|
|
775
|
+
}
|
|
776
|
+
return true;
|
|
777
|
+
}
|
|
778
|
+
const deleteMatch = /^\/api\/session\/([^/]+)\/delete$/.exec(pathname);
|
|
779
|
+
if (req.method === "POST" && deleteMatch) {
|
|
780
|
+
if (!isSameOrigin(req)) {
|
|
781
|
+
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
782
|
+
return true;
|
|
783
|
+
}
|
|
784
|
+
const id = decodeURIComponent(deleteMatch[1]);
|
|
785
|
+
if (!isSafeSessionId(id)) {
|
|
786
|
+
sendJson(res, 400, { error: "invalid session id" });
|
|
787
|
+
return true;
|
|
788
|
+
}
|
|
789
|
+
if (hub.list(mode).some((entry) => entry.sessionId === id)) {
|
|
790
|
+
sendJson(res, 409, { error: "cannot delete a running conversation" });
|
|
791
|
+
return true;
|
|
792
|
+
}
|
|
793
|
+
if (!deleteSession(id, mode)) {
|
|
794
|
+
sendJson(res, 404, { error: "session not found" });
|
|
795
|
+
return true;
|
|
796
|
+
}
|
|
797
|
+
sendJson(res, 200, { ok: true, deleted: true });
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
// Read-only history recorded by other agents (Claude Code, Codex, OpenCode).
|
|
801
|
+
const agentSessionsMatch = /^\/api\/agents\/([^/]+)\/sessions$/.exec(pathname);
|
|
802
|
+
if (req.method === "GET" && agentSessionsMatch) {
|
|
803
|
+
const agent = decodeURIComponent(agentSessionsMatch[1]);
|
|
804
|
+
if (!isAgentId(agent)) {
|
|
805
|
+
sendJson(res, 400, { error: "unknown agent" });
|
|
806
|
+
return true;
|
|
807
|
+
}
|
|
808
|
+
const cwd = url.searchParams.get("cwd") || process.cwd();
|
|
809
|
+
sendJson(res, 200, { agent, cwd, sessions: listAgentSessions(agent, cwd) });
|
|
810
|
+
return true;
|
|
811
|
+
}
|
|
812
|
+
const agentSessionMatch = /^\/api\/agents\/([^/]+)\/sessions\/([^/]+)$/.exec(pathname);
|
|
813
|
+
if (req.method === "GET" && agentSessionMatch) {
|
|
814
|
+
const agent = decodeURIComponent(agentSessionMatch[1]);
|
|
815
|
+
const id = decodeURIComponent(agentSessionMatch[2]);
|
|
816
|
+
if (!isAgentId(agent)) {
|
|
817
|
+
sendJson(res, 400, { error: "unknown agent" });
|
|
818
|
+
return true;
|
|
819
|
+
}
|
|
820
|
+
if (!safeAgentSessionId(id)) {
|
|
821
|
+
sendJson(res, 400, { error: "invalid session id" });
|
|
822
|
+
return true;
|
|
823
|
+
}
|
|
824
|
+
sendJson(res, 200, { agent, id, messages: loadAgentMessages(agent, id) });
|
|
425
825
|
return true;
|
|
426
826
|
}
|
|
427
827
|
const sessionMatch = /^\/api\/session\/([^/]+)$/.exec(pathname);
|
|
@@ -431,7 +831,7 @@ async function handleApi(req, res, url, auth) {
|
|
|
431
831
|
sendJson(res, 400, { error: "invalid session id" });
|
|
432
832
|
return true;
|
|
433
833
|
}
|
|
434
|
-
const data = loadSessionSafe(id);
|
|
834
|
+
const data = loadSessionSafe(id, mode);
|
|
435
835
|
if (!data) {
|
|
436
836
|
sendJson(res, 404, { error: "session not found" });
|
|
437
837
|
return true;
|
|
@@ -451,21 +851,32 @@ async function handleApi(req, res, url, auth) {
|
|
|
451
851
|
return true;
|
|
452
852
|
}
|
|
453
853
|
let selection;
|
|
854
|
+
let preset;
|
|
454
855
|
try {
|
|
455
856
|
const body = await readJsonBody(req);
|
|
456
857
|
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
457
858
|
throw new Error("invalid JSON body");
|
|
458
859
|
const requested = body.selection;
|
|
459
|
-
if (requested !== undefined)
|
|
860
|
+
if (requested !== undefined && mode !== "pro")
|
|
460
861
|
selection = resolveModelSelection(requested);
|
|
862
|
+
const requestedPreset = body.pro_preset;
|
|
863
|
+
if (requestedPreset !== undefined && mode === "pro")
|
|
864
|
+
preset = requireProPreset(requestedPreset);
|
|
461
865
|
}
|
|
462
866
|
catch (err) {
|
|
463
867
|
sendJson(res, 400, { error: err instanceof Error ? err.message : "invalid model selection" });
|
|
464
868
|
return true;
|
|
465
869
|
}
|
|
466
870
|
// Already running (TUI or another tab)? Attach instead of duplicating.
|
|
467
|
-
const existing = hub.list().find((entry) => entry.sessionId === id);
|
|
871
|
+
const existing = hub.list(mode).find((entry) => entry.sessionId === id);
|
|
468
872
|
if (existing) {
|
|
873
|
+
if (preset !== undefined) {
|
|
874
|
+
const changed = hub.setProPreset(existing.key, preset);
|
|
875
|
+
if (!changed.ok) {
|
|
876
|
+
sendJson(res, 409, { error: changed.error });
|
|
877
|
+
return true;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
469
880
|
if (selection) {
|
|
470
881
|
const changed = hub.configure(existing.key, selection);
|
|
471
882
|
if (!changed.ok) {
|
|
@@ -476,15 +887,17 @@ async function handleApi(req, res, url, auth) {
|
|
|
476
887
|
sendJson(res, 200, { key: existing.key, attached: true });
|
|
477
888
|
return true;
|
|
478
889
|
}
|
|
479
|
-
const data = loadSessionSafe(id);
|
|
890
|
+
const data = loadSessionSafe(id, mode);
|
|
480
891
|
if (!data) {
|
|
481
892
|
sendJson(res, 404, { error: "session not found" });
|
|
482
893
|
return true;
|
|
483
894
|
}
|
|
895
|
+
if (preset !== undefined)
|
|
896
|
+
data.pro_preset = preset;
|
|
484
897
|
const requested = selection ? {
|
|
485
898
|
provider: selection.provider, model: selection.model.name, intensity: selection.intensity,
|
|
486
899
|
} : undefined;
|
|
487
|
-
const outcome = resumeSession(data, requested);
|
|
900
|
+
const outcome = resumeSession(data, requested, mode);
|
|
488
901
|
if (!outcome.ok) {
|
|
489
902
|
sendJson(res, 400, { error: outcome.error });
|
|
490
903
|
return true;
|
|
@@ -496,15 +909,22 @@ async function handleApi(req, res, url, auth) {
|
|
|
496
909
|
}
|
|
497
910
|
const streamMatch = /^\/api\/live\/([^/]+)\/stream$/.exec(pathname);
|
|
498
911
|
if (req.method === "GET" && streamMatch) {
|
|
499
|
-
|
|
912
|
+
const key = decodeURIComponent(streamMatch[1]);
|
|
913
|
+
// A Pro tab must never observe (or be handed) a session from the other
|
|
914
|
+
// workspace just because it guessed the key.
|
|
915
|
+
if (!hub.has(key, mode)) {
|
|
916
|
+
sendJson(res, 404, { error: "unknown live session" });
|
|
917
|
+
return true;
|
|
918
|
+
}
|
|
919
|
+
streamLive(key, req, res);
|
|
500
920
|
return true;
|
|
501
921
|
}
|
|
502
|
-
const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|approval-mode))?$/.exec(pathname);
|
|
922
|
+
const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|pro-preset|approval-mode|clear))?$/.exec(pathname);
|
|
503
923
|
if (liveMatch) {
|
|
504
924
|
const key = decodeURIComponent(liveMatch[1]);
|
|
505
925
|
const action = liveMatch[2];
|
|
506
926
|
if (req.method === "GET" && !action) {
|
|
507
|
-
const snapshot = hub.snapshot(key);
|
|
927
|
+
const snapshot = hub.has(key, mode) ? hub.snapshot(key) : null;
|
|
508
928
|
if (!snapshot) {
|
|
509
929
|
sendJson(res, 404, { error: "unknown live session" });
|
|
510
930
|
return true;
|
|
@@ -517,10 +937,29 @@ async function handleApi(req, res, url, auth) {
|
|
|
517
937
|
sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
518
938
|
return true;
|
|
519
939
|
}
|
|
520
|
-
if (!hub.has(key)) {
|
|
940
|
+
if (!hub.has(key, mode)) {
|
|
521
941
|
sendJson(res, 404, { error: "unknown live session" });
|
|
522
942
|
return true;
|
|
523
943
|
}
|
|
944
|
+
if (action === "pro-preset") {
|
|
945
|
+
if (mode !== "pro") {
|
|
946
|
+
sendJson(res, 404, { error: "Pro presets are only available in Pro mode" });
|
|
947
|
+
return true;
|
|
948
|
+
}
|
|
949
|
+
try {
|
|
950
|
+
const body = await readJsonBody(req);
|
|
951
|
+
const preset = requireProPreset(body?.preset);
|
|
952
|
+
const changed = hub.setProPreset(key, preset);
|
|
953
|
+
if (!changed.ok)
|
|
954
|
+
sendJson(res, 409, { error: changed.error });
|
|
955
|
+
else
|
|
956
|
+
sendJson(res, 200, { ok: true, snapshot: hub.snapshot(key) });
|
|
957
|
+
}
|
|
958
|
+
catch (error) {
|
|
959
|
+
sendJson(res, 400, { error: error instanceof Error ? error.message : "invalid Pro preset" });
|
|
960
|
+
}
|
|
961
|
+
return true;
|
|
962
|
+
}
|
|
524
963
|
if (action === "approval-mode") {
|
|
525
964
|
let body;
|
|
526
965
|
try {
|
|
@@ -593,6 +1032,14 @@ async function handleApi(req, res, url, auth) {
|
|
|
593
1032
|
sendJson(res, 200, { ok: true, closed: true });
|
|
594
1033
|
return true;
|
|
595
1034
|
}
|
|
1035
|
+
if (action === "clear") {
|
|
1036
|
+
if (!hub.clear(key)) {
|
|
1037
|
+
sendJson(res, 409, { error: "this session cannot be cleared" });
|
|
1038
|
+
return true;
|
|
1039
|
+
}
|
|
1040
|
+
sendJson(res, 200, { ok: true, snapshot: hub.snapshot(key) });
|
|
1041
|
+
return true;
|
|
1042
|
+
}
|
|
596
1043
|
// action === "message"
|
|
597
1044
|
let body;
|
|
598
1045
|
try {
|
|
@@ -617,7 +1064,7 @@ async function handleApi(req, res, url, auth) {
|
|
|
617
1064
|
sendJson(res, 404, { error: "not found" });
|
|
618
1065
|
return true;
|
|
619
1066
|
}
|
|
620
|
-
async function handleRequest(req, res, auth) {
|
|
1067
|
+
async function handleRequest(req, res, auth, binding) {
|
|
621
1068
|
let url;
|
|
622
1069
|
try {
|
|
623
1070
|
url = new URL(req.url ?? "/", "http://localhost");
|
|
@@ -626,20 +1073,59 @@ async function handleRequest(req, res, auth) {
|
|
|
626
1073
|
sendText(res, 400, "Bad request");
|
|
627
1074
|
return;
|
|
628
1075
|
}
|
|
629
|
-
|
|
1076
|
+
// The Pro workspace is the same single-page app at /pro, talking to an
|
|
1077
|
+
// isolated API under /pro/api. Static assets stay at the root (the app uses
|
|
1078
|
+
// root-absolute URLs), so only the page route and the API prefix are special.
|
|
1079
|
+
const pathname = url.pathname;
|
|
1080
|
+
if (pathname === "/pro" || pathname === "/pro/") {
|
|
1081
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1082
|
+
sendText(res, 405, "Method not allowed");
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
if (pathname === "/pro/") {
|
|
1086
|
+
// Canonical URL without the trailing slash keeps relative links rooted.
|
|
1087
|
+
res.writeHead(308, { Location: `/pro${url.search}` });
|
|
1088
|
+
res.end();
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
serveStatic(res, "/index.html");
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
let mode = "standard";
|
|
1095
|
+
let apiPathname = pathname;
|
|
1096
|
+
if (pathname.startsWith("/pro/api/")) {
|
|
1097
|
+
mode = "pro";
|
|
1098
|
+
apiPathname = pathname.slice("/pro".length);
|
|
1099
|
+
}
|
|
1100
|
+
if (await handleApi(req, res, url, auth, mode, apiPathname, binding))
|
|
630
1101
|
return;
|
|
1102
|
+
if (mode === "pro") {
|
|
1103
|
+
// Nothing else lives under /pro but the page and its API.
|
|
1104
|
+
sendText(res, 404, "Not found");
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
631
1107
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
632
1108
|
sendText(res, 405, "Method not allowed");
|
|
633
1109
|
return;
|
|
634
1110
|
}
|
|
635
|
-
serveStatic(res,
|
|
1111
|
+
serveStatic(res, pathname);
|
|
636
1112
|
}
|
|
637
1113
|
/** Start the web UI. Resolves once the socket is actually listening. */
|
|
638
1114
|
export async function startWebServer(options) {
|
|
639
|
-
const host = options.host ?? "127.0.0.1";
|
|
640
1115
|
const auth = new WebAuth(options.auth !== false);
|
|
1116
|
+
// An explicit --host/--lan wins for this run; otherwise the saved Web access
|
|
1117
|
+
// switch is applied exactly as if `--lan` had been passed.
|
|
1118
|
+
let host = options.host?.trim() || defaultWebHost();
|
|
1119
|
+
let port = options.port;
|
|
1120
|
+
let closed = false;
|
|
1121
|
+
// The settings page reads and changes this binding while the process runs.
|
|
1122
|
+
const binding = {
|
|
1123
|
+
host,
|
|
1124
|
+
port,
|
|
1125
|
+
setHost: (next) => rebind(next),
|
|
1126
|
+
};
|
|
641
1127
|
const server = http.createServer((req, res) => {
|
|
642
|
-
handleRequest(req, res, auth).catch((err) => {
|
|
1128
|
+
handleRequest(req, res, auth, binding).catch((err) => {
|
|
643
1129
|
if (!res.headersSent) {
|
|
644
1130
|
sendJson(res, 500, { error: err instanceof Error ? err.message : "internal error" });
|
|
645
1131
|
}
|
|
@@ -648,41 +1134,116 @@ export async function startWebServer(options) {
|
|
|
648
1134
|
}
|
|
649
1135
|
});
|
|
650
1136
|
});
|
|
651
|
-
|
|
1137
|
+
/** Wait for the socket to actually listen, or for it to fail. */
|
|
1138
|
+
const listen = (onHost) => new Promise((resolve, reject) => {
|
|
652
1139
|
const onError = (err) => {
|
|
653
1140
|
server.removeListener("listening", onListening);
|
|
654
1141
|
reject(err);
|
|
655
1142
|
};
|
|
656
1143
|
const onListening = () => {
|
|
657
1144
|
server.removeListener("error", onError);
|
|
658
|
-
// Once listening, the server must tolerate connection-level failures
|
|
659
|
-
// (resets, EMFILE, half-open sockets) without the process dying. An
|
|
660
|
-
// 'error' event with no listener is fatal in Node.
|
|
661
|
-
server.on("error", (err) => {
|
|
662
|
-
writeCrashLog("web server", err);
|
|
663
|
-
});
|
|
664
|
-
server.on("clientError", (_err, socket) => {
|
|
665
|
-
if (socket.writable)
|
|
666
|
-
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
667
|
-
socket.destroy();
|
|
668
|
-
});
|
|
669
1145
|
resolve();
|
|
670
1146
|
};
|
|
671
1147
|
server.once("error", onError);
|
|
672
1148
|
server.once("listening", onListening);
|
|
673
|
-
server.listen(
|
|
1149
|
+
server.listen(port, onHost);
|
|
674
1150
|
});
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
1151
|
+
/** A just-closed port can need a moment to reappear: retry only that race. */
|
|
1152
|
+
async function listenRetry(onHost) {
|
|
1153
|
+
for (let attempt = 0;; attempt += 1) {
|
|
1154
|
+
try {
|
|
1155
|
+
await listen(onHost);
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
catch (err) {
|
|
1159
|
+
if (err.code !== "EADDRINUSE" || attempt >= 20)
|
|
1160
|
+
throw err;
|
|
1161
|
+
await new Promise((done) => setTimeout(done, 10));
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
const syncAddress = () => {
|
|
1166
|
+
const address = webUrls(host, port);
|
|
1167
|
+
binding.host = host;
|
|
1168
|
+
binding.port = port;
|
|
1169
|
+
handle.host = host;
|
|
1170
|
+
handle.port = port;
|
|
1171
|
+
handle.url = address.url;
|
|
1172
|
+
handle.urls = address.urls;
|
|
1173
|
+
};
|
|
1174
|
+
// Two tabs saving at once must not interleave close/listen on one socket.
|
|
1175
|
+
let switches = Promise.resolve();
|
|
1176
|
+
function rebind(next) {
|
|
1177
|
+
const target = next.trim() || LOOPBACK_HOST;
|
|
1178
|
+
const task = switches.then(async () => {
|
|
1179
|
+
if (closed)
|
|
1180
|
+
throw new Error("the Web console is shutting down");
|
|
1181
|
+
if (target === host && server.listening)
|
|
1182
|
+
return;
|
|
1183
|
+
// `close` ends acceptance, not the connections already accepted. The
|
|
1184
|
+
// listener is released immediately, which is all the switch needs, so a
|
|
1185
|
+
// streaming turn and the tab that asked for the change stay connected.
|
|
1186
|
+
if (server.listening)
|
|
1187
|
+
server.close();
|
|
1188
|
+
try {
|
|
1189
|
+
await listenRetry(target);
|
|
1190
|
+
}
|
|
1191
|
+
catch (err) {
|
|
1192
|
+
// Put the console back where it was rather than leaving it dark.
|
|
1193
|
+
if (!closed) {
|
|
1194
|
+
try {
|
|
1195
|
+
await listenRetry(host);
|
|
1196
|
+
}
|
|
1197
|
+
catch {
|
|
1198
|
+
// The settings response still reports the original failure.
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
throw err;
|
|
1202
|
+
}
|
|
1203
|
+
// A concurrent shutdown wins: never resurrect a console being closed.
|
|
1204
|
+
if (closed) {
|
|
1205
|
+
server.close();
|
|
1206
|
+
throw new Error("the Web Console is shutting down");
|
|
1207
|
+
}
|
|
1208
|
+
host = target;
|
|
1209
|
+
syncAddress();
|
|
1210
|
+
});
|
|
1211
|
+
switches = task.catch(() => undefined);
|
|
1212
|
+
return task;
|
|
1213
|
+
}
|
|
1214
|
+
const handle = {
|
|
678
1215
|
host,
|
|
679
1216
|
port,
|
|
680
1217
|
authRequired: auth.enabled,
|
|
681
|
-
|
|
1218
|
+
url: "",
|
|
1219
|
+
urls: [],
|
|
1220
|
+
setHost: (next) => rebind(next),
|
|
682
1221
|
close: () => new Promise((resolve) => {
|
|
1222
|
+
closed = true;
|
|
683
1223
|
for (const key of [...webSessions.keys()])
|
|
684
1224
|
closeWebSession(key);
|
|
1225
|
+
if (!server.listening) {
|
|
1226
|
+
resolve();
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
685
1229
|
server.close(() => resolve());
|
|
686
1230
|
}),
|
|
687
1231
|
};
|
|
1232
|
+
await listenRetry(host);
|
|
1233
|
+
// Port 0 means "let the OS pick": remember the real one for every URL.
|
|
1234
|
+
const address = server.address();
|
|
1235
|
+
port = typeof address === "object" && address ? address.port : options.port;
|
|
1236
|
+
syncAddress();
|
|
1237
|
+
// Once listening, the server must tolerate connection-level failures
|
|
1238
|
+
// (resets, EMFILE, half-open sockets) without the process dying: an 'error'
|
|
1239
|
+
// event with no listener is fatal in Node.
|
|
1240
|
+
server.on("error", (err) => {
|
|
1241
|
+
writeCrashLog("web server", err);
|
|
1242
|
+
});
|
|
1243
|
+
server.on("clientError", (_err, socket) => {
|
|
1244
|
+
if (socket.writable)
|
|
1245
|
+
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
1246
|
+
socket.destroy();
|
|
1247
|
+
});
|
|
1248
|
+
return handle;
|
|
688
1249
|
}
|