steerdev 0.4.27__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.
- steerdev-0.4.27.dist-info/METADATA +224 -0
- steerdev-0.4.27.dist-info/RECORD +57 -0
- steerdev-0.4.27.dist-info/WHEEL +4 -0
- steerdev-0.4.27.dist-info/entry_points.txt +2 -0
- steerdev_agent/__init__.py +10 -0
- steerdev_agent/api/__init__.py +32 -0
- steerdev_agent/api/activity.py +278 -0
- steerdev_agent/api/agents.py +145 -0
- steerdev_agent/api/client.py +158 -0
- steerdev_agent/api/commands.py +399 -0
- steerdev_agent/api/configs.py +238 -0
- steerdev_agent/api/context.py +306 -0
- steerdev_agent/api/events.py +294 -0
- steerdev_agent/api/hooks.py +178 -0
- steerdev_agent/api/implementation_plan.py +408 -0
- steerdev_agent/api/messages.py +231 -0
- steerdev_agent/api/prd.py +281 -0
- steerdev_agent/api/runs.py +526 -0
- steerdev_agent/api/sessions.py +403 -0
- steerdev_agent/api/specs.py +321 -0
- steerdev_agent/api/tasks.py +659 -0
- steerdev_agent/api/workflow_runs.py +351 -0
- steerdev_agent/api/workflows.py +191 -0
- steerdev_agent/cli.py +2254 -0
- steerdev_agent/config/__init__.py +19 -0
- steerdev_agent/config/models.py +236 -0
- steerdev_agent/config/platform.py +272 -0
- steerdev_agent/config/settings.py +62 -0
- steerdev_agent/daemon.py +675 -0
- steerdev_agent/executor/__init__.py +64 -0
- steerdev_agent/executor/base.py +121 -0
- steerdev_agent/executor/claude.py +328 -0
- steerdev_agent/executor/stream.py +163 -0
- steerdev_agent/git/__init__.py +1 -0
- steerdev_agent/handlers/__init__.py +5 -0
- steerdev_agent/handlers/prd.py +533 -0
- steerdev_agent/integration.py +334 -0
- steerdev_agent/prompt/__init__.py +10 -0
- steerdev_agent/prompt/builder.py +263 -0
- steerdev_agent/prompt/templates.py +422 -0
- steerdev_agent/py.typed +0 -0
- steerdev_agent/runner.py +829 -0
- steerdev_agent/setup/__init__.py +5 -0
- steerdev_agent/setup/claude_setup.py +560 -0
- steerdev_agent/setup/templates/claude_md_section.md +140 -0
- steerdev_agent/setup/templates/settings.json +69 -0
- steerdev_agent/setup/templates/skills/activity/SKILL.md +160 -0
- steerdev_agent/setup/templates/skills/context/SKILL.md +122 -0
- steerdev_agent/setup/templates/skills/git-workflow/SKILL.md +218 -0
- steerdev_agent/setup/templates/skills/progress-logging/SKILL.md +211 -0
- steerdev_agent/setup/templates/skills/specs-management/SKILL.md +161 -0
- steerdev_agent/setup/templates/skills/task-management/SKILL.md +343 -0
- steerdev_agent/setup/templates/steerdev.yaml +51 -0
- steerdev_agent/version.py +149 -0
- steerdev_agent/workflow/__init__.py +10 -0
- steerdev_agent/workflow/executor.py +494 -0
- steerdev_agent/workflow/memory.py +185 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Executor module for running AI agents."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from steerdev_agent.executor.base import AgentExecutor, StreamEvent
|
|
8
|
+
from steerdev_agent.executor.claude import ClaudeExecutor
|
|
9
|
+
from steerdev_agent.executor.stream import StreamParser
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from steerdev_agent.config.models import ExecutorConfig
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AgentExecutor",
|
|
16
|
+
"ClaudeExecutor",
|
|
17
|
+
"ExecutorFactory",
|
|
18
|
+
"StreamEvent",
|
|
19
|
+
"StreamParser",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ExecutorFactory:
|
|
24
|
+
"""Factory for creating agent executors based on configuration."""
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def create(
|
|
28
|
+
config: ExecutorConfig,
|
|
29
|
+
working_directory: str,
|
|
30
|
+
model: str | None = None,
|
|
31
|
+
max_turns: int | None = None,
|
|
32
|
+
dry_run: bool = False,
|
|
33
|
+
worktree_name: str | None = None,
|
|
34
|
+
) -> AgentExecutor:
|
|
35
|
+
"""Create an executor based on the configuration.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
config: Executor configuration specifying type and settings.
|
|
39
|
+
working_directory: Directory to run the agent in.
|
|
40
|
+
model: Model override (takes precedence over config).
|
|
41
|
+
max_turns: Maximum number of agent turns.
|
|
42
|
+
dry_run: If True, print command without executing.
|
|
43
|
+
worktree_name: Name for Claude CLI --worktree flag.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Configured AgentExecutor instance.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
ValueError: If executor type is not supported.
|
|
50
|
+
"""
|
|
51
|
+
if config.type == "claude":
|
|
52
|
+
return ClaudeExecutor(
|
|
53
|
+
working_directory=working_directory,
|
|
54
|
+
model=model,
|
|
55
|
+
max_turns=max_turns,
|
|
56
|
+
allowed_tools=config.allowed_tools or None,
|
|
57
|
+
disallowed_tools=config.disallowed_tools or None,
|
|
58
|
+
mcp_config=config.mcp_config,
|
|
59
|
+
permission_mode=config.permission_mode,
|
|
60
|
+
dry_run=dry_run,
|
|
61
|
+
worktree_name=worktree_name,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
raise ValueError(f"Unsupported executor type: {config.type}")
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Base executor interface for AI agents."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EventType(str, Enum):
|
|
12
|
+
"""Types of events emitted by agents."""
|
|
13
|
+
|
|
14
|
+
SYSTEM = "system"
|
|
15
|
+
USER = "user"
|
|
16
|
+
ASSISTANT = "assistant"
|
|
17
|
+
TOOL_USE = "tool_use"
|
|
18
|
+
TOOL_RESULT = "tool_result"
|
|
19
|
+
ERROR = "error"
|
|
20
|
+
RESULT = "result"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class StreamEvent:
|
|
25
|
+
"""An event from the agent's output stream."""
|
|
26
|
+
|
|
27
|
+
event_type: EventType
|
|
28
|
+
timestamp: datetime
|
|
29
|
+
data: dict[str, Any]
|
|
30
|
+
raw_json: str
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def is_final(self) -> bool:
|
|
34
|
+
"""Check if this is the final event (result)."""
|
|
35
|
+
return self.event_type == EventType.RESULT
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class AgentExecutor(ABC):
|
|
39
|
+
"""Abstract base class for agent executors.
|
|
40
|
+
|
|
41
|
+
Executors handle launching and managing CLI agents (Claude Code, Codex, Aider)
|
|
42
|
+
as subprocesses, streaming their JSON output.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, working_directory: str) -> None:
|
|
46
|
+
"""Initialize the executor.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
working_directory: Directory to run the agent in.
|
|
50
|
+
"""
|
|
51
|
+
self.working_directory = working_directory
|
|
52
|
+
self._session_id: str | None = None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def session_id(self) -> str | None:
|
|
56
|
+
"""Get the agent's native session ID for resume support."""
|
|
57
|
+
return self._session_id
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def agent_type(self) -> str:
|
|
62
|
+
"""Return the agent type identifier (e.g., 'claude', 'codex', 'aider')."""
|
|
63
|
+
...
|
|
64
|
+
|
|
65
|
+
@abstractmethod
|
|
66
|
+
async def start(self, prompt: str) -> None:
|
|
67
|
+
"""Start the agent with an initial prompt.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
prompt: The initial prompt to send to the agent.
|
|
71
|
+
"""
|
|
72
|
+
...
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
async def resume(self, session_id: str, message: str) -> None:
|
|
76
|
+
"""Resume an existing session with a new message.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
session_id: The native session ID to resume.
|
|
80
|
+
message: The message to continue the conversation with.
|
|
81
|
+
"""
|
|
82
|
+
...
|
|
83
|
+
|
|
84
|
+
@abstractmethod
|
|
85
|
+
def stream_events(self) -> AsyncIterator[StreamEvent]:
|
|
86
|
+
"""Stream events from the agent's output.
|
|
87
|
+
|
|
88
|
+
Yields:
|
|
89
|
+
StreamEvent objects as they are parsed from the agent's output.
|
|
90
|
+
"""
|
|
91
|
+
...
|
|
92
|
+
|
|
93
|
+
@abstractmethod
|
|
94
|
+
async def stop(self) -> None:
|
|
95
|
+
"""Stop the agent process."""
|
|
96
|
+
...
|
|
97
|
+
|
|
98
|
+
@abstractmethod
|
|
99
|
+
async def wait(self) -> int:
|
|
100
|
+
"""Wait for the agent process to complete.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
The exit code of the agent process.
|
|
104
|
+
"""
|
|
105
|
+
...
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
@abstractmethod
|
|
109
|
+
def is_running(self) -> bool:
|
|
110
|
+
"""Check if the agent process is still running."""
|
|
111
|
+
...
|
|
112
|
+
|
|
113
|
+
async def get_stderr(self) -> str:
|
|
114
|
+
"""Get any stderr output from the agent.
|
|
115
|
+
|
|
116
|
+
Override in subclasses that support stderr capture.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Stderr output as a string, empty by default.
|
|
120
|
+
"""
|
|
121
|
+
return ""
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""Claude Code executor implementation."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import shutil
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
from steerdev_agent.executor.base import AgentExecutor, StreamEvent
|
|
10
|
+
from steerdev_agent.executor.stream import StreamParser
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ClaudeExecutorError(Exception):
|
|
14
|
+
"""Error during Claude executor operation."""
|
|
15
|
+
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ClaudeExecutor(AgentExecutor):
|
|
20
|
+
"""Executor for Claude Code CLI.
|
|
21
|
+
|
|
22
|
+
Launches Claude Code as a subprocess with JSON streaming output
|
|
23
|
+
and manages the process lifecycle.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
# Claude Code CLI executable name
|
|
27
|
+
CLAUDE_CLI = "claude"
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
working_directory: str,
|
|
32
|
+
model: str | None = None,
|
|
33
|
+
max_turns: int | None = None,
|
|
34
|
+
allowed_tools: list[str] | None = None,
|
|
35
|
+
disallowed_tools: list[str] | None = None,
|
|
36
|
+
mcp_config: str | None = None,
|
|
37
|
+
permission_mode: str = "dangerously-skip-permissions",
|
|
38
|
+
dry_run: bool = False,
|
|
39
|
+
worktree_name: str | None = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Initialize the Claude executor.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
working_directory: Directory to run Claude in.
|
|
45
|
+
model: Model to use (e.g., 'claude-sonnet-4-20250514').
|
|
46
|
+
max_turns: Maximum number of agent turns.
|
|
47
|
+
allowed_tools: List of allowed tools.
|
|
48
|
+
disallowed_tools: List of disallowed tools.
|
|
49
|
+
mcp_config: Path to MCP config file.
|
|
50
|
+
permission_mode: Permission mode. Default skips all permissions.
|
|
51
|
+
dry_run: If True, print the command without executing it.
|
|
52
|
+
worktree_name: Name for Claude CLI --worktree flag.
|
|
53
|
+
"""
|
|
54
|
+
super().__init__(working_directory)
|
|
55
|
+
|
|
56
|
+
self.model = model
|
|
57
|
+
self.max_turns = max_turns
|
|
58
|
+
self.allowed_tools = allowed_tools or []
|
|
59
|
+
self.disallowed_tools = disallowed_tools or []
|
|
60
|
+
self.mcp_config = mcp_config
|
|
61
|
+
self.permission_mode = permission_mode
|
|
62
|
+
self.dry_run = dry_run
|
|
63
|
+
self.worktree_name = worktree_name
|
|
64
|
+
|
|
65
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
66
|
+
self._parser = StreamParser()
|
|
67
|
+
self._dry_run_complete = False
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def agent_type(self) -> str:
|
|
71
|
+
"""Return the agent type identifier."""
|
|
72
|
+
return "claude"
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def session_id(self) -> str | None:
|
|
76
|
+
"""Get Claude's session ID from parsed output."""
|
|
77
|
+
return self._parser.session_id or self._session_id
|
|
78
|
+
|
|
79
|
+
def _find_claude_cli(self) -> str:
|
|
80
|
+
"""Find the Claude CLI executable.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Path to the Claude CLI.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ClaudeExecutorError: If Claude CLI is not found.
|
|
87
|
+
"""
|
|
88
|
+
claude_path = shutil.which(self.CLAUDE_CLI)
|
|
89
|
+
if claude_path is None:
|
|
90
|
+
raise ClaudeExecutorError(
|
|
91
|
+
f"Claude CLI '{self.CLAUDE_CLI}' not found in PATH. "
|
|
92
|
+
"Please install Claude Code: https://claude.ai/code"
|
|
93
|
+
)
|
|
94
|
+
return claude_path
|
|
95
|
+
|
|
96
|
+
def _build_command(self, prompt: str, resume_session: str | None = None) -> list[str]:
|
|
97
|
+
"""Build the Claude CLI command.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
prompt: The prompt or message to send.
|
|
101
|
+
resume_session: Session ID to resume, if any.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Command and arguments as a list.
|
|
105
|
+
"""
|
|
106
|
+
claude_path = self._find_claude_cli()
|
|
107
|
+
cmd = [claude_path]
|
|
108
|
+
|
|
109
|
+
# Add prompt
|
|
110
|
+
cmd.extend(["-p", prompt])
|
|
111
|
+
|
|
112
|
+
# Output format for streaming (requires --verbose with -p)
|
|
113
|
+
cmd.extend(["--output-format", "stream-json", "--verbose"])
|
|
114
|
+
|
|
115
|
+
# Permission mode
|
|
116
|
+
if self.permission_mode == "dangerously-skip-permissions":
|
|
117
|
+
cmd.append("--dangerously-skip-permissions")
|
|
118
|
+
|
|
119
|
+
# Resume session if specified
|
|
120
|
+
if resume_session:
|
|
121
|
+
cmd.extend(["--resume", resume_session])
|
|
122
|
+
|
|
123
|
+
# Model
|
|
124
|
+
if self.model:
|
|
125
|
+
cmd.extend(["--model", self.model])
|
|
126
|
+
|
|
127
|
+
# Max turns
|
|
128
|
+
if self.max_turns:
|
|
129
|
+
cmd.extend(["--max-turns", str(self.max_turns)])
|
|
130
|
+
|
|
131
|
+
# Allowed tools
|
|
132
|
+
for tool in self.allowed_tools:
|
|
133
|
+
cmd.extend(["--allowedTools", tool])
|
|
134
|
+
|
|
135
|
+
# Disallowed tools
|
|
136
|
+
for tool in self.disallowed_tools:
|
|
137
|
+
cmd.extend(["--disallowedTools", tool])
|
|
138
|
+
|
|
139
|
+
# MCP config
|
|
140
|
+
if self.mcp_config:
|
|
141
|
+
cmd.extend(["--mcp-config", self.mcp_config])
|
|
142
|
+
|
|
143
|
+
# Worktree isolation
|
|
144
|
+
if self.worktree_name:
|
|
145
|
+
cmd.extend(["--worktree", self.worktree_name])
|
|
146
|
+
|
|
147
|
+
return cmd
|
|
148
|
+
|
|
149
|
+
async def start(self, prompt: str) -> None:
|
|
150
|
+
"""Start Claude with an initial prompt.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
prompt: The initial prompt to send.
|
|
154
|
+
|
|
155
|
+
Raises:
|
|
156
|
+
ClaudeExecutorError: If Claude fails to start.
|
|
157
|
+
"""
|
|
158
|
+
if self._process is not None and self._process.returncode is None:
|
|
159
|
+
raise ClaudeExecutorError("Claude is already running")
|
|
160
|
+
|
|
161
|
+
cmd = self._build_command(prompt)
|
|
162
|
+
logger.info(f"Starting Claude in {self.working_directory}")
|
|
163
|
+
logger.debug(f"Command: {' '.join(cmd)}")
|
|
164
|
+
|
|
165
|
+
if self.dry_run:
|
|
166
|
+
# Print the command that would be executed
|
|
167
|
+
print("\n[DRY RUN] Would execute command:")
|
|
168
|
+
print(f" {' '.join(cmd)}")
|
|
169
|
+
print(f"\n[DRY RUN] Working directory: {self.working_directory}")
|
|
170
|
+
print("\n[DRY RUN] Waiting 5 seconds...")
|
|
171
|
+
await asyncio.sleep(5)
|
|
172
|
+
print("[DRY RUN] Done.")
|
|
173
|
+
self._dry_run_complete = True
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
178
|
+
*cmd,
|
|
179
|
+
stdout=asyncio.subprocess.PIPE,
|
|
180
|
+
stderr=asyncio.subprocess.PIPE,
|
|
181
|
+
cwd=self.working_directory,
|
|
182
|
+
limit=10 * 1024 * 1024, # 10MB buffer for large JSON outputs
|
|
183
|
+
)
|
|
184
|
+
logger.info(f"Claude started with PID {self._process.pid}")
|
|
185
|
+
except OSError as e:
|
|
186
|
+
raise ClaudeExecutorError(f"Failed to start Claude: {e}") from e
|
|
187
|
+
|
|
188
|
+
async def resume(self, session_id: str, message: str) -> None:
|
|
189
|
+
"""Resume an existing Claude session.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
session_id: The Claude session ID to resume.
|
|
193
|
+
message: The message to continue the conversation with.
|
|
194
|
+
|
|
195
|
+
Raises:
|
|
196
|
+
ClaudeExecutorError: If Claude fails to start.
|
|
197
|
+
"""
|
|
198
|
+
if self._process is not None and self._process.returncode is None:
|
|
199
|
+
raise ClaudeExecutorError("Claude is already running")
|
|
200
|
+
|
|
201
|
+
self._session_id = session_id
|
|
202
|
+
cmd = self._build_command(message, resume_session=session_id)
|
|
203
|
+
logger.info(f"Resuming Claude session {session_id}")
|
|
204
|
+
logger.debug(f"Command: {' '.join(cmd)}")
|
|
205
|
+
|
|
206
|
+
if self.dry_run:
|
|
207
|
+
# Print the command that would be executed
|
|
208
|
+
print("\n[DRY RUN] Would execute command:")
|
|
209
|
+
print(f" {' '.join(cmd)}")
|
|
210
|
+
print(f"\n[DRY RUN] Working directory: {self.working_directory}")
|
|
211
|
+
print("\n[DRY RUN] Waiting 5 seconds...")
|
|
212
|
+
await asyncio.sleep(5)
|
|
213
|
+
print("[DRY RUN] Done.")
|
|
214
|
+
self._dry_run_complete = True
|
|
215
|
+
return
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
219
|
+
*cmd,
|
|
220
|
+
stdout=asyncio.subprocess.PIPE,
|
|
221
|
+
stderr=asyncio.subprocess.PIPE,
|
|
222
|
+
cwd=self.working_directory,
|
|
223
|
+
limit=10 * 1024 * 1024, # 10MB buffer for large JSON outputs
|
|
224
|
+
)
|
|
225
|
+
logger.info(f"Claude resumed with PID {self._process.pid}")
|
|
226
|
+
except OSError as e:
|
|
227
|
+
raise ClaudeExecutorError(f"Failed to resume Claude: {e}") from e
|
|
228
|
+
|
|
229
|
+
async def stream_events(self) -> AsyncIterator[StreamEvent]:
|
|
230
|
+
"""Stream events from Claude's output.
|
|
231
|
+
|
|
232
|
+
Yields:
|
|
233
|
+
StreamEvent objects parsed from Claude's JSON output.
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
ClaudeExecutorError: If Claude is not running.
|
|
237
|
+
"""
|
|
238
|
+
# In dry_run mode, there are no events to stream
|
|
239
|
+
if self.dry_run:
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
if self._process is None or self._process.stdout is None:
|
|
243
|
+
raise ClaudeExecutorError("Claude is not running")
|
|
244
|
+
|
|
245
|
+
async for line_bytes in self._process.stdout:
|
|
246
|
+
line = line_bytes.decode("utf-8", errors="replace")
|
|
247
|
+
event = self._parser.parse_line(line)
|
|
248
|
+
if event is not None:
|
|
249
|
+
# Update session ID if found
|
|
250
|
+
if self._parser.session_id:
|
|
251
|
+
self._session_id = self._parser.session_id
|
|
252
|
+
yield event
|
|
253
|
+
|
|
254
|
+
# Handle any remaining buffered data
|
|
255
|
+
final_event = self._parser.flush()
|
|
256
|
+
if final_event is not None:
|
|
257
|
+
yield final_event
|
|
258
|
+
|
|
259
|
+
async def stop(self) -> None:
|
|
260
|
+
"""Stop the Claude process."""
|
|
261
|
+
# In dry_run mode, nothing to stop
|
|
262
|
+
if self.dry_run:
|
|
263
|
+
self._dry_run_complete = True
|
|
264
|
+
return
|
|
265
|
+
|
|
266
|
+
if self._process is None:
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
if self._process.returncode is not None:
|
|
270
|
+
logger.debug("Claude process already stopped")
|
|
271
|
+
return
|
|
272
|
+
|
|
273
|
+
logger.info("Stopping Claude process")
|
|
274
|
+
|
|
275
|
+
# Try graceful termination first
|
|
276
|
+
self._process.terminate()
|
|
277
|
+
try:
|
|
278
|
+
await asyncio.wait_for(self._process.wait(), timeout=5.0)
|
|
279
|
+
logger.debug("Claude terminated gracefully")
|
|
280
|
+
except TimeoutError:
|
|
281
|
+
logger.warning("Claude did not terminate, killing...")
|
|
282
|
+
self._process.kill()
|
|
283
|
+
await self._process.wait()
|
|
284
|
+
logger.debug("Claude killed")
|
|
285
|
+
|
|
286
|
+
async def wait(self) -> int:
|
|
287
|
+
"""Wait for Claude to complete.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
The exit code of the Claude process.
|
|
291
|
+
|
|
292
|
+
Raises:
|
|
293
|
+
ClaudeExecutorError: If Claude is not running.
|
|
294
|
+
"""
|
|
295
|
+
# In dry_run mode, return success immediately
|
|
296
|
+
if self.dry_run:
|
|
297
|
+
return 0
|
|
298
|
+
|
|
299
|
+
if self._process is None:
|
|
300
|
+
raise ClaudeExecutorError("Claude is not running")
|
|
301
|
+
|
|
302
|
+
return await self._process.wait()
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def is_running(self) -> bool:
|
|
306
|
+
"""Check if Claude is still running."""
|
|
307
|
+
# In dry_run mode, it's not actually running
|
|
308
|
+
if self.dry_run:
|
|
309
|
+
return not self._dry_run_complete
|
|
310
|
+
if self._process is None:
|
|
311
|
+
return False
|
|
312
|
+
return self._process.returncode is None
|
|
313
|
+
|
|
314
|
+
async def get_stderr(self) -> str:
|
|
315
|
+
"""Get any stderr output from Claude.
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
Stderr output as a string.
|
|
319
|
+
"""
|
|
320
|
+
if self._process is None or self._process.stderr is None:
|
|
321
|
+
return ""
|
|
322
|
+
|
|
323
|
+
try:
|
|
324
|
+
stderr_bytes = await self._process.stderr.read()
|
|
325
|
+
return stderr_bytes.decode("utf-8", errors="replace")
|
|
326
|
+
except Exception as e:
|
|
327
|
+
logger.warning(f"Failed to read stderr: {e}")
|
|
328
|
+
return ""
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""JSON stream parser for Claude's stream-json output format."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import AsyncIterator
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from typing import Any, ClassVar
|
|
7
|
+
|
|
8
|
+
from loguru import logger
|
|
9
|
+
|
|
10
|
+
from steerdev_agent.executor.base import EventType, StreamEvent
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StreamParser:
|
|
14
|
+
"""Parser for Claude Code's stream-json output format.
|
|
15
|
+
|
|
16
|
+
Claude Code outputs newline-delimited JSON when run with --output-format stream-json.
|
|
17
|
+
Each line is a complete JSON object representing an event.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
# Event type mapping from Claude's output to our EventType
|
|
21
|
+
EVENT_TYPE_MAP: ClassVar[dict[str, EventType]] = {
|
|
22
|
+
"system": EventType.SYSTEM,
|
|
23
|
+
"user": EventType.USER,
|
|
24
|
+
"assistant": EventType.ASSISTANT,
|
|
25
|
+
"tool_use": EventType.TOOL_USE,
|
|
26
|
+
"tool_result": EventType.TOOL_RESULT,
|
|
27
|
+
"error": EventType.ERROR,
|
|
28
|
+
"result": EventType.RESULT,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def __init__(self) -> None:
|
|
32
|
+
"""Initialize the parser."""
|
|
33
|
+
self._buffer = ""
|
|
34
|
+
self._session_id: str | None = None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def session_id(self) -> str | None:
|
|
38
|
+
"""Get the session ID extracted from the stream."""
|
|
39
|
+
return self._session_id
|
|
40
|
+
|
|
41
|
+
def parse_line(self, line: str) -> StreamEvent | None:
|
|
42
|
+
"""Parse a single JSON line into a StreamEvent.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
line: A single line from the JSON stream.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
A StreamEvent if parsing succeeded, None otherwise.
|
|
49
|
+
"""
|
|
50
|
+
line = line.strip()
|
|
51
|
+
if not line:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(line)
|
|
56
|
+
except json.JSONDecodeError as e:
|
|
57
|
+
logger.warning(f"Failed to parse JSON line: {e}")
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
# Extract event type
|
|
61
|
+
event_type_str = data.get("type", "unknown")
|
|
62
|
+
event_type = self.EVENT_TYPE_MAP.get(event_type_str, EventType.SYSTEM)
|
|
63
|
+
|
|
64
|
+
# Extract session ID from result events
|
|
65
|
+
if event_type == EventType.RESULT:
|
|
66
|
+
self._extract_session_id(data)
|
|
67
|
+
|
|
68
|
+
# Parse timestamp if available, otherwise use now
|
|
69
|
+
timestamp = self._parse_timestamp(data.get("timestamp"))
|
|
70
|
+
|
|
71
|
+
return StreamEvent(
|
|
72
|
+
event_type=event_type,
|
|
73
|
+
timestamp=timestamp,
|
|
74
|
+
data=data,
|
|
75
|
+
raw_json=line,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def _extract_session_id(self, data: dict[str, Any]) -> None:
|
|
79
|
+
"""Extract session ID from result data.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
data: The parsed JSON data.
|
|
83
|
+
"""
|
|
84
|
+
# Claude stores session ID in result.session_id
|
|
85
|
+
if "session_id" in data:
|
|
86
|
+
self._session_id = data["session_id"]
|
|
87
|
+
elif (
|
|
88
|
+
"result" in data and isinstance(data["result"], dict) and "session_id" in data["result"]
|
|
89
|
+
):
|
|
90
|
+
self._session_id = data["result"]["session_id"]
|
|
91
|
+
|
|
92
|
+
def _parse_timestamp(self, ts: str | None) -> datetime:
|
|
93
|
+
"""Parse a timestamp string or return current time.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
ts: ISO format timestamp string or None.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Parsed datetime or current UTC time.
|
|
100
|
+
"""
|
|
101
|
+
if ts is None:
|
|
102
|
+
return datetime.now(UTC)
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
# Handle ISO format with or without Z suffix
|
|
106
|
+
if ts.endswith("Z"):
|
|
107
|
+
ts = ts[:-1] + "+00:00"
|
|
108
|
+
return datetime.fromisoformat(ts)
|
|
109
|
+
except ValueError:
|
|
110
|
+
return datetime.now(UTC)
|
|
111
|
+
|
|
112
|
+
async def parse_stream(
|
|
113
|
+
self,
|
|
114
|
+
lines: AsyncIterator[str],
|
|
115
|
+
) -> AsyncIterator[StreamEvent]:
|
|
116
|
+
"""Parse an async stream of lines into events.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
lines: Async iterator of lines from the process output.
|
|
120
|
+
|
|
121
|
+
Yields:
|
|
122
|
+
StreamEvent objects for each valid JSON line.
|
|
123
|
+
"""
|
|
124
|
+
async for line in lines:
|
|
125
|
+
event = self.parse_line(line)
|
|
126
|
+
if event is not None:
|
|
127
|
+
yield event
|
|
128
|
+
|
|
129
|
+
def feed(self, chunk: str) -> list[StreamEvent]:
|
|
130
|
+
"""Feed a chunk of data and return any complete events.
|
|
131
|
+
|
|
132
|
+
For use with non-async streams or buffered reading.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
chunk: A chunk of data (may contain partial lines).
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
List of parsed events from complete lines.
|
|
139
|
+
"""
|
|
140
|
+
self._buffer += chunk
|
|
141
|
+
events: list[StreamEvent] = []
|
|
142
|
+
|
|
143
|
+
while "\n" in self._buffer:
|
|
144
|
+
line, self._buffer = self._buffer.split("\n", 1)
|
|
145
|
+
event = self.parse_line(line)
|
|
146
|
+
if event is not None:
|
|
147
|
+
events.append(event)
|
|
148
|
+
|
|
149
|
+
return events
|
|
150
|
+
|
|
151
|
+
def flush(self) -> StreamEvent | None:
|
|
152
|
+
"""Flush any remaining buffered data.
|
|
153
|
+
|
|
154
|
+
Call this when the stream ends to handle any partial line.
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
A StreamEvent if there was buffered data, None otherwise.
|
|
158
|
+
"""
|
|
159
|
+
if self._buffer.strip():
|
|
160
|
+
event = self.parse_line(self._buffer)
|
|
161
|
+
self._buffer = ""
|
|
162
|
+
return event
|
|
163
|
+
return None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Git module for steerdev."""
|