om-memory-system 3.6.0-next.39 → 3.6.0-next.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude-code/hook-client.d.ts +4 -12
- package/dist/adapters/claude-code/hook-client.js +21 -52
- package/dist/adapters/pi/extension.js +18 -0
- package/dist/cli/web-command.js +12 -0
- package/dist/cli/web-power.d.ts +40 -0
- package/dist/cli/web-power.js +61 -0
- package/dist/importer/import-readiness.d.ts +1 -1
- package/dist/importer/import-readiness.js +6 -1
- package/dist/importer/settings-health.js +24 -15
- package/dist/index.js +32 -99
- package/dist/services/web-autostart.d.ts +6 -0
- package/dist/services/web-autostart.js +31 -0
- package/dist/services/web-ensure.d.ts +59 -0
- package/dist/services/web-ensure.js +214 -0
- package/dist/services/web-server.d.ts +6 -0
- package/dist/services/web-server.js +58 -0
- package/dist/web/assets/index-Ctzj_Zg0.js +124 -0
- package/dist/web/assets/index-DG7RjW5B.css +2 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-BFiVixOK.js +0 -124
- package/dist/web/assets/index-D5MY5Kjs.css +0 -2
|
@@ -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>;
|
|
@@ -152,6 +152,8 @@ export class WebServer {
|
|
|
152
152
|
onTakeoverCallback = null;
|
|
153
153
|
onPortsExhaustedCallback = null;
|
|
154
154
|
onStepAsideCallback = null;
|
|
155
|
+
onPowerActionCallback = null;
|
|
156
|
+
powerActionRunning = false;
|
|
155
157
|
stepAsideTimer = null;
|
|
156
158
|
holdOffTimer = null;
|
|
157
159
|
portsExhaustedNotified = false;
|
|
@@ -211,6 +213,42 @@ export class WebServer {
|
|
|
211
213
|
setOnStepAside(callback) {
|
|
212
214
|
this.onStepAsideCallback = callback;
|
|
213
215
|
}
|
|
216
|
+
/** Standalone web apps register this to stop or restart from the page. */
|
|
217
|
+
setOnPowerAction(callback) {
|
|
218
|
+
this.onPowerActionCallback = callback;
|
|
219
|
+
}
|
|
220
|
+
handlePowerAction(action, remoteAddress) {
|
|
221
|
+
const record = (outcome) => log("Web server power request", { action, outcome, ownVersion: packageVersion() });
|
|
222
|
+
if (!isLoopbackAddress(remoteAddress)) {
|
|
223
|
+
record("refused_not_loopback");
|
|
224
|
+
return this.jsonResponse({ success: false, error: "Loopback caller required" }, 403);
|
|
225
|
+
}
|
|
226
|
+
const callback = this.onPowerActionCallback;
|
|
227
|
+
if (!callback) {
|
|
228
|
+
record("unsupported");
|
|
229
|
+
return this.jsonResponse({ success: false, error: "Not supported by this web app" }, 409);
|
|
230
|
+
}
|
|
231
|
+
// A repeated request must not start a second stop or restart while one runs.
|
|
232
|
+
if (this.powerActionRunning) {
|
|
233
|
+
record("already_running");
|
|
234
|
+
return this.jsonResponse({ success: true }, 202);
|
|
235
|
+
}
|
|
236
|
+
this.powerActionRunning = true;
|
|
237
|
+
record(action === "stop" ? "stopping" : "restarting");
|
|
238
|
+
// Let the 202 reply reach the caller before the web app goes down.
|
|
239
|
+
setTimeout(async () => {
|
|
240
|
+
try {
|
|
241
|
+
await callback(action);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
log("Power action callback error", { action, error: String(error) });
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
this.powerActionRunning = false;
|
|
248
|
+
}
|
|
249
|
+
}, STEP_ASIDE_REPLY_GRACE_MS).unref();
|
|
250
|
+
return this.jsonResponse({ success: true }, 202);
|
|
251
|
+
}
|
|
214
252
|
scheduleStepAside() {
|
|
215
253
|
if (this.stepAsideTimer)
|
|
216
254
|
return;
|
|
@@ -281,6 +319,10 @@ export class WebServer {
|
|
|
281
319
|
fetch: this.handleRequest.bind(this),
|
|
282
320
|
});
|
|
283
321
|
this.isOwner = true;
|
|
322
|
+
// A restart copy is named in the start lock until it owns the port.
|
|
323
|
+
void import("./web-ensure.js")
|
|
324
|
+
.then(({ removeStartLockFor }) => removeStartLockFor(process.pid))
|
|
325
|
+
.catch(() => { });
|
|
284
326
|
// This process serves the Claude Code hooks, so it also retries their failed captures.
|
|
285
327
|
void import("../importer/claude-hook-api.js")
|
|
286
328
|
.then(({ startClaudeCodeWorker }) => startClaudeCodeWorker())
|
|
@@ -477,6 +519,13 @@ export class WebServer {
|
|
|
477
519
|
callerVersion: "",
|
|
478
520
|
});
|
|
479
521
|
}
|
|
522
|
+
if (method === "POST" && (path === "/api/web/stop" || path === "/api/web/restart")) {
|
|
523
|
+
log("Web server power request", {
|
|
524
|
+
action: path === "/api/web/stop" ? "stop" : "restart",
|
|
525
|
+
outcome: "refused_auth",
|
|
526
|
+
ownVersion: packageVersion(),
|
|
527
|
+
});
|
|
528
|
+
}
|
|
480
529
|
return (configuredTokenFailure ??
|
|
481
530
|
this.jsonResponse({ success: false, error: "Unauthorized" }, 401));
|
|
482
531
|
}
|
|
@@ -542,6 +591,15 @@ export class WebServer {
|
|
|
542
591
|
? controls.resume(host, cwd)
|
|
543
592
|
: controls.runNow(host, cwd));
|
|
544
593
|
}
|
|
594
|
+
if (path === "/api/web/status" && method === "GET") {
|
|
595
|
+
return this.jsonResponse({
|
|
596
|
+
version: packageVersion(),
|
|
597
|
+
canControl: isLoopbackAddress(remoteAddress) && this.onPowerActionCallback !== null,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
if ((path === "/api/web/stop" || path === "/api/web/restart") && method === "POST") {
|
|
601
|
+
return this.handlePowerAction(path === "/api/web/stop" ? "stop" : "restart", remoteAddress);
|
|
602
|
+
}
|
|
545
603
|
if (path === "/api/web/step-aside" && method === "POST") {
|
|
546
604
|
const body = await req.json().catch(() => null);
|
|
547
605
|
return this.handleStepAside(remoteAddress, body);
|