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/public/sw.js ADDED
@@ -0,0 +1,58 @@
1
+ const CACHE_NAME = "agent-relay-dashboard-v1";
2
+ const APP_SHELL = [
3
+ "/",
4
+ "/index.html",
5
+ "/dashboard.js",
6
+ "/manifest.webmanifest",
7
+ "/icons/agent-relay.svg",
8
+ "/icons/agent-relay-192.png",
9
+ "/icons/agent-relay-512.png",
10
+ ];
11
+
12
+ self.addEventListener("install", (event) => {
13
+ event.waitUntil(
14
+ caches.open(CACHE_NAME)
15
+ .then((cache) => cache.addAll(APP_SHELL))
16
+ .then(() => self.skipWaiting()),
17
+ );
18
+ });
19
+
20
+ self.addEventListener("activate", (event) => {
21
+ event.waitUntil(
22
+ caches.keys()
23
+ .then((names) => Promise.all(names
24
+ .filter((name) => name !== CACHE_NAME)
25
+ .map((name) => caches.delete(name))))
26
+ .then(() => self.clients.claim()),
27
+ );
28
+ });
29
+
30
+ self.addEventListener("fetch", (event) => {
31
+ const request = event.request;
32
+ const url = new URL(request.url);
33
+
34
+ if (request.method !== "GET" || url.origin !== self.location.origin || url.pathname.startsWith("/api/")) {
35
+ return;
36
+ }
37
+
38
+ if (request.headers.get("accept")?.includes("text/event-stream")) {
39
+ return;
40
+ }
41
+
42
+ event.respondWith(
43
+ fetch(request)
44
+ .then((response) => {
45
+ if (response.ok && APP_SHELL.includes(url.pathname === "/" ? "/" : url.pathname)) {
46
+ const copy = response.clone();
47
+ caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
48
+ }
49
+ return response;
50
+ })
51
+ .catch(async () => {
52
+ const cached = await caches.match(request);
53
+ if (cached) return cached;
54
+ if (request.mode === "navigate") return caches.match("/index.html");
55
+ throw new Error("offline");
56
+ }),
57
+ );
58
+ });
package/src/cli.ts CHANGED
@@ -485,11 +485,13 @@ async function handleMessageCommand(args: string[], defaults: { claimable?: bool
485
485
  const message = await apiRequest("POST", "/api/messages", {
486
486
  from,
487
487
  to: target,
488
+ kind: claimable ? "task" : "chat",
488
489
  subject,
489
490
  channel,
490
491
  body,
491
492
  replyTo,
492
493
  claimable,
494
+ payload: claimable ? { title: subject || "Claimable task" } : undefined,
493
495
  idempotencyKey,
494
496
  });
495
497
  if (json) console.log(JSON.stringify(message, null, 2));
@@ -606,9 +608,16 @@ async function detectAgentId(): Promise<string | undefined> {
606
608
  const explicit = process.env.AGENT_RELAY_ID;
607
609
  if (explicit) return explicit;
608
610
 
611
+ const contextMatch = currentAgentContextId();
612
+ if (contextMatch) return contextMatch;
613
+
609
614
  const cwd = process.cwd();
615
+ const explicitCodexState = process.env.AGENT_RELAY_CODEX_STATE_PATH
616
+ ? readCodexState(process.env.AGENT_RELAY_CODEX_STATE_PATH)
617
+ : null;
618
+ if (explicitCodexState?.agentId) return explicitCodexState.agentId;
619
+
610
620
  const stateCandidates = [
611
- process.env.AGENT_RELAY_CODEX_STATE_PATH,
612
621
  resolve(cwd, "codex/runtime/live-state.json"),
613
622
  ...collectCodexStateFiles(),
614
623
  ].filter((path): path is string => Boolean(path));
@@ -616,7 +625,7 @@ async function detectAgentId(): Promise<string | undefined> {
616
625
  const codexMatch = newestCodexAgentId(stateCandidates, cwd);
617
626
  if (codexMatch) return codexMatch;
618
627
 
619
- const claudeMatch = newestClaudeAgentId();
628
+ const claudeMatch = currentClaudeAgentId();
620
629
  if (claudeMatch) return claudeMatch;
621
630
 
622
631
  try {
@@ -624,18 +633,77 @@ async function detectAgentId(): Promise<string | undefined> {
624
633
  const cwdAgents = agents
625
634
  .filter((agent) => agent.status !== "offline" && agent.ready !== false && agent.meta?.cwd === cwd && typeof agent.id === "string")
626
635
  .sort((a, b) => (b.lastSeen ?? 0) - (a.lastSeen ?? 0));
627
- return cwdAgents[0]?.id;
636
+ const uniqueAgentIds = uniqueStrings(cwdAgents.map((agent) => agent.id!));
637
+ return uniqueAgentIds.length === 1 ? uniqueAgentIds[0] : undefined;
628
638
  } catch {
629
639
  return undefined;
630
640
  }
631
641
  }
632
642
 
643
+ function currentAgentContextId(): string | undefined {
644
+ const explicitPath = process.env.AGENT_RELAY_CONTEXT_PATH;
645
+ if (explicitPath) {
646
+ const explicit = readAgentContext(explicitPath);
647
+ if (explicit?.agentId) return explicit.agentId;
648
+ }
649
+
650
+ const candidates = collectAgentContextFiles();
651
+ const matches = candidates
652
+ .map((path) => readAgentContext(path))
653
+ .filter((context): context is { agentId: string; updatedAtMs: number; matchEnv: Array<{ name: string; value: string }> } => Boolean(context))
654
+ .filter((context) => context.matchEnv.some((match) => process.env[match.name] === match.value))
655
+ .sort((a, b) => b.updatedAtMs - a.updatedAtMs);
656
+
657
+ const uniqueAgentIds = uniqueStrings(matches.map((context) => context.agentId));
658
+ return uniqueAgentIds.length === 1 ? uniqueAgentIds[0] : undefined;
659
+ }
660
+
661
+ function readAgentContext(path: string): { agentId: string; updatedAtMs: number; matchEnv: Array<{ name: string; value: string }> } | null {
662
+ if (!existsSync(path)) return null;
663
+ try {
664
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as {
665
+ agentId?: unknown;
666
+ updatedAt?: unknown;
667
+ matchEnv?: unknown;
668
+ };
669
+ if (typeof parsed.agentId !== "string" || !parsed.agentId) return null;
670
+ const matchEnv = Array.isArray(parsed.matchEnv)
671
+ ? parsed.matchEnv.flatMap((item) => {
672
+ if (!item || typeof item !== "object") return [];
673
+ const record = item as { name?: unknown; value?: unknown };
674
+ return typeof record.name === "string" && typeof record.value === "string"
675
+ ? [{ name: record.name, value: record.value }]
676
+ : [];
677
+ })
678
+ : [];
679
+ const stat = statSync(path);
680
+ const updatedAt = typeof parsed.updatedAt === "string" ? Date.parse(parsed.updatedAt) : Number.NaN;
681
+ return {
682
+ agentId: parsed.agentId,
683
+ matchEnv,
684
+ updatedAtMs: Number.isFinite(updatedAt) ? updatedAt : stat.mtimeMs,
685
+ };
686
+ } catch {
687
+ return null;
688
+ }
689
+ }
690
+
691
+ function collectAgentContextFiles(): string[] {
692
+ const roots = [
693
+ join(process.env.HOME || "", ".agent-relay", "contexts"),
694
+ ].filter((root) => root && existsSync(root));
695
+ const files: string[] = [];
696
+ for (const root of roots) collectFiles(root, ".json", files, 2);
697
+ return files;
698
+ }
699
+
633
700
  function newestCodexAgentId(paths: string[], cwd: string): string | undefined {
634
701
  const states = paths
635
702
  .map((path) => readCodexState(path))
636
703
  .filter((state): state is { agentId: string; cwd?: string; updatedAtMs: number } => Boolean(state))
637
704
  .sort((a, b) => b.updatedAtMs - a.updatedAtMs);
638
- return states.find((state) => state.cwd === cwd)?.agentId ?? states[0]?.agentId;
705
+ const cwdAgentIds = uniqueStrings(states.filter((state) => state.cwd === cwd).map((state) => state.agentId));
706
+ return cwdAgentIds.length === 1 ? cwdAgentIds[0] : undefined;
639
707
  }
640
708
 
641
709
  function readCodexState(path: string): { agentId: string; cwd?: string; updatedAtMs: number } | null {
@@ -678,29 +746,24 @@ function collectFiles(dir: string, name: string, output: string[], depth: number
678
746
  try {
679
747
  const stat = statSync(path);
680
748
  if (stat.isDirectory()) collectFiles(path, name, output, depth - 1);
681
- else if (entry === name) output.push(path);
749
+ else if (name.startsWith(".") ? entry.endsWith(name) : entry === name) output.push(path);
682
750
  } catch {
683
751
  // Ignore state files that disappear while scanning.
684
752
  }
685
753
  }
686
754
  }
687
755
 
688
- function newestClaudeAgentId(): string | undefined {
689
- if (!existsSync("/tmp")) return undefined;
756
+ function currentClaudeAgentId(): string | undefined {
757
+ const sessionKey = process.env.CLAUDE_CODE_SESSION_ID || String(process.ppid || "");
758
+ if (!sessionKey) return undefined;
759
+ const safeSessionKey = sessionKey.replace(/[^A-Za-z0-9_.:-]/g, "_");
760
+ const statePath = join("/tmp", `agent-relay-instance-${safeSessionKey}.state`);
761
+ if (!existsSync(statePath)) return undefined;
690
762
  try {
691
- const candidates = readdirSync("/tmp")
692
- .filter((entry) => entry.startsWith("agent-relay-instance-") && entry.endsWith(".state"))
693
- .map((entry) => join("/tmp", entry))
694
- .map((path) => ({ path, mtimeMs: statSync(path).mtimeMs }))
695
- .sort((a, b) => b.mtimeMs - a.mtimeMs);
696
- for (const candidate of candidates) {
697
- const id = readFileSync(candidate.path, "utf8").split(/\r?\n/)[0]?.trim();
698
- if (id) return id;
699
- }
763
+ return readFileSync(statePath, "utf8").split(/\r?\n/)[0]?.trim() || undefined;
700
764
  } catch {
701
765
  return undefined;
702
766
  }
703
- return undefined;
704
767
  }
705
768
 
706
769
  function formatPairs(pairs: any[]): string {
@@ -0,0 +1,256 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import type { ConnectorAction, ConnectorActionResult, ConnectorManifest, ConnectorSummary } from "./types";
5
+ import { ValidationError } from "./db";
6
+
7
+ const CONNECTOR_SCHEMA = "agent-relay.connector.v1";
8
+ const VALID_KINDS = new Set(["channel", "event", "provider", "orchestrator"]);
9
+ const VALID_ACTIONS = new Set(["install", "uninstall", "enable", "disable", "start", "stop", "restart", "status", "doctor"]);
10
+
11
+ function connectorRegistryDir(): string {
12
+ const configured = process.env.AGENT_RELAY_CONNECTORS_DIR;
13
+ if (configured) return resolveHome(configured);
14
+ return join(homedir(), ".agent-relay", "connectors");
15
+ }
16
+
17
+ function resolveHome(path: string): string {
18
+ return path.startsWith("~") ? join(homedir(), path.slice(1)) : path;
19
+ }
20
+
21
+ function connectorDir(id: string): string {
22
+ validateConnectorId(id);
23
+ return join(connectorRegistryDir(), id);
24
+ }
25
+
26
+ function readJsonFile(path: string): unknown {
27
+ return JSON.parse(readFileSync(path, "utf8"));
28
+ }
29
+
30
+ function readRecordFile(path: string): Record<string, unknown> | undefined {
31
+ if (!existsSync(path)) return undefined;
32
+ const parsed = readJsonFile(path);
33
+ return isRecord(parsed) ? parsed : undefined;
34
+ }
35
+
36
+ function isRecord(value: unknown): value is Record<string, unknown> {
37
+ return typeof value === "object" && value !== null && !Array.isArray(value);
38
+ }
39
+
40
+ function validateConnectorId(id: string): void {
41
+ if (!/^[a-z0-9][a-z0-9._-]{0,79}$/.test(id)) {
42
+ throw new ValidationError("connector id must be lowercase alphanumeric plus dot, underscore, or dash");
43
+ }
44
+ }
45
+
46
+ function normalizeConnectorManifest(value: unknown): ConnectorManifest {
47
+ if (!isRecord(value)) throw new ValidationError("connector manifest must be an object");
48
+ if (value.schema !== CONNECTOR_SCHEMA) throw new ValidationError(`connector manifest schema must be ${CONNECTOR_SCHEMA}`);
49
+ const id = stringField(value, "id");
50
+ validateConnectorId(id);
51
+ const kind = stringField(value, "kind");
52
+ if (!VALID_KINDS.has(kind)) throw new ValidationError("connector kind must be channel, event, provider, or orchestrator");
53
+ const binary = stringField(value, "binary");
54
+ const displayName = stringField(value, "displayName");
55
+ const version = stringField(value, "version");
56
+ const commands = value.commands;
57
+ if (!isRecord(commands)) throw new ValidationError("connector commands must be an object");
58
+
59
+ const normalizedCommands: ConnectorManifest["commands"] = {};
60
+ for (const [action, command] of Object.entries(commands)) {
61
+ if (!VALID_ACTIONS.has(action)) throw new ValidationError(`unsupported connector action: ${action}`);
62
+ if (!Array.isArray(command) || !command.every((part) => typeof part === "string" && part.trim())) {
63
+ throw new ValidationError(`connector command ${action} must be a non-empty string array`);
64
+ }
65
+ normalizedCommands[action as ConnectorAction] = command;
66
+ }
67
+
68
+ return {
69
+ schema: CONNECTOR_SCHEMA,
70
+ id,
71
+ kind: kind as ConnectorManifest["kind"],
72
+ packageName: optionalString(value.packageName),
73
+ binary,
74
+ displayName,
75
+ description: optionalString(value.description),
76
+ version,
77
+ capabilities: stringArray(value.capabilities, "capabilities"),
78
+ commands: normalizedCommands,
79
+ configSchema: isRecord(value.configSchema) ? value.configSchema : undefined,
80
+ };
81
+ }
82
+
83
+ function stringField(record: Record<string, unknown>, field: string): string {
84
+ const value = record[field];
85
+ if (typeof value !== "string" || !value.trim()) throw new ValidationError(`connector ${field} required`);
86
+ if (value.length > 500) throw new ValidationError(`connector ${field} is too long`);
87
+ return value;
88
+ }
89
+
90
+ function optionalString(value: unknown): string | undefined {
91
+ return typeof value === "string" && value.trim() ? value : undefined;
92
+ }
93
+
94
+ function stringArray(value: unknown, field: string): string[] {
95
+ if (value === undefined) return [];
96
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
97
+ throw new ValidationError(`connector ${field} must be an array of strings`);
98
+ }
99
+ return [...new Set(value.map((item) => item.trim()).filter(Boolean))];
100
+ }
101
+
102
+ export function registerConnectorManifest(manifest: ConnectorManifest, opts: { config?: Record<string, unknown>; state?: Record<string, unknown> } = {}): ConnectorSummary {
103
+ const normalized = normalizeConnectorManifest(manifest);
104
+ const dir = connectorDir(normalized.id);
105
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
106
+ writeFileSync(join(dir, "manifest.json"), JSON.stringify(normalized, null, 2) + "\n", { mode: 0o600 });
107
+ if (opts.config) writeConnectorConfig(normalized.id, opts.config);
108
+ if (opts.state) writeFileSync(join(dir, "state.json"), JSON.stringify(opts.state, null, 2) + "\n", { mode: 0o600 });
109
+ return getConnector(normalized.id)!;
110
+ }
111
+
112
+ export function listConnectors(): ConnectorSummary[] {
113
+ const root = connectorRegistryDir();
114
+ if (!existsSync(root)) return [];
115
+ return readdirSync(root, { withFileTypes: true })
116
+ .filter((entry) => entry.isDirectory())
117
+ .flatMap((entry) => {
118
+ try {
119
+ const connector = getConnector(entry.name);
120
+ return connector ? [connector] : [];
121
+ } catch {
122
+ return [];
123
+ }
124
+ })
125
+ .sort((a, b) => a.displayName.localeCompare(b.displayName));
126
+ }
127
+
128
+ export function getConnector(id: string): ConnectorSummary | null {
129
+ const dir = connectorDir(id);
130
+ const manifestPath = join(dir, "manifest.json");
131
+ if (!existsSync(manifestPath)) return null;
132
+ const manifest = normalizeConnectorManifest(readJsonFile(manifestPath));
133
+ const config = readRecordFile(join(dir, "config.json"));
134
+ const state = readRecordFile(join(dir, "state.json"));
135
+ return {
136
+ id: manifest.id,
137
+ kind: manifest.kind,
138
+ displayName: manifest.displayName,
139
+ description: manifest.description,
140
+ version: manifest.version,
141
+ packageName: manifest.packageName,
142
+ binary: manifest.binary,
143
+ capabilities: manifest.capabilities,
144
+ registryPath: dir,
145
+ manifest,
146
+ config,
147
+ state,
148
+ runtime: summarizeRuntime(manifest, state),
149
+ };
150
+ }
151
+
152
+ function summarizeRuntime(manifest: ConnectorManifest, state?: Record<string, unknown>): ConnectorSummary["runtime"] {
153
+ return {
154
+ installed: true,
155
+ enabled: typeof state?.enabled === "boolean" ? state.enabled : undefined,
156
+ running: typeof state?.running === "boolean" ? state.running : undefined,
157
+ status: typeof state?.status === "string" && ["ok", "warn", "error", "unknown"].includes(state.status) ? state.status as any : "unknown",
158
+ detail: typeof state?.detail === "string" ? state.detail : undefined,
159
+ updatedAt: typeof state?.updatedAt === "string" ? state.updatedAt : undefined,
160
+ raw: state?.raw ?? (manifest.commands.status ? undefined : { detail: "status command not declared" }),
161
+ };
162
+ }
163
+
164
+ export function readConnectorConfig(id: string): Record<string, unknown> {
165
+ return readRecordFile(join(connectorDir(id), "config.json")) ?? {};
166
+ }
167
+
168
+ export function writeConnectorConfig(id: string, config: Record<string, unknown>): Record<string, unknown> {
169
+ if (!isRecord(config)) throw new ValidationError("connector config must be an object");
170
+ const path = join(connectorDir(id), "config.json");
171
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
172
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
173
+ return config;
174
+ }
175
+
176
+ export function runConnectorAction(id: string, action: ConnectorAction): ConnectorActionResult {
177
+ if (!VALID_ACTIONS.has(action)) throw new ValidationError("unsupported connector action");
178
+ const connector = getConnector(id);
179
+ if (!connector) throw new ValidationError(`connector ${id} not found`);
180
+ const command = connector.manifest.commands[action];
181
+ if (!command?.length) throw new ValidationError(`connector ${id} does not support ${action}`);
182
+
183
+ const proc = Bun.spawnSync({
184
+ cmd: command,
185
+ stdout: "pipe",
186
+ stderr: "pipe",
187
+ env: { ...process.env, AGENT_RELAY_CONNECTOR_ID: id, AGENT_RELAY_CONNECTORS_DIR: connectorRegistryDir() },
188
+ });
189
+ const stdout = new TextDecoder().decode(proc.stdout).trim();
190
+ const stderr = new TextDecoder().decode(proc.stderr).trim();
191
+ const parsed = parseJsonMaybe(stdout);
192
+ const ok = proc.success;
193
+ const result: ConnectorActionResult = {
194
+ connectorId: id,
195
+ action,
196
+ command,
197
+ ok,
198
+ exitCode: proc.exitCode,
199
+ stdout,
200
+ stderr,
201
+ parsed,
202
+ };
203
+ if ((action === "status" || action === "doctor") && ok) {
204
+ writeActionState(id, action, parsed ?? stdout);
205
+ }
206
+ return result;
207
+ }
208
+
209
+ function parseJsonMaybe(value: string): unknown {
210
+ if (!value) return undefined;
211
+ try {
212
+ return JSON.parse(value);
213
+ } catch {
214
+ return undefined;
215
+ }
216
+ }
217
+
218
+ function writeActionState(id: string, action: ConnectorAction, raw: unknown): void {
219
+ const current = readRecordFile(join(connectorDir(id), "state.json")) ?? {};
220
+ const next = {
221
+ ...current,
222
+ status: action === "doctor" ? doctorStatus(raw) : statusValue(raw),
223
+ detail: detailValue(raw),
224
+ running: runningValue(raw),
225
+ enabled: enabledValue(raw),
226
+ updatedAt: new Date().toISOString(),
227
+ raw,
228
+ };
229
+ writeFileSync(join(connectorDir(id), "state.json"), JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
230
+ }
231
+
232
+ function statusValue(raw: unknown): ConnectorSummary["runtime"]["status"] {
233
+ if (isRecord(raw) && typeof raw.status === "string" && ["ok", "warn", "error", "unknown"].includes(raw.status)) return raw.status as any;
234
+ return "unknown";
235
+ }
236
+
237
+ function doctorStatus(raw: unknown): ConnectorSummary["runtime"]["status"] {
238
+ if (!isRecord(raw) || !Array.isArray(raw.checks)) return statusValue(raw);
239
+ if (raw.checks.some((check) => isRecord(check) && check.status === "error")) return "error";
240
+ if (raw.checks.some((check) => isRecord(check) && check.status === "warn")) return "warn";
241
+ return "ok";
242
+ }
243
+
244
+ function detailValue(raw: unknown): string | undefined {
245
+ if (isRecord(raw) && typeof raw.detail === "string") return raw.detail;
246
+ if (isRecord(raw) && typeof raw.summary === "string") return raw.summary;
247
+ return undefined;
248
+ }
249
+
250
+ function runningValue(raw: unknown): boolean | undefined {
251
+ return isRecord(raw) && typeof raw.running === "boolean" ? raw.running : undefined;
252
+ }
253
+
254
+ function enabledValue(raw: unknown): boolean | undefined {
255
+ return isRecord(raw) && typeof raw.enabled === "boolean" ? raw.enabled : undefined;
256
+ }