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.
Files changed (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. 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
+ )