Skip to content
Merged
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
60 changes: 59 additions & 1 deletion council-automation/council_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from pathlib import Path

import shutil
import subprocess
import tempfile

from council_config import (
Expand Down Expand Up @@ -250,6 +251,28 @@ def _load_selectors() -> dict:
}


def _get_chrome_pids() -> set[int]:
"""Get all chrome.exe PIDs on Windows. Returns empty set on other platforms."""
if sys.platform != 'win32':
return set()
try:
out = subprocess.run(
['tasklist', '/FI', 'IMAGENAME eq chrome.exe', '/FO', 'CSV', '/NH'],
capture_output=True, text=True, timeout=5,
)
pids = set()
for line in out.stdout.strip().split('\n'):
parts = line.split(',')
if len(parts) >= 2:
try:
pids.add(int(parts[1].strip('"')))
except ValueError:
pass
return pids
except Exception:
return set()


class PerplexityCouncil:
"""Autonomous Playwright-based Perplexity automation.

Expand Down Expand Up @@ -288,6 +311,7 @@ def __init__(
self._artifact_count = 0
self._artifact_dir: Path | None = None
self._temp_profile_dir: str | None = None # Cloudflare fallback temp dir
self._spawned_pids: set[int] = set() # Chrome PIDs we launched (for force-kill)

def _init_artifact_dir(self, query: str) -> None:
"""Create run artifact directory based on timestamp + query slug."""
Expand Down Expand Up @@ -389,6 +413,7 @@ async def _start_non_persistent(self) -> None:

No shared profile directory — supports multiple concurrent sessions.
"""
pids_before = _get_chrome_pids() if not self.headless else set()
self._browser = await self.playwright.chromium.launch(
channel="chrome",
headless=self.headless,
Expand Down Expand Up @@ -422,9 +447,13 @@ async def _start_non_persistent(self) -> None:
# Apply stealth scripts
await self.context.add_init_script(self._stealth_scripts())

if not self.headless:
self._spawned_pids |= (_get_chrome_pids() - pids_before)

async def _start_persistent(self) -> None:
"""Launch with persistent context (used for --save-session only)."""
BROWSER_USER_DATA_DIR.mkdir(parents=True, exist_ok=True)
pids_before = _get_chrome_pids() if not self.headless else set()

self.context = await self.playwright.chromium.launch_persistent_context(
user_data_dir=str(BROWSER_USER_DATA_DIR),
Expand All @@ -441,6 +470,9 @@ async def _start_persistent(self) -> None:
if self.session_path.exists():
await self._load_session()

if not self.headless:
self._spawned_pids |= (_get_chrome_pids() - pids_before)

async def _start_with_temp_profile(self) -> None:
"""Cloudflare fallback: persistent context with a temp profile directory.

Expand All @@ -449,6 +481,7 @@ async def _start_with_temp_profile(self) -> None:
"""
self._temp_profile_dir = tempfile.mkdtemp(prefix="council_browser_")
_log(f"Cloudflare fallback: using temp profile {self._temp_profile_dir}")
pids_before = _get_chrome_pids() if not self.headless else set()

self.context = await self.playwright.chromium.launch_persistent_context(
user_data_dir=self._temp_profile_dir,
Expand All @@ -465,6 +498,9 @@ async def _start_with_temp_profile(self) -> None:
if self.session_path.exists():
await self._load_session()

if not self.headless:
self._spawned_pids |= (_get_chrome_pids() - pids_before)

async def _load_session(self) -> None:
"""Load session from playwright-session.json + playwright-localstorage.json."""
try:
Expand Down Expand Up @@ -1605,7 +1641,29 @@ async def stop(self) -> None:
except Exception:
pass
if self.playwright:
await self.playwright.stop()
try:
await self.playwright.stop()
except Exception:
pass
# Force-kill Chrome processes we spawned if they survived graceful close
if self._spawned_pids:
await asyncio.sleep(1) # Grace period for process exit
for pid in list(self._spawned_pids):
try:
os.kill(pid, 0) # Check if still alive
_log(f"Chrome PID {pid} still alive after graceful close, force-killing...")
if sys.platform == 'win32':
subprocess.run(
['taskkill', '/F', '/PID', str(pid), '/T'],
capture_output=True, timeout=5,
)
else:
os.kill(pid, 9) # SIGKILL
except (OSError, SystemError):
pass # Already dead
except Exception as e:
_log(f"WARNING: Failed to kill Chrome PID {pid}: {e}")
self._spawned_pids.clear()
# Clean up temp profile dir (Cloudflare fallback)
if self._temp_profile_dir and Path(self._temp_profile_dir).exists():
try:
Expand Down
Loading