1"""Host CLI abstraction layer.

2

3 Provides 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

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

7

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

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

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

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

12 ClaudeCode implementation; no production sites are migrated yet.

13

14 Public 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

24 from __future__ import annotations

25

26 import json

27 import shutil

28 import sys

29 import tempfile

30 import tomllib

31 import warnings

32 from dataclasses import dataclass, field

33 from pathlib import Path

34 from 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

54 class 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

63 class 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)

75 class 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)

91 class 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)

112 class 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)

125 class 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)

137 class 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

153 class 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

213 class 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

351 class 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

626 class 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

698 class 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

789 def _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

797 def 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

844 def 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