linco-connect 1.0.4 → 1.0.5

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/README.md CHANGED
@@ -106,16 +106,51 @@ linco-connect init \
106
106
 
107
107
  ### Hermes Agent 初始化
108
108
 
109
- Hermes HTTP 网关模式,除了 `init` 命令外,还需要启用 Hermes 的 `api_server` 平台:
109
+ Hermes 使用本机 Hermes Gateway。默认情况下,`ddchat-connect` 会在第一次 Hermes 对话前自动检查 Gateway;如果未运行,会参考 Hermes Web UI 的方式自动补齐当前 Hermes profile 的 `platforms.api_server` 配置,生成本机 API key,并执行 `hermes gateway run --replace`。
110
110
 
111
- ```bash
112
- hermes config set platforms.api_server.enabled true
113
- hermes config set platforms.api_server.host 127.0.0.1
114
- hermes config set platforms.api_server.port 8642
115
- hermes gateway restart
111
+ 最小配置只需要启用 Hermes:
112
+
113
+ ```json
114
+ {
115
+ "agents": {
116
+ "hermes": {
117
+ "enabled": true
118
+ }
119
+ }
120
+ }
121
+ ```
122
+
123
+ 如需覆盖默认值,可配置:
124
+
125
+ ```json
126
+ {
127
+ "agents": {
128
+ "hermes": {
129
+ "enabled": true,
130
+ "gatewayUrl": "http://127.0.0.1:8642",
131
+ "autoStartGateway": true,
132
+ "hermesBin": "hermes",
133
+ "profile": "default"
134
+ }
135
+ }
136
+ }
116
137
  ```
117
138
 
118
- 可通过 `hermes gateway status` 确认 Gateway 是否运行,也可用 `linco-connect doctor` 检查 Hermes Gateway 连通性。
139
+ 如果已经有外部 Gateway,可关闭自动启动:
140
+
141
+ ```json
142
+ {
143
+ "agents": {
144
+ "hermes": {
145
+ "enabled": true,
146
+ "gatewayUrl": "http://127.0.0.1:9000",
147
+ "autoStartGateway": false
148
+ }
149
+ }
150
+ }
151
+ ```
152
+
153
+ 可通过 `linco-connect doctor` 检查 Hermes Gateway 状态;Gateway 未运行但自动启动开启时,发送 Hermes 消息会自动启动。
119
154
 
120
155
  #### 接口鉴权
121
156
 
@@ -138,7 +173,7 @@ hermes gateway restart
138
173
  export LINCO_HERMES_API_KEY=你的API_SERVER_KEY值
139
174
  ```
140
175
 
141
- 如果连接时报 `401` `fetch failed` 错误,请检查 Hermes 是否已启用 `api_server` 平台,以及是否正确配置了 API Key。
176
+ 如果连接时报 `401`,请检查 Hermes `api_server.key` 是否与 `LINCO_HERMES_API_KEY` / `agents.hermes.apiKey` 一致。自动启动会保留用户已有 key;如果 key 缺失,会生成新的本机 API key,当前和后续连接器进程会从 Hermes 配置中读取并使用。
142
177
 
143
178
  ## 配置文件
144
179
 
@@ -356,6 +391,11 @@ outbox 目录: .../outbox
356
391
  | `LINCO_CLAUDE_ENABLED` | 无配置文件时为启用 | 是否启用 Claude 连接器 |
357
392
  | `LINCO_CODEX_ENABLED` | `false` | 是否启用 Codex 连接器 |
358
393
  | `LINCO_HERMES_ENABLED` | `false` | 是否启用 Hermes 连接器 |
394
+ | `LINCO_HERMES_GATEWAY_URL` | `http://127.0.0.1:8642` | Hermes Gateway 地址 |
395
+ | `LINCO_HERMES_AUTO_START_GATEWAY` | `true` | Gateway 未运行时是否自动补齐配置并启动 |
396
+ | `LINCO_HERMES_BIN` / `HERMES_BIN` | `hermes` | Hermes CLI 命令或路径 |
397
+ | `LINCO_HERMES_PROFILE` | `default` | Hermes profile 名称 |
398
+ | `LINCO_HERMES_HOME` / `HERMES_HOME` | 自动检测 | Hermes home 目录 |
359
399
  | `LINCO_HERMES_API_KEY` | 无 | Hermes Gateway 接口鉴权 Key |
360
400
  | `LINCO_CLAUDE_WS_URL` | `wss://chat.ddjf.info/socket/ai/claude` | Claude 端点覆盖 |
361
401
  | `LINCO_CODEX_WS_URL` | `wss://chat.ddjf.info/socket/ai/codex` | Codex 端点覆盖 |
@@ -418,50 +458,41 @@ outbox 目录: .../outbox
418
458
 
419
459
  ### Hermes 模式报 "fetch failed" 错误
420
460
 
421
- 如果选择 Hermes Agent 后发送消息报错 **"Hermes 错误: fetch failed"**,说明 Hermes Gateway HTTP API 服务未启动。
422
-
423
- `ddchat-connect` 通过 HTTP 请求 `http://127.0.0.1:8642/v1/runs` 与 Hermes Gateway 通信,需要确保 `api_server` 平台已启用。
424
-
425
- **修复方法**:
461
+ 默认情况下,`ddchat-connect` 会在第一次 Hermes 对话前自动检查并启动 Hermes Gateway。如果仍然报 **"Hermes 错误: fetch failed"**,通常是 Hermes CLI 不可用、Hermes 自身模型配置异常、端口被占用,或配置了外部 Gateway 但未运行。
426
462
 
427
- 方式一:使用 Hermes 命令行配置(推荐)
463
+ 先运行:
428
464
 
429
465
  ```bash
430
- hermes config set platforms.api_server.enabled true
431
- hermes config set platforms.api_server.host 127.0.0.1
432
- hermes config set platforms.api_server.port 8642
433
- hermes gateway stop
434
- hermes gateway run
466
+ linco-connect doctor
435
467
  ```
436
468
 
437
- 方式二:手动编辑配置文件
469
+ 排查方向:
470
+
471
+ - 确认终端可执行 `hermes`,或通过 `agents.hermes.hermesBin` / `LINCO_HERMES_BIN` 配置 Hermes 可执行文件路径。
472
+ - 确认 Hermes 自身模型、账号或 provider 配置可用。
473
+ - 确认 `gatewayUrl` 对应端口未被其他程序占用。
474
+ - 如果使用 hermes-web-ui 已启动的 Gateway,确认 `gatewayUrl` 和 `profile` 与 web-ui 当前 Gateway 一致。
475
+ - 如果返回 `401`,确认 `agents.hermes.apiKey` / `LINCO_HERMES_API_KEY` 与 Hermes `api_server.key` 一致。
438
476
 
439
- 编辑 Hermes 配置文件 `~/.hermes/config.yaml`,确保 `platforms` 中包含 `api_server`:
477
+ 高级排障时可手动检查 Hermes profile 的 `config.yaml`,Gateway 配置应使用 `extra.host` / `extra.port`:
440
478
 
441
479
  ```yaml
442
480
  platforms:
443
- linco:
444
- enabled: true
445
481
  api_server:
446
482
  enabled: true
447
- host: 127.0.0.1
448
- port: 8642
449
- ```
450
-
451
- 然后重启 Hermes Gateway:
452
-
453
- ```bash
454
- hermes gateway stop
455
- hermes gateway run
483
+ key: '<本机 API key>'
484
+ cors_origins: 'http://127.0.0.1:*'
485
+ extra:
486
+ host: 127.0.0.1
487
+ port: 8642
456
488
  ```
457
489
 
458
- 确认端口已监听:
490
+ 确认 Gateway 健康:
459
491
 
460
492
  ```bash
461
- netstat -ano | grep 8642
493
+ curl http://127.0.0.1:8642/health
462
494
  ```
463
495
 
464
- 输出应包含 `127.0.0.1:8642` 处于 LISTENING 状态。
465
496
 
466
497
  ## 安全注意事项
467
498
 
@@ -15,6 +15,7 @@ const {
15
15
  saveUserConfig,
16
16
  } = require('../src/config');
17
17
  const { ensureLocalToken, localUrlWithToken } = require('../src/localAuth');
18
+ const { checkGatewayHealth, resolveHermesGatewayOptions } = require('../src/hermesGateway');
18
19
  const pkg = require('../package.json');
19
20
 
20
21
  const rootDir = path.resolve(__dirname, '..');
@@ -412,9 +413,18 @@ async function doctorCommand() {
412
413
  for (const [agentType, agent] of Object.entries(config.agents || {})) {
413
414
  checks.push([`${agentType} Agent`, true, agent.enabled ? `已启用 ${safeUrlForDisplay(agent.wsUrl)}` : `未启用 ${safeUrlForDisplay(agent.wsUrl)}`]);
414
415
  if (agentType === 'hermes') {
415
- const gatewayUrl = agent.gatewayUrl || 'http://127.0.0.1:8642';
416
- const reachable = await checkHermesGateway(gatewayUrl);
417
- checks.push([`${agentType} Gateway`, reachable, gatewayUrl]);
416
+ const options = resolveHermesGatewayOptions(agent);
417
+ const health = await checkGatewayHealth(options.gatewayUrl, agent, { timeoutMs: 3000 });
418
+ if (health.ok) {
419
+ checks.push([`${agentType} Gateway`, true, `${options.gatewayUrl}/health`]);
420
+ } else if (agent.autoStartGateway !== false) {
421
+ const hasCli = !agent.enabled || (path.isAbsolute(options.hermesBin) ? fs.existsSync(options.hermesBin) : commandExists(options.hermesBin));
422
+ checks.push([`${agentType} Gateway`, true, `未运行,发送 Hermes 消息时将自动启动 ${options.gatewayUrl}`]);
423
+ checks.push([`${agentType} CLI`, hasCli, options.hermesBin]);
424
+ checks.push([`${agentType} Profile`, true, options.profileDir]);
425
+ } else {
426
+ checks.push([`${agentType} Gateway`, false, `未运行,且 autoStartGateway=false ${options.gatewayUrl}`]);
427
+ }
418
428
  } else {
419
429
  checks.push([`${agentType} CLI`, !agent.enabled || (path.isAbsolute(agent.bin) ? fs.existsSync(agent.bin) : commandExists(agent.bin)), agent.bin]);
420
430
  }
@@ -434,18 +444,6 @@ async function doctorCommand() {
434
444
  if (!ok) process.exitCode = 1;
435
445
  }
436
446
 
437
- async function checkHermesGateway(gatewayUrl) {
438
- try {
439
- const controller = new AbortController();
440
- const timer = setTimeout(() => controller.abort(), 3000);
441
- const response = await fetch(gatewayUrl, { signal: controller.signal });
442
- clearTimeout(timer);
443
- return response.ok || response.status === 404;
444
- } catch {
445
- return false;
446
- }
447
- }
448
-
449
447
  function safeUrlForDisplay(value) {
450
448
  try {
451
449
  const url = new URL(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linco-connect",
3
- "version": "1.0.4",
3
+ "version": "1.0.5",
4
4
  "description": "自研 IM 桥接多 Agent 服务",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -20,6 +20,7 @@
20
20
  "node": ">=18"
21
21
  },
22
22
  "dependencies": {
23
- "ws": "^8.16.0"
23
+ "ws": "^8.16.0",
24
+ "yaml": "^2.9.0"
24
25
  }
25
26
  }
@@ -3,8 +3,7 @@ const { send, sendError, sendSystem } = require('../protocol');
3
3
  const { persistAgentSessionId, stopAgentProcess: stopSessionProcess, updateAgentSessionHistory, createAgentSessionEntry, saveSessionMetadata } = require('../session');
4
4
  const { getOutboxDir } = require('../outgoingAttachmentHandler');
5
5
  const { createTextStreamBuffer, appendTextStream, flushTextStream, resetTextStream } = require('../streamBuffer');
6
-
7
- const DEFAULT_GATEWAY_URL = 'http://127.0.0.1:8642';
6
+ const { DEFAULT_GATEWAY_URL, ensureHermesGateway, normalizeGatewayUrl } = require('../hermesGateway');
8
7
 
9
8
  function extractText(input) {
10
9
  if (!Array.isArray(input)) return String(input || '');
@@ -47,11 +46,12 @@ async function runHermesTurn(input, ws, session, config) {
47
46
  resetHermesAssistantText(session);
48
47
 
49
48
  const agentConfig = config.agents?.hermes || {};
50
- const gatewayUrl = normalizeGatewayUrl(agentConfig.gatewayUrl || agentConfig.baseUrl || DEFAULT_GATEWAY_URL);
51
49
  const hermesSessionId = ensureHermesSessionId(session);
52
50
  const inputWithOutbox = maybeAddOutboxHint(input, session, config);
53
51
 
54
52
  try {
53
+ const gatewayUrl = await ensureHermesGateway(agentConfig, config.logger);
54
+ session.hermesGatewayUrl = gatewayUrl;
55
55
  const run = await createRun(gatewayUrl, agentConfig, inputWithOutbox, hermesSessionId);
56
56
  if (!run?.run_id) throw new Error('Hermes Gateway 未返回 run_id');
57
57
  session.hermesRunId = run.run_id;
@@ -219,6 +219,7 @@ function finishTurn(ws, session, config, options = {}) {
219
219
  }
220
220
  session.hermesAbortController = null;
221
221
  session.hermesRunId = null;
222
+ session.hermesGatewayUrl = null;
222
223
  session.isTurnActive = false;
223
224
  session.currentInputForNoOutput = null;
224
225
  session.pendingPermission = null;
@@ -362,7 +363,7 @@ async function resolvePendingPermission(approved, ws, session, config) {
362
363
  if (!pending || pending.provider !== 'hermes') return false;
363
364
 
364
365
  const agentConfig = config.agents?.hermes || {};
365
- const gatewayUrl = normalizeGatewayUrl(agentConfig.gatewayUrl || agentConfig.baseUrl || DEFAULT_GATEWAY_URL);
366
+ const gatewayUrl = session.hermesGatewayUrl || normalizeGatewayUrl(agentConfig.gatewayUrl || agentConfig.baseUrl || DEFAULT_GATEWAY_URL);
366
367
  const runId = pending.runId || session.hermesRunId;
367
368
  session.pendingPermission = null;
368
369
 
@@ -387,7 +388,7 @@ function stop(session, options = {}) {
387
388
  } catch {}
388
389
  }
389
390
  if (runId) {
390
- const gatewayUrl = normalizeGatewayUrl(agentConfig.gatewayUrl || agentConfig.baseUrl || DEFAULT_GATEWAY_URL);
391
+ const gatewayUrl = session.hermesGatewayUrl || normalizeGatewayUrl(agentConfig.gatewayUrl || agentConfig.baseUrl || DEFAULT_GATEWAY_URL);
391
392
  fetchJson(`${gatewayUrl}/v1/runs/${encodeURIComponent(runId)}/stop`, agentConfig, { method: 'POST' }).catch(() => {});
392
393
  }
393
394
  session.hermesAbortController = null;
@@ -424,10 +425,6 @@ function drainQueue(ws, session, config) {
424
425
  setImmediate(() => execute(next, ws, session, config));
425
426
  }
426
427
 
427
- function normalizeGatewayUrl(value) {
428
- return String(value || DEFAULT_GATEWAY_URL).replace(/\/+$/, '');
429
- }
430
-
431
428
  function buildHeaders(agentConfig) {
432
429
  const headers = { Accept: 'application/json' };
433
430
  if (agentConfig.apiKey) headers.Authorization = `Bearer ${agentConfig.apiKey}`;
package/src/config.js CHANGED
@@ -267,6 +267,9 @@ function agentConfig(userConfig, imConfig, agentType, defaults = {}) {
267
267
  const instructionsEnv = `LINCO_${agentType.toUpperCase()}_INSTRUCTIONS`;
268
268
  const appIdEnv = `LINCO_${agentType.toUpperCase()}_APP_ID`;
269
269
  const appSecretEnv = `LINCO_${agentType.toUpperCase()}_APP_SECRET`;
270
+ const hermesBin = agentType === 'hermes'
271
+ ? resolveCommand(stringFromEnv('LINCO_HERMES_BIN', process.env.HERMES_BIN || configured.hermesBin || configured.bin || defaults.hermesBin || defaults.bin || 'hermes'))
272
+ : undefined;
270
273
 
271
274
  return {
272
275
  type: agentType,
@@ -275,9 +278,16 @@ function agentConfig(userConfig, imConfig, agentType, defaults = {}) {
275
278
  wsUrl: stringFromEnv(wsEnv, configured.wsUrl || defaults.wsUrl),
276
279
  mode: stringFromEnv(`LINCO_${agentType.toUpperCase()}_MODE`, configured.mode || defaults.mode),
277
280
  model: stringFromEnv(`LINCO_${agentType.toUpperCase()}_MODEL`, configured.model || defaults.model),
278
- gatewayUrl: stringFromEnv(gatewayEnv, configured.gatewayUrl || defaults.gatewayUrl),
281
+ gatewayUrl: stringFromEnv(gatewayEnv, configured.gatewayUrl || configured.baseUrl || defaults.gatewayUrl),
279
282
  apiKey: stringFromEnv(apiKeyEnv, configured.apiKey || defaults.apiKey),
280
283
  instructions: stringFromEnv(instructionsEnv, configured.instructions || defaults.instructions),
284
+ ...(agentType === 'hermes' ? {
285
+ autoStartGateway: booleanFromEnv('LINCO_HERMES_AUTO_START_GATEWAY', configured.autoStartGateway ?? defaults.autoStartGateway ?? true),
286
+ hermesBin,
287
+ profile: stringFromEnv('LINCO_HERMES_PROFILE', configured.profile || defaults.profile || 'default'),
288
+ hermesHome: stringFromEnv('LINCO_HERMES_HOME', process.env.HERMES_HOME || configured.hermesHome || defaults.hermesHome),
289
+ gatewayStartTimeoutMs: numberFromEnv('LINCO_HERMES_GATEWAY_START_TIMEOUT_MS', configured.gatewayStartTimeoutMs || defaults.gatewayStartTimeoutMs || 15000),
290
+ } : {}),
281
291
  appId: stringFromEnv(appIdEnv, channelAccount?.appId || configured.appId),
282
292
  appSecret: stringFromEnv(appSecretEnv, channelAccount?.appSecret || configured.appSecret),
283
293
  };
@@ -320,6 +330,9 @@ function loadConfig(rootDir) {
320
330
  hermes: agentConfig(userConfig, imConfig, 'hermes', {
321
331
  wsUrl: hermesAccountConfig.wsUrl || DEFAULT_AGENT_WS_URLS.hermes,
322
332
  gatewayUrl: 'http://127.0.0.1:8642',
333
+ autoStartGateway: true,
334
+ hermesBin: 'hermes',
335
+ profile: 'default',
323
336
  }),
324
337
  };
325
338
  const accountAgent = agents[accountAgentType];
@@ -0,0 +1,342 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const crypto = require('crypto');
5
+ const { spawn } = require('child_process');
6
+ const YAML = require('yaml');
7
+
8
+ const DEFAULT_GATEWAY_URL = 'http://127.0.0.1:8642';
9
+ const DEFAULT_GATEWAY_HOST = '127.0.0.1';
10
+ const DEFAULT_GATEWAY_PORT = 8642;
11
+ const DEFAULT_START_TIMEOUT_MS = 30000;
12
+ const DEFAULT_HEALTH_TIMEOUT_MS = 1500;
13
+ const DEFAULT_POLL_INTERVAL_MS = 500;
14
+
15
+ const bootstrapPromises = new Map();
16
+ const ownedGateways = new Map();
17
+ let cleanupRegistered = false;
18
+
19
+ function normalizeGatewayUrl(value) {
20
+ const raw = String(value || DEFAULT_GATEWAY_URL).trim() || DEFAULT_GATEWAY_URL;
21
+ let parsed;
22
+ try {
23
+ parsed = new URL(raw);
24
+ } catch {
25
+ throw new Error(`Hermes Gateway 地址无效:${raw}`);
26
+ }
27
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
28
+ throw new Error(`Hermes Gateway 地址无效:${raw}`);
29
+ }
30
+ return parsed.toString().replace(/\/+$/, '');
31
+ }
32
+
33
+ function resolveHermesGatewayOptions(agentConfig = {}) {
34
+ const gatewayUrl = normalizeGatewayUrl(agentConfig.gatewayUrl || agentConfig.baseUrl || DEFAULT_GATEWAY_URL);
35
+ const url = new URL(gatewayUrl);
36
+ const host = url.hostname || DEFAULT_GATEWAY_HOST;
37
+ const port = Number(url.port || (url.protocol === 'https:' ? 443 : DEFAULT_GATEWAY_PORT));
38
+ const profile = agentConfig.profile || process.env.LINCO_HERMES_PROFILE || 'default';
39
+ const profileDir = resolveHermesProfileDir({ ...agentConfig, profile });
40
+ const hermesBin = resolveHermesBin(agentConfig);
41
+ const autoStartGateway = agentConfig.autoStartGateway !== false;
42
+ const startTimeoutMs = Number(agentConfig.gatewayStartTimeoutMs) > 0
43
+ ? Number(agentConfig.gatewayStartTimeoutMs)
44
+ : DEFAULT_START_TIMEOUT_MS;
45
+
46
+ return { gatewayUrl, host, port, profile, profileDir, hermesBin, autoStartGateway, startTimeoutMs };
47
+ }
48
+
49
+ function resolveHermesBin(agentConfig = {}) {
50
+ return process.env.LINCO_HERMES_BIN
51
+ || process.env.HERMES_BIN
52
+ || agentConfig.hermesBin
53
+ || agentConfig.bin
54
+ || 'hermes';
55
+ }
56
+
57
+ function resolveHermesProfileDir(agentConfig = {}) {
58
+ const baseHome = path.resolve(
59
+ agentConfig.hermesHome
60
+ || process.env.LINCO_HERMES_HOME
61
+ || process.env.HERMES_HOME
62
+ || defaultHermesHome()
63
+ );
64
+ const profile = agentConfig.profile || process.env.LINCO_HERMES_PROFILE || 'default';
65
+ if (!profile || profile === 'default') return baseHome;
66
+ return path.join(baseHome, 'profiles', profile);
67
+ }
68
+
69
+ function defaultHermesHome() {
70
+ return path.join(os.homedir(), '.hermes');
71
+ }
72
+
73
+ async function ensureHermesGateway(agentConfig = {}, logger) {
74
+ const options = resolveHermesGatewayOptions(agentConfig);
75
+ let health = await checkGatewayHealth(options.gatewayUrl, agentConfig);
76
+ if (health.authFailed && !agentConfig.apiKey) {
77
+ const configKey = readApiServerKey(options.profileDir);
78
+ if (configKey) {
79
+ agentConfig.apiKey = configKey;
80
+ health = await checkGatewayHealth(options.gatewayUrl, agentConfig);
81
+ }
82
+ }
83
+ if (health.ok) return options.gatewayUrl;
84
+ if (health.authFailed) throw authError();
85
+ if (!options.autoStartGateway) {
86
+ throw new Error(`Hermes Gateway 未运行:${options.gatewayUrl}。当前 autoStartGateway=false,请启动外部 Gateway 或启用自动启动。`);
87
+ }
88
+ if (!isLoopbackHost(options.host)) {
89
+ throw new Error(`Hermes Gateway 自动启动仅支持本机地址:${options.gatewayUrl}。请使用 127.0.0.1/localhost,或设置 autoStartGateway=false 使用外部 Gateway。`);
90
+ }
91
+
92
+ const key = `${options.gatewayUrl}:${options.profileDir}`;
93
+ if (!bootstrapPromises.has(key)) {
94
+ bootstrapPromises.set(key, bootstrapGateway(options, agentConfig, logger).finally(() => {
95
+ bootstrapPromises.delete(key);
96
+ }));
97
+ }
98
+ await bootstrapPromises.get(key);
99
+ return options.gatewayUrl;
100
+ }
101
+
102
+ async function bootstrapGateway(options, agentConfig, logger) {
103
+ logger?.info?.('hermes gateway bootstrap starting', {
104
+ gatewayUrl: options.gatewayUrl,
105
+ profile: options.profile,
106
+ profileDir: options.profileDir,
107
+ });
108
+
109
+ const apiKey = ensureApiServerConfig({ profileDir: options.profileDir, host: options.host, port: options.port, apiKey: agentConfig.apiKey });
110
+ const gatewayAgentConfig = { ...agentConfig, apiKey };
111
+ if (!agentConfig.apiKey) agentConfig.apiKey = apiKey;
112
+ cleanStaleGatewayLock(options.profileDir, logger);
113
+ const child = startGateway({ hermesBin: options.hermesBin, profileDir: options.profileDir, gatewayUrl: options.gatewayUrl, logger });
114
+ await waitForGatewayReady(options.gatewayUrl, gatewayAgentConfig, {
115
+ timeoutMs: options.startTimeoutMs,
116
+ child,
117
+ });
118
+ logger?.info?.('hermes gateway bootstrap ready', { gatewayUrl: options.gatewayUrl });
119
+ }
120
+
121
+ async function checkGatewayHealth(gatewayUrl, agentConfig = {}, options = {}) {
122
+ const normalized = normalizeGatewayUrl(gatewayUrl);
123
+ const timeoutMs = options.timeoutMs || DEFAULT_HEALTH_TIMEOUT_MS;
124
+ const controller = new AbortController();
125
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
126
+ try {
127
+ const response = await fetch(`${normalized}/health`, {
128
+ method: 'GET',
129
+ headers: buildHealthHeaders(agentConfig),
130
+ signal: controller.signal,
131
+ });
132
+ return {
133
+ ok: response.ok,
134
+ status: response.status,
135
+ authFailed: response.status === 401,
136
+ error: null,
137
+ };
138
+ } catch (err) {
139
+ return { ok: false, status: null, authFailed: false, error: err };
140
+ } finally {
141
+ clearTimeout(timer);
142
+ }
143
+ }
144
+
145
+ function buildHealthHeaders(agentConfig) {
146
+ const headers = { Accept: 'application/json' };
147
+ if (agentConfig.apiKey) headers.Authorization = `Bearer ${agentConfig.apiKey}`;
148
+ return headers;
149
+ }
150
+
151
+ function ensureApiServerConfig({ profileDir, host, port, apiKey }) {
152
+ fs.mkdirSync(profileDir, { recursive: true });
153
+ const configFile = path.join(profileDir, 'config.yaml');
154
+ let cfg = {};
155
+ if (fs.existsSync(configFile)) {
156
+ try {
157
+ cfg = YAML.parse(fs.readFileSync(configFile, 'utf8')) || {};
158
+ } catch (err) {
159
+ throw new Error(`无法读取 Hermes 配置文件:${configFile}。${err.message}`);
160
+ }
161
+ }
162
+
163
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) cfg = {};
164
+ cfg.platforms = objectValue(cfg.platforms);
165
+ cfg.platforms.api_server = objectValue(cfg.platforms.api_server);
166
+ const apiServer = cfg.platforms.api_server;
167
+ apiServer.extra = objectValue(apiServer.extra);
168
+
169
+ apiServer.enabled = true;
170
+ if (apiServer.key == null || apiServer.key === '') apiServer.key = apiKey || crypto.randomBytes(24).toString('hex');
171
+ apiServer.cors_origins = apiServer.cors_origins || 'http://127.0.0.1:*';
172
+ apiServer.extra.host = host;
173
+ apiServer.extra.port = port;
174
+ delete apiServer.host;
175
+ delete apiServer.port;
176
+
177
+ try {
178
+ fs.writeFileSync(configFile, YAML.stringify(cfg, { lineWidth: 0 }));
179
+ } catch (err) {
180
+ throw new Error(`无法写入 Hermes 配置文件:${configFile}。请检查文件权限。${err.message}`);
181
+ }
182
+ return apiServer.key;
183
+ }
184
+
185
+ function readApiServerKey(profileDir) {
186
+ const configFile = path.join(profileDir, 'config.yaml');
187
+ if (!fs.existsSync(configFile)) return '';
188
+ try {
189
+ const cfg = YAML.parse(fs.readFileSync(configFile, 'utf8')) || {};
190
+ return cfg.platforms?.api_server?.key || '';
191
+ } catch {
192
+ return '';
193
+ }
194
+ }
195
+
196
+ function objectValue(value) {
197
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
198
+ }
199
+
200
+ function cleanStaleGatewayLock(profileDir, logger) {
201
+ if (process.platform !== 'win32') return;
202
+ const lockFile = path.join(profileDir, 'gateway.lock');
203
+ if (!fs.existsSync(lockFile)) return;
204
+ try {
205
+ const raw = fs.readFileSync(lockFile, 'utf8');
206
+ const lock = JSON.parse(raw || '{}');
207
+ const pid = Number(lock.pid);
208
+ if (pid && isProcessAlive(pid)) return;
209
+ fs.unlinkSync(lockFile);
210
+ } catch (err) {
211
+ logger?.warn?.('failed to clean stale hermes gateway lock', { lockFile, error: err.message });
212
+ }
213
+ }
214
+
215
+ function startGateway({ hermesBin, profileDir, gatewayUrl, logger }) {
216
+ let child;
217
+ try {
218
+ child = spawn(hermesBin, ['gateway', 'run', '--replace'], {
219
+ stdio: 'ignore',
220
+ windowsHide: true,
221
+ env: {
222
+ ...process.env,
223
+ HERMES_HOME: profileDir,
224
+ },
225
+ });
226
+ } catch (err) {
227
+ if (err.code === 'ENOENT') throw hermesBinError(hermesBin);
228
+ throw err;
229
+ }
230
+
231
+ if (!child.pid) throw hermesBinError(hermesBin);
232
+ const record = { child, gatewayUrl, profileDir, ready: false, earlyExit: null };
233
+ ownedGateways.set(`${gatewayUrl}:${profileDir}`, record);
234
+ registerCleanup();
235
+
236
+ child.once('error', err => {
237
+ record.earlyExit = err;
238
+ if (err.code === 'ENOENT') {
239
+ logger?.error?.('hermes gateway command not found', { hermesBin });
240
+ } else {
241
+ logger?.error?.('hermes gateway process error', { error: err.message });
242
+ }
243
+ });
244
+ child.once('exit', (code, signal) => {
245
+ if (!record.ready) record.earlyExit = new Error(`exit ${code ?? signal ?? 'unknown'}`);
246
+ logger?.info?.('hermes gateway process exited', { code, signal });
247
+ });
248
+
249
+ return child;
250
+ }
251
+
252
+ async function waitForGatewayReady(gatewayUrl, agentConfig = {}, options = {}) {
253
+ const timeoutMs = options.timeoutMs || DEFAULT_START_TIMEOUT_MS;
254
+ const pollIntervalMs = options.pollIntervalMs || DEFAULT_POLL_INTERVAL_MS;
255
+ const deadline = Date.now() + timeoutMs;
256
+ while (Date.now() < deadline) {
257
+ const ownedRecord = getOwnedGatewayRecord(gatewayUrl);
258
+ if (ownedRecord?.earlyExit) {
259
+ if (ownedRecord.earlyExit.code === 'ENOENT') throw hermesBinError(options.child?.spawnfile || 'hermes');
260
+ throw new Error('Hermes Gateway 启动失败:hermes gateway run --replace 进程提前退出。');
261
+ }
262
+ if (options.child?.exitCode != null || options.child?.signalCode) {
263
+ throw new Error('Hermes Gateway 启动失败:hermes gateway run --replace 进程提前退出。');
264
+ }
265
+ const health = await checkGatewayHealth(gatewayUrl, agentConfig, { timeoutMs: options.healthTimeoutMs || DEFAULT_HEALTH_TIMEOUT_MS });
266
+ if (health.ok) {
267
+ markOwnedGatewayReady(gatewayUrl);
268
+ return;
269
+ }
270
+ if (health.authFailed) throw authError();
271
+ await delay(pollIntervalMs);
272
+ }
273
+ throw new Error(`Hermes Gateway 启动超时:${normalizeGatewayUrl(gatewayUrl)}/health 未就绪。已尝试启用 api_server 并执行 hermes gateway run --replace,请检查 Hermes 配置、模型配置或端口占用。`);
274
+ }
275
+
276
+ function getOwnedGatewayRecord(gatewayUrl) {
277
+ const normalized = normalizeGatewayUrl(gatewayUrl);
278
+ for (const record of ownedGateways.values()) {
279
+ if (record.gatewayUrl === normalized) return record;
280
+ }
281
+ return null;
282
+ }
283
+
284
+ function markOwnedGatewayReady(gatewayUrl) {
285
+ const record = getOwnedGatewayRecord(gatewayUrl);
286
+ if (record) record.ready = true;
287
+ }
288
+
289
+ function hermesBinError(hermesBin) {
290
+ return new Error(`未找到 ${hermesBin} 命令。请先安装 Hermes Agent,或在 agents.hermes.hermesBin / LINCO_HERMES_BIN 中配置 Hermes 可执行文件路径。`);
291
+ }
292
+
293
+ function isLoopbackHost(host) {
294
+ const value = String(host || '').toLowerCase();
295
+ return value === 'localhost' || value === '127.0.0.1' || value === '::1' || value === '[::1]';
296
+ }
297
+
298
+ function authError() {
299
+ return new Error('Hermes Gateway 返回 401。请检查 agents.hermes.apiKey / LINCO_HERMES_API_KEY 是否与 Hermes api_server.key 一致。');
300
+ }
301
+
302
+ function isProcessAlive(pid) {
303
+ try {
304
+ process.kill(pid, 0);
305
+ return true;
306
+ } catch {
307
+ return false;
308
+ }
309
+ }
310
+
311
+ function registerCleanup() {
312
+ if (cleanupRegistered) return;
313
+ cleanupRegistered = true;
314
+ const cleanup = () => {
315
+ for (const record of ownedGateways.values()) {
316
+ try {
317
+ if (record.child && !record.child.killed) record.child.kill();
318
+ } catch {}
319
+ }
320
+ };
321
+ process.once('exit', cleanup);
322
+ for (const signal of ['SIGINT', 'SIGTERM']) {
323
+ process.once(signal, () => {
324
+ cleanup();
325
+ process.kill(process.pid, signal);
326
+ });
327
+ }
328
+ }
329
+
330
+ function delay(ms) {
331
+ return new Promise(resolve => setTimeout(resolve, ms));
332
+ }
333
+
334
+ module.exports = {
335
+ DEFAULT_GATEWAY_URL,
336
+ checkGatewayHealth,
337
+ ensureApiServerConfig,
338
+ ensureHermesGateway,
339
+ normalizeGatewayUrl,
340
+ resolveHermesGatewayOptions,
341
+ resolveHermesProfileDir,
342
+ };
package/src/serverApp.js CHANGED
@@ -5,6 +5,18 @@ const { startImConnectors } = require('./imConnector');
5
5
  const { createLogger } = require('./logger');
6
6
  const { attachWebSocketServer } = require('./wsServer');
7
7
 
8
+ async function prewarmHermesGateway(config) {
9
+ const hermes = config.agents?.hermes;
10
+ if (!hermes?.enabled || hermes.autoStartGateway === false) return;
11
+ try {
12
+ const { ensureHermesGateway } = require('./hermesGateway');
13
+ await ensureHermesGateway(hermes, config.logger);
14
+ config.logger?.info('hermes gateway pre-warmed', { gatewayUrl: hermes.gatewayUrl || 'http://127.0.0.1:8642' });
15
+ } catch (err) {
16
+ config.logger?.warn('hermes gateway pre-warm failed', { error: err.message });
17
+ }
18
+ }
19
+
8
20
  function startServer(rootDir, options = {}) {
9
21
  const config = options.config || loadConfig(rootDir);
10
22
  config.logger = options.logger || config.logger || createLogger(config);
@@ -52,6 +64,7 @@ function startServer(rootDir, options = {}) {
52
64
  const enabledAgents = Object.entries(config.agents || {}).filter(([, agent]) => agent.enabled).map(([type]) => type).join(', ');
53
65
  console.log(` 远端 IM: 已启用 (${config.im.channel}/${config.im.account}; agents: ${enabledAgents || 'none'})`);
54
66
  }
67
+ prewarmHermesGateway(config);
55
68
  options.onListening?.(server, config);
56
69
  });
57
70
 
@@ -77,6 +90,7 @@ function startServer(rootDir, options = {}) {
77
90
  }
78
91
  console.log('');
79
92
  console.log('💡 如需开启本地测试页,请运行 linco-connect start --local-im');
93
+ prewarmHermesGateway(config);
80
94
  options.onListening?.(null, config);
81
95
  return null;
82
96
  }