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.
- ralphx/__init__.py +1 -1
- ralphx/adapters/base.py +18 -2
- ralphx/adapters/claude_cli.py +415 -350
- ralphx/api/routes/auth.py +105 -32
- ralphx/api/routes/items.py +4 -0
- ralphx/api/routes/loops.py +101 -15
- ralphx/api/routes/planning.py +866 -17
- ralphx/api/routes/resources.py +528 -6
- ralphx/api/routes/stream.py +161 -114
- ralphx/api/routes/templates.py +1 -0
- ralphx/api/routes/workflows.py +257 -25
- ralphx/core/auth.py +32 -7
- ralphx/core/checkpoint.py +118 -0
- ralphx/core/executor.py +292 -85
- ralphx/core/loop_templates.py +59 -14
- ralphx/core/planning_iteration_executor.py +633 -0
- ralphx/core/planning_service.py +11 -4
- ralphx/core/project_db.py +835 -85
- ralphx/core/resources.py +28 -2
- ralphx/core/session.py +62 -10
- ralphx/core/templates.py +74 -87
- ralphx/core/workflow_executor.py +35 -3
- ralphx/mcp/tools/diagnostics.py +1 -1
- ralphx/mcp/tools/monitoring.py +10 -16
- ralphx/mcp/tools/workflows.py +5 -5
- ralphx/models/loop.py +1 -1
- ralphx/models/session.py +5 -0
- ralphx/static/assets/index-DnihHetG.js +265 -0
- ralphx/static/assets/index-DnihHetG.js.map +1 -0
- ralphx/static/assets/index-nIDWmtzm.css +1 -0
- ralphx/static/index.html +2 -2
- ralphx/templates/loop_templates/consumer.md +2 -2
- {ralphx-0.3.5.dist-info → ralphx-0.4.1.dist-info}/METADATA +1 -1
- {ralphx-0.3.5.dist-info → ralphx-0.4.1.dist-info}/RECORD +36 -35
- ralphx/static/assets/index-0ovNnfOq.css +0 -1
- ralphx/static/assets/index-CY9s08ZB.js +0 -251
- ralphx/static/assets/index-CY9s08ZB.js.map +0 -1
- {ralphx-0.3.5.dist-info → ralphx-0.4.1.dist-info}/WHEEL +0 -0
- {ralphx-0.3.5.dist-info → ralphx-0.4.1.dist-info}/entry_points.txt +0 -0
ralphx/adapters/claude_cli.py
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
"""Claude CLI adapter for RalphX.
|
|
2
2
|
|
|
3
|
-
Spawns Claude CLI as a subprocess and
|
|
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
|
|
34
|
-
-
|
|
35
|
-
-
|
|
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
|
-
#
|
|
99
|
-
#
|
|
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",
|
|
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
|
-
#
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
#
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
#
|
|
336
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
stderr_task =
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
444
|
-
#
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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"
|
|
454
|
-
error_code="
|
|
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.
|
|
472
|
-
|
|
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
|
-
#
|
|
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=
|
|
372
|
+
data=completion_data,
|
|
480
373
|
)
|
|
481
374
|
|
|
482
375
|
self._process = None
|
|
483
376
|
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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
|
-
|
|
393
|
+
Path to new session file, or None if not found.
|
|
492
394
|
"""
|
|
493
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
|
|
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
|
-
|
|
528
|
-
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
|
544
|
-
|
|
545
|
-
|
|
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"
|
|
598
|
+
content = message.get("content", [])
|
|
548
599
|
if isinstance(content, list):
|
|
549
600
|
for block in content:
|
|
550
|
-
if block.get("type") == "
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
613
|
+
async def _drain_pipe(self, pipe) -> bytes:
|
|
614
|
+
"""Read all data from a subprocess pipe without parsing.
|
|
573
615
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
622
|
+
All bytes read from the pipe.
|
|
580
623
|
"""
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
656
|
+
Marker string to append to prompt.
|
|
590
657
|
"""
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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}" -->"""
|