om-memory-system 4.8.0 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
- import { existsSync, readdirSync, statSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { basename, dirname, join, parse } from "node:path";
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { basename, dirname, join, parse, resolve, sep } from "node:path";
4
4
  const PROJECT_MARKERS = [".git", "package.json", "pyproject.toml", "Cargo.toml", "go.mod"];
5
5
  function isDirectory(path) {
6
6
  try {
@@ -10,8 +10,45 @@ function isDirectory(path) {
10
10
  return false;
11
11
  }
12
12
  }
13
- function isProject(path) {
14
- return PROJECT_MARKERS.some((marker) => existsSync(join(path, marker)));
13
+ function isProject(path, projects) {
14
+ const cached = projects?.get(path);
15
+ if (cached !== undefined)
16
+ return cached;
17
+ const found = PROJECT_MARKERS.some((marker) => existsSync(join(path, marker)));
18
+ projects?.set(path, found);
19
+ return found;
20
+ }
21
+ /** Windows paths can mix `\` and `/`, so both count as a separator here. */
22
+ function isInside(path, folder) {
23
+ const slashes = (value) => value.replace(/\\/g, "/").replace(/\/+$/, "");
24
+ const inner = slashes(path);
25
+ const outer = slashes(folder);
26
+ return inner === outer || inner.startsWith(`${outer}/`);
27
+ }
28
+ /**
29
+ * The main working tree of a linked Git worktree, from the `gitdir:` line of
30
+ * its `.git` file; the folder itself otherwise. Null when a linked worktree's
31
+ * main tree is gone. Reads one file and starts no `git` process.
32
+ */
33
+ function projectRoot(path) {
34
+ let gitFile;
35
+ try {
36
+ if (!statSync(join(path, ".git")).isFile())
37
+ return path;
38
+ gitFile = readFileSync(join(path, ".git"), "utf8");
39
+ }
40
+ catch {
41
+ return path;
42
+ }
43
+ const gitdir = /^gitdir:\s*(.+)$/m.exec(gitFile)?.[1]?.trim();
44
+ if (!gitdir)
45
+ return path;
46
+ const marker = `${sep}.git${sep}worktrees${sep}`;
47
+ const at = resolve(path, gitdir).indexOf(marker);
48
+ if (at < 0)
49
+ return path;
50
+ const main = resolve(path, gitdir).slice(0, at);
51
+ return isDirectory(main) && isProject(main) ? main : null;
15
52
  }
16
53
  function childDirectories(parent, cache) {
17
54
  const cached = cache?.get(parent);
@@ -33,23 +70,112 @@ function childDirectories(parent, cache) {
33
70
  function isLeadingPart(candidate, name) {
34
71
  return name === candidate || name.startsWith(`${candidate}-`);
35
72
  }
73
+ function nameParts(name) {
74
+ return name
75
+ .toLowerCase()
76
+ .split(/[-_.]+/)
77
+ .filter(Boolean);
78
+ }
36
79
  /**
37
- * Suggest an existing target for a directory that no longer exists: first the
38
- * main repository of a deleted worktree, by the longest leading part of the
39
- * directory's name or one of its parents' names, then OpenCode's recorded
40
- * project worktree. Returns null when there is no candidate.
80
+ * True when every part of `candidate` matches, in order, one part of
81
+ * `missing` or the initials of two or more consecutive parts, and at least two
82
+ * parts match exactly: `om-pi-subagents` for `opinionated-modular-pi-subagents-system`.
41
83
  */
42
- export function suggestMapTarget(missing, context = {}) {
43
- const searchRoots = new Set();
44
- for (const project of context.knownProjects ?? []) {
45
- if (!isDirectory(project))
84
+ function isRenameOf(candidate, missing) {
85
+ const want = nameParts(missing);
86
+ let next = 0;
87
+ let exact = 0;
88
+ for (const part of nameParts(candidate)) {
89
+ let matched = false;
90
+ for (let at = next; at < want.length && !matched; at++) {
91
+ if (want[at] === part) {
92
+ exact++;
93
+ next = at + 1;
94
+ matched = true;
95
+ continue;
96
+ }
97
+ for (let end = at + 2; end <= want.length; end++) {
98
+ const initials = want
99
+ .slice(at, end)
100
+ .map((word) => word[0])
101
+ .join("");
102
+ if (initials !== part)
103
+ continue;
104
+ next = end;
105
+ matched = true;
106
+ break;
107
+ }
108
+ }
109
+ if (!matched)
110
+ return false;
111
+ }
112
+ return exact >= 2;
113
+ }
114
+ /** Folders that never hold a project worth mapping. Pure path checks. */
115
+ function notProjectReason(missing, home, claudeFolder = join(home, ".claude")) {
116
+ if (missing.split(/[\\/]/).includes("node_modules"))
117
+ return "node_modules";
118
+ if (isInside(missing, join(home, "Library", "Application Support")))
119
+ return "app-data";
120
+ const skills = [join(home, ".agents", "skills"), join(claudeFolder, "skills")];
121
+ if (skills.some((folder) => isInside(missing, folder)))
122
+ return "skills";
123
+ const temporary = ["/tmp", "/private/tmp", "/private/var/folders", tmpdir()];
124
+ if (!temporary.some((folder) => isInside(missing, folder)))
125
+ return null;
126
+ // A test home sits in a temporary folder; its projects are not temporary.
127
+ // Windows is the other way round: its temporary folder sits in the home folder.
128
+ const homeIsTemporary = temporary.some((folder) => isInside(home, folder));
129
+ return homeIsTemporary && isInside(missing, home) ? null : "temporary";
130
+ }
131
+ /** The one distinct target in `targets`, after resolving linked worktrees; null otherwise. */
132
+ function onlyTarget(targets, missing) {
133
+ const found = new Set();
134
+ for (const target of targets) {
135
+ if (target === missing || !isDirectory(target))
46
136
  continue;
47
- searchRoots.add(dirname(project));
137
+ const root = projectRoot(target);
138
+ if (root)
139
+ found.add(root);
48
140
  }
49
- // Never list the filesystem root or the folder that holds every home folder,
50
- // and stop at the home folder itself: broad scans suggest unrelated folders.
51
- const home = context.home ?? homedir();
52
- const blocked = new Set([parse(missing).root, dirname(home)]);
141
+ return found.size === 1 ? [...found][0] : null;
142
+ }
143
+ /** Rule 2: the one existing project with the git remote the store recorded for `missing`. */
144
+ function sameRemote(recorded, known) {
145
+ // Store paths are resolved; a recorded path may carry a trailing separator.
146
+ const missing = resolve(recorded);
147
+ const remotes = new Set(known
148
+ .filter((project) => project.path === missing || project.candidates?.includes(missing))
149
+ .map((project) => project.remote)
150
+ .filter((remote) => Boolean(remote)));
151
+ if (!remotes.size)
152
+ return null;
153
+ const matches = known.filter((project) => project.remote && remotes.has(project.remote));
154
+ return onlyTarget(matches.flatMap((project) => [
155
+ project.path,
156
+ // An older recorded folder may now hold another project, so it must still have the remote.
157
+ ...(project.candidates ?? []).filter((path) => {
158
+ const root = isDirectory(path) ? projectRoot(path) : null;
159
+ return root !== null && originUrl(root) === project.remote;
160
+ }),
161
+ ]), missing);
162
+ }
163
+ /** `remote.origin.url` from a repository's `.git/config`, read without starting `git`. */
164
+ function originUrl(root) {
165
+ try {
166
+ const config = readFileSync(join(root, ".git", "config"), "utf8");
167
+ const origin = /\[remote "origin"\]([^[]*)/.exec(config)?.[1] ?? "";
168
+ return /^\s*url\s*=\s*(.+?)\s*$/m.exec(origin)?.[1] ?? null;
169
+ }
170
+ catch {
171
+ return null;
172
+ }
173
+ }
174
+ /**
175
+ * Rule 4: the main repository of a deleted worktree, by the longest leading
176
+ * part of the directory's name or one of its parents' names.
177
+ */
178
+ function deletedWorktree(missing, searchRoots, blocked, home, cache, projects) {
53
179
  let ancestor = missing;
54
180
  while (dirname(ancestor) !== ancestor && ancestor !== home) {
55
181
  const name = basename(ancestor);
@@ -58,26 +184,118 @@ export function suggestMapTarget(missing, context = {}) {
58
184
  for (const parent of parents) {
59
185
  if (blocked.has(parent))
60
186
  continue;
61
- for (const candidate of childDirectories(parent, context.cache)) {
187
+ for (const candidate of childDirectories(parent, cache)) {
62
188
  if (candidate === ancestor || !isLeadingPart(basename(candidate), name))
63
189
  continue;
64
- if (!isProject(candidate))
190
+ if (!isProject(candidate, projects))
191
+ continue;
192
+ const root = projectRoot(candidate);
193
+ if (!root)
65
194
  continue;
66
- if (!best || basename(candidate).length > basename(best).length)
67
- best = candidate;
195
+ if (!best || basename(candidate).length > best.name.length) {
196
+ best = { name: basename(candidate), root };
197
+ }
68
198
  }
69
199
  }
70
200
  if (best)
71
- return best;
201
+ return best.root;
72
202
  ancestor = dirname(ancestor);
73
203
  }
74
- const worktree = context.opencodeWorktree?.(missing);
75
- if (worktree && worktree !== "/" && isDirectory(worktree))
76
- return worktree;
77
204
  return null;
78
205
  }
206
+ /** Rule 6: the one project beside `missing` or beside a known project whose name is a rename of it. */
207
+ function renameGuess(missing, searchRoots, blocked, cache, projects) {
208
+ const name = basename(missing);
209
+ const candidates = [];
210
+ for (const parent of new Set([dirname(missing), ...searchRoots])) {
211
+ if (blocked.has(parent))
212
+ continue;
213
+ for (const candidate of childDirectories(parent, cache)) {
214
+ if (isRenameOf(basename(candidate), name) && isProject(candidate, projects)) {
215
+ candidates.push(candidate);
216
+ }
217
+ }
218
+ }
219
+ return onlyTarget(candidates, missing);
220
+ }
221
+ /**
222
+ * The parent folders of existing known projects, searched for siblings. The
223
+ * home folder and folders that are not projects (temporary, `node_modules`,
224
+ * app data, skills) are left out: their other children are rarely related.
225
+ */
226
+ function searchRootsOf(known, home, claudeFolder) {
227
+ const roots = new Set();
228
+ for (const project of known) {
229
+ if (!isDirectory(project.path))
230
+ continue;
231
+ const parent = dirname(project.path);
232
+ if (parent === home || notProjectReason(parent, home, claudeFolder))
233
+ continue;
234
+ roots.add(parent);
235
+ }
236
+ return roots;
237
+ }
238
+ /** Rules 4 to 6 for one missing directory, first result wins. */
239
+ function suggestByName(missing, context, home) {
240
+ const known = context.knownProjects ?? [];
241
+ const searchRoots = context.searchRoots ?? searchRootsOf(known, home, context.claudeFolder);
242
+ // Never list the filesystem root or the folder that holds every home folder,
243
+ // and stop at the home folder itself: broad scans suggest unrelated folders.
244
+ const blocked = new Set([parse(missing).root, dirname(home)]);
245
+ const worktree = deletedWorktree(missing, searchRoots, blocked, home, context.cache, context.projects);
246
+ if (worktree)
247
+ return { kind: "map", target: worktree, confidence: "name" };
248
+ const moved = onlyTarget(known
249
+ .flatMap((project) => [project.path, ...(project.candidates ?? [])])
250
+ .filter((path) => basename(path) === basename(missing)), missing);
251
+ if (moved)
252
+ return { kind: "map", target: moved, confidence: "name" };
253
+ const renamed = renameGuess(missing, searchRoots, blocked, context.cache, context.projects);
254
+ if (renamed)
255
+ return { kind: "map", target: renamed, confidence: "guess" };
256
+ return null;
257
+ }
258
+ /**
259
+ * Suggest what to do with a directory that no longer exists. Rules run in
260
+ * order and the first result wins: (1) ignore a folder that is not a project,
261
+ * (2) the project with the same stored git remote, (3) OpenCode's recorded
262
+ * project folder, or rules 2 and 4 to 6 on it when it is gone too, (4) the
263
+ * main repository of a deleted worktree, (5) the one known project with the
264
+ * same folder name, (6) a rename guess by name parts and initials. Returns
265
+ * null when no rule gives a result. Reads folders and `.git` files only.
266
+ */
267
+ export function suggestMapTarget(missing, context = {}) {
268
+ const home = context.home ?? homedir();
269
+ const reason = notProjectReason(missing, home, context.claudeFolder);
270
+ if (reason)
271
+ return { kind: "ignore", reason };
272
+ const known = context.knownProjects ?? [];
273
+ const byRemote = (path) => {
274
+ const target = sameRemote(path, known);
275
+ return target ? { kind: "map", target, confidence: "exact" } : null;
276
+ };
277
+ const remote = byRemote(missing);
278
+ if (remote)
279
+ return remote;
280
+ const recorded = context.opencodeWorktree?.(missing);
281
+ if (recorded && recorded !== "/" && recorded !== missing) {
282
+ if (isDirectory(recorded))
283
+ return { kind: "map", target: recorded, confidence: "exact" };
284
+ // One level deep: the recorded folder is gone too.
285
+ const followed = byRemote(recorded) ?? suggestByName(recorded, context, home);
286
+ if (followed)
287
+ return followed;
288
+ }
289
+ return suggestByName(missing, context, home);
290
+ }
79
291
  export function suggestMapTargets(directories, context = {}) {
80
- const withCache = { ...context, cache: context.cache ?? new Map() };
292
+ const withCache = {
293
+ ...context,
294
+ cache: context.cache ?? new Map(),
295
+ projects: context.projects ?? new Map(),
296
+ searchRoots: context.searchRoots ??
297
+ searchRootsOf(context.knownProjects ?? [], context.home ?? homedir(), context.claudeFolder),
298
+ };
81
299
  return directories.map((item) => ({
82
300
  ...item,
83
301
  // Sessions with no recorded directory have nothing to map.
@@ -105,14 +323,42 @@ export async function readOpencodeWorktrees(dbPath) {
105
323
  }
106
324
  }
107
325
  /**
108
- * The Directory maps list: saved maps and each host's unresolved directories
109
- * from its latest run, with suggestions. Paths and counts only.
326
+ * The memory store's projects with their recorded paths and git remote, read
327
+ * once through the shard inventory. Empty when there is no store or the read
328
+ * fails, so the other sources still give suggestions. Never creates a store.
329
+ */
330
+ export async function readStoreProjects(currentDirectory) {
331
+ try {
332
+ const { CONFIG } = await import("../config.js");
333
+ if (!existsSync(join(CONFIG.storagePath, "metadata.db")))
334
+ return [];
335
+ const { shardInventoryService } = await import("../services/shard-inventory-service.js");
336
+ const inventory = await shardInventoryService.listShards(currentDirectory);
337
+ return inventory.shards.flatMap((group) => {
338
+ const candidates = group.projectPathCandidates.map((candidate) => resolve(candidate.path));
339
+ const path = group.projectPath ? resolve(group.projectPath) : candidates[0];
340
+ return path ? [{ path, candidates, remote: group.gitRepoUrl }] : [];
341
+ });
342
+ }
343
+ catch {
344
+ return [];
345
+ }
346
+ }
347
+ /**
348
+ * The Directory maps list: saved maps, ignored directories, and each host's
349
+ * unresolved directories from its latest run, with suggestions. Paths and
350
+ * counts only.
110
351
  */
111
352
  export async function directoryMapsView(options = {}) {
112
353
  const { CONFIG } = await import("../config.js");
113
354
  const { readUnresolvedDirectories } = await import("../services/backfill-state.js");
114
355
  const { DEFAULT_OPENCODE_DB } = await import("./opencode-reader.js");
356
+ const { resolveClaudeFolder } = await import("../services/claude-folder.js");
357
+ const claudeFolder = resolveClaudeFolder(CONFIG.claudeConfigDir).folder;
115
358
  const saved = new Set(CONFIG.importPathMaps.map((map) => map.from));
359
+ // Older config stubs in tests have no list.
360
+ const ignored = CONFIG.importIgnoredDirectories ?? [];
361
+ const hidden = new Set([...saved, ...ignored].map((path) => resolve(path)));
116
362
  let worktrees = new Map();
117
363
  const dbPath = options.opencodeDbPath ?? DEFAULT_OPENCODE_DB;
118
364
  if (existsSync(dbPath)) {
@@ -120,18 +366,22 @@ export async function directoryMapsView(options = {}) {
120
366
  }
121
367
  const knownProjects = [
122
368
  ...(options.knownProjects ?? []),
123
- ...CONFIG.importPathMaps.map((map) => map.to),
124
- ...worktrees.values(),
369
+ ...CONFIG.importPathMaps.map((map) => ({ path: map.to })),
370
+ ...[...worktrees.values()].map((path) => ({ path })),
125
371
  ];
126
372
  const host = async (name) => {
127
- const pending = (await readUnresolvedDirectories(name)).filter((item) => !saved.has(item.directory));
373
+ const pending = (await readUnresolvedDirectories(name)).filter(
374
+ // Config paths are resolved; a recorded path may carry a trailing separator.
375
+ (item) => !item.directory || !hidden.has(resolve(item.directory)));
128
376
  return suggestMapTargets(pending, {
129
377
  knownProjects,
378
+ claudeFolder,
130
379
  ...(name === "opencode" ? { opencodeWorktree: (dir) => worktrees.get(dir) } : {}),
131
380
  });
132
381
  };
133
382
  return {
134
383
  saved: CONFIG.importPathMaps,
384
+ ignored,
135
385
  pi: await host("pi"),
136
386
  opencode: await host("opencode"),
137
387
  "claude-code": await host("claude-code"),
@@ -8,6 +8,6 @@ export { listImportSessions, validateSessionListRequest } from "./import-session
8
8
  export { opencodeSnapshots, sweepOrphanSnapshots } from "./opencode-snapshot.js";
9
9
  export { stopStandaloneOpencodeReads } from "./opencode-standalone-models.js";
10
10
  export { BackfillControls } from "./backfill-controls.js";
11
- export { directoryMapsView } from "./map-suggestions.js";
11
+ export { directoryMapsView, readStoreProjects, type KnownProject } from "./map-suggestions.js";
12
12
  export { testExternalApi } from "./external-api-test.js";
13
13
  export { catchUpState, pauseCatchUp, previewCatchUp, startCatchUp } from "./profile-catch-up.js";
@@ -8,6 +8,6 @@ export { listImportSessions, validateSessionListRequest } from "./import-session
8
8
  export { opencodeSnapshots, sweepOrphanSnapshots } from "./opencode-snapshot.js";
9
9
  export { stopStandaloneOpencodeReads } from "./opencode-standalone-models.js";
10
10
  export { BackfillControls } from "./backfill-controls.js";
11
- export { directoryMapsView } from "./map-suggestions.js";
11
+ export { directoryMapsView, readStoreProjects } from "./map-suggestions.js";
12
12
  export { testExternalApi } from "./external-api-test.js";
13
13
  export { catchUpState, pauseCatchUp, previewCatchUp, startCatchUp } from "./profile-catch-up.js";
@@ -51,6 +51,16 @@ export declare function unresolvedSessionCount(report: {
51
51
  }>;
52
52
  unresolvableSessions: ReadonlyArray<unknown>;
53
53
  }): number;
54
+ /**
55
+ * A host's unresolved session count without the sessions in ignored
56
+ * directories. It comes from the saved directory list, the same list the
57
+ * Directory maps page shows, because a later listing or CLI run can replace
58
+ * that list after the run that set `count`. With no list, or a list cut at
59
+ * {@link MAX_UNRESOLVED_DIRECTORIES}, it subtracts from `count` instead and
60
+ * stops at 0. Sessions with no recorded directory always count. Paths are
61
+ * compared in resolved form.
62
+ */
63
+ export declare function visibleUnresolvedCount(count: number, directories: readonly UnresolvedDirectory[], ignored: readonly string[]): number;
54
64
  /**
55
65
  * Save the unresolved directories of a host's latest full run or listing. They
56
66
  * live in their own table, so recording them never sets a backfill cutoff.
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, resolve } from "node:path";
3
3
  import { CONFIG } from "../config.js";
4
4
  import { tursoConnectionManager } from "./turso/connection-manager.js";
5
5
  import { safeHealthError } from "./safe-health-error.js";
@@ -79,6 +79,24 @@ export function unresolvedSessionCount(report) {
79
79
  const mapped = (report.unresolvedProjects ?? []).reduce((sum, item) => sum + item.sessions, 0);
80
80
  return mapped + report.unresolvableSessions.length;
81
81
  }
82
+ /**
83
+ * A host's unresolved session count without the sessions in ignored
84
+ * directories. It comes from the saved directory list, the same list the
85
+ * Directory maps page shows, because a later listing or CLI run can replace
86
+ * that list after the run that set `count`. With no list, or a list cut at
87
+ * {@link MAX_UNRESOLVED_DIRECTORIES}, it subtracts from `count` instead and
88
+ * stops at 0. Sessions with no recorded directory always count. Paths are
89
+ * compared in resolved form.
90
+ */
91
+ export function visibleUnresolvedCount(count, directories, ignored) {
92
+ const skip = new Set(ignored.map((path) => resolve(path)));
93
+ const isIgnored = (item) => item.directory !== NO_DIRECTORY && skip.has(resolve(item.directory));
94
+ const sum = (items) => items.reduce((total, item) => total + item.sessions, 0);
95
+ if (directories.length > 0 && directories.length < MAX_UNRESOLVED_DIRECTORIES) {
96
+ return sum(directories.filter((item) => !isIgnored(item)));
97
+ }
98
+ return Math.max(0, count - sum(directories.filter(isIgnored)));
99
+ }
82
100
  /**
83
101
  * Save the unresolved directories of a host's latest full run or listing. They
84
102
  * live in their own table, so recording them never sets a backfill cutoff.
@@ -23,6 +23,7 @@ const keys = new Set([
23
23
  "memoryModel",
24
24
  "memoryApiKey",
25
25
  "importPathMaps",
26
+ "importIgnoredDirectories",
26
27
  "claudeConfigDir",
27
28
  "maxMemories",
28
29
  "chatMessage.maxMemories",
@@ -81,7 +82,7 @@ function isValidEdit(key, value) {
81
82
  if (key === "memoryProvider")
82
83
  return MEMORY_PROVIDERS.includes(value);
83
84
  // Entries are checked by the startup validation below.
84
- if (key === "importPathMaps")
85
+ if (key === "importPathMaps" || key === "importIgnoredDirectories")
85
86
  return Array.isArray(value);
86
87
  // The five memory limits share one validation rule set.
87
88
  const memoryLimit = getMemoryLimitRule(key);
@@ -24,6 +24,7 @@ const editable = [
24
24
  "memoryApiUrl",
25
25
  "memoryModel",
26
26
  "importPathMaps",
27
+ "importIgnoredDirectories",
27
28
  "claudeConfigDir",
28
29
  ...MEMORY_LIMIT_SETTINGS,
29
30
  ];
@@ -37,6 +38,7 @@ const globalOnly = [
37
38
  "memoryProvider",
38
39
  "memoryApiUrl",
39
40
  "importPathMaps",
41
+ "importIgnoredDirectories",
40
42
  "claudeConfigDir",
41
43
  ];
42
44
  function readSettingsFile(path) {
@@ -1,3 +1,4 @@
1
+ import type { WebUpdate } from "./web-update.js";
1
2
  import { WebAuth } from "./web-auth.js";
2
3
  type NodeEventSource = {
3
4
  on(event: string, listener: (...args: any[]) => void): unknown;
@@ -37,6 +38,7 @@ export declare class WebServer {
37
38
  private onPortsExhaustedCallback;
38
39
  private onStepAsideCallback;
39
40
  private onPowerActionCallback;
41
+ private webUpdate;
40
42
  private powerActionRunning;
41
43
  private stepAsideTimer;
42
44
  private holdOffTimer;
@@ -45,6 +47,8 @@ export declare class WebServer {
45
47
  private readonly maxFallbackPort;
46
48
  private settingsImportJobs?;
47
49
  private backfillControlsInstance?;
50
+ /** The memory store's projects for Directory maps suggestions, read at most once a minute. */
51
+ private storeProjects?;
48
52
  /** Run an import-page action; errors carry their own status and never file contents. */
49
53
  private importResponse;
50
54
  /** Claude Code hook requests; they reach this point only with a valid API token. */
@@ -57,9 +61,13 @@ export declare class WebServer {
57
61
  /** Standalone web apps register this to exit when a newer OMMS asks them to step aside. */
58
62
  setOnStepAside(callback: () => void | Promise<void>): void;
59
63
  /** Standalone web apps register this to stop or restart from the page. */
60
- setOnPowerAction(callback: (action: PowerAction) => void | Promise<void>): void;
64
+ setOnPowerAction(callback: (action: PowerAction) => unknown): void;
65
+ /** Standalone web apps register this to report and install npm releases. */
66
+ setWebUpdate(update: Pick<WebUpdate, "status" | "requestInstall">): void;
61
67
  /** Only a local caller may stop the web app; the token alone is not enough. */
62
68
  private handlePowerAction;
69
+ /** Install the npm release and restart onto it. Same guards as Restart. */
70
+ private handleUpdate;
63
71
  /** API token routes. Only a caller on this machine with the local token manages tokens. */
64
72
  private handleTokenRoute;
65
73
  /** Profile catch-up: a preview for anyone with API access; runs only from this machine. */
@@ -75,6 +83,7 @@ export declare class WebServer {
75
83
  start(): Promise<void>;
76
84
  private _start;
77
85
  private startHealthCheckLoop;
86
+ private ownerIsNewer;
78
87
  private stopHealthCheckLoop;
79
88
  private attemptTakeover;
80
89
  private notifyPortsExhausted;