feat(cli): add omnigent host --background to run the host daemon detached (#4317)

## Related issue

Closes OMNI-2516 — https://linear.app/omnigent/issue/OMNI-2516

## Summary

- `omnigent host` only ever ran in the foreground, so registering a machine as
  a host cost a dedicated terminal — even though the detached daemon it needs
  already exists and is what `run` / `claude` / `codex` spawn via
  `_ensure_host_daemon()`. `--background` exposes that path directly: spawn (or
  adopt) the daemon, report it, and return.
- Sign-in stays interactive. A detached daemon has no terminal to run the
  browser login on, so `_ensure_databricks_server_auth()` runs in the
  foreground *before* the spawn; otherwise the daemon dies in the background
  with an opaque "redirected to a login page" error. `--non-interactive` still
  fails with the `omnigent login` hint instead of prompting.
- In local mode the daemon also owns the local Omnigent server, so the command
  waits for that server and reports its URL — otherwise the Web UI is
  unreachable without a follow-up `omnigent server status`. That makes
  `omnigent host --background` the whole "start everything" step, which is now
  the README quickstart (it replaces the `server --background` + `host` pair).
- A daemon that dies on startup (bad URL, missing credentials) leaves nothing
  on the terminal, so the command waits a 2s grace and surfaces the daemon log
  rather than falsely reporting success.

Output is a colorized headline plus aligned detail rows, with the stop command
on its own line so it can be copied:

```
Started the host daemon in the background (pid 74241).
  server: https://dbc-…/api/2.0/omnigent
  log:    ~/.omnigent/logs/host/host-20260806-205308-765542.log

Stop it with:
  omnigent host stop --server https://dbc-…/api/2.0/omnigent
```

That stop command mirrors the invocation: `host` and `host stop` resolve their
target identically (the `--server` value, else config, else local), so the flag
is echoed only when the user named a target — a bare `host --background` prints
a bare `omnigent host stop`. Colorizing reuses the existing `NO_COLOR`-aware
helper, renamed `_help_style` → `_cli_style` now that it is not help-only.

## Test Plan

- `uv run --extra dev pytest tests/host/test_cli_host.py -q` → 21 passed.
- Manually, local mode: `omnigent host --background` reported
  `server: http://127.0.0.1:6767` and a bare `omnigent host stop` (no
  `--server` typed, none echoed), which then stopped it.
- Manually, remote mode: `omnigent host --background --server https://dbc-…`
  printed the block quoted above; `omnigent host status` showed
  `process=online host=online`; re-running reported `already running (pid …)`
  with no second spawn; and the echoed `host stop --server …` stopped it.

## Demo

N/A — CLI-only change; the new output is quoted above.

## Type of change

- [ ] Bug fix
- [x] Feature
- [ ] UI / frontend change
- [ ] Refactor / chore
- [ ] Docs
- [ ] Test / CI
- [ ] Breaking change

## Test coverage

- [x] Unit tests added / updated
- [ ] Integration tests added / updated
- [ ] E2E tests added / updated
- [x] Manual verification completed
- [ ] Existing tests cover this change
- [ ] Not applicable

## Coverage notes

Four new tests in `tests/host/test_cli_host.py` cover the spawn output
(including the local server URL and a flagless stop hint), that the foreground
daemon loop and in-process local-server bring-up are skipped, reuse of a
healthy daemon via an explicit `--server ""` (whose stop hint keeps the flag),
and that sign-in runs before the spawn. The daemon spawn and local-server
discovery are stubbed, so no process or log file is created. Manual
verification covered both modes end to end; the exits-immediately grace path is
covered by tests only.

## Changelog

`omnigent host --background` starts the local server and registers this machine
as a host without tying up a terminal.

Signed-off-by: Zeyi (Rice) Fan <zeyi.f@databricks.com>
This commit is contained in:
Zeyi (Rice) Fan
2026-08-06 21:11:20 -07:00
committed by GitHub
parent 8329fad713
commit 52166e5dec
3 changed files with 361 additions and 15 deletions
+6 -5
View File
@@ -301,15 +301,16 @@ full pages through an MCP search server, and verifies each claim across
independent sources. It's also the simplest example to copy from: one agent
plus one `tools/mcp/*.yaml` server, no sub-agents.
**Prefer the browser?** Start a server and register your machine as a host:
**Prefer the browser?** One command starts the local server and registers this
machine as a host:
```bash
omnigent server --background # start the local server and web UI in the background
omnigent host # (separate terminal) register this machine as a host
omnigent host --background # starts the local server too, then returns
```
In the web UI, hit **New Chat**, pick your machine, and go. Check status with
`omnigent server status`; stop everything with `omnigent stop`.
Open the server URL it prints, hit **New Chat**, pick your machine, and go.
Check status with `omnigent server status`; stop everything with
`omnigent stop`.
### 3. Choose & switch models
+168 -10
View File
@@ -1604,8 +1604,8 @@ def _harness_extra_checks() -> dict[str, Callable[[], bool]]:
}
def _help_style(text: str, **style: object) -> str:
"""Colorize *text* for help output, honoring ``NO_COLOR``.
def _cli_style(text: str, **style: object) -> str:
"""Colorize *text* for terminal output, honoring ``NO_COLOR``.
Click's ``echo`` already strips ANSI when the sink is not a TTY, so
this only needs to guard the explicit ``NO_COLOR`` opt-out; on an
@@ -1629,7 +1629,7 @@ class _OmnigentCLI(click.Group):
def format_usage(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
"""Render the usage line with an accent-colored ``Usage:`` prefix."""
pieces = self.collect_usage_pieces(ctx)
prefix = f"{_help_style('Usage:', fg=_ACCENT_RGB, bold=True)} "
prefix = f"{_cli_style('Usage:', fg=_ACCENT_RGB, bold=True)} "
formatter.write_usage(ctx.command_path, " ".join(pieces), prefix=prefix)
def format_options(self, ctx: click.Context, formatter: click.HelpFormatter) -> None:
@@ -1638,9 +1638,9 @@ class _OmnigentCLI(click.Group):
for param in self.get_params(ctx):
rv = param.get_help_record(ctx)
if rv is not None:
opts.append((_help_style(rv[0], fg="green"), rv[1]))
opts.append((_cli_style(rv[0], fg="green"), rv[1]))
if opts:
with formatter.section(_help_style("Options", fg=_ACCENT_RGB, bold=True)):
with formatter.section(_cli_style("Options", fg=_ACCENT_RGB, bold=True)):
formatter.write_dl(opts)
self.format_commands(ctx, formatter)
@@ -1688,10 +1688,10 @@ class _OmnigentCLI(click.Group):
def _emit(title: str, rows: list[tuple[str, click.Command]], name_fg: object) -> None:
if not rows:
return
with formatter.section(_help_style(title, fg=_ACCENT_RGB, bold=True)):
with formatter.section(_cli_style(title, fg=_ACCENT_RGB, bold=True)):
formatter.write_dl(
[
(_help_style(name, fg=name_fg), cmd.get_short_help_str(limit))
(_cli_style(name, fg=name_fg), cmd.get_short_help_str(limit))
for name, cmd in rows
]
)
@@ -1700,7 +1700,7 @@ class _OmnigentCLI(click.Group):
if any_hidden:
formatter.write_paragraph()
formatter.write_text(
_help_style(
_cli_style(
"Some harnesses need an optional extra — run `omnigent setup` to enable them.",
dim=True,
)
@@ -7642,8 +7642,146 @@ def _prompt_stop_local_server() -> None:
click.echo(f"Left the local server running at {url}.")
# Grace period a freshly spawned background host daemon must survive before
# `host --background` reports success. A daemon that dies on startup (bad
# server URL, missing credentials) leaves nothing on the terminal, so we wait
# this long and surface its log instead of falsely reporting success.
_BACKGROUND_HOST_GRACE_S = 2.0
def _confirm_background_host_alive(record: _HostDaemonRecord) -> None:
"""Fail loud if a freshly spawned background host daemon dies at once.
:param record: Registry record of the spawned daemon.
:raises click.ClickException: If the daemon exits within
:data:`_BACKGROUND_HOST_GRACE_S`.
"""
deadline = time.time() + _BACKGROUND_HOST_GRACE_S
while True:
if not _pid_alive(record.pid):
from omnigent._runner_startup import format_runner_log_tail
log_path = Path(record.log_path) if record.log_path else None
raise click.ClickException(
"The host daemon exited immediately after starting."
f"{format_runner_log_tail(log_path)}"
)
if time.time() >= deadline:
return
time.sleep(0.1)
def _run_background_host(
server: str | None,
*,
explicit_server: str | None,
non_interactive: bool,
) -> None:
"""Spawn (or reuse) the detached host daemon and report it.
The background counterpart of the foreground ``omnigent host`` body,
selected by ``--background``: the same daemon loop runs detached (see
:func:`_ensure_host_daemon`) so the command returns instead of blocking.
Sign-in stays in the foreground. The detached daemon has no terminal to
prompt on, so a Databricks-fronted server is authenticated here, before the
spawn otherwise the daemon would die in the background with an opaque
redirect error.
:param server: Resolved Omnigent server URL, e.g.
``"https://example.databricksapps.com"``. ``None`` or ``""`` selects
local mode (the daemon starts or reuses a local Omnigent server).
:param explicit_server: The ``--server`` value as the user spelled it
(``""`` for local mode), or ``None`` when the option was omitted.
Only used to echo a matching ``host stop`` command.
:param non_interactive: When ``True``, never launch the browser login
fail with the ``omnigent login`` hint instead.
:raises click.ClickException: If the daemon cannot be spawned, exits
immediately after starting, or (local mode) never serves its local
Omnigent server.
"""
if server:
_ensure_databricks_server_auth(server, non_interactive=non_interactive)
target = _normalize_daemon_target(server)
previous = _find_daemon_record(target)
_ensure_host_daemon(server or None)
record = _find_daemon_record(target)
if record is None:
# No record for this target: either the live local-mode daemon already
# serves the requested URL, or the spawn itself failed.
if _local_daemon_serves_target(target, server or None):
click.echo(f"The local host daemon already serves {target}.")
return
raise click.ClickException(
"Could not spawn the background host daemon. See ~/.omnigent/logs/host/ for details."
)
if previous is not None and previous.pid == record.pid:
headline = _cli_style("Host daemon already running", fg="yellow", bold=True)
else:
_confirm_background_host_alive(record)
headline = _cli_style("Started the host daemon in the background", fg="green", bold=True)
click.echo(f"{headline} (pid {record.pid}).")
if record.mode == "local":
# A local-mode daemon owns the local Omnigent server, so this command is
# the whole "start everything" step — wait for that server and report
# its URL, otherwise the Web UI is unreachable without a follow-up
# `omnigent server status`. Resolved after the headline above so a cold
# start isn't a silent terminal.
server_url = _discover_local_server_url()
_update_daemon_resolved_server_url(target, server_url)
else:
server_url = target
_echo_host_field("server", _cli_style(server_url, fg="cyan"))
if record.log_path is not None:
_echo_host_field("log", _display_path(Path(record.log_path)))
click.echo()
click.echo(_cli_style("Stop it with:", dim=True))
click.echo(f" {_cli_style(_host_stop_command(explicit_server), bold=True)}")
def _echo_host_field(label: str, value: str) -> None:
"""Echo one aligned ``label: value`` detail row.
:param label: Row label without its colon, e.g. ``"server"``.
:param value: Row value, possibly already ANSI-styled padding is
applied to the label so escape codes can't skew the alignment.
"""
click.echo(f" {label + ':':<8}{value}")
def _host_stop_command(explicit_server: str | None) -> str:
"""Build the ``host stop`` command that mirrors how ``host`` was invoked.
``host`` and ``host stop`` resolve their target the same way (the
``--server`` value, else config, else local), so repeating the flag the
user omitted would be noise and repeating the one they passed keeps the
command correct when config names a different target.
:param explicit_server: The ``--server`` value as the user spelled it,
e.g. ``"https://example.databricksapps.com"`` or ``""`` for local
mode. ``None`` when the option was omitted.
:returns: A copy-pasteable command, e.g. ``"omnigent host stop"``.
"""
if explicit_server is None:
return "omnigent host stop"
stop_target = explicit_server if explicit_server else '""'
return f"omnigent host stop --server {stop_target}"
@cli.group("host", cls=_HostGroup, invoke_without_command=True)
@click.option("--server", default=None, help="Remote omnigent server URL.")
@click.option(
"--background",
"background",
is_flag=True,
default=False,
help=(
"Spawn the host daemon as a detached background process (returning "
"immediately) instead of running it in the foreground. Reuses a "
"healthy daemon if one is already up. Sign-in still happens in the "
"foreground, before the spawn."
),
)
@click.option(
"--non-interactive",
"non_interactive",
@@ -7656,7 +7794,12 @@ def _prompt_stop_local_server() -> None:
),
)
@click.pass_context
def host(ctx: click.Context, server: str | None, non_interactive: bool) -> None:
def host(
ctx: click.Context,
server: str | None,
background: bool,
non_interactive: bool,
) -> None:
"""
Register this machine as a host with a server.
@@ -7665,6 +7808,7 @@ def host(ctx: click.Context, server: str | None, non_interactive: bool) -> None:
omnigent host https://omnigent-app.databricksapps.com
omnigent host --server https://omnigent-app.databricksapps.com
omnigent host "" # spawn + connect to a local server
omnigent host --background # spawn detached, return immediately
The server URL may be given positionally (``omnigent host
<url>``) or via ``--server <url>``. A leading ``status``, ``stop``,
@@ -7674,13 +7818,16 @@ def host(ctx: click.Context, server: str | None, non_interactive: bool) -> None:
in, ``host`` runs the same flow ``omnigent login`` would before
connecting (an interactive browser flow). Pass ``--non-interactive``
to keep the old scripted behavior: fail with the login command to run
instead of prompting.
instead of prompting. This holds for ``--background`` too: the login
flow runs here, in your terminal, before the daemon is detached.
:param ctx: Click invocation context. ``ctx.invoked_subcommand`` is
set when a management subcommand such as ``"status"`` is running.
:param server: Remote Omnigent server URL, e.g.
``"https://example.databricksapps.com"``. ``None`` falls back
to config; empty string selects local mode.
:param background: When ``True``, spawn the daemon detached and return
instead of running the daemon loop in the foreground.
:param non_interactive: When ``True``, never launch the browser login
for an un-authed remote server fail with the ``omnigent login``
hint instead.
@@ -7689,6 +7836,9 @@ def host(ctx: click.Context, server: str | None, non_interactive: bool) -> None:
ctx.obj["server"] = server
if ctx.invoked_subcommand is not None:
return
# Kept before the config fallback below: `--background` echoes a `host
# stop` command that mirrors how this command was invoked.
explicit_server = server
cfg = _load_effective_config()
if server is None:
server = cfg.get("server")
@@ -7699,6 +7849,14 @@ def host(ctx: click.Context, server: str | None, non_interactive: bool) -> None:
# the sign-in pre-flight.
remote_mode = bool(server)
if background:
_run_background_host(
server,
explicit_server=explicit_server,
non_interactive=non_interactive,
)
return
from omnigent.host.connect import run_host_process
# ``host`` IS the daemon (foreground). With no server URL, start (or
+187
View File
@@ -635,3 +635,190 @@ def test_host_http_json_handles_remote_headers_oserror() -> None:
assert "OSError" in result.body if isinstance(result.body, str) else True
# Ensure we didn't cache the failed result.
assert url not in _host_http_headers_cache
# ── host --background ─────────────────────────────────────────────
def _patch_background_host_spawn(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
*,
pid: int = 4242,
) -> tuple[list[list[str]], Path]:
"""
Isolate ``host --background`` from real daemon spawns and log files.
:param monkeypatch: Pytest monkeypatch fixture.
:param tmp_path: Test-scoped directory used as the config home.
:param pid: Fake pid the stub spawn reports, e.g. ``4242``.
:returns: The recorded spawn argv list and the fake daemon log path.
"""
from omnigent.cli import _SpawnedDaemonProcess
monkeypatch.setenv("OMNIGENT_CONFIG_HOME", str(tmp_path))
monkeypatch.setattr("omnigent.cli._HOST_PID_PATH", tmp_path / "host.pid")
# No fixed grace: the stubbed pid is trivially "alive", so waiting for it
# only slows the test down.
monkeypatch.setattr("omnigent.cli._BACKGROUND_HOST_GRACE_S", 0.0)
monkeypatch.setattr("omnigent.cli._pid_alive", lambda checked: checked == pid)
# Local mode waits for the server the daemon owns; no real server here.
monkeypatch.setattr("omnigent.cli._discover_local_server_url", lambda: "http://127.0.0.1:6767")
log_path = tmp_path / "host-test.log"
log_path.write_text("")
spawned_args: list[list[str]] = []
def _fake_spawn(*, args: list[str], env: dict[str, str]) -> _SpawnedDaemonProcess:
"""Record the daemon argv instead of spawning a process.
:param args: Daemon process argv.
:param env: Daemon environment (ignored).
:returns: Fake spawned-process metadata.
"""
del env
spawned_args.append(args)
return _SpawnedDaemonProcess(pid=pid, log_path=str(log_path))
monkeypatch.setattr("omnigent.cli._spawn_host_daemon_process", _fake_spawn)
return spawned_args, log_path
def test_host_background_spawns_detached_daemon(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
``host --background`` spawns the detached daemon and reports pid + log.
A detached daemon prints nothing to the terminal, so the pid and log
path are the only handles the user gets on it.
"""
spawned_args, log_path = _patch_background_host_spawn(monkeypatch, tmp_path)
result = CliRunner().invoke(cli, ["host", "--background"])
assert result.exit_code == 0, result.output
assert "pid 4242" in result.output
assert log_path.name in result.output
# `--server` was omitted, so the stop hint omits it too — a bare `host
# stop` resolves its target exactly like the bare `host` that started it.
assert "omnigent host stop" in result.output
assert "--server" not in result.output
# Bare `--background` is local mode: the daemon owns the local server, so
# its URL is reported too (the Web UI is otherwise unreachable).
assert spawned_args and "--local" in spawned_args[0]
assert "server: http://127.0.0.1:6767" in result.output
def test_host_background_does_not_block(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
``host --background`` must not run the foreground daemon loop.
The whole point of the flag is that the blocking ``run_host_process``
loop (and the in-process local-server bring-up that precedes it) moves
into the detached child.
"""
_patch_background_host_spawn(monkeypatch, tmp_path)
foreground_runs: list[str] = []
def _fail_local_server() -> LocalServerStartup:
raise AssertionError("--background must not start the local server in-process")
with (
patch(
"omnigent.host.connect.run_host_process",
lambda server_url, **kwargs: foreground_runs.append(server_url),
),
patch("omnigent.cli.ensure_local_omnigent_server", _fail_local_server),
):
result = CliRunner().invoke(cli, ["host", "--background"])
assert result.exit_code == 0, result.output
assert foreground_runs == []
def test_host_background_reuses_running_daemon(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
``host --background`` adopts a healthy daemon instead of double-spawning.
Two daemons for one target would register the same machine twice; the
command reports the existing pid and returns.
"""
from omnigent.cli import (
_LOCAL_DAEMON_MARKER,
_HostDaemonRecord,
_write_daemon_record,
server_config_signature,
)
spawned_args, _ = _patch_background_host_spawn(monkeypatch, tmp_path)
monkeypatch.setattr("omnigent.cli._pid_alive", lambda checked: checked in {4242, 5150})
_write_daemon_record(
_HostDaemonRecord(
pid=5150,
target=_LOCAL_DAEMON_MARKER,
mode="local",
server_url=None,
log_path=str(tmp_path / "existing.log"),
# Fresh record: young daemons skip the tunnel-health probe.
started_at=int(time.time()),
# No host_id: the tmp config home has no identity file, and a
# mismatch would tear the "running" daemon down as stale.
host_id=None,
config_sig=server_config_signature(),
)
)
result = CliRunner().invoke(cli, ["host", "--background", "--server", ""])
assert result.exit_code == 0, result.output
assert "already running (pid 5150" in result.output
assert "server: http://127.0.0.1:6767" in result.output
# Local mode was requested explicitly, so the stop hint says so too.
assert 'omnigent host stop --server ""' in result.output
assert spawned_args == [], "a healthy daemon must not be respawned"
def test_host_background_signs_in_before_spawning(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""
Sign-in for a remote server happens in the foreground, before the spawn.
The detached daemon has no terminal to run the browser login on, so an
un-authed Databricks-fronted target must be authenticated by this
invocation first — and ``--non-interactive`` must still reach that check.
"""
spawned_args, _ = _patch_background_host_spawn(monkeypatch, tmp_path)
auth_calls: list[tuple[str, bool]] = []
def _fake_auth(server: str, *, non_interactive: bool = False) -> None:
"""Record the sign-in pre-flight.
:param server: Server URL being authenticated.
:param non_interactive: Whether prompting is suppressed.
"""
assert spawned_args == [], "sign-in must precede the daemon spawn"
auth_calls.append((server, non_interactive))
monkeypatch.setattr("omnigent.cli._ensure_databricks_server_auth", _fake_auth)
result = CliRunner().invoke(
cli, ["host", "--background", "--server", "https://example.databricksapps.com"]
)
assert result.exit_code == 0, result.output
assert auth_calls == [("https://example.databricksapps.com", False)]
assert spawned_args and "--server" in spawned_args[0]
# Remote target: reported as-is (a server we don't own isn't discovered),
# and named in the stop hint because a bare stop would resolve the
# configured target instead.
assert "server: https://example.databricksapps.com" in result.output
assert "omnigent host stop --server https://example.databricksapps.com" in result.output