@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
@@ -0,0 +1,409 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ export function getDefaultOpenClawHome() {
5
+ return path.join(os.homedir(), ".openclaw");
6
+ }
7
+ export function resolveOpenClawStateDir(options = {}) {
8
+ return options.stateDir
9
+ ?? process.env.OPENCLAW_STATE_DIR
10
+ ?? options.openClawHome
11
+ ?? process.env.OPENCLAW_HOME
12
+ ?? getDefaultOpenClawHome();
13
+ }
14
+ export function listOpenClawSessions(limit = 20, options = {}) {
15
+ const payload = options.sessionsJson ?? readOpenClawSessionsJson(limit, options);
16
+ return parseOpenClawSessionsPayload(payload, options)
17
+ .sort((left, right) => right.updatedAt.getTime() - left.updatedAt.getTime())
18
+ .slice(0, Math.max(1, limit));
19
+ }
20
+ export function getOpenClawSession(id, options = {}) {
21
+ const normalized = id.trim();
22
+ if (!normalized) {
23
+ return null;
24
+ }
25
+ const sessions = listOpenClawSessions(500, options);
26
+ return sessions.find((record) => record.id === normalized || record.sessionKey === normalized)
27
+ ?? sessions.find((record) => record.id.startsWith(normalized) || record.sessionKey.startsWith(normalized))
28
+ ?? null;
29
+ }
30
+ export function listOpenClawWorkspaces(options = {}) {
31
+ const workspaces = new Set();
32
+ for (const record of listOpenClawSessions(500, options)) {
33
+ if (record.cwd) {
34
+ workspaces.add(record.cwd);
35
+ }
36
+ }
37
+ if (options.workspace) {
38
+ workspaces.add(options.workspace);
39
+ }
40
+ return [...workspaces].sort((left, right) => left.localeCompare(right));
41
+ }
42
+ export function getOpenClawSessionActivity(id, options = {}) {
43
+ return getOpenClawSessionSnapshot(id, { ...options, maxEvents: 0 })?.activity ?? null;
44
+ }
45
+ export function getOpenClawSessionActivityLog(id, limit = 50, options = {}) {
46
+ return getOpenClawSessionSnapshot(id, { ...options, maxEvents: Math.max(1, limit) })?.events ?? [];
47
+ }
48
+ export function getOpenClawSessionSnapshot(id, options = {}) {
49
+ const record = getOpenClawSession(id, options);
50
+ if (!record) {
51
+ return null;
52
+ }
53
+ const events = parseOpenClawActivityEvents(record, options.afterLine ?? 0);
54
+ const latestUser = [...events].reverse().find((event) => event.kind === "user");
55
+ const latestAgent = [...events].reverse().find((event) => event.kind === "agent");
56
+ const latestTerminal = [...events].reverse().find((event) => event.kind === "task" && event.status && event.status !== "started");
57
+ const latestTool = [...events].reverse().find((event) => event.kind === "tool" && event.toolName);
58
+ const latestTimestamp = events.at(-1)?.timestamp ?? record.updatedAt;
59
+ const staleAfterMs = options.staleAfterMs ?? 5 * 60 * 1000;
60
+ const nowMs = options.nowMs ?? Date.now();
61
+ const stale = Boolean(record.active && latestTimestamp && nowMs - latestTimestamp.getTime() > staleAfterMs);
62
+ const maxEvents = options.maxEvents ?? 50;
63
+ const lineCount = Math.max(events.length, record.active ? 1 : 0);
64
+ const returnedEvents = maxEvents <= 0 ? [] : events.slice(-maxEvents);
65
+ return {
66
+ agentId: "openclaw",
67
+ agentLabel: "OpenClaw",
68
+ threadId: record.id,
69
+ sourcePath: record.sessionPath ?? sourcePath(options),
70
+ sourceLabel: "OpenClaw sessions",
71
+ lineCount,
72
+ activity: {
73
+ agentId: "openclaw",
74
+ agentLabel: "OpenClaw",
75
+ threadId: record.id,
76
+ sourcePath: record.sessionPath ?? sourcePath(options),
77
+ sourceLabel: "OpenClaw sessions",
78
+ active: record.active && !stale,
79
+ stale,
80
+ turnId: latestUser?.turnId ?? latestTerminal?.turnId ?? record.id,
81
+ startedAt: latestUser?.timestamp ?? (record.active ? record.updatedAt : null),
82
+ updatedAt: latestTimestamp,
83
+ },
84
+ events: returnedEvents,
85
+ latestAgentMessage: latestAgent?.text ?? null,
86
+ latestUserMessage: latestUser?.text ?? record.firstUserMessage,
87
+ latestToolName: latestTool?.toolName ?? null,
88
+ };
89
+ }
90
+ export function getOpenClawSessionDiagnostics(id, options = {}) {
91
+ const source = sourcePath(options);
92
+ if (!id) {
93
+ return {
94
+ sourcePath: source,
95
+ status: "unavailable",
96
+ reason: "no active OpenClaw session",
97
+ lineCount: 0,
98
+ updatedAt: null,
99
+ };
100
+ }
101
+ const snapshot = getOpenClawSessionSnapshot(id, { ...options, maxEvents: 0 });
102
+ if (!snapshot) {
103
+ return {
104
+ sourcePath: source,
105
+ status: "unavailable",
106
+ reason: "OpenClaw session not found",
107
+ lineCount: 0,
108
+ updatedAt: null,
109
+ };
110
+ }
111
+ const status = snapshot.activity.active ? "active" : snapshot.activity.stale ? "stale" : "idle";
112
+ const reason = snapshot.activity.active
113
+ ? "OpenClaw session reports an active run"
114
+ : snapshot.activity.stale
115
+ ? "OpenClaw active run exceeded stale timeout"
116
+ : "OpenClaw session is idle";
117
+ return {
118
+ sourcePath: snapshot.sourcePath,
119
+ status,
120
+ reason,
121
+ lineCount: snapshot.lineCount,
122
+ updatedAt: snapshot.activity.updatedAt,
123
+ };
124
+ }
125
+ export function parseOpenClawSessionsPayload(payload, options = {}) {
126
+ const root = objectValue(payload);
127
+ const storePaths = new Map();
128
+ for (const store of arrayValue(root?.stores)) {
129
+ const storeObject = objectValue(store);
130
+ const agentId = stringValue(storeObject?.agentId) ?? stringValue(storeObject?.agent_id);
131
+ const storePath = stringValue(storeObject?.path) ?? stringValue(storeObject?.storePath);
132
+ if (agentId && storePath) {
133
+ storePaths.set(agentId, storePath);
134
+ }
135
+ }
136
+ const sessions = arrayValue(root?.sessions ?? payload);
137
+ return sessions
138
+ .map((entry) => mapOpenClawSession(entry, options, storePaths))
139
+ .filter((record) => Boolean(record));
140
+ }
141
+ function readOpenClawSessionsJson(limit, options) {
142
+ const cliPath = options.cliPath ?? process.env.OPENCLAW_CLI_PATH ?? "openclaw";
143
+ const args = ["sessions", "--all-agents", "--limit", String(Math.max(1, limit)), "--json"];
144
+ const result = spawnSync(cliPath, args, {
145
+ encoding: "utf8",
146
+ timeout: 5000,
147
+ windowsHide: true,
148
+ env: process.env,
149
+ });
150
+ if (result.error || result.status !== 0) {
151
+ return { sessions: [] };
152
+ }
153
+ try {
154
+ return JSON.parse(result.stdout.trim() || "{}");
155
+ }
156
+ catch {
157
+ return { sessions: [] };
158
+ }
159
+ }
160
+ function mapOpenClawSession(raw, options, storePaths) {
161
+ const object = objectValue(raw);
162
+ if (!object) {
163
+ return null;
164
+ }
165
+ const key = stringValue(object.key)
166
+ ?? stringValue(object.sessionKey)
167
+ ?? stringValue(object.session_key)
168
+ ?? stringValue(object.id)
169
+ ?? stringValue(object.sessionId)
170
+ ?? stringValue(object.session_id);
171
+ if (!key) {
172
+ return null;
173
+ }
174
+ const id = stringValue(object.id)
175
+ ?? stringValue(object.sessionId)
176
+ ?? stringValue(object.session_id)
177
+ ?? key;
178
+ const openClawAgentId = stringValue(object.agentId)
179
+ ?? stringValue(object.agent_id)
180
+ ?? parseAgentFromSessionKey(key)
181
+ ?? options.openClawAgentId
182
+ ?? null;
183
+ const sessionPath = stringValue(object.path)
184
+ ?? stringValue(object.storePath)
185
+ ?? stringValue(object.store_path)
186
+ ?? (openClawAgentId ? storePaths.get(openClawAgentId) : undefined);
187
+ const workspace = stringValue(object.workspace)
188
+ ?? stringValue(object.cwd)
189
+ ?? workspaceFromSource(stringValue(object.source))
190
+ ?? options.workspace
191
+ ?? process.cwd();
192
+ const createdAt = dateValue(object.createdAt)
193
+ ?? dateValue(object.created_at)
194
+ ?? dateValue(object.startedAt)
195
+ ?? dateValue(object.started_at)
196
+ ?? new Date();
197
+ const updatedAt = dateValue(object.updatedAt)
198
+ ?? dateValue(object.updated_at)
199
+ ?? dateValue(object.lastActive)
200
+ ?? dateValue(object.last_active)
201
+ ?? dateValue(object.endedAt)
202
+ ?? dateValue(object.ended_at)
203
+ ?? createdAt;
204
+ const firstUserMessage = stringValue(object.firstUserMessage)
205
+ ?? stringValue(object.first_user_message)
206
+ ?? firstMessageText(object);
207
+ const status = stringValue(object.status);
208
+ const active = booleanValue(object.active)
209
+ ?? booleanValue(object.running)
210
+ ?? booleanValue(object.inProgress)
211
+ ?? booleanValue(object.in_progress)
212
+ ?? booleanValue(object.isRunning)
213
+ ?? statusIsActive(status);
214
+ return {
215
+ id,
216
+ sessionKey: key,
217
+ title: stringValue(object.title),
218
+ cwd: workspace,
219
+ model: stringValue(object.model),
220
+ reasoningEffort: stringValue(object.thinking)
221
+ ?? stringValue(object.thinkingLevel)
222
+ ?? stringValue(object.thinking_level)
223
+ ?? stringValue(object.reasoningEffort)
224
+ ?? stringValue(object.reasoning_effort),
225
+ createdAt,
226
+ updatedAt,
227
+ firstUserMessage,
228
+ agentId: "openclaw",
229
+ sessionPath,
230
+ openClawAgentId,
231
+ status,
232
+ active,
233
+ usage: usageFromSession(object),
234
+ raw,
235
+ };
236
+ }
237
+ function parseOpenClawActivityEvents(record, afterLine) {
238
+ const raw = objectValue(record.raw);
239
+ const sourceEvents = arrayValue(raw?.events ?? raw?.messages ?? raw?.turns ?? raw?.transcript);
240
+ const events = [];
241
+ let lineNumber = 0;
242
+ if (record.active) {
243
+ lineNumber += 1;
244
+ events.push({
245
+ lineNumber,
246
+ kind: "task",
247
+ timestamp: record.updatedAt,
248
+ type: "run",
249
+ turnId: record.id,
250
+ status: "started",
251
+ text: record.status,
252
+ toolName: null,
253
+ phase: null,
254
+ });
255
+ }
256
+ for (const event of sourceEvents) {
257
+ const object = objectValue(event);
258
+ if (!object)
259
+ continue;
260
+ lineNumber += 1;
261
+ const parsed = mapActivityObject(object, lineNumber, record.id);
262
+ if (parsed)
263
+ events.push(parsed);
264
+ }
265
+ if (!record.active && events.some((event) => event.kind === "user") && !events.some((event) => event.kind === "task" && event.status !== "started")) {
266
+ lineNumber += 1;
267
+ events.push({
268
+ lineNumber,
269
+ kind: "task",
270
+ timestamp: record.updatedAt,
271
+ type: "run",
272
+ turnId: record.id,
273
+ status: statusIsFailure(record.status) ? "failed" : "completed",
274
+ text: record.status,
275
+ toolName: null,
276
+ phase: null,
277
+ });
278
+ }
279
+ if (events.length === 0 && record.firstUserMessage) {
280
+ events.push({
281
+ lineNumber: 1,
282
+ kind: "user",
283
+ timestamp: record.createdAt,
284
+ type: "message",
285
+ turnId: record.id,
286
+ status: null,
287
+ text: record.firstUserMessage,
288
+ toolName: null,
289
+ phase: null,
290
+ });
291
+ }
292
+ return events.filter((event) => event.lineNumber > afterLine);
293
+ }
294
+ function mapActivityObject(object, lineNumber, fallbackTurnId) {
295
+ const role = stringValue(object.role)?.toLowerCase();
296
+ const type = stringValue(object.type) ?? stringValue(object.event) ?? role ?? "message";
297
+ const status = stringValue(object.status);
298
+ const timestamp = dateValue(object.timestamp) ?? dateValue(object.createdAt) ?? dateValue(object.created_at);
299
+ const text = stringValue(object.text)
300
+ ?? stringValue(object.content)
301
+ ?? stringValue(object.message)
302
+ ?? stringValue(object.summary);
303
+ const toolName = stringValue(object.toolName)
304
+ ?? stringValue(object.tool_name)
305
+ ?? stringValue(object.name)
306
+ ?? stringValue(object.tool);
307
+ const turnId = stringValue(object.turnId) ?? stringValue(object.turn_id) ?? stringValue(object.runId) ?? fallbackTurnId;
308
+ if (role === "user") {
309
+ return { lineNumber, kind: "user", timestamp, type, turnId, status, text, toolName: null, phase: null };
310
+ }
311
+ if (role === "assistant" || role === "agent") {
312
+ return { lineNumber, kind: "agent", timestamp, type, turnId, status, text, toolName: null, phase: stringValue(object.phase) };
313
+ }
314
+ if (role === "tool" || toolName || type.includes("tool")) {
315
+ return {
316
+ lineNumber,
317
+ kind: "tool",
318
+ timestamp,
319
+ type,
320
+ turnId,
321
+ status: status === "completed" ? "finished" : status ?? (type.includes("start") ? "started" : "finished"),
322
+ text,
323
+ toolName: toolName ?? "tool",
324
+ phase: null,
325
+ };
326
+ }
327
+ if (type.includes("run") || type.includes("task")) {
328
+ return { lineNumber, kind: "task", timestamp, type, turnId, status, text, toolName: null, phase: null };
329
+ }
330
+ return text ? { lineNumber, kind: "agent", timestamp, type, turnId, status, text, toolName: null, phase: null } : null;
331
+ }
332
+ function sourcePath(options) {
333
+ return path.join(resolveOpenClawStateDir(options), "sessions");
334
+ }
335
+ function parseAgentFromSessionKey(key) {
336
+ const match = key.match(/^agent:([^:]+):/);
337
+ return match?.[1] ?? null;
338
+ }
339
+ function workspaceFromSource(source) {
340
+ if (!source)
341
+ return null;
342
+ if (path.isAbsolute(source))
343
+ return source;
344
+ const match = source.match(/(?:cwd|workspace)=([^,;]+)/i);
345
+ return match && path.isAbsolute(match[1]) ? match[1] : null;
346
+ }
347
+ function firstMessageText(object) {
348
+ for (const entry of arrayValue(object.messages ?? object.transcript ?? object.events)) {
349
+ const row = objectValue(entry);
350
+ if (stringValue(row?.role)?.toLowerCase() === "user") {
351
+ return stringValue(row?.text) ?? stringValue(row?.content) ?? stringValue(row?.message);
352
+ }
353
+ }
354
+ return null;
355
+ }
356
+ function usageFromSession(object) {
357
+ const usage = objectValue(object.usage) ?? object;
358
+ const input = numberValue(usage.input) ?? numberValue(usage.inputTokens) ?? numberValue(usage.input_tokens) ?? 0;
359
+ const output = numberValue(usage.output) ?? numberValue(usage.outputTokens) ?? numberValue(usage.output_tokens) ?? 0;
360
+ const cacheRead = numberValue(usage.cacheRead) ?? numberValue(usage.cache_read_tokens) ?? 0;
361
+ const cacheWrite = numberValue(usage.cacheWrite) ?? numberValue(usage.cache_write_tokens) ?? 0;
362
+ const total = input + output + cacheRead + cacheWrite;
363
+ if (total <= 0) {
364
+ return undefined;
365
+ }
366
+ return {
367
+ input,
368
+ output,
369
+ cacheRead,
370
+ cacheWrite,
371
+ total,
372
+ cost: numberValue(usage.cost) ?? numberValue(usage.estimated_cost_usd) ?? undefined,
373
+ };
374
+ }
375
+ function statusIsActive(status) {
376
+ return Boolean(status && /^(active|running|processing|queued|pending|accepted)$/i.test(status));
377
+ }
378
+ function statusIsFailure(status) {
379
+ return Boolean(status && /^(failed|error)$/i.test(status));
380
+ }
381
+ function arrayValue(value) {
382
+ return Array.isArray(value) ? value : [];
383
+ }
384
+ function objectValue(value) {
385
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
386
+ }
387
+ function stringValue(value) {
388
+ if (typeof value === "string" && value.trim())
389
+ return value;
390
+ if (typeof value === "number" && Number.isFinite(value))
391
+ return String(value);
392
+ return null;
393
+ }
394
+ function numberValue(value) {
395
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
396
+ }
397
+ function booleanValue(value) {
398
+ return typeof value === "boolean" ? value : null;
399
+ }
400
+ function dateValue(value) {
401
+ if (typeof value === "number" && Number.isFinite(value)) {
402
+ return new Date(value > 10_000_000_000 ? value : value * 1000);
403
+ }
404
+ if (typeof value === "string" && value.trim()) {
405
+ const parsed = new Date(value);
406
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
407
+ }
408
+ return null;
409
+ }
@@ -5,11 +5,19 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { describeCodexCli, resolveCodexCli } from "./codex-cli.js";
7
7
  import { findLatestDatabase } from "./codex-state.js";
8
+ import { describeClaudeCodeCli, resolveClaudeCodeCli } from "./claude-code-cli.js";
9
+ import { describeHermesCli, resolveHermesCli } from "./hermes-cli.js";
10
+ import { describeOpenClawCli, resolveOpenClawCli } from "./openclaw-cli.js";
8
11
  import { describePiCli, resolvePiCli } from "./pi-cli.js";
9
12
  const APP_NAME = "nordrelay";
10
13
  const PACKAGE_NAME = "@nordbyte/nordrelay";
11
14
  const CODEX_PACKAGE_NAME = "@openai/codex";
12
- const PI_PACKAGE_NAME = "@mariozechner/pi-coding-agent";
15
+ const PI_PACKAGE_NAME = "@earendil-works/pi-coding-agent";
16
+ const LEGACY_PI_PACKAGE_NAME = "@mariozechner/pi-coding-agent";
17
+ const HERMES_PACKAGE_NAME = "hermes-agent";
18
+ const OPENCLAW_PACKAGE_NAME = "openclaw";
19
+ const CLAUDE_CODE_PACKAGE_NAME = "@anthropic-ai/claude-code";
20
+ const CLAUDE_CODE_SDK_PACKAGE_NAME = "@anthropic-ai/claude-agent-sdk";
13
21
  const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
14
22
  const SECRET_RE = /(bot|token|api[_-]?key|authorization|bearer|password|secret)(["'=: ]+)([^\s"',]+)/gi;
15
23
  const DEFAULT_VERSION_CACHE_TTL_MS = 60 * 60 * 1000;
@@ -25,6 +33,9 @@ export function getConnectorLogPath() {
25
33
  export function getUpdateLogPath() {
26
34
  return path.join(getConnectorHome(), "update.log");
27
35
  }
36
+ export function getAgentUpdateLogPath(home = getConnectorHome()) {
37
+ return path.join(home, "agent-updates.log");
38
+ }
28
39
  export async function readConnectorState() {
29
40
  try {
30
41
  return JSON.parse(await readFile(getConnectorStatePath(), "utf8"));
@@ -67,6 +78,14 @@ export async function readFormattedLogTail(lines = 80, filePath = getConnectorLo
67
78
  };
68
79
  }
69
80
  }
81
+ export function clearLogFile(filePath = getConnectorLogPath()) {
82
+ mkdirSync(path.dirname(filePath), { recursive: true });
83
+ writeFileSync(filePath, "", "utf8");
84
+ return {
85
+ filePath,
86
+ clearedAt: new Date(),
87
+ };
88
+ }
70
89
  export async function getPackageVersion() {
71
90
  try {
72
91
  const pkg = JSON.parse(await readFile(path.join(getSourceRoot(), "package.json"), "utf8"));
@@ -80,10 +99,22 @@ export async function getVersionChecks(options = {}) {
80
99
  const nordrelayVersion = await getPackageVersion();
81
100
  const codexCli = resolveCodexCli();
82
101
  const piCli = resolvePiCli(process.env, options.piCliPath);
102
+ const hermesCli = resolveHermesCli(process.env, options.hermesCliPath);
103
+ const openClawCli = resolveOpenClawCli(process.env, options.openClawCliPath);
104
+ const claudeCodeCli = resolveClaudeCodeCli(process.env, options.claudeCodeCliPath);
83
105
  const codexVersionLabel = codexCli.path
84
106
  ? detectCliVersion(codexCli.path)
85
107
  : readInstalledPackageVersion(CODEX_PACKAGE_NAME) ?? "not installed";
86
- const piVersionLabel = piCli.path ? detectCliVersion(piCli.path) : "not installed";
108
+ const piVersionLabel = piCli.path
109
+ ? detectCliVersion(piCli.path)
110
+ : readInstalledPackageVersion(PI_PACKAGE_NAME) ?? readInstalledPackageVersion(LEGACY_PI_PACKAGE_NAME) ?? "not installed";
111
+ const legacyPiPackageVersion = readInstalledPackageVersion(LEGACY_PI_PACKAGE_NAME);
112
+ const hermesVersionLabel = hermesCli.path ? detectCliVersion(hermesCli.path) : "not installed";
113
+ const openClawVersionLabel = openClawCli.path ? detectCliVersion(openClawCli.path) : "not installed";
114
+ const claudeCodeVersionLabel = claudeCodeCli.path
115
+ ? detectCliVersion(claudeCodeCli.path)
116
+ : readInstalledPackageVersion(CLAUDE_CODE_SDK_PACKAGE_NAME) ?? "bundled";
117
+ const claudeCodePackageName = claudeCodeCli.path ? CLAUDE_CODE_PACKAGE_NAME : CLAUDE_CODE_SDK_PACKAGE_NAME;
87
118
  return {
88
119
  nordrelay: buildVersionCheck({
89
120
  label: "NordRelay",
@@ -104,16 +135,36 @@ export async function getVersionChecks(options = {}) {
104
135
  installedLabel: piVersionLabel,
105
136
  installedVersion: extractVersion(piVersionLabel),
106
137
  notInstalled: piVersionLabel === "not installed",
138
+ detail: legacyPiPackageVersion ? `Legacy package ${LEGACY_PI_PACKAGE_NAME} is present; current package is ${PI_PACKAGE_NAME}.` : undefined,
139
+ }),
140
+ hermes: buildHermesVersionCheck(hermesVersionLabel),
141
+ openclaw: buildVersionCheck({
142
+ label: "OpenClaw",
143
+ packageName: OPENCLAW_PACKAGE_NAME,
144
+ installedLabel: openClawVersionLabel,
145
+ installedVersion: extractVersion(openClawVersionLabel),
146
+ notInstalled: openClawVersionLabel === "not installed",
147
+ }),
148
+ claudeCode: buildVersionCheck({
149
+ label: "Claude Code",
150
+ packageName: claudeCodePackageName,
151
+ installedLabel: claudeCodeVersionLabel,
152
+ installedVersion: extractVersion(claudeCodeVersionLabel),
153
+ notInstalled: claudeCodeVersionLabel === "not installed",
107
154
  }),
108
155
  };
109
156
  }
110
- export async function getConnectorHealth() {
111
- const state = await readConnectorState();
157
+ export async function getConnectorHealth(options = {}) {
158
+ const rawState = await readConnectorState();
112
159
  const version = await getPackageVersion();
113
- const pidRunning = isProcessRunning(state.pid);
114
- const appPidRunning = isProcessRunning(state.appPid);
160
+ const pidRunning = isProcessRunning(rawState.pid);
161
+ const appPidRunning = isProcessRunning(rawState.appPid);
162
+ const state = normalizeConnectorState(rawState, pidRunning, appPidRunning);
115
163
  const codexCli = resolveCodexCli();
116
- const piCli = resolvePiCli();
164
+ const piCli = resolvePiCli(process.env, options.piCliPath);
165
+ const hermesCli = resolveHermesCli(process.env, options.hermesCliPath);
166
+ const openClawCli = resolveOpenClawCli(process.env, options.openClawCliPath);
167
+ const claudeCodeCli = resolveClaudeCodeCli(process.env, options.claudeCodeCliPath);
117
168
  return {
118
169
  version,
119
170
  state,
@@ -125,6 +176,17 @@ export async function getConnectorHealth() {
125
176
  piCli: describePiCli(piCli),
126
177
  piCliPath: piCli.path ?? null,
127
178
  piCliVersion: detectCliVersion(piCli.path),
179
+ hermesCli: describeHermesCli(hermesCli),
180
+ hermesCliPath: hermesCli.path ?? null,
181
+ hermesCliVersion: detectCliVersion(hermesCli.path),
182
+ openClawCli: describeOpenClawCli(openClawCli),
183
+ openClawCliPath: openClawCli.path ?? null,
184
+ openClawCliVersion: detectCliVersion(openClawCli.path),
185
+ claudeCodeCli: describeClaudeCodeCli(claudeCodeCli),
186
+ claudeCodeCliPath: claudeCodeCli.path ?? null,
187
+ claudeCodeCliVersion: claudeCodeCli.path
188
+ ? detectCliVersion(claudeCodeCli.path)
189
+ : readInstalledPackageVersion(CLAUDE_CODE_SDK_PACKAGE_NAME) ?? "bundled",
128
190
  stateFile: getConnectorStatePath(),
129
191
  logFile: getConnectorLogPath(),
130
192
  databasePath: findLatestDatabase(),
@@ -202,6 +264,13 @@ function isProcessRunning(pid) {
202
264
  return false;
203
265
  }
204
266
  }
267
+ function normalizeConnectorState(state, pidRunning, appPidRunning) {
268
+ const stoppedSignal = state.signal === "SIGTERM" || state.signal === "SIGINT";
269
+ if (state.status === "error" && stoppedSignal && !state.error && !pidRunning && !appPidRunning) {
270
+ return { ...state, status: "stopped" };
271
+ }
272
+ return state;
273
+ }
205
274
  function redactSecrets(text) {
206
275
  return text.replace(SECRET_RE, "$1$2[redacted]");
207
276
  }
@@ -249,6 +318,31 @@ function detectCliVersion(commandPath) {
249
318
  }
250
319
  return output || "unknown";
251
320
  }
321
+ function buildHermesVersionCheck(installedLabel) {
322
+ if (installedLabel === "not installed") {
323
+ return {
324
+ label: "Hermes",
325
+ packageName: HERMES_PACKAGE_NAME,
326
+ installedLabel: "not installed",
327
+ installedVersion: null,
328
+ latestVersion: null,
329
+ status: "not-installed",
330
+ };
331
+ }
332
+ const lines = installedLabel.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
333
+ const versionLine = lines[0] ?? installedLabel;
334
+ const updateLine = lines.find((line) => /^Update available:/i.test(line));
335
+ const installedVersion = extractVersion(versionLine);
336
+ return {
337
+ label: "Hermes",
338
+ packageName: HERMES_PACKAGE_NAME,
339
+ installedLabel: versionLine,
340
+ installedVersion,
341
+ latestVersion: updateLine?.replace(/^Update available:\s*/i, "") ?? null,
342
+ status: updateLine ? "outdated" : installedVersion ? "current" : "unknown",
343
+ detail: updateLine ?? (installedVersion ? undefined : "Could not parse Hermes version or update status"),
344
+ };
345
+ }
252
346
  function buildVersionCheck(options) {
253
347
  if (options.notInstalled) {
254
348
  return {
@@ -258,6 +352,18 @@ function buildVersionCheck(options) {
258
352
  installedVersion: null,
259
353
  latestVersion: null,
260
354
  status: "not-installed",
355
+ detail: options.detail,
356
+ };
357
+ }
358
+ if (options.skipLatest) {
359
+ return {
360
+ label: options.label,
361
+ packageName: options.packageName,
362
+ installedLabel: options.installedLabel,
363
+ installedVersion: options.installedVersion,
364
+ latestVersion: null,
365
+ status: options.installedVersion ? "unknown" : "unknown",
366
+ detail: options.detail ?? "Latest-version lookup is not available for this package source",
261
367
  };
262
368
  }
263
369
  const latest = detectLatestNpmVersion(options.packageName);
@@ -269,7 +375,7 @@ function buildVersionCheck(options) {
269
375
  installedVersion: options.installedVersion,
270
376
  latestVersion: latest.version,
271
377
  status: "unknown",
272
- detail: latest.error ?? "Could not parse installed version",
378
+ detail: [options.detail, latest.error ?? "Could not parse installed version"].filter(Boolean).join(" "),
273
379
  };
274
380
  }
275
381
  return {
@@ -279,7 +385,7 @@ function buildVersionCheck(options) {
279
385
  installedVersion: options.installedVersion,
280
386
  latestVersion: latest.version,
281
387
  status: compareVersions(options.installedVersion, latest.version) < 0 ? "outdated" : "current",
282
- detail: latest.error,
388
+ detail: [options.detail, latest.error].filter(Boolean).join(" ") || undefined,
283
389
  };
284
390
  }
285
391
  function detectLatestNpmVersion(packageName) {
@@ -0,0 +1,59 @@
1
+ const PROVIDER_ENV_KEYS = {
2
+ anthropic: ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"],
3
+ "aws-bedrock": ["AWS_BEARER_TOKEN_BEDROCK", "AWS_PROFILE", "AWS_ACCESS_KEY_ID"],
4
+ azure: ["AZURE_OPENAI_API_KEY"],
5
+ "azure-openai": ["AZURE_OPENAI_API_KEY"],
6
+ cerebras: ["CEREBRAS_API_KEY"],
7
+ cloudflare: ["CLOUDFLARE_API_KEY"],
8
+ deepseek: ["DEEPSEEK_API_KEY"],
9
+ fireworks: ["FIREWORKS_API_KEY"],
10
+ gemini: ["GEMINI_API_KEY"],
11
+ google: ["GEMINI_API_KEY"],
12
+ groq: ["GROQ_API_KEY"],
13
+ kimi: ["KIMI_API_KEY"],
14
+ minimax: ["MINIMAX_API_KEY"],
15
+ mistral: ["MISTRAL_API_KEY"],
16
+ moonshot: ["MOONSHOT_API_KEY"],
17
+ opencode: ["OPENCODE_API_KEY"],
18
+ openrouter: ["OPENROUTER_API_KEY"],
19
+ openai: ["OPENAI_API_KEY"],
20
+ "openai-codex": ["OPENAI_API_KEY"],
21
+ xai: ["XAI_API_KEY"],
22
+ xiaomi: ["XIAOMI_API_KEY", "XIAOMI_TOKEN_PLAN_CN_API_KEY", "XIAOMI_TOKEN_PLAN_AMS_API_KEY", "XIAOMI_TOKEN_PLAN_SGP_API_KEY"],
23
+ zai: ["ZAI_API_KEY"],
24
+ };
25
+ export function checkPiAuthStatus(model, env = process.env) {
26
+ const provider = providerFromModel(model);
27
+ const keys = PROVIDER_ENV_KEYS[provider];
28
+ if (!keys) {
29
+ return {
30
+ authenticated: true,
31
+ method: "cli",
32
+ detail: `Pi provider "${provider}" is not verifiable by NordRelay. Run "pi" on the host if auth fails.`,
33
+ };
34
+ }
35
+ const configured = keys.filter((key) => Boolean(env[key]?.trim()));
36
+ if (configured.length > 0) {
37
+ return {
38
+ authenticated: true,
39
+ method: "api-key",
40
+ detail: `Pi provider "${provider}" has ${configured.join(" or ")} configured.`,
41
+ };
42
+ }
43
+ return {
44
+ authenticated: false,
45
+ method: "none",
46
+ detail: `Pi provider "${provider}" needs one of: ${keys.join(", ")}.`,
47
+ };
48
+ }
49
+ function providerFromModel(model) {
50
+ const trimmed = model?.trim();
51
+ if (!trimmed) {
52
+ return "google";
53
+ }
54
+ const separator = trimmed.indexOf("/");
55
+ if (separator === -1) {
56
+ return "google";
57
+ }
58
+ return trimmed.slice(0, separator).toLowerCase();
59
+ }