agent-relay-orchestrator 0.9.0 → 0.10.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/package.json +1 -1
- package/src/api.ts +90 -0
- package/src/config.ts +5 -1
- package/src/control.ts +15 -125
- package/src/index.ts +17 -13
- package/src/relay.ts +28 -75
- package/src/tmux.ts +28 -44
- package/src/version.ts +1 -1
package/package.json
CHANGED
package/src/api.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import type { OrchestratorConfig } from "./config";
|
|
4
|
+
|
|
5
|
+
interface DirectoryEntry {
|
|
6
|
+
name: string;
|
|
7
|
+
path: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface DirectoryListing {
|
|
11
|
+
path: string;
|
|
12
|
+
parent?: string;
|
|
13
|
+
baseDir: string;
|
|
14
|
+
entries: DirectoryEntry[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function listDirectories(requestedPath: string | undefined, baseDir: string): DirectoryListing {
|
|
18
|
+
const target = resolve(requestedPath || baseDir);
|
|
19
|
+
|
|
20
|
+
if (!target.startsWith(baseDir) && target !== baseDir) {
|
|
21
|
+
throw new Error(`Path must be within baseDir: ${baseDir}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let stat;
|
|
25
|
+
try { stat = statSync(target); } catch { throw new Error(`Path does not exist: ${target}`); }
|
|
26
|
+
if (!stat.isDirectory()) throw new Error(`Not a directory: ${target}`);
|
|
27
|
+
|
|
28
|
+
const entries = readdirSync(target, { withFileTypes: true })
|
|
29
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
|
30
|
+
.map((e) => ({ name: e.name, path: join(target, e.name) }))
|
|
31
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
32
|
+
|
|
33
|
+
const parent = dirname(target);
|
|
34
|
+
return {
|
|
35
|
+
path: target,
|
|
36
|
+
parent: parent.startsWith(baseDir) && parent !== target ? parent : undefined,
|
|
37
|
+
baseDir,
|
|
38
|
+
entries,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function json(data: unknown, status = 200): Response {
|
|
43
|
+
return new Response(JSON.stringify(data), {
|
|
44
|
+
status,
|
|
45
|
+
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function error(message: string, status = 400): Response {
|
|
50
|
+
return json({ error: message }, status);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function startApiServer(config: OrchestratorConfig): { stop(): void; url: string } {
|
|
54
|
+
const server = Bun.serve({
|
|
55
|
+
port: config.apiPort,
|
|
56
|
+
hostname: "0.0.0.0",
|
|
57
|
+
fetch(req) {
|
|
58
|
+
const url = new URL(req.url);
|
|
59
|
+
|
|
60
|
+
if (req.method === "OPTIONS") {
|
|
61
|
+
return new Response(null, {
|
|
62
|
+
headers: {
|
|
63
|
+
"Access-Control-Allow-Origin": "*",
|
|
64
|
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
65
|
+
"Access-Control-Allow-Headers": "Content-Type, X-Agent-Relay-Token",
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (req.method === "GET" && url.pathname === "/api/directories") {
|
|
71
|
+
try {
|
|
72
|
+
const listing = listDirectories(url.searchParams.get("path") || undefined, config.baseDir);
|
|
73
|
+
return json(listing);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
return error((e as Error).message);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (req.method === "GET" && url.pathname === "/api/health") {
|
|
80
|
+
return json({ ok: true, id: config.id, hostname: config.hostname });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return error("Not found", 404);
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const url = `http://${config.hostname}:${config.apiPort}`;
|
|
88
|
+
console.error(`[orchestrator] API server listening on :${config.apiPort}`);
|
|
89
|
+
return { stop: () => server.stop(), url };
|
|
90
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface OrchestratorConfig {
|
|
|
12
12
|
env: Record<string, string>;
|
|
13
13
|
heartbeatIntervalMs: number;
|
|
14
14
|
tmuxPrefix: string;
|
|
15
|
+
apiPort: number;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
const DEFAULT_CONFIG_PATH = join(homedir(), ".agent-relay", "orchestrator.json");
|
|
@@ -26,6 +27,7 @@ interface RawConfig {
|
|
|
26
27
|
env?: Record<string, string>;
|
|
27
28
|
heartbeatIntervalMs?: number;
|
|
28
29
|
tmuxPrefix?: string;
|
|
30
|
+
apiPort?: number;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export function loadConfig(path?: string): OrchestratorConfig {
|
|
@@ -45,8 +47,9 @@ export function loadConfig(path?: string): OrchestratorConfig {
|
|
|
45
47
|
const env = raw.env || {};
|
|
46
48
|
const heartbeatIntervalMs = raw.heartbeatIntervalMs || 30_000;
|
|
47
49
|
const tmuxPrefix = raw.tmuxPrefix || "ar";
|
|
50
|
+
const apiPort = raw.apiPort || Number(process.env.AGENT_RELAY_ORCHESTRATOR_API_PORT) || 4860;
|
|
48
51
|
|
|
49
|
-
return { id, hostname, relayUrl, token, providers, baseDir, env, heartbeatIntervalMs, tmuxPrefix };
|
|
52
|
+
return { id, hostname, relayUrl, token, providers, baseDir, env, heartbeatIntervalMs, tmuxPrefix, apiPort };
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
export function initConfigFile(config: Partial<RawConfig>): string {
|
|
@@ -58,6 +61,7 @@ export function initConfigFile(config: Partial<RawConfig>): string {
|
|
|
58
61
|
relayUrl: "http://localhost:4850",
|
|
59
62
|
providers: ["claude", "codex"],
|
|
60
63
|
baseDir: join(homedir(), "projects"),
|
|
64
|
+
apiPort: 4860,
|
|
61
65
|
env: {},
|
|
62
66
|
};
|
|
63
67
|
const merged = { ...defaults, ...config };
|
package/src/control.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { OrchestratorConfig } from "./config";
|
|
2
|
-
import type {
|
|
3
|
-
import {
|
|
2
|
+
import type { ManagedAgentReport, RelayClient, RelayCommand } from "./relay";
|
|
3
|
+
import { spawnAgent, type SpawnOptions } from "./tmux";
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
interface ControlHandler {
|
|
6
|
+
handleCommand(command: RelayCommand): Promise<boolean>;
|
|
7
7
|
getManagedAgents(): ManagedAgentReport[];
|
|
8
8
|
setManagedAgents(agents: ManagedAgentReport[]): void;
|
|
9
9
|
}
|
|
@@ -14,30 +14,6 @@ export function createControlHandler(
|
|
|
14
14
|
): ControlHandler {
|
|
15
15
|
let managedAgents: ManagedAgentReport[] = [];
|
|
16
16
|
|
|
17
|
-
async function handle(message: ControlMessage): Promise<boolean> {
|
|
18
|
-
const ctrl = message.payload?.orchestratorControl;
|
|
19
|
-
if (!ctrl) return false;
|
|
20
|
-
|
|
21
|
-
let handled = false;
|
|
22
|
-
switch (ctrl.action) {
|
|
23
|
-
case "spawn":
|
|
24
|
-
handled = await handleSpawn(ctrl);
|
|
25
|
-
break;
|
|
26
|
-
case "restart":
|
|
27
|
-
handled = await handleRestart(ctrl);
|
|
28
|
-
break;
|
|
29
|
-
case "shutdown":
|
|
30
|
-
handled = await handleShutdown(ctrl);
|
|
31
|
-
break;
|
|
32
|
-
default:
|
|
33
|
-
console.error(`[orchestrator] Unknown control action: ${ctrl.action}`);
|
|
34
|
-
return false;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
await relay.updateManagedAgents(managedAgents);
|
|
38
|
-
return handled;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
17
|
async function handleSpawn(ctrl: Record<string, any>): Promise<boolean> {
|
|
42
18
|
const opts: SpawnOptions = {
|
|
43
19
|
provider: ctrl.provider || "claude",
|
|
@@ -58,108 +34,22 @@ export function createControlHandler(
|
|
|
58
34
|
}
|
|
59
35
|
}
|
|
60
36
|
|
|
61
|
-
async function
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
// Restart all managed agents
|
|
65
|
-
let ok = true;
|
|
66
|
-
for (const agent of [...managedAgents]) {
|
|
67
|
-
ok = await restartSingle(agent) && ok;
|
|
68
|
-
}
|
|
69
|
-
return ok;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const agent = managedAgents.find(
|
|
73
|
-
(a) => a.agentId === targetId || a.tmuxSession === targetId || a.label === targetId,
|
|
74
|
-
);
|
|
75
|
-
if (!agent) {
|
|
76
|
-
console.error(`[orchestrator] Restart: agent not found: ${targetId}`);
|
|
77
|
-
return false;
|
|
78
|
-
}
|
|
79
|
-
return restartSingle(agent);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async function restartSingle(agent: ManagedAgentReport): Promise<boolean> {
|
|
83
|
-
console.error(`[orchestrator] Restarting: ${agent.tmuxSession}`);
|
|
84
|
-
await killSession(agent.tmuxSession);
|
|
85
|
-
|
|
86
|
-
// Remove from managed list
|
|
87
|
-
managedAgents = managedAgents.filter((a) => a.tmuxSession !== agent.tmuxSession);
|
|
88
|
-
await deleteRelayAgentsForManaged(agent);
|
|
89
|
-
|
|
90
|
-
// Re-spawn with same options
|
|
37
|
+
async function handleCommand(command: RelayCommand): Promise<boolean> {
|
|
38
|
+
await relay.updateCommand(command.id, "accepted");
|
|
39
|
+
await relay.updateCommand(command.id, "running");
|
|
91
40
|
try {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}, config);
|
|
98
|
-
managedAgents.push(newAgent);
|
|
99
|
-
console.error(`[orchestrator] Restarted: ${newAgent.tmuxSession}`);
|
|
41
|
+
if (command.type !== "agent.spawn") throw new Error(`unsupported orchestrator command: ${command.type}`);
|
|
42
|
+
const handled = await handleSpawn(command.params);
|
|
43
|
+
if (!handled) throw new Error("spawn failed");
|
|
44
|
+
await relay.updateCommand(command.id, "succeeded", { managedAgents });
|
|
45
|
+
await relay.updateManagedAgents(managedAgents);
|
|
100
46
|
return true;
|
|
101
|
-
} catch (
|
|
102
|
-
|
|
47
|
+
} catch (error) {
|
|
48
|
+
await relay.updateCommand(command.id, "failed", undefined, error instanceof Error ? error.message : String(error));
|
|
103
49
|
return false;
|
|
104
50
|
}
|
|
105
51
|
}
|
|
106
52
|
|
|
107
|
-
async function handleShutdown(ctrl: Record<string, any>): Promise<boolean> {
|
|
108
|
-
const targetId = ctrl.agentId;
|
|
109
|
-
if (!targetId) {
|
|
110
|
-
// Shutdown all managed agents
|
|
111
|
-
let ok = true;
|
|
112
|
-
for (const agent of [...managedAgents]) {
|
|
113
|
-
ok = await shutdownSingle(agent) && ok;
|
|
114
|
-
}
|
|
115
|
-
return ok;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const agent = managedAgents.find(
|
|
119
|
-
(a) => a.agentId === targetId || a.tmuxSession === targetId || a.label === targetId,
|
|
120
|
-
);
|
|
121
|
-
if (!agent) {
|
|
122
|
-
console.error(`[orchestrator] Shutdown: agent not found: ${targetId}`);
|
|
123
|
-
return false;
|
|
124
|
-
}
|
|
125
|
-
return shutdownSingle(agent);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
async function shutdownSingle(agent: ManagedAgentReport): Promise<boolean> {
|
|
129
|
-
console.error(`[orchestrator] Shutting down: ${agent.tmuxSession}`);
|
|
130
|
-
const alive = await hasSession(agent.tmuxSession);
|
|
131
|
-
const killed = alive ? await killSession(agent.tmuxSession) : true;
|
|
132
|
-
if (killed) {
|
|
133
|
-
managedAgents = managedAgents.filter((a) => a.tmuxSession !== agent.tmuxSession);
|
|
134
|
-
await deleteRelayAgentsForManaged(agent);
|
|
135
|
-
}
|
|
136
|
-
return killed;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
async function deleteRelayAgentsForManaged(agent: ManagedAgentReport): Promise<void> {
|
|
140
|
-
const agents = await relay.listAgents();
|
|
141
|
-
const matches = agents.filter((candidate) => relayAgentMatchesManaged(candidate, agent));
|
|
142
|
-
for (const candidate of matches) {
|
|
143
|
-
const deleted = await relay.deleteAgent(candidate.id);
|
|
144
|
-
if (deleted) {
|
|
145
|
-
console.error(`[orchestrator] Removed relay agent record: ${candidate.id}`);
|
|
146
|
-
} else {
|
|
147
|
-
console.error(`[orchestrator] Failed to remove relay agent record: ${candidate.id}`);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function relayAgentMatchesManaged(candidate: RelayAgentSummary, managed: ManagedAgentReport): boolean {
|
|
153
|
-
if (managed.agentId && candidate.id === managed.agentId) return true;
|
|
154
|
-
|
|
155
|
-
const tags = candidate.tags || [];
|
|
156
|
-
const sameLabel = Boolean(managed.label && candidate.label === managed.label);
|
|
157
|
-
const sameProvider = tags.includes(managed.provider) || candidate.meta?.provider === managed.provider;
|
|
158
|
-
const sameCwd = candidate.meta?.cwd === managed.cwd;
|
|
159
|
-
const orchestratorOwned = tags.includes("dashboard-spawned");
|
|
160
|
-
return sameLabel && sameProvider && sameCwd && orchestratorOwned;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
53
|
function getManagedAgents(): ManagedAgentReport[] {
|
|
164
54
|
return managedAgents;
|
|
165
55
|
}
|
|
@@ -168,5 +58,5 @@ export function createControlHandler(
|
|
|
168
58
|
managedAgents = agents;
|
|
169
59
|
}
|
|
170
60
|
|
|
171
|
-
return {
|
|
61
|
+
return { handleCommand, getManagedAgents, setManagedAgents };
|
|
172
62
|
}
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { loadConfig, initConfigFile } from "./config";
|
|
|
3
3
|
import { createRelayClient } from "./relay";
|
|
4
4
|
import { createControlHandler } from "./control";
|
|
5
5
|
import { recoverExistingSessions, hasSession } from "./tmux";
|
|
6
|
+
import { startApiServer } from "./api";
|
|
6
7
|
|
|
7
8
|
const args = process.argv.slice(2);
|
|
8
9
|
|
|
@@ -25,6 +26,7 @@ Environment:
|
|
|
25
26
|
AGENT_RELAY_TOKEN Authentication token
|
|
26
27
|
AGENT_RELAY_ORCHESTRATOR_ID Orchestrator ID (default: hostname)
|
|
27
28
|
AGENT_RELAY_ORCHESTRATOR_BASE_DIR Base directory for agent CWDs
|
|
29
|
+
AGENT_RELAY_ORCHESTRATOR_API_PORT API server port (default: 4860)
|
|
28
30
|
AGENT_RELAY_ORCHESTRATOR_CONFIG Path to config file
|
|
29
31
|
|
|
30
32
|
Config file: ~/.agent-relay/orchestrator.json
|
|
@@ -40,6 +42,7 @@ const POLL_INTERVAL_MS = 3_000;
|
|
|
40
42
|
const REGISTER_RETRY_MS = 5_000;
|
|
41
43
|
let pollTimer: Timer | null = null;
|
|
42
44
|
let healthCheckTimer: Timer | null = null;
|
|
45
|
+
let apiServer: { stop(): void; url: string } | null = null;
|
|
43
46
|
|
|
44
47
|
async function startup(): Promise<void> {
|
|
45
48
|
console.error(`[orchestrator] Starting orchestrator: ${config.id}`);
|
|
@@ -48,8 +51,13 @@ async function startup(): Promise<void> {
|
|
|
48
51
|
console.error(`[orchestrator] providers: ${config.providers.join(", ")}`);
|
|
49
52
|
console.error(`[orchestrator] env keys: ${Object.keys(config.env).length}`);
|
|
50
53
|
|
|
54
|
+
// Start API server before registration so we can advertise the URL
|
|
55
|
+
apiServer = startApiServer(config);
|
|
56
|
+
console.error(`[orchestrator] apiUrl: ${apiServer.url}`);
|
|
57
|
+
|
|
51
58
|
// Register with relay. The server and orchestrator are often restarted
|
|
52
59
|
// together, so startup must tolerate the server not listening yet.
|
|
60
|
+
relay.setApiUrl(apiServer.url);
|
|
53
61
|
await registerUntilConnected();
|
|
54
62
|
relay.startHeartbeatLoop();
|
|
55
63
|
|
|
@@ -61,31 +69,26 @@ async function startup(): Promise<void> {
|
|
|
61
69
|
await relay.updateManagedAgents(recovered);
|
|
62
70
|
}
|
|
63
71
|
|
|
64
|
-
// Start polling for
|
|
72
|
+
// Start polling for command requests
|
|
65
73
|
startPolling();
|
|
66
74
|
|
|
67
75
|
// Periodic health check — remove dead sessions
|
|
68
76
|
healthCheckTimer = setInterval(healthCheck, 60_000);
|
|
69
77
|
|
|
70
|
-
console.error("[orchestrator] Ready. Polling for
|
|
78
|
+
console.error("[orchestrator] Ready. Polling for command requests...");
|
|
71
79
|
}
|
|
72
80
|
|
|
73
81
|
function startPolling(): void {
|
|
74
82
|
pollTimer = setInterval(async () => {
|
|
75
83
|
if (!relay.connected) return;
|
|
76
84
|
try {
|
|
77
|
-
const
|
|
78
|
-
if (
|
|
79
|
-
console.error(`[orchestrator] Received ${
|
|
85
|
+
const commands = await relay.pollCommands();
|
|
86
|
+
if (commands.length > 0) {
|
|
87
|
+
console.error(`[orchestrator] Received ${commands.length} command(s)`);
|
|
80
88
|
}
|
|
81
|
-
for (const
|
|
82
|
-
console.error(`[orchestrator] Handling: ${
|
|
83
|
-
|
|
84
|
-
if (handled) {
|
|
85
|
-
await relay.ackMessage(msg.id);
|
|
86
|
-
} else {
|
|
87
|
-
console.error(`[orchestrator] Control message ${msg.id} was not handled; leaving it unread for retry`);
|
|
88
|
-
}
|
|
89
|
+
for (const command of commands) {
|
|
90
|
+
console.error(`[orchestrator] Handling command: ${command.type} ${command.id}`);
|
|
91
|
+
await control.handleCommand(command);
|
|
89
92
|
}
|
|
90
93
|
} catch (err) {
|
|
91
94
|
console.error(`[orchestrator] Poll error: ${err}`);
|
|
@@ -126,6 +129,7 @@ async function shutdown(): Promise<void> {
|
|
|
126
129
|
console.error("[orchestrator] Shutting down...");
|
|
127
130
|
if (pollTimer) clearInterval(pollTimer);
|
|
128
131
|
if (healthCheckTimer) clearInterval(healthCheckTimer);
|
|
132
|
+
if (apiServer) apiServer.stop();
|
|
129
133
|
relay.stopHeartbeatLoop();
|
|
130
134
|
process.exit(0);
|
|
131
135
|
}
|
package/src/relay.ts
CHANGED
|
@@ -5,10 +5,9 @@ export interface RelayClient {
|
|
|
5
5
|
register(): Promise<void>;
|
|
6
6
|
heartbeat(): Promise<void>;
|
|
7
7
|
updateManagedAgents(agents: ManagedAgentReport[]): Promise<void>;
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
deleteAgent(agentId: string): Promise<boolean>;
|
|
8
|
+
pollCommands(): Promise<RelayCommand[]>;
|
|
9
|
+
updateCommand(commandId: string, status: string, result?: Record<string, unknown>, error?: string): Promise<boolean>;
|
|
10
|
+
setApiUrl(url: string): void;
|
|
12
11
|
startHeartbeatLoop(): void;
|
|
13
12
|
stopHeartbeatLoop(): void;
|
|
14
13
|
connected: boolean;
|
|
@@ -25,40 +24,12 @@ export interface ManagedAgentReport {
|
|
|
25
24
|
startedAt: number;
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
export interface
|
|
27
|
+
export interface RelayCommand {
|
|
29
28
|
id: string;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
meta?: { cwd?: string; provider?: string; [key: string]: unknown };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export interface OrchestratorControlPayload {
|
|
38
|
-
action: string;
|
|
39
|
-
provider?: string;
|
|
40
|
-
cwd?: string;
|
|
41
|
-
label?: string;
|
|
42
|
-
approvalMode?: string;
|
|
43
|
-
prompt?: string;
|
|
44
|
-
agentId?: string;
|
|
45
|
-
requestedBy?: string;
|
|
46
|
-
requestedAt?: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface ControlMessage {
|
|
50
|
-
id: number;
|
|
51
|
-
body: string;
|
|
52
|
-
kind: string;
|
|
53
|
-
payload: {
|
|
54
|
-
orchestratorControl?: OrchestratorControlPayload;
|
|
55
|
-
[key: string]: unknown;
|
|
56
|
-
};
|
|
57
|
-
meta: {
|
|
58
|
-
orchestratorControl?: OrchestratorControlPayload;
|
|
59
|
-
[key: string]: unknown;
|
|
60
|
-
};
|
|
61
|
-
createdAt: number;
|
|
29
|
+
type: string;
|
|
30
|
+
target: string;
|
|
31
|
+
params: Record<string, unknown>;
|
|
32
|
+
status: string;
|
|
62
33
|
}
|
|
63
34
|
|
|
64
35
|
const BACKOFF_SCHEDULE_MS = [
|
|
@@ -72,6 +43,7 @@ export function createRelayClient(config: OrchestratorConfig): RelayClient {
|
|
|
72
43
|
let connected = false;
|
|
73
44
|
let backoffIndex = 0;
|
|
74
45
|
let cursorFloor = 0;
|
|
46
|
+
let apiUrl: string | undefined;
|
|
75
47
|
|
|
76
48
|
function headers(): Record<string, string> {
|
|
77
49
|
const h: Record<string, string> = { "Content-Type": "application/json" };
|
|
@@ -95,6 +67,7 @@ export function createRelayClient(config: OrchestratorConfig): RelayClient {
|
|
|
95
67
|
hostname: config.hostname,
|
|
96
68
|
providers: config.providers,
|
|
97
69
|
baseDir: config.baseDir,
|
|
70
|
+
apiUrl,
|
|
98
71
|
envKeys: Object.keys(config.env),
|
|
99
72
|
version: VERSION,
|
|
100
73
|
protocolVersion: ORCHESTRATOR_PROTOCOL_VERSION,
|
|
@@ -127,7 +100,11 @@ export function createRelayClient(config: OrchestratorConfig): RelayClient {
|
|
|
127
100
|
|
|
128
101
|
async function heartbeat(): Promise<void> {
|
|
129
102
|
try {
|
|
130
|
-
const res = await apiCall("POST", `/orchestrators/${config.id}/heartbeat
|
|
103
|
+
const res = await apiCall("POST", `/orchestrators/${config.id}/heartbeat`, {
|
|
104
|
+
version: VERSION,
|
|
105
|
+
protocolVersion: ORCHESTRATOR_PROTOCOL_VERSION,
|
|
106
|
+
gitSha: GIT_SHA,
|
|
107
|
+
});
|
|
131
108
|
if (!res.ok) throw new Error(`heartbeat failed: ${res.status}`);
|
|
132
109
|
if (!connected) {
|
|
133
110
|
console.error("[orchestrator] Reconnected to relay");
|
|
@@ -161,45 +138,22 @@ export function createRelayClient(config: OrchestratorConfig): RelayClient {
|
|
|
161
138
|
await apiCall("PATCH", `/orchestrators/${config.id}/agents`, { agents });
|
|
162
139
|
}
|
|
163
140
|
|
|
164
|
-
async function
|
|
165
|
-
const
|
|
166
|
-
const res = await apiCall(
|
|
167
|
-
"GET",
|
|
168
|
-
`/messages?for=${agentId}&sinceId=${sinceId}&unread=true`,
|
|
169
|
-
);
|
|
141
|
+
async function pollCommands(): Promise<RelayCommand[]> {
|
|
142
|
+
const url = `/commands?target=${encodeURIComponent(agentId)}&status=pending&limit=50`;
|
|
143
|
+
const res = await apiCall("GET", url);
|
|
170
144
|
if (!res.ok) return [];
|
|
171
|
-
|
|
172
|
-
if (messages.length > 0) {
|
|
173
|
-
console.error(`[orchestrator] Polled ${messages.length} message(s) (sinceId=${sinceId}), ids: ${messages.map(m => m.id).join(",")}`);
|
|
174
|
-
}
|
|
175
|
-
const controlMessages = messages
|
|
176
|
-
.filter((m) => m.kind === "control" && (m.payload?.orchestratorControl != null || m.meta?.orchestratorControl != null))
|
|
177
|
-
.map((m) => ({
|
|
178
|
-
...m,
|
|
179
|
-
payload: {
|
|
180
|
-
...m.payload,
|
|
181
|
-
orchestratorControl: m.payload?.orchestratorControl ?? m.meta?.orchestratorControl,
|
|
182
|
-
},
|
|
183
|
-
}));
|
|
184
|
-
return controlMessages;
|
|
145
|
+
return await res.json() as RelayCommand[];
|
|
185
146
|
}
|
|
186
147
|
|
|
187
|
-
async function
|
|
188
|
-
const res = await apiCall("PATCH", `/
|
|
148
|
+
async function updateCommand(commandId: string, status: string, result?: Record<string, unknown>, error?: string): Promise<boolean> {
|
|
149
|
+
const res = await apiCall("PATCH", `/commands/${encodeURIComponent(commandId)}`, {
|
|
150
|
+
status,
|
|
151
|
+
...(result ? { result } : {}),
|
|
152
|
+
...(error ? { error } : {}),
|
|
153
|
+
});
|
|
189
154
|
return res.ok;
|
|
190
155
|
}
|
|
191
156
|
|
|
192
|
-
async function listAgents(): Promise<RelayAgentSummary[]> {
|
|
193
|
-
const res = await apiCall("GET", "/agents");
|
|
194
|
-
if (!res.ok) return [];
|
|
195
|
-
return await res.json() as RelayAgentSummary[];
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
async function deleteAgent(agentId: string): Promise<boolean> {
|
|
199
|
-
const res = await apiCall("DELETE", `/agents/${encodeURIComponent(agentId)}`);
|
|
200
|
-
return res.ok || res.status === 404;
|
|
201
|
-
}
|
|
202
|
-
|
|
203
157
|
function startHeartbeatLoop(): void {
|
|
204
158
|
if (heartbeatTimer) return;
|
|
205
159
|
heartbeatTimer = setInterval(heartbeat, config.heartbeatIntervalMs);
|
|
@@ -216,10 +170,9 @@ export function createRelayClient(config: OrchestratorConfig): RelayClient {
|
|
|
216
170
|
register,
|
|
217
171
|
heartbeat,
|
|
218
172
|
updateManagedAgents,
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
deleteAgent,
|
|
173
|
+
pollCommands,
|
|
174
|
+
updateCommand,
|
|
175
|
+
setApiUrl(url: string) { apiUrl = url; },
|
|
223
176
|
startHeartbeatLoop,
|
|
224
177
|
stopHeartbeatLoop,
|
|
225
178
|
get connected() { return connected; },
|
package/src/tmux.ts
CHANGED
|
@@ -8,12 +8,13 @@ export interface SpawnOptions {
|
|
|
8
8
|
provider: "claude" | "codex";
|
|
9
9
|
cwd: string;
|
|
10
10
|
label?: string;
|
|
11
|
+
agentId?: string;
|
|
11
12
|
approvalMode: string;
|
|
12
13
|
prompt?: string;
|
|
13
14
|
env?: Record<string, string>;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
|
|
17
|
+
interface TmuxSession {
|
|
17
18
|
name: string;
|
|
18
19
|
pid: number;
|
|
19
20
|
attached: boolean;
|
|
@@ -24,7 +25,7 @@ export function sessionName(config: OrchestratorConfig, provider: string, label:
|
|
|
24
25
|
return `${config.tmuxPrefix}-${provider}-${clean}`;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
async function listTmuxSessions(prefix: string): Promise<TmuxSession[]> {
|
|
28
29
|
try {
|
|
29
30
|
const proc = Bun.spawn(["tmux", "list-sessions", "-F", "#{session_name}\t#{pid}\t#{session_attached}"], {
|
|
30
31
|
stdout: "pipe",
|
|
@@ -51,34 +52,23 @@ export async function hasSession(name: string): Promise<boolean> {
|
|
|
51
52
|
return (await proc.exited) === 0;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
export
|
|
55
|
-
const
|
|
56
|
-
stdout: "ignore",
|
|
57
|
-
stderr: "ignore",
|
|
58
|
-
});
|
|
59
|
-
return (await proc.exited) === 0;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function buildClaudeCommand(opts: SpawnOptions, config: OrchestratorConfig): string[] {
|
|
63
|
-
const args = ["claude"];
|
|
64
|
-
if (opts.prompt) {
|
|
65
|
-
args.push(opts.prompt);
|
|
66
|
-
} else {
|
|
67
|
-
args.push("You are a headless relay agent spawned by the orchestrator. Await and respond to incoming relay messages.");
|
|
68
|
-
}
|
|
69
|
-
args.push("--dangerously-skip-permissions");
|
|
70
|
-
return args;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function buildCodexCommand(opts: SpawnOptions, config: OrchestratorConfig): string[] {
|
|
74
|
-
const repoLauncher = resolve(import.meta.dir, "../../codex/bin/agent-relay-codex.ts");
|
|
55
|
+
export function buildRunnerCommand(opts: SpawnOptions, config: OrchestratorConfig): string[] {
|
|
56
|
+
const repoLauncher = resolve(import.meta.dir, "../../runner/src/index.ts");
|
|
75
57
|
const launcher = existsSync(repoLauncher)
|
|
76
|
-
? ["bun", "run", repoLauncher,
|
|
77
|
-
: [
|
|
78
|
-
|
|
58
|
+
? ["bun", "run", repoLauncher, opts.provider]
|
|
59
|
+
: [`${opts.provider}-relay`];
|
|
60
|
+
const args = [
|
|
79
61
|
...launcher,
|
|
80
62
|
"--headless",
|
|
63
|
+
"--cwd", opts.cwd,
|
|
81
64
|
"--relay-url", config.relayUrl,
|
|
65
|
+
"--approval", opts.approvalMode || "guarded",
|
|
66
|
+
];
|
|
67
|
+
if (opts.label) args.push("--label", opts.label);
|
|
68
|
+
if (opts.agentId) args.push("--agent-id", opts.agentId);
|
|
69
|
+
if (opts.prompt) args.push("--prompt", opts.prompt);
|
|
70
|
+
return [
|
|
71
|
+
...args,
|
|
82
72
|
];
|
|
83
73
|
}
|
|
84
74
|
|
|
@@ -101,7 +91,7 @@ function buildEnv(opts: SpawnOptions, config: OrchestratorConfig): Record<string
|
|
|
101
91
|
AGENT_RELAY_URL: config.relayUrl,
|
|
102
92
|
AGENT_RELAY_APPROVAL: opts.approvalMode || "guarded",
|
|
103
93
|
AGENT_RELAY_TAGS: ["headless", "dashboard-spawned", config.hostname].join(","),
|
|
104
|
-
|
|
94
|
+
AGENT_RELAY_HEADLESS: "1",
|
|
105
95
|
...(opts.label ? { AGENT_RELAY_LABEL: opts.label } : {}),
|
|
106
96
|
...(config.token ? { AGENT_RELAY_TOKEN: config.token } : {}),
|
|
107
97
|
};
|
|
@@ -112,7 +102,8 @@ export async function spawnAgent(
|
|
|
112
102
|
config: OrchestratorConfig,
|
|
113
103
|
): Promise<ManagedAgentReport> {
|
|
114
104
|
const label = opts.label || `${opts.provider}-${Date.now()}`;
|
|
115
|
-
const
|
|
105
|
+
const agentId = opts.agentId || managedAgentId(config, opts.provider, label);
|
|
106
|
+
const spawnOpts: SpawnOptions = { ...opts, label, agentId };
|
|
116
107
|
const name = sessionName(config, opts.provider, label);
|
|
117
108
|
|
|
118
109
|
if (!existsSync(opts.cwd)) {
|
|
@@ -122,9 +113,7 @@ export async function spawnAgent(
|
|
|
122
113
|
throw new Error(`cwd must be within base directory: ${config.baseDir}`);
|
|
123
114
|
}
|
|
124
115
|
|
|
125
|
-
const command = spawnOpts
|
|
126
|
-
? buildClaudeCommand(spawnOpts, config)
|
|
127
|
-
: buildCodexCommand(spawnOpts, config);
|
|
116
|
+
const command = buildRunnerCommand(spawnOpts, config);
|
|
128
117
|
|
|
129
118
|
const env = buildEnv(spawnOpts, config);
|
|
130
119
|
|
|
@@ -160,22 +149,11 @@ export async function spawnAgent(
|
|
|
160
149
|
throw new Error(`tmux spawn failed (exit ${exitCode}): ${stderr.trim()}`);
|
|
161
150
|
}
|
|
162
151
|
|
|
163
|
-
// Wait for Claude to show the trust prompt, then accept it
|
|
164
|
-
if (spawnOpts.provider === "claude") {
|
|
165
|
-
await new Promise((resolve) => setTimeout(resolve, 4000));
|
|
166
|
-
const accept = Bun.spawn(["tmux", "send-keys", "-t", name, "Enter"], {
|
|
167
|
-
stdout: "ignore",
|
|
168
|
-
stderr: "ignore",
|
|
169
|
-
});
|
|
170
|
-
await accept.exited;
|
|
171
|
-
console.error(`[orchestrator] Sent trust-accept Enter to ${name}`);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
152
|
// Get the PID from tmux
|
|
175
153
|
const pid = await getSessionPid(name);
|
|
176
154
|
|
|
177
155
|
return {
|
|
178
|
-
agentId
|
|
156
|
+
agentId,
|
|
179
157
|
provider: spawnOpts.provider,
|
|
180
158
|
tmuxSession: name,
|
|
181
159
|
cwd: spawnOpts.cwd,
|
|
@@ -186,6 +164,12 @@ export async function spawnAgent(
|
|
|
186
164
|
};
|
|
187
165
|
}
|
|
188
166
|
|
|
167
|
+
function managedAgentId(config: OrchestratorConfig, provider: string, label: string): string {
|
|
168
|
+
const cleanHost = config.hostname.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
|
|
169
|
+
const cleanLabel = label.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
|
|
170
|
+
return `${cleanHost}-${provider}-${cleanLabel}-${crypto.randomUUID().slice(0, 8)}`;
|
|
171
|
+
}
|
|
172
|
+
|
|
189
173
|
async function getSessionPid(name: string): Promise<number | null> {
|
|
190
174
|
try {
|
|
191
175
|
const proc = Bun.spawn(["tmux", "display-message", "-p", "-t", name, "#{pane_pid}"], {
|
|
@@ -219,7 +203,7 @@ export async function recoverExistingSessions(
|
|
|
219
203
|
const cwd = await getSessionCwd(session.name);
|
|
220
204
|
|
|
221
205
|
managed.push({
|
|
222
|
-
agentId:
|
|
206
|
+
agentId: managedAgentId(config, provider, label || session.name),
|
|
223
207
|
provider,
|
|
224
208
|
tmuxSession: session.name,
|
|
225
209
|
cwd: cwd || config.baseDir,
|
package/src/version.ts
CHANGED
|
@@ -6,5 +6,5 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
6
6
|
const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8")) as { version?: string };
|
|
7
7
|
|
|
8
8
|
export const VERSION = pkg.version || "0.0.0";
|
|
9
|
-
export const ORCHESTRATOR_PROTOCOL_VERSION =
|
|
9
|
+
export const ORCHESTRATOR_PROTOCOL_VERSION = 3;
|
|
10
10
|
export const GIT_SHA = process.env.AGENT_RELAY_GIT_SHA || process.env.GIT_SHA || undefined;
|