@xingjisoft/neutrino 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tray.js ADDED
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Windows notification-area icon for the background Web console.
3
+ *
4
+ * Node cannot create a tray icon by itself, and the project deliberately has
5
+ * no native dependencies, so a tiny Windows PowerShell process hosts a
6
+ * `System.Windows.Forms.NotifyIcon`. It uses the same orbit badge as the
7
+ * regular console's top-left mark. Everything is best-effort: an unavailable
8
+ * icon must never stop the service.
9
+ */
10
+ import { spawn } from "node:child_process";
11
+ import { randomBytes } from "node:crypto";
12
+ import fs from "node:fs";
13
+ import path from "node:path";
14
+ import process from "node:process";
15
+ import { fileURLToPath } from "node:url";
16
+ import { BACKGROUND_LOG_FILE, backgroundLog } from "./background.js";
17
+ import { NEUTRINO_DIR } from "./config.js";
18
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
19
+ const PKG_ROOT = path.resolve(__dirname, "..");
20
+ const ICON_FILE = path.join(PKG_ROOT, "assets", "neutrino.ico");
21
+ /** Quote a value as a PowerShell single-quoted literal. */
22
+ function psString(value) {
23
+ return `'${value.replace(/'/g, "''")}'`;
24
+ }
25
+ /** The icon itself. Only Windows has a notification area, and only it can run this. */
26
+ export function startBackgroundTray(options) {
27
+ if (process.platform !== "win32")
28
+ return null;
29
+ if (process.env.NEUTRINO_NO_TRAY === "1")
30
+ return null;
31
+ if (!fs.existsSync(ICON_FILE)) {
32
+ backgroundLog(`tray icon not found at ${ICON_FILE}`);
33
+ return null;
34
+ }
35
+ // Separate files let an old icon close cleanly even if residency is turned
36
+ // back on before its PowerShell message loop has processed the shutdown.
37
+ const suffix = `${process.pid}-${randomBytes(8).toString("hex")}`;
38
+ const scriptFile = path.join(NEUTRINO_DIR, `tray-${suffix}.ps1`);
39
+ const closeFile = path.join(NEUTRINO_DIR, `tray-${suffix}.stop`);
40
+ const script = `
41
+ $ErrorActionPreference = 'Continue'
42
+ $scriptFile = ${psString(scriptFile)}
43
+ $closeFile = ${psString(closeFile)}
44
+ try {
45
+ Add-Type -AssemblyName System.Windows.Forms
46
+ Add-Type -AssemblyName System.Drawing
47
+ $iconPath = ${psString(ICON_FILE)}
48
+ $url = ${psString(options.url)}
49
+ $stopUrl = ${psString(options.stopUrl)}
50
+ $token = ${psString(options.token)}
51
+ $parent = ${options.pid}
52
+ $notify = New-Object System.Windows.Forms.NotifyIcon
53
+ $notify.Icon = New-Object System.Drawing.Icon($iconPath)
54
+ $notify.Text = 'Neutrino - background console'
55
+ $notify.Visible = $true
56
+ $menu = New-Object System.Windows.Forms.ContextMenuStrip
57
+ $open = $menu.Items.Add('Open Neutrino console')
58
+ $open.add_Click({ Start-Process -FilePath $url })
59
+ $stop = $menu.Items.Add('Stop background service')
60
+ $stop.add_Click({
61
+ try {
62
+ Invoke-RestMethod -Method Post -Uri $stopUrl -Headers @{ Authorization = "Bearer $token" } -TimeoutSec 5 | Out-Null
63
+ } catch {
64
+ Stop-Process -Id $parent -Force
65
+ }
66
+ $notify.Visible = $false
67
+ [System.Windows.Forms.Application]::Exit()
68
+ })
69
+ $menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator)) | Out-Null
70
+ $notify.ContextMenuStrip = $menu
71
+ $notify.add_MouseClick({ param($sender, $eventArgs) if ($eventArgs.Button -eq [System.Windows.Forms.MouseButtons]::Left) { Start-Process -FilePath $url } })
72
+ $notify.add_DoubleClick({ Start-Process -FilePath $url })
73
+ $timer = New-Object System.Windows.Forms.Timer
74
+ $timer.Interval = 250
75
+ $timer.add_Tick({
76
+ if ((Test-Path -LiteralPath $closeFile) -or -not (Get-Process -Id $parent -ErrorAction SilentlyContinue)) {
77
+ $notify.Visible = $false
78
+ [System.Windows.Forms.Application]::Exit()
79
+ }
80
+ })
81
+ $timer.Start()
82
+ [System.Windows.Forms.Application]::Run()
83
+ } finally {
84
+ if ($notify) { $notify.Visible = $false; $notify.Dispose() }
85
+ Remove-Item -LiteralPath $closeFile, $scriptFile -Force -ErrorAction SilentlyContinue
86
+ }
87
+ `;
88
+ const cleanup = () => {
89
+ for (const file of [scriptFile, closeFile]) {
90
+ try {
91
+ fs.rmSync(file, { force: true });
92
+ }
93
+ catch { /* The helper also cleans up. */ }
94
+ }
95
+ };
96
+ try {
97
+ fs.mkdirSync(NEUTRINO_DIR, { recursive: true });
98
+ fs.writeFileSync(scriptFile, `\ufeff${script}`, { encoding: "utf-8", mode: 0o600 });
99
+ const logFd = fs.openSync(BACKGROUND_LOG_FILE, "a");
100
+ let child;
101
+ try {
102
+ child = spawn("powershell.exe", [
103
+ "-NoProfile", "-NonInteractive", "-STA", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden",
104
+ "-File", scriptFile,
105
+ ],
106
+ // Not detached: on Windows a detached PowerShell exits before its
107
+ // message loop starts. The icon outlives the service on its own; its
108
+ // timer notices the parent pid disappearing and shuts it down.
109
+ { stdio: ["ignore", "ignore", logFd], windowsHide: true });
110
+ }
111
+ finally {
112
+ fs.closeSync(logFd);
113
+ }
114
+ let active = true;
115
+ child.once("error", (error) => { active = false; cleanup(); backgroundLog(`tray failed: ${error.message}`); });
116
+ child.once("exit", (code) => { active = false; cleanup(); backgroundLog(`tray exited (${code ?? "signal"})`); });
117
+ child.unref();
118
+ backgroundLog(`tray icon started (pid ${child.pid ?? "unknown"})`);
119
+ return { close: () => {
120
+ if (!active)
121
+ return;
122
+ try {
123
+ fs.writeFileSync(closeFile, "", { mode: 0o600 });
124
+ }
125
+ catch {
126
+ try {
127
+ child.kill();
128
+ }
129
+ catch { /* Already gone. */ }
130
+ }
131
+ } };
132
+ }
133
+ catch (error) {
134
+ cleanup();
135
+ backgroundLog(`tray failed: ${error instanceof Error ? error.message : String(error)}`);
136
+ return null;
137
+ }
138
+ }
package/dist/web/hub.js CHANGED
@@ -58,6 +58,10 @@ class LiveHub {
58
58
  const entry = this.entries.get(key);
59
59
  return Boolean(entry) && (!mode || entry.mode === mode);
60
60
  }
61
+ remoteAction(key) {
62
+ const agent = this.entries.get(key)?.agent;
63
+ return agent?.remoteAction ? agent.remoteAction.bind(agent) : null;
64
+ }
61
65
  /** All live sessions, newest registration last. Optionally one workspace only. */
62
66
  list(mode) {
63
67
  const out = [];
@@ -15,6 +15,7 @@ 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,9 +23,11 @@ import { WebAuth } from "./auth.js";
22
23
  import { normalizeCwd, sameCwd } from "../cwd.js";
23
24
  import { hub } from "./hub.js";
24
25
  import { writeCrashLog } from "../terminal/restore.js";
25
- import { createWebSession, proInitialSelection, resumeSession } from "./session.js";
26
+ import { createWebSession, proInitialSelection, resumeSession, WebSession } from "./session.js";
26
27
  import { defaultProPreset, requireProPreset } from "../pro/config.js";
27
- import { listAgentSessions, loadAgentMessages } from "../agents.js";
28
+ import { listAgentSessions, loadAgentMessages, loadAgentSession, safeAgentSessionId } from "../agents.js";
29
+ import { migrateAgentSession, SessionMigrationError } from "../migration.js";
30
+ import { runningBackground } from "../background.js";
28
31
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
32
  // dist/web -> package root (mirrors the same trick used in config.ts).
30
33
  const PKG_ROOT = path.resolve(__dirname, "..", "..");
@@ -119,10 +122,26 @@ export function webUrls(host, port) {
119
122
  * is listening right now, so a run started with --lan shows as on even though
120
123
  * nothing was saved yet.
121
124
  */
125
+ function validTerminalBridge(bridge, ownerPid) {
126
+ if (!bridge || typeof bridge !== "object" || Array.isArray(bridge) || typeof ownerPid !== "number")
127
+ return false;
128
+ const info = bridge;
129
+ if (typeof info.url !== "string" || typeof info.token !== "string" || !/^[a-f0-9]{64}$/.test(info.token))
130
+ return false;
131
+ try {
132
+ const url = new URL(info.url);
133
+ return url.protocol === "http:" && url.hostname === "127.0.0.1" && Boolean(url.port)
134
+ && url.pathname === "/" && !url.username && !url.password && !url.search && !url.hash;
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ }
122
140
  function settingsPayload(mode, binding) {
123
141
  const payload = getWebSettings(mode);
124
142
  const lan = isWildcardHost(binding.host);
125
143
  const { urls } = webUrls(binding.host, binding.port);
144
+ const background = binding.service?.status?.() ?? runningBackground();
126
145
  return {
127
146
  ...payload,
128
147
  settings: {
@@ -131,6 +150,12 @@ function settingsPayload(mode, binding) {
131
150
  web_host: binding.host,
132
151
  // Addresses another device can open; empty unless every interface is bound.
133
152
  web_urls: lan ? urls.filter((url) => !url.includes("//127.0.0.1:")) : [],
153
+ // The background service is a process, not a workspace: both pages report
154
+ // the same state. The prompt is never shown once the setting is on.
155
+ background: background?.pid === process.pid ? background.resident : payload.settings.background,
156
+ background_running: background?.resident === true,
157
+ background_url: background?.url ?? "",
158
+ background_prompt: binding.prompt.pending && payload.settings.background !== true,
134
159
  },
135
160
  };
136
161
  }
@@ -194,7 +219,6 @@ function sendApiError(res, error) {
194
219
  }
195
220
  const MAX_RECENT_WORKSPACES = 12;
196
221
  const AGENT_IDS = new Set(["claude", "codex", "opencode"]);
197
- const AGENT_SESSION_ID_RE = /^[A-Za-z0-9._-]{1,200}$/;
198
222
  /** Expand `~` and require an absolute path; used by every folder route. */
199
223
  function absolutePath(raw) {
200
224
  if (typeof raw !== "string" || !raw.trim()) {
@@ -354,9 +378,6 @@ function createFolder(raw) {
354
378
  function isAgentId(value) {
355
379
  return AGENT_IDS.has(value);
356
380
  }
357
- function safeAgentSessionId(id) {
358
- return AGENT_SESSION_ID_RE.test(id) && !id.includes("..") && id !== "." && id !== "..";
359
- }
360
381
  /** Build the sidebar payload: the selected workspace's sessions grouped by directory. */
361
382
  function buildOverview(mode = "standard") {
362
383
  const sessions = listSessions(mode);
@@ -484,6 +505,16 @@ function isSameOrigin(req) {
484
505
  return false;
485
506
  }
486
507
  }
508
+ /** Constant-time comparison of the service's management token. */
509
+ function hasServiceToken(req, token) {
510
+ const header = String(req.headers.authorization ?? "");
511
+ const prefix = "Bearer ";
512
+ if (!header.startsWith(prefix))
513
+ return false;
514
+ const supplied = Buffer.from(header.slice(prefix.length));
515
+ const expected = Buffer.from(token);
516
+ return supplied.length === expected.length && timingSafeEqual(supplied, expected);
517
+ }
487
518
  function serveStatic(res, pathname) {
488
519
  let rel = pathname === "/" ? "/index.html" : pathname;
489
520
  try {
@@ -576,6 +607,59 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
576
607
  res.end();
577
608
  return true;
578
609
  }
610
+ // Management endpoints of the detached service. They are called by the CLI
611
+ // and the tray icon, which have the token from ~/.neutrino/background.json
612
+ // but not necessarily a browser login cookie.
613
+ if (["/api/background/ping", "/api/background/stop", "/api/background/configure"].includes(pathname)) {
614
+ if (req.method !== "GET" && req.method !== "POST") {
615
+ sendJson(res, 405, { error: "method not allowed" });
616
+ return true;
617
+ }
618
+ if (!binding.service) {
619
+ sendJson(res, 404, { error: "this console is not running in the background" });
620
+ return true;
621
+ }
622
+ if (!hasServiceToken(req, binding.service.token)) {
623
+ sendJson(res, 401, { error: "background service token required" });
624
+ return true;
625
+ }
626
+ if (pathname.endsWith("/ping")) {
627
+ sendJson(res, 200, { ok: true, pid: process.pid, url: webUrls(binding.host, binding.port).url });
628
+ return true;
629
+ }
630
+ if (pathname.endsWith("/configure")) {
631
+ if (req.method !== "POST") {
632
+ sendJson(res, 405, { error: "method not allowed" });
633
+ return true;
634
+ }
635
+ if (!binding.service.configure) {
636
+ sendJson(res, 409, { error: "Restart the console to update background residency." });
637
+ return true;
638
+ }
639
+ try {
640
+ const body = await readJsonBody(req, 4096);
641
+ if (!body || typeof body !== "object" || Array.isArray(body)
642
+ || Object.keys(body).some(key => key !== "resident" && key !== "ownerPid" && key !== "bridge")
643
+ || (body.resident !== undefined && typeof body.resident !== "boolean")
644
+ || (body.ownerPid !== undefined && (!Number.isSafeInteger(body.ownerPid) || body.ownerPid <= 0))
645
+ || (body.bridge !== undefined && !validTerminalBridge(body.bridge, body.ownerPid))) {
646
+ throw new Error("invalid background settings");
647
+ }
648
+ const updated = binding.service.configure(body);
649
+ if (updated.resident)
650
+ binding.prompt.pending = false;
651
+ sendJson(res, 200, updated);
652
+ }
653
+ catch (error) {
654
+ sendJson(res, 400, { error: error instanceof Error ? error.message : "invalid background settings" });
655
+ }
656
+ return true;
657
+ }
658
+ sendJson(res, 200, { ok: true, stopping: true });
659
+ // Let the response reach the caller before the socket closes.
660
+ setImmediate(() => binding.service?.onStop());
661
+ return true;
662
+ }
579
663
  if (pathname === "/api/auth/login" || pathname === "/api/auth/logout") {
580
664
  if (req.method !== "POST") {
581
665
  sendJson(res, 405, { error: "method not allowed" });
@@ -610,6 +694,21 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
610
694
  sendJson(res, 401, { error: "Password login required." });
611
695
  return true;
612
696
  }
697
+ // The first browser to see the background-residency prompt consumes it for
698
+ // this console run, whether it accepted or dismissed it.
699
+ if (pathname === "/api/background/dismiss") {
700
+ if (req.method !== "POST") {
701
+ sendJson(res, 405, { error: "method not allowed" });
702
+ }
703
+ else if (!isSameOrigin(req)) {
704
+ sendJson(res, 403, { error: "cross-origin request rejected" });
705
+ }
706
+ else {
707
+ binding.prompt.pending = false;
708
+ sendJson(res, 200, { ok: true });
709
+ }
710
+ return true;
711
+ }
613
712
  if (pathname === "/api/settings") {
614
713
  if (req.method === "GET") {
615
714
  sendJson(res, 200, settingsPayload(mode, binding));
@@ -619,39 +718,15 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
619
718
  sendJson(res, 403, { error: "cross-origin request rejected" });
620
719
  return true;
621
720
  }
622
- const wasLan = loadConfig(mode).web_lan === true;
623
721
  try {
624
722
  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
- }
723
+ await binding.updateSettings(body, mode);
637
724
  if (body.settings?.web_password !== undefined) {
638
725
  auth.remember(res);
639
726
  }
640
727
  sendJson(res, 200, settingsPayload(mode, binding));
641
728
  }
642
729
  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
730
  sendJson(res, err?.code ? 500 : 400, {
656
731
  error: err instanceof Error ? err.message : "invalid settings",
657
732
  });
@@ -797,7 +872,7 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
797
872
  sendJson(res, 200, { ok: true, deleted: true });
798
873
  return true;
799
874
  }
800
- // Read-only history recorded by other agents (Claude Code, Codex, OpenCode).
875
+ // History recorded by other agents (Claude Code, Codex, OpenCode).
801
876
  const agentSessionsMatch = /^\/api\/agents\/([^/]+)\/sessions$/.exec(pathname);
802
877
  if (req.method === "GET" && agentSessionsMatch) {
803
878
  const agent = decodeURIComponent(agentSessionsMatch[1]);
@@ -821,7 +896,59 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
821
896
  sendJson(res, 400, { error: "invalid session id" });
822
897
  return true;
823
898
  }
824
- sendJson(res, 200, { agent, id, messages: loadAgentMessages(agent, id) });
899
+ const cwd = url.searchParams.get("cwd");
900
+ if (cwd) {
901
+ const source = loadAgentSession(agent, id, cwd);
902
+ if (!source) {
903
+ sendJson(res, 404, { error: "session not found in this working directory" });
904
+ return true;
905
+ }
906
+ sendJson(res, 200, { ...source, agent });
907
+ }
908
+ else {
909
+ sendJson(res, 200, { agent, id, messages: loadAgentMessages(agent, id) });
910
+ }
911
+ return true;
912
+ }
913
+ const migrateMatch = /^\/api\/agents\/([^/]+)\/sessions\/([^/]+)\/migrate$/.exec(pathname);
914
+ if (migrateMatch) {
915
+ if (req.method !== "POST") {
916
+ sendJson(res, 405, { error: "method not allowed" });
917
+ return true;
918
+ }
919
+ if (!isSameOrigin(req)) {
920
+ sendJson(res, 403, { error: "cross-origin request rejected" });
921
+ return true;
922
+ }
923
+ const agent = decodeURIComponent(migrateMatch[1]);
924
+ const id = decodeURIComponent(migrateMatch[2]);
925
+ if (!isAgentId(agent) || !safeAgentSessionId(id)) {
926
+ sendJson(res, 400, { error: !isAgentId(agent) ? "unknown agent" : "invalid session id" });
927
+ return true;
928
+ }
929
+ try {
930
+ const body = await readJsonBody(req);
931
+ if (!body || typeof body !== "object" || Array.isArray(body))
932
+ throw new Error("invalid JSON body");
933
+ const input = body;
934
+ const cwd = existingDirectory(input.cwd ?? process.cwd());
935
+ const preset = mode === "pro" ? requireProPreset(input.pro_preset ?? defaultProPreset()) : undefined;
936
+ const selection = mode === "pro" ? proInitialSelection(preset)
937
+ : resolveModelSelection(input.selection ?? getWebSettings(mode).selection, false);
938
+ const migrated = migrateAgentSession(agent, id, cwd, {
939
+ provider: selection.provider, model: selection.model.name, intensity: selection.intensity,
940
+ }, mode, preset);
941
+ const session = new WebSession(migrated, selection.model, mode);
942
+ const key = session.start();
943
+ webSessions.set(key, session);
944
+ sendJson(res, 201, { ok: true, id: migrated.id, key, cwd, snapshot: hub.snapshot(key) });
945
+ }
946
+ catch (err) {
947
+ if (err instanceof SessionMigrationError)
948
+ sendJson(res, err.status, { error: err.message });
949
+ else
950
+ sendApiError(res, err);
951
+ }
825
952
  return true;
826
953
  }
827
954
  const sessionMatch = /^\/api\/session\/([^/]+)$/.exec(pathname);
@@ -870,14 +997,29 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
870
997
  // Already running (TUI or another tab)? Attach instead of duplicating.
871
998
  const existing = hub.list(mode).find((entry) => entry.sessionId === id);
872
999
  if (existing) {
873
- if (preset !== undefined) {
1000
+ const remote = hub.remoteAction(existing.key);
1001
+ if (remote && preset !== undefined) {
1002
+ const result = await remote("pro-preset", { preset });
1003
+ if (result.status !== 200) {
1004
+ sendJson(res, result.status, result.data);
1005
+ return true;
1006
+ }
1007
+ }
1008
+ else if (remote && selection) {
1009
+ const result = await remote("model", { provider: selection.provider, model: selection.model.name, intensity: selection.intensity });
1010
+ if (result.status !== 200) {
1011
+ sendJson(res, result.status, result.data);
1012
+ return true;
1013
+ }
1014
+ }
1015
+ if (!remote && preset !== undefined) {
874
1016
  const changed = hub.setProPreset(existing.key, preset);
875
1017
  if (!changed.ok) {
876
1018
  sendJson(res, 409, { error: changed.error });
877
1019
  return true;
878
1020
  }
879
1021
  }
880
- if (selection) {
1022
+ if (!remote && selection) {
881
1023
  const changed = hub.configure(existing.key, selection);
882
1024
  if (!changed.ok) {
883
1025
  sendJson(res, 409, { error: changed.error });
@@ -941,6 +1083,23 @@ async function handleApi(req, res, url, auth, mode, pathname, binding) {
941
1083
  sendJson(res, 404, { error: "unknown live session" });
942
1084
  return true;
943
1085
  }
1086
+ const remote = hub.remoteAction(key);
1087
+ if (remote) {
1088
+ if (action === "close") {
1089
+ sendJson(res, 400, { error: "this session is owned by the terminal UI" });
1090
+ return true;
1091
+ }
1092
+ try {
1093
+ const body = ["approve", "approval-mode", "message", "model", "pro-preset"].includes(action)
1094
+ ? await readJsonBody(req) : {};
1095
+ const result = await remote(action, body);
1096
+ sendJson(res, result.status, result.data);
1097
+ }
1098
+ catch (error) {
1099
+ sendJson(res, 400, { error: error instanceof Error ? error.message : "invalid request" });
1100
+ }
1101
+ return true;
1102
+ }
944
1103
  if (action === "pro-preset") {
945
1104
  if (mode !== "pro") {
946
1105
  sendJson(res, 404, { error: "Pro presets are only available in Pro mode" });
@@ -1123,6 +1282,9 @@ export async function startWebServer(options) {
1123
1282
  host,
1124
1283
  port,
1125
1284
  setHost: (next) => rebind(next),
1285
+ prompt: { pending: options.backgroundPrompt === true },
1286
+ service: options.backgroundService,
1287
+ updateSettings: (body, mode) => updateSettings(body, mode),
1126
1288
  };
1127
1289
  const server = http.createServer((req, res) => {
1128
1290
  handleRequest(req, res, auth, binding).catch((err) => {
@@ -1170,6 +1332,7 @@ export async function startWebServer(options) {
1170
1332
  handle.port = port;
1171
1333
  handle.url = address.url;
1172
1334
  handle.urls = address.urls;
1335
+ options.onAddressChange?.();
1173
1336
  };
1174
1337
  // Two tabs saving at once must not interleave close/listen on one socket.
1175
1338
  let switches = Promise.resolve();
@@ -1211,6 +1374,51 @@ export async function startWebServer(options) {
1211
1374
  switches = task.catch(() => undefined);
1212
1375
  return task;
1213
1376
  }
1377
+ // Serialize saves from both workspaces so a failed switch cannot roll back
1378
+ // another tab's successful change.
1379
+ let settingsChanges = Promise.resolve();
1380
+ function updateSettings(body, mode) {
1381
+ const task = settingsChanges.then(async () => {
1382
+ const previous = loadConfig(mode);
1383
+ const wasHost = binding.host;
1384
+ try {
1385
+ updateWebSettings(body, mode);
1386
+ const patch = body?.settings;
1387
+ const desiredHost = patch && Object.prototype.hasOwnProperty.call(patch, "web_lan")
1388
+ ? patch.web_lan === true ? WILDCARD_HOST : LOOPBACK_HOST
1389
+ : binding.host;
1390
+ if (desiredHost !== binding.host)
1391
+ await binding.setHost(desiredHost);
1392
+ if (patch && Object.prototype.hasOwnProperty.call(patch, "background")) {
1393
+ if (!binding.service?.configure) {
1394
+ throw new Error("Background residency could not be applied. Restart the console and try again.");
1395
+ }
1396
+ binding.service.configure({ resident: patch.background === true });
1397
+ if (patch.background === true)
1398
+ binding.prompt.pending = false;
1399
+ }
1400
+ }
1401
+ catch (error) {
1402
+ // A failed lifetime or address change must not look saved or active.
1403
+ if (binding.host !== wasHost) {
1404
+ try {
1405
+ await binding.setHost(wasHost);
1406
+ }
1407
+ catch { /* Report the original failure. */ }
1408
+ }
1409
+ const config = loadConfig(mode);
1410
+ config.web_lan = previous.web_lan;
1411
+ config.background = previous.background;
1412
+ try {
1413
+ saveConfig(config, mode);
1414
+ }
1415
+ catch { /* Report the original failure. */ }
1416
+ throw error;
1417
+ }
1418
+ });
1419
+ settingsChanges = task.catch(() => undefined);
1420
+ return task;
1421
+ }
1214
1422
  const handle = {
1215
1423
  host,
1216
1424
  port,