agent-relay-server 0.4.15 → 0.4.17

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
@@ -82,18 +82,42 @@ Open a second session and say:
82
82
 
83
83
  ## Configuration
84
84
 
85
- Both integrations share the same four env vars:
85
+ Both integrations share the same provider env vars:
86
86
 
87
87
  | Env var | Default | Purpose |
88
88
  |---------|---------|---------|
89
89
  | `AGENT_RELAY_URL` | `http://localhost:4850` | Relay server URL |
90
90
  | `AGENT_RELAY_TOKEN` | unset | Auth token (required for remote relays) |
91
91
  | `AGENT_RELAY_CAPS` | `chat` | Comma-separated agent capabilities |
92
+ | `AGENT_RELAY_TAGS` | unset | Extra comma-separated tags added to provider defaults |
93
+ | `AGENT_RELAY_LABEL` | unset | Human-friendly agent label set at registration |
94
+ | `AGENT_RELAY_CHANNELS` | all | Comma-separated channel subscriptions; unchannelled direct work still arrives |
95
+ | `AGENT_RELAY_PROFILE` | unset | Named profile loaded from the profiles file |
96
+ | `AGENT_RELAY_PROFILES_FILE` | `~/.config/agent-relay/profiles.json` | JSON profile file |
92
97
  | `AGENT_RELAY_APPROVAL` | `open` | Approval mode: `open`, `guarded`, `read-only` |
93
98
 
99
+ Profiles preconfigure labels, tags, capabilities, channels, approval mode, and meta fields:
100
+
101
+ ```json
102
+ {
103
+ "backend-tester": {
104
+ "label": "backend tester",
105
+ "tags": ["backend", "api", "test"],
106
+ "capabilities": ["chat", "review", "test", "backend"],
107
+ "channels": ["backend", "qa"],
108
+ "approval": "guarded",
109
+ "meta": { "role": "backend-tester" }
110
+ }
111
+ }
112
+ ```
113
+
114
+ Launch with `AGENT_RELAY_PROFILE=backend-tester codex` or
115
+ `AGENT_RELAY_PROFILE=backend-tester claude`. Explicit env vars override the
116
+ profile where they overlap.
117
+
94
118
  Codex has additional tuning vars documented in [codex/README.md](codex/README.md).
95
119
 
96
- Agent IDs are deterministic: `{hostname}-{rig}-{project}-{session-hash}`.
120
+ Agent IDs are deterministic: `{hostname}-{project}-{session-hash}`.
97
121
 
98
122
  ## Message Targeting
99
123
 
@@ -111,7 +135,7 @@ The plugin or sidecar registers the agent and injects messaging context:
111
135
 
112
136
  ```
113
137
  Agent Relay active. Your agent ID: macmini2-cli-myproject-a1b2c3
114
- Relay URL: http://localhost:4850 | Server: 0.4.15 | Plugin: 0.4.15
138
+ Relay URL: http://localhost:4850 | Server: 0.4.16 | Plugin: 0.4.16
115
139
  Approval mode: open
116
140
  ```
117
141
 
@@ -167,6 +191,14 @@ claimable message. Posting `"status": "resolved"` marks the active task `done`.
167
191
  When an integration has `callbackUrl`, Agent Relay posts task lifecycle events
168
192
  (creation, claim, status changes) back to the caller.
169
193
 
194
+ Integration tokens can also be used as scoped API tokens when the full admin
195
+ `AGENT_RELAY_TOKEN` would be too broad. Supported scopes include `stats:read`,
196
+ `health:read`, `events:read`, `agents:read`, `agents:write`, `messages:read`,
197
+ `messages:write`, `tasks:read`, `tasks:write`, `pairs:read`, `pairs:write`,
198
+ `system:write`, and `*`.
199
+ The event ingress endpoint also accepts the legacy `tasks:create` and
200
+ `events:create` scopes.
201
+
170
202
  Task lifecycle API:
171
203
 
172
204
  | Method | Path | Purpose |
@@ -281,6 +313,46 @@ curl -H "X-Agent-Relay-Token: $AGENT_RELAY_TOKEN" http://localhost:4850/api/stat
281
313
  | `DELETE` | `/messages/:id` | Delete message |
282
314
  | `GET` | `/messages/cursor` | Latest message ID (for poller bootstrap) |
283
315
 
316
+ ### Pair Sessions
317
+
318
+ Pair sessions are exclusive two-agent live chats backed by normal relay
319
+ messages. They are useful when you want two agent-sessions to collaborate
320
+ in real time without turning the relay into a group chat.
321
+ The sessions can be claude to codex, codex to codex or claude to claude,
322
+ since agent-relay is provider-independent.
323
+
324
+ ```bash
325
+ agent-relay /pair codex "Debug flaky auth tests"
326
+ agent-relay pair codex --objective "Debug flaky auth tests"
327
+ agent-relay pair accept PAIR_ID --agent "$AGENT_RELAY_ID"
328
+ agent-relay pair send PAIR_ID --from "$AGENT_RELAY_ID" --body "What do you see?"
329
+ agent-relay /disconnect
330
+ agent-relay /status
331
+ agent-relay /label backend-fixer
332
+ agent-relay /tags backend tests urgent
333
+ ```
334
+
335
+ Inside provider sessions, the Codex skill and Claude plugin context instruct
336
+ agents to treat slash-style user messages like `/pair codex`, `/status`,
337
+ `/label backend-fixer`, and `/tags backend tests` as relay commands and run the
338
+ CLI. The CLI tries to auto-detect the current agent id from provider state; pass
339
+ `--from` or `--agent` if you want to be explicit.
340
+
341
+ If the target is already in a pending or active pair, the relay returns
342
+ `409 Busy`. If a target like `codex` matches multiple available agents, the
343
+ relay asks for a more specific target such as `id:...`, `label:...`, `tag:...`,
344
+ `cap:...` or `machine:...`.
345
+
346
+ | Method | Path | Purpose |
347
+ |--------|------|---------|
348
+ | `POST` | `/pairs` | Create a pair invite |
349
+ | `GET` | `/pairs` | List pairs (`?agent=`, `?status=`) |
350
+ | `GET` | `/pairs/:id` | Get one pair |
351
+ | `POST` | `/pairs/:id/accept` | Accept a pending pair |
352
+ | `POST` | `/pairs/:id/reject` | Reject a pending pair |
353
+ | `POST` | `/pairs/:id/messages` | Send a pair message |
354
+ | `POST` | `/pairs/:id/hangup` | End a pending or active pair |
355
+
284
356
  ### Tasks
285
357
 
286
358
  | Method | Path | Purpose |
@@ -358,3 +430,10 @@ claude --plugin-dir ./claude # test plugin locally
358
430
  ## License
359
431
 
360
432
  AGPL-3.0-or-later. See [LICENSE](LICENSE).
433
+
434
+ ## Related
435
+
436
+ - **[callmux](https://github.com/edimuj/callmux)** - MCP multiplexer: parallel execution, batching, caching, pipelining, and shared infrastructure for any AI agent
437
+ - **[tokenlean](https://github.com/edimuj/tokenlean)** - Make agents less wasteful: Token-efficient CLI tool-toolbox for AI agents
438
+ - **[claude-mneme](https://github.com/edimuj/claude-mneme) / [codex-mneme](https://github.com/edimuj/codex-mneme)** - Lightweight persistent agent memory: so every session picks up where the last one left off
439
+ - **[agent-awareness](https://github.com/edimuj/agent-awareness)** - Real-time situational awareness for your agents
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.4.15",
3
+ "version": "0.4.17",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  "scripts": {
20
20
  "start": "bun run src/index.ts",
21
21
  "dev": "bun --watch run src/index.ts",
22
- "test": "bun test src/",
22
+ "test": "bun test",
23
23
  "typecheck": "tsc --noEmit"
24
24
  },
25
25
  "keywords": [
@@ -3,6 +3,7 @@
3
3
  const DEFAULT_COMPOSE = { from: "", to: "", body: "", channel: "", subject: "", claimable: false };
4
4
  const CLOSED_TASK_STATUSES = new Set(["done", "failed", "canceled"]);
5
5
  const STATUS_SORT_ORDER = { online: 0, idle: 1, busy: 2, offline: 3 };
6
+ const LIVE_REFRESH_MS = 5_000;
6
7
 
7
8
  function loadPref(key, fallback) {
8
9
  try {
@@ -32,6 +33,8 @@
32
33
  tasks: [],
33
34
  taskEvents: [],
34
35
  stats: {},
36
+ health: null,
37
+ now: Date.now(),
35
38
  authToken: loadPref("authToken", ""),
36
39
 
37
40
  selectedAgent: "",
@@ -57,12 +60,19 @@
57
60
 
58
61
  chartInstances: {},
59
62
  _es: null,
60
- _statsTimer: null,
63
+ _clockTimer: null,
64
+ _refreshTimer: null,
65
+ _refreshInFlight: false,
61
66
  };
62
67
  }
63
68
 
64
69
  function watchPersistedPrefs(vm) {
65
70
  vm.$watch("showOffline", (value) => vm.save("showOffline", value));
71
+ vm.$watch("autoRefresh", (value) => {
72
+ vm.save("autoRefresh", value);
73
+ if (value) vm.startAutoRefresh();
74
+ else vm.stopAutoRefresh();
75
+ });
66
76
  vm.$watch("agentSort", (value) => vm.save("agentSort", value));
67
77
  vm.$watch("agentSortDir", (value) => vm.save("agentSortDir", value));
68
78
  vm.$watch("agentStatusFilter", (value) => vm.save("agentStatusFilter", value));
@@ -95,12 +105,31 @@
95
105
  else vm.tasks.unshift(task);
96
106
  }
97
107
 
108
+ function syncAgentStats(vm) {
109
+ vm.stats.agents = vm.agents.length;
110
+ vm.stats.online = vm.agents.filter((agent) => agent.status !== "offline").length;
111
+ }
112
+
113
+ function syncMessageStats(vm, msg) {
114
+ vm.stats.messages = (vm.stats.messages ?? 0) + 1;
115
+ const createdAt = new Date(msg.createdAt || Date.now()).getTime();
116
+ if (Number.isFinite(createdAt) && Date.now() - createdAt <= 86_400_000) {
117
+ vm.stats.messagesLast24h = (vm.stats.messagesLast24h ?? 0) + 1;
118
+ }
119
+ }
120
+
121
+ function refreshChartsIfVisible(vm) {
122
+ if (vm.view !== "analytics") return;
123
+ vm.$nextTick(() => vm.renderCharts());
124
+ }
125
+
98
126
  function createLifecycleMethods() {
99
127
  return {
100
128
  async init() {
101
129
  await this.refresh();
102
130
  this.connectSSE();
103
- this._statsTimer = setInterval(() => this.fetchStats(), 30_000);
131
+ this.startClock();
132
+ this.startAutoRefresh();
104
133
  watchPersistedPrefs(this);
105
134
  },
106
135
 
@@ -113,6 +142,25 @@
113
142
  if (view === "messages") this.fetchMessages();
114
143
  if (view === "tasks") this.fetchTasks();
115
144
  },
145
+
146
+ startClock() {
147
+ if (this._clockTimer) return;
148
+ this._clockTimer = setInterval(() => {
149
+ this.now = Date.now();
150
+ }, 1_000);
151
+ },
152
+
153
+ startAutoRefresh() {
154
+ this.stopAutoRefresh();
155
+ if (!this.autoRefresh) return;
156
+ this._refreshTimer = setInterval(() => this.refreshLiveData(), LIVE_REFRESH_MS);
157
+ },
158
+
159
+ stopAutoRefresh() {
160
+ if (!this._refreshTimer) return;
161
+ clearInterval(this._refreshTimer);
162
+ this._refreshTimer = null;
163
+ },
116
164
  };
117
165
  }
118
166
 
@@ -139,6 +187,7 @@
139
187
  es.addEventListener("agent.status", (event) => handleAgentStatus(this, parseEventData(event)));
140
188
  es.addEventListener("agent.removed", (event) => handleAgentRemoved(this, parseEventData(event)));
141
189
  es.addEventListener("message.claimed", (event) => handleMessageClaimed(this, parseEventData(event)));
190
+ es.addEventListener("message.claim_released", (event) => handleMessageClaimReleased(this, parseEventData(event)));
142
191
  es.addEventListener("message.deleted", (event) => handleMessageDeleted(this, parseEventData(event)));
143
192
  registerTaskEvents(this, es);
144
193
  }
@@ -156,22 +205,37 @@
156
205
 
157
206
  vm.messages.push(msg);
158
207
  if (vm.messages.length > 200) vm.messages.shift();
159
- vm.stats.messages = (vm.stats.messages ?? 0) + 1;
208
+ syncMessageStats(vm, msg);
209
+ refreshChartsIfVisible(vm);
160
210
  }
161
211
 
162
212
  function handleAgentStatus(vm, agent) {
163
213
  upsertById(vm.agents, agent);
164
214
  vm.agentsById[agent.id] = agent;
215
+ syncAgentStats(vm);
216
+ refreshChartsIfVisible(vm);
165
217
  }
166
218
 
167
219
  function handleAgentRemoved(vm, data) {
168
220
  vm.agents = vm.agents.filter((agent) => agent.id !== data.id);
169
221
  delete vm.agentsById[data.id];
222
+ syncAgentStats(vm);
223
+ refreshChartsIfVisible(vm);
170
224
  }
171
225
 
172
226
  function handleMessageClaimed(vm, data) {
173
227
  const msg = vm.messages.find((item) => item.id === data.messageId);
174
- if (msg) msg.claimedBy = data.claimedBy;
228
+ if (!msg) return;
229
+ msg.claimedBy = data.claimedBy;
230
+ msg.claimExpiresAt = data.claimExpiresAt;
231
+ }
232
+
233
+ function handleMessageClaimReleased(vm, data) {
234
+ const msg = vm.messages.find((item) => item.id === data.messageId);
235
+ if (!msg) return;
236
+ delete msg.claimedBy;
237
+ delete msg.claimedAt;
238
+ delete msg.claimExpiresAt;
175
239
  }
176
240
 
177
241
  function handleMessageDeleted(vm, data) {
@@ -213,7 +277,18 @@
213
277
  },
214
278
 
215
279
  async refresh() {
216
- await Promise.all([this.fetchStats(), this.fetchAgents(), this.fetchMessages(), this.fetchTasks()]);
280
+ await Promise.all([this.fetchStats(), this.fetchHealth(), this.fetchAgents(), this.fetchMessages(), this.fetchTasks()]);
281
+ },
282
+
283
+ async refreshLiveData() {
284
+ if (this._refreshInFlight || this.authNeeded) return;
285
+ this._refreshInFlight = true;
286
+ try {
287
+ await this.refresh();
288
+ refreshChartsIfVisible(this);
289
+ } finally {
290
+ this._refreshInFlight = false;
291
+ }
217
292
  },
218
293
 
219
294
  async fetchStats() {
@@ -222,6 +297,12 @@
222
297
  } catch {}
223
298
  },
224
299
 
300
+ async fetchHealth() {
301
+ try {
302
+ this.health = await this.api("GET", "/health");
303
+ } catch {}
304
+ },
305
+
225
306
  async fetchAgents() {
226
307
  try {
227
308
  this.agents = await this.api("GET", "/agents");
@@ -260,6 +341,7 @@
260
341
  uniqueLabels: { get: getUniqueLabels },
261
342
  uniqueCaps: { get: getUniqueCaps },
262
343
  uniqueTags: { get: getUniqueTags },
344
+ healthIssues: { get: getHealthIssues },
263
345
  };
264
346
  }
265
347
 
@@ -336,6 +418,10 @@
336
418
  return [...new Set(this.agents.flatMap((agent) => agent.tags || []))];
337
419
  }
338
420
 
421
+ function getHealthIssues() {
422
+ return (this.health?.checks || []).filter((check) => check.status !== "ok");
423
+ }
424
+
339
425
  function compareAgents(vm, a, b) {
340
426
  switch (vm.agentSort) {
341
427
  case "name":
@@ -359,6 +445,7 @@
359
445
  agentStatusTitle,
360
446
  timeAgo,
361
447
  fmtTime,
448
+ healthAlertClass,
362
449
  };
363
450
  }
364
451
 
@@ -392,7 +479,7 @@
392
479
  const lastSeenMs = new Date(agent.lastSeen).getTime();
393
480
  if (!Number.isFinite(lastSeenMs)) return "Trying to reconnect...";
394
481
 
395
- const ageSec = Math.max(0, (Date.now() - lastSeenMs) / 1000);
482
+ const ageSec = Math.max(0, ((this.now || Date.now()) - lastSeenMs) / 1000);
396
483
  return ageSec <= 45 ? "Starting up..." : "Trying to reconnect...";
397
484
  }
398
485
 
@@ -401,7 +488,7 @@
401
488
  const ts = new Date(iso).getTime();
402
489
  if (!Number.isFinite(ts)) return "";
403
490
 
404
- const diff = Math.max(0, (Date.now() - ts) / 1000);
491
+ const diff = Math.max(0, ((this.now || Date.now()) - ts) / 1000);
405
492
  if (diff < 60) return Math.floor(diff) + "s ago";
406
493
  if (diff < 3600) return Math.floor(diff / 60) + "m ago";
407
494
  if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
@@ -413,6 +500,12 @@
413
500
  return new Date(iso).toLocaleString();
414
501
  }
415
502
 
503
+ function healthAlertClass(status) {
504
+ if (status === "error") return "alert-danger";
505
+ if (status === "degraded") return "alert-warning";
506
+ return "alert-success";
507
+ }
508
+
416
509
  function createMessageActions() {
417
510
  return {
418
511
  openCompose,
package/public/index.html CHANGED
@@ -109,6 +109,12 @@
109
109
  <input type="checkbox" class="form-check-input" x-model="showOffline">
110
110
  <span class="form-check-label small">Show offline</span>
111
111
  </label>
112
+ </div>
113
+ <div class="d-flex align-items-center gap-2 mb-2">
114
+ <label class="form-check form-switch mb-0">
115
+ <input type="checkbox" class="form-check-input" x-model="autoRefresh">
116
+ <span class="form-check-label small">Auto refresh</span>
117
+ </label>
112
118
  </div>
113
119
  <div class="text-muted small" x-show="stats.version" x-text="'v' + stats.version"></div>
114
120
  </div>
@@ -201,6 +207,25 @@
201
207
  </div>
202
208
  </div>
203
209
 
210
+ <template x-if="health">
211
+ <div class="alert d-flex align-items-start gap-3 mb-4" :class="healthAlertClass(health.status)">
212
+ <i class="ti ti-heartbeat mt-1"></i>
213
+ <div class="flex-grow-1">
214
+ <div class="fw-bold" x-text="'Relay health: ' + health.status"></div>
215
+ <template x-if="healthIssues.length === 0">
216
+ <div class="small">All checks passing</div>
217
+ </template>
218
+ <template x-if="healthIssues.length > 0">
219
+ <div class="small">
220
+ <template x-for="check in healthIssues" :key="check.name">
221
+ <span class="me-3" x-text="check.detail || check.name"></span>
222
+ </template>
223
+ </div>
224
+ </template>
225
+ </div>
226
+ </div>
227
+ </template>
228
+
204
229
  <!-- Two-column: Agents + Recent messages -->
205
230
  <div class="row g-3">
206
231
  <div class="col-lg-5">
@@ -389,7 +414,7 @@
389
414
  <div class="ms-auto d-flex gap-2 align-items-center flex-wrap">
390
415
  <select class="form-select form-select-sm" style="width: auto; min-width: 160px" x-model="selectedAgent" @change="fetchMessages()">
391
416
  <option value="">All agents</option>
392
- <template x-for="a in agents" :key="a.id">
417
+ <template x-for="a in composeAgents" :key="a.id">
393
418
  <option :value="a.id" x-text="displayName(a) + ' [' + a.id.slice(-6) + ']'"></option>
394
419
  </template>
395
420
  </select>