agent-relay-server 0.5.0 → 0.6.1

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/README.md CHANGED
@@ -40,20 +40,20 @@ Agent Relay has two parts:
40
40
 
41
41
  Pick your provider: install the Claude Code plugin, the Codex connector, or both. The shared config is the same four env vars either way. See the [full mental model](docs/mental-model.md) for routing, identity, and task lifecycle details, or the [provider spec](docs/provider-spec.md) if you want to build your own integration.
42
42
 
43
- ## Extension Model
43
+ ## Connector Model
44
44
 
45
- Agent Relay is built around four extension points:
45
+ Agent Relay extensions run as external **Connectors**. The relay discovers them from `~/.agent-relay/connectors/<id>/manifest.json`, then can show status and run lifecycle actions without loading connector code in-process.
46
46
 
47
47
  > **Agents do work. Providers host agents. Integrations create work. Channels carry conversations.**
48
48
 
49
- That split keeps the system small without making everything a one-off plugin:
49
+ Connector kinds:
50
50
 
51
- - **Agents** are running AI sessions or workers that can receive messages, claim tasks, and produce results.
52
- - **Providers** connect an AI runtime to the relay. The Claude plugin and Codex connector are providers.
53
- - **Integrations** connect external systems that create or update work. CI, monitoring, support desks, and deployment tools fit here.
54
- - **Channels** connect communication surfaces where humans and systems already talk. Telegram is the first channel; Slack, email, SMS, Matrix, Discord, and web chat are natural next ones.
51
+ - **Provider connectors** host AI runtimes. The Claude plugin and Codex connector register coding-agent sessions as Relay agents.
52
+ - **Event connectors** connect external systems that create or update work. CI, monitoring, support desks, and deployment tools fit here.
53
+ - **Channel connectors** connect communication surfaces where humans and systems already talk. Telegram is the first channel; Slack, email, SMS, Matrix, Discord, and web chat are natural next ones.
54
+ - **Orchestrator connectors** supervise hosts and can spawn or control provider sessions.
55
55
 
56
- If you want to contribute, pick the shape that matches your system. Building a new AI runtime adapter? Build a provider. Sending alerts or tickets into Agent Relay? Build an integration. Opening Agent Relay to another messaging app? Build a channel.
56
+ If you want to contribute, pick the shape that matches your system. Building a new AI runtime adapter? Build a provider connector. Sending alerts or tickets into Agent Relay? Build an event connector. Opening Agent Relay to another messaging app? Build a channel connector.
57
57
 
58
58
  ## Quick Start
59
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -60,6 +60,7 @@
60
60
  tasks: [],
61
61
  integrations: [],
62
62
  channels: [],
63
+ connectors: [],
63
64
  taskEvents: [],
64
65
  taskEventCache: {},
65
66
  stats: {},
@@ -240,6 +241,7 @@
240
241
  if (view === "work") await Promise.all([this.fetchMessages(), this.fetchTasks()]);
241
242
  if (view === "pairs") this.fetchPairs();
242
243
  if (view === "channels") await this.fetchChannels();
244
+ if (view === "connectors") await this.fetchConnectors();
243
245
  if (view === "integrations") await this.fetchIntegrations();
244
246
  if (view === "tasks") this.fetchTasks();
245
247
  },
@@ -393,7 +395,7 @@
393
395
  },
394
396
 
395
397
  async refresh() {
396
- await Promise.all([this.fetchStats(), this.fetchHealth(), this.fetchAgents(), this.fetchOrchestrators(), this.fetchPairs(), this.fetchMessages(), this.fetchTasks(), this.fetchChannels(), this.fetchIntegrations(), this.fetchInboxState(), this.fetchActivityEvents()]);
398
+ await Promise.all([this.fetchStats(), this.fetchHealth(), this.fetchAgents(), this.fetchOrchestrators(), this.fetchPairs(), this.fetchMessages(), this.fetchTasks(), this.fetchChannels(), this.fetchConnectors(), this.fetchIntegrations(), this.fetchInboxState(), this.fetchActivityEvents()]);
397
399
  },
398
400
 
399
401
  async refreshLiveData() {
@@ -476,6 +478,12 @@
476
478
  } catch {}
477
479
  },
478
480
 
481
+ async fetchConnectors() {
482
+ try {
483
+ this.connectors = await this.api("GET", "/connectors");
484
+ } catch {}
485
+ },
486
+
479
487
  async fetchChannels() {
480
488
  try {
481
489
  this.channels = await this.api("GET", "/channels");
@@ -548,6 +556,7 @@
548
556
  activityItems: { get: getActivityItems },
549
557
  workQueueItems: { get: getWorkQueueItems },
550
558
  channelCards: { get: getChannelCards },
559
+ connectorCards: { get: getConnectorCards },
551
560
  integrationCards: { get: getIntegrationCards },
552
561
  openPairCount: { get: getOpenPairCount },
553
562
  filteredMessages: { get: getFilteredMessages },
@@ -644,6 +653,11 @@
644
653
  if (!message || !channel) return false;
645
654
  const channelKeys = [channel.id, channel.type, ...(channel.topicChannels || [])].filter(Boolean);
646
655
  if (message.channel && channelKeys.includes(message.channel)) return true;
656
+ const payloadChannel = message.payload?.channel;
657
+ if (payloadChannel && typeof payloadChannel === "object") {
658
+ const payloadKeys = [payloadChannel.agentId, payloadChannel.provider, payloadChannel.accountId].filter(Boolean);
659
+ if (payloadKeys.includes(channel.id) || payloadKeys.includes(channel.type) || payloadKeys.includes(channel.accountId)) return true;
660
+ }
647
661
  if (channel.agentId && (message.from === channel.agentId || message.to === channel.agentId)) return true;
648
662
  return false;
649
663
  }
@@ -710,7 +724,7 @@
710
724
  const haystack = [
711
725
  vm.displayTarget(thread.peer),
712
726
  thread.peer,
713
- ...thread.messages.flatMap((msg) => [msg.subject || "", msg.body || "", msg.channel || "", vm.displayTarget(msg.from), vm.displayTarget(msg.to)]),
727
+ ...thread.messages.flatMap((msg) => [msg.subject || "", messageBody.call(vm, msg) || "", msg.channel || "", vm.displayTarget(msg.from), vm.displayTarget(msg.to)]),
714
728
  ].join("\n").toLowerCase();
715
729
  return haystack.includes(search);
716
730
  }
@@ -799,12 +813,12 @@
799
813
  }));
800
814
 
801
815
  const messageItems = (this.messages || [])
802
- .filter((msg) => msg.claimable)
816
+ .filter(isClaimableMessageWaiting)
803
817
  .map((msg) => ({
804
818
  id: "message-" + msg.id,
805
819
  sourceType: "message",
806
820
  title: msg.subject || "Claimable message #" + msg.id,
807
- body: msg.body,
821
+ body: messageBody(msg),
808
822
  severity: "warning",
809
823
  status: msg.claimedBy ? "claimed" : "open",
810
824
  owner: msg.claimedBy || "",
@@ -852,7 +866,7 @@
852
866
  return (vm.messages || []).flatMap((msg) => {
853
867
  const ts = toTimestamp(msg.createdAt);
854
868
  const items = [];
855
- const pairEvent = msg.meta?.pairEvent;
869
+ const pairEvent = msg.payload?.pairEvent;
856
870
  if (pairEvent) {
857
871
  items.push(activityItem({
858
872
  id: "pair-message-" + msg.id,
@@ -868,10 +882,10 @@
868
882
  } else if (msg.from === HUMAN_AGENT_ID) {
869
883
  items.push(activityItem({
870
884
  id: "human-send-" + msg.id,
871
- kind: msg.claimable ? "task" : "message",
885
+ kind: msg.kind === "task" ? "task" : "message",
872
886
  ts,
873
- icon: msg.claimable ? "ti-hand-grab" : "ti-send",
874
- title: msg.claimable ? "Claimable task sent" : "Message sent",
887
+ icon: msg.kind === "task" ? "ti-hand-grab" : "ti-send",
888
+ title: msg.kind === "task" ? "Claimable task sent" : "Message sent",
875
889
  body: vm.messagePreview(msg),
876
890
  meta: "to " + vm.displayTarget(msg.to),
877
891
  view: inboxPeer(msg) ? "inbox" : "messages",
@@ -1049,7 +1063,7 @@
1049
1063
  }
1050
1064
 
1051
1065
  function messageLooksLikeQuestion(msg) {
1052
- return /\?/.test(`${msg.subject || ""}\n${msg.body || ""}`);
1066
+ return /\?/.test(`${msg.subject || ""}\n${messageBody(msg)}`);
1053
1067
  }
1054
1068
 
1055
1069
  function countClaimableWaiting(vm) {
@@ -1069,7 +1083,7 @@
1069
1083
  }
1070
1084
 
1071
1085
  function isClaimableMessageWaiting(msg) {
1072
- return Boolean(msg.claimable && !msg.claimedBy);
1086
+ return Boolean(msg.claimable && !msg.claimedBy && !(msg.kind === "task" && Number.isSafeInteger(msg.payload?.taskId)));
1073
1087
  }
1074
1088
 
1075
1089
  function targetMatchesAgent(target, agent) {
@@ -1133,6 +1147,11 @@
1133
1147
 
1134
1148
  function getChannelCards() {
1135
1149
  return [...(this.channels || [])].sort((a, b) => {
1150
+ const healthRank = { error: 0, warning: 1, ok: 2 };
1151
+ const aHealth = a.targetHealth?.status || "ok";
1152
+ const bHealth = b.targetHealth?.status || "ok";
1153
+ const healthDiff = (healthRank[aHealth] ?? 2) - (healthRank[bHealth] ?? 2);
1154
+ if (healthDiff !== 0) return healthDiff;
1136
1155
  const readyDiff = Number(Boolean(b.ready)) - Number(Boolean(a.ready));
1137
1156
  if (readyDiff !== 0) return readyDiff;
1138
1157
  const statusDiff = String(a.status || "").localeCompare(String(b.status || ""));
@@ -1141,6 +1160,17 @@
1141
1160
  });
1142
1161
  }
1143
1162
 
1163
+ function getConnectorCards() {
1164
+ return [...(this.connectors || [])].sort((a, b) => {
1165
+ const statusRank = { error: 0, warn: 1, unknown: 2, ok: 3 };
1166
+ const aStatus = a.runtime?.status || "unknown";
1167
+ const bStatus = b.runtime?.status || "unknown";
1168
+ const statusDiff = (statusRank[aStatus] ?? 2) - (statusRank[bStatus] ?? 2);
1169
+ if (statusDiff !== 0) return statusDiff;
1170
+ return String(a.displayName || a.id || "").localeCompare(String(b.displayName || b.id || ""));
1171
+ });
1172
+ }
1173
+
1144
1174
  function getOpenPairCount() {
1145
1175
  return (this.pairs || []).filter(isOpenPair).length;
1146
1176
  }
@@ -1204,7 +1234,7 @@
1204
1234
  }
1205
1235
 
1206
1236
  function isChannelAgent(agent) {
1207
- return agent?.meta?.kind === "channel" || agent?.tags?.includes("channel");
1237
+ return agent?.kind === "channel" || agent?.meta?.kind === "channel" || agent?.tags?.includes("channel");
1208
1238
  }
1209
1239
 
1210
1240
  function agentSupportsControlActions(agent) {
@@ -1265,6 +1295,11 @@
1265
1295
  { label: "Run reaper", icon: "ti-broom", api: "POST", path: "/system/reap" },
1266
1296
  { label: "Show stale", icon: "ti-filter", view: "agents", preset: "offline_stale" }
1267
1297
  );
1298
+ } else if (check.name === "channel-delivery-targets") {
1299
+ base.actions.unshift(
1300
+ { label: "Open channels", icon: "ti-messages", view: "channels" },
1301
+ { label: "Run reaper", icon: "ti-broom", api: "POST", path: "/system/reap" }
1302
+ );
1268
1303
  } else if (check.name === "expired-message-claims" || check.name === "expired-task-claims" || check.name === "offline-claimed-tasks") {
1269
1304
  base.actions.unshift(
1270
1305
  { label: "Run reaper", icon: "ti-broom", api: "POST", path: "/system/reap" },
@@ -1281,6 +1316,7 @@
1281
1316
  if (check.name === "expired-message-claims") return "Claimable messages may be stuck until the reaper releases expired claims.";
1282
1317
  if (check.name === "expired-task-claims") return "Tasks can appear owned by agents that no longer hold a live lease.";
1283
1318
  if (check.name === "offline-claimed-tasks") return "Offline agents are still shown as owners for active work.";
1319
+ if (check.name === "channel-delivery-targets") return "Inbound channel messages may be accepted but routed to no live delivery agent.";
1284
1320
  return "Relay health is degraded for this check.";
1285
1321
  }
1286
1322
 
@@ -1343,6 +1379,7 @@
1343
1379
  displayName,
1344
1380
  displayTarget,
1345
1381
  conversationTitle,
1382
+ messageBody,
1346
1383
  messagePreview,
1347
1384
  agentPair,
1348
1385
  pairPeerId,
@@ -1363,6 +1400,7 @@
1363
1400
  isBuiltInAgent,
1364
1401
  agentChannels,
1365
1402
  channelPresence,
1403
+ connectorPresence,
1366
1404
  integrationPresence,
1367
1405
  composeTargetAllowsClaimable,
1368
1406
  inboxComposeTargetAllowsClaimable,
@@ -1396,10 +1434,43 @@
1396
1434
  }
1397
1435
 
1398
1436
  function messagePreview(msg) {
1399
- const text = msg?.subject || msg?.body || "";
1437
+ const text = msg?.subject || messageBody(msg) || "";
1400
1438
  return text.length > 90 ? text.slice(0, 90) + "..." : text;
1401
1439
  }
1402
1440
 
1441
+ function messageBody(msg) {
1442
+ if (!msg) return "";
1443
+ const payload = msg.payload || {};
1444
+ const channelMessage = payload.message;
1445
+ if (channelMessage && typeof channelMessage === "object" && typeof channelMessage.text === "string" && channelMessage.text.trim()) {
1446
+ return channelMessage.text;
1447
+ }
1448
+ const interaction = payload.interaction;
1449
+ if (interaction && typeof interaction === "object") {
1450
+ const title = typeof interaction.title === "string" ? interaction.title.trim() : "";
1451
+ const description = typeof interaction.description === "string" ? interaction.description.trim() : "";
1452
+ if (title && description) return title + "\n" + description;
1453
+ if (title) return title;
1454
+ if (description) return description;
1455
+ }
1456
+ const reaction = payload.reaction;
1457
+ if (reaction && typeof reaction === "object") {
1458
+ const name = typeof reaction.name === "string" ? reaction.name : "";
1459
+ const emoji = typeof reaction.emoji === "string" ? reaction.emoji : "";
1460
+ const value = typeof reaction.value === "string" ? reaction.value : "";
1461
+ return ["Reaction", emoji || name || value].filter(Boolean).join(": ");
1462
+ }
1463
+ const activity = payload.activity;
1464
+ if (activity && typeof activity === "object") {
1465
+ const kind = typeof activity.kind === "string" ? activity.kind : "activity";
1466
+ const state = typeof activity.state === "string" ? activity.state : "";
1467
+ return [kind, state].filter(Boolean).join(" ");
1468
+ }
1469
+ if (typeof payload.text === "string" && payload.text.trim()) return payload.text;
1470
+ if (typeof payload.message === "string" && payload.message.trim()) return payload.message;
1471
+ return msg.body || "";
1472
+ }
1473
+
1403
1474
  function agentPair(agent) {
1404
1475
  return agent ? this.pairsByAgentId[agent.id] : null;
1405
1476
  }
@@ -1583,6 +1654,16 @@
1583
1654
  return { label: "configured", tone: "primary", icon: "ti-plug-connected" };
1584
1655
  }
1585
1656
 
1657
+ function connectorPresence(connector) {
1658
+ const runtime = connector?.runtime || {};
1659
+ if (runtime.status === "error") return { label: "error", tone: "danger", icon: "ti-alert-triangle" };
1660
+ if (runtime.status === "warn") return { label: "warning", tone: "warning", icon: "ti-alert-circle" };
1661
+ if (runtime.running) return { label: "running", tone: "success", icon: "ti-circle-check" };
1662
+ if (runtime.enabled === false) return { label: "disabled", tone: "secondary", icon: "ti-player-pause" };
1663
+ if (runtime.status === "ok") return { label: "ok", tone: "success", icon: "ti-circle-check" };
1664
+ return { label: "unknown", tone: "secondary", icon: "ti-help-circle" };
1665
+ }
1666
+
1586
1667
  function agentChannels(agent) {
1587
1668
  if (!agent) return [];
1588
1669
  return (this.channels || []).filter((ch) => ch.agentId === agent.id);
@@ -1590,6 +1671,8 @@
1590
1671
 
1591
1672
  function channelPresence(channel) {
1592
1673
  if (!channel) return { label: "unknown", tone: "secondary", icon: "ti-plug-off" };
1674
+ if (channel.targetHealth?.status === "error") return { label: "target broken", tone: "danger", icon: "ti-alert-triangle" };
1675
+ if (channel.targetHealth?.status === "warning") return { label: "target warning", tone: "warning", icon: "ti-alert-circle" };
1593
1676
  if (channel.status === "offline") return { label: "offline", tone: "secondary", icon: "ti-plug-off" };
1594
1677
  if (!channel.ready) return { label: "not ready", tone: "warning", icon: "ti-loader" };
1595
1678
  if (channel.status === "busy") return { label: "busy", tone: "warning", icon: "ti-activity" };
@@ -1675,6 +1758,7 @@
1675
1758
  recordOperatorActivity,
1676
1759
  openActivityItem,
1677
1760
  runHealthAction,
1761
+ runConnectorAction,
1678
1762
  openCommandPalette,
1679
1763
  closeCommandPalette,
1680
1764
  runCommand,
@@ -1940,7 +2024,11 @@
1940
2024
  };
1941
2025
  if (this.inboxCompose.channel) payload.channel = this.inboxCompose.channel;
1942
2026
  if (this.inboxCompose.subject) payload.subject = this.inboxCompose.subject;
1943
- if (this.inboxCompose.claimable && inboxComposeTargetAllowsClaimable.call(this)) payload.claimable = true;
2027
+ if (this.inboxCompose.claimable && inboxComposeTargetAllowsClaimable.call(this)) {
2028
+ payload.claimable = true;
2029
+ payload.kind = "task";
2030
+ payload.payload = { title: this.inboxCompose.subject || "Claimable task" };
2031
+ }
1944
2032
  await this.api("POST", "/messages", payload);
1945
2033
  this.recordOperatorActivity({
1946
2034
  title: payload.claimable ? "Claimable task sent" : "Message sent",
@@ -1983,7 +2071,11 @@
1983
2071
  if (vm.compose.channel) payload.channel = vm.compose.channel;
1984
2072
  if (vm.compose.subject) payload.subject = vm.compose.subject;
1985
2073
  if (vm.replyTo) payload.replyTo = vm.replyTo.id;
1986
- if (vm.compose.claimable && composeTargetAllowsClaimable.call(vm)) payload.claimable = true;
2074
+ if (vm.compose.claimable && composeTargetAllowsClaimable.call(vm)) {
2075
+ payload.claimable = true;
2076
+ payload.kind = "task";
2077
+ payload.payload = { title: vm.compose.subject || "Claimable task" };
2078
+ }
1987
2079
  return payload;
1988
2080
  }
1989
2081
 
@@ -2200,6 +2292,16 @@
2200
2292
  if (action.copy) await copyText(action.copy);
2201
2293
  }
2202
2294
 
2295
+ async function runConnectorAction(connector, action) {
2296
+ if (!connector || !action) return;
2297
+ try {
2298
+ await this.api("POST", "/connectors/" + encodeURIComponent(connector.id) + "/actions", { action });
2299
+ await this.fetchConnectors();
2300
+ } catch (e) {
2301
+ alert("Connector action failed: " + e.message);
2302
+ }
2303
+ }
2304
+
2203
2305
  async function copyText(value) {
2204
2306
  if (typeof navigator === "undefined") return;
2205
2307
  try {
@@ -2289,8 +2391,8 @@
2289
2391
 
2290
2392
  function pairMessages(vm, pair) {
2291
2393
  return (vm.messages || []).filter((msg) =>
2292
- msg.meta?.pairId === pair.id ||
2293
- (msg.meta?.pairEvent && [pair.requesterId, pair.targetId].includes(msg.from) && [pair.requesterId, pair.targetId].includes(msg.to))
2394
+ msg.payload?.pairId === pair.id ||
2395
+ (msg.payload?.pairEvent && [pair.requesterId, pair.targetId].includes(msg.from) && [pair.requesterId, pair.targetId].includes(msg.to))
2294
2396
  );
2295
2397
  }
2296
2398
 
@@ -2343,7 +2445,7 @@
2343
2445
  if (msg.channel) lines.push("- Channel: " + msg.channel);
2344
2446
  if (msg.subject) lines.push("- Subject: " + msg.subject);
2345
2447
  if (msg.claimedBy) lines.push("- Claimed by: " + vm.displayTarget(msg.claimedBy));
2346
- lines.push("", msg.body || "", "");
2448
+ lines.push("", messageBody.call(vm, msg) || "", "");
2347
2449
  }
2348
2450
  }
2349
2451
 
Binary file
Binary file
@@ -0,0 +1,14 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2
+ <rect width="512" height="512" rx="96" fill="#0d1117"/>
3
+ <g stroke="#30363d" stroke-width="22" stroke-linecap="round">
4
+ <line x1="137" y1="151" x2="221" y2="229"/>
5
+ <line x1="375" y1="151" x2="291" y2="229"/>
6
+ <line x1="137" y1="361" x2="221" y2="283"/>
7
+ <line x1="375" y1="361" x2="291" y2="283"/>
8
+ </g>
9
+ <circle cx="256" cy="256" r="72" fill="#58a6ff"/>
10
+ <circle cx="96" cy="116" r="42" fill="#3fb950"/>
11
+ <circle cx="416" cy="116" r="42" fill="#3fb950"/>
12
+ <circle cx="96" cy="396" r="42" fill="#3fb950"/>
13
+ <circle cx="416" cy="396" r="42" fill="#3fb950"/>
14
+ </svg>
package/public/index.html CHANGED
@@ -3,8 +3,15 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="theme-color" content="#0d1117">
7
+ <meta name="mobile-web-app-capable" content="yes">
8
+ <meta name="apple-mobile-web-app-capable" content="yes">
9
+ <meta name="apple-mobile-web-app-title" content="Agent Relay">
10
+ <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
6
11
  <title>Agent Relay</title>
7
12
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%230d1117'/%3E%3Ccircle cx='16' cy='16' r='4.5' fill='%2358a6ff'/%3E%3Ccircle cx='6' cy='8' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='26' cy='8' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='6' cy='24' r='2.5' fill='%233fb950'/%3E%3Ccircle cx='26' cy='24' r='2.5' fill='%233fb950'/%3E%3Cline x1='8' y1='9.5' x2='13' y2='14' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='24' y1='9.5' x2='19' y2='14' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='8' y1='22.5' x2='13' y2='18' stroke='%2330363d' stroke-width='1.5'/%3E%3Cline x1='24' y1='22.5' x2='19' y2='18' stroke='%2330363d' stroke-width='1.5'/%3E%3C/svg%3E">
13
+ <link rel="manifest" href="/manifest.webmanifest">
14
+ <link rel="apple-touch-icon" href="/icons/agent-relay-192.png">
8
15
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/css/tabler.min.css">
9
16
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/dist/tabler-icons.min.css">
10
17
  <style>
@@ -220,6 +227,10 @@
220
227
  <i class="ti ti-messages"></i>Channels
221
228
  <span class="badge bg-success text-white ms-auto" x-show="channelCards.filter((item) => item.ready).length > 0" x-text="channelCards.filter((item) => item.ready).length"></span>
222
229
  </a>
230
+ <a href="#" class="nav-link" :class="{ active: view === 'connectors' }" @click.prevent="switchView('connectors')">
231
+ <i class="ti ti-plug"></i>Connectors
232
+ <span class="badge bg-secondary text-white ms-auto" x-show="connectorCards.length > 0" x-text="connectorCards.length"></span>
233
+ </a>
223
234
  <a href="#" class="nav-link" :class="{ active: view === 'integrations' }" @click.prevent="switchView('integrations')">
224
235
  <i class="ti ti-plug-connected"></i>Integrations
225
236
  <span class="ms-auto d-inline-flex gap-1 align-items-center">
@@ -292,7 +303,7 @@
292
303
 
293
304
  <!-- Mobile nav -->
294
305
  <div class="mobile-nav d-none border-bottom p-2 gap-1 position-fixed top-0 w-100 bg-dark" style="z-index:50">
295
- <template x-for="v in ['overview','agents','channels','integrations','inbox','activity','pairs','messages','work','tasks','analytics']">
306
+ <template x-for="v in ['overview','agents','channels','connectors','integrations','inbox','activity','pairs','messages','work','tasks','analytics']">
296
307
  <button class="btn btn-sm" :class="view === v ? 'btn-primary' : 'btn-ghost-secondary'" @click="switchView(v)" x-text="v.charAt(0).toUpperCase() + v.slice(1)"></button>
297
308
  </template>
298
309
  </div>
@@ -328,8 +339,8 @@
328
339
  <div class="card-body">
329
340
  <div class="d-flex align-items-center">
330
341
  <div>
331
- <div class="text-secondary small">Total Agents</div>
332
- <div class="h1 mb-0" x-text="stats.agents ?? 0"></div>
342
+ <div class="text-secondary small">Agents</div>
343
+ <div class="h1 mb-0" x-text="onlineCount"></div>
333
344
  </div>
334
345
  <i class="ti ti-robot ms-auto stat-card"></i>
335
346
  </div>
@@ -341,10 +352,10 @@
341
352
  <div class="card-body">
342
353
  <div class="d-flex align-items-center">
343
354
  <div>
344
- <div class="text-secondary small">Active</div>
345
- <div class="h1 mb-0 text-success" x-text="onlineCount"></div>
355
+ <div class="text-secondary small">Busy</div>
356
+ <div class="h1 mb-0 text-warning" x-text="busyAgentCount"></div>
346
357
  </div>
347
- <i class="ti ti-circle-check ms-auto stat-card"></i>
358
+ <i class="ti ti-activity ms-auto stat-card"></i>
348
359
  </div>
349
360
  </div>
350
361
  </div>
@@ -848,6 +859,8 @@ agent-relay-orchestrator</pre>
848
859
  <div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
849
860
  <h2 class="page-title mb-0">Channels</h2>
850
861
  <span class="badge bg-success-lt" x-show="channelCards.filter((item) => item.ready).length > 0" x-text="channelCards.filter((item) => item.ready).length + ' ready'"></span>
862
+ <span class="badge bg-danger-lt" x-show="channelCards.filter((item) => item.targetHealth?.status === 'error').length > 0" x-text="channelCards.filter((item) => item.targetHealth?.status === 'error').length + ' target issue' + (channelCards.filter((item) => item.targetHealth?.status === 'error').length === 1 ? '' : 's')"></span>
863
+ <span class="badge bg-warning-lt" x-show="channelCards.filter((item) => item.targetHealth?.status === 'warning').length > 0" x-text="channelCards.filter((item) => item.targetHealth?.status === 'warning').length + ' target warning' + (channelCards.filter((item) => item.targetHealth?.status === 'warning').length === 1 ? '' : 's')"></span>
851
864
  <div class="ms-auto d-flex gap-2 align-items-center">
852
865
  <button class="btn btn-sm btn-ghost-secondary" @click="fetchChannels()">
853
866
  <i class="ti ti-refresh"></i>
@@ -876,6 +889,12 @@ agent-relay-orchestrator</pre>
876
889
  <span class="badge bg-cyan-lt" x-text="channel.direction"></span>
877
890
  <span class="badge bg-secondary-lt" x-text="displayTarget(channel.target || channel.agentId)"></span>
878
891
  </div>
892
+ <div class="alert py-2 px-2 mt-2 mb-0"
893
+ :class="channel.targetHealth?.status === 'error' ? 'alert-danger' : 'alert-warning'"
894
+ x-show="channel.targetHealth && channel.targetHealth.status !== 'ok'">
895
+ <i class="ti me-1" :class="channel.targetHealth?.status === 'error' ? 'ti-alert-triangle' : 'ti-alert-circle'"></i>
896
+ <span x-text="channel.targetHealth?.detail"></span>
897
+ </div>
879
898
  <div class="d-flex gap-1 mt-2 flex-wrap" x-show="channel.capabilities?.length">
880
899
  <template x-for="capability in (channel.capabilities || [])" :key="capability">
881
900
  <span class="badge bg-secondary-lt" x-text="capability"></span>
@@ -911,6 +930,75 @@ agent-relay-orchestrator</pre>
911
930
  </template>
912
931
  </div>
913
932
 
933
+ <!-- ==================== CONNECTORS ==================== -->
934
+ <div x-show="view === 'connectors'" x-cloak class="fade-in">
935
+ <div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
936
+ <h2 class="page-title mb-0">Connectors</h2>
937
+ <span class="badge bg-secondary-lt" x-show="connectorCards.length > 0" x-text="connectorCards.length + ' registered'"></span>
938
+ <div class="ms-auto">
939
+ <button class="btn btn-sm btn-ghost-secondary" @click="fetchConnectors()" title="Refresh">
940
+ <i class="ti ti-refresh"></i>
941
+ </button>
942
+ </div>
943
+ </div>
944
+
945
+ <div class="row g-3">
946
+ <template x-for="connector in connectorCards" :key="connector.id">
947
+ <div class="col-md-6 col-xl-4">
948
+ <div class="card">
949
+ <div class="card-body">
950
+ <div class="d-flex align-items-start gap-2">
951
+ <span class="agent-type-icon agent mt-0">
952
+ <i class="ti" :class="connectorPresence(connector).icon"></i>
953
+ </span>
954
+ <div class="flex-grow-1 min-width-0">
955
+ <div class="d-flex align-items-center gap-2 flex-wrap">
956
+ <span class="fw-bold text-truncate" x-text="connector.displayName || connector.id"></span>
957
+ <span class="badge" :class="'bg-' + connectorPresence(connector).tone + '-lt'">
958
+ <i class="ti me-1" :class="connectorPresence(connector).icon"></i><span x-text="connectorPresence(connector).label"></span>
959
+ </span>
960
+ <span class="badge bg-secondary-lt" x-text="connector.kind"></span>
961
+ </div>
962
+ <div class="text-secondary small mt-1" x-text="connector.description || connector.packageName || connector.binary"></div>
963
+ <div class="d-flex gap-1 mt-2 flex-wrap">
964
+ <template x-for="capability in (connector.capabilities || [])" :key="capability">
965
+ <span class="badge bg-cyan-lt" x-text="capability"></span>
966
+ </template>
967
+ </div>
968
+ <div class="text-secondary small mt-2 d-flex gap-2 flex-wrap">
969
+ <span x-text="'v' + connector.version"></span>
970
+ <span x-show="connector.runtime?.detail" x-text="connector.runtime.detail"></span>
971
+ <span x-show="connector.runtime?.updatedAt" x-text="'Updated ' + timeAgo(connector.runtime.updatedAt)"></span>
972
+ </div>
973
+ </div>
974
+ </div>
975
+ <div class="d-flex gap-1 mt-3 flex-wrap">
976
+ <template x-for="action in ['status','doctor','start','stop','restart','enable','disable']" :key="connector.id + action">
977
+ <button
978
+ class="btn btn-sm btn-ghost-secondary"
979
+ x-show="connector.manifest?.commands?.[action]"
980
+ @click="runConnectorAction(connector, action)"
981
+ :title="action.charAt(0).toUpperCase() + action.slice(1)">
982
+ <i class="ti" :class="action === 'doctor' ? 'ti-stethoscope' : action === 'status' ? 'ti-activity' : action === 'start' ? 'ti-player-play' : action === 'stop' ? 'ti-player-stop' : action === 'restart' ? 'ti-refresh' : action === 'enable' ? 'ti-toggle-right' : 'ti-toggle-left'"></i>
983
+ </button>
984
+ </template>
985
+ </div>
986
+ </div>
987
+ </div>
988
+ </div>
989
+ </template>
990
+ </div>
991
+
992
+ <template x-if="connectorCards.length === 0">
993
+ <div class="card">
994
+ <div class="card-body text-center text-secondary py-5">
995
+ <i class="ti ti-plug-off" style="font-size:48px; opacity:0.3"></i>
996
+ <p class="mt-2">No connectors registered</p>
997
+ </div>
998
+ </div>
999
+ </template>
1000
+ </div>
1001
+
914
1002
  <!-- ==================== INTEGRATIONS ==================== -->
915
1003
  <div x-show="view === 'integrations'" x-cloak class="fade-in">
916
1004
  <div class="d-flex align-items-center mb-3 gap-2 flex-wrap">
@@ -1164,7 +1252,7 @@ agent-relay-orchestrator</pre>
1164
1252
  <template x-if="m.subject">
1165
1253
  <div class="fw-bold small mb-1" x-text="m.subject"></div>
1166
1254
  </template>
1167
- <div class="msg-body" x-text="m.body"></div>
1255
+ <div class="msg-body" x-text="messageBody(m)"></div>
1168
1256
  <div class="d-flex align-items-center gap-2 mt-2 flex-wrap">
1169
1257
  <button class="btn btn-sm btn-ghost-primary py-0 px-1" @click="startReply(m)">
1170
1258
  <i class="ti ti-corner-up-left" style="font-size:14px"></i> Reply
@@ -1419,7 +1507,7 @@ agent-relay-orchestrator</pre>
1419
1507
  <template x-if="m.subject">
1420
1508
  <div class="fw-bold small mb-1" x-text="m.subject"></div>
1421
1509
  </template>
1422
- <div class="msg-body" x-text="m.body"></div>
1510
+ <div class="msg-body" x-text="messageBody(m)"></div>
1423
1511
  <div class="d-flex align-items-center gap-2 mt-2 flex-wrap">
1424
1512
  <button class="btn btn-sm btn-ghost-primary py-0 px-1" @click="startReply(m)">
1425
1513
  <i class="ti ti-corner-up-left" style="font-size:14px"></i> Reply
@@ -2388,7 +2476,7 @@ agent-relay-orchestrator</pre>
2388
2476
  <template x-if="m.subject">
2389
2477
  <div class="fw-bold small mb-1" x-text="m.subject"></div>
2390
2478
  </template>
2391
- <div class="msg-body" x-text="m.body"></div>
2479
+ <div class="msg-body" x-text="messageBody(m)"></div>
2392
2480
  </div>
2393
2481
  </template>
2394
2482
  <template x-if="threadMessages.length === 0">
@@ -2481,6 +2569,13 @@ agent-relay-orchestrator</pre>
2481
2569
  <script src="https://cdn.jsdelivr.net/npm/apexcharts@latest/dist/apexcharts.min.js"></script>
2482
2570
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>
2483
2571
  <script src="dashboard.js"></script>
2572
+ <script>
2573
+ if ("serviceWorker" in navigator) {
2574
+ window.addEventListener("load", () => {
2575
+ navigator.serviceWorker.register("/sw.js").catch(() => {});
2576
+ });
2577
+ }
2578
+ </script>
2484
2579
 
2485
2580
  </body>
2486
2581
  </html>
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "Agent Relay",
3
+ "short_name": "Relay",
4
+ "description": "Local control panel for Agent Relay agents, channels, tasks, and messages.",
5
+ "id": "/",
6
+ "start_url": "/",
7
+ "scope": "/",
8
+ "display": "standalone",
9
+ "background_color": "#0d1117",
10
+ "theme_color": "#0d1117",
11
+ "orientation": "any",
12
+ "categories": ["developer", "productivity", "utilities"],
13
+ "icons": [
14
+ {
15
+ "src": "/icons/agent-relay.svg",
16
+ "sizes": "any",
17
+ "type": "image/svg+xml",
18
+ "purpose": "any maskable"
19
+ },
20
+ {
21
+ "src": "/icons/agent-relay-192.png",
22
+ "sizes": "192x192",
23
+ "type": "image/png",
24
+ "purpose": "any maskable"
25
+ },
26
+ {
27
+ "src": "/icons/agent-relay-512.png",
28
+ "sizes": "512x512",
29
+ "type": "image/png",
30
+ "purpose": "any maskable"
31
+ }
32
+ ]
33
+ }