Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .claude/tools/amplihack/remote/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def remote_cli():
@click.option("--no-reuse", is_flag=True, help="Always provision fresh VM")
@click.option("--timeout", default=120, type=int, help="Max execution time in minutes")
@click.option("--region", default=None, help="Azure region")
@click.option("--port", default=None, type=int, help="Reuse existing bastion tunnel on this local port")
@click.argument("azlin_args", nargs=-1, type=click.UNPROCESSED)
def remote_execute(
command: str,
Expand All @@ -64,6 +65,7 @@ def remote_execute(
no_reuse: bool,
timeout: int,
region: str | None,
port: int | None,
azlin_args: tuple,
):
"""Execute amplihack command on remote Azure VM.
Expand Down Expand Up @@ -103,6 +105,7 @@ def remote_execute(
no_reuse=no_reuse,
keep_vm=keep_vm,
azlin_extra_args=list(azlin_args) if azlin_args else None,
tunnel_port=port,
)

# Execute with progress reporting
Expand Down Expand Up @@ -184,7 +187,7 @@ def execute_remote_workflow(

# Step 4: Transfer context
click.echo("\n[4/7] Transferring context...")
executor = Executor(vm, timeout_minutes=timeout)
executor = Executor(vm, timeout_minutes=timeout, tunnel_port=vm_options.tunnel_port)
executor.transfer_context(archive_path)
click.echo(" \u2713 Context transferred")

Expand Down Expand Up @@ -403,7 +406,8 @@ def cmd_list(status: str | None, output_json: bool):
help="VM size tier (s=1, m=2, l=4, xl=8 sessions) [default: l]",
)
@click.option("--region", default=None, help="Azure region")
def cmd_start(prompts: tuple, command: str, max_turns: int, size: str, region: str | None):
@click.option("--port", default=None, type=int, help="Reuse existing bastion tunnel on this local port")
def cmd_start(prompts: tuple, command: str, max_turns: int, size: str, region: str | None, port: int | None):
"""Start one or more detached remote sessions.

Usage: amplihack remote start [options] "<prompt1>" "<prompt2>" ...
Expand Down Expand Up @@ -501,7 +505,7 @@ def cmd_start(prompts: tuple, command: str, max_turns: int, size: str, region: s

# Step 3: Transfer context
click.echo(" → Transferring context...")
executor = Executor(vm)
executor = Executor(vm, tunnel_port=port)
executor.transfer_context(archive_path)
click.echo(" ✓ Context transferred")

Expand Down
28 changes: 18 additions & 10 deletions .claude/tools/amplihack/remote/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,18 @@ class Executor:
and output capture.
"""

def __init__(self, vm: VM, timeout_minutes: int = 120):
def __init__(self, vm: VM, timeout_minutes: int = 120, tunnel_port: int | None = None):
"""Initialize executor.

Args:
vm: Target VM for execution
timeout_minutes: Maximum execution time (default: 120)
tunnel_port: Local port of existing bastion tunnel to reuse (default: None)
"""
self.vm = vm
self.timeout_seconds = timeout_minutes * 60
self.remote_workspace = "~/workspace"
self.tunnel_port = tunnel_port

@staticmethod
def _encode_b64(text: str) -> str:
Expand All @@ -59,6 +61,12 @@ def _encode_b64(text: str) -> str:
"""
return base64.b64encode(text.encode()).decode()

def _azlin_port_args(self) -> list[str]:
"""Return --port flag args if tunnel_port is set, else empty list."""
if self.tunnel_port is not None:
return ["--port", str(self.tunnel_port)]
return []

def transfer_context(self, archive_path: Path) -> bool:
"""Transfer context archive to remote VM.

Expand Down Expand Up @@ -93,7 +101,7 @@ def transfer_context(self, archive_path: Path) -> bool:
start_time = time.time()

subprocess.run(
["azlin", "cp", archive_name, remote_path],
["azlin", "cp", *self._azlin_port_args(), archive_name, remote_path],
cwd=str(archive_dir), # Run from archive directory
capture_output=True,
text=True,
Expand Down Expand Up @@ -230,7 +238,7 @@ def execute_remote(

try:
result = subprocess.run(
["azlin", "connect", self.vm.name, setup_and_run],
["azlin", "connect", *self._azlin_port_args(), self.vm.name, setup_and_run],
capture_output=True,
text=True,
timeout=self.timeout_seconds,
Expand Down Expand Up @@ -259,7 +267,7 @@ def execute_remote(
# Try to terminate remote process
try:
subprocess.run(
["azlin", "connect", self.vm.name, "pkill -TERM -f amplihack"],
["azlin", "connect", *self._azlin_port_args(), self.vm.name, "pkill -TERM -f amplihack"],
timeout=30,
capture_output=True,
)
Expand Down Expand Up @@ -314,7 +322,7 @@ def retrieve_logs(self, local_dest: Path) -> bool:
try:
# Create archive
subprocess.run(
["azlin", "connect", self.vm.name, create_archive],
["azlin", "connect", *self._azlin_port_args(), self.vm.name, create_archive],
capture_output=True,
text=True,
timeout=60,
Expand All @@ -324,7 +332,7 @@ def retrieve_logs(self, local_dest: Path) -> bool:
# Download archive (azlin cp requires relative paths)
local_archive = local_dest / "logs.tar.gz"
subprocess.run(
["azlin", "cp", f"{self.vm.name}:~/logs.tar.gz", "logs.tar.gz"],
["azlin", "cp", *self._azlin_port_args(), f"{self.vm.name}:~/logs.tar.gz", "logs.tar.gz"],
cwd=str(local_dest), # Run from destination directory
capture_output=True,
text=True,
Expand Down Expand Up @@ -377,7 +385,7 @@ def retrieve_git_state(self, local_dest: Path) -> bool:
try:
# Create bundle
subprocess.run(
["azlin", "connect", self.vm.name, create_bundle],
["azlin", "connect", *self._azlin_port_args(), self.vm.name, create_bundle],
capture_output=True,
text=True,
timeout=300,
Expand All @@ -387,7 +395,7 @@ def retrieve_git_state(self, local_dest: Path) -> bool:
# Download bundle (azlin cp requires relative paths)
local_bundle = local_dest / "results.bundle"
subprocess.run(
["azlin", "cp", f"{self.vm.name}:~/results.bundle", "results.bundle"],
["azlin", "cp", *self._azlin_port_args(), f"{self.vm.name}:~/results.bundle", "results.bundle"],
cwd=str(local_dest), # Run from destination directory
capture_output=True,
text=True,
Expand Down Expand Up @@ -536,7 +544,7 @@ def execute_remote_tmux(

try:
result = subprocess.run(
["azlin", "connect", self.vm.name, setup_and_run],
["azlin", "connect", *self._azlin_port_args(), self.vm.name, setup_and_run],
capture_output=True,
text=True,
timeout=600, # 10 minutes for setup
Expand Down Expand Up @@ -588,7 +596,7 @@ def check_tmux_status(self, session_id: str) -> str:

try:
result = subprocess.run(
["azlin", "connect", self.vm.name, check_command],
["azlin", "connect", *self._azlin_port_args(), self.vm.name, check_command],
capture_output=True,
text=True,
timeout=30,
Expand Down
1 change: 1 addition & 0 deletions .claude/tools/amplihack/remote/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class VMOptions:
no_reuse: bool = False
keep_vm: bool = False
azlin_extra_args: list | None = None # Pass-through for any azlin parameters
tunnel_port: int | None = None # Reuse existing bastion tunnel on this local port


class Orchestrator:
Expand Down
8 changes: 6 additions & 2 deletions .claude/tools/amplihack/remote/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,21 +447,25 @@ def check_session_status(self, session_id: str) -> SessionStatus:

return session.status

def _execute_ssh_command(self, vm_name: str, command: str) -> str:
def _execute_ssh_command(
self, vm_name: str, command: str, tunnel_port: int | None = None
) -> str:
"""Execute command on remote VM via SSH.

Uses azlin for SSH connectivity.

Args:
vm_name: Name of the VM
command: Command to execute
tunnel_port: Local port of existing bastion tunnel to reuse (default: None)

Returns:
Command output as string
"""
port_args = ["--port", str(tunnel_port)] if tunnel_port is not None else []
try:
result = subprocess.run(
["azlin", "ssh", vm_name, "--", command],
["azlin", "ssh", *port_args, vm_name, "--", command],
capture_output=True,
text=True,
timeout=30,
Expand Down
10 changes: 7 additions & 3 deletions amplifier-bundle/tools/amplihack/remote/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ def remote_cli():
@click.option("--no-reuse", is_flag=True, help="Always provision fresh VM")
@click.option("--timeout", default=120, type=int, help="Max execution time in minutes")
@click.option("--region", default=None, help="Azure region")
@click.option("--port", default=None, type=int, help="Reuse existing bastion tunnel on this local port")
@click.argument("azlin_args", nargs=-1, type=click.UNPROCESSED)
def remote_execute(
command: str,
Expand All @@ -64,6 +65,7 @@ def remote_execute(
no_reuse: bool,
timeout: int,
region: str | None,
port: int | None,
azlin_args: tuple,
):
"""Execute amplihack command on remote Azure VM.
Expand Down Expand Up @@ -103,6 +105,7 @@ def remote_execute(
no_reuse=no_reuse,
keep_vm=keep_vm,
azlin_extra_args=list(azlin_args) if azlin_args else None,
tunnel_port=port,
)

# Execute with progress reporting
Expand Down Expand Up @@ -184,7 +187,7 @@ def execute_remote_workflow(

# Step 4: Transfer context
click.echo("\n[4/7] Transferring context...")
executor = Executor(vm, timeout_minutes=timeout)
executor = Executor(vm, timeout_minutes=timeout, tunnel_port=vm_options.tunnel_port)
executor.transfer_context(archive_path)
click.echo(" \u2713 Context transferred")

Expand Down Expand Up @@ -403,7 +406,8 @@ def cmd_list(status: str | None, output_json: bool):
help="VM size tier (s=1, m=2, l=4, xl=8 sessions) [default: l]",
)
@click.option("--region", default=None, help="Azure region")
def cmd_start(prompts: tuple, command: str, max_turns: int, size: str, region: str | None):
@click.option("--port", default=None, type=int, help="Reuse existing bastion tunnel on this local port")
def cmd_start(prompts: tuple, command: str, max_turns: int, size: str, region: str | None, port: int | None):
"""Start one or more detached remote sessions.

Usage: amplihack remote start [options] "<prompt1>" "<prompt2>" ...
Expand Down Expand Up @@ -501,7 +505,7 @@ def cmd_start(prompts: tuple, command: str, max_turns: int, size: str, region: s

# Step 3: Transfer context
click.echo(" → Transferring context...")
executor = Executor(vm)
executor = Executor(vm, tunnel_port=port)
executor.transfer_context(archive_path)
click.echo(" ✓ Context transferred")

Expand Down
28 changes: 18 additions & 10 deletions amplifier-bundle/tools/amplihack/remote/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,18 @@ class Executor:
and output capture.
"""

def __init__(self, vm: VM, timeout_minutes: int = 120):
def __init__(self, vm: VM, timeout_minutes: int = 120, tunnel_port: int | None = None):
"""Initialize executor.

Args:
vm: Target VM for execution
timeout_minutes: Maximum execution time (default: 120)
tunnel_port: Local port of existing bastion tunnel to reuse (default: None)
"""
self.vm = vm
self.timeout_seconds = timeout_minutes * 60
self.remote_workspace = "~/workspace"
self.tunnel_port = tunnel_port

@staticmethod
def _encode_b64(text: str) -> str:
Expand All @@ -59,6 +61,12 @@ def _encode_b64(text: str) -> str:
"""
return base64.b64encode(text.encode()).decode()

def _azlin_port_args(self) -> list[str]:
"""Return --port flag args if tunnel_port is set, else empty list."""
if self.tunnel_port is not None:
return ["--port", str(self.tunnel_port)]
return []

def transfer_context(self, archive_path: Path) -> bool:
"""Transfer context archive to remote VM.

Expand Down Expand Up @@ -93,7 +101,7 @@ def transfer_context(self, archive_path: Path) -> bool:
start_time = time.time()

subprocess.run(
["azlin", "cp", archive_name, remote_path],
["azlin", "cp", *self._azlin_port_args(), archive_name, remote_path],
cwd=str(archive_dir), # Run from archive directory
capture_output=True,
text=True,
Expand Down Expand Up @@ -230,7 +238,7 @@ def execute_remote(

try:
result = subprocess.run(
["azlin", "connect", self.vm.name, setup_and_run],
["azlin", "connect", *self._azlin_port_args(), self.vm.name, setup_and_run],
capture_output=True,
text=True,
timeout=self.timeout_seconds,
Expand Down Expand Up @@ -259,7 +267,7 @@ def execute_remote(
# Try to terminate remote process
try:
subprocess.run(
["azlin", "connect", self.vm.name, "pkill -TERM -f amplihack"],
["azlin", "connect", *self._azlin_port_args(), self.vm.name, "pkill -TERM -f amplihack"],
timeout=30,
capture_output=True,
)
Expand Down Expand Up @@ -314,7 +322,7 @@ def retrieve_logs(self, local_dest: Path) -> bool:
try:
# Create archive
subprocess.run(
["azlin", "connect", self.vm.name, create_archive],
["azlin", "connect", *self._azlin_port_args(), self.vm.name, create_archive],
capture_output=True,
text=True,
timeout=60,
Expand All @@ -324,7 +332,7 @@ def retrieve_logs(self, local_dest: Path) -> bool:
# Download archive (azlin cp requires relative paths)
local_archive = local_dest / "logs.tar.gz"
subprocess.run(
["azlin", "cp", f"{self.vm.name}:~/logs.tar.gz", "logs.tar.gz"],
["azlin", "cp", *self._azlin_port_args(), f"{self.vm.name}:~/logs.tar.gz", "logs.tar.gz"],
cwd=str(local_dest), # Run from destination directory
capture_output=True,
text=True,
Expand Down Expand Up @@ -377,7 +385,7 @@ def retrieve_git_state(self, local_dest: Path) -> bool:
try:
# Create bundle
subprocess.run(
["azlin", "connect", self.vm.name, create_bundle],
["azlin", "connect", *self._azlin_port_args(), self.vm.name, create_bundle],
capture_output=True,
text=True,
timeout=300,
Expand All @@ -387,7 +395,7 @@ def retrieve_git_state(self, local_dest: Path) -> bool:
# Download bundle (azlin cp requires relative paths)
local_bundle = local_dest / "results.bundle"
subprocess.run(
["azlin", "cp", f"{self.vm.name}:~/results.bundle", "results.bundle"],
["azlin", "cp", *self._azlin_port_args(), f"{self.vm.name}:~/results.bundle", "results.bundle"],
cwd=str(local_dest), # Run from destination directory
capture_output=True,
text=True,
Expand Down Expand Up @@ -536,7 +544,7 @@ def execute_remote_tmux(

try:
result = subprocess.run(
["azlin", "connect", self.vm.name, setup_and_run],
["azlin", "connect", *self._azlin_port_args(), self.vm.name, setup_and_run],
capture_output=True,
text=True,
timeout=600, # 10 minutes for setup
Expand Down Expand Up @@ -588,7 +596,7 @@ def check_tmux_status(self, session_id: str) -> str:

try:
result = subprocess.run(
["azlin", "connect", self.vm.name, check_command],
["azlin", "connect", *self._azlin_port_args(), self.vm.name, check_command],
capture_output=True,
text=True,
timeout=30,
Expand Down
1 change: 1 addition & 0 deletions amplifier-bundle/tools/amplihack/remote/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class VMOptions:
no_reuse: bool = False
keep_vm: bool = False
azlin_extra_args: list | None = None # Pass-through for any azlin parameters
tunnel_port: int | None = None # Reuse existing bastion tunnel on this local port


class Orchestrator:
Expand Down
Loading
Loading