remnote-bridge 0.1.11 → 0.1.12

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 (64) hide show
  1. package/dist/cli/addon/addon-manager.js +163 -0
  2. package/dist/cli/addon/registry.js +24 -0
  3. package/dist/cli/commands/addon.js +149 -0
  4. package/dist/cli/commands/clean.js +121 -52
  5. package/dist/cli/commands/connect.js +72 -33
  6. package/dist/cli/commands/disconnect.js +19 -19
  7. package/dist/cli/commands/edit-rem.js +3 -31
  8. package/dist/cli/commands/edit-tree.js +3 -20
  9. package/dist/cli/commands/health.js +19 -18
  10. package/dist/cli/commands/read-context.js +3 -20
  11. package/dist/cli/commands/read-globe.js +3 -20
  12. package/dist/cli/commands/read-rem.js +3 -31
  13. package/dist/cli/commands/read-tree.js +3 -20
  14. package/dist/cli/commands/search.js +97 -21
  15. package/dist/cli/config.js +148 -72
  16. package/dist/cli/daemon/daemon.js +104 -24
  17. package/dist/cli/daemon/dev-server.js +9 -1
  18. package/dist/cli/daemon/pid.js +36 -22
  19. package/dist/cli/daemon/registry.js +160 -0
  20. package/dist/cli/daemon/send-request.js +11 -11
  21. package/dist/cli/daemon/static-server.js +97 -34
  22. package/dist/cli/handlers/read-handler.js +4 -3
  23. package/dist/cli/handlers/tree-parser.js +16 -9
  24. package/dist/cli/main.js +49 -9
  25. package/dist/cli/protocol.js +18 -4
  26. package/dist/cli/server/config-server.js +280 -14
  27. package/dist/cli/server/ws-server.js +93 -44
  28. package/dist/cli/utils/output.js +29 -0
  29. package/dist/mcp/instructions.js +101 -9
  30. package/dist/mcp/resources/edit-rem-guide.js +3 -4
  31. package/dist/mcp/resources/error-reference.js +2 -2
  32. package/dist/mcp/resources/rem-object-fields.js +3 -3
  33. package/dist/mcp/tools/infra-tools.js +54 -6
  34. package/dist/mcp/tools/read-tools.js +9 -2
  35. package/package.json +2 -2
  36. package/remnote-plugin/dist/bridge_widget-sandbox.js +17 -17
  37. package/remnote-plugin/dist/bridge_widget.js +17 -17
  38. package/remnote-plugin/dist/index-sandbox.js +31 -31
  39. package/remnote-plugin/dist/index.js +31 -31
  40. package/remnote-plugin/dist/manifest.json +1 -1
  41. package/remnote-plugin/package.json +1 -1
  42. package/remnote-plugin/public/manifest.json +1 -1
  43. package/remnote-plugin/src/bridge/multi-connection-manager.ts +151 -0
  44. package/remnote-plugin/src/bridge/websocket-client.ts +62 -16
  45. package/remnote-plugin/src/services/index.ts +0 -8
  46. package/remnote-plugin/src/services/read-rem.ts +1 -9
  47. package/remnote-plugin/src/services/search.ts +13 -10
  48. package/remnote-plugin/src/settings.ts +9 -7
  49. package/remnote-plugin/src/utils/index.ts +0 -5
  50. package/remnote-plugin/src/widgets/bridge_widget.tsx +105 -20
  51. package/remnote-plugin/src/widgets/index.tsx +41 -44
  52. package/remnote-plugin/webpack.config.js +35 -0
  53. package/skills/remnote-bridge/SKILL.md +14 -9
  54. package/skills/remnote-bridge/instructions/addon.md +134 -0
  55. package/skills/remnote-bridge/instructions/clean.md +110 -0
  56. package/skills/remnote-bridge/instructions/connect.md +80 -37
  57. package/skills/remnote-bridge/instructions/disconnect.md +22 -9
  58. package/skills/remnote-bridge/instructions/edit-rem.md +37 -9
  59. package/skills/remnote-bridge/instructions/health.md +23 -13
  60. package/skills/remnote-bridge/instructions/install-skill.md +58 -0
  61. package/skills/remnote-bridge/instructions/overall.md +76 -21
  62. package/skills/remnote-bridge/instructions/read-rem.md +10 -10
  63. package/skills/remnote-bridge/instructions/search.md +73 -14
  64. package/skills/remnote-bridge/instructions/setup.md +1 -1
@@ -4,9 +4,75 @@
4
4
  * 在知识库中按文本搜索 Rem。
5
5
  * - --limit N 结果数量上限(默认 20)
6
6
  * - --json 结构化 JSON 输出
7
+ *
8
+ * 配置驱动:检查 addons.remnote-rag.enabled 决定是否使用 RAG 语义搜索。
9
+ * 已启用且已安装时使用 RAG,否则降级到 SDK 搜索。
10
+ */
11
+ import { execFile } from 'node:child_process';
12
+ import { sendDaemonRequest } from '../daemon/send-request.js';
13
+ import { loadConfig } from '../config.js';
14
+ import { jsonOutput, handleCommandError } from '../utils/output.js';
15
+ const RAG_TIMEOUT_MS = 10_000;
16
+ /**
17
+ * 尝试通过 remnote-rag 子进程进行语义搜索(配置驱动)。
18
+ *
19
+ * 1. 检查 addons.remnote-rag.enabled — 未启用则跳过
20
+ * 2. remnote-rag 从 ~/.remnote-bridge/addons/remnote-rag/config.json 读取配置
21
+ * 3. 未安装(ENOENT)、超时、JSON 解析失败、ok:false 均返回 null(静默降级)
7
22
  */
8
- import { sendDaemonRequest, DaemonNotRunningError, DaemonUnreachableError } from '../daemon/send-request.js';
9
- import { jsonOutput } from '../utils/output.js';
23
+ async function tryRagSearch(query, numResults) {
24
+ const config = loadConfig();
25
+ const ragConfig = config.addons?.['remnote-rag'];
26
+ if (!ragConfig?.enabled) {
27
+ return null;
28
+ }
29
+ const jsonPayload = JSON.stringify({ query, numResults });
30
+ const args = ['search', '--json', jsonPayload];
31
+ try {
32
+ const stdout = await new Promise((resolve, reject) => {
33
+ execFile('remnote-rag', args, {
34
+ timeout: RAG_TIMEOUT_MS,
35
+ maxBuffer: 5 * 1024 * 1024,
36
+ }, (error, stdout) => {
37
+ if (error) {
38
+ // execFile 的 error 对象包含 stdout,尝试从中提取 RAG 的诊断信息
39
+ const output = (stdout ?? '').trim();
40
+ if (output) {
41
+ try {
42
+ const errData = JSON.parse(output);
43
+ if (errData?.error) {
44
+ reject(new Error(`remnote-rag: ${errData.error}`));
45
+ return;
46
+ }
47
+ }
48
+ catch {
49
+ // stdout 不是有效 JSON,忽略
50
+ }
51
+ }
52
+ reject(error);
53
+ return;
54
+ }
55
+ resolve(stdout.trim());
56
+ });
57
+ });
58
+ const parsed = JSON.parse(stdout);
59
+ if (typeof parsed !== 'object' || parsed === null || !('ok' in parsed) || !('results' in parsed)) {
60
+ return null;
61
+ }
62
+ const result = parsed;
63
+ if (!result.ok || !Array.isArray(result.results)) {
64
+ return null;
65
+ }
66
+ return result;
67
+ }
68
+ catch (err) {
69
+ // 静默降级,但保留诊断信息到 stderr 供调试
70
+ if (err instanceof Error && err.message.startsWith('remnote-rag:')) {
71
+ process.stderr.write(`[search] RAG 降级: ${err.message}\n`);
72
+ }
73
+ return null;
74
+ }
75
+ }
10
76
  export async function searchCommand(query, options = {}) {
11
77
  const { json } = options;
12
78
  const numResults = options.limit !== undefined ? parseInt(options.limit, 10) : undefined;
@@ -21,34 +87,44 @@ export async function searchCommand(query, options = {}) {
21
87
  process.exitCode = 1;
22
88
  return;
23
89
  }
90
+ // 尝试 RAG 语义搜索
91
+ const ragResult = await tryRagSearch(query, numResults ?? 20);
92
+ if (ragResult !== null) {
93
+ if (json) {
94
+ jsonOutput({ ok: true, command: 'search', data: { query, ...ragResult, source: 'rag' } });
95
+ }
96
+ else {
97
+ if (ragResult.results.length === 0) {
98
+ console.log(`未找到与 "${query}" 相关的结果 (RAG)`);
99
+ }
100
+ else {
101
+ console.log(`搜索 "${query}",找到 ${ragResult.totalFound} 条结果 (RAG 语义搜索):\n`);
102
+ for (const item of ragResult.results) {
103
+ const docTag = item.isDocument ? ' [Doc]' : '';
104
+ const typeTag = item.type !== 'default' ? ` [${item.type}]` : '';
105
+ const path = item.ancestorPath.length > 0 ? ` (${item.ancestorPath.join(' > ')})` : '';
106
+ const score = ` [${(item.score * 100).toFixed(0)}%]`;
107
+ console.log(` [${item.remId}]${docTag}${typeTag}${score} ${item.text}${path}`);
108
+ if (item.backText) {
109
+ console.log(` → ${item.backText}`);
110
+ }
111
+ }
112
+ }
113
+ }
114
+ return;
115
+ }
116
+ // 降级到 SDK 搜索
24
117
  let result;
25
118
  try {
26
119
  result = await sendDaemonRequest('search', { query, numResults });
27
120
  }
28
121
  catch (err) {
29
- if (err instanceof DaemonNotRunningError || err instanceof DaemonUnreachableError) {
30
- if (json) {
31
- jsonOutput({ ok: false, command: 'search', error: err.message });
32
- }
33
- else {
34
- console.error(`错误: ${err.message}`);
35
- }
36
- process.exitCode = 2;
37
- return;
38
- }
39
- const errorMsg = err instanceof Error ? err.message : String(err);
40
- if (json) {
41
- jsonOutput({ ok: false, command: 'search', error: errorMsg });
42
- }
43
- else {
44
- console.error(`错误: ${errorMsg}`);
45
- }
46
- process.exitCode = 1;
122
+ handleCommandError(err, 'search', json);
47
123
  return;
48
124
  }
49
125
  const data = result;
50
126
  if (json) {
51
- jsonOutput({ ok: true, command: 'search', data });
127
+ jsonOutput({ ok: true, command: 'search', data: { ...data, source: 'sdk' } });
52
128
  }
53
129
  else {
54
130
  if (data.results.length === 0) {
@@ -1,11 +1,22 @@
1
1
  /**
2
2
  * 配置加载
3
3
  *
4
- * 从项目根目录读取 .remnote-bridge.json,合并默认值。
4
+ * ~/.remnote-bridge/config.json 读取全局配置,合并默认值。
5
5
  * 文件不存在时使用全部默认值,不报错。
6
+ *
7
+ * 端口字段由 slots.json 管理,不再出现在配置文件中。
8
+ * BridgeConfig 仍保留端口字段(填充默认值),供旧代码兼容。
6
9
  */
7
10
  import fs from 'fs';
8
11
  import path from 'path';
12
+ import os from 'os';
13
+ // ── 全局目录 ──
14
+ /** ~/.remnote-bridge/ — 所有运行时文件的根目录 */
15
+ export const GLOBAL_DIR = path.join(os.homedir(), '.remnote-bridge');
16
+ /** 确保全局目录和 instances/ 子目录存在 */
17
+ export function ensureGlobalDir() {
18
+ fs.mkdirSync(path.join(GLOBAL_DIR, 'instances'), { recursive: true });
19
+ }
9
20
  export const DEFAULT_DEFAULTS = {
10
21
  maxNodes: 200,
11
22
  maxSiblings: 20,
@@ -20,37 +31,14 @@ export const DEFAULT_DEFAULTS = {
20
31
  searchNumResults: 20,
21
32
  };
22
33
  export const DEFAULT_CONFIG = {
23
- wsPort: 3002,
24
- devServerPort: 8080,
25
- configPort: 3003,
34
+ wsPort: 29100,
35
+ devServerPort: 29101,
36
+ configPort: 29102,
26
37
  daemonTimeoutMinutes: 30,
27
38
  defaults: { ...DEFAULT_DEFAULTS },
28
39
  };
29
- const CONFIG_FILENAME = '.remnote-bridge.json';
30
- function isValidPort(value) {
31
- return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 65535;
32
- }
33
- /**
34
- * 查找项目根目录(monorepo 根:包含 .git 目录的最近祖先)
35
- *
36
- * 从 startDir 向上查找 .git 目录,找到即返回。
37
- * 到达文件系统根仍未找到时回退到 cwd。
38
- */
39
- export function findProjectRoot(startDir = process.cwd()) {
40
- let dir = path.resolve(startDir);
41
- while (true) {
42
- // 优先匹配 .git 目录(monorepo 根标识)
43
- if (fs.existsSync(path.join(dir, '.git'))) {
44
- return dir;
45
- }
46
- const parent = path.dirname(dir);
47
- if (parent === dir) {
48
- // 到达文件系统根,回退到 cwd
49
- return process.cwd();
50
- }
51
- dir = parent;
52
- }
53
- }
40
+ const CONFIG_FILENAME = 'config.json';
41
+ const LEGACY_CONFIG_FILENAME = '.remnote-bridge.json';
54
42
  function isPositiveNumber(value) {
55
43
  return typeof value === 'number' && value > 0;
56
44
  }
@@ -75,65 +63,153 @@ function mergeDefaults(parsed) {
75
63
  searchNumResults: isPositiveNumber(parsed.searchNumResults) ? parsed.searchNumResults : DEFAULT_DEFAULTS.searchNumResults,
76
64
  };
77
65
  }
66
+ function mergeAddons(parsed) {
67
+ if (!parsed || typeof parsed !== 'object')
68
+ return undefined;
69
+ const result = {};
70
+ for (const [name, raw] of Object.entries(parsed)) {
71
+ if (typeof raw !== 'object' || raw === null)
72
+ continue;
73
+ const obj = raw;
74
+ result[name] = {
75
+ enabled: typeof obj.enabled === 'boolean' ? obj.enabled : false,
76
+ };
77
+ }
78
+ return Object.keys(result).length > 0 ? result : undefined;
79
+ }
78
80
  /**
79
- * 加载配置。不存在时返回默认值。
81
+ * 解析配置 JSON(不含端口字段)。端口由 slots.json 管理,此处填充默认值。
80
82
  */
81
- export function loadConfig(projectRoot) {
82
- const root = projectRoot ?? findProjectRoot();
83
- const configPath = path.join(root, CONFIG_FILENAME);
84
- if (!fs.existsSync(configPath)) {
85
- return { ...DEFAULT_CONFIG, defaults: { ...DEFAULT_DEFAULTS } };
86
- }
87
- try {
88
- const raw = fs.readFileSync(configPath, 'utf-8');
89
- const parsed = JSON.parse(raw);
90
- const config = {
91
- wsPort: isValidPort(parsed.wsPort) ? parsed.wsPort : DEFAULT_CONFIG.wsPort,
92
- devServerPort: isValidPort(parsed.devServerPort) ? parsed.devServerPort : DEFAULT_CONFIG.devServerPort,
93
- configPort: isValidPort(parsed.configPort) ? parsed.configPort : DEFAULT_CONFIG.configPort,
94
- daemonTimeoutMinutes: typeof parsed.daemonTimeoutMinutes === 'number' && parsed.daemonTimeoutMinutes > 0
95
- ? parsed.daemonTimeoutMinutes
96
- : DEFAULT_CONFIG.daemonTimeoutMinutes,
97
- defaults: mergeDefaults(parsed.defaults),
98
- };
99
- // 端口冲突校验
100
- const ports = [config.wsPort, config.devServerPort, config.configPort];
101
- if (new Set(ports).size !== ports.length) {
102
- throw new Error('wsPort, devServerPort, configPort must be different');
83
+ function parseConfig(raw) {
84
+ const parsed = JSON.parse(raw);
85
+ return {
86
+ // 端口填充默认值(实际使用 slots 分配的端口)
87
+ wsPort: DEFAULT_CONFIG.wsPort,
88
+ devServerPort: DEFAULT_CONFIG.devServerPort,
89
+ configPort: DEFAULT_CONFIG.configPort,
90
+ daemonTimeoutMinutes: typeof parsed.daemonTimeoutMinutes === 'number' && parsed.daemonTimeoutMinutes > 0
91
+ ? parsed.daemonTimeoutMinutes
92
+ : DEFAULT_CONFIG.daemonTimeoutMinutes,
93
+ defaults: mergeDefaults(parsed.defaults),
94
+ addons: mergeAddons(parsed.addons),
95
+ };
96
+ }
97
+ /**
98
+ * 全局配置文件路径 (~/.remnote-bridge/config.json)
99
+ * @param configDir 可选,覆盖全局目录(测试用)
100
+ */
101
+ export function configFilePath(configDir) {
102
+ return path.join(configDir ?? GLOBAL_DIR, CONFIG_FILENAME);
103
+ }
104
+ /**
105
+ * 加载配置。从 ~/.remnote-bridge/config.json 读取。
106
+ * 不存在时返回默认值。
107
+ * @param configDir 可选,覆盖全局目录(测试用)
108
+ */
109
+ export function loadConfig(configDir) {
110
+ const globalPath = configFilePath(configDir);
111
+ // 全局配置存在 → 直接读取
112
+ if (fs.existsSync(globalPath)) {
113
+ try {
114
+ return parseConfig(fs.readFileSync(globalPath, 'utf-8'));
115
+ }
116
+ catch {
117
+ return { ...DEFAULT_CONFIG, defaults: { ...DEFAULT_DEFAULTS } };
103
118
  }
104
- return config;
105
119
  }
106
- catch {
107
- // 配置文件损坏时使用默认值
120
+ // 尝试从旧项目根迁移(仅在使用默认全局目录时)
121
+ if (configDir)
108
122
  return { ...DEFAULT_CONFIG, defaults: { ...DEFAULT_DEFAULTS } };
123
+ const legacyPath = findLegacyConfigPath();
124
+ if (legacyPath) {
125
+ try {
126
+ const config = parseConfig(fs.readFileSync(legacyPath, 'utf-8'));
127
+ // 迁移到全局(去掉端口字段)
128
+ ensureGlobalDir();
129
+ saveConfig(globalPath, config);
130
+ return config;
131
+ }
132
+ catch {
133
+ // 迁移失败,使用默认值
134
+ }
109
135
  }
136
+ return { ...DEFAULT_CONFIG, defaults: { ...DEFAULT_DEFAULTS } };
110
137
  }
111
138
  /**
112
- * 获取配置文件路径
113
- */
114
- export function configFilePath(projectRoot) {
115
- const root = projectRoot ?? findProjectRoot();
116
- return path.join(root, CONFIG_FILENAME);
117
- }
118
- /**
119
- * 原子写入配置文件(写临时文件 → rename)
139
+ * 原子写入配置文件(写临时文件 → rename)。
140
+ * 只写非端口字段。
120
141
  */
121
142
  export function saveConfig(filePath, config) {
143
+ ensureGlobalDir();
144
+ // 持久化时去掉端口字段(端口由 slots.json 管理)
145
+ const persisted = {
146
+ daemonTimeoutMinutes: config.daemonTimeoutMinutes,
147
+ defaults: config.defaults,
148
+ ...(config.addons ? { addons: config.addons } : {}),
149
+ };
122
150
  const tmpPath = filePath + '.tmp.' + process.pid;
123
- fs.writeFileSync(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
151
+ fs.writeFileSync(tmpPath, JSON.stringify(persisted, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
124
152
  fs.renameSync(tmpPath, filePath);
125
153
  }
154
+ // ── 旧配置迁移辅助 ──
126
155
  /**
127
- * PID 文件路径
156
+ * 查找项目根目录(monorepo 根:包含 .git 目录的最近祖先)。
157
+ * 仅用于旧配置迁移,不再作为实例标识。
128
158
  */
129
- export function pidFilePath(projectRoot) {
130
- const root = projectRoot ?? findProjectRoot();
131
- return path.join(root, '.remnote-bridge.pid');
159
+ export function findProjectRoot(startDir = process.cwd()) {
160
+ let dir = path.resolve(startDir);
161
+ while (true) {
162
+ if (fs.existsSync(path.join(dir, '.git'))) {
163
+ return dir;
164
+ }
165
+ const parent = path.dirname(dir);
166
+ if (parent === dir) {
167
+ return process.cwd();
168
+ }
169
+ dir = parent;
170
+ }
132
171
  }
133
172
  /**
134
- * 日志文件路径
173
+ * 查找旧的项目根配置文件路径(.remnote-bridge.json)。
174
+ * 存在时返回路径,不存在返回 null。
135
175
  */
136
- export function logFilePath(projectRoot) {
137
- const root = projectRoot ?? findProjectRoot();
138
- return path.join(root, '.remnote-bridge.log');
176
+ function findLegacyConfigPath() {
177
+ try {
178
+ const root = findProjectRoot();
179
+ const legacyPath = path.join(root, LEGACY_CONFIG_FILENAME);
180
+ return fs.existsSync(legacyPath) ? legacyPath : null;
181
+ }
182
+ catch {
183
+ return null;
184
+ }
185
+ }
186
+ // ── Addon 独立配置 ──
187
+ /** addon 数据根目录 ~/.remnote-bridge/addons/<name>/ */
188
+ export function addonDataDir(name) {
189
+ return path.join(GLOBAL_DIR, 'addons', name);
190
+ }
191
+ /** addon 配置文件路径 ~/.remnote-bridge/addons/<name>/config.json */
192
+ export function addonConfigPath(name) {
193
+ return path.join(addonDataDir(name), 'config.json');
194
+ }
195
+ /** 读取 addon 配置 JSON(文件不存在返回 null) */
196
+ export function loadAddonConfig(name) {
197
+ const p = addonConfigPath(name);
198
+ if (!fs.existsSync(p))
199
+ return null;
200
+ try {
201
+ return JSON.parse(fs.readFileSync(p, 'utf-8'));
202
+ }
203
+ catch {
204
+ return null;
205
+ }
206
+ }
207
+ /** 保存 addon 配置 JSON(原子写入) */
208
+ export function saveAddonConfig(name, data) {
209
+ const dir = addonDataDir(name);
210
+ fs.mkdirSync(dir, { recursive: true });
211
+ const filePath = path.join(dir, 'config.json');
212
+ const tmpPath = filePath + '.tmp.' + process.pid;
213
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
214
+ fs.renameSync(tmpPath, filePath);
139
215
  }
@@ -7,6 +7,10 @@
7
7
  * 3. 写入 PID 文件
8
8
  * 4. 管理自动超时关闭
9
9
  * 5. 通过 IPC 向父进程发送 ready 信号
10
+ *
11
+ * 端口来源:env SLOT_WS_PORT / SLOT_DEV_PORT / SLOT_CONFIG_PORT
12
+ * 实例标识:env REMNOTE_BRIDGE_INSTANCE
13
+ * 日志/PID:~/.remnote-bridge/instances/N.*
10
14
  */
11
15
  import path from 'path';
12
16
  import fs from 'fs';
@@ -16,16 +20,27 @@ import { DevServerManager } from './dev-server.js';
16
20
  import { StaticServer } from './static-server.js';
17
21
  import { HeadlessBrowserManager } from './headless-browser.js';
18
22
  import { writePid, removePid } from './pid.js';
19
- import { loadConfig, pidFilePath, logFilePath, findProjectRoot } from '../config.js';
23
+ import { instancePidPath, instanceLogPath } from './registry.js';
24
+ import { loadConfig, ensureGlobalDir } from '../config.js';
20
25
  let shutdownInProgress = false;
21
26
  async function main() {
22
- const projectRoot = findProjectRoot();
23
- let config = loadConfig(projectRoot);
24
- const pidPath = pidFilePath(projectRoot);
25
- const logPath = logFilePath(projectRoot);
27
+ // 从环境变量获取槽位信息
28
+ const slotIndex = parseInt(process.env.SLOT_INDEX ?? '', 10);
29
+ const wsPort = parseInt(process.env.SLOT_WS_PORT ?? '', 10);
30
+ const devServerPort = parseInt(process.env.SLOT_DEV_PORT ?? '', 10);
31
+ const configPort = parseInt(process.env.SLOT_CONFIG_PORT ?? '', 10);
32
+ const instanceId = process.env.REMNOTE_BRIDGE_INSTANCE ?? 'default';
33
+ if (isNaN(slotIndex) || isNaN(wsPort) || isNaN(devServerPort) || isNaN(configPort)) {
34
+ console.error('守护进程缺少必要的环境变量 (SLOT_INDEX, SLOT_WS_PORT, SLOT_DEV_PORT, SLOT_CONFIG_PORT)');
35
+ process.exit(1);
36
+ }
37
+ let config = loadConfig();
38
+ ensureGlobalDir();
39
+ const pidPath = instancePidPath(slotIndex);
40
+ const logPath = instanceLogPath(slotIndex);
26
41
  // 日志写入文件(追加模式,保留前次会话日志)
27
42
  const logStream = fs.createWriteStream(logPath, { flags: 'a' });
28
- logStream.write(`\n${'='.repeat(60)}\n[${new Date().toISOString()}] 守护进程启动 (PID: ${process.pid})\n${'='.repeat(60)}\n`);
43
+ logStream.write(`\n${'='.repeat(60)}\n[${new Date().toISOString()}] 守护进程启动 (PID: ${process.pid}, instance: ${instanceId}, slot: ${slotIndex})\n${'='.repeat(60)}\n`);
29
44
  function log(message, level = 'info') {
30
45
  const timestamp = new Date().toISOString();
31
46
  const line = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`;
@@ -50,8 +65,9 @@ async function main() {
50
65
  // 创建 WS Server(抽取为函数,供软重启复用)
51
66
  function createServer(cfg) {
52
67
  const srv = new BridgeServer({
53
- port: cfg.wsPort,
68
+ port: wsPort,
54
69
  host: '127.0.0.1',
70
+ slotIndex,
55
71
  onLog: log,
56
72
  getTimeoutRemaining,
57
73
  defaults: cfg.defaults,
@@ -95,19 +111,18 @@ async function main() {
95
111
  catch (err) {
96
112
  log(`旧 WS Server 关闭失败: ${err}`, 'error');
97
113
  }
98
- config = loadConfig(projectRoot);
114
+ config = loadConfig();
99
115
  timeoutMs = config.daemonTimeoutMinutes * 60 * 1000;
100
116
  server = createServer(config);
101
117
  await server.start();
102
- log(`新 WS Server 已启动 (端口 ${config.wsPort})`);
118
+ log(`新 WS Server 已启动 (端口 ${wsPort})`);
103
119
  resetTimeout();
104
120
  log('软重启完成');
105
121
  }
106
122
  // 启动 ConfigServer
107
123
  const configServer = new ConfigServer({
108
- port: config.configPort,
124
+ port: configPort,
109
125
  host: '127.0.0.1',
110
- projectRoot,
111
126
  onRestart: reload,
112
127
  onLog: log,
113
128
  });
@@ -134,7 +149,7 @@ async function main() {
134
149
  const pluginServer = devMode
135
150
  ? new DevServerManager({
136
151
  pluginDir,
137
- port: config.devServerPort,
152
+ port: devServerPort,
138
153
  onLog: log,
139
154
  onExit: (code) => {
140
155
  if (!shutdownInProgress && code !== 0) {
@@ -145,7 +160,7 @@ async function main() {
145
160
  })
146
161
  : new StaticServer({
147
162
  distDir,
148
- port: config.devServerPort,
163
+ port: devServerPort,
149
164
  onLog: log,
150
165
  });
151
166
  async function shutdown() {
@@ -197,7 +212,7 @@ async function main() {
197
212
  process.on('SIGINT', shutdown);
198
213
  try {
199
214
  await server.start();
200
- log(`WS Server 已启动 (端口 ${config.wsPort})`);
215
+ log(`WS Server 已启动 (端口 ${wsPort})`);
201
216
  }
202
217
  catch (err) {
203
218
  log(`WS Server 启动失败: ${err}`, 'error');
@@ -206,7 +221,7 @@ async function main() {
206
221
  }
207
222
  try {
208
223
  await configServer.start();
209
- log(`ConfigServer 已启动 (端口 ${config.configPort})`);
224
+ log(`ConfigServer 已启动 (端口 ${configPort})`);
210
225
  }
211
226
  catch (err) {
212
227
  log(`ConfigServer 启动失败: ${err}`, 'warn');
@@ -214,7 +229,7 @@ async function main() {
214
229
  }
215
230
  try {
216
231
  await pluginServer.start();
217
- log(`${pluginServerLabel} 已启动 (端口 ${config.devServerPort})`);
232
+ log(`${pluginServerLabel} 已启动 (端口 ${devServerPort})`);
218
233
  }
219
234
  catch (err) {
220
235
  log(`${pluginServerLabel} 启动失败: ${err}`, 'error');
@@ -222,12 +237,59 @@ async function main() {
222
237
  process.send?.({ type: 'error', message: `${pluginServerLabel} 启动失败: ${err}` });
223
238
  process.exit(1);
224
239
  }
225
- // 写入 PID 文件
226
- writePid(pidPath, process.pid);
240
+ // 取实际绑定的端口(可能与 env 传入的不同,若原端口被占用则 OS 自动分配)
241
+ const actualWsPort = server.actualPort;
242
+ const actualConfigPort = configServer.actualPort;
243
+ // StaticServer 有 actualPort;DevServerManager 通过 spawn 无法获取,用 env 值
244
+ const actualDevPort = 'actualPort' in pluginServer
245
+ ? pluginServer.actualPort
246
+ : devServerPort;
247
+ // Headless 模式:关键端口不允许回退(Plugin 无法发现新端口)
248
+ if (headlessMode) {
249
+ if (actualWsPort !== wsPort) {
250
+ log(`Headless 模式下 WS 端口被占用(${wsPort} → ${actualWsPort}),终止启动`, 'error');
251
+ await server.stop();
252
+ await configServer.stop();
253
+ await pluginServer.stop();
254
+ process.send?.({
255
+ type: 'error',
256
+ message: `Headless 模式不支持端口回退:WS 端口 ${wsPort} 被占用。请释放端口或修改配置后重试`,
257
+ });
258
+ process.exit(1);
259
+ }
260
+ if (actualDevPort !== devServerPort) {
261
+ log(`Headless 模式下 Plugin 服务端口被占用(${devServerPort} → ${actualDevPort}),终止启动`, 'error');
262
+ await server.stop();
263
+ await configServer.stop();
264
+ await pluginServer.stop();
265
+ process.send?.({
266
+ type: 'error',
267
+ message: `Headless 模式不支持端口回退:Plugin 服务端口 ${devServerPort} 被占用。请释放端口或修改配置后重试`,
268
+ });
269
+ process.exit(1);
270
+ }
271
+ }
272
+ // 设置 discovery 数据(供 Plugin 通过 /api/discovery 自动发现端口)
273
+ if (pluginServer instanceof StaticServer) {
274
+ pluginServer.setDiscovery({
275
+ wsPort: actualWsPort,
276
+ configPort: actualConfigPort,
277
+ instance: instanceId,
278
+ slotIndex,
279
+ });
280
+ log(`Discovery 端点已就绪 (wsPort=${actualWsPort}, configPort=${actualConfigPort}, instance=${instanceId}, slotIndex=${slotIndex})`);
281
+ }
282
+ // 写入 PID 文件(JSON 格式,使用实际端口)
283
+ writePid(pidPath, {
284
+ pid: process.pid,
285
+ slotIndex,
286
+ instance: instanceId,
287
+ wsPort: actualWsPort,
288
+ devServerPort: actualDevPort,
289
+ configPort: actualConfigPort,
290
+ });
227
291
  log(`PID 文件已写入: ${pidPath} (PID: ${process.pid})`);
228
292
  // 启动 Headless Chrome(如果启用)
229
- // headless Chrome 加载 RemNote 本身(不是 plugin 静态文件),
230
- // RemNote 会自动加载已配置的 dev plugin(从 localhost:8080)
231
293
  if (headlessMode) {
232
294
  const remNoteUrl = 'https://www.remnote.com';
233
295
  headlessBrowser = new HeadlessBrowserManager({
@@ -247,19 +309,37 @@ async function main() {
247
309
  }
248
310
  // 启动超时计时器
249
311
  resetTimeout();
250
- // 通知父进程就绪
312
+ // 通知父进程就绪(使用实际端口)
251
313
  process.send?.({
252
314
  type: 'ready',
253
- wsPort: config.wsPort,
254
- devServerPort: config.devServerPort,
255
- configPort: config.configPort,
315
+ wsPort: actualWsPort,
316
+ devServerPort: actualDevPort,
317
+ configPort: actualConfigPort,
256
318
  pid: process.pid,
257
319
  headless: headlessMode,
320
+ slotIndex,
321
+ instance: instanceId,
258
322
  });
259
323
  // 断开 IPC 通道(让父进程可以退出)
260
324
  if (process.channel) {
261
325
  process.channel.unref();
262
326
  }
327
+ // 自动安装已启用的 addon(非阻塞,不影响启动速度)
328
+ import('../addon/addon-manager.js').then(({ AddonManager }) => {
329
+ const addonManager = new AddonManager(config);
330
+ return addonManager.ensureEnabledAddons(log);
331
+ }).then((addonResults) => {
332
+ for (const r of addonResults) {
333
+ if (r.action === 'installed') {
334
+ log(`[addon] ${r.name} 已自动安装`);
335
+ }
336
+ else if (r.action === 'failed') {
337
+ log(`[addon] ${r.name} 自动安装失败: ${r.error}`, 'warn');
338
+ }
339
+ }
340
+ }).catch((err) => {
341
+ log(`[addon] 自动安装检查失败: ${err}`, 'warn');
342
+ });
263
343
  }
264
344
  main().catch((err) => {
265
345
  console.error('守护进程启动失败:', err);
@@ -61,7 +61,15 @@ export class DevServerManager {
61
61
  // shell: true 确保 Windows 上能找到 npm.cmd
62
62
  this.child = spawn('npm', ['run', 'dev'], {
63
63
  cwd: pluginDir,
64
- env: { ...process.env, PORT: String(port) },
64
+ env: {
65
+ ...process.env,
66
+ PORT: String(port),
67
+ // 供 webpack devServer.setupMiddlewares 劫持 /api/discovery
68
+ DISCOVERY_WS_PORT: process.env.SLOT_WS_PORT ?? '',
69
+ DISCOVERY_CONFIG_PORT: process.env.SLOT_CONFIG_PORT ?? '',
70
+ DISCOVERY_INSTANCE: process.env.REMNOTE_BRIDGE_INSTANCE ?? '',
71
+ DISCOVERY_SLOT_INDEX: process.env.SLOT_INDEX ?? '',
72
+ },
65
73
  stdio: 'pipe',
66
74
  shell: true,
67
75
  });