@nordbyte/nordrelay 0.4.0 → 0.5.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.
@@ -0,0 +1,42 @@
1
+ import { escapeHTML } from "./format.js";
2
+ export const AGENT_FEATURES = [
3
+ { key: "modelSelection", label: "Model", description: "Pick the model used for new turns or sessions." },
4
+ { key: "reasoningSelection", label: "Reasoning", description: "Pick thinking/reasoning effort where the agent exposes it." },
5
+ { key: "launchProfiles", label: "Launch profiles", description: "Switch sandbox/approval launch behavior for new sessions." },
6
+ { key: "fastMode", label: "Fast mode", description: "Toggle the agent-specific low-latency mode." },
7
+ { key: "workspaces", label: "Workspaces", description: "List and switch allowed workspaces." },
8
+ { key: "attachments", label: "Files/images", description: "Send files, photos, staged attachments, and voice transcripts." },
9
+ { key: "externalActivity", label: "External busy", description: "Detect active CLI turns started outside NordRelay." },
10
+ { key: "cliMirror", label: "CLI mirror", description: "Mirror CLI-started turns back to Telegram/WebUI." },
11
+ { key: "activityLog", label: "Activity", description: "Read activity timelines for sessions and turns." },
12
+ { key: "usageStats", label: "Usage stats", description: "Show token/context usage reported by the agent." },
13
+ { key: "subscriptionLimits", label: "Limits", description: "Show subscription/quota limits when the agent exposes them." },
14
+ { key: "auth", label: "Auth status", description: "Check whether the agent is authenticated." },
15
+ { key: "login", label: "Login", description: "Start an agent login flow from NordRelay." },
16
+ { key: "logout", label: "Logout", description: "Sign out of the agent from NordRelay." },
17
+ { key: "handback", label: "Handback", description: "Return a remote session to the native CLI." },
18
+ ];
19
+ export function agentFeatureStates(capabilities) {
20
+ return AGENT_FEATURES.map((feature) => ({
21
+ ...feature,
22
+ supported: Boolean(capabilities[feature.key]),
23
+ }));
24
+ }
25
+ export function formatAgentFeatureSummaryPlain(capabilities) {
26
+ const states = agentFeatureStates(capabilities);
27
+ const supported = states.filter((feature) => feature.supported).map((feature) => feature.label);
28
+ const unsupported = states.filter((feature) => !feature.supported).map((feature) => feature.label);
29
+ return [
30
+ `Supported: ${supported.join(", ") || "-"}`,
31
+ `Not supported: ${unsupported.join(", ") || "-"}`,
32
+ ];
33
+ }
34
+ export function formatAgentFeatureSummaryHTML(capabilities) {
35
+ const states = agentFeatureStates(capabilities);
36
+ const supported = states.filter((feature) => feature.supported).map((feature) => feature.label);
37
+ const unsupported = states.filter((feature) => !feature.supported).map((feature) => feature.label);
38
+ return [
39
+ `<b>Supported:</b> ${escapeHTML(supported.join(", ") || "-")}`,
40
+ `<b>Not supported:</b> ${escapeHTML(unsupported.join(", ") || "-")}`,
41
+ ];
42
+ }
@@ -0,0 +1,312 @@
1
+ import { spawn } from "node:child_process";
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { agentLabel } from "./agent.js";
6
+ import { resolveClaudeCodeCli } from "./claude-code-cli.js";
7
+ import { resolveCodexCli } from "./codex-cli.js";
8
+ import { resolveHermesCli } from "./hermes-cli.js";
9
+ import { resolveOpenClawCli } from "./openclaw-cli.js";
10
+ import { getAgentUpdateLogPath, getConnectorHome } from "./operations.js";
11
+ import { resolvePiCli } from "./pi-cli.js";
12
+ import { redactText } from "./redaction.js";
13
+ export class AgentUpdateManager {
14
+ options;
15
+ jobs = new Map();
16
+ home;
17
+ manifestPath;
18
+ aggregateLogPath;
19
+ constructor(options = {}) {
20
+ this.options = options;
21
+ this.home = options.home ?? getConnectorHome();
22
+ this.manifestPath = path.join(this.home, "updates", "jobs.json");
23
+ this.aggregateLogPath = getAgentUpdateLogPath(this.home);
24
+ this.loadPersistedJobs();
25
+ }
26
+ list() {
27
+ return [...this.jobs.values()]
28
+ .sort((left, right) => right.startedAt.localeCompare(left.startedAt))
29
+ .map((job) => this.snapshot(job));
30
+ }
31
+ get(id) {
32
+ const job = this.jobs.get(id);
33
+ return job ? this.snapshot(job) : null;
34
+ }
35
+ readLog(id) {
36
+ const job = this.requireJob(id);
37
+ if (job.logDeletedAt) {
38
+ return { job: this.snapshot(job), plain: `Update log was deleted at ${job.logDeletedAt}.` };
39
+ }
40
+ try {
41
+ return { job: this.snapshot(job), plain: redactText(readFileSync(job.logPath, "utf8")) };
42
+ }
43
+ catch (error) {
44
+ return {
45
+ job: this.snapshot(job),
46
+ plain: `Cannot read update log: ${error instanceof Error ? error.message : String(error)}`,
47
+ };
48
+ }
49
+ }
50
+ deleteLog(id) {
51
+ const job = this.requireJob(id);
52
+ if (job.status === "running") {
53
+ throw new Error("Cannot delete the update log while the update job is still running.");
54
+ }
55
+ const snapshot = this.snapshot(job);
56
+ rmSync(job.logPath, { force: true });
57
+ this.jobs.delete(id);
58
+ this.persistJobs();
59
+ return snapshot;
60
+ }
61
+ start(agentId, context = {}) {
62
+ const running = [...this.jobs.values()].find((job) => job.agentId === agentId && job.status === "running");
63
+ if (running) {
64
+ throw new Error(`${agentLabel(agentId)} update is already running.`);
65
+ }
66
+ const plan = resolveAgentUpdatePlan(agentId, { ...context, env: context.env ?? this.options.env });
67
+ const now = new Date().toISOString();
68
+ const id = `${agentId.replace(/[^a-z0-9]/gi, "")}-${Date.now().toString(36)}`;
69
+ const logPath = path.join(this.home, "updates", `${id}.log`);
70
+ mkdirSync(path.dirname(logPath), { recursive: true });
71
+ const job = {
72
+ id,
73
+ agentId,
74
+ agentLabel: plan.agentLabel,
75
+ status: "running",
76
+ method: plan.method,
77
+ command: plan.command,
78
+ args: plan.args,
79
+ cwd: plan.cwd,
80
+ summary: plan.summary,
81
+ interactive: plan.interactive,
82
+ canInput: true,
83
+ needsInput: false,
84
+ startedAt: now,
85
+ updatedAt: now,
86
+ logPath,
87
+ ownerPid: process.pid,
88
+ output: "",
89
+ outputTail: "",
90
+ };
91
+ this.jobs.set(id, job);
92
+ this.persistJobs();
93
+ this.append(job, [
94
+ `[${now}] Starting ${job.agentLabel} update`,
95
+ `Method: ${job.method}`,
96
+ `Command: ${[job.command, ...job.args].join(" ")}`,
97
+ `Working directory: ${job.cwd}`,
98
+ "",
99
+ ].join("\n"));
100
+ const child = spawn(plan.command, plan.args, {
101
+ cwd: plan.cwd,
102
+ env: { ...process.env, ...(this.options.env ?? {}), ...(context.env ?? {}) },
103
+ shell: process.platform === "win32",
104
+ windowsHide: true,
105
+ stdio: "pipe",
106
+ });
107
+ job.child = child;
108
+ child.stdin.setDefaultEncoding("utf8");
109
+ child.stdout.on("data", (chunk) => this.append(job, chunk.toString("utf8")));
110
+ child.stderr.on("data", (chunk) => this.append(job, chunk.toString("utf8")));
111
+ child.on("error", (error) => {
112
+ this.finish(job, "failed", null, null, error.message);
113
+ });
114
+ child.on("close", (code, signal) => {
115
+ if (job.status !== "running") {
116
+ return;
117
+ }
118
+ this.finish(job, code === 0 ? "completed" : "failed", code, signal, code === 0 ? undefined : `Update exited with code ${code ?? "unknown"}`);
119
+ });
120
+ this.emit(job);
121
+ return this.snapshot(job);
122
+ }
123
+ sendInput(id, input) {
124
+ const job = this.requireJob(id);
125
+ if (job.status !== "running" || !job.child?.stdin.writable) {
126
+ throw new Error("Update job is not accepting input.");
127
+ }
128
+ const line = input.endsWith("\n") ? input : `${input}\n`;
129
+ job.child.stdin.write(line);
130
+ job.needsInput = false;
131
+ this.append(job, `[${new Date().toISOString()}] Input sent from dashboard.\n`);
132
+ return this.snapshot(job);
133
+ }
134
+ cancel(id) {
135
+ const job = this.requireJob(id);
136
+ if (job.status !== "running") {
137
+ return this.snapshot(job);
138
+ }
139
+ job.child?.kill("SIGTERM");
140
+ this.finish(job, "cancelled", null, "SIGTERM", "Cancelled from dashboard.");
141
+ return this.snapshot(job);
142
+ }
143
+ cancelAll() {
144
+ for (const job of this.jobs.values()) {
145
+ if (job.status === "running") {
146
+ job.child?.kill("SIGTERM");
147
+ }
148
+ }
149
+ }
150
+ requireJob(id) {
151
+ const job = this.jobs.get(id);
152
+ if (!job) {
153
+ throw new Error(`Unknown update job: ${id}`);
154
+ }
155
+ return job;
156
+ }
157
+ append(job, text) {
158
+ const redacted = redactText(text);
159
+ job.output += redacted;
160
+ if (job.output.length > 120_000) {
161
+ job.output = job.output.slice(-120_000);
162
+ }
163
+ job.outputTail = job.output.slice(-16_000);
164
+ job.updatedAt = new Date().toISOString();
165
+ job.needsInput = job.status === "running" && looksLikePrompt(job.outputTail);
166
+ writeFileSync(job.logPath, redacted, { flag: "a", encoding: "utf8" });
167
+ this.appendAggregate(job, redacted);
168
+ this.persistJobs();
169
+ this.emit(job);
170
+ }
171
+ finish(job, status, code, signal, error) {
172
+ job.status = status;
173
+ job.canInput = false;
174
+ job.needsInput = false;
175
+ job.exitCode = code;
176
+ job.signal = signal;
177
+ job.error = error ? redactText(error) : undefined;
178
+ job.finishedAt = new Date().toISOString();
179
+ job.updatedAt = job.finishedAt;
180
+ job.child = undefined;
181
+ this.append(job, `\n[${job.finishedAt}] ${job.agentLabel} update ${status}${error ? `: ${job.error}` : ""}\n`);
182
+ }
183
+ emit(job) {
184
+ this.options.onUpdate?.(this.snapshot(job));
185
+ }
186
+ snapshot(job) {
187
+ const { child: _child, output: _output, ...snapshot } = job;
188
+ return { ...snapshot };
189
+ }
190
+ loadPersistedJobs() {
191
+ if (!existsSync(this.manifestPath)) {
192
+ return;
193
+ }
194
+ try {
195
+ const parsed = JSON.parse(readFileSync(this.manifestPath, "utf8"));
196
+ let changed = false;
197
+ for (const snapshot of parsed) {
198
+ if (snapshot.logDeletedAt) {
199
+ changed = true;
200
+ continue;
201
+ }
202
+ const staleRunning = snapshot.status === "running" && !isProcessRunning(snapshot.ownerPid);
203
+ if (staleRunning) {
204
+ changed = true;
205
+ }
206
+ const job = {
207
+ ...snapshot,
208
+ status: staleRunning ? "failed" : snapshot.status,
209
+ canInput: false,
210
+ needsInput: false,
211
+ error: staleRunning
212
+ ? "Update process was still running when NordRelay restarted; inspect the agent update log before retrying."
213
+ : snapshot.error,
214
+ finishedAt: staleRunning ? new Date().toISOString() : snapshot.finishedAt,
215
+ updatedAt: staleRunning ? new Date().toISOString() : snapshot.updatedAt,
216
+ output: snapshot.outputTail ?? "",
217
+ outputTail: snapshot.outputTail ?? "",
218
+ };
219
+ this.jobs.set(job.id, job);
220
+ }
221
+ if (changed) {
222
+ this.persistJobs();
223
+ }
224
+ }
225
+ catch {
226
+ this.jobs.clear();
227
+ }
228
+ }
229
+ persistJobs() {
230
+ mkdirSync(path.dirname(this.manifestPath), { recursive: true });
231
+ const snapshots = this.list().slice(0, 100);
232
+ writeFileSync(this.manifestPath, `${JSON.stringify(snapshots, null, 2)}\n`, "utf8");
233
+ }
234
+ appendAggregate(job, text) {
235
+ const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
236
+ if (lines.length === 0) {
237
+ return;
238
+ }
239
+ mkdirSync(path.dirname(this.aggregateLogPath), { recursive: true });
240
+ const now = new Date().toISOString();
241
+ const prefix = `[${now}] INFO [${job.id}]`;
242
+ appendFileSync(this.aggregateLogPath, `${lines.map((line) => `${prefix} ${line}`).join("\n")}\n`, "utf8");
243
+ }
244
+ }
245
+ function isProcessRunning(pid) {
246
+ if (!Number.isInteger(pid) || pid <= 0) {
247
+ return false;
248
+ }
249
+ try {
250
+ process.kill(pid, 0);
251
+ return true;
252
+ }
253
+ catch {
254
+ return false;
255
+ }
256
+ }
257
+ export function resolveAgentUpdatePlan(agentId, context = {}) {
258
+ const env = context.env ?? process.env;
259
+ switch (agentId) {
260
+ case "codex": {
261
+ const cli = resolveCodexCli(env);
262
+ if (!cli.path) {
263
+ throw new Error("Codex CLI is not installed on PATH. Install or update it with npm i -g @openai/codex@latest.");
264
+ }
265
+ return plan(agentId, "codex update", cli.path, ["update"], "Runs the Codex CLI self-updater. If this install cannot self-update, use npm i -g @openai/codex@latest.", env);
266
+ }
267
+ case "pi": {
268
+ const cli = resolvePiCli(env, context.piCliPath);
269
+ if (!cli.path) {
270
+ throw new Error("Pi CLI is not installed on PATH. Install or update it with npm install -g @earendil-works/pi-coding-agent@latest.");
271
+ }
272
+ return plan(agentId, "pi update pi", cli.path, ["update", "pi"], "Updates only the Pi coding agent, not installed Pi extensions.", env);
273
+ }
274
+ case "hermes": {
275
+ const cli = resolveHermesCli(env, context.hermesCliPath);
276
+ if (!cli.path) {
277
+ throw new Error("Hermes CLI is not installed on PATH. Install Hermes with the official installer before updating.");
278
+ }
279
+ return plan(agentId, "hermes update --yes", cli.path, ["update", "--yes"], "Runs the Hermes git/dependency updater with confirmation prompts accepted where supported.", env);
280
+ }
281
+ case "openclaw": {
282
+ const cli = resolveOpenClawCli(env, context.openClawCliPath);
283
+ if (!cli.path) {
284
+ throw new Error("OpenClaw CLI is not installed on PATH. Install OpenClaw before updating.");
285
+ }
286
+ return plan(agentId, "openclaw update --yes", cli.path, ["update", "--yes"], "Runs the OpenClaw updater, which detects npm/git installs and may restart the Gateway.", env);
287
+ }
288
+ case "claude-code": {
289
+ const cli = resolveClaudeCodeCli(env, context.claudeCodeCliPath);
290
+ if (!cli.path) {
291
+ throw new Error("Claude Code host CLI is not installed on PATH. Bundled SDK updates arrive with NordRelay releases.");
292
+ }
293
+ return plan(agentId, "claude update", cli.path, ["update"], "Runs the Claude Code updater. Some package-manager installs may print a manual command instead.", env);
294
+ }
295
+ }
296
+ }
297
+ function plan(agentId, method, command, args, summary, env) {
298
+ return {
299
+ agentId,
300
+ agentLabel: agentLabel(agentId),
301
+ method,
302
+ command,
303
+ args,
304
+ cwd: env.HOME || os.homedir(),
305
+ summary,
306
+ interactive: true,
307
+ };
308
+ }
309
+ function looksLikePrompt(text) {
310
+ const tail = text.split(/\r?\n/).slice(-4).join("\n");
311
+ return /\b(y\/n|yes\/no|continue|proceed|confirm|password|passphrase|token|api key|enter|select)\b|[?>]\s*$/i.test(tail);
312
+ }