@nordbyte/nordrelay 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.env.example +45 -2
  2. package/README.md +221 -35
  3. package/dist/access-control.js +3 -0
  4. package/dist/agent-activity.js +300 -0
  5. package/dist/agent-adapter.js +17 -30
  6. package/dist/agent-factory.js +27 -0
  7. package/dist/agent-feature-matrix.js +42 -0
  8. package/dist/agent-updates.js +294 -0
  9. package/dist/agent.js +123 -9
  10. package/dist/artifacts.js +1 -1
  11. package/dist/audit-log.js +1 -1
  12. package/dist/bot-ui.js +1 -1
  13. package/dist/bot.js +483 -354
  14. package/dist/channel-actions.js +372 -0
  15. package/dist/claude-code-auth.js +121 -0
  16. package/dist/claude-code-cli.js +19 -0
  17. package/dist/claude-code-launch.js +73 -0
  18. package/dist/claude-code-session.js +660 -0
  19. package/dist/claude-code-state.js +590 -0
  20. package/dist/codex-session.js +12 -1
  21. package/dist/config.js +113 -9
  22. package/dist/hermes-api.js +150 -0
  23. package/dist/hermes-auth.js +96 -0
  24. package/dist/hermes-cli.js +19 -0
  25. package/dist/hermes-launch.js +57 -0
  26. package/dist/hermes-session.js +477 -0
  27. package/dist/hermes-state.js +609 -0
  28. package/dist/index.js +51 -8
  29. package/dist/openclaw-auth.js +27 -0
  30. package/dist/openclaw-cli.js +19 -0
  31. package/dist/openclaw-gateway.js +285 -0
  32. package/dist/openclaw-launch.js +65 -0
  33. package/dist/openclaw-session.js +549 -0
  34. package/dist/openclaw-state.js +409 -0
  35. package/dist/operations.js +115 -9
  36. package/dist/pi-auth.js +59 -0
  37. package/dist/pi-launch.js +61 -0
  38. package/dist/pi-rpc.js +18 -0
  39. package/dist/pi-session.js +103 -15
  40. package/dist/pi-state.js +253 -0
  41. package/dist/relay-runtime.js +798 -72
  42. package/dist/session-format.js +98 -19
  43. package/dist/session-registry.js +40 -15
  44. package/dist/settings-service.js +35 -4
  45. package/dist/web-dashboard-assets.js +2 -0
  46. package/dist/web-dashboard-client.js +275 -0
  47. package/dist/web-dashboard-style.js +9 -0
  48. package/dist/web-dashboard-ui.js +18 -0
  49. package/dist/web-dashboard.js +296 -196
  50. package/package.json +8 -3
  51. package/plugins/nordrelay/.codex-plugin/plugin.json +7 -4
  52. package/plugins/nordrelay/commands/remote.md +2 -2
  53. package/plugins/nordrelay/scripts/nordrelay.mjs +187 -12
  54. package/plugins/nordrelay/skills/telegram-remote/SKILL.md +2 -2
  55. package/CHANGELOG.md +0 -26
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nordbyte/nordrelay",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "Remote control plane for coding agents across messaging channels.",
5
5
  "type": "module",
6
6
  "author": "Ricardo",
@@ -18,6 +18,10 @@
18
18
  "remote-control",
19
19
  "codex",
20
20
  "pi",
21
+ "hermes",
22
+ "openclaw",
23
+ "claude-code",
24
+ "claude",
21
25
  "telegram",
22
26
  "bot",
23
27
  "automation"
@@ -30,7 +34,6 @@
30
34
  "dist/",
31
35
  "plugins/",
32
36
  "launchd/",
33
- "CHANGELOG.md",
34
37
  ".env.example",
35
38
  "Dockerfile",
36
39
  "docker-compose.yml"
@@ -50,9 +53,11 @@
50
53
  "test": "vitest run"
51
54
  },
52
55
  "dependencies": {
56
+ "@anthropic-ai/claude-agent-sdk": "^0.2.140",
53
57
  "@grammyjs/auto-retry": "^2.0.2",
54
58
  "@openai/codex-sdk": "^0.130.0",
55
- "grammy": "^1.41.1"
59
+ "grammy": "^1.41.1",
60
+ "zod": "^4.4.3"
56
61
  },
57
62
  "devDependencies": {
58
63
  "@types/better-sqlite3": "^7.6.0",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nordrelay",
3
- "version": "0.3.0",
4
- "description": "Run a remote-control bridge for coding agents. The current adapter connects Codex sessions to Telegram with streaming replies, multi-session controls, attachments, voice input, model selection, thread browsing, and handback.",
3
+ "version": "0.3.1",
4
+ "description": "Run a remote-control bridge for coding agents. Current adapters connect Codex, Pi, Hermes, and OpenClaw sessions to Telegram with streaming replies, multi-session controls, attachments, voice input, model selection, thread browsing, and handback.",
5
5
  "author": {
6
6
  "name": "Ricardo",
7
7
  "url": "https://github.com/nordbyte"
@@ -14,6 +14,9 @@
14
14
  "messaging",
15
15
  "remote-control",
16
16
  "codex",
17
+ "pi",
18
+ "hermes",
19
+ "openclaw",
17
20
  "telegram",
18
21
  "bot",
19
22
  "app-server"
@@ -22,7 +25,7 @@
22
25
  "interface": {
23
26
  "displayName": "NordRelay",
24
27
  "shortDescription": "Remote-control bridge for coding agents",
25
- "longDescription": "Starts NordRelay, a messaging bridge for coding agents. The current runtime connects Codex and Pi sessions to Telegram: messages become agent turns, replies and tool activity stream back to Telegram, each chat or forum topic has its own session, and commands provide thread browsing, model and reasoning controls, launch profiles, attachments, voice transcription, artifacts, login, abort, retry, and CLI handback.",
28
+ "longDescription": "Starts NordRelay, a messaging bridge for coding agents. The current runtime connects Codex, Pi, Hermes, and OpenClaw sessions to Telegram: messages become agent turns, replies and tool activity stream back to Telegram, each chat or forum topic has its own session, and commands provide thread browsing, model and reasoning controls, launch profiles, attachments, voice transcription, artifacts, login, abort, retry, and CLI handback.",
26
29
  "developerName": "Ricardo",
27
30
  "category": "Productivity",
28
31
  "capabilities": [
@@ -38,7 +41,7 @@
38
41
  "Show NordRelay status",
39
42
  "Stop NordRelay",
40
43
  "Show Telegram session browser",
41
- "Select Codex model"
44
+ "Select agent model"
42
45
  ],
43
46
  "brandColor": "#229ED9",
44
47
  "composerIcon": "./assets/nordrelay.svg",
@@ -21,7 +21,7 @@ Codex plugin commands are namespaced by the plugin id in current plugin-aware co
21
21
  ## Workflow
22
22
 
23
23
  1. Locate the plugin root containing `.codex-plugin/plugin.json` with `"name": "nordrelay"`. In a source checkout this is usually `<repo>/plugins/nordrelay`.
24
- 2. Check whether `TELEGRAM_BOT_TOKEN` and either `TELEGRAM_ALLOWED_USER_IDS`, `TELEGRAM_ALLOWED_CHAT_IDS`, or `TELEGRAM_ALLOW_ANY_CHAT=1` are available from the environment or from `.env`.
24
+ 2. Check whether `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ADMIN_USER_IDS` are available from the environment or from the NordRelay env file.
25
25
  3. Run the connector command from the plugin root:
26
26
 
27
27
  ```bash
@@ -29,5 +29,5 @@ node scripts/nordrelay.mjs ${ARGUMENTS:-start}
29
29
  ```
30
30
 
31
31
  4. If `${ARGUMENTS}` is empty, use `start`.
32
- 5. After `start` or `restart`, run `node scripts/nordrelay.mjs status` and report the PID, selected Codex thread id, and log file.
32
+ 5. After `start` or `restart`, run `node scripts/nordrelay.mjs status` and report the PID, selected thread id if visible, and log file.
33
33
  6. If startup fails because dependencies are missing, run `npm install` and `npm run build` in the repository root.
@@ -9,22 +9,36 @@ import readline from "node:readline/promises";
9
9
  import { spawn } from "node:child_process";
10
10
  import { fileURLToPath } from "node:url";
11
11
 
12
- const VERSION = "0.3.0";
12
+ const FALLBACK_VERSION = "0.3.1";
13
13
  const require = createRequire(import.meta.url);
14
14
  const APP_NAME = "nordrelay";
15
15
  const SCRIPT_PATH = fileURLToPath(import.meta.url);
16
16
  const PLUGIN_ROOT = path.resolve(path.dirname(SCRIPT_PATH), "..");
17
17
  const DEFAULT_MARKETPLACE_ROOT = path.resolve(PLUGIN_ROOT, "../..");
18
18
  const RUNTIME_ROOT = findRuntimeRoot();
19
+ const VERSION = readRuntimePackageVersion() || FALLBACK_VERSION;
19
20
  const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
20
21
 
21
22
  function nowIso() {
22
23
  return new Date().toISOString();
23
24
  }
24
25
 
26
+ function readRuntimePackageVersion() {
27
+ try {
28
+ const pkg = JSON.parse(fs.readFileSync(path.join(RUNTIME_ROOT, "package.json"), "utf8"));
29
+ return typeof pkg.version === "string" && pkg.version.trim() ? pkg.version.trim() : null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
25
35
  function parseArgs(argv) {
26
36
  const copy = [...argv];
27
37
  let command = "foreground";
38
+ if (copy[0] === "--version" || copy[0] === "-v") {
39
+ command = "version";
40
+ copy.shift();
41
+ }
28
42
  if (copy[0] && !copy[0].startsWith("-")) {
29
43
  command = copy.shift();
30
44
  }
@@ -35,8 +49,8 @@ function parseArgs(argv) {
35
49
  home: process.env.NORDRELAY_HOME || DEFAULT_HOME,
36
50
  dropPendingUpdates: !envFlag("NORDRELAY_KEEP_PENDING_UPDATES"),
37
51
  force: false,
38
- host: process.env.NORDRELAY_DASHBOARD_HOST || "127.0.0.1",
39
- port: Number.parseInt(process.env.NORDRELAY_DASHBOARD_PORT || "31878", 10),
52
+ host: undefined,
53
+ port: undefined,
40
54
  };
41
55
 
42
56
  for (let i = 0; i < copy.length; i += 1) {
@@ -50,6 +64,9 @@ function parseArgs(argv) {
50
64
  else if (arg === "--admin-id") options.telegramAdminUserIds = requireValue(copy, ++i, arg);
51
65
  else if (arg === "--state-backend") options.stateBackend = requireValue(copy, ++i, arg);
52
66
  else if (arg === "--enable-pi") options.enablePi = true;
67
+ else if (arg === "--enable-hermes") options.enableHermes = true;
68
+ else if (arg === "--enable-openclaw") options.enableOpenClaw = true;
69
+ else if (arg === "--enable-claude-code") options.enableClaudeCode = true;
53
70
  else if (arg === "--disable-codex") options.disableCodex = true;
54
71
  }
55
72
 
@@ -158,9 +175,31 @@ async function readPid(pidFile) {
158
175
  }
159
176
  }
160
177
 
161
- async function commandStart(options) {
178
+ function resolveDashboardEndpoint(options, settings = {}) {
179
+ const host = options.host || process.env.NORDRELAY_DASHBOARD_HOST || "127.0.0.1";
180
+ const rawPort = options.port ?? Number.parseInt(process.env.NORDRELAY_DASHBOARD_PORT || "31878", 10);
181
+ if (!Number.isFinite(rawPort) || rawPort <= 0) {
182
+ if (settings.strict) {
183
+ throw new Error("Dashboard port must be a positive number.");
184
+ }
185
+ return { host, port: 31878 };
186
+ }
187
+ const port = rawPort;
188
+ return { host, port };
189
+ }
190
+
191
+ function formatDashboardUrl(endpoint) {
192
+ const host = endpoint.host || "127.0.0.1";
193
+ const displayHost = host === "0.0.0.0" || host === "" ? "127.0.0.1" : host === "::" ? "::1" : host;
194
+ const formattedHost = displayHost.includes(":") && !displayHost.startsWith("[") ? `[${displayHost}]` : displayHost;
195
+ const bindHint = displayHost === host ? "" : ` (binds ${host || "all interfaces"})`;
196
+ return `http://${formattedHost}:${endpoint.port}/${bindHint}`;
197
+ }
198
+
199
+ async function commandStart(options, settings = {}) {
162
200
  await mkdirp(options.home);
163
201
  loadEnvFiles(options.home);
202
+ const dashboard = resolveDashboardEndpoint(options);
164
203
 
165
204
  const currentPid = await readPid(options.pidFile);
166
205
  if (isProcessRunning(currentPid)) {
@@ -193,6 +232,9 @@ async function commandStart(options) {
193
232
  console.log(`Started ${APP_NAME} ${VERSION} with PID ${child.pid}`);
194
233
  console.log(`Workspace: ${state.workspace || "-"}`);
195
234
  console.log(`Mode: ${state.sessionMode || "per Telegram context"}`);
235
+ if (!settings.skipWebHint) {
236
+ console.log(`WebUI: ${formatDashboardUrl(dashboard)} (run \`nordrelay web\` to start it)`);
237
+ }
196
238
  console.log(`Log: ${options.logFile}`);
197
239
  return;
198
240
  }
@@ -208,9 +250,27 @@ async function commandStart(options) {
208
250
  }
209
251
 
210
252
  console.log(`Started ${APP_NAME} ${VERSION} with PID ${child.pid}`);
253
+ if (!settings.skipWebHint) {
254
+ console.log(`WebUI: ${formatDashboardUrl(dashboard)} (run \`nordrelay web\` to start it)`);
255
+ }
211
256
  console.log(`Startup is still in progress. Log: ${options.logFile}`);
212
257
  }
213
258
 
259
+ async function ensureConnectorStartedForWeb(options) {
260
+ const currentPid = await readPid(options.pidFile);
261
+ if (isProcessRunning(currentPid)) {
262
+ console.log(`NordRelay connector already running with PID ${currentPid}.`);
263
+ return;
264
+ }
265
+
266
+ console.log("Starting NordRelay connector...");
267
+ const previousExitCode = process.exitCode;
268
+ await commandStart(options, { skipWebHint: true });
269
+ if (process.exitCode && process.exitCode !== previousExitCode) {
270
+ throw new Error(`NordRelay connector failed to start. See ${options.logFile}.`);
271
+ }
272
+ }
273
+
214
274
  async function waitForState(stateFile, pid, timeoutMs) {
215
275
  const deadline = Date.now() + timeoutMs;
216
276
  while (Date.now() < deadline) {
@@ -250,6 +310,8 @@ async function commandStop(options) {
250
310
  }
251
311
 
252
312
  async function commandStatus(options) {
313
+ loadEnvFiles(options.home);
314
+ const dashboard = resolveDashboardEndpoint(options);
253
315
  const pid = await readPid(options.pidFile);
254
316
  const state = await readJson(options.stateFile, {});
255
317
  const running = isProcessRunning(pid);
@@ -259,6 +321,12 @@ async function commandStatus(options) {
259
321
  console.log(`Mode: ${state.sessionMode || "per Telegram context"}`);
260
322
  console.log(`Auth: ${state.authenticated === undefined ? "-" : state.authenticated ? "yes" : "no"}`);
261
323
  console.log(`Codex CLI: ${state.codexCli || "-"}`);
324
+ console.log(`Pi CLI: ${state.piCli || "-"}`);
325
+ console.log(`Hermes CLI: ${state.hermesCli || "-"}`);
326
+ console.log(`OpenClaw CLI: ${state.openClawCli || "-"}`);
327
+ console.log(`Claude Code CLI: ${state.claudeCodeCli || "-"}`);
328
+ console.log(`OpenClaw Gateway: ${state.openClawGateway || process.env.OPENCLAW_GATEWAY_URL || "-"}`);
329
+ console.log(`WebUI: ${formatDashboardUrl(dashboard)}`);
262
330
  console.log(`Log: ${options.logFile}`);
263
331
  if (state.error) console.log(`Error: ${state.error}`);
264
332
  }
@@ -284,11 +352,23 @@ async function commandInit(options) {
284
352
  await ask(rl, "Telegram admin user id", "");
285
353
  const enableCodex = options.disableCodex ? "false" : await askChoice(rl, "Enable Codex", "true");
286
354
  const enablePi = options.enablePi ? "true" : await askChoice(rl, "Enable Pi", "false");
355
+ const enableHermes = options.enableHermes ? "true" : await askChoice(rl, "Enable Hermes", "false");
356
+ const enableOpenClaw = options.enableOpenClaw ? "true" : await askChoice(rl, "Enable OpenClaw", "false");
357
+ const enableClaudeCode = options.enableClaudeCode ? "true" : await askChoice(rl, "Enable Claude Code", "false");
287
358
  const stateBackend = options.stateBackend || await askChoice(rl, "State backend (json/sqlite)", "json");
288
359
 
289
360
  if (!telegramBotToken) throw new Error("Telegram bot token is required.");
290
361
  if (!telegramAdminUserIds) throw new Error("Telegram admin user id is required.");
291
- if (enableCodex !== "true" && enablePi !== "true") throw new Error("At least one agent must be enabled.");
362
+ if (enableCodex !== "true" && enablePi !== "true" && enableHermes !== "true" && enableOpenClaw !== "true" && enableClaudeCode !== "true") throw new Error("At least one agent must be enabled.");
363
+ const defaultAgent = enableCodex === "true"
364
+ ? "codex"
365
+ : enablePi === "true"
366
+ ? "pi"
367
+ : enableHermes === "true"
368
+ ? "hermes"
369
+ : enableOpenClaw === "true"
370
+ ? "openclaw"
371
+ : "claude-code";
292
372
 
293
373
  const lines = [
294
374
  "# NordRelay local runtime config.",
@@ -298,7 +378,18 @@ async function commandInit(options) {
298
378
  "TELEGRAM_ALLOW_ANY_CHAT=false",
299
379
  `NORDRELAY_CODEX_ENABLED=${enableCodex}`,
300
380
  `NORDRELAY_PI_ENABLED=${enablePi}`,
301
- `NORDRELAY_DEFAULT_AGENT=${enableCodex === "true" ? "codex" : "pi"}`,
381
+ `NORDRELAY_HERMES_ENABLED=${enableHermes}`,
382
+ `NORDRELAY_OPENCLAW_ENABLED=${enableOpenClaw}`,
383
+ `NORDRELAY_CLAUDE_CODE_ENABLED=${enableClaudeCode}`,
384
+ `NORDRELAY_DEFAULT_AGENT=${defaultAgent}`,
385
+ "PI_DEFAULT_PROFILE=default",
386
+ "HERMES_API_BASE_URL=http://127.0.0.1:8642",
387
+ "HERMES_DEFAULT_PROFILE=default",
388
+ "OPENCLAW_GATEWAY_URL=ws://127.0.0.1:18789",
389
+ "OPENCLAW_AGENT_ID=main",
390
+ "OPENCLAW_DEFAULT_PROFILE=default",
391
+ "CLAUDE_CODE_DEFAULT_PROFILE=default",
392
+ "CLAUDE_CODE_MAX_TURNS=100",
302
393
  `NORDRELAY_STATE_BACKEND=${stateBackend === "sqlite" ? "sqlite" : "json"}`,
303
394
  "TELEGRAM_TRANSPORT=polling",
304
395
  "TELEGRAM_AUTO_SEND_ARTIFACTS=false",
@@ -324,8 +415,18 @@ async function commandDoctor(options) {
324
415
  checks.push(check("Private by default", process.env.TELEGRAM_ALLOW_ANY_CHAT !== "true", "TELEGRAM_ALLOW_ANY_CHAT is not true"));
325
416
  checks.push(check("Codex enabled flag", process.env.NORDRELAY_CODEX_ENABLED !== "false", `NORDRELAY_CODEX_ENABLED=${process.env.NORDRELAY_CODEX_ENABLED ?? "true"}`));
326
417
  checks.push(check("Pi enabled flag", process.env.NORDRELAY_PI_ENABLED === "true" || process.env.NORDRELAY_PI_ENABLED === undefined, `NORDRELAY_PI_ENABLED=${process.env.NORDRELAY_PI_ENABLED ?? "false"}`, process.env.NORDRELAY_PI_ENABLED === "true" ? "pass" : "warn"));
418
+ checks.push(check("Hermes enabled flag", process.env.NORDRELAY_HERMES_ENABLED === "true", `NORDRELAY_HERMES_ENABLED=${process.env.NORDRELAY_HERMES_ENABLED ?? "false"}`, process.env.NORDRELAY_HERMES_ENABLED === "true" ? "pass" : "warn"));
419
+ checks.push(check("OpenClaw enabled flag", process.env.NORDRELAY_OPENCLAW_ENABLED === "true", `NORDRELAY_OPENCLAW_ENABLED=${process.env.NORDRELAY_OPENCLAW_ENABLED ?? "false"}`, process.env.NORDRELAY_OPENCLAW_ENABLED === "true" ? "pass" : "warn"));
420
+ checks.push(check("Claude Code enabled flag", process.env.NORDRELAY_CLAUDE_CODE_ENABLED === "true", `NORDRELAY_CLAUDE_CODE_ENABLED=${process.env.NORDRELAY_CLAUDE_CODE_ENABLED ?? "false"}`, process.env.NORDRELAY_CLAUDE_CODE_ENABLED === "true" ? "pass" : "warn"));
327
421
  checks.push(check("Codex CLI", Boolean(findExecutable(process.env.CODEX_CLI_PATH || "codex")), process.env.CODEX_CLI_PATH || findExecutable("codex") || "not found", process.env.NORDRELAY_CODEX_ENABLED === "false" ? "warn" : "fail"));
328
422
  checks.push(check("Pi CLI", Boolean(findExecutable(process.env.PI_CLI_PATH || "pi")), process.env.PI_CLI_PATH || findExecutable("pi") || "not found", process.env.NORDRELAY_PI_ENABLED === "true" ? "fail" : "warn"));
423
+ checks.push(check("Hermes CLI", Boolean(findExecutable(process.env.HERMES_CLI_PATH || "hermes")), process.env.HERMES_CLI_PATH || findExecutable("hermes") || "not found", process.env.NORDRELAY_HERMES_ENABLED === "true" ? "fail" : "warn"));
424
+ checks.push(check("OpenClaw CLI", Boolean(findExecutable(process.env.OPENCLAW_CLI_PATH || "openclaw")), process.env.OPENCLAW_CLI_PATH || findExecutable("openclaw") || "not found", process.env.NORDRELAY_OPENCLAW_ENABLED === "true" ? "fail" : "warn"));
425
+ checks.push(check("Claude Code CLI", Boolean(findExecutable(process.env.CLAUDE_CODE_CLI_PATH || "claude")), process.env.CLAUDE_CODE_CLI_PATH || findExecutable("claude") || "SDK bundled runtime", "warn"));
426
+ const hermesApiCheck = await checkHermesApiServer();
427
+ checks.push(check("Hermes API Server", hermesApiCheck.ok, hermesApiCheck.detail, process.env.NORDRELAY_HERMES_ENABLED === "true" ? "fail" : "warn"));
428
+ const openClawGatewayCheck = await checkOpenClawGateway();
429
+ checks.push(check("OpenClaw Gateway", openClawGatewayCheck.ok, openClawGatewayCheck.detail, process.env.NORDRELAY_OPENCLAW_ENABLED === "true" ? "fail" : "warn"));
329
430
  checks.push(check("ffmpeg", Boolean(findExecutable("ffmpeg")), findExecutable("ffmpeg") || "not found", "warn"));
330
431
  const stateBackendCheck = validateStateBackend();
331
432
  checks.push(check("State backend", stateBackendCheck.ok, stateBackendCheck.detail));
@@ -341,11 +442,83 @@ async function commandDoctor(options) {
341
442
  if (failed.length > 0) process.exitCode = 1;
342
443
  }
343
444
 
445
+ async function checkHermesApiServer() {
446
+ const baseUrl = (process.env.HERMES_API_BASE_URL || "http://127.0.0.1:8642").replace(/\/+$/, "");
447
+ const headers = process.env.HERMES_API_KEY ? { authorization: `Bearer ${process.env.HERMES_API_KEY}` } : {};
448
+ try {
449
+ const response = await fetch(`${baseUrl}/health`, { headers, signal: AbortSignal.timeout(2000) });
450
+ return {
451
+ ok: response.ok,
452
+ detail: response.ok ? `${baseUrl}/health ok` : `${baseUrl}/health HTTP ${response.status}`,
453
+ };
454
+ } catch (error) {
455
+ return {
456
+ ok: false,
457
+ detail: `${baseUrl}/health failed: ${error instanceof Error ? error.message : String(error)}`,
458
+ };
459
+ }
460
+ }
461
+
462
+ async function checkOpenClawGateway() {
463
+ const gatewayUrl = process.env.OPENCLAW_GATEWAY_URL || "ws://127.0.0.1:18789";
464
+ const WebSocketClass = globalThis.WebSocket;
465
+ if (!WebSocketClass) {
466
+ return { ok: false, detail: "Node.js WebSocket runtime is unavailable" };
467
+ }
468
+
469
+ return new Promise((resolve) => {
470
+ let settled = false;
471
+ const finish = (value) => {
472
+ if (settled) return;
473
+ settled = true;
474
+ clearTimeout(timeout);
475
+ try {
476
+ socket.close();
477
+ } catch {
478
+ // Ignore close errors during diagnostics.
479
+ }
480
+ resolve(value);
481
+ };
482
+ const timeout = setTimeout(() => {
483
+ finish({ ok: false, detail: `${gatewayUrl} timed out` });
484
+ }, 2000);
485
+ timeout.unref?.();
486
+
487
+ let socket;
488
+ try {
489
+ socket = new WebSocketClass(gatewayUrl);
490
+ } catch (error) {
491
+ clearTimeout(timeout);
492
+ resolve({ ok: false, detail: `${gatewayUrl} failed: ${error instanceof Error ? error.message : String(error)}` });
493
+ return;
494
+ }
495
+
496
+ socket.addEventListener("open", () => {
497
+ const auth = {};
498
+ if (process.env.OPENCLAW_GATEWAY_TOKEN) auth.token = process.env.OPENCLAW_GATEWAY_TOKEN;
499
+ if (process.env.OPENCLAW_GATEWAY_PASSWORD) auth.password = process.env.OPENCLAW_GATEWAY_PASSWORD;
500
+ const params = {
501
+ client: { name: "NordRelay doctor", deviceFamily: "nordrelay" },
502
+ role: "operator",
503
+ subscribe: ["health"],
504
+ };
505
+ if (Object.keys(auth).length > 0) params.auth = auth;
506
+ socket.send(JSON.stringify({ type: "connect", id: "doctor", params }));
507
+ }, { once: true });
508
+ socket.addEventListener("message", () => {
509
+ finish({ ok: true, detail: `${gatewayUrl} reachable` });
510
+ }, { once: true });
511
+ socket.addEventListener("error", () => {
512
+ finish({ ok: false, detail: `${gatewayUrl} failed` });
513
+ }, { once: true });
514
+ });
515
+ }
516
+
344
517
  async function commandWeb(options) {
345
518
  await mkdirp(options.home);
346
519
  loadEnvFiles(options.home);
347
- const host = options.host || "127.0.0.1";
348
- const port = Number.isFinite(options.port) ? options.port : 31878;
520
+ const { host, port } = resolveDashboardEndpoint(options, { strict: true });
521
+ await ensureConnectorStartedForWeb(options);
349
522
  const entry = await resolveWebRuntimeEntry();
350
523
  if (!entry) {
351
524
  throw new Error(`Missing dashboard runtime. Run \`npm install\` and \`npm run build\` in ${RUNTIME_ROOT}.`);
@@ -436,21 +609,23 @@ async function commandForeground(options) {
436
609
  });
437
610
 
438
611
  const previousState = await readJson(options.stateFile, {});
612
+ const stoppedBySignal = exit.signal === "SIGTERM" || exit.signal === "SIGINT";
613
+ const stopped = exit.code === 0 || stoppedBySignal;
439
614
  await writeJsonAtomic(options.stateFile, {
440
- status: exit.code === 0 ? "stopped" : "error",
615
+ status: stopped ? "stopped" : "error",
441
616
  pid: process.pid,
442
617
  updatedAt: nowIso(),
443
618
  exitCode: exit.code,
444
619
  signal: exit.signal,
445
- error: exit.code === 0 ? undefined : previousState.error,
620
+ error: stopped ? undefined : previousState.error,
446
621
  logFile: options.logFile,
447
622
  });
448
623
 
449
- if (exit.signal) {
624
+ if (exit.signal && !stoppedBySignal) {
450
625
  process.kill(process.pid, exit.signal);
451
626
  return;
452
627
  }
453
- process.exit(exit.code ?? 0);
628
+ process.exit(stopped ? 0 : exit.code ?? 1);
454
629
  }
455
630
 
456
631
  async function resolveRuntimeEntry() {
@@ -5,7 +5,7 @@ description: Use when the user wants to start, inspect, stop, or troubleshoot th
5
5
 
6
6
  # Telegram Remote
7
7
 
8
- After the bot process is running, Telegram provides the actual controls (`/new`, `/sessions`, `/sync`, `/pinned`, `/pin`, `/unpin`, `/attach`, `/handback`, `/model`, `/reasoning`, `/fast`, `/launch_profiles`, `/retry`, `/queue`, `/cancel`, `/clearqueue`, `/artifacts`, `/abort`, `/stop`, `/tasks`, `/progress`, `/status`, `/health`, `/version`, `/logs`, `/diagnostics`, `/restart`, `/update`, voice, photos, documents, media groups, artifacts, login). The Codex-side command is only a process manager.
8
+ After the bot process is running, Telegram provides the actual controls (`/agent`, `/new`, `/sessions`, `/sync`, `/pinned`, `/pin`, `/unpin`, `/attach`, `/handback`, `/model`, `/reasoning`, `/fast`, `/launch_profiles`, `/retry`, `/queue`, `/cancel`, `/clearqueue`, `/artifacts`, `/abort`, `/stop`, `/tasks`, `/progress`, `/status`, `/health`, `/version`, `/logs`, `/diagnostics`, `/restart`, `/update`, voice, photos, documents, media groups, artifacts, login). The Codex-side command is only a process manager.
9
9
 
10
10
  Use the local connector script in the plugin root. In a source checkout, the plugin root is usually:
11
11
 
@@ -21,6 +21,6 @@ node scripts/nordrelay.mjs status
21
21
  node scripts/nordrelay.mjs stop
22
22
  ```
23
23
 
24
- The bridge needs `TELEGRAM_BOT_TOKEN` and either `TELEGRAM_ALLOWED_USER_IDS`, `TELEGRAM_ALLOWED_CHAT_IDS`, or `TELEGRAM_ALLOW_ANY_CHAT=1`.
24
+ The bridge needs `TELEGRAM_BOT_TOKEN` and `TELEGRAM_ADMIN_USER_IDS` by default. Optional non-admin operators can be added with `TELEGRAM_ALLOWED_USER_IDS` or trusted group/topic access can be added with `TELEGRAM_ALLOWED_CHAT_IDS`.
25
25
 
26
26
  Prefer `start` for normal use. Use `foreground` only when debugging connection problems, because it keeps the current command running. If the runtime is missing, run `npm install` and `npm run build` in the repository root.
package/CHANGELOG.md DELETED
@@ -1,26 +0,0 @@
1
- # Changelog
2
-
3
- ## v0.3.1 - 2026-05-12
4
-
5
- Commits since `v0.3.0`:
6
-
7
- - Move runtime home to .nordrelay.
8
- - Improve dashboard live status handling.
9
- - Separate dashboard contexts from Telegram sessions.
10
- - Expand dashboard controls and activity views.
11
-
12
- ## v0.3.0 - 2026-05-12
13
-
14
- Commits since `v0.2.1`:
15
-
16
- - Prepare v0.3.0 release with copyable WebUI thread IDs and aligned session controls.
17
- - Improve dashboard usability and uploads.
18
- - Expand WebUI dashboard.
19
- - Keep dashboard server running.
20
- - Expand NordRelay platform features.
21
- - Hide missing timestamp marker in logs.
22
- - Show CLI paths directly in version output.
23
- - Add version freshness checks.
24
- - Render log messages as normal text.
25
- - Improve version and log display.
26
- - Improve logs and self-update behavior.