om-memory-system 3.6.0-next.39 → 3.6.0-next.42

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) });
@@ -496,7 +496,7 @@ export async function handleSearch(query, tag, page = 1, pageSize = 20) {
496
496
  type: "memory",
497
497
  id: r.id,
498
498
  content: r.memory,
499
- memoryType: r.metadata?.type,
499
+ memoryType: r.type ?? r.metadata?.type,
500
500
  tags: r.tags,
501
501
  createdAt: safeToISOString(r.createdAt ?? r.metadata?.createdAt),
502
502
  updatedAt: r.metadata?.updatedAt ? safeToISOString(r.metadata.updatedAt) : undefined,
@@ -64,6 +64,7 @@ export declare class LocalMemoryClient {
64
64
  memories: {
65
65
  id: any;
66
66
  summary: any;
67
+ type: string | undefined;
67
68
  createdAt: string;
68
69
  metadata: any;
69
70
  displayName: any;
@@ -204,6 +204,7 @@ export class LocalMemoryClient {
204
204
  const memories = allMemories.slice(0, limit).map((r) => ({
205
205
  id: r.id,
206
206
  summary: r.content,
207
+ type: r.type ? String(r.type) : undefined,
207
208
  createdAt: safeToISOString(r.created_at),
208
209
  metadata: safeJSONParse(r.metadata),
209
210
  displayName: r.display_name,
@@ -32,6 +32,7 @@ export interface SearchResult {
32
32
  similarity: number;
33
33
  createdAt?: number;
34
34
  tags?: string[];
35
+ type?: string;
35
36
  metadata?: Record<string, unknown>;
36
37
  displayName?: string;
37
38
  userName?: string;
@@ -109,7 +109,7 @@ export class TursoVectorSearch {
109
109
  // hits still get a full hybrid score (missing ANN side is not forced to 0).
110
110
  const rows = await db.all(containerTag === ""
111
111
  ? `
112
- SELECT id, content, tags, created_at, metadata, container_tag,
112
+ SELECT id, content, tags, type, created_at, metadata, container_tag,
113
113
  display_name, user_name, user_email, project_path, project_name,
114
114
  git_repo_url, is_pinned,
115
115
  vector_distance_cos(vector, vector32(?)) AS content_dist,
@@ -120,7 +120,7 @@ export class TursoVectorSearch {
120
120
  WHERE id IN (${placeholders})
121
121
  `
122
122
  : `
123
- SELECT id, content, tags, created_at, metadata, container_tag,
123
+ SELECT id, content, tags, type, created_at, metadata, container_tag,
124
124
  display_name, user_name, user_email, project_path, project_name,
125
125
  git_repo_url, is_pinned,
126
126
  vector_distance_cos(vector, vector32(?)) AS content_dist,
@@ -158,6 +158,7 @@ export class TursoVectorSearch {
158
158
  similarity,
159
159
  createdAt: Number(row.created_at),
160
160
  tags: memoryTagsStr ? memoryTagsStr.split(",") : [],
161
+ type: row.type ? String(row.type) : undefined,
161
162
  metadata: parseMetadata(row.metadata),
162
163
  containerTag: String(row.container_tag),
163
164
  displayName: row.display_name ? String(row.display_name) : undefined,
@@ -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
+ }
@@ -26,6 +26,7 @@ interface WebServerConfig {
26
26
  /** How long a stepped-aside server waits before it may take the port back. */
27
27
  stepAsideHoldOffMs?: number;
28
28
  }
29
+ type PowerAction = "stop" | "restart";
29
30
  export declare class WebServer {
30
31
  private server;
31
32
  private config;
@@ -35,6 +36,8 @@ export declare class WebServer {
35
36
  private onTakeoverCallback;
36
37
  private onPortsExhaustedCallback;
37
38
  private onStepAsideCallback;
39
+ private onPowerActionCallback;
40
+ private powerActionRunning;
38
41
  private stepAsideTimer;
39
42
  private holdOffTimer;
40
43
  private portsExhaustedNotified;
@@ -53,6 +56,9 @@ export declare class WebServer {
53
56
  setOnPortsExhaustedCallback(callback: () => void): void;
54
57
  /** Standalone web apps register this to exit when a newer OMMS asks them to step aside. */
55
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;
56
62
  private scheduleStepAside;
57
63
  private handleStepAside;
58
64
  start(): Promise<void>;