personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add dynamic port allocation for Convey and MCP servers

Replace hardcoded default ports with OS-assigned dynamic ports to avoid
conflicts. Services now bind to port 0 by default, letting the OS choose
an available port.

- Add port discovery utilities: find_available_port(), write_service_port(),
read_service_port() in think/utils.py
- Convey writes its port to health/convey.port on startup
- Screenshot and restart commands read port from health file
- MCP server (in Cortex) uses dynamic port by default
- Update integration tests to discover Convey port from health file

Explicit port configuration still works via --port flag or SOLSTONE_MCP_PORT
environment variable.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+215 -22
+20 -3
convey/cli.py
··· 65 65 """Main CLI entry point for convey command.""" 66 66 from pathlib import Path 67 67 68 - from think.utils import get_journal, setup_cli 68 + from think.utils import ( 69 + find_available_port, 70 + get_journal, 71 + setup_cli, 72 + write_service_port, 73 + ) 69 74 70 75 from . import create_app 71 76 from .maint import run_pending_tasks 72 77 73 78 parser = argparse.ArgumentParser(description="Convey web interface") 74 - parser.add_argument("--port", type=int, default=8000, help="Port to serve on") 79 + parser.add_argument( 80 + "--port", 81 + type=int, 82 + default=0, 83 + help="Port to serve on (0 = auto-select available port)", 84 + ) 75 85 parser.add_argument( 76 86 "--skip-maint", 77 87 action="store_true", ··· 95 105 "No password configured - add to config/journal.json to enable authentication" 96 106 ) 97 107 98 - run_service(app, host="0.0.0.0", port=args.port, debug=args.debug) 108 + # Determine port: use specified port or find an available one 109 + port = args.port if args.port != 0 else find_available_port() 110 + 111 + # Write port to health directory for discovery by other tools 112 + write_service_port("convey", port) 113 + logger.info(f"Convey starting on port {port}") 114 + 115 + run_service(app, host="0.0.0.0", port=port, debug=args.debug)
+7 -3
convey/restart.py
··· 13 13 import time 14 14 15 15 from think.callosum import CallosumConnection 16 - from think.utils import setup_cli 16 + from think.utils import read_service_port, setup_cli 17 17 18 18 19 19 def _format_log(timestamp: float, stream: str, line: str) -> str: ··· 197 197 print("-" * 60, file=sys.stderr) 198 198 sys.exit(1) 199 199 200 - # Success - print the URL 201 - print("Convey running at http://localhost:8000/") 200 + # Success - print the URL with discovered port 201 + port = read_service_port("convey") 202 + if port: 203 + print(f"Convey running at http://localhost:{port}/") 204 + else: 205 + print("Convey restarted successfully (port unknown)") 202 206 203 207 204 208 if __name__ == "__main__":
+21 -3
convey/screenshot.py
··· 12 12 13 13 from playwright.sync_api import sync_playwright 14 14 15 - from think.utils import setup_cli 15 + from think.utils import read_service_port, setup_cli 16 16 17 17 18 18 class _HelpOnErrorParser(argparse.ArgumentParser): ··· 136 136 default="logs/screenshot.png", 137 137 help="Output path (default: logs/screenshot.png)", 138 138 ) 139 - parser.add_argument("--port", type=int, default=8000, help="Server port") 139 + parser.add_argument( 140 + "--port", 141 + type=int, 142 + default=None, 143 + help="Server port (default: read from Convey port file)", 144 + ) 140 145 parser.add_argument("--width", type=int, default=1440, help="Viewport width") 141 146 parser.add_argument("--height", type=int, default=900, help="Viewport height") 142 147 parser.add_argument( ··· 156 161 157 162 args = setup_cli(parser) 158 163 164 + # Determine port: CLI arg takes precedence, then port file, then error 165 + if args.port is not None: 166 + port = args.port 167 + else: 168 + port = read_service_port("convey") 169 + if port is None: 170 + print( 171 + "Error: Convey port not found. Is Convey running? " 172 + "Use --port to specify manually.", 173 + file=sys.stderr, 174 + ) 175 + sys.exit(1) 176 + 159 177 # Auto-set delay for fragment routes if not explicitly specified 160 178 if args.delay is None: 161 179 args.delay = 500 if "#" in args.route else 0 ··· 167 185 screenshot( 168 186 route=args.route, 169 187 output_path=args.output, 170 - port=args.port, 188 + port=port, 171 189 width=args.width, 172 190 height=args.height, 173 191 script=args.script,
+26 -12
tests/integration/test_apps.py
··· 4 4 """Tests for Convey app endpoints. 5 5 6 6 Tests that all apps with workspace.html can be accessed via /app/{app_name}. 7 - Requires Convey to be running on localhost:8000. 7 + Requires Convey to be running (port discovered from health file). 8 8 """ 9 9 10 10 from pathlib import Path ··· 12 12 import pytest 13 13 import requests 14 14 15 + from think.utils import read_service_port 15 16 16 - def is_convey_running(port: int = 8000) -> bool: 17 - """Check if Convey is running on localhost.""" 17 + 18 + def get_convey_port() -> int | None: 19 + """Get the port Convey is running on, or None if not available.""" 20 + return read_service_port("convey") 21 + 22 + 23 + def is_convey_running() -> tuple[bool, int | None]: 24 + """Check if Convey is running and return (is_running, port).""" 25 + port = get_convey_port() 26 + if port is None: 27 + return False, None 18 28 try: 19 29 requests.get(f"http://localhost:{port}/", timeout=2) 20 - return True # Any response means server is running 30 + return True, port # Any response means server is running 21 31 except (requests.ConnectionError, requests.Timeout): 22 - return False 32 + return False, port 23 33 24 34 25 35 def get_app_names() -> list[str]: ··· 39 49 40 50 41 51 @pytest.fixture(scope="module") 42 - def convey_running(): 43 - """Fixture that checks if Convey is running and skips if not.""" 44 - if not is_convey_running(): 45 - pytest.skip("Convey is not running on localhost:8000") 46 - return True 52 + def convey_port(): 53 + """Fixture that returns Convey port and skips if not running.""" 54 + running, port = is_convey_running() 55 + if not running: 56 + if port is None: 57 + pytest.skip("Convey port file not found - is Convey running?") 58 + else: 59 + pytest.skip(f"Convey is not responding on port {port}") 60 + return port 47 61 48 62 49 63 @pytest.mark.parametrize("app_name", get_app_names()) 50 - def test_app_endpoint(app_name: str, convey_running): 64 + def test_app_endpoint(app_name: str, convey_port: int): 51 65 """Test that each app endpoint returns 200.""" 52 - url = f"http://localhost:8000/app/{app_name}" 66 + url = f"http://localhost:{convey_port}/app/{app_name}" 53 67 response = requests.get(url, timeout=5) 54 68 55 69 assert response.status_code == 200, (
+80
tests/test_think_utils.py
··· 890 890 assert result["sources"]["audio"] is False 891 891 assert result["sources"]["screen"] is True # Default preserved 892 892 assert result["sources"]["agents"] is True # Overridden 893 + 894 + 895 + class TestPortDiscovery: 896 + """Tests for service port discovery utilities.""" 897 + 898 + def test_find_available_port_returns_valid_port(self): 899 + """Test that find_available_port returns a valid port number.""" 900 + from think.utils import find_available_port 901 + 902 + port = find_available_port() 903 + assert isinstance(port, int) 904 + assert 1024 <= port <= 65535 # User-space port range 905 + 906 + def test_find_available_port_different_each_call(self): 907 + """Test that multiple calls can return different ports.""" 908 + from think.utils import find_available_port 909 + 910 + # Get multiple ports - they may or may not be unique, but should all be valid 911 + ports = [find_available_port() for _ in range(3)] 912 + for port in ports: 913 + assert isinstance(port, int) 914 + assert 1024 <= port <= 65535 915 + 916 + def test_write_and_read_service_port(self, monkeypatch, tmp_path): 917 + """Test writing and reading a service port file.""" 918 + from think.utils import read_service_port, write_service_port 919 + 920 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 921 + 922 + # Write port 923 + write_service_port("test_service", 12345) 924 + 925 + # Read port back 926 + port = read_service_port("test_service") 927 + assert port == 12345 928 + 929 + # Verify file exists in correct location 930 + port_file = tmp_path / "health" / "test_service.port" 931 + assert port_file.exists() 932 + assert port_file.read_text() == "12345" 933 + 934 + def test_read_service_port_missing_file(self, monkeypatch, tmp_path): 935 + """Test that reading missing port file returns None.""" 936 + from think.utils import read_service_port 937 + 938 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 939 + 940 + port = read_service_port("nonexistent") 941 + assert port is None 942 + 943 + def test_read_service_port_invalid_content(self, monkeypatch, tmp_path): 944 + """Test that reading invalid port file content returns None.""" 945 + from think.utils import read_service_port 946 + 947 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 948 + 949 + # Create port file with invalid content 950 + health_dir = tmp_path / "health" 951 + health_dir.mkdir() 952 + port_file = health_dir / "bad_service.port" 953 + port_file.write_text("not a number") 954 + 955 + port = read_service_port("bad_service") 956 + assert port is None 957 + 958 + def test_write_service_port_creates_health_dir(self, monkeypatch, tmp_path): 959 + """Test that write_service_port creates health directory if needed.""" 960 + from think.utils import write_service_port 961 + 962 + monkeypatch.setenv("JOURNAL_PATH", str(tmp_path)) 963 + 964 + # Health dir doesn't exist yet 965 + health_dir = tmp_path / "health" 966 + assert not health_dir.exists() 967 + 968 + write_service_port("new_service", 9999) 969 + 970 + # Now it should exist 971 + assert health_dir.exists() 972 + assert (health_dir / "new_service.port").read_text() == "9999"
+3 -1
think/cortex.py
··· 98 98 return 99 99 100 100 from think.mcp import mcp 101 + from think.utils import find_available_port 101 102 102 103 host = os.getenv("SOLSTONE_MCP_HOST", "127.0.0.1") 103 - port = int(os.getenv("SOLSTONE_MCP_PORT", "6270")) 104 + port_env = os.getenv("SOLSTONE_MCP_PORT", "0") 105 + port = int(port_env) if port_env != "0" else find_available_port(host) 104 106 path = os.getenv("SOLSTONE_MCP_PATH", "/mcp") or "/mcp" 105 107 if not path.startswith("/"): 106 108 path = f"/{path}"
+58
think/utils.py
··· 1407 1407 mime = "application/octet-stream" 1408 1408 1409 1409 return rel, mime, meta 1410 + 1411 + 1412 + # ============================================================================= 1413 + # Service Port Discovery 1414 + # ============================================================================= 1415 + 1416 + 1417 + def find_available_port(host: str = "127.0.0.1") -> int: 1418 + """Find an available port by binding to port 0. 1419 + 1420 + Uses the socket bind/getsockname/close pattern to let the OS assign 1421 + an available port. 1422 + 1423 + Args: 1424 + host: Host address to bind to (default: 127.0.0.1) 1425 + 1426 + Returns: 1427 + Available port number 1428 + """ 1429 + import socket 1430 + 1431 + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 1432 + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 1433 + sock.bind((host, 0)) 1434 + _, port = sock.getsockname() 1435 + sock.close() 1436 + return port 1437 + 1438 + 1439 + def write_service_port(service: str, port: int) -> None: 1440 + """Write a service's port to the health directory. 1441 + 1442 + Creates $JOURNAL_PATH/health/{service}.port with the port number. 1443 + 1444 + Args: 1445 + service: Service name (e.g., "convey", "mcp") 1446 + port: Port number to write 1447 + """ 1448 + health_dir = Path(get_journal()) / "health" 1449 + health_dir.mkdir(parents=True, exist_ok=True) 1450 + port_file = health_dir / f"{service}.port" 1451 + port_file.write_text(str(port)) 1452 + 1453 + 1454 + def read_service_port(service: str) -> int | None: 1455 + """Read a service's port from the health directory. 1456 + 1457 + Args: 1458 + service: Service name (e.g., "convey", "mcp") 1459 + 1460 + Returns: 1461 + Port number if file exists and is valid, None otherwise 1462 + """ 1463 + port_file = Path(get_journal()) / "health" / f"{service}.port" 1464 + try: 1465 + return int(port_file.read_text().strip()) 1466 + except (FileNotFoundError, ValueError): 1467 + return None