Coverage for little_loops / host_runner.py: 42%

238 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-08 15:34 -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 ) -> HostInvocation: 

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

183 

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

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

186 """ 

187 ... 

188 

189 def build_blocking_json( 

190 self, 

191 *, 

192 prompt: str, 

193 model: str | None = None, 

194 json_schema: dict | None = None, 

195 ) -> HostInvocation: 

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

197 ... 

198 

199 def build_version_check(self) -> HostInvocation: 

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

201 ... 

202 

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

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

205 ... 

206 

207 def describe_capabilities(self) -> CapabilityReport: 

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

209 ... 

210 

211 

212class ClaudeCodeRunner: 

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

214 

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

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

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

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

219 """ 

220 

221 name = "claude-code" 

222 

223 capabilities = HostCapabilities( 

224 streaming=True, 

225 permission_skip=True, 

226 agent_select=True, 

227 tool_allowlist=True, 

228 ) 

229 

230 def detect(self) -> bool: 

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

232 

233 def build_streaming( 

234 self, 

235 *, 

236 prompt: str, 

237 working_dir: Path | None = None, 

238 resume: bool = False, 

239 agent: str | None = None, 

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

241 ) -> HostInvocation: 

242 args: list[str] = [ 

243 "--dangerously-skip-permissions", 

244 "--verbose", 

245 "--output-format", 

246 "stream-json", 

247 ] 

248 if resume: 

249 args.append("--continue") 

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

251 if agent: 

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

253 if tools: 

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

255 

256 env: dict[str, str] = {"CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR": "1"} 

257 if working_dir is not None: 

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

259 if git_path.is_file(): 

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

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

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

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

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

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

266 

267 return HostInvocation( 

268 binary="claude", 

269 args=args, 

270 env=env, 

271 capabilities=self.capabilities, 

272 ) 

273 

274 def build_blocking_json( 

275 self, 

276 *, 

277 prompt: str, 

278 model: str | None = None, 

279 json_schema: dict | None = None, 

280 ) -> HostInvocation: 

281 args: list[str] = [ 

282 "--dangerously-skip-permissions", 

283 "--output-format", 

284 "json", 

285 "-p", 

286 prompt, 

287 ] 

288 if model: 

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

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

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

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

293 # require schema enforcement should warn via CapabilityNotSupported. 

294 _ = json_schema 

295 return HostInvocation( 

296 binary="claude", 

297 args=args, 

298 env={}, 

299 capabilities=self.capabilities, 

300 ) 

301 

302 def build_version_check(self) -> HostInvocation: 

303 return HostInvocation( 

304 binary="claude", 

305 args=["--version"], 

306 env={}, 

307 capabilities=self.capabilities, 

308 ) 

309 

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

311 args = [ 

312 "--dangerously-skip-permissions", 

313 "-p", 

314 prompt, 

315 ] 

316 return HostInvocation( 

317 binary="claude", 

318 args=args, 

319 env={}, 

320 capabilities=self.capabilities, 

321 ) 

322 

323 def describe_capabilities(self) -> CapabilityReport: 

324 return CapabilityReport( 

325 host=self.name, 

326 binary="claude", 

327 version="", 

328 capabilities=[ 

329 CapabilityEntry("streaming", "full"), 

330 CapabilityEntry("permission_skip", "full"), 

331 CapabilityEntry("agent_select", "full"), 

332 CapabilityEntry("tool_allowlist", "full"), 

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

334 CapabilityEntry( 

335 "json_schema", 

336 "unsupported", 

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

338 ), 

339 ], 

340 ) 

341 

342 

343class CodexRunner: 

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

345 

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

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

348 the verified flag-translation table and source citations. 

349 

350 Key divergences from :class:`ClaudeCodeRunner`: 

351 

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

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

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

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

356 both approval prompt and sandbox restrictions). 

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

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

359 the final event. 

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

361 Codex *does* support custom subagents 

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

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

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

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

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

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

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

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

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

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

372 warnings here deliberately diverge from 

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

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

375 callers can degrade explicitly. 

376 

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

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

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

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

381 """ 

382 

383 name = "codex" 

384 

385 capabilities = HostCapabilities( 

386 streaming=True, 

387 permission_skip=True, 

388 agent_select=False, 

389 tool_allowlist=False, 

390 ) 

391 

392 def detect(self) -> bool: 

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

394 

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

396 

397 @staticmethod 

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

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

400 

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

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

403 ``"read-only"`` → ``--sandbox read-only`` 

404 ``"workspace-write"`` → ``--sandbox workspace-write`` 

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

406 Other values raise :exc:`ValueError`. 

407 """ 

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

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

410 if sandbox_mode in CodexRunner._VALID_SANDBOX_MODES: 

411 return ["--sandbox", sandbox_mode] 

412 raise ValueError( 

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

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

415 ) 

416 

417 @staticmethod 

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

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

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

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

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

423 warnings.warn( 

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

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

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

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

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

429 CapabilityNotSupported, 

430 stacklevel=3, 

431 ) 

432 print( 

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

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

435 f" run `ll-adapt-agents-for-codex --apply` and use `--agent {agent}`\n" 

436 f" in the Codex TUI.", 

437 file=sys.stderr, 

438 ) 

439 

440 @staticmethod 

441 def _inject_agent_persona( 

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

443 ) -> tuple[str, bool]: 

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

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

446 if not toml_path.exists(): 

447 return prompt, False 

448 try: 

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

450 except (OSError, tomllib.TOMLDecodeError): 

451 return prompt, False 

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

453 if not instructions: 

454 return prompt, False 

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

456 

457 def build_streaming( 

458 self, 

459 *, 

460 prompt: str, 

461 working_dir: Path | None = None, 

462 resume: bool = False, 

463 agent: str | None = None, 

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

465 sandbox_mode: str | None = None, 

466 ) -> HostInvocation: 

467 if agent is not None: 

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

469 if not injected: 

470 self._emit_agent_warning(agent) 

471 if tools: 

472 warnings.warn( 

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

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

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

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

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

478 CapabilityNotSupported, 

479 stacklevel=2, 

480 ) 

481 

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

483 if resume: 

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

485 args += self._sandbox_args(sandbox_mode) 

486 args += [ 

487 "--json", 

488 "--skip-git-repo-check", 

489 ] 

490 if working_dir is not None: 

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

492 args.append(prompt) 

493 

494 env: dict[str, str] = {} 

495 if working_dir is not None: 

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

497 if git_path.is_file(): 

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

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

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

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

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

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

504 

505 return HostInvocation( 

506 binary="codex", 

507 args=args, 

508 env=env, 

509 capabilities=self.capabilities, 

510 ) 

511 

512 def build_blocking_json( 

513 self, 

514 *, 

515 prompt: str, 

516 model: str | None = None, 

517 json_schema: dict | None = None, 

518 sandbox_mode: str | None = None, 

519 ) -> HostInvocation: 

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

521 args += self._sandbox_args(sandbox_mode) 

522 args += [ 

523 "--json", 

524 "--skip-git-repo-check", 

525 ] 

526 if model: 

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

528 

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

530 if json_schema is not None: 

531 with tempfile.NamedTemporaryFile( 

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

533 ) as f: 

534 json.dump(json_schema, f) 

535 schema_file = Path(f.name) 

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

537 cleanup = (schema_file,) 

538 

539 args.append(prompt) 

540 return HostInvocation( 

541 binary="codex", 

542 args=args, 

543 env={}, 

544 capabilities=self.capabilities, 

545 cleanup_paths=cleanup, 

546 ) 

547 

548 def build_version_check(self) -> HostInvocation: 

549 return HostInvocation( 

550 binary="codex", 

551 args=["--version"], 

552 env={}, 

553 capabilities=self.capabilities, 

554 ) 

555 

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

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

558 args += self._sandbox_args(sandbox_mode) 

559 args += [ 

560 "--skip-git-repo-check", 

561 prompt, 

562 ] 

563 return HostInvocation( 

564 binary="codex", 

565 args=args, 

566 env={}, 

567 capabilities=self.capabilities, 

568 ) 

569 

570 def describe_capabilities(self) -> CapabilityReport: 

571 return CapabilityReport( 

572 host=self.name, 

573 binary="codex", 

574 version="", 

575 capabilities=[ 

576 CapabilityEntry("streaming", "full"), 

577 CapabilityEntry("permission_skip", "full"), 

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

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

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

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

582 CapabilityEntry( 

583 "agent_select", 

584 "partial", 

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

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

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

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

589 ), 

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

591 # parameter on build_streaming / build_blocking_json / build_detached 

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

593 CapabilityEntry( 

594 "tool_allowlist", 

595 "partial", 

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

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

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

599 "danger-full-access)", 

600 ), 

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

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

603 CapabilityEntry( 

604 "json_schema", 

605 "partial", 

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

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

608 ), 

609 ], 

610 ) 

611 

612 

613class OpenCodeRunner: 

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

615 

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

617 raises :class:`HostNotConfigured` pointing callers at 

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

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

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

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

622 """ 

623 

624 name = "opencode" 

625 

626 capabilities = HostCapabilities() 

627 

628 def detect(self) -> bool: 

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

630 

631 def build_streaming( 

632 self, 

633 *, 

634 prompt: str, 

635 working_dir: Path | None = None, 

636 resume: bool = False, 

637 agent: str | None = None, 

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

639 ) -> HostInvocation: 

640 raise HostNotConfigured( 

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

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

643 ) 

644 

645 def build_blocking_json( 

646 self, 

647 *, 

648 prompt: str, 

649 model: str | None = None, 

650 json_schema: dict | None = None, 

651 ) -> HostInvocation: 

652 raise HostNotConfigured( 

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

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

655 ) 

656 

657 def build_version_check(self) -> HostInvocation: 

658 raise HostNotConfigured( 

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

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

661 ) 

662 

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

664 raise HostNotConfigured( 

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

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

667 ) 

668 

669 def describe_capabilities(self) -> CapabilityReport: 

670 return CapabilityReport( 

671 host=self.name, 

672 binary="opencode", 

673 version="", 

674 capabilities=[ 

675 CapabilityEntry( 

676 "host", 

677 "unsupported", 

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

679 ) 

680 ], 

681 ) 

682 

683 

684class PiRunner: 

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

686 

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

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

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

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

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

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

693 """ 

694 

695 name = "pi" 

696 

697 capabilities = HostCapabilities() 

698 

699 def detect(self) -> bool: 

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

701 

702 def build_streaming( 

703 self, 

704 *, 

705 prompt: str, 

706 working_dir: Path | None = None, 

707 resume: bool = False, 

708 agent: str | None = None, 

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

710 ) -> HostInvocation: 

711 raise HostNotConfigured( 

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

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

714 ) 

715 

716 def build_blocking_json( 

717 self, 

718 *, 

719 prompt: str, 

720 model: str | None = None, 

721 json_schema: dict | None = None, 

722 ) -> HostInvocation: 

723 raise HostNotConfigured( 

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

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

726 ) 

727 

728 def build_version_check(self) -> HostInvocation: 

729 raise HostNotConfigured( 

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

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

732 ) 

733 

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

735 raise HostNotConfigured( 

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

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

738 ) 

739 

740 def describe_capabilities(self) -> CapabilityReport: 

741 return CapabilityReport( 

742 host=self.name, 

743 binary="pi", 

744 version="", 

745 capabilities=[ 

746 CapabilityEntry( 

747 "host", 

748 "unsupported", 

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

750 ) 

751 ], 

752 ) 

753 

754 

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

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

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

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

759 "claude-code": ClaudeCodeRunner, 

760 "codex": CodexRunner, 

761 "opencode": OpenCodeRunner, 

762 "pi": PiRunner, 

763} 

764 

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

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

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

768 ("claude-code", "claude"), 

769 ("codex", "codex"), 

770 ("pi", "pi"), 

771] 

772 

773 

774def _remediation_hint() -> str: 

775 return ( 

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

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

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

779 ) 

780 

781 

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

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

784 

785 Detection order (first match wins): 

786 

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

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

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

790 second knob. 

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

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

793 

794 Args: 

795 env: Optional environment dict for testability. Defaults to 

796 ``os.environ`` when omitted. 

797 

798 Returns: 

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

800 

801 Raises: 

802 HostNotConfigured: if no host can be resolved. 

803 """ 

804 import os 

805 

806 if env is None: 

807 env = dict(os.environ) 

808 

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

810 if explicit: 

811 runner_cls = _HOST_RUNNER_REGISTRY.get(explicit) 

812 if runner_cls is not None: 

813 return runner_cls() 

814 raise HostNotConfigured( 

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

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

817 ) 

818 

819 for host_name, binary in _PROBE_ORDER: 

820 if shutil.which(binary) is None: 

821 continue 

822 runner_cls = _HOST_RUNNER_REGISTRY.get(host_name) 

823 if runner_cls is not None: 

824 return runner_cls() 

825 

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

827 

828 

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

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

831 

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

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

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

835 

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

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

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

839 

840 Args: 

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

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

843 is ``config.orchestration.host_cli``). 

844 """ 

845 import os 

846 

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

848 return # explicit env override takes precedence 

849 try: 

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

851 except AttributeError: 

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

853 if host_cli: 

854 os.environ["LL_HOST_CLI"] = host_cli