remnote-bridge 0.1.11 → 0.1.13

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 (70) 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 +8 -36
  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 +6 -32
  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/edit-handler.js +49 -140
  23. package/dist/cli/handlers/read-handler.js +9 -9
  24. package/dist/cli/handlers/rem-cache.js +10 -5
  25. package/dist/cli/handlers/tree-parser.js +16 -9
  26. package/dist/cli/main.js +67 -19
  27. package/dist/cli/protocol.js +18 -4
  28. package/dist/cli/server/config-server.js +280 -14
  29. package/dist/cli/server/ws-server.js +93 -44
  30. package/dist/cli/utils/output.js +29 -0
  31. package/dist/mcp/format.js +43 -0
  32. package/dist/mcp/index.js +0 -55
  33. package/dist/mcp/instructions.js +424 -216
  34. package/dist/mcp/resources/edit-rem-guide.js +37 -158
  35. package/dist/mcp/resources/edit-tree-guide.js +1 -1
  36. package/dist/mcp/resources/error-reference.js +9 -13
  37. package/dist/mcp/resources/rem-object-fields.js +6 -6
  38. package/dist/mcp/tools/edit-tools.js +69 -8
  39. package/dist/mcp/tools/infra-tools.js +44 -8
  40. package/dist/mcp/tools/read-tools.js +136 -20
  41. package/package.json +2 -2
  42. package/remnote-plugin/dist/bridge_widget-sandbox.js +17 -17
  43. package/remnote-plugin/dist/bridge_widget.js +17 -17
  44. package/remnote-plugin/dist/index-sandbox.js +31 -31
  45. package/remnote-plugin/dist/index.js +31 -31
  46. package/remnote-plugin/dist/manifest.json +1 -1
  47. package/remnote-plugin/package.json +1 -1
  48. package/remnote-plugin/public/manifest.json +1 -1
  49. package/remnote-plugin/src/bridge/multi-connection-manager.ts +151 -0
  50. package/remnote-plugin/src/bridge/websocket-client.ts +62 -16
  51. package/remnote-plugin/src/services/index.ts +0 -8
  52. package/remnote-plugin/src/services/read-rem.ts +1 -9
  53. package/remnote-plugin/src/services/search.ts +13 -10
  54. package/remnote-plugin/src/settings.ts +9 -7
  55. package/remnote-plugin/src/utils/index.ts +0 -5
  56. package/remnote-plugin/src/widgets/bridge_widget.tsx +105 -20
  57. package/remnote-plugin/src/widgets/index.tsx +41 -44
  58. package/remnote-plugin/webpack.config.js +35 -0
  59. package/skills/remnote-bridge/SKILL.md +45 -40
  60. package/skills/remnote-bridge/instructions/addon.md +134 -0
  61. package/skills/remnote-bridge/instructions/clean.md +110 -0
  62. package/skills/remnote-bridge/instructions/connect.md +80 -37
  63. package/skills/remnote-bridge/instructions/disconnect.md +22 -9
  64. package/skills/remnote-bridge/instructions/edit-rem.md +113 -327
  65. package/skills/remnote-bridge/instructions/health.md +23 -13
  66. package/skills/remnote-bridge/instructions/install-skill.md +58 -0
  67. package/skills/remnote-bridge/instructions/overall.md +99 -35
  68. package/skills/remnote-bridge/instructions/read-rem.md +15 -15
  69. package/skills/remnote-bridge/instructions/search.md +77 -18
  70. package/skills/remnote-bridge/instructions/setup.md +5 -6
@@ -8,10 +8,13 @@
8
8
  * - POST /api/restart — 调用 onRestart() 回调 → 返回成功
9
9
  */
10
10
  import http from 'http';
11
- import { DEFAULT_CONFIG, DEFAULT_DEFAULTS, loadConfig, saveConfig, configFilePath } from '../config.js';
11
+ import { DEFAULT_CONFIG, DEFAULT_DEFAULTS, loadConfig, saveConfig, configFilePath, loadAddonConfig, saveAddonConfig } from '../config.js';
12
12
  export class ConfigServer {
13
13
  server = null;
14
+ _actualPort = 0;
14
15
  options;
16
+ /** 实际监听的端口(可能与 options.port 不同,若原端口被占用则 OS 自动分配) */
17
+ get actualPort() { return this._actualPort; }
15
18
  constructor(options) {
16
19
  this.options = options;
17
20
  }
@@ -20,15 +23,30 @@ export class ConfigServer {
20
23
  }
21
24
  start() {
22
25
  return new Promise((resolve, reject) => {
23
- this.server = http.createServer((req, res) => this.handleRequest(req, res));
24
- this.server.on('error', (err) => {
25
- this.log(`ConfigServer 错误: ${err.message}`, 'error');
26
- reject(err);
27
- });
28
- this.server.listen(this.options.port, this.options.host ?? '127.0.0.1', () => {
29
- this.log(`ConfigServer 监听 ${this.options.host ?? '127.0.0.1'}:${this.options.port}`);
30
- resolve();
31
- });
26
+ const host = this.options.host ?? '127.0.0.1';
27
+ const tryListen = (port) => {
28
+ this.server = http.createServer((req, res) => this.handleRequest(req, res));
29
+ this.server.on('error', (err) => {
30
+ if (err.code === 'EADDRINUSE' && port !== 0) {
31
+ this.log(`端口 ${port} 被占用,尝试自动分配...`, 'warn');
32
+ this.server = null;
33
+ tryListen(0);
34
+ }
35
+ else {
36
+ this.log(`ConfigServer 错误: ${err.message}`, 'error');
37
+ reject(err);
38
+ }
39
+ });
40
+ this.server.listen(port, host, () => {
41
+ this._actualPort = this.server.address().port;
42
+ if (this._actualPort !== this.options.port) {
43
+ this.log(`端口 ${this.options.port} 被占用,ConfigServer 自动分配到 ${this._actualPort}`, 'warn');
44
+ }
45
+ this.log(`ConfigServer 监听 ${host}:${this._actualPort}`);
46
+ resolve();
47
+ });
48
+ };
49
+ tryListen(this.options.port);
32
50
  });
33
51
  }
34
52
  stop() {
@@ -46,6 +64,10 @@ export class ConfigServer {
46
64
  }
47
65
  async handleRequest(req, res) {
48
66
  const url = req.url ?? '/';
67
+ // 安全响应头
68
+ res.setHeader('X-Content-Type-Options', 'nosniff');
69
+ res.setHeader('X-Frame-Options', 'DENY');
70
+ res.setHeader('Cache-Control', 'no-store');
49
71
  try {
50
72
  if (req.method === 'GET' && url === '/') {
51
73
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
@@ -53,14 +75,29 @@ export class ConfigServer {
53
75
  return;
54
76
  }
55
77
  if (req.method === 'GET' && url === '/api/config') {
56
- const config = loadConfig(this.options.projectRoot);
78
+ const config = loadConfig();
57
79
  res.writeHead(200, { 'Content-Type': 'application/json' });
58
80
  res.end(JSON.stringify(config));
59
81
  return;
60
82
  }
61
83
  if (req.method === 'POST' && url === '/api/config') {
62
84
  const body = await readBody(req);
63
- const newConfig = JSON.parse(body);
85
+ let parsed;
86
+ try {
87
+ parsed = JSON.parse(body);
88
+ }
89
+ catch {
90
+ res.writeHead(400, { 'Content-Type': 'application/json' });
91
+ res.end(JSON.stringify({ ok: false, error: '无效的 JSON' }));
92
+ return;
93
+ }
94
+ const validation = validateConfigPayload(parsed);
95
+ if (!validation.ok) {
96
+ res.writeHead(400, { 'Content-Type': 'application/json' });
97
+ res.end(JSON.stringify({ ok: false, error: validation.error }));
98
+ return;
99
+ }
100
+ const newConfig = validation.config;
64
101
  // 端口冲突校验
65
102
  const ports = [newConfig.wsPort, newConfig.devServerPort, newConfig.configPort];
66
103
  if (new Set(ports).size !== ports.length) {
@@ -68,7 +105,7 @@ export class ConfigServer {
68
105
  res.end(JSON.stringify({ ok: false, error: 'wsPort, devServerPort, configPort 不能重复' }));
69
106
  return;
70
107
  }
71
- const filePath = configFilePath(this.options.projectRoot);
108
+ const filePath = configFilePath();
72
109
  saveConfig(filePath, newConfig);
73
110
  this.log('配置已保存');
74
111
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -88,6 +125,44 @@ export class ConfigServer {
88
125
  }
89
126
  return;
90
127
  }
128
+ // Addon 配置读取
129
+ if (req.method === 'GET' && url.startsWith('/api/addon-config')) {
130
+ const params = new URL(url, 'http://localhost').searchParams;
131
+ const name = params.get('name');
132
+ if (!name) {
133
+ res.writeHead(400, { 'Content-Type': 'application/json' });
134
+ res.end(JSON.stringify({ ok: false, error: '缺少 name 参数' }));
135
+ return;
136
+ }
137
+ const addonConfig = loadAddonConfig(name);
138
+ res.writeHead(200, { 'Content-Type': 'application/json' });
139
+ res.end(JSON.stringify({ ok: true, name, config: addonConfig ?? {} }));
140
+ return;
141
+ }
142
+ // Addon 配置保存
143
+ if (req.method === 'POST' && url === '/api/addon-config') {
144
+ const body = await readBody(req);
145
+ let parsed;
146
+ try {
147
+ parsed = JSON.parse(body);
148
+ }
149
+ catch {
150
+ res.writeHead(400, { 'Content-Type': 'application/json' });
151
+ res.end(JSON.stringify({ ok: false, error: '无效的 JSON' }));
152
+ return;
153
+ }
154
+ const payload = parsed;
155
+ if (!payload.name || typeof payload.name !== 'string' || typeof payload.config !== 'object' || !payload.config) {
156
+ res.writeHead(400, { 'Content-Type': 'application/json' });
157
+ res.end(JSON.stringify({ ok: false, error: '需要 name (string) 和 config (object) 字段' }));
158
+ return;
159
+ }
160
+ saveAddonConfig(payload.name, payload.config);
161
+ this.log(`addon ${payload.name} 配置已保存`);
162
+ res.writeHead(200, { 'Content-Type': 'application/json' });
163
+ res.end(JSON.stringify({ ok: true }));
164
+ return;
165
+ }
91
166
  res.writeHead(404, { 'Content-Type': 'application/json' });
92
167
  res.end(JSON.stringify({ error: 'Not found' }));
93
168
  }
@@ -232,6 +307,77 @@ export class ConfigServer {
232
307
  <button class="primary" onclick="saveConfig()">保存配置</button>
233
308
  <button onclick="resetDefaults()">恢复默认值</button>
234
309
  </div>
310
+
311
+ <h1 style="margin-top:40px;">增强项目配置</h1>
312
+ <p class="subtitle">配置存储于 ~/.remnote-bridge/addons/&lt;name&gt;/config.json</p>
313
+
314
+ <div class="card">
315
+ <h2>remnote-rag(语义搜索增强)</h2>
316
+ <h3 style="font-size:14px;margin-bottom:12px;color:#555;">Embedding 配置</h3>
317
+ <div class="field">
318
+ <label>API Key</label>
319
+ <input type="password" id="rag-embedding-api-key">
320
+ </div>
321
+ <div class="field">
322
+ <label>API Key 环境变量名</label>
323
+ <input type="text" id="rag-embedding-api-key-env" placeholder="留空则使用上方 api_key">
324
+ </div>
325
+ <div class="field">
326
+ <label>Base URL</label>
327
+ <input type="text" id="rag-embedding-base-url">
328
+ </div>
329
+ <div class="field">
330
+ <label>Model</label>
331
+ <input type="text" id="rag-embedding-model">
332
+ </div>
333
+ <div class="field">
334
+ <label>Dimensions</label>
335
+ <input type="number" id="rag-embedding-dimensions" min="1">
336
+ </div>
337
+
338
+ <h3 style="font-size:14px;margin:16px 0 12px;color:#555;">Reranker 配置</h3>
339
+ <div class="field">
340
+ <label>启用 Reranker</label>
341
+ <select id="rag-reranker-enabled">
342
+ <option value="true">是</option>
343
+ <option value="false">否</option>
344
+ </select>
345
+ </div>
346
+ <div class="field">
347
+ <label>API Key</label>
348
+ <input type="password" id="rag-reranker-api-key">
349
+ </div>
350
+ <div class="field">
351
+ <label>API Key 环境变量名</label>
352
+ <input type="text" id="rag-reranker-api-key-env" placeholder="留空则使用 Embedding API Key">
353
+ </div>
354
+ <div class="field">
355
+ <label>Base URL</label>
356
+ <input type="text" id="rag-reranker-base-url">
357
+ </div>
358
+ <div class="field">
359
+ <label>Model</label>
360
+ <input type="text" id="rag-reranker-model">
361
+ </div>
362
+ <div class="field">
363
+ <label>Top-K 倍数</label>
364
+ <input type="number" id="rag-reranker-top-k-multiplier" min="1">
365
+ </div>
366
+
367
+ <h3 style="font-size:14px;margin:16px 0 12px;color:#555;">通用配置</h3>
368
+ <div class="field">
369
+ <label>最小文本长度 (min_text_length)</label>
370
+ <input type="number" id="rag-min-text-length" min="1">
371
+ </div>
372
+ <div class="field">
373
+ <label>批量大小 (batch_size)</label>
374
+ <input type="number" id="rag-batch-size" min="1">
375
+ </div>
376
+
377
+ <div class="actions" style="margin-top:16px;">
378
+ <button class="primary" onclick="saveAddonConfigUI()">保存 addon 配置</button>
379
+ </div>
380
+ </div>
235
381
  </div>
236
382
 
237
383
  <div class="toast" id="toast"></div>
@@ -347,17 +493,137 @@ function resetDefaults() {
347
493
  }
348
494
  }
349
495
 
496
+ // ── Addon 配置 ──
497
+
498
+ const RAG_FIELD_MAP = {
499
+ 'rag-embedding-api-key': ['embedding', 'api_key'],
500
+ 'rag-embedding-api-key-env': ['embedding', 'api_key_env'],
501
+ 'rag-embedding-base-url': ['embedding', 'base_url'],
502
+ 'rag-embedding-model': ['embedding', 'model'],
503
+ 'rag-embedding-dimensions': ['embedding', 'dimensions'],
504
+ 'rag-reranker-enabled': ['reranker', 'enabled'],
505
+ 'rag-reranker-api-key': ['reranker', 'api_key'],
506
+ 'rag-reranker-api-key-env': ['reranker', 'api_key_env'],
507
+ 'rag-reranker-base-url': ['reranker', 'base_url'],
508
+ 'rag-reranker-model': ['reranker', 'model'],
509
+ 'rag-reranker-top-k-multiplier': ['reranker', 'top_k_multiplier'],
510
+ 'rag-min-text-length': [null, 'min_text_length'],
511
+ 'rag-batch-size': [null, 'batch_size'],
512
+ };
513
+
514
+ function fillAddonForm(config) {
515
+ for (const [elId, path] of Object.entries(RAG_FIELD_MAP)) {
516
+ const el = document.getElementById(elId);
517
+ if (!el) continue;
518
+ const val = path[0] ? (config[path[0]] || {})[path[1]] : config[path[1]];
519
+ if (val !== undefined && val !== null) {
520
+ el.value = el.tagName === 'SELECT' ? String(val) : val;
521
+ }
522
+ }
523
+ }
524
+
525
+ function readAddonForm() {
526
+ const config = { embedding: {}, reranker: {} };
527
+ for (const [elId, path] of Object.entries(RAG_FIELD_MAP)) {
528
+ const el = document.getElementById(elId);
529
+ if (!el) continue;
530
+ let val = el.value;
531
+ if (elId.includes('dimensions') || elId.includes('multiplier') || elId.includes('text-length') || elId.includes('batch-size')) {
532
+ val = Number(val);
533
+ } else if (elId.includes('enabled')) {
534
+ val = val === 'true';
535
+ }
536
+ if (path[0]) {
537
+ config[path[0]][path[1]] = val;
538
+ } else {
539
+ config[path[1]] = val;
540
+ }
541
+ }
542
+ return config;
543
+ }
544
+
545
+ async function loadAddonConfigUI() {
546
+ try {
547
+ const res = await fetch('/api/addon-config?name=remnote-rag');
548
+ const data = await res.json();
549
+ if (data.ok) fillAddonForm(data.config);
550
+ } catch (e) { /* addon 可能未配置 */ }
551
+ }
552
+
553
+ async function saveAddonConfigUI() {
554
+ const config = readAddonForm();
555
+ try {
556
+ const res = await fetch('/api/addon-config', {
557
+ method: 'POST',
558
+ headers: { 'Content-Type': 'application/json' },
559
+ body: JSON.stringify({ name: 'remnote-rag', config }),
560
+ });
561
+ const result = await res.json();
562
+ if (result.ok) showToast('addon 配置已保存', 'success');
563
+ else showToast('保存失败: ' + result.error, 'error');
564
+ } catch (e) {
565
+ showToast('保存请求失败: ' + e, 'error');
566
+ }
567
+ }
568
+
350
569
  loadConfig();
570
+ loadAddonConfigUI();
351
571
  </script>
352
572
  </body>
353
573
  </html>`;
354
574
  }
355
575
  }
576
+ const MAX_BODY_SIZE = 1024 * 1024; // 1 MB
356
577
  function readBody(req) {
357
578
  return new Promise((resolve, reject) => {
358
579
  const chunks = [];
359
- req.on('data', (chunk) => chunks.push(chunk));
580
+ let totalSize = 0;
581
+ req.on('data', (chunk) => {
582
+ totalSize += chunk.length;
583
+ if (totalSize > MAX_BODY_SIZE) {
584
+ req.destroy();
585
+ reject(new Error('请求体超过大小限制 (1 MB)'));
586
+ return;
587
+ }
588
+ chunks.push(chunk);
589
+ });
360
590
  req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
361
591
  req.on('error', reject);
362
592
  });
363
593
  }
594
+ function isValidPort(v) {
595
+ return typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= 65535;
596
+ }
597
+ function validateConfigPayload(raw) {
598
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
599
+ return { ok: false, error: '配置必须是对象' };
600
+ }
601
+ const obj = raw;
602
+ if (!isValidPort(obj.wsPort))
603
+ return { ok: false, error: 'wsPort 必须为有效端口号 (1-65535)' };
604
+ if (!isValidPort(obj.devServerPort))
605
+ return { ok: false, error: 'devServerPort 必须为有效端口号 (1-65535)' };
606
+ if (!isValidPort(obj.configPort))
607
+ return { ok: false, error: 'configPort 必须为有效端口号 (1-65535)' };
608
+ if (typeof obj.daemonTimeoutMinutes !== 'number' || obj.daemonTimeoutMinutes <= 0) {
609
+ return { ok: false, error: 'daemonTimeoutMinutes 必须为正数' };
610
+ }
611
+ if (obj.defaults !== undefined) {
612
+ if (typeof obj.defaults !== 'object' || obj.defaults === null || Array.isArray(obj.defaults)) {
613
+ return { ok: false, error: 'defaults 必须是对象' };
614
+ }
615
+ }
616
+ return {
617
+ ok: true,
618
+ config: {
619
+ wsPort: obj.wsPort,
620
+ devServerPort: obj.devServerPort,
621
+ configPort: obj.configPort,
622
+ daemonTimeoutMinutes: obj.daemonTimeoutMinutes,
623
+ defaults: obj.defaults
624
+ ? { ...DEFAULT_DEFAULTS, ...obj.defaults }
625
+ : { ...DEFAULT_DEFAULTS },
626
+ addons: obj.addons, // 仅保留 enabled 状态
627
+ },
628
+ };
629
+ }
@@ -10,7 +10,7 @@
10
10
  * 业务编排逻辑在 handlers/ 中,本文件只做连接管理和请求分发。
11
11
  */
12
12
  import { WebSocketServer, WebSocket } from 'ws';
13
- import { isHelloMessage, isPongMessage, isBridgeRequest, isBridgeResponse } from '../protocol.js';
13
+ import { isHelloMessage, isPongMessage, isBridgeRequest, isBridgeResponse, WS_CLOSE_OTHER_CONNECTED, WS_CLOSE_PONG_TIMEOUT, WS_CLOSE_PREEMPTED, WS_CLOSE_TWIN_EXISTS, } from '../protocol.js';
14
14
  import { DEFAULT_DEFAULTS } from '../config.js';
15
15
  import { RemCache } from '../handlers/rem-cache.js';
16
16
  import { ReadHandler } from '../handlers/read-handler.js';
@@ -23,9 +23,12 @@ import crypto from 'crypto';
23
23
  const PLUGIN_REQUEST_TIMEOUT_MS = 15_000;
24
24
  export class BridgeServer {
25
25
  wss = null;
26
+ _actualPort = 0;
26
27
  pluginSocket = null;
27
28
  pluginVersion = null;
28
29
  pluginSdkReady = false;
30
+ pluginIsTwin = false;
31
+ slotIndex;
29
32
  pingTimer = null;
30
33
  pongTimer = null;
31
34
  startTime = Date.now();
@@ -39,9 +42,12 @@ export class BridgeServer {
39
42
  contextReadHandler;
40
43
  defaults;
41
44
  config;
45
+ /** 实际监听的端口(可能与 config.port 不同,若原端口被占用则 OS 自动分配) */
46
+ get actualPort() { return this._actualPort; }
42
47
  /** 每当收到 CLI 命令请求时触发(用于刷新守护进程超时计时器) */
43
48
  onCliRequest;
44
49
  constructor(config) {
50
+ this.slotIndex = config.slotIndex;
45
51
  this.config = {
46
52
  port: config.port,
47
53
  host: config.host ?? '127.0.0.1',
@@ -73,23 +79,36 @@ export class BridgeServer {
73
79
  start() {
74
80
  return new Promise((resolve, reject) => {
75
81
  this.startTime = Date.now();
76
- this.wss = new WebSocketServer({
77
- port: this.config.port,
78
- host: this.config.host,
79
- maxPayload: 1 * 1024 * 1024, // 1MB,足够所有 JSON 消息
80
- });
81
- this.wss.on('listening', () => {
82
- this.log(`WS Server 监听 ${this.config.host}:${this.config.port}`);
83
- this.startPingInterval();
84
- resolve();
85
- });
86
- this.wss.on('error', (err) => {
87
- this.log(`WS Server 错误: ${err.message}`, 'error');
88
- reject(err);
89
- });
90
- this.wss.on('connection', (ws) => {
91
- this.handleConnection(ws);
92
- });
82
+ const tryListen = (port) => {
83
+ this.wss = new WebSocketServer({
84
+ port,
85
+ host: this.config.host,
86
+ maxPayload: 1 * 1024 * 1024, // 1MB,足够所有 JSON 消息
87
+ });
88
+ this.wss.on('listening', () => {
89
+ this._actualPort = this.wss.address().port;
90
+ if (this._actualPort !== this.config.port) {
91
+ this.log(`端口 ${this.config.port} 被占用,WS Server 自动分配到 ${this._actualPort}`, 'warn');
92
+ }
93
+ this.log(`WS Server 监听 ${this.config.host}:${this._actualPort}`);
94
+ this.startPingInterval();
95
+ resolve();
96
+ });
97
+ this.wss.on('error', (err) => {
98
+ if (err.code === 'EADDRINUSE' && port !== 0) {
99
+ this.log(`端口 ${port} 被占用,尝试自动分配...`, 'warn');
100
+ tryListen(0);
101
+ }
102
+ else {
103
+ this.log(`WS Server 错误: ${err.message}`, 'error');
104
+ reject(err);
105
+ }
106
+ });
107
+ this.wss.on('connection', (ws) => {
108
+ this.handleConnection(ws);
109
+ });
110
+ };
111
+ tryListen(this.config.port);
93
112
  });
94
113
  }
95
114
  /**
@@ -161,37 +180,69 @@ export class BridgeServer {
161
180
  ws.on('close', () => {
162
181
  if (ws === this.pluginSocket) {
163
182
  this.log('Plugin 已断开', 'warn');
164
- this.pluginSocket = null;
165
- this.pluginVersion = null;
166
- this.pluginSdkReady = false;
167
- // 清理可能残留的 pong 超时定时器
168
- if (this.pongTimer) {
169
- clearTimeout(this.pongTimer);
170
- this.pongTimer = null;
171
- }
172
- // 拒绝所有等待中的 Plugin 子请求
173
- for (const [id, pending] of this.pendingPluginRequests) {
174
- clearTimeout(pending.timer);
175
- pending.reject(new Error('Plugin 已断开'));
176
- this.pendingPluginRequests.delete(id);
177
- }
183
+ this.clearPluginState();
178
184
  }
179
185
  });
180
186
  ws.on('error', (err) => {
181
187
  this.log(`连接错误: ${err.message}`, 'error');
182
188
  });
183
189
  }
190
+ /** 重置 Plugin 连接状态(onclose 和 pong 超时共用) */
191
+ clearPluginState() {
192
+ this.pluginSocket = null;
193
+ this.pluginVersion = null;
194
+ this.pluginSdkReady = false;
195
+ this.pluginIsTwin = false;
196
+ if (this.pongTimer) {
197
+ clearTimeout(this.pongTimer);
198
+ this.pongTimer = null;
199
+ }
200
+ for (const [id, pending] of this.pendingPluginRequests) {
201
+ clearTimeout(pending.timer);
202
+ pending.reject(new Error('Plugin 已断开'));
203
+ this.pendingPluginRequests.delete(id);
204
+ }
205
+ }
184
206
  handlePluginHello(ws, hello) {
185
- if (this.pluginSocket && this.pluginSocket.readyState === WebSocket.OPEN) {
186
- // 已有 Plugin 连接,拒绝新连接
187
- this.log(`拒绝 Plugin 连接(已有连接)`, 'warn');
188
- ws.close(4000, 'Another plugin is already connected');
207
+ const isTwin = (hello.twinSlotIndex === this.slotIndex);
208
+ if (!this.pluginSocket || this.pluginSocket.readyState !== WebSocket.OPEN) {
209
+ // 无现有连接 接受
210
+ this.pluginSocket = ws;
211
+ this.pluginVersion = hello.version;
212
+ this.pluginSdkReady = hello.sdkReady ?? false;
213
+ this.pluginIsTwin = isTwin;
214
+ this.log(`Plugin 已连接 (v${hello.version}, SDK: ${this.pluginSdkReady ? '就绪' : '未就绪'}, ${isTwin ? '孪生' : '非孪生'})`);
189
215
  return;
190
216
  }
191
- this.pluginSocket = ws;
192
- this.pluginVersion = hello.version;
193
- this.pluginSdkReady = hello.sdkReady ?? false;
194
- this.log(`Plugin 已连接 (v${hello.version}, SDK: ${this.pluginSdkReady ? '就绪' : '未就绪'})`);
217
+ // 有现有连接且存活
218
+ if (isTwin) {
219
+ // 孪生来了 抢占现有非孪生连接
220
+ this.log(`孪生 Plugin 连接,抢占当前${this.pluginIsTwin ? '孪生' : '非孪生'}连接`, 'warn');
221
+ const preempted = { type: 'preempted', reason: 'twin_plugin_connected' };
222
+ try {
223
+ this.pluginSocket.send(JSON.stringify(preempted));
224
+ }
225
+ catch { /* 发送失败无碍 */ }
226
+ this.pluginSocket.close(WS_CLOSE_PREEMPTED, 'Preempted by twin plugin');
227
+ // 接受新孪生连接
228
+ this.pluginSocket = ws;
229
+ this.pluginVersion = hello.version;
230
+ this.pluginSdkReady = hello.sdkReady ?? false;
231
+ this.pluginIsTwin = true;
232
+ this.log(`孪生 Plugin 已接管 (v${hello.version})`);
233
+ return;
234
+ }
235
+ // 非孪生来了
236
+ if (this.pluginIsTwin) {
237
+ // 已有孪生 → 拒绝
238
+ this.log(`拒绝非孪生 Plugin 连接(孪生 Plugin 已连接)`, 'warn');
239
+ ws.close(WS_CLOSE_TWIN_EXISTS, 'Twin plugin is connected');
240
+ }
241
+ else {
242
+ // 已有非孪生 → 拒绝
243
+ this.log(`拒绝 Plugin 连接(已有连接)`, 'warn');
244
+ ws.close(WS_CLOSE_OTHER_CONNECTED, 'Another plugin is already connected');
245
+ }
195
246
  }
196
247
  async handleCliRequest(ws, request) {
197
248
  // 通知守护进程刷新超时计时器
@@ -361,10 +412,8 @@ export class BridgeServer {
361
412
  this.pluginSocket.send(JSON.stringify({ type: 'ping' }));
362
413
  this.pongTimer = setTimeout(() => {
363
414
  this.log('Plugin 心跳超时,断开连接', 'warn');
364
- this.pluginSocket?.close(4001, 'Pong timeout');
365
- this.pluginSocket = null;
366
- this.pluginVersion = null;
367
- this.pluginSdkReady = false;
415
+ this.pluginSocket?.close(WS_CLOSE_PONG_TIMEOUT, 'Pong timeout');
416
+ this.clearPluginState();
368
417
  }, this.config.pongTimeoutMs);
369
418
  }
370
419
  }, this.config.pingIntervalMs);
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * 统一 JSON 输出格式,自动注入 timestamp 字段。
5
5
  */
6
+ import { DaemonNotRunningError, DaemonUnreachableError } from '../daemon/send-request.js';
6
7
  /**
7
8
  * 输出 JSON 到 stdout,自动追加 ISO 8601 时间戳。
8
9
  *
@@ -11,3 +12,31 @@
11
12
  export function jsonOutput(data) {
12
13
  console.log(JSON.stringify({ ...data, timestamp: new Date().toISOString() }));
13
14
  }
15
+ /**
16
+ * 命令层统一错误处理。
17
+ *
18
+ * - DaemonNotRunningError / DaemonUnreachableError → exitCode 2
19
+ * - 其他错误 → exitCode 1
20
+ *
21
+ * 返回 void,调用方在 catch 中直接 `handleCommandError(err, cmd, json); return;` 即可。
22
+ */
23
+ export function handleCommandError(err, command, json) {
24
+ if (err instanceof DaemonNotRunningError || err instanceof DaemonUnreachableError) {
25
+ if (json) {
26
+ jsonOutput({ ok: false, command, error: err.message });
27
+ }
28
+ else {
29
+ console.error(`错误: ${err.message}`);
30
+ }
31
+ process.exitCode = 2;
32
+ return;
33
+ }
34
+ const errorMsg = err instanceof Error ? err.message : String(err);
35
+ if (json) {
36
+ jsonOutput({ ok: false, command, error: errorMsg });
37
+ }
38
+ else {
39
+ console.error(`错误: ${errorMsg}`);
40
+ }
41
+ process.exitCode = 1;
42
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * MCP 返回值格式化工具
3
+ *
4
+ * 两种模式:
5
+ * - formatFrontmatter:YAML frontmatter + body(read 类工具)
6
+ * - formatDataJson:剥离 wrapper 的 JSON(action/infra 工具)
7
+ */
8
+ // ---------------------------------------------------------------------------
9
+ // 模式 A:Frontmatter + Body
10
+ // ---------------------------------------------------------------------------
11
+ /**
12
+ * 将元数据序列化为 YAML frontmatter,拼接 body 内容。
13
+ *
14
+ * - null/undefined 的值自动跳过
15
+ * - 数字/布尔:裸值(`nodeCount: 42`)
16
+ * - 字符串:JSON 引号(`mode: "focus"`)
17
+ * - 数组/对象:JSON 内联(`ancestors: [{"id":"x"}]`)
18
+ * - 无元数据时省略 `---` 分隔符,直接返回 body
19
+ */
20
+ export function formatFrontmatter(meta, body) {
21
+ const entries = Object.entries(meta).filter(([, v]) => v !== undefined && v !== null);
22
+ if (entries.length === 0)
23
+ return body;
24
+ const lines = entries.map(([k, v]) => {
25
+ if (typeof v === 'number' || typeof v === 'boolean')
26
+ return `${k}: ${v}`;
27
+ return `${k}: ${JSON.stringify(v)}`;
28
+ });
29
+ return `---\n${lines.join('\n')}\n---\n\n${body}`;
30
+ }
31
+ // ---------------------------------------------------------------------------
32
+ // 模式 B:Data JSON(剥离 ok/command/timestamp wrapper)
33
+ // ---------------------------------------------------------------------------
34
+ /**
35
+ * 从 CLI 响应中剥离 ok/command/timestamp,返回剩余字段的 JSON。
36
+ *
37
+ * 用于 action/infra 工具——AI 不需要看到冗余的 wrapper 字段。
38
+ * callCli 已在 ok===false 时抛出 CliError,成功路径 ok 始终为 true。
39
+ */
40
+ export function formatDataJson(response) {
41
+ const { ok: _ok, command: _cmd, timestamp: _ts, ...rest } = response;
42
+ return JSON.stringify(rest, null, 2);
43
+ }