Coverage for little_loops / host_runner.py: 92%

225 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-22 16:19 -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 @staticmethod 

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

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

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

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

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

401 warnings.warn( 

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

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

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

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

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

407 CapabilityNotSupported, 

408 stacklevel=3, 

409 ) 

410 print( 

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

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

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

414 f" in the Codex TUI.", 

415 file=sys.stderr, 

416 ) 

417 

418 @staticmethod 

419 def _inject_agent_persona( 

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

421 ) -> tuple[str, bool]: 

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

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

424 if not toml_path.exists(): 

425 return prompt, False 

426 try: 

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

428 except (OSError, tomllib.TOMLDecodeError): 

429 return prompt, False 

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

431 if not instructions: 

432 return prompt, False 

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

434 

435 def build_streaming( 

436 self, 

437 *, 

438 prompt: str, 

439 working_dir: Path | None = None, 

440 resume: bool = False, 

441 agent: str | None = None, 

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

443 ) -> HostInvocation: 

444 if agent is not None: 

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

446 if not injected: 

447 self._emit_agent_warning(agent) 

448 if tools: 

449 warnings.warn( 

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

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

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

453 CapabilityNotSupported, 

454 stacklevel=2, 

455 ) 

456 

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

458 if resume: 

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

460 args += [ 

461 "--dangerously-bypass-approvals-and-sandbox", 

462 "--json", 

463 "--skip-git-repo-check", 

464 ] 

465 if working_dir is not None: 

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

467 args.append(prompt) 

468 

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

470 if working_dir is not None: 

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

472 if git_path.is_file(): 

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

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

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

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

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

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

479 

480 return HostInvocation( 

481 binary="codex", 

482 args=args, 

483 env=env, 

484 capabilities=self.capabilities, 

485 ) 

486 

487 def build_blocking_json( 

488 self, 

489 *, 

490 prompt: str, 

491 model: str | None = None, 

492 json_schema: dict | None = None, 

493 ) -> HostInvocation: 

494 args: list[str] = [ 

495 "exec", 

496 "--dangerously-bypass-approvals-and-sandbox", 

497 "--json", 

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

499 ] 

500 if model: 

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

502 

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

504 if json_schema is not None: 

505 with tempfile.NamedTemporaryFile( 

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

507 ) as f: 

508 json.dump(json_schema, f) 

509 schema_file = Path(f.name) 

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

511 cleanup = (schema_file,) 

512 

513 args.append(prompt) 

514 return HostInvocation( 

515 binary="codex", 

516 args=args, 

517 env={}, 

518 capabilities=self.capabilities, 

519 cleanup_paths=cleanup, 

520 ) 

521 

522 def build_version_check(self) -> HostInvocation: 

523 return HostInvocation( 

524 binary="codex", 

525 args=["--version"], 

526 env={}, 

527 capabilities=self.capabilities, 

528 ) 

529 

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

531 args = [ 

532 "exec", 

533 "--dangerously-bypass-approvals-and-sandbox", 

534 "--skip-git-repo-check", 

535 prompt, 

536 ] 

537 return HostInvocation( 

538 binary="codex", 

539 args=args, 

540 env={}, 

541 capabilities=self.capabilities, 

542 ) 

543 

544 def describe_capabilities(self) -> CapabilityReport: 

545 return CapabilityReport( 

546 host=self.name, 

547 binary="codex", 

548 version="", 

549 capabilities=[ 

550 CapabilityEntry("streaming", "full"), 

551 CapabilityEntry("permission_skip", "full"), 

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

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

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

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

556 CapabilityEntry( 

557 "agent_select", 

558 "partial", 

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

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

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

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

563 ), 

564 # tool_allowlist=False (line 304); warning at build_streaming lines 326-333 

565 CapabilityEntry( 

566 "tool_allowlist", 

567 "unsupported", 

568 "codex uses sandbox modes for tool access; --tools parameter is ignored", 

569 ), 

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

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

572 CapabilityEntry( 

573 "json_schema", 

574 "partial", 

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

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

577 ), 

578 ], 

579 ) 

580 

581 

582class OpenCodeRunner: 

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

584 

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

586 raises :class:`HostNotConfigured` pointing callers at 

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

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

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

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

591 """ 

592 

593 name = "opencode" 

594 

595 capabilities = HostCapabilities() 

596 

597 def detect(self) -> bool: 

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

599 

600 def build_streaming( 

601 self, 

602 *, 

603 prompt: str, 

604 working_dir: Path | None = None, 

605 resume: bool = False, 

606 agent: str | None = None, 

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

608 ) -> HostInvocation: 

609 raise HostNotConfigured( 

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

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

612 ) 

613 

614 def build_blocking_json( 

615 self, 

616 *, 

617 prompt: str, 

618 model: str | None = None, 

619 json_schema: dict | None = None, 

620 ) -> HostInvocation: 

621 raise HostNotConfigured( 

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

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

624 ) 

625 

626 def build_version_check(self) -> HostInvocation: 

627 raise HostNotConfigured( 

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

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

630 ) 

631 

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

633 raise HostNotConfigured( 

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

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

636 ) 

637 

638 def describe_capabilities(self) -> CapabilityReport: 

639 return CapabilityReport( 

640 host=self.name, 

641 binary="opencode", 

642 version="", 

643 capabilities=[ 

644 CapabilityEntry( 

645 "host", 

646 "unsupported", 

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

648 ) 

649 ], 

650 ) 

651 

652 

653class PiRunner: 

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

655 

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

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

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

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

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

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

662 """ 

663 

664 name = "pi" 

665 

666 capabilities = HostCapabilities() 

667 

668 def detect(self) -> bool: 

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

670 

671 def build_streaming( 

672 self, 

673 *, 

674 prompt: str, 

675 working_dir: Path | None = None, 

676 resume: bool = False, 

677 agent: str | None = None, 

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

679 ) -> HostInvocation: 

680 raise HostNotConfigured( 

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

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

683 ) 

684 

685 def build_blocking_json( 

686 self, 

687 *, 

688 prompt: str, 

689 model: str | None = None, 

690 json_schema: dict | None = None, 

691 ) -> HostInvocation: 

692 raise HostNotConfigured( 

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

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

695 ) 

696 

697 def build_version_check(self) -> HostInvocation: 

698 raise HostNotConfigured( 

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

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

701 ) 

702 

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

704 raise HostNotConfigured( 

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

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

707 ) 

708 

709 def describe_capabilities(self) -> CapabilityReport: 

710 return CapabilityReport( 

711 host=self.name, 

712 binary="pi", 

713 version="", 

714 capabilities=[ 

715 CapabilityEntry( 

716 "host", 

717 "unsupported", 

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

719 ) 

720 ], 

721 ) 

722 

723 

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

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

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

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

728 "claude-code": ClaudeCodeRunner, 

729 "codex": CodexRunner, 

730 "opencode": OpenCodeRunner, 

731 "pi": PiRunner, 

732} 

733 

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

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

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

737 ("claude-code", "claude"), 

738 ("codex", "codex"), 

739 ("pi", "pi"), 

740] 

741 

742 

743def _remediation_hint() -> str: 

744 return ( 

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

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

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

748 ) 

749 

750 

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

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

753 

754 Detection order (first match wins): 

755 

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

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

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

759 second knob. 

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

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

762 

763 Args: 

764 env: Optional environment dict for testability. Defaults to 

765 ``os.environ`` when omitted. 

766 

767 Returns: 

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

769 

770 Raises: 

771 HostNotConfigured: if no host can be resolved. 

772 """ 

773 import os 

774 

775 if env is None: 

776 env = dict(os.environ) 

777 

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

779 if explicit: 

780 runner_cls = _HOST_RUNNER_REGISTRY.get(explicit) 

781 if runner_cls is not None: 

782 return runner_cls() 

783 raise HostNotConfigured( 

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

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

786 ) 

787 

788 for host_name, binary in _PROBE_ORDER: 

789 if shutil.which(binary) is None: 

790 continue 

791 runner_cls = _HOST_RUNNER_REGISTRY.get(host_name) 

792 if runner_cls is not None: 

793 return runner_cls() 

794 

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

796 

797 

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

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

800 

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

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

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

804 

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

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

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

808 

809 Args: 

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

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

812 is ``config.orchestration.host_cli``). 

813 """ 

814 import os 

815 

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

817 return # explicit env override takes precedence 

818 try: 

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

820 except AttributeError: 

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

822 if host_cli: 

823 os.environ["LL_HOST_CLI"] = host_cli