linco-connect 1.1.4 → 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 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
@@ -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
- startServer(rootDir, { config });
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
- const pidFile = daemonPidFile(config);
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
- process.kill(metadata.pid, 'SIGTERM');
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 连接器(本地测试页需加 --local-im 开启)
497
- stop 停止后台运行的 Linco Connect
542
+ start 启动本机 Agent 连接器(已运行时会先停止旧进程再启动)
543
+ stop 停止运行中的 Linco Connect
498
544
  doctor 检查本地运行环境
499
545
 
500
546
  Agent:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "linco-connect",
3
- "version": "1.1.4",
3
+ "version": "1.1.5",
4
4
  "description": "自研 IM 桥接多 Agent 服务",
5
5
  "main": "server.js",
6
6
  "bin": {
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
- if (cliFlags.includes('--daemon') || cliFlags.includes('--local-im') || cliFlags.includes('--mock-im')) {
7
- const cli = path.join(__dirname, 'bin', 'linco-connect.js');
8
- const result = spawnSync(process.execPath, [cli, 'start', ...cliFlags], {
9
- cwd: __dirname,
10
- env: process.env,
11
- stdio: 'inherit',
12
- windowsHide: true,
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);
@@ -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
- if (session.autoApprove === true) {
19
- sendSystem(ws, '✅ 已自动批准危险操作确认。');
20
- } else {
21
- const preview = textForCheck.slice(0, 200);
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
- return;
29
- }
24
+ });
25
+ return;
30
26
  }
31
27
 
32
28
  if (session.isTurnActive) {
@@ -378,15 +374,24 @@ function buildCodexThreadSandbox(session) {
378
374
  }
379
375
 
380
376
  function buildCodexPermissionGrant(session) {
377
+ const readableRoots = [session.attachmentsDir].filter(Boolean);
381
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
+
382
390
  return {
383
391
  fileSystem: {
384
- read: null,
392
+ read: readableRoots,
385
393
  write: writableRoots,
386
- entries: writableRoots.map(root => ({
387
- path: { type: 'path', path: root },
388
- access: 'write',
389
- })),
394
+ entries,
390
395
  },
391
396
  network: { enabled: false },
392
397
  };
@@ -397,29 +402,14 @@ function handleServerRequest(message, session) {
397
402
  const params = message.params || {};
398
403
  const ws = session._lastWs;
399
404
 
400
- // File change approval — auto-approve but notify user
405
+ // File change approval — auto-approve silently
401
406
  if (method === 'item/fileChange/requestApproval') {
402
- const toolId = String(message.id);
403
- const input = summarizeCodexParams(params);
404
407
  session._log?.info('codex auto-approving file change');
405
- if (ws) {
406
- send(ws, 'tool_call', {
407
- id: toolId,
408
- name: 'fileChange',
409
- input,
410
- });
411
- }
412
408
  sendJsonRpc(session.codexAppServer, {
413
409
  jsonrpc: '2.0',
414
410
  id: message.id,
415
411
  result: { decision: 'accept' },
416
412
  });
417
- if (ws) {
418
- send(ws, 'tool_result', {
419
- id: toolId,
420
- output: 'approved',
421
- });
422
- }
423
413
  return;
424
414
  }
425
415
 
@@ -436,9 +426,6 @@ function handleServerRequest(message, session) {
436
426
  id: message.id,
437
427
  result: { decision: 'accept' },
438
428
  });
439
- if (ws) {
440
- sendSystem(ws, `✅ 已自动批准命令执行:${cmd}`);
441
- }
442
429
  return;
443
430
  }
444
431
 
@@ -472,29 +459,14 @@ function handleServerRequest(message, session) {
472
459
  return;
473
460
  }
474
461
 
475
- // Apply patch approval — auto-approve
462
+ // Apply patch approval — auto-approve silently
476
463
  if (method === 'applyPatchApproval') {
477
- const toolId = String(message.id);
478
- const input = summarizeCodexParams(params);
479
464
  session._log?.info('codex auto-approving patch');
480
- if (ws) {
481
- send(ws, 'tool_call', {
482
- id: toolId,
483
- name: 'applyPatch',
484
- input,
485
- });
486
- }
487
465
  sendJsonRpc(session.codexAppServer, {
488
466
  jsonrpc: '2.0',
489
467
  id: message.id,
490
468
  result: { decision: 'approved' },
491
469
  });
492
- if (ws) {
493
- send(ws, 'tool_result', {
494
- id: toolId,
495
- output: 'approved',
496
- });
497
- }
498
470
  return;
499
471
  }
500
472
 
@@ -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
- if (session.autoApprove === true) {
27
- sendSystem(ws, '✅ 已自动批准危险操作确认。');
28
- } else {
29
- const preview = textForCheck.slice(0, 200);
30
- session.pendingDanger = { input };
31
- send(ws, 'danger_warning', {
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
- sendSystem(ws, approved ? '✅ 已批准 Hermes 操作。' : '🚫 已拒绝 Hermes 操作。');
413
+ if (!options.silent) {
414
+ sendSystem(ws, approved ? '✅ 已批准 Hermes 操作。' : '🚫 已拒绝 Hermes 操作。');
415
+ }
419
416
  } catch (err) {
420
417
  sendError(ws, `Hermes 审批响应失败: ${err.message}`);
421
418
  }
@@ -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
- if (session.autoApprove === true) {
33
- sendSystem(ws, 'Dangerous operation auto-approved.');
34
- } else {
35
- const preview = textForCheck.slice(0, 200);
36
- session.pendingDanger = { input };
37
- send(ws, 'danger_warning', {
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 result = await client.request('chat.send', {
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 (!isClosedAbort(err)) sendError(ws, `OpenClaw error: ${err.message}`);
104
- finishTurn(ws, session, config, { drain: !isClosedAbort(err) });
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
- sendSystem(ws, approved ? 'OpenClaw operation approved.' : 'OpenClaw operation denied.');
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}:ddchat:${safeSessionId}`;
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 = `System hint: if you need to send a generated file or image to the user, write it to this session outbox directory. linco-connect will deliver new files automatically:\n${getOutboxDir(session, config)}`;
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
  };
@@ -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),
@@ -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 => rejectOnce(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
- this.rejectAllPending(new Error(`OpenClaw Gateway closed: ${code} ${reason?.toString?.() || ''}`.trim()));
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 child = session.agentProcess || session.claudeProcess;
310
+ const children = collectSessionChildren(session);
310
311
  session.agentProcess = null;
311
312
  session.claudeProcess = null;
313
+ session.codexAppServer = null;
312
314
 
313
- if (child) {
314
- try {
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
  }