agent-relay-server 0.4.16 → 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
 
@@ -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.16",
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": [
@@ -33,6 +33,7 @@
33
33
  tasks: [],
34
34
  taskEvents: [],
35
35
  stats: {},
36
+ health: null,
36
37
  now: Date.now(),
37
38
  authToken: loadPref("authToken", ""),
38
39
 
@@ -186,6 +187,7 @@
186
187
  es.addEventListener("agent.status", (event) => handleAgentStatus(this, parseEventData(event)));
187
188
  es.addEventListener("agent.removed", (event) => handleAgentRemoved(this, parseEventData(event)));
188
189
  es.addEventListener("message.claimed", (event) => handleMessageClaimed(this, parseEventData(event)));
190
+ es.addEventListener("message.claim_released", (event) => handleMessageClaimReleased(this, parseEventData(event)));
189
191
  es.addEventListener("message.deleted", (event) => handleMessageDeleted(this, parseEventData(event)));
190
192
  registerTaskEvents(this, es);
191
193
  }
@@ -223,7 +225,17 @@
223
225
 
224
226
  function handleMessageClaimed(vm, data) {
225
227
  const msg = vm.messages.find((item) => item.id === data.messageId);
226
- 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;
227
239
  }
228
240
 
229
241
  function handleMessageDeleted(vm, data) {
@@ -265,7 +277,7 @@
265
277
  },
266
278
 
267
279
  async refresh() {
268
- 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()]);
269
281
  },
270
282
 
271
283
  async refreshLiveData() {
@@ -285,6 +297,12 @@
285
297
  } catch {}
286
298
  },
287
299
 
300
+ async fetchHealth() {
301
+ try {
302
+ this.health = await this.api("GET", "/health");
303
+ } catch {}
304
+ },
305
+
288
306
  async fetchAgents() {
289
307
  try {
290
308
  this.agents = await this.api("GET", "/agents");
@@ -323,6 +341,7 @@
323
341
  uniqueLabels: { get: getUniqueLabels },
324
342
  uniqueCaps: { get: getUniqueCaps },
325
343
  uniqueTags: { get: getUniqueTags },
344
+ healthIssues: { get: getHealthIssues },
326
345
  };
327
346
  }
328
347
 
@@ -399,6 +418,10 @@
399
418
  return [...new Set(this.agents.flatMap((agent) => agent.tags || []))];
400
419
  }
401
420
 
421
+ function getHealthIssues() {
422
+ return (this.health?.checks || []).filter((check) => check.status !== "ok");
423
+ }
424
+
402
425
  function compareAgents(vm, a, b) {
403
426
  switch (vm.agentSort) {
404
427
  case "name":
@@ -422,6 +445,7 @@
422
445
  agentStatusTitle,
423
446
  timeAgo,
424
447
  fmtTime,
448
+ healthAlertClass,
425
449
  };
426
450
  }
427
451
 
@@ -476,6 +500,12 @@
476
500
  return new Date(iso).toLocaleString();
477
501
  }
478
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
+
479
509
  function createMessageActions() {
480
510
  return {
481
511
  openCompose,
package/public/index.html CHANGED
@@ -207,6 +207,25 @@
207
207
  </div>
208
208
  </div>
209
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
+
210
229
  <!-- Two-column: Agents + Recent messages -->
211
230
  <div class="row g-3">
212
231
  <div class="col-lg-5">
package/src/cli.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
  import { stdin as input, stdout as output } from "node:process";
3
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
3
5
  import {
4
6
  createDaemonPlan,
5
7
  detectDaemonEnvironment,
@@ -23,8 +25,25 @@ Usage:
23
25
  agent-relay [start]
24
26
  agent-relay setup [--yes] [--dry-run] [--force] [--env-file PATH] [--host HOST] [--port PORT] [--db-path PATH] [--token TOKEN|--no-token]
25
27
  agent-relay daemon <install|uninstall|start|stop|restart|enable|disable|status|logs> [options]
28
+ agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
29
+ agent-relay /pair <target|accept|reject|send|status> [...]
30
+ agent-relay /disconnect [PAIR_ID]
31
+ agent-relay /status
32
+ agent-relay /label [LABEL]
33
+ agent-relay /tags [TAG ...]
26
34
  agent-relay --help
27
35
 
36
+ Pair examples:
37
+ agent-relay pair codex --objective "Debug flaky tests"
38
+ agent-relay /pair codex "Debug flaky tests"
39
+ agent-relay pair status
40
+ agent-relay pair accept PAIR_ID --agent AGENT_ID
41
+ agent-relay pair send PAIR_ID --from AGENT_ID --body "What do you see?"
42
+ agent-relay /disconnect
43
+ agent-relay /status
44
+ agent-relay /label backend-fixer
45
+ agent-relay /tags backend tests urgent
46
+
28
47
  Daemon options:
29
48
  --env-file PATH Env file sourced by the daemon (default: platform user config dir)
30
49
  --binary PATH Stable agent-relay binary/script path for the service
@@ -71,9 +90,41 @@ export async function handleCli(args: string[]): Promise<"start" | "handled"> {
71
90
  await handleDaemonCommand(args.slice(1));
72
91
  return "handled";
73
92
  }
93
+ if (command === "pair" || command === "/pair" || command === "/disconnect") {
94
+ await handleSlashOrPairCommand(command, args.slice(1));
95
+ return "handled";
96
+ }
97
+ if (command === "/status" || command === "status") {
98
+ await handleStatusCommand(args.slice(1));
99
+ return "handled";
100
+ }
101
+ if (command === "/label" || command === "label") {
102
+ await handleLabelCommand(args.slice(1));
103
+ return "handled";
104
+ }
105
+ if (command === "/tags" || command === "tags") {
106
+ await handleTagsCommand(args.slice(1));
107
+ return "handled";
108
+ }
109
+ if (command === "/reconnect") {
110
+ console.log("Reconnect is handled automatically by provider sidecars; use `agent-relay pair status` to inspect current pair state.");
111
+ return "handled";
112
+ }
74
113
  throw new Error(`Unknown command "${command}". Run agent-relay --help.`);
75
114
  }
76
115
 
116
+ async function handleSlashOrPairCommand(command: string, args: string[]): Promise<void> {
117
+ if (command === "/disconnect") {
118
+ await handlePairCommand(["hangup", ...args]);
119
+ return;
120
+ }
121
+ if (command === "/pair") {
122
+ await handlePairCommand(args);
123
+ return;
124
+ }
125
+ await handlePairCommand(args);
126
+ }
127
+
77
128
  async function handleSetupCommand(args: string[]): Promise<void> {
78
129
  let envFile: string | undefined;
79
130
  let host: string | undefined;
@@ -205,6 +256,339 @@ function parseDaemonAction(value: string | undefined): DaemonAction {
205
256
  return value as DaemonAction;
206
257
  }
207
258
 
259
+ async function handlePairCommand(args: string[]): Promise<void> {
260
+ if (!args.length) throw new Error("Usage: agent-relay pair <target|create|status|accept|reject|hangup|send> [options]");
261
+ const knownActions = new Set(["create", "status", "list", "accept", "reject", "hangup", "disconnect", "send"]);
262
+ const action = knownActions.has(args[0]!) ? args[0]! : "create";
263
+ const rest = action === "create" && args[0] !== "create" ? args : args.slice(1);
264
+
265
+ if (action === "status" || action === "list") {
266
+ let agent: string | undefined = await detectAgentId();
267
+ let status: string | undefined;
268
+ let json = false;
269
+ for (let i = 0; i < rest.length; i++) {
270
+ const arg = rest[i];
271
+ if (arg === "--agent" && i + 1 < rest.length) agent = rest[++i];
272
+ else if (arg === "--status" && i + 1 < rest.length) status = rest[++i];
273
+ else if (arg === "--json") json = true;
274
+ else throw new Error(`Unknown pair status option "${arg}"`);
275
+ }
276
+ const query = new URLSearchParams();
277
+ if (agent) query.set("agent", agent);
278
+ if (status) query.set("status", status);
279
+ const pairs = await apiRequest("GET", `/api/pairs${query.size ? `?${query}` : ""}`);
280
+ if (json) console.log(JSON.stringify(pairs, null, 2));
281
+ else console.log(formatPairs(pairs as any[]));
282
+ return;
283
+ }
284
+
285
+ if (action === "create") {
286
+ const target = rest[0];
287
+ if (!target || target.startsWith("--")) throw new Error("Usage: agent-relay pair <target> [--from AGENT_ID] [--objective TEXT]");
288
+ let from = await detectAgentId();
289
+ let objective: string | undefined;
290
+ let ttlMs: number | undefined;
291
+ let json = false;
292
+ const objectiveParts: string[] = [];
293
+ for (let i = 1; i < rest.length; i++) {
294
+ const arg = rest[i];
295
+ if (arg === "--from" && i + 1 < rest.length) from = rest[++i];
296
+ else if (arg === "--objective" && i + 1 < rest.length) objective = rest[++i];
297
+ else if (arg === "--ttl-ms" && i + 1 < rest.length) ttlMs = parseInt(rest[++i]!, 10);
298
+ else if (arg === "--json") json = true;
299
+ else objectiveParts.push(arg!);
300
+ }
301
+ objective ??= objectiveParts.length ? objectiveParts.join(" ") : undefined;
302
+ if (!from) throw new Error("Could not detect current Agent Relay ID. Pass --from AGENT_ID or set AGENT_RELAY_ID.");
303
+ const result = await apiRequest("POST", "/api/pairs", { from, target, objective, ttlMs });
304
+ if (json) console.log(JSON.stringify(result, null, 2));
305
+ else {
306
+ const pair = (result as any).pair;
307
+ console.log(`Pair invite ${pair.id} sent: ${pair.requesterId} -> ${pair.targetId}`);
308
+ }
309
+ return;
310
+ }
311
+
312
+ if (action === "accept" || action === "reject" || action === "hangup" || action === "disconnect") {
313
+ const pairId = rest[0];
314
+ let agentId = await detectAgentId();
315
+ let reason: string | undefined;
316
+ let json = false;
317
+ let startIndex = 0;
318
+ if (pairId && !pairId.startsWith("--")) startIndex = 1;
319
+ for (let i = startIndex; i < rest.length; i++) {
320
+ const arg = rest[i];
321
+ if (arg === "--agent" && i + 1 < rest.length) agentId = rest[++i];
322
+ else if (arg === "--reason" && i + 1 < rest.length) reason = rest[++i];
323
+ else if (arg === "--json") json = true;
324
+ else throw new Error(`Unknown pair ${action} option "${arg}"`);
325
+ }
326
+ if (!agentId) throw new Error("Could not detect current Agent Relay ID. Pass --agent AGENT_ID or set AGENT_RELAY_ID.");
327
+ const resolvedPairId = pairId && !pairId.startsWith("--") ? pairId : await detectActivePairId(agentId);
328
+ if (!resolvedPairId) throw new Error(`Usage: agent-relay pair ${action} PAIR_ID --agent AGENT_ID`);
329
+ const endpoint = action === "disconnect" ? "hangup" : action;
330
+ const pair = await apiRequest("POST", `/api/pairs/${encodeURIComponent(resolvedPairId)}/${endpoint}`, { agentId, reason });
331
+ if (json) console.log(JSON.stringify(pair, null, 2));
332
+ else console.log(`Pair ${resolvedPairId}: ${(pair as any).status}`);
333
+ return;
334
+ }
335
+
336
+ if (action === "send") {
337
+ const pairId = rest[0];
338
+ if (!pairId || pairId.startsWith("--")) throw new Error("Usage: agent-relay pair send PAIR_ID --from AGENT_ID --body TEXT");
339
+ let from = await detectAgentId();
340
+ let body: string | undefined;
341
+ let subject: string | undefined;
342
+ let json = false;
343
+ for (let i = 1; i < rest.length; i++) {
344
+ const arg = rest[i];
345
+ if (arg === "--from" && i + 1 < rest.length) from = rest[++i];
346
+ else if (arg === "--body" && i + 1 < rest.length) body = rest[++i];
347
+ else if (arg === "--subject" && i + 1 < rest.length) subject = rest[++i];
348
+ else if (arg === "--json") json = true;
349
+ else throw new Error(`Unknown pair send option "${arg}"`);
350
+ }
351
+ if (!from) throw new Error("Could not detect current Agent Relay ID. Pass --from AGENT_ID or set AGENT_RELAY_ID.");
352
+ if (!body) throw new Error("--body TEXT required");
353
+ const result = await apiRequest("POST", `/api/pairs/${encodeURIComponent(pairId)}/messages`, { from, body, subject });
354
+ if (json) console.log(JSON.stringify(result, null, 2));
355
+ else console.log(`Pair message sent: ${(result as any).message.id}`);
356
+ return;
357
+ }
358
+ }
359
+
360
+ async function handleStatusCommand(args: string[]): Promise<void> {
361
+ let agentId = await detectAgentId();
362
+ let json = false;
363
+ for (let i = 0; i < args.length; i++) {
364
+ const arg = args[i];
365
+ if (arg === "--agent" && i + 1 < args.length) agentId = args[++i];
366
+ else if (arg === "--json") json = true;
367
+ else throw new Error(`Unknown status option "${arg}"`);
368
+ }
369
+
370
+ const [stats, health, pairs, agent] = await Promise.all([
371
+ apiRequest("GET", "/api/stats"),
372
+ apiRequest("GET", "/api/health"),
373
+ agentId ? apiRequest("GET", `/api/pairs?agent=${encodeURIComponent(agentId)}`) : Promise.resolve([]),
374
+ agentId ? apiRequest("GET", `/api/agents/${encodeURIComponent(agentId)}`).catch(() => null) : Promise.resolve(null),
375
+ ]);
376
+ const payload = { agent, stats, health, pairs };
377
+ if (json) console.log(JSON.stringify(payload, null, 2));
378
+ else console.log(formatStatus(payload));
379
+ }
380
+
381
+ async function handleLabelCommand(args: string[]): Promise<void> {
382
+ let agentId = await detectAgentId();
383
+ let label: string | null | undefined;
384
+ let json = false;
385
+ for (let i = 0; i < args.length; i++) {
386
+ const arg = args[i];
387
+ if (arg === "--agent" && i + 1 < args.length) agentId = args[++i];
388
+ else if (arg === "--clear") label = null;
389
+ else if (arg === "--json") json = true;
390
+ else if (label === undefined) label = args.slice(i).join(" ");
391
+ }
392
+ if (!agentId) throw new Error("Could not detect current Agent Relay ID. Pass --agent AGENT_ID or set AGENT_RELAY_ID.");
393
+ if (label === undefined) {
394
+ const agent = await apiRequest("GET", `/api/agents/${encodeURIComponent(agentId)}`) as { label?: string };
395
+ console.log(agent.label ?? "(no label)");
396
+ return;
397
+ }
398
+ const result = await apiRequest("PATCH", `/api/agents/${encodeURIComponent(agentId)}/label`, { label });
399
+ if (json) console.log(JSON.stringify(result, null, 2));
400
+ else console.log(label ? `Label set: ${label}` : "Label cleared.");
401
+ }
402
+
403
+ async function handleTagsCommand(args: string[]): Promise<void> {
404
+ let agentId = await detectAgentId();
405
+ let json = false;
406
+ let listOnly = false;
407
+ let add: string[] = [];
408
+ let remove: string[] = [];
409
+ const positional: string[] = [];
410
+ for (let i = 0; i < args.length; i++) {
411
+ const arg = args[i];
412
+ if (arg === "--agent" && i + 1 < args.length) agentId = args[++i];
413
+ else if (arg === "--json") json = true;
414
+ else if (arg === "--list") listOnly = true;
415
+ else if (arg === "--add" && i + 1 < args.length) add = add.concat(splitTagArgs(args[++i]!));
416
+ else if (arg === "--remove" && i + 1 < args.length) remove = remove.concat(splitTagArgs(args[++i]!));
417
+ else positional.push(...splitTagArgs(arg!));
418
+ }
419
+ if (!agentId) throw new Error("Could not detect current Agent Relay ID. Pass --agent AGENT_ID or set AGENT_RELAY_ID.");
420
+ const current = await apiRequest("GET", `/api/agents/${encodeURIComponent(agentId)}`) as { tags?: string[] };
421
+ if (listOnly || (positional.length === 0 && add.length === 0 && remove.length === 0)) {
422
+ console.log((current.tags ?? []).join(", ") || "(no tags)");
423
+ return;
424
+ }
425
+ const next = positional.length > 0
426
+ ? uniqueStrings(positional)
427
+ : uniqueStrings([...(current.tags ?? []), ...add]).filter((tag) => !remove.includes(tag));
428
+ const updated = await apiRequest("PATCH", `/api/agents/${encodeURIComponent(agentId)}/tags`, { tags: next });
429
+ if (json) console.log(JSON.stringify(updated, null, 2));
430
+ else console.log(`Tags: ${next.join(", ") || "(none)"}`);
431
+ }
432
+
433
+ async function apiRequest(method: string, path: string, body?: unknown): Promise<unknown> {
434
+ const baseUrl = process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850";
435
+ const headers: Record<string, string> = {};
436
+ const token = process.env.AGENT_RELAY_TOKEN;
437
+ if (token) headers["X-Agent-Relay-Token"] = token;
438
+ if (body !== undefined) headers["Content-Type"] = "application/json";
439
+ const response = await fetch(new URL(path, baseUrl), {
440
+ method,
441
+ headers,
442
+ body: body === undefined ? undefined : JSON.stringify(body),
443
+ });
444
+ const text = await response.text();
445
+ const payload = text ? JSON.parse(text) : null;
446
+ if (!response.ok) {
447
+ const message = payload && typeof payload === "object" && "error" in payload ? String((payload as any).error) : text;
448
+ throw new Error(`agent-relay ${method} ${path} failed (${response.status}): ${message}`);
449
+ }
450
+ return payload;
451
+ }
452
+
453
+ function splitTagArgs(raw: string): string[] {
454
+ return raw.split(",").map((tag) => tag.trim()).filter(Boolean);
455
+ }
456
+
457
+ function uniqueStrings(values: string[]): string[] {
458
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
459
+ }
460
+
461
+ async function detectActivePairId(agentId: string): Promise<string | undefined> {
462
+ const pairs = await apiRequest("GET", `/api/pairs?agent=${encodeURIComponent(agentId)}&status=active`) as Array<{ id?: string }>;
463
+ return Array.isArray(pairs) && typeof pairs[0]?.id === "string" ? pairs[0].id : undefined;
464
+ }
465
+
466
+ async function detectAgentId(): Promise<string | undefined> {
467
+ const explicit = process.env.AGENT_RELAY_ID;
468
+ if (explicit) return explicit;
469
+
470
+ const cwd = process.cwd();
471
+ const stateCandidates = [
472
+ process.env.AGENT_RELAY_CODEX_STATE_PATH,
473
+ resolve(cwd, "codex/runtime/live-state.json"),
474
+ ...collectCodexStateFiles(),
475
+ ].filter((path): path is string => Boolean(path));
476
+
477
+ const codexMatch = newestCodexAgentId(stateCandidates, cwd);
478
+ if (codexMatch) return codexMatch;
479
+
480
+ const claudeMatch = newestClaudeAgentId();
481
+ if (claudeMatch) return claudeMatch;
482
+
483
+ try {
484
+ const agents = await apiRequest("GET", "/api/agents") as Array<{ id?: string; status?: string; ready?: boolean; meta?: { cwd?: unknown }; lastSeen?: number }>;
485
+ const cwdAgents = agents
486
+ .filter((agent) => agent.status !== "offline" && agent.ready !== false && agent.meta?.cwd === cwd && typeof agent.id === "string")
487
+ .sort((a, b) => (b.lastSeen ?? 0) - (a.lastSeen ?? 0));
488
+ return cwdAgents[0]?.id;
489
+ } catch {
490
+ return undefined;
491
+ }
492
+ }
493
+
494
+ function newestCodexAgentId(paths: string[], cwd: string): string | undefined {
495
+ const states = paths
496
+ .map((path) => readCodexState(path))
497
+ .filter((state): state is { agentId: string; cwd?: string; updatedAtMs: number } => Boolean(state))
498
+ .sort((a, b) => b.updatedAtMs - a.updatedAtMs);
499
+ return states.find((state) => state.cwd === cwd)?.agentId ?? states[0]?.agentId;
500
+ }
501
+
502
+ function readCodexState(path: string): { agentId: string; cwd?: string; updatedAtMs: number } | null {
503
+ if (!existsSync(path)) return null;
504
+ try {
505
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as { agentId?: unknown; cwd?: unknown; updatedAt?: unknown };
506
+ if (typeof parsed.agentId !== "string" || !parsed.agentId) return null;
507
+ const stat = statSync(path);
508
+ const updatedAt = typeof parsed.updatedAt === "string" ? Date.parse(parsed.updatedAt) : Number.NaN;
509
+ return {
510
+ agentId: parsed.agentId,
511
+ cwd: typeof parsed.cwd === "string" ? parsed.cwd : undefined,
512
+ updatedAtMs: Number.isFinite(updatedAt) ? updatedAt : stat.mtimeMs,
513
+ };
514
+ } catch {
515
+ return null;
516
+ }
517
+ }
518
+
519
+ function collectCodexStateFiles(): string[] {
520
+ const roots = [
521
+ join(process.env.HOME || "", ".agent-relay", "codex", "runtime"),
522
+ resolve(process.cwd(), "codex", "runtime"),
523
+ ].filter((root) => root && existsSync(root));
524
+ const files: string[] = [];
525
+ for (const root of roots) collectFiles(root, "live-state.json", files, 4);
526
+ return files;
527
+ }
528
+
529
+ function collectFiles(dir: string, name: string, output: string[], depth: number): void {
530
+ if (depth < 0) return;
531
+ let entries: string[];
532
+ try {
533
+ entries = readdirSync(dir);
534
+ } catch {
535
+ return;
536
+ }
537
+ for (const entry of entries) {
538
+ const path = join(dir, entry);
539
+ try {
540
+ const stat = statSync(path);
541
+ if (stat.isDirectory()) collectFiles(path, name, output, depth - 1);
542
+ else if (entry === name) output.push(path);
543
+ } catch {
544
+ // Ignore state files that disappear while scanning.
545
+ }
546
+ }
547
+ }
548
+
549
+ function newestClaudeAgentId(): string | undefined {
550
+ if (!existsSync("/tmp")) return undefined;
551
+ try {
552
+ const candidates = readdirSync("/tmp")
553
+ .filter((entry) => entry.startsWith("agent-relay-instance-") && entry.endsWith(".state"))
554
+ .map((entry) => join("/tmp", entry))
555
+ .map((path) => ({ path, mtimeMs: statSync(path).mtimeMs }))
556
+ .sort((a, b) => b.mtimeMs - a.mtimeMs);
557
+ for (const candidate of candidates) {
558
+ const id = readFileSync(candidate.path, "utf8").split(/\r?\n/)[0]?.trim();
559
+ if (id) return id;
560
+ }
561
+ } catch {
562
+ return undefined;
563
+ }
564
+ return undefined;
565
+ }
566
+
567
+ function formatPairs(pairs: any[]): string {
568
+ if (!pairs.length) return "No pair sessions.";
569
+ return pairs
570
+ .map((pair) => `${pair.id} ${pair.status} ${pair.requesterId} <-> ${pair.targetId}${pair.objective ? ` ${pair.objective}` : ""}`)
571
+ .join("\n");
572
+ }
573
+
574
+ function formatStatus(payload: any): string {
575
+ const agent = payload.agent;
576
+ const stats = payload.stats ?? {};
577
+ const health = payload.health ?? {};
578
+ const pairs = Array.isArray(payload.pairs) ? payload.pairs : [];
579
+ const activePair = pairs.find((pair: any) => pair.status === "active") ?? pairs.find((pair: any) => pair.status === "pending");
580
+ return [
581
+ `Relay: ${health.status ?? "unknown"} version=${stats.version ?? "unknown"}`,
582
+ `Agents: ${stats.online ?? "?"}/${stats.agents ?? "?"} online Messages: ${stats.messages ?? "?"} Tasks: ${stats.openTasks ?? "?"}/${stats.tasks ?? "?"} open`,
583
+ agent
584
+ ? `Current: ${agent.id} status=${agent.status} ready=${agent.ready ? "yes" : "no"} label=${agent.label ?? "(none)"} tags=${(agent.tags ?? []).join(", ") || "(none)"}`
585
+ : "Current: unknown",
586
+ activePair
587
+ ? `Pair: ${activePair.id} ${activePair.status} ${activePair.requesterId} <-> ${activePair.targetId}`
588
+ : "Pair: none active",
589
+ ].join("\n");
590
+ }
591
+
208
592
  async function confirm(message: string): Promise<boolean> {
209
593
  if (!input.isTTY) return false;
210
594
  const rl = createInterface({ input, output });
package/src/config.ts CHANGED
@@ -20,6 +20,7 @@ function envPositiveInt(name: string, fallback: number): number {
20
20
  export const STALE_TTL_MS = envPositiveInt("STALE_TTL_MS", 120_000); // 2min without heartbeat → offline
21
21
  export const OFFLINE_PRUNE_MS = envPositiveInt("OFFLINE_PRUNE_MS", DAY_MS); // 24h offline → delete
22
22
  export const REAP_INTERVAL_MS = envPositiveInt("REAP_INTERVAL_MS", 60_000); // reaper cadence
23
+ export const CLAIM_LEASE_MS = envPositiveInt("AGENT_RELAY_CLAIM_LEASE_MS", 1_800_000); // 30min claim lease
23
24
 
24
25
  // Max body size for any POST/PATCH request (64 KiB).
25
26
  export const MAX_BODY_BYTES = 64 * 1024;