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:
@@ -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