om-memory-system 4.4.0-next.67 → 4.4.0-next.70

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.
Files changed (32) hide show
  1. package/README.md +8 -4
  2. package/bin/omms-launch.mjs +198 -0
  3. package/dist/adapters/claude-code/hook-client.d.ts +2 -0
  4. package/dist/adapters/claude-code/hook-client.js +27 -2
  5. package/dist/adapters/claude-code/hook-command.d.ts +2 -1
  6. package/dist/adapters/claude-code/hook-command.js +11 -0
  7. package/dist/adapters/claude-code/status.d.ts +25 -0
  8. package/dist/adapters/claude-code/status.js +33 -0
  9. package/dist/adapters/opencode/tui-status.d.ts +0 -4
  10. package/dist/adapters/opencode/tui-status.js +1 -23
  11. package/dist/adapters/pi/extension.js +18 -8
  12. package/dist/cli/index.js +5 -0
  13. package/dist/cli/web-command.js +1 -59
  14. package/dist/index.js +15 -2
  15. package/dist/services/global-version.d.ts +11 -20
  16. package/dist/services/global-version.js +74 -44
  17. package/dist/services/runtime-handoff.d.ts +60 -0
  18. package/dist/services/runtime-handoff.js +134 -0
  19. package/dist/services/runtime-record.d.ts +47 -0
  20. package/dist/services/runtime-record.js +143 -0
  21. package/dist/services/update-check.d.ts +4 -0
  22. package/dist/services/update-check.js +23 -0
  23. package/dist/services/web-autostart.d.ts +9 -7
  24. package/dist/services/web-autostart.js +33 -54
  25. package/dist/services/web-ensure.d.ts +11 -0
  26. package/dist/services/web-ensure.js +37 -5
  27. package/dist/services/web-handover.d.ts +22 -0
  28. package/dist/services/web-handover.js +62 -0
  29. package/dist/services/web-server.js +3 -2
  30. package/dist/web/assets/{index-45F5ouFN.js → index-D_e39BA6.js} +2 -2
  31. package/dist/web/index.html +1 -1
  32. package/package.json +2 -1
@@ -6,6 +6,7 @@ import { basename, dirname, join, delimiter, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { CONFIG } from "../config.js";
8
8
  import { log } from "./logger.js";
9
+ import { copyVersion, ensureLauncher, launcherPath, recordedCopy, } from "./runtime-record.js";
9
10
  import { compareVersions } from "./version-compare.js";
10
11
  const MARKER = "OMMS login item";
11
12
  const NAME = "io.github.cmdaltctr.omms.web";
@@ -70,53 +71,18 @@ function packageRoot() {
70
71
  dir = parent;
71
72
  }
72
73
  }
73
- function packageVersionAt(root) {
74
- try {
75
- const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
76
- if (pkg.name !== "om-memory-system" || !existsSync(join(root, "dist", "cli", "index.js"))) {
77
- return null;
78
- }
79
- return typeof pkg.version === "string" ? pkg.version : null;
80
- }
81
- catch {
82
- return null;
83
- }
84
- }
85
- /** The package folder a written login item runs, read back from its command line. */
86
- export function itemPackageRoot(file) {
87
- try {
88
- const content = readFileSync(file, "utf8");
89
- const match = /([^"<>\r\n]*?)[\\/]dist[\\/]cli[\\/]index\.js/.exec(content);
90
- const path = match?.[1]?.replace(/^.*?<string>/, "").trim();
91
- if (!path)
92
- return null;
93
- // Only plists escape XML. Decode ampersands last to preserve literal entity text.
94
- return content.includes("<plist")
95
- ? path
96
- .replaceAll("&quot;", '"')
97
- .replaceAll("&apos;", "'")
98
- .replaceAll("&lt;", "<")
99
- .replaceAll("&gt;", ">")
100
- .replaceAll("&amp;", "&")
101
- : path;
102
- }
103
- catch {
104
- return null;
105
- }
106
- }
107
74
  /**
108
- * The newest OMMS copy among this one, the global install beside the runtime,
109
- * and the copy the item already runs. A host starts OMMS from its own cache,
110
- * which can be older than the global install; that copy must not downgrade
111
- * the login item.
75
+ * The newest valid OMMS copy among the candidates. On equal versions the first
76
+ * wins, so a host's cached copy goes last. This is the copy the launcher starts,
77
+ * and it matches the launcher's own choice.
112
78
  */
113
79
  export function preferredPackageRoot(candidates) {
114
80
  let best = null;
115
81
  for (const root of candidates) {
116
82
  if (!root)
117
83
  continue;
118
- const version = packageVersionAt(root);
119
- if (!version || compareVersions(version, version) === null)
84
+ const version = copyVersion(root);
85
+ if (!version)
120
86
  continue;
121
87
  if (!best || (compareVersions(version, best.version) ?? 0) > 0)
122
88
  best = { root, version };
@@ -145,15 +111,20 @@ function details(options) {
145
111
  const platform = options.platform ?? process.platform;
146
112
  const path = itemPath(home, platform);
147
113
  const runtime = options.runtime === undefined ? resolveWebRuntime() : options.runtime;
148
- const own = options.packageRoot === undefined ? packageRoot() : options.packageRoot;
149
- const current = path && existsSync(path) ? itemPackageRoot(path) : null;
114
+ const dir = join(home, ".omms");
115
+ const own = options.ownRoot !== undefined
116
+ ? options.ownRoot
117
+ : options.packageRoot !== undefined
118
+ ? options.packageRoot
119
+ : packageRoot();
120
+ const recorded = recordedCopy(dir, logCode)?.root ?? null;
150
121
  const root = options.packageRoot === undefined
151
- ? // On equal versions the first wins, so a host's cached copy goes last.
152
- preferredPackageRoot([globalPackageRoot(runtime ?? null, platform), current, own])
153
- : own;
122
+ ? preferredPackageRoot([recorded, globalPackageRoot(runtime ?? null, platform), own])
123
+ : options.packageRoot;
154
124
  const supported = platform !== "linux" || (options.systemctlAvailable ?? Boolean(executable("systemctl")));
155
- return { home, platform, path, runtime, root, supported };
125
+ return { home, platform, path, runtime, root, own, recorded, dir, supported };
156
126
  }
127
+ const logCode = (code, data) => log("Runtime record", { code, ...data });
157
128
  const xml = (value) => value
158
129
  .replaceAll("&", "&amp;")
159
130
  .replaceAll("<", "&lt;")
@@ -161,21 +132,21 @@ const xml = (value) => value
161
132
  .replaceAll('"', "&quot;");
162
133
  const systemd = (value) => `"${value.replaceAll("%", "%%").replaceAll('"', '\\"')}"`;
163
134
  const windows = (value) => `"${value.replaceAll("%", "%%")}"`;
164
- function itemContent(platform, runtime, cli) {
135
+ function itemContent(platform, runtime, launcher) {
165
136
  if (platform === "darwin")
166
137
  return `<?xml version="1.0" encoding="UTF-8"?>
167
138
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
168
139
  <plist version="1.0"><dict>
169
140
  <!-- ${MARKER} -->
170
141
  <key>Label</key><string>${NAME}</string>
171
- <key>ProgramArguments</key><array><string>${xml(runtime)}</string><string>${xml(cli)}</string><string>web</string><string>--login-item</string></array>
142
+ <key>ProgramArguments</key><array><string>${xml(runtime)}</string><string>${xml(launcher)}</string><string>web</string><string>--login-item</string></array>
172
143
  <key>RunAtLoad</key><true/><key>KeepAlive</key><false/>
173
144
  <key>StandardOutPath</key><string>/dev/null</string><key>StandardErrorPath</key><string>/dev/null</string>
174
145
  </dict></plist>\n`;
175
146
  if (platform === "linux")
176
147
  return `# ${MARKER}
177
- [Unit]\nDescription=OMMS web app\n[Service]\nType=simple\nExecStart=${systemd(runtime)} ${systemd(cli)} web --login-item\n[Install]\nWantedBy=default.target\n`;
178
- return `@echo off\r\nrem ${MARKER}\r\nstart "" /min ${windows(runtime)} ${windows(cli)} web --login-item\r\n`;
148
+ [Unit]\nDescription=OMMS web app\n[Service]\nType=simple\nExecStart=${systemd(runtime)} ${systemd(launcher)} web --login-item\n[Install]\nWantedBy=default.target\n`;
149
+ return `@echo off\r\nrem ${MARKER}\r\nstart "" /min ${windows(runtime)} ${windows(launcher)} web --login-item\r\n`;
179
150
  }
180
151
  function owned(path) {
181
152
  return readFileSync(path, "utf8").includes(MARKER);
@@ -188,15 +159,16 @@ function runCommand(options, home, command, args) {
188
159
  }
189
160
  /** Read the login item's state without changing any system files. */
190
161
  export function webAutostartStatus(options = {}) {
191
- const { path, runtime, root, supported } = details(options);
162
+ const { path, runtime, root, dir, supported } = details(options);
192
163
  if (!path || !supported)
193
164
  return { state: "unsupported" };
194
165
  if (!runtime)
195
166
  return { state: "no-runtime", path };
196
167
  if (!root)
197
168
  return { state: "no-package", path, runtime };
169
+ const launcher = launcherPath(dir);
198
170
  if (!existsSync(path))
199
- return { state: "not-installed", path, runtime, packagePath: root };
171
+ return { state: "not-installed", path, runtime, packagePath: root, launcher };
200
172
  if (!owned(path))
201
173
  throw new Error("Login item at the OMMS path is not owned by OMMS");
202
174
  return {
@@ -204,6 +176,7 @@ export function webAutostartStatus(options = {}) {
204
176
  path,
205
177
  runtime,
206
178
  packagePath: root,
179
+ launcher,
207
180
  command: readFileSync(path, "utf8"),
208
181
  };
209
182
  }
@@ -214,9 +187,15 @@ export function installWebAutostart(options = {}) {
214
187
  status.state === "no-runtime" ||
215
188
  status.state === "no-package")
216
189
  return status;
217
- const { home, platform, path, runtime, root } = details(options);
190
+ const { home, platform, path, runtime, root, own, recorded, dir } = details(options);
191
+ // The item runs a fixed launcher, so the launcher must exist before the item does.
192
+ if (!ensureLauncher({ dir, sources: [root, own, recorded], log: logCode })) {
193
+ // A valid package exists. Only the launcher is missing, so say that.
194
+ logCode("launcher-missing", {});
195
+ return { state: "no-launcher", path: path, runtime: runtime };
196
+ }
218
197
  const file = path;
219
- const content = itemContent(platform, runtime, join(root, "dist", "cli", "index.js"));
198
+ const content = itemContent(platform, runtime, launcherPath(dir));
220
199
  mkdirSync(dirname(file), { recursive: true });
221
200
  const changed = !existsSync(file) || readFileSync(file, "utf8") !== content;
222
201
  if (changed) {
@@ -37,6 +37,8 @@ export interface EnsureDeps {
37
37
  resolveRuntime: () => Promise<string | null> | string | null;
38
38
  /** The `om-memory-system` script that `web` is run from. */
39
39
  cliScript: string;
40
+ /** `~/.omms/bin/omms-launch.mjs` when it exists. `web` then starts through it, on the newest recorded copy. */
41
+ launcherScript: string | null;
40
42
  lockPath: string;
41
43
  lockFs: LockFs;
42
44
  pidAlive: (pid: number) => boolean;
@@ -51,6 +53,15 @@ export interface EnsureOptions {
51
53
  budgetMs: number;
52
54
  /** `false` starts the web app and returns at once (session starts). Default `true`. */
53
55
  wait?: boolean;
56
+ /**
57
+ * Replace a running web app older than `version`: ask it to step aside, then
58
+ * start as for an empty port. `headers` are the local token headers the
59
+ * step-aside route needs. Without it, any running OMMS web app is used.
60
+ */
61
+ replaceOlder?: {
62
+ version: string;
63
+ headers: Record<string, string>;
64
+ };
54
65
  deps?: Partial<EnsureDeps>;
55
66
  }
56
67
  /** A lock older than this is stale. Also the longest a background start holds it. */
@@ -1,9 +1,12 @@
1
1
  import { spawn as nodeSpawn } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
- import { linkSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
+ import { existsSync, linkSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
+ import { launcherPath } from "./runtime-record.js";
8
+ import { isOlderVersion } from "./version-compare.js";
9
+ import { negotiateOwner, readWebVersion } from "./web-handover.js";
7
10
  const HEALTH_PROBE_MS = 1_000;
8
11
  const POLL_INTERVAL_MS = 250;
9
12
  /** A lock older than this is stale. Also the longest a background start holds it. */
@@ -106,6 +109,13 @@ function processAlive(pid) {
106
109
  return error.code === "EPERM";
107
110
  }
108
111
  }
112
+ /** Tests set the switch, so they never start the developer's real recorded copy. */
113
+ function existingLauncher() {
114
+ if (process.env.OMMS_DISABLE_RUNTIME_RECORD === "1")
115
+ return null;
116
+ const file = launcherPath(join(homedir(), ".omms"));
117
+ return existsSync(file) ? file : null;
118
+ }
109
119
  function productionDefaults() {
110
120
  return {
111
121
  fetch: globalThis.fetch,
@@ -115,6 +125,7 @@ function productionDefaults() {
115
125
  resolveRuntime: async () => (await import("./web-autostart.js")).resolveWebRuntime(),
116
126
  // From dist/services/ to the CLI entry point.
117
127
  cliScript: fileURLToPath(new URL("../cli/index.js", import.meta.url)),
128
+ launcherScript: existingLauncher(),
118
129
  lockPath: startLockPath(),
119
130
  lockFs: nodeLockFs,
120
131
  pidAlive: processAlive,
@@ -211,7 +222,12 @@ export async function ensureWebApp(options) {
211
222
  let held = false;
212
223
  try {
213
224
  const first = await probe(settings.baseUrl, probeMs(), deps);
214
- if (first === "omms")
225
+ const { replaceOlder } = options;
226
+ // An OMMS web app that is older than the newest copy is replaced; any other is used.
227
+ const replacing = first === "omms" &&
228
+ replaceOlder !== undefined &&
229
+ isOlderVersion((await readWebVersion(deps.fetch, settings.baseUrl, replaceOlder.headers)) ?? "", replaceOlder.version);
230
+ if (first === "omms" && !replacing)
215
231
  return "running";
216
232
  if (first === "other") {
217
233
  await logCode("port-busy");
@@ -220,11 +236,27 @@ export async function ensureWebApp(options) {
220
236
  const runtime = await deps.resolveRuntime();
221
237
  if (!runtime) {
222
238
  await logCode("no-runtime");
223
- return "no-runtime";
239
+ return replacing ? "running" : "no-runtime";
224
240
  }
225
241
  held = takeStartLock(deps);
242
+ // The caller that holds the lock replaces the web app; the others use it.
226
243
  if (!held)
227
- return await waitForOther(settings.baseUrl, wait, remaining, deps);
244
+ return replacing ? "running" : await waitForOther(settings.baseUrl, wait, remaining, deps);
245
+ if (replacing && replaceOlder) {
246
+ const outcome = await negotiateOwner({
247
+ fetchFn: deps.fetch,
248
+ sleep: deps.sleep,
249
+ url: settings.baseUrl,
250
+ port: Number(new URL(settings.baseUrl).port) || 80,
251
+ headers: replaceOlder.headers,
252
+ version: replaceOlder.version,
253
+ });
254
+ if (outcome.kind !== "handed") {
255
+ if (outcome.kind === "stuck")
256
+ await logCode("step-aside-failed");
257
+ return "running";
258
+ }
259
+ }
228
260
  // Another caller may have finished its start before this one took the lock.
229
261
  const second = await probe(settings.baseUrl, probeMs(), deps);
230
262
  if (second === "omms")
@@ -233,7 +265,7 @@ export async function ensureWebApp(options) {
233
265
  await logCode("port-busy");
234
266
  return "port-busy";
235
267
  }
236
- const child = deps.spawn(runtime, [deps.cliScript, "web"], {
268
+ const child = deps.spawn(runtime, [deps.launcherScript ?? deps.cliScript, "web"], {
237
269
  detached: true,
238
270
  stdio: "ignore",
239
271
  cwd: homedir(),
@@ -0,0 +1,22 @@
1
+ export declare const POLL_MS = 250;
2
+ export declare const POLL_LIMIT = 40;
3
+ /** The version an OMMS web app reports on `url`, or null when none answers with one. */
4
+ export declare function readWebVersion(fetchFn: typeof fetch, url: string, headers: Record<string, string>): Promise<string | null>;
5
+ export type Handover = {
6
+ kind: "none";
7
+ } | {
8
+ kind: "handed";
9
+ } | {
10
+ kind: "stuck" | "same" | "newer";
11
+ owner: string;
12
+ };
13
+ export interface HandoverContext {
14
+ fetchFn: typeof fetch;
15
+ sleep: (ms: number) => Promise<void>;
16
+ url: string;
17
+ port: number;
18
+ headers: Record<string, string>;
19
+ version: string;
20
+ }
21
+ /** Ask an older OMMS web app on the configured port to step aside, and wait for the port. */
22
+ export declare function negotiateOwner(ctx: HandoverContext): Promise<Handover>;
@@ -0,0 +1,62 @@
1
+ import { compareVersions } from "./version-compare.js";
2
+ // The version handover shared by `web install` and the host start: ask an older
3
+ // OMMS web app to step aside, then wait for its port. It imports no host adapter,
4
+ // no store, and no embedding model.
5
+ export const POLL_MS = 250;
6
+ export const POLL_LIMIT = 40;
7
+ const REQUEST_TIMEOUT_MS = 2_000;
8
+ /** The version an OMMS web app reports on `url`, or null when none answers with one. */
9
+ export async function readWebVersion(fetchFn, url, headers) {
10
+ try {
11
+ const response = await fetchFn(`${url}/api/settings/version`, {
12
+ headers,
13
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
14
+ });
15
+ if (!response.ok)
16
+ return null;
17
+ const body = (await response.json());
18
+ return typeof body.running === "string" ? body.running : null;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ async function portAnswers(fetchFn, url) {
25
+ try {
26
+ await fetchFn(`${url}/api/health`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
27
+ return true;
28
+ }
29
+ catch {
30
+ return false;
31
+ }
32
+ }
33
+ /** Ask an older OMMS web app on the configured port to step aside, and wait for the port. */
34
+ export async function negotiateOwner(ctx) {
35
+ const owner = await readWebVersion(ctx.fetchFn, ctx.url, ctx.headers);
36
+ const order = owner === null ? null : compareVersions(owner, ctx.version);
37
+ // A version that cannot be placed is never stopped.
38
+ if (owner === null || order === null)
39
+ return { kind: "none" };
40
+ if (order === 0)
41
+ return { kind: "same", owner };
42
+ if (order > 0)
43
+ return { kind: "newer", owner };
44
+ try {
45
+ const reply = await ctx.fetchFn(`${ctx.url}/api/web/step-aside`, {
46
+ method: "POST",
47
+ headers: { ...ctx.headers, "content-type": "application/json" },
48
+ body: JSON.stringify({ version: ctx.version }),
49
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
50
+ });
51
+ if (reply.status !== 202)
52
+ return { kind: "stuck", owner };
53
+ }
54
+ catch {
55
+ return { kind: "stuck", owner };
56
+ }
57
+ // If the old app is slow to stop, the login item still takes the port later.
58
+ for (let i = 0; i < POLL_LIMIT && (await portAnswers(ctx.fetchFn, ctx.url)); i++) {
59
+ await ctx.sleep(POLL_MS);
60
+ }
61
+ return { kind: "handed" };
62
+ }
@@ -802,17 +802,18 @@ export class WebServer {
802
802
  return this.handleStepAside(remoteAddress, body);
803
803
  }
804
804
  if (path === "/api/settings/version" && method === "GET") {
805
- const [{ packageVersion }, { globalCommandVersion }] = await Promise.all([
805
+ const [{ packageVersion }, { globalCommandVersion, globalRelation }] = await Promise.all([
806
806
  import("./package-version.js"),
807
807
  import("./global-version.js"),
808
808
  ]);
809
809
  const running = packageVersion();
810
- const global = await globalCommandVersion();
810
+ const global = globalCommandVersion();
811
811
  return this.jsonResponse({
812
812
  running,
813
813
  global: global.version,
814
814
  globalPath: global.path,
815
815
  mismatch: global.version !== null && global.version !== running,
816
+ relation: globalRelation(running, global.version),
816
817
  });
817
818
  }
818
819
  if (path === "/api/settings/web-autostart" && method === "GET") {