@xingjisoft/neutrino 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,16 +15,21 @@ 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
20
  import { deleteSession, listSessions, loadConfig, normalizeApprovalMode, resolveModelSelection, saveConfig, sessionDir, } from "../config.js";
20
21
  import { getWebSettings, updateWebSettings } from "./settings.js";
22
+ import { isSeenIdentity, loadSeen, updateSeen, SEEN_LIMIT } from "./clientState.js";
21
23
  import { WebAuth } from "./auth.js";
22
24
  import { normalizeCwd, sameCwd } from "../cwd.js";
23
25
  import { hub } from "./hub.js";
24
26
  import { writeCrashLog } from "../terminal/restore.js";
25
- import { createWebSession, proInitialSelection, resumeSession } from "./session.js";
26
- import { defaultProPreset, requireProPreset } from "../pro/config.js";
27
- import { listAgentSessions, loadAgentMessages } from "../agents.js";
27
+ import { createWebSession, proInitialSelection, resumeSession, WebSession } from "./session.js";
28
+ import { defaultProPreset, ensureProModels, requireProPreset } from "../pro/config.js";
29
+ import { listAgentSessions, loadAgentMessages, loadAgentSession, safeAgentSessionId } from "../agents.js";
30
+ import { migrateAgentSession, SessionMigrationError } from "../migration.js";
31
+ import { runningBackground } from "../background.js";
32
+ import { MANUAL_UPDATE_COMMAND, installUpdate, updateStatus } from "../update.js";
28
33
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
34
  // dist/web -> package root (mirrors the same trick used in config.ts).
30
35
  const PKG_ROOT = path.resolve(__dirname, "..", "..");
@@ -119,10 +124,26 @@ export function webUrls(host, port) {
119
124
  * is listening right now, so a run started with --lan shows as on even though
120
125
  * nothing was saved yet.
121
126
  */
127
+ function validTerminalBridge(bridge, ownerPid) {
128
+ if (!bridge || typeof bridge !== "object" || Array.isArray(bridge) || typeof ownerPid !== "number")
129
+ return false;
130
+ const info = bridge;
131
+ if (typeof info.url !== "string" || typeof info.token !== "string" || !/^[a-f0-9]{64}$/.test(info.token))
132
+ return false;
133
+ try {
134
+ const url = new URL(info.url);
135
+ return url.protocol === "http:" && url.hostname === "127.0.0.1" && Boolean(url.port)
136
+ && url.pathname === "/" && !url.username && !url.password && !url.search && !url.hash;
137
+ }
138
+ catch {
139
+ return false;
140
+ }
141
+ }
122
142
  function settingsPayload(mode, binding) {
123
143
  const payload = getWebSettings(mode);
124
144
  const lan = isWildcardHost(binding.host);
125
145
  const { urls } = webUrls(binding.host, binding.port);
146
+ const background = binding.service?.status?.() ?? runningBackground();
126
147
  return {
127
148
  ...payload,
128
149
  settings: {
@@ -131,6 +152,12 @@ function settingsPayload(mode, binding) {
131
152
  web_host: binding.host,
132
153
  // Addresses another device can open; empty unless every interface is bound.
133
154
  web_urls: lan ? urls.filter((url) => !url.includes("//127.0.0.1:")) : [],
155
+ // The background service is a process, not a workspace: both pages report
156
+ // the same state. The prompt is never shown once the setting is on.
157
+ background: background?.pid === process.pid ? background.resident : payload.settings.background,
158
+ background_running: background?.resident === true,
159
+ background_url: background?.url ?? "",
160
+ background_prompt: binding.prompt.pending && payload.settings.background !== true,
134
161
  },
135
162
  };
136
163
  }
@@ -194,7 +221,6 @@ function sendApiError(res, error) {
194
221
  }
195
222
  const MAX_RECENT_WORKSPACES = 12;
196
223
  const AGENT_IDS = new Set(["claude", "codex", "opencode"]);
197
- const AGENT_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,200}$/;
198
224
  /** Expand `~` and require an absolute path; used by every folder route. */
199
225
  function absolutePath(raw) {
200
226
  if (typeof raw !== "string" || !raw.trim()) {
@@ -354,9 +380,6 @@ function createFolder(raw) {
354
380
  function isAgentId(value) {
355
381
  return AGENT_IDS.has(value);
356
382
  }
357
- function safeAgentSessionId(id) {
358
- return AGENT_SESSION_ID_RE.test(id) && !id.includes("..") && id !== "." && id !== "..";
359
- }
360
383
  /** Build the sidebar payload: the selected workspace's sessions grouped by directory. */
361
384
  function buildOverview(mode = "standard") {
362
385
  const sessions = listSessions(mode);
@@ -384,10 +407,15 @@ function buildOverview(mode = "standard") {
384
407
  seen.add(session.id);
385
408
  }
386
409
  // Live conversations that have not been saved yet (or whose newest save has
387
- // not landed) still deserve a spot at the top of their directory group.
410
+ // not landed) still deserve a spot at the top of their directory group. A
411
+ // conversation nobody has said anything in yet is not history: it stays out
412
+ // of the list (but in `live`, so the browser keeps its stream) until it has
413
+ // content.
388
414
  for (const live of lives) {
389
415
  if (live.sessionId && seen.has(live.sessionId))
390
416
  continue;
417
+ if (live.empty)
418
+ continue;
391
419
  const key = normalizeCwd(live.cwd) || "(unknown)";
392
420
  let group = groups.get(key);
393
421
  if (!group) {
@@ -484,6 +512,16 @@ function isSameOrigin(req) {
484
512
  return false;
485
513
  }
486
514
  }
515
+ /** Constant-time comparison of the service's management token. */
516
+ function hasServiceToken(req, token) {
517
+ const header = String(req.headers.authorization ?? "");
518
+ const prefix = "Bearer ";
519
+ if (!header.startsWith(prefix))
520
+ return false;
521
+ const supplied = Buffer.from(header.slice(prefix.length));
522
+ const expected = Buffer.from(token);
523
+ return supplied.length === expected.length && timingSafeEqual(supplied, expected);
524
+ }
487
525
  function serveStatic(res, pathname) {
488
526
  let rel = pathname === "/" ? "/index.html" : pathname;
489
527
  try {
@@ -569,6 +607,10 @@ function streamLive(key, req, res) {
569
607
  async function handleApi(req, res, url, auth, mode, pathname, binding) {
570
608
  if (!pathname.startsWith("/api/"))
571
609
  return false;
610
+ // The Pro presets are not bundled either: the first Pro request makes sure they
611
+ // are on disk. When the download fails the route below reports the missing file.
612
+ if (mode === "pro")
613
+ await ensureProModels().catch(() => undefined);
572
614
  if (req.method === "OPTIONS") {
573
615
  // Same-origin only: no CORS headers, so cross-site preflights are rejected.
574
616
  // Answered before the login check because it reveals nothing.
@@ -576,6 +618,59 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
576
618
  res.end();
577
619
  return true;
578
620
  }
621
+ // Management endpoints of the detached service. They are called by the CLI
622
+ // and the tray icon, which have the token from ~/.neutrino/background.json
623
+ // but not necessarily a browser login cookie.
624
+ if (["/api/background/ping", "/api/background/stop", "/api/background/configure"].includes(pathname)) {
625
+ if (req.method !== "GET" && req.method !== "POST") {
626
+ sendJson(res, 405, { error: "method not allowed" });
627
+ return true;
628
+ }
629
+ if (!binding.service) {
630
+ sendJson(res, 404, { error: "this console is not running in the background" });
631
+ return true;
632
+ }
633
+ if (!hasServiceToken(req, binding.service.token)) {
634
+ sendJson(res, 401, { error: "background service token required" });
635
+ return true;
636
+ }
637
+ if (pathname.endsWith("/ping")) {
638
+ sendJson(res, 200, { ok: true, pid: process.pid, url: webUrls(binding.host, binding.port).url });
639
+ return true;
640
+ }
641
+ if (pathname.endsWith("/configure")) {
642
+ if (req.method !== "POST") {
643
+ sendJson(res, 405, { error: "method not allowed" });
644
+ return true;
645
+ }
646
+ if (!binding.service.configure) {
647
+ sendJson(res, 409, { error: "Restart the console to update background residency." });
648
+ return true;
649
+ }
650
+ try {
651
+ const body = await readJsonBody(req, 4096);
652
+ if (!body || typeof body !== "object" || Array.isArray(body)
653
+ || Object.keys(body).some(key => key !== "resident" && key !== "ownerPid" && key !== "bridge")
654
+ || (body.resident !== undefined && typeof body.resident !== "boolean")
655
+ || (body.ownerPid !== undefined && (!Number.isSafeInteger(body.ownerPid) || body.ownerPid <= 0))
656
+ || (body.bridge !== undefined && !validTerminalBridge(body.bridge, body.ownerPid))) {
657
+ throw new Error("invalid background settings");
658
+ }
659
+ const updated = binding.service.configure(body);
660
+ if (updated.resident)
661
+ binding.prompt.pending = false;
662
+ sendJson(res, 200, updated);
663
+ }
664
+ catch (error) {
665
+ sendJson(res, 400, { error: error instanceof Error ? error.message : "invalid background settings" });
666
+ }
667
+ return true;
668
+ }
669
+ sendJson(res, 200, { ok: true, stopping: true });
670
+ // Let the response reach the caller before the socket closes.
671
+ setImmediate(() => binding.service?.onStop());
672
+ return true;
673
+ }
579
674
  if (pathname === "/api/auth/login" || pathname === "/api/auth/logout") {
580
675
  if (req.method !== "POST") {
581
676
  sendJson(res, 405, { error: "method not allowed" });
@@ -610,6 +705,21 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
610
705
  sendJson(res, 401, { error: "Password login required." });
611
706
  return true;
612
707
  }
708
+ // The first browser to see the background-residency prompt consumes it for
709
+ // this console run, whether it accepted or dismissed it.
710
+ if (pathname === "/api/background/dismiss") {
711
+ if (req.method !== "POST") {
712
+ sendJson(res, 405, { error: "method not allowed" });
713
+ }
714
+ else if (!isSameOrigin(req)) {
715
+ sendJson(res, 403, { error: "cross-origin request rejected" });
716
+ }
717
+ else {
718
+ binding.prompt.pending = false;
719
+ sendJson(res, 200, { ok: true });
720
+ }
721
+ return true;
722
+ }
613
723
  if (pathname === "/api/settings") {
614
724
  if (req.method === "GET") {
615
725
  sendJson(res, 200, settingsPayload(mode, binding));
@@ -619,39 +729,15 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
619
729
  sendJson(res, 403, { error: "cross-origin request rejected" });
620
730
  return true;
621
731
  }
622
- const wasLan = loadConfig(mode).web_lan === true;
623
732
  try {
624
733
  const body = await readJsonBody(req);
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
- }
734
+ await binding.updateSettings(body, mode);
637
735
  if (body.settings?.web_password !== undefined) {
638
736
  auth.remember(res);
639
737
  }
640
738
  sendJson(res, 200, settingsPayload(mode, binding));
641
739
  }
642
740
  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
- }
655
741
  sendJson(res, err?.code ? 500 : 400, {
656
742
  error: err instanceof Error ? err.message : "invalid settings",
657
743
  });
@@ -662,6 +748,33 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
662
748
  }
663
749
  return true;
664
750
  }
751
+ // The update prompt's confirm button: npm runs on this machine, so the route
752
+ // sits behind the login cookie and the same-origin check like every other
753
+ // mutation, and only a release the registry announced can be installed.
754
+ if (pathname === "/api/update") {
755
+ if (req.method !== "POST") {
756
+ sendJson(res, 405, { error: "method not allowed" });
757
+ return true;
758
+ }
759
+ if (!isSameOrigin(req)) {
760
+ sendJson(res, 403, { error: "cross-origin request rejected" });
761
+ return true;
762
+ }
763
+ if (!updateStatus().hasUpdate) {
764
+ sendJson(res, 409, { error: "No newer version is published." });
765
+ return true;
766
+ }
767
+ // Never rejects: a failure comes back as a result, carrying the command the
768
+ // browser shows so the user can finish the update by hand.
769
+ const result = await installUpdate();
770
+ sendJson(res, result.ok ? 200 : 500, {
771
+ ok: result.ok,
772
+ version: result.version,
773
+ error: result.error ?? "",
774
+ command: MANUAL_UPDATE_COMMAND,
775
+ });
776
+ return true;
777
+ }
665
778
  if (req.method === "GET" && pathname === "/api/health") {
666
779
  sendJson(res, 200, { ok: true, live: hub.list(mode).length });
667
780
  return true;
@@ -670,6 +783,53 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
670
783
  sendJson(res, 200, buildOverview(mode));
671
784
  return true;
672
785
  }
786
+ // The sidebar's blue dot is the browser's own memory of what it has read. It
787
+ // belongs to the console rather than to a conversation, so it is stored in
788
+ // ~/.neutrino/web.json (see clientState.ts) and never in the config or the
789
+ // transcript. Each workspace keeps its own record.
790
+ if (pathname === "/api/seen") {
791
+ if (req.method === "GET") {
792
+ sendJson(res, 200, { seen: loadSeen(mode) });
793
+ return true;
794
+ }
795
+ if (req.method === "POST") {
796
+ if (!isSameOrigin(req)) {
797
+ sendJson(res, 403, { error: "cross-origin request rejected" });
798
+ return true;
799
+ }
800
+ try {
801
+ const body = await readJsonBody(req, 64 * 1024);
802
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
803
+ throw new ApiError(400, "invalid request", "invalid request");
804
+ }
805
+ const input = body;
806
+ if (Object.keys(body).some((key) => key !== "identities" && key !== "seen")) {
807
+ throw new ApiError(400, "unknown field", "unknown field");
808
+ }
809
+ const identities = input.identities;
810
+ if (!Array.isArray(identities) || identities.length === 0 || identities.length > SEEN_LIMIT) {
811
+ throw new ApiError(400, `identities must be a list of 1 to ${SEEN_LIMIT}`, "invalid identities");
812
+ }
813
+ if (!identities.every(isSeenIdentity)) {
814
+ throw new ApiError(400, "invalid conversation id", "invalid conversation id");
815
+ }
816
+ if (typeof input.seen !== "boolean") {
817
+ throw new ApiError(400, "seen must be a boolean", "seen must be a boolean");
818
+ }
819
+ sendJson(res, 200, { ok: true, seen: updateSeen(mode, identities, input.seen) });
820
+ }
821
+ catch (err) {
822
+ // A rejected body is the caller's fault; a file that cannot be written
823
+ // is ours, and must not read as a malformed request.
824
+ sendJson(res, err instanceof ApiError ? err.status : 500, {
825
+ error: err instanceof Error ? err.message : "invalid request",
826
+ });
827
+ }
828
+ return true;
829
+ }
830
+ sendJson(res, 405, { error: "method not allowed" });
831
+ return true;
832
+ }
673
833
  if (req.method === "GET" && pathname === "/api/sessions") {
674
834
  sendJson(res, 200, { sessions: listSessions(mode) });
675
835
  return true;
@@ -797,7 +957,7 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
797
957
  sendJson(res, 200, { ok: true, deleted: true });
798
958
  return true;
799
959
  }
800
- // Read-only history recorded by other agents (Claude Code, Codex, OpenCode).
960
+ // History recorded by other agents (Claude Code, Codex, OpenCode).
801
961
  const agentSessionsMatch = /^\/api\/agents\/([^/]+)\/sessions$/.exec(pathname);
802
962
  if (req.method === "GET" && agentSessionsMatch) {
803
963
  const agent = decodeURIComponent(agentSessionsMatch[1]);
@@ -821,7 +981,59 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
821
981
  sendJson(res, 400, { error: "invalid session id" });
822
982
  return true;
823
983
  }
824
- sendJson(res, 200, { agent, id, messages: loadAgentMessages(agent, id) });
984
+ const cwd = url.searchParams.get("cwd");
985
+ if (cwd) {
986
+ const source = loadAgentSession(agent, id, cwd);
987
+ if (!source) {
988
+ sendJson(res, 404, { error: "session not found in this working directory" });
989
+ return true;
990
+ }
991
+ sendJson(res, 200, { ...source, agent });
992
+ }
993
+ else {
994
+ sendJson(res, 200, { agent, id, messages: loadAgentMessages(agent, id) });
995
+ }
996
+ return true;
997
+ }
998
+ const migrateMatch = /^\/api\/agents\/([^/]+)\/sessions\/([^/]+)\/migrate$/.exec(pathname);
999
+ if (migrateMatch) {
1000
+ if (req.method !== "POST") {
1001
+ sendJson(res, 405, { error: "method not allowed" });
1002
+ return true;
1003
+ }
1004
+ if (!isSameOrigin(req)) {
1005
+ sendJson(res, 403, { error: "cross-origin request rejected" });
1006
+ return true;
1007
+ }
1008
+ const agent = decodeURIComponent(migrateMatch[1]);
1009
+ const id = decodeURIComponent(migrateMatch[2]);
1010
+ if (!isAgentId(agent) || !safeAgentSessionId(id)) {
1011
+ sendJson(res, 400, { error: !isAgentId(agent) ? "unknown agent" : "invalid session id" });
1012
+ return true;
1013
+ }
1014
+ try {
1015
+ const body = await readJsonBody(req);
1016
+ if (!body || typeof body !== "object" || Array.isArray(body))
1017
+ throw new Error("invalid JSON body");
1018
+ const input = body;
1019
+ const cwd = existingDirectory(input.cwd ?? process.cwd());
1020
+ const preset = mode === "pro" ? requireProPreset(input.pro_preset ?? defaultProPreset()) : undefined;
1021
+ const selection = mode === "pro" ? proInitialSelection(preset)
1022
+ : resolveModelSelection(input.selection ?? getWebSettings(mode).selection, false);
1023
+ const migrated = migrateAgentSession(agent, id, cwd, {
1024
+ provider: selection.provider, model: selection.model.name, intensity: selection.intensity,
1025
+ }, mode, preset);
1026
+ const session = new WebSession(migrated, selection.model, mode);
1027
+ const key = session.start();
1028
+ webSessions.set(key, session);
1029
+ sendJson(res, 201, { ok: true, id: migrated.id, key, cwd, snapshot: hub.snapshot(key) });
1030
+ }
1031
+ catch (err) {
1032
+ if (err instanceof SessionMigrationError)
1033
+ sendJson(res, err.status, { error: err.message });
1034
+ else
1035
+ sendApiError(res, err);
1036
+ }
825
1037
  return true;
826
1038
  }
827
1039
  const sessionMatch = /^\/api\/session\/([^/]+)$/.exec(pathname);
@@ -870,14 +1082,29 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
870
1082
  // Already running (TUI or another tab)? Attach instead of duplicating.
871
1083
  const existing = hub.list(mode).find((entry) => entry.sessionId === id);
872
1084
  if (existing) {
873
- if (preset !== undefined) {
1085
+ const remote = hub.remoteAction(existing.key);
1086
+ if (remote && preset !== undefined) {
1087
+ const result = await remote("pro-preset", { preset });
1088
+ if (result.status !== 200) {
1089
+ sendJson(res, result.status, result.data);
1090
+ return true;
1091
+ }
1092
+ }
1093
+ else if (remote && selection) {
1094
+ const result = await remote("model", { provider: selection.provider, model: selection.model.name, intensity: selection.intensity });
1095
+ if (result.status !== 200) {
1096
+ sendJson(res, result.status, result.data);
1097
+ return true;
1098
+ }
1099
+ }
1100
+ if (!remote && preset !== undefined) {
874
1101
  const changed = hub.setProPreset(existing.key, preset);
875
1102
  if (!changed.ok) {
876
1103
  sendJson(res, 409, { error: changed.error });
877
1104
  return true;
878
1105
  }
879
1106
  }
880
- if (selection) {
1107
+ if (!remote && selection) {
881
1108
  const changed = hub.configure(existing.key, selection);
882
1109
  if (!changed.ok) {
883
1110
  sendJson(res, 409, { error: changed.error });
@@ -941,6 +1168,23 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
941
1168
  sendJson(res, 404, { error: "unknown live session" });
942
1169
  return true;
943
1170
  }
1171
+ const remote = hub.remoteAction(key);
1172
+ if (remote) {
1173
+ if (action === "close") {
1174
+ sendJson(res, 400, { error: "this session is owned by the terminal UI" });
1175
+ return true;
1176
+ }
1177
+ try {
1178
+ const body = ["approve", "approval-mode", "message", "model", "pro-preset"].includes(action)
1179
+ ? await readJsonBody(req) : {};
1180
+ const result = await remote(action, body);
1181
+ sendJson(res, result.status, result.data);
1182
+ }
1183
+ catch (error) {
1184
+ sendJson(res, 400, { error: error instanceof Error ? error.message : "invalid request" });
1185
+ }
1186
+ return true;
1187
+ }
944
1188
  if (action === "pro-preset") {
945
1189
  if (mode !== "pro") {
946
1190
  sendJson(res, 404, { error: "Pro presets are only available in Pro mode" });
@@ -1123,6 +1367,9 @@ export async function startWebServer(options) {
1123
1367
  host,
1124
1368
  port,
1125
1369
  setHost: (next) => rebind(next),
1370
+ prompt: { pending: options.backgroundPrompt === true },
1371
+ service: options.backgroundService,
1372
+ updateSettings: (body, mode) => updateSettings(body, mode),
1126
1373
  };
1127
1374
  const server = http.createServer((req, res) => {
1128
1375
  handleRequest(req, res, auth, binding).catch((err) => {
@@ -1170,6 +1417,7 @@ export async function startWebServer(options) {
1170
1417
  handle.port = port;
1171
1418
  handle.url = address.url;
1172
1419
  handle.urls = address.urls;
1420
+ options.onAddressChange?.();
1173
1421
  };
1174
1422
  // Two tabs saving at once must not interleave close/listen on one socket.
1175
1423
  let switches = Promise.resolve();
@@ -1211,6 +1459,51 @@ export async function startWebServer(options) {
1211
1459
  switches = task.catch(() => undefined);
1212
1460
  return task;
1213
1461
  }
1462
+ // Serialize saves from both workspaces so a failed switch cannot roll back
1463
+ // another tab's successful change.
1464
+ let settingsChanges = Promise.resolve();
1465
+ function updateSettings(body, mode) {
1466
+ const task = settingsChanges.then(async () => {
1467
+ const previous = loadConfig(mode);
1468
+ const wasHost = binding.host;
1469
+ try {
1470
+ updateWebSettings(body, mode);
1471
+ const patch = body?.settings;
1472
+ const desiredHost = patch && Object.prototype.hasOwnProperty.call(patch, "web_lan")
1473
+ ? patch.web_lan === true ? WILDCARD_HOST : LOOPBACK_HOST
1474
+ : binding.host;
1475
+ if (desiredHost !== binding.host)
1476
+ await binding.setHost(desiredHost);
1477
+ if (patch && Object.prototype.hasOwnProperty.call(patch, "background")) {
1478
+ if (!binding.service?.configure) {
1479
+ throw new Error("Background residency could not be applied. Restart the console and try again.");
1480
+ }
1481
+ binding.service.configure({ resident: patch.background === true });
1482
+ if (patch.background === true)
1483
+ binding.prompt.pending = false;
1484
+ }
1485
+ }
1486
+ catch (error) {
1487
+ // A failed lifetime or address change must not look saved or active.
1488
+ if (binding.host !== wasHost) {
1489
+ try {
1490
+ await binding.setHost(wasHost);
1491
+ }
1492
+ catch { /* Report the original failure. */ }
1493
+ }
1494
+ const config = loadConfig(mode);
1495
+ config.web_lan = previous.web_lan;
1496
+ config.background = previous.background;
1497
+ try {
1498
+ saveConfig(config, mode);
1499
+ }
1500
+ catch { /* Report the original failure. */ }
1501
+ throw error;
1502
+ }
1503
+ });
1504
+ settingsChanges = task.catch(() => undefined);
1505
+ return task;
1506
+ }
1214
1507
  const handle = {
1215
1508
  host,
1216
1509
  port,