@nordbyte/nordrelay 0.3.0 → 0.4.0
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/.env.example +45 -2
- package/README.md +227 -47
- package/dist/agent-activity.js +300 -0
- package/dist/agent-adapter.js +17 -30
- package/dist/agent-factory.js +27 -0
- package/dist/agent.js +123 -9
- package/dist/artifacts.js +1 -1
- package/dist/audit-log.js +1 -1
- package/dist/bot-ui.js +1 -1
- package/dist/bot.js +333 -161
- package/dist/claude-code-auth.js +121 -0
- package/dist/claude-code-cli.js +19 -0
- package/dist/claude-code-launch.js +73 -0
- package/dist/claude-code-session.js +660 -0
- package/dist/claude-code-state.js +590 -0
- package/dist/codex-session.js +15 -2
- package/dist/config.js +113 -9
- package/dist/context-key.js +23 -0
- package/dist/hermes-api.js +150 -0
- package/dist/hermes-auth.js +96 -0
- package/dist/hermes-cli.js +19 -0
- package/dist/hermes-launch.js +57 -0
- package/dist/hermes-session.js +477 -0
- package/dist/hermes-state.js +609 -0
- package/dist/index.js +51 -8
- package/dist/openclaw-auth.js +27 -0
- package/dist/openclaw-cli.js +19 -0
- package/dist/openclaw-gateway.js +285 -0
- package/dist/openclaw-launch.js +65 -0
- package/dist/openclaw-session.js +549 -0
- package/dist/openclaw-state.js +409 -0
- package/dist/operations.js +84 -3
- package/dist/pi-auth.js +59 -0
- package/dist/pi-launch.js +61 -0
- package/dist/pi-rpc.js +18 -0
- package/dist/pi-session.js +103 -15
- package/dist/pi-state.js +253 -0
- package/dist/relay-runtime.js +1073 -22
- package/dist/session-format.js +28 -18
- package/dist/session-registry.js +43 -18
- package/dist/settings-service.js +80 -26
- package/dist/state-backend.js +17 -8
- package/dist/web-dashboard-ui.js +18 -0
- package/dist/web-dashboard.js +463 -55
- package/dist/web-state.js +131 -0
- package/docker-compose.yml +1 -1
- package/package.json +8 -3
- package/plugins/nordrelay/.codex-plugin/plugin.json +7 -4
- package/plugins/nordrelay/commands/remote.md +2 -2
- package/plugins/nordrelay/scripts/nordrelay.mjs +173 -20
- package/plugins/nordrelay/skills/telegram-remote/SKILL.md +2 -2
- package/CHANGELOG.md +0 -17
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createDocumentStore } from "./state-backend.js";
|
|
3
|
+
const DEFAULT_CHAT_LIMIT = 300;
|
|
4
|
+
const DEFAULT_ACTIVITY_LIMIT = 1000;
|
|
5
|
+
export class WebChatStore {
|
|
6
|
+
store;
|
|
7
|
+
maxMessages;
|
|
8
|
+
constructor(workspace, backend = "json", maxMessages = DEFAULT_CHAT_LIMIT) {
|
|
9
|
+
this.store = createDocumentStore({
|
|
10
|
+
workspace,
|
|
11
|
+
fileName: "web-chat.json",
|
|
12
|
+
sqliteKey: "web-chat",
|
|
13
|
+
backend,
|
|
14
|
+
});
|
|
15
|
+
this.maxMessages = maxMessages;
|
|
16
|
+
}
|
|
17
|
+
append(input) {
|
|
18
|
+
const payload = this.readPayload();
|
|
19
|
+
const threadId = input.threadId || "pending";
|
|
20
|
+
const messages = payload.messagesByThread[threadId] ?? [];
|
|
21
|
+
const message = {
|
|
22
|
+
id: randomId(),
|
|
23
|
+
timestamp: input.timestamp ?? new Date().toISOString(),
|
|
24
|
+
...input,
|
|
25
|
+
threadId,
|
|
26
|
+
};
|
|
27
|
+
messages.push(message);
|
|
28
|
+
if (messages.length > this.maxMessages) {
|
|
29
|
+
messages.splice(0, messages.length - this.maxMessages);
|
|
30
|
+
}
|
|
31
|
+
payload.messagesByThread[threadId] = messages;
|
|
32
|
+
this.store.write(payload);
|
|
33
|
+
return message;
|
|
34
|
+
}
|
|
35
|
+
list(threadId, limit = 200) {
|
|
36
|
+
const messages = this.readPayload().messagesByThread[threadId || "pending"] ?? [];
|
|
37
|
+
return messages.slice(-Math.max(1, Math.min(this.maxMessages, limit)));
|
|
38
|
+
}
|
|
39
|
+
clear(threadId) {
|
|
40
|
+
const payload = this.readPayload();
|
|
41
|
+
const key = threadId || "pending";
|
|
42
|
+
const count = payload.messagesByThread[key]?.length ?? 0;
|
|
43
|
+
delete payload.messagesByThread[key];
|
|
44
|
+
this.store.write(payload);
|
|
45
|
+
return count;
|
|
46
|
+
}
|
|
47
|
+
readPayload() {
|
|
48
|
+
const payload = this.store.read();
|
|
49
|
+
if (!payload || payload.version !== 1 || !payload.messagesByThread || typeof payload.messagesByThread !== "object") {
|
|
50
|
+
return { version: 1, messagesByThread: {} };
|
|
51
|
+
}
|
|
52
|
+
const messagesByThread = {};
|
|
53
|
+
for (const [threadId, messages] of Object.entries(payload.messagesByThread)) {
|
|
54
|
+
if (Array.isArray(messages)) {
|
|
55
|
+
messagesByThread[threadId] = messages.filter(isWebChatMessage).slice(-this.maxMessages);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { version: 1, messagesByThread };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export class WebActivityStore {
|
|
62
|
+
store;
|
|
63
|
+
maxEvents;
|
|
64
|
+
constructor(workspace, backend = "json", maxEvents = DEFAULT_ACTIVITY_LIMIT) {
|
|
65
|
+
this.store = createDocumentStore({
|
|
66
|
+
workspace,
|
|
67
|
+
fileName: "web-activity.json",
|
|
68
|
+
sqliteKey: "web-activity",
|
|
69
|
+
backend,
|
|
70
|
+
});
|
|
71
|
+
this.maxEvents = maxEvents;
|
|
72
|
+
}
|
|
73
|
+
append(input) {
|
|
74
|
+
const payload = this.readPayload();
|
|
75
|
+
const event = {
|
|
76
|
+
id: randomId(),
|
|
77
|
+
timestamp: input.timestamp ?? new Date().toISOString(),
|
|
78
|
+
...input,
|
|
79
|
+
};
|
|
80
|
+
payload.events.push(event);
|
|
81
|
+
if (payload.events.length > this.maxEvents) {
|
|
82
|
+
payload.events.splice(0, payload.events.length - this.maxEvents);
|
|
83
|
+
}
|
|
84
|
+
this.store.write(payload);
|
|
85
|
+
return event;
|
|
86
|
+
}
|
|
87
|
+
list(options = {}) {
|
|
88
|
+
const limit = Math.max(1, Math.min(500, options.limit ?? 100));
|
|
89
|
+
return this.readPayload().events
|
|
90
|
+
.filter((event) => !options.source || options.source === "all" || event.source === options.source)
|
|
91
|
+
.filter((event) => !options.status || options.status === "all" || event.status === options.status)
|
|
92
|
+
.slice(-limit)
|
|
93
|
+
.reverse();
|
|
94
|
+
}
|
|
95
|
+
readPayload() {
|
|
96
|
+
const payload = this.store.read();
|
|
97
|
+
if (!payload || payload.version !== 1 || !Array.isArray(payload.events)) {
|
|
98
|
+
return { version: 1, events: [] };
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
version: 1,
|
|
102
|
+
events: payload.events.filter(isWebActivityEvent).slice(-this.maxEvents),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function isWebChatMessage(value) {
|
|
107
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
const candidate = value;
|
|
111
|
+
return typeof candidate.id === "string" &&
|
|
112
|
+
typeof candidate.threadId === "string" &&
|
|
113
|
+
typeof candidate.text === "string" &&
|
|
114
|
+
typeof candidate.timestamp === "string" &&
|
|
115
|
+
["user", "agent", "system", "tool"].includes(candidate.role) &&
|
|
116
|
+
["web", "cli"].includes(candidate.source);
|
|
117
|
+
}
|
|
118
|
+
function isWebActivityEvent(value) {
|
|
119
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
const candidate = value;
|
|
123
|
+
return typeof candidate.id === "string" &&
|
|
124
|
+
typeof candidate.timestamp === "string" &&
|
|
125
|
+
typeof candidate.type === "string" &&
|
|
126
|
+
["web", "cli"].includes(candidate.source) &&
|
|
127
|
+
["queued", "running", "completed", "failed", "aborted", "info"].includes(candidate.status);
|
|
128
|
+
}
|
|
129
|
+
function randomId() {
|
|
130
|
+
return randomUUID().replace(/-/g, "").slice(0, 12);
|
|
131
|
+
}
|
package/docker-compose.yml
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nordbyte/nordrelay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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.
|
|
4
|
-
"description": "Run a remote-control bridge for coding agents.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
19
|
+
const VERSION = readRuntimePackageVersion() || FALLBACK_VERSION;
|
|
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
|
}
|
|
@@ -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
|
|
|
@@ -77,16 +94,11 @@ async function mkdirp(dir) {
|
|
|
77
94
|
}
|
|
78
95
|
|
|
79
96
|
function loadEnvFiles(home) {
|
|
80
|
-
const
|
|
81
|
-
path.
|
|
82
|
-
path.join(
|
|
83
|
-
path.join(PLUGIN_ROOT, ".env"),
|
|
84
|
-
path.join(home, "nordrelay.env"),
|
|
85
|
-
];
|
|
97
|
+
const envPath = process.env.NORDRELAY_ENV_FILE
|
|
98
|
+
? path.resolve(process.env.NORDRELAY_ENV_FILE)
|
|
99
|
+
: path.join(home, "nordrelay.env");
|
|
86
100
|
|
|
87
|
-
|
|
88
|
-
loadEnvFile(envPath);
|
|
89
|
-
}
|
|
101
|
+
loadEnvFile(envPath);
|
|
90
102
|
|
|
91
103
|
normalizeEnvAliases();
|
|
92
104
|
}
|
|
@@ -207,7 +219,7 @@ async function commandStart(options) {
|
|
|
207
219
|
await fsp.rm(options.pidFile, { force: true });
|
|
208
220
|
}
|
|
209
221
|
console.log(`Startup failed. Log: ${options.logFile}`);
|
|
210
|
-
console.log(state.error || "Unknown error");
|
|
222
|
+
console.log(state.error || await readStartupError(options.logFile) || "Unknown error");
|
|
211
223
|
process.exitCode = 1;
|
|
212
224
|
return;
|
|
213
225
|
}
|
|
@@ -255,6 +267,7 @@ async function commandStop(options) {
|
|
|
255
267
|
}
|
|
256
268
|
|
|
257
269
|
async function commandStatus(options) {
|
|
270
|
+
loadEnvFiles(options.home);
|
|
258
271
|
const pid = await readPid(options.pidFile);
|
|
259
272
|
const state = await readJson(options.stateFile, {});
|
|
260
273
|
const running = isProcessRunning(pid);
|
|
@@ -264,6 +277,11 @@ async function commandStatus(options) {
|
|
|
264
277
|
console.log(`Mode: ${state.sessionMode || "per Telegram context"}`);
|
|
265
278
|
console.log(`Auth: ${state.authenticated === undefined ? "-" : state.authenticated ? "yes" : "no"}`);
|
|
266
279
|
console.log(`Codex CLI: ${state.codexCli || "-"}`);
|
|
280
|
+
console.log(`Pi CLI: ${state.piCli || "-"}`);
|
|
281
|
+
console.log(`Hermes CLI: ${state.hermesCli || "-"}`);
|
|
282
|
+
console.log(`OpenClaw CLI: ${state.openClawCli || "-"}`);
|
|
283
|
+
console.log(`Claude Code CLI: ${state.claudeCodeCli || "-"}`);
|
|
284
|
+
console.log(`OpenClaw Gateway: ${state.openClawGateway || process.env.OPENCLAW_GATEWAY_URL || "-"}`);
|
|
267
285
|
console.log(`Log: ${options.logFile}`);
|
|
268
286
|
if (state.error) console.log(`Error: ${state.error}`);
|
|
269
287
|
}
|
|
@@ -289,11 +307,23 @@ async function commandInit(options) {
|
|
|
289
307
|
await ask(rl, "Telegram admin user id", "");
|
|
290
308
|
const enableCodex = options.disableCodex ? "false" : await askChoice(rl, "Enable Codex", "true");
|
|
291
309
|
const enablePi = options.enablePi ? "true" : await askChoice(rl, "Enable Pi", "false");
|
|
310
|
+
const enableHermes = options.enableHermes ? "true" : await askChoice(rl, "Enable Hermes", "false");
|
|
311
|
+
const enableOpenClaw = options.enableOpenClaw ? "true" : await askChoice(rl, "Enable OpenClaw", "false");
|
|
312
|
+
const enableClaudeCode = options.enableClaudeCode ? "true" : await askChoice(rl, "Enable Claude Code", "false");
|
|
292
313
|
const stateBackend = options.stateBackend || await askChoice(rl, "State backend (json/sqlite)", "json");
|
|
293
314
|
|
|
294
315
|
if (!telegramBotToken) throw new Error("Telegram bot token is required.");
|
|
295
316
|
if (!telegramAdminUserIds) throw new Error("Telegram admin user id is required.");
|
|
296
|
-
if (enableCodex !== "true" && enablePi !== "true") throw new Error("At least one agent must be enabled.");
|
|
317
|
+
if (enableCodex !== "true" && enablePi !== "true" && enableHermes !== "true" && enableOpenClaw !== "true" && enableClaudeCode !== "true") throw new Error("At least one agent must be enabled.");
|
|
318
|
+
const defaultAgent = enableCodex === "true"
|
|
319
|
+
? "codex"
|
|
320
|
+
: enablePi === "true"
|
|
321
|
+
? "pi"
|
|
322
|
+
: enableHermes === "true"
|
|
323
|
+
? "hermes"
|
|
324
|
+
: enableOpenClaw === "true"
|
|
325
|
+
? "openclaw"
|
|
326
|
+
: "claude-code";
|
|
297
327
|
|
|
298
328
|
const lines = [
|
|
299
329
|
"# NordRelay local runtime config.",
|
|
@@ -303,7 +333,18 @@ async function commandInit(options) {
|
|
|
303
333
|
"TELEGRAM_ALLOW_ANY_CHAT=false",
|
|
304
334
|
`NORDRELAY_CODEX_ENABLED=${enableCodex}`,
|
|
305
335
|
`NORDRELAY_PI_ENABLED=${enablePi}`,
|
|
306
|
-
`
|
|
336
|
+
`NORDRELAY_HERMES_ENABLED=${enableHermes}`,
|
|
337
|
+
`NORDRELAY_OPENCLAW_ENABLED=${enableOpenClaw}`,
|
|
338
|
+
`NORDRELAY_CLAUDE_CODE_ENABLED=${enableClaudeCode}`,
|
|
339
|
+
`NORDRELAY_DEFAULT_AGENT=${defaultAgent}`,
|
|
340
|
+
"PI_DEFAULT_PROFILE=default",
|
|
341
|
+
"HERMES_API_BASE_URL=http://127.0.0.1:8642",
|
|
342
|
+
"HERMES_DEFAULT_PROFILE=default",
|
|
343
|
+
"OPENCLAW_GATEWAY_URL=ws://127.0.0.1:18789",
|
|
344
|
+
"OPENCLAW_AGENT_ID=main",
|
|
345
|
+
"OPENCLAW_DEFAULT_PROFILE=default",
|
|
346
|
+
"CLAUDE_CODE_DEFAULT_PROFILE=default",
|
|
347
|
+
"CLAUDE_CODE_MAX_TURNS=100",
|
|
307
348
|
`NORDRELAY_STATE_BACKEND=${stateBackend === "sqlite" ? "sqlite" : "json"}`,
|
|
308
349
|
"TELEGRAM_TRANSPORT=polling",
|
|
309
350
|
"TELEGRAM_AUTO_SEND_ARTIFACTS=false",
|
|
@@ -329,10 +370,21 @@ async function commandDoctor(options) {
|
|
|
329
370
|
checks.push(check("Private by default", process.env.TELEGRAM_ALLOW_ANY_CHAT !== "true", "TELEGRAM_ALLOW_ANY_CHAT is not true"));
|
|
330
371
|
checks.push(check("Codex enabled flag", process.env.NORDRELAY_CODEX_ENABLED !== "false", `NORDRELAY_CODEX_ENABLED=${process.env.NORDRELAY_CODEX_ENABLED ?? "true"}`));
|
|
331
372
|
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"));
|
|
373
|
+
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"));
|
|
374
|
+
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"));
|
|
375
|
+
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"));
|
|
332
376
|
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"));
|
|
333
377
|
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"));
|
|
378
|
+
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"));
|
|
379
|
+
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"));
|
|
380
|
+
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"));
|
|
381
|
+
const hermesApiCheck = await checkHermesApiServer();
|
|
382
|
+
checks.push(check("Hermes API Server", hermesApiCheck.ok, hermesApiCheck.detail, process.env.NORDRELAY_HERMES_ENABLED === "true" ? "fail" : "warn"));
|
|
383
|
+
const openClawGatewayCheck = await checkOpenClawGateway();
|
|
384
|
+
checks.push(check("OpenClaw Gateway", openClawGatewayCheck.ok, openClawGatewayCheck.detail, process.env.NORDRELAY_OPENCLAW_ENABLED === "true" ? "fail" : "warn"));
|
|
334
385
|
checks.push(check("ffmpeg", Boolean(findExecutable("ffmpeg")), findExecutable("ffmpeg") || "not found", "warn"));
|
|
335
|
-
|
|
386
|
+
const stateBackendCheck = validateStateBackend();
|
|
387
|
+
checks.push(check("State backend", stateBackendCheck.ok, stateBackendCheck.detail));
|
|
336
388
|
checks.push(check("Runtime entry", Boolean(await resolveRuntimeEntry()), RUNTIME_ROOT));
|
|
337
389
|
|
|
338
390
|
for (const item of checks) {
|
|
@@ -345,6 +397,78 @@ async function commandDoctor(options) {
|
|
|
345
397
|
if (failed.length > 0) process.exitCode = 1;
|
|
346
398
|
}
|
|
347
399
|
|
|
400
|
+
async function checkHermesApiServer() {
|
|
401
|
+
const baseUrl = (process.env.HERMES_API_BASE_URL || "http://127.0.0.1:8642").replace(/\/+$/, "");
|
|
402
|
+
const headers = process.env.HERMES_API_KEY ? { authorization: `Bearer ${process.env.HERMES_API_KEY}` } : {};
|
|
403
|
+
try {
|
|
404
|
+
const response = await fetch(`${baseUrl}/health`, { headers, signal: AbortSignal.timeout(2000) });
|
|
405
|
+
return {
|
|
406
|
+
ok: response.ok,
|
|
407
|
+
detail: response.ok ? `${baseUrl}/health ok` : `${baseUrl}/health HTTP ${response.status}`,
|
|
408
|
+
};
|
|
409
|
+
} catch (error) {
|
|
410
|
+
return {
|
|
411
|
+
ok: false,
|
|
412
|
+
detail: `${baseUrl}/health failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function checkOpenClawGateway() {
|
|
418
|
+
const gatewayUrl = process.env.OPENCLAW_GATEWAY_URL || "ws://127.0.0.1:18789";
|
|
419
|
+
const WebSocketClass = globalThis.WebSocket;
|
|
420
|
+
if (!WebSocketClass) {
|
|
421
|
+
return { ok: false, detail: "Node.js WebSocket runtime is unavailable" };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return new Promise((resolve) => {
|
|
425
|
+
let settled = false;
|
|
426
|
+
const finish = (value) => {
|
|
427
|
+
if (settled) return;
|
|
428
|
+
settled = true;
|
|
429
|
+
clearTimeout(timeout);
|
|
430
|
+
try {
|
|
431
|
+
socket.close();
|
|
432
|
+
} catch {
|
|
433
|
+
// Ignore close errors during diagnostics.
|
|
434
|
+
}
|
|
435
|
+
resolve(value);
|
|
436
|
+
};
|
|
437
|
+
const timeout = setTimeout(() => {
|
|
438
|
+
finish({ ok: false, detail: `${gatewayUrl} timed out` });
|
|
439
|
+
}, 2000);
|
|
440
|
+
timeout.unref?.();
|
|
441
|
+
|
|
442
|
+
let socket;
|
|
443
|
+
try {
|
|
444
|
+
socket = new WebSocketClass(gatewayUrl);
|
|
445
|
+
} catch (error) {
|
|
446
|
+
clearTimeout(timeout);
|
|
447
|
+
resolve({ ok: false, detail: `${gatewayUrl} failed: ${error instanceof Error ? error.message : String(error)}` });
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
socket.addEventListener("open", () => {
|
|
452
|
+
const auth = {};
|
|
453
|
+
if (process.env.OPENCLAW_GATEWAY_TOKEN) auth.token = process.env.OPENCLAW_GATEWAY_TOKEN;
|
|
454
|
+
if (process.env.OPENCLAW_GATEWAY_PASSWORD) auth.password = process.env.OPENCLAW_GATEWAY_PASSWORD;
|
|
455
|
+
const params = {
|
|
456
|
+
client: { name: "NordRelay doctor", deviceFamily: "nordrelay" },
|
|
457
|
+
role: "operator",
|
|
458
|
+
subscribe: ["health"],
|
|
459
|
+
};
|
|
460
|
+
if (Object.keys(auth).length > 0) params.auth = auth;
|
|
461
|
+
socket.send(JSON.stringify({ type: "connect", id: "doctor", params }));
|
|
462
|
+
}, { once: true });
|
|
463
|
+
socket.addEventListener("message", () => {
|
|
464
|
+
finish({ ok: true, detail: `${gatewayUrl} reachable` });
|
|
465
|
+
}, { once: true });
|
|
466
|
+
socket.addEventListener("error", () => {
|
|
467
|
+
finish({ ok: false, detail: `${gatewayUrl} failed` });
|
|
468
|
+
}, { once: true });
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
348
472
|
async function commandWeb(options) {
|
|
349
473
|
await mkdirp(options.home);
|
|
350
474
|
loadEnvFiles(options.home);
|
|
@@ -439,12 +563,14 @@ async function commandForeground(options) {
|
|
|
439
563
|
child.once("exit", (code, signal) => resolve({ code, signal }));
|
|
440
564
|
});
|
|
441
565
|
|
|
566
|
+
const previousState = await readJson(options.stateFile, {});
|
|
442
567
|
await writeJsonAtomic(options.stateFile, {
|
|
443
568
|
status: exit.code === 0 ? "stopped" : "error",
|
|
444
569
|
pid: process.pid,
|
|
445
570
|
updatedAt: nowIso(),
|
|
446
571
|
exitCode: exit.code,
|
|
447
572
|
signal: exit.signal,
|
|
573
|
+
error: exit.code === 0 ? undefined : previousState.error,
|
|
448
574
|
logFile: options.logFile,
|
|
449
575
|
});
|
|
450
576
|
|
|
@@ -550,13 +676,40 @@ function findExecutable(command) {
|
|
|
550
676
|
|
|
551
677
|
function validateStateBackend() {
|
|
552
678
|
const backend = process.env.NORDRELAY_STATE_BACKEND || "json";
|
|
553
|
-
if (backend === "json") return true;
|
|
554
|
-
if (backend !== "sqlite") return false;
|
|
679
|
+
if (backend === "json") return { ok: true, detail: "NORDRELAY_STATE_BACKEND=json" };
|
|
680
|
+
if (backend !== "sqlite") return { ok: false, detail: `Invalid NORDRELAY_STATE_BACKEND=${backend}` };
|
|
555
681
|
try {
|
|
556
|
-
require("better-sqlite3");
|
|
557
|
-
|
|
682
|
+
const Database = require("better-sqlite3");
|
|
683
|
+
const filePath = path.join(process.cwd(), ".nordrelay", "state.sqlite");
|
|
684
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
685
|
+
const db = new Database(filePath);
|
|
686
|
+
db.exec([
|
|
687
|
+
"CREATE TABLE IF NOT EXISTS documents (",
|
|
688
|
+
"key TEXT PRIMARY KEY,",
|
|
689
|
+
"json TEXT NOT NULL,",
|
|
690
|
+
"updated_at TEXT NOT NULL",
|
|
691
|
+
")",
|
|
692
|
+
].join(" "));
|
|
693
|
+
db.close?.();
|
|
694
|
+
return { ok: true, detail: `NORDRELAY_STATE_BACKEND=sqlite (${filePath})` };
|
|
695
|
+
} catch (error) {
|
|
696
|
+
return {
|
|
697
|
+
ok: false,
|
|
698
|
+
detail: `NORDRELAY_STATE_BACKEND=sqlite failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function readStartupError(logFile) {
|
|
704
|
+
try {
|
|
705
|
+
const lines = (await fsp.readFile(logFile, "utf8")).split(/\r?\n/).filter(Boolean).slice(-80).reverse();
|
|
706
|
+
const startupLine = lines.find((line) => line.includes("Failed to start NordRelay:"));
|
|
707
|
+
if (startupLine) return startupLine.replace(/^.*Failed to start NordRelay:\s*/, "");
|
|
708
|
+
const errorLine = lines.find((line) => /\bERROR\b/i.test(line));
|
|
709
|
+
if (errorLine) return errorLine;
|
|
710
|
+
return lines[0] || null;
|
|
558
711
|
} catch {
|
|
559
|
-
return
|
|
712
|
+
return null;
|
|
560
713
|
}
|
|
561
714
|
}
|
|
562
715
|
|
|
@@ -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
|
|
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,17 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## v0.3.0 - 2026-05-12
|
|
4
|
-
|
|
5
|
-
Commits since `v0.2.1`:
|
|
6
|
-
|
|
7
|
-
- Prepare v0.3.0 release with copyable WebUI thread IDs and aligned session controls.
|
|
8
|
-
- Improve dashboard usability and uploads.
|
|
9
|
-
- Expand WebUI dashboard.
|
|
10
|
-
- Keep dashboard server running.
|
|
11
|
-
- Expand NordRelay platform features.
|
|
12
|
-
- Hide missing timestamp marker in logs.
|
|
13
|
-
- Show CLI paths directly in version output.
|
|
14
|
-
- Add version freshness checks.
|
|
15
|
-
- Render log messages as normal text.
|
|
16
|
-
- Improve version and log display.
|
|
17
|
-
- Improve logs and self-update behavior.
|