@nordbyte/nordrelay 0.5.0 → 0.5.2

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 (44) hide show
  1. package/.env.example +2 -0
  2. package/README.md +23 -14
  3. package/dist/access-control.js +2 -0
  4. package/dist/agent-updates.js +61 -10
  5. package/dist/bot-ui.js +1 -0
  6. package/dist/bot.js +142 -1065
  7. package/dist/channel-actions.js +8 -8
  8. package/dist/codex-cli.js +1 -1
  9. package/dist/config-metadata.js +2 -0
  10. package/dist/operations.js +233 -122
  11. package/dist/relay-artifact-service.js +126 -0
  12. package/dist/relay-external-activity-monitor.js +216 -0
  13. package/dist/relay-queue-service.js +66 -0
  14. package/dist/relay-runtime-types.js +1 -0
  15. package/dist/relay-runtime.js +119 -371
  16. package/dist/state-backend.js +3 -0
  17. package/dist/support-bundle.js +221 -0
  18. package/dist/telegram-agent-commands.js +212 -0
  19. package/dist/telegram-artifact-commands.js +139 -0
  20. package/dist/telegram-command-menu.js +1 -0
  21. package/dist/telegram-command-types.js +1 -0
  22. package/dist/telegram-diagnostics-command.js +102 -0
  23. package/dist/telegram-general-commands.js +52 -0
  24. package/dist/telegram-operational-commands.js +153 -0
  25. package/dist/telegram-preference-commands.js +198 -0
  26. package/dist/telegram-queue-commands.js +278 -0
  27. package/dist/telegram-support-command.js +53 -0
  28. package/dist/telegram-update-commands.js +6 -1
  29. package/dist/web-api-contract.js +79 -31
  30. package/dist/web-api-types.js +1 -0
  31. package/dist/web-dashboard-access-routes.js +163 -0
  32. package/dist/web-dashboard-artifact-routes.js +65 -0
  33. package/dist/web-dashboard-assets.js +2 -0
  34. package/dist/web-dashboard-http.js +143 -0
  35. package/dist/web-dashboard-pages.js +257 -0
  36. package/dist/web-dashboard-runtime-routes.js +92 -0
  37. package/dist/web-dashboard-session-routes.js +209 -0
  38. package/dist/web-dashboard.js +44 -882
  39. package/dist/webui-assets/dashboard.css +74 -4
  40. package/dist/webui-assets/dashboard.js +163 -24
  41. package/dist/zip-writer.js +83 -0
  42. package/package.json +10 -4
  43. package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
  44. package/plugins/nordrelay/scripts/nordrelay.mjs +258 -5
@@ -52,13 +52,13 @@ export function renderAgentUpdatePickerAction(descriptors) {
52
52
  "Agent updates:",
53
53
  ...available.map((descriptor) => `${descriptor.label}: /update ${descriptor.id}`),
54
54
  "",
55
- "Use /update jobs to list running and recent agent updates.",
55
+ "Use /update install <agent> for missing CLIs and /update jobs to list running and recent agent updates.",
56
56
  ].join("\n"),
57
57
  html: [
58
58
  "<b>Agent updates:</b>",
59
59
  ...available.map((descriptor) => `<b>${escapeHTML(descriptor.label)}:</b> <code>/update ${escapeHTML(descriptor.id)}</code>`),
60
60
  "",
61
- "Use <code>/update jobs</code> to list running and recent agent updates.",
61
+ "Use <code>/update install &lt;agent&gt;</code> for missing CLIs and <code>/update jobs</code> to list running and recent agent updates.",
62
62
  ].join("\n"),
63
63
  buttons,
64
64
  };
@@ -86,13 +86,13 @@ export function renderAgentUpdateJobsAction(jobs) {
86
86
  return {
87
87
  plain: [
88
88
  "Agent update jobs:",
89
- ...limited.map((job) => `${job.id}: ${job.agentLabel} · ${job.status} · ${formatLocalDateTime(new Date(job.updatedAt))}`),
89
+ ...limited.map((job) => `${job.id}: ${job.agentLabel} ${job.operation ?? "update"} · ${job.status} · ${formatLocalDateTime(new Date(job.updatedAt))}`),
90
90
  "",
91
91
  "Use /update log <id>, /update cancel <id>, or /update input <id> <text>.",
92
92
  ].join("\n"),
93
93
  html: [
94
94
  "<b>Agent update jobs:</b>",
95
- ...limited.map((job) => `<code>${escapeHTML(job.id)}</code> ${escapeHTML(job.agentLabel)} · <b>${escapeHTML(job.status)}</b> · <code>${escapeHTML(formatLocalDateTime(new Date(job.updatedAt)))}</code>`),
95
+ ...limited.map((job) => `<code>${escapeHTML(job.id)}</code> ${escapeHTML(job.agentLabel)} ${escapeHTML(job.operation ?? "update")} · <b>${escapeHTML(job.status)}</b> · <code>${escapeHTML(formatLocalDateTime(new Date(job.updatedAt)))}</code>`),
96
96
  "",
97
97
  "Use <code>/update log &lt;id&gt;</code>, <code>/update cancel &lt;id&gt;</code>, or <code>/update input &lt;id&gt; &lt;text&gt;</code>.",
98
98
  ].join("\n"),
@@ -106,7 +106,7 @@ export function renderAgentUpdateJobAction(job) {
106
106
  const tail = trimLine(job.outputTail || "(waiting for output)", 1200);
107
107
  return {
108
108
  plain: [
109
- `${job.agentLabel} update ${job.status}.`,
109
+ `${job.agentLabel} ${job.operation ?? "update"} ${job.status}.`,
110
110
  `ID: ${job.id}`,
111
111
  `Method: ${job.method}`,
112
112
  `Command: ${command}`,
@@ -120,7 +120,7 @@ export function renderAgentUpdateJobAction(job) {
120
120
  tail,
121
121
  ].filter(Boolean).join("\n"),
122
122
  html: [
123
- `<b>${escapeHTML(job.agentLabel)} update ${escapeHTML(job.status)}.</b>`,
123
+ `<b>${escapeHTML(job.agentLabel)} ${escapeHTML(job.operation ?? "update")} ${escapeHTML(job.status)}.</b>`,
124
124
  `<b>ID:</b> <code>${escapeHTML(job.id)}</code>`,
125
125
  `<b>Method:</b> <code>${escapeHTML(job.method)}</code>`,
126
126
  `<b>Command:</b> <code>${escapeHTML(command)}</code>`,
@@ -145,7 +145,7 @@ export function renderAgentUpdateLogAction(result) {
145
145
  const tail = trimLine(result.plain || "(empty)", 3000);
146
146
  return {
147
147
  plain: [
148
- `${result.job.agentLabel} update log`,
148
+ `${result.job.agentLabel} ${result.job.operation ?? "update"} log`,
149
149
  `ID: ${result.job.id}`,
150
150
  `Status: ${result.job.status}`,
151
151
  `File: ${result.job.logPath}`,
@@ -153,7 +153,7 @@ export function renderAgentUpdateLogAction(result) {
153
153
  tail,
154
154
  ].join("\n"),
155
155
  html: [
156
- `<b>${escapeHTML(result.job.agentLabel)} update log</b>`,
156
+ `<b>${escapeHTML(result.job.agentLabel)} ${escapeHTML(result.job.operation ?? "update")} log</b>`,
157
157
  `<b>ID:</b> <code>${escapeHTML(result.job.id)}</code>`,
158
158
  `<b>Status:</b> <code>${escapeHTML(result.job.status)}</code>`,
159
159
  `<b>File:</b> <code>${escapeHTML(result.job.logPath)}</code>`,
package/dist/codex-cli.js CHANGED
@@ -26,7 +26,7 @@ export function findExecutableOnPath(command, pathValue) {
26
26
  return undefined;
27
27
  }
28
28
  const extensions = process.platform === "win32"
29
- ? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")
29
+ ? ["", ...(process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";")]
30
30
  : [""];
31
31
  for (const rawDirectory of pathValue.split(path.delimiter)) {
32
32
  const directory = rawDirectory.trim();
@@ -88,6 +88,7 @@ export const SETTING_DEFINITIONS = [
88
88
  setting("NORDRELAY_AUDIT_MAX_EVENTS", "Audit max events", "Workspace", "number", "Retained audit events.", true),
89
89
  setting("NORDRELAY_SESSION_LOCK_TTL_MS", "Session lock TTL", "Workspace", "number", "Write-lock TTL.", true),
90
90
  setting("NORDRELAY_VERSION_CACHE_TTL_MS", "Version cache TTL", "Workspace", "number", "NPM version cache TTL.", true),
91
+ setting("NORDRELAY_CLI_VERSION_CACHE_TTL_MS", "CLI version cache TTL", "Workspace", "number", "Installed agent CLI version cache TTL.", true),
91
92
  setting("OPENAI_API_KEY", "OpenAI API key", "Voice", "secret", "Whisper fallback API key.", true),
92
93
  setting("VOICE_PREFERRED_BACKEND", "Voice backend", "Voice", "string", "auto, parakeet, faster-whisper, or openai.", false, ["auto", "parakeet", "faster-whisper", "openai"]),
93
94
  setting("VOICE_DEFAULT_LANGUAGE", "Voice language", "Voice", "string", "Default transcription language.", false),
@@ -180,6 +181,7 @@ const EXAMPLE_VALUES = {
180
181
  "NORDRELAY_AUDIT_MAX_EVENTS": "1000",
181
182
  "NORDRELAY_SESSION_LOCK_TTL_MS": "1800000",
182
183
  "NORDRELAY_VERSION_CACHE_TTL_MS": "3600000",
184
+ "NORDRELAY_CLI_VERSION_CACHE_TTL_MS": "60000",
183
185
  "NORDRELAY_DASHBOARD_HOST": "127.0.0.1",
184
186
  "NORDRELAY_DASHBOARD_PORT": "31878",
185
187
  "NORDRELAY_ENV_FILE": "",
@@ -1,9 +1,9 @@
1
- import { spawn, spawnSync } from "node:child_process";
2
- import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { readFile, stat } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
- import { describeCodexCli, resolveCodexCli } from "./codex-cli.js";
6
+ import { describeCodexCli, findExecutableOnPath, resolveCodexCli } from "./codex-cli.js";
7
7
  import { findLatestDatabase } from "./codex-state.js";
8
8
  import { describeClaudeCodeCli, resolveClaudeCodeCli } from "./claude-code-cli.js";
9
9
  import { describeHermesCli, resolveHermesCli } from "./hermes-cli.js";
@@ -21,6 +21,8 @@ const CLAUDE_CODE_SDK_PACKAGE_NAME = "@anthropic-ai/claude-agent-sdk";
21
21
  const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
22
22
  const SECRET_RE = /(bot|token|api[_-]?key|authorization|bearer|password|secret)(["'=: ]+)([^\s"',]+)/gi;
23
23
  const DEFAULT_VERSION_CACHE_TTL_MS = 60 * 60 * 1000;
24
+ const DEFAULT_CLI_VERSION_CACHE_TTL_MS = 60 * 1000;
25
+ const cliVersionCache = new Map();
24
26
  export function getConnectorHome() {
25
27
  return process.env.NORDRELAY_HOME || DEFAULT_HOME;
26
28
  }
@@ -96,97 +98,86 @@ export async function getPackageVersion() {
96
98
  }
97
99
  }
98
100
  export async function getVersionChecks(options = {}) {
99
- const nordrelayVersion = await getPackageVersion();
100
- const codexCli = resolveCodexCli();
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);
105
- const codexVersionLabel = codexCli.path
106
- ? detectCliVersion(codexCli.path)
107
- : readInstalledPackageVersion(CODEX_PACKAGE_NAME) ?? "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;
118
- return {
119
- nordrelay: buildVersionCheck({
101
+ const [nordrelayVersion, cliVersions] = await Promise.all([
102
+ getPackageVersion(),
103
+ resolveAgentCliVersions(options),
104
+ ]);
105
+ const [nordrelay, codex, pi, hermes, openclaw, claudeCode,] = await Promise.all([
106
+ buildVersionCheck({
120
107
  label: "NordRelay",
121
108
  packageName: PACKAGE_NAME,
122
109
  installedLabel: nordrelayVersion,
123
110
  installedVersion: extractVersion(nordrelayVersion),
124
111
  }),
125
- codex: buildVersionCheck({
112
+ buildVersionCheck({
126
113
  label: "Codex",
127
114
  packageName: CODEX_PACKAGE_NAME,
128
- installedLabel: codexVersionLabel,
129
- installedVersion: extractVersion(codexVersionLabel),
130
- notInstalled: codexVersionLabel === "not installed",
115
+ installedLabel: cliVersions.codexVersionLabel,
116
+ installedVersion: extractVersion(cliVersions.codexVersionLabel),
117
+ notInstalled: cliVersions.codexVersionLabel === "not installed",
131
118
  }),
132
- pi: buildVersionCheck({
119
+ buildVersionCheck({
133
120
  label: "Pi",
134
121
  packageName: PI_PACKAGE_NAME,
135
- installedLabel: piVersionLabel,
136
- installedVersion: extractVersion(piVersionLabel),
137
- notInstalled: piVersionLabel === "not installed",
138
- detail: legacyPiPackageVersion ? `Legacy package ${LEGACY_PI_PACKAGE_NAME} is present; current package is ${PI_PACKAGE_NAME}.` : undefined,
122
+ installedLabel: cliVersions.piVersionLabel,
123
+ installedVersion: extractVersion(cliVersions.piVersionLabel),
124
+ notInstalled: cliVersions.piVersionLabel === "not installed",
125
+ detail: cliVersions.legacyPiPackageVersion ? `Legacy package ${LEGACY_PI_PACKAGE_NAME} is present; current package is ${PI_PACKAGE_NAME}.` : undefined,
139
126
  }),
140
- hermes: buildHermesVersionCheck(hermesVersionLabel),
141
- openclaw: buildVersionCheck({
127
+ buildHermesVersionCheck(cliVersions.hermesVersionLabel),
128
+ buildVersionCheck({
142
129
  label: "OpenClaw",
143
130
  packageName: OPENCLAW_PACKAGE_NAME,
144
- installedLabel: openClawVersionLabel,
145
- installedVersion: extractVersion(openClawVersionLabel),
146
- notInstalled: openClawVersionLabel === "not installed",
131
+ installedLabel: cliVersions.openClawVersionLabel,
132
+ installedVersion: extractVersion(cliVersions.openClawVersionLabel),
133
+ notInstalled: cliVersions.openClawVersionLabel === "not installed",
147
134
  }),
148
- claudeCode: buildVersionCheck({
135
+ buildVersionCheck({
149
136
  label: "Claude Code",
150
- packageName: claudeCodePackageName,
151
- installedLabel: claudeCodeVersionLabel,
152
- installedVersion: extractVersion(claudeCodeVersionLabel),
153
- notInstalled: claudeCodeVersionLabel === "not installed",
137
+ packageName: cliVersions.claudeCodePackageName,
138
+ installedLabel: cliVersions.claudeCodeVersionLabel,
139
+ installedVersion: extractVersion(cliVersions.claudeCodeVersionLabel),
140
+ notInstalled: cliVersions.claudeCodeVersionLabel === "not installed",
154
141
  }),
142
+ ]);
143
+ return {
144
+ nordrelay,
145
+ codex,
146
+ pi,
147
+ hermes,
148
+ openclaw,
149
+ claudeCode,
155
150
  };
156
151
  }
157
152
  export async function getConnectorHealth(options = {}) {
158
- const rawState = await readConnectorState();
159
- const version = await getPackageVersion();
153
+ const [rawState, version, cliVersions] = await Promise.all([
154
+ readConnectorState(),
155
+ getPackageVersion(),
156
+ resolveAgentCliVersions(options),
157
+ ]);
160
158
  const pidRunning = isProcessRunning(rawState.pid);
161
159
  const appPidRunning = isProcessRunning(rawState.appPid);
162
160
  const state = normalizeConnectorState(rawState, pidRunning, appPidRunning);
163
- const codexCli = resolveCodexCli();
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);
168
161
  return {
169
162
  version,
170
163
  state,
171
164
  pidRunning,
172
165
  appPidRunning,
173
- codexCli: describeCodexCli(codexCli),
174
- codexCliPath: codexCli.path ?? null,
175
- codexCliVersion: detectCliVersion(codexCli.path),
176
- piCli: describePiCli(piCli),
177
- piCliPath: piCli.path ?? null,
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",
166
+ codexCli: describeCodexCli(cliVersions.codexCli),
167
+ codexCliPath: cliVersions.codexCli.path ?? null,
168
+ codexCliVersion: cliVersions.codexVersionLabel,
169
+ piCli: describePiCli(cliVersions.piCli),
170
+ piCliPath: cliVersions.piCli.path ?? null,
171
+ piCliVersion: cliVersions.piVersionLabel,
172
+ hermesCli: describeHermesCli(cliVersions.hermesCli),
173
+ hermesCliPath: cliVersions.hermesCli.path ?? null,
174
+ hermesCliVersion: cliVersions.hermesVersionLabel,
175
+ openClawCli: describeOpenClawCli(cliVersions.openClawCli),
176
+ openClawCliPath: cliVersions.openClawCli.path ?? null,
177
+ openClawCliVersion: cliVersions.openClawVersionLabel,
178
+ claudeCodeCli: describeClaudeCodeCli(cliVersions.claudeCodeCli),
179
+ claudeCodeCliPath: cliVersions.claudeCodeCli.path ?? null,
180
+ claudeCodeCliVersion: cliVersions.claudeCodeVersionLabel,
190
181
  stateFile: getConnectorStatePath(),
191
182
  logFile: getConnectorLogPath(),
192
183
  databasePath: findLatestDatabase(),
@@ -208,23 +199,22 @@ export function spawnSelfUpdate() {
208
199
  const script = getWrapperScriptPath();
209
200
  const updateLog = getUpdateLogPath();
210
201
  const method = detectSelfUpdateMethod(sourceRoot);
211
- const commands = method === "npm"
212
- ? buildNpmSelfUpdateCommands()
213
- : buildGitSelfUpdateCommands(script);
214
- const logFd = openSync(updateLog, "a");
215
- const command = [
216
- "set -e",
217
- `printf '\\n[%s] Starting ${method} connector self-update\\n' "$(date -Is)"`,
218
- ...commands,
219
- ].join(" && ");
220
- const child = spawn("sh", ["-lc", command], {
202
+ mkdirSync(path.dirname(updateLog), { recursive: true });
203
+ const child = spawn(process.execPath, [
204
+ script,
205
+ "update",
206
+ "--method",
207
+ method,
208
+ "--home",
209
+ getConnectorHome(),
210
+ "--keep-pending-updates",
211
+ ], {
221
212
  cwd: sourceRoot,
222
213
  detached: true,
223
214
  env: process.env,
224
- stdio: ["ignore", logFd, logFd],
215
+ stdio: "ignore",
225
216
  });
226
217
  child.unref();
227
- closeSync(logFd);
228
218
  return {
229
219
  logPath: updateLog,
230
220
  method,
@@ -274,40 +264,62 @@ function normalizeConnectorState(state, pidRunning, appPidRunning) {
274
264
  function redactSecrets(text) {
275
265
  return text.replace(SECRET_RE, "$1$2[redacted]");
276
266
  }
277
- function buildGitSelfUpdateCommands(script) {
278
- return [
279
- "git pull --ff-only origin main",
280
- "npm install",
281
- "npm run check",
282
- "npm test",
283
- "npm run build",
284
- `printf '[%s] Checks passed; restarting connector\\n' "$(date -Is)"`,
285
- `${shellQuote(process.execPath)} ${shellQuote(script)} restart --keep-pending-updates`,
286
- ];
287
- }
288
- function buildNpmSelfUpdateCommands() {
289
- return [
290
- `${resolveNpmCommand()} install -g ${PACKAGE_NAME}@latest`,
291
- "nordrelay version",
292
- `printf '[%s] npm update finished; restarting connector\\n' "$(date -Is)"`,
293
- "nordrelay restart --keep-pending-updates",
294
- ];
295
- }
296
- function resolveNpmCommand() {
297
- const npmExecPath = process.env.npm_execpath;
298
- if (npmExecPath && existsSync(npmExecPath)) {
299
- return `${shellQuote(process.execPath)} ${shellQuote(npmExecPath)}`;
300
- }
301
- return "npm";
267
+ async function resolveAgentCliVersions(options = {}) {
268
+ const codexCli = resolveCodexCli();
269
+ const piCli = resolvePiCli(process.env, options.piCliPath);
270
+ const hermesCli = resolveHermesCli(process.env, options.hermesCliPath);
271
+ const openClawCli = resolveOpenClawCli(process.env, options.openClawCliPath);
272
+ const claudeCodeCli = resolveClaudeCodeCli(process.env, options.claudeCodeCliPath);
273
+ const legacyPiPackageVersion = readInstalledPackageVersion(LEGACY_PI_PACKAGE_NAME);
274
+ const [codexVersionLabel, piVersionLabel, hermesVersionLabel, openClawVersionLabel, claudeCodeVersionLabel,] = await Promise.all([
275
+ codexCli.path ? detectCliVersion(codexCli.path) : Promise.resolve(readInstalledPackageVersion(CODEX_PACKAGE_NAME) ?? "not installed"),
276
+ piCli.path ? detectCliVersion(piCli.path) : Promise.resolve(readInstalledPackageVersion(PI_PACKAGE_NAME) ?? legacyPiPackageVersion ?? "not installed"),
277
+ hermesCli.path ? detectCliVersion(hermesCli.path) : Promise.resolve("not installed"),
278
+ openClawCli.path ? detectCliVersion(openClawCli.path) : Promise.resolve("not installed"),
279
+ claudeCodeCli.path ? detectCliVersion(claudeCodeCli.path) : Promise.resolve(readInstalledPackageVersion(CLAUDE_CODE_SDK_PACKAGE_NAME) ?? "bundled"),
280
+ ]);
281
+ return {
282
+ codexCli,
283
+ piCli,
284
+ hermesCli,
285
+ openClawCli,
286
+ claudeCodeCli,
287
+ codexVersionLabel,
288
+ piVersionLabel,
289
+ hermesVersionLabel,
290
+ openClawVersionLabel,
291
+ claudeCodeVersionLabel,
292
+ claudeCodePackageName: claudeCodeCli.path ? CLAUDE_CODE_PACKAGE_NAME : CLAUDE_CODE_SDK_PACKAGE_NAME,
293
+ legacyPiPackageVersion,
294
+ };
302
295
  }
303
- function detectCliVersion(commandPath) {
296
+ async function detectCliVersion(commandPath) {
304
297
  if (!commandPath) {
305
298
  return "not installed";
306
299
  }
307
- const result = spawnSync(commandPath, ["--version"], {
308
- encoding: "utf8",
300
+ const ttlMs = parseCliVersionCacheTtlMs();
301
+ if (ttlMs > 0) {
302
+ const cached = cliVersionCache.get(commandPath);
303
+ if (cached && Date.now() < cached.expiresAt) {
304
+ if (cached.value !== undefined) {
305
+ return cached.value;
306
+ }
307
+ if (cached.promise) {
308
+ return cached.promise;
309
+ }
310
+ }
311
+ const promise = detectCliVersionUncached(commandPath);
312
+ cliVersionCache.set(commandPath, { promise, expiresAt: Date.now() + ttlMs });
313
+ const value = await promise;
314
+ cliVersionCache.set(commandPath, { value, expiresAt: Date.now() + ttlMs });
315
+ return value;
316
+ }
317
+ return detectCliVersionUncached(commandPath);
318
+ }
319
+ async function detectCliVersionUncached(commandPath) {
320
+ const result = await runCommand(commandPath, ["--version"], {
321
+ shell: isWindowsShellScript(commandPath),
309
322
  timeout: 3000,
310
- windowsHide: true,
311
323
  });
312
324
  const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
313
325
  if (result.error) {
@@ -318,15 +330,17 @@ function detectCliVersion(commandPath) {
318
330
  }
319
331
  return output || "unknown";
320
332
  }
321
- function buildHermesVersionCheck(installedLabel) {
333
+ async function buildHermesVersionCheck(installedLabel) {
322
334
  if (installedLabel === "not installed") {
335
+ const latest = await detectLatestNpmVersion(HERMES_PACKAGE_NAME);
323
336
  return {
324
337
  label: "Hermes",
325
338
  packageName: HERMES_PACKAGE_NAME,
326
339
  installedLabel: "not installed",
327
340
  installedVersion: null,
328
- latestVersion: null,
341
+ latestVersion: latest.version,
329
342
  status: "not-installed",
343
+ detail: latest.error,
330
344
  };
331
345
  }
332
346
  const lines = installedLabel.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
@@ -343,16 +357,17 @@ function buildHermesVersionCheck(installedLabel) {
343
357
  detail: updateLine ?? (installedVersion ? undefined : "Could not parse Hermes version or update status"),
344
358
  };
345
359
  }
346
- function buildVersionCheck(options) {
360
+ async function buildVersionCheck(options) {
347
361
  if (options.notInstalled) {
362
+ const latest = options.skipLatest ? { version: null, error: undefined } : await detectLatestNpmVersion(options.packageName);
348
363
  return {
349
364
  label: options.label,
350
365
  packageName: options.packageName,
351
366
  installedLabel: "not installed",
352
367
  installedVersion: null,
353
- latestVersion: null,
368
+ latestVersion: latest.version,
354
369
  status: "not-installed",
355
- detail: options.detail,
370
+ detail: [options.detail, latest.error].filter(Boolean).join(" ") || undefined,
356
371
  };
357
372
  }
358
373
  if (options.skipLatest) {
@@ -366,7 +381,7 @@ function buildVersionCheck(options) {
366
381
  detail: options.detail ?? "Latest-version lookup is not available for this package source",
367
382
  };
368
383
  }
369
- const latest = detectLatestNpmVersion(options.packageName);
384
+ const latest = await detectLatestNpmVersion(options.packageName);
370
385
  if (!options.installedVersion || !latest.version) {
371
386
  return {
372
387
  label: options.label,
@@ -388,19 +403,22 @@ function buildVersionCheck(options) {
388
403
  detail: [options.detail, latest.error].filter(Boolean).join(" ") || undefined,
389
404
  };
390
405
  }
391
- function detectLatestNpmVersion(packageName) {
406
+ async function detectLatestNpmVersion(packageName) {
392
407
  const cached = readVersionCache(packageName);
393
408
  if (cached) {
394
409
  return cached;
395
410
  }
396
- const result = spawnSync("npm", ["view", packageName, "version", "--registry=https://registry.npmjs.org"], {
397
- encoding: "utf8",
411
+ const npm = resolveNpmSpawnCommand();
412
+ if (!npm) {
413
+ return { version: null, error: "npm was not found on PATH; latest-version lookup is unavailable" };
414
+ }
415
+ const result = await runCommand(npm.command, [...npm.argsPrefix, "view", packageName, "version", "--registry=https://registry.npmjs.org"], {
416
+ shell: npm.shell,
398
417
  timeout: 5000,
399
- windowsHide: true,
400
418
  });
401
419
  const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
402
420
  if (result.error) {
403
- return { version: null, error: result.error.message };
421
+ return { version: null, error: `${npm.display}: ${result.error.message}` };
404
422
  }
405
423
  if (result.status !== 0) {
406
424
  return { version: null, error: output || `npm exited ${result.status ?? "unknown"}` };
@@ -409,6 +427,94 @@ function detectLatestNpmVersion(packageName) {
409
427
  writeVersionCache(packageName, resolved.version);
410
428
  return resolved;
411
429
  }
430
+ function runCommand(command, args, options) {
431
+ return new Promise((resolve) => {
432
+ const useShell = Boolean(options.shell);
433
+ execFile(useShell ? formatShellCommand(command, args) : command, useShell ? [] : args, {
434
+ encoding: "utf8",
435
+ shell: useShell,
436
+ timeout: options.timeout,
437
+ windowsHide: true,
438
+ env: process.env,
439
+ maxBuffer: 1024 * 1024,
440
+ }, (error, stdout, stderr) => {
441
+ const enriched = error;
442
+ resolve({
443
+ stdout: typeof stdout === "string" ? stdout : "",
444
+ stderr: typeof stderr === "string" ? stderr : "",
445
+ status: typeof enriched?.code === "number" ? enriched.code : error ? 1 : 0,
446
+ signal: enriched?.signal,
447
+ error: enriched ?? undefined,
448
+ });
449
+ });
450
+ });
451
+ }
452
+ function formatShellCommand(command, args) {
453
+ return [command, ...args].map(quoteShellArg).join(" ");
454
+ }
455
+ function quoteShellArg(value) {
456
+ if (process.platform === "win32") {
457
+ return quoteWindowsCmdArg(value);
458
+ }
459
+ return `'${value.replace(/'/g, `'\\''`)}'`;
460
+ }
461
+ function quoteWindowsCmdArg(value) {
462
+ if (value.length === 0) {
463
+ return "\"\"";
464
+ }
465
+ if (!/[\s"&|<>()^%]/.test(value)) {
466
+ return value;
467
+ }
468
+ return `"${value
469
+ .replace(/%/g, "%%")
470
+ .replace(/(\\*)"/g, '$1$1\\"')
471
+ .replace(/(\\+)$/g, "$1$1")}"`;
472
+ }
473
+ export function resolveNpmSpawnCommand(env = process.env) {
474
+ const npmExecPath = env.npm_execpath?.trim();
475
+ if (npmExecPath && existsSync(npmExecPath)) {
476
+ return {
477
+ command: process.execPath,
478
+ argsPrefix: [npmExecPath],
479
+ display: `${process.execPath} ${npmExecPath}`,
480
+ shell: false,
481
+ };
482
+ }
483
+ const pathMatch = findExecutableOnPath("npm", env.PATH);
484
+ if (pathMatch) {
485
+ return {
486
+ command: pathMatch,
487
+ argsPrefix: [],
488
+ display: pathMatch,
489
+ shell: isWindowsShellScript(pathMatch),
490
+ };
491
+ }
492
+ for (const candidate of commonNpmCandidates(env)) {
493
+ if (!existsSync(candidate)) {
494
+ continue;
495
+ }
496
+ return {
497
+ command: candidate,
498
+ argsPrefix: [],
499
+ display: candidate,
500
+ shell: isWindowsShellScript(candidate),
501
+ };
502
+ }
503
+ return null;
504
+ }
505
+ function commonNpmCandidates(env) {
506
+ const names = process.platform === "win32" ? ["npm.cmd", "npm.bat", "npm"] : ["npm"];
507
+ const directories = [
508
+ path.dirname(process.execPath),
509
+ env.APPDATA ? path.join(env.APPDATA, "npm") : undefined,
510
+ env.ProgramFiles ? path.join(env.ProgramFiles, "nodejs") : undefined,
511
+ env["ProgramFiles(x86)"] ? path.join(env["ProgramFiles(x86)"], "nodejs") : undefined,
512
+ ].filter((value) => Boolean(value));
513
+ return directories.flatMap((directory) => names.map((name) => path.join(directory, name)));
514
+ }
515
+ function isWindowsShellScript(filePath) {
516
+ return process.platform === "win32" && /\.(?:cmd|bat)$/i.test(filePath);
517
+ }
412
518
  function readVersionCache(packageName) {
413
519
  const ttlMs = parseVersionCacheTtlMs();
414
520
  if (ttlMs <= 0) {
@@ -458,6 +564,14 @@ function parseVersionCacheTtlMs() {
458
564
  const parsed = Number(raw);
459
565
  return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : DEFAULT_VERSION_CACHE_TTL_MS;
460
566
  }
567
+ function parseCliVersionCacheTtlMs() {
568
+ const raw = process.env.NORDRELAY_CLI_VERSION_CACHE_TTL_MS;
569
+ if (!raw) {
570
+ return DEFAULT_CLI_VERSION_CACHE_TTL_MS;
571
+ }
572
+ const parsed = Number(raw);
573
+ return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : DEFAULT_CLI_VERSION_CACHE_TTL_MS;
574
+ }
461
575
  function readInstalledPackageVersion(packageName) {
462
576
  try {
463
577
  const packagePath = path.join(getSourceRoot(), "node_modules", ...packageName.split("/"), "package.json");
@@ -489,9 +603,6 @@ function compareVersions(left, right) {
489
603
  function parseVersionParts(value) {
490
604
  return value.split(/[.-]/).slice(0, 3).map((part) => Number.parseInt(part, 10) || 0);
491
605
  }
492
- function shellQuote(value) {
493
- return `'${value.replace(/'/g, `'\\''`)}'`;
494
- }
495
606
  function formatLogLine(line) {
496
607
  const trimmed = line.trim();
497
608
  if (!trimmed) {