shadowcat 2.0.0__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.
- agent/__init__.py +17 -0
- agent/benchmark/__init__.py +11 -0
- agent/benchmark/cli.py +179 -0
- agent/benchmark/config.py +15 -0
- agent/benchmark/docker.py +192 -0
- agent/benchmark/registry.py +99 -0
- agent/core/__init__.py +0 -0
- agent/core/agent.py +362 -0
- agent/core/backend.py +1667 -0
- agent/core/config.py +106 -0
- agent/core/controller.py +638 -0
- agent/core/events.py +177 -0
- agent/core/langfuse.py +320 -0
- agent/core/phantom.py +2327 -0
- agent/core/planner.py +493 -0
- agent/core/profiling.py +58 -0
- agent/core/sanitizer.py +104 -0
- agent/core/session.py +228 -0
- agent/core/tracer.py +137 -0
- agent/interface/__init__.py +0 -0
- agent/interface/components/__init__.py +0 -0
- agent/interface/components/activity_feed.py +202 -0
- agent/interface/components/renderers.py +149 -0
- agent/interface/components/splash.py +112 -0
- agent/interface/main.py +1126 -0
- agent/interface/styles.tcss +421 -0
- agent/interface/tui.py +508 -0
- agent/parsing/html_distiller.py +230 -0
- agent/parsing/tool_parser.py +115 -0
- agent/prompts/__init__.py +0 -0
- agent/prompts/pentesting.py +238 -0
- agent/rag_module/knowledge_base.py +116 -0
- agent/tests/benchmark_phantom.py +455 -0
- agent/tools/__init__.py +14 -0
- agent/tools/base.py +99 -0
- agent/tools/executor.py +345 -0
- agent/tools/registry.py +47 -0
- backend/README.md +244 -0
- backend/__init__.py +10 -0
- backend/api/__init__.py +1 -0
- backend/api/routes_scan.py +932 -0
- backend/authz.py +75 -0
- backend/cli.py +261 -0
- backend/compliance/__init__.py +1 -0
- backend/compliance/pdpa_mapping.py +16 -0
- backend/core/__init__.py +1 -0
- backend/core/coverage.py +93 -0
- backend/core/events.py +166 -0
- backend/core/llm_client.py +614 -0
- backend/core/orchestrator.py +402 -0
- backend/core/scan_memory.py +236 -0
- backend/crawler/__init__.py +1 -0
- backend/crawler/csrf.py +75 -0
- backend/crawler/headless.py +232 -0
- backend/crawler/js_analyzer.py +133 -0
- backend/crawler/spider.py +550 -0
- backend/crawler/subdomains.py +279 -0
- backend/daemon.py +252 -0
- backend/db/README.md +86 -0
- backend/db/__init__.py +17 -0
- backend/db/engine.py +87 -0
- backend/db/models.py +206 -0
- backend/db/repository.py +316 -0
- backend/db/schema.sql +247 -0
- backend/modes/__init__.py +5 -0
- backend/modes/base.py +178 -0
- backend/modes/ctf.py +39 -0
- backend/modes/enterprise.py +210 -0
- backend/modes/general.py +226 -0
- backend/modes/registry.py +34 -0
- backend/reporting/__init__.py +0 -0
- backend/reporting/generator.py +285 -0
- backend/schemas/__init__.py +1 -0
- backend/schemas/api.py +423 -0
- backend/tools/__init__.py +62 -0
- backend/tools/dirbrute_tool.py +304 -0
- backend/tools/general_report_tool.py +135 -0
- backend/tools/http_tool.py +351 -0
- backend/tools/nuclei_tool.py +20 -0
- backend/tools/report_tool.py +145 -0
- backend/tools/shell_tools.py +23 -0
- backend/verification/__init__.py +1 -0
- backend/verification/evidence_store.py +125 -0
- backend/verification/general_oracle.py +369 -0
- backend/verification/idor_oracle.py +131 -0
- backend/waf/__init__.py +0 -0
- backend/waf/detector.py +147 -0
- backend/waf/evasion.py +117 -0
- backend/webui/index.html +713 -0
- backend/workspace.py +182 -0
- shadowcat-2.0.0.dist-info/METADATA +360 -0
- shadowcat-2.0.0.dist-info/RECORD +95 -0
- shadowcat-2.0.0.dist-info/WHEEL +4 -0
- shadowcat-2.0.0.dist-info/entry_points.txt +4 -0
- shadowcat-2.0.0.dist-info/licenses/LICENSE.md +21 -0
agent/core/session.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Session management for ShadowCat - persistence and state tracking."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SessionStatus(Enum):
|
|
13
|
+
"""Session lifecycle status."""
|
|
14
|
+
|
|
15
|
+
RUNNING = "running"
|
|
16
|
+
PAUSED = "paused"
|
|
17
|
+
COMPLETED = "completed"
|
|
18
|
+
ERROR = "error"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SessionInfo:
|
|
23
|
+
"""Session state - framework agnostic."""
|
|
24
|
+
|
|
25
|
+
session_id: str
|
|
26
|
+
target: str
|
|
27
|
+
created_at: datetime
|
|
28
|
+
status: SessionStatus = SessionStatus.RUNNING
|
|
29
|
+
backend_session_id: str | None = None # Backend-specific ID (e.g., Claude session)
|
|
30
|
+
updated_at: datetime | None = None
|
|
31
|
+
task: str = ""
|
|
32
|
+
user_instructions: list[str] = field(default_factory=list)
|
|
33
|
+
flags_found: list[dict[str, str]] = field(default_factory=list)
|
|
34
|
+
total_cost_usd: float = 0.0
|
|
35
|
+
model: str = ""
|
|
36
|
+
last_error: str | None = None
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict[str, Any]:
|
|
39
|
+
"""Serialize session to dictionary for JSON storage."""
|
|
40
|
+
return {
|
|
41
|
+
"session_id": self.session_id,
|
|
42
|
+
"target": self.target,
|
|
43
|
+
"created_at": self.created_at.isoformat(),
|
|
44
|
+
"status": self.status.value,
|
|
45
|
+
"backend_session_id": self.backend_session_id,
|
|
46
|
+
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
|
47
|
+
"task": self.task,
|
|
48
|
+
"user_instructions": self.user_instructions,
|
|
49
|
+
"flags_found": self.flags_found,
|
|
50
|
+
"total_cost_usd": self.total_cost_usd,
|
|
51
|
+
"model": self.model,
|
|
52
|
+
"last_error": self.last_error,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@classmethod
|
|
56
|
+
def from_dict(cls, data: dict[str, Any]) -> "SessionInfo":
|
|
57
|
+
"""Deserialize session from dictionary."""
|
|
58
|
+
return cls(
|
|
59
|
+
session_id=data["session_id"],
|
|
60
|
+
target=data["target"],
|
|
61
|
+
created_at=datetime.fromisoformat(data["created_at"]),
|
|
62
|
+
status=SessionStatus(data["status"]),
|
|
63
|
+
backend_session_id=data.get("backend_session_id"),
|
|
64
|
+
updated_at=(
|
|
65
|
+
datetime.fromisoformat(data["updated_at"]) if data.get("updated_at") else None
|
|
66
|
+
),
|
|
67
|
+
task=data.get("task", ""),
|
|
68
|
+
user_instructions=data.get("user_instructions", []),
|
|
69
|
+
flags_found=data.get("flags_found", []),
|
|
70
|
+
total_cost_usd=data.get("total_cost_usd", 0.0),
|
|
71
|
+
model=data.get("model", ""),
|
|
72
|
+
last_error=data.get("last_error"),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class SessionStore:
|
|
77
|
+
"""Simple file-based session persistence."""
|
|
78
|
+
|
|
79
|
+
SESSIONS_DIR = Path.home() / ".shadowcat_agent" / "sessions"
|
|
80
|
+
|
|
81
|
+
def __init__(self, sessions_dir: Path | None = None):
|
|
82
|
+
"""Initialize session store.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
sessions_dir: Optional custom sessions directory
|
|
86
|
+
"""
|
|
87
|
+
self._sessions_dir = sessions_dir or self.SESSIONS_DIR
|
|
88
|
+
self._sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
self._current: SessionInfo | None = None
|
|
90
|
+
|
|
91
|
+
def create(self, target: str, task: str, model: str) -> SessionInfo:
|
|
92
|
+
"""Create a new session.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
target: Target URL/IP/domain
|
|
96
|
+
task: Task description
|
|
97
|
+
model: Model name
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
New SessionInfo instance
|
|
101
|
+
"""
|
|
102
|
+
session = SessionInfo(
|
|
103
|
+
session_id=str(uuid.uuid4())[:8],
|
|
104
|
+
target=target,
|
|
105
|
+
created_at=datetime.now(),
|
|
106
|
+
task=task,
|
|
107
|
+
model=model,
|
|
108
|
+
)
|
|
109
|
+
self._current = session
|
|
110
|
+
self.save()
|
|
111
|
+
return session
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def current(self) -> SessionInfo | None:
|
|
115
|
+
"""Get current active session."""
|
|
116
|
+
return self._current
|
|
117
|
+
|
|
118
|
+
def save(self) -> None:
|
|
119
|
+
"""Save current session to disk."""
|
|
120
|
+
if not self._current:
|
|
121
|
+
return
|
|
122
|
+
self._current.updated_at = datetime.now()
|
|
123
|
+
path = self._sessions_dir / f"{self._current.session_id}.json"
|
|
124
|
+
path.write_text(json.dumps(self._current.to_dict(), indent=2))
|
|
125
|
+
|
|
126
|
+
def load(self, session_id: str) -> SessionInfo | None:
|
|
127
|
+
"""Load a session by ID.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
session_id: Session ID to load
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
SessionInfo if found, None otherwise
|
|
134
|
+
"""
|
|
135
|
+
path = self._sessions_dir / f"{session_id}.json"
|
|
136
|
+
if not path.exists():
|
|
137
|
+
return None
|
|
138
|
+
try:
|
|
139
|
+
self._current = SessionInfo.from_dict(json.loads(path.read_text()))
|
|
140
|
+
return self._current
|
|
141
|
+
except (json.JSONDecodeError, KeyError, ValueError):
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
def list_sessions(self, target: str | None = None) -> list[SessionInfo]:
|
|
145
|
+
"""List all sessions, optionally filtered by target.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
target: Optional target filter
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
List of SessionInfo, sorted by creation date (newest first)
|
|
152
|
+
"""
|
|
153
|
+
sessions = []
|
|
154
|
+
for path in self._sessions_dir.glob("*.json"):
|
|
155
|
+
try:
|
|
156
|
+
session = SessionInfo.from_dict(json.loads(path.read_text()))
|
|
157
|
+
if target is None or session.target == target:
|
|
158
|
+
sessions.append(session)
|
|
159
|
+
except (json.JSONDecodeError, KeyError, ValueError):
|
|
160
|
+
continue
|
|
161
|
+
return sorted(sessions, key=lambda s: s.created_at, reverse=True)
|
|
162
|
+
|
|
163
|
+
def get_latest(self, target: str | None = None) -> SessionInfo | None:
|
|
164
|
+
"""Get the most recent session.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
target: Optional target filter
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
Most recent SessionInfo if any exist
|
|
171
|
+
"""
|
|
172
|
+
sessions = self.list_sessions(target)
|
|
173
|
+
return sessions[0] if sessions else None
|
|
174
|
+
|
|
175
|
+
def delete(self, session_id: str) -> bool:
|
|
176
|
+
"""Delete a session by ID.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
session_id: Session ID to delete
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
True if deleted, False if not found
|
|
183
|
+
"""
|
|
184
|
+
path = self._sessions_dir / f"{session_id}.json"
|
|
185
|
+
if path.exists():
|
|
186
|
+
path.unlink()
|
|
187
|
+
if self._current and self._current.session_id == session_id:
|
|
188
|
+
self._current = None
|
|
189
|
+
return True
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
# Convenience methods for updating current session
|
|
193
|
+
|
|
194
|
+
def update_status(self, status: SessionStatus) -> None:
|
|
195
|
+
"""Update current session status."""
|
|
196
|
+
if self._current:
|
|
197
|
+
self._current.status = status
|
|
198
|
+
self.save()
|
|
199
|
+
|
|
200
|
+
def add_instruction(self, instruction: str) -> None:
|
|
201
|
+
"""Add a user instruction to current session."""
|
|
202
|
+
if self._current:
|
|
203
|
+
self._current.user_instructions.append(instruction)
|
|
204
|
+
self.save()
|
|
205
|
+
|
|
206
|
+
def add_flag(self, flag: str, context: str) -> None:
|
|
207
|
+
"""Add a found flag to current session."""
|
|
208
|
+
if self._current:
|
|
209
|
+
self._current.flags_found.append({"flag": flag, "context": context})
|
|
210
|
+
self.save()
|
|
211
|
+
|
|
212
|
+
def set_backend_session_id(self, backend_id: str) -> None:
|
|
213
|
+
"""Set the backend-specific session ID."""
|
|
214
|
+
if self._current:
|
|
215
|
+
self._current.backend_session_id = backend_id
|
|
216
|
+
self.save()
|
|
217
|
+
|
|
218
|
+
def add_cost(self, cost: float) -> None:
|
|
219
|
+
"""Add to total cost."""
|
|
220
|
+
if self._current:
|
|
221
|
+
self._current.total_cost_usd += cost
|
|
222
|
+
self.save()
|
|
223
|
+
|
|
224
|
+
def set_error(self, error: str) -> None:
|
|
225
|
+
"""Set last error."""
|
|
226
|
+
if self._current:
|
|
227
|
+
self._current.last_error = error
|
|
228
|
+
self.save()
|
agent/core/tracer.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Activity tracer for tracking agent actions and tool executions."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Tracer:
|
|
10
|
+
"""Lightweight tracer for tracking agent activity."""
|
|
11
|
+
|
|
12
|
+
def __init__(self) -> None:
|
|
13
|
+
"""Initialize the tracer."""
|
|
14
|
+
self._lock = threading.Lock()
|
|
15
|
+
self._activities: list[dict[str, Any]] = []
|
|
16
|
+
self._on_activity_callback: Callable[[dict[str, Any]], None] | None = None
|
|
17
|
+
|
|
18
|
+
def set_activity_callback(self, callback: Callable[[dict[str, Any]], None]) -> None:
|
|
19
|
+
"""Set callback function to be called when new activity is tracked."""
|
|
20
|
+
self._on_activity_callback = callback
|
|
21
|
+
|
|
22
|
+
def track_message(
|
|
23
|
+
self,
|
|
24
|
+
message: str,
|
|
25
|
+
message_type: str = "info",
|
|
26
|
+
timestamp: datetime | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Track a simple message."""
|
|
29
|
+
if timestamp is None:
|
|
30
|
+
timestamp = datetime.now()
|
|
31
|
+
|
|
32
|
+
activity = {
|
|
33
|
+
"type": "message",
|
|
34
|
+
"message": message,
|
|
35
|
+
"message_type": message_type,
|
|
36
|
+
"timestamp": timestamp,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
with self._lock:
|
|
40
|
+
self._activities.append(activity)
|
|
41
|
+
|
|
42
|
+
if self._on_activity_callback:
|
|
43
|
+
self._on_activity_callback(activity)
|
|
44
|
+
|
|
45
|
+
def track_tool_start(
|
|
46
|
+
self,
|
|
47
|
+
tool_name: str,
|
|
48
|
+
args: dict[str, Any],
|
|
49
|
+
timestamp: datetime | None = None,
|
|
50
|
+
) -> int:
|
|
51
|
+
"""Track the start of a tool execution. Returns activity ID."""
|
|
52
|
+
if timestamp is None:
|
|
53
|
+
timestamp = datetime.now()
|
|
54
|
+
|
|
55
|
+
activity = {
|
|
56
|
+
"type": "tool",
|
|
57
|
+
"tool_name": tool_name,
|
|
58
|
+
"args": args,
|
|
59
|
+
"status": "running",
|
|
60
|
+
"result": None,
|
|
61
|
+
"timestamp": timestamp,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
with self._lock:
|
|
65
|
+
activity_id = len(self._activities)
|
|
66
|
+
self._activities.append(activity)
|
|
67
|
+
|
|
68
|
+
if self._on_activity_callback:
|
|
69
|
+
self._on_activity_callback(activity)
|
|
70
|
+
|
|
71
|
+
return activity_id
|
|
72
|
+
|
|
73
|
+
def track_tool_complete(
|
|
74
|
+
self,
|
|
75
|
+
activity_id: int,
|
|
76
|
+
result: Any = None,
|
|
77
|
+
status: str = "completed",
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Mark a tool execution as complete."""
|
|
80
|
+
with self._lock:
|
|
81
|
+
if 0 <= activity_id < len(self._activities):
|
|
82
|
+
activity = self._activities[activity_id]
|
|
83
|
+
activity["status"] = status
|
|
84
|
+
activity["result"] = result
|
|
85
|
+
|
|
86
|
+
if self._on_activity_callback:
|
|
87
|
+
self._on_activity_callback(activity)
|
|
88
|
+
|
|
89
|
+
def track_agent_status(
|
|
90
|
+
self,
|
|
91
|
+
status: str,
|
|
92
|
+
details: str | None = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Track agent status change."""
|
|
95
|
+
message = f"Agent status: {status}"
|
|
96
|
+
if details:
|
|
97
|
+
message = f"{message} - {details}"
|
|
98
|
+
|
|
99
|
+
self.track_message(message, message_type="info")
|
|
100
|
+
|
|
101
|
+
def get_recent_activities(self, count: int = 50) -> list[dict[str, Any]]:
|
|
102
|
+
"""Get the most recent activities."""
|
|
103
|
+
with self._lock:
|
|
104
|
+
return self._activities[-count:] if self._activities else []
|
|
105
|
+
|
|
106
|
+
def get_all_activities(self) -> list[dict[str, Any]]:
|
|
107
|
+
"""Get all tracked activities."""
|
|
108
|
+
with self._lock:
|
|
109
|
+
return self._activities.copy()
|
|
110
|
+
|
|
111
|
+
def clear(self) -> None:
|
|
112
|
+
"""Clear all tracked activities."""
|
|
113
|
+
with self._lock:
|
|
114
|
+
self._activities.clear()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# Global tracer instance
|
|
118
|
+
_global_tracer: Tracer | None = None
|
|
119
|
+
_tracer_lock = threading.Lock()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_global_tracer() -> Tracer:
|
|
123
|
+
"""Get or create the global tracer instance."""
|
|
124
|
+
global _global_tracer
|
|
125
|
+
|
|
126
|
+
if _global_tracer is None:
|
|
127
|
+
with _tracer_lock:
|
|
128
|
+
if _global_tracer is None:
|
|
129
|
+
_global_tracer = Tracer()
|
|
130
|
+
|
|
131
|
+
return _global_tracer
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def set_global_tracer(tracer: Tracer) -> None:
|
|
135
|
+
"""Set a custom global tracer."""
|
|
136
|
+
global _global_tracer
|
|
137
|
+
_global_tracer = tracer
|
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Activity feed component for displaying real-time agent updates."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from rich.markup import escape as rich_escape
|
|
8
|
+
from textual.containers import VerticalScroll
|
|
9
|
+
from textual.widgets import Static
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def escape_markup(text: str) -> str:
|
|
13
|
+
"""Escape Rich markup characters."""
|
|
14
|
+
return str(rich_escape(text))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ActivityFeed(VerticalScroll):
|
|
18
|
+
"""Scrollable activity feed showing agent actions in real-time."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
21
|
+
"""Initialize activity feed."""
|
|
22
|
+
super().__init__(*args, **kwargs)
|
|
23
|
+
self._content_widget: Static | None = None
|
|
24
|
+
self._activities: list[dict[str, Any]] = []
|
|
25
|
+
|
|
26
|
+
def compose(self) -> Iterator[Static]:
|
|
27
|
+
"""Create the feed content area."""
|
|
28
|
+
content = Static("", id="activity_content")
|
|
29
|
+
self._content_widget = content
|
|
30
|
+
yield content
|
|
31
|
+
|
|
32
|
+
def add_message(
|
|
33
|
+
self, message: str, message_type: str = "info", timestamp: datetime | None = None
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Add a simple text message to the feed."""
|
|
36
|
+
if timestamp is None:
|
|
37
|
+
timestamp = datetime.now()
|
|
38
|
+
|
|
39
|
+
activity = {
|
|
40
|
+
"type": "message",
|
|
41
|
+
"message": message,
|
|
42
|
+
"message_type": message_type,
|
|
43
|
+
"timestamp": timestamp,
|
|
44
|
+
}
|
|
45
|
+
self._activities.append(activity)
|
|
46
|
+
self._render_activities()
|
|
47
|
+
|
|
48
|
+
def add_tool_execution(
|
|
49
|
+
self,
|
|
50
|
+
tool_name: str,
|
|
51
|
+
args: dict[str, Any],
|
|
52
|
+
status: str = "running",
|
|
53
|
+
result: Any = None,
|
|
54
|
+
timestamp: datetime | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Add a tool execution block to the feed."""
|
|
57
|
+
if timestamp is None:
|
|
58
|
+
timestamp = datetime.now()
|
|
59
|
+
|
|
60
|
+
activity = {
|
|
61
|
+
"type": "tool",
|
|
62
|
+
"tool_name": tool_name,
|
|
63
|
+
"args": args,
|
|
64
|
+
"status": status,
|
|
65
|
+
"result": result,
|
|
66
|
+
"timestamp": timestamp,
|
|
67
|
+
}
|
|
68
|
+
self._activities.append(activity)
|
|
69
|
+
self._render_activities()
|
|
70
|
+
|
|
71
|
+
def update_last_tool_status(self, status: str, result: Any = None) -> None:
|
|
72
|
+
"""Update the status of the most recent tool execution."""
|
|
73
|
+
# Find the last tool activity
|
|
74
|
+
for activity in reversed(self._activities):
|
|
75
|
+
if activity["type"] == "tool":
|
|
76
|
+
activity["status"] = status
|
|
77
|
+
if result is not None:
|
|
78
|
+
activity["result"] = result
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
self._render_activities()
|
|
82
|
+
|
|
83
|
+
def clear(self) -> None:
|
|
84
|
+
"""Clear all activities from the feed."""
|
|
85
|
+
self._activities.clear()
|
|
86
|
+
if self._content_widget:
|
|
87
|
+
self._content_widget.update("")
|
|
88
|
+
|
|
89
|
+
def _render_activities(self) -> None:
|
|
90
|
+
"""Render all activities to the content widget."""
|
|
91
|
+
if not self._content_widget:
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
if not self._activities:
|
|
95
|
+
placeholder = (
|
|
96
|
+
"\n\n[dim italic]Waiting for agent to start...[/]\n\n"
|
|
97
|
+
"[dim]The activity feed will show real-time updates here.[/]"
|
|
98
|
+
)
|
|
99
|
+
self._content_widget.update(placeholder)
|
|
100
|
+
self._content_widget.set_classes("placeholder")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
lines = []
|
|
104
|
+
for activity in self._activities:
|
|
105
|
+
if activity["type"] == "message":
|
|
106
|
+
lines.append(self._render_message(activity))
|
|
107
|
+
elif activity["type"] == "tool":
|
|
108
|
+
lines.append(self._render_tool(activity))
|
|
109
|
+
|
|
110
|
+
content = "\n\n".join(lines)
|
|
111
|
+
self._content_widget.update(content)
|
|
112
|
+
self._content_widget.remove_class("placeholder")
|
|
113
|
+
|
|
114
|
+
# Auto-scroll to bottom
|
|
115
|
+
self.call_later(self.scroll_end, animate=False)
|
|
116
|
+
|
|
117
|
+
def _render_message(self, activity: dict[str, Any]) -> str:
|
|
118
|
+
"""Render a simple message activity."""
|
|
119
|
+
timestamp = activity["timestamp"].strftime("%H:%M:%S")
|
|
120
|
+
message = escape_markup(activity["message"])
|
|
121
|
+
message_type = activity["message_type"]
|
|
122
|
+
|
|
123
|
+
# Type-specific styling
|
|
124
|
+
if message_type == "success":
|
|
125
|
+
icon = "[#10b981]✓[/]"
|
|
126
|
+
style = "activity-status-success"
|
|
127
|
+
elif message_type == "error":
|
|
128
|
+
icon = "[#ef4444]✗[/]"
|
|
129
|
+
style = "activity-status-error"
|
|
130
|
+
elif message_type == "warning":
|
|
131
|
+
icon = "[#f59e0b]⚠[/]"
|
|
132
|
+
style = ""
|
|
133
|
+
else:
|
|
134
|
+
icon = "[#6366f1]●[/]"
|
|
135
|
+
style = "activity-status-active"
|
|
136
|
+
|
|
137
|
+
return f"[dim]{timestamp}[/] {icon} [{style}]{message}[/]" if style else f"{message}"
|
|
138
|
+
|
|
139
|
+
def _render_tool(self, activity: dict[str, Any]) -> str:
|
|
140
|
+
"""Render a tool execution block."""
|
|
141
|
+
timestamp = activity["timestamp"].strftime("%H:%M:%S")
|
|
142
|
+
tool_name = activity["tool_name"]
|
|
143
|
+
args = activity["args"]
|
|
144
|
+
status = activity["status"]
|
|
145
|
+
result = activity["result"]
|
|
146
|
+
|
|
147
|
+
# Determine tool type for styling
|
|
148
|
+
tool_class = self._get_tool_class(tool_name)
|
|
149
|
+
|
|
150
|
+
# Status indicator
|
|
151
|
+
if status == "running":
|
|
152
|
+
status_icon = "[#f59e0b]●[/] In progress..."
|
|
153
|
+
elif status == "completed":
|
|
154
|
+
status_icon = "[#10b981]✓[/] Done"
|
|
155
|
+
elif status == "failed":
|
|
156
|
+
status_icon = "[#ef4444]✗[/] Failed"
|
|
157
|
+
else:
|
|
158
|
+
status_icon = "[dim]○[/] Unknown"
|
|
159
|
+
|
|
160
|
+
# Build the tool block
|
|
161
|
+
lines = []
|
|
162
|
+
lines.append(f"[dim]{timestamp}[/]")
|
|
163
|
+
|
|
164
|
+
# Tool header
|
|
165
|
+
header_class = "tool-header" if tool_class else ""
|
|
166
|
+
lines.append(f"[{header_class}]▍ {escape_markup(tool_name)}[/] {status_icon}")
|
|
167
|
+
|
|
168
|
+
# Arguments
|
|
169
|
+
if args:
|
|
170
|
+
for key, value in list(args.items())[:3]: # Show first 3 args
|
|
171
|
+
value_str = str(value)
|
|
172
|
+
if len(value_str) > 100:
|
|
173
|
+
value_str = value_str[:97] + "..."
|
|
174
|
+
|
|
175
|
+
# Special formatting for command
|
|
176
|
+
if key == "command" and tool_name == "terminal_execute":
|
|
177
|
+
lines.append(f" [#10b981]$ {escape_markup(value_str)}[/]")
|
|
178
|
+
else:
|
|
179
|
+
lines.append(f" [dim]{key}:[/] {escape_markup(value_str)}")
|
|
180
|
+
|
|
181
|
+
# Result (if completed)
|
|
182
|
+
if status in ("completed", "failed") and result:
|
|
183
|
+
result_str = str(result)
|
|
184
|
+
if len(result_str) > 200:
|
|
185
|
+
result_str = result_str[:197] + "..."
|
|
186
|
+
|
|
187
|
+
lines.append(f" [dim]→[/] {escape_markup(result_str)}")
|
|
188
|
+
|
|
189
|
+
content = "\n".join(lines)
|
|
190
|
+
|
|
191
|
+
# Wrap in styled container
|
|
192
|
+
return f"[{tool_class}]{content}[/]"
|
|
193
|
+
|
|
194
|
+
def _get_tool_class(self, tool_name: str) -> str:
|
|
195
|
+
"""Get the CSS class for a tool type."""
|
|
196
|
+
tool_classes = {
|
|
197
|
+
"terminal_execute": "tool-terminal",
|
|
198
|
+
"thinking": "tool-thinking",
|
|
199
|
+
"result": "tool-result",
|
|
200
|
+
"error": "tool-error",
|
|
201
|
+
}
|
|
202
|
+
return tool_classes.get(tool_name, "tool-block")
|