1"""Subprocess utilities for Claude CLI invocation.

2

3 Provides shared functionality for running Claude CLI commands with

4 real-time output streaming, timeout handling, and context handoff detection.

5"""

6

7 from __future__ import annotations

8

9 import json

10 import logging

11 import os

12 import re

13 import selectors

14 import signal

15 import subprocess

16 import time

17 from collections.abc import Callable

18 from dataclasses import dataclass

19 from pathlib import Path

20 from typing import TYPE_CHECKING

21

22 from little_loops.context_window import context_window_for

23 from little_loops.host_runner import resolve_host

24

25 if TYPE_CHECKING:

26 from little_loops.parallel.types import SprintWorkerContext

27

28 logger = logging.getLogger(__name__)

29

30# Callback type: (line: str, is_stderr: bool) -> None

31 OutputCallback = Callable[[str, bool], None]

32

33# Process lifecycle callback: (process: Popen) -> None

34 ProcessCallback = Callable[[subprocess.Popen[str]], None]

35

36# Model detection callback: (model: str) -> None

37 ModelCallback = Callable[[str], None]

38

39# Usage callback: (input_tokens: int, output_tokens: int) -> None

40# Kept for back-compat with issue_manager.py and worker_pool.py callers.

41 UsageCallback = Callable[[int, int], None]

42

43

44@dataclass

45 class TokenUsage:

46 """Token usage from a single host-CLI invocation."""

47

48 input_tokens: int

49 output_tokens: int

50 cache_read_tokens: int

51 cache_creation_tokens: int

52 model: str

53

54

55# Detailed usage callback — receives all four token fields plus model ID.

56 DetailedUsageCallback = Callable[[TokenUsage], None]

57

58# Context handoff detection pattern

59 CONTEXT_HANDOFF_PATTERN = re.compile(r"CONTEXT_HANDOFF:\s*Ready for fresh session")

60 CONTINUATION_PROMPT_PATH = Path(".ll/ll-continue-prompt.md")

61

62# Sentinel file written when a session ends with high context usage (Option G).

63# Consumed by run_with_continuation; NOT deleted by session-cleanup.sh.

64 SENTINEL_PATH = Path(".ll/ll-context-handoff-needed")

65

66# Chars of captured_stdout to include in Option J guillotine prompt (≈3K tokens).

67_GUILLOTINE_TAIL_CHARS = 12_000

68# Lines of original_command to include for task intent.

69_GUILLOTINE_MAX_TASK_LINES = 20

70

71

72 def detect_context_handoff(output: str) -> bool:

73 """Check if output contains a context handoff signal.

74

75 Args:

76 output: Command output to check

77

78 Returns:

79 True if context handoff was signaled

80 """

81 return bool(CONTEXT_HANDOFF_PATTERN.search(output))

82

83

84 def read_continuation_prompt(repo_path: Path | None = None) -> str | None:

85 """Read the continuation prompt file if it exists.

86

87 Args:

88 repo_path: Optional repository root path

89

90 Returns:

91 Contents of continuation prompt, or None if not found

92 """

93 prompt_path = (repo_path or Path.cwd()) / CONTINUATION_PROMPT_PATH

94 if prompt_path.exists():

95 return prompt_path.read_text()

96 return None

97

98

99 def read_sentinel(repo_path: Path | None = None) -> dict | None:

100 """Read and consume the context-handoff sentinel file if it exists.

101

102 The sentinel is written by context-handoff-sentinel.sh (Stop hook) or

103 the Python layer in run_with_continuation when a session ends with high

104 context usage but no CONTEXT_HANDOFF signal.

105

106 Args:

107 repo_path: Optional repository root path

108

109 Returns:

110 Parsed sentinel dict, or None if not present

111 """

112 sentinel_path = (repo_path or Path.cwd()) / SENTINEL_PATH

113 if not sentinel_path.exists():

114 return None

115 try:

116 data = json.loads(sentinel_path.read_text())

117 sentinel_path.unlink(missing_ok=True)

118 return data

119 except Exception:

120 sentinel_path.unlink(missing_ok=True)

121 return {}

122

123

124 def write_sentinel(

125 repo_path: Path | None = None,

126 token_count: int = 0,

127 context_limit: int | None = None,

128) -> None:

129 """Write the context-handoff sentinel file.

130

131 Args:

132 repo_path: Optional repository root path

133 token_count: Total tokens used in the session

134 context_limit: Context window size

135 """

136 import datetime

137

138 if context_limit is None:

139 context_limit = context_window_for(None)

140 sentinel_path = (repo_path or Path.cwd()) / SENTINEL_PATH

141 usage_percent = int(token_count * 100 / context_limit) if context_limit > 0 else 0

142 try:

143 sentinel_path.parent.mkdir(parents=True, exist_ok=True)

144 sentinel_path.write_text(

145 json.dumps(

146 {

147 "written_at": datetime.datetime.now(datetime.UTC).strftime(

148 "%Y-%m-%dT%H:%M:%SZ"

149 ),

150 "token_count": token_count,

151 "context_limit": context_limit,

152 "usage_percent": usage_percent,

153 }

154 )

155 )

156 except Exception:

157 pass

158

159

160 def assemble_guillotine_prompt(

161 original_command: str,

162 captured_stdout: str,

163 token_stats: dict,

164 sprint_context: SprintWorkerContext | None = None,

165 issue_id: str | None = None,

166) -> str:

167 """Assemble a fresh-session continuation prompt for Option J (parent-side guillotine).

168

169 Called when context > 90% or "Prompt is too long" is detected with no handoff.

170 The resulting prompt is passed to a BRAND-NEW claude -p session (not --resume),

171 so it starts with 0 tokens.

172

173 Args:

174 original_command: The original task command / skill invocation

175 captured_stdout: All Claude text output captured so far

176 token_stats: Dict with keys: input_tokens, output_tokens, context_limit,

177 trigger_reason (optional)

178

179 Returns:

180 Assembled continuation prompt string

181 """

182 task_lines = original_command.strip().splitlines()[:_GUILLOTINE_MAX_TASK_LINES]

183 task_excerpt = "\n".join(task_lines)

184 if len(original_command.strip().splitlines()) > _GUILLOTINE_MAX_TASK_LINES:

185 task_excerpt += f"\n... (truncated to {_GUILLOTINE_MAX_TASK_LINES} lines)"

186

187 stdout_tail = (captured_stdout or "")[-_GUILLOTINE_TAIL_CHARS:]

188 if not stdout_tail:

189 stdout_tail = "(no output captured before interruption)"

190

191 input_tokens = token_stats.get("input_tokens", 0)

192 output_tokens = token_stats.get("output_tokens", 0)

193 context_limit = token_stats.get("context_limit") or context_window_for(None)

194 trigger_reason = token_stats.get("trigger_reason", "context > 90%")

195

196 scratch_listing = _list_scratch_files()

197

198 body = f"""\

199⚠ CONTEXT LIMIT REACHED — FRESH SESSION CONTINUATION

200

201 The previous automation session exhausted its context window before completing.

202 This fresh session (new context window, starts at 0 tokens) is continuing from

203 that interrupted session.

204

205## Original Task

206{task_excerpt}

207

208## Session Progress at Interruption

209- Approximate tokens used: {input_tokens + output_tokens:,} / {context_limit:,}

210- Trigger reason: {trigger_reason}

211

212## Last Session Output (what was happening at interruption)

213{stdout_tail}

214

215## Scratch Pad Files Available

216{scratch_listing}

217

218## Instructions for This Session

219 1. Do NOT restart from scratch — the previous session made progress (see above)

220 2. Read the "Last Session Output" section to understand exactly where we were

221 3. Check the scratch pad files before re-running expensive operations

222 4. Continue implementation from the interruption point

223 5. Complete normally: test, commit, close the issue as usual

224"""

225

226 if sprint_context is not None:

227 framing = (

228 f"## Sprint Worker Context\n"

229 f"You are a sprint worker. Process exactly ONE issue: {sprint_context.issue_id}\n"

230 f"After completing this issue, exit immediately — do NOT process other issues.\n"

231 f"Do NOT ask for further instructions. Exit with code 0.\n"

232 f"Branch: {sprint_context.branch}\n\n"

233 )

234 return framing + body

235

236 if issue_id is not None:

237 framing = (

238 f"## Scope Constraint\n"

239 f"Process exactly ONE issue: {issue_id}\n"

240 f"After completing this issue, exit immediately — do NOT process other issues.\n"

241 f"Do NOT ask for further instructions. Exit with code 0.\n\n"

242 )

243 return framing + body

244

245 return body

246

247

248 def _list_scratch_files() -> str:

249 """List files in .loops/tmp/scratch/ with sizes for the guillotine prompt."""

250 scratch_dir = Path(".loops/tmp/scratch")

251 if not scratch_dir.exists():

252 return "None"

253 try:

254 files = sorted(scratch_dir.iterdir())

255 if not files:

256 return "None"

257 lines = []

258 for f in files:

259 try:

260 size_kb = f.stat().st_size // 1024

261 lines.append(f" {f.name} ({size_kb}KB)")

262 except Exception:

263 lines.append(f" {f.name}")

264 return "\n".join(lines)

265 except Exception:

266 return "None"

267

268

269 def _kill_process_group(process: subprocess.Popen) -> None:

270 """Send SIGKILL to the process group; fall back to single-PID kill on error.

271

272 Uses os.getpgid / os.killpg (POSIX) so background Workflow/Task children

273 launched by the session are reaped together with the main process.

274 AttributeError catches platforms where os.killpg is absent (Windows).

275 """

276 try:

277 os.killpg(os.getpgid(process.pid), signal.SIGKILL)

278 except (ProcessLookupError, PermissionError, AttributeError):

279 process.kill()

280

281

282 def run_claude_command(

283 command: str,

284 timeout: int = 3600,

285 working_dir: Path | None = None,

286 stream_callback: OutputCallback | None = None,

287 on_process_start: ProcessCallback | None = None,

288 on_process_end: ProcessCallback | None = None,

289 idle_timeout: int = 0,

290 on_model_detected: ModelCallback | None = None,

291 on_usage: UsageCallback | None = None,

292 on_usage_detailed: DetailedUsageCallback | None = None,

293 agent: str | None = None,

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

295 resume_session: bool = False,

296 model: str | None = None,

297) -> subprocess.CompletedProcess[str]:

298 """Invoke Claude CLI command with real-time output streaming.

299

300 Args:

301 command: Command to pass to Claude CLI

302 timeout: Timeout in seconds (0 for no timeout)

303 working_dir: Optional working directory for the command

304 stream_callback: Optional callback for streaming output lines.

305 Called with (line, is_stderr) for each line of output.

306 on_process_start: Optional callback invoked after process starts.

307 Receives the Popen object for tracking/management.

308 on_process_end: Optional callback invoked after process completes.

309 Receives the Popen object. Called in finally block.

310 idle_timeout: Kill process if no output for this many seconds (0 to disable).

311 on_model_detected: Optional callback invoked with the model name from the

312 stream-json system/init event. Called at most once per invocation.

313 on_usage: Optional callback invoked with (input_tokens, output_tokens) from

314 the stream-json result event. input_tokens includes cache_read_input_tokens.

315 on_usage_detailed: Optional callback invoked with a TokenUsage dataclass

316 carrying all four token fields (input, output, cache_read, cache_creation)

317 plus the model ID from the stream-json result event.

318 resume_session: If True, passes --continue to the Claude CLI to continue the

319 most recent conversation. Used for the Option E explicit-handoff path.

320

321 Returns:

322 CompletedProcess with stdout/stderr captured

323

324 Raises:

325 subprocess.TimeoutExpired: If command exceeds timeout or idle timeout.

326 When triggered by idle timeout, the output field is set to "idle_timeout".

327 """

328 runner = resolve_host()

329 invocation = runner.build_streaming(

330 prompt=command,

331 working_dir=working_dir,

332 resume=resume_session,

333 agent=agent,

334 tools=tools,

335 model=model,

336 )

337 cmd_args = [invocation.binary, *invocation.args]

338

339 env = os.environ.copy()

340 env.update(invocation.env)

341 if "GIT_DIR" in invocation.env:

342 logger.debug("Worktree detected: GIT_DIR=%s", invocation.env["GIT_DIR"])

343

344 try:

345 process = subprocess.Popen(

346 cmd_args,

347 stdout=subprocess.PIPE,

348 stderr=subprocess.PIPE,

349 text=True,

350 bufsize=1, # Line buffered

351 cwd=working_dir,

352 env=env,

353 start_new_session=True,

354 )

355 except Exception as exc:

356 return subprocess.CompletedProcess(

357 args=cmd_args,

358 returncode=1,

359 stdout="",

360 stderr=f"Subprocess spawn failed: {exc}",

361 )

362

363 if on_process_start:

364 on_process_start(process)

365

366 stdout_lines: list[str] = []

367 stderr_lines: list[str] = []

368 detected_model: str = "unknown"

369

370 # Use selectors for non-blocking read from both streams

371 with selectors.DefaultSelector() as sel:

372 if process.stdout:

373 sel.register(process.stdout, selectors.EVENT_READ)

374 if process.stderr:

375 sel.register(process.stderr, selectors.EVENT_READ)

376

377 start_time = time.time()

378 last_output_time = start_time

379 # End-of-turn detection: the stream-json "result" event is the canonical

380 # signal that the headless `claude -p` session is done. We break on it

381 # instead of waiting for pipe EOF, because background Workflow/Task child

382 # processes inherit the stdout/stderr write-ends and a pipe only reports

383 # EOF when the *last* writer closes it — so EOF may never arrive even

384 # though the turn finished, hanging the reader until the wall-clock

385 # timeout fires on a successful run.

386 result_seen = False

387

388 try:

389 while sel.get_map():

390 now = time.time()

391 if timeout and (now - start_time) > timeout:

392 _kill_process_group(process)

393 try:

394 process.wait(timeout=10)

395 except subprocess.TimeoutExpired:

396 logger.warning(

397 "Process %s did not terminate within 10s after kill",

398 process.pid,

399 )

400 raise subprocess.TimeoutExpired(cmd_args, timeout)

401

402 if idle_timeout and (now - last_output_time) > idle_timeout:

403 _kill_process_group(process)

404 try:

405 process.wait(timeout=10)

406 except subprocess.TimeoutExpired:

407 logger.warning(

408 "Process %s did not terminate within 10s after kill",

409 process.pid,

410 )

411 raise subprocess.TimeoutExpired(cmd_args, idle_timeout, output="idle_timeout")

412

413 ready = sel.select(timeout=1.0)

414 for key, _ in ready:

415 line = key.fileobj.readline() # type: ignore[union-attr]

416 if not line:

417 sel.unregister(key.fileobj)

418 continue

419

420 last_output_time = time.time()

421 line = line.rstrip("\n")

422 is_stderr = key.fileobj is process.stderr

423

424 if not is_stderr:

425 try:

426 event = json.loads(line)

427 etype = event.get("type")

428 if etype == "system" and event.get("subtype") == "init":

429 if "model" in event:

430 detected_model = event["model"]

431 if on_model_detected:

432 on_model_detected(event["model"])

433 continue # don't add to stdout_lines

434 elif etype == "assistant":

435 msg = event.get("message", {})

436 text_parts = [

437 block["text"]

438 for block in msg.get("content", [])

439 if block.get("type") == "text"

440 ]

441 text = "\n\n".join(text_parts)

442 if not text:

443 continue

444 for sub_line in text.splitlines() or [""]:

445 stdout_lines.append(sub_line)

446 if stream_callback:

447 stream_callback(sub_line, is_stderr)

448 continue

449 elif etype == "result":

450 usage = event.get("usage", {})

451 if on_usage and usage:

452 on_usage(

453 usage.get("input_tokens", 0)

454 + usage.get("cache_read_input_tokens", 0),

455 usage.get("output_tokens", 0),

456 )

457 if on_usage_detailed and usage:

458 on_usage_detailed(

459 TokenUsage(

460 input_tokens=usage.get("input_tokens", 0),

461 output_tokens=usage.get("output_tokens", 0),

462 cache_read_tokens=usage.get(

463 "cache_read_input_tokens", 0

464 ),

465 cache_creation_tokens=usage.get(

466 "cache_creation_input_tokens", 0

467 ),

468 model=event.get("model", detected_model),

469 )

470 )

471 if event.get("is_error"):

472 error_text = event.get("error") or event.get("result", "")

473 if error_text:

474 stderr_lines.append(f"[result] {error_text}")

475 # Turn is done. Finish draining the current ready

476 # batch (so trailing buffered lines aren't lost),

477 # then break the loop below instead of blocking on

478 # a pipe EOF that inherited background-task FDs may

479 # never deliver.

480 result_seen = True

481 continue # skip other event types (tool_use, etc.)

482 else:

483 continue # skip other event types (tool_use, etc.)

484 except (json.JSONDecodeError, KeyError, TypeError):

485 pass # non-JSON line: pass through as raw text

486

487 if is_stderr:

488 stderr_lines.append(line)

489 else:

490 stdout_lines.append(line)

491

492 if stream_callback:

493 stream_callback(line, is_stderr)

494

495 # The "result" event ended the turn and the current ready batch

496 # has now been fully drained; stop reading rather than blocking

497 # for a pipe EOF that may never arrive.

498 if result_seen:

499 break

500

501 try:

502 process.wait(timeout=30)

503 except subprocess.TimeoutExpired:

504 logger.warning(

505 "Process %s did not exit within 30s after streams closed, killing",

506 process.pid,

507 )

508 _kill_process_group(process)

509 try:

510 process.wait(timeout=10)

511 except subprocess.TimeoutExpired:

512 logger.warning(

513 "Process %s did not terminate within 10s after kill",

514 process.pid,

515 )

516 finally:

517 if on_process_end:

518 on_process_end(process)

519

520 return subprocess.CompletedProcess(

521 cmd_args,

522 process.returncode if process.returncode is not None else -9,

523 stdout="\n".join(stdout_lines),

524 stderr="\n".join(stderr_lines),

525 )