rol-websocket-channel 1.7.4 → 1.7.6

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/dist/index.js CHANGED
@@ -34,16 +34,30 @@ function getChannelConfig(cfg) {
34
34
  export function getPluginRuntime() {
35
35
  return pluginRuntime;
36
36
  }
37
+ // Chinese translations keyed by error code, so CLI failures are returned
38
+ // bilingually (English `message` + Chinese `messageZh`).
39
+ const ERROR_MESSAGES_ZH = {
40
+ PAIR_KEY_REQUIRED: "配对码不能为空",
41
+ PAIR_CONFIG_NOT_FOUND: "找不到 openclaw.json 配置文件(请确认插件位于 OpenClaw 安装目录内,或设置环境变量 OPENCLAW_HOME 指向 .openclaw 目录)",
42
+ PAIR_EXCHANGE_REQUEST_FAILED: "配对请求发送失败(无法连接配对服务,请检查网络、DNS 或代理)",
43
+ PAIR_EXCHANGE_FAILED: "配对兑换被拒绝(配对码无效、已过期或已被使用;请向签发服务获取新配对码,并在 10 分钟内尽快完成配对)",
44
+ PAIR_CHANNEL_CONFIG_INVALID: "配对返回的频道配置无效(缺少 mqttUrl 或 mqttTopic)",
45
+ };
37
46
  export function formatCliErrorPayload(error) {
38
47
  const data = error?.data;
39
48
  const dataObject = data && typeof data === "object" && !Array.isArray(data)
40
49
  ? data
41
50
  : {};
42
51
  const code = dataObject.code ?? error?.code;
52
+ const message = error instanceof Error ? error.message : String(error);
53
+ // Fall back to the English message when no Chinese translation exists for
54
+ // the code, so both fields are always present.
55
+ const messageZh = (typeof code === "string" && ERROR_MESSAGES_ZH[code]) || message;
43
56
  return {
44
57
  ok: false,
45
58
  error: {
46
- message: error instanceof Error ? error.message : String(error),
59
+ message,
60
+ messageZh,
47
61
  ...dataObject,
48
62
  ...(code === undefined ? {} : { code }),
49
63
  },
@@ -74,6 +88,14 @@ const WebSocketChannel = {
74
88
  mentions: true,
75
89
  },
76
90
  },
91
+ // 让配对写入的 channels.rol-websocket-channel 变更被判为 hot:
92
+ // OpenClaw 只热重载本 channel(进程内 stop→start,加载新 MQTT 连接),
93
+ // 不整机重启、不等 health-monitor。配置写完即在 gateway 进程内生效。
94
+ // (plugins.entries.rol-websocket-channel.* 已由 OpenClaw 内置的
95
+ // channel-plugin-state 规则自动判为 hot+restart-channel,无需在此声明。)
96
+ reload: {
97
+ configPrefixes: ["channels.rol-websocket-channel"],
98
+ },
77
99
  configSchema: {
78
100
  schema: {
79
101
  type: "object",
@@ -466,6 +488,19 @@ async function handleIncomingMessage(payload, account, cfg, runtime, log, mqttTo
466
488
  await handleCustomMessageType(msgType, innerData, traceId, account.accountId, mqttTopic);
467
489
  return;
468
490
  }
491
+ // 收到 sender 消息时,先回复一个确认
492
+ publishCustomMessageResponse({
493
+ type: "receiver",
494
+ trace_id: traceId,
495
+ source: "system-pre",
496
+ timestamp: Date.now(),
497
+ success: true,
498
+ data: {
499
+ ok: true,
500
+ status: "received",
501
+ message: "已收到消息"
502
+ }
503
+ }, innerData, mqttTopic);
469
504
  // 构造标准消息格式
470
505
  const attachments = (innerData.attachments || []).map((att) => ({
471
506
  url: att.url,
@@ -545,9 +580,9 @@ async function handleIncomingMessage(payload, account, cfg, runtime, log, mqttTo
545
580
  let targetTopic = mqttTopic;
546
581
  const sourceType = innerData?.source_type;
547
582
  if (targetTopic.endsWith("#")) {
548
- const replacement = sourceType === "device" ? "device" : "bot";
549
- targetTopic = targetTopic.slice(0, -1) + replacement;
583
+ targetTopic = targetTopic.slice(0, -1) + 'app';
550
584
  }
585
+ targetTopic = targetTopic.replace('device', 'app').replace('bot', 'app');
551
586
  conn.ws.publish(targetTopic, JSON.stringify(replyMessage));
552
587
  },
553
588
  onError: (err) => {
@@ -689,9 +724,9 @@ function publishCustomMessageResponse(response, innerData, mqttTopic) {
689
724
  let targetTopic = mqttTopic;
690
725
  const sourceType = innerData?.source_type;
691
726
  if (targetTopic.endsWith("#")) {
692
- const replacement = sourceType === "device" ? "device" : "bot";
693
- targetTopic = targetTopic.slice(0, -1) + replacement;
727
+ targetTopic = targetTopic.slice(0, -1) + 'app';
694
728
  }
729
+ targetTopic = targetTopic.replace('device', 'app').replace('bot', 'app');
695
730
  conn.ws.publish(targetTopic, JSON.stringify(response));
696
731
  }
697
732
  }
@@ -779,7 +814,11 @@ function registerAdminBridgeCli(api) {
779
814
  }
780
815
  catch (error) {
781
816
  process.exitCode = 1;
782
- process.stderr.write(JSON.stringify(formatCliErrorPayload(error), null, 2) + "\n");
817
+ // Emit the structured error on stdout (same channel as the
818
+ // success payload) so callers that only capture stdout still
819
+ // receive the full diagnostic — message, code, status,
820
+ // serviceMessage — instead of just exit code 1.
821
+ process.stdout.write(JSON.stringify(formatCliErrorPayload(error), null, 2) + "\n");
783
822
  }
784
823
  });
785
824
  const mem9 = root
@@ -9,38 +9,38 @@ export function getProjectRoot() {
9
9
  if (path.basename(root) === 'dist') {
10
10
  root = path.dirname(root);
11
11
  }
12
- console.log('[paths] __dirname:', __dirname);
13
- console.log('[paths] getProjectRoot:', root);
12
+ console.error('[paths] __dirname:', __dirname);
13
+ console.error('[paths] getProjectRoot:', root);
14
14
  return root;
15
15
  }
16
16
  export function getOpenClawRoot() {
17
17
  if (process.env.OPENCLAW_HOME) {
18
- console.log('[paths] getOpenClawRoot from env:', process.env.OPENCLAW_HOME);
18
+ console.error('[paths] getOpenClawRoot from env:', process.env.OPENCLAW_HOME);
19
19
  return path.resolve(process.env.OPENCLAW_HOME);
20
20
  }
21
21
  const projectRoot = getProjectRoot();
22
22
  const configuredRoot = findNearestOpenClawConfigRoot(projectRoot);
23
23
  if (configuredRoot) {
24
- console.log('[paths] getOpenClawRoot from discovered openclaw.json:', configuredRoot);
24
+ console.error('[paths] getOpenClawRoot from discovered openclaw.json:', configuredRoot);
25
25
  return configuredRoot;
26
26
  }
27
27
  const openclawDir = findAncestorNamed(projectRoot, '.openclaw');
28
28
  if (openclawDir) {
29
- console.log('[paths] getOpenClawRoot from .openclaw ancestor:', openclawDir);
29
+ console.error('[paths] getOpenClawRoot from .openclaw ancestor:', openclawDir);
30
30
  return openclawDir;
31
31
  }
32
32
  const parentDir = path.dirname(projectRoot);
33
33
  const parentName = path.basename(parentDir);
34
- console.log('[paths] projectRoot:', projectRoot);
35
- console.log('[paths] parentDir:', parentDir);
36
- console.log('[paths] parentName:', parentName);
34
+ console.error('[paths] projectRoot:', projectRoot);
35
+ console.error('[paths] parentDir:', parentDir);
36
+ console.error('[paths] parentName:', parentName);
37
37
  if (parentName === 'extensions') {
38
38
  const openclawRoot = path.dirname(parentDir);
39
- console.log('[paths] getOpenClawRoot (extensions):', openclawRoot);
39
+ console.error('[paths] getOpenClawRoot (extensions):', openclawRoot);
40
40
  return openclawRoot;
41
41
  }
42
42
  const fallbackRoot = path.resolve(projectRoot, '..');
43
- console.log('[paths] getOpenClawRoot (fallback):', fallbackRoot);
43
+ console.error('[paths] getOpenClawRoot (fallback):', fallbackRoot);
44
44
  return fallbackRoot;
45
45
  }
46
46
  function findNearestOpenClawConfigRoot(startDir) {
@@ -1,14 +1,9 @@
1
- import { exec, execFile } from 'node:child_process';
2
1
  import path from 'node:path';
3
- import { promisify } from 'node:util';
4
2
  import { pathExists, readJsonFile, writeJsonFile } from '../lib/fs.js';
5
3
  import { JsonRpcException, JSON_RPC_ERRORS } from '../jsonrpc.js';
6
- const execFileAsync = promisify(execFile);
7
- const execAsync = promisify(exec);
8
4
  const DEFAULT_PLUGIN_ID = 'rol-websocket-channel';
9
5
  const DEFAULT_PRODUCTION_BASE_URL = 'https://api.deotaland.ai';
10
6
  const PAIR_ENDPOINT_PATH = '/api-core-bot/front/agent/agent/key/query';
11
- const GATEWAY_SERVICE = 'openclaw-gateway.service';
12
7
  export async function pairWithKey(options, context) {
13
8
  const key = options.key.trim();
14
9
  if (!key) {
@@ -21,7 +16,6 @@ export async function pairWithKey(options, context) {
21
16
  const payload = await exchangePairKey(key, endpoint, options.auth, existingMqttUrl);
22
17
  applyPairingConfig(config, key, payload);
23
18
  await writeJsonFile(configPath, config);
24
- const restart = await restartGateway(context.projectRoot);
25
19
  return {
26
20
  ok: true,
27
21
  openclawRoot: context.openclawRoot,
@@ -34,7 +28,18 @@ export async function pairWithKey(options, context) {
34
28
  `channels.${payload.pluginId}`
35
29
  ],
36
30
  channel: payload.channel,
37
- restart
31
+ // No gateway restart is dispatched here on purpose. Writing openclaw.json is
32
+ // enough: OpenClaw's config watcher detects the `channels` change, plans a
33
+ // gateway restart, and defers it until in-flight operations/replies/embedded
34
+ // runs drain before restarting with the new config. Spawning our own
35
+ // `openclaw gateway restart` would restart immediately and jump ahead of that
36
+ // deferral, killing the gateway mid-operation (the original pair-hang bug).
37
+ reload: {
38
+ dispatched: false,
39
+ handledBy: 'openclaw-config-watcher',
40
+ mode: 'deferred-until-idle',
41
+ note: 'OpenClaw restarts the gateway automatically after the config write.'
42
+ }
38
43
  };
39
44
  }
40
45
  async function loadConfig(configPath) {
@@ -292,56 +297,6 @@ function resolveExistingMqttUrl(config) {
292
297
  }
293
298
  return pickString(channelConfig.mqttUrl);
294
299
  }
295
- async function restartGateway(cwd) {
296
- const attempts = [];
297
- try {
298
- const { stdout, stderr } = await execAsync('openclaw gateway restart', { cwd });
299
- return {
300
- attempted: true,
301
- success: true,
302
- command: 'openclaw gateway restart',
303
- stdout: stdout.trim(),
304
- stderr: stderr.trim()
305
- };
306
- }
307
- catch (error) {
308
- attempts.push(formatRestartAttempt('openclaw gateway restart', error));
309
- }
310
- if (process.platform === 'win32') {
311
- return {
312
- attempted: true,
313
- success: false,
314
- message: 'openclaw gateway restart failed',
315
- attempts
316
- };
317
- }
318
- try {
319
- await execFileAsync('systemctl', ['--user', 'restart', GATEWAY_SERVICE], { cwd });
320
- return {
321
- attempted: true,
322
- success: true,
323
- command: `systemctl --user restart ${GATEWAY_SERVICE}`,
324
- attempts
325
- };
326
- }
327
- catch (error) {
328
- attempts.push(formatRestartAttempt(`systemctl --user restart ${GATEWAY_SERVICE}`, error));
329
- return {
330
- attempted: true,
331
- success: false,
332
- message: 'gateway restart failed',
333
- attempts
334
- };
335
- }
336
- }
337
- function formatRestartAttempt(command, error) {
338
- return {
339
- command,
340
- message: error instanceof Error ? error.message : String(error),
341
- stdout: typeof error?.stdout === 'string' ? error.stdout.trim() : '',
342
- stderr: typeof error?.stderr === 'string' ? error.stderr.trim() : ''
343
- };
344
- }
345
300
  function normalizeGroupPolicy(value) {
346
301
  if (value === 'pairing' || value === 'allowlist' || value === 'open' || value === 'disabled') {
347
302
  return value;
@@ -12,9 +12,10 @@ export const getSession = async (params, context) => {
12
12
  : undefined;
13
13
  const sessionId = typeof objectParams.sessionId === 'string' ? objectParams.sessionId : null;
14
14
  const requestedLimit = typeof objectParams.limit === 'number' ? objectParams.limit : MAX_SESSION_MESSAGES;
15
- const requestedOffset = typeof objectParams.offset === 'number' ? objectParams.offset : 0;
15
+ const beforeId = typeof objectParams.beforeId === 'string' && objectParams.beforeId.trim()
16
+ ? objectParams.beforeId.trim()
17
+ : undefined;
16
18
  const limit = normalizePageSize(requestedLimit, MAX_SESSION_MESSAGES);
17
- const offset = normalizeOffset(requestedOffset);
18
19
  if (!sessionId) {
19
20
  throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, 'Missing required parameter: sessionId');
20
21
  }
@@ -23,7 +24,7 @@ export const getSession = async (params, context) => {
23
24
  throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, agentId ? `Session not found: ${sessionId} for agent ${agentId}` : `Session not found: ${sessionId}`);
24
25
  }
25
26
  const sessionFile = session.sessionFilePath ?? path.join(context.openclawRoot, 'agents', session.agentName, 'sessions', `${session.sessionId ?? sessionId}.jsonl`);
26
- const messages = await readSessionMessages(sessionFile, limit, offset);
27
+ const messages = await readSessionMessages(sessionFile, limit, beforeId);
27
28
  return {
28
29
  agentId: session.agentId,
29
30
  agentName: session.agentName,
@@ -39,7 +40,8 @@ export const getSession = async (params, context) => {
39
40
  messages: {
40
41
  total: messages.total,
41
42
  limit,
42
- offset,
43
+ hasMore: messages.hasMore,
44
+ nextBeforeId: messages.nextBeforeId,
43
45
  items: messages.items
44
46
  }
45
47
  };
@@ -131,15 +133,9 @@ function normalizePageSize(value, max) {
131
133
  }
132
134
  return Math.min(normalized, max);
133
135
  }
134
- function normalizeOffset(value) {
135
- if (!Number.isFinite(value)) {
136
- return 0;
137
- }
138
- return Math.max(0, Math.floor(value));
139
- }
140
- async function readSessionMessages(filePath, limit, offset) {
136
+ async function readSessionMessages(filePath, limit, beforeId) {
141
137
  if (!(await pathExists(filePath))) {
142
- return { total: 0, items: [] };
138
+ return { total: 0, hasMore: false, nextBeforeId: null, items: [] };
143
139
  }
144
140
  const messages = [];
145
141
  const fileStream = fs.createReadStream(filePath);
@@ -158,9 +154,19 @@ async function readSessionMessages(filePath, limit, offset) {
158
154
  continue;
159
155
  }
160
156
  }
157
+ const endExclusive = beforeId ? messages.findIndex((message) => message.id === beforeId) : messages.length;
158
+ if (endExclusive < 0) {
159
+ throw new JsonRpcException(JSON_RPC_ERRORS.invalidParams, `beforeId not found: ${beforeId}`);
160
+ }
161
+ const start = Math.max(0, endExclusive - limit);
162
+ const items = messages.slice(start, endExclusive);
163
+ const hasMore = start > 0;
164
+ const firstItemId = typeof items[0]?.id === 'string' ? items[0].id : null;
161
165
  return {
162
166
  total: messages.length,
163
- items: messages.slice(offset, offset + limit)
167
+ hasMore,
168
+ nextBeforeId: hasMore ? firstItemId : null,
169
+ items
164
170
  };
165
171
  }
166
172
  async function findInstalledSkill(context, skillSlug) {
package/index.ts CHANGED
@@ -67,6 +67,20 @@ export function getPluginRuntime(): any {
67
67
  return pluginRuntime;
68
68
  }
69
69
 
70
+ // Chinese translations keyed by error code, so CLI failures are returned
71
+ // bilingually (English `message` + Chinese `messageZh`).
72
+ const ERROR_MESSAGES_ZH: Record<string, string> = {
73
+ PAIR_KEY_REQUIRED: "配对码不能为空",
74
+ PAIR_CONFIG_NOT_FOUND:
75
+ "找不到 openclaw.json 配置文件(请确认插件位于 OpenClaw 安装目录内,或设置环境变量 OPENCLAW_HOME 指向 .openclaw 目录)",
76
+ PAIR_EXCHANGE_REQUEST_FAILED:
77
+ "配对请求发送失败(无法连接配对服务,请检查网络、DNS 或代理)",
78
+ PAIR_EXCHANGE_FAILED:
79
+ "配对兑换被拒绝(配对码无效、已过期或已被使用;请向签发服务获取新配对码,并在 10 分钟内尽快完成配对)",
80
+ PAIR_CHANNEL_CONFIG_INVALID:
81
+ "配对返回的频道配置无效(缺少 mqttUrl 或 mqttTopic)",
82
+ };
83
+
70
84
  export function formatCliErrorPayload(error: unknown): {
71
85
  ok: false;
72
86
  error: Record<string, unknown>;
@@ -78,11 +92,17 @@ export function formatCliErrorPayload(error: unknown): {
78
92
  : {};
79
93
  const code =
80
94
  dataObject.code ?? (error as { code?: unknown } | null | undefined)?.code;
95
+ const message = error instanceof Error ? error.message : String(error);
96
+ // Fall back to the English message when no Chinese translation exists for
97
+ // the code, so both fields are always present.
98
+ const messageZh =
99
+ (typeof code === "string" && ERROR_MESSAGES_ZH[code]) || message;
81
100
 
82
101
  return {
83
102
  ok: false,
84
103
  error: {
85
- message: error instanceof Error ? error.message : String(error),
104
+ message,
105
+ messageZh,
86
106
  ...dataObject,
87
107
  ...(code === undefined ? {} : { code }),
88
108
  },
@@ -117,6 +137,15 @@ const WebSocketChannel: any = {
117
137
  },
118
138
  },
119
139
 
140
+ // 让配对写入的 channels.rol-websocket-channel 变更被判为 hot:
141
+ // OpenClaw 只热重载本 channel(进程内 stop→start,加载新 MQTT 连接),
142
+ // 不整机重启、不等 health-monitor。配置写完即在 gateway 进程内生效。
143
+ // (plugins.entries.rol-websocket-channel.* 已由 OpenClaw 内置的
144
+ // channel-plugin-state 规则自动判为 hot+restart-channel,无需在此声明。)
145
+ reload: {
146
+ configPrefixes: ["channels.rol-websocket-channel"],
147
+ },
148
+
120
149
  configSchema: {
121
150
  schema: {
122
151
  type: "object",
@@ -388,7 +417,7 @@ const WebSocketChannel: any = {
388
417
 
389
418
  const text = typeof params.message === "string" ? params.message
390
419
  : typeof params.text === "string" ? params.text
391
- : "";
420
+ : "";
392
421
 
393
422
  const conn = ConnectionManager.getGlobalConnection();
394
423
  if (!conn || !conn.ws || !conn.ws.connected) {
@@ -599,6 +628,20 @@ async function handleIncomingMessage(
599
628
  return;
600
629
  }
601
630
 
631
+ // 收到 sender 消息时,先回复一个确认
632
+ publishCustomMessageResponse({
633
+ type: "receiver",
634
+ trace_id: traceId,
635
+ source: "system-pre",
636
+ timestamp: Date.now(),
637
+ success: true,
638
+ data: {
639
+ ok: true,
640
+ status: "received",
641
+ message: "已收到消息"
642
+ }
643
+ }, innerData, mqttTopic);
644
+
602
645
  // 构造标准消息格式
603
646
  const attachments = (innerData.attachments || []).map((att: any) => ({
604
647
  url: att.url,
@@ -689,9 +732,9 @@ async function handleIncomingMessage(
689
732
  let targetTopic = mqttTopic;
690
733
  const sourceType = innerData?.source_type;
691
734
  if (targetTopic.endsWith("#")) {
692
- const replacement = sourceType === "device" ? "device" : "bot";
693
- targetTopic = targetTopic.slice(0, -1) + replacement;
735
+ targetTopic = targetTopic.slice(0, -1) + 'app';
694
736
  }
737
+ targetTopic = targetTopic.replace('device', 'app').replace('bot', 'app');
695
738
 
696
739
  conn.ws.publish(targetTopic, JSON.stringify(replyMessage));
697
740
  },
@@ -867,9 +910,9 @@ function publishCustomMessageResponse(response: any, innerData: any, mqttTopic:
867
910
  let targetTopic = mqttTopic;
868
911
  const sourceType = innerData?.source_type;
869
912
  if (targetTopic.endsWith("#")) {
870
- const replacement = sourceType === "device" ? "device" : "bot";
871
- targetTopic = targetTopic.slice(0, -1) + replacement;
913
+ targetTopic = targetTopic.slice(0, -1) + 'app';
872
914
  }
915
+ targetTopic = targetTopic.replace('device', 'app').replace('bot', 'app');
873
916
 
874
917
  conn.ws.publish(targetTopic, JSON.stringify(response));
875
918
  }
@@ -997,7 +1040,11 @@ function registerAdminBridgeCli(api: OpenClawPluginApi) {
997
1040
  );
998
1041
  } catch (error) {
999
1042
  process.exitCode = 1;
1000
- process.stderr.write(
1043
+ // Emit the structured error on stdout (same channel as the
1044
+ // success payload) so callers that only capture stdout still
1045
+ // receive the full diagnostic — message, code, status,
1046
+ // serviceMessage — instead of just exit code 1.
1047
+ process.stdout.write(
1001
1048
  JSON.stringify(formatCliErrorPayload(error), null, 2) + "\n",
1002
1049
  );
1003
1050
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rol-websocket-channel",
3
- "version": "1.7.4",
3
+ "version": "1.7.6",
4
4
  "description": "Unified OpenClaw plugin: MQTT Channel + Admin Bridge for remote management",
5
5
  "license": "MIT",
6
6
  "author": "nixgnehc",
@@ -13,45 +13,45 @@ export function getProjectRoot(): string {
13
13
  root = path.dirname(root);
14
14
  }
15
15
 
16
- console.log('[paths] __dirname:', __dirname);
17
- console.log('[paths] getProjectRoot:', root);
16
+ console.error('[paths] __dirname:', __dirname);
17
+ console.error('[paths] getProjectRoot:', root);
18
18
  return root;
19
19
  }
20
20
 
21
21
  export function getOpenClawRoot(): string {
22
22
  if (process.env.OPENCLAW_HOME) {
23
- console.log('[paths] getOpenClawRoot from env:', process.env.OPENCLAW_HOME);
23
+ console.error('[paths] getOpenClawRoot from env:', process.env.OPENCLAW_HOME);
24
24
  return path.resolve(process.env.OPENCLAW_HOME);
25
25
  }
26
26
 
27
27
  const projectRoot = getProjectRoot();
28
28
  const configuredRoot = findNearestOpenClawConfigRoot(projectRoot);
29
29
  if (configuredRoot) {
30
- console.log('[paths] getOpenClawRoot from discovered openclaw.json:', configuredRoot);
30
+ console.error('[paths] getOpenClawRoot from discovered openclaw.json:', configuredRoot);
31
31
  return configuredRoot;
32
32
  }
33
33
 
34
34
  const openclawDir = findAncestorNamed(projectRoot, '.openclaw');
35
35
  if (openclawDir) {
36
- console.log('[paths] getOpenClawRoot from .openclaw ancestor:', openclawDir);
36
+ console.error('[paths] getOpenClawRoot from .openclaw ancestor:', openclawDir);
37
37
  return openclawDir;
38
38
  }
39
39
 
40
40
  const parentDir = path.dirname(projectRoot);
41
41
  const parentName = path.basename(parentDir);
42
42
 
43
- console.log('[paths] projectRoot:', projectRoot);
44
- console.log('[paths] parentDir:', parentDir);
45
- console.log('[paths] parentName:', parentName);
43
+ console.error('[paths] projectRoot:', projectRoot);
44
+ console.error('[paths] parentDir:', parentDir);
45
+ console.error('[paths] parentName:', parentName);
46
46
 
47
47
  if (parentName === 'extensions') {
48
48
  const openclawRoot = path.dirname(parentDir);
49
- console.log('[paths] getOpenClawRoot (extensions):', openclawRoot);
49
+ console.error('[paths] getOpenClawRoot (extensions):', openclawRoot);
50
50
  return openclawRoot;
51
51
  }
52
52
 
53
53
  const fallbackRoot = path.resolve(projectRoot, '..');
54
- console.log('[paths] getOpenClawRoot (fallback):', fallbackRoot);
54
+ console.error('[paths] getOpenClawRoot (fallback):', fallbackRoot);
55
55
  return fallbackRoot;
56
56
  }
57
57
 
@@ -1,18 +1,12 @@
1
- import { exec, execFile } from 'node:child_process';
2
1
  import path from 'node:path';
3
- import { promisify } from 'node:util';
4
2
 
5
3
  import { pathExists, readJsonFile, writeJsonFile } from '../lib/fs.js';
6
4
  import { JsonRpcException, JSON_RPC_ERRORS } from '../jsonrpc.js';
7
5
  import type { JsonValue, MethodContext } from '../types.js';
8
6
 
9
- const execFileAsync = promisify(execFile);
10
- const execAsync = promisify(exec);
11
-
12
7
  const DEFAULT_PLUGIN_ID = 'rol-websocket-channel';
13
8
  const DEFAULT_PRODUCTION_BASE_URL = 'https://api.deotaland.ai';
14
9
  const PAIR_ENDPOINT_PATH = '/api-core-bot/front/agent/agent/key/query';
15
- const GATEWAY_SERVICE = 'openclaw-gateway.service';
16
10
 
17
11
  interface PairingCommandOptions {
18
12
  key: string;
@@ -73,7 +67,6 @@ export async function pairWithKey(
73
67
  const payload = await exchangePairKey(key, endpoint, options.auth, existingMqttUrl);
74
68
  applyPairingConfig(config, key, payload);
75
69
  await writeJsonFile(configPath, config);
76
- const restart = await restartGateway(context.projectRoot);
77
70
 
78
71
  return {
79
72
  ok: true,
@@ -87,7 +80,18 @@ export async function pairWithKey(
87
80
  `channels.${payload.pluginId}`
88
81
  ],
89
82
  channel: payload.channel,
90
- restart
83
+ // No gateway restart is dispatched here on purpose. Writing openclaw.json is
84
+ // enough: OpenClaw's config watcher detects the `channels` change, plans a
85
+ // gateway restart, and defers it until in-flight operations/replies/embedded
86
+ // runs drain before restarting with the new config. Spawning our own
87
+ // `openclaw gateway restart` would restart immediately and jump ahead of that
88
+ // deferral, killing the gateway mid-operation (the original pair-hang bug).
89
+ reload: {
90
+ dispatched: false,
91
+ handledBy: 'openclaw-config-watcher',
92
+ mode: 'deferred-until-idle',
93
+ note: 'OpenClaw restarts the gateway automatically after the config write.'
94
+ }
91
95
  };
92
96
  }
93
97
 
@@ -399,59 +403,6 @@ function resolveExistingMqttUrl(config: OpenClawConfig): string | null {
399
403
  return pickString(channelConfig.mqttUrl);
400
404
  }
401
405
 
402
- async function restartGateway(cwd: string): Promise<JsonValue> {
403
- const attempts: JsonValue[] = [];
404
-
405
- try {
406
- const { stdout, stderr } = await execAsync('openclaw gateway restart', { cwd });
407
- return {
408
- attempted: true,
409
- success: true,
410
- command: 'openclaw gateway restart',
411
- stdout: stdout.trim(),
412
- stderr: stderr.trim()
413
- };
414
- } catch (error: any) {
415
- attempts.push(formatRestartAttempt('openclaw gateway restart', error));
416
- }
417
-
418
- if (process.platform === 'win32') {
419
- return {
420
- attempted: true,
421
- success: false,
422
- message: 'openclaw gateway restart failed',
423
- attempts
424
- };
425
- }
426
-
427
- try {
428
- await execFileAsync('systemctl', ['--user', 'restart', GATEWAY_SERVICE], { cwd });
429
- return {
430
- attempted: true,
431
- success: true,
432
- command: `systemctl --user restart ${GATEWAY_SERVICE}`,
433
- attempts
434
- };
435
- } catch (error: any) {
436
- attempts.push(formatRestartAttempt(`systemctl --user restart ${GATEWAY_SERVICE}`, error));
437
- return {
438
- attempted: true,
439
- success: false,
440
- message: 'gateway restart failed',
441
- attempts
442
- };
443
- }
444
- }
445
-
446
- function formatRestartAttempt(command: string, error: any): JsonValue {
447
- return {
448
- command,
449
- message: error instanceof Error ? error.message : String(error),
450
- stdout: typeof error?.stdout === 'string' ? error.stdout.trim() : '',
451
- stderr: typeof error?.stderr === 'string' ? error.stderr.trim() : ''
452
- };
453
- }
454
-
455
406
  function normalizeGroupPolicy(value: string): 'pairing' | 'allowlist' | 'open' | 'disabled' {
456
407
  if (value === 'pairing' || value === 'allowlist' || value === 'open' || value === 'disabled') {
457
408
  return value;