@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/app.js CHANGED
@@ -16,7 +16,8 @@ import { AgentSelectScreen } from "./screens/agentSelect.js";
16
16
  import { ModelSelectScreen } from "./screens/modelSelect.js";
17
17
  import { SettingsScreen } from "./screens/settings.js";
18
18
  import { StartScreen } from "./screens/start.js";
19
- import { startWebServer } from "./web/server.js";
19
+ import { configureBackgroundService, readBackgroundStatus, isProcessAlive, runningBackground, startBackgroundService, stopBackgroundService, } from "./background.js";
20
+ import { startTerminalBridge } from "./web/terminalBridge.js";
20
21
  const CURSOR_BLINK_MS = 530;
21
22
  const TOAST_MS = 1600;
22
23
  const INTERRUPT_WINDOW_MS = 1500;
@@ -90,9 +91,15 @@ export class App {
90
91
  // its own (streamed output, a resize) can still carry the anchor along.
91
92
  selOffset = null;
92
93
  resizeHandler = () => this.onResize();
93
- webHandle = null;
94
+ webService = null;
95
+ webStarting = null;
96
+ terminalBridge = null;
97
+ backgroundChanges = Promise.resolve();
98
+ webTray = true;
94
99
  /** Where the web UI lives; kept even while it is switched off. */
95
100
  webOptions = { port: 8765, auth: true };
101
+ /** Offer the background-residency prompt when this process mounts the UI. */
102
+ webPromptBackground = true;
96
103
  /** True while a click is bringing the Web console up, so double clicks are safe. */
97
104
  proOpening = false;
98
105
  constructor() {
@@ -131,22 +138,147 @@ export class App {
131
138
  /**
132
139
  * Remember where the browser UI should listen. Called at startup even when
133
140
  * the console is disabled, so Settings can mount it later without guessing.
141
+ * `backgroundPrompt` is false when the console is meant to run detached.
134
142
  */
135
- configureWeb(port, host, auth) {
143
+ configureWeb(port, host, auth, backgroundPrompt = true, tray = true) {
136
144
  this.webOptions = { port, host, auth };
145
+ this.webPromptBackground = backgroundPrompt;
146
+ this.webTray = tray;
147
+ }
148
+ /**
149
+ * Adopt a Web console that another process owns. No socket is opened here:
150
+ * the terminal only records the address, so Settings and the Pro button can
151
+ * reach it later.
152
+ */
153
+ attachBackground(status) {
154
+ this.webService = status;
155
+ this.webOptions = { port: status.port, host: status.host, auth: status.auth_required };
156
+ this.state.webUrl = status.url;
157
+ this.state.webBackground = status.resident;
137
158
  }
138
159
  /**
139
160
  * Mount the browser UI on the given port. Safe to call once; subsequent calls
140
161
  * return the already-running server.
141
162
  */
142
163
  async enableWeb(port, host, auth = true) {
143
- if (this.webHandle)
144
- return this.webHandle;
164
+ if (this.webService)
165
+ return this.webService;
166
+ if (this.webStarting)
167
+ return this.webStarting;
145
168
  this.webOptions = { port, host, auth };
146
- const handle = await startWebServer({ port, host, auth });
147
- this.webHandle = handle;
148
- this.state.webUrl = handle.url;
149
- return handle;
169
+ this.webStarting = (async () => {
170
+ const bridge = await startTerminalBridge();
171
+ let started = null;
172
+ try {
173
+ const result = await startBackgroundService({
174
+ port, host, auth, tray: this.webTray, resident: loadConfig().background === true,
175
+ ownerPid: process.pid, prompt: this.webPromptBackground,
176
+ });
177
+ if (!result.ok)
178
+ throw new Error(result.error);
179
+ if (result.started)
180
+ started = result.status;
181
+ if (result.status.lifetime_supported === false) {
182
+ // Keep an older resident service reachable while its current tasks
183
+ // finish; it cannot accept live lifetime changes until restarted.
184
+ bridge.close();
185
+ this.attachBackground(result.status);
186
+ return result.status;
187
+ }
188
+ const status = await configureBackgroundService(result.status, {
189
+ ownerPid: process.pid, bridge: { url: bridge.url, token: bridge.token },
190
+ });
191
+ this.terminalBridge = bridge;
192
+ this.attachBackground(status);
193
+ return status;
194
+ }
195
+ catch (error) {
196
+ bridge.close();
197
+ if (started) {
198
+ const current = readBackgroundStatus();
199
+ if (current && current.owner_pids.every(owner => owner === process.pid)) {
200
+ await stopBackgroundService(started);
201
+ }
202
+ }
203
+ throw error;
204
+ }
205
+ })();
206
+ try {
207
+ return await this.webStarting;
208
+ }
209
+ finally {
210
+ this.webStarting = null;
211
+ }
212
+ }
213
+ /**
214
+ * The address the console is reachable at right now: the local socket, the
215
+ * detached service, or nothing.
216
+ */
217
+ currentWebUrl() {
218
+ return this.webService ? this.state.webUrl : null;
219
+ }
220
+ syncWebStatus() {
221
+ if (!this.webService)
222
+ return false;
223
+ const status = readBackgroundStatus();
224
+ if (!status && isProcessAlive(this.webService.pid))
225
+ return false;
226
+ if (!status || status.pid !== this.webService.pid || !isProcessAlive(status.pid)) {
227
+ this.terminalBridge?.close();
228
+ this.terminalBridge = null;
229
+ this.webService = null;
230
+ this.state.webUrl = null;
231
+ this.state.webBackground = false;
232
+ return true;
233
+ }
234
+ const changed = this.state.webUrl !== status.url || this.state.webBackground !== status.resident;
235
+ this.attachBackground(status);
236
+ return changed;
237
+ }
238
+ /**
239
+ * Start the Web console when it is not up yet. Returns the URL to open.
240
+ */
241
+ async ensureWebConsole() {
242
+ this.syncWebStatus();
243
+ const existing = this.currentWebUrl();
244
+ if (existing)
245
+ return existing;
246
+ const status = await this.enableWeb(this.webOptions.port, this.webOptions.host, this.webOptions.auth);
247
+ return status.url;
248
+ }
249
+ /** Change the existing worker's lifetime without moving its conversations. */
250
+ setBackgroundResidency(enabled) {
251
+ const task = this.backgroundChanges.then(async () => {
252
+ try {
253
+ this.syncWebStatus();
254
+ const existing = this.webService ? null : runningBackground();
255
+ if (existing)
256
+ await this.enableWeb(existing.port, existing.host, existing.auth_required);
257
+ if (!enabled && !this.webService) {
258
+ saveConfig({ ...loadConfig(), background: false });
259
+ }
260
+ else {
261
+ await this.ensureWebConsole();
262
+ if (this.webService?.lifetime_supported === false) {
263
+ throw new Error("Restart the console to update background residency.");
264
+ }
265
+ const status = await configureBackgroundService(this.webService, { resident: enabled, ownerPid: process.pid });
266
+ this.attachBackground(status);
267
+ }
268
+ this.showToast(enabled
269
+ ? "Background residency on: browser tasks keep running after this terminal closes"
270
+ : "Background residency off: the console follows this terminal");
271
+ this.render();
272
+ return { ok: true };
273
+ }
274
+ catch (err) {
275
+ const error = err instanceof Error ? err.message : String(err);
276
+ this.showToast(`Background residency failed: ${error}`, "top");
277
+ return { ok: false, error };
278
+ }
279
+ });
280
+ this.backgroundChanges = task.then(() => undefined, () => undefined);
281
+ return task;
150
282
  }
151
283
  /**
152
284
  * Start or stop the browser UI while the terminal keeps running. Returns a
@@ -155,25 +287,32 @@ export class App {
155
287
  */
156
288
  async setWebConsole(enabled) {
157
289
  if (enabled) {
158
- if (this.webHandle)
159
- return { ok: true };
160
290
  try {
161
- await this.enableWeb(this.webOptions.port, this.webOptions.host, this.webOptions.auth);
162
- this.showToast(`Web console: ${this.state.webUrl}`);
291
+ const url = await this.ensureWebConsole();
292
+ this.showToast(`Web console: ${url}`);
163
293
  return { ok: true };
164
294
  }
165
295
  catch (err) {
166
296
  const error = err instanceof Error ? err.message : String(err);
167
- this.state.webUrl = null;
168
297
  this.showToast(`Web console failed: ${error}`, "top");
169
298
  return { ok: false, error };
170
299
  }
171
300
  }
172
- const handle = this.webHandle;
173
- this.webHandle = null;
301
+ if (this.webStarting)
302
+ await this.webStarting.catch(() => { });
303
+ if (this.webService) {
304
+ const stopped = await stopBackgroundService(this.webService);
305
+ if (!stopped.ok) {
306
+ this.showToast(`Web console could not be stopped: ${stopped.error}`, "top");
307
+ return { ok: false, error: stopped.error };
308
+ }
309
+ }
310
+ this.terminalBridge?.close();
311
+ this.terminalBridge = null;
312
+ this.webService = null;
174
313
  this.state.webUrl = null;
314
+ this.state.webBackground = false;
175
315
  this.render();
176
- await handle?.close();
177
316
  this.showToast("Web console stopped");
178
317
  return { ok: true };
179
318
  }
@@ -188,13 +327,10 @@ export class App {
188
327
  return;
189
328
  this.proOpening = true;
190
329
  try {
191
- let handle = this.webHandle;
192
- if (!handle) {
193
- handle = await this.enableWeb(this.webOptions.port, this.webOptions.host, this.webOptions.auth);
194
- }
195
- const url = proModeUrl(handle.url);
196
- openInBrowser(url);
197
- this.showToast(`Opening Pro mode: ${url}`);
330
+ const url = await this.ensureWebConsole();
331
+ const target = proModeUrl(url);
332
+ openInBrowser(target);
333
+ this.showToast(`Opening Pro mode: ${target}`);
198
334
  }
199
335
  catch (err) {
200
336
  const message = err instanceof Error ? err.message : String(err);
@@ -215,7 +351,8 @@ export class App {
215
351
  process.on("SIGINT", () => this.handleInterrupt());
216
352
  process.on("SIGTERM", () => this.quit());
217
353
  this.sizeTimer = setInterval(() => {
218
- if (this.syncSize())
354
+ const webChanged = this.syncWebStatus();
355
+ if (this.syncSize() || webChanged)
219
356
  this.render();
220
357
  }, 200);
221
358
  this.repaintTimer = setInterval(() => this.renderer.repaint(), REPAINT_MS);
@@ -895,7 +1032,7 @@ export class App {
895
1032
  catch {
896
1033
  // Terminal restoration has already run, even if stdin is unusable.
897
1034
  }
898
- void this.webHandle?.close();
1035
+ this.terminalBridge?.close();
899
1036
  process.exit(exitCode);
900
1037
  }
901
1038
  }
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Background residency.
3
+ *
4
+ * A background Neutrino is the ordinary Web console running in a detached
5
+ * process, with no terminal attached. Conversations started from the browser
6
+ * are owned by that process. With residency off, it follows the terminals that
7
+ * opened it; with residency on, it survives them. Changing that lifetime never
8
+ * moves or restarts a conversation. Terminal conversations stay local.
9
+ *
10
+ * The service is discovered through a small status file under `~/.neutrino`
11
+ * (`background.json`). It carries the process id, the address it bound and a
12
+ * random management token; the token protects the stop/ping endpoints from
13
+ * other local processes. The file is short-lived and removed on every clean
14
+ * shutdown, so a stale file is detected by checking the pid.
15
+ */
16
+ import { spawn } from "node:child_process";
17
+ import { randomBytes } from "node:crypto";
18
+ import fs from "node:fs";
19
+ import net from "node:net";
20
+ import path from "node:path";
21
+ import process from "node:process";
22
+ import { fileURLToPath } from "node:url";
23
+ import { NEUTRINO_DIR } from "./config.js";
24
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
+ export const BACKGROUND_STATUS_FILE = path.join(NEUTRINO_DIR, "background.json");
26
+ export const BACKGROUND_LOG_FILE = path.join(NEUTRINO_DIR, "background.log");
27
+ const START_TIMEOUT_MS = 12_000;
28
+ const STOP_TIMEOUT_MS = 5_000;
29
+ const LOG_LIMIT_BYTES = 256 * 1024;
30
+ function ensureDir() {
31
+ fs.mkdirSync(NEUTRINO_DIR, { recursive: true });
32
+ }
33
+ /** True for a positive, ordinary process id. */
34
+ function isPid(value) {
35
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
36
+ }
37
+ /** Validate a status file (or an HTTP response) before trusting it. */
38
+ export function parseBackgroundStatus(value) {
39
+ if (!value || typeof value !== "object" || Array.isArray(value))
40
+ return null;
41
+ const data = value;
42
+ if (!isPid(data.pid))
43
+ return null;
44
+ if (typeof data.port !== "number" || !Number.isSafeInteger(data.port) || data.port < 1 || data.port > 65535)
45
+ return null;
46
+ if (typeof data.host !== "string" || data.host.trim() === "")
47
+ return null;
48
+ if (typeof data.url !== "string" || !/^https?:\/\//i.test(data.url))
49
+ return null;
50
+ if (data.auth_required !== undefined && typeof data.auth_required !== "boolean")
51
+ return null;
52
+ if (typeof data.token !== "string" || data.token.length < 16)
53
+ return null;
54
+ if (typeof data.started_at !== "string" || data.started_at === "")
55
+ return null;
56
+ if (data.resident !== undefined && typeof data.resident !== "boolean")
57
+ return null;
58
+ if (data.owner_pids !== undefined && (!Array.isArray(data.owner_pids) || !data.owner_pids.every(isPid)))
59
+ return null;
60
+ return {
61
+ pid: data.pid,
62
+ port: data.port,
63
+ host: data.host,
64
+ url: data.url.replace(/\/+$/, ""),
65
+ // A status written by an older build predates the flag; the console asked
66
+ // for a password then, which is still the safe reading.
67
+ auth_required: data.auth_required !== false,
68
+ token: data.token,
69
+ started_at: data.started_at,
70
+ // Older services were always resident and had no terminal owners.
71
+ resident: data.resident !== false,
72
+ owner_pids: Array.isArray(data.owner_pids) ? data.owner_pids : [],
73
+ lifetime_supported: typeof data.resident === "boolean" && Array.isArray(data.owner_pids),
74
+ };
75
+ }
76
+ /** The saved status, including a stale file from a process that no longer runs. */
77
+ export function readBackgroundStatus() {
78
+ try {
79
+ return parseBackgroundStatus(JSON.parse(fs.readFileSync(BACKGROUND_STATUS_FILE, "utf-8")));
80
+ }
81
+ catch {
82
+ return null;
83
+ }
84
+ }
85
+ /** Whether `pid` is a live process. Permission errors still mean "alive". */
86
+ export function isProcessAlive(pid) {
87
+ if (!isPid(pid))
88
+ return false;
89
+ try {
90
+ process.kill(pid, 0);
91
+ return true;
92
+ }
93
+ catch (error) {
94
+ return error.code === "EPERM";
95
+ }
96
+ }
97
+ /** The running background service, or null when there is none. */
98
+ export function runningBackground() {
99
+ const status = readBackgroundStatus();
100
+ if (!status)
101
+ return null;
102
+ if (isProcessAlive(status.pid))
103
+ return status;
104
+ clearBackgroundStatus(status.pid);
105
+ return null;
106
+ }
107
+ /** Write the status file atomically so another process never reads half of it. */
108
+ export function writeBackgroundStatus(status) {
109
+ ensureDir();
110
+ const temp = `${BACKGROUND_STATUS_FILE}.${process.pid}.tmp`;
111
+ fs.writeFileSync(temp, JSON.stringify(status, null, 2), { encoding: "utf-8", mode: 0o600 });
112
+ fs.renameSync(temp, BACKGROUND_STATUS_FILE);
113
+ }
114
+ /**
115
+ * Remove the status file. With `pid`, only remove it when it still belongs to
116
+ * that process, so one service can never delete another's registration.
117
+ */
118
+ export function clearBackgroundStatus(pid) {
119
+ try {
120
+ if (pid !== undefined) {
121
+ const current = readBackgroundStatus();
122
+ if (current && current.pid !== pid)
123
+ return;
124
+ }
125
+ fs.rmSync(BACKGROUND_STATUS_FILE, { force: true });
126
+ }
127
+ catch {
128
+ // A status file we cannot remove is retried by the next liveness check.
129
+ }
130
+ }
131
+ /** Append a diagnostic line, rotating the file instead of letting it grow. */
132
+ export function backgroundLog(message) {
133
+ try {
134
+ ensureDir();
135
+ const line = `[${new Date().toISOString()}] ${message}\n`;
136
+ try {
137
+ if (fs.statSync(BACKGROUND_LOG_FILE).size > LOG_LIMIT_BYTES) {
138
+ fs.writeFileSync(BACKGROUND_LOG_FILE, line, "utf-8");
139
+ return;
140
+ }
141
+ }
142
+ catch {
143
+ // No log yet.
144
+ }
145
+ fs.appendFileSync(BACKGROUND_LOG_FILE, line, "utf-8");
146
+ }
147
+ catch {
148
+ // Diagnostics must never break the service.
149
+ }
150
+ }
151
+ /** The last few log lines, for a failed start message. */
152
+ export function backgroundLogTail(lines = 6) {
153
+ try {
154
+ return fs.readFileSync(BACKGROUND_LOG_FILE, "utf-8").trimEnd().split(/\r?\n/).slice(-lines).join("\n");
155
+ }
156
+ catch {
157
+ return "";
158
+ }
159
+ }
160
+ function sleep(ms) {
161
+ return new Promise((resolve) => setTimeout(resolve, ms));
162
+ }
163
+ /** Can a client connect to the listening socket yet? */
164
+ function canConnect(port, host) {
165
+ return new Promise((resolve) => {
166
+ const target = host === "0.0.0.0" || host === "" ? "127.0.0.1"
167
+ : host === "::" || host === "[::]" ? "::1"
168
+ : host;
169
+ const socket = net.connect({ port, host: target });
170
+ let settled = false;
171
+ const done = (ok) => {
172
+ if (settled)
173
+ return;
174
+ settled = true;
175
+ socket.destroy();
176
+ resolve(ok);
177
+ };
178
+ socket.setTimeout(500);
179
+ socket.once("connect", () => done(true));
180
+ socket.once("timeout", () => done(false));
181
+ socket.once("error", () => done(false));
182
+ });
183
+ }
184
+ /** The CLI entry point, used by the detached child. */
185
+ function cliEntry() {
186
+ // Always the built CLI next to this module. `process.argv[1]` belongs to the
187
+ // caller (a test, the terminal UI, a wrapper) and must not be re-executed.
188
+ const bundled = path.join(__dirname, "bin.js");
189
+ if (fs.existsSync(bundled))
190
+ return bundled;
191
+ const entry = process.argv[1];
192
+ if (entry && fs.existsSync(entry))
193
+ return path.resolve(entry);
194
+ return bundled;
195
+ }
196
+ /**
197
+ * Start the detached console, or reuse the one already running.
198
+ *
199
+ * The child writes the same status file we poll here, so "the address is
200
+ * reachable" and "the file names this exact process" both have to hold before
201
+ * the start counts as successful. The random token in the env is what proves
202
+ * the file was written by *our* child rather than an older instance.
203
+ */
204
+ export async function startBackgroundService(options) {
205
+ const existing = runningBackground();
206
+ if (existing) {
207
+ if (existing.auth_required !== options.auth) {
208
+ return { ok: false, error: "the running Web console uses a different password setting" };
209
+ }
210
+ if (options.host && existing.host !== options.host) {
211
+ return { ok: false, error: `the running Web console is bound to ${existing.host}, not ${options.host}` };
212
+ }
213
+ if (options.strictPort && options.port !== null && existing.port !== options.port) {
214
+ return { ok: false, error: `the running Web console uses port ${existing.port}, not ${options.port}` };
215
+ }
216
+ // An older detached service is already resident. Reuse it without trying
217
+ // to register an owner through an endpoint that does not exist yet.
218
+ if (existing.lifetime_supported === false)
219
+ return { ok: true, status: existing, started: false };
220
+ const update = {};
221
+ if (options.ownerPid && !existing.owner_pids.includes(options.ownerPid))
222
+ update.ownerPid = options.ownerPid;
223
+ if (options.resident !== false && !existing.resident)
224
+ update.resident = true;
225
+ try {
226
+ const status = Object.keys(update).length ? await configureBackgroundService(existing, update) : existing;
227
+ return { ok: true, status, started: false };
228
+ }
229
+ catch (error) {
230
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
231
+ }
232
+ }
233
+ if (options.resident === false && (!options.ownerPid || !isProcessAlive(options.ownerPid))) {
234
+ return { ok: false, error: "a non-resident Web console needs a running terminal" };
235
+ }
236
+ clearBackgroundStatus();
237
+ const token = randomBytes(32).toString("hex");
238
+ const port = options.port ?? 8765;
239
+ const args = [cliEntry(), "background", "run", "--port", String(port)];
240
+ if (options.host)
241
+ args.push("--host", options.host);
242
+ if (!options.auth)
243
+ args.push("--no-auth");
244
+ if (options.tray === false)
245
+ args.push("--no-tray");
246
+ let child;
247
+ try {
248
+ child = spawn(process.execPath, args, {
249
+ detached: true,
250
+ stdio: "ignore",
251
+ windowsHide: true,
252
+ env: {
253
+ ...process.env,
254
+ NEUTRINO_BACKGROUND_TOKEN: token,
255
+ NEUTRINO_BACKGROUND_RESIDENT: options.resident === false ? "0" : "1",
256
+ NEUTRINO_BACKGROUND_OWNER: options.ownerPid ? String(options.ownerPid) : "",
257
+ NEUTRINO_BACKGROUND_PROMPT: options.prompt ? "1" : "0",
258
+ },
259
+ });
260
+ }
261
+ catch (error) {
262
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
263
+ }
264
+ child.once("error", (error) => backgroundLog(`spawn failed: ${error.message}`));
265
+ child.unref();
266
+ const deadline = Date.now() + START_TIMEOUT_MS;
267
+ while (Date.now() < deadline) {
268
+ if (child.exitCode !== null) {
269
+ const tail = backgroundLogTail();
270
+ return {
271
+ ok: false,
272
+ error: `the background service exited with code ${child.exitCode}${tail ? `: ${tail}` : ""}`,
273
+ };
274
+ }
275
+ const status = readBackgroundStatus();
276
+ if (status && status.token === token && isProcessAlive(status.pid) && await canConnect(status.port, status.host)) {
277
+ return { ok: true, status, started: true };
278
+ }
279
+ await sleep(100);
280
+ }
281
+ // Don't leave a half-started service behind; it has no registration yet.
282
+ if (child.pid && isProcessAlive(child.pid)) {
283
+ try {
284
+ process.kill(child.pid, "SIGTERM");
285
+ }
286
+ catch { /* Already gone. */ }
287
+ }
288
+ const tail = backgroundLogTail();
289
+ return {
290
+ ok: false,
291
+ error: `the background service did not become reachable${tail ? `: ${tail}` : `; see ${BACKGROUND_LOG_FILE}`}`,
292
+ };
293
+ }
294
+ /** Apply a lifetime change to the process that already owns the conversations. */
295
+ export async function configureBackgroundService(status, update) {
296
+ const response = await fetch(`${status.url}/api/background/configure`, {
297
+ method: "POST",
298
+ headers: { Authorization: `Bearer ${status.token}`, "Content-Type": "application/json" },
299
+ body: JSON.stringify(update),
300
+ signal: AbortSignal.timeout(5000),
301
+ });
302
+ const data = await response.json();
303
+ if (!response.ok)
304
+ throw new Error(data.error || "could not update background residency");
305
+ const updated = parseBackgroundStatus(data);
306
+ if (!updated || updated.pid !== status.pid || updated.token !== status.token) {
307
+ throw new Error("the Web console returned an invalid background status");
308
+ }
309
+ return updated;
310
+ }
311
+ /** Stop the detached service, politely first and by signal if it is stuck. */
312
+ export async function stopBackgroundService(expected) {
313
+ const status = runningBackground();
314
+ if (!status) {
315
+ if (!expected)
316
+ clearBackgroundStatus();
317
+ return { ok: true, stopped: false };
318
+ }
319
+ if (expected && (status.pid !== expected.pid || status.token !== expected.token)) {
320
+ return { ok: false, stopped: false, error: "the Web console changed before it could be stopped" };
321
+ }
322
+ let requested = false;
323
+ try {
324
+ const response = await fetch(`${status.url}/api/background/stop`, {
325
+ method: "POST",
326
+ headers: { Authorization: `Bearer ${status.token}` },
327
+ signal: AbortSignal.timeout(2500),
328
+ });
329
+ requested = response.ok;
330
+ }
331
+ catch {
332
+ // The process may already be gone, or its socket may be wedged.
333
+ }
334
+ if (!requested) {
335
+ try {
336
+ process.kill(status.pid, "SIGTERM");
337
+ }
338
+ catch { /* Already gone. */ }
339
+ }
340
+ const deadline = Date.now() + STOP_TIMEOUT_MS;
341
+ while (Date.now() < deadline && isProcessAlive(status.pid))
342
+ await sleep(100);
343
+ if (isProcessAlive(status.pid)) {
344
+ try {
345
+ process.kill(status.pid, "SIGKILL");
346
+ }
347
+ catch { /* Already gone. */ }
348
+ const hardDeadline = Date.now() + 1500;
349
+ while (Date.now() < hardDeadline && isProcessAlive(status.pid))
350
+ await sleep(100);
351
+ }
352
+ const alive = isProcessAlive(status.pid);
353
+ if (!alive)
354
+ clearBackgroundStatus(status.pid);
355
+ return alive
356
+ ? { ok: false, stopped: false, error: `background service ${status.pid} did not stop` }
357
+ : { ok: true, stopped: true };
358
+ }