Coverage for little_loops / host_runner.py: 41%

241 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:54 -0500

1"""Host CLI abstraction layer. 

2 

3Provides a host-agnostic ``HostRunner`` Protocol and a concrete 

4``ClaudeCodeRunner`` implementation that builds argv for the ``claude`` CLI. 

5``resolve_host()`` discovers the active host runner from environment overrides 

6or by probing for known host binaries on ``PATH``. 

7 

8This module is the foundation for FEAT-1464's call-site migrations 

9(FEAT-1468): production sites currently building ``claude`` argv inline will 

10consult ``resolve_host()`` and use the returned runner's factory methods to 

11build invocations. FEAT-1467 introduces only the abstraction and its 

12ClaudeCode implementation; no production sites are migrated yet. 

13 

14Public exports: 

15 HostInvocation: frozen value object describing a host invocation 

16 HostCapabilities: capability flags describing what a host supports 

17 HostRunner: Protocol that host runners must satisfy 

18 ClaudeCodeRunner: ClaudeCode CLI implementation 

19 resolve_host: discovery entry point 

20 HostNotConfigured: raised when no host can be resolved 

21 CapabilityNotSupported: warning emitted when a host lacks a capability 

22""" 

23 

24from __future__ import annotations 

25 

26import json 

27import shutil 

28import sys 

29import tempfile 

30import tomllib 

31import warnings 

32from dataclasses import dataclass, field 

33from pathlib import Path 

34from typing import Literal, Protocol, runtime_checkable 

35 

36__all__ = [ 

37 "CapabilityEntry", 

38 "CapabilityNotSupported", 

39 "CapabilityReport", 

40 "ClaudeCodeRunner", 

41 "CodexRunner", 

42 "HookEntry", 

43 "HostCapabilities", 

44 "HostInvocation", 

45 "HostNotConfigured", 

46 "HostRunner", 

47 "OpenCodeRunner", 

48 "PiRunner", 

49 "apply_host_cli_from_config", 

50 "resolve_host", 

51] 

52 

53 

54class HostNotConfigured(RuntimeError): 

55 """Raised when no host runner can be resolved from env or binary probe. 

56 

57 The error message includes a remediation hint pointing at the 

58 ``LL_HOST_CLI`` and ``LL_HOOK_HOST`` env vars and the ``orchestration.host_cli`` 

59 config key so users have a clear path to fix the failure. 

60 """ 

61 

62 

63class CapabilityNotSupported(UserWarning): 

64 """Emitted when a caller requests a capability the active host lacks. 

65 

66 Subclasses ``UserWarning`` (not ``Warning``) so test code can capture it 

67 via :func:`pytest.warns` and production code can route it through 

68 :func:`warnings.simplefilter("error", CapabilityNotSupported)` for strict 

69 contexts. Mirrors the precedent set by :mod:`config.core` which emits 

70 :class:`DeprecationWarning` via ``warnings.warn(..., stacklevel=2)``. 

71 """ 

72 

73 

74@dataclass(frozen=True) 

75class HostCapabilities: 

76 """Capability flags describing what a host runner supports. 

77 

78 Each flag corresponds to a feature that may or may not be available on 

79 a given host. Call sites that require a capability should check the 

80 relevant flag and either fall back gracefully or emit 

81 :class:`CapabilityNotSupported`. 

82 """ 

83 

84 streaming: bool = False 

85 permission_skip: bool = False 

86 agent_select: bool = False 

87 tool_allowlist: bool = False 

88 

89 

90@dataclass(frozen=True) 

91class HostInvocation: 

92 """Immutable description of how to invoke a host CLI. 

93 

94 Returned by the ``build_*`` factory methods on :class:`HostRunner`. Call 

95 sites pass ``binary`` + ``args`` to :mod:`subprocess` and merge ``env`` 

96 into the child process environment. ``capabilities`` records the host's 

97 capability surface so callers can branch on what was actually wired. 

98 

99 Frozen because instances cross the runner/caller boundary; mutating one 

100 in-flight would silently corrupt argv. This establishes the 

101 ``frozen=True`` convention for new value objects in ``scripts/little_loops/``. 

102 """ 

103 

104 binary: str 

105 args: list[str] 

106 env: dict[str, str] = field(default_factory=dict) 

107 capabilities: HostCapabilities = field(default_factory=HostCapabilities) 

108 cleanup_paths: tuple[Path, ...] = field(default_factory=tuple) 

109 

110 

111@dataclass(frozen=True) 

112class CapabilityEntry: 

113 """A single host capability with its support status. 

114 

115 ``status`` is one of ``"full"``, ``"partial"``, or ``"unsupported"``. 

116 ``note`` carries an optional human-readable explanation (e.g. workaround). 

117 """ 

118 

119 name: str 

120 status: Literal["full", "partial", "unsupported"] 

121 note: str = "" 

122 

123 

124@dataclass(frozen=True) 

125class HookEntry: 

126 """A single hook's installation status for a given host. 

127 

128 ``status`` is one of ``"installed"``, ``"registered"``, ``"deferred"``, or ``"absent"``. 

129 """ 

130 

131 name: str 

132 status: Literal["installed", "registered", "deferred", "absent"] 

133 note: str = "" 

134 

135 

136@dataclass(frozen=True) 

137class CapabilityReport: 

138 """Full capability and hook report for one host runner. 

139 

140 Returned by :meth:`HostRunner.describe_capabilities`. Consumers (e.g. the 

141 ``ll-doctor`` CLI) iterate ``capabilities`` and ``hooks`` to produce a 

142 tabular preflight report. 

143 """ 

144 

145 host: str 

146 binary: str 

147 version: str 

148 capabilities: list[CapabilityEntry] = field(default_factory=list) 

149 hooks: list[HookEntry] = field(default_factory=list) 

150 

151 

152@runtime_checkable 

153class HostRunner(Protocol): 

154 """Protocol for host-specific CLI invocation builders. 

155 

156 Implementations construct argv lists for a particular agent host (Claude 

157 Code, Codex, OpenCode, Pi, ...). Each ``build_*`` factory returns a 

158 :class:`HostInvocation` describing the binary, arguments, environment, 

159 and capability surface. 

160 

161 Protocols are matched structurally — any class with a ``name`` attribute 

162 and the five methods below satisfies ``HostRunner`` whether or not it 

163 subclasses this Protocol explicitly. ``@runtime_checkable`` enables 

164 ``isinstance(obj, HostRunner)`` checks for registry validation. 

165 """ 

166 

167 name: str 

168 

169 def detect(self) -> bool: 

170 """Return True if this host is available in the current environment.""" 

171 ... 

172 

173 def build_streaming( 

174 self, 

175 *, 

176 prompt: str, 

177 working_dir: Path | None = None, 

178 resume: bool = False, 

179 agent: str | None = None, 

180 tools: list[str] | None = None, 

181 model: str | None = None, 

182 ) -> HostInvocation: 

183 """Build an invocation that streams structured output. 

184 

185 Used by the long-running orchestration paths (``ll-auto``, ``ll-parallel``, 

186 ``fsm.runners``) that need to consume turn-by-turn JSON events. 

187 """ 

188 ... 

189 

190 def build_blocking_json( 

191 self, 

192 *, 

193 prompt: str, 

194 model: str | None = None, 

195 json_schema: dict | None = None, 

196 ) -> HostInvocation: 

197 """Build a one-shot invocation that returns a single JSON blob.""" 

198 ... 

199 

200 def build_version_check(self) -> HostInvocation: 

201 """Build an invocation that prints the host's version and exits.""" 

202 ... 

203 

204 def build_detached(self, *, prompt: str) -> HostInvocation: 

205 """Build an invocation suitable for fire-and-forget detached execution.""" 

206 ... 

207 

208 def describe_capabilities(self) -> CapabilityReport: 

209 """Return a structured capability and hook report for this host.""" 

210 ... 

211 

212 

213class ClaudeCodeRunner: 

214 """``HostRunner`` for the ``claude`` CLI (Claude Code). 

215 

216 The argv produced by :meth:`build_streaming` mirrors 

217 :func:`little_loops.subprocess_utils.run_claude_command` so existing 

218 behavior is preserved when FEAT-1468 migrates call sites. The version 

219 snapshot lives in ``tests/test_host_runner.py::test_claude_runner_matches_legacy_args``. 

220 """ 

221 

222 name = "claude-code" 

223 

224 capabilities = HostCapabilities( 

225 streaming=True, 

226 permission_skip=True, 

227 agent_select=True, 

228 tool_allowlist=True, 

229 ) 

230 

231 def detect(self) -> bool: 

232 return shutil.which("claude") is not None 

233 

234 def build_streaming( 

235 self, 

236 *, 

237 prompt: str, 

238 working_dir: Path | None = None, 

239 resume: bool = False, 

240 agent: str | None = None, 

241 tools: list[str] | None = None, 

242 model: str | None = None, 

243 ) -> HostInvocation: 

244 args: list[str] = [ 

245 "--dangerously-skip-permissions", 

246 "--verbose", 

247 "--output-format", 

248 "stream-json", 

249 ] 

250 if resume: 

251 args.append("--continue") 

252 args += ["-p", prompt] 

253 if agent: 

254 args += ["--agent", agent] 

255 if tools: 

256 args += ["--tools", ",".join(tools)] 

257 if model: 

258 args += ["--model", model] 

259 

260 env: dict[str, str] = { 

261 "CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR": "1", 

262 "LL_NON_INTERACTIVE": "1", 

263 "DANGEROUSLY_SKIP_PERMISSIONS": "1", 

264 } 

265 if working_dir is not None: 

266 git_path = Path(working_dir) / ".git" 

267 if git_path.is_file(): 

268 gitdir_ref = git_path.read_text().strip() 

269 if gitdir_ref.startswith("gitdir: "): 

270 actual_gitdir = gitdir_ref[8:].strip() 

271 resolved = (Path(working_dir) / actual_gitdir).resolve() 

272 env["GIT_DIR"] = str(resolved) 

273 env["GIT_WORK_TREE"] = str(working_dir) 

274 

275 return HostInvocation( 

276 binary="claude", 

277 args=args, 

278 env=env, 

279 capabilities=self.capabilities, 

280 ) 

281 

282 def build_blocking_json( 

283 self, 

284 *, 

285 prompt: str, 

286 model: str | None = None, 

287 json_schema: dict | None = None, 

288 ) -> HostInvocation: 

289 args: list[str] = [ 

290 "--dangerously-skip-permissions", 

291 "--output-format", 

292 "json", 

293 "-p", 

294 prompt, 

295 ] 

296 if model: 

297 args += ["--model", model] 

298 # json_schema is part of the Protocol surface so other hosts can wire 

299 # structured-output constraints; the claude CLI does not currently 

300 # accept a schema flag, so we silently drop it here. Callers that 

301 # require schema enforcement should warn via CapabilityNotSupported. 

302 _ = json_schema 

303 return HostInvocation( 

304 binary="claude", 

305 args=args, 

306 env={"LL_NON_INTERACTIVE": "1", "DANGEROUSLY_SKIP_PERMISSIONS": "1"}, 

307 capabilities=self.capabilities, 

308 ) 

309 

310 def build_version_check(self) -> HostInvocation: 

311 return HostInvocation( 

312 binary="claude", 

313 args=["--version"], 

314 env={}, 

315 capabilities=self.capabilities, 

316 ) 

317 

318 def build_detached(self, *, prompt: str) -> HostInvocation: 

319 args = [ 

320 "--dangerously-skip-permissions", 

321 "-p", 

322 prompt, 

323 ] 

324 return HostInvocation( 

325 binary="claude", 

326 args=args, 

327 env={"LL_NON_INTERACTIVE": "1", "DANGEROUSLY_SKIP_PERMISSIONS": "1"}, 

328 capabilities=self.capabilities, 

329 ) 

330 

331 def describe_capabilities(self) -> CapabilityReport: 

332 return CapabilityReport( 

333 host=self.name, 

334 binary="claude", 

335 version="", 

336 capabilities=[ 

337 CapabilityEntry("streaming", "full"), 

338 CapabilityEntry("permission_skip", "full"), 

339 CapabilityEntry("agent_select", "full"), 

340 CapabilityEntry("tool_allowlist", "full"), 

341 # build_blocking_json silently drops json_schema (no Codex-style warning) 

342 CapabilityEntry( 

343 "json_schema", 

344 "unsupported", 

345 "claude CLI does not accept an inline schema flag; parameter is silently dropped", 

346 ), 

347 ], 

348 ) 

349 

350 

351class CodexRunner: 

352 """``HostRunner`` for the ``codex`` CLI (OpenAI Codex, Rust-based GA build). 

353 

354 Translates the Claude-shaped Protocol surface to Codex's ``codex exec`` 

355 headless mode. See ``thoughts/research/codex-headless-invocation.md`` for 

356 the verified flag-translation table and source citations. 

357 

358 Key divergences from :class:`ClaudeCodeRunner`: 

359 

360 - Prompt is **positional** (``codex exec <prompt>``); Claude's ``-p`` maps 

361 to Codex ``--profile``, so we cannot reuse the same flag. 

362 - The combined ``--dangerously-bypass-approvals-and-sandbox`` flag is the 

363 1:1 equivalent of Claude's ``--dangerously-skip-permissions`` (skips 

364 both approval prompt and sandbox restrictions). 

365 - Codex has no single-blob JSON mode; ``--json`` always streams NDJSON 

366 events. ``build_blocking_json`` uses ``--json`` and callers must consume 

367 the final event. 

368 - Agent selection (``--agent``) has no CLI-flag equivalent in Codex. 

369 Codex *does* support custom subagents 

370 (`developers.openai.com/codex/subagents`_) defined in 

371 ``.codex/agents/*.toml``, but they are spawned by the model during a 

372 conversation rather than selected by the caller at invocation. The 

373 ``agent`` parameter is therefore dropped with a 

374 :class:`CapabilityNotSupported` warning; to get persona behavior under 

375 Codex, ship native ``.codex/agents/*.toml`` files via 

376 ``ll-adapt-agents-for-codex`` (mirrors ``ll-adapt-skills-for-codex``). 

377 - Tool allowlist (``--tools``) has no Codex equivalent — Codex routes 

378 tool access through sandbox modes, and ``--profile`` is for auth, not 

379 persona. Emits :class:`CapabilityNotSupported` when requested. The 

380 warnings here deliberately diverge from 

381 :class:`ClaudeCodeRunner.build_blocking_json` which silently drops 

382 ``json_schema``; FEAT-1465 AC requires the warning be emitted here so 

383 callers can degrade explicitly. 

384 

385 .. _developers.openai.com/codex/subagents: 

386 https://developers.openai.com/codex/subagents 

387 - Resume restructures the subcommand to ``codex exec resume --last`` per 

388 Codex CLI reference, rather than appending a ``--continue`` flag. 

389 """ 

390 

391 name = "codex" 

392 

393 capabilities = HostCapabilities( 

394 streaming=True, 

395 permission_skip=True, 

396 agent_select=False, 

397 tool_allowlist=False, 

398 ) 

399 

400 def detect(self) -> bool: 

401 return shutil.which("codex") is not None 

402 

403 _VALID_SANDBOX_MODES = frozenset({"off", "read-only", "workspace-write", "danger-full-access"}) 

404 

405 @staticmethod 

406 def _sandbox_args(sandbox_mode: str | None) -> list[str]: 

407 """Return the Codex sandbox flag(s) for *sandbox_mode*. 

408 

409 ``None`` or ``"off"`` → ``--dangerously-bypass-approvals-and-sandbox`` 

410 (current default — skips both approval prompt and sandbox restrictions). 

411 ``"read-only"`` → ``--sandbox read-only`` 

412 ``"workspace-write"`` → ``--sandbox workspace-write`` 

413 ``"danger-full-access"`` → ``--sandbox danger-full-access`` 

414 Other values raise :exc:`ValueError`. 

415 """ 

416 if sandbox_mode is None or sandbox_mode == "off": 

417 return ["--dangerously-bypass-approvals-and-sandbox"] 

418 if sandbox_mode in CodexRunner._VALID_SANDBOX_MODES: 

419 return ["--sandbox", sandbox_mode] 

420 raise ValueError( 

421 f"Invalid sandbox_mode {sandbox_mode!r}. " 

422 f"Valid values: None, 'off', {', '.join(sorted(CodexRunner._VALID_SANDBOX_MODES))!r}" 

423 ) 

424 

425 @staticmethod 

426 def _emit_agent_warning(agent: str) -> None: 

427 # Note: this stderr print writes to the parent process's sys.stderr. 

428 # `subprocess_utils.run_claude_command()`'s `stream_callback(is_stderr=True)` 

429 # captures the spawned subprocess's stderr only — programmatic stream 

430 # consumers will not see this message; interactive terminals will. 

431 warnings.warn( 

432 "codex has no CLI-flag agent selection. Codex subagents " 

433 "(.codex/agents/*.toml) exist but are spawned by the model " 

434 "during a conversation, not selected at invocation. The " 

435 "'agent' parameter will be ignored; ship native Codex agent " 

436 "files for persona behavior under this host.", 

437 CapabilityNotSupported, 

438 stacklevel=3, 

439 ) 

440 print( 

441 f"[ll] Warning: Codex does not support --agent at invocation time (ENH-1531).\n" 

442 f" Persona hint {agent!r} was dropped. For interactive sessions,\n" 

443 f" run `ll-adapt --host codex --apply` and use `--agent {agent}`\n" 

444 f" in the Codex TUI.", 

445 file=sys.stderr, 

446 ) 

447 

448 @staticmethod 

449 def _inject_agent_persona( 

450 agent: str, prompt: str, working_dir: Path | None 

451 ) -> tuple[str, bool]: 

452 base = Path(working_dir) if working_dir is not None else Path.cwd() 

453 toml_path = base / ".codex" / "agents" / f"{agent}.toml" 

454 if not toml_path.exists(): 

455 return prompt, False 

456 try: 

457 data = tomllib.loads(toml_path.read_text()) 

458 except (OSError, tomllib.TOMLDecodeError): 

459 return prompt, False 

460 instructions = str(data.get("developer_instructions", "")).strip() 

461 if not instructions: 

462 return prompt, False 

463 return f"[Persona: {agent}]\n{instructions}\n\n---\n\n{prompt}", True 

464 

465 def build_streaming( 

466 self, 

467 *, 

468 prompt: str, 

469 working_dir: Path | None = None, 

470 resume: bool = False, 

471 agent: str | None = None, 

472 tools: list[str] | None = None, 

473 sandbox_mode: str | None = None, 

474 model: str | None = None, 

475 ) -> HostInvocation: 

476 del model # codex does not support --model in streaming mode 

477 if agent is not None: 

478 prompt, injected = self._inject_agent_persona(agent, prompt, working_dir) 

479 if not injected: 

480 self._emit_agent_warning(agent) 

481 if tools: 

482 warnings.warn( 

483 "codex host does not support a tool allowlist; " 

484 "tool access is controlled via --sandbox mode. " 

485 "Use sandbox_mode='read-only' or 'workspace-write' for " 

486 "constrained Codex execution (ENH-1529). " 

487 "The 'tools' parameter will be ignored.", 

488 CapabilityNotSupported, 

489 stacklevel=2, 

490 ) 

491 

492 args: list[str] = ["exec"] 

493 if resume: 

494 args += ["resume", "--last"] 

495 args += self._sandbox_args(sandbox_mode) 

496 args += [ 

497 "--json", 

498 "--skip-git-repo-check", 

499 ] 

500 if working_dir is not None: 

501 args += ["-C", str(working_dir)] 

502 args.append(prompt) 

503 

504 env: dict[str, str] = { 

505 "LL_NON_INTERACTIVE": "1", 

506 "DANGEROUSLY_SKIP_PERMISSIONS": "1", 

507 } 

508 if working_dir is not None: 

509 git_path = Path(working_dir) / ".git" 

510 if git_path.is_file(): 

511 gitdir_ref = git_path.read_text().strip() 

512 if gitdir_ref.startswith("gitdir: "): 

513 actual_gitdir = gitdir_ref[8:].strip() 

514 resolved = (Path(working_dir) / actual_gitdir).resolve() 

515 env["GIT_DIR"] = str(resolved) 

516 env["GIT_WORK_TREE"] = str(working_dir) 

517 

518 return HostInvocation( 

519 binary="codex", 

520 args=args, 

521 env=env, 

522 capabilities=self.capabilities, 

523 ) 

524 

525 def build_blocking_json( 

526 self, 

527 *, 

528 prompt: str, 

529 model: str | None = None, 

530 json_schema: dict | None = None, 

531 sandbox_mode: str | None = None, 

532 ) -> HostInvocation: 

533 args: list[str] = ["exec"] 

534 args += self._sandbox_args(sandbox_mode) 

535 args += [ 

536 "--json", 

537 "--skip-git-repo-check", 

538 ] 

539 if model: 

540 args += ["--model", model] 

541 

542 cleanup: tuple[Path, ...] = () 

543 if json_schema is not None: 

544 with tempfile.NamedTemporaryFile( 

545 delete=False, suffix=".json", prefix="ll-schema-", mode="w" 

546 ) as f: 

547 json.dump(json_schema, f) 

548 schema_file = Path(f.name) 

549 args += ["--output-schema", str(schema_file)] 

550 cleanup = (schema_file,) 

551 

552 args.append(prompt) 

553 return HostInvocation( 

554 binary="codex", 

555 args=args, 

556 env={"LL_NON_INTERACTIVE": "1", "DANGEROUSLY_SKIP_PERMISSIONS": "1"}, 

557 capabilities=self.capabilities, 

558 cleanup_paths=cleanup, 

559 ) 

560 

561 def build_version_check(self) -> HostInvocation: 

562 return HostInvocation( 

563 binary="codex", 

564 args=["--version"], 

565 env={}, 

566 capabilities=self.capabilities, 

567 ) 

568 

569 def build_detached(self, *, prompt: str, sandbox_mode: str | None = None) -> HostInvocation: 

570 args: list[str] = ["exec"] 

571 args += self._sandbox_args(sandbox_mode) 

572 args += [ 

573 "--skip-git-repo-check", 

574 prompt, 

575 ] 

576 return HostInvocation( 

577 binary="codex", 

578 args=args, 

579 env={"LL_NON_INTERACTIVE": "1", "DANGEROUSLY_SKIP_PERMISSIONS": "1"}, 

580 capabilities=self.capabilities, 

581 ) 

582 

583 def describe_capabilities(self) -> CapabilityReport: 

584 return CapabilityReport( 

585 host=self.name, 

586 binary="codex", 

587 version="", 

588 capabilities=[ 

589 CapabilityEntry("streaming", "full"), 

590 CapabilityEntry("permission_skip", "full"), 

591 # agent_select=False bool stays False (no native --agent CLI flag), 

592 # but status is "partial" because build_streaming injects persona via 

593 # .codex/agents/<name>.toml `developer_instructions` when present. 

594 # Fallback path (TOML absent) emits CapabilityNotSupported + stderr notice. 

595 CapabilityEntry( 

596 "agent_select", 

597 "partial", 

598 "codex has no native --agent CLI flag; build_streaming injects " 

599 "`developer_instructions` from .codex/agents/<name>.toml into the " 

600 "prompt as a persona prefix when the file exists. Falls back to " 

601 "CapabilityNotSupported + stderr warning when the TOML is absent.", 

602 ), 

603 # tool_allowlist=False; partial constraint available via sandbox_mode= 

604 # parameter on build_streaming / build_blocking_json / build_detached 

605 # (ENH-1529). The --tools allowlist parameter is still unsupported. 

606 CapabilityEntry( 

607 "tool_allowlist", 

608 "partial", 

609 "codex uses sandbox modes for tool access; --tools parameter is " 

610 "unsupported, but sandbox_mode= parameter on build methods offers " 

611 "constrained execution (off, read-only, workspace-write, " 

612 "danger-full-access)", 

613 ), 

614 # json_schema: partial — --output-schema requires a file path; ENH-1530 bridges 

615 # via temp file written in build_blocking_json, path returned in cleanup_paths 

616 CapabilityEntry( 

617 "json_schema", 

618 "partial", 

619 "codex --output-schema requires a file path; schema is written to a " 

620 "temp file and path returned in HostInvocation.cleanup_paths for caller cleanup", 

621 ), 

622 ], 

623 ) 

624 

625 

626class OpenCodeRunner: 

627 """``HostRunner`` stub for the ``opencode`` CLI (FEAT-1472, Option B). 

628 

629 No external CLI research has been performed. Every ``build_*`` method 

630 raises :class:`HostNotConfigured` pointing callers at 

631 ``LL_HOST_CLI=claude-code``. Registration in ``_HOST_RUNNER_REGISTRY`` 

632 means an explicit ``LL_HOST_CLI=opencode`` resolves to a useful error 

633 message rather than the generic "unknown host" error. The runner is 

634 deliberately absent from ``_PROBE_ORDER`` so no auto-detection occurs. 

635 """ 

636 

637 name = "opencode" 

638 

639 capabilities = HostCapabilities() 

640 

641 def detect(self) -> bool: 

642 return shutil.which("opencode") is not None 

643 

644 def build_streaming( 

645 self, 

646 *, 

647 prompt: str, 

648 working_dir: Path | None = None, 

649 resume: bool = False, 

650 agent: str | None = None, 

651 tools: list[str] | None = None, 

652 model: str | None = None, 

653 ) -> HostInvocation: 

654 raise HostNotConfigured( 

655 "OpenCode orchestration not yet wired — research OpenCode headless CLI. " 

656 "Set LL_HOST_CLI=claude-code to use Claude Code instead." 

657 ) 

658 

659 def build_blocking_json( 

660 self, 

661 *, 

662 prompt: str, 

663 model: str | None = None, 

664 json_schema: dict | None = None, 

665 ) -> HostInvocation: 

666 raise HostNotConfigured( 

667 "OpenCode orchestration not yet wired — research OpenCode headless CLI. " 

668 "Set LL_HOST_CLI=claude-code to use Claude Code instead." 

669 ) 

670 

671 def build_version_check(self) -> HostInvocation: 

672 raise HostNotConfigured( 

673 "OpenCode orchestration not yet wired — research OpenCode headless CLI. " 

674 "Set LL_HOST_CLI=claude-code to use Claude Code instead." 

675 ) 

676 

677 def build_detached(self, *, prompt: str) -> HostInvocation: 

678 raise HostNotConfigured( 

679 "OpenCode orchestration not yet wired — research OpenCode headless CLI. " 

680 "Set LL_HOST_CLI=claude-code to use Claude Code instead." 

681 ) 

682 

683 def describe_capabilities(self) -> CapabilityReport: 

684 return CapabilityReport( 

685 host=self.name, 

686 binary="opencode", 

687 version="", 

688 capabilities=[ 

689 CapabilityEntry( 

690 "host", 

691 "unsupported", 

692 "binary not configured (HostNotConfigured) — opencode orchestration not yet wired", 

693 ) 

694 ], 

695 ) 

696 

697 

698class PiRunner: 

699 """``HostRunner`` stub for the ``pi`` CLI (FEAT-1472). 

700 

701 Pi orchestration is tracked under FEAT-992; until that lands, all four 

702 ``build_*`` methods raise :class:`HostNotConfigured`. Unlike 

703 :class:`OpenCodeRunner`, ``("pi", "pi")`` is already present in 

704 ``_PROBE_ORDER`` (added in FEAT-1464), so registering ``PiRunner`` now 

705 activates that probe edge: hosts with ``pi`` on PATH will resolve to 

706 this stub and raise on the first ``build_*`` call. 

707 """ 

708 

709 name = "pi" 

710 

711 capabilities = HostCapabilities() 

712 

713 def detect(self) -> bool: 

714 return shutil.which("pi") is not None 

715 

716 def build_streaming( 

717 self, 

718 *, 

719 prompt: str, 

720 working_dir: Path | None = None, 

721 resume: bool = False, 

722 agent: str | None = None, 

723 tools: list[str] | None = None, 

724 model: str | None = None, 

725 ) -> HostInvocation: 

726 raise HostNotConfigured( 

727 "Pi orchestration not yet wired — see FEAT-992. " 

728 "Set LL_HOST_CLI=claude-code to use Claude Code instead." 

729 ) 

730 

731 def build_blocking_json( 

732 self, 

733 *, 

734 prompt: str, 

735 model: str | None = None, 

736 json_schema: dict | None = None, 

737 ) -> HostInvocation: 

738 raise HostNotConfigured( 

739 "Pi orchestration not yet wired — see FEAT-992. " 

740 "Set LL_HOST_CLI=claude-code to use Claude Code instead." 

741 ) 

742 

743 def build_version_check(self) -> HostInvocation: 

744 raise HostNotConfigured( 

745 "Pi orchestration not yet wired — see FEAT-992. " 

746 "Set LL_HOST_CLI=claude-code to use Claude Code instead." 

747 ) 

748 

749 def build_detached(self, *, prompt: str) -> HostInvocation: 

750 raise HostNotConfigured( 

751 "Pi orchestration not yet wired — see FEAT-992. " 

752 "Set LL_HOST_CLI=claude-code to use Claude Code instead." 

753 ) 

754 

755 def describe_capabilities(self) -> CapabilityReport: 

756 return CapabilityReport( 

757 host=self.name, 

758 binary="pi", 

759 version="", 

760 capabilities=[ 

761 CapabilityEntry( 

762 "host", 

763 "unsupported", 

764 "binary not configured (HostNotConfigured) — see FEAT-992", 

765 ) 

766 ], 

767 ) 

768 

769 

770# Built-in host runners keyed by their ``name`` attribute. Extensions may 

771# register additional runners but built-ins always win on collision — 

772# mirrors ``hooks/__init__.py:_dispatch_table`` (built-ins shadow extensions). 

773_HOST_RUNNER_REGISTRY: dict[str, type[HostRunner]] = { 

774 "claude-code": ClaudeCodeRunner, 

775 "codex": CodexRunner, 

776 "opencode": OpenCodeRunner, 

777 "pi": PiRunner, 

778} 

779 

780# Order of probing when no explicit host is configured. Matches the binary 

781# names users typically have on PATH; extends as new runners land. 

782_PROBE_ORDER: list[tuple[str, str]] = [ 

783 ("claude-code", "claude"), 

784 ("codex", "codex"), 

785 ("pi", "pi"), 

786] 

787 

788 

789def _remediation_hint() -> str: 

790 return ( 

791 "Set LL_HOST_CLI=<host> (one of: claude-code, codex, opencode, pi), " 

792 "or LL_HOOK_HOST, or configure orchestration.host_cli in ll-config.json, " 

793 "or install a supported host CLI on PATH (claude, codex, or pi)." 

794 ) 

795 

796 

797def resolve_host(env: dict[str, str] | None = None) -> HostRunner: 

798 """Resolve the active :class:`HostRunner`. 

799 

800 Detection order (first match wins): 

801 

802 1. ``LL_HOST_CLI`` environment variable — explicit override. 

803 2. ``LL_HOOK_HOST`` environment variable — falls back to the hooks-layer 

804 host identifier so users with an existing hook config don't need a 

805 second knob. 

806 3. Binary probe: ``claude`` → ``codex`` → ``pi`` (see ``_PROBE_ORDER``). 

807 4. Raise :class:`HostNotConfigured` with a remediation hint. 

808 

809 Args: 

810 env: Optional environment dict for testability. Defaults to 

811 ``os.environ`` when omitted. 

812 

813 Returns: 

814 A :class:`HostRunner` instance ready to build invocations. 

815 

816 Raises: 

817 HostNotConfigured: if no host can be resolved. 

818 """ 

819 import os 

820 

821 if env is None: 

822 env = dict(os.environ) 

823 

824 explicit = env.get("LL_HOST_CLI") or env.get("LL_HOOK_HOST") 

825 if explicit: 

826 runner_cls = _HOST_RUNNER_REGISTRY.get(explicit) 

827 if runner_cls is not None: 

828 return runner_cls() 

829 raise HostNotConfigured( 

830 f"Host {explicit!r} is not registered. Available: " 

831 f"{sorted(_HOST_RUNNER_REGISTRY)}. {_remediation_hint()}" 

832 ) 

833 

834 for host_name, binary in _PROBE_ORDER: 

835 if shutil.which(binary) is None: 

836 continue 

837 runner_cls = _HOST_RUNNER_REGISTRY.get(host_name) 

838 if runner_cls is not None: 

839 return runner_cls() 

840 

841 raise HostNotConfigured(f"No host CLI detected on PATH. {_remediation_hint()}") 

842 

843 

844def apply_host_cli_from_config(config: object) -> None: 

845 """Export ``orchestration.host_cli`` from *config* as ``LL_HOST_CLI``. 

846 

847 Reads ``config.orchestration.host_cli`` (a :class:`~little_loops.config.OrchestrationConfig` 

848 attribute) and sets ``LL_HOST_CLI`` in the process environment so that a 

849 subsequent call to :func:`resolve_host` picks up the config-driven value. 

850 

851 The env var takes precedence if already set — callers that set ``LL_HOST_CLI`` 

852 explicitly in their environment are not overridden. This matches the 

853 documented resolution order (env var > config key > binary probe). 

854 

855 Args: 

856 config: A :class:`~little_loops.config.BRConfig` instance (typed as 

857 ``object`` to avoid a circular import; the attribute access pattern 

858 is ``config.orchestration.host_cli``). 

859 """ 

860 import os 

861 

862 if os.environ.get("LL_HOST_CLI"): 

863 return # explicit env override takes precedence 

864 try: 

865 host_cli: str | None = config.orchestration.host_cli # type: ignore[attr-defined] 

866 except AttributeError: 

867 return # config object doesn't support orchestration (e.g. tests) 

868 if host_cli: 

869 os.environ["LL_HOST_CLI"] = host_cli