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 +8 -0
- package/package.json +25 -0
- package/src/config.ts +66 -0
- package/src/control.ts +172 -0
- package/src/index.ts +139 -0
- package/src/relay.ts +227 -0
- package/src/tmux.ts +255 -0
- package/src/version.ts +10 -0
package/README.md
ADDED
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;
|