agent-relay-server 0.19.2 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.17.0",
5
+ "version": "0.19.3",
6
6
  "description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
7
7
  "license": {
8
8
  "name": "MIT",
@@ -10774,21 +10774,57 @@
10774
10774
  "System"
10775
10775
  ],
10776
10776
  "description": "Returns agent count, online count, and message statistics.",
10777
+ "parameters": [
10778
+ {
10779
+ "name": "period",
10780
+ "in": "query",
10781
+ "schema": {
10782
+ "type": "string"
10783
+ }
10784
+ }
10785
+ ],
10777
10786
  "responses": {
10778
10787
  "200": {
10779
10788
  "description": "Success",
10780
10789
  "content": {
10781
10790
  "application/json": {}
10782
10791
  }
10792
+ }
10793
+ },
10794
+ "security": [
10795
+ {
10796
+ "bearerAuth": []
10783
10797
  },
10784
- "404": {
10785
- "description": "Not found",
10798
+ {
10799
+ "tokenHeader": []
10800
+ },
10801
+ {
10802
+ "tokenQuery": []
10803
+ }
10804
+ ]
10805
+ }
10806
+ },
10807
+ "/api/stats/analytics": {
10808
+ "get": {
10809
+ "operationId": "getAnalyticsRoute",
10810
+ "summary": "Get Analytics",
10811
+ "tags": [
10812
+ "System"
10813
+ ],
10814
+ "parameters": [
10815
+ {
10816
+ "name": "period",
10817
+ "in": "query",
10818
+ "schema": {
10819
+ "type": "string"
10820
+ }
10821
+ }
10822
+ ],
10823
+ "responses": {
10824
+ "200": {
10825
+ "description": "Success",
10786
10826
  "content": {
10787
- "application/json": {
10788
- "schema": {
10789
- "$ref": "#/components/schemas/Error"
10790
- }
10791
- }
10827
+ "application/json": {}
10792
10828
  }
10793
10829
  }
10794
10830
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.19.2",
3
+ "version": "0.20.0",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "CONTRIBUTING.md"
34
34
  ],
35
35
  "dependencies": {
36
- "agent-relay-sdk": "0.2.10"
36
+ "agent-relay-sdk": "0.2.11"
37
37
  },
38
38
  "scripts": {
39
39
  "prepack": "bun run build:dashboard:bundle >&2",
package/public/index.html CHANGED
@@ -11944,6 +11944,7 @@ var useRelayStore = create$1()(persist((set, get) => ({
11944
11944
  agents: [],
11945
11945
  agentsById: {},
11946
11946
  messages: [],
11947
+ threadHistory: {},
11947
11948
  pairs: [],
11948
11949
  tasks: [],
11949
11950
  orchestrators: [],
@@ -12028,6 +12029,7 @@ var useRelayStore = create$1()(persist((set, get) => ({
12028
12029
  chatHasNewItems: false,
12029
12030
  pendingForkImport: null,
12030
12031
  analyticsPeriod: "24h",
12032
+ analyticsData: null,
12031
12033
  inboxReadCursors: {},
12032
12034
  inboxArchivedThreads: {},
12033
12035
  inboxDrafts: {},
@@ -12152,7 +12154,8 @@ var useRelayStore = create$1()(persist((set, get) => ({
12152
12154
  context: true,
12153
12155
  skills: true,
12154
12156
  plugins: true,
12155
- statusLine: true
12157
+ statusLine: true,
12158
+ mcp: true
12156
12159
  },
12157
12160
  skills: [],
12158
12161
  plugins: [],
@@ -12248,6 +12251,7 @@ var useRelayStore = create$1()(persist((set, get) => ({
12248
12251
  s.fetchAgents()
12249
12252
  ]);
12250
12253
  if (view === "files") await s.fetchOrchestrators();
12254
+ if (view === "analytics") await s.fetchAnalytics();
12251
12255
  },
12252
12256
  startClock() {
12253
12257
  useClock.getState().start();
@@ -12362,6 +12366,7 @@ var useRelayStore = create$1()(persist((set, get) => ({
12362
12366
  if (view === "maintenance") work.push(s.fetchMaintenanceJobs());
12363
12367
  if (view === "profiles") work.push(s.fetchAgentProfiles());
12364
12368
  if (view === "workspaces") work.push(s.fetchWorkspaces());
12369
+ if (view === "analytics") work.push(s.fetchAnalytics());
12365
12370
  await Promise.all(work);
12366
12371
  } finally {
12367
12372
  set({ _refreshInFlight: false });
@@ -12372,6 +12377,15 @@ var useRelayStore = create$1()(persist((set, get) => ({
12372
12377
  set({ stats: await api("GET", "/stats") });
12373
12378
  } catch {}
12374
12379
  },
12380
+ async fetchAnalytics() {
12381
+ try {
12382
+ set({ analyticsData: await api("GET", "/stats/analytics?period=" + get().analyticsPeriod) });
12383
+ } catch {}
12384
+ },
12385
+ setAnalyticsPeriod(period) {
12386
+ set({ analyticsPeriod: period });
12387
+ get().fetchAnalytics();
12388
+ },
12375
12389
  async fetchHealth() {
12376
12390
  try {
12377
12391
  set({ health: await api("GET", "/health") });
@@ -12630,6 +12644,16 @@ var useRelayStore = create$1()(persist((set, get) => ({
12630
12644
  set({ messages: mergeFetchedMessages(get().messages, messages) });
12631
12645
  } catch {}
12632
12646
  },
12647
+ async fetchThreadHistory(peer) {
12648
+ if (!peer) return;
12649
+ try {
12650
+ const history = await api("GET", "/messages?for=" + encodeURIComponent(peer) + "&limit=500");
12651
+ set({ threadHistory: {
12652
+ ...get().threadHistory,
12653
+ [peer]: history
12654
+ } });
12655
+ } catch {}
12656
+ },
12633
12657
  async fetchChatHistoryImports() {
12634
12658
  try {
12635
12659
  set({ chatHistoryImports: await api("GET", "/chat/history-imports?limit=500") });
@@ -13240,6 +13264,7 @@ var useRelayStore = create$1()(persist((set, get) => ({
13240
13264
  openInboxThread(peer, messages = []) {
13241
13265
  set({ selectedInboxThread: peer });
13242
13266
  if (messages.length) get().markInboxThreadRead(peer, messages);
13267
+ get().fetchThreadHistory(peer);
13243
13268
  },
13244
13269
  async sendChatMessage(body, thread, attachments = []) {
13245
13270
  if (!body.trim() && attachments.length === 0 || get().chatSending) return;
@@ -13995,7 +14020,8 @@ var useRelayStore = create$1()(persist((set, get) => ({
13995
14020
  context: true,
13996
14021
  skills: true,
13997
14022
  plugins: true,
13998
- statusLine: true
14023
+ statusLine: true,
14024
+ mcp: true
13999
14025
  },
14000
14026
  skills: [],
14001
14027
  plugins: [],
@@ -125295,6 +125321,7 @@ function useChatAgents() {
125295
125321
  ]);
125296
125322
  }
125297
125323
  var NO_STATUS_EVENTS = [];
125324
+ var NO_THREAD_HISTORY = [];
125298
125325
  function useAgentStatusEvents(agentId) {
125299
125326
  return useRelayStore((s) => s.chatStatusEvents[agentId] ?? NO_STATUS_EVENTS);
125300
125327
  }
@@ -126768,6 +126795,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
126768
126795
  const doAgentAction = useRelayStore((s) => s.doAgentAction);
126769
126796
  const forkFromAgent = useRelayStore((s) => s.forkFromAgent);
126770
126797
  const chatHistoryImports = useRelayStore((s) => s.chatHistoryImports);
126798
+ const peerHistory = useRelayStore((s) => s.threadHistory[s.selectedInboxThread] ?? NO_THREAD_HISTORY);
126771
126799
  const openConfirm = useRelayStore((s) => s.openConfirm);
126772
126800
  const showError = useRelayStore((s) => s.showError);
126773
126801
  const orchestrators = useRelayStore((s) => s.orchestrators);
@@ -126805,6 +126833,18 @@ function ChatPanel({ threads, onBack, showBackButton }) {
126805
126833
  const filePreviewRequest = (0, import_react.useRef)(0);
126806
126834
  const statusEvents = useAgentStatusEvents(selectedInboxThread);
126807
126835
  const thread = (0, import_react.useMemo)(() => threads.find((t) => t.peer === selectedInboxThread) || null, [threads, selectedInboxThread]);
126836
+ const threadMessages = (0, import_react.useMemo)(() => {
126837
+ const live = thread?.messages ?? [];
126838
+ if (peerHistory.length === 0) return live;
126839
+ const byId = /* @__PURE__ */ new Map();
126840
+ for (const m of peerHistory) if (inboxPeer(m) === selectedInboxThread && !isReactionEventMessage(m)) byId.set(m.id, m);
126841
+ for (const m of live) byId.set(m.id, m);
126842
+ return [...byId.values()].sort((a, b) => a.id - b.id);
126843
+ }, [
126844
+ thread?.messages,
126845
+ peerHistory,
126846
+ selectedInboxThread
126847
+ ]);
126808
126848
  const agent = agentsById[selectedInboxThread] || null;
126809
126849
  const agentSpawnRequestId = typeof agent?.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : "";
126810
126850
  const importedHistory = (0, import_react.useMemo)(() => {
@@ -126824,7 +126864,7 @@ function ChatPanel({ threads, onBack, showBackButton }) {
126824
126864
  const draft = inboxDrafts[selectedInboxThread] || "";
126825
126865
  const importedEntryCount = importedHistory.reduce((sum, history) => sum + history.entries.length, 0);
126826
126866
  const pinnedScroll = usePinnedScroll(selectedInboxThread, [
126827
- thread?.messages.length,
126867
+ threadMessages.length,
126828
126868
  statusEvents.length,
126829
126869
  importedEntryCount,
126830
126870
  pendingApproval?.id,
@@ -126880,8 +126920,8 @@ function ChatPanel({ threads, onBack, showBackButton }) {
126880
126920
  if (filePreviewCloseTimer.current !== null) window.clearTimeout(filePreviewCloseTimer.current);
126881
126921
  };
126882
126922
  }, []);
126883
- const timeline = (0, import_react.useMemo)(() => buildTimeline(thread?.messages || [], statusEvents, agent?.createdAt, importedHistory), [
126884
- thread?.messages,
126923
+ const timeline = (0, import_react.useMemo)(() => buildTimeline(threadMessages, statusEvents, agent?.createdAt, importedHistory), [
126924
+ threadMessages,
126885
126925
  statusEvents,
126886
126926
  agent?.createdAt,
126887
126927
  importedHistory
@@ -152495,13 +152535,14 @@ var PERIODS = [
152495
152535
  function periodMs(id) {
152496
152536
  return PERIODS.find((p) => p.id === id)?.ms ?? 864e5;
152497
152537
  }
152498
- function categorizeMessage(msg) {
152499
- if (msg.claimable) return "Work items";
152500
- if (msg.kind === "system" || msg.from === "system") return "System";
152501
- if (msg.kind === "pair") return "Pair";
152502
- if (msg.kind === "channel.event") return "Channel";
152503
- return "Messages";
152504
- }
152538
+ var CATEGORY_ORDER = [
152539
+ "Messages",
152540
+ "Replies",
152541
+ "Work items",
152542
+ "System",
152543
+ "Pair",
152544
+ "Channel"
152545
+ ];
152505
152546
  function StatCard({ label, value, icon: Icon, className = "" }) {
152506
152547
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Card, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CardContent, {
152507
152548
  className: "pt-4",
@@ -152522,11 +152563,11 @@ function StatCard({ label, value, icon: Icon, className = "" }) {
152522
152563
  }
152523
152564
  function PeriodSelector() {
152524
152565
  const period = useRelayStore((s) => s.analyticsPeriod);
152525
- const set = useRelayStore((s) => s.set);
152566
+ const setPeriod = useRelayStore((s) => s.setAnalyticsPeriod);
152526
152567
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
152527
152568
  className: "flex items-center gap-1 bg-muted rounded-lg p-1",
152528
152569
  children: PERIODS.map((p) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", {
152529
- onClick: () => set({ analyticsPeriod: p.id }),
152570
+ onClick: () => setPeriod(p.id),
152530
152571
  className: `px-3 py-1 rounded-md text-xs font-medium transition-colors ${period === p.id ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"}`,
152531
152572
  children: p.label
152532
152573
  }, p.id))
@@ -152566,67 +152607,38 @@ function useChart(id, getOptions, deps) {
152566
152607
  }
152567
152608
  function AnalyticsView() {
152568
152609
  const agents = useRelayStore((s) => s.agents);
152569
- const messages = useRelayStore((s) => s.messages);
152570
- useRelayStore((s) => s.stats);
152610
+ const data = useRelayStore((s) => s.analyticsData);
152571
152611
  const period = useRelayStore((s) => s.analyticsPeriod);
152572
152612
  const theme = useRelayStore((s) => s.theme);
152573
152613
  const onlineCount = useOnlineCount();
152574
152614
  const busyCount = useBusyCount();
152575
152615
  const isDark = THEMES.find((t) => t.id === theme)?.dark ?? true;
152576
- const cutoff = Date.now() - periodMs(period);
152577
- const filteredMessages = (0, import_react.useMemo)(() => {
152578
- return messages.filter((m) => {
152579
- const ts = toTimestamp(m.createdAt);
152580
- return ts && ts >= cutoff;
152581
- });
152582
- }, [messages, cutoff]);
152583
152616
  const volumeData = (0, import_react.useMemo)(() => {
152584
- const ms = periodMs(period);
152585
- const bucketCount = period === "1h" ? 12 : period === "6h" ? 12 : period === "24h" ? 24 : period === "7d" ? 14 : period === "14d" ? 14 : 15;
152586
- const bucketMs = ms / bucketCount;
152587
- const now = Date.now();
152588
- const categories = [
152589
- "Messages",
152590
- "Replies",
152591
- "Work items",
152592
- "System",
152593
- "Pair",
152594
- "Channel"
152595
- ];
152596
- const series = {};
152597
- for (const cat of categories) series[cat] = new Array(bucketCount).fill(0);
152598
- const labels = [];
152599
- for (let i = 0; i < bucketCount; i++) {
152600
- const t = /* @__PURE__ */ new Date(now - (bucketCount - 1 - i) * bucketMs);
152601
- if (ms <= 864e5) labels.push(t.toLocaleTimeString(void 0, {
152602
- hour: "2-digit",
152603
- minute: "2-digit"
152604
- }));
152605
- else labels.push(t.toLocaleDateString(void 0, {
152606
- month: "short",
152607
- day: "numeric"
152608
- }));
152609
- }
152610
- for (const msg of filteredMessages) {
152611
- const ts = toTimestamp(msg.createdAt);
152612
- if (!ts) continue;
152613
- const bucketIdx = Math.floor((ts - (now - ms)) / bucketMs);
152614
- if (bucketIdx < 0 || bucketIdx >= bucketCount) continue;
152615
- const bucket = series[categorizeMessage(msg)];
152616
- if (bucket) bucket[bucketIdx] = (bucket[bucketIdx] ?? 0) + 1;
152617
- }
152617
+ if (!data) return {
152618
+ labels: [],
152619
+ series: []
152620
+ };
152621
+ const asTime = periodMs(period) <= 864e5;
152618
152622
  return {
152619
- labels,
152620
- series: categories.flatMap((c) => {
152621
- const data = series[c];
152622
- if (!data || !data.some((v) => v > 0)) return [];
152623
- return [{
152624
- name: c,
152625
- data
152626
- }];
152623
+ labels: data.buckets.map((b) => {
152624
+ const t = new Date(b.start);
152625
+ return asTime ? t.toLocaleTimeString(void 0, {
152626
+ hour: "2-digit",
152627
+ minute: "2-digit"
152628
+ }) : t.toLocaleDateString(void 0, {
152629
+ month: "short",
152630
+ day: "numeric"
152631
+ });
152632
+ }),
152633
+ series: CATEGORY_ORDER.flatMap((cat) => {
152634
+ const series = data.buckets.map((b) => b.counts[cat] ?? 0);
152635
+ return series.some((v) => v > 0) ? [{
152636
+ name: cat,
152637
+ data: series
152638
+ }] : [];
152627
152639
  })
152628
152640
  };
152629
- }, [filteredMessages, period]);
152641
+ }, [data, period]);
152630
152642
  const utilizationData = (0, import_react.useMemo)(() => {
152631
152643
  let idle = 0, busy = 0;
152632
152644
  for (const a of agents) {
@@ -152641,14 +152653,7 @@ function AnalyticsView() {
152641
152653
  };
152642
152654
  }, [agents]);
152643
152655
  const heatmapData = (0, import_react.useMemo)(() => {
152644
- const grid = Array.from({ length: 7 }, () => new Array(24).fill(0));
152645
- for (const msg of filteredMessages) {
152646
- const ts = toTimestamp(msg.createdAt);
152647
- if (!ts) continue;
152648
- const d = new Date(ts);
152649
- const row = grid[d.getDay()];
152650
- if (row) row[d.getHours()] = (row[d.getHours()] ?? 0) + 1;
152651
- }
152656
+ const grid = data?.heatmap ?? Array.from({ length: 7 }, () => new Array(24).fill(0));
152652
152657
  return [
152653
152658
  "Sun",
152654
152659
  "Mon",
@@ -152659,24 +152664,20 @@ function AnalyticsView() {
152659
152664
  "Sat"
152660
152665
  ].map((name, dayIdx) => ({
152661
152666
  name,
152662
- data: (grid[dayIdx] ?? []).map((v, hour) => ({
152667
+ data: (grid[dayIdx] ?? new Array(24).fill(0)).map((v, hour) => ({
152663
152668
  x: String(hour).padStart(2, "0"),
152664
152669
  y: v
152665
152670
  }))
152666
152671
  }));
152667
- }, [filteredMessages]);
152672
+ }, [data]);
152668
152673
  const activityBreakdown = (0, import_react.useMemo)(() => {
152669
- const counts = {};
152670
- for (const msg of filteredMessages) {
152671
- const cat = categorizeMessage(msg);
152672
- counts[cat] = (counts[cat] ?? 0) + 1;
152673
- }
152674
- const entries = Object.entries(counts).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]);
152674
+ const counts = data?.categories;
152675
+ const entries = counts ? Object.entries(counts).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]) : [];
152675
152676
  return {
152676
152677
  labels: entries.map(([k]) => k),
152677
152678
  values: entries.map(([, v]) => v)
152678
152679
  };
152679
- }, [filteredMessages]);
152680
+ }, [data]);
152680
152681
  const chartColors = isDark ? {
152681
152682
  text: "rgba(255,255,255,0.4)",
152682
152683
  grid: "rgba(255,255,255,0.06)",
@@ -152815,8 +152816,9 @@ function AnalyticsView() {
152815
152816
  }] }
152816
152817
  } }
152817
152818
  }), [heatmapData, isDark]);
152818
- const periodMessageCount = filteredMessages.length;
152819
- const reactionCount = filteredMessages.reduce((sum, m) => sum + (m.reactions?.length ?? 0), 0);
152819
+ const periodMessageCount = data?.totalMessages ?? 0;
152820
+ const reactionCount = data?.totalReactions ?? 0;
152821
+ const hasHeatmapData = (data?.totalMessages ?? 0) > 0;
152820
152822
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
152821
152823
  className: "space-y-6",
152822
152824
  children: [
@@ -152882,7 +152884,7 @@ function AnalyticsView() {
152882
152884
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Card, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(CardHeader, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CardTitle, {
152883
152885
  className: "text-sm",
152884
152886
  children: "Busiest hours"
152885
- }) }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CardContent, { children: filteredMessages.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
152887
+ }) }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CardContent, { children: !hasHeatmapData ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
152886
152888
  className: "flex items-center justify-center h-[240px] text-muted-foreground text-sm",
152887
152889
  children: "No data for heatmap"
152888
152890
  }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { id: "analytics-heatmap-chart" }) })] })
@@ -157595,7 +157597,8 @@ function AgentProfileModal() {
157595
157597
  context: isHost,
157596
157598
  skills: isHost,
157597
157599
  plugins: isHost,
157598
- statusLine: isHost
157600
+ statusLine: isHost,
157601
+ mcp: isHost
157599
157602
  },
157600
157603
  mcp: {
157601
157604
  ...profile.mcp,
@@ -157695,14 +157698,15 @@ function AgentProfileModal() {
157695
157698
  "context",
157696
157699
  "skills",
157697
157700
  "plugins",
157698
- "statusLine"
157701
+ "statusLine",
157702
+ "mcp"
157699
157703
  ].map((key) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
157700
157704
  className: "flex items-center justify-between gap-2",
157701
157705
  children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Label, {
157702
157706
  className: "capitalize",
157703
- children: key === "statusLine" ? "Status Line" : key
157707
+ children: key === "statusLine" ? "Status Line" : key === "mcp" ? "MCP Tools" : key
157704
157708
  }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Switch, {
157705
- checked: profile.relay[key],
157709
+ checked: profile.relay[key] ?? true,
157706
157710
  onCheckedChange: (v) => updateRelay({ [key]: v }),
157707
157711
  disabled: readOnly
157708
157712
  })]
@@ -40,6 +40,8 @@ export interface ProviderSessionEvent {
40
40
  origin?: "chat" | "terminal" | "provider";
41
41
  turnId?: string;
42
42
  label?: string;
43
+ status?: "running" | "completed" | "failed";
44
+ streaming?: boolean;
43
45
  }
44
46
 
45
47
  export interface ProviderConfig {
@@ -204,6 +204,7 @@ function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Parti
204
204
  skills: input.relay?.skills ?? !isolated,
205
205
  plugins: input.relay?.plugins ?? !isolated,
206
206
  statusLine: input.relay?.statusLine ?? !isolated,
207
+ mcp: input.relay?.mcp ?? !isolated,
207
208
  },
208
209
  skills: input.skills ?? [],
209
210
  plugins: input.plugins ?? [],
@@ -274,6 +275,7 @@ function validateAgentProfile(key: string, value: unknown): AgentProfile {
274
275
  skills: relay.skills === undefined ? defaults.relay.skills : cleanBoolean(relay.skills, "relay.skills"),
275
276
  plugins: relay.plugins === undefined ? defaults.relay.plugins : cleanBoolean(relay.plugins, "relay.plugins"),
276
277
  statusLine: relay.statusLine === undefined ? defaults.relay.statusLine : cleanBoolean(relay.statusLine, "relay.statusLine"),
278
+ mcp: relay.mcp === undefined ? defaults.relay.mcp : cleanBoolean(relay.mcp, "relay.mcp"),
277
279
  },
278
280
  skills: cleanAgentProfileAssetArray(value.skills, "skills"),
279
281
  plugins: cleanAgentProfileAssetArray(value.plugins, "plugins"),
package/src/db.ts CHANGED
@@ -4678,6 +4678,113 @@ export function getStats(): {
4678
4678
  return { version: VERSION, agents, online, messages, messagesLast24h, tasks, openTasks };
4679
4679
  }
4680
4680
 
4681
+ // Analytics aggregation periods. Bucket counts mirror the dashboard chart
4682
+ // granularity so the client renders pre-aggregated buckets directly. This is the
4683
+ // single source of truth — the dashboard no longer windows raw messages (which
4684
+ // capped it at the last ~100 rows ≈ 3h on a busy relay; the data was always
4685
+ // retained server-side per RETENTION_DAYS). See issue #212 for the Layer-2 rollup
4686
+ // that will let stats outlive the message-retention window.
4687
+ export const ANALYTICS_PERIODS = {
4688
+ "1h": { ms: 3_600_000, buckets: 12 },
4689
+ "6h": { ms: 21_600_000, buckets: 12 },
4690
+ "24h": { ms: 86_400_000, buckets: 24 },
4691
+ "7d": { ms: 604_800_000, buckets: 14 },
4692
+ "14d": { ms: 1_209_600_000, buckets: 14 },
4693
+ "30d": { ms: 2_592_000_000, buckets: 15 },
4694
+ } as const;
4695
+
4696
+ export type AnalyticsPeriod = keyof typeof ANALYTICS_PERIODS;
4697
+
4698
+ // Message → category, the ONE place this mapping lives (server-side SQL). Order is
4699
+ // significant: a claimable/system/pair/channel message is classified as such even
4700
+ // when it is also a reply; only an otherwise-plain reply counts as "Replies".
4701
+ export const ANALYTICS_CATEGORIES = ["Messages", "Replies", "Work items", "System", "Pair", "Channel"] as const;
4702
+ export type AnalyticsCategory = (typeof ANALYTICS_CATEGORIES)[number];
4703
+ const ANALYTICS_CATEGORY_SQL = `
4704
+ CASE
4705
+ WHEN claimable = 1 THEN 'Work items'
4706
+ WHEN kind = 'system' OR from_agent = 'system' THEN 'System'
4707
+ WHEN kind = 'pair' THEN 'Pair'
4708
+ WHEN kind = 'channel.event' THEN 'Channel'
4709
+ WHEN reply_to IS NOT NULL THEN 'Replies'
4710
+ ELSE 'Messages'
4711
+ END`;
4712
+
4713
+ export interface AnalyticsData {
4714
+ period: AnalyticsPeriod;
4715
+ start: number;
4716
+ now: number;
4717
+ bucketMs: number;
4718
+ bucketCount: number;
4719
+ // Per-bucket category counts, oldest → newest, length === bucketCount.
4720
+ buckets: Array<{ start: number; counts: Record<AnalyticsCategory, number> }>;
4721
+ // Category totals over the whole period (powers the breakdown donut).
4722
+ categories: Record<AnalyticsCategory, number>;
4723
+ // Busiest-hours grid: heatmap[dayOfWeek 0=Sun][hour 0-23], in server-local time.
4724
+ heatmap: number[][];
4725
+ totalMessages: number;
4726
+ totalReactions: number;
4727
+ }
4728
+
4729
+ function emptyCategoryCounts(): Record<AnalyticsCategory, number> {
4730
+ return { Messages: 0, Replies: 0, "Work items": 0, System: 0, Pair: 0, Channel: 0 };
4731
+ }
4732
+
4733
+ export function getAnalytics(period: AnalyticsPeriod, now: number = Date.now()): AnalyticsData {
4734
+ const { ms, buckets: bucketCount } = ANALYTICS_PERIODS[period] ?? ANALYTICS_PERIODS["24h"];
4735
+ const start = now - ms;
4736
+ const bucketMs = ms / bucketCount;
4737
+
4738
+ const buckets = Array.from({ length: bucketCount }, (_, i) => ({
4739
+ start: start + i * bucketMs,
4740
+ counts: emptyCategoryCounts(),
4741
+ }));
4742
+ const categories = emptyCategoryCounts();
4743
+
4744
+ // One pass: bucket index × category counts. CAST floors toward zero; created_at
4745
+ // in (start, now] keeps the index in [0, bucketCount).
4746
+ const volumeRows = db
4747
+ .query(
4748
+ `SELECT CAST((created_at - ?) / ? AS INTEGER) AS bucket, ${ANALYTICS_CATEGORY_SQL} AS category, COUNT(*) AS c
4749
+ FROM messages WHERE created_at > ? AND created_at <= ? GROUP BY bucket, category`,
4750
+ )
4751
+ .all(start, bucketMs, start, now) as Array<{ bucket: number; category: AnalyticsCategory; c: number }>;
4752
+ for (const row of volumeRows) {
4753
+ const idx = Math.min(Math.max(row.bucket, 0), bucketCount - 1);
4754
+ const bucket = buckets[idx];
4755
+ if (bucket && row.category in bucket.counts) {
4756
+ bucket.counts[row.category] += row.c;
4757
+ categories[row.category] += row.c;
4758
+ }
4759
+ }
4760
+ const totalMessages = Object.values(categories).reduce((sum, v) => sum + v, 0);
4761
+
4762
+ // Busiest-hours heatmap (day-of-week × hour), server-local time.
4763
+ const heatmap: number[][] = Array.from({ length: 7 }, () => new Array(24).fill(0));
4764
+ const heatRows = db
4765
+ .query(
4766
+ `SELECT CAST(strftime('%w', created_at / 1000, 'unixepoch', 'localtime') AS INTEGER) AS dow,
4767
+ CAST(strftime('%H', created_at / 1000, 'unixepoch', 'localtime') AS INTEGER) AS hour, COUNT(*) AS c
4768
+ FROM messages WHERE created_at > ? AND created_at <= ? GROUP BY dow, hour`,
4769
+ )
4770
+ .all(start, now) as Array<{ dow: number; hour: number; c: number }>;
4771
+ for (const row of heatRows) {
4772
+ const day = heatmap[row.dow];
4773
+ if (day && row.hour >= 0 && row.hour < 24) day[row.hour] = row.c;
4774
+ }
4775
+
4776
+ const totalReactions = (
4777
+ db
4778
+ .query(
4779
+ `SELECT COUNT(*) AS c FROM message_reactions r
4780
+ JOIN messages m ON m.id = r.message_id WHERE m.created_at > ? AND m.created_at <= ?`,
4781
+ )
4782
+ .get(start, now) as { c: number }
4783
+ ).c;
4784
+
4785
+ return { period, start, now, bucketMs, bucketCount, buckets, categories, heatmap, totalMessages, totalReactions };
4786
+ }
4787
+
4681
4788
  export function getHealth(now: number = Date.now()): HealthReport {
4682
4789
  const checks: HealthCheck[] = [];
4683
4790
 
package/src/routes.ts CHANGED
@@ -27,6 +27,9 @@ import {
27
27
  markRead,
28
28
  deleteMessage,
29
29
  getStats,
30
+ getAnalytics,
31
+ ANALYTICS_PERIODS,
32
+ type AnalyticsPeriod,
30
33
  getHealth,
31
34
  getLatestMessageId,
32
35
  ingestIntegrationEvent,
@@ -6514,6 +6517,15 @@ const getEvents: Handler = (req) => {
6514
6517
  // --- Stats ---
6515
6518
 
6516
6519
  const getStatsRoute: Handler = () => json(getStats());
6520
+
6521
+ const getAnalyticsRoute: Handler = (req) => {
6522
+ const period = new URL(req.url).searchParams.get("period") ?? "24h";
6523
+ if (!(period in ANALYTICS_PERIODS)) {
6524
+ return error(`period must be one of ${Object.keys(ANALYTICS_PERIODS).join(", ")}`);
6525
+ }
6526
+ return json(getAnalytics(period as AnalyticsPeriod));
6527
+ };
6528
+
6517
6529
  const getHealthRoute: Handler = () => json(getHealth());
6518
6530
 
6519
6531
  const getMaintenanceJobs: Handler = () => json(listMaintenanceJobs());
@@ -6819,6 +6831,7 @@ const routes: Route[] = [
6819
6831
 
6820
6832
  route("GET", "/api/events", getEvents),
6821
6833
  route("GET", "/api/stats", getStatsRoute),
6834
+ route("GET", "/api/stats/analytics", getAnalyticsRoute),
6822
6835
  route("GET", "/api/health", getHealthRoute),
6823
6836
  route("GET", "/api/maintenance/jobs", getMaintenanceJobs),
6824
6837
  route("POST", "/api/maintenance/jobs/:id/run", postMaintenanceJobRun),
package/src/token-db.ts CHANGED
@@ -57,7 +57,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
57
57
  name: "Provider Agent",
58
58
  description: "Coding-agent runtime access for messages, commands, tasks, and scoped memory reads.",
59
59
  role: "provider",
60
- scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read"],
60
+ scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use"],
61
61
  ttlSeconds: 24 * 60 * 60,
62
62
  builtIn: true,
63
63
  createdBy: "system",
@@ -67,7 +67,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
67
67
  name: "Provider Child Agent",
68
68
  description: "Delegated child-agent runtime access, constrained to its parent and spawn request.",
69
69
  role: "provider",
70
- scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read"],
70
+ scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use"],
71
71
  constraints: { canDelegate: false },
72
72
  ttlSeconds: 2 * 60 * 60,
73
73
  builtIn: true,
@@ -78,7 +78,7 @@ const BUILT_IN_PROFILES: Array<Omit<TokenProfile, "createdAt" | "updatedAt">> =
78
78
  name: "Provider Interactive Agent",
79
79
  description: "User-launched provider runtime access constrained to its own agent and cwd for long interactive sessions.",
80
80
  role: "provider",
81
- scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read"],
81
+ scope: ["agent:read", "agent:write", "message:read", "message:send", "command:read", "command:write", "task:read", "task:write", "memory:read", "artifact:read", "artifact:write", "mcp:use"],
82
82
  constraints: { terminalAttach: false, logsRead: false, canDelegate: false },
83
83
  ttlSeconds: 30 * 24 * 60 * 60,
84
84
  builtIn: true,
package/src/upgrade.ts CHANGED
@@ -158,11 +158,14 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
158
158
  if (snapshot.packageManager === "none") {
159
159
  if (packages.length > 0) warnings.push("No supported global package manager found. Install Bun or npm, then rerun `agent-relay upgrade`.");
160
160
  } else if (packages.length > 0) {
161
+ // --prefer-online: revalidate the packument so a just-published version is
162
+ // visible to this host's npm cache right away, avoiding the post-publish
163
+ // ETARGET race (#211). bun add always hits the network for the spec.
161
164
  const command = snapshot.packageManager === "runtime-npm"
162
- ? ["npm", "install", "--prefix", snapshot.runtimePrefix, ...packages]
165
+ ? ["npm", "install", "--prefer-online", "--prefix", snapshot.runtimePrefix, ...packages]
163
166
  : snapshot.packageManager === "bun"
164
167
  ? ["bun", "add", "-g", ...packages]
165
- : ["npm", "install", "-g", ...packages];
168
+ : ["npm", "install", "--prefer-online", "-g", ...packages];
166
169
  actions.push({
167
170
  label: snapshot.packageManager === "runtime-npm" ? "Upgrade runtime packages" : "Upgrade global packages",
168
171
  command,