agent-relay-orchestrator 0.9.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/README.md ADDED
@@ -0,0 +1,8 @@
1
+ # agent-relay-orchestrator
2
+
3
+ Host daemon for Agent Relay. It registers host capabilities, receives lifecycle control messages, and spawns managed Claude/Codex agents in tmux sessions.
4
+
5
+ ```bash
6
+ agent-relay-orchestrator init
7
+ agent-relay-orchestrator
8
+ ```
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "agent-relay-orchestrator",
3
+ "version": "0.9.0",
4
+ "description": "Agent Relay orchestrator — manages agent lifecycle across hosts",
5
+ "type": "module",
6
+ "bin": {
7
+ "agent-relay-orchestrator": "./src/index.ts"
8
+ },
9
+ "files": [
10
+ "src/**/*.ts",
11
+ "!src/**/*.test.ts",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "start": "bun run src/index.ts",
16
+ "test": "bun test"
17
+ },
18
+ "dependencies": {},
19
+ "devDependencies": {
20
+ "@types/bun": "latest"
21
+ },
22
+ "peerDependencies": {
23
+ "typescript": "^5"
24
+ }
25
+ }
package/src/config.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
2
+ import { homedir, hostname as osHostname } from "node:os";
3
+ import { join, dirname } from "node:path";
4
+
5
+ export interface OrchestratorConfig {
6
+ id: string;
7
+ hostname: string;
8
+ relayUrl: string;
9
+ token?: string;
10
+ providers: ("claude" | "codex")[];
11
+ baseDir: string;
12
+ env: Record<string, string>;
13
+ heartbeatIntervalMs: number;
14
+ tmuxPrefix: string;
15
+ }
16
+
17
+ const DEFAULT_CONFIG_PATH = join(homedir(), ".agent-relay", "orchestrator.json");
18
+
19
+ interface RawConfig {
20
+ id?: string;
21
+ hostname?: string;
22
+ relayUrl?: string;
23
+ token?: string;
24
+ providers?: string[];
25
+ baseDir?: string;
26
+ env?: Record<string, string>;
27
+ heartbeatIntervalMs?: number;
28
+ tmuxPrefix?: string;
29
+ }
30
+
31
+ export function loadConfig(path?: string): OrchestratorConfig {
32
+ const configPath = path || process.env.AGENT_RELAY_ORCHESTRATOR_CONFIG || DEFAULT_CONFIG_PATH;
33
+
34
+ let raw: RawConfig = {};
35
+ if (existsSync(configPath)) {
36
+ raw = JSON.parse(readFileSync(configPath, "utf8"));
37
+ }
38
+
39
+ const id = raw.id || process.env.AGENT_RELAY_ORCHESTRATOR_ID || osHostname().replace(/\./g, "-");
40
+ const hostname = raw.hostname || process.env.AGENT_RELAY_ORCHESTRATOR_HOSTNAME || osHostname();
41
+ const relayUrl = raw.relayUrl || process.env.AGENT_RELAY_URL || "http://localhost:4850";
42
+ const token = raw.token || process.env.AGENT_RELAY_TOKEN || undefined;
43
+ const providers = (raw.providers || process.env.AGENT_RELAY_ORCHESTRATOR_PROVIDERS?.split(",") || ["claude", "codex"]) as ("claude" | "codex")[];
44
+ const baseDir = raw.baseDir || process.env.AGENT_RELAY_ORCHESTRATOR_BASE_DIR || join(homedir(), "projects");
45
+ const env = raw.env || {};
46
+ const heartbeatIntervalMs = raw.heartbeatIntervalMs || 30_000;
47
+ const tmuxPrefix = raw.tmuxPrefix || "ar";
48
+
49
+ return { id, hostname, relayUrl, token, providers, baseDir, env, heartbeatIntervalMs, tmuxPrefix };
50
+ }
51
+
52
+ export function initConfigFile(config: Partial<RawConfig>): string {
53
+ const configPath = DEFAULT_CONFIG_PATH;
54
+ mkdirSync(dirname(configPath), { recursive: true });
55
+ const defaults: RawConfig = {
56
+ id: osHostname().replace(/\./g, "-"),
57
+ hostname: osHostname(),
58
+ relayUrl: "http://localhost:4850",
59
+ providers: ["claude", "codex"],
60
+ baseDir: join(homedir(), "projects"),
61
+ env: {},
62
+ };
63
+ const merged = { ...defaults, ...config };
64
+ writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n");
65
+ return configPath;
66
+ }
package/src/control.ts ADDED
@@ -0,0 +1,172 @@
1
+ import type { OrchestratorConfig } from "./config";
2
+ import type { ControlMessage, ManagedAgentReport, RelayAgentSummary, RelayClient } from "./relay";
3
+ import { hasSession, killSession, spawnAgent, type SpawnOptions } from "./tmux";
4
+
5
+ export interface ControlHandler {
6
+ handle(message: ControlMessage): Promise<boolean>;
7
+ getManagedAgents(): ManagedAgentReport[];
8
+ setManagedAgents(agents: ManagedAgentReport[]): void;
9
+ }
10
+
11
+ export function createControlHandler(
12
+ config: OrchestratorConfig,
13
+ relay: RelayClient,
14
+ ): ControlHandler {
15
+ let managedAgents: ManagedAgentReport[] = [];
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
+ async function handleSpawn(ctrl: Record<string, any>): Promise<boolean> {
42
+ const opts: SpawnOptions = {
43
+ provider: ctrl.provider || "claude",
44
+ cwd: ctrl.cwd || config.baseDir,
45
+ label: ctrl.label,
46
+ approvalMode: ctrl.approvalMode || "guarded",
47
+ prompt: ctrl.prompt,
48
+ };
49
+
50
+ try {
51
+ const agent = await spawnAgent(opts, config);
52
+ managedAgents.push(agent);
53
+ console.error(`[orchestrator] Spawned ${opts.provider} agent: ${agent.tmuxSession}`);
54
+ return true;
55
+ } catch (err) {
56
+ console.error(`[orchestrator] Spawn failed: ${err}`);
57
+ return false;
58
+ }
59
+ }
60
+
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
91
+ 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}`);
100
+ return true;
101
+ } catch (err) {
102
+ console.error(`[orchestrator] Restart spawn failed: ${err}`);
103
+ return false;
104
+ }
105
+ }
106
+
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
+ function getManagedAgents(): ManagedAgentReport[] {
164
+ return managedAgents;
165
+ }
166
+
167
+ function setManagedAgents(agents: ManagedAgentReport[]): void {
168
+ managedAgents = agents;
169
+ }
170
+
171
+ return { handle, getManagedAgents, setManagedAgents };
172
+ }
package/src/index.ts ADDED
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env bun
2
+ import { loadConfig, initConfigFile } from "./config";
3
+ import { createRelayClient } from "./relay";
4
+ import { createControlHandler } from "./control";
5
+ import { recoverExistingSessions, hasSession } from "./tmux";
6
+
7
+ const args = process.argv.slice(2);
8
+
9
+ if (args[0] === "init") {
10
+ const path = initConfigFile({});
11
+ console.error(`[orchestrator] Config initialized at ${path}`);
12
+ console.error("[orchestrator] Edit it to set your relay URL, base directory, and secrets.");
13
+ process.exit(0);
14
+ }
15
+
16
+ if (args[0] === "--help" || args[0] === "-h") {
17
+ console.log(`agent-relay-orchestrator — manage agent lifecycle across hosts
18
+
19
+ Usage:
20
+ agent-relay-orchestrator Start the orchestrator daemon
21
+ agent-relay-orchestrator init Create default config file
22
+
23
+ Environment:
24
+ AGENT_RELAY_URL Relay server URL (default: http://localhost:4850)
25
+ AGENT_RELAY_TOKEN Authentication token
26
+ AGENT_RELAY_ORCHESTRATOR_ID Orchestrator ID (default: hostname)
27
+ AGENT_RELAY_ORCHESTRATOR_BASE_DIR Base directory for agent CWDs
28
+ AGENT_RELAY_ORCHESTRATOR_CONFIG Path to config file
29
+
30
+ Config file: ~/.agent-relay/orchestrator.json
31
+ `);
32
+ process.exit(0);
33
+ }
34
+
35
+ const config = loadConfig();
36
+ const relay = createRelayClient(config);
37
+ const control = createControlHandler(config, relay);
38
+
39
+ const POLL_INTERVAL_MS = 3_000;
40
+ const REGISTER_RETRY_MS = 5_000;
41
+ let pollTimer: Timer | null = null;
42
+ let healthCheckTimer: Timer | null = null;
43
+
44
+ async function startup(): Promise<void> {
45
+ console.error(`[orchestrator] Starting orchestrator: ${config.id}`);
46
+ console.error(`[orchestrator] relay: ${config.relayUrl}`);
47
+ console.error(`[orchestrator] baseDir: ${config.baseDir}`);
48
+ console.error(`[orchestrator] providers: ${config.providers.join(", ")}`);
49
+ console.error(`[orchestrator] env keys: ${Object.keys(config.env).length}`);
50
+
51
+ // Register with relay. The server and orchestrator are often restarted
52
+ // together, so startup must tolerate the server not listening yet.
53
+ await registerUntilConnected();
54
+ relay.startHeartbeatLoop();
55
+
56
+ // Recover existing tmux sessions
57
+ const recovered = await recoverExistingSessions(config);
58
+ if (recovered.length > 0) {
59
+ console.error(`[orchestrator] Recovered ${recovered.length} existing session(s)`);
60
+ control.setManagedAgents(recovered);
61
+ await relay.updateManagedAgents(recovered);
62
+ }
63
+
64
+ // Start polling for control messages
65
+ startPolling();
66
+
67
+ // Periodic health check — remove dead sessions
68
+ healthCheckTimer = setInterval(healthCheck, 60_000);
69
+
70
+ console.error("[orchestrator] Ready. Polling for control messages...");
71
+ }
72
+
73
+ function startPolling(): void {
74
+ pollTimer = setInterval(async () => {
75
+ if (!relay.connected) return;
76
+ try {
77
+ const messages = await relay.pollControlMessages();
78
+ if (messages.length > 0) {
79
+ console.error(`[orchestrator] Received ${messages.length} control message(s)`);
80
+ }
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
+ }
90
+ } catch (err) {
91
+ console.error(`[orchestrator] Poll error: ${err}`);
92
+ }
93
+ }, POLL_INTERVAL_MS);
94
+ }
95
+
96
+ async function registerUntilConnected(): Promise<void> {
97
+ for (;;) {
98
+ try {
99
+ await relay.register();
100
+ return;
101
+ } catch (err) {
102
+ console.error(`[orchestrator] Register failed: ${err}`);
103
+ console.error(`[orchestrator] Retrying registration in ${Math.round(REGISTER_RETRY_MS / 1000)}s...`);
104
+ await new Promise((resolve) => setTimeout(resolve, REGISTER_RETRY_MS));
105
+ }
106
+ }
107
+ }
108
+
109
+ async function healthCheck(): Promise<void> {
110
+ const agents = control.getManagedAgents();
111
+ let changed = false;
112
+ for (const agent of agents) {
113
+ const alive = await hasSession(agent.tmuxSession);
114
+ if (!alive) {
115
+ console.error(`[orchestrator] Session dead: ${agent.tmuxSession} — removing from managed list`);
116
+ control.setManagedAgents(agents.filter((a) => a.tmuxSession !== agent.tmuxSession));
117
+ changed = true;
118
+ }
119
+ }
120
+ if (changed) {
121
+ await relay.updateManagedAgents(control.getManagedAgents());
122
+ }
123
+ }
124
+
125
+ async function shutdown(): Promise<void> {
126
+ console.error("[orchestrator] Shutting down...");
127
+ if (pollTimer) clearInterval(pollTimer);
128
+ if (healthCheckTimer) clearInterval(healthCheckTimer);
129
+ relay.stopHeartbeatLoop();
130
+ process.exit(0);
131
+ }
132
+
133
+ process.on("SIGINT", shutdown);
134
+ process.on("SIGTERM", shutdown);
135
+
136
+ startup().catch((err) => {
137
+ console.error(`[orchestrator] Fatal: ${err}`);
138
+ process.exit(1);
139
+ });
package/src/relay.ts ADDED
@@ -0,0 +1,227 @@
1
+ import type { OrchestratorConfig } from "./config";
2
+ import { GIT_SHA, ORCHESTRATOR_PROTOCOL_VERSION, VERSION } from "./version";
3
+
4
+ export interface RelayClient {
5
+ register(): Promise<void>;
6
+ heartbeat(): Promise<void>;
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>;
12
+ startHeartbeatLoop(): void;
13
+ stopHeartbeatLoop(): void;
14
+ connected: boolean;
15
+ }
16
+
17
+ export interface ManagedAgentReport {
18
+ agentId: string;
19
+ provider: "claude" | "codex";
20
+ tmuxSession: string;
21
+ cwd: string;
22
+ label?: string;
23
+ approvalMode: string;
24
+ pid?: number;
25
+ startedAt: number;
26
+ }
27
+
28
+ export interface RelayAgentSummary {
29
+ 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;
62
+ }
63
+
64
+ const BACKOFF_SCHEDULE_MS = [
65
+ 30_000, 60_000, 120_000, 240_000, 480_000, 960_000,
66
+ ];
67
+ const MAX_BACKOFF_MS = 3_600_000; // 1 hour
68
+
69
+ export function createRelayClient(config: OrchestratorConfig): RelayClient {
70
+ const agentId = `orchestrator-${config.id}`;
71
+ let heartbeatTimer: Timer | null = null;
72
+ let connected = false;
73
+ let backoffIndex = 0;
74
+ let cursorFloor = 0;
75
+
76
+ function headers(): Record<string, string> {
77
+ const h: Record<string, string> = { "Content-Type": "application/json" };
78
+ if (config.token) h["X-Agent-Relay-Token"] = config.token;
79
+ return h;
80
+ }
81
+
82
+ async function apiCall(method: string, path: string, body?: unknown): Promise<Response> {
83
+ const url = `${config.relayUrl}/api${path}`;
84
+ const res = await fetch(url, {
85
+ method,
86
+ headers: headers(),
87
+ body: body ? JSON.stringify(body) : undefined,
88
+ });
89
+ return res;
90
+ }
91
+
92
+ async function register(): Promise<void> {
93
+ const res = await apiCall("POST", "/orchestrators", {
94
+ id: config.id,
95
+ hostname: config.hostname,
96
+ providers: config.providers,
97
+ baseDir: config.baseDir,
98
+ envKeys: Object.keys(config.env),
99
+ version: VERSION,
100
+ protocolVersion: ORCHESTRATOR_PROTOCOL_VERSION,
101
+ gitSha: GIT_SHA,
102
+ meta: {
103
+ pid: process.pid,
104
+ tmuxPrefix: config.tmuxPrefix,
105
+ startedAt: Date.now(),
106
+ version: VERSION,
107
+ protocolVersion: ORCHESTRATOR_PROTOCOL_VERSION,
108
+ gitSha: GIT_SHA,
109
+ },
110
+ });
111
+ if (!res.ok) {
112
+ const err = await res.text();
113
+ throw new Error(`Failed to register orchestrator: ${res.status} ${err}`);
114
+ }
115
+ connected = true;
116
+ backoffIndex = 0;
117
+
118
+ // Bootstrap message cursor
119
+ const cursor = await apiCall("GET", "/messages/cursor");
120
+ if (cursor.ok) {
121
+ const data = await cursor.json() as { latestId: number };
122
+ cursorFloor = data.latestId;
123
+ }
124
+
125
+ console.error(`[orchestrator] Registered as ${config.id} (agent: ${agentId})`);
126
+ }
127
+
128
+ async function heartbeat(): Promise<void> {
129
+ try {
130
+ const res = await apiCall("POST", `/orchestrators/${config.id}/heartbeat`);
131
+ if (!res.ok) throw new Error(`heartbeat failed: ${res.status}`);
132
+ if (!connected) {
133
+ console.error("[orchestrator] Reconnected to relay");
134
+ connected = true;
135
+ backoffIndex = 0;
136
+ }
137
+ } catch (err) {
138
+ if (connected) {
139
+ console.error(`[orchestrator] Lost connection to relay: ${err}`);
140
+ connected = false;
141
+ }
142
+ await reconnect();
143
+ }
144
+ }
145
+
146
+ async function reconnect(): Promise<void> {
147
+ const delay = backoffIndex < BACKOFF_SCHEDULE_MS.length
148
+ ? BACKOFF_SCHEDULE_MS[backoffIndex]!
149
+ : MAX_BACKOFF_MS;
150
+ backoffIndex = Math.min(backoffIndex + 1, BACKOFF_SCHEDULE_MS.length);
151
+ console.error(`[orchestrator] Reconnecting in ${Math.round(delay / 1000)}s...`);
152
+ await new Promise((resolve) => setTimeout(resolve, delay));
153
+ try {
154
+ await register();
155
+ } catch {
156
+ // Will retry on next heartbeat
157
+ }
158
+ }
159
+
160
+ async function updateManagedAgents(agents: ManagedAgentReport[]): Promise<void> {
161
+ await apiCall("PATCH", `/orchestrators/${config.id}/agents`, { agents });
162
+ }
163
+
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
+ );
170
+ 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;
185
+ }
186
+
187
+ async function ackMessage(messageId: number): Promise<boolean> {
188
+ const res = await apiCall("PATCH", `/messages/${messageId}`, { readBy: agentId });
189
+ return res.ok;
190
+ }
191
+
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
+ function startHeartbeatLoop(): void {
204
+ if (heartbeatTimer) return;
205
+ heartbeatTimer = setInterval(heartbeat, config.heartbeatIntervalMs);
206
+ }
207
+
208
+ function stopHeartbeatLoop(): void {
209
+ if (heartbeatTimer) {
210
+ clearInterval(heartbeatTimer);
211
+ heartbeatTimer = null;
212
+ }
213
+ }
214
+
215
+ return {
216
+ register,
217
+ heartbeat,
218
+ updateManagedAgents,
219
+ pollControlMessages,
220
+ ackMessage,
221
+ listAgents,
222
+ deleteAgent,
223
+ startHeartbeatLoop,
224
+ stopHeartbeatLoop,
225
+ get connected() { return connected; },
226
+ };
227
+ }
package/src/tmux.ts ADDED
@@ -0,0 +1,255 @@
1
+ import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import type { OrchestratorConfig } from "./config";
5
+ import type { ManagedAgentReport } from "./relay";
6
+
7
+ export interface SpawnOptions {
8
+ provider: "claude" | "codex";
9
+ cwd: string;
10
+ label?: string;
11
+ approvalMode: string;
12
+ prompt?: string;
13
+ env?: Record<string, string>;
14
+ }
15
+
16
+ export interface TmuxSession {
17
+ name: string;
18
+ pid: number;
19
+ attached: boolean;
20
+ }
21
+
22
+ export function sessionName(config: OrchestratorConfig, provider: string, label: string): string {
23
+ const clean = label.replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
24
+ return `${config.tmuxPrefix}-${provider}-${clean}`;
25
+ }
26
+
27
+ export async function listTmuxSessions(prefix: string): Promise<TmuxSession[]> {
28
+ try {
29
+ const proc = Bun.spawn(["tmux", "list-sessions", "-F", "#{session_name}\t#{pid}\t#{session_attached}"], {
30
+ stdout: "pipe",
31
+ stderr: "pipe",
32
+ });
33
+ const out = await new Response(proc.stdout).text();
34
+ const code = await proc.exited;
35
+ if (code !== 0) return [];
36
+
37
+ return out.trim().split("\n").filter(Boolean).map((line) => {
38
+ const [name, pid, attached] = line.split("\t");
39
+ return { name: name!, pid: parseInt(pid!, 10), attached: attached === "1" };
40
+ }).filter((s) => s.name.startsWith(`${prefix}-`));
41
+ } catch {
42
+ return [];
43
+ }
44
+ }
45
+
46
+ export async function hasSession(name: string): Promise<boolean> {
47
+ const proc = Bun.spawn(["tmux", "has-session", "-t", name], {
48
+ stdout: "ignore",
49
+ stderr: "ignore",
50
+ });
51
+ return (await proc.exited) === 0;
52
+ }
53
+
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");
75
+ const launcher = existsSync(repoLauncher)
76
+ ? ["bun", "run", repoLauncher, "start"]
77
+ : ["codex-relay"];
78
+ return [
79
+ ...launcher,
80
+ "--headless",
81
+ "--relay-url", config.relayUrl,
82
+ ];
83
+ }
84
+
85
+ function buildEnv(opts: SpawnOptions, config: OrchestratorConfig): Record<string, string> {
86
+ const currentPath = process.env.PATH || "";
87
+ const extraPaths = [
88
+ join(homedir(), ".local", "bin"),
89
+ join(homedir(), ".bun", "bin"),
90
+ join(homedir(), ".npm-global", "bin"),
91
+ ];
92
+ const fullPath = [...extraPaths, ...currentPath.split(":").filter(Boolean)]
93
+ .filter((v, i, a) => a.indexOf(v) === i)
94
+ .join(":");
95
+
96
+ return {
97
+ ...process.env as Record<string, string>,
98
+ ...config.env,
99
+ ...(opts.env || {}),
100
+ PATH: fullPath,
101
+ AGENT_RELAY_URL: config.relayUrl,
102
+ AGENT_RELAY_APPROVAL: opts.approvalMode || "guarded",
103
+ AGENT_RELAY_TAGS: ["headless", "dashboard-spawned", config.hostname].join(","),
104
+ ...(opts.provider === "codex" ? { AGENT_RELAY_CODEX_HEADLESS: "1" } : {}),
105
+ ...(opts.label ? { AGENT_RELAY_LABEL: opts.label } : {}),
106
+ ...(config.token ? { AGENT_RELAY_TOKEN: config.token } : {}),
107
+ };
108
+ }
109
+
110
+ export async function spawnAgent(
111
+ opts: SpawnOptions,
112
+ config: OrchestratorConfig,
113
+ ): Promise<ManagedAgentReport> {
114
+ const label = opts.label || `${opts.provider}-${Date.now()}`;
115
+ const spawnOpts: SpawnOptions = { ...opts, label };
116
+ const name = sessionName(config, opts.provider, label);
117
+
118
+ if (!existsSync(opts.cwd)) {
119
+ throw new Error(`cwd does not exist: ${opts.cwd}`);
120
+ }
121
+ if (!opts.cwd.startsWith(config.baseDir)) {
122
+ throw new Error(`cwd must be within base directory: ${config.baseDir}`);
123
+ }
124
+
125
+ const command = spawnOpts.provider === "claude"
126
+ ? buildClaudeCommand(spawnOpts, config)
127
+ : buildCodexCommand(spawnOpts, config);
128
+
129
+ const env = buildEnv(spawnOpts, config);
130
+
131
+ // Build the env export string for tmux
132
+ const envExports = Object.entries(env)
133
+ .filter(([k]) => k === "PATH" || k.startsWith("AGENT_RELAY_") || k.startsWith("CLAUDE_") || k.startsWith("ANTHROPIC_") || k === "GITHUB_TOKEN" || k === "NPM_TOKEN" || k === "HOME" || k === "USER" || k === "SHELL")
134
+ .map(([k, v]) => `${k}=${shellEscape(v)}`)
135
+ .join(" ");
136
+
137
+ const fullCommand = envExports
138
+ ? `env ${envExports} ${command.map(shellEscape).join(" ")}`
139
+ : command.map(shellEscape).join(" ");
140
+
141
+ const tmuxArgs = [
142
+ "tmux", "new-session", "-d",
143
+ "-s", name,
144
+ "-c", opts.cwd,
145
+ fullCommand,
146
+ ];
147
+
148
+ console.error(`[orchestrator] Spawning ${opts.provider} agent: ${name}`);
149
+ console.error(`[orchestrator] cwd: ${opts.cwd}`);
150
+ console.error(`[orchestrator] command: ${fullCommand}`);
151
+
152
+ const proc = Bun.spawn(tmuxArgs, {
153
+ stdout: "pipe",
154
+ stderr: "pipe",
155
+ env,
156
+ });
157
+ const exitCode = await proc.exited;
158
+ if (exitCode !== 0) {
159
+ const stderr = await new Response(proc.stderr).text();
160
+ throw new Error(`tmux spawn failed (exit ${exitCode}): ${stderr.trim()}`);
161
+ }
162
+
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
+ // Get the PID from tmux
175
+ const pid = await getSessionPid(name);
176
+
177
+ return {
178
+ agentId: "", // will be filled when the agent self-registers on the relay
179
+ provider: spawnOpts.provider,
180
+ tmuxSession: name,
181
+ cwd: spawnOpts.cwd,
182
+ label,
183
+ approvalMode: spawnOpts.approvalMode || "guarded",
184
+ pid: pid ?? undefined,
185
+ startedAt: Date.now(),
186
+ };
187
+ }
188
+
189
+ async function getSessionPid(name: string): Promise<number | null> {
190
+ try {
191
+ const proc = Bun.spawn(["tmux", "display-message", "-p", "-t", name, "#{pane_pid}"], {
192
+ stdout: "pipe",
193
+ stderr: "pipe",
194
+ });
195
+ const out = await new Response(proc.stdout).text();
196
+ const code = await proc.exited;
197
+ if (code !== 0) return null;
198
+ const pid = parseInt(out.trim(), 10);
199
+ return isNaN(pid) ? null : pid;
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ export async function recoverExistingSessions(
206
+ config: OrchestratorConfig,
207
+ ): Promise<ManagedAgentReport[]> {
208
+ const sessions = await listTmuxSessions(config.tmuxPrefix);
209
+ const managed: ManagedAgentReport[] = [];
210
+
211
+ for (const session of sessions) {
212
+ // Parse provider and label from session name: "ar-claude-backend" → provider=claude, label=backend
213
+ const parts = session.name.slice(config.tmuxPrefix.length + 1).split("-");
214
+ const provider = parts[0] as "claude" | "codex";
215
+ if (provider !== "claude" && provider !== "codex") continue;
216
+ const label = parts.slice(1).join("-") || undefined;
217
+
218
+ // Get cwd from tmux
219
+ const cwd = await getSessionCwd(session.name);
220
+
221
+ managed.push({
222
+ agentId: "",
223
+ provider,
224
+ tmuxSession: session.name,
225
+ cwd: cwd || config.baseDir,
226
+ label,
227
+ approvalMode: "guarded",
228
+ pid: session.pid || undefined,
229
+ startedAt: Date.now(),
230
+ });
231
+
232
+ console.error(`[orchestrator] Recovered existing session: ${session.name} (${provider}, pid ${session.pid})`);
233
+ }
234
+
235
+ return managed;
236
+ }
237
+
238
+ async function getSessionCwd(name: string): Promise<string | null> {
239
+ try {
240
+ const proc = Bun.spawn(["tmux", "display-message", "-p", "-t", name, "#{pane_current_path}"], {
241
+ stdout: "pipe",
242
+ stderr: "pipe",
243
+ });
244
+ const out = await new Response(proc.stdout).text();
245
+ const code = await proc.exited;
246
+ return code === 0 ? out.trim() || null : null;
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+
252
+ function shellEscape(s: string): string {
253
+ if (/^[a-zA-Z0-9._\-/:=@]+$/.test(s)) return s;
254
+ return `'${s.replace(/'/g, "'\\''")}'`;
255
+ }
package/src/version.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const __dirname = dirname(fileURLToPath(import.meta.url));
6
+ const pkg = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf8")) as { version?: string };
7
+
8
+ export const VERSION = pkg.version || "0.0.0";
9
+ export const ORCHESTRATOR_PROTOCOL_VERSION = 2;
10
+ export const GIT_SHA = process.env.AGENT_RELAY_GIT_SHA || process.env.GIT_SHA || undefined;