linco-connect 1.0.2 → 1.0.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/README.md CHANGED
@@ -104,6 +104,42 @@ linco-connect init \
104
104
  - 初始化不会询问或写入 `wsUrl`。
105
105
  - 多次执行 `init` 并传入不同 `--agent` 可在同一账号下启用多个 Agent。
106
106
 
107
+ ### Hermes Agent 初始化
108
+
109
+ Hermes 是 HTTP 网关模式,除了 `init` 命令外,还需要启用 Hermes 的 `api_server` 平台:
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
116
+ ```
117
+
118
+ 可通过 `hermes gateway status` 确认 Gateway 是否运行,也可用 `linco-connect doctor` 检查 Hermes Gateway 连通性。
119
+
120
+ #### 接口鉴权
121
+
122
+ 如果 Hermes 的 `api_server` 配置了 `key`(接口鉴权),需要在 `~/.linco/config.json` 中添加对应的 API Key:
123
+
124
+ ```json
125
+ {
126
+ "agents": {
127
+ "hermes": {
128
+ "enabled": true,
129
+ "apiKey": "你的API_SERVER_KEY值"
130
+ }
131
+ }
132
+ }
133
+ ```
134
+
135
+ 或设置环境变量:
136
+
137
+ ```bash
138
+ export LINCO_HERMES_API_KEY=你的API_SERVER_KEY值
139
+ ```
140
+
141
+ 如果连接时报 `401` 或 `fetch failed` 错误,请检查 Hermes 是否已启用 `api_server` 平台,以及是否正确配置了 API Key。
142
+
107
143
  ## 配置文件
108
144
 
109
145
  配置统一保存在用户主目录下的 `.linco` 根目录:
@@ -319,6 +355,8 @@ outbox 目录: .../outbox
319
355
  | `CODEX_BIN` | `codex` | Codex CLI 命令或路径 |
320
356
  | `LINCO_CLAUDE_ENABLED` | 无配置文件时为启用 | 是否启用 Claude 连接器 |
321
357
  | `LINCO_CODEX_ENABLED` | `false` | 是否启用 Codex 连接器 |
358
+ | `LINCO_HERMES_ENABLED` | `false` | 是否启用 Hermes 连接器 |
359
+ | `LINCO_HERMES_API_KEY` | 无 | Hermes Gateway 接口鉴权 Key |
322
360
  | `LINCO_CLAUDE_WS_URL` | `wss://chat.ddjf.info/socket/ai/claude` | Claude 端点覆盖 |
323
361
  | `LINCO_CODEX_WS_URL` | `wss://chat.ddjf.info/socket/ai/codex` | Codex 端点覆盖 |
324
362
  | `LINCO_LOCAL_AGENT` | `claude` | 本地测试页默认使用的 Agent |
@@ -376,6 +414,55 @@ outbox 目录: .../outbox
376
414
  - `outbox/` 用于 Agent 生成文件并自动下发给前端。
377
415
  - Codex 默认使用系统安装时的认证和配置,不会创建项目托管的 `codex-home`。
378
416
 
417
+ ## 故障排查
418
+
419
+ ### Hermes 模式报 "fetch failed" 错误
420
+
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
+ **修复方法**:
426
+
427
+ 方式一:使用 Hermes 命令行配置(推荐)
428
+
429
+ ```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
435
+ ```
436
+
437
+ 方式二:手动编辑配置文件
438
+
439
+ 编辑 Hermes 配置文件 `~/.hermes/config.yaml`,确保 `platforms` 中包含 `api_server`:
440
+
441
+ ```yaml
442
+ platforms:
443
+ linco:
444
+ enabled: true
445
+ api_server:
446
+ 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
456
+ ```
457
+
458
+ 确认端口已监听:
459
+
460
+ ```bash
461
+ netstat -ano | grep 8642
462
+ ```
463
+
464
+ 输出应包含 `127.0.0.1:8642` 处于 LISTENING 状态。
465
+
379
466
  ## 安全注意事项
380
467
 
381
468
  - 默认只监听 `127.0.0.1`,不建议直接暴露到公网。
@@ -50,7 +50,7 @@ async function main() {
50
50
  await stopCommand();
51
51
  break;
52
52
  case 'doctor':
53
- doctorCommand();
53
+ await doctorCommand();
54
54
  break;
55
55
  default:
56
56
  throw new Error(`未知命令: ${command}\n运行 linco-connect --help 查看用法。`);
@@ -105,9 +105,12 @@ function initCommand(options) {
105
105
  const agentType = options.agent ? options.agent.trim().toLowerCase() : null;
106
106
 
107
107
  if (agentType) {
108
- const agentBin = resolveCommand(agentType);
109
- if (!agentBin || !commandExists(agentType)) {
110
- throw new Error(`未检测到 ${agentType} CLI,请先安装 ${agentType} 再初始化。`);
108
+ // Hermes is an HTTP gateway service, not a CLI binary
109
+ if (agentType !== 'hermes') {
110
+ const agentBin = resolveCommand(agentType);
111
+ if (!agentBin || !commandExists(agentType)) {
112
+ throw new Error(`未检测到 ${agentType} CLI,请先安装 ${agentType} 再初始化。`);
113
+ }
111
114
  }
112
115
  }
113
116
 
@@ -393,7 +396,7 @@ function waitForDaemonStart(pid, pidFile, timeoutMs) {
393
396
  });
394
397
  }
395
398
 
396
- function doctorCommand() {
399
+ async function doctorCommand() {
397
400
  const checks = [];
398
401
  const config = loadConfig(rootDir);
399
402
  ensureLocalToken(config);
@@ -408,7 +411,13 @@ function doctorCommand() {
408
411
  checks.push(['本地测试 token', Boolean(config.localWeb?.token), '已生成']);
409
412
  for (const [agentType, agent] of Object.entries(config.agents || {})) {
410
413
  checks.push([`${agentType} Agent`, true, agent.enabled ? `已启用 ${safeUrlForDisplay(agent.wsUrl)}` : `未启用 ${safeUrlForDisplay(agent.wsUrl)}`]);
411
- checks.push([`${agentType} CLI`, !agent.enabled || (path.isAbsolute(agent.bin) ? fs.existsSync(agent.bin) : commandExists(agent.bin)), agent.bin]);
414
+ 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]);
418
+ } else {
419
+ checks.push([`${agentType} CLI`, !agent.enabled || (path.isAbsolute(agent.bin) ? fs.existsSync(agent.bin) : commandExists(agent.bin)), agent.bin]);
420
+ }
412
421
  }
413
422
  checks.push(['Git Bash', process.platform !== 'win32' || Boolean(findGitBash(readUserConfig(config.configFile))), process.platform === 'win32' ? (config.gitBashEnv || '未找到') : '非 Windows 不需要']);
414
423
  checks.push(['Linco Home', canEnsureWritable(config.lincoHome), config.lincoHome]);
@@ -425,6 +434,18 @@ function doctorCommand() {
425
434
  if (!ok) process.exitCode = 1;
426
435
  }
427
436
 
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
+
428
449
  function safeUrlForDisplay(value) {
429
450
  try {
430
451
  const url = new URL(value);
@@ -455,7 +476,7 @@ function printHelp() {
455
476
 
456
477
  说明:
457
478
  init 初始化本地配置,不需要填写 wsUrl
458
- start 启动本机 Agent 连接器和本地测试页
479
+ start 启动本机 Agent 连接器(本地测试页需加 --local-im 开启)
459
480
  stop 停止后台运行的 Linco Connect
460
481
  doctor 检查本地运行环境
461
482
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linco-connect",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "自研 IM 桥接多 Agent 服务",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -576,6 +576,7 @@
576
576
  <select id="agent-select" title="选择本地测试 Agent">
577
577
  <option value="claude">Claude Code</option>
578
578
  <option value="codex">Codex</option>
579
+ <option value="hermes">Hermes</option>
579
580
  </select>
580
581
  </div>
581
582
  <div id="messages"></div>
@@ -930,12 +931,21 @@
930
931
  }
931
932
 
932
933
  function addPermissionConfirm(data) {
934
+ const requestId = data.requestId || '';
935
+ const selector = requestId ? `.permission-confirm[data-request-id="${cssEscape(requestId)}"]` : '';
936
+ const existing = selector ? messagesEl.querySelector(selector) : null;
937
+ if (existing) {
938
+ scrollToBottom();
939
+ return;
940
+ }
941
+
933
942
  const container = document.createElement('div');
934
943
  container.className = 'danger-confirm permission-confirm';
944
+ container.dataset.requestId = requestId;
935
945
 
936
946
  const title = document.createElement('div');
937
947
  title.className = 'danger-text';
938
- title.textContent = `Claude 请求使用工具:${data.toolName || 'tool'}`;
948
+ title.textContent = `${agentLabel(currentAgentType)} 请求使用工具:${data.toolName || 'tool'}`;
939
949
  container.appendChild(title);
940
950
 
941
951
  if (data.input) {
@@ -1170,11 +1180,11 @@
1170
1180
 
1171
1181
  function normalizeAgentType(value) {
1172
1182
  const type = String(value || 'claude').trim().toLowerCase();
1173
- return ['claude', 'codex'].includes(type) ? type : 'claude';
1183
+ return ['claude', 'codex', 'hermes'].includes(type) ? type : 'claude';
1174
1184
  }
1175
1185
 
1176
1186
  function agentLabel(type) {
1177
- return type === 'codex' ? 'Codex' : 'Claude Code';
1187
+ return type === 'codex' ? 'Codex' : type === 'hermes' ? 'Hermes' : 'Claude Code';
1178
1188
  }
1179
1189
 
1180
1190
  function setAgentSelection(agentType, options = {}) {
@@ -1,9 +1,11 @@
1
1
  const claude = require('./agents/claude');
2
2
  const codex = require('./agents/codex');
3
+ const hermes = require('./agents/hermes');
3
4
 
4
5
  const providers = {
5
6
  claude,
6
7
  codex,
8
+ hermes,
7
9
  };
8
10
 
9
11
  function providerFor(session) {
@@ -319,12 +319,27 @@ function handleServerRequest(message, session) {
319
319
 
320
320
  // File change approval — auto-approve but notify user
321
321
  if (method === 'item/fileChange/requestApproval') {
322
+ const toolId = String(message.id);
323
+ const input = summarizeCodexParams(params);
322
324
  session._log?.info('codex auto-approving file change');
325
+ if (ws) {
326
+ send(ws, 'tool_call', {
327
+ id: toolId,
328
+ name: 'fileChange',
329
+ input,
330
+ });
331
+ }
323
332
  sendJsonRpc(session.codexAppServer, {
324
333
  jsonrpc: '2.0',
325
334
  id: message.id,
326
335
  result: { decision: 'accept' },
327
336
  });
337
+ if (ws) {
338
+ send(ws, 'tool_result', {
339
+ id: toolId,
340
+ output: 'approved',
341
+ });
342
+ }
328
343
  return;
329
344
  }
330
345
 
@@ -333,8 +348,11 @@ function handleServerRequest(message, session) {
333
348
  const cmd = params.command || params.tool || '';
334
349
  session._log?.info('codex command execution approval requested', { method, command: cmd });
335
350
 
351
+ if (session.pendingPermission?.requestId === String(message.id)) return;
352
+
336
353
  session.pendingPermission = {
337
354
  provider: 'codex',
355
+ requestId: String(message.id),
338
356
  toolName: 'exec',
339
357
  input: cmd,
340
358
  _codexMethod: method,
@@ -342,11 +360,6 @@ function handleServerRequest(message, session) {
342
360
  };
343
361
 
344
362
  if (ws) {
345
- send(ws, 'tool_call', {
346
- id: String(message.id),
347
- name: 'exec',
348
- input: cmd,
349
- });
350
363
  send(ws, 'permission_request', {
351
364
  requestId: String(message.id),
352
365
  toolName: 'exec',
@@ -369,12 +382,27 @@ function handleServerRequest(message, session) {
369
382
 
370
383
  // Apply patch approval — auto-approve
371
384
  if (method === 'applyPatchApproval') {
385
+ const toolId = String(message.id);
386
+ const input = summarizeCodexParams(params);
372
387
  session._log?.info('codex auto-approving patch');
388
+ if (ws) {
389
+ send(ws, 'tool_call', {
390
+ id: toolId,
391
+ name: 'applyPatch',
392
+ input,
393
+ });
394
+ }
373
395
  sendJsonRpc(session.codexAppServer, {
374
396
  jsonrpc: '2.0',
375
397
  id: message.id,
376
398
  result: { decision: 'approved' },
377
399
  });
400
+ if (ws) {
401
+ send(ws, 'tool_result', {
402
+ id: toolId,
403
+ output: 'approved',
404
+ });
405
+ }
378
406
  return;
379
407
  }
380
408
 
@@ -477,6 +505,25 @@ function handleAppServerMessage(message, session) {
477
505
  }
478
506
 
479
507
  if (method === 'item/completed') {
508
+ const itemType = params.item?.type || '';
509
+ session._log?.info('codex item completed', { itemType, item: summarizeCodexItemForLog(params.item) });
510
+ if (itemType === 'toolCall' || itemType === 'commandExecution' || itemType === 'webSearch') {
511
+ const itemId = params.item?.id || params.itemId || '';
512
+ if (itemType === 'webSearch' && params.item?.query) {
513
+ send(ws, 'tool_call', {
514
+ id: itemId,
515
+ name: 'webSearch',
516
+ input: params.item.query,
517
+ });
518
+ }
519
+ const output = params.item?.output || params.item?.result || params.item?.results || params.output || params.result || '';
520
+ send(ws, 'tool_result', {
521
+ id: itemId,
522
+ output: typeof output === 'string' ? output : JSON.stringify(output).slice(0, 1000),
523
+ });
524
+ return;
525
+ }
526
+
480
527
  // final content fallback if no deltas were emitted
481
528
  const text = extractFinalText(params);
482
529
  if (text && !session.sawPartialAssistantText) {
@@ -549,10 +596,16 @@ function handleAppServerMessage(message, session) {
549
596
 
550
597
  if (method === 'item/started') {
551
598
  const itemType = params.item?.type || '';
552
- session._log?.info('codex item started', { itemType });
553
- if (itemType === 'toolCall' || itemType === 'commandExecution') {
554
- const toolName = params.item?.name || params.item?.tool || params.item?.command || '';
555
- const toolInput = params.item?.input || params.item?.arguments || {};
599
+ session._log?.info('codex item started', { itemType, item: summarizeCodexItemForLog(params.item) });
600
+ if (itemType === 'toolCall' || itemType === 'commandExecution' || itemType === 'webSearch') {
601
+ const isCommand = itemType === 'commandExecution';
602
+ const isWebSearch = itemType === 'webSearch';
603
+ const toolName = isCommand ? 'exec' : isWebSearch ? 'webSearch' : (params.item?.name || params.item?.tool || '');
604
+ const toolInput = isCommand
605
+ ? (params.item?.command || '')
606
+ : isWebSearch
607
+ ? (params.item?.query || params.item?.input || params.item?.arguments || {})
608
+ : (params.item?.input || params.item?.arguments || {});
556
609
  const itemId = params.item?.id || params.itemId || '';
557
610
  if (toolName) {
558
611
  send(ws, 'tool_call', {
@@ -595,6 +648,30 @@ function extractFinalText(params) {
595
648
  return '';
596
649
  }
597
650
 
651
+ function summarizeCodexItemForLog(item) {
652
+ if (!item || typeof item !== 'object') return {};
653
+ const summary = {};
654
+ for (const key of Object.keys(item)) {
655
+ const value = item[key];
656
+ if (typeof value === 'string') {
657
+ summary[key] = value.slice(0, 200);
658
+ } else if (Array.isArray(value)) {
659
+ summary[key] = `[array:${value.length}]`;
660
+ } else if (value && typeof value === 'object') {
661
+ summary[key] = `[object:${Object.keys(value).slice(0, 10).join(',')}]`;
662
+ } else {
663
+ summary[key] = value;
664
+ }
665
+ }
666
+ return summary;
667
+ }
668
+
669
+ function summarizeCodexParams(params) {
670
+ if (!params || typeof params !== 'object') return '';
671
+ const input = params.path || params.file || params.filePath || params.command || params.patch || params.diff || params.input || params;
672
+ return typeof input === 'string' ? input.slice(0, 1000) : JSON.stringify(input).slice(0, 1000);
673
+ }
674
+
598
675
  function buildCodexInput(input, workspace) {
599
676
  if (Array.isArray(input)) {
600
677
  const result = [];
@@ -0,0 +1,470 @@
1
+ const { isDangerousCommand } = require('../danger');
2
+ const { send, sendError, sendSystem } = require('../protocol');
3
+ const { persistAgentSessionId, stopAgentProcess: stopSessionProcess, updateAgentSessionHistory, createAgentSessionEntry, saveSessionMetadata } = require('../session');
4
+ const { getOutboxDir } = require('../outgoingAttachmentHandler');
5
+ const { createTextStreamBuffer, appendTextStream, flushTextStream, resetTextStream } = require('../streamBuffer');
6
+
7
+ const DEFAULT_GATEWAY_URL = 'http://127.0.0.1:8642';
8
+
9
+ function extractText(input) {
10
+ if (!Array.isArray(input)) return String(input || '');
11
+ return input
12
+ .filter(block => block?.type === 'text')
13
+ .map(block => block.text || '')
14
+ .join('\n');
15
+ }
16
+
17
+ function execute(input, ws, session, config) {
18
+ const textForCheck = stringifyInput(input);
19
+ if (isDangerousCommand(textForCheck)) {
20
+ const preview = textForCheck.slice(0, 200);
21
+ session.pendingDanger = { input };
22
+ send(ws, 'danger_warning', {
23
+ text: `⚠️ 检测到可能的危险操作,请确认是否继续执行:\n\n"${preview}${textForCheck.length > 200 ? '...' : ''}"`,
24
+ });
25
+ return;
26
+ }
27
+
28
+ if (session.isTurnActive) {
29
+ if (session.messageQueue.length >= config.maxMessageQueue) {
30
+ sendError(ws, '消息队列已满,请稍后再试');
31
+ return;
32
+ }
33
+ session.messageQueue.push(input);
34
+ sendSystem(ws, `Hermes 正在处理上一条消息,已加入队列(${session.messageQueue.length})`);
35
+ return;
36
+ }
37
+
38
+ runHermesTurn(input, ws, session, config);
39
+ }
40
+
41
+ async function runHermesTurn(input, ws, session, config) {
42
+ session.isTurnActive = true;
43
+ session.currentInputForNoOutput = input;
44
+ session.sawPartialAssistantText = false;
45
+ session._lastWs = ws;
46
+ session._lastConfig = config;
47
+ resetHermesAssistantText(session);
48
+
49
+ const agentConfig = config.agents?.hermes || {};
50
+ const gatewayUrl = normalizeGatewayUrl(agentConfig.gatewayUrl || agentConfig.baseUrl || DEFAULT_GATEWAY_URL);
51
+ const hermesSessionId = ensureHermesSessionId(session);
52
+ const inputWithOutbox = maybeAddOutboxHint(input, session, config);
53
+
54
+ try {
55
+ const run = await createRun(gatewayUrl, agentConfig, inputWithOutbox, hermesSessionId);
56
+ if (!run?.run_id) throw new Error('Hermes Gateway 未返回 run_id');
57
+ session.hermesRunId = run.run_id;
58
+ config.logger?.info('hermes run started', { runId: run.run_id, sessionId: hermesSessionId });
59
+ await streamRunEvents(gatewayUrl, agentConfig, run.run_id, ws, session, config);
60
+ if (session.isTurnActive) {
61
+ sendSystem(ws, 'Hermes 事件流已关闭。');
62
+ finishTurn(ws, session, config);
63
+ }
64
+ } catch (err) {
65
+ if (!isAbortError(err)) {
66
+ sendError(ws, `Hermes 错误: ${err.message}`);
67
+ }
68
+ finishTurn(ws, session, config, { drain: !isAbortError(err) });
69
+ }
70
+ }
71
+
72
+ async function createRun(gatewayUrl, agentConfig, input, sessionId) {
73
+ const body = {
74
+ input: buildHermesInput(input),
75
+ session_id: sessionId,
76
+ };
77
+ if (agentConfig.model) body.model = agentConfig.model;
78
+ if (agentConfig.instructions) body.instructions = agentConfig.instructions;
79
+
80
+ const response = await fetchJson(`${gatewayUrl}/v1/runs`, agentConfig, {
81
+ method: 'POST',
82
+ body: JSON.stringify(body),
83
+ });
84
+ return response;
85
+ }
86
+
87
+ async function streamRunEvents(gatewayUrl, agentConfig, runId, ws, session, config) {
88
+ const controller = new AbortController();
89
+ session.hermesAbortController = controller;
90
+ session.agentProcess = createHermesProcessHandle(gatewayUrl, agentConfig, runId, controller);
91
+
92
+ const response = await fetch(`${gatewayUrl}/v1/runs/${encodeURIComponent(runId)}/events`, {
93
+ method: 'GET',
94
+ headers: buildHeaders(agentConfig),
95
+ signal: controller.signal,
96
+ });
97
+
98
+ if (!response.ok) {
99
+ throw new Error(await responseErrorText(response));
100
+ }
101
+
102
+ let buffer = '';
103
+ const decoder = new TextDecoder();
104
+ for await (const chunk of response.body) {
105
+ buffer += decoder.decode(chunk, { stream: true });
106
+ const frames = buffer.split(/\r?\n\r?\n/);
107
+ buffer = frames.pop() || '';
108
+ for (const frame of frames) {
109
+ const event = parseSseFrame(frame);
110
+ if (event) handleHermesEvent(event, ws, session, config);
111
+ if (!session.isTurnActive) return;
112
+ }
113
+ }
114
+
115
+ if (buffer.trim()) {
116
+ const event = parseSseFrame(buffer);
117
+ if (event) handleHermesEvent(event, ws, session, config);
118
+ }
119
+ }
120
+
121
+ function parseSseFrame(frame) {
122
+ const data = frame
123
+ .split(/\r?\n/)
124
+ .filter(line => line.startsWith('data:'))
125
+ .map(line => line.slice(5).trimStart())
126
+ .join('\n')
127
+ .trim();
128
+ if (!data || data === '[DONE]') return null;
129
+ try {
130
+ return JSON.parse(data);
131
+ } catch {
132
+ return null;
133
+ }
134
+ }
135
+
136
+ function handleHermesEvent(event, ws, session, config) {
137
+ switch (event.event) {
138
+ case 'message.delta':
139
+ if (event.delta) appendHermesAssistantText(event.delta, ws, session);
140
+ return;
141
+ case 'tool.started':
142
+ send(ws, 'tool_call', {
143
+ id: toolId(event),
144
+ name: event.tool || 'Hermes Tool',
145
+ input: event.preview || '',
146
+ });
147
+ return;
148
+ case 'tool.completed':
149
+ send(ws, 'tool_result', {
150
+ toolUseId: toolId(event),
151
+ output: formatToolCompleted(event),
152
+ isError: event.error === true,
153
+ });
154
+ return;
155
+ case 'reasoning.available':
156
+ send(ws, 'thinking', { text: event.text || '' });
157
+ return;
158
+ case 'approval.request':
159
+ handleApprovalRequest(event, ws, session);
160
+ return;
161
+ case 'approval.responded':
162
+ sendSystem(ws, event.choice === 'deny' ? '🚫 已拒绝 Hermes 操作。' : '✅ 已批准 Hermes 操作。');
163
+ return;
164
+ case 'run.completed':
165
+ completeRun(event, ws, session, config);
166
+ return;
167
+ case 'run.failed':
168
+ sendError(ws, event.error || 'Hermes run 执行失败');
169
+ finishTurn(ws, session, config);
170
+ return;
171
+ case 'run.cancelled':
172
+ sendSystem(ws, 'Hermes run 已停止。');
173
+ finishTurn(ws, session, config);
174
+ return;
175
+ default:
176
+ return;
177
+ }
178
+ }
179
+
180
+ function handleApprovalRequest(event, ws, session) {
181
+ const requestId = `hermes-${event.run_id || session.hermesRunId}-${Date.now()}`;
182
+ const input = event.command || event.description || event.preview || '';
183
+ session.pendingPermission = {
184
+ provider: 'hermes',
185
+ requestId,
186
+ runId: event.run_id || session.hermesRunId,
187
+ toolName: 'exec',
188
+ input,
189
+ choices: event.choices || [],
190
+ };
191
+ send(ws, 'permission_request', {
192
+ requestId,
193
+ toolName: 'exec',
194
+ input,
195
+ });
196
+ }
197
+
198
+ function completeRun(event, ws, session, config) {
199
+ if (!session.streamState?.assistantStarted && event.output) {
200
+ appendHermesAssistantText(event.output, ws, session);
201
+ }
202
+ const hadOutput = session.streamState?.assistantStarted || Boolean(event.output);
203
+ flushHermesAssistantText(ws, session);
204
+ if (hadOutput) {
205
+ send(ws, 'assistant_end', {});
206
+ } else {
207
+ sendSystem(ws, 'Hermes 本次执行没有输出。');
208
+ }
209
+ updateHermesSessionStats(session, event.usage);
210
+ finishTurn(ws, session, config);
211
+ }
212
+
213
+ function finishTurn(ws, session, config, options = {}) {
214
+ const { drain = true } = options;
215
+ if (session.hermesAbortController) {
216
+ try {
217
+ session.hermesAbortController.abort();
218
+ } catch {}
219
+ }
220
+ session.hermesAbortController = null;
221
+ session.hermesRunId = null;
222
+ session.isTurnActive = false;
223
+ session.currentInputForNoOutput = null;
224
+ session.pendingPermission = null;
225
+ flushHermesAssistantText(ws, session);
226
+ resetHermesAssistantText(session);
227
+ if (drain) drainQueue(ws, session, config);
228
+ }
229
+
230
+ function ensureHermesSessionId(session) {
231
+ if (!session.agentSessionId) {
232
+ const newId = `hermes-${crypto.randomUUID().slice(0, 12)}`;
233
+ session.agentSessionId = newId;
234
+ session.claudeSessionId = newId;
235
+ // Capture the first message text from the turn input for /list display
236
+ const firstMsg = session.currentInputForNoOutput
237
+ ? extractText(session.currentInputForNoOutput).slice(0, 200)
238
+ : '';
239
+ if (!session.agentSessionHistory) session.agentSessionHistory = [];
240
+ for (const entry of session.agentSessionHistory) {
241
+ entry.isActive = false;
242
+ }
243
+ const entry = createAgentSessionEntry(session, newId, firstMsg);
244
+ entry.isActive = true;
245
+ session.agentSessionHistory.push(entry);
246
+ saveSessionMetadata(session);
247
+ }
248
+ return session.agentSessionId;
249
+ }
250
+
251
+ function updateHermesSessionStats(session, usage = {}) {
252
+ session.messageCount = (session.messageCount || 0) + 1;
253
+ if (!session.usage) {
254
+ session.usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
255
+ }
256
+ session.usage.inputTokens += usage.input_tokens || usage.inputTokens || 0;
257
+ session.usage.outputTokens += usage.output_tokens || usage.outputTokens || 0;
258
+ updateAgentSessionHistory(session);
259
+ }
260
+
261
+ function buildHermesInput(input) {
262
+ if (!Array.isArray(input)) {
263
+ return [{ role: 'user', content: String(input || '') }];
264
+ }
265
+ const contentParts = [];
266
+ for (const block of input) {
267
+ if (typeof block === 'string') {
268
+ contentParts.push({ type: 'text', text: block });
269
+ } else if (block?.type === 'text') {
270
+ contentParts.push({ type: 'text', text: block.text || '' });
271
+ } else if (block?.type === 'image') {
272
+ const mediaType = block.source?.media_type || 'image/png';
273
+ const data = block.source?.data;
274
+ if (data) {
275
+ contentParts.push({ type: 'image_url', image_url: { url: `data:${mediaType};base64,${data}` } });
276
+ } else if (block.path) {
277
+ contentParts.push({ type: 'text', text: `用户发送了一张图片,文件路径:${block.path}` });
278
+ } else {
279
+ contentParts.push({ type: 'text', text: '[图片附件]' });
280
+ }
281
+ } else {
282
+ contentParts.push({ type: 'text', text: JSON.stringify(block) });
283
+ }
284
+ }
285
+ return [{ role: 'user', content: contentParts }];
286
+ }
287
+
288
+ function maybeAddImageDataUrls(input) {
289
+ if (!Array.isArray(input)) return String(input || '');
290
+ return input.map(block => {
291
+ if (typeof block === 'string') return block;
292
+ if (block?.type === 'text') return block.text || '';
293
+ if (block?.type === 'image') {
294
+ const mediaType = block.source?.media_type || 'image/png';
295
+ const data = block.source?.data;
296
+ if (data) return `用户发送了一张图片:data:${mediaType};base64,${data}`;
297
+ if (block.path) return `用户发送了一张图片,文件路径:${block.path}`;
298
+ return '[图片附件]';
299
+ }
300
+ return JSON.stringify(block);
301
+ }).filter(Boolean).join('\n');
302
+ }
303
+
304
+ function stringifyInput(input) {
305
+ return maybeAddImageDataUrls(input);
306
+ }
307
+
308
+ function maybeAddOutboxHint(input, session, config) {
309
+ const text = stringifyInput(input);
310
+ if (!/发送.*(文件|图片)|把.*(文件|图片).*发|发给我|发送给我|文件发送|发文件|下载文件/.test(text)) {
311
+ return input;
312
+ }
313
+ const hint = `系统提示:用户正在要求发送文件/图片。请将要发送给用户的最终文件保存或复制到以下 outbox 目录,系统只会自动发送这个目录中的新文件:\n${getOutboxDir(session, config)}`;
314
+ if (Array.isArray(input)) return [...input, { type: 'text', text: hint }];
315
+ return `${text}\n\n${hint}`;
316
+ }
317
+
318
+ function appendHermesAssistantText(text, ws, session) {
319
+ ensureHermesStreamState(session);
320
+ appendTextStream(text, ws, session.streamState);
321
+ }
322
+
323
+ function flushHermesAssistantText(ws, session) {
324
+ flushTextStream(ws, session.streamState);
325
+ }
326
+
327
+ function resetHermesAssistantText(session) {
328
+ ensureHermesStreamState(session);
329
+ resetTextStream(session.streamState);
330
+ }
331
+
332
+ function ensureHermesStreamState(session) {
333
+ if (!session.streamState) {
334
+ session.streamState = createTextStreamBuffer({ onStart: ws => send(ws, 'assistant_start', {}) });
335
+ }
336
+ session.streamState.onStart = ws => send(ws, 'assistant_start', {});
337
+ }
338
+
339
+ function toolId(event) {
340
+ return `${event.run_id || 'hermes'}:${event.tool || 'tool'}`;
341
+ }
342
+
343
+ function formatToolCompleted(event) {
344
+ const duration = typeof event.duration === 'number' ? `,耗时 ${event.duration}s` : '';
345
+ return `${event.tool || '工具'} ${event.error ? '执行失败' : '执行完成'}${duration}`;
346
+ }
347
+
348
+ async function resolvePendingDanger(confirmed, ws, session, config) {
349
+ const pending = session.pendingDanger;
350
+ if (!pending) return false;
351
+ session.pendingDanger = null;
352
+ if (!confirmed) {
353
+ sendSystem(ws, '已取消危险操作。');
354
+ return true;
355
+ }
356
+ execute(pending.input, ws, session, config);
357
+ return true;
358
+ }
359
+
360
+ async function resolvePendingPermission(approved, ws, session, config) {
361
+ const pending = session.pendingPermission;
362
+ if (!pending || pending.provider !== 'hermes') return false;
363
+
364
+ const agentConfig = config.agents?.hermes || {};
365
+ const gatewayUrl = normalizeGatewayUrl(agentConfig.gatewayUrl || agentConfig.baseUrl || DEFAULT_GATEWAY_URL);
366
+ const runId = pending.runId || session.hermesRunId;
367
+ session.pendingPermission = null;
368
+
369
+ try {
370
+ await fetchJson(`${gatewayUrl}/v1/runs/${encodeURIComponent(runId)}/approval`, agentConfig, {
371
+ method: 'POST',
372
+ body: JSON.stringify({ choice: approved ? 'once' : 'deny' }),
373
+ });
374
+ sendSystem(ws, approved ? '✅ 已批准 Hermes 操作。' : '🚫 已拒绝 Hermes 操作。');
375
+ } catch (err) {
376
+ sendError(ws, `Hermes 审批响应失败: ${err.message}`);
377
+ }
378
+ return true;
379
+ }
380
+
381
+ function stop(session, options = {}) {
382
+ const agentConfig = session._lastConfig?.agents?.hermes || {};
383
+ const runId = session.hermesRunId;
384
+ if (session.hermesAbortController) {
385
+ try {
386
+ session.hermesAbortController.abort();
387
+ } catch {}
388
+ }
389
+ if (runId) {
390
+ const gatewayUrl = normalizeGatewayUrl(agentConfig.gatewayUrl || agentConfig.baseUrl || DEFAULT_GATEWAY_URL);
391
+ fetchJson(`${gatewayUrl}/v1/runs/${encodeURIComponent(runId)}/stop`, agentConfig, { method: 'POST' }).catch(() => {});
392
+ }
393
+ session.hermesAbortController = null;
394
+ session.hermesRunId = null;
395
+ stopSessionProcess(session, options);
396
+ }
397
+
398
+ function createHermesProcessHandle(gatewayUrl, agentConfig, runId, controller) {
399
+ const handle = {
400
+ killed: false,
401
+ exitCode: null,
402
+ stdin: {
403
+ destroyed: false,
404
+ end() {
405
+ this.destroyed = true;
406
+ handle.kill();
407
+ },
408
+ },
409
+ kill() {
410
+ if (this.killed) return;
411
+ this.killed = true;
412
+ this.exitCode = 0;
413
+ try {
414
+ controller.abort();
415
+ } catch {}
416
+ fetchJson(`${gatewayUrl}/v1/runs/${encodeURIComponent(runId)}/stop`, agentConfig, { method: 'POST' }).catch(() => {});
417
+ },
418
+ };
419
+ return handle;
420
+ }
421
+ function drainQueue(ws, session, config) {
422
+ const next = session.messageQueue.shift();
423
+ if (!next) return;
424
+ setImmediate(() => execute(next, ws, session, config));
425
+ }
426
+
427
+ function normalizeGatewayUrl(value) {
428
+ return String(value || DEFAULT_GATEWAY_URL).replace(/\/+$/, '');
429
+ }
430
+
431
+ function buildHeaders(agentConfig) {
432
+ const headers = { Accept: 'application/json' };
433
+ if (agentConfig.apiKey) headers.Authorization = `Bearer ${agentConfig.apiKey}`;
434
+ return headers;
435
+ }
436
+
437
+ async function fetchJson(url, agentConfig, options = {}) {
438
+ const response = await fetch(url, {
439
+ ...options,
440
+ headers: {
441
+ ...buildHeaders(agentConfig),
442
+ 'Content-Type': 'application/json',
443
+ ...(options.headers || {}),
444
+ },
445
+ });
446
+ if (!response.ok) throw new Error(await responseErrorText(response));
447
+ return response.json();
448
+ }
449
+
450
+ async function responseErrorText(response) {
451
+ const text = await response.text().catch(() => '');
452
+ if (!text) return `${response.status} ${response.statusText}`;
453
+ try {
454
+ const parsed = JSON.parse(text);
455
+ return parsed.error?.message || text;
456
+ } catch {
457
+ return text;
458
+ }
459
+ }
460
+
461
+ function isAbortError(err) {
462
+ return err?.name === 'AbortError';
463
+ }
464
+
465
+ module.exports = {
466
+ execute,
467
+ resolvePendingDanger,
468
+ resolvePendingPermission,
469
+ stop,
470
+ };
package/src/config.js CHANGED
@@ -9,6 +9,7 @@ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
9
9
  const DEFAULT_AGENT_WS_URLS = {
10
10
  claude: 'wss://chat.ddjf.info/socket/ai/claude',
11
11
  codex: 'wss://chat.ddjf.info/socket/ai/codex',
12
+ hermes: 'wss://chat.ddjf.info/socket/ai/hermes',
12
13
  };
13
14
  const DEFAULT_LINCO_WS_URL = DEFAULT_AGENT_WS_URLS.claude;
14
15
  function getConfigDir() {
@@ -220,6 +221,11 @@ function localImEnabled(userConfig) {
220
221
  return booleanFromEnv('LINCO_LOCAL_IM_ENABLED', userConfig.localWeb?.imEnabled === true);
221
222
  }
222
223
 
224
+ function localWebEnabled(userConfig) {
225
+ // 本地测试页跟随本地模拟IM的开关,--local-im 时一并启用
226
+ return booleanFromEnv('LINCO_LOCAL_WEB_ENABLED', userConfig.localWeb?.enabled === true || localImEnabled(userConfig));
227
+ }
228
+
223
229
  function hasOwn(object, key) {
224
230
  return Object.prototype.hasOwnProperty.call(object || {}, key);
225
231
  }
@@ -245,6 +251,9 @@ function agentConfig(userConfig, imConfig, agentType, defaults = {}) {
245
251
  ? selectedImEnabled(userConfig, imConfig)
246
252
  : false;
247
253
  const channelAgentConfig = agentConfigFromChannels(userConfig, agentType);
254
+ const channelAccount = channelAgentConfig
255
+ ? channelAgentConfig.accounts?.[channelAgentConfig.defaultAccount || 'default']
256
+ : null;
248
257
  const channelAccountEnabled = channelAgentConfig && Object.values(channelAgentConfig.accounts || {}).some(
249
258
  account => account.enabled === true
250
259
  );
@@ -253,6 +262,11 @@ function agentConfig(userConfig, imConfig, agentType, defaults = {}) {
253
262
  const enabled = booleanFromEnv(`LINCO_${agentType.toUpperCase()}_ENABLED`, enabledFallback);
254
263
  const binEnv = `LINCO_${agentType.toUpperCase()}_BIN`;
255
264
  const wsEnv = `LINCO_${agentType.toUpperCase()}_WS_URL`;
265
+ const gatewayEnv = `LINCO_${agentType.toUpperCase()}_GATEWAY_URL`;
266
+ const apiKeyEnv = `LINCO_${agentType.toUpperCase()}_API_KEY`;
267
+ const instructionsEnv = `LINCO_${agentType.toUpperCase()}_INSTRUCTIONS`;
268
+ const appIdEnv = `LINCO_${agentType.toUpperCase()}_APP_ID`;
269
+ const appSecretEnv = `LINCO_${agentType.toUpperCase()}_APP_SECRET`;
256
270
 
257
271
  return {
258
272
  type: agentType,
@@ -261,6 +275,11 @@ function agentConfig(userConfig, imConfig, agentType, defaults = {}) {
261
275
  wsUrl: stringFromEnv(wsEnv, configured.wsUrl || defaults.wsUrl),
262
276
  mode: stringFromEnv(`LINCO_${agentType.toUpperCase()}_MODE`, configured.mode || defaults.mode),
263
277
  model: stringFromEnv(`LINCO_${agentType.toUpperCase()}_MODEL`, configured.model || defaults.model),
278
+ gatewayUrl: stringFromEnv(gatewayEnv, configured.gatewayUrl || defaults.gatewayUrl),
279
+ apiKey: stringFromEnv(apiKeyEnv, configured.apiKey || defaults.apiKey),
280
+ instructions: stringFromEnv(instructionsEnv, configured.instructions || defaults.instructions),
281
+ appId: stringFromEnv(appIdEnv, channelAccount?.appId || configured.appId),
282
+ appSecret: stringFromEnv(appSecretEnv, channelAccount?.appSecret || configured.appSecret),
264
283
  };
265
284
  }
266
285
 
@@ -288,6 +307,7 @@ function loadConfig(rootDir) {
288
307
  const sessionsDir = stringFromEnv('LINCO_SESSIONS_DIR', userConfig.sessionsDir || path.join(lincoHome, 'sessions'));
289
308
  const accountAgentType = imConfig.agentType;
290
309
  const codexAccountConfig = defaultAccountFromChannels(userConfig, 'codex');
310
+ const hermesAccountConfig = defaultAccountFromChannels(userConfig, 'hermes');
291
311
  const agents = {
292
312
  claude: agentConfig(userConfig, imConfig, 'claude', {
293
313
  bin: stringFromEnv('CLAUDE_BIN', userConfig.claudeBin || 'claude'),
@@ -297,6 +317,10 @@ function loadConfig(rootDir) {
297
317
  bin: stringFromEnv('CODEX_BIN', userConfig.codexBin || codexAccountConfig.bin || 'codex'),
298
318
  wsUrl: codexAccountConfig.wsUrl || DEFAULT_AGENT_WS_URLS.codex,
299
319
  }),
320
+ hermes: agentConfig(userConfig, imConfig, 'hermes', {
321
+ wsUrl: hermesAccountConfig.wsUrl || DEFAULT_AGENT_WS_URLS.hermes,
322
+ gatewayUrl: 'http://127.0.0.1:8642',
323
+ }),
300
324
  };
301
325
  const accountAgent = agents[accountAgentType];
302
326
  const imEnabled = selectedImEnabled(userConfig, imConfig) || (accountAgent && accountAgent.enabled);
@@ -330,6 +354,7 @@ function loadConfig(rootDir) {
330
354
  configFile,
331
355
  localWeb: {
332
356
  ...(userConfig.localWeb || {}),
357
+ enabled: localWebEnabled(userConfig),
333
358
  imEnabled: localImEnabled(userConfig),
334
359
  },
335
360
  logsDir: path.join(lincoHome, 'logs'),
package/src/httpStatic.js CHANGED
@@ -101,11 +101,11 @@ function buildClientWebSocketUrl(req, config) {
101
101
 
102
102
  function localAgentOptions(config) {
103
103
  const agents = config.agents || {};
104
- return ['claude', 'codex']
104
+ return ['claude', 'codex', 'hermes']
105
105
  .filter(type => agents[type])
106
106
  .map(type => ({
107
107
  type,
108
- label: type === 'claude' ? 'Claude Code' : type === 'codex' ? 'Codex' : type,
108
+ label: type === 'claude' ? 'Claude Code' : type === 'codex' ? 'Codex' : type === 'hermes' ? 'Hermes' : type,
109
109
  enabled: agents[type]?.enabled === true,
110
110
  }));
111
111
  }
@@ -49,7 +49,9 @@ class ImConnector {
49
49
  }
50
50
 
51
51
  start() {
52
- if (!this.config.im?.appId || !this.config.im?.appSecret) {
52
+ const appId = this.agentConfig.appId || this.config.im.appId;
53
+ const appSecret = this.agentConfig.appSecret || this.config.im.appSecret;
54
+ if (!appId || !appSecret) {
53
55
  console.log(`[IM:${this.agentType}] 远端 IM 已启用,但缺少 Linco token,已跳过连接。`);
54
56
  return;
55
57
  }
@@ -149,7 +151,9 @@ class ImConnector {
149
151
  }
150
152
  url.searchParams.delete('appId');
151
153
  url.searchParams.delete('appSecret');
152
- url.searchParams.set('token', `${this.config.im.appId}:${this.config.im.appSecret}`);
154
+ const appId = this.agentConfig.appId || this.config.im.appId;
155
+ const appSecret = this.agentConfig.appSecret || this.config.im.appSecret;
156
+ url.searchParams.set('token', `${appId}:${appSecret}`);
153
157
  return url.toString();
154
158
  }
155
159
 
@@ -438,7 +442,7 @@ function sendSessionInfo(ws, session, config) {
438
442
  },
439
443
  capabilities: {
440
444
  incomingAttachments: true,
441
- multimodalImages: session.agentType === 'claude',
445
+ multimodalImages: ['claude', 'hermes'].includes(session.agentType),
442
446
  outgoingAttachments: true,
443
447
  remoteIm: true,
444
448
  agentType: session.agentType,
@@ -36,7 +36,7 @@ function createLincoAdapter(rawWs, session, config) {
36
36
  }
37
37
  const payload = mapLocalEventToLinco(event, session, config, linco);
38
38
  if (!payload || closed.current) return;
39
- const wrapped = wrapLincoEnvelope(payload, config);
39
+ const wrapped = wrapLincoEnvelope(payload, config, session);
40
40
  if (Array.isArray(wrapped)) {
41
41
  for (const item of wrapped) rawWs.send(JSON.stringify(item));
42
42
  return;
@@ -55,11 +55,11 @@ function createLincoAdapter(rawWs, session, config) {
55
55
  };
56
56
  }
57
57
 
58
- function wrapLincoEnvelope(payload, config) {
58
+ function wrapLincoEnvelope(payload, config, session) {
59
59
  const meta = lincoMetaDefaults(config, {});
60
60
  return pruneUndefined({
61
61
  ...payload,
62
- from: payload.from || (config.agents ? Object.keys(config.agents)[0] : 'claude'),
62
+ from: payload.from || session?.agentType || (config.agents ? Object.keys(config.agents)[0] : 'claude'),
63
63
  to: payload.to || 'robot',
64
64
  source: payload.source || 'ws',
65
65
  ts: payload.ts || Date.now(),
package/src/serverApp.js CHANGED
@@ -15,33 +15,58 @@ function startServer(rootDir, options = {}) {
15
15
  log.info('git bash detected', { path: config.gitBashEnv });
16
16
  }
17
17
 
18
- const server = createStaticServer(config);
19
- attachWebSocketServer(server, config);
20
18
  const imConnectors = startImConnectors(config);
21
19
 
22
- server.on('close', () => {
23
- log.info('server closing');
24
- for (const connector of imConnectors) connector.stop();
25
- options.onClose?.(server, config);
26
- });
20
+ if (config.localWeb?.enabled) {
21
+ const server = createStaticServer(config);
22
+ attachWebSocketServer(server, config);
27
23
 
28
- server.listen(config.port, config.host, () => {
24
+ server.on('close', () => {
25
+ log.info('server closing');
26
+ for (const connector of imConnectors) connector.stop();
27
+ options.onClose?.(server, config);
28
+ });
29
+
30
+ server.listen(config.port, config.host, () => {
31
+ ensureDir(config.lincoHome);
32
+ ensureDir(config.sessionsDir);
33
+ ensureDir(config.logsDir);
34
+ const localUrl = localUrlWithToken(config);
35
+ log.info('server started', {
36
+ host: config.host,
37
+ port: config.port,
38
+ lincoHome: config.lincoHome,
39
+ sessionsDir: config.sessionsDir,
40
+ imEnabled: !!config.im?.enabled,
41
+ agents: Object.entries(config.agents || {}).filter(([, agent]) => agent.enabled).map(([type]) => type),
42
+ });
43
+ console.log('🚀 IM + Agent 桥接服务已启动');
44
+ console.log('');
45
+ console.log('📋 请复制下面的完整地址到浏览器打开本地测试页:');
46
+ console.log(localUrl);
47
+ console.log('');
48
+ console.log(` Linco 运行目录: ${config.lincoHome}`);
49
+ console.log(` 会话目录: ${config.sessionsDir}`);
50
+ console.log(` 日志级别: ${config.logLevel}`);
51
+ if (config.im?.enabled) {
52
+ const enabledAgents = Object.entries(config.agents || {}).filter(([, agent]) => agent.enabled).map(([type]) => type).join(', ');
53
+ console.log(` 远端 IM: 已启用 (${config.im.channel}/${config.im.account}; agents: ${enabledAgents || 'none'})`);
54
+ }
55
+ options.onListening?.(server, config);
56
+ });
57
+
58
+ return server;
59
+ } else {
29
60
  ensureDir(config.lincoHome);
30
61
  ensureDir(config.sessionsDir);
31
62
  ensureDir(config.logsDir);
32
- const localUrl = localUrlWithToken(config);
33
- log.info('server started', {
34
- host: config.host,
35
- port: config.port,
63
+ log.info('server started (no local web)', {
36
64
  lincoHome: config.lincoHome,
37
65
  sessionsDir: config.sessionsDir,
38
66
  imEnabled: !!config.im?.enabled,
39
67
  agents: Object.entries(config.agents || {}).filter(([, agent]) => agent.enabled).map(([type]) => type),
40
68
  });
41
- console.log('🚀 IM + Agent 桥接服务已启动');
42
- console.log('');
43
- console.log('📋 请复制下面的完整地址到浏览器打开本地测试页:');
44
- console.log(localUrl);
69
+ console.log('🚀 IM + Agent 桥接服务已启动(本地测试页未启用)');
45
70
  console.log('');
46
71
  console.log(` Linco 运行目录: ${config.lincoHome}`);
47
72
  console.log(` 会话目录: ${config.sessionsDir}`);
@@ -50,10 +75,11 @@ function startServer(rootDir, options = {}) {
50
75
  const enabledAgents = Object.entries(config.agents || {}).filter(([, agent]) => agent.enabled).map(([type]) => type).join(', ');
51
76
  console.log(` 远端 IM: 已启用 (${config.im.channel}/${config.im.account}; agents: ${enabledAgents || 'none'})`);
52
77
  }
53
- options.onListening?.(server, config);
54
- });
55
-
56
- return server;
78
+ console.log('');
79
+ console.log('💡 如需开启本地测试页,请运行 linco-connect start --local-im');
80
+ options.onListening?.(null, config);
81
+ return null;
82
+ }
57
83
  }
58
84
 
59
85
  module.exports = {
@@ -2,7 +2,8 @@ const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { resetOutgoingAttachments, startOutboxWatcher, stopOutboxWatcher } = require('./outgoingAttachmentHandler');
4
4
  const { sendError, sendSystem } = require('./protocol');
5
- const { removeAgentSessionFromHistory, saveSessionMetadata, stopAgentProcess } = require('./session');
5
+ const { removeAgentSessionFromHistory, saveSessionMetadata } = require('./session');
6
+ const { stopAgentProcess } = require('./agentRunner');
6
7
 
7
8
  function localCommandsHelp() {
8
9
  return `📋 Linco 本地命令:
@@ -48,10 +49,18 @@ function handleSlashCommand(text, ws, session, config) {
48
49
  return true;
49
50
 
50
51
  case '/pwd':
52
+ if (isHermesSession(session)) {
53
+ sendHermesWorkspaceNotice(ws);
54
+ return true;
55
+ }
51
56
  sendSystem(ws, `📂 ${session.workspace}`);
52
57
  return true;
53
58
 
54
59
  case '/cd':
60
+ if (isHermesSession(session)) {
61
+ sendHermesWorkspaceNotice(ws);
62
+ return true;
63
+ }
55
64
  handleCd(parts[1], ws, session, config);
56
65
  return true;
57
66
 
@@ -120,6 +129,14 @@ Agent 进程: ${processRunning ? '运行中' : '未运行'}
120
129
  待确认: ${session.pendingPermission ? '工具权限' : session.pendingDanger ? '危险操作' : '无'}`);
121
130
  }
122
131
 
132
+ function isHermesSession(session) {
133
+ return (session.agentType || 'claude') === 'hermes';
134
+ }
135
+
136
+ function sendHermesWorkspaceNotice(ws) {
137
+ sendSystem(ws, 'Hermes 模式下工作目录由 Hermes Gateway/Agent 自身决定,/pwd 与 /cd 不在插件侧处理。');
138
+ }
139
+
123
140
  function handleCd(targetPath, ws, session, config) {
124
141
  if (!targetPath) {
125
142
  try {
package/src/wsServer.js CHANGED
@@ -134,7 +134,7 @@ function sendSessionInfo(ws, session, config) {
134
134
  },
135
135
  capabilities: {
136
136
  incomingAttachments: true,
137
- multimodalImages: session.agentType === 'claude',
137
+ multimodalImages: ['claude', 'hermes'].includes(session.agentType),
138
138
  outgoingAttachments: true,
139
139
  },
140
140
  });