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
package/README.md CHANGED
@@ -17,8 +17,8 @@ It runs inside [OpenCode](https://opencode.ai) and the
17
17
  Both share one memory per project, so a note written in one is available in
18
18
  the other. Everything is stored locally on your machine.
19
19
 
20
- Claude Code is also supported, through a plugin of hooks. It needs a global
21
- install and your own external API for capture. See the
20
+ Claude Code is also supported, through a plugin of hooks. It needs Node.js and
21
+ your own external API for capture. A global install is optional. See the
22
22
  [Claude Code adapter](docs/claude-code-adapter.md) guide.
23
23
 
24
24
  ## What it does
@@ -81,8 +81,12 @@ pi install npm:om-memory-system
81
81
 
82
82
  Restart Pi. You can install OMMS in both agents; they share the same memory.
83
83
 
84
- **Terminal command (optional, recommended).** A global install lets the login
85
- web app and the `om-memory-system` terminal commands run without `npx`:
84
+ **Terminal command (optional).** A global install puts the `om-memory-system`
85
+ command on your `PATH`. You do not need it. OMMS runs the newest copy it finds on
86
+ the machine, so updating one host is enough. A global install from 4.3 or earlier
87
+ needs one update. See
88
+ [Updating and upgrading](docs/upgrading.md#one-update-updates-every-host).
89
+ To install it:
86
90
 
87
91
  ```bash
88
92
  npm i -g om-memory-system # or: bun add -g om-memory-system
@@ -0,0 +1,198 @@
1
+ #!/usr/bin/env node
2
+ // Runs the newest valid OMMS copy on the machine. It needs only Node.js and its
3
+ // built-in modules, so the Claude Code plugin (which has no `dist/`) and the
4
+ // login item can both run it.
5
+ //
6
+ // node omms-launch.mjs [--at-least-own-version] <om-memory-system arguments>
7
+ //
8
+ // Candidates: the copy named in ~/.omms/runtime.json, the global install beside
9
+ // the running Node.js, and the copy that holds this file. With
10
+ // `--at-least-own-version`, the version in the `package.json` beside this file is
11
+ // the minimum. When no candidate reaches it, the launcher runs
12
+ // `npx --yes om-memory-system@<that version>`.
13
+
14
+ import { spawn } from "node:child_process";
15
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
16
+ import { homedir, tmpdir } from "node:os";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const SEMVER = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
21
+
22
+ function parse(version) {
23
+ const match = SEMVER.exec(String(version).trim());
24
+ if (!match) return null;
25
+ return {
26
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
27
+ pre: match[4] ? match[4].split(".") : [],
28
+ };
29
+ }
30
+
31
+ function comparePart(a, b) {
32
+ const aNumeric = /^\d+$/.test(a);
33
+ const bNumeric = /^\d+$/.test(b);
34
+ if (aNumeric && bNumeric) return Math.sign(Number(a) - Number(b));
35
+ if (aNumeric) return -1;
36
+ if (bNumeric) return 1;
37
+ return a < b ? -1 : a > b ? 1 : 0;
38
+ }
39
+
40
+ /**
41
+ * SemVer order, the same as `compareVersions` in `src/services/version-compare.ts`.
42
+ * It cannot import that file, because the plugin has no `dist/`;
43
+ * `tests/omms-launch.test.ts` keeps the two the same. Returns null when either
44
+ * version cannot be parsed.
45
+ */
46
+ export function compareVersions(a, b) {
47
+ const left = parse(a);
48
+ const right = parse(b);
49
+ if (!left || !right) return null;
50
+ for (let i = 0; i < 3; i++) {
51
+ const diff = left.core[i] - right.core[i];
52
+ if (diff) return Math.sign(diff);
53
+ }
54
+ if (!left.pre.length || !right.pre.length) return right.pre.length - left.pre.length;
55
+ for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i++) {
56
+ const l = left.pre[i];
57
+ const r = right.pre[i];
58
+ if (l === undefined) return -1;
59
+ if (r === undefined) return 1;
60
+ const diff = comparePart(l, r);
61
+ if (diff) return diff;
62
+ }
63
+ return 0;
64
+ }
65
+
66
+ /** A valid copy has the package name, a comparable version, and `dist/cli/index.js`. */
67
+ function readCopy(root) {
68
+ try {
69
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
70
+ if (pkg.name !== "om-memory-system" || typeof pkg.version !== "string") return null;
71
+ if (compareVersions(pkg.version, pkg.version) === null) return null;
72
+ return existsSync(join(root, "dist", "cli", "index.js"))
73
+ ? { root, version: pkg.version }
74
+ : null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /** The package folder of a global npm install beside the given Node.js binary. */
81
+ export function globalPackageRoot(execPath, platform = process.platform) {
82
+ if (!execPath) return null;
83
+ if (platform === "win32") return join(dirname(execPath), "node_modules", "om-memory-system");
84
+ return join(dirname(dirname(execPath)), "lib", "node_modules", "om-memory-system");
85
+ }
86
+
87
+ /**
88
+ * Every place a global install can be for this Node.js binary. Homebrew runs Node
89
+ * from a versioned `Cellar/node/<version>` folder, but npm installs under the
90
+ * prefix above `Cellar`, so that prefix counts too.
91
+ */
92
+ export function globalPackageRoots(execPath, platform = process.platform) {
93
+ const direct = globalPackageRoot(execPath, platform);
94
+ if (!direct) return [];
95
+ const brew = /^(.*)[\\/]Cellar[\\/][^\\/]+[\\/][^\\/]+[\\/]bin[\\/][^\\/]+$/.exec(execPath);
96
+ return brew ? [direct, join(brew[1], "lib", "node_modules", "om-memory-system")] : [direct];
97
+ }
98
+
99
+ /** The record's copy, the global installs, and the own copy, in that order. Invalid ones are null. */
100
+ export function findCandidates({ dir, execPath, ownRoot }) {
101
+ let recorded = null;
102
+ try {
103
+ const record = JSON.parse(readFileSync(join(dir, "runtime.json"), "utf8"));
104
+ if (record && typeof record.root === "string") recorded = readCopy(record.root);
105
+ } catch {
106
+ /* No record, or an unreadable one: the other candidates still work. */
107
+ }
108
+ const globals = globalPackageRoots(execPath).map((root) => readCopy(root));
109
+ return [recorded, ...globals, ownRoot ? readCopy(ownRoot) : null];
110
+ }
111
+
112
+ /**
113
+ * The newest candidate. On a tie the first one wins. With `minVersion`, a newest
114
+ * candidate that is older asks for `npx` at that version instead.
115
+ */
116
+ export function chooseCopy(candidates, minVersion) {
117
+ let best = null;
118
+ for (const candidate of candidates) {
119
+ if (!candidate) continue;
120
+ if (!best || (compareVersions(candidate.version, best.version) ?? 0) > 0) best = candidate;
121
+ }
122
+ const wanted = minVersion && parse(minVersion) ? minVersion : null;
123
+ if (wanted && (!best || (compareVersions(best.version, wanted) ?? 0) < 0))
124
+ return { kind: "npx", version: wanted };
125
+ return best ? { kind: "copy", copy: best } : { kind: "none" };
126
+ }
127
+
128
+ export function parseLauncherArgs(argv) {
129
+ return argv[0] === "--at-least-own-version"
130
+ ? { atLeastOwnVersion: true, args: argv.slice(1) }
131
+ : { atLeastOwnVersion: false, args: [...argv] };
132
+ }
133
+
134
+ /** Run a command with inherited input and output, and resolve to its exit code. */
135
+ function spawnInherit(command, args, options = {}) {
136
+ return new Promise((resolve) => {
137
+ const child = spawn(command, args, { stdio: "inherit", ...options });
138
+ child.on("error", () => resolve(1));
139
+ child.on("close", (code) => resolve(code ?? 1));
140
+ });
141
+ }
142
+
143
+ /** The version in the `package.json` beside this file, or null when there is none. */
144
+ function ownVersion(ownRoot) {
145
+ try {
146
+ const pkg = JSON.parse(readFileSync(join(ownRoot, "package.json"), "utf8"));
147
+ return pkg.name === "om-memory-system" && typeof pkg.version === "string" ? pkg.version : null;
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ /** Choose a copy and run it. Resolves to the exit code. */
154
+ export async function runLauncher(argv, options = {}) {
155
+ const dir = options.dir ?? join(homedir(), ".omms");
156
+ const execPath = options.execPath ?? process.execPath;
157
+ const ownRoot = options.ownRoot ?? dirname(dirname(fileURLToPath(import.meta.url)));
158
+ const run = options.spawn ?? spawnInherit;
159
+ const { atLeastOwnVersion, args } = parseLauncherArgs(argv);
160
+ const choice = chooseCopy(
161
+ findCandidates({ dir, execPath, ownRoot }),
162
+ atLeastOwnVersion ? ownVersion(ownRoot) : null
163
+ );
164
+ if (choice.kind === "copy")
165
+ return run(execPath, [join(choice.copy.root, "dist", "cli", "index.js"), ...args]);
166
+ if (choice.kind === "npx") {
167
+ // npm installs npx as a .cmd wrapper on Windows, which only a shell can run.
168
+ const windows = process.platform === "win32";
169
+ // Run from the temp folder. npx prefers a project in the current folder, so a project
170
+ // named om-memory-system (such as this repository) would win over the npm package.
171
+ // The hook input carries the real folder, so nothing else needs the current folder.
172
+ return run(
173
+ windows ? "npx.cmd" : "npx",
174
+ ["--yes", `om-memory-system@${choice.version}`, ...args],
175
+ {
176
+ shell: windows,
177
+ cwd: tmpdir(),
178
+ }
179
+ );
180
+ }
181
+ process.stderr.write("omms-launch: no OMMS copy found. Install om-memory-system.\n");
182
+ return 1;
183
+ }
184
+
185
+ /** True when this file is the program Node.js started. A path that cannot be resolved is not. */
186
+ function runDirectly() {
187
+ try {
188
+ const entry = process.argv[1];
189
+ return Boolean(entry) && realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
190
+ } catch {
191
+ // A loader or wrapper can leave argv[1] pointing at a file that is not there.
192
+ return false;
193
+ }
194
+ }
195
+
196
+ if (runDirectly()) {
197
+ runLauncher(process.argv.slice(2)).then((code) => process.exit(code));
198
+ }
@@ -33,6 +33,8 @@ export interface ClaudeHookOptions {
33
33
  sleep?: (ms: number) => Promise<void>;
34
34
  log?: (message: string, data: Record<string, unknown>) => void | Promise<void>;
35
35
  readToken?: () => Promise<string>;
36
+ /** The version of the newest recorded OMMS copy, or null. Tests inject it. */
37
+ recordedVersion?: () => string | null;
36
38
  resolveRuntime?: () => Promise<string | null>;
37
39
  loadSettings?: () => Promise<HookServerSettings>;
38
40
  /** The `om-memory-system` script that `web` is run from. */
@@ -182,7 +182,7 @@ async function runSteps(event, deps, result) {
182
182
  if (!settings.enabled)
183
183
  return "server-disabled";
184
184
  const budget = BUDGETS[event];
185
- const ready = await ensureServer(settings, budget.startMs, deps, result);
185
+ const ready = await ensureServer(event, settings, budget.startMs, deps, result);
186
186
  if (ready !== "ok")
187
187
  return ready;
188
188
  const headers = {
@@ -230,13 +230,38 @@ async function runSteps(event, deps, result) {
230
230
  result.outputBytes = Buffer.byteLength(output);
231
231
  return "ok";
232
232
  }
233
+ /**
234
+ * The newest recorded copy's version with the local token and, when the web app has
235
+ * a browser password, Basic Auth. Undefined when no copy is recorded.
236
+ */
237
+ async function replaceOlderFor(settings, deps) {
238
+ try {
239
+ const handoff = await import("../../services/runtime-handoff.js");
240
+ const version = deps.recordedVersion ? deps.recordedVersion() : handoff.newestRecordedVersion();
241
+ if (!version)
242
+ return undefined;
243
+ return {
244
+ version,
245
+ headers: await handoff.replaceHeaders(await deps.readToken(), {
246
+ webServerAuthPassword: settings.basicAuth?.password,
247
+ webServerAuthUsername: settings.basicAuth?.username,
248
+ }),
249
+ };
250
+ }
251
+ catch {
252
+ // Without a target, any running web app is used.
253
+ return undefined;
254
+ }
255
+ }
233
256
  /** Use a running web app, or start one through the shared rule and wait for it within the budget. */
234
- async function ensureServer(settings, budgetMs, deps, result) {
257
+ async function ensureServer(event, settings, budgetMs, deps, result) {
235
258
  const { ensureWebApp } = await import("../../services/web-ensure.js");
236
259
  const answer = await ensureWebApp({
237
260
  settings: { enabled: settings.enabled, baseUrl: settings.baseUrl },
238
261
  budgetMs,
239
262
  wait: true,
263
+ // Only a new session replaces an older web app. A prompt or a stop uses any running one.
264
+ ...(event === "session-start" ? { replaceOlder: await replaceOlderFor(settings, deps) } : {}),
240
265
  deps: {
241
266
  fetch: deps.fetch,
242
267
  now: deps.now,
@@ -1,6 +1,7 @@
1
1
  import { type ClaudeHookOptions } from "./hook-client.js";
2
+ import { type ClaudeStatusOptions } from "./status.js";
2
3
  /**
3
4
  * `om-memory-system claude-hook <event>`. Always returns 0: a hook must never
4
5
  * block or break a Claude Code session, whatever goes wrong.
5
6
  */
6
- export declare function runClaudeHookCommand(argv: string[], io?: ClaudeHookOptions): Promise<number>;
7
+ export declare function runClaudeHookCommand(argv: string[], io?: ClaudeHookOptions & ClaudeStatusOptions): Promise<number>;
@@ -1,7 +1,14 @@
1
1
  import { CLAUDE_HOOK_EVENTS, runClaudeHook, } from "./hook-client.js";
2
+ import { buildClaudeStatus, loadClaudeStatusInputs } from "./status.js";
2
3
  function isHookEvent(value) {
3
4
  return CLAUDE_HOOK_EVENTS.includes(value ?? "");
4
5
  }
6
+ /** `claude-hook status`: one JSON line for the status line mod. Prints nothing on failure. */
7
+ async function printStatus(io) {
8
+ const inputs = await (io.statusInputs ?? loadClaudeStatusInputs)();
9
+ const status = await buildClaudeStatus({ ...inputs, fetch: io.fetch ?? globalThis.fetch });
10
+ (io.writeStdout ?? ((text) => process.stdout.write(text)))(`${JSON.stringify(status)}\n`);
11
+ }
5
12
  /**
6
13
  * `om-memory-system claude-hook <event>`. Always returns 0: a hook must never
7
14
  * block or break a Claude Code session, whatever goes wrong.
@@ -9,6 +16,10 @@ function isHookEvent(value) {
9
16
  export async function runClaudeHookCommand(argv, io = {}) {
10
17
  try {
11
18
  const [event] = argv;
19
+ if (event === "status") {
20
+ await printStatus(io);
21
+ return 0;
22
+ }
12
23
  if (isHookEvent(event)) {
13
24
  await runClaudeHook(event, io);
14
25
  return 0;
@@ -0,0 +1,25 @@
1
+ export interface ClaudeStatus {
2
+ healthUrl: string;
3
+ /** The version of the OMMS copy that ran this command. */
4
+ version: string;
5
+ /** The `latest` version on npm, or null when the check is off or fails. */
6
+ latest: string | null;
7
+ }
8
+ export interface ClaudeStatusInputs {
9
+ config: {
10
+ webServerHost: string;
11
+ webServerPort: number;
12
+ };
13
+ version: string;
14
+ checkUpdates: boolean;
15
+ }
16
+ /** Options for tests; production reads the global config and the package version. */
17
+ export interface ClaudeStatusOptions {
18
+ statusInputs?: () => Promise<ClaudeStatusInputs>;
19
+ }
20
+ /** Pure: `CONFIG` is passed in, because tests stub `src/config.js`. */
21
+ export declare function buildClaudeStatus(inputs: ClaudeStatusInputs & {
22
+ fetch: typeof fetch;
23
+ }): Promise<ClaudeStatus>;
24
+ /** The global config and this copy's version, as `om-memory-system web` reads them. */
25
+ export declare function loadClaudeStatusInputs(): Promise<ClaudeStatusInputs>;
@@ -0,0 +1,33 @@
1
+ import { homedir } from "node:os";
2
+ import { latestNpmVersion } from "../../services/update-check.js";
3
+ /** Pure: `CONFIG` is passed in, because tests stub `src/config.js`. */
4
+ export async function buildClaudeStatus(inputs) {
5
+ const { webServerHost, webServerPort } = inputs.config;
6
+ const host = webServerHost === "0.0.0.0" ? "127.0.0.1" : webServerHost;
7
+ const urlHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
8
+ return {
9
+ healthUrl: `http://${urlHost}:${webServerPort}/api/health`,
10
+ version: inputs.version,
11
+ latest: inputs.checkUpdates ? await latestNpmVersion(inputs.fetch) : null,
12
+ };
13
+ }
14
+ /** The global config and this copy's version, as `om-memory-system web` reads them. */
15
+ export async function loadClaudeStatusInputs() {
16
+ // Importing the config on a first run prints "Created config template" with
17
+ // console.log. Stdout must hold only the JSON line, so that goes to stderr.
18
+ const log = console.log;
19
+ console.log = console.error;
20
+ try {
21
+ const config = await import("../../config.js");
22
+ config.initConfig(homedir());
23
+ const { packageVersion } = await import("../../services/package-version.js");
24
+ return {
25
+ config: config.CONFIG,
26
+ version: packageVersion(),
27
+ checkUpdates: process.env.OMMS_DISABLE_UPDATE_CHECK !== "1",
28
+ };
29
+ }
30
+ finally {
31
+ console.log = log;
32
+ }
33
+ }
@@ -4,10 +4,6 @@ export interface TuiStatusState {
4
4
  update: string | null;
5
5
  }
6
6
  export declare function tuiStatusText(state: TuiStatusState): string;
7
- /** The `latest` version on npm, or null when the registry cannot be reached. */
8
- export declare function latestNpmVersion(fetchFn: typeof fetch, timeoutMs?: number): Promise<string | null>;
9
- /** A newer release than `current`, ignoring prereleases; null otherwise. */
10
- export declare function availableUpdate(current: string, latest: string | null): string | null;
11
7
  export interface TuiStatusDeps {
12
8
  currentVersion: string;
13
9
  healthUrl: string;
@@ -1,30 +1,8 @@
1
- import { isOlderVersion } from "../../services/version-compare.js";
1
+ import { availableUpdate, latestNpmVersion } from "../../services/update-check.js";
2
2
  export function tuiStatusText(state) {
3
3
  const web = state.web === "offline" ? "omms:web app off" : `omms:${state.web}`;
4
4
  return state.update ? `${web} · ${state.update} available` : web;
5
5
  }
6
- /** The `latest` version on npm, or null when the registry cannot be reached. */
7
- export async function latestNpmVersion(fetchFn, timeoutMs = 5_000) {
8
- try {
9
- const response = await fetchFn("https://registry.npmjs.org/om-memory-system/latest", {
10
- signal: AbortSignal.timeout(timeoutMs),
11
- headers: { accept: "application/json" },
12
- });
13
- if (!response.ok)
14
- return null;
15
- const body = (await response.json());
16
- return typeof body.version === "string" ? body.version : null;
17
- }
18
- catch {
19
- return null;
20
- }
21
- }
22
- /** A newer release than `current`, ignoring prereleases; null otherwise. */
23
- export function availableUpdate(current, latest) {
24
- if (!latest || latest.includes("-"))
25
- return null;
26
- return isOlderVersion(current, latest) ? latest : null;
27
- }
28
6
  /**
29
7
  * Keep the footer text current: the web app is polled, and npm is asked for a
30
8
  * newer release at start and then every few hours. Returns a stop function.
@@ -105,6 +105,10 @@ export default function ommsPiExtension(pi) {
105
105
  // Runs even with tracing off, so turning it off does not leave old traces behind.
106
106
  pruneTraces(CONFIG);
107
107
  captureState = createPiCaptureState();
108
+ // Record this copy first: the login item and the other hosts run the newest recorded copy.
109
+ void import("../../services/runtime-handoff.js")
110
+ .then(({ registerOwnCopy }) => registerOwnCopy())
111
+ .catch((error) => log("Pi runtime record failed", { code: error instanceof Error ? error.name : "unknown" }));
108
112
  if (CONFIG.webServerAutoStart !== undefined &&
109
113
  process.env.OMMS_DISABLE_WEB_AUTOSTART !== "1") {
110
114
  void import("../../services/web-autostart.js")
@@ -118,15 +122,21 @@ export default function ommsPiExtension(pi) {
118
122
  void Promise.all([
119
123
  import("../../services/web-ensure.js"),
120
124
  import("../../services/web-api-auth.js"),
125
+ import("../../services/runtime-handoff.js"),
121
126
  ])
122
- .then(([{ ensureWebApp }, { webServerUrl }]) => ensureWebApp({
123
- settings: {
124
- enabled: CONFIG.webServerEnabled,
125
- baseUrl: webServerUrl(CONFIG.webServerHost, CONFIG.webServerPort),
126
- },
127
- budgetMs: 0,
128
- wait: false,
129
- }))
127
+ .then(async ([{ ensureWebApp }, { webServerUrl }, { hostReplaceOlder }]) => {
128
+ // A web app older than the newest recorded copy steps aside for it.
129
+ const replaceOlder = await hostReplaceOlder(CONFIG).catch(() => undefined);
130
+ return ensureWebApp({
131
+ settings: {
132
+ enabled: CONFIG.webServerEnabled,
133
+ baseUrl: webServerUrl(CONFIG.webServerHost, CONFIG.webServerPort),
134
+ },
135
+ budgetMs: 0,
136
+ wait: false,
137
+ ...(replaceOlder ? { replaceOlder } : {}),
138
+ });
139
+ })
130
140
  .catch((error) => log("Pi web app start failed", {
131
141
  code: error instanceof Error ? error.name : "unknown",
132
142
  }));
package/dist/cli/index.js CHANGED
@@ -38,6 +38,11 @@ export function parseImportArgs(argv) {
38
38
  return { host, args };
39
39
  }
40
40
  export async function runCli(argv) {
41
+ // An older copy runs the newest recorded copy instead; otherwise it records itself.
42
+ const { handOffOrRegister } = await import("../services/runtime-handoff.js");
43
+ const handedOff = await handOffOrRegister(argv);
44
+ if (handedOff !== null)
45
+ return handedOff;
41
46
  if (argv[0] === "--version" || argv[0] === "-v") {
42
47
  const { packageVersion } = await import("../services/package-version.js");
43
48
  console.log(packageVersion());
@@ -1,63 +1,5 @@
1
1
  import { homedir } from "node:os";
2
- const POLL_MS = 250;
3
- const POLL_LIMIT = 40;
4
- const REQUEST_TIMEOUT_MS = 2_000;
5
- /** The version an OMMS web app reports on `url`, or null when none answers with one. */
6
- async function readWebVersion(fetchFn, url, headers) {
7
- try {
8
- const response = await fetchFn(`${url}/api/settings/version`, {
9
- headers,
10
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
11
- });
12
- if (!response.ok)
13
- return null;
14
- const body = (await response.json());
15
- return typeof body.running === "string" ? body.running : null;
16
- }
17
- catch {
18
- return null;
19
- }
20
- }
21
- async function portAnswers(fetchFn, url) {
22
- try {
23
- await fetchFn(`${url}/api/health`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
24
- return true;
25
- }
26
- catch {
27
- return false;
28
- }
29
- }
30
- /** Ask an older OMMS web app on the configured port to step aside, and wait for the port. */
31
- async function negotiateOwner(ctx) {
32
- const { compareVersions } = await import("../services/version-compare.js");
33
- const owner = await readWebVersion(ctx.fetchFn, ctx.url, ctx.headers);
34
- const order = owner === null ? null : compareVersions(owner, ctx.version);
35
- // A version that cannot be placed is never stopped.
36
- if (owner === null || order === null)
37
- return { kind: "none" };
38
- if (order === 0)
39
- return { kind: "same", owner };
40
- if (order > 0)
41
- return { kind: "newer", owner };
42
- try {
43
- const reply = await ctx.fetchFn(`${ctx.url}/api/web/step-aside`, {
44
- method: "POST",
45
- headers: { ...ctx.headers, "content-type": "application/json" },
46
- body: JSON.stringify({ version: ctx.version }),
47
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
48
- });
49
- if (reply.status !== 202)
50
- return { kind: "stuck", owner };
51
- }
52
- catch {
53
- return { kind: "stuck", owner };
54
- }
55
- // If the old app is slow to stop, the login item still takes the port later.
56
- for (let i = 0; i < POLL_LIMIT && (await portAnswers(ctx.fetchFn, ctx.url)); i++) {
57
- await ctx.sleep(POLL_MS);
58
- }
59
- return { kind: "handed" };
60
- }
2
+ import { negotiateOwner, POLL_LIMIT, POLL_MS, readWebVersion, } from "../services/web-handover.js";
61
3
  /** Print which version serves the port after `web install`. */
62
4
  async function announceHandover(ctx, handover) {
63
5
  const { url, port, version } = ctx;
package/dist/index.js CHANGED
@@ -183,6 +183,12 @@ export const OmmsPlugin = async (ctx) => {
183
183
  initConfigWithLegacyMigration(directory);
184
184
  // Runs even with tracing off, so turning it off does not leave old traces behind.
185
185
  pruneTraces(CONFIG);
186
+ // Record this copy first: the login item and the other hosts run the newest recorded copy.
187
+ void import("./services/runtime-handoff.js")
188
+ .then(({ registerOwnCopy }) => registerOwnCopy())
189
+ .catch((error) => log("OpenCode runtime record failed", {
190
+ code: error instanceof Error ? error.name : "unknown",
191
+ }));
186
192
  if (CONFIG.webServerAutoStart !== undefined && process.env.OMMS_DISABLE_WEB_AUTOSTART !== "1") {
187
193
  void import("./services/web-autostart.js")
188
194
  .then(({ reconcileWebAutostart }) => reconcileWebAutostart(CONFIG))
@@ -300,12 +306,19 @@ export const OmmsPlugin = async (ctx) => {
300
306
  .catch(() => { });
301
307
  };
302
308
  // The shared web app serves the page. This plugin starts it only when none runs.
303
- void Promise.all([import("./services/web-ensure.js"), import("./services/web-api-auth.js")])
304
- .then(([{ ensureWebApp }, { webServerUrl }]) => {
309
+ void Promise.all([
310
+ import("./services/web-ensure.js"),
311
+ import("./services/web-api-auth.js"),
312
+ import("./services/runtime-handoff.js"),
313
+ ])
314
+ .then(async ([{ ensureWebApp }, { webServerUrl }, { hostReplaceOlder }]) => {
305
315
  const baseUrl = webServerUrl(CONFIG.webServerHost, CONFIG.webServerPort);
316
+ // A web app older than the newest recorded copy steps aside for it.
317
+ const replaceOlder = await hostReplaceOlder(CONFIG).catch(() => undefined);
306
318
  return ensureWebApp({
307
319
  settings: { enabled: CONFIG.webServerEnabled, baseUrl },
308
320
  budgetMs: 10_000,
321
+ ...(replaceOlder ? { replaceOlder } : {}),
309
322
  }).then((result) => {
310
323
  if (result === "running" || result === "started") {
311
324
  showToast(`Web UI available at ${baseUrl}`, "info", 3000);
@@ -1,31 +1,22 @@
1
1
  export interface GlobalCommandVersion {
2
- /** The version the global `om-memory-system --version` printed, or null when not installed. */
2
+ /** The version in the global install's `package.json`, or null when not installed or unreadable. */
3
3
  version: string | null;
4
4
  path: string | null;
5
- error?: "timeout" | "failed";
5
+ error?: "failed";
6
6
  }
7
- type Runner = (file: string, args: string[], options: {
8
- timeout: number;
9
- windowsHide: boolean;
10
- shell: false;
11
- }) => Promise<string>;
12
7
  /** The first `om-memory-system` on PATH; on Windows npm installs a `.cmd` wrapper. */
13
8
  export declare function findGlobalCommand(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, exists?: (path: string) => boolean): string | null;
14
9
  /**
15
- * The command and arguments that print the version without a user-controlled
16
- * shell. A Windows `.cmd` wrapper can only run through cmd.exe, so it runs
17
- * through the system's own cmd.exe with fixed arguments.
10
+ * The global command's version, read from its install's `package.json`. The
11
+ * command does not run: after a hand-off it would print the newest copy's version.
12
+ * The login item runs with a minimal PATH, so with no command on PATH the install
13
+ * beside the running Node.js counts too.
18
14
  */
19
- export declare function versionInvocation(path: string, platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): {
20
- file: string;
21
- args: string[];
22
- };
23
- /** Look up the global command's version, at most once every 10 minutes. */
24
15
  export declare function globalCommandVersion(options?: {
25
- now?: () => number;
26
- runner?: Runner;
27
16
  find?: () => string | null;
28
17
  platform?: NodeJS.Platform;
29
- }): Promise<GlobalCommandVersion>;
30
- export declare function resetGlobalVersionCache(): void;
31
- export {};
18
+ execPath?: string;
19
+ }): GlobalCommandVersion;
20
+ export type GlobalRelation = "missing" | "same" | "older" | "newer" | "unknown";
21
+ /** How the global install compares with the running OMMS. */
22
+ export declare function globalRelation(running: string, global: string | null): GlobalRelation;