crabcode 0.1.6__py3-none-win_amd64.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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
"""Background command and WebSocket monitors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import ipaddress
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import socket
|
|
10
|
+
import uuid
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import urlsplit
|
|
16
|
+
from xml.sax.saxutils import escape
|
|
17
|
+
|
|
18
|
+
from crabcode_core.session.storage import get_task_output_path
|
|
19
|
+
from crabcode_core.subprocess_utils import (
|
|
20
|
+
decode_subprocess_output,
|
|
21
|
+
managed_process_command,
|
|
22
|
+
shell_command,
|
|
23
|
+
subprocess_group_options,
|
|
24
|
+
terminate_process_tree,
|
|
25
|
+
)
|
|
26
|
+
from crabcode_core.types.tool import Tool, ToolContext, ToolResult
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _now_iso() -> str:
|
|
30
|
+
return datetime.now(timezone.utc).isoformat()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class MonitorSnapshot:
|
|
35
|
+
task_id: str
|
|
36
|
+
session_id: str
|
|
37
|
+
description: str
|
|
38
|
+
source: str
|
|
39
|
+
status: str
|
|
40
|
+
output_file: str
|
|
41
|
+
timeout_ms: int
|
|
42
|
+
persistent: bool
|
|
43
|
+
tool_use_id: str | None = None
|
|
44
|
+
created_at: str = field(default_factory=_now_iso)
|
|
45
|
+
started_at: str | None = None
|
|
46
|
+
finished_at: str | None = None
|
|
47
|
+
updated_at: str = field(default_factory=_now_iso)
|
|
48
|
+
sequence: int = 0
|
|
49
|
+
error: str = ""
|
|
50
|
+
exit_code: int | None = None
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict[str, Any]:
|
|
53
|
+
return {
|
|
54
|
+
"task_id": self.task_id,
|
|
55
|
+
"session_id": self.session_id,
|
|
56
|
+
"description": self.description,
|
|
57
|
+
"task_type": "local_bash",
|
|
58
|
+
"source": self.source,
|
|
59
|
+
"status": self.status,
|
|
60
|
+
"output_file": self.output_file,
|
|
61
|
+
"timeout_ms": self.timeout_ms,
|
|
62
|
+
"persistent": self.persistent,
|
|
63
|
+
"tool_use_id": self.tool_use_id,
|
|
64
|
+
"created_at": self.created_at,
|
|
65
|
+
"started_at": self.started_at,
|
|
66
|
+
"finished_at": self.finished_at,
|
|
67
|
+
"updated_at": self.updated_at,
|
|
68
|
+
"sequence": self.sequence,
|
|
69
|
+
"error": self.error,
|
|
70
|
+
"exit_code": self.exit_code,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class _MonitorRun:
|
|
76
|
+
snapshot: MonitorSnapshot
|
|
77
|
+
session: Any
|
|
78
|
+
task: asyncio.Task[None] | None = None
|
|
79
|
+
process: asyncio.subprocess.Process | None = None
|
|
80
|
+
stop_reason: str = ""
|
|
81
|
+
suppress_notification: bool = False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class MonitorManager:
|
|
85
|
+
"""Own session-scoped monitors and their output files."""
|
|
86
|
+
|
|
87
|
+
def __init__(self) -> None:
|
|
88
|
+
self._runs: dict[str, _MonitorRun] = {}
|
|
89
|
+
self._closed = False
|
|
90
|
+
|
|
91
|
+
def list_tasks(self, session_id: str | None = None) -> list[MonitorSnapshot]:
|
|
92
|
+
snapshots = [
|
|
93
|
+
run.snapshot
|
|
94
|
+
for run in self._runs.values()
|
|
95
|
+
if session_id is None or run.snapshot.session_id == session_id
|
|
96
|
+
]
|
|
97
|
+
return sorted(snapshots, key=lambda item: item.created_at, reverse=True)
|
|
98
|
+
|
|
99
|
+
def get_task(self, task_id: str) -> MonitorSnapshot | None:
|
|
100
|
+
run = self._runs.get(task_id)
|
|
101
|
+
return run.snapshot if run is not None else None
|
|
102
|
+
|
|
103
|
+
def resolve_task_id(self, task_id: str) -> str | None:
|
|
104
|
+
if task_id in self._runs:
|
|
105
|
+
return task_id
|
|
106
|
+
matches = [
|
|
107
|
+
candidate for candidate in self._runs if candidate.startswith(task_id)
|
|
108
|
+
]
|
|
109
|
+
return matches[0] if len(matches) == 1 else None
|
|
110
|
+
|
|
111
|
+
async def start(
|
|
112
|
+
self,
|
|
113
|
+
*,
|
|
114
|
+
context: ToolContext,
|
|
115
|
+
description: str,
|
|
116
|
+
command: str | None,
|
|
117
|
+
ws: dict[str, Any] | None,
|
|
118
|
+
timeout_ms: int,
|
|
119
|
+
persistent: bool,
|
|
120
|
+
) -> MonitorSnapshot:
|
|
121
|
+
if self._closed:
|
|
122
|
+
raise RuntimeError("Monitor manager is closed")
|
|
123
|
+
if context.session is None or not context.session_id:
|
|
124
|
+
raise RuntimeError("Monitor requires an active main session")
|
|
125
|
+
|
|
126
|
+
task_id = str(uuid.uuid4())
|
|
127
|
+
output_path = get_task_output_path(context.cwd, context.session_id, task_id)
|
|
128
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
output_path.touch(exist_ok=True)
|
|
130
|
+
snapshot = MonitorSnapshot(
|
|
131
|
+
task_id=task_id,
|
|
132
|
+
session_id=context.session_id,
|
|
133
|
+
description=description,
|
|
134
|
+
source="websocket" if ws is not None else "command",
|
|
135
|
+
status="running",
|
|
136
|
+
output_file=str(output_path),
|
|
137
|
+
timeout_ms=0 if persistent else timeout_ms,
|
|
138
|
+
persistent=persistent,
|
|
139
|
+
tool_use_id=context.tool_use_id,
|
|
140
|
+
started_at=_now_iso(),
|
|
141
|
+
)
|
|
142
|
+
run = _MonitorRun(snapshot=snapshot, session=context.session)
|
|
143
|
+
self._runs[task_id] = run
|
|
144
|
+
run.task = asyncio.create_task(
|
|
145
|
+
self._run_source(
|
|
146
|
+
run,
|
|
147
|
+
command=command,
|
|
148
|
+
ws=ws,
|
|
149
|
+
env={**os.environ, **context.env},
|
|
150
|
+
cwd=context.cwd,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return snapshot
|
|
154
|
+
|
|
155
|
+
async def stop_task(self, task_id: str) -> bool:
|
|
156
|
+
resolved = self.resolve_task_id(task_id)
|
|
157
|
+
run = self._runs.get(resolved or "")
|
|
158
|
+
if run is None or run.task is None or run.task.done():
|
|
159
|
+
return False
|
|
160
|
+
run.stop_reason = "stopped by request"
|
|
161
|
+
run.task.cancel()
|
|
162
|
+
await asyncio.gather(run.task, return_exceptions=True)
|
|
163
|
+
# A task cancelled before its coroutine is first scheduled never enters
|
|
164
|
+
# _run_source(), so its finally block cannot update the snapshot.
|
|
165
|
+
if run.snapshot.status == "running":
|
|
166
|
+
await self._finish(
|
|
167
|
+
run,
|
|
168
|
+
status="stopped",
|
|
169
|
+
error=run.stop_reason,
|
|
170
|
+
exit_code=None,
|
|
171
|
+
)
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
def cancel_session_now(self, session_id: str, reason: str) -> None:
|
|
175
|
+
for run in self._runs.values():
|
|
176
|
+
if (
|
|
177
|
+
run.snapshot.session_id != session_id
|
|
178
|
+
or run.task is None
|
|
179
|
+
or run.task.done()
|
|
180
|
+
):
|
|
181
|
+
continue
|
|
182
|
+
run.stop_reason = reason
|
|
183
|
+
run.suppress_notification = True
|
|
184
|
+
self._mark_finished(
|
|
185
|
+
run,
|
|
186
|
+
status="stopped",
|
|
187
|
+
error=reason,
|
|
188
|
+
exit_code=None,
|
|
189
|
+
)
|
|
190
|
+
run.task.cancel()
|
|
191
|
+
|
|
192
|
+
async def close(self) -> None:
|
|
193
|
+
if self._closed:
|
|
194
|
+
return
|
|
195
|
+
self._closed = True
|
|
196
|
+
tasks: list[asyncio.Task[None]] = []
|
|
197
|
+
for run in self._runs.values():
|
|
198
|
+
if run.task is None or run.task.done():
|
|
199
|
+
continue
|
|
200
|
+
run.stop_reason = "session ended"
|
|
201
|
+
run.suppress_notification = True
|
|
202
|
+
self._mark_finished(
|
|
203
|
+
run,
|
|
204
|
+
status="stopped",
|
|
205
|
+
error=run.stop_reason,
|
|
206
|
+
exit_code=None,
|
|
207
|
+
)
|
|
208
|
+
run.task.cancel()
|
|
209
|
+
tasks.append(run.task)
|
|
210
|
+
if tasks:
|
|
211
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
212
|
+
|
|
213
|
+
async def _run_source(
|
|
214
|
+
self,
|
|
215
|
+
run: _MonitorRun,
|
|
216
|
+
*,
|
|
217
|
+
command: str | None,
|
|
218
|
+
ws: dict[str, Any] | None,
|
|
219
|
+
env: dict[str, str],
|
|
220
|
+
cwd: str,
|
|
221
|
+
) -> None:
|
|
222
|
+
status = "completed"
|
|
223
|
+
error = ""
|
|
224
|
+
exit_code: int | None = None
|
|
225
|
+
try:
|
|
226
|
+
source = (
|
|
227
|
+
self._consume_websocket(run, ws or {})
|
|
228
|
+
if ws is not None
|
|
229
|
+
else self._consume_command(run, command or "", env=env, cwd=cwd)
|
|
230
|
+
)
|
|
231
|
+
if run.snapshot.persistent:
|
|
232
|
+
exit_code = await source
|
|
233
|
+
else:
|
|
234
|
+
exit_code = await asyncio.wait_for(
|
|
235
|
+
source,
|
|
236
|
+
timeout=max(0.001, run.snapshot.timeout_ms / 1000.0),
|
|
237
|
+
)
|
|
238
|
+
if command is not None and exit_code not in {None, 0}:
|
|
239
|
+
status = "failed"
|
|
240
|
+
error = f"command exited with status {exit_code}"
|
|
241
|
+
except asyncio.TimeoutError:
|
|
242
|
+
status = "stopped"
|
|
243
|
+
error = f"monitor reached its {run.snapshot.timeout_ms}ms timeout"
|
|
244
|
+
except asyncio.CancelledError:
|
|
245
|
+
status = "stopped"
|
|
246
|
+
error = run.stop_reason or "stopped"
|
|
247
|
+
except Exception as exc:
|
|
248
|
+
status = "failed"
|
|
249
|
+
error = str(exc)
|
|
250
|
+
finally:
|
|
251
|
+
await self._terminate_process(run)
|
|
252
|
+
await self._finish(run, status=status, error=error, exit_code=exit_code)
|
|
253
|
+
|
|
254
|
+
async def _consume_command(
|
|
255
|
+
self,
|
|
256
|
+
run: _MonitorRun,
|
|
257
|
+
command: str,
|
|
258
|
+
*,
|
|
259
|
+
env: dict[str, str],
|
|
260
|
+
cwd: str,
|
|
261
|
+
) -> int:
|
|
262
|
+
run.process = await asyncio.create_subprocess_exec(
|
|
263
|
+
*managed_process_command(shell_command(command)),
|
|
264
|
+
stdout=asyncio.subprocess.PIPE,
|
|
265
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
266
|
+
cwd=cwd,
|
|
267
|
+
env=env,
|
|
268
|
+
**subprocess_group_options(),
|
|
269
|
+
)
|
|
270
|
+
assert run.process.stdout is not None
|
|
271
|
+
while True:
|
|
272
|
+
raw = await run.process.stdout.readline()
|
|
273
|
+
if not raw:
|
|
274
|
+
break
|
|
275
|
+
line = decode_subprocess_output(raw).rstrip("\r\n")
|
|
276
|
+
await self._emit_line(run, line)
|
|
277
|
+
return await run.process.wait()
|
|
278
|
+
|
|
279
|
+
async def _consume_websocket(
|
|
280
|
+
self,
|
|
281
|
+
run: _MonitorRun,
|
|
282
|
+
ws: dict[str, Any],
|
|
283
|
+
) -> None:
|
|
284
|
+
url = str(ws.get("url") or "")
|
|
285
|
+
protocols = ws.get("protocols")
|
|
286
|
+
await self._validate_websocket_target(url)
|
|
287
|
+
try:
|
|
288
|
+
from websockets.asyncio.client import connect
|
|
289
|
+
except ImportError as exc: # pragma: no cover - dependency packaging guard
|
|
290
|
+
raise RuntimeError(
|
|
291
|
+
"WebSocket monitoring requires the 'websockets' package"
|
|
292
|
+
) from exc
|
|
293
|
+
|
|
294
|
+
async with connect(url, subprotocols=protocols) as websocket:
|
|
295
|
+
async for message in websocket:
|
|
296
|
+
if isinstance(message, str):
|
|
297
|
+
line = message
|
|
298
|
+
else:
|
|
299
|
+
line = f"[binary frame, {len(message)} bytes]"
|
|
300
|
+
if len(line.encode("utf-8", errors="replace")) > 1024 * 1024:
|
|
301
|
+
raise RuntimeError(
|
|
302
|
+
"WebSocket message exceeded the 1 MiB monitor limit"
|
|
303
|
+
)
|
|
304
|
+
await self._emit_line(run, line)
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
@staticmethod
|
|
308
|
+
async def _validate_websocket_target(url: str) -> None:
|
|
309
|
+
if any(char.isspace() for char in url):
|
|
310
|
+
raise ValueError("WebSocket URL must not contain whitespace")
|
|
311
|
+
try:
|
|
312
|
+
url.encode("ascii")
|
|
313
|
+
except UnicodeEncodeError as exc:
|
|
314
|
+
raise ValueError(
|
|
315
|
+
"WebSocket URL must contain ASCII characters only"
|
|
316
|
+
) from exc
|
|
317
|
+
parsed = urlsplit(url)
|
|
318
|
+
if parsed.scheme not in {"ws", "wss"} or not parsed.hostname:
|
|
319
|
+
raise ValueError("WebSocket URL must use ws:// or wss://")
|
|
320
|
+
if parsed.username is not None or parsed.password is not None:
|
|
321
|
+
raise ValueError("WebSocket URL must not contain credentials")
|
|
322
|
+
|
|
323
|
+
port = parsed.port or (443 if parsed.scheme == "wss" else 80)
|
|
324
|
+
loop = asyncio.get_running_loop()
|
|
325
|
+
addresses = await loop.getaddrinfo(
|
|
326
|
+
parsed.hostname,
|
|
327
|
+
port,
|
|
328
|
+
type=socket.SOCK_STREAM,
|
|
329
|
+
)
|
|
330
|
+
for address in addresses:
|
|
331
|
+
ip = ipaddress.ip_address(address[4][0])
|
|
332
|
+
if not ip.is_global:
|
|
333
|
+
raise ValueError(
|
|
334
|
+
"WebSocket monitor refuses private, local, or reserved addresses"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
async def _emit_line(self, run: _MonitorRun, line: str) -> None:
|
|
338
|
+
output_path = Path(run.snapshot.output_file)
|
|
339
|
+
with open(output_path, "a", encoding="utf-8") as output:
|
|
340
|
+
output.write(line + "\n")
|
|
341
|
+
output.flush()
|
|
342
|
+
|
|
343
|
+
run.snapshot.sequence += 1
|
|
344
|
+
run.snapshot.updated_at = _now_iso()
|
|
345
|
+
safe_line = line
|
|
346
|
+
if len(safe_line) > 100_000:
|
|
347
|
+
safe_line = (
|
|
348
|
+
safe_line[:100_000] + "… [event truncated; full line in output file]"
|
|
349
|
+
)
|
|
350
|
+
notification = "\n".join(
|
|
351
|
+
[
|
|
352
|
+
"<monitor-event>",
|
|
353
|
+
f"<task-id>{run.snapshot.task_id}</task-id>",
|
|
354
|
+
f"<description>{escape(run.snapshot.description)}</description>",
|
|
355
|
+
f"<sequence>{run.snapshot.sequence}</sequence>",
|
|
356
|
+
f"<output-file>{escape(run.snapshot.output_file)}</output-file>",
|
|
357
|
+
f"<timestamp>{run.snapshot.updated_at}</timestamp>",
|
|
358
|
+
f"<event>{escape(safe_line)}</event>",
|
|
359
|
+
"</monitor-event>",
|
|
360
|
+
]
|
|
361
|
+
)
|
|
362
|
+
await run.session.enqueue_monitor_notification(
|
|
363
|
+
notification,
|
|
364
|
+
session_id=run.snapshot.session_id,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
async def _finish(
|
|
368
|
+
self,
|
|
369
|
+
run: _MonitorRun,
|
|
370
|
+
*,
|
|
371
|
+
status: str,
|
|
372
|
+
error: str,
|
|
373
|
+
exit_code: int | None,
|
|
374
|
+
) -> None:
|
|
375
|
+
self._mark_finished(
|
|
376
|
+
run,
|
|
377
|
+
status=status,
|
|
378
|
+
error=error,
|
|
379
|
+
exit_code=exit_code,
|
|
380
|
+
)
|
|
381
|
+
if run.suppress_notification:
|
|
382
|
+
return
|
|
383
|
+
summary = error or f"{run.snapshot.description} {status}"
|
|
384
|
+
notification = "\n".join(
|
|
385
|
+
[
|
|
386
|
+
"<task-notification>",
|
|
387
|
+
f"<task-id>{run.snapshot.task_id}</task-id>",
|
|
388
|
+
f"<session-id>{run.snapshot.session_id}</session-id>",
|
|
389
|
+
f"<uuid>{uuid.uuid4()}</uuid>",
|
|
390
|
+
*(
|
|
391
|
+
[f"<tool-use-id>{escape(run.snapshot.tool_use_id)}</tool-use-id>"]
|
|
392
|
+
if run.snapshot.tool_use_id
|
|
393
|
+
else []
|
|
394
|
+
),
|
|
395
|
+
"<task-type>local_bash</task-type>",
|
|
396
|
+
f"<status>{status}</status>",
|
|
397
|
+
f"<output-file>{escape(run.snapshot.output_file)}</output-file>",
|
|
398
|
+
f"<summary>{escape(summary)}</summary>",
|
|
399
|
+
"</task-notification>",
|
|
400
|
+
]
|
|
401
|
+
)
|
|
402
|
+
await run.session.enqueue_monitor_notification(
|
|
403
|
+
notification,
|
|
404
|
+
session_id=run.snapshot.session_id,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
@staticmethod
|
|
408
|
+
def _mark_finished(
|
|
409
|
+
run: _MonitorRun,
|
|
410
|
+
*,
|
|
411
|
+
status: str,
|
|
412
|
+
error: str,
|
|
413
|
+
exit_code: int | None,
|
|
414
|
+
) -> None:
|
|
415
|
+
run.snapshot.status = status
|
|
416
|
+
run.snapshot.error = error
|
|
417
|
+
run.snapshot.exit_code = exit_code
|
|
418
|
+
run.snapshot.updated_at = _now_iso()
|
|
419
|
+
run.snapshot.finished_at = run.snapshot.updated_at
|
|
420
|
+
|
|
421
|
+
@staticmethod
|
|
422
|
+
async def _terminate_process(run: _MonitorRun) -> None:
|
|
423
|
+
process = run.process
|
|
424
|
+
if process is None or process.returncode is not None:
|
|
425
|
+
return
|
|
426
|
+
await terminate_process_tree(process, timeout=2)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
class MonitorTool(Tool):
|
|
430
|
+
name = "Monitor"
|
|
431
|
+
description = (
|
|
432
|
+
"Watch a command or WebSocket in the background. Every output line or text "
|
|
433
|
+
"message is delivered as an event that automatically resumes the conversation."
|
|
434
|
+
)
|
|
435
|
+
is_read_only = False
|
|
436
|
+
is_concurrency_safe = True
|
|
437
|
+
input_schema = {
|
|
438
|
+
"type": "object",
|
|
439
|
+
"properties": {
|
|
440
|
+
"command": {
|
|
441
|
+
"type": "string",
|
|
442
|
+
"description": "Shell command; every stdout/stderr line becomes an event.",
|
|
443
|
+
},
|
|
444
|
+
"ws": {
|
|
445
|
+
"type": "object",
|
|
446
|
+
"properties": {
|
|
447
|
+
"url": {"type": "string"},
|
|
448
|
+
"protocols": {
|
|
449
|
+
"type": "array",
|
|
450
|
+
"items": {"type": "string"},
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
"required": ["url"],
|
|
454
|
+
"additionalProperties": False,
|
|
455
|
+
},
|
|
456
|
+
"description": {
|
|
457
|
+
"type": "string",
|
|
458
|
+
"description": "Short description shown in event notifications.",
|
|
459
|
+
},
|
|
460
|
+
"timeout_ms": {
|
|
461
|
+
"type": "integer",
|
|
462
|
+
"minimum": 1,
|
|
463
|
+
"maximum": 3_600_000,
|
|
464
|
+
"default": 300_000,
|
|
465
|
+
},
|
|
466
|
+
"persistent": {
|
|
467
|
+
"type": "boolean",
|
|
468
|
+
"description": "Run until TaskStop or session end.",
|
|
469
|
+
"default": False,
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
"required": ["description"],
|
|
473
|
+
"additionalProperties": False,
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
def __init__(self, manager: MonitorManager | None = None) -> None:
|
|
477
|
+
self.manager = manager or MonitorManager()
|
|
478
|
+
|
|
479
|
+
async def validate_input(self, tool_input: dict[str, Any]) -> str | None:
|
|
480
|
+
command = tool_input.get("command")
|
|
481
|
+
ws = tool_input.get("ws")
|
|
482
|
+
if bool(command) == bool(ws):
|
|
483
|
+
return "Provide exactly one of command or ws."
|
|
484
|
+
if command is not None and not str(command).strip():
|
|
485
|
+
return "command must not be empty."
|
|
486
|
+
if not str(tool_input.get("description") or "").strip():
|
|
487
|
+
return "description is required."
|
|
488
|
+
timeout_ms = tool_input.get("timeout_ms", 300_000)
|
|
489
|
+
if not isinstance(timeout_ms, int) or isinstance(timeout_ms, bool):
|
|
490
|
+
return "timeout_ms must be an integer."
|
|
491
|
+
if timeout_ms < 1 or timeout_ms > 3_600_000:
|
|
492
|
+
return "timeout_ms must be between 1 and 3600000."
|
|
493
|
+
if ws is not None and not isinstance(ws, dict):
|
|
494
|
+
return "ws must be an object."
|
|
495
|
+
if isinstance(ws, dict):
|
|
496
|
+
url = ws.get("url")
|
|
497
|
+
if not isinstance(url, str) or not url:
|
|
498
|
+
return "ws.url is required."
|
|
499
|
+
if any(char.isspace() for char in url):
|
|
500
|
+
return "ws.url must not contain whitespace."
|
|
501
|
+
try:
|
|
502
|
+
url.encode("ascii")
|
|
503
|
+
except UnicodeEncodeError:
|
|
504
|
+
return "ws.url must contain ASCII characters only."
|
|
505
|
+
parsed = urlsplit(url)
|
|
506
|
+
if parsed.scheme not in {"ws", "wss"} or not parsed.hostname:
|
|
507
|
+
return "ws.url must use ws:// or wss://."
|
|
508
|
+
if parsed.username is not None or parsed.password is not None:
|
|
509
|
+
return "ws.url must not contain credentials."
|
|
510
|
+
protocols = ws.get("protocols")
|
|
511
|
+
if protocols is not None:
|
|
512
|
+
if not isinstance(protocols, list) or not all(
|
|
513
|
+
isinstance(protocol, str) for protocol in protocols
|
|
514
|
+
):
|
|
515
|
+
return "ws.protocols must be an array of strings."
|
|
516
|
+
if len(set(protocols)) != len(protocols):
|
|
517
|
+
return "ws.protocols must not contain duplicates."
|
|
518
|
+
token = re.compile(r"^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
|
|
519
|
+
if any(not token.fullmatch(protocol) for protocol in protocols):
|
|
520
|
+
return "ws.protocols contains an invalid subprotocol token."
|
|
521
|
+
return None
|
|
522
|
+
|
|
523
|
+
async def call(
|
|
524
|
+
self,
|
|
525
|
+
tool_input: dict[str, Any],
|
|
526
|
+
context: ToolContext,
|
|
527
|
+
) -> ToolResult:
|
|
528
|
+
validation_error = await self.validate_input(tool_input)
|
|
529
|
+
if validation_error:
|
|
530
|
+
return ToolResult(
|
|
531
|
+
result_for_model=f"Error: {validation_error}", is_error=True
|
|
532
|
+
)
|
|
533
|
+
# A monitor is a long-lived watcher, not a file-mutating Bash call. Do
|
|
534
|
+
# not take the synchronous working-tree snapshot used by BashTool here:
|
|
535
|
+
# for a non-git cwd (for example a user's home directory) it recursively
|
|
536
|
+
# copies the tree before the monitor is even registered, blocking the
|
|
537
|
+
# event loop and hiding the task ID from the frontend.
|
|
538
|
+
try:
|
|
539
|
+
snapshot = await self.manager.start(
|
|
540
|
+
context=context,
|
|
541
|
+
description=str(tool_input["description"]).strip(),
|
|
542
|
+
command=(
|
|
543
|
+
str(tool_input["command"])
|
|
544
|
+
if tool_input.get("command") is not None
|
|
545
|
+
else None
|
|
546
|
+
),
|
|
547
|
+
ws=tool_input.get("ws"),
|
|
548
|
+
timeout_ms=int(tool_input.get("timeout_ms", 300_000)),
|
|
549
|
+
persistent=bool(tool_input.get("persistent", False)),
|
|
550
|
+
)
|
|
551
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
552
|
+
return ToolResult(result_for_model=f"Error: {exc}", is_error=True)
|
|
553
|
+
return ToolResult(
|
|
554
|
+
data={
|
|
555
|
+
"taskId": snapshot.task_id,
|
|
556
|
+
"timeoutMs": snapshot.timeout_ms,
|
|
557
|
+
"persistent": snapshot.persistent,
|
|
558
|
+
"outputFile": snapshot.output_file,
|
|
559
|
+
},
|
|
560
|
+
result_for_model=(
|
|
561
|
+
f"taskId: {snapshot.task_id}\n"
|
|
562
|
+
f"timeoutMs: {snapshot.timeout_ms}\n"
|
|
563
|
+
f"persistent: {str(snapshot.persistent).lower()}\n"
|
|
564
|
+
f"outputFile: {snapshot.output_file}"
|
|
565
|
+
),
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
async def close(self) -> None:
|
|
569
|
+
await self.manager.close()
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
class TaskListTool(Tool):
|
|
573
|
+
name = "TaskList"
|
|
574
|
+
description = "List background monitors and managed sub-agents in this session."
|
|
575
|
+
is_read_only = True
|
|
576
|
+
is_concurrency_safe = True
|
|
577
|
+
input_schema = {"type": "object", "properties": {}}
|
|
578
|
+
|
|
579
|
+
def __init__(self, manager: MonitorManager) -> None:
|
|
580
|
+
self.manager = manager
|
|
581
|
+
|
|
582
|
+
async def call(
|
|
583
|
+
self,
|
|
584
|
+
tool_input: dict[str, Any],
|
|
585
|
+
context: ToolContext,
|
|
586
|
+
) -> ToolResult:
|
|
587
|
+
tasks: list[dict[str, Any]] = [
|
|
588
|
+
snapshot.to_dict()
|
|
589
|
+
for snapshot in self.manager.list_tasks(context.session_id or None)
|
|
590
|
+
]
|
|
591
|
+
if context.agent_manager is not None:
|
|
592
|
+
tasks.extend(
|
|
593
|
+
{
|
|
594
|
+
"task_id": snapshot.agent_id,
|
|
595
|
+
"session_id": snapshot.session_id,
|
|
596
|
+
"description": snapshot.title,
|
|
597
|
+
"task_type": "local_agent",
|
|
598
|
+
"status": snapshot.status,
|
|
599
|
+
"output_file": snapshot.transcript_path,
|
|
600
|
+
"created_at": snapshot.created_at,
|
|
601
|
+
"finished_at": snapshot.finished_at,
|
|
602
|
+
"error": snapshot.error,
|
|
603
|
+
}
|
|
604
|
+
for snapshot in context.agent_manager.list_agents()
|
|
605
|
+
if not context.session_id or snapshot.session_id == context.session_id
|
|
606
|
+
)
|
|
607
|
+
if not tasks:
|
|
608
|
+
return ToolResult(
|
|
609
|
+
data={"tasks": []}, result_for_model="No background tasks."
|
|
610
|
+
)
|
|
611
|
+
lines = [
|
|
612
|
+
f"{item['task_id']} · {item['status']} · {item['task_type']} · "
|
|
613
|
+
f"{item['description']}"
|
|
614
|
+
for item in tasks
|
|
615
|
+
]
|
|
616
|
+
return ToolResult(data={"tasks": tasks}, result_for_model="\n".join(lines))
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
class TaskStopTool(Tool):
|
|
620
|
+
name = "TaskStop"
|
|
621
|
+
description = "Stop a running background monitor or managed sub-agent by ID."
|
|
622
|
+
is_read_only = False
|
|
623
|
+
is_concurrency_safe = True
|
|
624
|
+
uses_tool_permission_policy = True
|
|
625
|
+
input_schema = {
|
|
626
|
+
"type": "object",
|
|
627
|
+
"properties": {
|
|
628
|
+
"task_id": {"type": "string", "description": "Background task ID."}
|
|
629
|
+
},
|
|
630
|
+
"required": ["task_id"],
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
def __init__(self, manager: MonitorManager) -> None:
|
|
634
|
+
self.manager = manager
|
|
635
|
+
|
|
636
|
+
async def call(
|
|
637
|
+
self,
|
|
638
|
+
tool_input: dict[str, Any],
|
|
639
|
+
context: ToolContext,
|
|
640
|
+
) -> ToolResult:
|
|
641
|
+
task_id = str(tool_input.get("task_id") or "").strip()
|
|
642
|
+
if not task_id:
|
|
643
|
+
return ToolResult(
|
|
644
|
+
result_for_model="Error: task_id is required", is_error=True
|
|
645
|
+
)
|
|
646
|
+
resolved_monitor_id = self.manager.resolve_task_id(task_id)
|
|
647
|
+
if resolved_monitor_id and await self.manager.stop_task(resolved_monitor_id):
|
|
648
|
+
snapshot = self.manager.get_task(resolved_monitor_id)
|
|
649
|
+
return ToolResult(
|
|
650
|
+
data={"task_id": resolved_monitor_id, "stopped": True},
|
|
651
|
+
result_for_model=(
|
|
652
|
+
f"Stopped task: {resolved_monitor_id}"
|
|
653
|
+
+ (f"\noutputFile: {snapshot.output_file}" if snapshot else "")
|
|
654
|
+
),
|
|
655
|
+
)
|
|
656
|
+
agent_id = task_id
|
|
657
|
+
if context.agent_manager is not None:
|
|
658
|
+
matches = [
|
|
659
|
+
snapshot.agent_id
|
|
660
|
+
for snapshot in context.agent_manager.list_agents()
|
|
661
|
+
if snapshot.agent_id.startswith(task_id)
|
|
662
|
+
]
|
|
663
|
+
if len(matches) == 1:
|
|
664
|
+
agent_id = matches[0]
|
|
665
|
+
if (
|
|
666
|
+
context.agent_manager is not None
|
|
667
|
+
and await context.agent_manager.cancel_agent(agent_id)
|
|
668
|
+
):
|
|
669
|
+
return ToolResult(
|
|
670
|
+
data={"task_id": agent_id, "stopped": True},
|
|
671
|
+
result_for_model=f"Stopped agent task: {agent_id}",
|
|
672
|
+
)
|
|
673
|
+
return ToolResult(
|
|
674
|
+
data={"task_id": task_id, "stopped": False},
|
|
675
|
+
result_for_model=f"Error: no running background task {task_id}",
|
|
676
|
+
is_error=True,
|
|
677
|
+
)
|