agent-relay-server 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.
@@ -0,0 +1,693 @@
1
+ import { HUMAN_AGENT_ID, CLOSED_TASK_STATUSES, WAITING_TASK_STATUSES, STATUS_SORT_ORDER } from "./constants.js";
2
+ import {
3
+ isBuiltInAgent, isChannelAgent, agentType, isAgentStale, visibleAgents, messageBody,
4
+ messageLooksLikeQuestion, inboxPeer, isHumanInboundMessage, isUnreadHumanMessage,
5
+ maxMessageId, readCursorForPeer, draftForPeer, isClaimableTaskWaiting,
6
+ isClaimableMessageWaiting, targetMatchesAgent, messageMatchesChannel,
7
+ activityItem, toTimestamp,
8
+ } from "./utils.js";
9
+ import { agentAttention } from "./display.js";
10
+ import { baseUrl } from "./api.js";
11
+
12
+ export function createComputedDescriptors() {
13
+ return {
14
+ onlineCount: { get: getOnlineCount },
15
+ busyAgentCount: { get: getBusyAgentCount },
16
+ hiddenBuiltInAgentCount: { get: getHiddenBuiltInAgentCount },
17
+ sortedAgents: { get: getSortedAgents },
18
+ pairsByAgentId: { get: getPairsByAgentId },
19
+ selectedAgentDetail: { get: getSelectedAgentDetail },
20
+ agentDetailMessages: { get: getAgentDetailMessages },
21
+ selectedChannelDetail: { get: getSelectedChannelDetail },
22
+ channelDetailMessages: { get: getChannelDetailMessages },
23
+ pairMessagePair: { get: getPairMessagePair },
24
+ allInboxThreads: { get: getAllInboxThreads },
25
+ inboxThreads: { get: getInboxThreads },
26
+ selectedInboxThreadData: { get: getSelectedInboxThreadData },
27
+ selectedInboxMessages: { get: getSelectedInboxMessages },
28
+ inboxComposeTargetOptions: { get: getInboxComposeTargetOptions },
29
+ attentionSummary: { get: getAttentionSummary },
30
+ attentionAgentCount: { get: getAttentionAgentCount },
31
+ activityItems: { get: getActivityItems },
32
+ workQueueItems: { get: getWorkQueueItems },
33
+ channelCards: { get: getChannelCards },
34
+ connectorCards: { get: getConnectorCards },
35
+ integrationCards: { get: getIntegrationCards },
36
+ openPairCount: { get: getOpenPairCount },
37
+ filteredMessages: { get: getFilteredMessages },
38
+ groupedMessages: { get: getGroupedMessages },
39
+ filteredTasks: { get: getFilteredTasks },
40
+ composeAgents: { get: getComposeAgents },
41
+ uniqueLabels: { get: getUniqueLabels },
42
+ uniqueCaps: { get: getUniqueCaps },
43
+ uniqueTags: { get: getUniqueTags },
44
+ healthIssues: { get: getHealthIssues },
45
+ healthDiagnostics: { get: getHealthDiagnostics },
46
+ commandPaletteItems: { get: getCommandPaletteItems },
47
+ spawnAvailableProviders: { get: getSpawnAvailableProviders },
48
+ onlineOrchestratorCount: { get: getOnlineOrchestratorCount },
49
+ readyChannelCount: { get: getReadyChannelCount },
50
+ };
51
+ }
52
+
53
+ function getSpawnAvailableProviders() {
54
+ const orch = this.orchestrators.find((o) => o.id === this.spawnOrchId);
55
+ return orch ? orch.providers : ["claude", "codex"];
56
+ }
57
+
58
+ function getOnlineCount() {
59
+ return visibleAgents(this).filter((agent) => agent.status !== "offline").length;
60
+ }
61
+
62
+ function getBusyAgentCount() {
63
+ return this.agents.filter((agent) => agent.status === "busy").length;
64
+ }
65
+
66
+ function getHiddenBuiltInAgentCount() {
67
+ return this.showBuiltIns ? 0 : this.agents.filter(isBuiltInAgent).length;
68
+ }
69
+
70
+ function getSortedAgents() {
71
+ let list = visibleAgents(this);
72
+ list = applyAgentPreset(this, list);
73
+ if (!this.showOffline) list = list.filter((agent) => agent.status !== "offline");
74
+ if (this.agentStatusFilter === "starting") {
75
+ list = list.filter((agent) => agent.status !== "offline" && !agent.ready);
76
+ } else if (this.agentStatusFilter) {
77
+ list = list.filter((agent) => agent.status === this.agentStatusFilter);
78
+ }
79
+ if (this.agentTagFilter) {
80
+ list = list.filter((agent) => (agent.tags || []).includes(this.agentTagFilter));
81
+ }
82
+ const dir = this.agentSortDir === "desc" ? -1 : 1;
83
+ return list.sort((a, b) => compareAgents(this, a, b) * dir);
84
+ }
85
+
86
+ function getPairsByAgentId() {
87
+ const byAgent = {};
88
+ for (const pair of this.pairs || []) {
89
+ if (pair.requesterId) byAgent[pair.requesterId] = pair;
90
+ if (pair.targetId) byAgent[pair.targetId] = pair;
91
+ }
92
+ return byAgent;
93
+ }
94
+
95
+ function getSelectedAgentDetail() {
96
+ if (!this.agentDetailId) return null;
97
+ return this.agentsById[this.agentDetailId] || null;
98
+ }
99
+
100
+ function getAgentDetailMessages() {
101
+ if (!this.agentDetailId) return [];
102
+ return this.messages
103
+ .filter((msg) => msg.from === this.agentDetailId || msg.to === this.agentDetailId)
104
+ .slice()
105
+ .sort((a, b) => b.id - a.id)
106
+ .slice(0, 8);
107
+ }
108
+
109
+ function getSelectedChannelDetail() {
110
+ if (!this.channelDetailId) return null;
111
+ return (this.channels || []).find((channel) => channel.id === this.channelDetailId) || null;
112
+ }
113
+
114
+ function getChannelDetailMessages() {
115
+ const channel = this.selectedChannelDetail;
116
+ if (!channel) return [];
117
+ return this.messages
118
+ .filter((msg) => messageMatchesChannel(msg, channel))
119
+ .slice()
120
+ .sort((a, b) => b.id - a.id)
121
+ .slice(0, 8);
122
+ }
123
+
124
+ function getPairMessagePair() {
125
+ return this.pairs.find((pair) => pair.id === this.pairMessage.pairId) || null;
126
+ }
127
+
128
+ function getAllInboxThreads() {
129
+ return buildInboxThreads(this);
130
+ }
131
+
132
+ function getInboxThreads() {
133
+ const search = this.inboxSearch.trim().toLowerCase();
134
+ const filtered = this.allInboxThreads.filter((thread) => {
135
+ if (!this.inboxShowArchived && thread.archived) return false;
136
+ if (search && !threadMatchesSearch(this, thread, search)) return false;
137
+ return true;
138
+ });
139
+ return sortInboxThreads(filtered, this.inboxSort, this.inboxSortDir);
140
+ }
141
+
142
+ function sortInboxThreads(threads, sort, dir) {
143
+ const mul = dir === "asc" ? 1 : -1;
144
+ return [...threads].sort((a, b) => {
145
+ switch (sort) {
146
+ case "updated":
147
+ return mul * ((b.lastMessage?.id || 0) - (a.lastMessage?.id || 0));
148
+ case "created": {
149
+ const aFirst = a.messages[0]?.id || 0;
150
+ const bFirst = b.messages[0]?.id || 0;
151
+ return mul * (bFirst - aFirst);
152
+ }
153
+ case "name":
154
+ return mul * String(a.peer).localeCompare(String(b.peer));
155
+ case "attention":
156
+ default:
157
+ return compareInboxThreads(a, b) * mul;
158
+ }
159
+ });
160
+ }
161
+
162
+ function buildInboxThreads(vm) {
163
+ const threads = new Map();
164
+ for (const msg of vm.messages) {
165
+ const peer = inboxPeer(msg);
166
+ if (!peer) continue;
167
+ if (!threads.has(peer)) threads.set(peer, { id: peer, peer, messages: [], lastMessage: null });
168
+ threads.get(peer).messages.push(msg);
169
+ }
170
+ for (const thread of threads.values()) {
171
+ thread.messages.sort((a, b) => a.id - b.id);
172
+ thread.lastMessage = thread.messages[thread.messages.length - 1] || null;
173
+ thread.attention = getThreadAttention(vm, thread);
174
+ thread.archived = isInboxThreadArchived(vm, thread);
175
+ thread.draft = draftForPeer(vm, thread.peer);
176
+ }
177
+ return [...threads.values()].sort(compareInboxThreads);
178
+ }
179
+
180
+ function threadMatchesSearch(vm, thread, search) {
181
+ const haystack = [
182
+ vm.displayTarget(thread.peer),
183
+ thread.peer,
184
+ ...thread.messages.flatMap((msg) => [msg.subject || "", messageBody(msg) || "", msg.channel || "", vm.displayTarget(msg.from), vm.displayTarget(msg.to)]),
185
+ ].join("\n").toLowerCase();
186
+ return haystack.includes(search);
187
+ }
188
+
189
+ function isInboxThreadArchived(vm, thread) {
190
+ const archivedAtId = Number(vm.inboxArchivedThreads?.[thread.peer] || 0);
191
+ return Boolean(thread.lastMessage?.id && archivedAtId >= thread.lastMessage.id);
192
+ }
193
+
194
+ function compareInboxThreads(a, b) {
195
+ const scoreDelta = (b.attention?.score || 0) - (a.attention?.score || 0);
196
+ if (scoreDelta !== 0) return scoreDelta;
197
+ return (b.lastMessage?.id || 0) - (a.lastMessage?.id || 0);
198
+ }
199
+
200
+ function getSelectedInboxThreadData() {
201
+ if (!this.selectedInboxThread) return null;
202
+ return this.inboxThreads.find((thread) => thread.id === this.selectedInboxThread) || null;
203
+ }
204
+
205
+ function getSelectedInboxMessages() {
206
+ return this.selectedInboxThreadData?.messages || [];
207
+ }
208
+
209
+ function getInboxComposeTargetOptions() {
210
+ if (this.inboxCompose.toMode === "tag") return this.uniqueTags.map((value) => ({ value, label: "#" + value }));
211
+ if (this.inboxCompose.toMode === "cap") return this.uniqueCaps.map((value) => ({ value, label: value }));
212
+ return this.composeAgents.map((agent) => ({ value: agent.id, label: `${this.displayName(agent)} [${agent.id.slice(-6)}]` }));
213
+ }
214
+
215
+ function getAttentionSummary() {
216
+ const threads = this.allInboxThreads.filter((thread) => !thread.archived);
217
+ const pendingPairInvites = this.pairs.filter((pair) => pair.status === "pending").length;
218
+ const claimableTasks = countClaimableWaiting(this);
219
+ const unreadInbox = threads.reduce((sum, thread) => sum + (thread.attention?.unread || 0), 0);
220
+ const agentQuestions = threads.filter((thread) => thread.attention?.agentQuestion).length;
221
+ return {
222
+ unreadInbox,
223
+ needsHumanResponse: 0,
224
+ agentQuestions,
225
+ pendingPairInvites,
226
+ claimableTasks,
227
+ total: unreadInbox + agentQuestions + pendingPairInvites + claimableTasks,
228
+ };
229
+ }
230
+
231
+ function getAttentionAgentCount() {
232
+ return this.sortedAgents.filter((agent) => this.agentAttention(agent).total > 0).length;
233
+ }
234
+
235
+ function getActivityItems() {
236
+ const items = [
237
+ ...serverActivityItems(this),
238
+ ...messageActivityItems(this),
239
+ ...pairActivityItems(this),
240
+ ...taskActivityItems(this),
241
+ ...operatorActivityItems(this),
242
+ ].filter((item) => item.ts);
243
+
244
+ const filter = this.activityFilter;
245
+ const filtered = filter ? items.filter((item) => item.kind === filter) : items;
246
+ return filtered.sort((a, b) => b.ts - a.ts).slice(0, 150);
247
+ }
248
+
249
+ function getWorkQueueItems() {
250
+ const taskItems = (this.tasks || [])
251
+ .filter((task) => !CLOSED_TASK_STATUSES.has(task.status))
252
+ .map((task) => ({
253
+ id: "task-" + task.id,
254
+ sourceType: "task",
255
+ title: task.title,
256
+ body: task.body,
257
+ severity: task.severity || "info",
258
+ status: task.status,
259
+ owner: task.claimedBy || "",
260
+ target: task.target,
261
+ source: task.source,
262
+ channel: task.channel || "",
263
+ updatedAt: task.updatedAt || task.createdAt,
264
+ createdAt: task.createdAt,
265
+ claimable: isClaimableTaskWaiting(task),
266
+ task,
267
+ }));
268
+
269
+ const messageItems = (this.messages || [])
270
+ .filter(isClaimableMessageWaiting)
271
+ .map((msg) => ({
272
+ id: "message-" + msg.id,
273
+ sourceType: "message",
274
+ title: msg.subject || "Claimable message #" + msg.id,
275
+ body: messageBody(msg),
276
+ severity: "warning",
277
+ status: msg.claimedBy ? "claimed" : "open",
278
+ owner: msg.claimedBy || "",
279
+ target: msg.to,
280
+ source: "message",
281
+ channel: msg.channel || "",
282
+ updatedAt: msg.claimedAt || msg.createdAt,
283
+ createdAt: msg.createdAt,
284
+ claimable: isClaimableMessageWaiting(msg),
285
+ message: msg,
286
+ }));
287
+
288
+ return [...taskItems, ...messageItems].sort(compareWorkQueueItems);
289
+ }
290
+
291
+ function compareWorkQueueItems(a, b) {
292
+ const claimableDelta = Number(b.claimable) - Number(a.claimable);
293
+ if (claimableDelta !== 0) return claimableDelta;
294
+ const severityOrder = { critical: 0, warning: 1, info: 2 };
295
+ const severityDelta = (severityOrder[a.severity] ?? 9) - (severityOrder[b.severity] ?? 9);
296
+ if (severityDelta !== 0) return severityDelta;
297
+ return toTimestamp(a.updatedAt) - toTimestamp(b.updatedAt);
298
+ }
299
+
300
+ function serverActivityItems(vm) {
301
+ return (vm.activityEvents || []).map((event) => activityItem({
302
+ id: "activity-" + event.id,
303
+ clientId: event.clientId,
304
+ kind: event.kind,
305
+ ts: toTimestamp(event.createdAt),
306
+ icon: event.icon,
307
+ title: event.title,
308
+ body: event.body,
309
+ meta: event.meta,
310
+ view: event.view,
311
+ peer: event.peer,
312
+ messageId: event.messageId,
313
+ pairId: event.pairId,
314
+ taskId: event.taskId,
315
+ agentId: event.agentId,
316
+ }));
317
+ }
318
+
319
+ function messageActivityItems(vm) {
320
+ return (vm.messages || []).flatMap((msg) => {
321
+ const ts = toTimestamp(msg.createdAt);
322
+ const items = [];
323
+ const pairEvent = msg.payload?.pairEvent;
324
+ if (pairEvent) {
325
+ items.push(activityItem({
326
+ id: "pair-message-" + msg.id,
327
+ kind: pairEvent === "message" ? "pair" : "state",
328
+ ts,
329
+ icon: pairEvent === "message" ? "ti-messages" : "ti-link",
330
+ title: pairEvent === "message" ? "Pair message" : "Pair " + pairEvent,
331
+ body: vm.messagePreview(msg),
332
+ meta: `${vm.displayTarget(msg.from)} -> ${vm.displayTarget(msg.to)}`,
333
+ view: "pairs",
334
+ messageId: msg.id,
335
+ }));
336
+ } else if (msg.from === HUMAN_AGENT_ID) {
337
+ items.push(activityItem({
338
+ id: "human-send-" + msg.id,
339
+ kind: msg.kind === "task" ? "task" : "message",
340
+ ts,
341
+ icon: msg.kind === "task" ? "ti-hand-grab" : "ti-send",
342
+ title: msg.kind === "task" ? "Claimable task sent" : "Message sent",
343
+ body: vm.messagePreview(msg),
344
+ meta: "to " + vm.displayTarget(msg.to),
345
+ view: inboxPeer(msg) ? "inbox" : "messages",
346
+ peer: inboxPeer(msg),
347
+ messageId: msg.id,
348
+ }));
349
+ } else if (msg.to === HUMAN_AGENT_ID) {
350
+ items.push(activityItem({
351
+ id: "agent-reply-" + msg.id,
352
+ kind: messageLooksLikeQuestion(msg) ? "question" : "reply",
353
+ ts,
354
+ icon: messageLooksLikeQuestion(msg) ? "ti-help-circle" : "ti-message-reply",
355
+ title: messageLooksLikeQuestion(msg) ? "Agent asked a question" : "Agent replied",
356
+ body: vm.messagePreview(msg),
357
+ meta: "from " + vm.displayTarget(msg.from),
358
+ view: "inbox",
359
+ peer: msg.from,
360
+ agentId: msg.from,
361
+ messageId: msg.id,
362
+ }));
363
+ } else {
364
+ items.push(activityItem({
365
+ id: "message-" + msg.id,
366
+ kind: "message",
367
+ ts,
368
+ icon: "ti-messages",
369
+ title: "Agent message",
370
+ body: vm.messagePreview(msg),
371
+ meta: `${vm.displayTarget(msg.from)} -> ${vm.displayTarget(msg.to)}`,
372
+ view: "messages",
373
+ messageId: msg.id,
374
+ }));
375
+ }
376
+ if (msg.claimedBy) {
377
+ items.push(activityItem({
378
+ id: "claim-" + msg.id,
379
+ kind: "task",
380
+ ts: toTimestamp(msg.claimedAt || msg.createdAt),
381
+ icon: "ti-user-check",
382
+ title: "Task claimed",
383
+ body: vm.messagePreview(msg),
384
+ meta: "by " + vm.displayTarget(msg.claimedBy),
385
+ view: "tasks",
386
+ messageId: msg.id,
387
+ agentId: msg.claimedBy,
388
+ }));
389
+ }
390
+ return items;
391
+ });
392
+ }
393
+
394
+ function pairActivityItems(vm) {
395
+ return (vm.pairs || []).flatMap((pair) => {
396
+ const created = activityItem({
397
+ id: "pair-created-" + pair.id,
398
+ kind: "pair",
399
+ ts: toTimestamp(pair.createdAt),
400
+ icon: "ti-link-plus",
401
+ title: "Pair invite created",
402
+ body: pair.objective || "",
403
+ meta: `${vm.displayTarget(pair.requesterId)} <-> ${vm.displayTarget(pair.targetId)}`,
404
+ view: "pairs",
405
+ pairId: pair.id,
406
+ });
407
+ const statusTs = toTimestamp(pair.updatedAt || pair.acceptedAt || pair.endedAt);
408
+ if (!statusTs || statusTs === created.ts || pair.status === "pending") return [created];
409
+ return [created, activityItem({
410
+ id: "pair-status-" + pair.id + "-" + pair.status,
411
+ kind: "state",
412
+ ts: statusTs,
413
+ icon: pair.status === "active" ? "ti-link" : "ti-phone-off",
414
+ title: "Pair " + pair.status,
415
+ body: pair.objective || "",
416
+ meta: pair.endedBy ? "by " + vm.displayTarget(pair.endedBy) : `${vm.displayTarget(pair.requesterId)} <-> ${vm.displayTarget(pair.targetId)}`,
417
+ view: "pairs",
418
+ pairId: pair.id,
419
+ })];
420
+ });
421
+ }
422
+
423
+ function taskActivityItems(vm) {
424
+ return (vm.tasks || []).map((task) => activityItem({
425
+ id: "task-" + task.id + "-" + task.status,
426
+ kind: "task",
427
+ ts: toTimestamp(task.updatedAt || task.createdAt),
428
+ icon: task.claimedBy ? "ti-user-check" : "ti-checkup-list",
429
+ title: task.status === "open" ? "Claimable task waiting" : "Task " + task.status,
430
+ body: task.title || task.body || "",
431
+ meta: task.claimedBy ? "claimed by " + vm.displayTarget(task.claimedBy) : vm.displayTarget(task.target),
432
+ view: "tasks",
433
+ taskId: task.id,
434
+ agentId: task.claimedBy || task.target,
435
+ }));
436
+ }
437
+
438
+ function operatorActivityItems(vm) {
439
+ const serverClientIds = new Set((vm.activityEvents || []).map((event) => event.clientId).filter(Boolean));
440
+ return (vm.operatorActivity || []).map((item) => activityItem({
441
+ ...item,
442
+ id: item.id || "operator-" + item.ts + "-" + item.title,
443
+ clientId: item.clientId || item.id,
444
+ })).filter((item) => !serverClientIds.has(item.clientId));
445
+ }
446
+
447
+ function getThreadAttention(vm, thread) {
448
+ const lastHumanReplyId = maxMessageId(thread.messages, (msg) => msg.from === HUMAN_AGENT_ID);
449
+ const unread = thread.messages.filter((msg) => isUnreadHumanMessage(vm, thread.peer, msg)).length;
450
+ const agentQuestion = thread.messages.some((msg) =>
451
+ isHumanInboundMessage(msg) && msg.id > lastHumanReplyId && messageLooksLikeQuestion(msg)
452
+ );
453
+ return {
454
+ unread,
455
+ needsHumanResponse: false,
456
+ agentQuestion,
457
+ score: unread * 10 + (agentQuestion ? 3 : 0),
458
+ };
459
+ }
460
+
461
+ function countClaimableWaiting(vm) {
462
+ const taskCount = vm.tasks.filter(isClaimableTaskWaiting).length;
463
+ const messageCount = vm.messages.filter(isClaimableMessageWaiting).length;
464
+ return taskCount + messageCount;
465
+ }
466
+
467
+ function getFilteredMessages() {
468
+ if (!this.tagFilter) return this.messages;
469
+ return this.messages.filter((msg) => messageMatchesTag(this, msg, this.tagFilter));
470
+ }
471
+
472
+ function messageMatchesTag(vm, msg, tag) {
473
+ if (msg.to === "tag:" + tag) return true;
474
+ if (vm.agentsById[msg.from]?.tags?.includes(tag)) return true;
475
+ if (vm.agentsById[msg.to]?.tags?.includes(tag)) return true;
476
+ return false;
477
+ }
478
+
479
+ function getGroupedMessages() {
480
+ const threads = new Map();
481
+ for (const msg of this.filteredMessages) {
482
+ const threadId = msg.threadId || msg.id;
483
+ if (!threads.has(threadId)) threads.set(threadId, { threadId, messages: [] });
484
+ threads.get(threadId).messages.push(msg);
485
+ }
486
+ for (const group of threads.values()) {
487
+ group.messages.sort((a, b) => a.id - b.id);
488
+ }
489
+ return [...threads.values()].sort(compareThreadGroups);
490
+ }
491
+
492
+ function compareThreadGroups(a, b) {
493
+ const aLast = a.messages[a.messages.length - 1].id;
494
+ const bLast = b.messages[b.messages.length - 1].id;
495
+ return bLast - aLast;
496
+ }
497
+
498
+ function getFilteredTasks() {
499
+ if (this.taskStatusFilter) return this.tasks;
500
+ return this.tasks.filter((task) => !CLOSED_TASK_STATUSES.has(task.status));
501
+ }
502
+
503
+ function getIntegrationCards() {
504
+ return [...(this.integrations || [])].sort((a, b) => {
505
+ const aStats = a.taskStats || {};
506
+ const bStats = b.taskStats || {};
507
+ const openDiff = (bStats.openTasks || 0) - (aStats.openTasks || 0);
508
+ if (openDiff !== 0) return openDiff;
509
+ const waitingDiff = (bStats.waitingTasks || 0) - (aStats.waitingTasks || 0);
510
+ if (waitingDiff !== 0) return waitingDiff;
511
+ return String(a.name || "").localeCompare(String(b.name || ""));
512
+ });
513
+ }
514
+
515
+ function getChannelCards() {
516
+ return [...(this.channels || [])].sort((a, b) => {
517
+ const healthRank = { error: 0, warning: 1, ok: 2 };
518
+ const aHealth = a.targetHealth?.status || "ok";
519
+ const bHealth = b.targetHealth?.status || "ok";
520
+ const healthDiff = (healthRank[aHealth] ?? 2) - (healthRank[bHealth] ?? 2);
521
+ if (healthDiff !== 0) return healthDiff;
522
+ const readyDiff = Number(Boolean(b.ready)) - Number(Boolean(a.ready));
523
+ if (readyDiff !== 0) return readyDiff;
524
+ const statusDiff = String(a.status || "").localeCompare(String(b.status || ""));
525
+ if (statusDiff !== 0) return statusDiff;
526
+ return String(a.name || "").localeCompare(String(b.name || ""));
527
+ });
528
+ }
529
+
530
+ function getConnectorCards() {
531
+ return [...(this.connectors || [])].sort((a, b) => {
532
+ const statusRank = { error: 0, warn: 1, unknown: 2, ok: 3 };
533
+ const aStatus = a.runtime?.status || "unknown";
534
+ const bStatus = b.runtime?.status || "unknown";
535
+ const statusDiff = (statusRank[aStatus] ?? 2) - (statusRank[bStatus] ?? 2);
536
+ if (statusDiff !== 0) return statusDiff;
537
+ return String(a.displayName || a.id || "").localeCompare(String(b.displayName || b.id || ""));
538
+ });
539
+ }
540
+
541
+ function getOpenPairCount() {
542
+ return (this.pairs || []).filter((pair) => pair?.status === "active" || pair?.status === "pending").length;
543
+ }
544
+
545
+ function getComposeAgents() {
546
+ const list = visibleAgents(this);
547
+ return this.showOffline ? list : list.filter((agent) => agent.status !== "offline");
548
+ }
549
+
550
+ function getUniqueLabels() {
551
+ return [...new Set(visibleAgents(this).filter((agent) => agent.label).map((agent) => agent.label))];
552
+ }
553
+
554
+ function getUniqueCaps() {
555
+ return [...new Set(visibleAgents(this).flatMap((agent) => agent.capabilities || []))];
556
+ }
557
+
558
+ function getUniqueTags() {
559
+ return [...new Set(visibleAgents(this).flatMap((agent) => agent.tags || []))];
560
+ }
561
+
562
+ function applyAgentPreset(vm, list) {
563
+ switch (vm.agentPresetFilter) {
564
+ case "active":
565
+ return list.filter((agent) => agent.status !== "offline");
566
+ case "offline_stale":
567
+ return list.filter((agent) => agent.status === "offline" || isAgentStale(vm, agent));
568
+ case "claude":
569
+ case "codex":
570
+ return list.filter((agent) => agentType(agent) === vm.agentPresetFilter);
571
+ case "paired":
572
+ return list.filter((agent) => Boolean(vm.agentPair(agent)));
573
+ case "unpaired":
574
+ return list.filter((agent) => !vm.agentPair(agent));
575
+ case "waiting":
576
+ return list.filter((agent) => vm.agentAttention(agent).total > 0);
577
+ case "claimable":
578
+ return list.filter((agent) => vm.agentAttention(agent).claimableTasks > 0);
579
+ case "errors":
580
+ return list.filter((agent) => agent.status === "offline" || (agent.status !== "offline" && !agent.ready) || isAgentStale(vm, agent));
581
+ default:
582
+ return list;
583
+ }
584
+ }
585
+
586
+ function getHealthIssues() {
587
+ return (this.health?.checks || []).filter((check) => check.status !== "ok");
588
+ }
589
+
590
+ function getHealthDiagnostics() {
591
+ return this.healthIssues.map((check) => {
592
+ const base = {
593
+ name: check.name,
594
+ status: check.status,
595
+ detail: check.detail || check.name,
596
+ impact: healthImpact(check),
597
+ actions: [
598
+ { label: "Inspect logs", icon: "ti-file-search", copy: "agent-relay daemon logs" },
599
+ { label: "Restart daemon", icon: "ti-refresh", copy: "agent-relay daemon restart" },
600
+ { label: "Copy env", icon: "ti-copy", copy: "agent-relay doctor" },
601
+ ],
602
+ };
603
+ if (check.name === "stale-live-agents") {
604
+ base.actions.unshift(
605
+ { label: "Run reaper", icon: "ti-broom", api: "POST", path: "/system/reap" },
606
+ { label: "Show stale", icon: "ti-filter", view: "agents", preset: "offline_stale" }
607
+ );
608
+ } else if (check.name === "channel-delivery-targets") {
609
+ base.actions.unshift(
610
+ { label: "Open channels", icon: "ti-messages", view: "channels" },
611
+ { label: "Run reaper", icon: "ti-broom", api: "POST", path: "/system/reap" }
612
+ );
613
+ } else if (check.name === "expired-message-claims" || check.name === "expired-task-claims" || check.name === "offline-claimed-tasks") {
614
+ base.actions.unshift(
615
+ { label: "Run reaper", icon: "ti-broom", api: "POST", path: "/system/reap" },
616
+ { label: "Open work", icon: "ti-list-check", view: "work" }
617
+ );
618
+ }
619
+ return base;
620
+ });
621
+ }
622
+
623
+ function healthImpact(check) {
624
+ if (check.name === "database") return "Relay persistence is unavailable; messages, state, and audit writes may fail.";
625
+ if (check.name === "stale-live-agents") return "Agents may look online even though their heartbeat has stopped.";
626
+ if (check.name === "expired-message-claims") return "Claimable messages may be stuck until the reaper releases expired claims.";
627
+ if (check.name === "expired-task-claims") return "Tasks can appear owned by agents that no longer hold a live lease.";
628
+ if (check.name === "offline-claimed-tasks") return "Offline agents are still shown as owners for active work.";
629
+ if (check.name === "channel-delivery-targets") return "Inbound channel messages may be accepted but routed to no live delivery agent.";
630
+ return "Relay health is degraded for this check.";
631
+ }
632
+
633
+ function getCommandPaletteItems() {
634
+ const query = this.commandQuery.trim().toLowerCase();
635
+ const commands = [
636
+ commandItem("open-inbox", "Open inbox", "Human console", "ti-inbox", "openView", { view: "inbox" }),
637
+ commandItem("open-work", "Open work queue", "Claimable messages and tasks", "ti-list-check", "openView", { view: "work" }),
638
+ commandItem("show-stale-agents", "Show stale agents", "Filter agents to offline or stale heartbeat", "ti-filter", "agentPreset", { preset: "offline_stale" }),
639
+ commandItem("copy-relay-url", "Copy relay URL", baseUrl(), "ti-copy", "copy", { value: baseUrl() }),
640
+ commandItem("export-timeline-md", "Export full timeline as Markdown", "Activity audit trace", "ti-file-export", "exportActivity", { format: "markdown" }),
641
+ commandItem("export-timeline-json", "Export full timeline as JSON", "Activity audit trace", "ti-braces", "exportActivity", { format: "json" }),
642
+ ...this.composeAgents.slice(0, 12).map((agent) =>
643
+ commandItem("message-" + agent.id, "Message agent: " + this.displayName(agent), agent.id, "ti-send", "messageAgent", { agentId: agent.id })
644
+ ),
645
+ ...this.composeAgents.filter((agent) => agentType(agent) === "codex").slice(0, 8).map((agent) =>
646
+ commandItem("pair-codex-" + agent.id, "Pair Codex: " + this.displayName(agent), agent.id, "ti-link-plus", "pairAgent", { agentId: agent.id })
647
+ ),
648
+ ...this.uniqueTags.map((tag) =>
649
+ commandItem("filter-tag-" + tag, "Filter tag: " + tag, "#" + tag, "ti-tag", "filterTag", { tag })
650
+ ),
651
+ ];
652
+ if (!query) return commands.slice(0, 24);
653
+ return commands.filter((command) => command.search.includes(query)).slice(0, 24);
654
+ }
655
+
656
+ function commandItem(id, title, subtitle, icon, action, payload) {
657
+ return {
658
+ id,
659
+ title,
660
+ subtitle,
661
+ icon,
662
+ action,
663
+ payload: payload || {},
664
+ search: `${title}\n${subtitle || ""}\n${action}`.toLowerCase(),
665
+ };
666
+ }
667
+
668
+ function compareAgents(vm, a, b) {
669
+ if (vm.agentSort === "status") {
670
+ const attentionDelta = vm.agentAttention(b).score - vm.agentAttention(a).score;
671
+ if (attentionDelta !== 0) return attentionDelta;
672
+ }
673
+ switch (vm.agentSort) {
674
+ case "name":
675
+ return vm.displayName(a).localeCompare(vm.displayName(b));
676
+ case "status":
677
+ return (STATUS_SORT_ORDER[a.status] ?? 9) - (STATUS_SORT_ORDER[b.status] ?? 9);
678
+ case "lastSeen":
679
+ return new Date(b.lastSeen) - new Date(a.lastSeen);
680
+ case "created":
681
+ return new Date(b.createdAt) - new Date(a.createdAt);
682
+ default:
683
+ return 0;
684
+ }
685
+ }
686
+
687
+ function getOnlineOrchestratorCount() {
688
+ return this.orchestrators.filter((o) => o.status === "online").length;
689
+ }
690
+
691
+ function getReadyChannelCount() {
692
+ return this.channelCards.filter((item) => item.ready).length;
693
+ }