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.
- package/package.json +1 -1
- package/public/dashboard.js +338 -14
- package/public/index.html +446 -23
- package/src/agent-spawn.ts +137 -0
- package/src/db.ts +171 -1
- package/src/routes.ts +380 -2
- package/src/sse.ts +15 -1
- package/src/types.ts +60 -0
|
@@ -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:
|
|
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
|
+
}
|