agent-relay-server 0.4.22 → 0.4.24

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.
@@ -1,7 +1,13 @@
1
1
  (() => {
2
2
  const PREF_PREFIX = "ar-";
3
+ const HUMAN_AGENT_ID = "user";
4
+ const INBOX_OPERATOR_ID = HUMAN_AGENT_ID;
3
5
  const DEFAULT_COMPOSE = { from: "", to: "", body: "", channel: "", subject: "", claimable: false };
6
+ const DEFAULT_INBOX_COMPOSE = { toMode: "agent", to: "", body: "", channel: "", subject: "", claimable: false };
7
+ const DEFAULT_PAIR_MESSAGE = { pairId: "", from: "", body: "", subject: "" };
8
+ const DEFAULT_PAIR_INVITE = { requesterId: "", targetId: "", objective: "" };
4
9
  const CLOSED_TASK_STATUSES = new Set(["done", "failed", "canceled"]);
10
+ const WAITING_TASK_STATUSES = new Set(["open", "blocked"]);
5
11
  const STATUS_SORT_ORDER = { online: 0, idle: 1, busy: 2, offline: 3 };
6
12
  const LIVE_REFRESH_MS = 5_000;
7
13
  const AGENT_TYPE_ICONS = {
@@ -33,30 +39,52 @@
33
39
  view: loadPref("view", "overview"),
34
40
 
35
41
  showOffline: loadPref("showOffline", false),
42
+ showBuiltIns: loadPref("showBuiltIns", false),
36
43
  autoRefresh: loadPref("autoRefresh", true),
37
44
  agentSort: loadPref("agentSort", "status"),
38
45
  agentSortDir: loadPref("agentSortDir", "asc"),
46
+ agentPresetFilter: loadPref("agentPresetFilter", ""),
39
47
 
40
48
  agents: [],
41
49
  agentsById: {},
50
+ pairs: [],
42
51
  messages: [],
43
52
  tasks: [],
44
53
  taskEvents: [],
54
+ taskEventCache: {},
45
55
  stats: {},
46
56
  health: null,
47
57
  now: Date.now(),
48
58
  authToken: loadPref("authToken", ""),
59
+ inboxReadCursors: loadPref("inboxReadCursors", {}),
60
+ inboxArchivedThreads: loadPref("inboxArchivedThreads", {}),
61
+ inboxDrafts: loadPref("inboxDrafts", {}),
62
+ inboxSearch: "",
63
+ inboxShowArchived: loadPref("inboxShowArchived", false),
64
+ operatorActivity: loadPref("operatorActivity", []),
65
+ activityEvents: [],
66
+ activityFilter: loadPref("activityFilter", ""),
49
67
 
50
68
  selectedAgent: "",
69
+ agentDetailOpen: false,
70
+ agentDetailId: "",
71
+ selectedInboxThread: "",
51
72
  replyTo: null,
52
73
  composeOpen: false,
74
+ pairInviteOpen: false,
75
+ pairMessageOpen: false,
53
76
  threadOpen: false,
54
77
  threadMessages: [],
55
78
  taskEventsOpen: false,
79
+ commandPaletteOpen: false,
80
+ commandQuery: "",
56
81
  connected: false,
57
82
  authNeeded: false,
58
83
 
59
84
  compose: { ...DEFAULT_COMPOSE },
85
+ pairInvite: { ...DEFAULT_PAIR_INVITE },
86
+ pairMessage: { ...DEFAULT_PAIR_MESSAGE },
87
+ inboxCompose: { ...DEFAULT_INBOX_COMPOSE },
60
88
 
61
89
  confirmModal: { show: false, title: "", message: "", action: null },
62
90
  renameModal: { show: false, agentId: "", label: "" },
@@ -65,6 +93,7 @@
65
93
  tagFilter: "",
66
94
  agentStatusFilter: loadPref("agentStatusFilter", ""),
67
95
  agentTagFilter: loadPref("agentTagFilter", ""),
96
+ pairStatusFilter: loadPref("pairStatusFilter", "open"),
68
97
  taskStatusFilter: "",
69
98
  taskSourceFilter: "",
70
99
 
@@ -78,6 +107,7 @@
78
107
 
79
108
  function watchPersistedPrefs(vm) {
80
109
  vm.$watch("showOffline", (value) => vm.save("showOffline", value));
110
+ vm.$watch("showBuiltIns", (value) => vm.save("showBuiltIns", value));
81
111
  vm.$watch("autoRefresh", (value) => {
82
112
  vm.save("autoRefresh", value);
83
113
  if (value) vm.startAutoRefresh();
@@ -85,8 +115,12 @@
85
115
  });
86
116
  vm.$watch("agentSort", (value) => vm.save("agentSort", value));
87
117
  vm.$watch("agentSortDir", (value) => vm.save("agentSortDir", value));
118
+ vm.$watch("agentPresetFilter", (value) => vm.save("agentPresetFilter", value));
88
119
  vm.$watch("agentStatusFilter", (value) => vm.save("agentStatusFilter", value));
89
120
  vm.$watch("agentTagFilter", (value) => vm.save("agentTagFilter", value));
121
+ vm.$watch("pairStatusFilter", (value) => vm.save("pairStatusFilter", value));
122
+ vm.$watch("inboxShowArchived", (value) => vm.save("inboxShowArchived", value));
123
+ vm.$watch("activityFilter", (value) => vm.save("activityFilter", value));
90
124
  vm.$watch("view", (value) => {
91
125
  vm.save("view", value);
92
126
  if (value === "analytics") vm.$nextTick(() => vm.renderCharts());
@@ -133,11 +167,27 @@
133
167
  vm.$nextTick(() => vm.renderCharts());
134
168
  }
135
169
 
170
+ function registerKeyboardShortcuts(vm) {
171
+ if (typeof window === "undefined" || !window.addEventListener || vm._keyboardShortcutsRegistered) return;
172
+ window.addEventListener("keydown", (event) => {
173
+ const key = event.key?.toLowerCase();
174
+ if ((event.metaKey || event.ctrlKey) && key === "k") {
175
+ event.preventDefault();
176
+ vm.openCommandPalette();
177
+ } else if (key === "escape" && vm.commandPaletteOpen) {
178
+ event.preventDefault();
179
+ vm.closeCommandPalette();
180
+ }
181
+ });
182
+ vm._keyboardShortcutsRegistered = true;
183
+ }
184
+
136
185
  function createLifecycleMethods() {
137
186
  return {
138
187
  async init() {
139
188
  this.startClock();
140
189
  watchPersistedPrefs(this);
190
+ registerKeyboardShortcuts(this);
141
191
 
142
192
  try {
143
193
  this.stats = await this.api("GET", "/stats");
@@ -154,9 +204,13 @@
154
204
  savePref(key, value);
155
205
  },
156
206
 
157
- switchView(view) {
207
+ async switchView(view) {
158
208
  this.view = view;
159
- if (view === "messages") this.fetchMessages();
209
+ if (view === "inbox" || view === "messages") await this.fetchMessages();
210
+ if (view === "inbox") this.markInboxThreadRead(this.selectedInboxThreadData);
211
+ if (view === "activity") await Promise.all([this.fetchMessages(), this.fetchPairs(), this.fetchTasks(), this.fetchActivityEvents()]);
212
+ if (view === "work") await Promise.all([this.fetchMessages(), this.fetchTasks()]);
213
+ if (view === "pairs") this.fetchPairs();
160
214
  if (view === "tasks") this.fetchTasks();
161
215
  },
162
216
 
@@ -222,8 +276,8 @@
222
276
 
223
277
  function handleNewMessage(vm, msg) {
224
278
  if (vm.messages.some((existing) => existing.id === msg.id)) return;
225
- if (vm.selectedAgent && msg.from !== vm.selectedAgent && msg.to !== vm.selectedAgent) return;
226
- if (vm.channelFilter && msg.channel !== vm.channelFilter) return;
279
+ if (vm.view === "messages" && vm.selectedAgent && msg.from !== vm.selectedAgent && msg.to !== vm.selectedAgent) return;
280
+ if (vm.view === "messages" && vm.channelFilter && msg.channel !== vm.channelFilter) return;
227
281
 
228
282
  vm.messages.push(msg);
229
283
  if (vm.messages.length > 200) vm.messages.shift();
@@ -299,7 +353,7 @@
299
353
  },
300
354
 
301
355
  async refresh() {
302
- await Promise.all([this.fetchStats(), this.fetchHealth(), this.fetchAgents(), this.fetchMessages(), this.fetchTasks()]);
356
+ await Promise.all([this.fetchStats(), this.fetchHealth(), this.fetchAgents(), this.fetchPairs(), this.fetchMessages(), this.fetchTasks(), this.fetchInboxState(), this.fetchActivityEvents()]);
303
357
  },
304
358
 
305
359
  async refreshLiveData() {
@@ -332,11 +386,31 @@
332
386
  } catch {}
333
387
  },
334
388
 
389
+ async fetchPairs() {
390
+ try {
391
+ let pairs;
392
+ if (this.view === "activity") {
393
+ pairs = await this.api("GET", "/pairs");
394
+ } else if (this.pairStatusFilter === "open") {
395
+ const [active, pending] = await Promise.all([
396
+ this.api("GET", "/pairs?status=active"),
397
+ this.api("GET", "/pairs?status=pending"),
398
+ ]);
399
+ pairs = [...active, ...pending];
400
+ } else if (this.pairStatusFilter) {
401
+ pairs = await this.api("GET", "/pairs?status=" + encodeURIComponent(this.pairStatusFilter));
402
+ } else {
403
+ pairs = await this.api("GET", "/pairs");
404
+ }
405
+ this.pairs = pairs.sort(comparePairs);
406
+ } catch {}
407
+ },
408
+
335
409
  async fetchMessages() {
336
410
  try {
337
411
  let path = "/messages?limit=100";
338
- if (this.selectedAgent) path += "&for=" + encodeURIComponent(this.selectedAgent);
339
- if (this.channelFilter) path += "&channel=" + encodeURIComponent(this.channelFilter);
412
+ if (this.view === "messages" && this.selectedAgent) path += "&for=" + encodeURIComponent(this.selectedAgent);
413
+ if (this.view === "messages" && this.channelFilter) path += "&channel=" + encodeURIComponent(this.channelFilter);
340
414
  this.messages = await this.api("GET", path);
341
415
  } catch {}
342
416
  },
@@ -349,13 +423,69 @@
349
423
  this.tasks = await this.api("GET", "/tasks?" + params.toString());
350
424
  } catch {}
351
425
  },
426
+
427
+ async fetchInboxState() {
428
+ try {
429
+ const state = await this.api("GET", "/inbox/state?operatorId=" + encodeURIComponent(INBOX_OPERATOR_ID));
430
+ applyInboxState(this, state);
431
+ } catch {}
432
+ },
433
+
434
+ async fetchActivityEvents() {
435
+ try {
436
+ this.activityEvents = await this.api("GET", "/activity?limit=200");
437
+ pruneSyncedOperatorActivity(this);
438
+ } catch {}
439
+ },
352
440
  };
353
441
  }
354
442
 
443
+ function applyInboxState(vm, state) {
444
+ const readCursors = {};
445
+ const archivedThreads = {};
446
+ const drafts = {};
447
+
448
+ for (const thread of state?.threads || []) {
449
+ if (thread.readCursorMessageId) readCursors[thread.peerId] = thread.readCursorMessageId;
450
+ if (thread.archivedAtMessageId) archivedThreads[thread.peerId] = thread.archivedAtMessageId;
451
+ }
452
+ for (const draft of state?.drafts || []) {
453
+ if (draft.body) drafts[draft.peerId] = draft.body;
454
+ }
455
+
456
+ vm.inboxReadCursors = readCursors;
457
+ vm.inboxArchivedThreads = archivedThreads;
458
+ vm.inboxDrafts = drafts;
459
+ savePref("inboxReadCursors", readCursors);
460
+ savePref("inboxArchivedThreads", archivedThreads);
461
+ savePref("inboxDrafts", drafts);
462
+ }
463
+
464
+ function pruneSyncedOperatorActivity(vm) {
465
+ const serverClientIds = new Set((vm.activityEvents || []).map((event) => event.clientId).filter(Boolean));
466
+ if (!serverClientIds.size || !vm.operatorActivity?.length) return;
467
+ vm.operatorActivity = vm.operatorActivity.filter((item) => !serverClientIds.has(item.clientId || item.id));
468
+ savePref("operatorActivity", vm.operatorActivity);
469
+ }
470
+
355
471
  function createComputedDescriptors() {
356
472
  return {
357
473
  onlineCount: { get: getOnlineCount },
474
+ hiddenBuiltInAgentCount: { get: getHiddenBuiltInAgentCount },
358
475
  sortedAgents: { get: getSortedAgents },
476
+ pairsByAgentId: { get: getPairsByAgentId },
477
+ selectedAgentDetail: { get: getSelectedAgentDetail },
478
+ agentDetailMessages: { get: getAgentDetailMessages },
479
+ pairMessagePair: { get: getPairMessagePair },
480
+ allInboxThreads: { get: getAllInboxThreads },
481
+ inboxThreads: { get: getInboxThreads },
482
+ selectedInboxThreadData: { get: getSelectedInboxThreadData },
483
+ selectedInboxMessages: { get: getSelectedInboxMessages },
484
+ inboxComposeTargetOptions: { get: getInboxComposeTargetOptions },
485
+ attentionSummary: { get: getAttentionSummary },
486
+ attentionAgentCount: { get: getAttentionAgentCount },
487
+ activityItems: { get: getActivityItems },
488
+ workQueueItems: { get: getWorkQueueItems },
359
489
  filteredMessages: { get: getFilteredMessages },
360
490
  groupedMessages: { get: getGroupedMessages },
361
491
  filteredTasks: { get: getFilteredTasks },
@@ -364,15 +494,23 @@
364
494
  uniqueCaps: { get: getUniqueCaps },
365
495
  uniqueTags: { get: getUniqueTags },
366
496
  healthIssues: { get: getHealthIssues },
497
+ healthDiagnostics: { get: getHealthDiagnostics },
498
+ commandPaletteItems: { get: getCommandPaletteItems },
367
499
  };
368
500
  }
369
501
 
370
502
  function getOnlineCount() {
371
- return this.agents.filter((agent) => agent.status !== "offline").length;
503
+ return visibleAgents(this).filter((agent) => agent.status !== "offline").length;
504
+ }
505
+
506
+ function getHiddenBuiltInAgentCount() {
507
+ return this.showBuiltIns ? 0 : this.agents.filter(isBuiltInAgent).length;
372
508
  }
373
509
 
374
510
  function getSortedAgents() {
375
- let list = this.showOffline ? [...this.agents] : this.agents.filter((agent) => agent.status !== "offline");
511
+ let list = visibleAgents(this);
512
+ list = applyAgentPreset(this, list);
513
+ if (!this.showOffline) list = list.filter((agent) => agent.status !== "offline");
376
514
  if (this.agentStatusFilter === "starting") {
377
515
  list = list.filter((agent) => agent.status !== "offline" && !agent.ready);
378
516
  } else if (this.agentStatusFilter) {
@@ -386,6 +524,448 @@
386
524
  return list.sort((a, b) => compareAgents(this, a, b) * dir);
387
525
  }
388
526
 
527
+ function getPairsByAgentId() {
528
+ const byAgent = {};
529
+ for (const pair of this.pairs || []) {
530
+ if (pair.requesterId) byAgent[pair.requesterId] = pair;
531
+ if (pair.targetId) byAgent[pair.targetId] = pair;
532
+ }
533
+ return byAgent;
534
+ }
535
+
536
+ function comparePairs(a, b) {
537
+ return new Date(b.updatedAt || b.createdAt || 0) - new Date(a.updatedAt || a.createdAt || 0);
538
+ }
539
+
540
+ function getSelectedAgentDetail() {
541
+ if (!this.agentDetailId) return null;
542
+ return this.agentsById[this.agentDetailId] || null;
543
+ }
544
+
545
+ function getAgentDetailMessages() {
546
+ if (!this.agentDetailId) return [];
547
+ return this.messages
548
+ .filter((msg) => msg.from === this.agentDetailId || msg.to === this.agentDetailId)
549
+ .slice()
550
+ .sort((a, b) => b.id - a.id)
551
+ .slice(0, 8);
552
+ }
553
+
554
+ function getPairMessagePair() {
555
+ return this.pairs.find((pair) => pair.id === this.pairMessage.pairId) || null;
556
+ }
557
+
558
+ function getAllInboxThreads() {
559
+ return buildInboxThreads(this);
560
+ }
561
+
562
+ function getInboxThreads() {
563
+ const search = this.inboxSearch.trim().toLowerCase();
564
+ return this.allInboxThreads.filter((thread) => {
565
+ if (!this.inboxShowArchived && thread.archived) return false;
566
+ if (search && !threadMatchesSearch(this, thread, search)) return false;
567
+ return true;
568
+ });
569
+ }
570
+
571
+ function buildInboxThreads(vm) {
572
+ const threads = new Map();
573
+ for (const msg of vm.messages) {
574
+ const peer = inboxPeer(msg);
575
+ if (!peer) continue;
576
+ if (!threads.has(peer)) threads.set(peer, { id: peer, peer, messages: [], lastMessage: null });
577
+ threads.get(peer).messages.push(msg);
578
+ }
579
+
580
+ for (const thread of threads.values()) {
581
+ thread.messages.sort((a, b) => a.id - b.id);
582
+ thread.lastMessage = thread.messages[thread.messages.length - 1] || null;
583
+ thread.attention = getThreadAttention(vm, thread);
584
+ thread.archived = isInboxThreadArchived(vm, thread);
585
+ thread.draft = draftForPeer(vm, thread.peer);
586
+ }
587
+
588
+ return [...threads.values()].sort(compareInboxThreads);
589
+ }
590
+
591
+ function threadMatchesSearch(vm, thread, search) {
592
+ const haystack = [
593
+ vm.displayTarget(thread.peer),
594
+ thread.peer,
595
+ ...thread.messages.flatMap((msg) => [msg.subject || "", msg.body || "", msg.channel || "", vm.displayTarget(msg.from), vm.displayTarget(msg.to)]),
596
+ ].join("\n").toLowerCase();
597
+ return haystack.includes(search);
598
+ }
599
+
600
+ function isInboxThreadArchived(vm, thread) {
601
+ const archivedAtId = Number(vm.inboxArchivedThreads?.[thread.peer] || 0);
602
+ return Boolean(thread.lastMessage?.id && archivedAtId >= thread.lastMessage.id);
603
+ }
604
+
605
+ function compareInboxThreads(a, b) {
606
+ const scoreDelta = (b.attention?.score || 0) - (a.attention?.score || 0);
607
+ if (scoreDelta !== 0) return scoreDelta;
608
+ return (b.lastMessage?.id || 0) - (a.lastMessage?.id || 0);
609
+ }
610
+
611
+ function getSelectedInboxThreadData() {
612
+ if (!this.selectedInboxThread) return null;
613
+ return this.inboxThreads.find((thread) => thread.id === this.selectedInboxThread) || null;
614
+ }
615
+
616
+ function getSelectedInboxMessages() {
617
+ return this.selectedInboxThreadData?.messages || [];
618
+ }
619
+
620
+ function getInboxComposeTargetOptions() {
621
+ if (this.inboxCompose.toMode === "tag") return this.uniqueTags.map((value) => ({ value, label: "#" + value }));
622
+ if (this.inboxCompose.toMode === "cap") return this.uniqueCaps.map((value) => ({ value, label: value }));
623
+ return this.composeAgents.map((agent) => ({ value: agent.id, label: `${this.displayName(agent)} [${agent.id.slice(-6)}]` }));
624
+ }
625
+
626
+ function getAttentionSummary() {
627
+ const threads = this.allInboxThreads.filter((thread) => !thread.archived);
628
+ const pendingPairInvites = this.pairs.filter((pair) => pair.status === "pending").length;
629
+ const claimableTasks = countClaimableWaiting(this);
630
+ const unreadInbox = threads.reduce((sum, thread) => sum + (thread.attention?.unread || 0), 0);
631
+ const needsHumanResponse = threads.filter((thread) => thread.attention?.needsHumanResponse).length;
632
+ const agentQuestions = threads.filter((thread) => thread.attention?.agentQuestion).length;
633
+
634
+ return {
635
+ unreadInbox,
636
+ needsHumanResponse,
637
+ agentQuestions,
638
+ pendingPairInvites,
639
+ claimableTasks,
640
+ total: unreadInbox + needsHumanResponse + agentQuestions + pendingPairInvites + claimableTasks,
641
+ };
642
+ }
643
+
644
+ function getAttentionAgentCount() {
645
+ return this.sortedAgents.filter((agent) => agentAttention.call(this, agent).total > 0).length;
646
+ }
647
+
648
+ function getActivityItems() {
649
+ const items = [
650
+ ...serverActivityItems(this),
651
+ ...messageActivityItems(this),
652
+ ...pairActivityItems(this),
653
+ ...taskActivityItems(this),
654
+ ...operatorActivityItems(this),
655
+ ].filter((item) => item.ts);
656
+
657
+ const filter = this.activityFilter;
658
+ const filtered = filter ? items.filter((item) => item.kind === filter) : items;
659
+ return filtered
660
+ .sort((a, b) => b.ts - a.ts)
661
+ .slice(0, 150);
662
+ }
663
+
664
+ function getWorkQueueItems() {
665
+ const taskItems = (this.tasks || [])
666
+ .filter((task) => !CLOSED_TASK_STATUSES.has(task.status))
667
+ .map((task) => ({
668
+ id: "task-" + task.id,
669
+ sourceType: "task",
670
+ title: task.title,
671
+ body: task.body,
672
+ severity: task.severity || "info",
673
+ status: task.status,
674
+ owner: task.claimedBy || "",
675
+ target: task.target,
676
+ source: task.source,
677
+ channel: task.channel || "",
678
+ updatedAt: task.updatedAt || task.createdAt,
679
+ createdAt: task.createdAt,
680
+ claimable: isClaimableTaskWaiting(task),
681
+ task,
682
+ }));
683
+
684
+ const messageItems = (this.messages || [])
685
+ .filter((msg) => msg.claimable)
686
+ .map((msg) => ({
687
+ id: "message-" + msg.id,
688
+ sourceType: "message",
689
+ title: msg.subject || "Claimable message #" + msg.id,
690
+ body: msg.body,
691
+ severity: "warning",
692
+ status: msg.claimedBy ? "claimed" : "open",
693
+ owner: msg.claimedBy || "",
694
+ target: msg.to,
695
+ source: "message",
696
+ channel: msg.channel || "",
697
+ updatedAt: msg.claimedAt || msg.createdAt,
698
+ createdAt: msg.createdAt,
699
+ claimable: isClaimableMessageWaiting(msg),
700
+ message: msg,
701
+ }));
702
+
703
+ return [...taskItems, ...messageItems].sort(compareWorkQueueItems);
704
+ }
705
+
706
+ function compareWorkQueueItems(a, b) {
707
+ const claimableDelta = Number(b.claimable) - Number(a.claimable);
708
+ if (claimableDelta !== 0) return claimableDelta;
709
+ const severityOrder = { critical: 0, warning: 1, info: 2 };
710
+ const severityDelta = (severityOrder[a.severity] ?? 9) - (severityOrder[b.severity] ?? 9);
711
+ if (severityDelta !== 0) return severityDelta;
712
+ return toTimestamp(a.updatedAt) - toTimestamp(b.updatedAt);
713
+ }
714
+
715
+ function serverActivityItems(vm) {
716
+ return (vm.activityEvents || []).map((event) => activityItem({
717
+ id: "activity-" + event.id,
718
+ clientId: event.clientId,
719
+ kind: event.kind,
720
+ ts: toTimestamp(event.createdAt),
721
+ icon: event.icon,
722
+ title: event.title,
723
+ body: event.body,
724
+ meta: event.meta,
725
+ view: event.view,
726
+ peer: event.peer,
727
+ messageId: event.messageId,
728
+ pairId: event.pairId,
729
+ taskId: event.taskId,
730
+ agentId: event.agentId,
731
+ }));
732
+ }
733
+
734
+ function messageActivityItems(vm) {
735
+ return (vm.messages || []).flatMap((msg) => {
736
+ const ts = toTimestamp(msg.createdAt);
737
+ const items = [];
738
+ const pairEvent = msg.meta?.pairEvent;
739
+ if (pairEvent) {
740
+ items.push(activityItem({
741
+ id: "pair-message-" + msg.id,
742
+ kind: pairEvent === "message" ? "pair" : "state",
743
+ ts,
744
+ icon: pairEvent === "message" ? "ti-messages" : "ti-link",
745
+ title: pairEvent === "message" ? "Pair message" : "Pair " + pairEvent,
746
+ body: vm.messagePreview(msg),
747
+ meta: `${vm.displayTarget(msg.from)} -> ${vm.displayTarget(msg.to)}`,
748
+ view: "pairs",
749
+ messageId: msg.id,
750
+ }));
751
+ } else if (msg.from === HUMAN_AGENT_ID) {
752
+ items.push(activityItem({
753
+ id: "human-send-" + msg.id,
754
+ kind: msg.claimable ? "task" : "message",
755
+ ts,
756
+ icon: msg.claimable ? "ti-hand-grab" : "ti-send",
757
+ title: msg.claimable ? "Claimable task sent" : "Message sent",
758
+ body: vm.messagePreview(msg),
759
+ meta: "to " + vm.displayTarget(msg.to),
760
+ view: inboxPeer(msg) ? "inbox" : "messages",
761
+ peer: inboxPeer(msg),
762
+ messageId: msg.id,
763
+ }));
764
+ } else if (msg.to === HUMAN_AGENT_ID) {
765
+ items.push(activityItem({
766
+ id: "agent-reply-" + msg.id,
767
+ kind: messageLooksLikeQuestion(msg) ? "question" : "reply",
768
+ ts,
769
+ icon: messageLooksLikeQuestion(msg) ? "ti-help-circle" : "ti-message-reply",
770
+ title: messageLooksLikeQuestion(msg) ? "Agent asked a question" : "Agent replied",
771
+ body: vm.messagePreview(msg),
772
+ meta: "from " + vm.displayTarget(msg.from),
773
+ view: "inbox",
774
+ peer: msg.from,
775
+ agentId: msg.from,
776
+ messageId: msg.id,
777
+ }));
778
+ } else {
779
+ items.push(activityItem({
780
+ id: "message-" + msg.id,
781
+ kind: "message",
782
+ ts,
783
+ icon: "ti-messages",
784
+ title: "Agent message",
785
+ body: vm.messagePreview(msg),
786
+ meta: `${vm.displayTarget(msg.from)} -> ${vm.displayTarget(msg.to)}`,
787
+ view: "messages",
788
+ messageId: msg.id,
789
+ }));
790
+ }
791
+ if (msg.claimedBy) {
792
+ items.push(activityItem({
793
+ id: "claim-" + msg.id,
794
+ kind: "task",
795
+ ts: toTimestamp(msg.claimedAt || msg.createdAt),
796
+ icon: "ti-user-check",
797
+ title: "Task claimed",
798
+ body: vm.messagePreview(msg),
799
+ meta: "by " + vm.displayTarget(msg.claimedBy),
800
+ view: "tasks",
801
+ messageId: msg.id,
802
+ agentId: msg.claimedBy,
803
+ }));
804
+ }
805
+ return items;
806
+ });
807
+ }
808
+
809
+ function pairActivityItems(vm) {
810
+ return (vm.pairs || []).flatMap((pair) => {
811
+ const created = activityItem({
812
+ id: "pair-created-" + pair.id,
813
+ kind: "pair",
814
+ ts: toTimestamp(pair.createdAt),
815
+ icon: "ti-link-plus",
816
+ title: "Pair invite created",
817
+ body: pair.objective || "",
818
+ meta: `${vm.displayTarget(pair.requesterId)} <-> ${vm.displayTarget(pair.targetId)}`,
819
+ view: "pairs",
820
+ pairId: pair.id,
821
+ });
822
+ const statusTs = toTimestamp(pair.updatedAt || pair.acceptedAt || pair.endedAt);
823
+ if (!statusTs || statusTs === created.ts || pair.status === "pending") return [created];
824
+ return [created, activityItem({
825
+ id: "pair-status-" + pair.id + "-" + pair.status,
826
+ kind: "state",
827
+ ts: statusTs,
828
+ icon: pair.status === "active" ? "ti-link" : "ti-phone-off",
829
+ title: "Pair " + pair.status,
830
+ body: pair.objective || "",
831
+ meta: pair.endedBy ? "by " + vm.displayTarget(pair.endedBy) : `${vm.displayTarget(pair.requesterId)} <-> ${vm.displayTarget(pair.targetId)}`,
832
+ view: "pairs",
833
+ pairId: pair.id,
834
+ })];
835
+ });
836
+ }
837
+
838
+ function taskActivityItems(vm) {
839
+ return (vm.tasks || []).map((task) => activityItem({
840
+ id: "task-" + task.id + "-" + task.status,
841
+ kind: "task",
842
+ ts: toTimestamp(task.updatedAt || task.createdAt),
843
+ icon: task.claimedBy ? "ti-user-check" : "ti-checkup-list",
844
+ title: task.status === "open" ? "Claimable task waiting" : "Task " + task.status,
845
+ body: task.title || task.body || "",
846
+ meta: task.claimedBy ? "claimed by " + vm.displayTarget(task.claimedBy) : vm.displayTarget(task.target),
847
+ view: "tasks",
848
+ taskId: task.id,
849
+ agentId: task.claimedBy || task.target,
850
+ }));
851
+ }
852
+
853
+ function operatorActivityItems(vm) {
854
+ const serverClientIds = new Set((vm.activityEvents || []).map((event) => event.clientId).filter(Boolean));
855
+ return (vm.operatorActivity || []).map((item) => activityItem({
856
+ ...item,
857
+ id: item.id || "operator-" + item.ts + "-" + item.title,
858
+ clientId: item.clientId || item.id,
859
+ })).filter((item) => !serverClientIds.has(item.clientId));
860
+ }
861
+
862
+ function activityItem(input) {
863
+ return {
864
+ id: input.id,
865
+ kind: input.kind || "state",
866
+ ts: Number(input.ts) || 0,
867
+ icon: input.icon || "ti-activity",
868
+ title: input.title || "Activity",
869
+ body: input.body || "",
870
+ meta: input.meta || "",
871
+ view: input.view || "",
872
+ peer: input.peer || "",
873
+ clientId: input.clientId || "",
874
+ messageId: input.messageId,
875
+ pairId: input.pairId,
876
+ taskId: input.taskId,
877
+ agentId: input.agentId,
878
+ };
879
+ }
880
+
881
+ function toTimestamp(value) {
882
+ const ts = typeof value === "number" ? value : new Date(value || 0).getTime();
883
+ return Number.isFinite(ts) ? ts : 0;
884
+ }
885
+
886
+ function inboxPeer(msg) {
887
+ if (msg.from === HUMAN_AGENT_ID && msg.to) return msg.to;
888
+ if (msg.to === HUMAN_AGENT_ID && msg.from) return msg.from;
889
+ return "";
890
+ }
891
+
892
+ function getThreadAttention(vm, thread) {
893
+ const lastHumanReplyId = maxMessageId(thread.messages, (msg) => msg.from === HUMAN_AGENT_ID);
894
+ const lastInboundId = maxMessageId(thread.messages, isHumanInboundMessage);
895
+ const unread = thread.messages.filter((msg) => isUnreadHumanMessage(vm, thread.peer, msg)).length;
896
+ const needsHumanResponse = lastInboundId > lastHumanReplyId;
897
+ const agentQuestion = thread.messages.some((msg) =>
898
+ isHumanInboundMessage(msg) && msg.id > lastHumanReplyId && messageLooksLikeQuestion(msg)
899
+ );
900
+
901
+ return {
902
+ unread,
903
+ needsHumanResponse,
904
+ agentQuestion,
905
+ score: unread * 10 + (needsHumanResponse ? 5 : 0) + (agentQuestion ? 3 : 0),
906
+ };
907
+ }
908
+
909
+ function maxMessageId(messages, predicate) {
910
+ let max = 0;
911
+ for (const msg of messages) {
912
+ if (predicate(msg) && msg.id > max) max = msg.id;
913
+ }
914
+ return max;
915
+ }
916
+
917
+ function isHumanInboundMessage(msg) {
918
+ return msg.to === HUMAN_AGENT_ID && msg.from !== HUMAN_AGENT_ID;
919
+ }
920
+
921
+ function isUnreadHumanMessage(vm, peer, msg) {
922
+ if (!isHumanInboundMessage(msg)) return false;
923
+ if ((msg.readBy || []).includes(HUMAN_AGENT_ID)) return false;
924
+ return msg.id > readCursorForPeer(vm, peer);
925
+ }
926
+
927
+ function readCursorForPeer(vm, peer) {
928
+ const value = Number(vm.inboxReadCursors?.[peer] || 0);
929
+ return Number.isFinite(value) ? value : 0;
930
+ }
931
+
932
+ function draftForPeer(vm, peer) {
933
+ return typeof vm.inboxDrafts?.[peer] === "string" ? vm.inboxDrafts[peer] : "";
934
+ }
935
+
936
+ function messageLooksLikeQuestion(msg) {
937
+ return /\?/.test(`${msg.subject || ""}\n${msg.body || ""}`);
938
+ }
939
+
940
+ function countClaimableWaiting(vm) {
941
+ const taskCount = vm.tasks.filter(isClaimableTaskWaiting).length;
942
+ const messageCount = vm.messages.filter(isClaimableMessageWaiting).length;
943
+ return taskCount + messageCount;
944
+ }
945
+
946
+ function countAgentClaimableWaiting(vm, agent) {
947
+ const taskCount = vm.tasks.filter((task) => isClaimableTaskWaiting(task) && targetMatchesAgent(task.target, agent)).length;
948
+ const messageCount = vm.messages.filter((msg) => isClaimableMessageWaiting(msg) && targetMatchesAgent(msg.to, agent)).length;
949
+ return taskCount + messageCount;
950
+ }
951
+
952
+ function isClaimableTaskWaiting(task) {
953
+ return WAITING_TASK_STATUSES.has(task.status) && !task.claimedBy;
954
+ }
955
+
956
+ function isClaimableMessageWaiting(msg) {
957
+ return Boolean(msg.claimable && !msg.claimedBy);
958
+ }
959
+
960
+ function targetMatchesAgent(target, agent) {
961
+ if (!target || !agent) return false;
962
+ if (target === "broadcast" || target === agent.id) return true;
963
+ if (target.startsWith("tag:")) return (agent.tags || []).includes(target.slice(4));
964
+ if (target.startsWith("cap:")) return (agent.capabilities || []).includes(target.slice(4));
965
+ if (target.startsWith("label:")) return agent.label === target.slice(6);
966
+ return false;
967
+ }
968
+
389
969
  function getFilteredMessages() {
390
970
  if (!this.tagFilter) return this.messages;
391
971
  return this.messages.filter((msg) => messageMatchesTag(this, msg, this.tagFilter));
@@ -425,26 +1005,142 @@
425
1005
  }
426
1006
 
427
1007
  function getComposeAgents() {
428
- return this.showOffline ? this.agents : this.agents.filter((agent) => agent.status !== "offline");
1008
+ const list = visibleAgents(this);
1009
+ return this.showOffline ? list : list.filter((agent) => agent.status !== "offline");
429
1010
  }
430
1011
 
431
1012
  function getUniqueLabels() {
432
- return [...new Set(this.agents.filter((agent) => agent.label).map((agent) => agent.label))];
1013
+ return [...new Set(visibleAgents(this).filter((agent) => agent.label).map((agent) => agent.label))];
433
1014
  }
434
1015
 
435
1016
  function getUniqueCaps() {
436
- return [...new Set(this.agents.flatMap((agent) => agent.capabilities || []))];
1017
+ return [...new Set(visibleAgents(this).flatMap((agent) => agent.capabilities || []))];
437
1018
  }
438
1019
 
439
1020
  function getUniqueTags() {
440
- return [...new Set(this.agents.flatMap((agent) => agent.tags || []))];
1021
+ return [...new Set(visibleAgents(this).flatMap((agent) => agent.tags || []))];
1022
+ }
1023
+
1024
+ function visibleAgents(vm) {
1025
+ return vm.showBuiltIns ? [...vm.agents] : vm.agents.filter((agent) => !isBuiltInAgent(agent));
1026
+ }
1027
+
1028
+ function isBuiltInAgent(agent) {
1029
+ return agent?.meta?.builtin === true || agent?.id === HUMAN_AGENT_ID || agent?.id === "system";
1030
+ }
1031
+
1032
+ function applyAgentPreset(vm, list) {
1033
+ switch (vm.agentPresetFilter) {
1034
+ case "active":
1035
+ return list.filter((agent) => agent.status !== "offline");
1036
+ case "offline_stale":
1037
+ return list.filter((agent) => agent.status === "offline" || isAgentStale(vm, agent));
1038
+ case "claude":
1039
+ case "codex":
1040
+ return list.filter((agent) => agentType(agent) === vm.agentPresetFilter);
1041
+ case "paired":
1042
+ return list.filter((agent) => Boolean(vm.agentPair(agent)));
1043
+ case "unpaired":
1044
+ return list.filter((agent) => !vm.agentPair(agent));
1045
+ case "waiting":
1046
+ return list.filter((agent) => agentAttention.call(vm, agent).total > 0);
1047
+ case "claimable":
1048
+ return list.filter((agent) => agentAttention.call(vm, agent).claimableTasks > 0);
1049
+ case "errors":
1050
+ return list.filter((agent) => agent.status === "offline" || (agent.status !== "offline" && !agent.ready) || isAgentStale(vm, agent));
1051
+ default:
1052
+ return list;
1053
+ }
1054
+ }
1055
+
1056
+ function isAgentStale(vm, agent) {
1057
+ if (!agent?.lastSeen || agent.status === "offline") return false;
1058
+ const lastSeenMs = new Date(agent.lastSeen).getTime();
1059
+ if (!Number.isFinite(lastSeenMs)) return false;
1060
+ return (vm.now || Date.now()) - lastSeenMs > 60_000;
441
1061
  }
442
1062
 
443
1063
  function getHealthIssues() {
444
1064
  return (this.health?.checks || []).filter((check) => check.status !== "ok");
445
1065
  }
446
1066
 
1067
+ function getHealthDiagnostics() {
1068
+ return this.healthIssues.map((check) => {
1069
+ const base = {
1070
+ name: check.name,
1071
+ status: check.status,
1072
+ detail: check.detail || check.name,
1073
+ impact: healthImpact(check),
1074
+ actions: [
1075
+ { label: "Inspect logs", icon: "ti-file-search", copy: "agent-relay daemon logs" },
1076
+ { label: "Restart daemon", icon: "ti-refresh", copy: "agent-relay daemon restart" },
1077
+ { label: "Copy env", icon: "ti-copy", copy: "agent-relay doctor" },
1078
+ ],
1079
+ };
1080
+ if (check.name === "stale-live-agents") {
1081
+ base.actions.unshift(
1082
+ { label: "Run reaper", icon: "ti-broom", api: "POST", path: "/system/reap" },
1083
+ { label: "Show stale", icon: "ti-filter", view: "agents", preset: "offline_stale" }
1084
+ );
1085
+ } else if (check.name === "expired-message-claims" || check.name === "expired-task-claims" || check.name === "offline-claimed-tasks") {
1086
+ base.actions.unshift(
1087
+ { label: "Run reaper", icon: "ti-broom", api: "POST", path: "/system/reap" },
1088
+ { label: "Open work", icon: "ti-list-check", view: "work" }
1089
+ );
1090
+ }
1091
+ return base;
1092
+ });
1093
+ }
1094
+
1095
+ function healthImpact(check) {
1096
+ if (check.name === "database") return "Relay persistence is unavailable; messages, state, and audit writes may fail.";
1097
+ if (check.name === "stale-live-agents") return "Agents may look online even though their heartbeat has stopped.";
1098
+ if (check.name === "expired-message-claims") return "Claimable messages may be stuck until the reaper releases expired claims.";
1099
+ if (check.name === "expired-task-claims") return "Tasks can appear owned by agents that no longer hold a live lease.";
1100
+ if (check.name === "offline-claimed-tasks") return "Offline agents are still shown as owners for active work.";
1101
+ return "Relay health is degraded for this check.";
1102
+ }
1103
+
1104
+ function getCommandPaletteItems() {
1105
+ const query = this.commandQuery.trim().toLowerCase();
1106
+ const commands = [
1107
+ commandItem("open-inbox", "Open inbox", "Human console", "ti-inbox", "openView", { view: "inbox" }),
1108
+ commandItem("open-work", "Open work queue", "Claimable messages and tasks", "ti-list-check", "openView", { view: "work" }),
1109
+ commandItem("show-stale-agents", "Show stale agents", "Filter agents to offline or stale heartbeat", "ti-filter", "agentPreset", { preset: "offline_stale" }),
1110
+ commandItem("copy-relay-url", "Copy relay URL", baseUrl(), "ti-copy", "copy", { value: baseUrl() }),
1111
+ commandItem("export-timeline-md", "Export full timeline as Markdown", "Activity audit trace", "ti-file-export", "exportActivity", { format: "markdown" }),
1112
+ commandItem("export-timeline-json", "Export full timeline as JSON", "Activity audit trace", "ti-braces", "exportActivity", { format: "json" }),
1113
+ ...this.composeAgents.slice(0, 12).map((agent) =>
1114
+ commandItem("message-" + agent.id, "Message agent: " + this.displayName(agent), agent.id, "ti-send", "messageAgent", { agentId: agent.id })
1115
+ ),
1116
+ ...this.composeAgents.filter((agent) => agentType(agent) === "codex").slice(0, 8).map((agent) =>
1117
+ commandItem("pair-codex-" + agent.id, "Pair Codex: " + this.displayName(agent), agent.id, "ti-link-plus", "pairAgent", { agentId: agent.id })
1118
+ ),
1119
+ ...this.uniqueTags.map((tag) =>
1120
+ commandItem("filter-tag-" + tag, "Filter tag: " + tag, "#" + tag, "ti-tag", "filterTag", { tag })
1121
+ ),
1122
+ ];
1123
+ if (!query) return commands.slice(0, 24);
1124
+ return commands.filter((command) => command.search.includes(query)).slice(0, 24);
1125
+ }
1126
+
1127
+ function commandItem(id, title, subtitle, icon, action, payload) {
1128
+ return {
1129
+ id,
1130
+ title,
1131
+ subtitle,
1132
+ icon,
1133
+ action,
1134
+ payload: payload || {},
1135
+ search: `${title}\n${subtitle || ""}\n${action}`.toLowerCase(),
1136
+ };
1137
+ }
1138
+
447
1139
  function compareAgents(vm, a, b) {
1140
+ if (vm.agentSort === "status") {
1141
+ const attentionDelta = agentAttention.call(vm, b).score - agentAttention.call(vm, a).score;
1142
+ if (attentionDelta !== 0) return attentionDelta;
1143
+ }
448
1144
  switch (vm.agentSort) {
449
1145
  case "name":
450
1146
  return vm.displayName(a).localeCompare(vm.displayName(b));
@@ -463,14 +1159,28 @@
463
1159
  return {
464
1160
  displayName,
465
1161
  displayTarget,
1162
+ conversationTitle,
1163
+ messagePreview,
1164
+ agentPair,
1165
+ pairPeerId,
1166
+ pairBadgeClass,
1167
+ pairStatusClass,
1168
+ pairBadgeLabel,
1169
+ pairTitle,
1170
+ agentAttention,
1171
+ agentAttentionTitle,
466
1172
  agentType,
467
1173
  agentTypeIcon,
468
1174
  agentTypeTitle,
1175
+ agentPresence,
1176
+ agentPresenceBadges,
1177
+ agentStatusClass,
469
1178
  severityClass,
470
1179
  agentStatusTitle,
471
1180
  timeAgo,
472
1181
  fmtTime,
473
1182
  healthAlertClass,
1183
+ activityKindClass,
474
1184
  };
475
1185
  }
476
1186
 
@@ -490,6 +1200,103 @@
490
1200
  return agent ? this.displayName(agent) : target.slice(-8);
491
1201
  }
492
1202
 
1203
+ function conversationTitle(thread) {
1204
+ if (!thread) return "Inbox";
1205
+ return this.displayTarget(thread.peer);
1206
+ }
1207
+
1208
+ function messagePreview(msg) {
1209
+ const text = msg?.subject || msg?.body || "";
1210
+ return text.length > 90 ? text.slice(0, 90) + "..." : text;
1211
+ }
1212
+
1213
+ function agentPair(agent) {
1214
+ return agent ? this.pairsByAgentId[agent.id] : null;
1215
+ }
1216
+
1217
+ function pairPeerId(pair, agentId) {
1218
+ if (!pair) return "";
1219
+ return pair.requesterId === agentId ? pair.targetId : pair.requesterId;
1220
+ }
1221
+
1222
+ function pairBadgeClass(pair) {
1223
+ if (pair?.status === "active") return "bg-success-lt";
1224
+ if (pair?.status === "pending") return "bg-warning-lt";
1225
+ return "bg-secondary-lt";
1226
+ }
1227
+
1228
+ function pairStatusClass(pair) {
1229
+ if (pair?.status === "active") return "bg-success";
1230
+ if (pair?.status === "pending") return "bg-warning";
1231
+ if (pair?.status === "rejected" || pair?.status === "expired") return "bg-danger";
1232
+ return "bg-secondary";
1233
+ }
1234
+
1235
+ function pairBadgeLabel(pair, agentId) {
1236
+ if (!pair) return "";
1237
+ const peer = this.displayTarget(pairPeerId(pair, agentId));
1238
+ if (pair.status === "active") return "paired with " + peer;
1239
+ if (pair.status === "pending" && pair.requesterId === agentId) return "invite to " + peer;
1240
+ if (pair.status === "pending") return "invite from " + peer;
1241
+ return pair.status;
1242
+ }
1243
+
1244
+ function pairTitle(pair, agentId) {
1245
+ if (!pair) return "";
1246
+ const label = pairBadgeLabel.call(this, pair, agentId);
1247
+ const objective = pair.objective ? " - " + pair.objective : "";
1248
+ return `${label} (${pair.id})${objective}`;
1249
+ }
1250
+
1251
+ function agentAttention(agent) {
1252
+ if (!agent) return emptyAttention();
1253
+ const thread = this.allInboxThreads.find((item) => item.peer === agent.id && !item.archived);
1254
+ const pair = this.agentPair(agent);
1255
+ const pendingPairInvite = pair?.status === "pending";
1256
+ const claimableTasks = countAgentClaimableWaiting(this, agent);
1257
+ const attention = {
1258
+ unread: thread?.attention?.unread || 0,
1259
+ needsHumanResponse: Boolean(thread?.attention?.needsHumanResponse),
1260
+ agentQuestion: Boolean(thread?.attention?.agentQuestion),
1261
+ pendingPairInvite,
1262
+ claimableTasks,
1263
+ };
1264
+ attention.total = attention.unread +
1265
+ (attention.needsHumanResponse ? 1 : 0) +
1266
+ (attention.agentQuestion ? 1 : 0) +
1267
+ (attention.pendingPairInvite ? 1 : 0) +
1268
+ attention.claimableTasks;
1269
+ attention.score = attention.unread * 10 +
1270
+ (attention.needsHumanResponse ? 5 : 0) +
1271
+ (attention.agentQuestion ? 3 : 0) +
1272
+ (attention.pendingPairInvite ? 4 : 0) +
1273
+ attention.claimableTasks * 2;
1274
+ return attention;
1275
+ }
1276
+
1277
+ function emptyAttention() {
1278
+ return {
1279
+ unread: 0,
1280
+ needsHumanResponse: false,
1281
+ agentQuestion: false,
1282
+ pendingPairInvite: false,
1283
+ claimableTasks: 0,
1284
+ total: 0,
1285
+ score: 0,
1286
+ };
1287
+ }
1288
+
1289
+ function agentAttentionTitle(agent) {
1290
+ const attention = agentAttention.call(this, agent);
1291
+ const parts = [];
1292
+ if (attention.unread) parts.push(`${attention.unread} unread`);
1293
+ if (attention.needsHumanResponse) parts.push("needs human response");
1294
+ if (attention.agentQuestion) parts.push("agent asked a question");
1295
+ if (attention.pendingPairInvite) parts.push("pair invite pending");
1296
+ if (attention.claimableTasks) parts.push(`${attention.claimableTasks} claimable waiting`);
1297
+ return parts.join(", ");
1298
+ }
1299
+
493
1300
  function agentType(agent) {
494
1301
  const values = [
495
1302
  ...(agent?.tags || []),
@@ -516,6 +1323,66 @@
516
1323
  return AGENT_TYPE_TITLES[agentType(agent)] || AGENT_TYPE_TITLES.agent;
517
1324
  }
518
1325
 
1326
+ function agentPresence(agent) {
1327
+ const attention = agentAttention.call(this, agent);
1328
+ const pair = this.agentPair(agent);
1329
+ const stale = isAgentStale(this, agent);
1330
+ const reconnecting = agent?.status !== "offline" && !agent?.ready && stale;
1331
+ const starting = agent?.status !== "offline" && !agent?.ready && !stale;
1332
+ const unreadIdle = attention.unread > 0 && agent?.status !== "busy";
1333
+
1334
+ if (agent?.status === "offline") {
1335
+ return { label: "offline", tone: "secondary", icon: "ti-plug-off", stale: false, reconnecting: false, badges: presenceBadges(attention, pair, { offline: true }) };
1336
+ }
1337
+ if (reconnecting) {
1338
+ return { label: "reconnecting", tone: "danger", icon: "ti-refresh", stale, reconnecting, badges: presenceBadges(attention, pair, { reconnecting }) };
1339
+ }
1340
+ if (starting) {
1341
+ return { label: "online, not ready", tone: "warning", icon: "ti-loader", stale, reconnecting, badges: presenceBadges(attention, pair, { starting }) };
1342
+ }
1343
+ if (agent?.status === "busy") {
1344
+ return { label: "busy in turn", tone: "warning", icon: "ti-player-play", stale, reconnecting, badges: presenceBadges(attention, pair, { busy: true }) };
1345
+ }
1346
+ if (pair?.status === "active") {
1347
+ return { label: "paired", tone: "success", icon: "ti-link", stale, reconnecting, badges: presenceBadges(attention, pair, { paired: true }) };
1348
+ }
1349
+ if (unreadIdle) {
1350
+ return { label: "idle, unread", tone: "danger", icon: "ti-bell", stale, reconnecting, badges: presenceBadges(attention, pair, { unreadIdle: true }) };
1351
+ }
1352
+ return { label: agent?.status === "idle" ? "idle" : "ready", tone: "success", icon: "ti-circle-check", stale, reconnecting, badges: presenceBadges(attention, pair, {}) };
1353
+ }
1354
+
1355
+ function presenceBadges(attention, pair, flags) {
1356
+ const badges = [];
1357
+ if (flags.reconnecting) badges.push({ label: "reconnecting", className: "bg-danger-lt" });
1358
+ if (flags.starting) badges.push({ label: "online, not ready", className: "bg-warning-lt" });
1359
+ if (flags.busy) badges.push({ label: "busy in turn", className: "bg-warning-lt" });
1360
+ if (pair?.status === "active") badges.push({ label: "paired", className: "bg-success-lt" });
1361
+ if (pair?.status === "pending") badges.push({ label: "pair invite pending", className: "bg-warning-lt" });
1362
+ if (attention.unread) badges.push({ label: attention.unread + " unread", className: "bg-danger-lt" });
1363
+ if (attention.needsHumanResponse) badges.push({ label: "needs response", className: "bg-warning-lt" });
1364
+ if (attention.agentQuestion) badges.push({ label: "question", className: "bg-info-lt" });
1365
+ if (attention.claimableTasks) badges.push({ label: attention.claimableTasks + " claimable", className: "bg-orange-lt" });
1366
+ if (!badges.length && !flags.offline) badges.push({ label: "ready", className: "bg-success-lt" });
1367
+ return badges;
1368
+ }
1369
+
1370
+ function agentPresenceBadges(agent) {
1371
+ return agentPresence.call(this, agent).badges;
1372
+ }
1373
+
1374
+ function agentStatusClass(agent) {
1375
+ const presence = agentPresence.call(this, agent);
1376
+ return [
1377
+ agent?.status || "offline",
1378
+ agent?.status !== "offline" && !agent?.ready ? "not-ready" : "",
1379
+ presence.stale ? "stale" : "",
1380
+ presence.reconnecting ? "reconnecting" : "",
1381
+ presence.label === "paired" ? "paired" : "",
1382
+ presence.label === "idle, unread" ? "attention" : "",
1383
+ ].filter(Boolean).join(" ");
1384
+ }
1385
+
519
1386
  function severityClass(severity) {
520
1387
  if (severity === "critical") return "bg-danger-lt";
521
1388
  if (severity === "warning") return "bg-warning-lt";
@@ -525,6 +1392,11 @@
525
1392
  function agentStatusTitle(agent) {
526
1393
  if (!agent) return "";
527
1394
  if (agent.status === "offline") return "offline";
1395
+ if (isAgentStale(this, agent) && !agent.ready) return "reconnecting";
1396
+ if (isAgentStale(this, agent)) return "stale heartbeat";
1397
+ if (agent.status === "busy") return "busy in turn";
1398
+ if (this.agentPair(agent)?.status === "active") return "paired";
1399
+ if (agentAttention.call(this, agent).unread) return "idle but has unread";
528
1400
  if (agent.ready) return agent.status;
529
1401
 
530
1402
  const lastSeenMs = new Date(agent.lastSeen).getTime();
@@ -557,17 +1429,66 @@
557
1429
  return "alert-success";
558
1430
  }
559
1431
 
1432
+ function activityKindClass(kind) {
1433
+ if (kind === "question") return "bg-info-lt";
1434
+ if (kind === "task") return "bg-warning-lt";
1435
+ if (kind === "pair") return "bg-success-lt";
1436
+ if (kind === "operator") return "bg-primary-lt";
1437
+ if (kind === "reply") return "bg-purple-lt";
1438
+ return "bg-secondary-lt";
1439
+ }
1440
+
560
1441
  function createMessageActions() {
561
1442
  return {
562
1443
  openCompose,
563
1444
  openComposeToAgent,
1445
+ openComposeToInboxThread,
1446
+ openInboxThread,
1447
+ markInboxThreadRead,
1448
+ markInboxThreadUnread,
1449
+ archiveInboxThread,
1450
+ unarchiveInboxThread,
1451
+ confirmDeleteInboxThread,
1452
+ doDeleteInboxThread,
1453
+ replyDraftForThread,
1454
+ setReplyDraft,
1455
+ clearReplyDraft,
1456
+ sendInboxReply,
1457
+ resetInboxComposeTarget,
1458
+ doSendInboxCompose,
564
1459
  startReply,
565
1460
  cancelReply,
566
1461
  doSend,
567
1462
  doClaim,
1463
+ doClaimTask,
1464
+ doUpdateTaskStatus,
568
1465
  doDeleteMessage,
569
1466
  openThread,
570
1467
  openTaskEvents,
1468
+ recordOperatorActivity,
1469
+ openActivityItem,
1470
+ runHealthAction,
1471
+ openCommandPalette,
1472
+ closeCommandPalette,
1473
+ runCommand,
1474
+ exportActivity,
1475
+ exportThread,
1476
+ exportPair,
1477
+ exportTask,
1478
+ };
1479
+ }
1480
+
1481
+ function createPairActions() {
1482
+ return {
1483
+ openPairMessage,
1484
+ closePairMessage,
1485
+ openPairInvite,
1486
+ closePairInvite,
1487
+ doCreatePair,
1488
+ doSendPairMessage,
1489
+ doAcceptPair,
1490
+ doRejectPair,
1491
+ doHangupPair,
571
1492
  };
572
1493
  }
573
1494
 
@@ -588,12 +1509,243 @@
588
1509
  focusComposeBody(this);
589
1510
  }
590
1511
 
1512
+ function openComposeToInboxThread(thread) {
1513
+ if (!thread) return;
1514
+ this.replyTo = thread.lastMessage ? { id: thread.lastMessage.id, from: thread.lastMessage.from } : null;
1515
+ this.compose = { ...DEFAULT_COMPOSE, from: HUMAN_AGENT_ID, to: thread.peer, channel: thread.lastMessage?.channel || "" };
1516
+ this.composeOpen = true;
1517
+ focusComposeBody(this);
1518
+ }
1519
+
1520
+ function openInboxThread(thread) {
1521
+ this.selectedInboxThread = thread?.id || "";
1522
+ this.markInboxThreadRead(thread);
1523
+ }
1524
+
1525
+ function markInboxThreadRead(thread) {
1526
+ if (!thread?.peer || !thread.messages?.length) return;
1527
+ const lastInboundId = maxMessageId(thread.messages, isHumanInboundMessage);
1528
+ if (lastInboundId <= readCursorForPeer(this, thread.peer)) return;
1529
+ this.inboxReadCursors = { ...this.inboxReadCursors, [thread.peer]: lastInboundId };
1530
+ savePref("inboxReadCursors", this.inboxReadCursors);
1531
+ void saveInboxThreadState(this, {
1532
+ peerId: thread.peer,
1533
+ readCursorMessageId: lastInboundId,
1534
+ });
1535
+ }
1536
+
1537
+ function markInboxThreadUnread(thread) {
1538
+ if (!thread?.peer) return;
1539
+ const next = { ...this.inboxReadCursors };
1540
+ delete next[thread.peer];
1541
+ this.inboxReadCursors = next;
1542
+ savePref("inboxReadCursors", this.inboxReadCursors);
1543
+ this.recordOperatorActivity({
1544
+ title: "Marked thread unread",
1545
+ body: this.conversationTitle(thread),
1546
+ meta: "Inbox",
1547
+ icon: "ti-mail",
1548
+ view: "inbox",
1549
+ peer: thread.peer,
1550
+ });
1551
+ void saveInboxThreadState(this, {
1552
+ peerId: thread.peer,
1553
+ readCursorMessageId: null,
1554
+ });
1555
+ }
1556
+
1557
+ function archiveInboxThread(thread) {
1558
+ if (!thread?.peer || !thread.lastMessage) return;
1559
+ this.inboxArchivedThreads = { ...this.inboxArchivedThreads, [thread.peer]: thread.lastMessage.id };
1560
+ savePref("inboxArchivedThreads", this.inboxArchivedThreads);
1561
+ this.recordOperatorActivity({
1562
+ title: "Archived thread",
1563
+ body: this.conversationTitle(thread),
1564
+ meta: "Inbox",
1565
+ icon: "ti-archive",
1566
+ view: "inbox",
1567
+ peer: thread.peer,
1568
+ });
1569
+ void saveInboxThreadState(this, {
1570
+ peerId: thread.peer,
1571
+ archivedAtMessageId: thread.lastMessage.id,
1572
+ });
1573
+ if (this.selectedInboxThread === thread.id) this.selectedInboxThread = "";
1574
+ }
1575
+
1576
+ function unarchiveInboxThread(thread) {
1577
+ if (!thread?.peer) return;
1578
+ const next = { ...this.inboxArchivedThreads };
1579
+ delete next[thread.peer];
1580
+ this.inboxArchivedThreads = next;
1581
+ savePref("inboxArchivedThreads", this.inboxArchivedThreads);
1582
+ this.recordOperatorActivity({
1583
+ title: "Unarchived thread",
1584
+ body: this.conversationTitle(thread),
1585
+ meta: "Inbox",
1586
+ icon: "ti-archive-off",
1587
+ view: "inbox",
1588
+ peer: thread.peer,
1589
+ });
1590
+ void saveInboxThreadState(this, {
1591
+ peerId: thread.peer,
1592
+ archivedAtMessageId: null,
1593
+ });
1594
+ }
1595
+
1596
+ async function saveInboxThreadState(vm, patch) {
1597
+ try {
1598
+ await vm.api("PATCH", "/inbox/threads", { operatorId: INBOX_OPERATOR_ID, ...patch });
1599
+ } catch {}
1600
+ }
1601
+
1602
+ function confirmDeleteInboxThread(thread) {
1603
+ if (!thread) return;
1604
+ this.openConfirm(
1605
+ "Delete Thread",
1606
+ `Delete ${thread.messages.length} message(s) in ${this.conversationTitle(thread)}? This cannot be undone.`,
1607
+ () => this.doDeleteInboxThread(thread)
1608
+ );
1609
+ }
1610
+
1611
+ async function doDeleteInboxThread(thread) {
1612
+ if (!thread?.messages?.length) return;
1613
+ try {
1614
+ await Promise.all(thread.messages.map((msg) => this.api("DELETE", "/messages/" + msg.id)));
1615
+ this.messages = this.messages.filter((msg) => !thread.messages.some((item) => item.id === msg.id));
1616
+ this.selectedInboxThread = "";
1617
+ this.clearReplyDraft(thread);
1618
+ this.recordOperatorActivity({
1619
+ title: "Deleted thread",
1620
+ body: this.conversationTitle(thread),
1621
+ meta: thread.messages.length + " message(s)",
1622
+ icon: "ti-trash",
1623
+ view: "inbox",
1624
+ });
1625
+ } catch (e) {
1626
+ alert("Delete thread failed: " + e.message);
1627
+ }
1628
+ }
1629
+
1630
+ function replyDraftForThread(thread) {
1631
+ return thread?.peer ? draftForPeer(this, thread.peer) : "";
1632
+ }
1633
+
1634
+ function setReplyDraft(thread, value) {
1635
+ if (!thread?.peer) return;
1636
+ const next = { ...this.inboxDrafts, [thread.peer]: value };
1637
+ if (!value) delete next[thread.peer];
1638
+ this.inboxDrafts = next;
1639
+ savePref("inboxDrafts", this.inboxDrafts);
1640
+ if (value) {
1641
+ void saveInboxDraft(this, thread.peer, value);
1642
+ } else {
1643
+ void deleteInboxDraftState(this, thread.peer);
1644
+ }
1645
+ }
1646
+
1647
+ function clearReplyDraft(thread) {
1648
+ if (!thread?.peer) return;
1649
+ this.setReplyDraft(thread, "");
1650
+ }
1651
+
1652
+ async function saveInboxDraft(vm, peerId, body) {
1653
+ try {
1654
+ await vm.api("PUT", "/inbox/drafts", { operatorId: INBOX_OPERATOR_ID, peerId, body });
1655
+ } catch {}
1656
+ }
1657
+
1658
+ async function deleteInboxDraftState(vm, peerId) {
1659
+ try {
1660
+ await vm.api("DELETE", "/inbox/drafts?operatorId=" + encodeURIComponent(INBOX_OPERATOR_ID) + "&peerId=" + encodeURIComponent(peerId));
1661
+ } catch {}
1662
+ }
1663
+
1664
+ async function sendInboxReply(thread) {
1665
+ if (!thread) return;
1666
+ const body = this.replyDraftForThread(thread).trim();
1667
+ if (!body) {
1668
+ alert("Reply body is required.");
1669
+ return;
1670
+ }
1671
+
1672
+ try {
1673
+ const payload = {
1674
+ from: HUMAN_AGENT_ID,
1675
+ to: thread.peer,
1676
+ body,
1677
+ };
1678
+ if (thread.lastMessage?.channel) payload.channel = thread.lastMessage.channel;
1679
+ if (thread.lastMessage?.id) payload.replyTo = thread.lastMessage.id;
1680
+ await this.api("POST", "/messages", payload);
1681
+ this.recordOperatorActivity({
1682
+ title: "Reply sent",
1683
+ body,
1684
+ meta: "to " + this.displayTarget(thread.peer),
1685
+ icon: "ti-corner-up-left",
1686
+ view: "inbox",
1687
+ peer: thread.peer,
1688
+ });
1689
+ this.clearReplyDraft(thread);
1690
+ this.markInboxThreadRead(thread);
1691
+ await this.fetchMessages();
1692
+ } catch (e) {
1693
+ alert("Reply failed: " + e.message);
1694
+ }
1695
+ }
1696
+
1697
+ function resetInboxComposeTarget() {
1698
+ this.inboxCompose = { ...this.inboxCompose, to: "" };
1699
+ }
1700
+
1701
+ function inboxComposeTarget(vm) {
1702
+ const target = vm.inboxCompose.to;
1703
+ if (!target) return "";
1704
+ if (vm.inboxCompose.toMode === "tag") return "tag:" + target;
1705
+ if (vm.inboxCompose.toMode === "cap") return "cap:" + target;
1706
+ return target;
1707
+ }
1708
+
1709
+ async function doSendInboxCompose() {
1710
+ const target = inboxComposeTarget(this);
1711
+ if (!target || !this.inboxCompose.body) {
1712
+ alert("Target and Message are required.");
1713
+ return;
1714
+ }
1715
+
1716
+ try {
1717
+ const payload = {
1718
+ from: HUMAN_AGENT_ID,
1719
+ to: target,
1720
+ body: this.inboxCompose.body,
1721
+ };
1722
+ if (this.inboxCompose.channel) payload.channel = this.inboxCompose.channel;
1723
+ if (this.inboxCompose.subject) payload.subject = this.inboxCompose.subject;
1724
+ if (this.inboxCompose.claimable) payload.claimable = true;
1725
+ await this.api("POST", "/messages", payload);
1726
+ this.recordOperatorActivity({
1727
+ title: this.inboxCompose.claimable ? "Claimable task sent" : "Message sent",
1728
+ body: this.inboxCompose.subject || this.inboxCompose.body,
1729
+ meta: "to " + this.displayTarget(target),
1730
+ icon: this.inboxCompose.claimable ? "ti-hand-grab" : "ti-send",
1731
+ kind: this.inboxCompose.claimable ? "task" : "operator",
1732
+ view: inboxPeer(payload) ? "inbox" : "messages",
1733
+ peer: inboxPeer(payload),
1734
+ });
1735
+ this.inboxCompose = { ...DEFAULT_INBOX_COMPOSE, toMode: this.inboxCompose.toMode, to: this.inboxCompose.to };
1736
+ await this.fetchMessages();
1737
+ } catch (e) {
1738
+ alert("Send failed: " + e.message);
1739
+ }
1740
+ }
1741
+
591
1742
  function startReply(msg) {
1743
+ const replyTarget = msg.from === HUMAN_AGENT_ID ? msg.to : msg.from;
592
1744
  this.replyTo = { id: msg.id, from: msg.from };
593
1745
  this.compose = {
594
1746
  ...DEFAULT_COMPOSE,
595
- from: "",
596
- to: msg.from,
1747
+ from: this.view === "inbox" ? HUMAN_AGENT_ID : "",
1748
+ to: replyTarget,
597
1749
  channel: msg.channel || "",
598
1750
  };
599
1751
  this.openCompose();
@@ -623,10 +1775,21 @@
623
1775
  }
624
1776
 
625
1777
  try {
626
- await this.api("POST", "/messages", buildMessagePayload(this));
1778
+ const payload = buildMessagePayload(this);
1779
+ await this.api("POST", "/messages", payload);
1780
+ this.recordOperatorActivity({
1781
+ title: payload.claimable ? "Claimable task sent" : "Message sent",
1782
+ body: payload.subject || payload.body,
1783
+ meta: `${this.displayTarget(payload.from)} -> ${this.displayTarget(payload.to)}`,
1784
+ icon: payload.claimable ? "ti-hand-grab" : "ti-send",
1785
+ kind: payload.claimable ? "task" : "operator",
1786
+ view: inboxPeer(payload) ? "inbox" : "messages",
1787
+ peer: inboxPeer(payload),
1788
+ });
627
1789
  this.composeOpen = false;
628
1790
  this.replyTo = null;
629
1791
  this.compose = { ...DEFAULT_COMPOSE };
1792
+ await this.fetchMessages();
630
1793
  } catch (e) {
631
1794
  alert("Send failed: " + e.message);
632
1795
  }
@@ -642,15 +1805,85 @@
642
1805
  try {
643
1806
  const result = await this.api("POST", "/messages/" + msgId + "/claim", { agentId });
644
1807
  if (!result.ok) alert("Claim failed: " + (result.error || "unknown"));
1808
+ else this.recordOperatorActivity({
1809
+ title: "Claim requested",
1810
+ body: "Message #" + msgId,
1811
+ meta: "as " + this.displayTarget(agentId),
1812
+ icon: "ti-hand-grab",
1813
+ kind: "task",
1814
+ view: "tasks",
1815
+ });
1816
+ if (result.ok) await Promise.all([this.fetchMessages(), this.fetchTasks()]);
645
1817
  } catch (e) {
646
1818
  alert("Claim failed: " + e.message);
647
1819
  }
648
1820
  }
649
1821
 
1822
+ async function doClaimTask(taskId) {
1823
+ if (!this.compose.from && !this.selectedAgent) {
1824
+ alert('Select an agent first (use the Messages agent filter or Compose From).');
1825
+ return;
1826
+ }
1827
+
1828
+ const agentId = this.compose.from || this.selectedAgent;
1829
+ try {
1830
+ const result = await this.api("POST", "/tasks/" + taskId + "/claim", { agentId });
1831
+ if (!result.ok) alert("Task claim failed: " + (result.error || "unknown"));
1832
+ else {
1833
+ this.recordOperatorActivity({
1834
+ title: "Task claimed",
1835
+ body: "Task #" + taskId,
1836
+ meta: "as " + this.displayTarget(agentId),
1837
+ icon: "ti-user-check",
1838
+ kind: "task",
1839
+ view: "work",
1840
+ taskId,
1841
+ agentId,
1842
+ });
1843
+ await Promise.all([this.fetchTasks(), this.fetchMessages()]);
1844
+ }
1845
+ } catch (e) {
1846
+ alert("Task claim failed: " + e.message);
1847
+ }
1848
+ }
1849
+
1850
+ async function doUpdateTaskStatus(task, status) {
1851
+ if (!task || !status || status === task.status) return;
1852
+ try {
1853
+ const body = { status };
1854
+ const agentId = task.claimedBy || this.compose.from || this.selectedAgent;
1855
+ if (agentId) body.agentId = agentId;
1856
+ const result = await this.api("PATCH", "/tasks/" + task.id + "/status", body);
1857
+ const updated = result.task || result;
1858
+ const idx = this.tasks.findIndex((item) => item.id === task.id);
1859
+ if (idx >= 0) this.tasks[idx] = updated;
1860
+ this.recordOperatorActivity({
1861
+ title: "Task moved to " + status,
1862
+ body: updated.title || "Task #" + task.id,
1863
+ meta: agentId ? "by " + this.displayTarget(agentId) : "Work Queue",
1864
+ icon: "ti-arrows-exchange",
1865
+ kind: "task",
1866
+ view: "work",
1867
+ taskId: task.id,
1868
+ agentId,
1869
+ });
1870
+ } catch (e) {
1871
+ alert("Task status update failed: " + e.message);
1872
+ await this.fetchTasks();
1873
+ }
1874
+ }
1875
+
650
1876
  async function doDeleteMessage(id) {
651
1877
  try {
652
1878
  await this.api("DELETE", "/messages/" + id);
653
1879
  this.messages = this.messages.filter((msg) => msg.id !== id);
1880
+ this.recordOperatorActivity({
1881
+ title: "Deleted message",
1882
+ body: "Message #" + id,
1883
+ meta: "Messages",
1884
+ icon: "ti-trash",
1885
+ view: "messages",
1886
+ });
654
1887
  } catch (e) {
655
1888
  alert("Delete failed: " + e.message);
656
1889
  }
@@ -671,13 +1904,423 @@
671
1904
  this.taskEventsOpen = true;
672
1905
  try {
673
1906
  this.taskEvents = await this.api("GET", "/tasks/" + task.id + "/events");
1907
+ this.taskEventCache = { ...this.taskEventCache, [task.id]: this.taskEvents };
674
1908
  } catch (e) {
675
1909
  alert("Failed to load task events: " + e.message);
676
1910
  }
677
1911
  }
678
1912
 
1913
+ function recordOperatorActivity(input) {
1914
+ const item = activityItem({
1915
+ kind: "operator",
1916
+ ts: Date.now(),
1917
+ ...input,
1918
+ });
1919
+ item.id = item.id || "operator-" + item.ts + "-" + (this.operatorActivity?.length || 0);
1920
+ item.clientId = item.clientId || item.id;
1921
+ this.operatorActivity = [
1922
+ item,
1923
+ ...(this.operatorActivity || []).filter((existing) => existing.id !== item.id),
1924
+ ].slice(0, 80);
1925
+ savePref("operatorActivity", this.operatorActivity);
1926
+ void saveActivityEvent(this, item);
1927
+ }
1928
+
1929
+ async function saveActivityEvent(vm, item) {
1930
+ try {
1931
+ const event = await vm.api("POST", "/activity", {
1932
+ operatorId: INBOX_OPERATOR_ID,
1933
+ clientId: item.clientId,
1934
+ kind: item.kind,
1935
+ title: item.title,
1936
+ body: item.body || undefined,
1937
+ meta: item.meta || undefined,
1938
+ icon: item.icon || undefined,
1939
+ view: item.view || undefined,
1940
+ peer: item.peer || undefined,
1941
+ messageId: item.messageId,
1942
+ pairId: item.pairId,
1943
+ taskId: item.taskId,
1944
+ agentId: item.agentId,
1945
+ });
1946
+ const existing = new Set((vm.activityEvents || []).map((entry) => entry.id));
1947
+ vm.activityEvents = existing.has(event.id)
1948
+ ? vm.activityEvents.map((entry) => entry.id === event.id ? event : entry)
1949
+ : [event, ...(vm.activityEvents || [])].slice(0, 200);
1950
+ pruneSyncedOperatorActivity(vm);
1951
+ } catch {}
1952
+ }
1953
+
1954
+ async function openActivityItem(item) {
1955
+ if (!item) return;
1956
+ if (item.view) await this.switchView(item.view);
1957
+ if (item.peer) {
1958
+ const thread = this.allInboxThreads.find((candidate) => candidate.peer === item.peer);
1959
+ if (thread?.archived) this.inboxShowArchived = true;
1960
+ this.selectedInboxThread = item.peer;
1961
+ if (thread) this.markInboxThreadRead(thread);
1962
+ }
1963
+ if (item.agentId && this.agentsById[item.agentId]) this.openAgentDetail(this.agentsById[item.agentId]);
1964
+ if (item.taskId) {
1965
+ const task = this.tasks.find((candidate) => candidate.id === item.taskId);
1966
+ if (task) await this.openTaskEvents(task);
1967
+ }
1968
+ }
1969
+
1970
+ async function runHealthAction(action) {
1971
+ if (!action) return;
1972
+ if (action.preset) {
1973
+ this.agentPresetFilter = action.preset;
1974
+ this.showOffline = true;
1975
+ }
1976
+ if (action.api && action.path) {
1977
+ await this.api(action.api, action.path);
1978
+ await this.refreshLiveData();
1979
+ }
1980
+ if (action.view) await this.switchView(action.view);
1981
+ if (action.copy) await copyText(action.copy);
1982
+ }
1983
+
1984
+ async function copyText(value) {
1985
+ if (typeof navigator === "undefined") return;
1986
+ try {
1987
+ await navigator.clipboard?.writeText(value);
1988
+ } catch {}
1989
+ }
1990
+
1991
+ function openCommandPalette() {
1992
+ this.commandQuery = "";
1993
+ this.commandPaletteOpen = true;
1994
+ this.$nextTick(() => this.$refs?.commandSearch?.focus());
1995
+ }
1996
+
1997
+ function closeCommandPalette() {
1998
+ this.commandPaletteOpen = false;
1999
+ this.commandQuery = "";
2000
+ }
2001
+
2002
+ async function runCommand(command) {
2003
+ if (!command) return;
2004
+ const payload = command.payload || {};
2005
+ this.closeCommandPalette();
2006
+ if (command.action === "openView") {
2007
+ await this.switchView(payload.view);
2008
+ } else if (command.action === "agentPreset") {
2009
+ this.showOffline = true;
2010
+ this.agentPresetFilter = payload.preset || "";
2011
+ await this.switchView("agents");
2012
+ } else if (command.action === "copy") {
2013
+ await copyText(payload.value || "");
2014
+ } else if (command.action === "messageAgent") {
2015
+ const agent = this.agentsById[payload.agentId];
2016
+ if (agent) this.openComposeToAgent(agent);
2017
+ } else if (command.action === "pairAgent") {
2018
+ this.openPairInvite(payload.agentId);
2019
+ } else if (command.action === "filterTag") {
2020
+ this.agentTagFilter = payload.tag || "";
2021
+ this.tagFilter = payload.tag || "";
2022
+ await this.switchView("agents");
2023
+ } else if (command.action === "exportActivity") {
2024
+ this.exportActivity(payload.format || "markdown");
2025
+ }
2026
+ }
2027
+
2028
+ function exportActivity(format) {
2029
+ exportDocument(this, "timeline", format, {
2030
+ title: "Agent Relay Timeline",
2031
+ items: this.activityItems,
2032
+ });
2033
+ }
2034
+
2035
+ function exportThread(thread, format) {
2036
+ if (!thread) return;
2037
+ exportDocument(this, "thread-" + safeFilename(thread.peer), format, {
2038
+ title: "Thread: " + this.conversationTitle(thread),
2039
+ thread,
2040
+ messages: thread.messages || [],
2041
+ });
2042
+ }
2043
+
2044
+ function exportPair(pair, format) {
2045
+ if (!pair) return;
2046
+ exportDocument(this, "pair-" + safeFilename(pair.id), format, {
2047
+ title: "Pair: " + pair.id,
2048
+ pair,
2049
+ messages: pairMessages(this, pair),
2050
+ });
2051
+ }
2052
+
2053
+ async function exportTask(task, format) {
2054
+ if (!task) return;
2055
+ let events = this.taskEventCache[task.id] || [];
2056
+ if (!events.length) {
2057
+ try {
2058
+ events = await this.api("GET", "/tasks/" + task.id + "/events");
2059
+ this.taskEventCache = { ...this.taskEventCache, [task.id]: events };
2060
+ } catch {
2061
+ events = [];
2062
+ }
2063
+ }
2064
+ exportDocument(this, "task-" + task.id, format, {
2065
+ title: "Task: " + (task.title || "#" + task.id),
2066
+ task,
2067
+ events,
2068
+ });
2069
+ }
2070
+
2071
+ function pairMessages(vm, pair) {
2072
+ return (vm.messages || []).filter((msg) =>
2073
+ msg.meta?.pairId === pair.id ||
2074
+ (msg.meta?.pairEvent && [pair.requesterId, pair.targetId].includes(msg.from) && [pair.requesterId, pair.targetId].includes(msg.to))
2075
+ );
2076
+ }
2077
+
2078
+ function exportDocument(vm, scope, format, data) {
2079
+ const normalizedFormat = format === "json" ? "json" : "markdown";
2080
+ const filename = `agent-relay-${scope}-${new Date().toISOString().slice(0, 10)}.${normalizedFormat === "json" ? "json" : "md"}`;
2081
+ const text = normalizedFormat === "json" ? JSON.stringify(exportJson(data), null, 2) : exportMarkdown(vm, data);
2082
+ downloadText(filename, text, normalizedFormat === "json" ? "application/json" : "text/markdown");
2083
+ }
2084
+
2085
+ function exportJson(data) {
2086
+ return {
2087
+ exportedAt: new Date().toISOString(),
2088
+ ...data,
2089
+ };
2090
+ }
2091
+
2092
+ function exportMarkdown(vm, data) {
2093
+ const lines = ["# " + data.title, "", "Exported: " + new Date().toISOString(), ""];
2094
+ if (data.thread) {
2095
+ lines.push("## Messages", "");
2096
+ appendMessages(lines, vm, data.messages || []);
2097
+ } else if (data.pair) {
2098
+ lines.push("## Pair", "", "- ID: " + data.pair.id, "- Status: " + data.pair.status, "- Requester: " + vm.displayTarget(data.pair.requesterId), "- Target: " + vm.displayTarget(data.pair.targetId));
2099
+ if (data.pair.objective) lines.push("- Objective: " + data.pair.objective);
2100
+ lines.push("", "## Messages", "");
2101
+ appendMessages(lines, vm, data.messages || []);
2102
+ } else if (data.task) {
2103
+ lines.push("## Task", "", "- ID: " + data.task.id, "- Status: " + data.task.status, "- Severity: " + (data.task.severity || "info"), "- Target: " + vm.displayTarget(data.task.target || ""));
2104
+ if (data.task.claimedBy) lines.push("- Claimed by: " + vm.displayTarget(data.task.claimedBy));
2105
+ if (data.task.title) lines.push("- Title: " + data.task.title);
2106
+ if (data.task.body) lines.push("", data.task.body);
2107
+ lines.push("", "## History", "");
2108
+ appendEvents(lines, vm, data.events || []);
2109
+ } else {
2110
+ lines.push("## Events", "");
2111
+ appendActivity(lines, vm, data.items || []);
2112
+ }
2113
+ return lines.join("\n").trim() + "\n";
2114
+ }
2115
+
2116
+ function appendMessages(lines, vm, messages) {
2117
+ if (!messages.length) {
2118
+ lines.push("_No messages loaded._", "");
2119
+ return;
2120
+ }
2121
+ for (const msg of messages) {
2122
+ lines.push(`### #${msg.id} ${vm.displayTarget(msg.from)} -> ${vm.displayTarget(msg.to)}`, "");
2123
+ if (msg.createdAt) lines.push("- Created: " + msg.createdAt);
2124
+ if (msg.channel) lines.push("- Channel: " + msg.channel);
2125
+ if (msg.subject) lines.push("- Subject: " + msg.subject);
2126
+ if (msg.claimedBy) lines.push("- Claimed by: " + vm.displayTarget(msg.claimedBy));
2127
+ lines.push("", msg.body || "", "");
2128
+ }
2129
+ }
2130
+
2131
+ function appendEvents(lines, vm, events) {
2132
+ if (!events.length) {
2133
+ lines.push("_No task events loaded._", "");
2134
+ return;
2135
+ }
2136
+ for (const event of events) {
2137
+ lines.push(`- ${event.createdAt || ""} [${event.severity || "info"}] ${event.type || "event"}: ${event.title || event.body || ""}`.trim());
2138
+ }
2139
+ }
2140
+
2141
+ function appendActivity(lines, vm, items) {
2142
+ if (!items.length) {
2143
+ lines.push("_No activity loaded._", "");
2144
+ return;
2145
+ }
2146
+ for (const item of items) {
2147
+ const when = item.ts ? new Date(item.ts).toISOString() : "";
2148
+ const meta = item.meta ? " - " + item.meta : "";
2149
+ lines.push(`- ${when} [${item.kind}] ${item.title}${meta}`);
2150
+ if (item.body) lines.push(" " + item.body.replace(/\n/g, "\n "));
2151
+ }
2152
+ }
2153
+
2154
+ function downloadText(filename, text, type) {
2155
+ if (typeof document === "undefined" || typeof URL === "undefined" || typeof Blob === "undefined") {
2156
+ void copyText(text);
2157
+ return;
2158
+ }
2159
+ const url = URL.createObjectURL(new Blob([text], { type }));
2160
+ const link = document.createElement("a");
2161
+ link.href = url;
2162
+ link.download = filename;
2163
+ link.click();
2164
+ URL.revokeObjectURL(url);
2165
+ }
2166
+
2167
+ function safeFilename(value) {
2168
+ return String(value || "export").replace(/[^a-z0-9._-]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "export";
2169
+ }
2170
+
2171
+ function openPairMessage(pair, fromId) {
2172
+ if (!pair) return;
2173
+ this.pairMessage = {
2174
+ ...DEFAULT_PAIR_MESSAGE,
2175
+ pairId: pair.id,
2176
+ from: fromId || pair.requesterId || pair.targetId || "",
2177
+ };
2178
+ this.pairMessageOpen = true;
2179
+ this.$nextTick(() => this.$refs?.pairMessageBody?.focus());
2180
+ }
2181
+
2182
+ function openPairInvite(requesterId) {
2183
+ this.pairInvite = {
2184
+ ...DEFAULT_PAIR_INVITE,
2185
+ requesterId: requesterId || this.selectedAgent || "",
2186
+ };
2187
+ this.pairInviteOpen = true;
2188
+ this.$nextTick(() => this.$refs?.pairInviteObjective?.focus());
2189
+ }
2190
+
2191
+ function closePairInvite() {
2192
+ this.pairInviteOpen = false;
2193
+ this.pairInvite = { ...DEFAULT_PAIR_INVITE };
2194
+ }
2195
+
2196
+ async function doCreatePair() {
2197
+ if (!this.pairInvite.requesterId || !this.pairInvite.targetId) {
2198
+ alert("Requester and Target are required.");
2199
+ return;
2200
+ }
2201
+ if (this.pairInvite.requesterId === this.pairInvite.targetId) {
2202
+ alert("Requester and Target must be different agents.");
2203
+ return;
2204
+ }
2205
+
2206
+ try {
2207
+ const payload = {
2208
+ from: this.pairInvite.requesterId,
2209
+ target: this.pairInvite.targetId,
2210
+ };
2211
+ if (this.pairInvite.objective) payload.objective = this.pairInvite.objective;
2212
+ await this.api("POST", "/pairs", payload);
2213
+ this.recordOperatorActivity({
2214
+ title: "Pair invite sent",
2215
+ body: payload.objective || "",
2216
+ meta: `${this.displayTarget(payload.from)} <-> ${this.displayTarget(payload.target)}`,
2217
+ icon: "ti-link-plus",
2218
+ kind: "pair",
2219
+ view: "pairs",
2220
+ });
2221
+ this.closePairInvite();
2222
+ await Promise.all([this.fetchPairs(), this.fetchMessages()]);
2223
+ } catch (e) {
2224
+ alert("Pair invite failed: " + e.message);
2225
+ }
2226
+ }
2227
+
2228
+ function closePairMessage() {
2229
+ this.pairMessageOpen = false;
2230
+ this.pairMessage = { ...DEFAULT_PAIR_MESSAGE };
2231
+ }
2232
+
2233
+ async function doSendPairMessage() {
2234
+ if (!this.pairMessage.pairId || !this.pairMessage.from || !this.pairMessage.body) {
2235
+ alert("Pair, From, and Message are required.");
2236
+ return;
2237
+ }
2238
+
2239
+ try {
2240
+ const payload = { from: this.pairMessage.from, body: this.pairMessage.body };
2241
+ if (this.pairMessage.subject) payload.subject = this.pairMessage.subject;
2242
+ await this.api("POST", "/pairs/" + encodeURIComponent(this.pairMessage.pairId) + "/messages", payload);
2243
+ this.recordOperatorActivity({
2244
+ title: "Pair message sent",
2245
+ body: payload.subject || payload.body,
2246
+ meta: "from " + this.displayTarget(payload.from),
2247
+ icon: "ti-messages",
2248
+ kind: "pair",
2249
+ view: "pairs",
2250
+ });
2251
+ this.closePairMessage();
2252
+ await Promise.all([this.fetchPairs(), this.fetchMessages()]);
2253
+ } catch (e) {
2254
+ alert("Pair message failed: " + e.message);
2255
+ }
2256
+ }
2257
+
2258
+ async function doAcceptPair(pair) {
2259
+ if (!pair) return;
2260
+ try {
2261
+ await this.api("POST", "/pairs/" + encodeURIComponent(pair.id) + "/accept", { agentId: pair.targetId });
2262
+ this.recordOperatorActivity({
2263
+ title: "Pair accepted",
2264
+ body: pair.objective || "",
2265
+ meta: `${this.displayTarget(pair.requesterId)} <-> ${this.displayTarget(pair.targetId)}`,
2266
+ icon: "ti-check",
2267
+ kind: "pair",
2268
+ view: "pairs",
2269
+ });
2270
+ await Promise.all([this.fetchPairs(), this.fetchMessages()]);
2271
+ } catch (e) {
2272
+ alert("Accept failed: " + e.message);
2273
+ }
2274
+ }
2275
+
2276
+ async function doRejectPair(pair) {
2277
+ if (!pair) return;
2278
+ try {
2279
+ await this.api("POST", "/pairs/" + encodeURIComponent(pair.id) + "/reject", { agentId: pair.targetId });
2280
+ this.recordOperatorActivity({
2281
+ title: "Pair rejected",
2282
+ body: pair.objective || "",
2283
+ meta: `${this.displayTarget(pair.requesterId)} <-> ${this.displayTarget(pair.targetId)}`,
2284
+ icon: "ti-x",
2285
+ kind: "pair",
2286
+ view: "pairs",
2287
+ });
2288
+ await Promise.all([this.fetchPairs(), this.fetchMessages()]);
2289
+ } catch (e) {
2290
+ alert("Reject failed: " + e.message);
2291
+ }
2292
+ }
2293
+
2294
+ async function doHangupPair(pair, agentId) {
2295
+ if (!pair) return;
2296
+ try {
2297
+ await this.api("POST", "/pairs/" + encodeURIComponent(pair.id) + "/hangup", { agentId: agentId || pair.requesterId });
2298
+ this.recordOperatorActivity({
2299
+ title: "Pair hung up",
2300
+ body: pair.objective || "",
2301
+ meta: "by " + this.displayTarget(agentId || pair.requesterId),
2302
+ icon: "ti-phone-off",
2303
+ kind: "pair",
2304
+ view: "pairs",
2305
+ });
2306
+ await Promise.all([this.fetchPairs(), this.fetchMessages()]);
2307
+ } catch (e) {
2308
+ alert("Hang up failed: " + e.message);
2309
+ }
2310
+ }
2311
+
679
2312
  function createAgentActions() {
680
2313
  return {
2314
+ openAgentDetail(agent) {
2315
+ if (!agent) return;
2316
+ this.agentDetailId = agent.id;
2317
+ this.agentDetailOpen = true;
2318
+ },
2319
+
2320
+ closeAgentDetail() {
2321
+ this.agentDetailOpen = false;
2322
+ },
2323
+
681
2324
  openRename(agent) {
682
2325
  this.renameModal = { show: true, agentId: agent.id, label: agent.label || "" };
683
2326
  this.$nextTick(() => this.$refs.renameInput?.focus());
@@ -826,6 +2469,7 @@
826
2469
  ...createApiMethods(),
827
2470
  ...createDisplayMethods(),
828
2471
  ...createMessageActions(),
2472
+ ...createPairActions(),
829
2473
  ...createAgentActions(),
830
2474
  ...createChartMethods(),
831
2475
  };
@@ -835,7 +2479,7 @@
835
2479
 
836
2480
  window.AgentRelayDashboard = {
837
2481
  createRelayDashboard,
838
- helpers: { loadPref, savePref, indexAgents, upsertById, upsertTask, compareAgents, agentType },
2482
+ helpers: { loadPref, savePref, indexAgents, upsertById, upsertTask, compareAgents, agentType, isBuiltInAgent },
839
2483
  };
840
2484
  window.relay = createRelayDashboard;
841
2485