ata-coder 2.4.2__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.
- ata_coder/__init__.py +1 -0
- ata_coder/agent.py +874 -0
- ata_coder/agent_compact.py +190 -0
- ata_coder/agent_controller.py +218 -0
- ata_coder/agent_extension.py +69 -0
- ata_coder/agent_routing.py +105 -0
- ata_coder/agent_subsystems.py +72 -0
- ata_coder/agent_tools.py +318 -0
- ata_coder/agent_undo.py +63 -0
- ata_coder/anthropic_client.py +465 -0
- ata_coder/change_tracker.py +368 -0
- ata_coder/clawd_integration.py +574 -0
- ata_coder/commands/__init__.py +128 -0
- ata_coder/commands/_core.py +184 -0
- ata_coder/commands/_safety.py +95 -0
- ata_coder/commands/_settings.py +241 -0
- ata_coder/commands/_workflow.py +451 -0
- ata_coder/commands.py +974 -0
- ata_coder/config.py +257 -0
- ata_coder/core/__init__.py +35 -0
- ata_coder/core/events.py +73 -0
- ata_coder/core/queue.py +85 -0
- ata_coder/core/state.py +17 -0
- ata_coder/event_queue.py +5 -0
- ata_coder/extension.py +654 -0
- ata_coder/extensions/__init__.py +1 -0
- ata_coder/extensions/hello_skill.py +47 -0
- ata_coder/fool_proof.py +295 -0
- ata_coder/git_workflow.py +371 -0
- ata_coder/gui.py +511 -0
- ata_coder/llm_client.py +543 -0
- ata_coder/main.py +814 -0
- ata_coder/mcp_client.py +1095 -0
- ata_coder/memory.py +539 -0
- ata_coder/model_registry.py +134 -0
- ata_coder/model_router.py +105 -0
- ata_coder/permissions.py +274 -0
- ata_coder/privilege.py +464 -0
- ata_coder/project.py +273 -0
- ata_coder/prompt_template.py +423 -0
- ata_coder/prompts/auto-mode.md +7 -0
- ata_coder/prompts/coding-rules.md +40 -0
- ata_coder/prompts/execution-guardrails.md +14 -0
- ata_coder/prompts/memory-system.md +24 -0
- ata_coder/prompts/output-style.md +23 -0
- ata_coder/prompts/safety.md +17 -0
- ata_coder/prompts/slash-commands.md +24 -0
- ata_coder/prompts/sub-agents.md +38 -0
- ata_coder/prompts/system-reminders.md +17 -0
- ata_coder/prompts/system.md +105 -0
- ata_coder/prompts/tool-policy.md +46 -0
- ata_coder/repl_theme.py +99 -0
- ata_coder/repl_tracker.py +89 -0
- ata_coder/repl_ui.py +1214 -0
- ata_coder/safety_guard.py +434 -0
- ata_coder/self_correct.py +346 -0
- ata_coder/server.py +882 -0
- ata_coder/server_session.py +159 -0
- ata_coder/server_shell.py +129 -0
- ata_coder/session.py +431 -0
- ata_coder/settings.py +439 -0
- ata_coder/setup_wizard.py +136 -0
- ata_coder/skill_extension.py +92 -0
- ata_coder/skills/architect/SKILL.md +42 -0
- ata_coder/skills/code-reviewer/SKILL.md +37 -0
- ata_coder/skills/codecraft/SKILL.md +452 -0
- ata_coder/skills/debugger/SKILL.md +45 -0
- ata_coder/skills/doc-writer/SKILL.md +36 -0
- ata_coder/skills/general-coder/SKILL.md +76 -0
- ata_coder/skills/math-calculator/README.md +40 -0
- ata_coder/skills/math-calculator/SKILL.md +59 -0
- ata_coder/skills/math-calculator/handler.py +103 -0
- ata_coder/skills/math-calculator/prompts/system.md +8 -0
- ata_coder/skills/math-calculator/requirements.txt +2 -0
- ata_coder/skills/math-calculator/resources/constants.json +8 -0
- ata_coder/skills/math-calculator/tests/test_handler.py +53 -0
- ata_coder/skills/security-auditor/SKILL.md +40 -0
- ata_coder/skills/test-writer/SKILL.md +36 -0
- ata_coder/skills/weather-skill/README.md +45 -0
- ata_coder/skills/weather-skill/handler.py +76 -0
- ata_coder/skills/weather-skill/manifest.json +48 -0
- ata_coder/skills/weather-skill/prompts/system_prompt.txt +9 -0
- ata_coder/skills/weather-skill/prompts/user_prompt_template.txt +3 -0
- ata_coder/skills/weather-skill/requirements.txt +1 -0
- ata_coder/skills/weather-skill/resources/city_list.json +17 -0
- ata_coder/skills/weather-skill/resources/error_messages.json +7 -0
- ata_coder/skills/weather-skill/tests/test_handler.py +28 -0
- ata_coder/skills/weather-skill/weather_utils.py +50 -0
- ata_coder/skills.py +1014 -0
- ata_coder/sub_agent.py +273 -0
- ata_coder/sub_agent_manager.py +203 -0
- ata_coder/system_prompt_builder.py +146 -0
- ata_coder/task_planner.py +391 -0
- ata_coder/terminal.py +318 -0
- ata_coder/test_runner.py +219 -0
- ata_coder/thread_supervisor.py +195 -0
- ata_coder/tool_defs.py +335 -0
- ata_coder/tools/__init__.py +11 -0
- ata_coder/tools/definitions.py +335 -0
- ata_coder/tools/executor.py +1036 -0
- ata_coder/tools/result.py +26 -0
- ata_coder/tools/subagent.py +332 -0
- ata_coder/tools/web.py +361 -0
- ata_coder/tools.py +1576 -0
- ata_coder/types.py +92 -0
- ata_coder/utils.py +113 -0
- ata_coder/web/css/style.css +180 -0
- ata_coder/web/index.html +84 -0
- ata_coder/web/js/app.js +489 -0
- ata_coder/web/package-lock.json +25 -0
- ata_coder/web/package.json +10 -0
- ata_coder/web/tsconfig.json +13 -0
- ata_coder-2.4.2.dist-info/METADATA +799 -0
- ata_coder-2.4.2.dist-info/RECORD +118 -0
- ata_coder-2.4.2.dist-info/WHEEL +5 -0
- ata_coder-2.4.2.dist-info/entry_points.txt +2 -0
- ata_coder-2.4.2.dist-info/licenses/LICENSE +21 -0
- ata_coder-2.4.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Clawd Desktop Pet integration for ATA Coder.
|
|
3
|
+
|
|
4
|
+
Posts lifecycle events to Clawd's local HTTP server so the desktop pet
|
|
5
|
+
can react to the agent's state in real-time.
|
|
6
|
+
|
|
7
|
+
Detection: reads ~/.clawd/runtime.json to find the running Clawd port.
|
|
8
|
+
Events are POSTed to http://127.0.0.1:<port>/state as JSON.
|
|
9
|
+
|
|
10
|
+
Permission bubbles: when Clawd is running, ATA Coder delegates interactive
|
|
11
|
+
permission decisions (Y/N/A/D) to Clawd's permission bubble UI. The HTTP
|
|
12
|
+
request blocks until the user clicks a bubble button.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
from .clawd_integration import ClawdIntegration, get_clawd
|
|
16
|
+
clawd = get_clawd()
|
|
17
|
+
clawd.start(session_id="...", cwd="...")
|
|
18
|
+
...
|
|
19
|
+
# Permission check — blocks until user clicks bubble
|
|
20
|
+
decision = clawd.request_permission("run_shell", {"command": "rm -rf /"}, "sid")
|
|
21
|
+
if decision == "allow":
|
|
22
|
+
execute()
|
|
23
|
+
...
|
|
24
|
+
clawd.session_end()
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import platform
|
|
31
|
+
import threading
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Callable
|
|
34
|
+
from urllib import request, error as urllib_error
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
CLAWD_SERVER_ID = "clawd-on-desk"
|
|
39
|
+
CLAWD_RUNTIME_PATH = Path.home() / ".clawd" / "runtime.json"
|
|
40
|
+
SERVER_PORTS = [23333, 23334, 23335, 23336, 23337]
|
|
41
|
+
|
|
42
|
+
# Fire-and-forget events use a short timeout — don't block the agent loop.
|
|
43
|
+
ASYNC_POST_TIMEOUT_S = 2.0 # async (background thread) — generous for localhost
|
|
44
|
+
|
|
45
|
+
# Critical lifecycle events (stop, error) MUST arrive or Clawd stays
|
|
46
|
+
# stuck in the "thinking" animation forever. Use a longer timeout and
|
|
47
|
+
# a generous body size limit. Still synchronous so it arrives before
|
|
48
|
+
# SessionEnd, but won't silently drop on a busy Electron main process.
|
|
49
|
+
CRITICAL_POST_TIMEOUT_S = 5.0
|
|
50
|
+
CRITICAL_POST_MAX_BYTES = 16_384
|
|
51
|
+
|
|
52
|
+
# Permission requests are blocking — Clawd holds the HTTP connection
|
|
53
|
+
# open until the user clicks a bubble button. Longer timeout as safety
|
|
54
|
+
# net in case Clawd crashes or the user walks away.
|
|
55
|
+
PERMISSION_TIMEOUT_S = 600.0 # 10 minutes
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _find_clawd_port() -> int | None:
|
|
59
|
+
"""Read the Clawd runtime port from ~/.clawd/runtime.json."""
|
|
60
|
+
try:
|
|
61
|
+
if CLAWD_RUNTIME_PATH.exists():
|
|
62
|
+
data = json.loads(CLAWD_RUNTIME_PATH.read_text(encoding="utf-8"))
|
|
63
|
+
port = int(data.get("port", 0))
|
|
64
|
+
if port in SERVER_PORTS:
|
|
65
|
+
return port
|
|
66
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
67
|
+
pass
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def is_clawd_running() -> bool:
|
|
72
|
+
"""Quick check: is Clawd currently running and reachable?"""
|
|
73
|
+
port = _find_clawd_port()
|
|
74
|
+
if not port:
|
|
75
|
+
return False
|
|
76
|
+
try:
|
|
77
|
+
req = request.Request(
|
|
78
|
+
f"http://127.0.0.1:{port}/health",
|
|
79
|
+
method="GET",
|
|
80
|
+
)
|
|
81
|
+
resp = request.urlopen(req, timeout=0.3)
|
|
82
|
+
resp.read()
|
|
83
|
+
return resp.status == 200
|
|
84
|
+
except Exception:
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _get_pid() -> int:
|
|
89
|
+
"""Get current process PID."""
|
|
90
|
+
return os.getpid()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _get_platform_tag() -> str:
|
|
94
|
+
"""Short platform tag for Clawd state events."""
|
|
95
|
+
system = platform.system()
|
|
96
|
+
if system == "Windows":
|
|
97
|
+
return "windows"
|
|
98
|
+
elif system == "Darwin":
|
|
99
|
+
return "macos"
|
|
100
|
+
else:
|
|
101
|
+
return "linux"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _truncate_tool_input(args: dict) -> dict:
|
|
105
|
+
"""Truncate large tool input so it fits the Clawd permission bubble."""
|
|
106
|
+
if not args:
|
|
107
|
+
return {}
|
|
108
|
+
out = {}
|
|
109
|
+
for k, v in args.items():
|
|
110
|
+
if isinstance(v, str) and len(v) > 200:
|
|
111
|
+
out[k] = v[:197] + "..."
|
|
112
|
+
elif isinstance(v, dict):
|
|
113
|
+
out[k] = _truncate_tool_input(v)
|
|
114
|
+
elif isinstance(v, list) and len(v) > 20:
|
|
115
|
+
out[k] = v[:20]
|
|
116
|
+
else:
|
|
117
|
+
out[k] = v
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ClawdIntegration:
|
|
122
|
+
"""Fire-and-forget event poster + blocking permission client.
|
|
123
|
+
|
|
124
|
+
State events are posted in background threads (non-blocking).
|
|
125
|
+
Permission requests are BLOCKING — they wait for the user to click
|
|
126
|
+
a bubble button in Clawd's UI.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(self):
|
|
130
|
+
self._port: int | None = None
|
|
131
|
+
self._session_id: str = ""
|
|
132
|
+
self._cwd: str = ""
|
|
133
|
+
self._enabled: bool = False
|
|
134
|
+
self._lock = threading.Lock()
|
|
135
|
+
|
|
136
|
+
# ── Lifecycle ──────────────────────────────────────────────────────────
|
|
137
|
+
|
|
138
|
+
def start(self, session_id: str = "", cwd: str = "", title: str = "") -> None:
|
|
139
|
+
"""Initialise and post SessionStart."""
|
|
140
|
+
self._port = _find_clawd_port()
|
|
141
|
+
self._enabled = self._port is not None
|
|
142
|
+
self._session_id = session_id
|
|
143
|
+
self._cwd = cwd or os.getcwd()
|
|
144
|
+
|
|
145
|
+
if self._enabled:
|
|
146
|
+
logger.debug("Clawd detected on port %d", self._port)
|
|
147
|
+
body: dict = {
|
|
148
|
+
"agent_id": "ata-coder",
|
|
149
|
+
"event": "SessionStart",
|
|
150
|
+
"state": "idle",
|
|
151
|
+
"session_id": self._session_id or "default",
|
|
152
|
+
"cwd": self._cwd,
|
|
153
|
+
"source_pid": _get_pid(),
|
|
154
|
+
"platform": _get_platform_tag(),
|
|
155
|
+
}
|
|
156
|
+
if title:
|
|
157
|
+
first_line = title.strip().split("\n")[0][:80]
|
|
158
|
+
if first_line:
|
|
159
|
+
body["session_title"] = first_line
|
|
160
|
+
self._post(body)
|
|
161
|
+
|
|
162
|
+
def user_prompt(self, prompt: str = "") -> None:
|
|
163
|
+
"""Post UserPromptSubmit — the user has sent a new task."""
|
|
164
|
+
if not self._enabled:
|
|
165
|
+
return
|
|
166
|
+
body: dict = {
|
|
167
|
+
"agent_id": "ata-coder",
|
|
168
|
+
"event": "UserPromptSubmit",
|
|
169
|
+
"state": "thinking",
|
|
170
|
+
"session_id": self._session_id or "default",
|
|
171
|
+
"cwd": self._cwd,
|
|
172
|
+
"source_pid": _get_pid(),
|
|
173
|
+
"platform": _get_platform_tag(),
|
|
174
|
+
}
|
|
175
|
+
if prompt:
|
|
176
|
+
first_line = prompt.strip().split("\n")[0][:120]
|
|
177
|
+
body["session_title"] = first_line if first_line else None
|
|
178
|
+
self._post(body)
|
|
179
|
+
|
|
180
|
+
def thinking(self) -> None:
|
|
181
|
+
"""Post a working state update — model is generating, show pet working."""
|
|
182
|
+
if not self._enabled:
|
|
183
|
+
return
|
|
184
|
+
self._post({
|
|
185
|
+
"agent_id": "ata-coder",
|
|
186
|
+
"event": "UserPromptSubmit",
|
|
187
|
+
"state": "working",
|
|
188
|
+
"session_id": self._session_id or "default",
|
|
189
|
+
"cwd": self._cwd,
|
|
190
|
+
"source_pid": _get_pid(),
|
|
191
|
+
"platform": _get_platform_tag(),
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
def tool_use(self, tool_name: str = "", tool_input: dict | None = None) -> None:
|
|
195
|
+
"""Post PreToolUse — the model is about to execute a tool."""
|
|
196
|
+
if not self._enabled:
|
|
197
|
+
return
|
|
198
|
+
body: dict = {
|
|
199
|
+
"agent_id": "ata-coder",
|
|
200
|
+
"event": "PreToolUse",
|
|
201
|
+
"state": "working",
|
|
202
|
+
"session_id": self._session_id or "default",
|
|
203
|
+
"cwd": self._cwd,
|
|
204
|
+
"source_pid": _get_pid(),
|
|
205
|
+
"platform": _get_platform_tag(),
|
|
206
|
+
}
|
|
207
|
+
if tool_name:
|
|
208
|
+
body["tool_name"] = tool_name
|
|
209
|
+
if tool_input:
|
|
210
|
+
body["tool_input_fingerprint"] = self._fingerprint(tool_input)
|
|
211
|
+
self._post(body)
|
|
212
|
+
|
|
213
|
+
def tool_result(self, tool_name: str = "", success: bool = True) -> None:
|
|
214
|
+
"""Post PostToolUse or PostToolUseFailure."""
|
|
215
|
+
if not self._enabled:
|
|
216
|
+
return
|
|
217
|
+
event = "PostToolUse" if success else "PostToolUseFailure"
|
|
218
|
+
state = "working" if success else "error"
|
|
219
|
+
body: dict = {
|
|
220
|
+
"agent_id": "ata-coder",
|
|
221
|
+
"event": event,
|
|
222
|
+
"state": state,
|
|
223
|
+
"session_id": self._session_id or "default",
|
|
224
|
+
"cwd": self._cwd,
|
|
225
|
+
"source_pid": _get_pid(),
|
|
226
|
+
"platform": _get_platform_tag(),
|
|
227
|
+
}
|
|
228
|
+
if tool_name:
|
|
229
|
+
body["tool_name"] = tool_name
|
|
230
|
+
self._post(body)
|
|
231
|
+
|
|
232
|
+
def stop(self, assistant_output: str = "") -> None:
|
|
233
|
+
"""Post Stop — CRITICAL: must arrive or Clawd stays stuck thinking.
|
|
234
|
+
|
|
235
|
+
Uses a longer timeout and larger body limit than fire-and-forget
|
|
236
|
+
events. Logs a warning on failure so the user knows something is
|
|
237
|
+
wrong instead of silently letting the pet animate forever.
|
|
238
|
+
"""
|
|
239
|
+
if not self._enabled:
|
|
240
|
+
return
|
|
241
|
+
body: dict = {
|
|
242
|
+
"agent_id": "ata-coder",
|
|
243
|
+
"event": "Stop",
|
|
244
|
+
"state": "attention",
|
|
245
|
+
"session_id": self._session_id or "default",
|
|
246
|
+
"cwd": self._cwd,
|
|
247
|
+
"source_pid": _get_pid(),
|
|
248
|
+
"platform": _get_platform_tag(),
|
|
249
|
+
}
|
|
250
|
+
if assistant_output:
|
|
251
|
+
# Keep body under 16KB: safe for localhost, avoids silent drop
|
|
252
|
+
text = assistant_output.strip()[:2000]
|
|
253
|
+
body["assistant_last_output"] = text
|
|
254
|
+
ok = self._send_one(body, timeout=CRITICAL_POST_TIMEOUT_S,
|
|
255
|
+
max_bytes=CRITICAL_POST_MAX_BYTES)
|
|
256
|
+
if not ok:
|
|
257
|
+
logger.warning("Clawd Stop event failed to send — pet may stay in thinking state")
|
|
258
|
+
|
|
259
|
+
def error(self, message: str = "") -> None:
|
|
260
|
+
"""Post StopFailure — CRITICAL: must arrive or Clawd stays stuck."""
|
|
261
|
+
if not self._enabled:
|
|
262
|
+
return
|
|
263
|
+
body: dict = {
|
|
264
|
+
"agent_id": "ata-coder",
|
|
265
|
+
"event": "StopFailure",
|
|
266
|
+
"state": "error",
|
|
267
|
+
"session_id": self._session_id or "default",
|
|
268
|
+
"cwd": self._cwd,
|
|
269
|
+
"source_pid": _get_pid(),
|
|
270
|
+
"platform": _get_platform_tag(),
|
|
271
|
+
"error_present": True,
|
|
272
|
+
}
|
|
273
|
+
if message:
|
|
274
|
+
body["assistant_last_output"] = message[:2000]
|
|
275
|
+
ok = self._send_one(body, timeout=CRITICAL_POST_TIMEOUT_S,
|
|
276
|
+
max_bytes=CRITICAL_POST_MAX_BYTES)
|
|
277
|
+
if not ok:
|
|
278
|
+
logger.warning("Clawd StopFailure event failed to send")
|
|
279
|
+
|
|
280
|
+
def subagent_start(self) -> None:
|
|
281
|
+
"""Post SubagentStart — a sub-agent is launching."""
|
|
282
|
+
if not self._enabled:
|
|
283
|
+
return
|
|
284
|
+
self._post({
|
|
285
|
+
"agent_id": "ata-coder",
|
|
286
|
+
"event": "SubagentStart",
|
|
287
|
+
"state": "juggling",
|
|
288
|
+
"session_id": self._session_id or "default",
|
|
289
|
+
"cwd": self._cwd,
|
|
290
|
+
"source_pid": _get_pid(),
|
|
291
|
+
"platform": _get_platform_tag(),
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
def subagent_stop(self) -> None:
|
|
295
|
+
"""Post SubagentStop — a sub-agent has finished."""
|
|
296
|
+
if not self._enabled:
|
|
297
|
+
return
|
|
298
|
+
self._post({
|
|
299
|
+
"agent_id": "ata-coder",
|
|
300
|
+
"event": "SubagentStop",
|
|
301
|
+
"state": "working",
|
|
302
|
+
"session_id": self._session_id or "default",
|
|
303
|
+
"cwd": self._cwd,
|
|
304
|
+
"source_pid": _get_pid(),
|
|
305
|
+
"platform": _get_platform_tag(),
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
def compact(self) -> None:
|
|
309
|
+
"""Post PreCompact — context compaction is starting."""
|
|
310
|
+
if not self._enabled:
|
|
311
|
+
return
|
|
312
|
+
self._post({
|
|
313
|
+
"agent_id": "ata-coder",
|
|
314
|
+
"event": "PreCompact",
|
|
315
|
+
"state": "sweeping",
|
|
316
|
+
"session_id": self._session_id or "default",
|
|
317
|
+
"cwd": self._cwd,
|
|
318
|
+
"source_pid": _get_pid(),
|
|
319
|
+
"platform": _get_platform_tag(),
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
def session_end(self) -> None:
|
|
323
|
+
"""Post SessionEnd — the agent run is complete.
|
|
324
|
+
|
|
325
|
+
Sends immediately (fire-and-forget). Clawd's ONESHOT attention
|
|
326
|
+
animation auto-decays — no artificial delay is needed.
|
|
327
|
+
"""
|
|
328
|
+
if not self._enabled:
|
|
329
|
+
return
|
|
330
|
+
self._post({
|
|
331
|
+
"agent_id": "ata-coder",
|
|
332
|
+
"event": "SessionEnd",
|
|
333
|
+
"state": "sleeping",
|
|
334
|
+
"session_id": self._session_id or "default",
|
|
335
|
+
"cwd": self._cwd,
|
|
336
|
+
"source_pid": _get_pid(),
|
|
337
|
+
"platform": _get_platform_tag(),
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
async def shutdown_async(self) -> None:
|
|
341
|
+
"""Called when the agent is shutting down — await the final event."""
|
|
342
|
+
if self._enabled:
|
|
343
|
+
self.session_end()
|
|
344
|
+
# Also send synchronously to guarantee delivery before exit
|
|
345
|
+
self._send_one({
|
|
346
|
+
"agent_id": "ata-coder",
|
|
347
|
+
"event": "SessionEnd",
|
|
348
|
+
"state": "sleeping",
|
|
349
|
+
"session_id": self._session_id or "default",
|
|
350
|
+
"cwd": self._cwd,
|
|
351
|
+
"source_pid": _get_pid(),
|
|
352
|
+
"platform": _get_platform_tag(),
|
|
353
|
+
}, timeout=CRITICAL_POST_TIMEOUT_S, max_bytes=CRITICAL_POST_MAX_BYTES)
|
|
354
|
+
self._enabled = False
|
|
355
|
+
|
|
356
|
+
def shutdown(self) -> None:
|
|
357
|
+
"""Called when the agent is shutting down (sync fallback)."""
|
|
358
|
+
self.session_end()
|
|
359
|
+
self._enabled = False
|
|
360
|
+
|
|
361
|
+
# ── Permission (blocking) ──────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
def request_permission(
|
|
364
|
+
self,
|
|
365
|
+
tool_name: str,
|
|
366
|
+
arguments: dict | None = None,
|
|
367
|
+
session_id: str = "",
|
|
368
|
+
) -> str | None:
|
|
369
|
+
"""Ask Clawd to show a 4-option permission bubble (Y/N/A/D).
|
|
370
|
+
|
|
371
|
+
This is a BLOCKING call — it holds the HTTP connection open
|
|
372
|
+
until the user clicks a bubble button or the request times out.
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
"allow" — user clicked Yes
|
|
376
|
+
"deny" — user clicked No
|
|
377
|
+
"allow_all" — user clicked Always (all tools of this category)
|
|
378
|
+
"deny_all" — user clicked Deny (all tools of this category)
|
|
379
|
+
None — Clawd not running, connection failed, or timeout
|
|
380
|
+
(caller should fall back to built-in prompt)
|
|
381
|
+
|
|
382
|
+
The Always/Deny-all actions are represented in Clawd's bubble as:
|
|
383
|
+
Y = allow once (this specific tool call)
|
|
384
|
+
N = deny once
|
|
385
|
+
A = allow all (allow this category for the session)
|
|
386
|
+
D = deny all (deny this category for the session)
|
|
387
|
+
"""
|
|
388
|
+
port = self._port
|
|
389
|
+
if not port:
|
|
390
|
+
return None
|
|
391
|
+
|
|
392
|
+
sid = session_id or self._session_id or "default"
|
|
393
|
+
tool_input = _truncate_tool_input(arguments or {})
|
|
394
|
+
|
|
395
|
+
body = json.dumps({
|
|
396
|
+
"agent_id": "ata-coder",
|
|
397
|
+
"session_id": sid,
|
|
398
|
+
"tool_name": tool_name,
|
|
399
|
+
"tool_input": tool_input,
|
|
400
|
+
"cwd": self._cwd,
|
|
401
|
+
"source_pid": _get_pid(),
|
|
402
|
+
"platform": _get_platform_tag(),
|
|
403
|
+
}, ensure_ascii=False).encode("utf-8")
|
|
404
|
+
|
|
405
|
+
try:
|
|
406
|
+
req = request.Request(
|
|
407
|
+
f"http://127.0.0.1:{port}/permission",
|
|
408
|
+
data=body,
|
|
409
|
+
headers={
|
|
410
|
+
"Content-Type": "application/json",
|
|
411
|
+
"x-clawd-server": CLAWD_SERVER_ID,
|
|
412
|
+
},
|
|
413
|
+
method="POST",
|
|
414
|
+
)
|
|
415
|
+
resp = request.urlopen(req, timeout=PERMISSION_TIMEOUT_S)
|
|
416
|
+
raw = resp.read().decode("utf-8")
|
|
417
|
+
data = json.loads(raw)
|
|
418
|
+
|
|
419
|
+
# Clawd response format:
|
|
420
|
+
# {"hookSpecificOutput": {"hookEventName": "PermissionRequest",
|
|
421
|
+
# "decision": {"behavior": "allow"|"deny"}}}
|
|
422
|
+
decision = data.get("hookSpecificOutput", {}).get("decision", {})
|
|
423
|
+
behavior = decision.get("behavior", "")
|
|
424
|
+
|
|
425
|
+
if behavior == "allow":
|
|
426
|
+
logger.info("Clawd permission: ALLOW %s", tool_name)
|
|
427
|
+
return "allow"
|
|
428
|
+
elif behavior == "deny":
|
|
429
|
+
logger.info("Clawd permission: DENY %s", tool_name)
|
|
430
|
+
return "deny"
|
|
431
|
+
else:
|
|
432
|
+
logger.warning("Clawd permission: unknown behavior=%r", behavior)
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
except urllib_error.URLError as e:
|
|
436
|
+
logger.debug("Clawd permission unavailable: %s", e)
|
|
437
|
+
return None
|
|
438
|
+
except (OSError, ValueError, json.JSONDecodeError) as e:
|
|
439
|
+
logger.debug("Clawd permission error: %s", e)
|
|
440
|
+
return None
|
|
441
|
+
except Exception:
|
|
442
|
+
logger.debug("Clawd permission failed", exc_info=True)
|
|
443
|
+
return None
|
|
444
|
+
|
|
445
|
+
# ── Internal ───────────────────────────────────────────────────────────
|
|
446
|
+
|
|
447
|
+
def _send_one(self, data: dict, timeout: float = ASYNC_POST_TIMEOUT_S,
|
|
448
|
+
max_bytes: int = 4096) -> bool:
|
|
449
|
+
"""POST state JSON to Clawd. Returns True on success.
|
|
450
|
+
|
|
451
|
+
*max_bytes* protects against oversized payloads that Clawd's
|
|
452
|
+
Express server would reject. Default 4KB for fire-and-forget;
|
|
453
|
+
critical events (stop, error) should use 16KB.
|
|
454
|
+
"""
|
|
455
|
+
port = self._port
|
|
456
|
+
if not port:
|
|
457
|
+
return False
|
|
458
|
+
try:
|
|
459
|
+
body_bytes = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
460
|
+
if len(body_bytes) > max_bytes:
|
|
461
|
+
logger.warning(
|
|
462
|
+
"Clawd event too large (%d bytes > %d), dropping: event=%s",
|
|
463
|
+
len(body_bytes), max_bytes, data.get("event", "?"),
|
|
464
|
+
)
|
|
465
|
+
return False
|
|
466
|
+
req = request.Request(
|
|
467
|
+
f"http://127.0.0.1:{port}/state",
|
|
468
|
+
data=body_bytes,
|
|
469
|
+
headers={
|
|
470
|
+
"Content-Type": "application/json",
|
|
471
|
+
"x-clawd-server": CLAWD_SERVER_ID,
|
|
472
|
+
},
|
|
473
|
+
method="POST",
|
|
474
|
+
)
|
|
475
|
+
request.urlopen(req, timeout=timeout)
|
|
476
|
+
return True
|
|
477
|
+
except (urllib_error.URLError, OSError, ValueError):
|
|
478
|
+
return False
|
|
479
|
+
except Exception:
|
|
480
|
+
logger.debug("Clawd post failed", exc_info=True)
|
|
481
|
+
return False
|
|
482
|
+
|
|
483
|
+
async def _post_async(self, data: dict) -> None:
|
|
484
|
+
"""POST state JSON to Clawd via asyncio thread pool (fire-and-forget)."""
|
|
485
|
+
if not self._port:
|
|
486
|
+
return
|
|
487
|
+
import asyncio
|
|
488
|
+
try:
|
|
489
|
+
await asyncio.to_thread(self._send_one, data)
|
|
490
|
+
except Exception:
|
|
491
|
+
pass
|
|
492
|
+
|
|
493
|
+
def _post(self, data: dict) -> None:
|
|
494
|
+
"""POST state JSON to Clawd (fire-and-forget).
|
|
495
|
+
|
|
496
|
+
When running inside an asyncio event loop, schedules the HTTP call
|
|
497
|
+
as a background task so the event loop can track it. Falls back to
|
|
498
|
+
a daemon thread when no loop is active (e.g. during startup).
|
|
499
|
+
"""
|
|
500
|
+
if not self._port:
|
|
501
|
+
return
|
|
502
|
+
import asyncio
|
|
503
|
+
try:
|
|
504
|
+
loop = asyncio.get_running_loop()
|
|
505
|
+
loop.create_task(self._post_async(data))
|
|
506
|
+
except RuntimeError:
|
|
507
|
+
# No running event loop — use daemon thread
|
|
508
|
+
t = threading.Thread(target=self._send_one, args=(data,), daemon=True)
|
|
509
|
+
t.start()
|
|
510
|
+
|
|
511
|
+
@staticmethod
|
|
512
|
+
def _fingerprint(tool_input: dict) -> str | None:
|
|
513
|
+
"""Lightweight input fingerprint for dedup."""
|
|
514
|
+
try:
|
|
515
|
+
import hashlib
|
|
516
|
+
raw = json.dumps(tool_input, sort_keys=True, ensure_ascii=False, default=str)
|
|
517
|
+
return hashlib.sha1(raw.encode()).hexdigest()
|
|
518
|
+
except Exception:
|
|
519
|
+
return None
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# ── Module-level singleton ─────────────────────────────────────────────────
|
|
523
|
+
|
|
524
|
+
_clawd: ClawdIntegration | None = None
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def get_clawd() -> ClawdIntegration:
|
|
528
|
+
"""Get or create the global ClawdIntegration singleton."""
|
|
529
|
+
global _clawd
|
|
530
|
+
if _clawd is None:
|
|
531
|
+
_clawd = ClawdIntegration()
|
|
532
|
+
return _clawd
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
# ── Permission callback wrapper ────────────────────────────────────────────
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def create_clawd_permission_handler(
|
|
539
|
+
clawd: ClawdIntegration | None = None,
|
|
540
|
+
) -> "Callable[[str, dict, str], bool]":
|
|
541
|
+
"""Create a permission handler that delegates to Clawd's bubble UI.
|
|
542
|
+
|
|
543
|
+
Returns a callable with signature (tool_name, arguments, category) -> bool
|
|
544
|
+
suitable for use as PermissionStore.set_prompt_callback().
|
|
545
|
+
|
|
546
|
+
When Clawd is running, the handler sends a blocking POST to /permission
|
|
547
|
+
and returns the user's bubble decision. When Clawd is unavailable,
|
|
548
|
+
returns None (caller should fall back to the built-in prompt).
|
|
549
|
+
|
|
550
|
+
Usage:
|
|
551
|
+
clawd = get_clawd()
|
|
552
|
+
handler = create_clawd_permission_handler(clawd)
|
|
553
|
+
# Wrap with fallback:
|
|
554
|
+
def combined_prompt(tool_name, args, category):
|
|
555
|
+
result = handler(tool_name, args, category)
|
|
556
|
+
if result is not None:
|
|
557
|
+
return result # Clawd decided
|
|
558
|
+
return builtin_prompt(tool_name, args, category) # fallback
|
|
559
|
+
"""
|
|
560
|
+
c = clawd or get_clawd()
|
|
561
|
+
|
|
562
|
+
def _handler(tool_name: str, arguments: dict, category: str) -> bool | None:
|
|
563
|
+
if not c._enabled:
|
|
564
|
+
return None
|
|
565
|
+
decision = c.request_permission(tool_name, arguments)
|
|
566
|
+
if decision is None:
|
|
567
|
+
return None # connection failed → fall back
|
|
568
|
+
if decision == "allow" or decision == "allow_all":
|
|
569
|
+
return True
|
|
570
|
+
if decision == "deny" or decision == "deny_all":
|
|
571
|
+
return False
|
|
572
|
+
return None
|
|
573
|
+
|
|
574
|
+
return _handler
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Slash command registry — replaces the 400+ line _handle_command function.
|
|
3
|
+
|
|
4
|
+
Each command is a small self-contained function registered with a decorator.
|
|
5
|
+
Command groups live in separate modules (_core, _safety, _settings, _workflow)
|
|
6
|
+
to keep each file focused and testable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class CommandContext:
|
|
15
|
+
"""Typed context passed to every slash-command handler.
|
|
16
|
+
|
|
17
|
+
Supports both attribute access (``ctx.agent``) and dict-style access
|
|
18
|
+
(``ctx["agent"]``) for backward compatibility with existing handlers.
|
|
19
|
+
"""
|
|
20
|
+
agent: Any = None # CoderAgent instance
|
|
21
|
+
config: Any = None # AppConfig instance
|
|
22
|
+
ui: Any = None # ClaudeCodeUI instance
|
|
23
|
+
skill_mgr: Any = None # SkillManager
|
|
24
|
+
memory_store: Any = None # MemoryStore
|
|
25
|
+
session_mgr: Any = None # SessionManager
|
|
26
|
+
mcp_client: Any = None # MCPClient
|
|
27
|
+
template_mgr: Any = None # TemplateManager
|
|
28
|
+
permission_store: Any = None # PermissionStore
|
|
29
|
+
auto_skill_state: dict = field(default_factory=lambda: {"value": True})
|
|
30
|
+
|
|
31
|
+
def __getitem__(self, key: str) -> Any:
|
|
32
|
+
"""Dict-style access for backward compatibility."""
|
|
33
|
+
return getattr(self, key)
|
|
34
|
+
|
|
35
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
36
|
+
"""Dict-style mutation for backward compatibility."""
|
|
37
|
+
setattr(self, key, value)
|
|
38
|
+
|
|
39
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
40
|
+
"""dict.get() compatibility."""
|
|
41
|
+
return getattr(self, key, default)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class Command:
|
|
46
|
+
name: str
|
|
47
|
+
handler: Callable[..., bool] # (arg: str, ctx: CommandContext) -> continue_running
|
|
48
|
+
help_text: str
|
|
49
|
+
category: str = "general"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CommandRegistry:
|
|
53
|
+
"""Registry of slash commands with dispatch."""
|
|
54
|
+
|
|
55
|
+
def __init__(self):
|
|
56
|
+
self._commands: dict[str, Command] = {}
|
|
57
|
+
|
|
58
|
+
def register(self, name: str, help_text: str, category: str = "general"):
|
|
59
|
+
"""Decorator to register a command handler."""
|
|
60
|
+
def decorator(fn: Callable[..., bool]):
|
|
61
|
+
self._commands[name] = Command(name=name, handler=fn, help_text=help_text, category=category)
|
|
62
|
+
return fn
|
|
63
|
+
return decorator
|
|
64
|
+
|
|
65
|
+
async def dispatch(self, cmd: str, arg: str, ctx: dict) -> bool | None:
|
|
66
|
+
"""Dispatch a command. Returns: True=continue, False=quit, None=unknown.
|
|
67
|
+
|
|
68
|
+
Accepts a dict for backward compatibility; wraps it in a
|
|
69
|
+
CommandContext before passing to the handler.
|
|
70
|
+
|
|
71
|
+
Supports both sync and async command handlers.
|
|
72
|
+
"""
|
|
73
|
+
command = self._commands.get(cmd)
|
|
74
|
+
if command is None:
|
|
75
|
+
return None
|
|
76
|
+
# Wrap dict in typed context if not already
|
|
77
|
+
if isinstance(ctx, dict):
|
|
78
|
+
ctx = CommandContext(**{k: ctx.get(k) for k in CommandContext.__dataclass_fields__})
|
|
79
|
+
import asyncio
|
|
80
|
+
if asyncio.iscoroutinefunction(command.handler):
|
|
81
|
+
return await command.handler(arg, ctx)
|
|
82
|
+
return command.handler(arg, ctx)
|
|
83
|
+
|
|
84
|
+
def list_all(self) -> list[Command]:
|
|
85
|
+
return sorted(self._commands.values(), key=lambda c: c.name)
|
|
86
|
+
|
|
87
|
+
def list_by_category(self) -> dict[str, list[Command]]:
|
|
88
|
+
cats: dict[str, list[Command]] = {}
|
|
89
|
+
for c in self._commands.values():
|
|
90
|
+
cats.setdefault(c.category, []).append(c)
|
|
91
|
+
return cats
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
95
|
+
# Build the registry
|
|
96
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
97
|
+
|
|
98
|
+
def build_registry():
|
|
99
|
+
"""Build the command registry from all sub-modules."""
|
|
100
|
+
r = CommandRegistry()
|
|
101
|
+
|
|
102
|
+
from . import _core
|
|
103
|
+
_core.register_commands(r)
|
|
104
|
+
|
|
105
|
+
from . import _safety
|
|
106
|
+
_safety.register_commands(r)
|
|
107
|
+
|
|
108
|
+
from . import _settings
|
|
109
|
+
_settings.register_commands(r)
|
|
110
|
+
|
|
111
|
+
from . import _workflow
|
|
112
|
+
_workflow.register_commands(r)
|
|
113
|
+
|
|
114
|
+
return r
|
|
115
|
+
|
|
116
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
117
|
+
# Command list for readline completion (auto-generated from registry)
|
|
118
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
119
|
+
|
|
120
|
+
_REGISTRY: CommandRegistry | None = None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_command_list() -> list[tuple[str, str]]:
|
|
124
|
+
"""Return list of (name, description) for all slash commands."""
|
|
125
|
+
global _REGISTRY
|
|
126
|
+
if _REGISTRY is None:
|
|
127
|
+
_REGISTRY = build_registry()
|
|
128
|
+
return [(c.name, c.help_text) for c in _REGISTRY.list_all()]
|