@nordbyte/nordrelay 0.6.0 → 0.7.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.
Files changed (42) hide show
  1. package/.env.example +17 -0
  2. package/README.md +67 -6
  3. package/dist/access-control.js +6 -1
  4. package/dist/activity-events.js +2 -2
  5. package/dist/bot-preferences.js +1 -0
  6. package/dist/bot.js +77 -6
  7. package/dist/channel-adapter.js +11 -5
  8. package/dist/channel-command-catalog.js +88 -0
  9. package/dist/channel-command-service.js +214 -1
  10. package/dist/channel-mirror-registry.js +77 -0
  11. package/dist/channel-peer-prompt.js +95 -0
  12. package/dist/channel-runtime.js +12 -5
  13. package/dist/codex-state.js +114 -78
  14. package/dist/config-metadata.js +15 -0
  15. package/dist/config.js +31 -6
  16. package/dist/context-key.js +10 -0
  17. package/dist/discord-bot.js +85 -26
  18. package/dist/discord-command-surface.js +11 -73
  19. package/dist/index.js +20 -0
  20. package/dist/metrics.js +46 -0
  21. package/dist/peer-auth.js +85 -0
  22. package/dist/peer-client.js +256 -0
  23. package/dist/peer-context.js +21 -0
  24. package/dist/peer-identity.js +127 -0
  25. package/dist/peer-runtime-service.js +636 -0
  26. package/dist/peer-server.js +220 -0
  27. package/dist/peer-store.js +294 -0
  28. package/dist/peer-types.js +52 -0
  29. package/dist/relay-runtime-helpers.js +208 -0
  30. package/dist/relay-runtime.js +72 -274
  31. package/dist/remote-prompt.js +98 -0
  32. package/dist/telegram-command-menu.js +3 -53
  33. package/dist/telegram-general-commands.js +14 -0
  34. package/dist/telegram-preference-commands.js +23 -127
  35. package/dist/web-api-contract.js +8 -0
  36. package/dist/web-dashboard-pages.js +12 -0
  37. package/dist/web-dashboard-peer-routes.js +204 -0
  38. package/dist/web-dashboard-ui.js +1 -0
  39. package/dist/web-dashboard.js +12 -0
  40. package/dist/webui-assets/dashboard.js +427 -14
  41. package/package.json +3 -2
  42. package/plugins/nordrelay/scripts/nordrelay.mjs +373 -7
@@ -1,6 +1,8 @@
1
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
1
+ import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, statSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { isCodexApprovalPolicy, isCodexSandboxMode, } from "./codex-launch.js";
4
+ const ROLLOUT_CACHE_MAX_EVENTS = 200;
5
+ const rolloutSnapshotCache = new Map();
4
6
  export const FALLBACK_MODELS = [
5
7
  { slug: "gpt-5.5", displayName: "GPT-5.5" },
6
8
  { slug: "gpt-5.4", displayName: "GPT-5.4" },
@@ -74,31 +76,7 @@ export function getThreadUsage(id) {
74
76
  }
75
77
  }
76
78
  export function getThreadActivity(id, options = {}) {
77
- const rolloutPath = getThreadRolloutPath(id);
78
- if (!rolloutPath || !existsSync(rolloutPath)) {
79
- return null;
80
- }
81
- try {
82
- const parsed = parseActivityFromRollout(id, rolloutPath, readFileSync(rolloutPath, "utf8"));
83
- const fileModifiedAtMs = statSync(rolloutPath).mtimeMs;
84
- const updatedAtMs = Math.max(parsed.updatedAt?.getTime() ?? 0, fileModifiedAtMs);
85
- const updatedAt = updatedAtMs > 0 ? new Date(updatedAtMs) : parsed.updatedAt;
86
- const staleAfterMs = options.staleAfterMs ?? 5 * 60 * 1000;
87
- const nowMs = options.nowMs ?? Date.now();
88
- const stale = Boolean(parsed.active &&
89
- updatedAt &&
90
- staleAfterMs > 0 &&
91
- nowMs - updatedAt.getTime() > staleAfterMs);
92
- return {
93
- ...parsed,
94
- updatedAt,
95
- stale,
96
- active: parsed.active && !stale,
97
- };
98
- }
99
- catch {
100
- return null;
101
- }
79
+ return getThreadRolloutSnapshot(id, { ...options, maxEvents: 0 })?.activity ?? null;
102
80
  }
103
81
  export function getThreadRolloutSnapshot(id, options = {}) {
104
82
  const rolloutPath = getThreadRolloutPath(id);
@@ -106,8 +84,9 @@ export function getThreadRolloutSnapshot(id, options = {}) {
106
84
  return null;
107
85
  }
108
86
  try {
109
- const parsed = parseRolloutSnapshot(id, rolloutPath, readFileSync(rolloutPath, "utf8"));
110
- return finalizeRolloutSnapshot(parsed, statSync(rolloutPath).mtimeMs, options);
87
+ const fileModifiedAtMs = statSync(rolloutPath).mtimeMs;
88
+ const parsed = readCachedRolloutSnapshot(id, rolloutPath);
89
+ return finalizeRolloutSnapshot(parsed, fileModifiedAtMs, options);
111
90
  }
112
91
  catch {
113
92
  return null;
@@ -167,13 +146,7 @@ export function getThreadRolloutPath(id) {
167
146
  }) ?? null);
168
147
  }
169
148
  function parseUsageFromRollout(contents) {
170
- let contextWindow = null;
171
- let contextUsedPercent = null;
172
- let lastTokenUsage = null;
173
- let totalTokenUsage = null;
174
- let rateLimits = null;
175
- let updatedAt = null;
176
- for (const line of contents.split(/\r?\n/)) {
149
+ for (const line of iterateLinesReverse(contents)) {
177
150
  if (!line.includes('"token_count"')) {
178
151
  continue;
179
152
  }
@@ -188,6 +161,7 @@ function parseUsageFromRollout(contents) {
188
161
  if (payload?.type !== "token_count") {
189
162
  continue;
190
163
  }
164
+ let updatedAt = null;
191
165
  const timestamp = readString(readObject(event)?.timestamp);
192
166
  if (timestamp) {
193
167
  const parsedTimestamp = new Date(timestamp);
@@ -196,54 +170,116 @@ function parseUsageFromRollout(contents) {
196
170
  }
197
171
  }
198
172
  const info = readObject(payload.info);
199
- const parsedTotal = parseTokenUsage(readObject(info?.total_token_usage));
200
- const parsedLast = parseTokenUsage(readObject(info?.last_token_usage));
173
+ const totalTokenUsage = parseTokenUsage(readObject(info?.total_token_usage));
174
+ const lastTokenUsage = parseTokenUsage(readObject(info?.last_token_usage));
201
175
  const parsedContextWindow = readNumber(info?.model_context_window);
202
- if (parsedTotal) {
203
- totalTokenUsage = parsedTotal;
176
+ const contextWindow = parsedContextWindow !== null && parsedContextWindow > 0
177
+ ? parsedContextWindow
178
+ : null;
179
+ const contextUsedPercent = lastTokenUsage && contextWindow
180
+ ? Math.min(100, (lastTokenUsage.totalTokens / contextWindow) * 100)
181
+ : null;
182
+ const rateLimits = parseRateLimits(readObject(payload.rate_limits));
183
+ if (!lastTokenUsage && !totalTokenUsage && !rateLimits) {
184
+ continue;
204
185
  }
205
- if (parsedLast) {
206
- lastTokenUsage = parsedLast;
186
+ return {
187
+ contextWindow,
188
+ contextUsedPercent,
189
+ lastTokenUsage,
190
+ totalTokenUsage,
191
+ rateLimits,
192
+ updatedAt,
193
+ };
194
+ }
195
+ return null;
196
+ }
197
+ function readCachedRolloutSnapshot(threadId, rolloutPath) {
198
+ const size = statSync(rolloutPath).size;
199
+ const cached = rolloutSnapshotCache.get(rolloutPath);
200
+ if (cached && size >= cached.byteOffset) {
201
+ const suffix = size > cached.byteOffset
202
+ ? readFileRangeUtf8(rolloutPath, cached.byteOffset, size - cached.byteOffset)
203
+ : "";
204
+ if (!suffix.trim()) {
205
+ return cached.parsed;
207
206
  }
208
- if (parsedContextWindow !== null && parsedContextWindow > 0) {
209
- contextWindow = parsedContextWindow;
207
+ const parsed = parseRolloutSnapshot(threadId, rolloutPath, suffix, {
208
+ base: cached.parsed,
209
+ maxEvents: ROLLOUT_CACHE_MAX_EVENTS,
210
+ });
211
+ rolloutSnapshotCache.set(rolloutPath, { byteOffset: size, parsed });
212
+ return parsed;
213
+ }
214
+ const contents = readFileSync(rolloutPath, "utf8");
215
+ const parsed = parseRolloutSnapshot(threadId, rolloutPath, contents, {
216
+ maxEvents: ROLLOUT_CACHE_MAX_EVENTS,
217
+ });
218
+ rolloutSnapshotCache.set(rolloutPath, {
219
+ byteOffset: Buffer.byteLength(contents),
220
+ parsed,
221
+ });
222
+ return parsed;
223
+ }
224
+ function readFileRangeUtf8(filePath, position, length) {
225
+ if (length <= 0) {
226
+ return "";
227
+ }
228
+ const fd = openSync(filePath, "r");
229
+ try {
230
+ const buffer = Buffer.allocUnsafe(length);
231
+ const bytesRead = readSync(fd, buffer, 0, length, position);
232
+ return buffer.subarray(0, bytesRead).toString("utf8");
233
+ }
234
+ finally {
235
+ closeSync(fd);
236
+ }
237
+ }
238
+ function* iterateLinesReverse(contents) {
239
+ let end = contents.length;
240
+ while (end > 0) {
241
+ let start = contents.lastIndexOf("\n", end - 1);
242
+ const lineStart = start === -1 ? 0 : start + 1;
243
+ let line = contents.slice(lineStart, end);
244
+ if (line.endsWith("\r")) {
245
+ line = line.slice(0, -1);
210
246
  }
211
- if (lastTokenUsage && contextWindow) {
212
- contextUsedPercent = Math.min(100, (lastTokenUsage.totalTokens / contextWindow) * 100);
247
+ if (line.trim()) {
248
+ yield line;
213
249
  }
214
- const parsedRateLimits = parseRateLimits(readObject(payload.rate_limits));
215
- if (parsedRateLimits) {
216
- rateLimits = parsedRateLimits;
250
+ if (start === -1) {
251
+ break;
217
252
  }
253
+ end = start;
218
254
  }
219
- if (!lastTokenUsage && !totalTokenUsage && !rateLimits) {
220
- return null;
221
- }
222
- return {
223
- contextWindow,
224
- contextUsedPercent,
225
- lastTokenUsage,
226
- totalTokenUsage,
227
- rateLimits,
228
- updatedAt,
229
- };
230
255
  }
231
- function parseActivityFromRollout(threadId, rolloutPath, contents) {
232
- return parseRolloutSnapshot(threadId, rolloutPath, contents).activity;
233
- }
234
- function parseRolloutSnapshot(threadId, rolloutPath, contents) {
235
- let activeTurnId = null;
236
- let startedAt = null;
237
- let updatedAt = null;
238
- let latestAgentMessage = null;
239
- let latestUserMessage = null;
240
- let latestToolName = null;
241
- const events = [];
256
+ function parseRolloutSnapshot(threadId, rolloutPath, contents, options = {}) {
257
+ let activeTurnId = options.base?.activity.active ? options.base.activity.turnId : null;
258
+ let startedAt = options.base?.activity.active ? options.base.activity.startedAt : null;
259
+ let updatedAt = options.base?.activity.updatedAt ?? null;
260
+ let latestAgentMessage = options.base?.latestAgentMessage ?? null;
261
+ let latestUserMessage = options.base?.latestUserMessage ?? null;
262
+ let latestToolName = options.base?.latestToolName ?? null;
263
+ const events = [...(options.base?.events ?? [])];
242
264
  const lines = contents.split(/\r?\n/);
265
+ const lineNumberOffset = options.base?.lineCount ?? 0;
266
+ let lineCount = lineNumberOffset;
267
+ const afterLine = options.afterLine ?? 0;
268
+ const maxEvents = options.maxEvents ?? Number.POSITIVE_INFINITY;
269
+ const pushEvent = (event) => {
270
+ if (maxEvents <= 0 || event.lineNumber <= afterLine) {
271
+ return;
272
+ }
273
+ events.push(event);
274
+ if (Number.isFinite(maxEvents) && events.length > maxEvents) {
275
+ events.splice(0, events.length - maxEvents);
276
+ }
277
+ };
243
278
  for (const [index, line] of lines.entries()) {
244
279
  if (!line.trim()) {
245
280
  continue;
246
281
  }
282
+ lineCount += 1;
247
283
  if (!line.includes('"task_') &&
248
284
  !line.includes('"turn_') &&
249
285
  !line.includes('"user_message"') &&
@@ -262,7 +298,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
262
298
  const eventObject = readObject(event);
263
299
  const payload = readObject(eventObject?.payload);
264
300
  const eventTimestamp = parseTimestamp(readString(eventObject?.timestamp));
265
- const lineNumber = index + 1;
301
+ const lineNumber = lineNumberOffset + index + 1;
266
302
  if (activeTurnId && eventTimestamp) {
267
303
  updatedAt = eventTimestamp;
268
304
  }
@@ -274,7 +310,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
274
310
  activeTurnId = readString(payload?.turn_id);
275
311
  startedAt = parseUnixSeconds(readNumber(payload?.started_at)) ?? eventTimestamp;
276
312
  updatedAt = eventTimestamp ?? startedAt;
277
- events.push({
313
+ pushEvent({
278
314
  lineNumber,
279
315
  kind: "task",
280
316
  timestamp: eventTimestamp,
@@ -289,7 +325,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
289
325
  }
290
326
  if (isTaskTerminalEvent(type)) {
291
327
  const turnId = readString(payload?.turn_id);
292
- events.push({
328
+ pushEvent({
293
329
  lineNumber,
294
330
  kind: "task",
295
331
  timestamp: eventTimestamp,
@@ -309,7 +345,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
309
345
  }
310
346
  if (type === "user_message") {
311
347
  latestUserMessage = readString(payload?.message);
312
- events.push({
348
+ pushEvent({
313
349
  lineNumber,
314
350
  kind: "user",
315
351
  timestamp: eventTimestamp,
@@ -324,7 +360,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
324
360
  }
325
361
  if (type === "agent_message") {
326
362
  latestAgentMessage = readString(payload?.message);
327
- events.push({
363
+ pushEvent({
328
364
  lineNumber,
329
365
  kind: "agent",
330
366
  timestamp: eventTimestamp,
@@ -339,7 +375,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
339
375
  }
340
376
  if (type === "function_call") {
341
377
  latestToolName = readString(payload?.name);
342
- events.push({
378
+ pushEvent({
343
379
  lineNumber,
344
380
  kind: "tool",
345
381
  timestamp: eventTimestamp,
@@ -353,7 +389,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
353
389
  continue;
354
390
  }
355
391
  if (type === "function_call_output") {
356
- events.push({
392
+ pushEvent({
357
393
  lineNumber,
358
394
  kind: "tool",
359
395
  timestamp: eventTimestamp,
@@ -369,7 +405,7 @@ function parseRolloutSnapshot(threadId, rolloutPath, contents) {
369
405
  return {
370
406
  threadId,
371
407
  rolloutPath,
372
- lineCount: lines.filter((line) => line.trim()).length,
408
+ lineCount,
373
409
  activity: {
374
410
  threadId,
375
411
  rolloutPath,
@@ -128,6 +128,13 @@ export const SETTING_DEFINITIONS = [
128
128
  setting("NORDRELAY_UNIFIED_JOB_MAX_ITEMS", "Unified job history", "Workspace", "number", "Maximum persisted unified jobs retained for the WebUI jobs view.", true),
129
129
  setting("NORDRELAY_VERSION_CACHE_TTL_MS", "Version cache TTL", "Workspace", "number", "NPM version cache TTL.", true),
130
130
  setting("NORDRELAY_CLI_VERSION_CACHE_TTL_MS", "CLI version cache TTL", "Workspace", "number", "Installed agent CLI version cache TTL.", true),
131
+ setting("NORDRELAY_PEER_ENABLED", "Enable peer server", "Peers", "boolean", "Expose the dedicated authenticated NordRelay peer API.", true),
132
+ setting("NORDRELAY_PEER_NAME", "Peer display name", "Peers", "string", "Human-readable name shown to paired NordRelay instances.", true),
133
+ setting("NORDRELAY_PEER_HOST", "Peer bind host", "Peers", "string", "Bind host for the peer API. Use 127.0.0.1 for local-only or a LAN/interface IP when explicitly exposing peers.", true),
134
+ setting("NORDRELAY_PEER_PORT", "Peer port", "Peers", "number", "Port for the peer API.", true),
135
+ setting("NORDRELAY_PEER_PUBLIC_URL", "Peer public URL", "Peers", "string", "Optional public URL other instances should use for this node.", true),
136
+ setting("NORDRELAY_PEER_TLS_ENABLED", "Peer TLS enabled", "Peers", "boolean", "Serve the peer API over HTTPS with an automatically generated local certificate.", true),
137
+ setting("NORDRELAY_PEER_REQUIRE_TLS", "Require peer TLS", "Peers", "boolean", "Reject plaintext peer serving on non-loopback hosts.", true),
131
138
  setting("OPENAI_API_KEY", "OpenAI API key", "Voice", "secret", "Whisper fallback API key.", true),
132
139
  setting("VOICE_PREFERRED_BACKEND", "Voice backend", "Voice", "string", "auto, parakeet, faster-whisper, or openai.", false, ["auto", "parakeet", "faster-whisper", "openai"]),
133
140
  setting("VOICE_DEFAULT_LANGUAGE", "Voice language", "Voice", "string", "Default transcription language.", false),
@@ -243,6 +250,13 @@ const EXAMPLE_VALUES = {
243
250
  "NORDRELAY_UNIFIED_JOB_MAX_ITEMS": "1000",
244
251
  "NORDRELAY_VERSION_CACHE_TTL_MS": "3600000",
245
252
  "NORDRELAY_CLI_VERSION_CACHE_TTL_MS": "60000",
253
+ "NORDRELAY_PEER_ENABLED": "false",
254
+ "NORDRELAY_PEER_NAME": "",
255
+ "NORDRELAY_PEER_HOST": "127.0.0.1",
256
+ "NORDRELAY_PEER_PORT": "31979",
257
+ "NORDRELAY_PEER_PUBLIC_URL": "",
258
+ "NORDRELAY_PEER_TLS_ENABLED": "true",
259
+ "NORDRELAY_PEER_REQUIRE_TLS": "true",
246
260
  "NORDRELAY_DASHBOARD_HOST": "127.0.0.1",
247
261
  "NORDRELAY_DASHBOARD_PORT": "31878",
248
262
  "NORDRELAY_ENV_FILE": "",
@@ -271,6 +285,7 @@ const GROUP_INTROS = {
271
285
  Operations: "Runtime output, logging, update, and Telegram behavior controls.",
272
286
  Artifacts: "File, artifact, and retention controls.",
273
287
  Workspace: "State and workspace guardrails.",
288
+ Peers: "Optional NordRelay-to-NordRelay federation. Pairing is explicit, authenticated, scoped, and TLS-protected.",
274
289
  Voice: "Optional voice transcription settings.",
275
290
  Dashboard: "Local WebUI dashboard. User login is required for every page, API route, SSE stream, artifact download, and health endpoint.",
276
291
  };
package/dist/config.js CHANGED
@@ -5,8 +5,9 @@ import { CLAUDE_CODE_EFFORT_LEVELS, HERMES_REASONING_EFFORTS, OPENCLAW_THINKING_
5
5
  import { parseMirrorMode, parseNotifyMode, parseQuietHours, parseVoiceBackendPreference, } from "./bot-preferences.js";
6
6
  export function loadConfig() {
7
7
  loadEnvFile(path.resolve(process.cwd(), ".env"));
8
- const telegramEnabled = parseBooleanEnv(optionalString(process.env.TELEGRAM_ENABLED), true);
9
- const telegramBotToken = telegramEnabled ? requireEnv("TELEGRAM_BOT_TOKEN") : "";
8
+ const adapterWarnings = [];
9
+ const requestedTelegramEnabled = parseBooleanEnv(optionalString(process.env.TELEGRAM_ENABLED), true);
10
+ const telegramBotToken = optionalString(process.env.TELEGRAM_BOT_TOKEN) ?? "";
10
11
  const telegramRateLimitMinIntervalMs = parseNonNegativeIntegerEnv(optionalString(process.env.TELEGRAM_RATE_LIMIT_MIN_INTERVAL_MS), 80, "TELEGRAM_RATE_LIMIT_MIN_INTERVAL_MS");
11
12
  const telegramEditMinIntervalMs = parseNonNegativeIntegerEnv(optionalString(process.env.TELEGRAM_EDIT_MIN_INTERVAL_MS), 1_200, "TELEGRAM_EDIT_MIN_INTERVAL_MS");
12
13
  const mirrorMode = parseMirrorMode(optionalString(process.env.NORDRELAY_CLI_MIRROR_MODE), "status");
@@ -25,7 +26,7 @@ export function loadConfig() {
25
26
  const telegramWebhookPort = parsePositiveIntegerEnv(optionalString(process.env.TELEGRAM_WEBHOOK_PORT), 8080, "TELEGRAM_WEBHOOK_PORT");
26
27
  const telegramWebhookPath = parseWebhookPath(optionalString(process.env.TELEGRAM_WEBHOOK_PATH));
27
28
  const telegramWebhookSecret = optionalString(process.env.TELEGRAM_WEBHOOK_SECRET);
28
- const discordEnabled = parseBooleanEnv(optionalString(process.env.DISCORD_ENABLED), false);
29
+ const requestedDiscordEnabled = parseBooleanEnv(optionalString(process.env.DISCORD_ENABLED), false);
29
30
  const discordBotToken = optionalString(process.env.DISCORD_BOT_TOKEN);
30
31
  const discordClientId = optionalString(process.env.DISCORD_CLIENT_ID);
31
32
  const discordGuildIds = parseOptionalStringList(optionalString(process.env.DISCORD_GUILD_IDS));
@@ -108,16 +109,33 @@ export function loadConfig() {
108
109
  const sessionLockTtlMs = parseNonNegativeIntegerEnv(optionalString(process.env.NORDRELAY_SESSION_LOCK_TTL_MS), 30 * 60 * 1000, "NORDRELAY_SESSION_LOCK_TTL_MS");
109
110
  const dashboardCacheTtlMs = parseNonNegativeIntegerEnv(optionalString(process.env.NORDRELAY_DASHBOARD_CACHE_TTL_MS), 10_000, "NORDRELAY_DASHBOARD_CACHE_TTL_MS");
110
111
  const unifiedJobMaxItems = parsePositiveIntegerEnv(optionalString(process.env.NORDRELAY_UNIFIED_JOB_MAX_ITEMS), 1000, "NORDRELAY_UNIFIED_JOB_MAX_ITEMS");
112
+ const peerEnabled = parseBooleanEnv(optionalString(process.env.NORDRELAY_PEER_ENABLED), false);
113
+ const peerName = optionalString(process.env.NORDRELAY_PEER_NAME);
114
+ const peerHost = optionalString(process.env.NORDRELAY_PEER_HOST) ?? "127.0.0.1";
115
+ const peerPort = parsePositiveIntegerEnv(optionalString(process.env.NORDRELAY_PEER_PORT), 31979, "NORDRELAY_PEER_PORT");
116
+ const peerPublicUrl = optionalString(process.env.NORDRELAY_PEER_PUBLIC_URL);
117
+ const peerTlsEnabled = parseBooleanEnv(optionalString(process.env.NORDRELAY_PEER_TLS_ENABLED), true);
118
+ const peerRequireTls = parseBooleanEnv(optionalString(process.env.NORDRELAY_PEER_REQUIRE_TLS), true);
119
+ let telegramEnabled = requestedTelegramEnabled;
111
120
  if (telegramEnabled && telegramTransport === "webhook" && !telegramWebhookUrl) {
112
- throw new Error("TELEGRAM_TRANSPORT=webhook requires TELEGRAM_WEBHOOK_URL");
121
+ telegramEnabled = false;
122
+ adapterWarnings.push("Telegram disabled: TELEGRAM_TRANSPORT=webhook requires TELEGRAM_WEBHOOK_URL.");
113
123
  }
124
+ if (telegramEnabled && !telegramBotToken) {
125
+ telegramEnabled = false;
126
+ adapterWarnings.push("Telegram disabled: TELEGRAM_BOT_TOKEN is missing.");
127
+ }
128
+ let discordEnabled = requestedDiscordEnabled;
114
129
  if (discordEnabled && !discordBotToken) {
115
- throw new Error("DISCORD_ENABLED=true requires DISCORD_BOT_TOKEN");
130
+ discordEnabled = false;
131
+ adapterWarnings.push("Discord disabled: DISCORD_ENABLED=true requires DISCORD_BOT_TOKEN.");
116
132
  }
117
133
  if (!telegramEnabled && !discordEnabled) {
118
- throw new Error("At least one chat adapter must be enabled.");
134
+ const detail = adapterWarnings.length > 0 ? ` ${adapterWarnings.join(" ")}` : "";
135
+ throw new Error(`At least one usable chat adapter must be enabled.${detail}`);
119
136
  }
120
137
  return {
138
+ adapterWarnings,
121
139
  telegramEnabled,
122
140
  telegramBotToken,
123
141
  telegramRateLimitMinIntervalMs,
@@ -220,6 +238,13 @@ export function loadConfig() {
220
238
  sessionLockTtlMs,
221
239
  dashboardCacheTtlMs,
222
240
  unifiedJobMaxItems,
241
+ peerEnabled,
242
+ peerName,
243
+ peerHost,
244
+ peerPort,
245
+ peerPublicUrl,
246
+ peerTlsEnabled,
247
+ peerRequireTls,
223
248
  };
224
249
  }
225
250
  /**
@@ -1,3 +1,4 @@
1
+ import { parsePeerRuntimeContextKey } from "./peer-context.js";
1
2
  export function telegramContextKeyFromMessage(chatId, messageThreadId) {
2
3
  if (messageThreadId !== undefined) {
3
4
  return `${chatId}:${messageThreadId}`;
@@ -111,6 +112,15 @@ export function parseChannelContextKey(key) {
111
112
  chatId: rawKey.slice("cli:".length) || "local",
112
113
  };
113
114
  }
115
+ const peer = parsePeerRuntimeContextKey(rawKey);
116
+ if (peer) {
117
+ return {
118
+ channelId: "peer",
119
+ contextKey: rawKey,
120
+ chatId: peer.peerId,
121
+ topicId: peer.sourceContextKey,
122
+ };
123
+ }
114
124
  return null;
115
125
  }
116
126
  export function channelIdForContextKey(key) {
@@ -9,10 +9,12 @@ import { enabledAgents } from "./agent-factory.js";
9
9
  import { collectRecentWorkspaceArtifacts, ensureOutDir, formatArtifactSummary, persistWorkspaceArtifactReport } from "./artifacts.js";
10
10
  import { buildFileInstructions, outboxPath, stageFile } from "./attachments.js";
11
11
  import { AuditLogStore } from "./audit-log.js";
12
- import { BotPreferencesStore, parseMirrorMode, parseNotifyMode, parseVoiceBackendPreference } from "./bot-preferences.js";
12
+ import { BotPreferencesStore } from "./bot-preferences.js";
13
13
  import { capabilitiesOf, filterActivityEvents, formatLocalDateTime, parseActivityOptions, renderExternalMirrorEvent, renderExternalMirrorStatus, renderPromptFailure, trimLine } from "./bot-rendering.js";
14
14
  import { renderAgentUpdateJobAction, renderAgentUpdateJobsAction, renderAgentUpdateLogAction, renderAgentUpdatePickerAction, renderQueueListAction } from "./channel-actions.js";
15
15
  import { ChannelCommandService } from "./channel-command-service.js";
16
+ import { discordHelpCommandList } from "./channel-command-catalog.js";
17
+ import { runChannelPeerPrompt } from "./channel-peer-prompt.js";
16
18
  import { deliverChannelAction } from "./channel-runtime.js";
17
19
  import { checkAuthStatus, startLogin as startCodexLogin, startLogout as startCodexLogout } from "./codex-auth.js";
18
20
  import { checkClaudeCodeAuthStatus, startClaudeCodeLogin, startClaudeCodeLogout } from "./claude-code-auth.js";
@@ -25,6 +27,7 @@ import { friendlyErrorText } from "./error-messages.js";
25
27
  import { checkHermesAuthStatus, startHermesLogin, startHermesLogout } from "./hermes-auth.js";
26
28
  import { spawnConnectorRestart, spawnSelfUpdate } from "./operations.js";
27
29
  import { checkOpenClawAuthStatus } from "./openclaw-auth.js";
30
+ import { RemoteRelayClient } from "./peer-client.js";
28
31
  import { checkPiAuthStatus } from "./pi-auth.js";
29
32
  import { PromptStore, toPromptEnvelope } from "./prompt-store.js";
30
33
  import { RelayArtifactService } from "./relay-artifact-service.js";
@@ -46,7 +49,8 @@ export function createDiscordBridge(config, registry) {
46
49
  return null;
47
50
  }
48
51
  if (!config.discordBotToken) {
49
- throw new Error("DISCORD_ENABLED=true requires DISCORD_BOT_TOKEN.");
52
+ console.warn("Discord adapter disabled: DISCORD_ENABLED=true requires DISCORD_BOT_TOKEN.");
53
+ return null;
50
54
  }
51
55
  configureRedaction(config.telegramRedactPatterns);
52
56
  const intents = [
@@ -522,10 +526,44 @@ export function createDiscordBridge(config, registry) {
522
526
  await reply(request, `Session is locked by ${lock?.ownerLabel || lock?.ownerUserId || "another user"}.`);
523
527
  return true;
524
528
  };
529
+ const remoteClient = new RemoteRelayClient();
530
+ const handleRemotePrompt = async (request, envelope) => {
531
+ const targetPeerId = preferencesStore.get(request.contextKey).targetPeerId ?? undefined;
532
+ return runChannelPeerPrompt({
533
+ targetPeerId,
534
+ contextKey: request.contextKey,
535
+ prompt: envelope,
536
+ remoteClient,
537
+ editMinIntervalMs: EDIT_DEBOUNCE_MS,
538
+ typingIntervalMs: TYPING_INTERVAL_MS,
539
+ sendTyping: () => runtime.sendTyping(request.context),
540
+ sendResponse: async (text) => {
541
+ const rendered = trimDiscordMessage(text);
542
+ const sent = await runtime.sendMessage(request.context, { text: rendered, fallbackText: rendered });
543
+ return sent.messageId;
544
+ },
545
+ editResponse: async (messageId, text) => {
546
+ const rendered = trimDiscordMessage(text);
547
+ await runtime.editMessage(request.context, messageId, { text: rendered, fallbackText: rendered });
548
+ },
549
+ sendTurnStart: (remotePrompt) => reply(request, `Remote peer working on:\n${remotePrompt}`),
550
+ sendToolStart: (toolName) => reply(request, `Remote tool: ${toolName}`),
551
+ sendQueued: async (queueId) => {
552
+ await reply(request, `Remote prompt queued${queueId ? `: ${queueId}` : ""}.`, queueId ? {
553
+ buttons: [[{ label: "Cancel queued message", action: `discord_peer_queue_cancel:${targetPeerId}:${queueId}` }]],
554
+ } : undefined);
555
+ },
556
+ sendCompleted: () => reply(request, "Remote turn completed."),
557
+ sendFailure: (message) => reply(request, `Remote peer failed: ${message}`),
558
+ });
559
+ };
525
560
  const handlePrompt = async (request, input, artifactOutDir, options = {}) => {
526
561
  const session = await getSession(request);
527
562
  const envelope = toPromptEnvelope(input, artifactOutDir);
528
563
  envelope.activityActor = actorFor(request);
564
+ if (!options.fromQueue && await handleRemotePrompt(request, envelope)) {
565
+ return;
566
+ }
529
567
  if (!options.fromQueue && await denyIfLocked(request)) {
530
568
  return;
531
569
  }
@@ -893,6 +931,17 @@ export function createDiscordBridge(config, registry) {
893
931
  case "channels":
894
932
  await deliverChannelAction(runtime, request.context, commandService.renderChannels());
895
933
  return;
934
+ case "peers":
935
+ await deliverChannelAction(runtime, request.context, commandService.renderPeers());
936
+ return;
937
+ case "target":
938
+ await deliverChannelAction(runtime, request.context, commandService.renderTargetPreference({
939
+ source: "discord",
940
+ contextKey: request.contextKey,
941
+ argument,
942
+ preferencesStore,
943
+ }));
944
+ return;
896
945
  case "agents":
897
946
  await deliverChannelAction(runtime, request.context, commandService.renderAgents());
898
947
  return;
@@ -1046,7 +1095,7 @@ export function createDiscordBridge(config, registry) {
1046
1095
  "",
1047
1096
  "Send a message to prompt the selected agent, or use slash commands.",
1048
1097
  "",
1049
- "Core commands: `/agent`, `/agents`, `/auth`, `/login`, `/logout`, `/session`, `/sessions`, `/new`, `/switch`, `/attach`, `/handback`, `/workspaces`, `/pin`, `/unpin`, `/pinned`, `/model`, `/reasoning`, `/fast`, `/launch`, `/launch_profiles`, `/queue`, `/clearqueue`, `/cancel`, `/stop`, `/retry`, `/sync`, `/progress`, `/activity`, `/audit`, `/artifacts`, `/logs`, `/version`, `/diagnostics`, `/support`, `/restart`, `/update`, `/lock`, `/unlock`, `/locks`, `/mirror`, `/notify`, `/voice`, `/channels`, `/whoami`, `/link`, `/register_channel`.",
1098
+ `Core commands: ${discordHelpCommandList()}.`,
1050
1099
  "",
1051
1100
  renderSessionInfoPlain(session.getInfo()),
1052
1101
  ].join("\n"));
@@ -1595,33 +1644,32 @@ export function createDiscordBridge(config, registry) {
1595
1644
  await deliverChannelAction(runtime, request.context, commandService.renderHandback(result));
1596
1645
  };
1597
1646
  const commandMirror = async (request, argument) => {
1598
- const mode = parseMirrorMode(argument, preferencesStore.get(request.contextKey).mirrorMode ?? config.discordMirrorMode);
1599
- preferencesStore.update(request.contextKey, { mirrorMode: mode });
1600
- await reply(request, `CLI mirror mode: ${mode}`);
1647
+ const session = await getSession(request, { deferThreadStart: true });
1648
+ const info = session.getInfo();
1649
+ await deliverChannelAction(runtime, request.context, commandService.renderMirrorPreference({
1650
+ source: "discord",
1651
+ contextKey: request.contextKey,
1652
+ argument,
1653
+ preferencesStore,
1654
+ cliMirrorSupported: capabilitiesOf(info).cliMirror,
1655
+ agentLabel: info.agentLabel,
1656
+ }));
1601
1657
  };
1602
1658
  const commandNotify = async (request, argument) => {
1603
- const mode = parseNotifyMode(argument, preferencesStore.get(request.contextKey).notifyMode ?? config.discordNotifyMode);
1604
- preferencesStore.update(request.contextKey, { notifyMode: mode });
1605
- await reply(request, `Notify mode: ${mode}`);
1659
+ await deliverChannelAction(runtime, request.context, commandService.renderNotifyPreference({
1660
+ source: "discord",
1661
+ contextKey: request.contextKey,
1662
+ argument,
1663
+ preferencesStore,
1664
+ }));
1606
1665
  };
1607
1666
  const commandVoice = async (request, argument) => {
1608
- const normalized = argument.trim().toLowerCase();
1609
- const parts = normalized.split(/\s+/).filter(Boolean);
1610
- if (parts[0] === "backend" && parts[1]) {
1611
- preferencesStore.update(request.contextKey, { voiceBackend: parseVoiceBackendPreference(parts[1]) });
1612
- }
1613
- else if (parts[0] === "language" && parts[1]) {
1614
- preferencesStore.update(request.contextKey, { voiceLanguage: parts[1] === "auto" ? null : parts[1] });
1615
- }
1616
- else if ((parts[0] === "transcribe-only" || parts[0] === "transcribe_only") && parts[1]) {
1617
- preferencesStore.update(request.contextKey, { voiceTranscribeOnly: ["on", "true", "yes", "1"].includes(parts[1]) });
1618
- }
1619
- else if (argument.trim()) {
1620
- await reply(request, "Usage: `/voice`, `/voice backend auto|parakeet|faster-whisper|openai`, `/voice language auto|<code>`, or `/voice transcribe_only on|off`.");
1621
- return;
1622
- }
1623
- const prefs = preferencesStore.get(request.contextKey);
1624
- await reply(request, `Voice backend: ${prefs.voiceBackend ?? config.voicePreferredBackend}\nLanguage: ${prefs.voiceLanguage ?? config.voiceDefaultLanguage ?? "auto"}\nTranscribe only: ${prefs.voiceTranscribeOnly ?? config.voiceTranscribeOnly}`);
1667
+ await deliverChannelAction(runtime, request.context, await commandService.renderVoicePreference({
1668
+ source: "discord",
1669
+ contextKey: request.contextKey,
1670
+ argument,
1671
+ preferencesStore,
1672
+ }));
1625
1673
  };
1626
1674
  const commandRegisterChannel = async (request) => {
1627
1675
  const channel = userStore.registerDiscordChannel({
@@ -1781,6 +1829,17 @@ export function createDiscordBridge(config, registry) {
1781
1829
  await commandQueue(request, `${queueMatch[1]} ${queueMatch[3]}`);
1782
1830
  return;
1783
1831
  }
1832
+ const peerQueueMatch = action.match(/^discord_peer_queue_cancel:([^:]+):([^:]+)$/);
1833
+ if (peerQueueMatch?.[1] && peerQueueMatch[2]) {
1834
+ await remoteClient.webProxy(peerQueueMatch[1], {
1835
+ method: "POST",
1836
+ path: "/api/queue",
1837
+ body: { action: "cancel", id: peerQueueMatch[2] },
1838
+ contextKey: request.contextKey,
1839
+ }, actorFor(request), request.contextKey);
1840
+ await reply(request, `Cancelled remote queued prompt ${peerQueueMatch[2]}.`, { ephemeral: true });
1841
+ return;
1842
+ }
1784
1843
  const artifactMatch = action.match(/^discord_artifact_(send|zip|delete):(.+):([^:]+)$/);
1785
1844
  if (artifactMatch?.[1] && artifactMatch[2] === request.contextKey) {
1786
1845
  await commandArtifacts(request, `${artifactMatch[1]} ${artifactMatch[3]}`);