agent-relay-orchestrator 0.10.6 → 0.10.8

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.10.6",
3
+ "version": "0.10.8",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
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 target = resolve(requestedPath || baseDir);
20
+ const base = resolve(baseDir);
21
+ const target = resolve(requestedPath || base);
22
+ const rel = relative(base, target);
19
23
 
20
- if (!target.startsWith(baseDir) && target !== baseDir) {
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: parent.startsWith(baseDir) && parent !== target ? parent : undefined,
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 !== "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 });
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
@@ -20,6 +20,8 @@ export interface ManagedAgentReport {
20
20
  cwd: string;
21
21
  label?: string;
22
22
  approvalMode: string;
23
+ policyName?: string;
24
+ spawnRequestId?: string;
23
25
  pid?: number;
24
26
  startedAt: number;
25
27
  }
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",
@@ -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.startsWith(config.baseDir)) {
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();