agent-relay-server 0.4.23 → 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.
@@ -39,9 +39,11 @@
39
39
  view: loadPref("view", "overview"),
40
40
 
41
41
  showOffline: loadPref("showOffline", false),
42
+ showBuiltIns: loadPref("showBuiltIns", false),
42
43
  autoRefresh: loadPref("autoRefresh", true),
43
44
  agentSort: loadPref("agentSort", "status"),
44
45
  agentSortDir: loadPref("agentSortDir", "asc"),
46
+ agentPresetFilter: loadPref("agentPresetFilter", ""),
45
47
 
46
48
  agents: [],
47
49
  agentsById: {},
@@ -49,6 +51,7 @@
49
51
  messages: [],
50
52
  tasks: [],
51
53
  taskEvents: [],
54
+ taskEventCache: {},
52
55
  stats: {},
53
56
  health: null,
54
57
  now: Date.now(),
@@ -58,6 +61,9 @@
58
61
  inboxDrafts: loadPref("inboxDrafts", {}),
59
62
  inboxSearch: "",
60
63
  inboxShowArchived: loadPref("inboxShowArchived", false),
64
+ operatorActivity: loadPref("operatorActivity", []),
65
+ activityEvents: [],
66
+ activityFilter: loadPref("activityFilter", ""),
61
67
 
62
68
  selectedAgent: "",
63
69
  agentDetailOpen: false,
@@ -70,6 +76,8 @@
70
76
  threadOpen: false,
71
77
  threadMessages: [],
72
78
  taskEventsOpen: false,
79
+ commandPaletteOpen: false,
80
+ commandQuery: "",
73
81
  connected: false,
74
82
  authNeeded: false,
75
83
 
@@ -99,6 +107,7 @@
99
107
 
100
108
  function watchPersistedPrefs(vm) {
101
109
  vm.$watch("showOffline", (value) => vm.save("showOffline", value));
110
+ vm.$watch("showBuiltIns", (value) => vm.save("showBuiltIns", value));
102
111
  vm.$watch("autoRefresh", (value) => {
103
112
  vm.save("autoRefresh", value);
104
113
  if (value) vm.startAutoRefresh();
@@ -106,10 +115,12 @@
106
115
  });
107
116
  vm.$watch("agentSort", (value) => vm.save("agentSort", value));
108
117
  vm.$watch("agentSortDir", (value) => vm.save("agentSortDir", value));
118
+ vm.$watch("agentPresetFilter", (value) => vm.save("agentPresetFilter", value));
109
119
  vm.$watch("agentStatusFilter", (value) => vm.save("agentStatusFilter", value));
110
120
  vm.$watch("agentTagFilter", (value) => vm.save("agentTagFilter", value));
111
121
  vm.$watch("pairStatusFilter", (value) => vm.save("pairStatusFilter", value));
112
122
  vm.$watch("inboxShowArchived", (value) => vm.save("inboxShowArchived", value));
123
+ vm.$watch("activityFilter", (value) => vm.save("activityFilter", value));
113
124
  vm.$watch("view", (value) => {
114
125
  vm.save("view", value);
115
126
  if (value === "analytics") vm.$nextTick(() => vm.renderCharts());
@@ -156,11 +167,27 @@
156
167
  vm.$nextTick(() => vm.renderCharts());
157
168
  }
158
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
+
159
185
  function createLifecycleMethods() {
160
186
  return {
161
187
  async init() {
162
188
  this.startClock();
163
189
  watchPersistedPrefs(this);
190
+ registerKeyboardShortcuts(this);
164
191
 
165
192
  try {
166
193
  this.stats = await this.api("GET", "/stats");
@@ -181,6 +208,8 @@
181
208
  this.view = view;
182
209
  if (view === "inbox" || view === "messages") await this.fetchMessages();
183
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()]);
184
213
  if (view === "pairs") this.fetchPairs();
185
214
  if (view === "tasks") this.fetchTasks();
186
215
  },
@@ -324,7 +353,7 @@
324
353
  },
325
354
 
326
355
  async refresh() {
327
- await Promise.all([this.fetchStats(), this.fetchHealth(), this.fetchAgents(), this.fetchPairs(), this.fetchMessages(), this.fetchTasks(), this.fetchInboxState()]);
356
+ await Promise.all([this.fetchStats(), this.fetchHealth(), this.fetchAgents(), this.fetchPairs(), this.fetchMessages(), this.fetchTasks(), this.fetchInboxState(), this.fetchActivityEvents()]);
328
357
  },
329
358
 
330
359
  async refreshLiveData() {
@@ -360,7 +389,9 @@
360
389
  async fetchPairs() {
361
390
  try {
362
391
  let pairs;
363
- if (this.pairStatusFilter === "open") {
392
+ if (this.view === "activity") {
393
+ pairs = await this.api("GET", "/pairs");
394
+ } else if (this.pairStatusFilter === "open") {
364
395
  const [active, pending] = await Promise.all([
365
396
  this.api("GET", "/pairs?status=active"),
366
397
  this.api("GET", "/pairs?status=pending"),
@@ -399,6 +430,13 @@
399
430
  applyInboxState(this, state);
400
431
  } catch {}
401
432
  },
433
+
434
+ async fetchActivityEvents() {
435
+ try {
436
+ this.activityEvents = await this.api("GET", "/activity?limit=200");
437
+ pruneSyncedOperatorActivity(this);
438
+ } catch {}
439
+ },
402
440
  };
403
441
  }
404
442
 
@@ -423,9 +461,17 @@
423
461
  savePref("inboxDrafts", drafts);
424
462
  }
425
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
+
426
471
  function createComputedDescriptors() {
427
472
  return {
428
473
  onlineCount: { get: getOnlineCount },
474
+ hiddenBuiltInAgentCount: { get: getHiddenBuiltInAgentCount },
429
475
  sortedAgents: { get: getSortedAgents },
430
476
  pairsByAgentId: { get: getPairsByAgentId },
431
477
  selectedAgentDetail: { get: getSelectedAgentDetail },
@@ -438,6 +484,8 @@
438
484
  inboxComposeTargetOptions: { get: getInboxComposeTargetOptions },
439
485
  attentionSummary: { get: getAttentionSummary },
440
486
  attentionAgentCount: { get: getAttentionAgentCount },
487
+ activityItems: { get: getActivityItems },
488
+ workQueueItems: { get: getWorkQueueItems },
441
489
  filteredMessages: { get: getFilteredMessages },
442
490
  groupedMessages: { get: getGroupedMessages },
443
491
  filteredTasks: { get: getFilteredTasks },
@@ -446,15 +494,23 @@
446
494
  uniqueCaps: { get: getUniqueCaps },
447
495
  uniqueTags: { get: getUniqueTags },
448
496
  healthIssues: { get: getHealthIssues },
497
+ healthDiagnostics: { get: getHealthDiagnostics },
498
+ commandPaletteItems: { get: getCommandPaletteItems },
449
499
  };
450
500
  }
451
501
 
452
502
  function getOnlineCount() {
453
- 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;
454
508
  }
455
509
 
456
510
  function getSortedAgents() {
457
- 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");
458
514
  if (this.agentStatusFilter === "starting") {
459
515
  list = list.filter((agent) => agent.status !== "offline" && !agent.ready);
460
516
  } else if (this.agentStatusFilter) {
@@ -589,6 +645,244 @@
589
645
  return this.sortedAgents.filter((agent) => agentAttention.call(this, agent).total > 0).length;
590
646
  }
591
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
+
592
886
  function inboxPeer(msg) {
593
887
  if (msg.from === HUMAN_AGENT_ID && msg.to) return msg.to;
594
888
  if (msg.to === HUMAN_AGENT_ID && msg.from) return msg.from;
@@ -711,25 +1005,137 @@
711
1005
  }
712
1006
 
713
1007
  function getComposeAgents() {
714
- 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");
715
1010
  }
716
1011
 
717
1012
  function getUniqueLabels() {
718
- 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))];
719
1014
  }
720
1015
 
721
1016
  function getUniqueCaps() {
722
- return [...new Set(this.agents.flatMap((agent) => agent.capabilities || []))];
1017
+ return [...new Set(visibleAgents(this).flatMap((agent) => agent.capabilities || []))];
723
1018
  }
724
1019
 
725
1020
  function getUniqueTags() {
726
- 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;
727
1061
  }
728
1062
 
729
1063
  function getHealthIssues() {
730
1064
  return (this.health?.checks || []).filter((check) => check.status !== "ok");
731
1065
  }
732
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
+
733
1139
  function compareAgents(vm, a, b) {
734
1140
  if (vm.agentSort === "status") {
735
1141
  const attentionDelta = agentAttention.call(vm, b).score - agentAttention.call(vm, a).score;
@@ -766,11 +1172,15 @@
766
1172
  agentType,
767
1173
  agentTypeIcon,
768
1174
  agentTypeTitle,
1175
+ agentPresence,
1176
+ agentPresenceBadges,
1177
+ agentStatusClass,
769
1178
  severityClass,
770
1179
  agentStatusTitle,
771
1180
  timeAgo,
772
1181
  fmtTime,
773
1182
  healthAlertClass,
1183
+ activityKindClass,
774
1184
  };
775
1185
  }
776
1186
 
@@ -913,6 +1323,66 @@
913
1323
  return AGENT_TYPE_TITLES[agentType(agent)] || AGENT_TYPE_TITLES.agent;
914
1324
  }
915
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
+
916
1386
  function severityClass(severity) {
917
1387
  if (severity === "critical") return "bg-danger-lt";
918
1388
  if (severity === "warning") return "bg-warning-lt";
@@ -922,6 +1392,11 @@
922
1392
  function agentStatusTitle(agent) {
923
1393
  if (!agent) return "";
924
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";
925
1400
  if (agent.ready) return agent.status;
926
1401
 
927
1402
  const lastSeenMs = new Date(agent.lastSeen).getTime();
@@ -954,6 +1429,15 @@
954
1429
  return "alert-success";
955
1430
  }
956
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
+
957
1441
  function createMessageActions() {
958
1442
  return {
959
1443
  openCompose,
@@ -976,9 +1460,21 @@
976
1460
  cancelReply,
977
1461
  doSend,
978
1462
  doClaim,
1463
+ doClaimTask,
1464
+ doUpdateTaskStatus,
979
1465
  doDeleteMessage,
980
1466
  openThread,
981
1467
  openTaskEvents,
1468
+ recordOperatorActivity,
1469
+ openActivityItem,
1470
+ runHealthAction,
1471
+ openCommandPalette,
1472
+ closeCommandPalette,
1473
+ runCommand,
1474
+ exportActivity,
1475
+ exportThread,
1476
+ exportPair,
1477
+ exportTask,
982
1478
  };
983
1479
  }
984
1480
 
@@ -1044,6 +1540,14 @@
1044
1540
  delete next[thread.peer];
1045
1541
  this.inboxReadCursors = next;
1046
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
+ });
1047
1551
  void saveInboxThreadState(this, {
1048
1552
  peerId: thread.peer,
1049
1553
  readCursorMessageId: null,
@@ -1054,6 +1558,14 @@
1054
1558
  if (!thread?.peer || !thread.lastMessage) return;
1055
1559
  this.inboxArchivedThreads = { ...this.inboxArchivedThreads, [thread.peer]: thread.lastMessage.id };
1056
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
+ });
1057
1569
  void saveInboxThreadState(this, {
1058
1570
  peerId: thread.peer,
1059
1571
  archivedAtMessageId: thread.lastMessage.id,
@@ -1067,6 +1579,14 @@
1067
1579
  delete next[thread.peer];
1068
1580
  this.inboxArchivedThreads = next;
1069
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
+ });
1070
1590
  void saveInboxThreadState(this, {
1071
1591
  peerId: thread.peer,
1072
1592
  archivedAtMessageId: null,
@@ -1095,6 +1615,13 @@
1095
1615
  this.messages = this.messages.filter((msg) => !thread.messages.some((item) => item.id === msg.id));
1096
1616
  this.selectedInboxThread = "";
1097
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
+ });
1098
1625
  } catch (e) {
1099
1626
  alert("Delete thread failed: " + e.message);
1100
1627
  }
@@ -1151,6 +1678,14 @@
1151
1678
  if (thread.lastMessage?.channel) payload.channel = thread.lastMessage.channel;
1152
1679
  if (thread.lastMessage?.id) payload.replyTo = thread.lastMessage.id;
1153
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
+ });
1154
1689
  this.clearReplyDraft(thread);
1155
1690
  this.markInboxThreadRead(thread);
1156
1691
  await this.fetchMessages();
@@ -1188,6 +1723,15 @@
1188
1723
  if (this.inboxCompose.subject) payload.subject = this.inboxCompose.subject;
1189
1724
  if (this.inboxCompose.claimable) payload.claimable = true;
1190
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
+ });
1191
1735
  this.inboxCompose = { ...DEFAULT_INBOX_COMPOSE, toMode: this.inboxCompose.toMode, to: this.inboxCompose.to };
1192
1736
  await this.fetchMessages();
1193
1737
  } catch (e) {
@@ -1231,7 +1775,17 @@
1231
1775
  }
1232
1776
 
1233
1777
  try {
1234
- 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
+ });
1235
1789
  this.composeOpen = false;
1236
1790
  this.replyTo = null;
1237
1791
  this.compose = { ...DEFAULT_COMPOSE };
@@ -1251,15 +1805,85 @@
1251
1805
  try {
1252
1806
  const result = await this.api("POST", "/messages/" + msgId + "/claim", { agentId });
1253
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()]);
1254
1817
  } catch (e) {
1255
1818
  alert("Claim failed: " + e.message);
1256
1819
  }
1257
1820
  }
1258
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
+
1259
1876
  async function doDeleteMessage(id) {
1260
1877
  try {
1261
1878
  await this.api("DELETE", "/messages/" + id);
1262
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
+ });
1263
1887
  } catch (e) {
1264
1888
  alert("Delete failed: " + e.message);
1265
1889
  }
@@ -1280,11 +1904,270 @@
1280
1904
  this.taskEventsOpen = true;
1281
1905
  try {
1282
1906
  this.taskEvents = await this.api("GET", "/tasks/" + task.id + "/events");
1907
+ this.taskEventCache = { ...this.taskEventCache, [task.id]: this.taskEvents };
1283
1908
  } catch (e) {
1284
1909
  alert("Failed to load task events: " + e.message);
1285
1910
  }
1286
1911
  }
1287
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
+
1288
2171
  function openPairMessage(pair, fromId) {
1289
2172
  if (!pair) return;
1290
2173
  this.pairMessage = {
@@ -1322,11 +2205,19 @@
1322
2205
 
1323
2206
  try {
1324
2207
  const payload = {
1325
- requesterId: this.pairInvite.requesterId,
1326
- targetId: this.pairInvite.targetId,
2208
+ from: this.pairInvite.requesterId,
2209
+ target: this.pairInvite.targetId,
1327
2210
  };
1328
2211
  if (this.pairInvite.objective) payload.objective = this.pairInvite.objective;
1329
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
+ });
1330
2221
  this.closePairInvite();
1331
2222
  await Promise.all([this.fetchPairs(), this.fetchMessages()]);
1332
2223
  } catch (e) {
@@ -1349,6 +2240,14 @@
1349
2240
  const payload = { from: this.pairMessage.from, body: this.pairMessage.body };
1350
2241
  if (this.pairMessage.subject) payload.subject = this.pairMessage.subject;
1351
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
+ });
1352
2251
  this.closePairMessage();
1353
2252
  await Promise.all([this.fetchPairs(), this.fetchMessages()]);
1354
2253
  } catch (e) {
@@ -1360,6 +2259,14 @@
1360
2259
  if (!pair) return;
1361
2260
  try {
1362
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
+ });
1363
2270
  await Promise.all([this.fetchPairs(), this.fetchMessages()]);
1364
2271
  } catch (e) {
1365
2272
  alert("Accept failed: " + e.message);
@@ -1370,6 +2277,14 @@
1370
2277
  if (!pair) return;
1371
2278
  try {
1372
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
+ });
1373
2288
  await Promise.all([this.fetchPairs(), this.fetchMessages()]);
1374
2289
  } catch (e) {
1375
2290
  alert("Reject failed: " + e.message);
@@ -1380,6 +2295,14 @@
1380
2295
  if (!pair) return;
1381
2296
  try {
1382
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
+ });
1383
2306
  await Promise.all([this.fetchPairs(), this.fetchMessages()]);
1384
2307
  } catch (e) {
1385
2308
  alert("Hang up failed: " + e.message);
@@ -1556,7 +2479,7 @@
1556
2479
 
1557
2480
  window.AgentRelayDashboard = {
1558
2481
  createRelayDashboard,
1559
- helpers: { loadPref, savePref, indexAgents, upsertById, upsertTask, compareAgents, agentType },
2482
+ helpers: { loadPref, savePref, indexAgents, upsertById, upsertTask, compareAgents, agentType, isBuiltInAgent },
1560
2483
  };
1561
2484
  window.relay = createRelayDashboard;
1562
2485