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/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;