Coverage for little_loops / subprocess_utils.py: 17%
113 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23:19 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-04-11 23:19 -0500
1"""Subprocess utilities for Claude CLI invocation.
3Provides shared functionality for running Claude CLI commands with
4real-time output streaming, timeout handling, and context handoff detection.
5"""
7from __future__ import annotations
9import json
10import logging
11import os
12import re
13import selectors
14import subprocess
15import time
16from collections.abc import Callable
17from pathlib import Path
19logger = logging.getLogger(__name__)
21# Callback type: (line: str, is_stderr: bool) -> None
22OutputCallback = Callable[[str, bool], None]
24# Process lifecycle callback: (process: Popen) -> None
25ProcessCallback = Callable[[subprocess.Popen[str]], None]
27# Model detection callback: (model: str) -> None
28ModelCallback = Callable[[str], None]
30# Context handoff detection pattern
31CONTEXT_HANDOFF_PATTERN = re.compile(r"CONTEXT_HANDOFF:\s*Ready for fresh session")
32CONTINUATION_PROMPT_PATH = Path(".ll/ll-continue-prompt.md")
35def detect_context_handoff(output: str) -> bool:
36 """Check if output contains a context handoff signal.
38 Args:
39 output: Command output to check
41 Returns:
42 True if context handoff was signaled
43 """
44 return bool(CONTEXT_HANDOFF_PATTERN.search(output))
47def read_continuation_prompt(repo_path: Path | None = None) -> str | None:
48 """Read the continuation prompt file if it exists.
50 Args:
51 repo_path: Optional repository root path
53 Returns:
54 Contents of continuation prompt, or None if not found
55 """
56 prompt_path = (repo_path or Path.cwd()) / CONTINUATION_PROMPT_PATH
57 if prompt_path.exists():
58 return prompt_path.read_text()
59 return None
62def run_claude_command(
63 command: str,
64 timeout: int = 3600,
65 working_dir: Path | None = None,
66 stream_callback: OutputCallback | None = None,
67 on_process_start: ProcessCallback | None = None,
68 on_process_end: ProcessCallback | None = None,
69 idle_timeout: int = 0,
70 on_model_detected: ModelCallback | None = None,
71 agent: str | None = None,
72 tools: list[str] | None = None,
73) -> subprocess.CompletedProcess[str]:
74 """Invoke Claude CLI command with real-time output streaming.
76 Args:
77 command: Command to pass to Claude CLI
78 timeout: Timeout in seconds (0 for no timeout)
79 working_dir: Optional working directory for the command
80 stream_callback: Optional callback for streaming output lines.
81 Called with (line, is_stderr) for each line of output.
82 on_process_start: Optional callback invoked after process starts.
83 Receives the Popen object for tracking/management.
84 on_process_end: Optional callback invoked after process completes.
85 Receives the Popen object. Called in finally block.
86 idle_timeout: Kill process if no output for this many seconds (0 to disable).
87 on_model_detected: Optional callback invoked with the model name from the
88 stream-json system/init event. Called at most once per invocation.
90 Returns:
91 CompletedProcess with stdout/stderr captured
93 Raises:
94 subprocess.TimeoutExpired: If command exceeds timeout or idle timeout.
95 When triggered by idle timeout, the output field is set to "idle_timeout".
96 """
97 cmd_args = [
98 "claude",
99 "--dangerously-skip-permissions",
100 "--verbose",
101 "--output-format",
102 "stream-json",
103 "-p",
104 command,
105 ]
106 if agent:
107 cmd_args += ["--agent", agent]
108 if tools:
109 cmd_args += ["--tools", ",".join(tools)]
111 # Set environment to keep Claude in the project working directory (BUG-007)
112 # This helps prevent file writes from leaking to the main repo in worktrees
113 env = os.environ.copy()
114 env["CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR"] = "1"
116 if working_dir is not None:
117 git_path = Path(working_dir) / ".git"
118 if git_path.is_file(): # worktree: .git is a file, not a directory
119 gitdir_ref = git_path.read_text().strip()
120 if gitdir_ref.startswith("gitdir: "):
121 actual_gitdir = gitdir_ref[8:].strip()
122 # Resolve relative gitdir references to absolute paths
123 resolved = (Path(working_dir) / actual_gitdir).resolve()
124 env["GIT_DIR"] = str(resolved)
125 env["GIT_WORK_TREE"] = str(working_dir)
126 logger.debug("Worktree detected: GIT_DIR=%s", env["GIT_DIR"])
128 process = subprocess.Popen(
129 cmd_args,
130 stdout=subprocess.PIPE,
131 stderr=subprocess.PIPE,
132 text=True,
133 bufsize=1, # Line buffered
134 cwd=working_dir,
135 env=env,
136 )
138 if on_process_start:
139 on_process_start(process)
141 stdout_lines: list[str] = []
142 stderr_lines: list[str] = []
144 # Use selectors for non-blocking read from both streams
145 with selectors.DefaultSelector() as sel:
146 if process.stdout:
147 sel.register(process.stdout, selectors.EVENT_READ)
148 if process.stderr:
149 sel.register(process.stderr, selectors.EVENT_READ)
151 start_time = time.time()
152 last_output_time = start_time
154 try:
155 while sel.get_map():
156 now = time.time()
157 if timeout and (now - start_time) > timeout:
158 process.kill()
159 try:
160 process.wait(timeout=10)
161 except subprocess.TimeoutExpired:
162 logger.warning(
163 "Process %s did not terminate within 10s after kill",
164 process.pid,
165 )
166 raise subprocess.TimeoutExpired(cmd_args, timeout)
168 if idle_timeout and (now - last_output_time) > idle_timeout:
169 process.kill()
170 try:
171 process.wait(timeout=10)
172 except subprocess.TimeoutExpired:
173 logger.warning(
174 "Process %s did not terminate within 10s after kill",
175 process.pid,
176 )
177 raise subprocess.TimeoutExpired(cmd_args, idle_timeout, output="idle_timeout")
179 ready = sel.select(timeout=1.0)
180 for key, _ in ready:
181 line = key.fileobj.readline() # type: ignore[union-attr]
182 if not line:
183 sel.unregister(key.fileobj)
184 continue
186 last_output_time = time.time()
187 line = line.rstrip("\n")
188 is_stderr = key.fileobj is process.stderr
190 if not is_stderr:
191 try:
192 event = json.loads(line)
193 etype = event.get("type")
194 if etype == "system" and event.get("subtype") == "init":
195 if on_model_detected and "model" in event:
196 on_model_detected(event["model"])
197 continue # don't add to stdout_lines
198 elif etype == "assistant":
199 msg = event.get("message", {})
200 text_parts = [
201 block["text"]
202 for block in msg.get("content", [])
203 if block.get("type") == "text"
204 ]
205 line = "".join(text_parts)
206 if not line:
207 continue
208 else:
209 continue # skip other event types (result, tool_use, etc.)
210 except (json.JSONDecodeError, KeyError, TypeError):
211 pass # non-JSON line: pass through as raw text
213 if is_stderr:
214 stderr_lines.append(line)
215 else:
216 stdout_lines.append(line)
218 if stream_callback:
219 stream_callback(line, is_stderr)
221 try:
222 process.wait(timeout=30)
223 except subprocess.TimeoutExpired:
224 logger.warning(
225 "Process %s did not exit within 30s after streams closed, killing",
226 process.pid,
227 )
228 process.kill()
229 try:
230 process.wait(timeout=10)
231 except subprocess.TimeoutExpired:
232 logger.warning(
233 "Process %s did not terminate within 10s after kill",
234 process.pid,
235 )
236 finally:
237 if on_process_end:
238 on_process_end(process)
240 return subprocess.CompletedProcess(
241 cmd_args,
242 process.returncode if process.returncode is not None else -9,
243 stdout="\n".join(stdout_lines),
244 stderr="\n".join(stderr_lines),
245 )