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 +87 -0
- package/bin/linco-connect.js +28 -7
- package/package.json +1 -1
- package/public/index.html +13 -3
- package/src/agentRunner.js +2 -0
- package/src/agents/codex.js +86 -9
- package/src/agents/hermes.js +470 -0
- package/src/config.js +25 -0
- package/src/httpStatic.js +2 -2
- package/src/imConnector.js +7 -3
- package/src/lincoProtocol.js +3 -3
- package/src/serverApp.js +46 -20
- package/src/slashCommands.js +18 -1
- package/src/wsServer.js +1 -1
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`,不建议直接暴露到公网。
|
package/bin/linco-connect.js
CHANGED
|
@@ -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
|
-
|
|
109
|
-
if (
|
|
110
|
-
|
|
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
|
-
|
|
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
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 =
|
|
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 = {}) {
|
package/src/agentRunner.js
CHANGED
package/src/agents/codex.js
CHANGED
|
@@ -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
|
|
555
|
-
const
|
|
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
|
}
|
package/src/imConnector.js
CHANGED
|
@@ -49,7 +49,9 @@ class ImConnector {
|
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
start() {
|
|
52
|
-
|
|
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
|
-
|
|
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:
|
|
445
|
+
multimodalImages: ['claude', 'hermes'].includes(session.agentType),
|
|
442
446
|
outgoingAttachments: true,
|
|
443
447
|
remoteIm: true,
|
|
444
448
|
agentType: session.agentType,
|
package/src/lincoProtocol.js
CHANGED
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
options.onClose?.(server, config);
|
|
26
|
-
});
|
|
20
|
+
if (config.localWeb?.enabled) {
|
|
21
|
+
const server = createStaticServer(config);
|
|
22
|
+
attachWebSocketServer(server, config);
|
|
27
23
|
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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 = {
|
package/src/slashCommands.js
CHANGED
|
@@ -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
|
|
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:
|
|
137
|
+
multimodalImages: ['claude', 'hermes'].includes(session.agentType),
|
|
138
138
|
outgoingAttachments: true,
|
|
139
139
|
},
|
|
140
140
|
});
|