ai-lab-bridge 0.1.1
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 +54 -0
- package/index.js +1333 -0
- package/package.json +21 -0
package/index.js
ADDED
|
@@ -0,0 +1,1333 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ai-lab-bridge —— AI Lab 平台设备桥接进程(Bridge Protocol v1,daemon 模式对齐 coze-bridge)
|
|
4
|
+
*
|
|
5
|
+
* 用法:
|
|
6
|
+
* npx -y ai-lab-bridge@latest connect --server=wss://<host>/api/bridge/ws --token=dev-xxx
|
|
7
|
+
* 首次连接:保存配置并以后台 daemon 运行(命令执行完即可关闭终端)
|
|
8
|
+
* npx -y ai-lab-bridge@latest connect # 后台运行/复用已有 daemon(读取已保存配置)
|
|
9
|
+
* npx -y ai-lab-bridge@latest connect --foreground # 前台运行(调试用,Ctrl+C 退出)
|
|
10
|
+
* npx -y ai-lab-bridge@latest status # 查看后台进程与平台连接状态
|
|
11
|
+
* npx -y ai-lab-bridge@latest stop # 停止后台进程
|
|
12
|
+
*
|
|
13
|
+
* 机制:
|
|
14
|
+
* - 主动外连平台 WSS(只出不进,穿透 NAT),连接即鉴权(token 走 query 参数)
|
|
15
|
+
* - ready 后上报本机 CLI 能力(register),每 30s 心跳保活
|
|
16
|
+
* - 收到 execute 后 spawn 本地 CLI headless 进程,stdout JSONL 逐行归一化为事件回传
|
|
17
|
+
* - 断线指数退避重连;cancel 中断当前执行
|
|
18
|
+
* - daemon 控制面(对齐 coze-bridge):pid/port/token 三件套落盘 ~/.ai-lab/bridge/,
|
|
19
|
+
* status/stop/配置投递通过本地回环 HTTP(Bearer token 鉴权)操作常驻进程
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const { spawn, spawnSync } = require("child_process");
|
|
23
|
+
const http = require("http");
|
|
24
|
+
const crypto = require("crypto");
|
|
25
|
+
const fs = require("fs");
|
|
26
|
+
const os = require("os");
|
|
27
|
+
const path = require("path");
|
|
28
|
+
const WebSocket = require("ws");
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// 命令行参数与本地状态目录
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
function parseArgs(argv) {
|
|
35
|
+
const args = { _: [] };
|
|
36
|
+
for (const raw of argv.slice(2)) {
|
|
37
|
+
const m = raw.match(/^--([a-z-]+)(?:=(.*))?$/i);
|
|
38
|
+
if (m) args[m[1]] = m[2] === undefined ? true : m[2];
|
|
39
|
+
else args._.push(raw);
|
|
40
|
+
}
|
|
41
|
+
return args;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const args = parseArgs(process.argv);
|
|
45
|
+
// 子命令:connect(默认,兼容裸 --server/--token 形式)/ status / stop / help
|
|
46
|
+
const SUBCOMMAND = (args._ && args._[0]) || "connect";
|
|
47
|
+
|
|
48
|
+
// 本地状态目录(对齐 coze-bridge 的 ~/.coze/bridge):
|
|
49
|
+
// config.json 连接凭据落盘(0600)
|
|
50
|
+
// bridge.pid/port/token 控制面三件套:daemon 进程寻址 + 本机 IPC 鉴权
|
|
51
|
+
// logs/ daemon 运行日志(按天滚动)
|
|
52
|
+
const STATE_DIR = path.join(os.homedir(), ".ai-lab", "bridge");
|
|
53
|
+
const CONFIG_FILE = path.join(STATE_DIR, "config.json");
|
|
54
|
+
const PID_FILE = path.join(STATE_DIR, "bridge.pid");
|
|
55
|
+
const PORT_FILE = path.join(STATE_DIR, "bridge.port");
|
|
56
|
+
const CTL_TOKEN_FILE = path.join(STATE_DIR, "bridge.token");
|
|
57
|
+
const LOGS_DIR = path.join(STATE_DIR, "logs");
|
|
58
|
+
|
|
59
|
+
function loadPersistedConfig() {
|
|
60
|
+
try {
|
|
61
|
+
const parsed = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
|
|
62
|
+
if (parsed && parsed.server && parsed.token) return parsed;
|
|
63
|
+
} catch {}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function persistConfig(server, token) {
|
|
68
|
+
try {
|
|
69
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
70
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify({ server, token }, null, 2), { mode: 0o600 });
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.warn(`[bridge] 配置落盘失败(不影响本次连接): ${err.message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 读取控制面三件套(pid/port/token 任一缺失视为未运行)。 */
|
|
77
|
+
function readCtlState() {
|
|
78
|
+
try {
|
|
79
|
+
const pid = Number(fs.readFileSync(PID_FILE, "utf8").trim());
|
|
80
|
+
const port = Number(fs.readFileSync(PORT_FILE, "utf8").trim());
|
|
81
|
+
const ctlToken = fs.readFileSync(CTL_TOKEN_FILE, "utf8").trim();
|
|
82
|
+
if (pid && port && ctlToken) return { pid, port, ctlToken };
|
|
83
|
+
} catch {}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 判断 pid 对应进程是否存活(signal 0 探活,Windows/Unix 通用,零子进程)。 */
|
|
88
|
+
function isPidAlive(pid) {
|
|
89
|
+
if (!pid) return false;
|
|
90
|
+
try {
|
|
91
|
+
process.kill(pid, 0);
|
|
92
|
+
return true;
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (err && err.code === "EPERM") return true; // 进程存在但无权限
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function removeCtlFiles() {
|
|
100
|
+
for (const f of [PID_FILE, PORT_FILE, CTL_TOKEN_FILE]) {
|
|
101
|
+
try { fs.unlinkSync(f); } catch {}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function todayLogFile() {
|
|
106
|
+
return path.join(LOGS_DIR, `bridge-${new Date().toISOString().slice(0, 10)}.log`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function printUsage() {
|
|
110
|
+
console.log(`ai-lab-bridge —— AI Lab 平台设备桥接进程
|
|
111
|
+
|
|
112
|
+
用法:
|
|
113
|
+
npx -y ai-lab-bridge@latest connect --server=<wss://host/api/bridge/ws> --token=dev-xxx # 首次连接(保存配置并后台运行)
|
|
114
|
+
npx -y ai-lab-bridge@latest connect # 后台运行/复用 daemon(终端可关闭)
|
|
115
|
+
npx -y ai-lab-bridge@latest connect --foreground # 前台运行(调试用)
|
|
116
|
+
npx -y ai-lab-bridge@latest status # 查看运行状态
|
|
117
|
+
npx -y ai-lab-bridge@latest stop # 停止后台进程
|
|
118
|
+
|
|
119
|
+
其他:
|
|
120
|
+
help 显示本帮助`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
124
|
+
const RECONNECT_BASE_MS = 2_000;
|
|
125
|
+
const RECONNECT_MAX_MS = 60_000;
|
|
126
|
+
|
|
127
|
+
// 版本号从 package.json 读取(--version / status / daemon 日志均输出)
|
|
128
|
+
const BRIDGE_VERSION = (() => {
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(fs.readFileSync(path.join(__dirname, "package.json"), "utf8")).version || "";
|
|
131
|
+
} catch {
|
|
132
|
+
return "";
|
|
133
|
+
}
|
|
134
|
+
})();
|
|
135
|
+
|
|
136
|
+
// 连接参数由各子命令解析填充(connect/daemon 需要,status/stop 不需要)
|
|
137
|
+
let SERVER = "";
|
|
138
|
+
let TOKEN = "";
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// CLI 能力探测
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
function runVersionProbe(cmd, versionFlag) {
|
|
145
|
+
return new Promise((resolve) => {
|
|
146
|
+
let done = false;
|
|
147
|
+
const finish = (version) => {
|
|
148
|
+
if (done) return;
|
|
149
|
+
done = true;
|
|
150
|
+
resolve(version);
|
|
151
|
+
};
|
|
152
|
+
try {
|
|
153
|
+
const child = spawn(cmd, [versionFlag], { shell: process.platform === "win32", windowsHide: true });
|
|
154
|
+
let out = "";
|
|
155
|
+
child.stdout.on("data", (d) => (out += d.toString()));
|
|
156
|
+
child.on("error", () => finish(null));
|
|
157
|
+
child.on("close", (code) => finish(code === 0 ? out.trim().split(/\r?\n/)[0] || "unknown" : null));
|
|
158
|
+
setTimeout(() => {
|
|
159
|
+
try { child.kill(); } catch {}
|
|
160
|
+
finish(null);
|
|
161
|
+
}, 8000);
|
|
162
|
+
} catch {
|
|
163
|
+
finish(null);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let cursorBinary = "agent";
|
|
169
|
+
|
|
170
|
+
async function detectCapabilities() {
|
|
171
|
+
// Cursor CLI 的二进制名是 agent(较老版本为 cursor-agent),探测并记录实际可用的那个。
|
|
172
|
+
const probeCursor = async () => {
|
|
173
|
+
const v = await runVersionProbe("agent", "--version");
|
|
174
|
+
if (v) {
|
|
175
|
+
cursorBinary = "agent";
|
|
176
|
+
return v;
|
|
177
|
+
}
|
|
178
|
+
const fallback = await runVersionProbe("cursor-agent", "--version");
|
|
179
|
+
if (fallback) cursorBinary = "cursor-agent";
|
|
180
|
+
return fallback;
|
|
181
|
+
};
|
|
182
|
+
const [claudeVersion, codexVersion, opencodeVersion, codefreeVersion, cursorVersion, geminiVersion, qwenVersion, iflowVersion] = await Promise.all([
|
|
183
|
+
runVersionProbe("claude", "--version"),
|
|
184
|
+
runVersionProbe("codex", "--version"),
|
|
185
|
+
runVersionProbe("opencode", "--version"),
|
|
186
|
+
runVersionProbe("codefree-o", "--version"),
|
|
187
|
+
probeCursor(),
|
|
188
|
+
runVersionProbe("gemini", "--version"),
|
|
189
|
+
runVersionProbe("qwen", "--version"),
|
|
190
|
+
runVersionProbe("iflow", "--version"),
|
|
191
|
+
]);
|
|
192
|
+
return [
|
|
193
|
+
{ cli: "claude", version: claudeVersion, available: !!claudeVersion },
|
|
194
|
+
{ cli: "codex", version: codexVersion, available: !!codexVersion },
|
|
195
|
+
{ cli: "opencode", version: opencodeVersion, available: !!opencodeVersion },
|
|
196
|
+
{ cli: "codefree-o", version: codefreeVersion, available: !!codefreeVersion },
|
|
197
|
+
{ cli: "cursor", version: cursorVersion, available: !!cursorVersion },
|
|
198
|
+
{ cli: "gemini", version: geminiVersion, available: !!geminiVersion },
|
|
199
|
+
{ cli: "qwen", version: qwenVersion, available: !!qwenVersion },
|
|
200
|
+
{ cli: "iflow", version: iflowVersion, available: !!iflowVersion },
|
|
201
|
+
];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// CLI 适配器:把各 CLI 输出归一化为统一事件
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
class Session {
|
|
209
|
+
constructor(requestId, send) {
|
|
210
|
+
this.requestId = requestId;
|
|
211
|
+
this.send = send;
|
|
212
|
+
this.seq = 0;
|
|
213
|
+
this.child = null;
|
|
214
|
+
this.cancelled = false;
|
|
215
|
+
this.cliSessionId = null;
|
|
216
|
+
this.textParts = [];
|
|
217
|
+
this.usage = null;
|
|
218
|
+
this.finished = false;
|
|
219
|
+
this.stderrTail = [];
|
|
220
|
+
this.cliError = null; // CLI 输出的最后一条可读错误(优先于 stderr 原始 JSON)
|
|
221
|
+
this.thinkOpen = false; // <think> 区间状态:思考内容包标签后按普通文本增量下发
|
|
222
|
+
this.options = {};
|
|
223
|
+
this.seenTools = new Set(); // 工具 callID 去重(opencode 会对同一 part 按状态多次推送)
|
|
224
|
+
this.fileDir = null; // 附件下载临时目录
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
emitEvent(kind, payload = {}) {
|
|
228
|
+
this.send({ type: "event", request_id: this.requestId, seq: ++this.seq, kind, ...payload });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
emitText(text) {
|
|
232
|
+
if (!text) return;
|
|
233
|
+
this.textParts.push(text);
|
|
234
|
+
this.emitEvent("text_delta", { text });
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
emitSession(cliSessionId) {
|
|
238
|
+
if (!cliSessionId || this.cliSessionId) return;
|
|
239
|
+
this.cliSessionId = cliSessionId;
|
|
240
|
+
this.emitEvent("session", { cli_session_id: cliSessionId });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 任务结束后把附件目录迁移到 CLI 会话 id 键下(首轮以 request_id 作键)。
|
|
245
|
+
* 必须在 CLI 进程退出后执行:--file 参数在启动时已指向旧路径,提前重命名会竞态。
|
|
246
|
+
* 迁移后续接轮(session_ref = 会话 id)复用同一目录,跨轮读取上一轮附件。
|
|
247
|
+
*/
|
|
248
|
+
migrateFileDirToSession() {
|
|
249
|
+
if (!this.fileDir || !this.cliSessionId) return;
|
|
250
|
+
const canonicalDir = path.join(FILE_DIR_ROOT, String(this.cliSessionId).replace(/[\\/:*?"<>|]/g, "_"));
|
|
251
|
+
if (path.resolve(this.fileDir) === path.resolve(canonicalDir)) return;
|
|
252
|
+
try {
|
|
253
|
+
if (fs.existsSync(canonicalDir)) {
|
|
254
|
+
// 已存在同名目录(如异常重发):把本轮文件并入后删掉本轮目录
|
|
255
|
+
for (const name of fs.readdirSync(this.fileDir)) {
|
|
256
|
+
try { fs.renameSync(path.join(this.fileDir, name), path.join(canonicalDir, name)); } catch {}
|
|
257
|
+
}
|
|
258
|
+
fs.rmSync(this.fileDir, { recursive: true, force: true });
|
|
259
|
+
} else {
|
|
260
|
+
fs.renameSync(this.fileDir, canonicalDir);
|
|
261
|
+
}
|
|
262
|
+
this.fileDir = canonicalDir;
|
|
263
|
+
} catch (err) {
|
|
264
|
+
console.log(`[bridge] 附件目录迁移失败: ${err.message}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
emitUsage(usage) {
|
|
269
|
+
if (usage && typeof usage === "object") {
|
|
270
|
+
this.usage = usage;
|
|
271
|
+
this.emitEvent("usage", { usage });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
emitToolUse(summary) {
|
|
276
|
+
if (summary) this.emitEvent("tool_use", { summary: String(summary).slice(0, 300) });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 思考内容标注:进入 <think> 区间(幂等,标签不嵌套),由各 CLI 适配器共用 */
|
|
280
|
+
beginThink() {
|
|
281
|
+
if (!this.thinkOpen) {
|
|
282
|
+
this.thinkOpen = true;
|
|
283
|
+
this.emitText("<think>\n");
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** 退出 <think> 区间(幂等):正文/工具事件前调用,避免后续内容被吞进思考块 */
|
|
288
|
+
endThink() {
|
|
289
|
+
if (this.thinkOpen) {
|
|
290
|
+
this.thinkOpen = false;
|
|
291
|
+
this.emitText("\n</think>\n");
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** 追加一段思考内容(自动维护区间开合) */
|
|
296
|
+
emitThink(text) {
|
|
297
|
+
if (!text) return;
|
|
298
|
+
this.beginThink();
|
|
299
|
+
this.emitText(String(text));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
finish(status, error = null) {
|
|
303
|
+
if (this.finished) return;
|
|
304
|
+
this.finished = true;
|
|
305
|
+
if (this.timer) clearTimeout(this.timer);
|
|
306
|
+
// 思考区间未闭合(CLI 结束时仍在思考态):补闭合标签,避免前端 think 渲染悬挂
|
|
307
|
+
this.endThink();
|
|
308
|
+
const output = this.textParts.join("");
|
|
309
|
+
send({
|
|
310
|
+
type: "result",
|
|
311
|
+
request_id: this.requestId,
|
|
312
|
+
status,
|
|
313
|
+
output,
|
|
314
|
+
usage: this.usage || undefined,
|
|
315
|
+
error: error || undefined,
|
|
316
|
+
});
|
|
317
|
+
if (typeof activeSessions !== "undefined") {
|
|
318
|
+
activeSessions.delete(this.requestId);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
kill() {
|
|
323
|
+
this.cancelled = true;
|
|
324
|
+
if (this.promptFile) {
|
|
325
|
+
try { fs.unlinkSync(this.promptFile); } catch {}
|
|
326
|
+
}
|
|
327
|
+
if (this.child && this.child.pid) {
|
|
328
|
+
try {
|
|
329
|
+
if (process.platform === "win32") {
|
|
330
|
+
spawn("taskkill", ["/pid", String(this.child.pid), "/T", "/F"], { windowsHide: true });
|
|
331
|
+
} else {
|
|
332
|
+
this.child.kill("SIGTERM");
|
|
333
|
+
setTimeout(() => { try { this.child.kill("SIGKILL"); } catch {} }, 3000);
|
|
334
|
+
}
|
|
335
|
+
} catch {}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** 解析一行 JSON;失败返回 null。 */
|
|
341
|
+
function safeJsonLine(line) {
|
|
342
|
+
const trimmed = line.trim();
|
|
343
|
+
if (!trimmed.startsWith("{")) return null;
|
|
344
|
+
try {
|
|
345
|
+
return JSON.parse(trimmed);
|
|
346
|
+
} catch {
|
|
347
|
+
return null;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function handleClaudeLine(session, obj) {
|
|
352
|
+
if (!obj || typeof obj !== "object") return;
|
|
353
|
+
if (obj.session_id) session.emitSession(obj.session_id);
|
|
354
|
+
if (obj.type === "assistant" && obj.message && Array.isArray(obj.message.content)) {
|
|
355
|
+
for (const block of obj.message.content) {
|
|
356
|
+
if (block.type === "text" && block.text) {
|
|
357
|
+
session.endThink();
|
|
358
|
+
session.emitText(block.text);
|
|
359
|
+
} else if (block.type === "thinking" && block.thinking) {
|
|
360
|
+
// Claude 扩展思考块:包 <think> 标签按文本增量下发,平台/前端已支持渲染
|
|
361
|
+
session.emitThink(block.thinking);
|
|
362
|
+
} else if (block.type === "tool_use") {
|
|
363
|
+
session.endThink();
|
|
364
|
+
session.emitToolUse(block.name);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
} else if (obj.type === "user" && obj.message && Array.isArray(obj.message.content)) {
|
|
368
|
+
for (const block of obj.message.content) {
|
|
369
|
+
if (block.type === "tool_result" && !block.is_error) session.emitToolUse("tool_result");
|
|
370
|
+
}
|
|
371
|
+
} else if (obj.type === "result") {
|
|
372
|
+
session.emitSession(obj.session_id);
|
|
373
|
+
session.emitUsage(obj.usage);
|
|
374
|
+
if (obj.subtype === "success") {
|
|
375
|
+
// result 字段是最终完整回复;stream-json 模式下 assistant 文本已回传,这里只兜底。
|
|
376
|
+
if (typeof obj.result === "string" && obj.result && !session.textParts.length) {
|
|
377
|
+
session.emitText(obj.result);
|
|
378
|
+
}
|
|
379
|
+
session.finish("completed");
|
|
380
|
+
} else {
|
|
381
|
+
session.finish("failed", obj.result || obj.error || `claude 执行异常: ${obj.subtype}`);
|
|
382
|
+
}
|
|
383
|
+
} else if (obj.type === "system" && obj.subtype && obj.subtype !== "init") {
|
|
384
|
+
// 其他系统事件忽略
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function handleCodexLine(session, obj) {
|
|
389
|
+
if (!obj || typeof obj !== "object") return;
|
|
390
|
+
if (obj.type === "thread.started" && obj.thread_id) session.emitSession(obj.thread_id);
|
|
391
|
+
else if (obj.type === "item.completed" && obj.item) {
|
|
392
|
+
const item = obj.item;
|
|
393
|
+
if (item.item_type === "agent_message" && item.text) {
|
|
394
|
+
session.endThink();
|
|
395
|
+
session.emitText(item.text);
|
|
396
|
+
} else if (item.item_type === "command_execution") {
|
|
397
|
+
session.endThink();
|
|
398
|
+
session.emitToolUse(item.command || "command");
|
|
399
|
+
} else if (item.item_type === "reasoning") {
|
|
400
|
+
// Codex 推理摘要:不再丢弃,包 <think> 标签下发
|
|
401
|
+
session.emitThink(item.text || (Array.isArray(item.summary) ? item.summary.join("\n") : "") || "");
|
|
402
|
+
}
|
|
403
|
+
} else if (obj.type === "turn.completed") {
|
|
404
|
+
session.emitUsage(obj.usage);
|
|
405
|
+
session.finish("completed");
|
|
406
|
+
} else if (obj.type === "turn.failed" || obj.type === "error") {
|
|
407
|
+
const message = (obj.error && obj.error.message) || obj.message || "codex 执行失败";
|
|
408
|
+
session.finish("failed", message);
|
|
409
|
+
} else if (obj.session_id) {
|
|
410
|
+
session.emitSession(obj.session_id);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* OpenCode 事件解析(`opencode run --format json` 的 raw JSON 事件流)。
|
|
416
|
+
* 字段命名在不同版本间有差异(sessionID/session_id、part.type 等),这里做宽松归一化;
|
|
417
|
+
* 非 JSON 行(老版本不支持 --format 时输出纯文本)按文本增量兜底。
|
|
418
|
+
*
|
|
419
|
+
* 工具事件({type:"tool", part:{type:"tool", tool, callID, state:{status, input, output}}})
|
|
420
|
+
* 升级为结构化 tool_call/tool_result:callID 配对,args/output 随事件回传,
|
|
421
|
+
* 平台侧按 tool_call_id 渲染工具块(call 建块、result 更新)。
|
|
422
|
+
*/
|
|
423
|
+
function handleOpencodeLine(session, obj) {
|
|
424
|
+
if (!obj || typeof obj !== "object") return;
|
|
425
|
+
const sid = obj.sessionID || obj.session_id || (obj.info && (obj.info.sessionID || obj.info.id));
|
|
426
|
+
if (sid) session.emitSession(sid);
|
|
427
|
+
|
|
428
|
+
const part = obj.part || obj;
|
|
429
|
+
const ptype = part.type || obj.type;
|
|
430
|
+
if (ptype === "text" && part.text) {
|
|
431
|
+
session.endThink();
|
|
432
|
+
session.emitText(part.text);
|
|
433
|
+
} else if (ptype === "reasoning" || ptype === "thinking") {
|
|
434
|
+
// OpenCode 系推理 part(codefree-o 同源):不再丢弃,包 <think> 标签下发
|
|
435
|
+
session.emitThink(part.text || part.thinking || "");
|
|
436
|
+
} else if (ptype === "tool" || ptype === "tool_use" || ptype === "tool-invocation") {
|
|
437
|
+
session.endThink();
|
|
438
|
+
const callId = String(part.callID || part.call_id || part.id || "");
|
|
439
|
+
const name = String(part.tool || part.name || "tool");
|
|
440
|
+
const state = part.state || {};
|
|
441
|
+
if (callId && !session.seenTools.has(callId)) {
|
|
442
|
+
session.seenTools.add(callId);
|
|
443
|
+
session.emitEvent("tool_call", {
|
|
444
|
+
call_id: callId,
|
|
445
|
+
name,
|
|
446
|
+
args: JSON.stringify(state.input || {}),
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
if (callId && state.status === "completed") {
|
|
450
|
+
const output = state.output;
|
|
451
|
+
session.emitEvent("tool_result", {
|
|
452
|
+
call_id: callId,
|
|
453
|
+
name,
|
|
454
|
+
output: typeof output === "string" ? output : JSON.stringify(output ?? ""),
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
if (callId && state.status === "error") {
|
|
458
|
+
// 工具调用失败:opencode 系以 tool part state.status="error" 上报(带 callID),
|
|
459
|
+
// 直接配对成错误型 tool_result,平台按失败状态渲染工具块。
|
|
460
|
+
const errOutput = state.error || state.output || "tool 执行失败";
|
|
461
|
+
session.emitEvent("tool_result", {
|
|
462
|
+
call_id: callId,
|
|
463
|
+
name,
|
|
464
|
+
output: typeof errOutput === "string" ? errOutput : JSON.stringify(errOutput),
|
|
465
|
+
status: "error",
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
if (!callId) {
|
|
469
|
+
// 无 callID 的极端情况退化为 summary 事件
|
|
470
|
+
session.emitToolUse(part.tool || part.name || (state && state.title));
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (obj.type === "error" || (obj.error && typeof obj.error === "object")) {
|
|
474
|
+
const err = obj.error && typeof obj.error === "object" ? obj.error : null;
|
|
475
|
+
// 提取可读错误消息(opencode 系: error.data.message / error.message;附带 name/ref 便于定位)
|
|
476
|
+
const msg = (err && ((err.data && err.data.message) || err.message)) || obj.message;
|
|
477
|
+
const meta = err && err.name ? err.name : "";
|
|
478
|
+
const ref = err && err.data && err.data.ref ? err.data.ref : "";
|
|
479
|
+
const readable = [meta, msg, ref ? `ref: ${ref}` : ""].filter(Boolean).join(" | ") || JSON.stringify(obj).slice(0, 500);
|
|
480
|
+
// 只收集、不立即定责:error 可能是局部异常(如读取二进制 --file 失败后 CLI 继续运行),
|
|
481
|
+
// 也可能是致命错误(认证失败/模型不可用后随即退出)。两者由进程退出时的行为判定
|
|
482
|
+
// (见 child.on("close")):error 之后 CLI 仍有输出 → 局部错误,以错误型工具块展示;
|
|
483
|
+
// error 之后无任何输出 → 致命错误,任务判 failed。
|
|
484
|
+
session.cliError = readable;
|
|
485
|
+
session.cliErrors = session.cliErrors || [];
|
|
486
|
+
session.cliErrors.push(readable);
|
|
487
|
+
session._lastErrLine = session._rawLineCount || 0;
|
|
488
|
+
session.stderrTail.push(JSON.stringify(obj.error || obj).slice(0, 500));
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* 将执行过程中收集的 CLI 局部错误以错误型工具块回传展示(call + result 配对),
|
|
494
|
+
* 平台侧渲染为 "CLI → 失败结果" 工具块。仅在按完成收尾时调用。
|
|
495
|
+
*/
|
|
496
|
+
function flushCliErrorTools(session) {
|
|
497
|
+
for (const readable of session.cliErrors || []) {
|
|
498
|
+
const callId = `cli_err_${(session._errSeq = (session._errSeq || 0) + 1)}`;
|
|
499
|
+
session.emitEvent("tool_call", {
|
|
500
|
+
call_id: callId,
|
|
501
|
+
name: "CLI",
|
|
502
|
+
args: JSON.stringify({ error_name: String(readable).split(" | ")[0] || "UnknownError" }),
|
|
503
|
+
});
|
|
504
|
+
session.emitEvent("tool_result", {
|
|
505
|
+
call_id: callId,
|
|
506
|
+
name: "CLI",
|
|
507
|
+
output: readable,
|
|
508
|
+
status: "error",
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
session.cliErrors = [];
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Cursor CLI 事件解析(`agent -p --output-format stream-json`,NDJSON)。
|
|
516
|
+
* 格式与 Claude Code stream-json 高度同构:
|
|
517
|
+
* {type:"system", subtype:"init", session_id} / {type:"assistant", message:{content:[{type:"text",text}]}}
|
|
518
|
+
* / {type:"tool_call", tool_call:{...}} / {type:"result", subtype:"success", result, session_id}
|
|
519
|
+
*/
|
|
520
|
+
function handleCursorLine(session, obj) {
|
|
521
|
+
if (!obj || typeof obj !== "object") return;
|
|
522
|
+
if (obj.session_id) session.emitSession(obj.session_id);
|
|
523
|
+
if (obj.type === "assistant" && obj.message && Array.isArray(obj.message.content)) {
|
|
524
|
+
for (const block of obj.message.content) {
|
|
525
|
+
if (block.type === "text" && block.text) {
|
|
526
|
+
session.endThink();
|
|
527
|
+
session.emitText(block.text);
|
|
528
|
+
} else if (block.type === "thinking" && block.thinking) {
|
|
529
|
+
session.emitThink(block.thinking);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
} else if (obj.type === "tool_call") {
|
|
533
|
+
const tc = obj.tool_call || {};
|
|
534
|
+
const variant = tc.writeToolCall || tc.readToolCall || tc.executeToolCall || {};
|
|
535
|
+
const summary = (variant.args && (variant.args.path || variant.args.command)) || tc.toolName || "tool";
|
|
536
|
+
session.emitToolUse(summary);
|
|
537
|
+
} else if (obj.type === "result") {
|
|
538
|
+
session.emitSession(obj.session_id);
|
|
539
|
+
if (obj.is_error || (obj.subtype && obj.subtype !== "success")) {
|
|
540
|
+
session.finish("failed", obj.error || (typeof obj.result === "string" ? obj.result : undefined) || `cursor 执行异常: ${obj.subtype}`);
|
|
541
|
+
} else {
|
|
542
|
+
if (typeof obj.result === "string" && obj.result && !session.textParts.length) {
|
|
543
|
+
session.emitText(obj.result);
|
|
544
|
+
}
|
|
545
|
+
session.finish("completed");
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Gemini CLI 家族事件解析(gemini / qwen / iflow 的 `-p --output-format stream-json`,JSONL)。
|
|
552
|
+
* 事件类型:init(session id) / message / tool_use / tool_result / result(stats)。
|
|
553
|
+
* 各 fork 字段命名可能有差异,做宽松归一化;终态以进程退出码兜底。
|
|
554
|
+
*/
|
|
555
|
+
function handleGeminiLine(session, obj) {
|
|
556
|
+
if (!obj || typeof obj !== "object") return;
|
|
557
|
+
const sid = obj.session_id || obj.sessionId || (obj.init && (obj.init.session_id || obj.init.sessionId));
|
|
558
|
+
if (sid) session.emitSession(sid);
|
|
559
|
+
|
|
560
|
+
if (obj.type === "message" || obj.type === "assistant") {
|
|
561
|
+
const role = obj.role || (obj.message && obj.message.role);
|
|
562
|
+
if (role === "user") return;
|
|
563
|
+
let text = obj.text;
|
|
564
|
+
if (!text && obj.content) {
|
|
565
|
+
if (typeof obj.content === "string") text = obj.content;
|
|
566
|
+
else if (Array.isArray(obj.content)) {
|
|
567
|
+
text = obj.content
|
|
568
|
+
.filter((b) => (b && b.type === "text") || typeof b === "string")
|
|
569
|
+
.map((b) => (typeof b === "string" ? b : b.text))
|
|
570
|
+
.join("");
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
if (text) session.emitText(text);
|
|
574
|
+
} else if (obj.type === "tool_use" || obj.type === "tool_call") {
|
|
575
|
+
session.emitToolUse(obj.name || obj.tool_name || obj.tool || "tool");
|
|
576
|
+
} else if (obj.type === "result") {
|
|
577
|
+
session.emitUsage(obj.stats);
|
|
578
|
+
if (obj.error) {
|
|
579
|
+
const msg = typeof obj.error === "string" ? obj.error : (obj.error.message || JSON.stringify(obj.error));
|
|
580
|
+
session.finish("failed", msg);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
if (typeof obj.response === "string" && obj.response && !session.textParts.length) {
|
|
584
|
+
session.emitText(obj.response);
|
|
585
|
+
}
|
|
586
|
+
session.finish("completed");
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* 启动 CLI 子进程。
|
|
592
|
+
* @returns {Session}
|
|
593
|
+
*/
|
|
594
|
+
|
|
595
|
+
// 启动参数模板中 prompt 的占位符:Windows 下替换为临时文件读取表达式,其余平台直接替换为 prompt 文本。
|
|
596
|
+
const PROMPT_TOKEN = "\u0000PROMPT\u0000";
|
|
597
|
+
|
|
598
|
+
function launchCli(cli, argsTemplate, prompt, requestId) {
|
|
599
|
+
if (process.platform === "win32") {
|
|
600
|
+
// Windows 下 cmd 对引号/换行的转义不可靠:prompt 写临时文件,经 PowerShell 注入。
|
|
601
|
+
const promptFile = path.join(os.tmpdir(), `ai-lab-bridge-prompt-${requestId}.txt`);
|
|
602
|
+
fs.writeFileSync(promptFile, prompt, "utf8");
|
|
603
|
+
const safeFile = promptFile.replace(/'/g, "''");
|
|
604
|
+
const parts = argsTemplate.map(a => {
|
|
605
|
+
if (a === PROMPT_TOKEN) return `(Get-Content -Raw -Encoding UTF8 '${safeFile}')`;
|
|
606
|
+
return `'${String(a).replace(/'/g, "''")}'`;
|
|
607
|
+
});
|
|
608
|
+
return { argv: ["powershell.exe", ["-NoProfile", "-Command", `${cli} ${parts.join(" ")}`]], promptFile };
|
|
609
|
+
}
|
|
610
|
+
const argvArgs = argsTemplate.map(a => (a === PROMPT_TOKEN ? prompt : a));
|
|
611
|
+
return { argv: [cli, argvArgs], promptFile: null };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** 从 execute.options 推导平台 HTTP base(WS 地址 → http 地址)。 */
|
|
615
|
+
function platformHttpBase() {
|
|
616
|
+
return SERVER.replace(/^ws/i, "http").replace(/\/api\/bridge\/ws.*$/i, "");
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/** 附件目录根:按 CLI 会话复用(跨轮保留),孤儿目录按 TTL 清理。 */
|
|
620
|
+
const FILE_DIR_ROOT = path.join(os.tmpdir(), "ai-lab-bridge-files");
|
|
621
|
+
/** 附件目录保留时长(毫秒):超过后由周期清理删除。 */
|
|
622
|
+
const FILE_DIR_TTL_MS = 24 * 60 * 60 * 1000;
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* 清理过期的附件目录(TTL):孤儿会话目录占用的磁盘空间定期回收。
|
|
626
|
+
* 活跃会话目录 mtime 会随文件写入刷新,24h 内不会被误删。
|
|
627
|
+
*/
|
|
628
|
+
function cleanupStaleFileDirs() {
|
|
629
|
+
try {
|
|
630
|
+
if (!fs.existsSync(FILE_DIR_ROOT)) return;
|
|
631
|
+
const now = Date.now();
|
|
632
|
+
for (const name of fs.readdirSync(FILE_DIR_ROOT)) {
|
|
633
|
+
const dir = path.join(FILE_DIR_ROOT, name);
|
|
634
|
+
try {
|
|
635
|
+
const st = fs.statSync(dir);
|
|
636
|
+
if (!st.isDirectory()) continue;
|
|
637
|
+
if (now - st.mtimeMs > FILE_DIR_TTL_MS) {
|
|
638
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
639
|
+
console.log(`[bridge] 已清理过期附件目录: ${name}`);
|
|
640
|
+
}
|
|
641
|
+
} catch {}
|
|
642
|
+
}
|
|
643
|
+
} catch {}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
/**
|
|
647
|
+
* 下载用户附件到本地临时目录(平台 /api/bridge/files,device token 鉴权)。
|
|
648
|
+
* 文件路径形如 /{task_id}/upload/xxx,task_id 从中提取。
|
|
649
|
+
* 目录按 CLI 会话复用:续接轮(带 session_ref)直接用会话 id 作键;
|
|
650
|
+
* 首轮用 request_id 作键,CLI 上报会话 id 后重命名(见 Session.emitSession),
|
|
651
|
+
* 保证下一轮 CLI 仍能读取上一轮的附件路径。任务结束不删除,孤儿目录按 TTL 清理。
|
|
652
|
+
* 返回 {files: [{filename, localPath}], promptNote};失败返回 {error}。
|
|
653
|
+
*/
|
|
654
|
+
async function downloadAttachments(message) {
|
|
655
|
+
const files = (message.options && message.options.files) || [];
|
|
656
|
+
const firstPath = String(files[0]?.path || "");
|
|
657
|
+
const taskMatch = firstPath.match(/^\/?([^/]+)\/upload\//);
|
|
658
|
+
const taskId = taskMatch ? taskMatch[1] : "";
|
|
659
|
+
const base = platformHttpBase();
|
|
660
|
+
if (!base || !taskId) return { error: "无法推导平台地址或任务 ID,附件下载失败" };
|
|
661
|
+
const dirKey = String(message.session_ref || message.request_id || "").replace(/[\\/:*?"<>|]/g, "_");
|
|
662
|
+
const dir = path.join(FILE_DIR_ROOT, dirKey);
|
|
663
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
664
|
+
|
|
665
|
+
const downloaded = [];
|
|
666
|
+
for (const file of files.slice(0, 10)) {
|
|
667
|
+
const safeName = String(file.filename || "file").replace(/[\\/:*?"<>|]/g, "_");
|
|
668
|
+
const localPath = path.join(dir, safeName);
|
|
669
|
+
const url = `${base}/api/bridge/files?token=${encodeURIComponent(TOKEN)}&task_id=${encodeURIComponent(taskId)}&file_path=${encodeURIComponent(file.path)}`;
|
|
670
|
+
let res;
|
|
671
|
+
try {
|
|
672
|
+
res = await fetch(url);
|
|
673
|
+
} catch (err) {
|
|
674
|
+
return { error: `附件 ${safeName} 下载请求失败: ${err.message}` };
|
|
675
|
+
}
|
|
676
|
+
if (!res.ok) {
|
|
677
|
+
return { error: `附件 ${safeName} 下载失败(HTTP ${res.status})` };
|
|
678
|
+
}
|
|
679
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
680
|
+
fs.writeFileSync(localPath, buf);
|
|
681
|
+
const entry = { filename: safeName, localPath };
|
|
682
|
+
// 文档类附件平台侧会转换出 Markdown 版本(file.md_path),一并下载供 CLI 优先读取;
|
|
683
|
+
// md 下载失败不阻断任务(降级为仅原始文件)。
|
|
684
|
+
const mdPath = String(file.md_path || "").trim();
|
|
685
|
+
if (mdPath) {
|
|
686
|
+
const mdSafeName = String(mdPath.split("/").pop() || "file.md").replace(/[\\/:*?"<>|]/g, "_");
|
|
687
|
+
const mdLocalPath = path.join(dir, mdSafeName);
|
|
688
|
+
try {
|
|
689
|
+
const mdUrl = `${base}/api/bridge/files?token=${encodeURIComponent(TOKEN)}&task_id=${encodeURIComponent(taskId)}&file_path=${encodeURIComponent(mdPath)}`;
|
|
690
|
+
const mdRes = await fetch(mdUrl);
|
|
691
|
+
if (mdRes.ok) {
|
|
692
|
+
fs.writeFileSync(mdLocalPath, Buffer.from(await mdRes.arrayBuffer()));
|
|
693
|
+
entry.mdLocalPath = mdLocalPath;
|
|
694
|
+
} else {
|
|
695
|
+
console.log(`[bridge] Markdown 版本下载失败(HTTP ${mdRes.status}),仅使用原始文件: ${safeName}`);
|
|
696
|
+
}
|
|
697
|
+
} catch (err) {
|
|
698
|
+
console.log(`[bridge] Markdown 版本下载失败(${err.message}),仅使用原始文件: ${safeName}`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
downloaded.push(entry);
|
|
702
|
+
}
|
|
703
|
+
const note = downloaded
|
|
704
|
+
.map(f => {
|
|
705
|
+
const lines = [`- ${f.localPath}${f.mdLocalPath ? "(原始文件)" : ""}`];
|
|
706
|
+
if (f.mdLocalPath) lines.push(`- ${f.mdLocalPath}(同一文件的 Markdown 转换版,优先读取此文件)`);
|
|
707
|
+
return lines.join("\n");
|
|
708
|
+
})
|
|
709
|
+
.join("\n");
|
|
710
|
+
return {
|
|
711
|
+
dir,
|
|
712
|
+
files: downloaded,
|
|
713
|
+
promptNote: `\n\n[用户上传的附件已下载到本机以下路径,可直接读取]\n${note}`,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function startSession(message) {
|
|
718
|
+
const { request_id: requestId, cli, prompt, session_ref: sessionRef, options = {} } = message;
|
|
719
|
+
const session = new Session(requestId, send);
|
|
720
|
+
session.options = options;
|
|
721
|
+
session.cli = cli;
|
|
722
|
+
session.sessionRef = sessionRef || null; // 会话续接占用判定(同 session_ref 互斥)
|
|
723
|
+
|
|
724
|
+
const timeoutSeconds = Number(options.timeout_seconds) > 0 ? Number(options.timeout_seconds) : 0;
|
|
725
|
+
const cwd = typeof options.cwd === "string" && options.cwd ? options.cwd : process.cwd();
|
|
726
|
+
let argv;
|
|
727
|
+
|
|
728
|
+
let promptViaStdin = true;
|
|
729
|
+
let launch = null;
|
|
730
|
+
if (cli === "claude") {
|
|
731
|
+
argv = ["claude", ["-p", "--output-format", "stream-json", "--verbose"]];
|
|
732
|
+
if (sessionRef) argv[1].push("--resume", sessionRef);
|
|
733
|
+
if (options.permission_mode) argv[1].push("--permission-mode", String(options.permission_mode));
|
|
734
|
+
if (options.model) argv[1].push("--model", String(options.model));
|
|
735
|
+
} else if (cli === "codex") {
|
|
736
|
+
argv = ["codex", ["exec", "--json", "-"]];
|
|
737
|
+
if (sessionRef) argv = ["codex", ["exec", "resume", sessionRef, "--json", "-"]];
|
|
738
|
+
if (options.sandbox_mode) argv[1].push("--sandbox", String(options.sandbox_mode));
|
|
739
|
+
if (options.model) argv[1].push("--model", String(options.model));
|
|
740
|
+
} else if (cli === "opencode" || cli === "codefree-o") {
|
|
741
|
+
// codefree-o 为 OpenCode 同源发行版,CLI 面兼容,复用同一解析器。
|
|
742
|
+
const runArgs = ["run", "--format", "json"];
|
|
743
|
+
if (sessionRef) runArgs.push("--session", sessionRef);
|
|
744
|
+
// codefree-o 与 opencode 同源支持 --auto:远程 bridge 无交互终端,权限确认无法通过,
|
|
745
|
+
// 敏感工具调用(读文件/执行命令)会被自动拒绝。未显式配置时默认启用 auto
|
|
746
|
+
//(--auto 只自动批准"未显式拒绝"的权限,设备本地显式 deny 的规则仍然生效)。
|
|
747
|
+
if (options.permission_mode !== "default") runArgs.push("--auto");
|
|
748
|
+
if (options.model) runArgs.push("--model", String(options.model));
|
|
749
|
+
// 注意参数顺序:--file 是可变长参数(--file a b c),若放在 prompt 之前会把 prompt 吞成文件值,
|
|
750
|
+
// 因此 prompt(位置参数 message)必须紧跟子命令,--file 选项放在其后。
|
|
751
|
+
runArgs.push(PROMPT_TOKEN);
|
|
752
|
+
for (const f of message._localFiles || []) {
|
|
753
|
+
// md 转换版在前:codefree-o/opencode 按顺序读取 --file,优先读到可读的 md 内容;
|
|
754
|
+
// 原始二进制(docx/pdf)读取会报 UnknownError,CLI 通常继续处理后续文件。
|
|
755
|
+
if (f.mdLocalPath) runArgs.push("--file", f.mdLocalPath);
|
|
756
|
+
runArgs.push("--file", f.localPath);
|
|
757
|
+
}
|
|
758
|
+
launch = launchCli(cli, runArgs, prompt, requestId);
|
|
759
|
+
promptViaStdin = false;
|
|
760
|
+
} else if (cli === "cursor") {
|
|
761
|
+
// Cursor CLI:-p 非交互模式;permission_mode=force 允许直接改文件,plan 仅规划。
|
|
762
|
+
const args = ["-p", "--output-format", "stream-json"];
|
|
763
|
+
if (sessionRef) args.push("--resume", sessionRef);
|
|
764
|
+
if (options.permission_mode === "force") args.push("--force");
|
|
765
|
+
else if (options.permission_mode === "plan") args.push("--plan");
|
|
766
|
+
if (options.model) args.push("--model", String(options.model));
|
|
767
|
+
args.push(PROMPT_TOKEN);
|
|
768
|
+
launch = launchCli(cursorBinary, args, prompt, requestId);
|
|
769
|
+
promptViaStdin = false;
|
|
770
|
+
} else if (cli === "gemini" || cli === "qwen" || cli === "iflow") {
|
|
771
|
+
// Gemini CLI 及其同源 fork(Qwen Code / iFlow CLI)。
|
|
772
|
+
const args = ["-p", PROMPT_TOKEN, "--output-format", "stream-json"];
|
|
773
|
+
if (options.model) args.push("--model", String(options.model));
|
|
774
|
+
launch = launchCli(cli, args, prompt, requestId);
|
|
775
|
+
promptViaStdin = false;
|
|
776
|
+
} else {
|
|
777
|
+
session.finish("failed", `不支持的 CLI 类型: ${cli}`);
|
|
778
|
+
return session;
|
|
779
|
+
}
|
|
780
|
+
if (launch) {
|
|
781
|
+
argv = launch.argv;
|
|
782
|
+
session.promptFile = launch.promptFile;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const [cmd, cmdArgs] = argv;
|
|
786
|
+
console.log(`[bridge] 启动 CLI: ${cmd} ${cmdArgs.join(" ").slice(0, 800)}`);
|
|
787
|
+
let child;
|
|
788
|
+
try {
|
|
789
|
+
child = spawn(cmd, cmdArgs, {
|
|
790
|
+
cwd,
|
|
791
|
+
shell: process.platform === "win32",
|
|
792
|
+
windowsHide: true,
|
|
793
|
+
env: { ...process.env },
|
|
794
|
+
});
|
|
795
|
+
} catch (err) {
|
|
796
|
+
session.finish("failed", `无法启动 ${cmd}: ${err.message}`);
|
|
797
|
+
return session;
|
|
798
|
+
}
|
|
799
|
+
session.child = child;
|
|
800
|
+
|
|
801
|
+
const opencodeLike = cli === "opencode" || cli === "codefree-o";
|
|
802
|
+
const handler = cli === "claude" ? handleClaudeLine
|
|
803
|
+
: cli === "codex" ? handleCodexLine
|
|
804
|
+
: cli === "cursor" ? handleCursorLine
|
|
805
|
+
: (cli === "gemini" || cli === "qwen" || cli === "iflow") ? handleGeminiLine
|
|
806
|
+
: (session, obj, rawLine) => {
|
|
807
|
+
// OpenCode / codefree-o:JSON 事件宽松解析;非 JSON 行按纯文本兜底(不支持 --format json 的版本)。
|
|
808
|
+
if (obj) handleOpencodeLine(session, obj);
|
|
809
|
+
else if (opencodeLike && rawLine && rawLine.trim()) session.emitText(rawLine + "\n");
|
|
810
|
+
};
|
|
811
|
+
let stdoutBuf = "";
|
|
812
|
+
child.stdout.setEncoding("utf8");
|
|
813
|
+
child.stdout.on("data", (chunk) => {
|
|
814
|
+
stdoutBuf += chunk;
|
|
815
|
+
let idx;
|
|
816
|
+
while ((idx = stdoutBuf.indexOf("\n")) >= 0) {
|
|
817
|
+
const line = stdoutBuf.slice(0, idx);
|
|
818
|
+
stdoutBuf = stdoutBuf.slice(idx + 1);
|
|
819
|
+
// 诊断日志:前 3 行原始输出,便于排查 CLI 事件格式差异
|
|
820
|
+
session._rawLineCount = (session._rawLineCount || 0) + 1;
|
|
821
|
+
if (session._rawLineCount <= 3) {
|
|
822
|
+
console.log(`[bridge][cli stdout #${session._rawLineCount}] ${line.slice(0, 240)}`);
|
|
823
|
+
}
|
|
824
|
+
handler(session, safeJsonLine(line), line);
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
child.stderr.setEncoding("utf8");
|
|
828
|
+
child.stderr.on("data", (chunk) => {
|
|
829
|
+
for (const line of chunk.split(/\r?\n/)) {
|
|
830
|
+
if (line.trim()) session.stderrTail.push(line.trim());
|
|
831
|
+
}
|
|
832
|
+
if (session.stderrTail.length > 20) session.stderrTail.splice(0, session.stderrTail.length - 20);
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
child.on("error", (err) => session.finish("failed", `进程启动失败: ${err.message}`));
|
|
836
|
+
child.on("close", (code, signal) => {
|
|
837
|
+
console.log(`[bridge] CLI 退出: code=${code} signal=${signal || "-"} 输出行=${session._rawLineCount || 0}`);
|
|
838
|
+
if (session.promptFile) {
|
|
839
|
+
try { fs.unlinkSync(session.promptFile); } catch {}
|
|
840
|
+
}
|
|
841
|
+
// 附件目录按 CLI 会话保留(迁移到会话 id 键下,供续接轮复用),不再任务结束即删;
|
|
842
|
+
// 孤儿目录由 cleanupStaleFileDirs 按 TTL 回收。
|
|
843
|
+
session.migrateFileDirToSession();
|
|
844
|
+
if (session.finished) return;
|
|
845
|
+
if (session.cancelled || signal) {
|
|
846
|
+
session.finish("cancelled", session.cancelled ? "任务被取消" : `进程被信号终止: ${signal}`);
|
|
847
|
+
} else if (code === 0) {
|
|
848
|
+
flushCliErrorTools(session);
|
|
849
|
+
session.emitUsage(session.usage);
|
|
850
|
+
session.finish("completed");
|
|
851
|
+
} else if (session.textParts.length > 0 && session._rawLineCount > (session._lastErrLine || 0)) {
|
|
852
|
+
// 局部错误容忍:CLI(如 codefree-o 读二进制 --file 失败)报错后仍继续执行并产出回复,
|
|
853
|
+
// 退出码非 0 只是局部失败的外显 —— 按完成收尾,错误以工具块展示,不全局判失败。
|
|
854
|
+
// 若 error 后 CLI 无任何后续输出(认证失败/崩溃等致命错误),仍走 failed 分支。
|
|
855
|
+
flushCliErrorTools(session);
|
|
856
|
+
session.emitUsage(session.usage);
|
|
857
|
+
session.finish("completed");
|
|
858
|
+
} else {
|
|
859
|
+
const tail = session.cliError || session.stderrTail.slice(-3).join(" | ");
|
|
860
|
+
session.finish("failed", `CLI 退出码 ${code}${tail ? `: ${tail}` : ""}`);
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
// claude / codex 的 prompt 走 stdin,避免 shell 引号转义问题;opencode 系的 prompt 已在启动参数中。
|
|
865
|
+
// 注意:无论哪种方式都必须关闭 stdin —— Windows 下 npm 的 .ps1 shim 会把父进程 stdin
|
|
866
|
+
// 通过 $input 管道接给 CLI,若不关闭,CLI 会一直等待 EOF 而挂起(表现为收到任务但无任何返回)。
|
|
867
|
+
try {
|
|
868
|
+
if (promptViaStdin) {
|
|
869
|
+
child.stdin.write(prompt);
|
|
870
|
+
}
|
|
871
|
+
child.stdin.end();
|
|
872
|
+
} catch {}
|
|
873
|
+
|
|
874
|
+
if (timeoutSeconds > 0) {
|
|
875
|
+
session.timer = setTimeout(() => {
|
|
876
|
+
session.kill();
|
|
877
|
+
session.finish("failed", `执行超时(${timeoutSeconds}s),进程已终止`);
|
|
878
|
+
}, timeoutSeconds * 1000);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
return session;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ---------------------------------------------------------------------------
|
|
885
|
+
// WebSocket 连接管理(反连 + 指数退避重连)
|
|
886
|
+
// ---------------------------------------------------------------------------
|
|
887
|
+
|
|
888
|
+
let ws = null;
|
|
889
|
+
let heartbeatTimer = null;
|
|
890
|
+
let reconnectAttempt = 0;
|
|
891
|
+
let shuttingDown = false;
|
|
892
|
+
let platformName = ""; // 鉴权 ready 时由平台下发(status 展示用)
|
|
893
|
+
const activeSessions = new Map(); // request_id -> Session
|
|
894
|
+
// 设备并发任务上限:不同会话的任务可并行(CLI 会话级互斥另有限制)
|
|
895
|
+
const MAX_CONCURRENT_SESSIONS = 3;
|
|
896
|
+
|
|
897
|
+
function send(payload) {
|
|
898
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
899
|
+
ws.send(JSON.stringify(payload));
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function scheduleReconnect() {
|
|
904
|
+
if (shuttingDown) return;
|
|
905
|
+
const delay = Math.min(RECONNECT_BASE_MS * 2 ** reconnectAttempt, RECONNECT_MAX_MS);
|
|
906
|
+
reconnectAttempt += 1;
|
|
907
|
+
console.log(`[bridge] ${delay / 1000}s 后重连(第 ${reconnectAttempt} 次)...`);
|
|
908
|
+
setTimeout(connect, delay);
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function startHeartbeat() {
|
|
912
|
+
stopHeartbeat();
|
|
913
|
+
heartbeatTimer = setInterval(() => send({ type: "heartbeat" }), HEARTBEAT_INTERVAL_MS);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
function stopHeartbeat() {
|
|
917
|
+
if (heartbeatTimer) {
|
|
918
|
+
clearInterval(heartbeatTimer);
|
|
919
|
+
heartbeatTimer = null;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
async function connect() {
|
|
924
|
+
const url = `${SERVER}${SERVER.includes("?") ? "&" : "?"}token=${encodeURIComponent(TOKEN)}`;
|
|
925
|
+
console.log(`[bridge] 连接平台: ${SERVER}`);
|
|
926
|
+
const socket = new WebSocket(url, { maxPayload: 16 * 1024 * 1024 });
|
|
927
|
+
ws = socket;
|
|
928
|
+
|
|
929
|
+
socket.on("open", async () => {
|
|
930
|
+
reconnectAttempt = 0;
|
|
931
|
+
console.log("[bridge] 连接已建立,等待平台鉴权...");
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
socket.on("message", async (data) => {
|
|
935
|
+
let message;
|
|
936
|
+
try {
|
|
937
|
+
message = JSON.parse(data.toString());
|
|
938
|
+
} catch {
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
if (message.type === "ready") {
|
|
942
|
+
platformName = message.platform_name || "";
|
|
943
|
+
console.log(`[bridge] 鉴权通过(device_id=${message.device_id}),上报本机能力...`);
|
|
944
|
+
const capabilities = await detectCapabilities();
|
|
945
|
+
for (const cap of capabilities) {
|
|
946
|
+
console.log(`[bridge] ${cap.cli}: ${cap.available ? `可用 (${cap.version})` : "未安装"}`);
|
|
947
|
+
}
|
|
948
|
+
send({ type: "register", capabilities });
|
|
949
|
+
startHeartbeat();
|
|
950
|
+
} else if (message.type === "registered") {
|
|
951
|
+
console.log("[bridge] 能力注册完成,等待任务下发");
|
|
952
|
+
} else if (message.type === "execute") {
|
|
953
|
+
// 并发模型:CLI 会话级互斥(同 session_ref 串行)+ 设备并发总量上限;
|
|
954
|
+
// 不同会话的任务各自独立 CLI 会话,可并行。
|
|
955
|
+
const sessionConflict = message.session_ref
|
|
956
|
+
&& [...activeSessions.values()].some((s) => s.sessionRef === message.session_ref);
|
|
957
|
+
if (sessionConflict) {
|
|
958
|
+
send({
|
|
959
|
+
type: "result",
|
|
960
|
+
request_id: message.request_id,
|
|
961
|
+
status: "failed",
|
|
962
|
+
error: "该 CLI 会话正在被其他任务使用,请稍后再试",
|
|
963
|
+
});
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
if (activeSessions.size >= MAX_CONCURRENT_SESSIONS) {
|
|
967
|
+
send({
|
|
968
|
+
type: "result",
|
|
969
|
+
request_id: message.request_id,
|
|
970
|
+
status: "failed",
|
|
971
|
+
error: `设备并发任务数已达上限(${MAX_CONCURRENT_SESSIONS}),请稍后再试`,
|
|
972
|
+
});
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
console.log(`[bridge] 收到任务 ${message.request_id.slice(0, 8)} cli=${message.cli}`);
|
|
976
|
+
// 用户附件:先从平台下载到本地临时目录,随后以 --file(opencode 系)
|
|
977
|
+
// 或 prompt 路径注记(其他 CLI)传给 CLI
|
|
978
|
+
let effectiveMessage = message;
|
|
979
|
+
if (Array.isArray(message.options?.files) && message.options.files.length) {
|
|
980
|
+
const download = await downloadAttachments(message);
|
|
981
|
+
if (download.error) {
|
|
982
|
+
send({ type: "result", request_id: message.request_id, status: "failed", error: download.error });
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
console.log(`[bridge] 已下载 ${download.files.length} 个附件`);
|
|
986
|
+
effectiveMessage = {
|
|
987
|
+
...message,
|
|
988
|
+
_localFiles: download.files,
|
|
989
|
+
_fileDir: download.dir,
|
|
990
|
+
prompt: ["claude", "codex", "gemini", "qwen", "iflow", "cursor"].includes(message.cli)
|
|
991
|
+
? message.prompt + download.promptNote
|
|
992
|
+
: message.prompt,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
const session = startSession(effectiveMessage);
|
|
996
|
+
if (effectiveMessage._fileDir) session.fileDir = effectiveMessage._fileDir;
|
|
997
|
+
activeSessions.set(message.request_id, session);
|
|
998
|
+
} else if (message.type === "cancel") {
|
|
999
|
+
const session = activeSessions.get(message.request_id);
|
|
1000
|
+
if (session) {
|
|
1001
|
+
console.log(`[bridge] 取消任务 ${message.request_id.slice(0, 8)}`);
|
|
1002
|
+
session.kill();
|
|
1003
|
+
}
|
|
1004
|
+
} else if (message.type === "error") {
|
|
1005
|
+
console.error(`[bridge] 平台错误: ${message.code} ${message.message}`);
|
|
1006
|
+
if (message.code === "BRIDGE_AUTH_FAILED") process.exit(1);
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
socket.on("close", () => {
|
|
1011
|
+
stopHeartbeat();
|
|
1012
|
+
for (const session of activeSessions.values()) session.kill();
|
|
1013
|
+
activeSessions.clear();
|
|
1014
|
+
if (!shuttingDown) scheduleReconnect();
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
socket.on("error", (err) => {
|
|
1018
|
+
console.error(`[bridge] 连接错误: ${err.message}`);
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
// ---------------------------------------------------------------------------
|
|
1023
|
+
// Daemon 控制面:本地回环 HTTP(Bearer token 鉴权),支撑 status/stop/配置投递
|
|
1024
|
+
// ---------------------------------------------------------------------------
|
|
1025
|
+
|
|
1026
|
+
let ctlToken = "";
|
|
1027
|
+
let ctlServer = null;
|
|
1028
|
+
let logStream = null;
|
|
1029
|
+
let logDate = "";
|
|
1030
|
+
|
|
1031
|
+
function readBody(req) {
|
|
1032
|
+
return new Promise((resolve) => {
|
|
1033
|
+
let data = "";
|
|
1034
|
+
req.on("data", (c) => (data += c));
|
|
1035
|
+
req.on("end", () => resolve(data));
|
|
1036
|
+
req.on("error", () => resolve(""));
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/** daemon 模式下 console 重定向到按天滚动日志文件(前台模式不启用)。 */
|
|
1041
|
+
function initDaemonLogging() {
|
|
1042
|
+
const origLog = console.log.bind(console);
|
|
1043
|
+
const origErr = console.error.bind(console);
|
|
1044
|
+
const origWarn = console.warn.bind(console);
|
|
1045
|
+
const write = (level, parts) => {
|
|
1046
|
+
try {
|
|
1047
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
1048
|
+
if (today !== logDate || !logStream) {
|
|
1049
|
+
if (logStream) logStream.end();
|
|
1050
|
+
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
|
1051
|
+
logStream = fs.createWriteStream(todayLogFile(), { flags: "a" });
|
|
1052
|
+
logDate = today;
|
|
1053
|
+
}
|
|
1054
|
+
logStream.write(`[${new Date().toISOString()}] [${level}] ${parts.map(String).join(" ")}\n`);
|
|
1055
|
+
} catch {}
|
|
1056
|
+
};
|
|
1057
|
+
console.log = (...p) => { origLog(...p); write("INFO", p); };
|
|
1058
|
+
console.error = (...p) => { origErr(...p); write("ERROR", p); };
|
|
1059
|
+
console.warn = (...p) => { origWarn(...p); write("WARN", p); };
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function startControlServer() {
|
|
1063
|
+
return new Promise((resolve, reject) => {
|
|
1064
|
+
ctlToken = crypto.randomBytes(32).toString("hex");
|
|
1065
|
+
ctlServer = http.createServer(async (req, res) => {
|
|
1066
|
+
if (String(req.headers.authorization || "") !== `Bearer ${ctlToken}`) {
|
|
1067
|
+
res.writeHead(401).end();
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
if (req.url === "/status" && req.method === "GET") {
|
|
1071
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1072
|
+
res.end(JSON.stringify({
|
|
1073
|
+
pid: process.pid,
|
|
1074
|
+
version: BRIDGE_VERSION,
|
|
1075
|
+
platform: ws && ws.readyState === WebSocket.OPEN ? "connected" : "disconnected",
|
|
1076
|
+
platform_name: platformName,
|
|
1077
|
+
server: SERVER,
|
|
1078
|
+
active_tasks: activeSessions.size,
|
|
1079
|
+
uptime_s: Math.floor(process.uptime()),
|
|
1080
|
+
}));
|
|
1081
|
+
} else if (req.url === "/stop" && req.method === "POST") {
|
|
1082
|
+
res.writeHead(200).end('{"ok":true}');
|
|
1083
|
+
setTimeout(shutdown, 100);
|
|
1084
|
+
} else if (req.url === "/connect" && req.method === "POST") {
|
|
1085
|
+
// pair 语义:向运行中的 daemon 投递(可能更新的)连接配置;变更时重拨
|
|
1086
|
+
try {
|
|
1087
|
+
const cfg = JSON.parse((await readBody(req)) || "{}");
|
|
1088
|
+
if (!cfg.server || !cfg.token) {
|
|
1089
|
+
res.writeHead(400).end(JSON.stringify({ error: "server/token 缺失" }));
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
const changed = cfg.server !== SERVER || cfg.token !== TOKEN;
|
|
1093
|
+
SERVER = cfg.server;
|
|
1094
|
+
TOKEN = cfg.token;
|
|
1095
|
+
persistConfig(SERVER, TOKEN);
|
|
1096
|
+
if (changed && ws) {
|
|
1097
|
+
try { ws.close(); } catch {}
|
|
1098
|
+
}
|
|
1099
|
+
res.writeHead(200).end(JSON.stringify({ ok: true, connected: !!(ws && ws.readyState === WebSocket.OPEN) }));
|
|
1100
|
+
} catch (err) {
|
|
1101
|
+
res.writeHead(400).end(JSON.stringify({ error: err.message }));
|
|
1102
|
+
}
|
|
1103
|
+
} else {
|
|
1104
|
+
res.writeHead(404).end();
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
1107
|
+
// 只监听回环地址:控制面不对局域网暴露
|
|
1108
|
+
ctlServer.on("error", reject);
|
|
1109
|
+
ctlServer.listen(0, "127.0.0.1", () => resolve(ctlServer.address().port));
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function writeCtlFiles(port) {
|
|
1114
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
1115
|
+
fs.writeFileSync(PID_FILE, String(process.pid), { mode: 0o600 });
|
|
1116
|
+
fs.writeFileSync(PORT_FILE, String(port), { mode: 0o600 });
|
|
1117
|
+
fs.writeFileSync(CTL_TOKEN_FILE, ctlToken, { mode: 0o600 });
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function shutdown() {
|
|
1121
|
+
if (shuttingDown) return;
|
|
1122
|
+
shuttingDown = true;
|
|
1123
|
+
for (const session of activeSessions.values()) session.kill();
|
|
1124
|
+
if (ws) { try { ws.close(); } catch {} }
|
|
1125
|
+
removeCtlFiles();
|
|
1126
|
+
if (ctlServer) { try { ctlServer.close(); } catch {} }
|
|
1127
|
+
if (logStream) { try { logStream.end(); } catch {} }
|
|
1128
|
+
setTimeout(() => process.exit(0), 200);
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// ---------------------------------------------------------------------------
|
|
1132
|
+
// 子命令实现
|
|
1133
|
+
// ---------------------------------------------------------------------------
|
|
1134
|
+
|
|
1135
|
+
/** daemon 主流程:写控制面文件 + 启动日志 + 连接平台(由 connect 拉起或 --foreground 内联运行)。 */
|
|
1136
|
+
async function daemonMain() {
|
|
1137
|
+
SERVER = args.server || process.env.AI_LAB_BRIDGE_SERVER || SERVER;
|
|
1138
|
+
TOKEN = args.token || process.env.AI_LAB_BRIDGE_TOKEN || TOKEN;
|
|
1139
|
+
if (!SERVER || !TOKEN) {
|
|
1140
|
+
console.error("[bridge] 后台进程缺少连接参数");
|
|
1141
|
+
process.exit(1);
|
|
1142
|
+
}
|
|
1143
|
+
persistConfig(SERVER, TOKEN);
|
|
1144
|
+
const foreground = !!args.foreground;
|
|
1145
|
+
if (!foreground) initDaemonLogging();
|
|
1146
|
+
// 启动即清理过期附件目录,并按 6 小时周期回收孤儿会话目录(TTL 24h)
|
|
1147
|
+
cleanupStaleFileDirs();
|
|
1148
|
+
setInterval(cleanupStaleFileDirs, 6 * 60 * 60 * 1000).unref();
|
|
1149
|
+
const port = await startControlServer();
|
|
1150
|
+
writeCtlFiles(port);
|
|
1151
|
+
process.on("SIGINT", shutdown);
|
|
1152
|
+
process.on("SIGTERM", shutdown);
|
|
1153
|
+
try { process.on("SIGHUP", shutdown); } catch {}
|
|
1154
|
+
if (!foreground) {
|
|
1155
|
+
console.log(`[bridge] 后台进程已启动 v${BRIDGE_VERSION}(pid=${process.pid},控制端口=${port})`);
|
|
1156
|
+
console.log(`[bridge] 日志文件: ${todayLogFile()}`);
|
|
1157
|
+
}
|
|
1158
|
+
connect();
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/** connect:解析配置 → 复用或拉起后台 daemon → 等待就绪。 */
|
|
1162
|
+
async function cmdConnect() {
|
|
1163
|
+
const persisted = loadPersistedConfig();
|
|
1164
|
+
SERVER = args.server || process.env.AI_LAB_BRIDGE_SERVER || (persisted && persisted.server) || "";
|
|
1165
|
+
TOKEN = args.token || process.env.AI_LAB_BRIDGE_TOKEN || (persisted && persisted.token) || "";
|
|
1166
|
+
if (!SERVER || !TOKEN) {
|
|
1167
|
+
console.error("[bridge] 未找到已保存的连接配置,请使用创建设备时平台提供的完整命令执行一次。");
|
|
1168
|
+
printUsage();
|
|
1169
|
+
process.exit(1);
|
|
1170
|
+
}
|
|
1171
|
+
persistConfig(SERVER, TOKEN);
|
|
1172
|
+
|
|
1173
|
+
if (args.foreground) {
|
|
1174
|
+
await daemonMain();
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// 已有 daemon 在跑:投递配置(可能更新 server/token)并复用,不重复拉起
|
|
1179
|
+
const ctl = readCtlState();
|
|
1180
|
+
if (ctl && isPidAlive(ctl.pid)) {
|
|
1181
|
+
try {
|
|
1182
|
+
const statusRes = await fetch(`http://127.0.0.1:${ctl.port}/status`, {
|
|
1183
|
+
headers: { authorization: `Bearer ${ctl.ctlToken}` },
|
|
1184
|
+
});
|
|
1185
|
+
const running = await statusRes.json();
|
|
1186
|
+
const res = await fetch(`http://127.0.0.1:${ctl.port}/connect`, {
|
|
1187
|
+
method: "POST",
|
|
1188
|
+
headers: { authorization: `Bearer ${ctl.ctlToken}`, "content-type": "application/json" },
|
|
1189
|
+
body: JSON.stringify({ server: SERVER, token: TOKEN }),
|
|
1190
|
+
});
|
|
1191
|
+
const data = await res.json();
|
|
1192
|
+
console.log(`[bridge] 后台进程已运行(pid=${ctl.pid}${running.version ? `,v${running.version}` : ""}),配置已投递并复用。`);
|
|
1193
|
+
if (running.version && BRIDGE_VERSION && running.version !== BRIDGE_VERSION) {
|
|
1194
|
+
console.log(`[bridge] 提示: 后台进程为 v${running.version},本次拉起的是 v${BRIDGE_VERSION}。`);
|
|
1195
|
+
console.log("[bridge] 如需升级请执行: npx -y ai-lab-bridge stop && npx -y ai-lab-bridge connect");
|
|
1196
|
+
}
|
|
1197
|
+
console.log(`[bridge] 平台连接: ${data.connected ? "在线" : "连接中(断线自动重连)"}`);
|
|
1198
|
+
console.log("[bridge] 查看状态: npx -y ai-lab-bridge status 停止: npx -y ai-lab-bridge stop");
|
|
1199
|
+
return;
|
|
1200
|
+
} catch {
|
|
1201
|
+
// 控制面不可达(进程僵死等),清理残留文件后重新拉起
|
|
1202
|
+
removeCtlFiles();
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// 分离式拉起 daemon:脱离终端,父进程退出后继续运行
|
|
1207
|
+
const child = spawn(process.execPath, [__filename, "--daemon"], {
|
|
1208
|
+
detached: true,
|
|
1209
|
+
stdio: "ignore",
|
|
1210
|
+
env: { ...process.env, AI_LAB_BRIDGE_SERVER: SERVER, AI_LAB_BRIDGE_TOKEN: TOKEN },
|
|
1211
|
+
windowsHide: true,
|
|
1212
|
+
});
|
|
1213
|
+
child.unref();
|
|
1214
|
+
console.log(`[bridge] 正在启动后台进程 v${BRIDGE_VERSION} (pid=${child.pid})...`);
|
|
1215
|
+
const ok = await waitForDaemonReady(child.pid, 15000);
|
|
1216
|
+
if (ok) {
|
|
1217
|
+
console.log("[bridge] 后台运行中,本终端可以关闭。");
|
|
1218
|
+
console.log(`[bridge] 平台地址: ${SERVER}`);
|
|
1219
|
+
console.log("[bridge] 查看状态: npx -y ai-lab-bridge status 停止: npx -y ai-lab-bridge stop");
|
|
1220
|
+
console.log(`[bridge] 日志文件: ${todayLogFile()}`);
|
|
1221
|
+
} else {
|
|
1222
|
+
console.error("[bridge] 后台进程启动超时,请用 connect --foreground 在前台查看日志排查。");
|
|
1223
|
+
process.exit(1);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
async function waitForDaemonReady(pid, timeoutMs) {
|
|
1228
|
+
const deadline = Date.now() + timeoutMs;
|
|
1229
|
+
while (Date.now() < deadline) {
|
|
1230
|
+
const ctl = readCtlState();
|
|
1231
|
+
if (ctl && ctl.pid === pid) {
|
|
1232
|
+
try {
|
|
1233
|
+
const res = await fetch(`http://127.0.0.1:${ctl.port}/status`, {
|
|
1234
|
+
headers: { authorization: `Bearer ${ctl.ctlToken}` },
|
|
1235
|
+
});
|
|
1236
|
+
if (res.ok) return true;
|
|
1237
|
+
} catch {}
|
|
1238
|
+
}
|
|
1239
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
1240
|
+
}
|
|
1241
|
+
return false;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/** 将秒数格式化为人类可读时长,如 "3小时47分"、"2天5小时"、"45秒"。 */
|
|
1245
|
+
function formatDuration(totalSeconds) {
|
|
1246
|
+
const s = Math.max(0, Math.floor(totalSeconds));
|
|
1247
|
+
const days = Math.floor(s / 86400);
|
|
1248
|
+
const hours = Math.floor((s % 86400) / 3600);
|
|
1249
|
+
const minutes = Math.floor((s % 3600) / 60);
|
|
1250
|
+
const seconds = s % 60;
|
|
1251
|
+
if (days > 0) return `${days}天${hours}小时`;
|
|
1252
|
+
if (hours > 0) return `${hours}小时${minutes}分`;
|
|
1253
|
+
if (minutes > 0) return `${minutes}分${seconds}秒`;
|
|
1254
|
+
return `${seconds}秒`;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
async function cmdStatus() {
|
|
1258
|
+
const ctl = readCtlState();
|
|
1259
|
+
if (!ctl || !isPidAlive(ctl.pid)) {
|
|
1260
|
+
console.log("[bridge] 后台进程未运行。执行 npx -y ai-lab-bridge connect 启动。");
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
try {
|
|
1264
|
+
const res = await fetch(`http://127.0.0.1:${ctl.port}/status`, {
|
|
1265
|
+
headers: { authorization: `Bearer ${ctl.ctlToken}` },
|
|
1266
|
+
});
|
|
1267
|
+
const s = await res.json();
|
|
1268
|
+
console.log(`[bridge] 后台进程 v${s.version || "?"}(pid=${s.pid})已运行 ${formatDuration(s.uptime_s)}`);
|
|
1269
|
+
if (s.platform_name) console.log(`[bridge] 平台: ${s.platform_name}`);
|
|
1270
|
+
console.log(`[bridge] 平台地址: ${s.server}`);
|
|
1271
|
+
console.log(`[bridge] 连接状态: ${s.platform === "connected" ? "在线" : "连接中/断线(自动重连)"}`);
|
|
1272
|
+
console.log(`[bridge] 执行中任务: ${s.active_tasks}`);
|
|
1273
|
+
if (s.version && BRIDGE_VERSION && s.version !== BRIDGE_VERSION) {
|
|
1274
|
+
console.log(`[bridge] ⚠ 版本不一致: 后台进程 v${s.version},本命令 v${BRIDGE_VERSION}。`);
|
|
1275
|
+
console.log("[bridge] 升级到最新版: npx -y ai-lab-bridge stop && npx -y ai-lab-bridge connect");
|
|
1276
|
+
}
|
|
1277
|
+
} catch (err) {
|
|
1278
|
+
console.error(`[bridge] 状态查询失败: ${err.message}`);
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
async function cmdStop() {
|
|
1283
|
+
const ctl = readCtlState();
|
|
1284
|
+
if (!ctl || !isPidAlive(ctl.pid)) {
|
|
1285
|
+
removeCtlFiles();
|
|
1286
|
+
console.log("[bridge] 后台进程未运行。");
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
try {
|
|
1290
|
+
await fetch(`http://127.0.0.1:${ctl.port}/stop`, {
|
|
1291
|
+
method: "POST",
|
|
1292
|
+
headers: { authorization: `Bearer ${ctl.ctlToken}` },
|
|
1293
|
+
});
|
|
1294
|
+
} catch {}
|
|
1295
|
+
const deadline = Date.now() + 5000;
|
|
1296
|
+
while (Date.now() < deadline && isPidAlive(ctl.pid)) {
|
|
1297
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
1298
|
+
}
|
|
1299
|
+
if (isPidAlive(ctl.pid)) {
|
|
1300
|
+
// 优雅退出超时,强杀兜底
|
|
1301
|
+
try {
|
|
1302
|
+
if (process.platform === "win32") {
|
|
1303
|
+
spawnSync("taskkill", ["/pid", String(ctl.pid), "/T", "/F"], { windowsHide: true });
|
|
1304
|
+
} else {
|
|
1305
|
+
process.kill(ctl.pid, "SIGKILL");
|
|
1306
|
+
}
|
|
1307
|
+
} catch {}
|
|
1308
|
+
}
|
|
1309
|
+
removeCtlFiles();
|
|
1310
|
+
console.log(`[bridge] 后台进程已停止 (pid=${ctl.pid})`);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// ---------------------------------------------------------------------------
|
|
1314
|
+
// 命令分发
|
|
1315
|
+
// ---------------------------------------------------------------------------
|
|
1316
|
+
|
|
1317
|
+
(async () => {
|
|
1318
|
+
if (args.version || args.v || SUBCOMMAND === "version" || SUBCOMMAND === "-v") {
|
|
1319
|
+
console.log(`ai-lab-bridge v${BRIDGE_VERSION || "(未知,package.json 缺失)"}`);
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
if (args.daemon) return daemonMain();
|
|
1323
|
+
switch (SUBCOMMAND) {
|
|
1324
|
+
case "connect": return cmdConnect();
|
|
1325
|
+
case "status": return cmdStatus();
|
|
1326
|
+
case "stop": return cmdStop();
|
|
1327
|
+
case "help": case "--help": case "-h": printUsage(); return;
|
|
1328
|
+
default:
|
|
1329
|
+
console.error(`未知命令: ${SUBCOMMAND}`);
|
|
1330
|
+
printUsage();
|
|
1331
|
+
process.exit(1);
|
|
1332
|
+
}
|
|
1333
|
+
})();
|