linco-connect 1.1.3 → 1.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/bin/linco-connect.js +71 -25
- package/package.json +1 -1
- package/server.js +8 -13
- package/src/agents/codex.js +52 -47
- package/src/agents/hermes.js +12 -15
- package/src/agents/openclaw.js +91 -22
- package/src/claudeRunner.js +0 -2
- package/src/config.js +1 -0
- package/src/openclawGateway.js +28 -2
- package/src/outgoingAttachmentHandler.js +0 -2
- package/src/serverApp.js +105 -1
- package/src/session.js +52 -13
package/README.md
CHANGED
|
@@ -253,6 +253,8 @@ C:\Users\<用户名>\.linco\config.json
|
|
|
253
253
|
linco-connect start
|
|
254
254
|
```
|
|
255
255
|
|
|
256
|
+
如果 Linco Connect 已经在运行,再次执行 `linco-connect start` 或 `npm start` 会先停止旧进程,再启动新的进程,相当于一次 restart。
|
|
257
|
+
|
|
256
258
|
需要使用本地测试页模拟前端 IM 时,显式启用:
|
|
257
259
|
|
|
258
260
|
```bash
|
|
@@ -267,6 +269,8 @@ linco-connect start --mock-im
|
|
|
267
269
|
linco-connect start --daemon
|
|
268
270
|
```
|
|
269
271
|
|
|
272
|
+
如果后台服务已经在运行,再次执行 `linco-connect start --daemon` 也会先停止旧进程,再启动新的后台进程。
|
|
273
|
+
|
|
270
274
|
后台启动并启用本地模拟前端 IM:
|
|
271
275
|
|
|
272
276
|
```bash
|
package/bin/linco-connect.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { execFileSync, spawn } = require('child_process');
|
|
6
|
-
const { startServer } = require('../src/serverApp');
|
|
6
|
+
const { startServer, stopServer } = require('../src/serverApp');
|
|
7
7
|
const {
|
|
8
8
|
ensureDir,
|
|
9
9
|
findGitBash,
|
|
@@ -205,21 +205,14 @@ async function startCommand(options = {}) {
|
|
|
205
205
|
return;
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
-
|
|
208
|
+
await startForeground(config);
|
|
209
209
|
}
|
|
210
210
|
|
|
211
211
|
async function startDaemon(config) {
|
|
212
212
|
ensureDir(config.lincoHome);
|
|
213
213
|
ensureDir(config.logsDir);
|
|
214
214
|
|
|
215
|
-
|
|
216
|
-
const existing = readDaemonPid(pidFile);
|
|
217
|
-
if (existing?.pid) {
|
|
218
|
-
if (isOwnDaemon(existing) && isProcessRunning(existing.pid)) {
|
|
219
|
-
throw new Error(`Linco Connect 已在后台运行,PID: ${existing.pid}`);
|
|
220
|
-
}
|
|
221
|
-
removeDaemonPid(pidFile);
|
|
222
|
-
}
|
|
215
|
+
await restartExistingProcessIfRunning(config);
|
|
223
216
|
|
|
224
217
|
const logs = daemonLogFiles(config);
|
|
225
218
|
const outFd = fs.openSync(logs.stdoutLog, 'a');
|
|
@@ -238,6 +231,7 @@ async function startDaemon(config) {
|
|
|
238
231
|
fs.closeSync(outFd);
|
|
239
232
|
fs.closeSync(errFd);
|
|
240
233
|
|
|
234
|
+
const pidFile = daemonPidFile(config);
|
|
241
235
|
await waitForDaemonStart(child.pid, pidFile, 5000);
|
|
242
236
|
|
|
243
237
|
console.log('✅ Linco Connect 已在后台启动');
|
|
@@ -247,26 +241,73 @@ async function startDaemon(config) {
|
|
|
247
241
|
console.log(` 错误日志: ${logs.stderrLog}`);
|
|
248
242
|
}
|
|
249
243
|
|
|
244
|
+
async function startForeground(config) {
|
|
245
|
+
const pidFile = daemonPidFile(config);
|
|
246
|
+
await restartExistingProcessIfRunning(config);
|
|
247
|
+
|
|
248
|
+
let cleaned = false;
|
|
249
|
+
let shuttingDown = false;
|
|
250
|
+
let server = null;
|
|
251
|
+
const cleanup = () => {
|
|
252
|
+
if (cleaned) return;
|
|
253
|
+
cleaned = true;
|
|
254
|
+
removeDaemonPid(pidFile);
|
|
255
|
+
};
|
|
256
|
+
const shutdown = () => {
|
|
257
|
+
if (shuttingDown) return;
|
|
258
|
+
shuttingDown = true;
|
|
259
|
+
cleanup();
|
|
260
|
+
stopServer(config, server).finally(() => process.exit(0));
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
process.once('SIGINT', shutdown);
|
|
264
|
+
process.once('SIGTERM', shutdown);
|
|
265
|
+
process.once('exit', cleanup);
|
|
266
|
+
|
|
267
|
+
server = startServer(rootDir, {
|
|
268
|
+
config,
|
|
269
|
+
onListening: () => writeDaemonPid(config, { mode: 'foreground' }),
|
|
270
|
+
onClose: cleanup,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function restartExistingProcessIfRunning(config) {
|
|
275
|
+
const pidFile = daemonPidFile(config);
|
|
276
|
+
const existing = readDaemonPid(pidFile);
|
|
277
|
+
if (existing?.pid) {
|
|
278
|
+
if (isOwnDaemon(existing) && isProcessRunning(existing.pid)) {
|
|
279
|
+
console.log(`Linco Connect 已在运行,正在重启,PID: ${existing.pid}`);
|
|
280
|
+
await stopDaemonProcess(existing.pid);
|
|
281
|
+
console.log(`已停止旧的 Linco Connect 进程,PID: ${existing.pid}`);
|
|
282
|
+
}
|
|
283
|
+
removeDaemonPid(pidFile);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
250
287
|
function startDaemonChild(config) {
|
|
251
288
|
const pidFile = daemonPidFile(config);
|
|
252
289
|
let cleaned = false;
|
|
290
|
+
let shuttingDown = false;
|
|
291
|
+
let server = null;
|
|
253
292
|
const cleanup = () => {
|
|
254
293
|
if (cleaned) return;
|
|
255
294
|
cleaned = true;
|
|
256
295
|
removeDaemonPid(pidFile);
|
|
257
296
|
};
|
|
258
297
|
const shutdown = () => {
|
|
298
|
+
if (shuttingDown) return;
|
|
299
|
+
shuttingDown = true;
|
|
259
300
|
cleanup();
|
|
260
|
-
process.exit(0);
|
|
301
|
+
stopServer(config, server).finally(() => process.exit(0));
|
|
261
302
|
};
|
|
262
303
|
|
|
263
304
|
process.once('SIGINT', shutdown);
|
|
264
305
|
process.once('SIGTERM', shutdown);
|
|
265
306
|
process.once('exit', cleanup);
|
|
266
307
|
|
|
267
|
-
startServer(rootDir, {
|
|
308
|
+
server = startServer(rootDir, {
|
|
268
309
|
config,
|
|
269
|
-
onListening: () => writeDaemonPid(config),
|
|
310
|
+
onListening: () => writeDaemonPid(config, { mode: 'daemon' }),
|
|
270
311
|
onClose: cleanup,
|
|
271
312
|
});
|
|
272
313
|
}
|
|
@@ -294,20 +335,24 @@ async function stopCommand() {
|
|
|
294
335
|
return;
|
|
295
336
|
}
|
|
296
337
|
|
|
297
|
-
|
|
298
|
-
const stopped = await waitForProcessExit(metadata.pid, 5000);
|
|
299
|
-
if (!stopped && isProcessRunning(metadata.pid)) {
|
|
300
|
-
process.kill(metadata.pid, 'SIGKILL');
|
|
301
|
-
const killed = await waitForProcessExit(metadata.pid, 2000);
|
|
302
|
-
if (!killed && isProcessRunning(metadata.pid)) {
|
|
303
|
-
throw new Error(`停止失败,进程仍在运行,PID: ${metadata.pid}`);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
338
|
+
await stopDaemonProcess(metadata.pid);
|
|
306
339
|
|
|
307
340
|
removeDaemonPid(pidFile);
|
|
308
341
|
console.log(`✅ Linco Connect 已停止,PID: ${metadata.pid}`);
|
|
309
342
|
}
|
|
310
343
|
|
|
344
|
+
async function stopDaemonProcess(pid) {
|
|
345
|
+
process.kill(pid, 'SIGTERM');
|
|
346
|
+
const stopped = await waitForProcessExit(pid, 5000);
|
|
347
|
+
if (!stopped && isProcessRunning(pid)) {
|
|
348
|
+
process.kill(pid, 'SIGKILL');
|
|
349
|
+
const killed = await waitForProcessExit(pid, 2000);
|
|
350
|
+
if (!killed && isProcessRunning(pid)) {
|
|
351
|
+
throw new Error(`停止失败,进程仍在运行,PID: ${pid}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
311
356
|
function daemonPidFile(config) {
|
|
312
357
|
return path.join(config.lincoHome, 'linco-connect.pid');
|
|
313
358
|
}
|
|
@@ -328,7 +373,7 @@ function readDaemonPid(pidFile) {
|
|
|
328
373
|
}
|
|
329
374
|
}
|
|
330
375
|
|
|
331
|
-
function writeDaemonPid(config) {
|
|
376
|
+
function writeDaemonPid(config, options = {}) {
|
|
332
377
|
ensureDir(config.lincoHome);
|
|
333
378
|
const logs = daemonLogFiles(config);
|
|
334
379
|
fs.writeFileSync(daemonPidFile(config), `${JSON.stringify({
|
|
@@ -336,6 +381,7 @@ function writeDaemonPid(config) {
|
|
|
336
381
|
cli: __filename,
|
|
337
382
|
cwd: rootDir,
|
|
338
383
|
pid: process.pid,
|
|
384
|
+
mode: options.mode || 'daemon',
|
|
339
385
|
startedAt: new Date().toISOString(),
|
|
340
386
|
host: config.host,
|
|
341
387
|
port: config.port,
|
|
@@ -493,8 +539,8 @@ function printHelp() {
|
|
|
493
539
|
|
|
494
540
|
说明:
|
|
495
541
|
init 初始化本地配置,不需要填写 wsUrl
|
|
496
|
-
start 启动本机 Agent
|
|
497
|
-
stop
|
|
542
|
+
start 启动本机 Agent 连接器(已运行时会先停止旧进程再启动)
|
|
543
|
+
stop 停止运行中的 Linco Connect
|
|
498
544
|
doctor 检查本地运行环境
|
|
499
545
|
|
|
500
546
|
Agent:
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
const { spawnSync } = require('child_process');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
const { startServer } = require('./src/serverApp');
|
|
4
3
|
|
|
5
4
|
const cliFlags = process.argv.slice(2);
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
process.exit(result.status || 0);
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
startServer(__dirname);
|
|
5
|
+
const cli = path.join(__dirname, 'bin', 'linco-connect.js');
|
|
6
|
+
const result = spawnSync(process.execPath, [cli, 'start', ...cliFlags], {
|
|
7
|
+
cwd: __dirname,
|
|
8
|
+
env: process.env,
|
|
9
|
+
stdio: 'inherit',
|
|
10
|
+
windowsHide: true,
|
|
11
|
+
});
|
|
12
|
+
process.exit(result.status || 0);
|
package/src/agents/codex.js
CHANGED
|
@@ -14,19 +14,15 @@ const {
|
|
|
14
14
|
|
|
15
15
|
function execute(input, ws, session, config) {
|
|
16
16
|
const textForCheck = stringifyInput(input);
|
|
17
|
-
if (isDangerousCommand(textForCheck)) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
session.pendingDanger = { input };
|
|
23
|
-
send(ws, 'danger_warning', {
|
|
24
|
-
text: `⚠️ 检测到可能的危险操作,请确认是否继续执行:
|
|
17
|
+
if (isDangerousCommand(textForCheck) && session.autoApprove !== true) {
|
|
18
|
+
const preview = textForCheck.slice(0, 200);
|
|
19
|
+
session.pendingDanger = { input };
|
|
20
|
+
send(ws, 'danger_warning', {
|
|
21
|
+
text: `⚠️ 检测到可能的危险操作,请确认是否继续执行:
|
|
25
22
|
|
|
26
23
|
"${preview}${textForCheck.length > 200 ? '...' : ''}"`,
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
24
|
+
});
|
|
25
|
+
return;
|
|
30
26
|
}
|
|
31
27
|
|
|
32
28
|
if (session.isTurnActive) {
|
|
@@ -232,6 +228,7 @@ function ensureThread(session) {
|
|
|
232
228
|
threadId: session.agentSessionId,
|
|
233
229
|
cwd: session.workspace,
|
|
234
230
|
approvalPolicy: 'untrusted',
|
|
231
|
+
...buildCodexThreadSandbox(session),
|
|
235
232
|
}).then(result => {
|
|
236
233
|
return session.agentSessionId;
|
|
237
234
|
}).catch(err => {
|
|
@@ -250,6 +247,7 @@ function startNewThread(session, agentConfig) {
|
|
|
250
247
|
cwd: session.workspace,
|
|
251
248
|
model: agentConfig.model || null,
|
|
252
249
|
approvalPolicy: 'untrusted',
|
|
250
|
+
...buildCodexThreadSandbox(session),
|
|
253
251
|
}).then(result => {
|
|
254
252
|
const threadId = result?.thread?.id || result?.id || result?.threadId;
|
|
255
253
|
if (threadId) {
|
|
@@ -359,34 +357,59 @@ function sendJsonRpc(child, message) {
|
|
|
359
357
|
}
|
|
360
358
|
}
|
|
361
359
|
|
|
360
|
+
function buildCodexThreadSandbox(session) {
|
|
361
|
+
const writableRoots = [session.workspace, session.outboxDir].filter(Boolean);
|
|
362
|
+
return {
|
|
363
|
+
sandbox: 'workspace-write',
|
|
364
|
+
config: {
|
|
365
|
+
sandbox_mode: 'workspace-write',
|
|
366
|
+
sandbox_workspace_write: {
|
|
367
|
+
writable_roots: [...new Set(writableRoots)],
|
|
368
|
+
network_access: false,
|
|
369
|
+
exclude_tmpdir_env_var: false,
|
|
370
|
+
exclude_slash_tmp: false,
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function buildCodexPermissionGrant(session) {
|
|
377
|
+
const readableRoots = [session.attachmentsDir].filter(Boolean);
|
|
378
|
+
const writableRoots = [session.outboxDir].filter(Boolean);
|
|
379
|
+
const entries = [
|
|
380
|
+
...readableRoots.map(root => ({
|
|
381
|
+
path: { type: 'path', path: root },
|
|
382
|
+
access: 'read',
|
|
383
|
+
})),
|
|
384
|
+
...writableRoots.map(root => ({
|
|
385
|
+
path: { type: 'path', path: root },
|
|
386
|
+
access: 'write',
|
|
387
|
+
})),
|
|
388
|
+
];
|
|
389
|
+
|
|
390
|
+
return {
|
|
391
|
+
fileSystem: {
|
|
392
|
+
read: readableRoots,
|
|
393
|
+
write: writableRoots,
|
|
394
|
+
entries,
|
|
395
|
+
},
|
|
396
|
+
network: { enabled: false },
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
362
400
|
function handleServerRequest(message, session) {
|
|
363
401
|
const method = message.method || '';
|
|
364
402
|
const params = message.params || {};
|
|
365
403
|
const ws = session._lastWs;
|
|
366
404
|
|
|
367
|
-
// File change approval — auto-approve
|
|
405
|
+
// File change approval — auto-approve silently
|
|
368
406
|
if (method === 'item/fileChange/requestApproval') {
|
|
369
|
-
const toolId = String(message.id);
|
|
370
|
-
const input = summarizeCodexParams(params);
|
|
371
407
|
session._log?.info('codex auto-approving file change');
|
|
372
|
-
if (ws) {
|
|
373
|
-
send(ws, 'tool_call', {
|
|
374
|
-
id: toolId,
|
|
375
|
-
name: 'fileChange',
|
|
376
|
-
input,
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
408
|
sendJsonRpc(session.codexAppServer, {
|
|
380
409
|
jsonrpc: '2.0',
|
|
381
410
|
id: message.id,
|
|
382
411
|
result: { decision: 'accept' },
|
|
383
412
|
});
|
|
384
|
-
if (ws) {
|
|
385
|
-
send(ws, 'tool_result', {
|
|
386
|
-
id: toolId,
|
|
387
|
-
output: 'approved',
|
|
388
|
-
});
|
|
389
|
-
}
|
|
390
413
|
return;
|
|
391
414
|
}
|
|
392
415
|
|
|
@@ -403,9 +426,6 @@ function handleServerRequest(message, session) {
|
|
|
403
426
|
id: message.id,
|
|
404
427
|
result: { decision: 'accept' },
|
|
405
428
|
});
|
|
406
|
-
if (ws) {
|
|
407
|
-
sendSystem(ws, `✅ 已自动批准命令执行:${cmd}`);
|
|
408
|
-
}
|
|
409
429
|
return;
|
|
410
430
|
}
|
|
411
431
|
|
|
@@ -434,34 +454,19 @@ function handleServerRequest(message, session) {
|
|
|
434
454
|
sendJsonRpc(session.codexAppServer, {
|
|
435
455
|
jsonrpc: '2.0',
|
|
436
456
|
id: message.id,
|
|
437
|
-
result: { permissions:
|
|
457
|
+
result: { permissions: buildCodexPermissionGrant(session), scope: 'session' },
|
|
438
458
|
});
|
|
439
459
|
return;
|
|
440
460
|
}
|
|
441
461
|
|
|
442
|
-
// Apply patch approval — auto-approve
|
|
462
|
+
// Apply patch approval — auto-approve silently
|
|
443
463
|
if (method === 'applyPatchApproval') {
|
|
444
|
-
const toolId = String(message.id);
|
|
445
|
-
const input = summarizeCodexParams(params);
|
|
446
464
|
session._log?.info('codex auto-approving patch');
|
|
447
|
-
if (ws) {
|
|
448
|
-
send(ws, 'tool_call', {
|
|
449
|
-
id: toolId,
|
|
450
|
-
name: 'applyPatch',
|
|
451
|
-
input,
|
|
452
|
-
});
|
|
453
|
-
}
|
|
454
465
|
sendJsonRpc(session.codexAppServer, {
|
|
455
466
|
jsonrpc: '2.0',
|
|
456
467
|
id: message.id,
|
|
457
468
|
result: { decision: 'approved' },
|
|
458
469
|
});
|
|
459
|
-
if (ws) {
|
|
460
|
-
send(ws, 'tool_result', {
|
|
461
|
-
id: toolId,
|
|
462
|
-
output: 'approved',
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
470
|
return;
|
|
466
471
|
}
|
|
467
472
|
|
package/src/agents/hermes.js
CHANGED
|
@@ -22,17 +22,13 @@ function extractText(input) {
|
|
|
22
22
|
|
|
23
23
|
function execute(input, ws, session, config) {
|
|
24
24
|
const textForCheck = stringifyInput(input);
|
|
25
|
-
if (isDangerousCommand(textForCheck)) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
text: `⚠️ 检测到可能的危险操作,请确认是否继续执行:\n\n"${preview}${textForCheck.length > 200 ? '...' : ''}"`,
|
|
33
|
-
});
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
25
|
+
if (isDangerousCommand(textForCheck) && session.autoApprove !== true) {
|
|
26
|
+
const preview = textForCheck.slice(0, 200);
|
|
27
|
+
session.pendingDanger = { input };
|
|
28
|
+
send(ws, 'danger_warning', {
|
|
29
|
+
text: `⚠️ 检测到可能的危险操作,请确认是否继续执行:\n\n"${preview}${textForCheck.length > 200 ? '...' : ''}"`,
|
|
30
|
+
});
|
|
31
|
+
return;
|
|
36
32
|
}
|
|
37
33
|
|
|
38
34
|
if (session.isTurnActive) {
|
|
@@ -170,7 +166,6 @@ function handleHermesEvent(event, ws, session, config) {
|
|
|
170
166
|
handleApprovalRequest(event, ws, session, config);
|
|
171
167
|
return;
|
|
172
168
|
case 'approval.responded':
|
|
173
|
-
sendSystem(ws, event.choice === 'deny' ? '🚫 已拒绝 Hermes 操作。' : '✅ 已批准 Hermes 操作。');
|
|
174
169
|
return;
|
|
175
170
|
case 'run.completed':
|
|
176
171
|
completeRun(event, ws, session, config);
|
|
@@ -217,7 +212,7 @@ function handleApprovalRequest(event, ws, session, config) {
|
|
|
217
212
|
sessionId: session.id,
|
|
218
213
|
requestId,
|
|
219
214
|
});
|
|
220
|
-
resolvePendingPermission(true, ws, session, config, requestId).catch(err => {
|
|
215
|
+
resolvePendingPermission(true, ws, session, config, requestId, { silent: true }).catch(err => {
|
|
221
216
|
sendError(ws, `Hermes 自动审批失败: ${err.message}`);
|
|
222
217
|
});
|
|
223
218
|
return;
|
|
@@ -394,7 +389,7 @@ async function resolvePendingDanger(confirmed, ws, session, config) {
|
|
|
394
389
|
return true;
|
|
395
390
|
}
|
|
396
391
|
|
|
397
|
-
async function resolvePendingPermission(approved, ws, session, config, requestId) {
|
|
392
|
+
async function resolvePendingPermission(approved, ws, session, config, requestId, options = {}) {
|
|
398
393
|
const pending = getPendingPermission(session, requestId, 'hermes');
|
|
399
394
|
if (!pending) {
|
|
400
395
|
config.logger?.warn('hermes permission response without pending request', {
|
|
@@ -415,7 +410,9 @@ async function resolvePendingPermission(approved, ws, session, config, requestId
|
|
|
415
410
|
method: 'POST',
|
|
416
411
|
body: JSON.stringify({ choice: approved ? 'once' : 'deny' }),
|
|
417
412
|
});
|
|
418
|
-
|
|
413
|
+
if (!options.silent) {
|
|
414
|
+
sendSystem(ws, approved ? '✅ 已批准 Hermes 操作。' : '🚫 已拒绝 Hermes 操作。');
|
|
415
|
+
}
|
|
419
416
|
} catch (err) {
|
|
420
417
|
sendError(ws, `Hermes 审批响应失败: ${err.message}`);
|
|
421
418
|
}
|
package/src/agents/openclaw.js
CHANGED
|
@@ -26,19 +26,17 @@ const {
|
|
|
26
26
|
setPendingPermission,
|
|
27
27
|
} = require('../permissionState');
|
|
28
28
|
|
|
29
|
+
const DEFAULT_TURN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
30
|
+
|
|
29
31
|
function execute(input, ws, session, config) {
|
|
30
32
|
const textForCheck = stringifyInput(input);
|
|
31
|
-
if (isDangerousCommand(textForCheck)) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
text: `Possible dangerous operation detected. Confirm to continue:\n\n"${preview}${textForCheck.length > 200 ? '...' : ''}"`,
|
|
39
|
-
});
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
33
|
+
if (isDangerousCommand(textForCheck) && session.autoApprove !== true) {
|
|
34
|
+
const preview = textForCheck.slice(0, 200);
|
|
35
|
+
session.pendingDanger = { input };
|
|
36
|
+
send(ws, 'danger_warning', {
|
|
37
|
+
text: `Possible dangerous operation detected. Confirm to continue:\n\n"${preview}${textForCheck.length > 200 ? '...' : ''}"`,
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
42
40
|
}
|
|
43
41
|
|
|
44
42
|
if (session.isTurnActive) {
|
|
@@ -82,15 +80,24 @@ async function runOpenClawTurn(input, ws, session, config) {
|
|
|
82
80
|
const done = new Promise(resolve => {
|
|
83
81
|
session._openclawFinish = resolve;
|
|
84
82
|
});
|
|
83
|
+
armOpenClawTurnTimeout(ws, session, config, agentConfig);
|
|
85
84
|
|
|
86
|
-
const
|
|
85
|
+
const sendStarted = client.request('chat.send', {
|
|
87
86
|
sessionKey,
|
|
88
87
|
message: stringifyInput(inputWithOutbox),
|
|
89
88
|
deliver: false,
|
|
90
89
|
idempotencyKey: runId,
|
|
91
90
|
attachments: buildOpenClawAttachments(inputWithOutbox),
|
|
92
|
-
}, { timeoutMs: null })
|
|
91
|
+
}, { timeoutMs: null }).catch(err => {
|
|
92
|
+
if (session.isTurnActive) throw err;
|
|
93
|
+
return null;
|
|
94
|
+
});
|
|
93
95
|
|
|
96
|
+
const result = await Promise.race([
|
|
97
|
+
sendStarted,
|
|
98
|
+
done.then(() => null),
|
|
99
|
+
]);
|
|
100
|
+
if (!session.isTurnActive) return;
|
|
94
101
|
if (result?.runId) session.openclawRunId = result.runId;
|
|
95
102
|
config.logger?.info('openclaw run started', {
|
|
96
103
|
runId: session.openclawRunId,
|
|
@@ -100,8 +107,10 @@ async function runOpenClawTurn(input, ws, session, config) {
|
|
|
100
107
|
|
|
101
108
|
await done;
|
|
102
109
|
} catch (err) {
|
|
103
|
-
if (
|
|
104
|
-
|
|
110
|
+
if (session.isTurnActive) {
|
|
111
|
+
if (!isClosedAbort(err)) sendError(ws, `OpenClaw error: ${err.message}`);
|
|
112
|
+
finishTurn(ws, session, config, { drain: !isClosedAbort(err) });
|
|
113
|
+
}
|
|
105
114
|
}
|
|
106
115
|
}
|
|
107
116
|
|
|
@@ -121,6 +130,9 @@ async function ensureOpenClawClient(session, agentConfig, gatewayUrl, config) {
|
|
|
121
130
|
session.openclawUnsubscribeEvents = client.onEvent((event, payload, frame) => {
|
|
122
131
|
handleOpenClawEvent(event, payload, frame, session._lastWs || session.ws, session, session._lastConfig || config);
|
|
123
132
|
});
|
|
133
|
+
session.openclawUnsubscribeClose = client.onClose(err => {
|
|
134
|
+
handleOpenClawGatewayClose(err, session._lastWs || session.ws, session, session._lastConfig || config);
|
|
135
|
+
});
|
|
124
136
|
session.agentProcess = createOpenClawProcessHandle(session, client);
|
|
125
137
|
await client.connect();
|
|
126
138
|
return client;
|
|
@@ -177,7 +189,6 @@ function handleOpenClawEvent(event, payload, frame, ws, session, config) {
|
|
|
177
189
|
return;
|
|
178
190
|
}
|
|
179
191
|
if (event === 'exec.approval.resolved' || event === 'plugin.approval.resolved') {
|
|
180
|
-
sendSystem(ws, 'OpenClaw approval resolved.');
|
|
181
192
|
return;
|
|
182
193
|
}
|
|
183
194
|
if (event.includes('tool') || event.includes('node') || event.includes('plugin')) {
|
|
@@ -235,6 +246,7 @@ function completeRun(payload, ws, session, config) {
|
|
|
235
246
|
|
|
236
247
|
function finishTurn(ws, session, config, options = {}) {
|
|
237
248
|
const { drain = true } = options;
|
|
249
|
+
clearOpenClawTurnTimeout(session);
|
|
238
250
|
session.openclawRunId = null;
|
|
239
251
|
session.openclawLastText = '';
|
|
240
252
|
session.openclawFinished = true;
|
|
@@ -249,6 +261,19 @@ function finishTurn(ws, session, config, options = {}) {
|
|
|
249
261
|
if (drain) drainQueue(ws, session, config);
|
|
250
262
|
}
|
|
251
263
|
|
|
264
|
+
function handleOpenClawGatewayClose(err, ws, session, config) {
|
|
265
|
+
if (!session?.isTurnActive) return;
|
|
266
|
+
const message = `OpenClaw Gateway disconnected: ${err?.message || 'connection closed'}`;
|
|
267
|
+
config.logger?.warn?.('openclaw gateway disconnected during turn', {
|
|
268
|
+
sessionId: session.id,
|
|
269
|
+
runId: session.openclawRunId,
|
|
270
|
+
error: err?.message,
|
|
271
|
+
});
|
|
272
|
+
sendError(ws, message);
|
|
273
|
+
sendTurnEnd(ws, session, 'error', { error: message });
|
|
274
|
+
finishTurn(ws, session, config);
|
|
275
|
+
}
|
|
276
|
+
|
|
252
277
|
function handleToolEvent(event, payload, ws) {
|
|
253
278
|
const state = String(payload.state || payload.status || event).toLowerCase();
|
|
254
279
|
const id = String(payload.id || payload.toolCallId || payload.callId || payload.runId || `${event}:${Date.now()}`);
|
|
@@ -289,7 +314,7 @@ function handleApprovalRequest(kind, payload, ws, session, config) {
|
|
|
289
314
|
|
|
290
315
|
if (session.autoApprove === true) {
|
|
291
316
|
config.logger?.info('openclaw permission auto-approved', { sessionId: session.id, requestId, kind });
|
|
292
|
-
resolvePendingPermission(true, ws, session, config, requestId).catch(err => {
|
|
317
|
+
resolvePendingPermission(true, ws, session, config, requestId, { silent: true }).catch(err => {
|
|
293
318
|
sendError(ws, `OpenClaw auto approval failed: ${err.message}`);
|
|
294
319
|
});
|
|
295
320
|
return;
|
|
@@ -302,7 +327,7 @@ function handleApprovalRequest(kind, payload, ws, session, config) {
|
|
|
302
327
|
});
|
|
303
328
|
}
|
|
304
329
|
|
|
305
|
-
async function resolvePendingPermission(approved, ws, session, config, requestId) {
|
|
330
|
+
async function resolvePendingPermission(approved, ws, session, config, requestId, options = {}) {
|
|
306
331
|
const pending = getPendingPermission(session, requestId, 'openclaw');
|
|
307
332
|
if (!pending) {
|
|
308
333
|
config.logger?.warn('openclaw permission response without pending request', {
|
|
@@ -324,7 +349,9 @@ async function resolvePendingPermission(approved, ws, session, config, requestId
|
|
|
324
349
|
id: pending.requestId,
|
|
325
350
|
decision: approved ? 'allow-once' : 'deny',
|
|
326
351
|
});
|
|
327
|
-
|
|
352
|
+
if (!options.silent) {
|
|
353
|
+
sendSystem(ws, approved ? 'OpenClaw operation approved.' : 'OpenClaw operation denied.');
|
|
354
|
+
}
|
|
328
355
|
} catch (err) {
|
|
329
356
|
sendError(ws, `OpenClaw approval response failed: ${err.message}`);
|
|
330
357
|
}
|
|
@@ -387,6 +414,10 @@ function createOpenClawProcessHandle(session, client) {
|
|
|
387
414
|
try { session.openclawUnsubscribeEvents(); } catch {}
|
|
388
415
|
session.openclawUnsubscribeEvents = null;
|
|
389
416
|
}
|
|
417
|
+
if (session.openclawUnsubscribeClose) {
|
|
418
|
+
try { session.openclawUnsubscribeClose(); } catch {}
|
|
419
|
+
session.openclawUnsubscribeClose = null;
|
|
420
|
+
}
|
|
390
421
|
client.close();
|
|
391
422
|
if (session.openclawClient === client) session.openclawClient = null;
|
|
392
423
|
},
|
|
@@ -406,14 +437,15 @@ function resolveOpenClawAgentId(input, session, agentConfig) {
|
|
|
406
437
|
|
|
407
438
|
function buildOpenClawSessionKey(agentId, session) {
|
|
408
439
|
const safeAgentId = sanitizeKeyPart(agentId || 'main');
|
|
440
|
+
const safeChatType = sanitizeKeyPart(session.linco?.chatType || 'direct');
|
|
409
441
|
const safeSessionId = sanitizeKeyPart(session.storageId || session.id || crypto.randomUUID());
|
|
410
|
-
return `agent:${safeAgentId}:
|
|
442
|
+
return `agent:${safeAgentId}:linco:${safeChatType}:${safeSessionId}`;
|
|
411
443
|
}
|
|
412
444
|
|
|
413
445
|
function isSessionKeyForAgent(sessionKey, agentId) {
|
|
414
446
|
const key = String(sessionKey || '');
|
|
415
447
|
const safeAgentId = sanitizeKeyPart(agentId || 'main');
|
|
416
|
-
return key.startsWith(`agent:${safeAgentId}:`);
|
|
448
|
+
return key.startsWith(`agent:${safeAgentId}:linco:`);
|
|
417
449
|
}
|
|
418
450
|
|
|
419
451
|
function ensureHistoryEntry(session, sessionKey, input) {
|
|
@@ -496,11 +528,43 @@ function stringifyInput(input) {
|
|
|
496
528
|
function maybeAddOutboxHint(input, session, config) {
|
|
497
529
|
const text = stringifyInput(input);
|
|
498
530
|
if (!/(send|upload|attach|file|image|download|发送|文件|图片)/i.test(text)) return input;
|
|
499
|
-
const hint =
|
|
531
|
+
const hint = `系统提示:如果你需要把生成的文件或图片发送给用户,请写入本会话的 outbox 目录。linco-connect 会自动下发新文件:\n${getOutboxDir(session, config)}`;
|
|
500
532
|
if (Array.isArray(input)) return [...input, { type: 'text', text: hint }];
|
|
501
533
|
return `${text}\n\n${hint}`;
|
|
502
534
|
}
|
|
503
535
|
|
|
536
|
+
function armOpenClawTurnTimeout(ws, session, config, agentConfig = {}) {
|
|
537
|
+
clearOpenClawTurnTimeout(session);
|
|
538
|
+
const timeoutMs = Number(agentConfig.turnTimeoutMs) > 0
|
|
539
|
+
? Number(agentConfig.turnTimeoutMs)
|
|
540
|
+
: DEFAULT_TURN_TIMEOUT_MS;
|
|
541
|
+
session.openclawTurnTimer = setTimeout(() => {
|
|
542
|
+
if (!session.isTurnActive) return;
|
|
543
|
+
const message = `OpenClaw turn timed out after ${Math.round(timeoutMs / 1000)}s.`;
|
|
544
|
+
config.logger?.warn?.('openclaw turn timeout', {
|
|
545
|
+
sessionId: session.id,
|
|
546
|
+
runId: session.openclawRunId,
|
|
547
|
+
timeoutMs,
|
|
548
|
+
});
|
|
549
|
+
if (session.openclawClient?.connected && session.agentSessionId) {
|
|
550
|
+
const params = session.openclawRunId
|
|
551
|
+
? { sessionKey: session.agentSessionId, runId: session.openclawRunId }
|
|
552
|
+
: { sessionKey: session.agentSessionId };
|
|
553
|
+
session.openclawClient.request('chat.abort', params).catch(() => {});
|
|
554
|
+
}
|
|
555
|
+
sendError(ws, message);
|
|
556
|
+
sendTurnEnd(ws, session, 'timeout', { error: message });
|
|
557
|
+
finishTurn(ws, session, config);
|
|
558
|
+
}, timeoutMs);
|
|
559
|
+
session.openclawTurnTimer.unref?.();
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function clearOpenClawTurnTimeout(session) {
|
|
563
|
+
if (!session.openclawTurnTimer) return;
|
|
564
|
+
clearTimeout(session.openclawTurnTimer);
|
|
565
|
+
session.openclawTurnTimer = null;
|
|
566
|
+
}
|
|
567
|
+
|
|
504
568
|
function firstText(input) {
|
|
505
569
|
if (!Array.isArray(input)) return String(input || '');
|
|
506
570
|
return input
|
|
@@ -614,5 +678,10 @@ module.exports = {
|
|
|
614
678
|
stop,
|
|
615
679
|
_internal: {
|
|
616
680
|
resolveOpenClawAgentId,
|
|
681
|
+
buildOpenClawSessionKey,
|
|
682
|
+
isSessionKeyForAgent,
|
|
683
|
+
handleOpenClawGatewayClose,
|
|
684
|
+
armOpenClawTurnTimeout,
|
|
685
|
+
clearOpenClawTurnTimeout,
|
|
617
686
|
},
|
|
618
687
|
};
|
package/src/claudeRunner.js
CHANGED
|
@@ -24,7 +24,6 @@ function executeClaudeQuery(input, ws, session, config) {
|
|
|
24
24
|
autoApprove: session.autoApprove === true,
|
|
25
25
|
});
|
|
26
26
|
if (session.autoApprove === true) {
|
|
27
|
-
sendSystem(ws, '✅ 已自动批准危险操作确认。');
|
|
28
27
|
enqueueClaudeQuery(input, ws, session, config);
|
|
29
28
|
return;
|
|
30
29
|
}
|
|
@@ -475,7 +474,6 @@ function handleControlRequest(parsed, ws, session, config) {
|
|
|
475
474
|
requestId: requestID,
|
|
476
475
|
toolName,
|
|
477
476
|
});
|
|
478
|
-
sendSystem(ws, `✅ 已自动批准工具使用:${toolName}`);
|
|
479
477
|
} catch (err) {
|
|
480
478
|
sendError(ws, `❌ 自动批准工具权限失败: ${err.message}`);
|
|
481
479
|
}
|
package/src/config.js
CHANGED
|
@@ -293,6 +293,7 @@ function agentConfig(userConfig, imConfig, agentType, defaults = {}) {
|
|
|
293
293
|
...(agentType === 'openclaw' ? {
|
|
294
294
|
autoStartGateway: booleanFromEnv('LINCO_OPENCLAW_AUTO_START_GATEWAY', configured.autoStartGateway ?? channelAccount?.autoStartGateway ?? defaults.autoStartGateway ?? true),
|
|
295
295
|
gatewayStartTimeoutMs: numberFromEnv('LINCO_OPENCLAW_GATEWAY_START_TIMEOUT_MS', configured.gatewayStartTimeoutMs || channelAccount?.gatewayStartTimeoutMs || defaults.gatewayStartTimeoutMs || 30000),
|
|
296
|
+
turnTimeoutMs: numberFromEnv('LINCO_OPENCLAW_TURN_TIMEOUT_MS', configured.turnTimeoutMs || channelAccount?.turnTimeoutMs || defaults.turnTimeoutMs || TIMEOUT),
|
|
296
297
|
openclawAgentId: stringFromEnv(agentIdEnv, channelAccount?.openclawAgentId || configured.openclawAgentId || defaults.openclawAgentId || 'main'),
|
|
297
298
|
} : {}),
|
|
298
299
|
appId: stringFromEnv(appIdEnv, channelAccount?.appId || configured.appId),
|
package/src/openclawGateway.js
CHANGED
|
@@ -201,11 +201,13 @@ class OpenClawGatewayClient {
|
|
|
201
201
|
this.requestTimeoutMs = options.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS;
|
|
202
202
|
this.pending = new Map();
|
|
203
203
|
this.eventHandlers = new Set();
|
|
204
|
+
this.closeHandlers = new Set();
|
|
204
205
|
this.methods = new Set();
|
|
205
206
|
this.hello = null;
|
|
206
207
|
this.ws = null;
|
|
207
208
|
this.connected = false;
|
|
208
209
|
this.closed = false;
|
|
210
|
+
this.closeNotified = false;
|
|
209
211
|
}
|
|
210
212
|
|
|
211
213
|
async connect() {
|
|
@@ -252,6 +254,7 @@ class OpenClawGatewayClient {
|
|
|
252
254
|
helloResolved = true;
|
|
253
255
|
clearTimeout(timer);
|
|
254
256
|
this.connected = true;
|
|
257
|
+
this.closeNotified = false;
|
|
255
258
|
this.hello = frame.payload || {};
|
|
256
259
|
for (const method of this.hello.features?.methods || []) this.methods.add(method);
|
|
257
260
|
resolve(this.hello);
|
|
@@ -262,10 +265,15 @@ class OpenClawGatewayClient {
|
|
|
262
265
|
});
|
|
263
266
|
|
|
264
267
|
ws.once('open', () => {});
|
|
265
|
-
ws.once('error', err =>
|
|
268
|
+
ws.once('error', err => {
|
|
269
|
+
if (this.connected && !this.closed) this.notifyClose(err);
|
|
270
|
+
rejectOnce(err);
|
|
271
|
+
});
|
|
266
272
|
ws.once('close', (code, reason) => {
|
|
267
273
|
this.connected = false;
|
|
268
|
-
|
|
274
|
+
const err = new Error(`OpenClaw Gateway closed: ${code} ${reason?.toString?.() || ''}`.trim());
|
|
275
|
+
this.rejectAllPending(err);
|
|
276
|
+
if (!this.closed) this.notifyClose(err);
|
|
269
277
|
if (!helloResolved) rejectOnce(new Error(`OpenClaw Gateway closed before connect: ${code}`));
|
|
270
278
|
});
|
|
271
279
|
});
|
|
@@ -314,6 +322,11 @@ class OpenClawGatewayClient {
|
|
|
314
322
|
return () => this.eventHandlers.delete(handler);
|
|
315
323
|
}
|
|
316
324
|
|
|
325
|
+
onClose(handler) {
|
|
326
|
+
this.closeHandlers.add(handler);
|
|
327
|
+
return () => this.closeHandlers.delete(handler);
|
|
328
|
+
}
|
|
329
|
+
|
|
317
330
|
close() {
|
|
318
331
|
this.closed = true;
|
|
319
332
|
this.connected = false;
|
|
@@ -323,6 +336,7 @@ class OpenClawGatewayClient {
|
|
|
323
336
|
} catch {}
|
|
324
337
|
this.ws = null;
|
|
325
338
|
this.eventHandlers.clear();
|
|
339
|
+
this.closeHandlers.clear();
|
|
326
340
|
}
|
|
327
341
|
|
|
328
342
|
handleFrame(frame) {
|
|
@@ -358,6 +372,18 @@ class OpenClawGatewayClient {
|
|
|
358
372
|
this.pending.delete(id);
|
|
359
373
|
}
|
|
360
374
|
}
|
|
375
|
+
|
|
376
|
+
notifyClose(err) {
|
|
377
|
+
if (this.closeNotified) return;
|
|
378
|
+
this.closeNotified = true;
|
|
379
|
+
for (const handler of Array.from(this.closeHandlers)) {
|
|
380
|
+
try {
|
|
381
|
+
handler(err);
|
|
382
|
+
} catch (handlerErr) {
|
|
383
|
+
this.logger?.warn?.('openclaw close handler failed', { error: handlerErr.message });
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
361
387
|
}
|
|
362
388
|
|
|
363
389
|
function buildConnectRequest(_nonce, agentConfig = {}) {
|
|
@@ -151,8 +151,6 @@ function getOutboxDir(session, config) {
|
|
|
151
151
|
function buildOutboxSystemPrompt(session, config) {
|
|
152
152
|
return `${config.systemPrompt}
|
|
153
153
|
|
|
154
|
-
工具使用要求:调用 Read 工具读取普通文件时不要传 pages 字段;只有读取 PDF 且需要指定页码时才传 pages,且 pages 不能是空字符串。
|
|
155
|
-
|
|
156
154
|
如果你需要把文件或图片发送给用户,请将文件保存或复制到以下目录:
|
|
157
155
|
${getOutboxDir(session, config)}
|
|
158
156
|
|
package/src/serverApp.js
CHANGED
|
@@ -4,6 +4,7 @@ const { createStaticServer } = require('./httpStatic');
|
|
|
4
4
|
const { startImConnectors } = require('./imConnector');
|
|
5
5
|
const { createLogger } = require('./logger');
|
|
6
6
|
const { attachWebSocketServer } = require('./wsServer');
|
|
7
|
+
const { cleanupSession } = require('./session');
|
|
7
8
|
|
|
8
9
|
async function prewarmHermesGateway(config) {
|
|
9
10
|
const hermes = config.agents?.hermes;
|
|
@@ -28,14 +29,16 @@ function startServer(rootDir, options = {}) {
|
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
const imConnectors = startImConnectors(config);
|
|
32
|
+
config._imConnectors = imConnectors;
|
|
31
33
|
|
|
32
34
|
if (config.localWeb?.enabled) {
|
|
33
35
|
const server = createStaticServer(config);
|
|
34
|
-
attachWebSocketServer(server, config);
|
|
36
|
+
config._localWss = attachWebSocketServer(server, config);
|
|
35
37
|
|
|
36
38
|
server.on('close', () => {
|
|
37
39
|
log.info('server closing');
|
|
38
40
|
for (const connector of imConnectors) connector.stop();
|
|
41
|
+
config._localWss = null;
|
|
39
42
|
options.onClose?.(server, config);
|
|
40
43
|
});
|
|
41
44
|
|
|
@@ -96,6 +99,107 @@ function startServer(rootDir, options = {}) {
|
|
|
96
99
|
}
|
|
97
100
|
}
|
|
98
101
|
|
|
102
|
+
async function stopServer(config, server, options = {}) {
|
|
103
|
+
if (!config?.lincoHome) return;
|
|
104
|
+
if (config._shutdownPromise) return config._shutdownPromise;
|
|
105
|
+
|
|
106
|
+
config._shutdownPromise = (async () => {
|
|
107
|
+
const log = config.logger;
|
|
108
|
+
log?.info('server shutdown started');
|
|
109
|
+
|
|
110
|
+
for (const connector of config._imConnectors || []) {
|
|
111
|
+
try {
|
|
112
|
+
connector.stop();
|
|
113
|
+
} catch (err) {
|
|
114
|
+
log?.warn('im connector stop failed', { error: err.message });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
closeLocalWebSockets(config);
|
|
119
|
+
cleanupActiveSessions(config);
|
|
120
|
+
await closeHttpServer(server, options.serverCloseMs || 1000);
|
|
121
|
+
|
|
122
|
+
// Session cleanup first closes stdin; wait long enough for the fallback
|
|
123
|
+
// process-tree kill to run before the parent exits.
|
|
124
|
+
await delay(options.childGraceMs || 3200);
|
|
125
|
+
log?.info('server shutdown completed');
|
|
126
|
+
})();
|
|
127
|
+
|
|
128
|
+
return config._shutdownPromise;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function closeLocalWebSockets(config) {
|
|
132
|
+
const wss = config._localWss;
|
|
133
|
+
if (!wss) return;
|
|
134
|
+
|
|
135
|
+
for (const client of wss.clients || []) {
|
|
136
|
+
try {
|
|
137
|
+
client.close(1001, 'Server shutdown');
|
|
138
|
+
} catch {}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
setTimeout(() => {
|
|
142
|
+
for (const client of wss.clients || []) {
|
|
143
|
+
try {
|
|
144
|
+
client.terminate();
|
|
145
|
+
} catch {}
|
|
146
|
+
}
|
|
147
|
+
}, 1000).unref?.();
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
wss.close();
|
|
151
|
+
} catch {}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function cleanupActiveSessions(config) {
|
|
155
|
+
const activeSessions = config.activeSessions;
|
|
156
|
+
if (!activeSessions?.size) return;
|
|
157
|
+
|
|
158
|
+
const sessions = [...activeSessions.values()];
|
|
159
|
+
activeSessions.clear();
|
|
160
|
+
for (const session of sessions) {
|
|
161
|
+
try {
|
|
162
|
+
cleanupSession(session);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
config.logger?.warn('session cleanup failed', {
|
|
165
|
+
sessionId: session?.id,
|
|
166
|
+
agentType: session?.agentType,
|
|
167
|
+
error: err.message,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function closeHttpServer(server, timeoutMs) {
|
|
174
|
+
if (!server || typeof server.close !== 'function') return Promise.resolve();
|
|
175
|
+
|
|
176
|
+
return new Promise((resolve) => {
|
|
177
|
+
let settled = false;
|
|
178
|
+
const finish = () => {
|
|
179
|
+
if (settled) return;
|
|
180
|
+
settled = true;
|
|
181
|
+
resolve();
|
|
182
|
+
};
|
|
183
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
184
|
+
timer.unref?.();
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
server.close(() => {
|
|
188
|
+
clearTimeout(timer);
|
|
189
|
+
finish();
|
|
190
|
+
});
|
|
191
|
+
} catch {
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
finish();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function delay(ms) {
|
|
199
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
200
|
+
}
|
|
201
|
+
|
|
99
202
|
module.exports = {
|
|
100
203
|
startServer,
|
|
204
|
+
stopServer,
|
|
101
205
|
};
|
package/src/session.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const crypto = require('crypto');
|
|
2
2
|
const fs = require('fs');
|
|
3
3
|
const path = require('path');
|
|
4
|
+
const { spawnSync } = require('child_process');
|
|
4
5
|
const { ensureDir } = require('./config');
|
|
5
6
|
const { stopOutboxWatcher } = require('./outgoingAttachmentHandler');
|
|
6
7
|
const { createTextStreamBuffer, resetTextStream } = require('./streamBuffer');
|
|
@@ -306,27 +307,65 @@ function resetConversationState(session, { clearAgentSession = true, clearClaude
|
|
|
306
307
|
|
|
307
308
|
function stopAgentProcess(session, { clearAgentSession = false, clearClaudeSession } = {}) {
|
|
308
309
|
const shouldClearAgentSession = clearClaudeSession == null ? clearAgentSession : clearClaudeSession;
|
|
309
|
-
const
|
|
310
|
+
const children = collectSessionChildren(session);
|
|
310
311
|
session.agentProcess = null;
|
|
311
312
|
session.claudeProcess = null;
|
|
313
|
+
session.codexAppServer = null;
|
|
312
314
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if (!child.stdin.destroyed) child.stdin.end();
|
|
316
|
-
} catch {}
|
|
317
|
-
|
|
318
|
-
if (!child.killed && child.exitCode === null) {
|
|
319
|
-
setTimeout(() => {
|
|
320
|
-
if (!child.killed && child.exitCode === null) {
|
|
321
|
-
child.kill();
|
|
322
|
-
}
|
|
323
|
-
}, 3000).unref?.();
|
|
324
|
-
}
|
|
315
|
+
for (const child of children) {
|
|
316
|
+
stopChildProcess(child);
|
|
325
317
|
}
|
|
326
318
|
|
|
327
319
|
resetConversationState(session, { clearAgentSession: shouldClearAgentSession });
|
|
328
320
|
}
|
|
329
321
|
|
|
322
|
+
function collectSessionChildren(session) {
|
|
323
|
+
const children = [];
|
|
324
|
+
const seen = new Set();
|
|
325
|
+
for (const child of [session.agentProcess, session.claudeProcess, session.codexAppServer]) {
|
|
326
|
+
if (!child || seen.has(child)) continue;
|
|
327
|
+
seen.add(child);
|
|
328
|
+
children.push(child);
|
|
329
|
+
}
|
|
330
|
+
return children;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function stopChildProcess(child, graceMs = 3000) {
|
|
334
|
+
try {
|
|
335
|
+
if (child.stdin && !child.stdin.destroyed) child.stdin.end();
|
|
336
|
+
} catch {}
|
|
337
|
+
|
|
338
|
+
if (isChildRunning(child)) {
|
|
339
|
+
setTimeout(() => {
|
|
340
|
+
forceKillChildProcess(child);
|
|
341
|
+
}, graceMs).unref?.();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function isChildRunning(child) {
|
|
346
|
+
return !!child && !child.killed && child.exitCode === null;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function forceKillChildProcess(child) {
|
|
350
|
+
if (!isChildRunning(child)) return;
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
if (process.platform === 'win32' && child.pid) {
|
|
354
|
+
spawnSync('taskkill.exe', ['/pid', String(child.pid), '/T', '/F'], {
|
|
355
|
+
stdio: 'ignore',
|
|
356
|
+
windowsHide: true,
|
|
357
|
+
});
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
child.kill('SIGKILL');
|
|
362
|
+
} catch {
|
|
363
|
+
try {
|
|
364
|
+
child.kill();
|
|
365
|
+
} catch {}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
330
369
|
function stopClaudeProcess(session, { clearClaudeSession = false } = {}) {
|
|
331
370
|
stopAgentProcess(session, { clearAgentSession: clearClaudeSession });
|
|
332
371
|
}
|