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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user