@tunnelbox/hermes 0.1.0
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 +82 -0
- package/dist/index.mjs +794 -0
- package/package.json +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# @tunnelbox/hermes
|
|
2
|
+
|
|
3
|
+
tunnelbox 的 **Hermes Agent 适配器**(B 类,独立进程,Python 框架/CLI 桥接):手机远程驱动电脑上的 Hermes Agent(会话/流式/中止/删除),复用 `@tunnelbox/core`(RelayClient/状态/二维码)。
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
手机 PWA ──WSS──► relay ──WSS──► 本适配器(电脑上常驻 Node 进程)
|
|
7
|
+
└─ hermes chat -q "<text>"(每消息一次子进程)
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## 前置条件
|
|
11
|
+
|
|
12
|
+
- Node.js ≥ 22;
|
|
13
|
+
- 本机已安装 **Hermes Agent**(NousResearch/hermes-agent)并完成登录/模型配置:
|
|
14
|
+
`curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash`(或各平台安装方式),
|
|
15
|
+
`hermes setup` / `hermes portal` 可用,`hermes chat -q "hi"` 能正常应答。
|
|
16
|
+
- CLI 非交互单轮模式:`hermes chat -q "…"`(`--model/--provider/--toolsets/--query-file`)。
|
|
17
|
+
|
|
18
|
+
## 构建与运行
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
cd plugin && npm install # workspaces 根(含 hermes)
|
|
22
|
+
cd hermes && npm run build
|
|
23
|
+
node dist/index.mjs # 或 npm start
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## 配置
|
|
27
|
+
|
|
28
|
+
| 环境变量 | 默认 | 说明 |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `TUNNELBOX_RELAY_URL` | state 保存地址 | 中继地址 |
|
|
31
|
+
| `TUNNELBOX_CWD` | `process.cwd()` | 默认工作区 |
|
|
32
|
+
| `TUNNELBOX_HERMES_MODEL` | hermes 默认 | `--model` |
|
|
33
|
+
| `TUNNELBOX_HERMES_PROVIDER` | hermes 默认 | `--provider`(如 nous/openrouter) |
|
|
34
|
+
| `TUNNELBOX_HERMES_TOOLSETS` | hermes 默认 | `--toolsets`(逗号分隔) |
|
|
35
|
+
| `TUNNELBOX_HERMES_VERBOSE` | 关 | `--verbose` |
|
|
36
|
+
| `TUNNELBOX_HERMES_BIN` | `hermes` | hermes 可执行名/绝对路径 |
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
TUNNELBOX_RELAY_URL=wss://chat.example.com TUNNELBOX_HERMES_MODEL="anthropic/claude-sonnet-4" node dist/index.mjs
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## 能力位
|
|
43
|
+
|
|
44
|
+
| 能力 | 值 | 说明 |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| streaming | true | `hermes chat -q` stdout 增量剥离 ANSI 后按到达转发 delta |
|
|
47
|
+
| thinking | false | `-q` 无结构化推理部件 |
|
|
48
|
+
| permission | **false** | 无审批回调;工具集/授权由 hermes 自身安全策略决定 |
|
|
49
|
+
| commands | true | `/new` `/help` |
|
|
50
|
+
| abort | true | kill 子进程(SIGTERM → SIGKILL) |
|
|
51
|
+
|
|
52
|
+
## 会话模型
|
|
53
|
+
|
|
54
|
+
- 会话 = 本地镜像(`~/.config/opencode/hermes-sessions/<uuid>/`):每个流式部件/用户消息即时落一行,供列表/历史/删除。
|
|
55
|
+
- 续聊:尽力从输出解析 hermes session id(形如 `20260225_143052_a1b2c3`,`meta.resumeId`)→ 后续消息用 `--resume <id>`;解析不到则该会话每次为独立问答(连续上下文受限)。
|
|
56
|
+
- 状态文件:`~/.config/opencode/remote-state.hermes.json`。
|
|
57
|
+
|
|
58
|
+
## 官方 Hermes 插件(native-plugin)
|
|
59
|
+
|
|
60
|
+
本包同时提供符合 Hermes 官方插件体系的 **Native 插件**(`native-plugin/`,Python,`plugin.yaml` + `register(ctx)`),
|
|
61
|
+
在本机 Hermes 会话内使用(工具 `tunnelbox_pair`/`tunnelbox_status`、`/tunnelbox` 命令、打包技能 + 实验性回合级手机桥)——
|
|
62
|
+
功能主体仍由本 headless 桥承担。安装/配置/局限见 [`native-plugin/README.md`](./native-plugin/README.md)。
|
|
63
|
+
|
|
64
|
+
## 目录结构
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
plugin/hermes/
|
|
68
|
+
├── package.json / tsconfig.json
|
|
69
|
+
├── scripts/build.mjs / smoke.mjs
|
|
70
|
+
├── native-plugin/ # ★ Hermes 官方 Native 插件(Python,本机会话内配对/状态 + 回合级桥实验)
|
|
71
|
+
└── src/
|
|
72
|
+
├── index.ts # env 解析 + 连接中继
|
|
73
|
+
├── bridge.ts # relay/配对/消息路由/回合/镜像写入
|
|
74
|
+
├── runner.ts # hermes chat -q 子进程(stdout 增量 + ANSI 剥离 + session id 采集 + kill)
|
|
75
|
+
├── mirror.ts # 会话镜像存储(列表/历史/删除/meta)
|
|
76
|
+
└── globals.d.ts
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 备注
|
|
80
|
+
|
|
81
|
+
- 未验证项(需装有 hermes 实测校正):`hermes chat -q` 在 stdout 的确切输出(是否 ANSI/分批/进度行)、`chat -q` 下 `--resume` 参数位置与语义、session id 是否出现在 `-q` 输出中、`--toolsets/--provider` 参数名。解析集中在 `runner.ts`。
|
|
82
|
+
- 安全:hermes 工具执行/命令授权由其自身策略(config/安全文档)管理,本适配器不自动放行;请勿在不受信目录运行时不受限工具集。
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,794 @@
|
|
|
1
|
+
// tunnelbox hermes adapter — built by scripts/build.mjs
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { resolve as resolve2 } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
// src/bridge.ts
|
|
8
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
9
|
+
import { existsSync as existsSync3, statSync as statSync2 } from "node:fs";
|
|
10
|
+
import { hostname } from "node:os";
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
|
|
13
|
+
// ../core/src/types.ts
|
|
14
|
+
function envelope(type, payload, id) {
|
|
15
|
+
return { id, type, payload, ts: Date.now() };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ../core/src/relay.ts
|
|
19
|
+
var BASE_DELAY = 1e3;
|
|
20
|
+
var MAX_DELAY = 15e3;
|
|
21
|
+
var RelayClient = class {
|
|
22
|
+
ws = null;
|
|
23
|
+
url = "";
|
|
24
|
+
headers = {};
|
|
25
|
+
handlers = null;
|
|
26
|
+
timer = null;
|
|
27
|
+
retry = 0;
|
|
28
|
+
stopped = false;
|
|
29
|
+
connected = false;
|
|
30
|
+
get isConnected() {
|
|
31
|
+
return this.connected;
|
|
32
|
+
}
|
|
33
|
+
connect(url, headers, handlers) {
|
|
34
|
+
this.url = url;
|
|
35
|
+
this.headers = headers;
|
|
36
|
+
this.handlers = handlers;
|
|
37
|
+
this.stopped = false;
|
|
38
|
+
this.retry = 0;
|
|
39
|
+
this.open();
|
|
40
|
+
}
|
|
41
|
+
send(env) {
|
|
42
|
+
if (this.ws && this.connected) {
|
|
43
|
+
try {
|
|
44
|
+
this.ws.send(JSON.stringify(env));
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
close() {
|
|
53
|
+
this.stopped = true;
|
|
54
|
+
if (this.timer) clearTimeout(this.timer);
|
|
55
|
+
this.timer = null;
|
|
56
|
+
if (this.ws) {
|
|
57
|
+
try {
|
|
58
|
+
this.ws.close();
|
|
59
|
+
} catch {
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
this.ws = null;
|
|
63
|
+
this.connected = false;
|
|
64
|
+
}
|
|
65
|
+
open() {
|
|
66
|
+
if (this.stopped) return;
|
|
67
|
+
try {
|
|
68
|
+
const ws = new WebSocket(this.url, { headers: this.headers });
|
|
69
|
+
this.ws = ws;
|
|
70
|
+
ws.onopen = () => {
|
|
71
|
+
this.connected = true;
|
|
72
|
+
this.retry = 0;
|
|
73
|
+
this.handlers?.onOpen();
|
|
74
|
+
};
|
|
75
|
+
ws.onmessage = (ev) => {
|
|
76
|
+
try {
|
|
77
|
+
const msg = JSON.parse(String(ev.data));
|
|
78
|
+
this.handlers?.onMessage(msg);
|
|
79
|
+
} catch {
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
ws.onerror = (e) => this.handlers?.onError(e);
|
|
83
|
+
ws.onclose = () => {
|
|
84
|
+
this.connected = false;
|
|
85
|
+
this.ws = null;
|
|
86
|
+
this.handlers?.onClose();
|
|
87
|
+
this.scheduleReconnect();
|
|
88
|
+
};
|
|
89
|
+
} catch (e) {
|
|
90
|
+
this.handlers?.onError(e);
|
|
91
|
+
this.scheduleReconnect();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
scheduleReconnect() {
|
|
95
|
+
if (this.stopped) return;
|
|
96
|
+
const delay = Math.min(BASE_DELAY * 2 ** this.retry, MAX_DELAY);
|
|
97
|
+
this.retry++;
|
|
98
|
+
this.timer = setTimeout(() => this.open(), delay);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// ../core/src/state.ts
|
|
103
|
+
import { randomBytes } from "node:crypto";
|
|
104
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
105
|
+
import { homedir } from "node:os";
|
|
106
|
+
import { dirname, join } from "node:path";
|
|
107
|
+
var DEFAULT_RELAY_URL = process.env.TUNNELBOX_RELAY_URL || (process.env.NODE_ENV === "production" ? "wss://chat.wxngrok.com" : "ws://127.0.0.1:8080");
|
|
108
|
+
function baseName() {
|
|
109
|
+
return join(homedir(), ".config", "opencode");
|
|
110
|
+
}
|
|
111
|
+
function suffix(type) {
|
|
112
|
+
return type ? `.${type}` : "";
|
|
113
|
+
}
|
|
114
|
+
function statePath(type) {
|
|
115
|
+
return join(baseName(), `remote-state${suffix(type)}.json`);
|
|
116
|
+
}
|
|
117
|
+
function saveState(st, type) {
|
|
118
|
+
try {
|
|
119
|
+
const p = statePath(type);
|
|
120
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
121
|
+
writeFileSync(p, JSON.stringify(st, null, 2), "utf8");
|
|
122
|
+
} catch (e) {
|
|
123
|
+
console.error("[tunnelbox] \u4FDD\u5B58\u72B6\u6001\u5931\u8D25", e);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function loadState(type) {
|
|
127
|
+
const fallback = {
|
|
128
|
+
agentId: randomBytes(16).toString("hex"),
|
|
129
|
+
relayUrl: DEFAULT_RELAY_URL
|
|
130
|
+
};
|
|
131
|
+
try {
|
|
132
|
+
if (!existsSync(statePath(type))) {
|
|
133
|
+
saveState(fallback, type);
|
|
134
|
+
return fallback;
|
|
135
|
+
}
|
|
136
|
+
const parsed = JSON.parse(readFileSync(statePath(type), "utf8"));
|
|
137
|
+
if (parsed.agentId && parsed.agentId.length >= 16) {
|
|
138
|
+
return {
|
|
139
|
+
agentId: parsed.agentId,
|
|
140
|
+
relayUrl: parsed.relayUrl || DEFAULT_RELAY_URL,
|
|
141
|
+
claimed: parsed.claimed === true
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
saveState(fallback, type);
|
|
145
|
+
return fallback;
|
|
146
|
+
} catch {
|
|
147
|
+
saveState(fallback, type);
|
|
148
|
+
return fallback;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function writePairingFile(link, code, type) {
|
|
152
|
+
const p = join(baseName(), `remote-pairing${suffix(type)}.txt`);
|
|
153
|
+
try {
|
|
154
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
155
|
+
writeFileSync(p, `\u914D\u5BF9\u7801: ${code}
|
|
156
|
+
\u914D\u5BF9\u94FE\u63A5: ${link}
|
|
157
|
+
`, "utf8");
|
|
158
|
+
} catch {
|
|
159
|
+
}
|
|
160
|
+
return p;
|
|
161
|
+
}
|
|
162
|
+
function writePairingJson(info, type) {
|
|
163
|
+
const p = join(baseName(), `remote-pairing${suffix(type)}.json`);
|
|
164
|
+
try {
|
|
165
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
166
|
+
writeFileSync(p, JSON.stringify(info, null, 2), "utf8");
|
|
167
|
+
} catch {
|
|
168
|
+
}
|
|
169
|
+
return p;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ../core/src/qr.ts
|
|
173
|
+
async function printPairing(relayHttpBase, code, type, name) {
|
|
174
|
+
const p = new URLSearchParams({ code });
|
|
175
|
+
if (type) p.set("type", type);
|
|
176
|
+
if (name) p.set("name", name);
|
|
177
|
+
const link = `${relayHttpBase}/app/#/pages/index/index?${p.toString()}`;
|
|
178
|
+
const file = writePairingFile(link, code, type);
|
|
179
|
+
const tag = type ? ` ${type}` : "";
|
|
180
|
+
console.log("");
|
|
181
|
+
console.log("\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510");
|
|
182
|
+
console.log(`\u2502 tunnelbox${tag} \u624B\u673A\u914D\u5BF9 \u2502`);
|
|
183
|
+
console.log("\u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524");
|
|
184
|
+
console.log(`\u2502 \u914D\u5BF9\u7801: ${code.padEnd(42)} \u2502`);
|
|
185
|
+
console.log(`\u2502 \u94FE\u63A5 : ${link.padEnd(42)} \u2502`);
|
|
186
|
+
console.log("\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518");
|
|
187
|
+
console.log(" \u624B\u673A\u6D4F\u89C8\u5668\u6253\u5F00\u94FE\u63A5\uFF0C\u6216\u6253\u5F00 App \u540E\u624B\u52A8\u8F93\u5165\u914D\u5BF9\u7801\u3002");
|
|
188
|
+
try {
|
|
189
|
+
const mod = await import("qrcode");
|
|
190
|
+
const render = mod.default ?? mod;
|
|
191
|
+
const toString = render.toString;
|
|
192
|
+
if (toString) {
|
|
193
|
+
const qr = await toString(link, { type: "terminal", small: true });
|
|
194
|
+
if (qr) console.log(qr);
|
|
195
|
+
}
|
|
196
|
+
} catch {
|
|
197
|
+
console.log(" \uFF08\u672A\u5B89\u88C5 qrcode\uFF0C\u8BF7\u4F7F\u7528\u4E0A\u65B9\u94FE\u63A5\u6216\u914D\u5BF9\u7801\uFF09");
|
|
198
|
+
}
|
|
199
|
+
console.log(` \u914D\u5BF9\u4FE1\u606F\u5DF2\u5199\u5165: ${file}`);
|
|
200
|
+
console.log("");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/runner.ts
|
|
204
|
+
import { spawn } from "node:child_process";
|
|
205
|
+
var HermesProcess = class {
|
|
206
|
+
constructor(displaySessionID, hooks) {
|
|
207
|
+
this.displaySessionID = displaySessionID;
|
|
208
|
+
this.hooks = hooks;
|
|
209
|
+
}
|
|
210
|
+
child = null;
|
|
211
|
+
killed = false;
|
|
212
|
+
seq = 0;
|
|
213
|
+
msgId = null;
|
|
214
|
+
emitCount = 0;
|
|
215
|
+
capturedId = null;
|
|
216
|
+
get resumeId() {
|
|
217
|
+
return this.capturedId;
|
|
218
|
+
}
|
|
219
|
+
get produced() {
|
|
220
|
+
return this.emitCount > 0;
|
|
221
|
+
}
|
|
222
|
+
start(opts, text, resumeId) {
|
|
223
|
+
return new Promise((resolve3, reject) => {
|
|
224
|
+
let stderrBuf = "";
|
|
225
|
+
let settled = false;
|
|
226
|
+
const done = (err) => {
|
|
227
|
+
if (settled) return;
|
|
228
|
+
settled = true;
|
|
229
|
+
if (err) reject(err);
|
|
230
|
+
else resolve3();
|
|
231
|
+
};
|
|
232
|
+
const bin = opts.bin || "hermes";
|
|
233
|
+
const args = ["chat", "-q"];
|
|
234
|
+
if (resumeId) args.push("--resume", resumeId);
|
|
235
|
+
if (opts.provider) args.push("--provider", opts.provider);
|
|
236
|
+
if (opts.model) args.push("--model", opts.model);
|
|
237
|
+
if (opts.toolsets) args.push("--toolsets", opts.toolsets);
|
|
238
|
+
if (opts.verbose) args.push("--verbose");
|
|
239
|
+
args.push(text);
|
|
240
|
+
let child;
|
|
241
|
+
try {
|
|
242
|
+
child = spawn(bin, args, { cwd: opts.cwd, env: process.env, stdio: ["ignore", "pipe", "pipe"] });
|
|
243
|
+
} catch (e) {
|
|
244
|
+
done(new Error(`\u65E0\u6CD5\u542F\u52A8 Hermes\uFF08hermes\uFF09\uFF1A\u8BF7\u786E\u8BA4\u5DF2\u5B89\u88C5\uFF08${e.message}\uFF09`));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
this.child = child;
|
|
248
|
+
child.stdout?.setEncoding("utf8");
|
|
249
|
+
child.stdout?.on("data", (chunk) => this.onStdout(chunk));
|
|
250
|
+
child.stderr?.setEncoding("utf8");
|
|
251
|
+
child.stderr?.on("data", (chunk) => {
|
|
252
|
+
stderrBuf += chunk;
|
|
253
|
+
for (const l of chunk.split("\n")) {
|
|
254
|
+
const s = stripAnsi(l).trim();
|
|
255
|
+
if (s && /error|login|auth|key|not (found|installed)|denied|failed/i.test(s)) {
|
|
256
|
+
this.hooks.log(`hermes: ${s.slice(0, 300)}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
child.on("error", (e) => {
|
|
261
|
+
if (!this.killed) done(new Error(`Hermes \u542F\u52A8\u5931\u8D25: ${e.message}`));
|
|
262
|
+
});
|
|
263
|
+
child.on("close", (code, signal) => {
|
|
264
|
+
if (this.killed || signal) {
|
|
265
|
+
done();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (code !== 0) {
|
|
269
|
+
const err = stderrBuf.trim().split("\n").pop() || `hermes \u9000\u51FA\u7801 ${code}`;
|
|
270
|
+
this.error(`hermes \u9000\u51FA(${code}): ${stripAnsi(err).slice(0, 500)}`);
|
|
271
|
+
}
|
|
272
|
+
done();
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
kill() {
|
|
277
|
+
this.killed = true;
|
|
278
|
+
if (this.child && this.child.exitCode === null) {
|
|
279
|
+
try {
|
|
280
|
+
this.child.kill("SIGTERM");
|
|
281
|
+
} catch {
|
|
282
|
+
}
|
|
283
|
+
setTimeout(() => {
|
|
284
|
+
try {
|
|
285
|
+
if (this.child && this.child.exitCode === null) this.child.kill("SIGKILL");
|
|
286
|
+
} catch {
|
|
287
|
+
}
|
|
288
|
+
}, 1500);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
error(text) {
|
|
292
|
+
this.hooks.emit(this.displaySessionID, `m-err-${Date.now()}`, {
|
|
293
|
+
id: "err",
|
|
294
|
+
type: "text",
|
|
295
|
+
text: `[hermes] ${text}`,
|
|
296
|
+
complete: true
|
|
297
|
+
});
|
|
298
|
+
this.hooks.status(this.displaySessionID, "error");
|
|
299
|
+
}
|
|
300
|
+
onStdout(chunk) {
|
|
301
|
+
const clean = stripAnsi(chunk);
|
|
302
|
+
if (!clean) return;
|
|
303
|
+
if (!this.capturedId) {
|
|
304
|
+
const m = clean.match(/(?:--resume\s+)?\b(\d{8}_\d{6}_[0-9a-fA-F]{3,})\b/);
|
|
305
|
+
if (m) {
|
|
306
|
+
this.capturedId = m[1];
|
|
307
|
+
this.hooks.onResumeId?.(m[1]);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const meaningful = clean.replace(/^\s*[\n\r]+/, "").replace(/[\n\r]+$/, "");
|
|
311
|
+
if (!meaningful) return;
|
|
312
|
+
if (!this.msgId) this.msgId = `m-${Date.now()}-${++this.seq}`;
|
|
313
|
+
this.emitCount++;
|
|
314
|
+
this.hooks.emit(this.displaySessionID, this.msgId, { id: "t", type: "text", delta: meaningful, complete: false });
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
var ANSI = /\u001B\[[0-9;?]*[ -\/]*[@-~]/g;
|
|
318
|
+
function stripAnsi(s) {
|
|
319
|
+
return s.replace(ANSI, "");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// src/mirror.ts
|
|
323
|
+
import {
|
|
324
|
+
existsSync as existsSync2,
|
|
325
|
+
mkdirSync as mkdirSync2,
|
|
326
|
+
readFileSync as readFileSync2,
|
|
327
|
+
readdirSync,
|
|
328
|
+
rmSync,
|
|
329
|
+
statSync,
|
|
330
|
+
writeFileSync as writeFileSync2
|
|
331
|
+
} from "node:fs";
|
|
332
|
+
import { homedir as homedir2 } from "node:os";
|
|
333
|
+
import { join as join2 } from "node:path";
|
|
334
|
+
function root() {
|
|
335
|
+
return join2(homedir2(), ".config", "opencode", "hermes-sessions");
|
|
336
|
+
}
|
|
337
|
+
function dirOf(sessionID) {
|
|
338
|
+
return join2(root(), sessionID);
|
|
339
|
+
}
|
|
340
|
+
var FILE = "session.jsonl";
|
|
341
|
+
var META = "meta.json";
|
|
342
|
+
function readMeta(sessionID) {
|
|
343
|
+
try {
|
|
344
|
+
const p = join2(dirOf(sessionID), META);
|
|
345
|
+
if (existsSync2(p)) return JSON.parse(readFileSync2(p, "utf8"));
|
|
346
|
+
} catch {
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
function getMeta(sessionID) {
|
|
351
|
+
return readMeta(sessionID);
|
|
352
|
+
}
|
|
353
|
+
function writeMeta(sessionID, meta) {
|
|
354
|
+
try {
|
|
355
|
+
mkdirSync2(dirOf(sessionID), { recursive: true });
|
|
356
|
+
writeFileSync2(join2(dirOf(sessionID), META), JSON.stringify(meta, null, 2), "utf8");
|
|
357
|
+
} catch {
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function newSessionId() {
|
|
361
|
+
if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
|
|
362
|
+
return `s-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
363
|
+
}
|
|
364
|
+
function saveMeta(sessionID, patch) {
|
|
365
|
+
const prev = readMeta(sessionID) || { title: "\u65B0\u4F1A\u8BDD", created: Date.now(), updated: Date.now() };
|
|
366
|
+
const meta = { ...prev, ...patch, updated: Date.now() };
|
|
367
|
+
writeMeta(sessionID, meta);
|
|
368
|
+
return meta;
|
|
369
|
+
}
|
|
370
|
+
function appendPart(sessionID, msgId, role, part, created = Date.now()) {
|
|
371
|
+
try {
|
|
372
|
+
const d = dirOf(sessionID);
|
|
373
|
+
mkdirSync2(d, { recursive: true });
|
|
374
|
+
const line = JSON.stringify({ id: msgId, role, parts: [part], created });
|
|
375
|
+
writeFileSync2(join2(d, FILE), line + "\n", { encoding: "utf8", flag: "a" });
|
|
376
|
+
if (role === "user" && part.type === "text" && part.text) {
|
|
377
|
+
const meta = readMeta(sessionID);
|
|
378
|
+
if (!meta || meta.title === "\u65B0\u4F1A\u8BDD") {
|
|
379
|
+
const t = part.text.trim();
|
|
380
|
+
saveMeta(sessionID, { title: t.length > 30 ? `${t.slice(0, 30)}\u2026` : t });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
saveMeta(sessionID, {});
|
|
384
|
+
} catch {
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
function exists(sessionID) {
|
|
388
|
+
return existsSync2(join2(dirOf(sessionID), FILE));
|
|
389
|
+
}
|
|
390
|
+
function walkDirs() {
|
|
391
|
+
try {
|
|
392
|
+
return readdirSync(root()).filter((n) => existsSync2(join2(root(), n, FILE)));
|
|
393
|
+
} catch {
|
|
394
|
+
return [];
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async function listSessions() {
|
|
398
|
+
const out = [];
|
|
399
|
+
for (const id of walkDirs()) {
|
|
400
|
+
const meta = readMeta(id);
|
|
401
|
+
if (!meta) continue;
|
|
402
|
+
out.push({ id, title: meta.title, created: meta.created, updated: meta.updated, status: "idle", workspace: meta.cwd });
|
|
403
|
+
}
|
|
404
|
+
return out.sort((a, b) => b.updated - a.updated);
|
|
405
|
+
}
|
|
406
|
+
async function readHistory(sessionID) {
|
|
407
|
+
let lines;
|
|
408
|
+
try {
|
|
409
|
+
lines = readFileSync2(join2(dirOf(sessionID), FILE), "utf8").split("\n");
|
|
410
|
+
} catch {
|
|
411
|
+
return [];
|
|
412
|
+
}
|
|
413
|
+
const byId = /* @__PURE__ */ new Map();
|
|
414
|
+
const order = [];
|
|
415
|
+
for (const line of lines) {
|
|
416
|
+
const s = line.trim();
|
|
417
|
+
if (!s) continue;
|
|
418
|
+
try {
|
|
419
|
+
const o = JSON.parse(s);
|
|
420
|
+
if (!o.id || !Array.isArray(o.parts)) continue;
|
|
421
|
+
const hit = byId.get(o.id);
|
|
422
|
+
if (hit) {
|
|
423
|
+
hit.parts.push(...o.parts);
|
|
424
|
+
} else {
|
|
425
|
+
order.push(o.id);
|
|
426
|
+
byId.set(o.id, { id: o.id, role: o.role, parts: [...o.parts], created: o.created ?? Date.now() });
|
|
427
|
+
}
|
|
428
|
+
} catch {
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return order.map((id) => byId.get(id)).filter((m) => m.parts.length);
|
|
432
|
+
}
|
|
433
|
+
async function removeSession(sessionID) {
|
|
434
|
+
try {
|
|
435
|
+
rmSync(dirOf(sessionID), { recursive: true, force: true });
|
|
436
|
+
} catch {
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// src/bridge.ts
|
|
441
|
+
var VERSION = "0.1.0";
|
|
442
|
+
var AGENT_TYPE = "hermes";
|
|
443
|
+
var ADAPTER_COMMANDS = [
|
|
444
|
+
{ name: "new", description: "\u65B0\u5EFA\u4F1A\u8BDD" },
|
|
445
|
+
{ name: "help", description: "\u663E\u793A\u5E2E\u52A9" }
|
|
446
|
+
];
|
|
447
|
+
var HermesBridge = class {
|
|
448
|
+
constructor(cfg) {
|
|
449
|
+
this.cfg = cfg;
|
|
450
|
+
this.currentWorkspace = resolve(cfg.cwd || process.cwd());
|
|
451
|
+
}
|
|
452
|
+
ws = new RelayClient();
|
|
453
|
+
state = loadState(AGENT_TYPE);
|
|
454
|
+
claimed = !!this.state.claimed;
|
|
455
|
+
everConnected = false;
|
|
456
|
+
relayBase = "";
|
|
457
|
+
currentWorkspace;
|
|
458
|
+
pendingWorkspaces = /* @__PURE__ */ new Map();
|
|
459
|
+
sessionCwd = /* @__PURE__ */ new Map();
|
|
460
|
+
activeRuns = /* @__PURE__ */ new Map();
|
|
461
|
+
get relayHttpBase() {
|
|
462
|
+
return this.relayBase.replace(/^ws/, "http").replace(/\/+$/, "");
|
|
463
|
+
}
|
|
464
|
+
async start() {
|
|
465
|
+
const url = (this.cfg.relayUrl || this.state.relayUrl || "").replace(/\/+$/, "");
|
|
466
|
+
this.relayBase = url;
|
|
467
|
+
this.connect(url);
|
|
468
|
+
}
|
|
469
|
+
dispose() {
|
|
470
|
+
this.ws.close();
|
|
471
|
+
for (const p of this.activeRuns.values()) p.kill();
|
|
472
|
+
this.activeRuns.clear();
|
|
473
|
+
}
|
|
474
|
+
log(msg) {
|
|
475
|
+
console.log(`[tunnelbox:hermes] ${msg}`);
|
|
476
|
+
}
|
|
477
|
+
send(e) {
|
|
478
|
+
return this.ws.send(e);
|
|
479
|
+
}
|
|
480
|
+
reply(src, type, payload) {
|
|
481
|
+
const msg = envelope(type, payload, src.id);
|
|
482
|
+
msg.clientID = src.clientID;
|
|
483
|
+
this.send(msg);
|
|
484
|
+
}
|
|
485
|
+
pairingLink(code) {
|
|
486
|
+
const p = new URLSearchParams({ code, type: AGENT_TYPE, name: hostname() });
|
|
487
|
+
return `${this.relayHttpBase}/app/#/pages/index/index?${p.toString()}`;
|
|
488
|
+
}
|
|
489
|
+
connect(url) {
|
|
490
|
+
const agentID = this.state.agentId;
|
|
491
|
+
const wsUrl = `${url}/ws/agent?token=${encodeURIComponent(agentID)}`;
|
|
492
|
+
this.ws.connect(
|
|
493
|
+
wsUrl,
|
|
494
|
+
{ Authorization: `Bearer ${agentID}` },
|
|
495
|
+
{
|
|
496
|
+
onOpen: () => {
|
|
497
|
+
this.everConnected = true;
|
|
498
|
+
this.log("\u5DF2\u8FDE\u63A5\u4E2D\u7EE7 " + url);
|
|
499
|
+
this.announce();
|
|
500
|
+
},
|
|
501
|
+
onMessage: (env) => {
|
|
502
|
+
void this.onMessage(env).catch((e) => this.log(`\u6D88\u606F\u5904\u7406\u672A\u6355\u83B7\u5F02\u5E38: ${e.message}`));
|
|
503
|
+
},
|
|
504
|
+
onClose: () => {
|
|
505
|
+
if (this.everConnected) {
|
|
506
|
+
this.everConnected = false;
|
|
507
|
+
this.log("\u4E0E\u4E2D\u7EE7\u65AD\u5F00\uFF0C\u6B63\u5728\u81EA\u52A8\u91CD\u8FDE\u2026");
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
onError: () => {
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
announce() {
|
|
516
|
+
const info = {
|
|
517
|
+
name: hostname(),
|
|
518
|
+
version: VERSION,
|
|
519
|
+
type: AGENT_TYPE,
|
|
520
|
+
capabilities: {
|
|
521
|
+
streaming: true,
|
|
522
|
+
// stdout 增量转发
|
|
523
|
+
thinking: false,
|
|
524
|
+
// hermes -q 无结构化推理部件
|
|
525
|
+
permission: false,
|
|
526
|
+
// 无审批回调:权限由 hermes 安全策略/工具集决定
|
|
527
|
+
commands: true,
|
|
528
|
+
abort: true
|
|
529
|
+
// kill 子进程
|
|
530
|
+
},
|
|
531
|
+
platform: process.platform,
|
|
532
|
+
directory: this.currentWorkspace,
|
|
533
|
+
timezone: localTimezone()
|
|
534
|
+
};
|
|
535
|
+
this.send(envelope("agent.info", info));
|
|
536
|
+
if (!this.claimed) this.send(envelope("pair.create", {}));
|
|
537
|
+
}
|
|
538
|
+
async onMessage(env) {
|
|
539
|
+
try {
|
|
540
|
+
switch (env.type) {
|
|
541
|
+
case "pair.created": {
|
|
542
|
+
const code = env.payload.code;
|
|
543
|
+
const link = this.pairingLink(code);
|
|
544
|
+
writePairingJson({ code, link, relayUrl: this.relayBase, at: Date.now() }, AGENT_TYPE);
|
|
545
|
+
this.log("\u751F\u6210\u914D\u5BF9\u7801: " + code);
|
|
546
|
+
void printPairing(this.relayHttpBase, code, AGENT_TYPE, hostname());
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
case "agent.claimed":
|
|
550
|
+
this.claimed = true;
|
|
551
|
+
this.state.claimed = true;
|
|
552
|
+
saveState(this.state, AGENT_TYPE);
|
|
553
|
+
this.log("\u5DF2\u7ED1\u5B9A\u5230\u624B\u673A\u8D26\u53F7\uFF0C\u4E4B\u540E\u624B\u673A\u4ECE\u300C\u6211\u7684\u7535\u8111\u300D\u76F4\u63A5\u8FDE\u63A5");
|
|
554
|
+
return;
|
|
555
|
+
case "agent.revoked":
|
|
556
|
+
this.resetIdentity();
|
|
557
|
+
return;
|
|
558
|
+
case "session.list":
|
|
559
|
+
return this.sessionList(env);
|
|
560
|
+
case "session.create":
|
|
561
|
+
return this.sessionCreate(env);
|
|
562
|
+
case "session.messages":
|
|
563
|
+
return this.sessionMessages(env);
|
|
564
|
+
case "session.prompt":
|
|
565
|
+
return this.sessionPrompt(env);
|
|
566
|
+
case "session.abort":
|
|
567
|
+
return this.sessionAbort(env);
|
|
568
|
+
case "session.delete":
|
|
569
|
+
return this.sessionDelete(env);
|
|
570
|
+
case "command.list":
|
|
571
|
+
return this.commandList(env);
|
|
572
|
+
case "workspace.list":
|
|
573
|
+
return this.workspaceList(env);
|
|
574
|
+
case "workspace.set":
|
|
575
|
+
return this.workspaceSet(env);
|
|
576
|
+
case "error":
|
|
577
|
+
return;
|
|
578
|
+
default:
|
|
579
|
+
this.log("\u672A\u5904\u7406\u7684\u6D88\u606F\u7C7B\u578B: " + env.type);
|
|
580
|
+
}
|
|
581
|
+
} catch (e) {
|
|
582
|
+
this.log(`\u5904\u7406 ${env.type} \u5931\u8D25: ${e.message}`);
|
|
583
|
+
this.reply(env, "error", { code: "HANDLER_ERROR", message: e.message, id: env.id });
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
async sessionList(env) {
|
|
587
|
+
const sessions = (await listSessions()).map((s) => ({
|
|
588
|
+
...s,
|
|
589
|
+
status: this.activeRuns.has(s.id) ? "running" : s.status
|
|
590
|
+
}));
|
|
591
|
+
this.reply(env, "sessions", { sessions });
|
|
592
|
+
}
|
|
593
|
+
async sessionCreate(env) {
|
|
594
|
+
const payload = env.payload;
|
|
595
|
+
const workspace = payload?.workspace || this.currentWorkspace;
|
|
596
|
+
const id = newSessionId();
|
|
597
|
+
const now = Date.now();
|
|
598
|
+
saveMeta(id, { title: "\u65B0\u4F1A\u8BDD", cwd: workspace, created: now, updated: now });
|
|
599
|
+
this.pendingWorkspaces.set(id, workspace);
|
|
600
|
+
this.reply(env, "session.created", {
|
|
601
|
+
session: { id, title: "\u65B0\u4F1A\u8BDD", created: now, updated: now, status: "idle", workspace }
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
async sessionMessages(env) {
|
|
605
|
+
const { sessionID } = env.payload;
|
|
606
|
+
const messages = exists(sessionID) ? await readHistory(sessionID) : [];
|
|
607
|
+
this.reply(env, "messages", { sessionID, messages });
|
|
608
|
+
}
|
|
609
|
+
async sessionPrompt(env) {
|
|
610
|
+
const payload = env.payload;
|
|
611
|
+
const text = (payload.text || "").trim();
|
|
612
|
+
if (!text) return;
|
|
613
|
+
if (text.startsWith("/new")) return this.sessionCreate(env);
|
|
614
|
+
if (text.startsWith("/help")) {
|
|
615
|
+
this.reply(env, "message.part", {
|
|
616
|
+
sessionID: payload.sessionID,
|
|
617
|
+
messageID: `m-${Date.now()}`,
|
|
618
|
+
part: { id: "t", type: "text", text: "\u652F\u6301\u7684\u547D\u4EE4\uFF1A\n- /new \u65B0\u5EFA\u4F1A\u8BDD\n- /help \u663E\u793A\u5E2E\u52A9", complete: true }
|
|
619
|
+
});
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (this.activeRuns.has(payload.sessionID)) {
|
|
623
|
+
this.reply(env, "error", { code: "BUSY", message: "\u8BE5\u4F1A\u8BDD\u6B63\u5728\u751F\u6210\u4E2D\uFF0C\u8BF7\u7B49\u5F85\u5B8C\u6210\u6216\u5148\u505C\u6B62", id: env.id });
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
try {
|
|
627
|
+
await this.runTurn(payload.sessionID, text);
|
|
628
|
+
} catch (e) {
|
|
629
|
+
const msg = e.message || String(e);
|
|
630
|
+
this.log(`session.prompt \u5931\u8D25: ${msg}`);
|
|
631
|
+
this.reply(env, "error", { code: "PROMPT_ERROR", message: msg, id: env.id });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
async sessionAbort(env) {
|
|
635
|
+
const { sessionID } = env.payload;
|
|
636
|
+
const p = this.activeRuns.get(sessionID);
|
|
637
|
+
if (p) {
|
|
638
|
+
p.kill();
|
|
639
|
+
this.log(`\u5DF2\u53D1\u9001\u4E2D\u6B62 (session=${sessionID})`);
|
|
640
|
+
}
|
|
641
|
+
this.send(envelope("session.status", { sessionID, status: "idle" }));
|
|
642
|
+
}
|
|
643
|
+
async sessionDelete(env) {
|
|
644
|
+
const { sessionID } = env.payload;
|
|
645
|
+
const p = this.activeRuns.get(sessionID);
|
|
646
|
+
if (p) p.kill();
|
|
647
|
+
this.activeRuns.delete(sessionID);
|
|
648
|
+
this.pendingWorkspaces.delete(sessionID);
|
|
649
|
+
this.sessionCwd.delete(sessionID);
|
|
650
|
+
await removeSession(sessionID);
|
|
651
|
+
this.reply(env, "session.deleted", { sessionID });
|
|
652
|
+
}
|
|
653
|
+
async commandList(env) {
|
|
654
|
+
this.reply(env, "commands", { commands: ADAPTER_COMMANDS });
|
|
655
|
+
}
|
|
656
|
+
async workspaceList(env) {
|
|
657
|
+
this.reply(env, "workspaces", {
|
|
658
|
+
workspaces: [{ path: this.currentWorkspace, name: this.currentWorkspace.split(/[\\/]/).pop() || this.currentWorkspace, sessionCount: 0, updated: 0 }],
|
|
659
|
+
current: this.currentWorkspace
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
async workspaceSet(env) {
|
|
663
|
+
const raw = (env.payload?.path || "").trim();
|
|
664
|
+
const path = raw ? resolve(raw) : "";
|
|
665
|
+
if (!path || !existsSync3(path) || !statSync2(path).isDirectory()) {
|
|
666
|
+
this.reply(env, "error", { code: "BAD_WORKSPACE", message: "\u8BE5\u76EE\u5F55\u4E0D\u5B58\u5728\u6216\u4E0D\u53EF\u7528", id: env.id });
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
this.currentWorkspace = path;
|
|
670
|
+
this.log("\u5207\u6362\u5DE5\u4F5C\u533A: " + path);
|
|
671
|
+
this.announce();
|
|
672
|
+
await this.workspaceList(env);
|
|
673
|
+
}
|
|
674
|
+
async runTurn(sessionID, text) {
|
|
675
|
+
const dir = this.pendingWorkspaces.get(sessionID) || this.sessionCwd.get(sessionID) || this.currentWorkspace;
|
|
676
|
+
if (!existsSync3(dir) || !statSync2(dir).isDirectory()) {
|
|
677
|
+
throw new Error(`\u5DE5\u4F5C\u533A\u4E0D\u5B58\u5728: ${dir}`);
|
|
678
|
+
}
|
|
679
|
+
this.pendingWorkspaces.delete(sessionID);
|
|
680
|
+
const now = Date.now();
|
|
681
|
+
appendPart(sessionID, `u-${now}`, "user", { id: "u", type: "text", text, complete: true }, now);
|
|
682
|
+
this.send(envelope("session.status", { sessionID, status: "running" }));
|
|
683
|
+
const resumeId = getMeta(sessionID)?.resumeId;
|
|
684
|
+
const proc = new HermesProcess(sessionID, {
|
|
685
|
+
emit: (s, messageID, part) => {
|
|
686
|
+
this.send(envelope("message.part", { sessionID: s, messageID, part }));
|
|
687
|
+
appendPart(sessionID, messageID, "assistant", part);
|
|
688
|
+
},
|
|
689
|
+
status: (s, status) => this.send(envelope("session.status", { sessionID: s, status })),
|
|
690
|
+
log: (m) => this.log(m),
|
|
691
|
+
onResumeId: (id) => saveMeta(sessionID, { resumeId: id, cwd: dir })
|
|
692
|
+
});
|
|
693
|
+
this.activeRuns.set(sessionID, proc);
|
|
694
|
+
try {
|
|
695
|
+
await proc.start(
|
|
696
|
+
{
|
|
697
|
+
cwd: dir,
|
|
698
|
+
model: this.cfg.model,
|
|
699
|
+
provider: this.cfg.provider,
|
|
700
|
+
toolsets: this.cfg.toolsets,
|
|
701
|
+
verbose: this.cfg.verbose,
|
|
702
|
+
bin: this.cfg.bin
|
|
703
|
+
},
|
|
704
|
+
text,
|
|
705
|
+
resumeId
|
|
706
|
+
);
|
|
707
|
+
this.sessionCwd.set(sessionID, dir);
|
|
708
|
+
if (!getMeta(sessionID)) saveMeta(sessionID, { title: "Hermes \u4F1A\u8BDD", cwd: dir });
|
|
709
|
+
} catch (e) {
|
|
710
|
+
const msg = e.message || String(e);
|
|
711
|
+
this.log(`\u56DE\u5408\u51FA\u9519 (session=${sessionID}): ${msg}`);
|
|
712
|
+
const id = `m-err-${Date.now()}`;
|
|
713
|
+
const part = { id: "err", type: "text", text: `[hermes] ${msg}`, complete: true };
|
|
714
|
+
this.send(envelope("message.part", { sessionID, messageID: id, part }));
|
|
715
|
+
appendPart(sessionID, id, "assistant", part);
|
|
716
|
+
} finally {
|
|
717
|
+
this.activeRuns.delete(sessionID);
|
|
718
|
+
this.send(envelope("session.status", { sessionID, status: "idle" }));
|
|
719
|
+
void this.broadcastSessionUpdate(sessionID);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
async broadcastSessionUpdate(sessionID) {
|
|
723
|
+
try {
|
|
724
|
+
if (!this.ws.isConnected) return;
|
|
725
|
+
const list = await listSessions().catch(() => []);
|
|
726
|
+
const found = list.find((s) => s.id === sessionID);
|
|
727
|
+
if (found) this.send(envelope("session.updated", { session: found }));
|
|
728
|
+
} catch {
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
resetIdentity() {
|
|
732
|
+
this.log("\u88AB\u8D26\u53F7\u89E3\u7ED1\uFF0C\u6B63\u5728\u91CD\u7F6E\u8EAB\u4EFD\u5E76\u91CD\u65B0\u751F\u6210\u914D\u5BF9\u7801\u2026");
|
|
733
|
+
this.claimed = false;
|
|
734
|
+
this.state.agentId = randomBytes2(16).toString("hex");
|
|
735
|
+
this.state.claimed = false;
|
|
736
|
+
saveState(this.state, AGENT_TYPE);
|
|
737
|
+
this.ws.close();
|
|
738
|
+
this.connect(this.relayBase);
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
function localTimezone() {
|
|
742
|
+
try {
|
|
743
|
+
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
744
|
+
if (tz) return tz;
|
|
745
|
+
} catch {
|
|
746
|
+
}
|
|
747
|
+
return "";
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
// src/index.ts
|
|
751
|
+
function start(opts = {}) {
|
|
752
|
+
const envOn = (name) => /^(1|true|yes|on)$/i.test(process.env[name] || "");
|
|
753
|
+
const cfg = {
|
|
754
|
+
relayUrl: (process.env.TUNNELBOX_RELAY_URL || opts.relayUrl || "").trim(),
|
|
755
|
+
cwd: process.env.TUNNELBOX_CWD || opts.cwd || process.cwd(),
|
|
756
|
+
model: process.env.TUNNELBOX_HERMES_MODEL?.trim() || opts.model || void 0,
|
|
757
|
+
provider: process.env.TUNNELBOX_HERMES_PROVIDER?.trim() || opts.provider || void 0,
|
|
758
|
+
toolsets: process.env.TUNNELBOX_HERMES_TOOLSETS?.trim() || opts.toolsets || void 0,
|
|
759
|
+
verbose: envOn("TUNNELBOX_HERMES_VERBOSE") || !!opts.verbose,
|
|
760
|
+
bin: process.env.TUNNELBOX_HERMES_BIN?.trim() || opts.bin || "hermes"
|
|
761
|
+
};
|
|
762
|
+
const bridge = new HermesBridge(cfg);
|
|
763
|
+
void bridge.start().catch((e) => {
|
|
764
|
+
console.error("[tunnelbox:hermes] \u542F\u52A8\u5931\u8D25:", e);
|
|
765
|
+
});
|
|
766
|
+
let stopped = false;
|
|
767
|
+
return {
|
|
768
|
+
stop() {
|
|
769
|
+
if (stopped) return;
|
|
770
|
+
stopped = true;
|
|
771
|
+
bridge.dispose();
|
|
772
|
+
}
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
var isEntry = (() => {
|
|
776
|
+
try {
|
|
777
|
+
return !!process.argv[1] && resolve2(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
778
|
+
} catch {
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
})();
|
|
782
|
+
if (isEntry) {
|
|
783
|
+
const { stop } = start();
|
|
784
|
+
const onSignal = () => {
|
|
785
|
+
stop();
|
|
786
|
+
process.exit(0);
|
|
787
|
+
};
|
|
788
|
+
process.once("SIGINT", onSignal);
|
|
789
|
+
process.once("SIGTERM", onSignal);
|
|
790
|
+
console.log("[tunnelbox:hermes] Hermes \u9002\u914D\u5668\u5DF2\u542F\u52A8\uFF08Ctrl+C \u9000\u51FA\uFF09");
|
|
791
|
+
}
|
|
792
|
+
export {
|
|
793
|
+
start
|
|
794
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tunnelbox/hermes",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "tunnelbox 的 Hermes 适配器(B 类,Python 框架/CLI 桥接):手机远程驱动本机 Hermes Agent(hermes chat -q,会话/流式/中止/删除)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.mjs",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"type-check": "tsc --noEmit",
|
|
13
|
+
"build": "node scripts/build.mjs",
|
|
14
|
+
"start": "node dist/index.mjs",
|
|
15
|
+
"smoke": "node scripts/smoke.mjs"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22.0.0"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"qrcode": "^1.5.3"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@tunnelbox/core": "0.1.0",
|
|
25
|
+
"@types/node": "^22.0.0",
|
|
26
|
+
"@types/qrcode": "^1.5.5",
|
|
27
|
+
"esbuild": "^0.24.0",
|
|
28
|
+
"typescript": "^5.4.5"
|
|
29
|
+
}
|
|
30
|
+
}
|