-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathctr-agent.py
More file actions
executable file
·705 lines (608 loc) · 28.8 KB
/
ctr-agent.py
File metadata and controls
executable file
·705 lines (608 loc) · 28.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
#!/usr/bin/env python3
import argparse
import getpass
import json
import os
import random
import signal
import subprocess
import sys
from pathlib import Path
ADJECTIVES = [
"happy", "clever", "brave", "calm", "eager", "gentle", "jolly", "kind",
"lively", "proud", "swift", "wise", "bright", "cool", "fair", "keen",
"noble", "quick", "sharp", "warm", "bold", "daring", "fuzzy", "silly",
"agile", "amber", "ancient", "arctic", "azure", "bouncy", "bronze", "charming",
"cosmic", "crimson", "crystal", "dapper", "dawn", "dreamy", "dynamic", "elegant",
"emerald", "epic", "fancy", "fiery", "fleet", "flying", "frosty", "gentle",
"gifted", "gleaming", "golden", "graceful", "grand", "groovy", "hardy", "hasty",
"heroic", "humble", "ivory", "jade", "jazzy", "joyful", "laser", "leafy",
"lucky", "lunar", "magic", "majestic", "mellow", "merry", "mighty", "misty",
"modern", "mystic", "nifty", "nimble", "novel", "orange", "peaceful", "perky",
"plucky", "polite", "prism", "proper", "quiet", "radiant", "rapid", "robust",
"royal", "rustic", "scarlet", "serene", "shadow", "shiny", "silent", "silver",
"sleek", "smooth", "snappy", "solar", "sonic", "sparkling", "speedy", "spry",
"steady", "stellar", "stormy", "strong", "sunny", "super", "tidy", "tiny",
"tranquil", "tribal", "tropical", "trusty", "twilight", "ultra", "unique", "upbeat",
"urban", "valiant", "velvet", "vibrant", "violet", "vivid", "wandering", "whimsical",
"wild", "witty", "zen", "zesty", "zippy"
]
ANIMALS = [
"ant", "bear", "cat", "dog", "eagle", "fox", "goat", "hawk", "ibex",
"jay", "koala", "lion", "mouse", "newt", "owl", "panda", "quail", "rabbit",
"seal", "tiger", "urchin", "viper", "wolf", "yak", "zebra", "otter", "penguin",
"albatross", "alligator", "alpaca", "anaconda", "angelfish", "armadillo", "baboon", "badger",
"barracuda", "bat", "beaver", "bee", "beetle", "bison", "boar", "buffalo",
"butterfly", "camel", "cardinal", "caribou", "cheetah", "chinchilla", "chipmunk", "cobra",
"cockatoo", "condor", "cougar", "coyote", "crab", "crane", "cricket", "crocodile",
"crow", "deer", "dingo", "dolphin", "donkey", "dove", "dragonfly", "duck",
"elephant", "elk", "emu", "falcon", "ferret", "finch", "flamingo", "fly",
"gazelle", "gecko", "giraffe", "gnu", "goose", "gopher", "gorilla", "grasshopper",
"grizzly", "hamster", "hare", "hedgehog", "heron", "hippo", "hornet", "horse",
"hummingbird", "husky", "iguana", "impala", "jackal", "jaguar", "jellyfish", "kangaroo",
"kestrel", "kingfisher", "kite", "kiwi", "lemming", "lemur", "leopard", "llama",
"lobster", "locust", "lynx", "macaw", "magpie", "mallard", "manatee", "mandrill",
"mantis", "marmot", "meerkat", "mink", "mole", "mongoose", "moose", "mosquito",
"moth", "narwhal", "nautilus", "nightingale", "octopus", "opossum", "orangutan", "orca",
"osprey", "ostrich", "otter", "oxen", "oyster", "panther", "parrot", "peacock",
"pelican", "pheasant", "pigeon", "pike", "platypus", "porcupine", "porpoise", "prairie",
"puffin", "puma", "python", "raccoon", "raven", "reindeer", "rhino", "roadrunner",
"robin", "salamander", "salmon", "sardine", "scorpion", "seahorse", "shark", "sheep",
"shrimp", "skunk", "sloth", "snail", "snake", "sparrow", "spider", "squid",
"squirrel", "starfish", "stingray", "stork", "swallow", "swan", "swordfish", "tapir",
"termite", "tern", "toad", "tortoise", "toucan", "trout", "tuna", "turkey",
"turtle", "vulture", "walrus", "wasp", "weasel", "whale", "wildcat", "woodpecker",
"wombat", "wren", "xerus", "yeti"
]
def merge_configs(base_config, overlay_config):
"""Merge overlay config on top of base config.
For lists (mounts, additional_panes, docker_options), overlay appends to base.
For dicts (env_vars, agents), overlay updates/extends base.
For scalars (image), overlay replaces base.
"""
result = base_config.copy()
for key, overlay_value in overlay_config.items():
if key not in result:
# New key in overlay, just add it
result[key] = overlay_value
elif isinstance(overlay_value, dict) and isinstance(result[key], dict):
# Both are dicts, merge them
result[key] = {**result[key], **overlay_value}
elif isinstance(overlay_value, list) and isinstance(result[key], list):
# Both are lists, append overlay to base
result[key] = result[key] + overlay_value
else:
# Scalar or type mismatch, overlay replaces base
result[key] = overlay_value
return result
def load_config():
"""Load configuration from JSON file."""
# Allow environment variable to override config path
config_path_str = os.environ.get("CTR_AGENT_CONFIG")
if config_path_str:
config_path = Path(config_path_str).expanduser()
else:
config_path = Path.home() / ".config" / "ctr-agent" / "config.json"
# Always ensure config directories exist
ctr_agent_dir = Path.home() / ".config" / "ctr-agent"
(ctr_agent_dir / "codex").mkdir(parents=True, exist_ok=True)
(ctr_agent_dir / "claude").mkdir(parents=True, exist_ok=True)
(ctr_agent_dir / "gemini").mkdir(parents=True, exist_ok=True)
if not config_path.exists():
config = get_default_config()
else:
with open(config_path, "r") as f:
config = json.load(f)
# Load overlay config if it exists
overlay_config_path_str = os.environ.get("CTR_AGENT_OVERLAY_CONFIG")
if overlay_config_path_str:
overlay_config_path = Path(overlay_config_path_str).expanduser()
else:
overlay_config_path = Path.home() / ".config" / "ctr-agent" / "config-overlay.json"
if overlay_config_path.exists():
with open(overlay_config_path, "r") as f:
overlay_config = json.load(f)
config = merge_configs(config, overlay_config)
print(f"Applied overlay config from: {overlay_config_path}")
return config
def get_default_config():
"""Return default configuration."""
return {
"image": "container-agent:dev",
"docker_options": ["-p", "0:9000"],
"env_vars": {
"OPENAI_API_KEY": None,
"ANTHROPIC_API_KEY": None,
"GEMINI_API_KEY": None,
"TS_AUTHKEY": None,
},
"mounts": [
{"host": "/var/run/docker.sock", "container": "/var/run/docker.sock"},
{"host": "{HOME}/.config/ctr-agent/codex", "container": "/home/agent/.codex"},
{"host": "{HOME}/.config/ctr-agent/claude", "container": "/home/agent/.claude"},
{"host": "{HOME}/.config/ctr-agent/gemini", "container": "/home/agent/.gemini"},
],
"agents": {
"codex": {
"command": "codex -s danger-full-access",
},
"claude": {
"command": "claude --dangerously-skip-permissions",
},
"gemini": {
"command": "gemini -y",
},
"bash": {
"command": "bash",
},
},
"additional_panes": [
{
"name": "tsproxy",
"command": "if [ -n \"$TS_AUTHKEY\" ]; then /go/bin/tsproxy -name {slug} -ports 8000-9999,11111 -magic-dns-suffix {magic_dns_suffix}; else sleep infinity; fi",
# Alternative: use tsnsrv instead
# "command": "if [ -n \"$TS_AUTHKEY\" ]; then /go/bin/tsnsrv -name {slug} -listenAddr :9000 -plaintext=true http://0.0.0.0:9000/; else sleep infinity; fi",
},
{
"name": "yatty",
"command": "/go/bin/yatty --port 8001 tmux attach",
},
{
"name": "headless",
"command": "/go/bin/headless start --foreground",
},
{
"name": "differing",
"command": "/go/bin/differing -port 8002 -addr 0.0.0.0",
},
],
}
def inside_mode(args, config):
"""Run inside the container - setup worktree and start agent."""
# Fix ownership
current_user = getpass.getuser()
subprocess.run(["sudo", "chown", "-R", current_user, os.getcwd()], check=True)
# Get MagicDNSSuffix for tsproxy health checking
magic_dns_suffix = args.magic_dns_suffix if hasattr(args, 'magic_dns_suffix') else None
# Create work directory with slug
unique_work_dir = f"/home/agent/work-{args.slug}"
os.mkdir(unique_work_dir)
# Add worktree to the unique directory
# subprocess.run("bash")
subprocess.run(
["git", "worktree", "add", unique_work_dir, "-b", args.slug, args.committish],
# I don't know why this is necessary:
cwd=args.git_dir + "/.git",
check=True
)
# Create symlink /home/agent/work -> work-{slug}
Path("/home/agent/work").symlink_to(unique_work_dir)
# Change to work directory and then to prefix directory
os.chdir("/home/agent/work")
os.chdir(args.prefix)
# Create symlink for .claude.json to work around directory-only mount limitation
claude_json_symlink = Path("/home/agent/.claude.json")
if not claude_json_symlink.exists():
# intentionalyl claude.json not .claude.json
claude_json_symlink.symlink_to("/home/agent/.claude/.claude.json")
# Get agent command from arguments (passed from outside mode)
agent_cmd = args.agent_cmd
# Get additional panes
additional_panes = config.get("additional_panes", [])
# Create tmux session with additional panes if configured
session_name = "s"
if additional_panes:
# Create detached tmux session
subprocess.run(["tmux", "new-session", "-d", "-s", session_name], check=True)
# Configure tmux status bar to not show date/time (causes false positives in change detection)
# Show only hostname/slug on the right side
subprocess.run(["tmux", "set-option", "-g", "status-right", f" {args.slug} "], check=False)
subprocess.run(["tmux", "set-option", "-g", "status-right-length", "50"], check=False)
# Create additional panes
for pane in additional_panes:
pane_name = pane.get("name", "pane")
pane_cmd = pane["command"].format(
slug=args.slug,
magic_dns_suffix=magic_dns_suffix or ""
)
subprocess.run(
["tmux", "new-window", "-t", session_name, "-n", pane_name, pane_cmd],
check=True
)
print(f"Started {pane_name} in tmux pane")
# Run agent in main window and select it
subprocess.run(
["tmux", "send-keys", "-t", f"{session_name}:0", agent_cmd, "Enter"],
check=True
)
# Switch to first window (main agent window)
subprocess.run(
["tmux", "select-window", "-t", f"{session_name}:0"],
check=True
)
else:
# No additional panes, just create new session with agent in detached mode
subprocess.run(["tmux", "new-session", "-d", "-s", session_name, agent_cmd], check=False)
# Configure tmux status bar to not show date/time (causes false positives in change detection)
subprocess.run(["tmux", "set-option", "-g", "status-right", f" {args.slug} "], check=False)
subprocess.run(["tmux", "set-option", "-g", "status-right-length", "50"], check=False)
# Keep container running - sleep until interrupted
print("Container running. Press Ctrl+C to exit.")
try:
subprocess.run(["sleep", "infinity"], check=False)
except KeyboardInterrupt:
pass
# After exit, print slug and clean up worktree if branch hasn't moved
print(f"\nExited container: {args.slug}")
# Check if workspace is dirty and commit if so
git_status = subprocess.run(
["git", "status", "--porcelain"],
capture_output=True, text=True, check=False
)
if git_status.returncode == 0 and git_status.stdout.strip():
print(f"Workspace is dirty, creating commit...")
# Add all changes
subprocess.run(["git", "add", "-A"], check=False)
# Create commit
commit_msg = f"Auto-commit by ctragent on exit\n\nAgent: {args.agent}\nBranch: {args.slug}"
subprocess.run(
["git", "commit", "-m", commit_msg],
check=False
)
print(f"Created commit for dirty workspace")
# Check if branch still points to original commit
current_commit = subprocess.run(
["git", "rev-parse", args.slug],
capture_output=True, text=True, check=False,
cwd=args.git_dir
)
if current_commit.returncode == 0 and current_commit.stdout.strip() == args.committish:
print(f"Branch {args.slug} unchanged, cleaning up...")
subprocess.run(
["git", "worktree", "remove", "--force", unique_work_dir],
cwd=args.git_dir,
check=False
)
subprocess.run(
["git", "branch", "-D", args.slug],
cwd=args.git_dir,
check=False
)
else:
print(f"Branch {args.slug} has moved, keeping worktree and branch")
def generate_random_slug():
"""Generate a random two-word hyphenated slug that doesn't conflict with existing docker containers."""
max_attempts = 100
for _ in range(max_attempts):
adjective = random.choice(ADJECTIVES)
animal = random.choice(ANIMALS)
slug = f"{adjective}-{animal}"
# Check if a container with this name already exists
result = subprocess.run(
["docker", "ps", "-a", "--filter", f"name={slug}", "--format", "{{.Names}}"],
capture_output=True,
text=True,
check=False
)
# If no container found, this slug is available
if result.returncode == 0 and slug not in result.stdout.strip().split('\n'):
return slug
# If we couldn't find a unique name after max_attempts, raise an error
raise RuntimeError(f"Could not generate unique container name after {max_attempts} attempts")
def outside_mode(args, config):
"""Run outside the container - setup and launch docker."""
# Check that Docker is running early
docker_check = subprocess.run(
["docker", "ps"],
capture_output=True,
text=True,
check=False
)
if docker_check.returncode != 0:
print(f"Error: Docker is not running or not accessible.", file=sys.stderr)
print(f"Please start Docker and try again.", file=sys.stderr)
sys.exit(1)
# Use provided name or generate random slug
if args.container_name:
args.slug = args.container_name
print(f"Using container name: {args.slug}")
else:
args.slug = generate_random_slug()
print(f"Generated slug: {args.slug}")
# Get MagicDNSSuffix from Tailscale
magic_dns_suffix = None
try:
import platform
import json as json_module
# Try to find tailscale binary
tailscale_paths = ["tailscale"]
if platform.system() == "Darwin":
tailscale_paths.append("/Applications/Tailscale.app/Contents/MacOS/Tailscale")
for ts_path in tailscale_paths:
try:
result = subprocess.run(
[ts_path, "status", "-json"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
ts_status = json_module.loads(result.stdout)
magic_dns_suffix = ts_status.get("MagicDNSSuffix", "").rstrip(".")
if magic_dns_suffix:
print(f"Detected Tailscale MagicDNSSuffix: {magic_dns_suffix}")
break
except (FileNotFoundError, subprocess.TimeoutExpired, json_module.JSONDecodeError):
continue
except Exception as e:
print(f"Warning: Could not detect MagicDNSSuffix: {e}")
# Handle --open flag to open browser to yatty
open_browser = getattr(args, 'open', True) # default True
# Get git information
git_dir = subprocess.run(
["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
capture_output=True, text=True, check=True
).stdout.strip()
# I don't really know why we need this, but we seem to, otherwise worktrees
# have a dirty "status" when you create them
if git_dir.endswith(".git"):
git_dir = os.path.dirname(git_dir)
committish = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True, text=True, check=True
).stdout.strip()
prefix = subprocess.run(
["git", "rev-parse", "--show-prefix"],
capture_output=True, text=True, check=True
).stdout.strip()
if not prefix:
prefix = "."
workdir = f"/home/agent"
print(f"Git dir: {git_dir}")
print(f"Workdir: {workdir}")
print(f"Committish: {committish}")
# Get script path
script_path = Path(__file__).resolve()
# Get image from config
image_tag = config.get("image", "container-agent:dev")
# Build docker command
# If --open is true, run detached; otherwise run interactive
if open_browser:
docker_cmd = [
"docker", "run", "-d",
"--hostname", args.slug,
"--name", args.slug,
"--cap-add=SYS_PTRACE",
]
else:
docker_cmd = [
"docker", "run", "--rm", "-it",
"--hostname", args.slug,
"--name", args.slug,
"--cap-add=SYS_PTRACE",
]
# Add docker options from config
docker_options = config.get("docker_options", [])
docker_cmd.extend(docker_options)
# Add environment variables from config
env_vars = config.get("env_vars", {})
for key, value in env_vars.items():
if value is None:
# Pass through from host environment
docker_cmd.extend(["-e", f"{key}={os.environ.get(key, '')}"])
else:
# Use configured value
docker_cmd.extend(["-e", f"{key}={value}"])
# Always add COMMITTISH
docker_cmd.extend(["-e", f"COMMITTISH={committish}"])
# Add mounts from config
mounts = config.get("mounts", [])
for mount in mounts:
# Expand variables in mount paths
host_path = mount["host"].format(
HOME=os.environ.get("HOME", ""),
git_dir=git_dir,
)
container_path = mount["container"]
docker_cmd.extend(["-v", f"{host_path}:{container_path}"])
# Add git_dir mount (dynamic)
docker_cmd.extend(["-v", f"{git_dir}:{git_dir}"])
# Add script mount (dynamic)
docker_cmd.extend(["-v", f"{script_path}:/mnt/ctr-agent.py"])
# Add working directory and image
docker_cmd.extend(["-w", workdir, image_tag])
# Get agent command from config
agent_config = config["agents"].get(args.agent)
if not agent_config:
raise ValueError(f"Unknown agent: {args.agent}")
agent_cmd = agent_config["command"]
# Add command to run inside container
docker_cmd.extend([
"python3", "/mnt/ctr-agent.py", "inside",
"--slug", args.slug,
"--git-dir", git_dir,
"--committish", committish,
"--prefix", prefix,
"--agent-cmd", agent_cmd,
])
# Add magic DNS suffix if available
if magic_dns_suffix:
docker_cmd.extend(["--magic-dns-suffix", magic_dns_suffix])
# Open browser if --open is True
redirect_server = None
if open_browser:
import socket
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
# Create a redirect handler that waits for hostname to resolve
class RedirectHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
pass # Suppress logging
def do_GET(self):
import subprocess
import time
import json
import platform
timeout = 20
start_time = time.time()
# Get MagicDNSSuffix from Tailscale
magic_dns_suffix = None
tailscale_error = None
# Try to find tailscale binary
tailscale_paths = ["tailscale"]
if platform.system() == "Darwin":
tailscale_paths.append("/Applications/Tailscale.app/Contents/MacOS/Tailscale")
for ts_path in tailscale_paths:
try:
result = subprocess.run(
[ts_path, "status", "-json"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode == 0:
ts_status = json.loads(result.stdout)
magic_dns_suffix = ts_status.get("MagicDNSSuffix", "").rstrip(".")
if magic_dns_suffix:
break
else:
tailscale_error = f"tailscale status failed: {result.stderr}"
except FileNotFoundError:
tailscale_error = f"tailscale binary not found at: {ts_path}"
except subprocess.TimeoutExpired:
tailscale_error = "tailscale status command timed out"
except json.JSONDecodeError as e:
tailscale_error = f"failed to parse tailscale status JSON: {e}"
# If we couldn't get MagicDNSSuffix, fail immediately
if not magic_dns_suffix:
self.send_response(500)
self.send_header('Content-type', 'text/html')
self.end_headers()
error_msg = tailscale_error or "Could not determine Tailscale MagicDNSSuffix"
self.wfile.write(f"<html><body><h1>Error</h1><p>{error_msg}</p></body></html>".encode())
return
# Build full hostname and target URL
full_hostname = f"{args.slug}.{magic_dns_suffix}"
target_url = f"http://{full_hostname}:8001/"
print(f"Waiting for {full_hostname} to resolve...")
resolved = False
attempt = 0
while time.time() - start_time < timeout:
attempt += 1
try:
# Log every 10 attempts (5 seconds)
if attempt % 10 == 1:
print(f"DNS query attempt {attempt} for {full_hostname}")
# Use dig to query the full hostname against Tailscale DNS
result = subprocess.run(
["dig", "+noall", "+answer", full_hostname, "@100.100.100.100"],
capture_output=True,
text=True,
timeout=2
)
# Check if we got a valid answer (non-empty output)
if result.returncode == 0 and result.stdout.strip():
print(f"DNS resolution succeeded for {full_hostname} after {attempt} attempts")
resolved = True
break
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
time.sleep(0.5)
if resolved:
self.send_response(302)
self.send_header('Location', target_url)
self.end_headers()
else:
self.send_response(504)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(f"<html><body><h1>Timeout</h1><p>Could not resolve {full_hostname} after {timeout} seconds</p></body></html>".encode())
# Start server on port 0 (random available port)
server = HTTPServer(('localhost', 0), RedirectHandler)
port = server.server_port
def run_server():
server.handle_request() # Handle one request then stop
server_thread = threading.Thread(target=run_server, daemon=True)
server_thread.start()
# Open browser to the local redirect server
redirect_url = f"http://localhost:{port}/"
print(f"Opening browser to: {redirect_url}")
print(f"Will redirect to: http://{args.slug}:8001/ once hostname resolves")
# Detect platform and use appropriate command
# Respect CTR_AGENT_BROWSER environment variable if set
import platform
browser = os.environ.get("CTR_AGENT_BROWSER")
try:
if platform.system() == "Darwin": # macOS
if browser:
subprocess.run(["open", "-a", browser, redirect_url], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
subprocess.run(["open", redirect_url], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
elif platform.system() == "Windows":
if browser:
subprocess.run([browser, redirect_url], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
subprocess.run(["start", redirect_url], shell=True, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else: # Linux and others
if browser:
subprocess.run([browser, redirect_url], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
else:
subprocess.run(["xdg-open", redirect_url], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception as e:
print(f"Failed to open browser: {e}")
if open_browser:
# Run container in detached mode
result = subprocess.run(docker_cmd, capture_output=True, text=True, check=False)
container_id = result.stdout.strip()
if result.returncode == 0:
print(f"\nContainer started: {args.slug}")
print(f"Container ID: {container_id}")
print(f"\nYatty URL: http://{args.slug}:8001/")
print(f"\nTo attach a terminal, run:")
print(f" docker exec -it {args.slug} tmux attach")
print(f"\nWaiting for container to exit (press Ctrl+C to stop container)...")
# Set up signal handler for Ctrl-C
def stop_container_handler(signum, frame):
print(f"\n\nReceived interrupt signal. Stopping container {args.slug}...")
subprocess.run(["docker", "stop", args.slug], check=False)
print(f"Container {args.slug} stopped.")
sys.exit(0)
signal.signal(signal.SIGINT, stop_container_handler)
# Wait for the container to exit
subprocess.run(["docker", "wait", args.slug], check=False)
else:
print(f"Failed to start container: {result.stderr}")
else:
# Run container in interactive mode (original behavior)
subprocess.run(docker_cmd, check=False)
print(f"\nExited container: {args.slug}")
def main():
# Load configuration
config = load_config()
# Check if running in inside mode
if len(sys.argv) > 1 and sys.argv[1] == "inside":
# Inside mode parser
parser = argparse.ArgumentParser(description="Run inside container")
parser.add_argument("mode", help="Must be 'inside'")
parser.add_argument("--git-dir", required=True, help="Git directory path")
parser.add_argument("--committish", required=True, help="Git commit hash")
parser.add_argument("--prefix", required=True, help="Working directory prefix")
parser.add_argument("--agent-cmd", required=True, help="Agent command to run")
parser.add_argument("--slug", help="slug")
parser.add_argument("--magic-dns-suffix", help="Tailscale MagicDNS suffix")
args = parser.parse_args()
inside_mode(args, config)
else:
# Outside mode parser (default, user-facing)
parser = argparse.ArgumentParser(description="Run agent in container")
parser.add_argument("agent", help="Agent to run")
parser.add_argument("name", nargs="?", default=None, help="Container name (optional, random if not specified)")
parser.add_argument("--name", dest="name_flag", default=None, help="Container name (alternative to positional)")
parser.add_argument("--open", type=lambda x: x.lower() != 'false', default=True,
help="Open browser to yatty session (default: true, disable with --open=false)")
args = parser.parse_args()
# Use --name flag if provided, otherwise use positional argument
args.container_name = args.name_flag if args.name_flag else args.name
outside_mode(args, config)
if __name__ == "__main__":
main()