@daemon.command()
@click.option("--host", default=None, help="Bind address.")
@click.option("--port", default=None, type=int, help="Port number.")
@click.option("-e", "--engine", "engine_key", default=None, help="Engine backend.")
@click.option("-m", "--model", "model_name", default=None, help="Default model.")
@click.option("-a", "--agent", "agent_name", default=None, help="Agent type.")
def start(
host: str | None,
port: int | None,
engine_key: str | None,
model_name: str | None,
agent_name: str | None,
) -> None:
"""Start the Locus server as a background daemon."""
console = Console(stderr=True)
existing = _read_pid()
if existing is not None:
console.print(f"[yellow]Server already running (PID {existing}).[/yellow]")
console.print("Use 'locus stop' to stop it first, or 'locus restart'.")
sys.exit(1)
config = load_config()
bind_host = host or config.server.host
bind_port = port if port is not None else config.server.port
# Build command to run locus serve
cmd = [sys.executable, "-m", "locus.cli", "serve"]
if host:
cmd.extend(["--host", host])
if port is not None:
cmd.extend(["--port", str(port)])
if engine_key:
cmd.extend(["--engine", engine_key])
if model_name:
cmd.extend(["--model", model_name])
if agent_name:
cmd.extend(["--agent", agent_name])
# Start as background process, fully detached from the launching terminal.
#
# ``start_new_session`` is POSIX-only: CPython's Windows ``_execute_child``
# names the parameter ``unused_start_new_session`` and ignores it. Relying
# on it there leaves the server sharing its parent's console, so closing
# that console — or logging off — delivers CTRL_CLOSE_EVENT and kills the
# daemon. CREATE_NO_WINDOW gives it its own hidden console, which its
# children inherit. DETACHED_PROCESS (no console at all) is not enough for a
# venv install: ``locus.exe``/``python.exe`` there are launcher stubs that
# spawn the real interpreter, and that child, finding no console to inherit,
# allocates a NEW visible one (a terminal window) that kills the server when
# closed. The new process group additionally stops a Ctrl-C in the parent
# reaching it.
DEFAULT_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
spawn_kwargs: dict = {}
if sys.platform == "win32":
spawn_kwargs["creationflags"] = (
subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP
)
else:
spawn_kwargs["start_new_session"] = True
launch_token = secrets.token_hex(16)
proc = None
registration_error = None
# Serialize the final check, spawn, and pending registration. The server
# waits on this same lock if it binds before its launcher finishes writing.
with _state_lock():
existing = _read_pid_unlocked()
if existing is None:
with open(_LOG_FILE, "a") as log_fh:
proc = subprocess.Popen(
cmd,
stdout=log_fh,
stderr=log_fh,
env={**os.environ, _LAUNCH_TOKEN_ENV: launch_token},
**spawn_kwargs,
)
try:
_write_pid_unlocked(
proc.pid,
bind_host,
bind_port,
ready=False,
launch_token=launch_token,
)
except (OSError, RuntimeError) as exc:
registration_error = exc
if existing is not None:
console.print(f"[yellow]Server already running (PID {existing}).[/yellow]")
console.print("Use 'locus stop' to stop it first, or 'locus restart'.")
sys.exit(1)
assert proc is not None
if registration_error is not None:
terminate_process(proc.pid, grace_seconds=10.0)
clear_server_state(proc.pid)
raise click.ClickException(str(registration_error)) from registration_error
console.print(
f"[green]Locus server starting[/green] (launch PID {proc.pid})\n"
f" Requested URL: {_server_url(bind_host, bind_port)}\n"
f" Log: {_LOG_FILE}"
)