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("--out", default=None, help="write to this local path (else stdout)")
gt.add_argument("--session", default=None) 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("objective", help="what the nested operator should achieve")
sp.add_argument("--room-host", required=True) sp.add_argument("--room-host", required=True)
sp.add_argument("--room-port", type=int, required=True) sp.add_argument("--room-port", type=int, required=True)
sp.add_argument("--room-name", default="operator") 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, sp.add_argument("--stop", action="append", default=None,
help="a stop condition (repeatable)") help="a stop condition (repeatable)")
sp.add_argument("--target", choices=["host"], default="host") sp.add_argument("--target", choices=["host"], default="host")
@@ -366,6 +370,7 @@ def _run_spawn(args) -> int:
"op": "spawn", "objective": args.objective, "op": "spawn", "objective": args.objective,
"room": {"host": args.room_host, "port": args.room_port, "room": {"host": args.room_host, "port": args.room_port,
"name": args.room_name}, "name": args.room_name},
"runner": args.runner,
"stop": args.stop, "target": args.target, "stop": args.stop, "target": args.target,
"config_dir": args.config_dir, "allow_creds": args.allow_creds, "config_dir": args.config_dir, "allow_creds": args.allow_creds,
"skip_permissions": args.skip_permissions, "dry_run": not args.go}) "skip_permissions": args.skip_permissions, "dry_run": not args.go})
+137 -31
View File
@@ -26,6 +26,7 @@ from __future__ import annotations
import os import os
import platform import platform
import shlex
import shutil import shutil
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from pathlib import Path from pathlib import Path
@@ -112,20 +113,110 @@ def claude_present() -> bool:
return shutil.which("claude") is not None return shutil.which("claude") is not None
def install_plan(sysinfo: dict | None = None) -> list[list[str]]: # ── runner registry ──────────────────────────────────────────────────────────
"""Ordered candidate commands to get the `claude` CLI onto the system. # 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 def present(self) -> bool:
its own channel. Otherwise prefers the documented npm package; if npm is return bool(self.bin) and shutil.which(self.bin) is not None
missing, prefix the distro's node install. We never fabricate a download
URL — if none of these apply the caller surfaces "install claude manually".""" def argv(self, directive: str, *, skip_permissions: bool = False) -> list[str]:
override = os.environ.get("HH_CLAUDE_INSTALL") """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: if override:
return [["sh", "-c", override]] return [["sh", "-c", override]]
if not r.npm_package:
return []
info = sysinfo or detect_system() info = sysinfo or detect_system()
plans: list[list[str]] = [] plans: list[list[str]] = []
if info.get("has_npm"): if info.get("has_npm"):
plans.append(["npm", "install", "-g", CLAUDE_NPM_PACKAGE]) plans.append(["npm", "install", "-g", r.npm_package])
else: else:
mgr = info.get("pkg_manager") mgr = info.get("pkg_manager")
node_install = { node_install = {
@@ -138,31 +229,38 @@ def install_plan(sysinfo: dict | None = None) -> list[list[str]]:
}.get(mgr) }.get(mgr)
if node_install: if node_install:
plans.append(node_install) plans.append(node_install)
plans.append(["npm", "install", "-g", CLAUDE_NPM_PACKAGE]) plans.append(["npm", "install", "-g", r.npm_package])
return plans return plans
# ── credentials (gated) ───────────────────────────────────────────────────── # ── credentials (gated) ─────────────────────────────────────────────────────
def creds_source() -> Path | None: def creds_source(runner: "Runner | str | None" = None) -> Path | None:
"""The parent session's own OAuth/API credentials file, if present.""" """The parent session's own credentials file for the runner, if present
p = Path.home() / ".claude" / ".credentials.json" (defaults to Claude's ``~/.claude/.credentials.json``)."""
return p if p.exists() else None 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. """Decide whether to carry credentials into a child's config dir.
Default (``allow=False``) → carry nothing; the child must authenticate Default (``allow=False``) → carry nothing; the child must authenticate
itself (`claude` interactive login or its own $ANTHROPIC_API_KEY). Only when itself (its own interactive login or API key). Only when the operator
the operator *explicitly* opts in do we stage a copy of our own creds — and *explicitly* opts in do we stage a copy of our own creds — and only into a
only into a path we were handed. This function plans; the bridge performs the path we were handed. The destination mirrors the runner's own creds layout
copy so the side effect stays behind one audited flag.""" under ``dest_home``. This function plans; the bridge performs the copy so the
side effect stays behind one audited flag."""
if not allow: if not allow:
return {"staged": False, "reason": "creds gating on — child authenticates itself"} 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: if src is None:
return {"staged": False, "reason": "no parent credentials to carry"} 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)} 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, def build_run_argv(directive: str, *, skip_permissions: bool = False,
config_dir: str | None = None, config_dir: str | None = None,
runner: "Runner | str | None" = None,
claude_bin: str = "claude") -> list[str]: claude_bin: str = "claude") -> list[str]:
"""argv to launch a headless nested Claude session. `skip_permissions` maps """argv to launch a headless nested operator session for the chosen runner
to Claude Code's unattended flag (only for trusted, sandboxed autonomy); (default ``claude``). `skip_permissions` maps to the runner's unattended flag
`config_dir` points the child at an isolated $CLAUDE_CONFIG_DIR so its creds (only for trusted, sandboxed autonomy); `config_dir` is applied via
and state don't collide with the parent.""" :func:`child_env`, not the argv. `claude_bin` overrides the executable for the
argv = [claude_bin, "-p", directive] 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: if skip_permissions:
argv.append("--dangerously-skip-permissions") argv += list(r.unattended_flags)
return argv return argv
return r.argv(directive, skip_permissions=skip_permissions)
def child_env(config_dir: str | None = None) -> dict: def child_env(config_dir: str | None = None, *,
"""Environment for the child process. Isolates its config dir when given so runner: "Runner | str | None" = None) -> dict:
a nested session's auth/state is its own.""" """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) env = dict(os.environ)
if config_dir: r = get_runner(runner)
env["CLAUDE_CONFIG_DIR"] = str(config_dir) if config_dir and r.config_env:
env[r.config_env] = str(config_dir)
return env return env
+12 -7
View File
@@ -606,22 +606,27 @@ class OperatorBridge(Client):
dry_run = bool(req.get("dry_run", True)) dry_run = bool(req.get("dry_run", True))
skip_perms = bool(req.get("skip_permissions", False)) skip_perms = bool(req.get("skip_permissions", False))
config_dir = req.get("config_dir") 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 stop = req.get("stop") or None
directive = boot.compose_directive(objective, room, child_budget, stop=stop) directive = boot.compose_directive(objective, room, child_budget, stop=stop)
argv = boot.build_run_argv(directive, skip_permissions=skip_perms, argv = boot.build_run_argv(directive, skip_permissions=skip_perms,
config_dir=config_dir) config_dir=config_dir, runner=runner)
creds = boot.plan_creds(config_dir or "~", allow=allow_creds) 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, plan = {"argv": argv, "budget": child_budget.as_dict(), "creds": creds,
"target": req.get("target", "host"), "target": req.get("target", "host"),
"claude_present": boot.claude_present()} "runner": runner.name, "runner_present": present}
if dry_run: if dry_run:
return {"ok": True, "dry_run": True, "plan": plan} return {"ok": True, "dry_run": True, "plan": plan}
if not boot.claude_present(): if not present:
return {"ok": False, "error": "claude CLI not installed", return {"ok": False, "error": f"{runner.name} CLI not installed",
"install_plan": boot.install_plan(), "plan": plan} "install_plan": boot.install_plan(runner=runner), "plan": plan}
# Carry creds only behind the explicit gate. # Carry creds only behind the explicit gate.
if creds.get("staged"): if creds.get("staged"):
@@ -638,7 +643,7 @@ class OperatorBridge(Client):
try: try:
logf = open(self.session.dir / f"spawn-{self.seq + 1}.log", "a") # noqa: SIM115 logf = open(self.session.dir / f"spawn-{self.seq + 1}.log", "a") # noqa: SIM115
proc = subprocess.Popen( 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, stdout=logf, stderr=logf, stdin=subprocess.DEVNULL,
start_new_session=True, close_fds=True) start_new_session=True, close_fds=True)
except OSError as e: 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 sys
import asyncio import asyncio
import json import json
import os
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent)) 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 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 ──────────────────────────────────────────── # ── spawn op: gating + dry-run ────────────────────────────────────────────
def test_spawn_dry_run_returns_plan_without_launch(): def test_spawn_dry_run_returns_plan_without_launch():
async def go(): async def go():