om-memory-system 3.6.0-next.38 → 3.6.0-next.40

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/index.js CHANGED
@@ -9,11 +9,9 @@ import { OPENCODE_IMPORT_COMMAND, OPENCODE_IMPORT_DESCRIPTION, latestUserMessage
9
9
  import { buildRecentMemoriesSection, formatMemoriesForCompaction } from "./core/retrieval.js";
10
10
  import { performUserProfileLearning } from "./adapters/opencode/profile-learning.js";
11
11
  import { userPromptManager } from "./services/user-prompt/user-prompt-manager.js";
12
- import { startWebServer, WebServer } from "./services/web-server.js";
13
12
  import { pruneTraces } from "./services/capture-diagnostics.js";
14
13
  import { ensureTursoReady } from "./services/turso/ready.js";
15
14
  import { tursoConnectionManager } from "./services/turso/connection-manager.js";
16
- import { WebAuth } from "./services/web-auth.js";
17
15
  import { isConfigured, CONFIG, initConfigWithLegacyMigration } from "./config.js";
18
16
  import { resolveOpencodeHostModel } from "./services/ai/live-model-choice.js";
19
17
  import { log } from "./services/logger.js";
@@ -193,7 +191,6 @@ export const OmmsPlugin = async (ctx) => {
193
191
  logAutoCaptureProviderStatus();
194
192
  const tags = getTags(directory);
195
193
  const autoCaptureHost = createOpenCodeAutoCaptureHost(ctx);
196
- let webServer = null;
197
194
  let idleTimeout = null;
198
195
  const GLOBAL_PLUGIN_WARMUP_KEY = Symbol.for("omms.plugin.warmedup");
199
196
  if (!globalThis[GLOBAL_PLUGIN_WARMUP_KEY] && isConfigured()) {
@@ -211,8 +208,8 @@ export const OmmsPlugin = async (ctx) => {
211
208
  await configureOpencodeHostTransport(ctx);
212
209
  // Shared profile dedup, conflict, description, and cleanup calls use OpenCode's host model.
213
210
  registerOpencodeProfileModel();
214
- // Web imports, Health, and Settings served by this process can list and use OpenCode's models.
215
- // Awaited so the web server never serves those routes before the registry is filled.
211
+ // In-process backfill, capture, and the capture retry drain use OpenCode's models.
212
+ // Awaited so those paths never run before the registry is filled.
216
213
  await import("./adapters/opencode/backfill-startup.js")
217
214
  .then(({ registerOpencodeImportModels, registerOpencodeCaptureRetryDrain }) => {
218
215
  registerOpencodeImportModels();
@@ -290,96 +287,36 @@ export const OmmsPlugin = async (ctx) => {
290
287
  }
291
288
  }
292
289
  }
293
- if (CONFIG.webServerEnabled && tursoReadyForWeb) {
294
- const webAuth = new WebAuth({
295
- password: CONFIG.webServerAuthPassword,
296
- username: CONFIG.webServerAuthUsername,
297
- });
298
- startWebServer({
299
- directory,
300
- port: CONFIG.webServerPort,
301
- host: CONFIG.webServerHost,
302
- enabled: CONFIG.webServerEnabled,
303
- auth: webAuth,
304
- apiToken: CONFIG.webServerApiToken,
305
- })
306
- .then((server) => {
307
- webServer = server;
308
- const url = webServer.getUrl();
309
- webServer.setOnTakeoverCallback(async () => {
310
- if (ctx.client?.tui) {
311
- ctx.client.tui
312
- .showToast({
313
- body: {
314
- title: "Memory Explorer",
315
- message: "Took over web server ownership",
316
- variant: "success",
317
- duration: 3000,
318
- },
319
- })
320
- .catch(() => { });
321
- }
322
- });
323
- webServer.setOnPortsExhaustedCallback(() => {
324
- if (ctx.client?.tui) {
325
- ctx.client.tui
326
- .showToast({
327
- body: {
328
- title: "Memory Explorer",
329
- message: `Web UI unavailable: ports ${CONFIG.webServerPort}-${CONFIG.webServerPort + 10} are held by non-responsive processes`,
330
- variant: "error",
331
- duration: 5000,
332
- },
333
- })
334
- .catch(() => { });
290
+ if (CONFIG.webServerEnabled &&
291
+ tursoReadyForWeb &&
292
+ process.env.OMMS_DISABLE_WEB_AUTOSTART !== "1") {
293
+ const showToast = (message, variant, duration) => {
294
+ if (!ctx.client?.tui)
295
+ return;
296
+ ctx.client.tui
297
+ .showToast({ body: { title: "Memory Explorer", message, variant, duration } })
298
+ .catch(() => { });
299
+ };
300
+ // The shared web app serves the page. This plugin starts it only when none runs.
301
+ void Promise.all([import("./services/web-ensure.js"), import("./services/web-api-auth.js")])
302
+ .then(([{ ensureWebApp }, { webServerUrl }]) => {
303
+ const baseUrl = webServerUrl(CONFIG.webServerHost, CONFIG.webServerPort);
304
+ return ensureWebApp({
305
+ settings: { enabled: CONFIG.webServerEnabled, baseUrl },
306
+ budgetMs: 10_000,
307
+ }).then((result) => {
308
+ if (result === "running" || result === "started") {
309
+ showToast(`Web UI available at ${baseUrl}`, "info", 3000);
335
310
  }
336
- });
337
- if (webServer.isServerOwner()) {
338
- if (ctx.client?.tui) {
339
- ctx.client.tui
340
- .showToast({
341
- body: {
342
- title: "Memory Explorer",
343
- message: webAuth.isEnabled()
344
- ? `Web UI started at ${url} (auth required)`
345
- : `Web UI started at ${url}`,
346
- variant: "success",
347
- duration: 5000,
348
- },
349
- })
350
- .catch(() => { });
311
+ else if (result === "port-busy") {
312
+ showToast(`Web UI unavailable: another program uses port ${CONFIG.webServerPort}`, "error", 5000);
351
313
  }
352
- }
353
- else {
354
- if (ctx.client?.tui) {
355
- ctx.client.tui
356
- .showToast({
357
- body: {
358
- title: "Memory Explorer",
359
- message: `Web UI available at ${url}`,
360
- variant: "info",
361
- duration: 3000,
362
- },
363
- })
364
- .catch(() => { });
314
+ else if (result === "no-runtime") {
315
+ showToast("Web UI unavailable: no Node or Bun runtime found to start it", "error", 5000);
365
316
  }
366
- }
317
+ });
367
318
  })
368
- .catch((error) => {
369
- log("Web server failed to start", { error: String(error) });
370
- if (ctx.client?.tui) {
371
- ctx.client.tui
372
- .showToast({
373
- body: {
374
- title: "Memory Explorer Error",
375
- message: `Failed to start: ${String(error)}`,
376
- variant: "error",
377
- duration: 5000,
378
- },
379
- })
380
- .catch(() => { });
381
- }
382
- });
319
+ .catch((error) => log("Web app start failed", { error: String(error) }));
383
320
  }
384
321
  let cleanedUp = false;
385
322
  const cleanupPlugin = async () => {
@@ -390,8 +327,6 @@ export const OmmsPlugin = async (ctx) => {
390
327
  clearTimeout(idleTimeout);
391
328
  idleTimeout = null;
392
329
  }
393
- if (webServer)
394
- await webServer.stop();
395
330
  if (memoryClient)
396
331
  await memoryClient.close();
397
332
  };
@@ -607,12 +542,10 @@ export const OmmsPlugin = async (ctx) => {
607
542
  idleTimeout = setTimeout(async () => {
608
543
  try {
609
544
  await performAutoCapture(autoCaptureHost, sessionID, directory);
610
- if (webServer?.isServerOwner()) {
611
- await performUserProfileLearning(ctx, directory);
612
- const { cleanupService } = await import("./services/cleanup-service.js");
613
- if (await cleanupService.shouldRunCleanup())
614
- await cleanupService.runCleanup();
615
- }
545
+ await performUserProfileLearning(ctx, directory);
546
+ const { cleanupService } = await import("./services/cleanup-service.js");
547
+ if (await cleanupService.shouldRunCleanup())
548
+ await cleanupService.runCleanup();
616
549
  }
617
550
  catch (error) {
618
551
  log("Idle processing error", { error: String(error) });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Compare two versions in SemVer order. Returns a negative number when `a` is older,
3
+ * a positive number when `a` is newer, `0` when equal, and `null` when either
4
+ * version cannot be parsed, such as `unknown`.
5
+ */
6
+ export declare function compareVersions(a: string, b: string): number | null;
7
+ /** True only when `a` is provably older than `b`. An unplaceable version is never older. */
8
+ export declare function isOlderVersion(a: string, b: string): boolean;
@@ -0,0 +1,58 @@
1
+ const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
2
+ function parse(version) {
3
+ const match = SEMVER.exec(version.trim());
4
+ if (!match)
5
+ return null;
6
+ return {
7
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
8
+ pre: match[4] ? match[4].split(".") : [],
9
+ };
10
+ }
11
+ function comparePart(a, b) {
12
+ const aNumeric = /^\d+$/.test(a);
13
+ const bNumeric = /^\d+$/.test(b);
14
+ if (aNumeric && bNumeric)
15
+ return Math.sign(Number(a) - Number(b));
16
+ // A numeric part is older than a text part.
17
+ if (aNumeric)
18
+ return -1;
19
+ if (bNumeric)
20
+ return 1;
21
+ return a < b ? -1 : a > b ? 1 : 0;
22
+ }
23
+ /**
24
+ * Compare two versions in SemVer order. Returns a negative number when `a` is older,
25
+ * a positive number when `a` is newer, `0` when equal, and `null` when either
26
+ * version cannot be parsed, such as `unknown`.
27
+ */
28
+ export function compareVersions(a, b) {
29
+ const left = parse(a);
30
+ const right = parse(b);
31
+ if (!left || !right)
32
+ return null;
33
+ for (let i = 0; i < 3; i++) {
34
+ const diff = left.core[i] - right.core[i];
35
+ if (diff)
36
+ return Math.sign(diff);
37
+ }
38
+ // A release is newer than any of its prereleases.
39
+ if (!left.pre.length || !right.pre.length)
40
+ return right.pre.length - left.pre.length;
41
+ for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i++) {
42
+ const l = left.pre[i];
43
+ const r = right.pre[i];
44
+ if (l === undefined)
45
+ return -1;
46
+ if (r === undefined)
47
+ return 1;
48
+ const diff = comparePart(l, r);
49
+ if (diff)
50
+ return diff;
51
+ }
52
+ return 0;
53
+ }
54
+ /** True only when `a` is provably older than `b`. An unplaceable version is never older. */
55
+ export function isOlderVersion(a, b) {
56
+ const order = compareVersions(a, b);
57
+ return order !== null && order < 0;
58
+ }
@@ -1,4 +1,6 @@
1
1
  export declare function isLoopbackHost(host: string): boolean;
2
+ /** True for a peer socket address on this machine: 127.x.x.x, ::1, or an IPv4-mapped 127.x.x.x. */
3
+ export declare function isLoopbackAddress(address: string | undefined): boolean;
2
4
  /** A URL needs brackets around an IPv6 literal such as ::1. */
3
5
  export declare function webServerUrl(host: string, port: number): string;
4
6
  export declare function assertWebServerNetworkAuth(host: string, apiToken?: string, basicAuthEnabled?: boolean): void;
@@ -3,6 +3,16 @@ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]", "0:0:0
3
3
  export function isLoopbackHost(host) {
4
4
  return LOOPBACK_HOSTS.has(host.trim().toLowerCase());
5
5
  }
6
+ /** True for a peer socket address on this machine: 127.x.x.x, ::1, or an IPv4-mapped 127.x.x.x. */
7
+ export function isLoopbackAddress(address) {
8
+ if (!address)
9
+ return false;
10
+ const value = address
11
+ .trim()
12
+ .toLowerCase()
13
+ .replace(/^::ffff:/, "");
14
+ return value === "::1" || /^127(?:\.\d{1,3}){3}$/.test(value);
15
+ }
6
16
  /** A URL needs brackets around an IPv6 literal such as ::1. */
7
17
  export function webServerUrl(host, port) {
8
18
  const urlHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
@@ -24,6 +24,12 @@ export declare function webAutostartStatus(options?: WebAutostartOptions): WebAu
24
24
  export declare function installWebAutostart(options?: WebAutostartOptions): WebAutostartStatus;
25
25
  /** Remove only an item carrying the OMMS marker. */
26
26
  export declare function removeWebAutostart(options?: WebAutostartOptions): WebAutostartStatus;
27
+ /**
28
+ * Restart the login item through the platform service manager. A detached child
29
+ * of the item would die with its launchd job or systemd control group, so the
30
+ * service manager does the restart. Returns false when it cannot.
31
+ */
32
+ export declare function restartWebAutostart(options?: WebAutostartOptions): boolean;
27
33
  /** Host start checks the global settings. A failed item cannot block a session. */
28
34
  export declare function reconcileWebAutostart(config?: Pick<typeof CONFIG, "webServerAutoStart" | "webServerEnabled">, options?: WebAutostartOptions): void;
29
35
  export {};
@@ -211,6 +211,37 @@ export function removeWebAutostart(options = {}) {
211
211
  unlinkSync(path);
212
212
  return { state: "not-installed", path };
213
213
  }
214
+ /**
215
+ * Restart the login item through the platform service manager. A detached child
216
+ * of the item would die with its launchd job or systemd control group, so the
217
+ * service manager does the restart. Returns false when it cannot.
218
+ */
219
+ export function restartWebAutostart(options = {}) {
220
+ const home = options.home ?? homedir();
221
+ const platform = options.platform ?? process.platform;
222
+ try {
223
+ if (platform === "darwin") {
224
+ runCommand(options, home, "launchctl", [
225
+ "kickstart",
226
+ "-k",
227
+ `gui/${process.getuid?.() ?? 0}/${NAME}`,
228
+ ]);
229
+ }
230
+ else if (platform === "linux") {
231
+ runCommand(options, home, "systemctl", ["--user", "restart", "omms-web.service"]);
232
+ }
233
+ else {
234
+ return false;
235
+ }
236
+ return true;
237
+ }
238
+ catch (error) {
239
+ log("OMMS login item restart failed", {
240
+ code: error instanceof Error ? error.name : "unknown",
241
+ });
242
+ return false;
243
+ }
244
+ }
214
245
  /** Host start checks the global settings. A failed item cannot block a session. */
215
246
  export function reconcileWebAutostart(config = CONFIG, options = {}) {
216
247
  if (process.env.OMMS_DISABLE_WEB_AUTOSTART === "1")
@@ -0,0 +1,59 @@
1
+ export type EnsureResult = "running" | "started" | "disabled" | "port-busy" | "no-runtime" | "start-timeout";
2
+ /** Port and switch of the web app, as the caller read them from `CONFIG`. */
3
+ export interface EnsureSettings {
4
+ enabled: boolean;
5
+ baseUrl: string;
6
+ }
7
+ interface SpawnedChild {
8
+ unref(): void;
9
+ on(event: "error", listener: (error: Error) => void): unknown;
10
+ }
11
+ type SpawnFn = (command: string, args: string[], options: {
12
+ detached: true;
13
+ stdio: "ignore";
14
+ cwd: string;
15
+ windowsHide: true;
16
+ }) => SpawnedChild;
17
+ /** The three file operations the start lock needs. */
18
+ export interface LockFs {
19
+ /** Create the file only when it does not exist. Returns false when it does. */
20
+ createExclusive(path: string, text: string): boolean;
21
+ read(path: string): string | null;
22
+ remove(path: string): void;
23
+ }
24
+ export interface EnsureDeps {
25
+ fetch: typeof fetch;
26
+ spawn: SpawnFn;
27
+ sleep: (ms: number) => Promise<void>;
28
+ now: () => number;
29
+ resolveRuntime: () => Promise<string | null> | string | null;
30
+ /** The `om-memory-system` script that `web` is run from. */
31
+ cliScript: string;
32
+ lockPath: string;
33
+ lockFs: LockFs;
34
+ pidAlive: (pid: number) => boolean;
35
+ /** The pid written into the lock. */
36
+ pid: number;
37
+ log: (message: string, data: Record<string, unknown>) => void | Promise<void>;
38
+ }
39
+ export interface EnsureOptions {
40
+ settings: EnsureSettings;
41
+ /** How long the caller may wait for the web app to answer. */
42
+ budgetMs: number;
43
+ /** `false` starts the web app and returns at once (session starts). Default `true`. */
44
+ wait?: boolean;
45
+ deps?: Partial<EnsureDeps>;
46
+ }
47
+ /** A lock older than this is stale. Also the longest a background start holds it. */
48
+ export declare const START_LOCK_STALE_MS = 20000;
49
+ /** Path of the start lock; the standalone command writes it for a restart copy. */
50
+ export declare function startLockPath(home?: string): string;
51
+ export declare const nodeLockFs: LockFs;
52
+ /** Remove the start lock when it names `pid`. A restart copy calls this once it owns the port. */
53
+ export declare function removeStartLockFor(pid: number, path?: string, lockFs?: LockFs): void;
54
+ /**
55
+ * Use a running OMMS web app, or start one detached `om-memory-system web`.
56
+ * Never throws; a failed check must not block or fail the host session.
57
+ */
58
+ export declare function ensureWebApp(options: EnsureOptions): Promise<EnsureResult>;
59
+ export {};
@@ -0,0 +1,214 @@
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ const HEALTH_PROBE_MS = 1_000;
7
+ const POLL_INTERVAL_MS = 250;
8
+ /** A lock older than this is stale. Also the longest a background start holds it. */
9
+ export const START_LOCK_STALE_MS = 20_000;
10
+ /** Path of the start lock; the standalone command writes it for a restart copy. */
11
+ export function startLockPath(home = homedir()) {
12
+ return join(home, ".omms", "web-start.lock");
13
+ }
14
+ export const nodeLockFs = {
15
+ createExclusive(path, text) {
16
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
17
+ try {
18
+ writeFileSync(path, text, { flag: "wx", mode: 0o600 });
19
+ return true;
20
+ }
21
+ catch (error) {
22
+ if (error.code === "EEXIST")
23
+ return false;
24
+ throw error;
25
+ }
26
+ },
27
+ read(path) {
28
+ try {
29
+ return readFileSync(path, "utf8");
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ },
35
+ remove(path) {
36
+ rmSync(path, { force: true });
37
+ },
38
+ };
39
+ /** Remove the start lock when it names `pid`. A restart copy calls this once it owns the port. */
40
+ export function removeStartLockFor(pid, path = startLockPath(), lockFs = nodeLockFs) {
41
+ try {
42
+ const held = JSON.parse(lockFs.read(path) ?? "null");
43
+ if (held?.pid === pid)
44
+ lockFs.remove(path);
45
+ }
46
+ catch {
47
+ /* An unreadable lock is stale and the next caller replaces it. */
48
+ }
49
+ }
50
+ function processAlive(pid) {
51
+ try {
52
+ process.kill(pid, 0);
53
+ return true;
54
+ }
55
+ catch (error) {
56
+ // EPERM means the process exists but belongs to someone else.
57
+ return error.code === "EPERM";
58
+ }
59
+ }
60
+ function productionDefaults() {
61
+ return {
62
+ fetch: globalThis.fetch,
63
+ spawn: nodeSpawn,
64
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
65
+ now: () => Date.now(),
66
+ resolveRuntime: async () => (await import("./web-autostart.js")).resolveWebRuntime(),
67
+ // From dist/services/ to the CLI entry point.
68
+ cliScript: fileURLToPath(new URL("../cli/index.js", import.meta.url)),
69
+ lockPath: startLockPath(),
70
+ lockFs: nodeLockFs,
71
+ pidAlive: processAlive,
72
+ pid: process.pid,
73
+ log: async (message, data) => (await import("./logger.js")).log(message, data),
74
+ };
75
+ }
76
+ /** An OMMS reply is `{ success: true, status: "ok" }`; another local service must not count. */
77
+ async function probe(baseUrl, timeoutMs, deps) {
78
+ try {
79
+ const response = await deps.fetch(`${baseUrl}/api/health`, {
80
+ method: "GET",
81
+ signal: AbortSignal.timeout(Math.max(1, timeoutMs)),
82
+ });
83
+ if (!response.ok)
84
+ return "other";
85
+ const body = (await response.json());
86
+ return body.success === true && body.status === "ok" ? "omms" : "other";
87
+ }
88
+ catch (error) {
89
+ // A reply that is not JSON came from a program that answered; a refused or
90
+ // timed out connection means nothing answers.
91
+ return error instanceof SyntaxError ? "other" : "none";
92
+ }
93
+ }
94
+ function lockIsStale(text, deps) {
95
+ if (text === null)
96
+ return true;
97
+ try {
98
+ const { pid, at } = JSON.parse(text);
99
+ if (typeof pid !== "number" || typeof at !== "number")
100
+ return true;
101
+ return !deps.pidAlive(pid) || deps.now() - at > START_LOCK_STALE_MS;
102
+ }
103
+ catch {
104
+ return true;
105
+ }
106
+ }
107
+ /** Take the start lock. A stale lock is removed and taken once more. */
108
+ function takeLock(deps) {
109
+ const mine = JSON.stringify({ pid: deps.pid, at: deps.now() });
110
+ if (deps.lockFs.createExclusive(deps.lockPath, mine))
111
+ return true;
112
+ const seen = deps.lockFs.read(deps.lockPath);
113
+ if (!lockIsStale(seen, deps))
114
+ return false;
115
+ // Another caller may have replaced the stale lock since it was read.
116
+ if (deps.lockFs.read(deps.lockPath) !== seen)
117
+ return false;
118
+ deps.lockFs.remove(deps.lockPath);
119
+ return deps.lockFs.createExclusive(deps.lockPath, mine);
120
+ }
121
+ /**
122
+ * Use a running OMMS web app, or start one detached `om-memory-system web`.
123
+ * Never throws; a failed check must not block or fail the host session.
124
+ */
125
+ export async function ensureWebApp(options) {
126
+ const { settings, budgetMs, wait = true } = options;
127
+ if (!settings.enabled)
128
+ return "disabled";
129
+ const deps = { ...productionDefaults(), ...options.deps };
130
+ const began = deps.now();
131
+ const remaining = () => budgetMs - (deps.now() - began);
132
+ const probeMs = () => budgetMs > 0 ? Math.min(HEALTH_PROBE_MS, Math.max(1, remaining())) : HEALTH_PROBE_MS;
133
+ const logCode = async (code) => {
134
+ try {
135
+ await deps.log("Web app ensure", { code });
136
+ }
137
+ catch {
138
+ /* A failed log line must not break the host. */
139
+ }
140
+ };
141
+ let held = false;
142
+ try {
143
+ const first = await probe(settings.baseUrl, probeMs(), deps);
144
+ if (first === "omms")
145
+ return "running";
146
+ if (first === "other") {
147
+ await logCode("port-busy");
148
+ return "port-busy";
149
+ }
150
+ const runtime = await deps.resolveRuntime();
151
+ if (!runtime) {
152
+ await logCode("no-runtime");
153
+ return "no-runtime";
154
+ }
155
+ held = takeLock(deps);
156
+ if (!held)
157
+ return await waitForOther(settings.baseUrl, wait, remaining, deps);
158
+ // Another caller may have finished its start before this one took the lock.
159
+ const second = await probe(settings.baseUrl, probeMs(), deps);
160
+ if (second === "omms")
161
+ return "running";
162
+ if (second === "other") {
163
+ await logCode("port-busy");
164
+ return "port-busy";
165
+ }
166
+ const child = deps.spawn(runtime, [deps.cliScript, "web"], {
167
+ detached: true,
168
+ stdio: "ignore",
169
+ cwd: homedir(),
170
+ windowsHide: true,
171
+ });
172
+ // A missing runtime reports through this event; polling then runs out.
173
+ child.on("error", () => undefined);
174
+ child.unref();
175
+ if (!wait) {
176
+ // Keep the lock until the web app answers, so a second host waits for it.
177
+ const releaseWhenUp = pollUntilUp(settings.baseUrl, START_LOCK_STALE_MS, deps)
178
+ .catch(() => false)
179
+ .finally(() => removeStartLockFor(deps.pid, deps.lockPath, deps.lockFs));
180
+ void releaseWhenUp;
181
+ held = false;
182
+ return "started";
183
+ }
184
+ const up = await pollUntilUp(settings.baseUrl, remaining(), deps);
185
+ return up ? "started" : "start-timeout";
186
+ }
187
+ catch {
188
+ await logCode("error");
189
+ return "start-timeout";
190
+ }
191
+ finally {
192
+ if (held)
193
+ removeStartLockFor(deps.pid, deps.lockPath, deps.lockFs);
194
+ }
195
+ }
196
+ /** A caller without the lock never spawns. It waits for the caller that holds it. */
197
+ async function waitForOther(baseUrl, wait, remaining, deps) {
198
+ if (!wait)
199
+ return "started";
200
+ return (await pollUntilUp(baseUrl, remaining(), deps)) ? "running" : "start-timeout";
201
+ }
202
+ /** Poll health every 250 ms until an OMMS web app answers or the budget ends. */
203
+ async function pollUntilUp(baseUrl, budgetMs, deps) {
204
+ const began = deps.now();
205
+ const remaining = () => budgetMs - (deps.now() - began);
206
+ while (remaining() > 0) {
207
+ await deps.sleep(Math.min(POLL_INTERVAL_MS, remaining()));
208
+ if (remaining() <= 0)
209
+ break;
210
+ if ((await probe(baseUrl, Math.min(HEALTH_PROBE_MS, remaining()), deps)) === "omms")
211
+ return true;
212
+ }
213
+ return false;
214
+ }
@@ -23,7 +23,10 @@ interface WebServerConfig {
23
23
  enabled: boolean;
24
24
  auth?: WebAuth;
25
25
  apiToken?: string;
26
+ /** How long a stepped-aside server waits before it may take the port back. */
27
+ stepAsideHoldOffMs?: number;
26
28
  }
29
+ type PowerAction = "stop" | "restart";
27
30
  export declare class WebServer {
28
31
  private server;
29
32
  private config;
@@ -32,6 +35,11 @@ export declare class WebServer {
32
35
  private healthCheckInterval;
33
36
  private onTakeoverCallback;
34
37
  private onPortsExhaustedCallback;
38
+ private onStepAsideCallback;
39
+ private onPowerActionCallback;
40
+ private powerActionRunning;
41
+ private stepAsideTimer;
42
+ private holdOffTimer;
35
43
  private portsExhaustedNotified;
36
44
  private takeoverFailures;
37
45
  private readonly maxFallbackPort;
@@ -46,6 +54,13 @@ export declare class WebServer {
46
54
  constructor(config: WebServerConfig);
47
55
  setOnTakeoverCallback(callback: () => Promise<void>): void;
48
56
  setOnPortsExhaustedCallback(callback: () => void): void;
57
+ /** Standalone web apps register this to exit when a newer OMMS asks them to step aside. */
58
+ setOnStepAside(callback: () => void | Promise<void>): void;
59
+ /** Standalone web apps register this to stop or restart from the page. */
60
+ setOnPowerAction(callback: (action: PowerAction) => void | Promise<void>): void;
61
+ private handlePowerAction;
62
+ private scheduleStepAside;
63
+ private handleStepAside;
49
64
  start(): Promise<void>;
50
65
  private _start;
51
66
  private startHealthCheckLoop;