feat(operator): runner registry — spawn any agent CLI, not just Claude (P3)

Generalize the three Claude-hardcoded spots in bootstrap (launch argv,
install plan, creds/config-dir) behind a Runner registry so a nested
operator can be spawned with any agent CLI. The Claude path stays the
default and byte-identical.

- bootstrap.Runner + RUNNERS{claude,codex,gemini,cmd} + get_runner/runner_present
- build_run_argv / install_plan / creds_source / plan_creds / child_env all
  take an optional `runner` (default claude → unchanged behaviour)
- claude validated end-to-end; codex/gemini are best-effort defaults
  overridable via $HH_<RUNNER>_INSTALL etc.; generic `cmd` runner reads a
  full launch template from $HH_OPERATOR_CMD ({directive} placeholder)
- bridge _op_spawn resolves req["runner"], rejects unknown; plan now carries
  runner/runner_present (was claude_present)
- CLI: `spawn --runner {claude|codex|gemini|cmd}`
- 7 new unit tests; full suite 132 passed

Note: compose_directive still emits the Claude-flavoured ("hh-operator
skill") directive — the portable Layer-1 prompt is P4.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-06-28 23:11:41 -07:00
parent 300e33980a
commit 09cea40494
4 changed files with 247 additions and 41 deletions
+6 -1
View File
@@ -87,11 +87,15 @@ def _build_parser() -> argparse.ArgumentParser:
gt.add_argument("--out", default=None, help="write to this local path (else stdout)")
gt.add_argument("--session", default=None)
sp = sub.add_parser("spawn", help="spawn a nested Claude operator (budgeted)")
sp = sub.add_parser("spawn", help="spawn a nested operator (budgeted)")
sp.add_argument("objective", help="what the nested operator should achieve")
sp.add_argument("--room-host", required=True)
sp.add_argument("--room-port", type=int, required=True)
sp.add_argument("--room-name", default="operator")
sp.add_argument("--runner", default="claude",
choices=["claude", "codex", "gemini", "cmd"],
help="which agent CLI to spawn as the child operator "
"(default: claude; 'cmd' reads $HH_OPERATOR_CMD)")
sp.add_argument("--stop", action="append", default=None,
help="a stop condition (repeatable)")
sp.add_argument("--target", choices=["host"], default="host")
@@ -366,6 +370,7 @@ def _run_spawn(args) -> int:
"op": "spawn", "objective": args.objective,
"room": {"host": args.room_host, "port": args.room_port,
"name": args.room_name},
"runner": args.runner,
"stop": args.stop, "target": args.target,
"config_dir": args.config_dir, "allow_creds": args.allow_creds,
"skip_permissions": args.skip_permissions, "dry_run": not args.go})
+139 -33
View File
@@ -26,6 +26,7 @@ from __future__ import annotations
import os
import platform
import shlex
import shutil
from dataclasses import dataclass, replace
from pathlib import Path
@@ -112,20 +113,110 @@ def claude_present() -> bool:
return shutil.which("claude") is not None
def install_plan(sysinfo: dict | None = None) -> list[list[str]]:
"""Ordered candidate commands to get the `claude` CLI onto the system.
# ── runner registry ──────────────────────────────────────────────────────────
# A *runner* captures everything that varies by which agent CLI we spawn as the
# nested operator. The Claude path stays the default and byte-identical; other
# CLIs are first-class but **best-effort** — their exact flags/creds paths are
# meant to be pinned per-site via the override env vars (or routed through the
# generic ``cmd`` runner). Only ``claude`` is validated end-to-end today.
@dataclass(frozen=True)
class Runner:
name: str
bin: str # executable to locate / invoke
prompt_flags: tuple[str, ...] = ("-p",) # flag(s) that precede the directive
unattended_flags: tuple[str, ...] = () # appended when skip_permissions
npm_package: str | None = None # for npm-installable CLIs
install_env: str | None = None # env var holding a full install cmd
cmd_env: str | None = None # generic template env var (the 'cmd' runner)
creds_path: str | None = None # ~-relative source credential file
config_env: str | None = None # env var for an isolated config dir
Honours $HH_CLAUDE_INSTALL (a full shell command) first so a site can pin
its own channel. Otherwise prefers the documented npm package; if npm is
missing, prefix the distro's node install. We never fabricate a download
URL — if none of these apply the caller surfaces "install claude manually"."""
override = os.environ.get("HH_CLAUDE_INSTALL")
def present(self) -> bool:
return bool(self.bin) and shutil.which(self.bin) is not None
def argv(self, directive: str, *, skip_permissions: bool = False) -> list[str]:
"""Launch argv for this runner. The generic ``cmd`` runner reads a full
command template from ``$cmd_env`` and substitutes ``{directive}``;
everything else is ``[bin, *prompt_flags, directive] (+ unattended)``."""
if self.cmd_env:
tmpl = os.environ.get(self.cmd_env)
if not tmpl:
raise ValueError(
f"runner '{self.name}' needs ${self.cmd_env} set to a command "
"template (use {directive} as the placeholder)"
)
if "{directive}" in tmpl:
return [p.replace("{directive}", directive) for p in shlex.split(tmpl)]
return [*shlex.split(tmpl), directive]
a = [self.bin, *self.prompt_flags, directive]
if skip_permissions:
a += list(self.unattended_flags)
return a
def creds_source(self) -> Path | None:
if not self.creds_path:
return None
p = Path(self.creds_path).expanduser()
return p if p.exists() else None
RUNNERS: dict[str, Runner] = {
"claude": Runner(
"claude", "claude", ("-p",), ("--dangerously-skip-permissions",),
npm_package=CLAUDE_NPM_PACKAGE, install_env="HH_CLAUDE_INSTALL",
creds_path="~/.claude/.credentials.json", config_env="CLAUDE_CONFIG_DIR"),
# Best-effort defaults — override flags/install/creds via the env vars below
# or use the generic `cmd` runner if a CLI's interface differs.
"codex": Runner(
"codex", "codex", ("exec",), ("--dangerously-bypass-approvals-and-sandbox",),
npm_package="@openai/codex", install_env="HH_CODEX_INSTALL",
creds_path="~/.codex/auth.json", config_env="CODEX_HOME"),
"gemini": Runner(
"gemini", "gemini", ("-p",), ("--yolo",),
npm_package="@google/gemini-cli", install_env="HH_GEMINI_INSTALL",
creds_path="~/.gemini/oauth_creds.json", config_env="GEMINI_SYSTEM_MD"),
# Fully user-controlled escape hatch: $HH_OPERATOR_CMD is the launch template.
"cmd": Runner("cmd", "", cmd_env="HH_OPERATOR_CMD", install_env="HH_OPERATOR_INSTALL"),
}
def get_runner(runner: "Runner | str | None" = None) -> Runner:
"""Resolve a runner by name (default ``claude``). Pass-through if already a
:class:`Runner`. Raises ``ValueError`` on an unknown name."""
if isinstance(runner, Runner):
return runner
r = RUNNERS.get(runner or "claude")
if r is None:
raise ValueError(
f"unknown runner '{runner}' (known: {', '.join(RUNNERS)})"
)
return r
def runner_present(runner: "Runner | str | None" = None) -> bool:
return get_runner(runner).present()
def install_plan(sysinfo: dict | None = None, *,
runner: "Runner | str | None" = None) -> list[list[str]]:
"""Ordered candidate commands to get the runner's CLI onto the system.
Honours the runner's install-override env (``$HH_CLAUDE_INSTALL`` for claude)
first so a site can pin its own channel. Otherwise prefers the runner's
documented npm package; if npm is missing, prefix the distro's node install.
We never fabricate a download URL — a runner with no npm package (e.g. the
generic ``cmd`` runner) returns ``[]`` and the caller surfaces "install
manually"."""
r = get_runner(runner)
override = os.environ.get(r.install_env) if r.install_env else None
if override:
return [["sh", "-c", override]]
if not r.npm_package:
return []
info = sysinfo or detect_system()
plans: list[list[str]] = []
if info.get("has_npm"):
plans.append(["npm", "install", "-g", CLAUDE_NPM_PACKAGE])
plans.append(["npm", "install", "-g", r.npm_package])
else:
mgr = info.get("pkg_manager")
node_install = {
@@ -138,31 +229,38 @@ def install_plan(sysinfo: dict | None = None) -> list[list[str]]:
}.get(mgr)
if node_install:
plans.append(node_install)
plans.append(["npm", "install", "-g", CLAUDE_NPM_PACKAGE])
plans.append(["npm", "install", "-g", r.npm_package])
return plans
# ── credentials (gated) ─────────────────────────────────────────────────────
def creds_source() -> Path | None:
"""The parent session's own OAuth/API credentials file, if present."""
p = Path.home() / ".claude" / ".credentials.json"
return p if p.exists() else None
def creds_source(runner: "Runner | str | None" = None) -> Path | None:
"""The parent session's own credentials file for the runner, if present
(defaults to Claude's ``~/.claude/.credentials.json``)."""
return get_runner(runner).creds_source()
def plan_creds(dest_home: str | os.PathLike, *, allow: bool) -> dict:
def plan_creds(dest_home: str | os.PathLike, *, allow: bool,
runner: "Runner | str | None" = None) -> dict:
"""Decide whether to carry credentials into a child's config dir.
Default (``allow=False``) → carry nothing; the child must authenticate
itself (`claude` interactive login or its own $ANTHROPIC_API_KEY). Only when
the operator *explicitly* opts in do we stage a copy of our own creds — and
only into a path we were handed. This function plans; the bridge performs the
copy so the side effect stays behind one audited flag."""
itself (its own interactive login or API key). Only when the operator
*explicitly* opts in do we stage a copy of our own creds — and only into a
path we were handed. The destination mirrors the runner's own creds layout
under ``dest_home``. This function plans; the bridge performs the copy so the
side effect stays behind one audited flag."""
if not allow:
return {"staged": False, "reason": "creds gating on — child authenticates itself"}
src = creds_source()
r = get_runner(runner)
src = r.creds_source()
if src is None:
return {"staged": False, "reason": "no parent credentials to carry"}
dest = Path(dest_home) / ".claude" / ".credentials.json"
try:
rel = Path(r.creds_path).expanduser().relative_to(Path.home())
except ValueError:
rel = Path(Path(r.creds_path).name)
dest = Path(dest_home) / rel
return {"staged": True, "src": str(src), "dest": str(dest)}
@@ -195,21 +293,29 @@ def compose_directive(objective: str, room: dict, budget: Budget,
def build_run_argv(directive: str, *, skip_permissions: bool = False,
config_dir: str | None = None,
runner: "Runner | str | None" = None,
claude_bin: str = "claude") -> list[str]:
"""argv to launch a headless nested Claude session. `skip_permissions` maps
to Claude Code's unattended flag (only for trusted, sandboxed autonomy);
`config_dir` points the child at an isolated $CLAUDE_CONFIG_DIR so its creds
and state don't collide with the parent."""
argv = [claude_bin, "-p", directive]
if skip_permissions:
argv.append("--dangerously-skip-permissions")
return argv
"""argv to launch a headless nested operator session for the chosen runner
(default ``claude``). `skip_permissions` maps to the runner's unattended flag
(only for trusted, sandboxed autonomy); `config_dir` is applied via
:func:`child_env`, not the argv. `claude_bin` overrides the executable for the
default claude runner (back-compat)."""
r = get_runner(runner)
if r.name == "claude" and claude_bin != "claude":
argv = [claude_bin, *r.prompt_flags, directive]
if skip_permissions:
argv += list(r.unattended_flags)
return argv
return r.argv(directive, skip_permissions=skip_permissions)
def child_env(config_dir: str | None = None) -> dict:
"""Environment for the child process. Isolates its config dir when given so
a nested session's auth/state is its own."""
def child_env(config_dir: str | None = None, *,
runner: "Runner | str | None" = None) -> dict:
"""Environment for the child process. Isolates its config dir when given (via
the runner's config-dir env var, e.g. $CLAUDE_CONFIG_DIR) so a nested
session's auth/state is its own."""
env = dict(os.environ)
if config_dir:
env["CLAUDE_CONFIG_DIR"] = str(config_dir)
r = get_runner(runner)
if config_dir and r.config_env:
env[r.config_env] = str(config_dir)
return env
+12 -7
View File
@@ -606,22 +606,27 @@ class OperatorBridge(Client):
dry_run = bool(req.get("dry_run", True))
skip_perms = bool(req.get("skip_permissions", False))
config_dir = req.get("config_dir")
try:
runner = boot.get_runner(req.get("runner"))
except ValueError as e:
return {"ok": False, "error": str(e)}
stop = req.get("stop") or None
directive = boot.compose_directive(objective, room, child_budget, stop=stop)
argv = boot.build_run_argv(directive, skip_permissions=skip_perms,
config_dir=config_dir)
creds = boot.plan_creds(config_dir or "~", allow=allow_creds)
config_dir=config_dir, runner=runner)
creds = boot.plan_creds(config_dir or "~", allow=allow_creds, runner=runner)
present = boot.runner_present(runner)
plan = {"argv": argv, "budget": child_budget.as_dict(), "creds": creds,
"target": req.get("target", "host"),
"claude_present": boot.claude_present()}
"runner": runner.name, "runner_present": present}
if dry_run:
return {"ok": True, "dry_run": True, "plan": plan}
if not boot.claude_present():
return {"ok": False, "error": "claude CLI not installed",
"install_plan": boot.install_plan(), "plan": plan}
if not present:
return {"ok": False, "error": f"{runner.name} CLI not installed",
"install_plan": boot.install_plan(runner=runner), "plan": plan}
# Carry creds only behind the explicit gate.
if creds.get("staged"):
@@ -638,7 +643,7 @@ class OperatorBridge(Client):
try:
logf = open(self.session.dir / f"spawn-{self.seq + 1}.log", "a") # noqa: SIM115
proc = subprocess.Popen(
argv, env=boot.child_env(config_dir),
argv, env=boot.child_env(config_dir, runner=runner),
stdout=logf, stderr=logf, stdin=subprocess.DEVNULL,
start_new_session=True, close_fds=True)
except OSError as e:
+90
View File
@@ -7,6 +7,7 @@ Each test builds the bridge *inside* its own ``asyncio.run`` so the bridge's
import sys
import asyncio
import json
import os
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
@@ -335,6 +336,95 @@ def test_build_run_argv_flags():
assert "--dangerously-skip-permissions" not in argv
# ── runner registry (P3): model-agnostic spawn ───────────────────────────────
def test_get_runner_default_and_unknown():
assert boot.get_runner().name == "claude"
assert boot.get_runner("codex").name == "codex"
import pytest
with pytest.raises(ValueError):
boot.get_runner("bogus")
def test_build_run_argv_per_runner():
# claude default unchanged
assert boot.build_run_argv("do x")[:3] == ["claude", "-p", "do x"]
# codex: own subcommand + unattended flag
argv = boot.build_run_argv("do x", skip_permissions=True, runner="codex")
assert argv[:3] == ["codex", "exec", "do x"]
assert "--dangerously-bypass-approvals-and-sandbox" in argv
# gemini
assert boot.build_run_argv("do x", runner="gemini")[:3] == ["gemini", "-p", "do x"]
# back-compat: claude_bin override still honoured for the default runner
assert boot.build_run_argv("do x", claude_bin="/opt/claude")[0] == "/opt/claude"
def test_cmd_runner_template():
import os
os.environ["HH_OPERATOR_CMD"] = "mycli --go {directive}"
try:
assert boot.build_run_argv("do x", runner="cmd") == ["mycli", "--go", "do x"]
finally:
del os.environ["HH_OPERATOR_CMD"]
# no {directive} placeholder → directive appended
os.environ["HH_OPERATOR_CMD"] = "mycli run"
try:
assert boot.build_run_argv("do x", runner="cmd") == ["mycli", "run", "do x"]
finally:
del os.environ["HH_OPERATOR_CMD"]
# missing template env → clear error
import pytest
with pytest.raises(ValueError):
boot.build_run_argv("do x", runner="cmd")
def test_install_plan_per_runner():
assert boot.install_plan({"has_npm": True}, runner="codex")[0] == \
["npm", "install", "-g", "@openai/codex"]
assert boot.install_plan({"has_npm": True}, runner="gemini")[0] == \
["npm", "install", "-g", "@google/gemini-cli"]
# generic cmd runner has no npm package → manual install
assert boot.install_plan({"has_npm": True}, runner="cmd") == []
# per-runner override env wins
import os
os.environ["HH_CODEX_INSTALL"] = "echo codex-install"
try:
assert boot.install_plan({"has_npm": True}, runner="codex") == \
[["sh", "-c", "echo codex-install"]]
finally:
del os.environ["HH_CODEX_INSTALL"]
def test_child_env_uses_runner_config_var():
assert boot.child_env("/tmp/cd")["CLAUDE_CONFIG_DIR"] == "/tmp/cd"
assert boot.child_env("/tmp/cd", runner="codex")["CODEX_HOME"] == "/tmp/cd"
# claude's config var is not set under a different runner
assert boot.child_env("/tmp/cd", runner="codex").get("CLAUDE_CONFIG_DIR") == \
os.environ.get("CLAUDE_CONFIG_DIR")
def test_spawn_dry_run_honours_runner():
async def go():
b = _bridge("oracle")
b.budget = boot.Budget(depth=1, fanout=1, cost_usd=2.0)
r = await b._op_spawn({"objective": "scout", "runner": "codex",
"room": {"host": "h", "port": 7, "name": "kid"},
"dry_run": True})
assert r["ok"] and r["plan"]["runner"] == "codex"
assert r["plan"]["argv"][0] == "codex"
assert "runner_present" in r["plan"]
asyncio.run(go())
def test_spawn_rejects_unknown_runner():
async def go():
b = _bridge("oracle")
b.budget = boot.Budget(depth=1, fanout=1, cost_usd=2.0)
r = await b._op_spawn({"objective": "x", "runner": "bogus",
"room": {"host": "h", "port": 7}})
assert r["ok"] is False and "unknown runner" in r["error"]
asyncio.run(go())
# ── spawn op: gating + dry-run ────────────────────────────────────────────
def test_spawn_dry_run_returns_plan_without_launch():
async def go():