agent-relay-server 0.4.38 → 0.5.0

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.
@@ -0,0 +1,137 @@
1
+ import { existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, delimiter, dirname, join, resolve } from "node:path";
4
+
5
+ export type CodexSpawnApprovalMode = "open" | "guarded" | "read-only";
6
+
7
+ interface CodexSpawnInput {
8
+ cwd?: string;
9
+ approvalMode: CodexSpawnApprovalMode;
10
+ label?: string;
11
+ relayUrl: string;
12
+ token?: string;
13
+ dryRun?: boolean;
14
+ }
15
+
16
+ interface CodexSpawnResult {
17
+ provider: "codex";
18
+ pid?: number;
19
+ cwd: string;
20
+ approvalMode: CodexSpawnApprovalMode;
21
+ logPath: string;
22
+ command: string[];
23
+ dryRun?: boolean;
24
+ }
25
+
26
+ interface HostDirectoryEntry {
27
+ name: string;
28
+ path: string;
29
+ }
30
+
31
+ interface HostDirectoryListing {
32
+ path: string;
33
+ parent?: string;
34
+ home: string;
35
+ cwd: string;
36
+ entries: HostDirectoryEntry[];
37
+ }
38
+
39
+ export function normalizeCodexSpawnCwd(raw: string | undefined, fallback = process.cwd()): string {
40
+ const cwd = resolve(raw || fallback);
41
+ if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
42
+ throw new Error(`cwd does not exist or is not a directory: ${cwd}`);
43
+ }
44
+ return cwd;
45
+ }
46
+
47
+ export function listHostDirectories(raw: string | undefined): HostDirectoryListing {
48
+ const cwd = process.cwd();
49
+ const home = homedir();
50
+ const path = normalizeCodexSpawnCwd(raw || cwd, cwd);
51
+ const entries = readdirSync(path, { withFileTypes: true })
52
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith("."))
53
+ .map((entry) => ({
54
+ name: entry.name,
55
+ path: join(path, entry.name),
56
+ }))
57
+ .sort((a, b) => a.name.localeCompare(b.name));
58
+ const parent = dirname(path);
59
+ return {
60
+ path,
61
+ parent: parent !== path ? parent : undefined,
62
+ home,
63
+ cwd,
64
+ entries,
65
+ };
66
+ }
67
+
68
+ export function codexSpawnCommand(): string[] {
69
+ const override = process.env.AGENT_RELAY_CODEX_RELAY_BIN;
70
+ if (override) return [override];
71
+
72
+ const repoLauncher = resolve(import.meta.dir, "../codex/bin/agent-relay-codex.ts");
73
+ if (existsSync(repoLauncher)) return ["bun", "run", repoLauncher, "start"];
74
+
75
+ const fromPath = findOnPath("codex-relay");
76
+ if (fromPath) return [fromPath];
77
+
78
+ return ["codex-relay"];
79
+ }
80
+
81
+ export function codexSpawnLogPath(cwd: string, now = Date.now()): string {
82
+ const project = basename(cwd).replace(/[^a-zA-Z0-9._-]+/g, "-") || "project";
83
+ return join(homedir(), ".agent-relay", "spawns", `codex-${project}-${now}.log`);
84
+ }
85
+
86
+ export function spawnCodexAgent(input: CodexSpawnInput): CodexSpawnResult {
87
+ const cwd = normalizeCodexSpawnCwd(input.cwd);
88
+ const command = [
89
+ ...codexSpawnCommand(),
90
+ "--headless",
91
+ "--relay-url",
92
+ input.relayUrl,
93
+ ];
94
+ const logPath = codexSpawnLogPath(cwd);
95
+ const env: Record<string, string | undefined> = {
96
+ ...process.env,
97
+ AGENT_RELAY_CODEX_HEADLESS: "1",
98
+ AGENT_RELAY_APPROVAL: input.approvalMode,
99
+ AGENT_RELAY_TAGS: mergeCsv(process.env.AGENT_RELAY_TAGS, ["headless", "dashboard-spawned"]),
100
+ AGENT_RELAY_LABEL: input.label || process.env.AGENT_RELAY_LABEL,
101
+ AGENT_RELAY_URL: input.relayUrl,
102
+ AGENT_RELAY_TOKEN: input.token || process.env.AGENT_RELAY_TOKEN,
103
+ };
104
+
105
+ if (input.dryRun) {
106
+ return { provider: "codex", cwd, approvalMode: input.approvalMode, logPath, command, dryRun: true };
107
+ }
108
+
109
+ mkdirSync(dirname(logPath), { recursive: true });
110
+ const log = Bun.file(logPath);
111
+ const child = Bun.spawn(command, {
112
+ cwd,
113
+ env,
114
+ stdin: "ignore",
115
+ stdout: log,
116
+ stderr: log,
117
+ });
118
+ child.unref();
119
+
120
+ return { provider: "codex", pid: child.pid, cwd, approvalMode: input.approvalMode, logPath, command };
121
+ }
122
+
123
+ function mergeCsv(raw: string | undefined, additions: string[]): string {
124
+ return [...new Set([
125
+ ...(raw || "").split(",").map((item) => item.trim()).filter(Boolean),
126
+ ...additions,
127
+ ])].join(",");
128
+ }
129
+
130
+ function findOnPath(command: string): string | null {
131
+ for (const dir of (process.env.PATH || "").split(delimiter)) {
132
+ if (!dir) continue;
133
+ const candidate = join(dir, command);
134
+ if (existsSync(candidate)) return candidate;
135
+ }
136
+ return null;
137
+ }
package/src/db.ts CHANGED
@@ -9,15 +9,21 @@ import type {
9
9
  CreatePairInput,
10
10
  HealthCheck,
11
11
  HealthReport,
12
+ ManagedAgent,
12
13
  Message,
13
14
  MessageType,
15
+ Orchestrator,
16
+ OrchestratorStatus,
14
17
  PairActionInput,
15
18
  PairMessageInput,
16
19
  PairSession,
17
20
  PairStatus,
18
21
  RegisterAgentInput,
22
+ RegisterOrchestratorInput,
19
23
  SendMessageInput,
20
24
  PollQuery,
25
+ SpawnApprovalMode,
26
+ SpawnProvider,
21
27
  Task,
22
28
  TaskEvent,
23
29
  TaskSeverity,
@@ -192,6 +198,20 @@ export function initDb(path: string = "agent-relay.db"): Database {
192
198
  );
193
199
  CREATE INDEX IF NOT EXISTS idx_activity_operator ON activity_events(operator_id, created_at);
194
200
  CREATE INDEX IF NOT EXISTS idx_activity_created ON activity_events(created_at);
201
+
202
+ CREATE TABLE IF NOT EXISTS orchestrators (
203
+ id TEXT PRIMARY KEY,
204
+ hostname TEXT NOT NULL,
205
+ status TEXT NOT NULL DEFAULT 'online',
206
+ agent_id TEXT NOT NULL,
207
+ providers TEXT NOT NULL DEFAULT '[]',
208
+ base_dir TEXT NOT NULL,
209
+ env_keys TEXT NOT NULL DEFAULT '[]',
210
+ meta TEXT NOT NULL DEFAULT '{}',
211
+ managed_agents TEXT NOT NULL DEFAULT '[]',
212
+ last_seen INTEGER NOT NULL,
213
+ created_at INTEGER NOT NULL
214
+ );
195
215
  `);
196
216
 
197
217
  // Migrations
@@ -1328,6 +1348,44 @@ function findMessageByIdempotencyKey(from: string, key: string): Message | null
1328
1348
  return row ? rowToMessage(row) : null;
1329
1349
  }
1330
1350
 
1351
+ function isDeliveryAgent(agent: AgentCard): boolean {
1352
+ return agent.status !== "offline" &&
1353
+ agent.id !== "user" &&
1354
+ agent.id !== "system" &&
1355
+ agent.meta?.kind !== "channel";
1356
+ }
1357
+
1358
+ function matchingDeliveryAgents(target: string): AgentCard[] {
1359
+ if (!target) return [];
1360
+ const candidates = listAgents().filter(isDeliveryAgent);
1361
+ if (target === "broadcast") return candidates;
1362
+ const direct = getAgent(target);
1363
+ if (direct) return isDeliveryAgent(direct) ? [direct] : [];
1364
+ if (target.startsWith("tag:")) {
1365
+ const tag = target.slice(4);
1366
+ return candidates.filter((agent) => agent.tags.includes(tag));
1367
+ }
1368
+ if (target.startsWith("cap:")) {
1369
+ const cap = target.slice(4);
1370
+ return candidates.filter((agent) => agent.capabilities.includes(cap));
1371
+ }
1372
+ if (target.startsWith("label:")) {
1373
+ const label = target.slice(6);
1374
+ return candidates.filter((agent) => agent.label === label);
1375
+ }
1376
+ return [];
1377
+ }
1378
+
1379
+ function claimableAllowedForTarget(target: string): boolean {
1380
+ return matchingDeliveryAgents(target).length > 1;
1381
+ }
1382
+
1383
+ function shouldStoreClaimable(input: SendMessageInput): boolean {
1384
+ if (!input.claimable) return false;
1385
+ if (input.type === "system" || typeof input.meta?.taskId === "number") return true;
1386
+ return claimableAllowedForTarget(input.to);
1387
+ }
1388
+
1331
1389
  export function sendMessageWithResult(input: SendMessageInput): { message: Message; created: boolean } {
1332
1390
  const now = Date.now();
1333
1391
 
@@ -1354,6 +1412,7 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
1354
1412
  VALUES ($from, $to, $type, $channel, $subject, $body, $threadId, $replyTo, $claimable, $idempotencyKey, $meta, $now)
1355
1413
  `);
1356
1414
  const setSelfThread = db.prepare("UPDATE messages SET thread_id = ? WHERE id = ?");
1415
+ const claimable = shouldStoreClaimable(input);
1357
1416
 
1358
1417
  const id = db.transaction(() => {
1359
1418
  const result = insert.run({
@@ -1365,7 +1424,7 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
1365
1424
  $body: input.body,
1366
1425
  $threadId: threadId,
1367
1426
  $replyTo: input.replyTo ?? null,
1368
- $claimable: input.claimable ? 1 : 0,
1427
+ $claimable: claimable ? 1 : 0,
1369
1428
  $idempotencyKey: input.idempotencyKey ?? null,
1370
1429
  $meta: JSON.stringify(input.meta ?? {}),
1371
1430
  $now: now,
@@ -1827,3 +1886,114 @@ export function getHealth(now: number = Date.now()): HealthReport {
1827
1886
  : "ok";
1828
1887
  return { status, version: VERSION, generatedAt: now, checks };
1829
1888
  }
1889
+
1890
+ // --- Orchestrators ---
1891
+
1892
+ function rowToOrchestrator(row: any): Orchestrator {
1893
+ return {
1894
+ id: row.id,
1895
+ hostname: row.hostname,
1896
+ status: row.status as OrchestratorStatus,
1897
+ agentId: row.agent_id,
1898
+ providers: parseJson<SpawnProvider[]>(row.providers, []),
1899
+ baseDir: row.base_dir,
1900
+ envKeys: parseJson<string[]>(row.env_keys, []),
1901
+ meta: parseJson(row.meta, {}),
1902
+ managedAgents: parseJson<ManagedAgent[]>(row.managed_agents, []),
1903
+ lastSeen: row.last_seen,
1904
+ createdAt: row.created_at,
1905
+ };
1906
+ }
1907
+
1908
+ export function upsertOrchestrator(input: RegisterOrchestratorInput): Orchestrator {
1909
+ const now = Date.now();
1910
+ const agentId = `orchestrator-${input.id}`;
1911
+ const stmt = db.prepare(`
1912
+ INSERT INTO orchestrators (id, hostname, status, agent_id, providers, base_dir, env_keys, meta, last_seen, created_at)
1913
+ VALUES ($id, $hostname, 'online', $agentId, $providers, $baseDir, $envKeys, $meta, $now, $now)
1914
+ ON CONFLICT(id) DO UPDATE SET
1915
+ hostname = $hostname,
1916
+ status = 'online',
1917
+ providers = $providers,
1918
+ base_dir = $baseDir,
1919
+ env_keys = $envKeys,
1920
+ meta = $meta,
1921
+ last_seen = $now
1922
+ `);
1923
+ stmt.run({
1924
+ $id: input.id,
1925
+ $hostname: input.hostname,
1926
+ $agentId: agentId,
1927
+ $providers: JSON.stringify(input.providers),
1928
+ $baseDir: input.baseDir,
1929
+ $envKeys: JSON.stringify(input.envKeys ?? []),
1930
+ $meta: JSON.stringify(input.meta ?? {}),
1931
+ $now: now,
1932
+ });
1933
+
1934
+ // Also register as an agent so the orchestrator can receive messages
1935
+ upsertAgent({
1936
+ id: agentId,
1937
+ name: `Orchestrator (${input.hostname})`,
1938
+ tags: ["orchestrator", input.hostname],
1939
+ machine: input.hostname,
1940
+ capabilities: ["orchestrator", "spawn"],
1941
+ status: "online",
1942
+ meta: { orchestratorId: input.id, builtin: true },
1943
+ });
1944
+
1945
+ return getOrchestrator(input.id)!;
1946
+ }
1947
+
1948
+ export function getOrchestrator(id: string): Orchestrator | null {
1949
+ const row = db.prepare("SELECT * FROM orchestrators WHERE id = ?").get(id) as any;
1950
+ return row ? rowToOrchestrator(row) : null;
1951
+ }
1952
+
1953
+ export function listOrchestrators(): Orchestrator[] {
1954
+ return (db.prepare("SELECT * FROM orchestrators ORDER BY hostname").all() as any[]).map(rowToOrchestrator);
1955
+ }
1956
+
1957
+ export function orchestratorHeartbeat(id: string): Orchestrator | null {
1958
+ const now = Date.now();
1959
+ db.prepare("UPDATE orchestrators SET last_seen = ?, status = 'online' WHERE id = ?").run(now, id);
1960
+ // Also heartbeat the agent
1961
+ const orch = getOrchestrator(id);
1962
+ if (orch) {
1963
+ heartbeat(orch.agentId);
1964
+ }
1965
+ return orch;
1966
+ }
1967
+
1968
+ export function setOrchestratorStatus(id: string, status: OrchestratorStatus): Orchestrator | null {
1969
+ db.prepare("UPDATE orchestrators SET status = ?, last_seen = ? WHERE id = ?").run(status, Date.now(), id);
1970
+ const orch = getOrchestrator(id);
1971
+ if (orch) {
1972
+ setStatus(orch.agentId, status === "online" ? "online" : "offline");
1973
+ }
1974
+ return orch;
1975
+ }
1976
+
1977
+ export function updateManagedAgents(id: string, agents: ManagedAgent[]): Orchestrator | null {
1978
+ db.prepare("UPDATE orchestrators SET managed_agents = ?, last_seen = ? WHERE id = ?")
1979
+ .run(JSON.stringify(agents), Date.now(), id);
1980
+ return getOrchestrator(id);
1981
+ }
1982
+
1983
+ export function deleteOrchestrator(id: string): boolean {
1984
+ const orch = getOrchestrator(id);
1985
+ if (!orch) return false;
1986
+ db.prepare("DELETE FROM orchestrators WHERE id = ?").run(id);
1987
+ deleteAgent(orch.agentId);
1988
+ return true;
1989
+ }
1990
+
1991
+ export function reapStaleOrchestrators(): string[] {
1992
+ const cutoff = Date.now() - STALE_TTL_MS;
1993
+ const stale = db.prepare("SELECT id, agent_id FROM orchestrators WHERE last_seen < ? AND status = 'online'").all(cutoff) as any[];
1994
+ for (const row of stale) {
1995
+ db.prepare("UPDATE orchestrators SET status = 'offline' WHERE id = ?").run(row.id);
1996
+ setStatus(row.agent_id, "offline");
1997
+ }
1998
+ return stale.map((row: any) => row.id);
1999
+ }