ralphx 0.3.5__py3-none-any.whl → 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. ralphx/__init__.py +1 -1
  2. ralphx/adapters/base.py +18 -2
  3. ralphx/adapters/claude_cli.py +415 -350
  4. ralphx/api/routes/auth.py +105 -32
  5. ralphx/api/routes/items.py +4 -0
  6. ralphx/api/routes/loops.py +101 -15
  7. ralphx/api/routes/planning.py +866 -17
  8. ralphx/api/routes/resources.py +528 -6
  9. ralphx/api/routes/stream.py +161 -114
  10. ralphx/api/routes/templates.py +1 -0
  11. ralphx/api/routes/workflows.py +257 -25
  12. ralphx/core/auth.py +32 -7
  13. ralphx/core/checkpoint.py +118 -0
  14. ralphx/core/executor.py +292 -85
  15. ralphx/core/loop_templates.py +59 -14
  16. ralphx/core/planning_iteration_executor.py +633 -0
  17. ralphx/core/planning_service.py +11 -4
  18. ralphx/core/project_db.py +835 -85
  19. ralphx/core/resources.py +28 -2
  20. ralphx/core/session.py +62 -10
  21. ralphx/core/templates.py +74 -87
  22. ralphx/core/workflow_executor.py +35 -3
  23. ralphx/mcp/tools/diagnostics.py +1 -1
  24. ralphx/mcp/tools/monitoring.py +10 -16
  25. ralphx/mcp/tools/workflows.py +5 -5
  26. ralphx/models/loop.py +1 -1
  27. ralphx/models/session.py +5 -0
  28. ralphx/static/assets/index-DnihHetG.js +265 -0
  29. ralphx/static/assets/index-DnihHetG.js.map +1 -0
  30. ralphx/static/assets/index-nIDWmtzm.css +1 -0
  31. ralphx/static/index.html +2 -2
  32. ralphx/templates/loop_templates/consumer.md +2 -2
  33. {ralphx-0.3.5.dist-info → ralphx-0.4.1.dist-info}/METADATA +1 -1
  34. {ralphx-0.3.5.dist-info → ralphx-0.4.1.dist-info}/RECORD +36 -35
  35. ralphx/static/assets/index-0ovNnfOq.css +0 -1
  36. ralphx/static/assets/index-CY9s08ZB.js +0 -251
  37. ralphx/static/assets/index-CY9s08ZB.js.map +0 -1
  38. {ralphx-0.3.5.dist-info → ralphx-0.4.1.dist-info}/WHEEL +0 -0
  39. {ralphx-0.3.5.dist-info → ralphx-0.4.1.dist-info}/entry_points.txt +0 -0
@@ -1,14 +1,18 @@
1
1
  """Claude CLI adapter for RalphX.
2
2
 
3
- Spawns Claude CLI as a subprocess and captures output via stream-json format.
3
+ Spawns Claude CLI as a subprocess and streams events via JSONL session file tailing.
4
4
  """
5
5
 
6
6
  import asyncio
7
7
  import json
8
+ import logging
9
+ import time
8
10
  from datetime import datetime
9
11
  from pathlib import Path
10
12
  from typing import AsyncIterator, Callable, Optional
11
13
 
14
+ logger = logging.getLogger(__name__)
15
+
12
16
  from ralphx.adapters.base import (
13
17
  AdapterEvent,
14
18
  ExecutionResult,
@@ -30,9 +34,10 @@ class ClaudeCLIAdapter(LLMAdapter):
30
34
  """Adapter for Claude CLI (claude command).
31
35
 
32
36
  Features:
33
- - Spawns claude -p with stream-json output
34
- - Captures session_id from init message
35
- - Streams text and tool_use events
37
+ - Spawns claude -p with json output
38
+ - Streams events by tailing the JSONL session file Claude writes
39
+ - Captures session_id from queue-operation event
40
+ - Streams text, thinking, tool_use, tool_result, and usage events
36
41
  - Handles timeouts and signals
37
42
  - Supports per-loop Claude Code settings files
38
43
  """
@@ -57,6 +62,8 @@ class ClaudeCLIAdapter(LLMAdapter):
57
62
  self._session_id: Optional[str] = None
58
63
  self._settings_path = settings_path
59
64
  self._project_id = project_id
65
+ self._structured_output: Optional[dict] = None
66
+ self._final_result_text: str = ""
60
67
 
61
68
  @property
62
69
  def is_running(self) -> bool:
@@ -95,16 +102,13 @@ class ClaudeCLIAdapter(LLMAdapter):
95
102
  # Resolve model name
96
103
  full_model = MODEL_MAP.get(model, model)
97
104
 
98
- # When using json_schema, we need --output-format json (not stream-json)
99
- # because structured_output is only in the final JSON result
100
- output_format = "json" if json_schema else "stream-json"
101
-
105
+ # Always use json output format — we stream via JSONL file tailing,
106
+ # stdout is only used for final metadata (cost, structured_output)
102
107
  cmd = [
103
108
  "claude",
104
109
  "-p", # Print mode (non-interactive)
105
- "--verbose", # Required when using -p with stream-json
106
110
  "--model", full_model,
107
- "--output-format", output_format,
111
+ "--output-format", "json",
108
112
  ]
109
113
 
110
114
  # Add JSON schema for structured output
@@ -115,10 +119,18 @@ class ClaudeCLIAdapter(LLMAdapter):
115
119
  if self._settings_path and self._settings_path.exists():
116
120
  cmd.extend(["--settings", str(self._settings_path)])
117
121
 
118
- # Add allowed tools
119
- if tools:
120
- for tool in tools:
121
- cmd.extend(["--allowedTools", tool])
122
+ # Configure available tools
123
+ # --tools specifies which built-in tools are available
124
+ # --allowedTools is for fine-grained permission patterns like Bash(git:*)
125
+ # Three-way semantics:
126
+ # tools=None → omit --tools flag → Claude uses all default tools
127
+ # tools=[] → --tools "" → explicitly disable all tools
128
+ # tools=[...] → --tools "Read,Glob" → only listed tools
129
+ if tools is not None:
130
+ if tools:
131
+ cmd.extend(["--tools", ",".join(tools)])
132
+ else:
133
+ cmd.extend(["--tools", ""])
122
134
 
123
135
  return cmd
124
136
 
@@ -130,33 +142,23 @@ class ClaudeCLIAdapter(LLMAdapter):
130
142
  timeout: int = 300,
131
143
  json_schema: Optional[dict] = None,
132
144
  on_session_start: Optional[Callable[[str], None]] = None,
145
+ on_event: Optional[Callable[[StreamEvent], None]] = None,
133
146
  ) -> ExecutionResult:
134
147
  """Execute a prompt and return the result.
135
148
 
136
- Args:
137
- prompt: The prompt to send.
138
- model: Model identifier.
139
- tools: List of tool names.
140
- timeout: Timeout in seconds.
141
- json_schema: Optional JSON schema for structured output.
142
-
143
- Returns:
144
- ExecutionResult with output and metadata.
149
+ Streams events via JSONL file tailing for all execution modes.
150
+ Structured output (json_schema) is extracted from stdout JSON after completion.
145
151
  """
146
- # When using json_schema, use dedicated non-streaming execution
147
- if json_schema:
148
- return await self._execute_with_schema(prompt, model, tools, timeout, json_schema)
149
-
150
- # Standard streaming execution
151
- import logging
152
- _exec_log = logging.getLogger(__name__)
152
+ # Reset per-execution state
153
+ self._structured_output = None
154
+ self._final_result_text = ""
153
155
 
154
156
  result = ExecutionResult(started_at=datetime.utcnow())
155
157
  text_parts = []
156
158
  tool_calls = []
157
159
 
158
160
  try:
159
- async for event in self.stream(prompt, model, tools, timeout):
161
+ async for event in self.stream(prompt, model, tools, timeout, json_schema):
160
162
  if event.type == AdapterEvent.INIT:
161
163
  result.session_id = event.data.get("session_id")
162
164
  if on_session_start and result.session_id:
@@ -164,22 +166,35 @@ class ClaudeCLIAdapter(LLMAdapter):
164
166
  elif event.type == AdapterEvent.TEXT:
165
167
  if event.text:
166
168
  text_parts.append(event.text)
167
- _exec_log.warning(f"[EXEC] Appended text part, total parts: {len(text_parts)}")
168
169
  elif event.type == AdapterEvent.TOOL_USE:
169
170
  tool_calls.append({
170
171
  "name": event.tool_name,
171
172
  "input": event.tool_input,
172
173
  })
174
+ elif event.type == AdapterEvent.THINKING:
175
+ pass # Don't aggregate thinking into text_output
176
+ elif event.type == AdapterEvent.USAGE:
177
+ pass # Usage tracked via on_event callback
173
178
  elif event.type == AdapterEvent.ERROR:
174
179
  result.error_message = event.error_message
175
180
  result.success = False
176
181
  elif event.type == AdapterEvent.COMPLETE:
177
182
  result.exit_code = event.data.get("exit_code", 0)
178
183
 
184
+ # Fire on_event callback for event persistence
185
+ if on_event:
186
+ on_event(event)
187
+
179
188
  except asyncio.TimeoutError:
180
189
  result.timeout = True
181
190
  result.success = False
182
191
  result.error_message = f"Execution timed out after {timeout}s"
192
+ if on_event:
193
+ on_event(StreamEvent(
194
+ type=AdapterEvent.ERROR,
195
+ error_message=result.error_message,
196
+ error_code="TIMEOUT",
197
+ ))
183
198
  await self.stop()
184
199
 
185
200
  result.completed_at = datetime.utcnow()
@@ -187,123 +202,10 @@ class ClaudeCLIAdapter(LLMAdapter):
187
202
  result.tool_calls = tool_calls
188
203
  result.session_id = self._session_id
189
204
 
190
- _exec_log.warning(f"[EXEC] Final text_output: {len(result.text_output)} chars from {len(text_parts)} parts")
191
- if result.text_output:
192
- _exec_log.warning(f"[EXEC] text_output preview: {result.text_output[:300]}...")
193
-
194
- return result
195
-
196
- async def _execute_with_schema(
197
- self,
198
- prompt: str,
199
- model: str,
200
- tools: Optional[list[str]],
201
- timeout: int,
202
- json_schema: dict,
203
- ) -> ExecutionResult:
204
- """Execute with JSON schema for structured output.
205
-
206
- Uses --output-format json which returns a single JSON result
207
- containing structured_output.
208
-
209
- Args:
210
- prompt: The prompt to send.
211
- model: Model identifier.
212
- tools: List of tool names.
213
- timeout: Timeout in seconds.
214
- json_schema: JSON schema for structured output validation.
215
-
216
- Returns:
217
- ExecutionResult with structured_output populated.
218
- """
219
- result = ExecutionResult(started_at=datetime.utcnow())
220
-
221
- # Validate and refresh token
222
- if not await refresh_token_if_needed(self._project_id, validate=True):
223
- result.success = False
224
- result.error_message = "No valid credentials. Token may be expired."
225
- return result
226
-
227
- # Swap credentials for execution
228
- with swap_credentials_for_loop(self._project_id) as has_creds:
229
- if not has_creds:
230
- result.success = False
231
- result.error_message = "No credentials available."
232
- return result
233
-
234
- cmd = self._build_command(model, tools, json_schema)
235
-
236
- try:
237
- # Start process
238
- self._process = await asyncio.create_subprocess_exec(
239
- *cmd,
240
- stdin=asyncio.subprocess.PIPE,
241
- stdout=asyncio.subprocess.PIPE,
242
- stderr=asyncio.subprocess.PIPE,
243
- cwd=str(self.project_path),
244
- )
245
-
246
- # Send prompt
247
- if self._process.stdin:
248
- self._process.stdin.write(prompt.encode())
249
- await self._process.stdin.drain()
250
- self._process.stdin.close()
251
- await self._process.stdin.wait_closed()
252
-
253
- # Read output with timeout
254
- async with asyncio.timeout(timeout):
255
- stdout, stderr = await self._process.communicate()
256
-
257
- result.exit_code = self._process.returncode or 0
258
- result.completed_at = datetime.utcnow()
259
-
260
- # Parse JSON result
261
- if stdout:
262
- try:
263
- data = json.loads(stdout.decode())
264
- result.session_id = data.get("session_id")
265
- result.structured_output = data.get("structured_output")
266
-
267
- # Check for errors in result
268
- if data.get("is_error"):
269
- result.success = False
270
- result.error_message = data.get("result", "Unknown error")
271
- elif data.get("subtype") == "error_max_structured_output_retries":
272
- result.success = False
273
- result.error_message = "Could not produce valid structured output"
274
- else:
275
- result.success = True
276
-
277
- # Extract text from result if available
278
- result.text_output = data.get("result", "")
279
-
280
- except json.JSONDecodeError as e:
281
- result.success = False
282
- result.error_message = f"Failed to parse JSON output: {e}"
283
-
284
- # Handle stderr
285
- if stderr:
286
- stderr_text = stderr.decode(errors="replace").strip()
287
- if stderr_text and not result.error_message:
288
- result.error_message = stderr_text[:500]
289
-
290
- if result.exit_code != 0 and result.success:
291
- result.success = False
292
- if not result.error_message:
293
- result.error_message = f"Exit code {result.exit_code}"
294
-
295
- except asyncio.TimeoutError:
296
- result.timeout = True
297
- result.success = False
298
- result.error_message = f"Execution timed out after {timeout}s"
299
- await self.stop()
300
-
301
- except Exception as e:
302
- result.success = False
303
- result.error_message = str(e)
304
-
305
- finally:
306
- self._process = None
205
+ # Pick up structured output and final result text from stream()'s stdout parsing
206
+ result.structured_output = self._structured_output
207
+ if not result.text_output and self._final_result_text:
208
+ result.text_output = self._final_result_text
307
209
 
308
210
  return result
309
211
 
@@ -315,25 +217,23 @@ class ClaudeCLIAdapter(LLMAdapter):
315
217
  timeout: int = 300,
316
218
  json_schema: Optional[dict] = None,
317
219
  ) -> AsyncIterator[StreamEvent]:
318
- """Stream execution events from Claude CLI.
319
-
320
- Automatically handles credential refresh and swap for the execution.
220
+ """Stream execution events by tailing Claude's JSONL session file.
321
221
 
322
- Note: When json_schema is provided, streaming is not truly supported.
323
- Use execute() instead for structured output.
324
-
325
- Args:
326
- prompt: The prompt to send.
327
- model: Model identifier.
328
- tools: List of tool names.
329
- timeout: Timeout in seconds.
330
- json_schema: Optional JSON schema (not recommended for streaming).
222
+ Instead of parsing stdout (stream-json format), we:
223
+ 1. Spawn Claude CLI with --output-format json
224
+ 2. Discover the JSONL session file Claude creates
225
+ 3. Tail that file for real-time events (richer than stdout)
226
+ 4. Read stdout after completion for final metadata
331
227
 
332
228
  Yields:
333
229
  StreamEvent objects as execution progresses.
334
230
  """
335
- # Validate and refresh token if needed (before spawning)
336
- # Use validate=True to actually test the token works
231
+ # Reset session state
232
+ self._session_id = None
233
+ self._structured_output = None
234
+ self._final_result_text = ""
235
+
236
+ # Validate and refresh token if needed
337
237
  if not await refresh_token_if_needed(self._project_id, validate=True):
338
238
  yield StreamEvent(
339
239
  type=AdapterEvent.ERROR,
@@ -352,7 +252,18 @@ class ClaudeCLIAdapter(LLMAdapter):
352
252
  )
353
253
  return
354
254
 
355
- cmd = self._build_command(model, tools)
255
+ # Compute session directory for JSONL file discovery
256
+ normalized = str(self.project_path).replace("/", "-")
257
+ session_dir = Path.home() / ".claude" / "projects" / normalized
258
+
259
+ # Snapshot existing session files before spawn
260
+ existing_files: set[str] = set()
261
+ if session_dir.exists():
262
+ existing_files = {
263
+ f.name for f in session_dir.iterdir() if f.suffix == ".jsonl"
264
+ }
265
+
266
+ cmd = self._build_command(model, tools, json_schema)
356
267
 
357
268
  # Start the process
358
269
  self._process = await asyncio.create_subprocess_exec(
@@ -361,6 +272,7 @@ class ClaudeCLIAdapter(LLMAdapter):
361
272
  stdout=asyncio.subprocess.PIPE,
362
273
  stderr=asyncio.subprocess.PIPE,
363
274
  cwd=str(self.project_path),
275
+ limit=4 * 1024 * 1024, # 4MB buffer
364
276
  )
365
277
 
366
278
  # Send the prompt
@@ -370,231 +282,384 @@ class ClaudeCLIAdapter(LLMAdapter):
370
282
  self._process.stdin.close()
371
283
  await self._process.stdin.wait_closed()
372
284
 
373
- # Read output with timeout
374
- # Note: We read stderr concurrently with stdout to avoid deadlock
375
- # if stderr buffer fills before stdout completes
376
- stderr_content = []
377
- stderr_task = None
378
-
379
- async def drain_stderr():
380
- """Read stderr in background to prevent buffer deadlock."""
381
- chunks = []
382
- if self._process and self._process.stderr:
383
- # Read in chunks with a max total size limit (1MB)
384
- max_size = 1024 * 1024
385
- total = 0
386
- while total < max_size:
387
- chunk = await self._process.stderr.read(8192)
388
- if not chunk:
389
- break
390
- chunks.append(chunk)
391
- total += len(chunk)
392
- return b"".join(chunks)
393
-
394
- try:
395
- async with asyncio.timeout(timeout):
396
- # Start stderr drain early to prevent buffer deadlock
397
- if self._process.stderr:
398
- stderr_task = asyncio.create_task(drain_stderr())
399
-
400
- if self._process.stdout:
401
- import logging
402
- _stream_log = logging.getLogger(__name__)
403
- line_count = 0
404
- text_events = 0
405
- async for line in self._process.stdout:
406
- line_count += 1
407
- try:
408
- line_text = line.decode(errors="replace").strip()
409
- except Exception:
410
- continue # Skip lines that can't be decoded
411
- if not line_text:
412
- continue
285
+ # Drain stdout/stderr in background to prevent pipe deadlock
286
+ stdout_task = asyncio.create_task(
287
+ self._drain_pipe(self._process.stdout)
288
+ )
289
+ stderr_task = asyncio.create_task(
290
+ self._drain_pipe(self._process.stderr)
291
+ )
413
292
 
414
- try:
415
- data = json.loads(line_text)
416
- msg_type = data.get("type", "unknown")
417
- _stream_log.warning(f"[STREAM] Line {line_count}: type={msg_type}")
418
-
419
- event = self._parse_event(data)
420
- if event:
421
- if event.type == AdapterEvent.TEXT:
422
- text_events += 1
423
- text_preview = (event.text or "")[:80].replace("\n", "\\n")
424
- _stream_log.warning(f"[STREAM] TEXT #{text_events}: {len(event.text or '')} chars, preview: {text_preview}")
425
- yield event
426
- except json.JSONDecodeError:
427
- # Non-JSON output, treat as plain text
428
- yield StreamEvent(
429
- type=AdapterEvent.TEXT,
430
- text=line_text,
431
- )
432
- _stream_log.warning(f"[STREAM] Done: {line_count} lines, {text_events} TEXT events")
433
-
434
- # Wait for process to complete
435
- await self._process.wait()
436
-
437
- # Collect stderr result
438
- if stderr_task:
439
- stderr_data = await stderr_task
440
- if stderr_data:
441
- stderr_content.append(stderr_data.decode(errors="replace").strip())
293
+ # Discover the new JSONL session file
294
+ session_file = await self._discover_session_file(
295
+ session_dir, existing_files, max_wait=15
296
+ )
442
297
 
443
- except asyncio.TimeoutError:
444
- # Cancel stderr task if still running
445
- if stderr_task and not stderr_task.done():
446
- stderr_task.cancel()
447
- try:
448
- await stderr_task
449
- except asyncio.CancelledError:
450
- pass
298
+ if not session_file:
299
+ # Process may have died before creating session file
300
+ await self._process.wait()
301
+ stdout_task.cancel()
302
+ stderr_data = await stderr_task
303
+ stderr_text = stderr_data.decode(errors="replace").strip() if stderr_data else ""
451
304
  yield StreamEvent(
452
305
  type=AdapterEvent.ERROR,
453
- error_message=f"Timeout after {timeout}s",
454
- error_code="TIMEOUT",
306
+ error_message=f"Could not find session file. stderr: {stderr_text[:500]}",
307
+ error_code="NO_SESSION_FILE",
455
308
  )
456
- await self.stop()
457
- raise
458
-
459
- # Emit error if non-zero exit code or stderr content
460
- exit_code = self._process.returncode or 0
461
- if exit_code != 0 or stderr_content:
462
- stderr_text = "\n".join(stderr_content)
463
- error_msg = f"Claude CLI error (exit {exit_code})"
464
- if stderr_text:
465
- # Truncate stderr to 500 chars with indicator
466
- truncated = stderr_text[:500]
467
- if len(stderr_text) > 500:
468
- truncated += "... [truncated]"
469
- error_msg = f"{error_msg}: {truncated}"
470
309
  yield StreamEvent(
471
- type=AdapterEvent.ERROR,
472
- error_message=error_msg,
473
- error_code=f"EXIT_{exit_code}",
310
+ type=AdapterEvent.COMPLETE,
311
+ data={"exit_code": self._process.returncode or 1},
474
312
  )
313
+ self._process = None
314
+ return
315
+
316
+ # Tail the JSONL file for streaming events
317
+ async for event in self._tail_session_file(session_file, timeout):
318
+ yield event
319
+
320
+ # Wait for process to complete
321
+ await self._process.wait()
475
322
 
476
- # Emit completion event
323
+ # Collect stdout/stderr
324
+ stdout_data = await stdout_task
325
+ stderr_data = await stderr_task
326
+
327
+ # Parse stdout JSON for final metadata
328
+ exit_code = self._process.returncode or 0
329
+ completion_data: dict = {
330
+ "exit_code": exit_code,
331
+ "session_id": self._session_id,
332
+ }
333
+
334
+ if stdout_data:
335
+ try:
336
+ final = json.loads(stdout_data.decode())
337
+ completion_data["cost_usd"] = final.get("cost_usd")
338
+ completion_data["num_turns"] = final.get("num_turns")
339
+ self._structured_output = final.get("structured_output")
340
+ self._final_result_text = final.get("result", "")
341
+
342
+ # Check for errors in final result
343
+ if final.get("is_error"):
344
+ yield StreamEvent(
345
+ type=AdapterEvent.ERROR,
346
+ error_message=final.get("result", "Unknown error"),
347
+ error_code="CLI_ERROR",
348
+ )
349
+ elif final.get("subtype") == "error_max_structured_output_retries":
350
+ yield StreamEvent(
351
+ type=AdapterEvent.ERROR,
352
+ error_message="Could not produce valid structured output",
353
+ error_code="STRUCTURED_OUTPUT_FAILED",
354
+ )
355
+ except json.JSONDecodeError:
356
+ pass
357
+
358
+ # Emit error for non-zero exit or stderr
359
+ if exit_code != 0:
360
+ stderr_text = stderr_data.decode(errors="replace").strip() if stderr_data else ""
361
+ if stderr_text:
362
+ logger.warning(f"Claude CLI exit code: {exit_code}, stderr: {stderr_text[:500]}")
363
+ yield StreamEvent(
364
+ type=AdapterEvent.ERROR,
365
+ error_message=f"Claude CLI error (exit {exit_code}): {stderr_text[:500]}",
366
+ error_code=f"EXIT_{exit_code}",
367
+ )
368
+
369
+ # Emit completion
477
370
  yield StreamEvent(
478
371
  type=AdapterEvent.COMPLETE,
479
- data={"exit_code": exit_code, "session_id": self._session_id},
372
+ data=completion_data,
480
373
  )
481
374
 
482
375
  self._process = None
483
376
 
484
- def _parse_event(self, data: dict) -> Optional[StreamEvent]:
485
- """Parse a stream-json event into a StreamEvent.
377
+ # ========== JSONL File Tailing Helpers ==========
378
+
379
+ async def _discover_session_file(
380
+ self,
381
+ session_dir: Path,
382
+ existing_files: set[str],
383
+ max_wait: float = 15,
384
+ ) -> Optional[Path]:
385
+ """Poll directory for a new .jsonl session file created by Claude CLI.
486
386
 
487
387
  Args:
488
- data: Parsed JSON data from stdout.
388
+ session_dir: Directory where Claude stores session files.
389
+ existing_files: Set of filenames that existed before process spawn.
390
+ max_wait: Maximum seconds to wait for file to appear.
489
391
 
490
392
  Returns:
491
- StreamEvent or None if not recognized.
393
+ Path to new session file, or None if not found.
492
394
  """
493
- msg_type = data.get("type")
395
+ start = time.time()
396
+ while time.time() - start < max_wait:
397
+ if session_dir.exists():
398
+ try:
399
+ current = {
400
+ f.name for f in session_dir.iterdir() if f.suffix == ".jsonl"
401
+ }
402
+ new_files = current - existing_files
403
+ if new_files:
404
+ # Return the newest file by modification time
405
+ newest = max(
406
+ new_files,
407
+ key=lambda f: (session_dir / f).stat().st_mtime,
408
+ )
409
+ return session_dir / newest
410
+ except OSError:
411
+ pass # Directory listing failed, retry
494
412
 
495
- # Init message with session ID (only for system/init events)
496
- if msg_type in ("init", "system"):
497
- self._session_id = data.get("session_id")
498
- return StreamEvent(
499
- type=AdapterEvent.INIT,
500
- data={"session_id": self._session_id},
413
+ # Check if process already exited before creating a file
414
+ if self._process and self._process.returncode is not None:
415
+ return None
416
+
417
+ await asyncio.sleep(0.2)
418
+
419
+ return None
420
+
421
+ async def _tail_session_file(
422
+ self,
423
+ session_file: Path,
424
+ timeout: int,
425
+ ) -> AsyncIterator[StreamEvent]:
426
+ """Tail a JSONL session file, yielding StreamEvents.
427
+
428
+ Reads new lines as they're written by Claude CLI.
429
+ Stops when the process exits and all remaining lines are read.
430
+
431
+ Args:
432
+ session_file: Path to the .jsonl file.
433
+ timeout: Timeout in seconds for the overall execution.
434
+
435
+ Yields:
436
+ StreamEvent objects parsed from JSONL lines.
437
+ """
438
+ position = 0
439
+ last_event_time = time.time()
440
+ meaningful_timeout = min(max(timeout - 30, 60), 270)
441
+
442
+ while True:
443
+ # Check if process has exited
444
+ process_done = (
445
+ self._process is not None
446
+ and self._process.returncode is not None
501
447
  )
502
448
 
503
- # Content block events
504
- if msg_type == "content_block_delta":
505
- delta = data.get("delta", {})
506
- delta_type = delta.get("type")
449
+ # Read new content from file
450
+ try:
451
+ size = session_file.stat().st_size
452
+ except FileNotFoundError:
453
+ break
454
+
455
+ if size > position:
456
+ try:
457
+ with open(session_file, "r") as f:
458
+ f.seek(position)
459
+ new_content = f.read()
460
+ position = f.tell()
461
+ except (IOError, PermissionError):
462
+ break
463
+
464
+ for line in new_content.split("\n"):
465
+ line = line.strip()
466
+ if not line:
467
+ continue
468
+ try:
469
+ data = json.loads(line)
470
+ for event in self._parse_jsonl_event(data):
471
+ if event.type in (
472
+ AdapterEvent.TEXT,
473
+ AdapterEvent.TOOL_USE,
474
+ AdapterEvent.TOOL_RESULT,
475
+ AdapterEvent.THINKING,
476
+ AdapterEvent.INIT,
477
+ ):
478
+ last_event_time = time.time()
479
+ yield event
480
+ except json.JSONDecodeError:
481
+ continue
482
+
483
+ if process_done:
484
+ # Final read to catch any remaining lines
485
+ try:
486
+ final_size = session_file.stat().st_size
487
+ if final_size > position:
488
+ with open(session_file, "r") as f:
489
+ f.seek(position)
490
+ remaining = f.read()
491
+ for line in remaining.split("\n"):
492
+ line = line.strip()
493
+ if not line:
494
+ continue
495
+ try:
496
+ data = json.loads(line)
497
+ for event in self._parse_jsonl_event(data):
498
+ yield event
499
+ except json.JSONDecodeError:
500
+ continue
501
+ except (FileNotFoundError, IOError):
502
+ pass
503
+ break
507
504
 
508
- if delta_type == "text_delta":
509
- return StreamEvent(
510
- type=AdapterEvent.TEXT,
511
- text=delta.get("text", ""),
505
+ # Check meaningful event timeout
506
+ if time.time() - last_event_time > meaningful_timeout:
507
+ yield StreamEvent(
508
+ type=AdapterEvent.ERROR,
509
+ error_message=f"No meaningful output for {meaningful_timeout}s",
510
+ error_code="TIMEOUT",
512
511
  )
512
+ break
513
513
 
514
- if delta_type == "input_json_delta":
515
- # Tool input being streamed
516
- return None # Accumulate in content_block_stop
517
-
518
- if msg_type == "content_block_start":
519
- content_block = data.get("content_block", {})
520
- if content_block.get("type") == "tool_use":
521
- return StreamEvent(
522
- type=AdapterEvent.TOOL_USE,
523
- tool_name=content_block.get("name"),
524
- tool_input=content_block.get("input", {}),
525
- )
514
+ await asyncio.sleep(0.1)
526
515
 
527
- # Tool result (from Claude Code's output)
528
- if msg_type == "tool_result":
529
- return StreamEvent(
530
- type=AdapterEvent.TOOL_RESULT,
531
- tool_name=data.get("name"),
532
- tool_result=data.get("result"),
533
- )
516
+ def _parse_jsonl_event(self, data: dict) -> list[StreamEvent]:
517
+ """Parse a session JSONL line into StreamEvent(s).
534
518
 
535
- # Error events
536
- if msg_type == "error":
537
- return StreamEvent(
538
- type=AdapterEvent.ERROR,
539
- error_message=data.get("message", "Unknown error"),
540
- error_code=data.get("code"),
541
- )
519
+ Claude's session JSONL format uses full message objects:
520
+ - queue-operation: Session start (contains sessionId)
521
+ - assistant: Claude's response (text, thinking, tool_use blocks + usage)
522
+ - user: User messages and tool results
523
+
524
+ Args:
525
+ data: Parsed JSON data from a JSONL line.
526
+
527
+ Returns:
528
+ List of StreamEvents (may be empty for unrecognized events).
529
+ """
530
+ events: list[StreamEvent] = []
531
+ event_type = data.get("type")
532
+
533
+ # Session start — first line of every session file
534
+ if event_type == "queue-operation":
535
+ self._session_id = data.get("sessionId")
536
+ events.append(StreamEvent(
537
+ type=AdapterEvent.INIT,
538
+ data={"session_id": self._session_id},
539
+ ))
540
+ return events
542
541
 
543
- # Assistant message with content
544
- # Claude Code stream-json format: {"type": "assistant", "message": {"content": [...]}}
545
- if msg_type == "assistant":
542
+ # Assistant message may contain text, thinking, tool_use blocks
543
+ if event_type == "assistant":
544
+ message = data.get("message", {})
545
+ content_blocks = message.get("content", [])
546
+ usage = message.get("usage")
547
+ is_error = data.get("isApiErrorMessage", False)
548
+
549
+ if is_error:
550
+ error_text = ""
551
+ if isinstance(content_blocks, list):
552
+ for block in content_blocks:
553
+ if block.get("type") == "text":
554
+ error_text = block.get("text", "")
555
+ events.append(StreamEvent(
556
+ type=AdapterEvent.ERROR,
557
+ error_message=error_text or data.get("error", "API error"),
558
+ error_code=data.get("error"),
559
+ ))
560
+ return events
561
+
562
+ if isinstance(content_blocks, list):
563
+ for block in content_blocks:
564
+ block_type = block.get("type")
565
+ if block_type == "thinking":
566
+ thinking_text = block.get("thinking", "")
567
+ if thinking_text:
568
+ events.append(StreamEvent(
569
+ type=AdapterEvent.THINKING,
570
+ thinking=thinking_text,
571
+ ))
572
+ elif block_type == "text":
573
+ text = block.get("text", "")
574
+ if text:
575
+ events.append(StreamEvent(
576
+ type=AdapterEvent.TEXT,
577
+ text=text,
578
+ ))
579
+ elif block_type == "tool_use":
580
+ events.append(StreamEvent(
581
+ type=AdapterEvent.TOOL_USE,
582
+ tool_name=block.get("name"),
583
+ tool_input=block.get("input"),
584
+ ))
585
+
586
+ # Emit usage data if present
587
+ if usage:
588
+ events.append(StreamEvent(
589
+ type=AdapterEvent.USAGE,
590
+ usage=usage,
591
+ ))
592
+
593
+ return events
594
+
595
+ # User message — may contain tool results
596
+ if event_type == "user":
546
597
  message = data.get("message", {})
547
- content = message.get("content") or data.get("content")
598
+ content = message.get("content", [])
548
599
  if isinstance(content, list):
549
600
  for block in content:
550
- if block.get("type") == "text":
551
- return StreamEvent(
552
- type=AdapterEvent.TEXT,
553
- text=block.get("text", ""),
554
- )
601
+ if isinstance(block, dict) and block.get("type") == "tool_result":
602
+ result_text = block.get("content", "")
603
+ events.append(StreamEvent(
604
+ type=AdapterEvent.TOOL_RESULT,
605
+ tool_name=None,
606
+ tool_result=str(result_text)[:1000],
607
+ ))
555
608
 
556
- # Result event contains the complete output (final message)
557
- if msg_type == "result":
558
- result_text = data.get("result", "")
559
- if result_text:
560
- return StreamEvent(
561
- type=AdapterEvent.TEXT,
562
- text=result_text,
563
- )
609
+ return events
564
610
 
565
- # Message completion
566
- if msg_type == "message_stop":
567
- return StreamEvent(
568
- type=AdapterEvent.COMPLETE,
569
- data={"session_id": self._session_id},
570
- )
611
+ return events # Ignore unrecognized event types
571
612
 
572
- return None
613
+ async def _drain_pipe(self, pipe) -> bytes:
614
+ """Read all data from a subprocess pipe without parsing.
573
615
 
574
- @staticmethod
575
- def is_available() -> bool:
576
- """Check if Claude CLI is available.
616
+ Used to drain stdout/stderr in background to prevent pipe buffer deadlock.
617
+
618
+ Args:
619
+ pipe: asyncio subprocess pipe (stdout or stderr).
577
620
 
578
621
  Returns:
579
- True if claude command is found in PATH.
622
+ All bytes read from the pipe.
580
623
  """
581
- import shutil
582
- return shutil.which("claude") is not None
624
+ if not pipe:
625
+ return b""
626
+ chunks = []
627
+ max_size = 4 * 1024 * 1024 # 4MB max
628
+ total = 0
629
+ while total < max_size:
630
+ chunk = await pipe.read(8192)
631
+ if not chunk:
632
+ break
633
+ chunks.append(chunk)
634
+ total += len(chunk)
635
+ return b"".join(chunks)
636
+
637
+ def build_run_marker(
638
+ self,
639
+ run_id: str,
640
+ project_slug: str,
641
+ iteration: int,
642
+ mode: str,
643
+ ) -> str:
644
+ """Build the run tracking marker to inject into prompts.
583
645
 
584
- @staticmethod
585
- async def check_auth() -> bool:
586
- """Check if Claude CLI is authenticated.
646
+ This marker is placed at the END of prompts to track which
647
+ session belongs to which run.
648
+
649
+ Args:
650
+ run_id: The run identifier.
651
+ project_slug: Project slug.
652
+ iteration: Current iteration number.
653
+ mode: Current mode name.
587
654
 
588
655
  Returns:
589
- True if authenticated.
656
+ Marker string to append to prompt.
590
657
  """
591
- try:
592
- proc = await asyncio.create_subprocess_exec(
593
- "claude", "--version",
594
- stdout=asyncio.subprocess.PIPE,
595
- stderr=asyncio.subprocess.PIPE,
596
- )
597
- await proc.wait()
598
- return proc.returncode == 0
599
- except Exception:
600
- return False
658
+ now = datetime.utcnow().isoformat()
659
+ # Sanitize values to prevent HTML comment injection (e.g., --> in mode name)
660
+ safe_run_id = run_id.replace("--", "").replace('"', "")
661
+ safe_slug = project_slug.replace("--", "").replace('"', "")
662
+ safe_mode = mode.replace("--", "").replace('"', "")
663
+ return f"""
664
+
665
+ <!-- RALPHX_TRACKING run_id="{safe_run_id}" project="{safe_slug}" iteration={iteration} mode="{safe_mode}" ts="{now}" -->"""