Coverage for little_loops / host_runner.py: 41%
241 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-15 17:27 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-15 17:27 -0500
1"""Host CLI abstraction layer.
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``.
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.
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"""
24from __future__ import annotations
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
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]
54class HostNotConfigured(RuntimeError):
55 """Raised when no host runner can be resolved from env or binary probe.
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 """
63class CapabilityNotSupported(UserWarning):
64 """Emitted when a caller requests a capability the active host lacks.
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 """
74@dataclass(frozen=True)
75class HostCapabilities:
76 """Capability flags describing what a host runner supports.
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 """
84 streaming: bool = False
85 permission_skip: bool = False
86 agent_select: bool = False
87 tool_allowlist: bool = False
90@dataclass(frozen=True)
91class HostInvocation:
92 """Immutable description of how to invoke a host CLI.
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.
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 """
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)
111@dataclass(frozen=True)
112class CapabilityEntry:
113 """A single host capability with its support status.
115 ``status`` is one of ``"full"``, ``"partial"``, or ``"unsupported"``.
116 ``note`` carries an optional human-readable explanation (e.g. workaround).
117 """
119 name: str
120 status: Literal["full", "partial", "unsupported"]
121 note: str = ""
124@dataclass(frozen=True)
125class HookEntry:
126 """A single hook's installation status for a given host.
128 ``status`` is one of ``"installed"``, ``"registered"``, ``"deferred"``, or ``"absent"``.
129 """
131 name: str
132 status: Literal["installed", "registered", "deferred", "absent"]
133 note: str = ""
136@dataclass(frozen=True)
137class CapabilityReport:
138 """Full capability and hook report for one host runner.
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 """
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)
152@runtime_checkable
153class HostRunner(Protocol):
154 """Protocol for host-specific CLI invocation builders.
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.
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 """
167 name: str
169 def detect(self) -> bool:
170 """Return True if this host is available in the current environment."""
171 ...
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.
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 ...
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 ...
200 def build_version_check(self) -> HostInvocation:
201 """Build an invocation that prints the host's version and exits."""
202 ...
204 def build_detached(self, *, prompt: str) -> HostInvocation:
205 """Build an invocation suitable for fire-and-forget detached execution."""
206 ...
208 def describe_capabilities(self) -> CapabilityReport:
209 """Return a structured capability and hook report for this host."""
210 ...
213class ClaudeCodeRunner:
214 """``HostRunner`` for the ``claude`` CLI (Claude Code).
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 """
222 name = "claude-code"
224 capabilities = HostCapabilities(
225 streaming=True,
226 permission_skip=True,
227 agent_select=True,
228 tool_allowlist=True,
229 )
231 def detect(self) -> bool:
232 return shutil.which("claude") is not None
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]
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)
275 return HostInvocation(
276 binary="claude",
277 args=args,
278 env=env,
279 capabilities=self.capabilities,
280 )
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 )
310 def build_version_check(self) -> HostInvocation:
311 return HostInvocation(
312 binary="claude",
313 args=["--version"],
314 env={},
315 capabilities=self.capabilities,
316 )
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 )
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 )
351class CodexRunner:
352 """``HostRunner`` for the ``codex`` CLI (OpenAI Codex, Rust-based GA build).
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.
358 Key divergences from :class:`ClaudeCodeRunner`:
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.
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 """
391 name = "codex"
393 capabilities = HostCapabilities(
394 streaming=True,
395 permission_skip=True,
396 agent_select=False,
397 tool_allowlist=False,
398 )
400 def detect(self) -> bool:
401 return shutil.which("codex") is not None
403 _VALID_SANDBOX_MODES = frozenset({"off", "read-only", "workspace-write", "danger-full-access"})
405 @staticmethod
406 def _sandbox_args(sandbox_mode: str | None) -> list[str]:
407 """Return the Codex sandbox flag(s) for *sandbox_mode*.
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 )
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-agents-for-codex --apply` and use `--agent {agent}`\n"
444 f" in the Codex TUI.",
445 file=sys.stderr,
446 )
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
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 )
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)
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)
518 return HostInvocation(
519 binary="codex",
520 args=args,
521 env=env,
522 capabilities=self.capabilities,
523 )
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]
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,)
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 )
561 def build_version_check(self) -> HostInvocation:
562 return HostInvocation(
563 binary="codex",
564 args=["--version"],
565 env={},
566 capabilities=self.capabilities,
567 )
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 )
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 )
626class OpenCodeRunner:
627 """``HostRunner`` stub for the ``opencode`` CLI (FEAT-1472, Option B).
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 """
637 name = "opencode"
639 capabilities = HostCapabilities()
641 def detect(self) -> bool:
642 return shutil.which("opencode") is not None
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 )
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 )
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 )
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 )
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 )
698class PiRunner:
699 """``HostRunner`` stub for the ``pi`` CLI (FEAT-1472).
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 """
709 name = "pi"
711 capabilities = HostCapabilities()
713 def detect(self) -> bool:
714 return shutil.which("pi") is not None
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 )
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 )
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 )
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 )
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 )
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}
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]
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 )
797def resolve_host(env: dict[str, str] | None = None) -> HostRunner:
798 """Resolve the active :class:`HostRunner`.
800 Detection order (first match wins):
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.
809 Args:
810 env: Optional environment dict for testability. Defaults to
811 ``os.environ`` when omitted.
813 Returns:
814 A :class:`HostRunner` instance ready to build invocations.
816 Raises:
817 HostNotConfigured: if no host can be resolved.
818 """
819 import os
821 if env is None:
822 env = dict(os.environ)
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 )
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()
841 raise HostNotConfigured(f"No host CLI detected on PATH. {_remediation_hint()}")
844def apply_host_cli_from_config(config: object) -> None:
845 """Export ``orchestration.host_cli`` from *config* as ``LL_HOST_CLI``.
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.
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).
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
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