agent-relay-orchestrator 0.9.0 → 0.10.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-orchestrator",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
5
  "type": "module",
6
6
  "bin": {
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 { ControlMessage, ManagedAgentReport, RelayAgentSummary, RelayClient } from "./relay";
3
- import { hasSession, killSession, spawnAgent, type SpawnOptions } from "./tmux";
2
+ import type { ManagedAgentReport, RelayClient, RelayCommand } from "./relay";
3
+ import { spawnAgent, type SpawnOptions } from "./tmux";
4
4
 
5
- export interface ControlHandler {
6
- handle(message: ControlMessage): Promise<boolean>;
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 handleRestart(ctrl: Record<string, any>): Promise<boolean> {
62
- const targetId = ctrl.agentId;
63
- if (!targetId) {
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
- const newAgent = await spawnAgent({
93
- provider: agent.provider,
94
- cwd: agent.cwd,
95
- label: agent.label,
96
- approvalMode: agent.approvalMode,
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 (err) {
102
- console.error(`[orchestrator] Restart spawn failed: ${err}`);
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 { handle, getManagedAgents, setManagedAgents };
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 control messages
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 control messages...");
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 messages = await relay.pollControlMessages();
78
- if (messages.length > 0) {
79
- console.error(`[orchestrator] Received ${messages.length} control message(s)`);
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 msg of messages) {
82
- console.error(`[orchestrator] Handling: ${JSON.stringify(msg.payload?.orchestratorControl)}`);
83
- const handled = await control.handle(msg);
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
- pollControlMessages(since?: number): Promise<ControlMessage[]>;
9
- ackMessage(messageId: number): Promise<boolean>;
10
- listAgents(): Promise<RelayAgentSummary[]>;
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 RelayAgentSummary {
27
+ export interface RelayCommand {
29
28
  id: string;
30
- label?: string;
31
- status?: string;
32
- ready?: boolean;
33
- tags?: string[];
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 pollControlMessages(since?: number): Promise<ControlMessage[]> {
165
- const sinceId = since ?? cursorFloor;
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
- const messages = await res.json() as ControlMessage[];
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 ackMessage(messageId: number): Promise<boolean> {
188
- const res = await apiCall("PATCH", `/messages/${messageId}`, { readBy: agentId });
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
- pollControlMessages,
220
- ackMessage,
221
- listAgents,
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
- export interface TmuxSession {
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
- export async function listTmuxSessions(prefix: string): Promise<TmuxSession[]> {
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 async function killSession(name: string): Promise<boolean> {
55
- const proc = Bun.spawn(["tmux", "kill-session", "-t", name], {
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, "start"]
77
- : ["codex-relay"];
78
- return [
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
- ...(opts.provider === "codex" ? { AGENT_RELAY_CODEX_HEADLESS: "1" } : {}),
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 spawnOpts: SpawnOptions = { ...opts, label };
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.provider === "claude"
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: "", // will be filled when the agent self-registers on the relay
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 = 2;
9
+ export const ORCHESTRATOR_PROTOCOL_VERSION = 3;
10
10
  export const GIT_SHA = process.env.AGENT_RELAY_GIT_SHA || process.env.GIT_SHA || undefined;