agent-relay-orchestrator 0.10.7 → 0.10.9
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 +2 -2
- package/src/api.ts +67 -5
- package/src/control.ts +32 -5
- package/src/relay.ts +2 -0
- package/src/tmux.ts +66 -7
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-relay-orchestrator",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.9",
|
|
4
4
|
"description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"agent-relay-orchestrator": "
|
|
7
|
+
"agent-relay-orchestrator": "src/index.ts"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"src/**/*.ts",
|
package/src/api.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readdirSync, statSync } from "node:fs";
|
|
2
|
-
import { dirname, join, resolve } from "node:path";
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
3
|
import type { OrchestratorConfig } from "./config";
|
|
4
|
+
import { captureSession, listTmuxSessions } from "./tmux";
|
|
5
|
+
import { VERSION } from "./version";
|
|
4
6
|
|
|
5
7
|
interface DirectoryEntry {
|
|
6
8
|
name: string;
|
|
@@ -15,9 +17,11 @@ interface DirectoryListing {
|
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
function listDirectories(requestedPath: string | undefined, baseDir: string): DirectoryListing {
|
|
18
|
-
const
|
|
20
|
+
const base = resolve(baseDir);
|
|
21
|
+
const target = resolve(requestedPath || base);
|
|
22
|
+
const rel = relative(base, target);
|
|
19
23
|
|
|
20
|
-
if (
|
|
24
|
+
if (rel && (rel.startsWith("..") || rel.startsWith("/"))) {
|
|
21
25
|
throw new Error(`Path must be within baseDir: ${baseDir}`);
|
|
22
26
|
}
|
|
23
27
|
|
|
@@ -31,10 +35,11 @@ function listDirectories(requestedPath: string | undefined, baseDir: string): Di
|
|
|
31
35
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
32
36
|
|
|
33
37
|
const parent = dirname(target);
|
|
38
|
+
const parentRel = relative(base, parent);
|
|
34
39
|
return {
|
|
35
40
|
path: target,
|
|
36
|
-
parent:
|
|
37
|
-
baseDir,
|
|
41
|
+
parent: parentRel && !parentRel.startsWith("..") && !parentRel.startsWith("/") && parent !== target ? parent : undefined,
|
|
42
|
+
baseDir: base,
|
|
38
43
|
entries,
|
|
39
44
|
};
|
|
40
45
|
}
|
|
@@ -50,6 +55,24 @@ function error(message: string, status = 400): Response {
|
|
|
50
55
|
return json({ error: message }, status);
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
function authorized(req: Request, config: OrchestratorConfig): boolean {
|
|
59
|
+
if (!config.token) return true;
|
|
60
|
+
return req.headers.get("x-agent-relay-token") === config.token;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function commandVersion(command: string): Promise<string | undefined> {
|
|
64
|
+
try {
|
|
65
|
+
const proc = Bun.spawn(["bash", "-lc", `command -v ${command} >/dev/null && ${command} --version | head -n 1`], {
|
|
66
|
+
stdout: "pipe",
|
|
67
|
+
stderr: "ignore",
|
|
68
|
+
});
|
|
69
|
+
const out = await new Response(proc.stdout).text();
|
|
70
|
+
return (await proc.exited) === 0 ? out.trim() || undefined : undefined;
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
53
76
|
export function startApiServer(config: OrchestratorConfig): { stop(): void; url: string } {
|
|
54
77
|
const server = Bun.serve({
|
|
55
78
|
port: config.apiPort,
|
|
@@ -76,6 +99,45 @@ export function startApiServer(config: OrchestratorConfig): { stop(): void; url:
|
|
|
76
99
|
}
|
|
77
100
|
}
|
|
78
101
|
|
|
102
|
+
if (req.method === "GET" && url.pathname === "/api/providers") {
|
|
103
|
+
return (async () => {
|
|
104
|
+
const providers = await Promise.all(config.providers.map(async (provider) => {
|
|
105
|
+
const version = await commandVersion(provider);
|
|
106
|
+
return { name: provider, available: Boolean(version), version, runnerVersion: VERSION };
|
|
107
|
+
}));
|
|
108
|
+
return json({ providers });
|
|
109
|
+
})();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (req.method === "GET" && url.pathname === "/api/sessions") {
|
|
113
|
+
return (async () => {
|
|
114
|
+
const sessions = await listTmuxSessions(config.tmuxPrefix);
|
|
115
|
+
return json({ sessions });
|
|
116
|
+
})();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (req.method === "GET" && url.pathname === "/api/version") {
|
|
120
|
+
return json({
|
|
121
|
+
orchestrator: VERSION,
|
|
122
|
+
runner: VERSION,
|
|
123
|
+
adapters: Object.fromEntries(config.providers.map((provider) => [provider, VERSION])),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const logMatch = url.pathname.match(/^\/api\/logs\/([^/]+)$/);
|
|
128
|
+
if (req.method === "GET" && logMatch) {
|
|
129
|
+
if (!authorized(req, config)) return error("unauthorized", 401);
|
|
130
|
+
return (async () => {
|
|
131
|
+
try {
|
|
132
|
+
const session = decodeURIComponent(logMatch[1]!);
|
|
133
|
+
const lines = Number(url.searchParams.get("lines") || "100");
|
|
134
|
+
return json(await captureSession(session, config, Number.isFinite(lines) ? lines : 100));
|
|
135
|
+
} catch (e) {
|
|
136
|
+
return error((e as Error).message, 400);
|
|
137
|
+
}
|
|
138
|
+
})();
|
|
139
|
+
}
|
|
140
|
+
|
|
79
141
|
if (req.method === "GET" && url.pathname === "/api/health") {
|
|
80
142
|
return json({ ok: true, id: config.id, hostname: config.hostname });
|
|
81
143
|
}
|
package/src/control.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { OrchestratorConfig } from "./config";
|
|
2
2
|
import type { ManagedAgentReport, RelayClient, RelayCommand } from "./relay";
|
|
3
|
-
import { spawnAgent, type SpawnOptions } from "./tmux";
|
|
3
|
+
import { spawnAgent, stopSession, type SpawnOptions } from "./tmux";
|
|
4
4
|
|
|
5
5
|
interface ControlHandler {
|
|
6
6
|
handleCommand(command: RelayCommand): Promise<boolean>;
|
|
@@ -21,6 +21,11 @@ export function createControlHandler(
|
|
|
21
21
|
label: ctrl.label,
|
|
22
22
|
approvalMode: ctrl.approvalMode || "guarded",
|
|
23
23
|
prompt: ctrl.prompt,
|
|
24
|
+
tags: Array.isArray(ctrl.tags) ? ctrl.tags.filter((item): item is string => typeof item === "string") : undefined,
|
|
25
|
+
capabilities: Array.isArray(ctrl.capabilities) ? ctrl.capabilities.filter((item): item is string => typeof item === "string") : undefined,
|
|
26
|
+
providerArgs: Array.isArray(ctrl.providerArgs) ? ctrl.providerArgs.filter((item): item is string => typeof item === "string") : undefined,
|
|
27
|
+
policyName: typeof ctrl.policyName === "string" ? ctrl.policyName : undefined,
|
|
28
|
+
spawnRequestId: typeof ctrl.spawnRequestId === "string" ? ctrl.spawnRequestId : undefined,
|
|
24
29
|
};
|
|
25
30
|
|
|
26
31
|
try {
|
|
@@ -34,14 +39,36 @@ export function createControlHandler(
|
|
|
34
39
|
}
|
|
35
40
|
}
|
|
36
41
|
|
|
42
|
+
async function handleShutdown(ctrl: Record<string, any>, restart = false): Promise<Record<string, unknown>> {
|
|
43
|
+
const session = typeof ctrl.tmuxSession === "string"
|
|
44
|
+
? ctrl.tmuxSession
|
|
45
|
+
: managedAgents.find((agent) => agent.agentId === ctrl.agentId || (ctrl.policyName && agent.policyName === ctrl.policyName))?.tmuxSession;
|
|
46
|
+
if (!session) return { stopped: false, wasRunning: false };
|
|
47
|
+
const result = await stopSession(session, config, typeof ctrl.reason === "string" ? ctrl.reason : restart ? "restart" : "shutdown", ctrl.graceful !== false);
|
|
48
|
+
managedAgents = managedAgents.filter((agent) => agent.tmuxSession !== session);
|
|
49
|
+
return {
|
|
50
|
+
...result,
|
|
51
|
+
restart,
|
|
52
|
+
policyName: ctrl.policyName,
|
|
53
|
+
spawnRequestId: ctrl.spawnRequestId,
|
|
54
|
+
tmuxSession: session,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
37
58
|
async function handleCommand(command: RelayCommand): Promise<boolean> {
|
|
38
59
|
await relay.updateCommand(command.id, "accepted");
|
|
39
60
|
await relay.updateCommand(command.id, "running");
|
|
40
61
|
try {
|
|
41
|
-
if (command.type
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
62
|
+
if (command.type === "agent.spawn") {
|
|
63
|
+
const handled = await handleSpawn(command.params);
|
|
64
|
+
if (!handled) throw new Error("spawn failed");
|
|
65
|
+
await relay.updateCommand(command.id, "succeeded", { managedAgents });
|
|
66
|
+
} else if (command.type === "agent.shutdown" || command.type === "agent.restart") {
|
|
67
|
+
const result = await handleShutdown(command.params, command.type === "agent.restart");
|
|
68
|
+
await relay.updateCommand(command.id, "succeeded", result);
|
|
69
|
+
} else {
|
|
70
|
+
throw new Error(`unsupported orchestrator command: ${command.type}`);
|
|
71
|
+
}
|
|
45
72
|
await relay.updateManagedAgents(managedAgents);
|
|
46
73
|
return true;
|
|
47
74
|
} catch (error) {
|
package/src/relay.ts
CHANGED
package/src/tmux.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { join, resolve } from "node:path";
|
|
3
|
+
import { isAbsolute, join, relative, resolve } from "node:path";
|
|
4
4
|
import type { OrchestratorConfig } from "./config";
|
|
5
5
|
import type { ManagedAgentReport } from "./relay";
|
|
6
6
|
|
|
@@ -12,20 +12,33 @@ export interface SpawnOptions {
|
|
|
12
12
|
approvalMode: string;
|
|
13
13
|
prompt?: string;
|
|
14
14
|
env?: Record<string, string>;
|
|
15
|
+
tags?: string[];
|
|
16
|
+
capabilities?: string[];
|
|
17
|
+
providerArgs?: string[];
|
|
18
|
+
policyName?: string;
|
|
19
|
+
spawnRequestId?: string;
|
|
20
|
+
tmuxSession?: string;
|
|
15
21
|
}
|
|
16
22
|
|
|
17
|
-
interface TmuxSession {
|
|
23
|
+
export interface TmuxSession {
|
|
18
24
|
name: string;
|
|
19
25
|
pid: number;
|
|
20
26
|
attached: boolean;
|
|
21
27
|
}
|
|
22
28
|
|
|
29
|
+
export function isWithinBaseDir(path: string, baseDir: string): boolean {
|
|
30
|
+
const base = resolve(baseDir);
|
|
31
|
+
const target = resolve(path);
|
|
32
|
+
const rel = relative(base, target);
|
|
33
|
+
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
export function sessionName(config: OrchestratorConfig, provider: string, label: string): string {
|
|
24
37
|
const clean = label.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
|
|
25
38
|
return `${config.tmuxPrefix}-${provider}-${clean}`;
|
|
26
39
|
}
|
|
27
40
|
|
|
28
|
-
async function listTmuxSessions(prefix: string): Promise<TmuxSession[]> {
|
|
41
|
+
export async function listTmuxSessions(prefix: string): Promise<TmuxSession[]> {
|
|
29
42
|
try {
|
|
30
43
|
const proc = Bun.spawn(["tmux", "list-sessions", "-F", "#{session_name}\t#{pid}\t#{session_attached}"], {
|
|
31
44
|
stdout: "pipe",
|
|
@@ -56,7 +69,7 @@ export function buildRunnerCommand(opts: SpawnOptions, config: OrchestratorConfi
|
|
|
56
69
|
const repoLauncher = resolve(import.meta.dir, "../../runner/src/index.ts");
|
|
57
70
|
const launcher = existsSync(repoLauncher)
|
|
58
71
|
? ["bun", "run", repoLauncher, opts.provider]
|
|
59
|
-
: [`${opts.provider}-relay
|
|
72
|
+
: [`${opts.provider}-relay`, opts.provider];
|
|
60
73
|
const args = [
|
|
61
74
|
...launcher,
|
|
62
75
|
"--headless",
|
|
@@ -67,6 +80,9 @@ export function buildRunnerCommand(opts: SpawnOptions, config: OrchestratorConfi
|
|
|
67
80
|
if (opts.label) args.push("--label", opts.label);
|
|
68
81
|
if (opts.agentId) args.push("--agent-id", opts.agentId);
|
|
69
82
|
if (opts.prompt) args.push("--prompt", opts.prompt);
|
|
83
|
+
if (opts.tags?.length) args.push("--tags", opts.tags.join(","));
|
|
84
|
+
if (opts.capabilities?.length) args.push("--caps", opts.capabilities.join(","));
|
|
85
|
+
if (opts.providerArgs?.length) args.push("--", ...opts.providerArgs);
|
|
70
86
|
return [
|
|
71
87
|
...args,
|
|
72
88
|
];
|
|
@@ -90,9 +106,14 @@ function buildEnv(opts: SpawnOptions, config: OrchestratorConfig): Record<string
|
|
|
90
106
|
PATH: fullPath,
|
|
91
107
|
AGENT_RELAY_URL: config.relayUrl,
|
|
92
108
|
AGENT_RELAY_APPROVAL: opts.approvalMode || "guarded",
|
|
93
|
-
AGENT_RELAY_TAGS: ["headless", "dashboard-spawned", config.hostname].join(","),
|
|
109
|
+
AGENT_RELAY_TAGS: [...new Set(["headless", "dashboard-spawned", config.hostname, ...(opts.tags ?? [])])].join(","),
|
|
110
|
+
AGENT_RELAY_CAPS: [...new Set(opts.capabilities ?? [])].join(","),
|
|
111
|
+
AGENT_RELAY_CAPABILITIES: [...new Set(opts.capabilities ?? [])].join(","),
|
|
94
112
|
AGENT_RELAY_HEADLESS: "1",
|
|
95
113
|
...(opts.label ? { AGENT_RELAY_LABEL: opts.label } : {}),
|
|
114
|
+
...(opts.policyName ? { AGENT_RELAY_POLICY: opts.policyName } : {}),
|
|
115
|
+
...(opts.spawnRequestId ? { AGENT_RELAY_SPAWN_REQUEST_ID: opts.spawnRequestId } : {}),
|
|
116
|
+
...(opts.tmuxSession ? { AGENT_RELAY_TMUX_SESSION: opts.tmuxSession } : {}),
|
|
96
117
|
...(config.token ? { AGENT_RELAY_TOKEN: config.token } : {}),
|
|
97
118
|
};
|
|
98
119
|
}
|
|
@@ -103,13 +124,13 @@ export async function spawnAgent(
|
|
|
103
124
|
): Promise<ManagedAgentReport> {
|
|
104
125
|
const label = opts.label || `${opts.provider}-${Date.now()}`;
|
|
105
126
|
const agentId = opts.agentId || managedAgentId(config, opts.provider, label);
|
|
106
|
-
const spawnOpts: SpawnOptions = { ...opts, label, agentId };
|
|
107
127
|
const name = sessionName(config, opts.provider, label);
|
|
128
|
+
const spawnOpts: SpawnOptions = { ...opts, label, agentId, tmuxSession: name };
|
|
108
129
|
|
|
109
130
|
if (!existsSync(opts.cwd)) {
|
|
110
131
|
throw new Error(`cwd does not exist: ${opts.cwd}`);
|
|
111
132
|
}
|
|
112
|
-
if (!opts.cwd
|
|
133
|
+
if (!isWithinBaseDir(opts.cwd, config.baseDir)) {
|
|
113
134
|
throw new Error(`cwd must be within base directory: ${config.baseDir}`);
|
|
114
135
|
}
|
|
115
136
|
|
|
@@ -159,11 +180,49 @@ export async function spawnAgent(
|
|
|
159
180
|
cwd: spawnOpts.cwd,
|
|
160
181
|
label,
|
|
161
182
|
approvalMode: spawnOpts.approvalMode || "guarded",
|
|
183
|
+
policyName: spawnOpts.policyName,
|
|
184
|
+
spawnRequestId: spawnOpts.spawnRequestId,
|
|
162
185
|
pid: pid ?? undefined,
|
|
163
186
|
startedAt: Date.now(),
|
|
164
187
|
};
|
|
165
188
|
}
|
|
166
189
|
|
|
190
|
+
export async function stopSession(name: string, config: OrchestratorConfig, reason: string, graceful = true): Promise<{ stopped: boolean; wasRunning: boolean }> {
|
|
191
|
+
if (!name.startsWith(`${config.tmuxPrefix}-`)) throw new Error("session is not managed by this orchestrator");
|
|
192
|
+
const wasRunning = await hasSession(name);
|
|
193
|
+
if (!wasRunning) return { stopped: false, wasRunning: false };
|
|
194
|
+
if (graceful) {
|
|
195
|
+
await Bun.spawn(["tmux", "send-keys", "-t", name, "C-c"], { stdout: "ignore", stderr: "ignore" }).exited;
|
|
196
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
197
|
+
}
|
|
198
|
+
if (await hasSession(name)) {
|
|
199
|
+
await Bun.spawn(["tmux", "kill-session", "-t", name], {
|
|
200
|
+
stdout: "ignore",
|
|
201
|
+
stderr: "ignore",
|
|
202
|
+
env: { ...process.env, AGENT_RELAY_STOP_REASON: reason },
|
|
203
|
+
}).exited;
|
|
204
|
+
}
|
|
205
|
+
return { stopped: true, wasRunning };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function captureSession(name: string, config: OrchestratorConfig, lines = 100): Promise<{ session: string; lines: string[]; running: boolean }> {
|
|
209
|
+
if (!name.startsWith(`${config.tmuxPrefix}-`)) throw new Error("session is not managed by this orchestrator");
|
|
210
|
+
const running = await hasSession(name);
|
|
211
|
+
if (!running) return { session: name, lines: [], running };
|
|
212
|
+
const safeLines = Math.min(Math.max(lines, 1), 1000);
|
|
213
|
+
const proc = Bun.spawn(["tmux", "capture-pane", "-p", "-t", name, "-S", `-${safeLines}`], {
|
|
214
|
+
stdout: "pipe",
|
|
215
|
+
stderr: "pipe",
|
|
216
|
+
});
|
|
217
|
+
const out = await new Response(proc.stdout).text();
|
|
218
|
+
const code = await proc.exited;
|
|
219
|
+
if (code !== 0) {
|
|
220
|
+
const err = await new Response(proc.stderr).text();
|
|
221
|
+
throw new Error(err.trim() || "tmux capture failed");
|
|
222
|
+
}
|
|
223
|
+
return { session: name, lines: out.split("\n").filter(Boolean), running };
|
|
224
|
+
}
|
|
225
|
+
|
|
167
226
|
function managedAgentId(config: OrchestratorConfig, provider: string, label: string): string {
|
|
168
227
|
const cleanHost = config.hostname.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
|
|
169
228
|
const cleanLabel = label.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
|