@mingxy/cerebro 1.20.2 โ†’ 1.20.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mingxy/cerebro",
3
- "version": "1.20.2",
3
+ "version": "1.20.4",
4
4
  "description": "Cerebro persistent memory plugin for OpenCode โ€” auto-recall, auto-capture, 9 memory tools with clustering, project-scoped memory isolation",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ import { join, dirname } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { CerebroClient } from "./client.js";
7
- import { chatMessageRecallHook, autocontinueHook, compactingHook, sessionIdleHook, createToast, sessionMessages, firstMessages } from "./hooks.js";
7
+ import { chatMessageRecallHook, autocontinueHook, compactingHook, sessionIdleHook, sessionMessages, firstMessages, showToast } from "./hooks.js";
8
8
  import { detectSaveKeyword, detectRecallKeyword, KEYWORD_NUDGE, RECALL_NUDGE } from "./keywords.js";
9
9
  import { getUserTag, getProjectTag } from "./tags.js";
10
10
  import { buildTools } from "./tools.js";
@@ -64,7 +64,7 @@ const OmemPlugin: Plugin = async (input) => {
64
64
  } catch {}
65
65
 
66
66
  const config = loadPluginConfig(overrides as any);
67
- const toast = createToast(config);
67
+ const STARTUP_DELAY = 2000;
68
68
 
69
69
  setOpencodeClient(client);
70
70
 
@@ -78,18 +78,20 @@ const OmemPlugin: Plugin = async (input) => {
78
78
  logError(`Connection failed: ${errMsg}`);
79
79
  if (errMsg.includes("[cerebro]")) {
80
80
  const cleanMsg = errMsg.replace(/^\[cerebro\]\s*/, "");
81
- toast(
81
+ showToast(
82
82
  tui,
83
83
  `๐Ÿง  Cerebro v${pluginVersion} ยท Server Error`,
84
84
  cleanMsg.substring(0, 150),
85
- "error"
85
+ "error",
86
+ STARTUP_DELAY
86
87
  );
87
88
  } else {
88
- toast(
89
+ showToast(
89
90
  tui,
90
91
  `๐Ÿง  Cerebro v${pluginVersion} ยท Connection Failed`,
91
92
  `Unable to reach ${config.connection.apiUrl}`,
92
- "error"
93
+ "error",
94
+ STARTUP_DELAY
93
95
  );
94
96
  }
95
97
  }
@@ -125,9 +127,9 @@ const OmemPlugin: Plugin = async (input) => {
125
127
  }
126
128
 
127
129
  if (webPort) {
128
- toast(tui, `๐Ÿง  Cerebro Connected ยท v${pluginVersion}`, `๐ŸŒ Open in browser http://localhost:${webPort}`, "success");
130
+ showToast(tui, `๐Ÿง  Cerebro Connected ยท v${pluginVersion}`, `๐ŸŒ Open in browser http://localhost:${webPort}`, "success", STARTUP_DELAY);
129
131
  } else {
130
- toast(tui, `๐Ÿง  Cerebro Connected ยท v${pluginVersion}`, "No web server", "success");
132
+ showToast(tui, `๐Ÿง  Cerebro Connected ยท v${pluginVersion}`, "No web server", "success", STARTUP_DELAY);
131
133
  }
132
134
 
133
135
  // Auto-update check (fire-and-forget, non-blocking)
package/src/logger.ts CHANGED
@@ -138,11 +138,11 @@ function writeLog(level: string, message: string, fields?: Record<string, unknow
138
138
  appendFileSync(logFile, parts.join(" ") + "\n");
139
139
  } catch {}
140
140
 
141
- // Track 2: opencode client
141
+ // Track 2: opencode client (async โ€” fire-and-forget with .catch)
142
142
  try {
143
143
  opencodeClient?.app?.log({
144
- body: { service: "cerebro", level: level.toLowerCase(), message, extra: fields },
145
- });
144
+ body: { service: "cerebro", level: level.toLowerCase() as "debug" | "info" | "warn" | "error", message, extra: fields },
145
+ })?.catch?.(() => {});
146
146
  } catch { /* opencode client not available, skip */ }
147
147
  }
148
148
 
package/src/updater.ts CHANGED
@@ -6,6 +6,7 @@ import { homedir, tmpdir } from "node:os";
6
6
  import { openSync, closeSync, unlinkSync, statSync, writeSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
7
7
  import { loadPluginConfig } from "./config.js";
8
8
  import { logInfo, logDebug, logError } from "./logger.js";
9
+ import { showToast } from "./hooks.js";
9
10
 
10
11
  const execFileAsync = promisify(execFile);
11
12
  const require = createRequire(import.meta.url);
@@ -142,9 +143,7 @@ export async function checkAndUpdate(tui: any, currentVersion: string): Promise<
142
143
  if (success) {
143
144
  logInfo("updater: update completed", { from: currentVersion, to: latest });
144
145
  try {
145
- tui?.showToast?.({
146
- body: { message: `Cerebro updated to v${latest} โ€” restart opencode to apply`, variant: "info" }
147
- });
146
+ showToast(tui, "๐Ÿง  Cerebro Updated", `v${currentVersion} โ†’ v${latest} ยท restart opencode to apply`, "info", 2000);
148
147
  } catch {}
149
148
  } else {
150
149
  logError("updater: update failed", { targetDir });
package/src/web-server.ts CHANGED
@@ -1,21 +1,17 @@
1
- /**
2
- * web-server.ts โ€” Cerebro Web Server Manager (spawn mode)
3
- *
4
- * ไธๅ†็›ดๆŽฅๅˆ›ๅปบ HTTP server๏ผŒ่€Œๆ˜ฏ fork ็‹ฌ็ซ‹ๅญ่ฟ›็จ‹ (web-server-child.ts)ใ€‚
5
- * ๅคš็ช—ๅฃๅ…ฑไบซไธ€ไธช server๏ผŒไปปไธ€็ช—ๅฃๅ…ณ้—ญไธๅฝฑๅ“ๅ…ถไป–็ช—ๅฃใ€‚
6
- * ๅญ่ฟ›็จ‹้€š่ฟ‡ๅฟƒ่ทณๆ–‡ไปถ mtime ๆŽขๆต‹ plugin ๅญ˜ๆดป็Šถๆ€๏ผŒๅ…จ้ƒจ้€€ๅ‡บๅŽ่‡ชๅŠจๅ…ณ้—ญใ€‚
7
- */
8
- import { fork } from "node:child_process";
1
+ import * as http from "node:http";
9
2
  import * as fs from "node:fs";
10
3
  import * as path from "node:path";
11
- import * as os from "node:os";
12
4
  import { fileURLToPath } from "node:url";
13
5
  import { logInfo, logWarn, logError } from "./logger.js";
14
6
 
15
7
  const __filename = fileURLToPath(import.meta.url);
16
8
  const __dirname = path.dirname(__filename);
17
9
 
18
- // โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
10
+ const DEFAULT_PORT = 5212;
11
+ const TAKEOVER_INTERVAL_MS = 5000;
12
+ const TAKEOVER_JITTER_MIN_MS = 500;
13
+ const TAKEOVER_JITTER_RANGE_MS = 1000;
14
+ const TAKEOVER_MAX_RETRIES = 60;
19
15
 
20
16
  export interface WebServerConfig {
21
17
  apiUrl: string;
@@ -24,32 +20,40 @@ export interface WebServerConfig {
24
20
 
25
21
  export interface WebServerHandle {
26
22
  address(): { port: number; family: string; address: string } | string | null;
23
+ isOwner(): boolean;
24
+ stop(): Promise<void>;
27
25
  }
28
26
 
29
- // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
30
-
31
- /** Touch a file โ€” update mtime or create if missing */
32
- function touchFile(filePath: string): void {
33
- try {
34
- fs.utimesSync(filePath, new Date(), new Date());
35
- } catch {
36
- try {
37
- fs.writeFileSync(filePath, "");
38
- } catch { /* ignore */ }
39
- }
40
- }
41
-
42
- /** Check if a process with the given PID is still running */
43
- function isProcessAlive(pid: number): boolean {
44
- try {
45
- process.kill(pid, 0);
46
- return true;
47
- } catch {
48
- return false;
27
+ const MIME_TYPES: Record<string, string> = {
28
+ ".html": "text/html; charset=utf-8",
29
+ ".js": "application/javascript; charset=utf-8",
30
+ ".mjs": "application/javascript; charset=utf-8",
31
+ ".css": "text/css; charset=utf-8",
32
+ ".json": "application/json; charset=utf-8",
33
+ ".svg": "image/svg+xml",
34
+ ".png": "image/png",
35
+ ".jpg": "image/jpeg",
36
+ ".ico": "image/x-icon",
37
+ ".woff": "font/woff",
38
+ ".woff2": "font/woff2",
39
+ ".ttf": "font/ttf",
40
+ ".webp": "image/webp",
41
+ ".map": "application/json",
42
+ };
43
+
44
+ const COMMON_HEADERS: Record<string, string> = {
45
+ "X-Content-Type-Options": "nosniff",
46
+ };
47
+
48
+ function resolveSafe(baseDir: string, pathname: string): string | null {
49
+ const relative = pathname.startsWith("/") ? pathname.slice(1) : pathname;
50
+ const resolved = path.resolve(baseDir, relative || ".");
51
+ if (!resolved.startsWith(baseDir + path.sep) && resolved !== baseDir) {
52
+ return null;
49
53
  }
54
+ return resolved;
50
55
  }
51
56
 
52
- /** Probe an existing server's /health endpoint */
53
57
  async function probeExistingServer(port: number): Promise<boolean> {
54
58
  try {
55
59
  const resp = await fetch(`http://127.0.0.1:${port}/health`);
@@ -57,48 +61,18 @@ async function probeExistingServer(port: number): Promise<boolean> {
57
61
  const body = await resp.text();
58
62
  return body.includes("cerebro");
59
63
  }
60
- } catch { /* connection refused โ†’ port free */ }
64
+ } catch {}
61
65
  return false;
62
66
  }
63
67
 
64
- // โ”€โ”€ Start / Stop โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
65
-
66
- export async function startWebServer(
67
- config: WebServerConfig,
68
- ): Promise<WebServerHandle | null> {
69
- const port =
70
- config.port || parseInt(process.env.OMEM_LOCAL_PORT || "", 10) || 5212;
71
- const pidFilePath = path.join(os.tmpdir(), `cerebro-web-${port}.pid`);
72
- const heartbeatFilePath = path.join(
73
- os.tmpdir(),
74
- `cerebro-web-${port}.heartbeat`,
75
- );
76
-
77
- // โ”€โ”€ Step 1: ๆฃ€ๆŸฅ็ซฏๅฃๆ˜ฏๅฆๅทฒๆœ‰ cerebro server โ”€โ”€
78
- if (await probeExistingServer(port)) {
79
- logInfo("web-server: reusing existing server", { port });
80
- return createHandle(port, heartbeatFilePath);
81
- }
82
-
83
- // โ”€โ”€ Step 2: ๆฃ€ๆŸฅ PID ๆ–‡ไปถ๏ผˆๅฏ่ƒฝๆœ‰ๅ…ถไป–่ฟ›็จ‹ๆญฃๅœจ fork๏ผ‰ โ”€โ”€
84
- try {
85
- const pidStr = fs.readFileSync(pidFilePath, "utf-8").trim();
86
- const pid = parseInt(pidStr, 10);
87
- if (pid > 0 && isProcessAlive(pid)) {
88
- // ๆœ‰ๅ…ถไป–่ฟ›็จ‹ๆญฃๅœจ fork ๆˆ–่ฟ่กŒ๏ผŒ็ญ‰ๅพ… 200ms ๅŽ้‡่ฏ•
89
- await new Promise((r) => setTimeout(r, 200));
90
- if (await probeExistingServer(port)) {
91
- logInfo("web-server: reusing server after PID check", { port });
92
- return createHandle(port, heartbeatFilePath);
93
- }
94
- } else {
95
- // ่ฟ›็จ‹ๅทฒๆญปไบก๏ผŒๆธ…็† PID ๆ–‡ไปถ
96
- try { fs.unlinkSync(pidFilePath); } catch { /* ignore */ }
97
- }
98
- } catch { /* PID ๆ–‡ไปถไธๅญ˜ๅœจ๏ผŒ็ปง็ปญ */ }
68
+ let activeServerHandle: WebServerHandle | null = null;
69
+ let takeoverTimer: ReturnType<typeof setInterval> | null = null;
70
+ let takeoverRetries = 0;
99
71
 
100
- // โ”€โ”€ Step 3: ๆฃ€ๆŸฅ web ็›ฎๅฝ• โ”€โ”€
72
+ export async function startWebServer(config: WebServerConfig): Promise<WebServerHandle | null> {
73
+ const port = config.port || parseInt(process.env.OMEM_LOCAL_PORT || "", 10) || DEFAULT_PORT;
101
74
  const webDir = path.resolve(__dirname, "..", "web");
75
+
102
76
  if (!fs.existsSync(webDir)) {
103
77
  logWarn("web-server: web directory not found, skipping", { webDir });
104
78
  return null;
@@ -108,96 +82,166 @@ export async function startWebServer(
108
82
  return null;
109
83
  }
110
84
 
111
- // โ”€โ”€ Step 4: ๅ†™ PID ๆ–‡ไปถ๏ผˆๆ ‡่ฎฐๆญฃๅœจ fork๏ผ‰ โ”€โ”€
112
- try {
113
- fs.writeFileSync(pidFilePath, String(process.pid));
114
- } catch { /* ignore */ }
115
-
116
- // โ”€โ”€ Step 5: Fork ๅญ่ฟ›็จ‹ โ”€โ”€
117
- // TS runtime: .ts exists; compiled: .js exists
118
- const childTs = path.resolve(__dirname, "web-server-child.ts");
119
- const childJs = path.resolve(__dirname, "web-server-child.js");
120
- const childPath = fs.existsSync(childTs) ? childTs : childJs;
121
-
122
- // Use the same executor as the parent process (tsx, node, etc.)
123
- // so the child can run .ts files. Also inherit execArgv (--import tsx, etc.)
124
- const child = fork(childPath, [], {
125
- detached: true,
126
- stdio: ["pipe", "pipe", "pipe", "ipc"],
127
- execPath: process.execPath,
128
- execArgv: process.execArgv,
129
- });
85
+ if (await probeExistingServer(port)) {
86
+ logInfo("web-server: reusing existing server", { port });
87
+ return createHandle(port);
88
+ }
89
+
90
+ const handle = await tryBind(port, webDir, config.apiUrl);
91
+ if (handle) {
92
+ activeServerHandle = handle;
93
+ return createHandle(port);
94
+ }
130
95
 
131
- // Drain stdout/stderr to prevent pipe buffer from blocking the child
132
- child.stdout?.on("data", () => {});
133
- child.stderr?.on("data", () => {});
96
+ logInfo("web-server: port busy, starting takeover watch", { port });
97
+ startTakeoverWatch(port, webDir, config.apiUrl);
98
+ return createHandle(port);
99
+ }
100
+
101
+ function tryBind(port: number, webDir: string, apiUrl: string): Promise<WebServerHandle | null> {
102
+ return new Promise((resolve) => {
103
+ const indexPath = path.join(webDir, "index.html");
134
104
 
135
- child.unref();
105
+ const server = http.createServer((req: http.IncomingMessage, res: http.ServerResponse) => {
106
+ if (req.url === "/health" || req.url === "/health/") {
107
+ res.writeHead(200, { ...COMMON_HEADERS, "Content-Type": "application/json" });
108
+ res.end(JSON.stringify({ status: "ok", service: "cerebro", port }));
109
+ return;
110
+ }
136
111
 
137
- // ๅ‘้€้…็ฝฎ็ป™ๅญ่ฟ›็จ‹
138
- child.send({ port, webDir, apiUrl: config.apiUrl });
112
+ if (req.method !== "GET" && req.method !== "HEAD") {
113
+ res.writeHead(405, { ...COMMON_HEADERS, "Content-Type": "text/plain" });
114
+ res.end("Method Not Allowed");
115
+ return;
116
+ }
139
117
 
140
- // โ”€โ”€ Step 6: ็ญ‰ๅพ… ready ๆˆ–่ถ…ๆ—ถ 5s โ”€โ”€
141
- const ready = await new Promise<boolean>((resolve) => {
142
- const timeout = setTimeout(() => resolve(false), 5000);
118
+ const url = new URL(req.url || "/", `http://localhost:${port}`);
119
+ const safePath = resolveSafe(webDir, url.pathname);
143
120
 
144
- child.on("message", (msg: { type: string }) => {
145
- if (msg.type === "ready") {
146
- clearTimeout(timeout);
147
- resolve(true);
148
- } else if (msg.type === "error") {
149
- clearTimeout(timeout);
150
- resolve(false);
121
+ if (!safePath) {
122
+ res.writeHead(403, { ...COMMON_HEADERS, "Content-Type": "text/plain" });
123
+ res.end("Forbidden");
124
+ return;
151
125
  }
126
+
127
+ fs.stat(safePath, (statErr, stats) => {
128
+ if (!statErr && stats.isFile()) {
129
+ serveFile(res, safePath, apiUrl);
130
+ return;
131
+ }
132
+ fs.stat(indexPath, (idxErr, idxStats) => {
133
+ if (idxErr || !idxStats.isFile()) {
134
+ res.writeHead(404, { ...COMMON_HEADERS, "Content-Type": "text/plain" });
135
+ res.end("Not Found");
136
+ return;
137
+ }
138
+ serveFile(res, indexPath, apiUrl);
139
+ });
140
+ });
152
141
  });
153
142
 
154
- child.on("error", () => {
155
- clearTimeout(timeout);
156
- resolve(false);
143
+ server.on("error", (err: NodeJS.ErrnoException) => {
144
+ if (err.code === "EADDRINUSE") {
145
+ resolve(null);
146
+ } else {
147
+ logError("web-server: bind error", { error: err.message });
148
+ resolve(null);
149
+ }
157
150
  });
158
151
 
159
- child.on("exit", () => {
160
- clearTimeout(timeout);
161
- resolve(false);
152
+ server.listen(port, "127.0.0.1", () => {
153
+ logInfo("web-server: server started", { port });
154
+ resolve({
155
+ address: () => ({ port, family: "IPv4", address: "127.0.0.1" }),
156
+ isOwner: () => true,
157
+ stop: () => new Promise<void>((r) => server.close(() => r())),
158
+ });
162
159
  });
163
160
  });
161
+ }
164
162
 
165
- if (!ready) {
166
- logError("web-server: failed to start child process", { port });
167
- try { child.kill(); } catch { /* ignore */ }
168
- try { fs.unlinkSync(pidFilePath); } catch { /* ignore */ }
169
- return null;
170
- }
163
+ function serveFile(res: http.ServerResponse, filePath: string, apiUrl: string): void {
164
+ const ext = path.extname(filePath).toLowerCase();
165
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
166
+
167
+ fs.readFile(filePath, (err, data) => {
168
+ if (err) {
169
+ logError("web-server: file read failed", { filePath, error: err.message });
170
+ res.writeHead(500, { ...COMMON_HEADERS, "Content-Type": "text/plain" });
171
+ res.end("Internal Server Error");
172
+ return;
173
+ }
174
+
175
+ let body: Buffer | string = data;
176
+ if (ext === ".html" && data.includes("__OMEM_API_URL__")) {
177
+ body = data.toString("utf-8").replace(
178
+ /window\.__OMEM_API_URL__\s*=\s*["']__OMEM_API_URL__["']/,
179
+ `window.__OMEM_API_URL__ = ${JSON.stringify(apiUrl)}`,
180
+ );
181
+ }
182
+
183
+ res.writeHead(200, {
184
+ ...COMMON_HEADERS,
185
+ "Content-Type": contentType,
186
+ "Cache-Control": ext === ".html" ? "no-cache, no-store, must-revalidate" : "public, max-age=86400",
187
+ });
188
+ res.end(body);
189
+ });
190
+ }
171
191
 
172
- logInfo("web-server: child process started", { port, pid: child.pid });
173
- return createHandle(port, heartbeatFilePath);
192
+ function clearTakeoverTimer(): void {
193
+ if (takeoverTimer) {
194
+ clearInterval(takeoverTimer);
195
+ takeoverTimer = null;
196
+ }
174
197
  }
175
198
 
176
- /** Create a handle with address() compat + heartbeat keep-alive */
177
- function createHandle(
178
- port: number,
179
- heartbeatFilePath: string,
180
- ): WebServerHandle {
181
- // ๅˆๅง‹ touch
182
- touchFile(heartbeatFilePath);
199
+ function startTakeoverWatch(port: number, webDir: string, apiUrl: string): void {
200
+ if (takeoverTimer) return;
201
+ takeoverRetries = 0;
202
+ takeoverTimer = setInterval(async () => {
203
+ if (await probeExistingServer(port)) {
204
+ takeoverRetries = 0;
205
+ return;
206
+ }
207
+
208
+ takeoverRetries++;
209
+ if (takeoverRetries > TAKEOVER_MAX_RETRIES) {
210
+ logWarn("web-server: takeover abandoned after max retries", { port });
211
+ clearTakeoverTimer();
212
+ return;
213
+ }
183
214
 
184
- // ๆฏ 30 ็ง’ touch ๅฟƒ่ทณๆ–‡ไปถ๏ผŒไฟๆŒๅญ่ฟ›็จ‹ๅญ˜ๆดป
185
- const timer = setInterval(() => {
186
- touchFile(heartbeatFilePath);
187
- }, 30_000);
215
+ const jitter = TAKEOVER_JITTER_MIN_MS + Math.random() * TAKEOVER_JITTER_RANGE_MS;
216
+ await new Promise((r) => setTimeout(r, jitter));
217
+ if (await probeExistingServer(port)) {
218
+ takeoverRetries = 0;
219
+ return;
220
+ }
188
221
 
189
- // ็กฎไฟๅฎšๆ—ถๅ™จไธ้˜ปๆญข่ฟ›็จ‹้€€ๅ‡บ
190
- timer.unref();
222
+ const handle = await tryBind(port, webDir, apiUrl);
223
+ if (handle) {
224
+ activeServerHandle = handle;
225
+ logInfo("web-server: takeover successful", { port });
226
+ clearTakeoverTimer();
227
+ }
228
+ }, TAKEOVER_INTERVAL_MS);
229
+ takeoverTimer.unref();
230
+ }
191
231
 
232
+ function createHandle(port: number): WebServerHandle {
192
233
  return {
193
- address() {
194
- return { port, family: "IPv4", address: "127.0.0.1" };
234
+ address: () => ({ port, family: "IPv4", address: "127.0.0.1" }),
235
+ isOwner: () => activeServerHandle?.isOwner() ?? false,
236
+ stop: () => {
237
+ clearTakeoverTimer();
238
+ const h = activeServerHandle;
239
+ activeServerHandle = null;
240
+ return h ? h.stop() : Promise.resolve();
195
241
  },
196
242
  };
197
243
  }
198
244
 
199
- export function stopWebServer(_handle: WebServerHandle): Promise<void> {
200
- // Intentionally do NOT touch heartbeat: parent exits โ†’ timer stops โ†’
201
- // heartbeat ages out โ†’ child detects mtime > 60s โ†’ self-terminate.
202
- return Promise.resolve();
245
+ export function stopWebServer(handle: WebServerHandle): Promise<void> {
246
+ return handle.stop();
203
247
  }
@@ -1,267 +0,0 @@
1
- /**
2
- * web-server-child.ts โ€” Cerebro Web Server Child Process
3
- *
4
- * ็‹ฌ็ซ‹ fork ๅ…ฅๅฃใ€‚ๅฎž้™… HTTP server ่ฟ่กŒๅœจๆญค่ฟ›็จ‹ไธญใ€‚
5
- * ้€š่ฟ‡ IPC ไปŽ็ˆถ่ฟ›็จ‹ๆŽฅๆ”ถ้…็ฝฎ๏ผŒๅฏๅŠจๅŽ้€š็Ÿฅ็ˆถ่ฟ›็จ‹ readyใ€‚
6
- * ้€š่ฟ‡ๅฟƒ่ทณๆ–‡ไปถ mtime ๆŽขๆต‹ plugin ่ฟ›็จ‹ๅญ˜ๆดป็Šถๆ€ใ€‚
7
- */
8
- import * as http from "node:http";
9
- import * as fs from "node:fs";
10
- import * as path from "node:path";
11
- import * as os from "node:os";
12
-
13
- // โ”€โ”€ Lightweight file logger for child process โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
14
- // Cannot import logger.ts (no opencodeClient + circular dependency risk).
15
- // Writes to cerebro-web-child.log in the configured log directory.
16
-
17
- const CHILD_LOG_FILE = path.join(
18
- process.env.OMEM_LOG_DIR || path.join(os.homedir(), ".config", "cerebro", "logs"),
19
- "cerebro-web-child.log",
20
- );
21
-
22
- function childLog(level: string, message: string, fields?: Record<string, unknown>): void {
23
- try {
24
- const dir = path.dirname(CHILD_LOG_FILE);
25
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
26
- const now = new Date();
27
- const ts = now.toISOString().replace("T", " ").replace(/\.\d+Z$/, "");
28
- const parts = [`${level.padEnd(5)} ${ts} service=cerebro-web-child`];
29
- if (fields) {
30
- for (const [k, v] of Object.entries(fields)) {
31
- parts.push(`${k}=${typeof v === "string" ? v : JSON.stringify(v)}`);
32
- }
33
- }
34
- parts.push(message);
35
- fs.appendFileSync(CHILD_LOG_FILE, parts.join(" ") + "\n");
36
- } catch { /* best effort */ }
37
- }
38
-
39
- // โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
40
-
41
- interface ChildConfig {
42
- port: number;
43
- webDir: string;
44
- apiUrl: string;
45
- }
46
-
47
- // โ”€โ”€ MIME map โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
48
-
49
- const MIME_TYPES: Record<string, string> = {
50
- ".html": "text/html; charset=utf-8",
51
- ".js": "application/javascript; charset=utf-8",
52
- ".mjs": "application/javascript; charset=utf-8",
53
- ".css": "text/css; charset=utf-8",
54
- ".json": "application/json; charset=utf-8",
55
- ".svg": "image/svg+xml",
56
- ".png": "image/png",
57
- ".jpg": "image/jpeg",
58
- ".jpeg": "image/jpeg",
59
- ".gif": "image/gif",
60
- ".ico": "image/x-icon",
61
- ".woff": "font/woff",
62
- ".woff2": "font/woff2",
63
- ".ttf": "font/ttf",
64
- ".eot": "application/vnd.ms-fontobject",
65
- ".webp": "image/webp",
66
- ".webmanifest": "application/manifest+json",
67
- ".map": "application/json",
68
- ".txt": "text/plain; charset=utf-8",
69
- };
70
-
71
- function getMimeType(ext: string): string {
72
- return MIME_TYPES[ext] || "application/octet-stream";
73
- }
74
-
75
- // โ”€โ”€ Safe path resolver (prevent traversal) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
76
-
77
- function resolveSafe(baseDir: string, pathname: string): string | null {
78
- const relative = pathname.startsWith("/") ? pathname.slice(1) : pathname;
79
- const resolved = path.resolve(baseDir, relative || ".");
80
- if (!resolved.startsWith(baseDir + path.sep) && resolved !== baseDir) {
81
- return null;
82
- }
83
- return resolved;
84
- }
85
-
86
- // โ”€โ”€ File serving โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
87
-
88
- function serveFile(
89
- res: http.ServerResponse,
90
- filePath: string,
91
- apiUrl: string,
92
- ): void {
93
- const ext = path.extname(filePath).toLowerCase();
94
- const contentType = getMimeType(ext);
95
-
96
- fs.readFile(filePath, (err, data) => {
97
- if (err) {
98
- res.writeHead(500, { "Content-Type": "text/plain" });
99
- res.end("Internal Server Error");
100
- return;
101
- }
102
-
103
- let body: Buffer | string = data;
104
-
105
- // Config injection: replace placeholder in index.html
106
- if (ext === ".html" && data.includes("__OMEM_API_URL__")) {
107
- body = data
108
- .toString("utf-8")
109
- .replace(
110
- /window\.__OMEM_API_URL__\s*=\s*["']__OMEM_API_URL__["']/,
111
- `window.__OMEM_API_URL__ = "${apiUrl}"`,
112
- );
113
- }
114
-
115
- res.writeHead(200, {
116
- "Content-Type": contentType,
117
- "Cache-Control":
118
- ext === ".html"
119
- ? "no-cache, no-store, must-revalidate"
120
- : "public, max-age=86400",
121
- });
122
- res.end(body);
123
- });
124
- }
125
-
126
- // โ”€โ”€ Server lifecycle โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
127
-
128
- let server: http.Server | null = null;
129
- let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
130
- let pidFilePath = "";
131
- let heartbeatFilePath = "";
132
-
133
- function cleanup(): void {
134
- if (heartbeatTimer) {
135
- clearInterval(heartbeatTimer);
136
- heartbeatTimer = null;
137
- }
138
- if (server) {
139
- server.closeAllConnections?.();
140
- const forceTimer = setTimeout(() => {
141
- try { fs.unlinkSync(pidFilePath); } catch { /* ignore */ }
142
- process.exit(0);
143
- }, 3000);
144
- server.close(() => {
145
- clearTimeout(forceTimer);
146
- try { fs.unlinkSync(pidFilePath); } catch { /* ignore */ }
147
- process.exit(0);
148
- });
149
- } else {
150
- try { fs.unlinkSync(pidFilePath); } catch { /* ignore */ }
151
- process.exit(0);
152
- }
153
- }
154
-
155
- function startServer(config: ChildConfig): void {
156
- const { port, webDir, apiUrl } = config;
157
-
158
- pidFilePath = path.join(os.tmpdir(), `cerebro-web-${port}.pid`);
159
- heartbeatFilePath = path.join(os.tmpdir(), `cerebro-web-${port}.heartbeat`);
160
-
161
- const indexPath = path.join(webDir, "index.html");
162
-
163
- // ๅ†™ PID ๆ–‡ไปถ๏ผˆๅญ่ฟ›็จ‹่‡ชๅทฑ็š„ PID๏ผ‰
164
- try {
165
- fs.writeFileSync(pidFilePath, String(process.pid));
166
- } catch {
167
- childLog("ERROR", "Failed to write PID file");
168
- }
169
-
170
- // ๅˆๅง‹ touch ๅฟƒ่ทณๆ–‡ไปถ
171
- try {
172
- fs.writeFileSync(heartbeatFilePath, "");
173
- } catch {
174
- childLog("ERROR", "Failed to create heartbeat file");
175
- }
176
-
177
- server = http.createServer(
178
- (req: http.IncomingMessage, res: http.ServerResponse) => {
179
- // โ”€โ”€ /health ็ซฏ็‚น โ”€โ”€
180
- if (req.url === "/health" || req.url === "/health/") {
181
- res.writeHead(200, { "Content-Type": "application/json" });
182
- res.end(JSON.stringify({ status: "ok", service: "cerebro", port }));
183
- return;
184
- }
185
-
186
- // Only handle GET / HEAD
187
- if (req.method !== "GET" && req.method !== "HEAD") {
188
- res.writeHead(405, { "Content-Type": "text/plain" });
189
- res.end("Method Not Allowed");
190
- return;
191
- }
192
-
193
- // Parse URL, strip query string
194
- const url = new URL(req.url || "/", `http://localhost:${port}`);
195
- // new URL() already decodes percent-encoding; no double-decode
196
- const pathname = url.pathname;
197
-
198
- // Resolve safe file path
199
- const safePath = resolveSafe(webDir, pathname);
200
-
201
- if (!safePath) {
202
- res.writeHead(403, { "Content-Type": "text/plain" });
203
- res.end("Forbidden");
204
- return;
205
- }
206
-
207
- // Try to serve the file directly
208
- fs.stat(safePath, (statErr, stats) => {
209
- if (!statErr && stats.isFile()) {
210
- serveFile(res, safePath, apiUrl);
211
- return;
212
- }
213
-
214
- // SPA fallback: serve index.html for non-file paths
215
- fs.stat(indexPath, (idxErr, idxStats) => {
216
- if (idxErr || !idxStats.isFile()) {
217
- res.writeHead(404, { "Content-Type": "text/plain" });
218
- res.end("Not Found");
219
- return;
220
- }
221
- serveFile(res, indexPath, apiUrl);
222
- });
223
- });
224
- },
225
- );
226
-
227
- server.on("error", (err: NodeJS.ErrnoException) => {
228
- childLog("ERROR", "Server error", { error: err.message });
229
- process.send?.({ type: "error", message: err.message });
230
- cleanup();
231
- });
232
-
233
- server.listen(port, "127.0.0.1", () => {
234
- childLog("INFO", "Server listening", { port });
235
- process.send?.({ type: "ready", port });
236
- });
237
-
238
- // โ”€โ”€ ๅฟƒ่ทณๆฃ€ๆต‹๏ผšๆฏ 30 ็ง’ๆฃ€ๆŸฅๅฟƒ่ทณๆ–‡ไปถ mtime โ”€โ”€
239
- heartbeatTimer = setInterval(() => {
240
- try {
241
- const stat = fs.statSync(heartbeatFilePath);
242
- if (Date.now() - stat.mtimeMs > 60_000) {
243
- childLog("INFO", "Heartbeat expired, shutting down");
244
- cleanup();
245
- }
246
- } catch {
247
- // ๅฟƒ่ทณๆ–‡ไปถไธๅญ˜ๅœจ๏ผŒๅฏ่ƒฝ่ขซๆธ…็†๏ผŒ็ปง็ปญ็ญ‰ๅพ…ไธไธปๅŠจ้€€ๅ‡บ
248
- }
249
- }, 30_000);
250
-
251
- // ็กฎไฟๅฎšๆ—ถๅ™จไธ้˜ปๆญข่ฟ›็จ‹้€€ๅ‡บ
252
- if (heartbeatTimer) heartbeatTimer.unref();
253
-
254
- // โ”€โ”€ ไฟกๅทๅค„็† โ”€โ”€
255
- process.on("SIGTERM", cleanup);
256
- process.on("SIGINT", cleanup);
257
- }
258
-
259
- // โ”€โ”€ IPC ็›‘ๅฌ๏ผˆๅชๅค„็†็ฌฌไธ€ๆกๆถˆๆฏ๏ผ‰ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
260
-
261
- let started = false;
262
-
263
- process.on("message", (config: ChildConfig) => {
264
- if (started) return;
265
- started = true;
266
- startServer(config);
267
- });