@xingjisoft/neutrino 0.3.0 → 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.
Files changed (42) hide show
  1. package/README.md +312 -464
  2. package/assets/pro/backend_output_model.txt +5 -0
  3. package/assets/pro/claude-module-prompt.txt +65 -0
  4. package/assets/pro/common.txt +9 -0
  5. package/assets/pro/confirm_model.txt +5 -0
  6. package/assets/pro/front_output_model.txt +9 -0
  7. package/assets/pro/genimage_model.txt +1 -0
  8. package/assets/pro/main_model.txt +5 -0
  9. package/assets/pro/model-template.json +376 -0
  10. package/assets/pro/modeling_model.txt +5 -0
  11. package/assets/pro/plan_model.txt +8 -0
  12. package/assets/pro/thinking_model.txt +3 -0
  13. package/dist/app.js +43 -7
  14. package/dist/bin.js +5 -2
  15. package/dist/browser.js +57 -0
  16. package/dist/config.js +170 -26
  17. package/dist/index.js +1 -1
  18. package/dist/llm/approval.js +6 -2
  19. package/dist/llm/client.js +197 -28
  20. package/dist/llm/subagent.js +37 -1
  21. package/dist/pro/agents.js +104 -0
  22. package/dist/pro/config.js +88 -0
  23. package/dist/pro/preflight.js +172 -0
  24. package/dist/screens/chat.js +14 -8
  25. package/dist/screens/modelSelect.js +15 -4
  26. package/dist/screens/settings.js +9 -7
  27. package/dist/screens/start.js +48 -15
  28. package/dist/ui.js +58 -0
  29. package/dist/web/hub.js +22 -4
  30. package/dist/web/server.js +279 -66
  31. package/dist/web/session.js +258 -22
  32. package/dist/web/settings.js +91 -24
  33. package/package.json +37 -37
  34. package/web/README.md +87 -13
  35. package/web/app.js +304 -14
  36. package/web/i18n.js +382 -345
  37. package/web/index.html +90 -14
  38. package/web/neutrino-pro.gif +0 -0
  39. package/web/neutrino.png +0 -0
  40. package/web/pro.js +184 -0
  41. package/web/settings.js +322 -247
  42. package/web/style.css +297 -0
@@ -16,13 +16,14 @@ 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 { SESSION_DIR, deleteSession, listSessions, loadConfig, normalizeApprovalMode, resolveModelSelection, saveConfig, } from "../config.js";
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
22
  import { normalizeCwd, sameCwd } from "../cwd.js";
23
23
  import { hub } from "./hub.js";
24
24
  import { writeCrashLog } from "../terminal/restore.js";
25
- import { createWebSession, resumeSession } from "./session.js";
25
+ import { createWebSession, proInitialSelection, resumeSession } from "./session.js";
26
+ import { defaultProPreset, requireProPreset } from "../pro/config.js";
26
27
  import { listAgentSessions, loadAgentMessages } from "../agents.js";
27
28
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
28
29
  // dist/web -> package root (mirrors the same trick used in config.ts).
@@ -43,6 +44,9 @@ function closeWebSession(key) {
43
44
  }
44
45
  /** Bind addresses that mean "every interface" rather than one host. */
45
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";
46
50
  /** True when `host` only accepts connections from this machine. */
47
51
  export function isLoopbackHost(host) {
48
52
  const h = host.trim().toLowerCase().replace(/^\[|\]$/g, "");
@@ -50,6 +54,17 @@ export function isLoopbackHost(host) {
50
54
  return false;
51
55
  return h === "localhost" || h === "::1" || h.startsWith("127.");
52
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
+ }
53
68
  /**
54
69
  * Adapters that only ever talk to this machine: hypervisor host-only switches,
55
70
  * container bridges and loopback variants. Listing them would bury the address
@@ -99,6 +114,26 @@ export function webUrls(host, port) {
99
114
  urls: hosts.map((h) => `http://${h}:${port}/`),
100
115
  };
101
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
+ }
102
137
  const MIME_TYPES = {
103
138
  ".html": "text/html; charset=utf-8",
104
139
  ".css": "text/css; charset=utf-8",
@@ -120,11 +155,12 @@ const SESSION_ID_RE = /^[A-Za-z0-9._-]{1,200}$/;
120
155
  function isSafeSessionId(id) {
121
156
  return SESSION_ID_RE.test(id) && id !== "." && id !== ".." && !id.includes("..");
122
157
  }
123
- function loadSessionSafe(id) {
158
+ function loadSessionSafe(id, mode = "standard") {
124
159
  if (!isSafeSessionId(id))
125
160
  return null;
126
- const filePath = path.join(SESSION_DIR, `${id}.json`);
127
- if (path.dirname(filePath) !== SESSION_DIR)
161
+ const dir = sessionDir(mode);
162
+ const filePath = path.join(dir, `${id}.json`);
163
+ if (path.dirname(filePath) !== dir)
128
164
  return null;
129
165
  try {
130
166
  return JSON.parse(fs.readFileSync(filePath, "utf-8"));
@@ -242,12 +278,12 @@ function readFolder(raw) {
242
278
  };
243
279
  }
244
280
  /** Recent working directories: explicitly opened ones first, then saved-session folders. */
245
- function recentWorkspacePaths() {
246
- const config = loadConfig();
281
+ function recentWorkspacePaths(mode = "standard") {
282
+ const config = loadConfig(mode);
247
283
  const stored = Array.isArray(config.web_workspaces)
248
284
  ? config.web_workspaces.filter((entry) => typeof entry === "string" && entry.trim() !== "")
249
285
  : [];
250
- const saved = listSessions().map((session) => session.cwd).filter(Boolean);
286
+ const saved = listSessions(mode).map((session) => session.cwd).filter(Boolean);
251
287
  const seen = new Set();
252
288
  const out = [];
253
289
  for (const dir of [...stored, ...saved, process.cwd()]) {
@@ -261,9 +297,9 @@ function recentWorkspacePaths() {
261
297
  }
262
298
  return out;
263
299
  }
264
- function workspaceList() {
265
- const sessions = listSessions();
266
- return recentWorkspacePaths().map((dir) => {
300
+ function workspaceList(mode = "standard") {
301
+ const sessions = listSessions(mode);
302
+ return recentWorkspacePaths(mode).map((dir) => {
267
303
  let exists = false;
268
304
  try {
269
305
  exists = fs.statSync(dir).isDirectory();
@@ -280,15 +316,15 @@ function workspaceList() {
280
316
  });
281
317
  }
282
318
  /** Best-effort persistence: failing to remember a folder must not fail the conversation. */
283
- function recordWorkspace(dir) {
319
+ function recordWorkspace(dir, mode = "standard") {
284
320
  try {
285
- const config = loadConfig();
321
+ const config = loadConfig(mode);
286
322
  const stored = Array.isArray(config.web_workspaces)
287
323
  ? config.web_workspaces.filter((entry) => typeof entry === "string")
288
324
  : [];
289
325
  config.web_workspaces = [dir, ...stored.filter((entry) => !sameCwd(entry, dir))]
290
326
  .slice(0, MAX_RECENT_WORKSPACES);
291
- saveConfig(config);
327
+ saveConfig(config, mode);
292
328
  }
293
329
  catch {
294
330
  // The picked folder still works for this conversation.
@@ -321,10 +357,10 @@ function isAgentId(value) {
321
357
  function safeAgentSessionId(id) {
322
358
  return AGENT_SESSION_ID_RE.test(id) && !id.includes("..") && id !== "." && id !== "..";
323
359
  }
324
- /** Build the sidebar payload: every session grouped by working directory. */
325
- function buildOverview() {
326
- const sessions = listSessions();
327
- const lives = hub.list();
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);
328
364
  const liveBySession = new Map();
329
365
  for (const live of lives) {
330
366
  if (live.sessionId)
@@ -530,8 +566,7 @@ function streamLive(key, req, res) {
530
566
  // Without this, a client reset during a write is an uncaught 'error' event.
531
567
  res.on("error", cleanup);
532
568
  }
533
- async function handleApi(req, res, url, auth) {
534
- const { pathname } = url;
569
+ async function handleApi(req, res, url, auth, mode, pathname, binding) {
535
570
  if (!pathname.startsWith("/api/"))
536
571
  return false;
537
572
  if (req.method === "OPTIONS") {
@@ -577,22 +612,46 @@ async function handleApi(req, res, url, auth) {
577
612
  }
578
613
  if (pathname === "/api/settings") {
579
614
  if (req.method === "GET") {
580
- sendJson(res, 200, getWebSettings());
615
+ sendJson(res, 200, settingsPayload(mode, binding));
581
616
  }
582
617
  else if (req.method === "POST") {
583
618
  if (!isSameOrigin(req)) {
584
619
  sendJson(res, 403, { error: "cross-origin request rejected" });
585
620
  return true;
586
621
  }
622
+ const wasLan = loadConfig(mode).web_lan === true;
587
623
  try {
588
624
  const body = await readJsonBody(req);
589
- 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
+ }
590
637
  if (body.settings?.web_password !== undefined) {
591
638
  auth.remember(res);
592
639
  }
593
- sendJson(res, 200, getWebSettings());
640
+ sendJson(res, 200, settingsPayload(mode, binding));
594
641
  }
595
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
+ }
596
655
  sendJson(res, err?.code ? 500 : 400, {
597
656
  error: err instanceof Error ? err.message : "invalid settings",
598
657
  });
@@ -604,15 +663,15 @@ async function handleApi(req, res, url, auth) {
604
663
  return true;
605
664
  }
606
665
  if (req.method === "GET" && pathname === "/api/health") {
607
- sendJson(res, 200, { ok: true, live: hub.list().length });
666
+ sendJson(res, 200, { ok: true, live: hub.list(mode).length });
608
667
  return true;
609
668
  }
610
669
  if (req.method === "GET" && pathname === "/api/overview") {
611
- sendJson(res, 200, buildOverview());
670
+ sendJson(res, 200, buildOverview(mode));
612
671
  return true;
613
672
  }
614
673
  if (req.method === "GET" && pathname === "/api/sessions") {
615
- sendJson(res, 200, { sessions: listSessions() });
674
+ sendJson(res, 200, { sessions: listSessions(mode) });
616
675
  return true;
617
676
  }
618
677
  if (pathname === "/api/workspaces") {
@@ -620,7 +679,7 @@ async function handleApi(req, res, url, auth) {
620
679
  sendJson(res, 200, {
621
680
  cwd: process.cwd(),
622
681
  home: os.homedir(),
623
- workspaces: workspaceList(),
682
+ workspaces: workspaceList(mode),
624
683
  });
625
684
  return true;
626
685
  }
@@ -632,8 +691,8 @@ async function handleApi(req, res, url, auth) {
632
691
  try {
633
692
  const body = await readJsonBody(req);
634
693
  const dir = existingDirectory(body?.path);
635
- recordWorkspace(dir);
636
- sendJson(res, 200, { ok: true, path: dir, workspaces: workspaceList() });
694
+ recordWorkspace(dir, mode);
695
+ sendJson(res, 200, { ok: true, path: dir, workspaces: workspaceList(mode) });
637
696
  }
638
697
  catch (err) {
639
698
  sendApiError(res, err);
@@ -694,9 +753,10 @@ async function handleApi(req, res, url, auth) {
694
753
  const input = body;
695
754
  const cwd = existingDirectory(input.cwd ?? process.cwd());
696
755
  const requested = input.selection === undefined
697
- ? getWebSettings().selection
756
+ ? getWebSettings(mode).selection
698
757
  : input.selection;
699
- const selection = resolveModelSelection(requested, false);
758
+ const preset = mode === "pro" ? requireProPreset(input.pro_preset ?? defaultProPreset()) : undefined;
759
+ const selection = mode === "pro" ? proInitialSelection(preset) : resolveModelSelection(requested, false);
700
760
  let approvalMode;
701
761
  if (input.approval_mode !== undefined) {
702
762
  if (input.approval_mode !== "request" && input.approval_mode !== "auto") {
@@ -704,10 +764,10 @@ async function handleApi(req, res, url, auth) {
704
764
  }
705
765
  approvalMode = normalizeApprovalMode(input.approval_mode);
706
766
  }
707
- const session = createWebSession(cwd, selection, approvalMode);
767
+ const session = createWebSession(cwd, selection, approvalMode, mode, preset);
708
768
  const key = session.start();
709
769
  webSessions.set(key, session);
710
- recordWorkspace(cwd);
770
+ recordWorkspace(cwd, mode);
711
771
  sendJson(res, 200, { ok: true, key, cwd, snapshot: hub.snapshot(key) });
712
772
  }
713
773
  catch (err) {
@@ -726,11 +786,11 @@ async function handleApi(req, res, url, auth) {
726
786
  sendJson(res, 400, { error: "invalid session id" });
727
787
  return true;
728
788
  }
729
- if (hub.list().some((entry) => entry.sessionId === id)) {
789
+ if (hub.list(mode).some((entry) => entry.sessionId === id)) {
730
790
  sendJson(res, 409, { error: "cannot delete a running conversation" });
731
791
  return true;
732
792
  }
733
- if (!deleteSession(id)) {
793
+ if (!deleteSession(id, mode)) {
734
794
  sendJson(res, 404, { error: "session not found" });
735
795
  return true;
736
796
  }
@@ -771,7 +831,7 @@ async function handleApi(req, res, url, auth) {
771
831
  sendJson(res, 400, { error: "invalid session id" });
772
832
  return true;
773
833
  }
774
- const data = loadSessionSafe(id);
834
+ const data = loadSessionSafe(id, mode);
775
835
  if (!data) {
776
836
  sendJson(res, 404, { error: "session not found" });
777
837
  return true;
@@ -791,21 +851,32 @@ async function handleApi(req, res, url, auth) {
791
851
  return true;
792
852
  }
793
853
  let selection;
854
+ let preset;
794
855
  try {
795
856
  const body = await readJsonBody(req);
796
857
  if (!body || typeof body !== "object" || Array.isArray(body))
797
858
  throw new Error("invalid JSON body");
798
859
  const requested = body.selection;
799
- if (requested !== undefined)
860
+ if (requested !== undefined && mode !== "pro")
800
861
  selection = resolveModelSelection(requested);
862
+ const requestedPreset = body.pro_preset;
863
+ if (requestedPreset !== undefined && mode === "pro")
864
+ preset = requireProPreset(requestedPreset);
801
865
  }
802
866
  catch (err) {
803
867
  sendJson(res, 400, { error: err instanceof Error ? err.message : "invalid model selection" });
804
868
  return true;
805
869
  }
806
870
  // Already running (TUI or another tab)? Attach instead of duplicating.
807
- const existing = hub.list().find((entry) => entry.sessionId === id);
871
+ const existing = hub.list(mode).find((entry) => entry.sessionId === id);
808
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
+ }
809
880
  if (selection) {
810
881
  const changed = hub.configure(existing.key, selection);
811
882
  if (!changed.ok) {
@@ -816,15 +887,17 @@ async function handleApi(req, res, url, auth) {
816
887
  sendJson(res, 200, { key: existing.key, attached: true });
817
888
  return true;
818
889
  }
819
- const data = loadSessionSafe(id);
890
+ const data = loadSessionSafe(id, mode);
820
891
  if (!data) {
821
892
  sendJson(res, 404, { error: "session not found" });
822
893
  return true;
823
894
  }
895
+ if (preset !== undefined)
896
+ data.pro_preset = preset;
824
897
  const requested = selection ? {
825
898
  provider: selection.provider, model: selection.model.name, intensity: selection.intensity,
826
899
  } : undefined;
827
- const outcome = resumeSession(data, requested);
900
+ const outcome = resumeSession(data, requested, mode);
828
901
  if (!outcome.ok) {
829
902
  sendJson(res, 400, { error: outcome.error });
830
903
  return true;
@@ -836,15 +909,22 @@ async function handleApi(req, res, url, auth) {
836
909
  }
837
910
  const streamMatch = /^\/api\/live\/([^/]+)\/stream$/.exec(pathname);
838
911
  if (req.method === "GET" && streamMatch) {
839
- streamLive(decodeURIComponent(streamMatch[1]), req, res);
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);
840
920
  return true;
841
921
  }
842
- const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|approval-mode|clear))?$/.exec(pathname);
922
+ const liveMatch = /^\/api\/live\/([^/]+)(?:\/(pause|resume|message|approve|close|model|pro-preset|approval-mode|clear))?$/.exec(pathname);
843
923
  if (liveMatch) {
844
924
  const key = decodeURIComponent(liveMatch[1]);
845
925
  const action = liveMatch[2];
846
926
  if (req.method === "GET" && !action) {
847
- const snapshot = hub.snapshot(key);
927
+ const snapshot = hub.has(key, mode) ? hub.snapshot(key) : null;
848
928
  if (!snapshot) {
849
929
  sendJson(res, 404, { error: "unknown live session" });
850
930
  return true;
@@ -857,10 +937,29 @@ async function handleApi(req, res, url, auth) {
857
937
  sendJson(res, 403, { error: "cross-origin request rejected" });
858
938
  return true;
859
939
  }
860
- if (!hub.has(key)) {
940
+ if (!hub.has(key, mode)) {
861
941
  sendJson(res, 404, { error: "unknown live session" });
862
942
  return true;
863
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
+ }
864
963
  if (action === "approval-mode") {
865
964
  let body;
866
965
  try {
@@ -965,7 +1064,7 @@ async function handleApi(req, res, url, auth) {
965
1064
  sendJson(res, 404, { error: "not found" });
966
1065
  return true;
967
1066
  }
968
- async function handleRequest(req, res, auth) {
1067
+ async function handleRequest(req, res, auth, binding) {
969
1068
  let url;
970
1069
  try {
971
1070
  url = new URL(req.url ?? "/", "http://localhost");
@@ -974,20 +1073,59 @@ async function handleRequest(req, res, auth) {
974
1073
  sendText(res, 400, "Bad request");
975
1074
  return;
976
1075
  }
977
- if (await handleApi(req, res, url, auth))
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))
978
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
+ }
979
1107
  if (req.method !== "GET" && req.method !== "HEAD") {
980
1108
  sendText(res, 405, "Method not allowed");
981
1109
  return;
982
1110
  }
983
- serveStatic(res, url.pathname);
1111
+ serveStatic(res, pathname);
984
1112
  }
985
1113
  /** Start the web UI. Resolves once the socket is actually listening. */
986
1114
  export async function startWebServer(options) {
987
- const host = options.host ?? "127.0.0.1";
988
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
+ };
989
1127
  const server = http.createServer((req, res) => {
990
- handleRequest(req, res, auth).catch((err) => {
1128
+ handleRequest(req, res, auth, binding).catch((err) => {
991
1129
  if (!res.headersSent) {
992
1130
  sendJson(res, 500, { error: err instanceof Error ? err.message : "internal error" });
993
1131
  }
@@ -996,41 +1134,116 @@ export async function startWebServer(options) {
996
1134
  }
997
1135
  });
998
1136
  });
999
- await new Promise((resolve, reject) => {
1137
+ /** Wait for the socket to actually listen, or for it to fail. */
1138
+ const listen = (onHost) => new Promise((resolve, reject) => {
1000
1139
  const onError = (err) => {
1001
1140
  server.removeListener("listening", onListening);
1002
1141
  reject(err);
1003
1142
  };
1004
1143
  const onListening = () => {
1005
1144
  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
1145
  resolve();
1018
1146
  };
1019
1147
  server.once("error", onError);
1020
1148
  server.once("listening", onListening);
1021
- server.listen(options.port, host);
1149
+ server.listen(port, onHost);
1022
1150
  });
1023
- const address = server.address();
1024
- const port = typeof address === "object" && address ? address.port : options.port;
1025
- return {
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 = {
1026
1215
  host,
1027
1216
  port,
1028
1217
  authRequired: auth.enabled,
1029
- ...webUrls(host, port),
1218
+ url: "",
1219
+ urls: [],
1220
+ setHost: (next) => rebind(next),
1030
1221
  close: () => new Promise((resolve) => {
1222
+ closed = true;
1031
1223
  for (const key of [...webSessions.keys()])
1032
1224
  closeWebSession(key);
1225
+ if (!server.listening) {
1226
+ resolve();
1227
+ return;
1228
+ }
1033
1229
  server.close(() => resolve());
1034
1230
  }),
1035
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;
1036
1249
  }