@nordbyte/nordrelay 0.8.2 → 0.8.3

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 (39) hide show
  1. package/README.md +4 -0
  2. package/dist/access/audit-log.js +30 -13
  3. package/dist/channels/discord/discord-bot.js +12 -27
  4. package/dist/channels/shared/channel-bridge-controller.js +1 -1
  5. package/dist/channels/shared/channel-prompt-queue.js +37 -0
  6. package/dist/channels/shared/channel-turn-service.js +23 -9
  7. package/dist/channels/slack/slack-bot.js +12 -15
  8. package/dist/channels/telegram/bot.js +18 -4
  9. package/dist/core/pagination.js +22 -0
  10. package/dist/peers/peer-store.js +16 -0
  11. package/dist/peers/peer-types.js +19 -0
  12. package/dist/peers/peer-web-proxy-contract.js +2 -0
  13. package/dist/runtime/relay-external-activity-monitor.js +15 -0
  14. package/dist/runtime/relay-queue-service.js +1 -0
  15. package/dist/runtime/relay-runtime-dashboard.js +3 -0
  16. package/dist/runtime/relay-runtime-helpers.js +3 -0
  17. package/dist/runtime/relay-runtime-prompt-queue-artifacts.js +14 -10
  18. package/dist/runtime/relay-runtime-sessions.js +8 -0
  19. package/dist/runtime/relay-runtime-trace.js +92 -0
  20. package/dist/runtime/relay-runtime-updates-jobs.js +11 -5
  21. package/dist/runtime/relay-runtime.js +16 -6
  22. package/dist/state/prompt-store.js +13 -1
  23. package/dist/web/web-api-contract.js +2 -0
  24. package/dist/web/web-dashboard-access-routes.js +15 -12
  25. package/dist/web/web-dashboard-artifact-routes.js +6 -2
  26. package/dist/web/web-dashboard-assets.js +1 -0
  27. package/dist/web/web-dashboard-pages.js +58 -20
  28. package/dist/web/web-dashboard-peer-routes.js +19 -0
  29. package/dist/web/web-dashboard-runtime-routes.js +8 -1
  30. package/dist/web/web-dashboard-session-routes.js +17 -12
  31. package/dist/web/web-dashboard-ui.js +46 -10
  32. package/dist/web/web-performance.js +2 -0
  33. package/dist/web/web-state.js +33 -4
  34. package/dist/webui-assets/dashboard.css +227 -39
  35. package/dist/webui-assets/dashboard.js +728 -58
  36. package/package.json +4 -2
  37. package/plugins/nordrelay/scripts/nordrelay.mjs +333 -8
  38. package/plugins/nordrelay/scripts/service-installer.mjs +183 -0
  39. package/scripts/postinstall.mjs +122 -0
package/README.md CHANGED
@@ -22,6 +22,8 @@ nordrelay doctor
22
22
  nordrelay start
23
23
  ```
24
24
 
25
+ If `nordrelay` is not found after a global npm install, the npm global bin directory is not in your shell `PATH`. New installs run a postinstall check and print the exact command to add the bin directory to your shell profile.
26
+
25
27
  Open the dashboard:
26
28
 
27
29
  ```bash
@@ -78,6 +80,8 @@ nordrelay doctor
78
80
  nordrelay web
79
81
  nordrelay restart
80
82
  nordrelay update
83
+ nordrelay service install
84
+ nordrelay service status
81
85
  ```
82
86
 
83
87
  Chat adapters share the core command set:
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { auditCategoryForAction, } from "../core/activity-events.js";
3
+ import { cursorPage, normalizeCursorLimit } from "../core/pagination.js";
3
4
  import { createDocumentStore } from "../state/state-backend.js";
4
5
  export class AuditLogStore {
5
6
  store;
@@ -31,20 +32,36 @@ export class AuditLogStore {
31
32
  }
32
33
  list(options = 20) {
33
34
  const resolved = typeof options === "number" ? { limit: options } : options;
34
- const limit = Math.max(1, Math.min(500, resolved.limit ?? 20));
35
- const since = normalizeSince(resolved.since);
35
+ return this.listPage(resolved).items;
36
+ }
37
+ listPage(options = {}) {
38
+ const limit = normalizeCursorLimit(options.limit, 20, 500);
39
+ const events = this.filteredEvents(options)
40
+ .sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp));
41
+ return cursorPage(events, options.cursor, limit, (event) => event.id);
42
+ }
43
+ findByCorrelationId(correlationId, limit = 100) {
44
+ const needle = correlationId.trim();
45
+ if (!needle) {
46
+ return [];
47
+ }
48
+ return this.readPayload().events
49
+ .filter((event) => event.correlationId === needle)
50
+ .sort((left, right) => Date.parse(left.timestamp) - Date.parse(right.timestamp))
51
+ .slice(-Math.max(1, Math.min(500, limit)));
52
+ }
53
+ filteredEvents(options) {
54
+ const since = normalizeSince(options.since);
36
55
  return this.readPayload().events
37
- .filter((event) => !resolved.channelId || resolved.channelId === "all" || event.channelId === resolved.channelId)
38
- .filter((event) => !resolved.status || resolved.status === "all" || event.status === resolved.status)
39
- .filter((event) => !resolved.action || resolved.action === "all" || event.action === resolved.action)
40
- .filter((event) => !resolved.category || resolved.category === "all" || (event.category ?? auditCategoryForAction(event.action)) === resolved.category)
41
- .filter((event) => !resolved.agentId || resolved.agentId === "all" || event.agentId === resolved.agentId)
42
- .filter((event) => !resolved.threadId || event.threadId === resolved.threadId)
43
- .filter((event) => !resolved.workspace || event.workspace === resolved.workspace)
44
- .filter((event) => !resolved.actor || auditActorMatches(event, resolved.actor))
45
- .filter((event) => !since || Date.parse(event.timestamp) >= since)
46
- .slice(-limit)
47
- .reverse();
56
+ .filter((event) => !options.channelId || options.channelId === "all" || event.channelId === options.channelId)
57
+ .filter((event) => !options.status || options.status === "all" || event.status === options.status)
58
+ .filter((event) => !options.action || options.action === "all" || event.action === options.action)
59
+ .filter((event) => !options.category || options.category === "all" || (event.category ?? auditCategoryForAction(event.action)) === options.category)
60
+ .filter((event) => !options.agentId || options.agentId === "all" || event.agentId === options.agentId)
61
+ .filter((event) => !options.threadId || event.threadId === options.threadId)
62
+ .filter((event) => !options.workspace || event.workspace === options.workspace)
63
+ .filter((event) => !options.actor || auditActorMatches(event, options.actor))
64
+ .filter((event) => !since || Date.parse(event.timestamp) >= since);
48
65
  }
49
66
  readPayload() {
50
67
  const payload = this.store.read();
@@ -18,6 +18,7 @@ import { createSharedChannelCommandDispatcher } from "../shared/channel-command-
18
18
  import { ChannelCommandService } from "../shared/channel-command-service.js";
19
19
  import { discordHelpCommandList } from "../shared/channel-command-catalog.js";
20
20
  import { createChannelPromptEngine } from "../shared/channel-prompt-engine.js";
21
+ import { queueChannelPromptIfBusy } from "../shared/channel-prompt-queue.js";
21
22
  import { runChannelPeerPrompt } from "../shared/channel-peer-prompt.js";
22
23
  import { deliverChannelAction } from "../shared/channel-runtime.js";
23
24
  import { deliverChannelCliArtifacts } from "../shared/channel-cli-artifacts.js";
@@ -304,30 +305,17 @@ export function createDiscordBridge(config, registry) {
304
305
  if (!options.fromQueue && await denyIfLocked(request)) {
305
306
  return;
306
307
  }
307
- const busy = getBusyReason(request.contextKey);
308
- if (busy.busy) {
309
- const item = options.fromQueue && isQueuedPrompt(envelope)
310
- ? envelope
311
- : promptStore.enqueue(request.contextKey, envelope);
312
- const position = promptStore.list(request.contextKey).findIndex((queued) => queued.id === item.id) + 1;
313
- const text = busy.kind === "external"
314
- ? `Queued prompt ${item.id} at position ${position}. The ${busy.agentLabel} session is still active and is processing a previous task.`
315
- : `Queued prompt ${item.id} at position ${position}.`;
316
- await reply(request, text, {
317
- buttons: [[{ label: "Cancel queued message", action: `discord_queue_cancel:${request.contextKey}:${item.id}` }]],
318
- });
319
- appendActivity(request, {
320
- status: "queued",
321
- type: "prompt_queued",
322
- prompt: item.description,
323
- detail: text,
324
- });
325
- audit(request, {
326
- action: "prompt_queued",
327
- status: "ok",
328
- promptId: item.id,
329
- description: item.description,
330
- });
308
+ if (await queueChannelPromptIfBusy({
309
+ request,
310
+ envelope,
311
+ fromQueue: options.fromQueue,
312
+ promptStore,
313
+ busy: getBusyReason(request.contextKey),
314
+ actionPrefix: "discord",
315
+ reply,
316
+ appendActivity,
317
+ audit,
318
+ })) {
331
319
  return;
332
320
  }
333
321
  const busyState = getBusyState(request.contextKey);
@@ -1580,6 +1568,3 @@ function inferMimeType(name) {
1580
1568
  return "audio/webm";
1581
1569
  return "application/octet-stream";
1582
1570
  }
1583
- function isQueuedPrompt(value) {
1584
- return Boolean(value && typeof value === "object" && "id" in value && "contextKey" in value);
1585
- }
@@ -42,7 +42,7 @@ export function createChannelQueueStatusController(options) {
42
42
  }
43
43
  export function createChannelActivityRecorder(options) {
44
44
  return (request, input) => {
45
- options.activityStore.append({
45
+ return options.activityStore.append({
46
46
  source: options.source,
47
47
  contextKey: request.contextKey,
48
48
  actor: input.actor ?? options.actorFor(request),
@@ -0,0 +1,37 @@
1
+ export async function queueChannelPromptIfBusy(options) {
2
+ if (!options.busy.busy) {
3
+ return false;
4
+ }
5
+ const item = options.fromQueue && isQueuedPrompt(options.envelope)
6
+ ? options.envelope
7
+ : options.promptStore.enqueue(options.request.contextKey, options.envelope);
8
+ const position = options.promptStore.list(options.request.contextKey).findIndex((queued) => queued.id === item.id) + 1;
9
+ const text = options.busy.kind === "external"
10
+ ? `Queued prompt ${item.id} at position ${position}. The ${options.busy.agentLabel} session is still active and is processing a previous task.`
11
+ : `Queued prompt ${item.id} at position ${position}.`;
12
+ await options.reply(options.request, text, {
13
+ buttons: [[{ label: "Cancel queued message", action: `${options.actionPrefix}_queue_cancel:${options.request.contextKey}:${item.id}` }]],
14
+ });
15
+ options.appendActivity(options.request, {
16
+ status: "queued",
17
+ type: "prompt_queued",
18
+ prompt: item.description,
19
+ detail: text,
20
+ correlationId: item.correlationId,
21
+ });
22
+ options.audit(options.request, {
23
+ action: "prompt_queued",
24
+ status: "ok",
25
+ promptId: item.id,
26
+ correlationId: item.correlationId,
27
+ description: item.description,
28
+ });
29
+ return true;
30
+ }
31
+ function isQueuedPrompt(value) {
32
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
33
+ return false;
34
+ }
35
+ const candidate = value;
36
+ return typeof candidate.id === "string" && typeof candidate.createdAt === "number" && typeof candidate.description === "string";
37
+ }
@@ -17,12 +17,14 @@ export class ChannelTurnService {
17
17
  }
18
18
  }
19
19
  const turnId = randomUUID().slice(0, 12);
20
+ const correlationId = envelope.correlationId ?? turnId;
20
21
  const startedMs = Date.now();
21
22
  this.options.setCurrentTurn(turnId, startedMs, "");
22
23
  this.options.setCurrentProgress({
23
24
  id: turnId,
24
25
  source: this.options.source,
25
26
  status: "running",
27
+ correlationId,
26
28
  prompt: envelope.description,
27
29
  agentId: info.agentId,
28
30
  agentLabel: info.agentLabel,
@@ -42,6 +44,7 @@ export class ChannelTurnService {
42
44
  role: "user",
43
45
  text: envelope.description,
44
46
  source: this.options.source,
47
+ correlationId,
45
48
  turnId,
46
49
  timestamp: startedAt,
47
50
  });
@@ -53,6 +56,7 @@ export class ChannelTurnService {
53
56
  workspace: info.workspace,
54
57
  agentId: info.agentId,
55
58
  actor,
59
+ correlationId,
56
60
  prompt: envelope.description,
57
61
  });
58
62
  this.options.appendAudit({
@@ -63,9 +67,10 @@ export class ChannelTurnService {
63
67
  threadId: info.threadId,
64
68
  workspace: info.workspace,
65
69
  actor,
70
+ correlationId,
66
71
  description: envelope.description,
67
72
  });
68
- this.options.broadcast({ type: "turn_start", id: turnId, prompt: envelope.description, at: startedAt, source: this.options.source });
73
+ this.options.broadcast({ type: "turn_start", id: turnId, prompt: envelope.description, at: startedAt, source: this.options.source, correlationId });
69
74
  try {
70
75
  await session.prompt(envelope.input, this.callbacks(turnId, info, envelope, actor));
71
76
  this.options.updateSession(session);
@@ -77,6 +82,7 @@ export class ChannelTurnService {
77
82
  role: "agent",
78
83
  text,
79
84
  source: this.options.source,
85
+ correlationId,
80
86
  turnId,
81
87
  });
82
88
  }
@@ -88,6 +94,7 @@ export class ChannelTurnService {
88
94
  workspace: info.workspace,
89
95
  agentId: info.agentId,
90
96
  actor,
97
+ correlationId,
91
98
  prompt: envelope.description,
92
99
  durationMs: Date.now() - this.options.getCurrentTurnStartedAt(),
93
100
  });
@@ -99,10 +106,11 @@ export class ChannelTurnService {
99
106
  threadId: info.threadId,
100
107
  workspace: info.workspace,
101
108
  actor,
109
+ correlationId,
102
110
  description: envelope.description,
103
111
  });
104
112
  this.updateCurrentProgress({ status: "completed" });
105
- this.options.broadcast({ type: "turn_complete", id: turnId, at: new Date().toISOString() });
113
+ this.options.broadcast({ type: "turn_complete", id: turnId, at: new Date().toISOString(), correlationId });
106
114
  this.options.broadcast({ type: "chat_history", messages: await this.options.chatHistory() });
107
115
  }
108
116
  catch (error) {
@@ -112,6 +120,7 @@ export class ChannelTurnService {
112
120
  role: "system",
113
121
  text: `Error: ${errorText}`,
114
122
  source: this.options.source,
123
+ correlationId,
115
124
  turnId,
116
125
  });
117
126
  this.options.appendActivity({
@@ -122,6 +131,7 @@ export class ChannelTurnService {
122
131
  workspace: info.workspace,
123
132
  agentId: info.agentId,
124
133
  actor,
134
+ correlationId,
125
135
  prompt: envelope.description,
126
136
  detail: errorText,
127
137
  durationMs: Date.now() - this.options.getCurrentTurnStartedAt(),
@@ -134,11 +144,12 @@ export class ChannelTurnService {
134
144
  threadId: info.threadId,
135
145
  workspace: info.workspace,
136
146
  actor,
147
+ correlationId,
137
148
  description: envelope.description,
138
149
  detail: errorText,
139
150
  });
140
151
  this.updateCurrentProgress({ status: "failed", detail: errorText });
141
- this.options.broadcast({ type: "turn_error", id: turnId, error: errorText, at: new Date().toISOString() });
152
+ this.options.broadcast({ type: "turn_error", id: turnId, error: errorText, at: new Date().toISOString(), correlationId });
142
153
  this.options.broadcast({ type: "chat_history", messages: await this.options.chatHistory() });
143
154
  throw error;
144
155
  }
@@ -152,12 +163,13 @@ export class ChannelTurnService {
152
163
  }
153
164
  }
154
165
  callbacks(turnId, info, envelope, actor) {
166
+ const correlationId = envelope.correlationId ?? turnId;
155
167
  return {
156
168
  onTextDelta: (delta) => {
157
169
  const nextText = this.options.getAccumulatedText() + delta;
158
170
  this.options.setAccumulatedText(nextText);
159
171
  this.updateCurrentProgress({ outputChars: nextText.length });
160
- this.options.broadcast({ type: "text_delta", id: turnId, delta });
172
+ this.options.broadcast({ type: "text_delta", id: turnId, delta, correlationId });
161
173
  },
162
174
  onToolStart: (toolName, toolCallId) => {
163
175
  this.addCurrentTool(toolName);
@@ -169,14 +181,15 @@ export class ChannelTurnService {
169
181
  workspace: info.workspace,
170
182
  agentId: info.agentId,
171
183
  actor,
184
+ correlationId,
172
185
  prompt: envelope.description,
173
186
  detail: toolName,
174
187
  });
175
- this.options.broadcast({ type: "tool_start", id: turnId, toolCallId, toolName });
188
+ this.options.broadcast({ type: "tool_start", id: turnId, toolCallId, toolName, correlationId });
176
189
  },
177
190
  onToolUpdate: (toolCallId, partialResult) => {
178
191
  this.updateCurrentProgress();
179
- this.options.broadcast({ type: "tool_update", id: turnId, toolCallId, partialResult });
192
+ this.options.broadcast({ type: "tool_update", id: turnId, toolCallId, partialResult, correlationId });
180
193
  },
181
194
  onToolEnd: (toolCallId, isError) => {
182
195
  const progress = this.options.getCurrentProgress();
@@ -190,17 +203,18 @@ export class ChannelTurnService {
190
203
  workspace: info.workspace,
191
204
  agentId: info.agentId,
192
205
  actor,
206
+ correlationId,
193
207
  prompt: envelope.description,
194
208
  detail: toolName,
195
209
  });
196
- this.options.broadcast({ type: "tool_end", id: turnId, toolCallId, isError });
210
+ this.options.broadcast({ type: "tool_end", id: turnId, toolCallId, isError, correlationId });
197
211
  },
198
212
  onTodoUpdate: (items) => {
199
213
  this.updateCurrentProgress({ detail: `Plan: ${items.filter((item) => item.completed).length}/${items.length} done` });
200
- this.options.broadcast({ type: "todo_update", id: turnId, items });
214
+ this.options.broadcast({ type: "todo_update", id: turnId, items, correlationId });
201
215
  },
202
216
  onTurnComplete: () => { },
203
- onAgentEnd: () => this.options.broadcast({ type: "turn_complete", id: turnId, at: new Date().toISOString() }),
217
+ onAgentEnd: () => this.options.broadcast({ type: "turn_complete", id: turnId, at: new Date().toISOString(), correlationId }),
204
218
  };
205
219
  }
206
220
  updateCurrentProgress(patch = {}) {
@@ -18,6 +18,7 @@ import { createSharedChannelCommandDispatcher } from "../shared/channel-command-
18
18
  import { slackHelpCommandList } from "../shared/channel-command-catalog.js";
19
19
  import { ChannelCommandService } from "../shared/channel-command-service.js";
20
20
  import { createChannelPromptEngine } from "../shared/channel-prompt-engine.js";
21
+ import { queueChannelPromptIfBusy } from "../shared/channel-prompt-queue.js";
21
22
  import { runChannelPeerPrompt } from "../shared/channel-peer-prompt.js";
22
23
  import { deliverChannelAction } from "../shared/channel-runtime.js";
23
24
  import { deliverChannelCliArtifacts } from "../shared/channel-cli-artifacts.js";
@@ -284,18 +285,17 @@ export function createSlackBridge(config, registry) {
284
285
  if (!options.fromQueue && await denyIfLocked(request)) {
285
286
  return;
286
287
  }
287
- const busy = getBusyReason(request.contextKey);
288
- if (busy.busy) {
289
- const item = options.fromQueue && isQueuedPrompt(envelope) ? envelope : promptStore.enqueue(request.contextKey, envelope);
290
- const position = promptStore.list(request.contextKey).findIndex((queued) => queued.id === item.id) + 1;
291
- const text = busy.kind === "external"
292
- ? `Queued prompt ${item.id} at position ${position}. The ${busy.agentLabel} session is still active and is processing a previous task.`
293
- : `Queued prompt ${item.id} at position ${position}.`;
294
- await reply(request, text, {
295
- buttons: [[{ label: "Cancel queued message", action: `slack_queue_cancel:${request.contextKey}:${item.id}` }]],
296
- });
297
- appendActivity(request, { status: "queued", type: "prompt_queued", prompt: item.description, detail: text });
298
- audit(request, { action: "prompt_queued", status: "ok", promptId: item.id, description: item.description });
288
+ if (await queueChannelPromptIfBusy({
289
+ request,
290
+ envelope,
291
+ fromQueue: options.fromQueue,
292
+ promptStore,
293
+ busy: getBusyReason(request.contextKey),
294
+ actionPrefix: "slack",
295
+ reply,
296
+ appendActivity,
297
+ audit,
298
+ })) {
299
299
  return;
300
300
  }
301
301
  const busyState = getBusyState(request.contextKey);
@@ -1321,6 +1321,3 @@ function inferMimeType(name) {
1321
1321
  return "audio/webm";
1322
1322
  return "application/octet-stream";
1323
1323
  }
1324
- function isQueuedPrompt(value) {
1325
- return Boolean(value && typeof value === "object" && "id" in value && "contextKey" in value);
1326
- }
@@ -11,7 +11,7 @@ import { AuditLogStore } from "../../access/audit-log.js";
11
11
  import { formatSessionLabel } from "./bot-ui.js";
12
12
  import { BotPreferencesStore, isQuietNow, } from "../../state/bot-preferences.js";
13
13
  import { renderAgentUpdateJobAction } from "../shared/channel-actions.js";
14
- import { createChannelBusyStore } from "../shared/channel-bridge-controller.js";
14
+ import { createChannelActivityRecorder, createChannelBusyStore } from "../shared/channel-bridge-controller.js";
15
15
  import { ChannelCommandService } from "../shared/channel-command-service.js";
16
16
  import { runChannelPeerPrompt } from "../shared/channel-peer-prompt.js";
17
17
  import { deliverChannelAction } from "../shared/channel-runtime.js";
@@ -585,17 +585,31 @@ export function createBot(config, registry) {
585
585
  function appendActivity(input) {
586
586
  return activityStore.append(input);
587
587
  }
588
+ const appendTelegramBridgeActivity = createChannelActivityRecorder({
589
+ source: "telegram",
590
+ workspace: config.workspace,
591
+ activityStore,
592
+ actorFor: (request) => telegramActivityActor(request.ctx),
593
+ });
588
594
  function appendTelegramActivity(ctx, contextKey, session, input) {
589
595
  const info = session.getInfo();
590
- return appendActivity({
591
- source: "telegram",
596
+ const event = appendTelegramBridgeActivity({
592
597
  contextKey,
598
+ context: telegramChannelContextFromCtx(ctx) ?? {
599
+ channelId: "telegram",
600
+ chatId: String(ctx.chat?.id ?? contextKey),
601
+ userId: ctx.from?.id !== undefined ? String(ctx.from.id) : undefined,
602
+ username: ctx.from?.username,
603
+ },
604
+ authUser: getAuthenticatedUser(ctx) ?? undefined,
605
+ ctx,
606
+ }, {
593
607
  ...input,
594
608
  threadId: input.threadId ?? info.threadId,
595
609
  workspace: input.workspace ?? info.workspace,
596
610
  agentId: input.agentId ?? idOf(info),
597
- actor: input.actor ?? telegramActivityActor(ctx),
598
611
  });
612
+ return event;
599
613
  }
600
614
  function recordTelegramAgentUpdateLifecycle(job) {
601
615
  const previous = agentUpdateStates.get(job.id);
@@ -0,0 +1,22 @@
1
+ export function normalizeCursorLimit(value, fallback = 100, max = 500) {
2
+ const parsed = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
3
+ const selected = Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
4
+ return Math.max(1, Math.min(max, selected));
5
+ }
6
+ export function cursorPage(items, cursor, limit, cursorOf) {
7
+ const normalizedLimit = normalizeCursorLimit(limit, limit);
8
+ const startIndex = cursor ? Math.max(0, items.findIndex((item) => cursorOf(item) === cursor) + 1) : 0;
9
+ const window = items.slice(startIndex, startIndex + normalizedLimit + 1);
10
+ const pageItems = window.slice(0, normalizedLimit);
11
+ const hasNext = window.length > normalizedLimit;
12
+ const last = pageItems.at(-1);
13
+ return {
14
+ items: pageItems,
15
+ pagination: {
16
+ limit: normalizedLimit,
17
+ nextCursor: hasNext && last ? cursorOf(last) ?? null : null,
18
+ hasNext,
19
+ total: items.length,
20
+ },
21
+ };
22
+ }
@@ -61,6 +61,22 @@ export class PeerStore {
61
61
  });
62
62
  return { invitation: publicInvitation(invitation), code };
63
63
  }
64
+ createRotationInvitation(id, options = {}) {
65
+ const peer = this.get(id);
66
+ if (!peer) {
67
+ throw new Error("Peer not found.");
68
+ }
69
+ const created = this.createInvitation({
70
+ name: `${peer.name} rotation`,
71
+ group: peer.group,
72
+ expiresInMs: options.expiresInMs,
73
+ scopes: peer.scopes,
74
+ allowedAgents: peer.allowedAgents,
75
+ allowedWorkspaceRoots: peer.allowedWorkspaceRoots,
76
+ workspaceAliases: peer.workspaceAliases,
77
+ });
78
+ return { peer: publicPeer(peer), ...created };
79
+ }
64
80
  consumeInvitation(code, usedByNodeId) {
65
81
  const trimmed = code.trim();
66
82
  if (!trimmed) {
@@ -13,6 +13,7 @@ export const DEFAULT_PEER_SCOPES = [
13
13
  "logs.read",
14
14
  ];
15
15
  export function publicPeer(record) {
16
+ const trust = peerTrust(record);
16
17
  return {
17
18
  id: record.id,
18
19
  name: record.name,
@@ -36,6 +37,8 @@ export function publicPeer(record) {
36
37
  remoteStatus: record.remoteStatus,
37
38
  lastError: record.lastError,
38
39
  healthHistory: record.healthHistory?.map((sample) => ({ ...sample })),
40
+ trustStatus: trust.status,
41
+ trustWarnings: trust.warnings,
39
42
  effectiveAccess: {
40
43
  scopes: [...record.scopes],
41
44
  allowedAgents: [...record.allowedAgents],
@@ -44,6 +47,22 @@ export function publicPeer(record) {
44
47
  },
45
48
  };
46
49
  }
50
+ function peerTrust(record) {
51
+ const warnings = [];
52
+ if (!record.enabled) {
53
+ warnings.push("Peer is disabled.");
54
+ return { status: "disabled", warnings };
55
+ }
56
+ if (record.lastError) {
57
+ warnings.push(record.lastError);
58
+ return { status: "error", warnings };
59
+ }
60
+ if (record.url?.startsWith("https://") && !record.tlsFingerprint) {
61
+ warnings.push("TLS fingerprint is not pinned yet. Probe or re-pin this peer before using it over untrusted networks.");
62
+ return { status: "tls-unpinned", warnings };
63
+ }
64
+ return { status: "trusted", warnings };
65
+ }
47
66
  export function publicInvitation(record) {
48
67
  return {
49
68
  id: record.id,
@@ -19,6 +19,7 @@ const LOCAL_ONLY_ROUTE_PATHS = new Set([
19
19
  "/api/peers/invitations/:id",
20
20
  "/api/peers/:id",
21
21
  "/api/peers/:id/repin",
22
+ "/api/peers/:id/rotate",
22
23
  "/api/peers/:id/health",
23
24
  "/api/peers/:id/proxy",
24
25
  "/api/peers/:id/events",
@@ -52,6 +53,7 @@ const IMPLEMENTED_ROUTE_PATHS = new Set([
52
53
  "/api/progress",
53
54
  "/api/metrics",
54
55
  "/api/jobs",
56
+ "/api/trace",
55
57
  "/api/jobs/:id/log",
56
58
  "/api/jobs/:id/action",
57
59
  "/api/active-sessions",
@@ -127,6 +127,7 @@ export class RelayExternalActivityMonitor {
127
127
  role: "agent",
128
128
  text: finalText,
129
129
  source: "cli",
130
+ correlationId: externalCorrelationId(snapshot),
130
131
  turnId: terminalEvent.turnId ?? undefined,
131
132
  key: externalMessageKey("final", snapshot, terminalEvent.lineNumber),
132
133
  });
@@ -138,6 +139,7 @@ export class RelayExternalActivityMonitor {
138
139
  type: "turn_complete",
139
140
  id: terminalEvent.turnId ?? "cli",
140
141
  at: terminalEvent.timestamp?.toISOString() ?? new Date().toISOString(),
142
+ correlationId: externalCorrelationId(snapshot),
141
143
  });
142
144
  }
143
145
  this.options.appendActivity({
@@ -148,6 +150,7 @@ export class RelayExternalActivityMonitor {
148
150
  workspace: info.workspace,
149
151
  agentId: info.agentId,
150
152
  actor: CLI_ACTIVITY_ACTOR,
153
+ correlationId: externalCorrelationId(snapshot),
151
154
  prompt: snapshot.latestUserMessage ?? undefined,
152
155
  detail: `${snapshot.agentLabel} CLI task ${terminalEvent.status ?? "finished"}.`,
153
156
  durationMs: durationFromDates(externalStartedAt, terminalEvent.timestamp),
@@ -176,6 +179,7 @@ export class RelayExternalActivityMonitor {
176
179
  role: "system",
177
180
  text: `Working on ${trimLine(prompt, 500)}`,
178
181
  source: "cli",
182
+ correlationId: externalCorrelationId(snapshot),
179
183
  turnId: snapshot.activity.turnId ?? undefined,
180
184
  timestamp: snapshot.activity.startedAt?.toISOString(),
181
185
  key: externalMessageKey("working", snapshot),
@@ -193,6 +197,7 @@ export class RelayExternalActivityMonitor {
193
197
  workspace: info.workspace,
194
198
  agentId: info.agentId,
195
199
  actor: CLI_ACTIVITY_ACTOR,
200
+ correlationId: externalCorrelationId(snapshot),
196
201
  prompt,
197
202
  detail: `${snapshot.sourceLabel}: ${snapshot.sourcePath}`,
198
203
  });
@@ -206,6 +211,7 @@ export class RelayExternalActivityMonitor {
206
211
  id: snapshot.activity.turnId ?? "cli",
207
212
  toolCallId: `cli-${event.lineNumber}`,
208
213
  toolName: event.toolName ?? "tool",
214
+ correlationId: externalCorrelationId(snapshot),
209
215
  });
210
216
  }
211
217
  this.options.appendActivity({
@@ -216,6 +222,7 @@ export class RelayExternalActivityMonitor {
216
222
  workspace: info.workspace,
217
223
  agentId: info.agentId,
218
224
  actor: CLI_ACTIVITY_ACTOR,
225
+ correlationId: externalCorrelationId(snapshot),
219
226
  detail: event.toolName ?? "tool",
220
227
  });
221
228
  }
@@ -226,6 +233,7 @@ export class RelayExternalActivityMonitor {
226
233
  id: snapshot.activity.turnId ?? "cli",
227
234
  toolCallId: `cli-${event.lineNumber}`,
228
235
  isError: false,
236
+ correlationId: externalCorrelationId(snapshot),
229
237
  });
230
238
  }
231
239
  this.options.appendActivity({
@@ -236,6 +244,7 @@ export class RelayExternalActivityMonitor {
236
244
  workspace: info.workspace,
237
245
  agentId: info.agentId,
238
246
  actor: CLI_ACTIVITY_ACTOR,
247
+ correlationId: externalCorrelationId(snapshot),
239
248
  detail: event.toolName ?? "tool",
240
249
  });
241
250
  }
@@ -246,6 +255,7 @@ export class RelayExternalActivityMonitor {
246
255
  id: snapshot.activity.turnId ?? "cli",
247
256
  toolCallId: `cli-${event.lineNumber}`,
248
257
  isError: true,
258
+ correlationId: externalCorrelationId(snapshot),
249
259
  });
250
260
  }
251
261
  this.options.appendActivity({
@@ -276,6 +286,7 @@ export class RelayExternalActivityMonitor {
276
286
  role: event.kind === "tool" ? "tool" : "system",
277
287
  text: rendered.plain,
278
288
  source: "cli",
289
+ correlationId: externalCorrelationId(snapshot),
279
290
  turnId: event.turnId ?? snapshot.activity.turnId ?? undefined,
280
291
  timestamp: event.timestamp?.toISOString(),
281
292
  key: externalMessageKey("event", snapshot, event.lineNumber),
@@ -298,6 +309,7 @@ export class RelayExternalActivityMonitor {
298
309
  role: "system",
299
310
  text: text ?? renderExternalMirrorStatus(snapshot, this.options.queueLength()).plain,
300
311
  source: "cli",
312
+ correlationId: externalCorrelationId(snapshot),
301
313
  turnId: snapshot.activity.turnId ?? undefined,
302
314
  key: externalMessageKey("status", snapshot),
303
315
  });
@@ -318,6 +330,9 @@ function externalMessageKey(kind, snapshot, lineNumber) {
318
330
  lineNumber ?? "",
319
331
  ].join(":");
320
332
  }
333
+ function externalCorrelationId(snapshot) {
334
+ return `cli:${snapshot.agentId}:${snapshot.activity.turnId ?? snapshot.threadId}`;
335
+ }
321
336
  function externalStatusLine(snapshot, queueLength) {
322
337
  const elapsed = snapshot.activity.startedAt
323
338
  ? formatDuration((Date.now() - snapshot.activity.startedAt.getTime()) / 1000)
@@ -60,6 +60,7 @@ export function queueItemDto(item) {
60
60
  description: item.description,
61
61
  createdAt: new Date(item.createdAt).toISOString(),
62
62
  attempts: item.attempts ?? 0,
63
+ correlationId: item.correlationId,
63
64
  notBefore: item.notBefore ? new Date(item.notBefore).toISOString() : undefined,
64
65
  lastError: item.lastError,
65
66
  };
@@ -111,6 +111,9 @@ export async function relayRuntimeMetrics(runtime) {
111
111
  export function relayRuntimeAudit(runtime, options = 50) {
112
112
  return runtime.auditStore.list(options);
113
113
  }
114
+ export function relayRuntimeAuditPage(runtime, options = {}) {
115
+ return runtime.auditStore.listPage(options);
116
+ }
114
117
  export async function relayRuntimeSupportBundle(runtime, actor) {
115
118
  const bundle = await createSupportBundle({
116
119
  config: runtime.config,