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.
Files changed (118) hide show
  1. ata_coder/__init__.py +1 -0
  2. ata_coder/agent.py +874 -0
  3. ata_coder/agent_compact.py +190 -0
  4. ata_coder/agent_controller.py +218 -0
  5. ata_coder/agent_extension.py +69 -0
  6. ata_coder/agent_routing.py +105 -0
  7. ata_coder/agent_subsystems.py +72 -0
  8. ata_coder/agent_tools.py +318 -0
  9. ata_coder/agent_undo.py +63 -0
  10. ata_coder/anthropic_client.py +465 -0
  11. ata_coder/change_tracker.py +368 -0
  12. ata_coder/clawd_integration.py +574 -0
  13. ata_coder/commands/__init__.py +128 -0
  14. ata_coder/commands/_core.py +184 -0
  15. ata_coder/commands/_safety.py +95 -0
  16. ata_coder/commands/_settings.py +241 -0
  17. ata_coder/commands/_workflow.py +451 -0
  18. ata_coder/commands.py +974 -0
  19. ata_coder/config.py +257 -0
  20. ata_coder/core/__init__.py +35 -0
  21. ata_coder/core/events.py +73 -0
  22. ata_coder/core/queue.py +85 -0
  23. ata_coder/core/state.py +17 -0
  24. ata_coder/event_queue.py +5 -0
  25. ata_coder/extension.py +654 -0
  26. ata_coder/extensions/__init__.py +1 -0
  27. ata_coder/extensions/hello_skill.py +47 -0
  28. ata_coder/fool_proof.py +295 -0
  29. ata_coder/git_workflow.py +371 -0
  30. ata_coder/gui.py +511 -0
  31. ata_coder/llm_client.py +543 -0
  32. ata_coder/main.py +814 -0
  33. ata_coder/mcp_client.py +1095 -0
  34. ata_coder/memory.py +539 -0
  35. ata_coder/model_registry.py +134 -0
  36. ata_coder/model_router.py +105 -0
  37. ata_coder/permissions.py +274 -0
  38. ata_coder/privilege.py +464 -0
  39. ata_coder/project.py +273 -0
  40. ata_coder/prompt_template.py +423 -0
  41. ata_coder/prompts/auto-mode.md +7 -0
  42. ata_coder/prompts/coding-rules.md +40 -0
  43. ata_coder/prompts/execution-guardrails.md +14 -0
  44. ata_coder/prompts/memory-system.md +24 -0
  45. ata_coder/prompts/output-style.md +23 -0
  46. ata_coder/prompts/safety.md +17 -0
  47. ata_coder/prompts/slash-commands.md +24 -0
  48. ata_coder/prompts/sub-agents.md +38 -0
  49. ata_coder/prompts/system-reminders.md +17 -0
  50. ata_coder/prompts/system.md +105 -0
  51. ata_coder/prompts/tool-policy.md +46 -0
  52. ata_coder/repl_theme.py +99 -0
  53. ata_coder/repl_tracker.py +89 -0
  54. ata_coder/repl_ui.py +1214 -0
  55. ata_coder/safety_guard.py +434 -0
  56. ata_coder/self_correct.py +346 -0
  57. ata_coder/server.py +882 -0
  58. ata_coder/server_session.py +159 -0
  59. ata_coder/server_shell.py +129 -0
  60. ata_coder/session.py +431 -0
  61. ata_coder/settings.py +439 -0
  62. ata_coder/setup_wizard.py +136 -0
  63. ata_coder/skill_extension.py +92 -0
  64. ata_coder/skills/architect/SKILL.md +42 -0
  65. ata_coder/skills/code-reviewer/SKILL.md +37 -0
  66. ata_coder/skills/codecraft/SKILL.md +452 -0
  67. ata_coder/skills/debugger/SKILL.md +45 -0
  68. ata_coder/skills/doc-writer/SKILL.md +36 -0
  69. ata_coder/skills/general-coder/SKILL.md +76 -0
  70. ata_coder/skills/math-calculator/README.md +40 -0
  71. ata_coder/skills/math-calculator/SKILL.md +59 -0
  72. ata_coder/skills/math-calculator/handler.py +103 -0
  73. ata_coder/skills/math-calculator/prompts/system.md +8 -0
  74. ata_coder/skills/math-calculator/requirements.txt +2 -0
  75. ata_coder/skills/math-calculator/resources/constants.json +8 -0
  76. ata_coder/skills/math-calculator/tests/test_handler.py +53 -0
  77. ata_coder/skills/security-auditor/SKILL.md +40 -0
  78. ata_coder/skills/test-writer/SKILL.md +36 -0
  79. ata_coder/skills/weather-skill/README.md +45 -0
  80. ata_coder/skills/weather-skill/handler.py +76 -0
  81. ata_coder/skills/weather-skill/manifest.json +48 -0
  82. ata_coder/skills/weather-skill/prompts/system_prompt.txt +9 -0
  83. ata_coder/skills/weather-skill/prompts/user_prompt_template.txt +3 -0
  84. ata_coder/skills/weather-skill/requirements.txt +1 -0
  85. ata_coder/skills/weather-skill/resources/city_list.json +17 -0
  86. ata_coder/skills/weather-skill/resources/error_messages.json +7 -0
  87. ata_coder/skills/weather-skill/tests/test_handler.py +28 -0
  88. ata_coder/skills/weather-skill/weather_utils.py +50 -0
  89. ata_coder/skills.py +1014 -0
  90. ata_coder/sub_agent.py +273 -0
  91. ata_coder/sub_agent_manager.py +203 -0
  92. ata_coder/system_prompt_builder.py +146 -0
  93. ata_coder/task_planner.py +391 -0
  94. ata_coder/terminal.py +318 -0
  95. ata_coder/test_runner.py +219 -0
  96. ata_coder/thread_supervisor.py +195 -0
  97. ata_coder/tool_defs.py +335 -0
  98. ata_coder/tools/__init__.py +11 -0
  99. ata_coder/tools/definitions.py +335 -0
  100. ata_coder/tools/executor.py +1036 -0
  101. ata_coder/tools/result.py +26 -0
  102. ata_coder/tools/subagent.py +332 -0
  103. ata_coder/tools/web.py +361 -0
  104. ata_coder/tools.py +1576 -0
  105. ata_coder/types.py +92 -0
  106. ata_coder/utils.py +113 -0
  107. ata_coder/web/css/style.css +180 -0
  108. ata_coder/web/index.html +84 -0
  109. ata_coder/web/js/app.js +489 -0
  110. ata_coder/web/package-lock.json +25 -0
  111. ata_coder/web/package.json +10 -0
  112. ata_coder/web/tsconfig.json +13 -0
  113. ata_coder-2.4.2.dist-info/METADATA +799 -0
  114. ata_coder-2.4.2.dist-info/RECORD +118 -0
  115. ata_coder-2.4.2.dist-info/WHEEL +5 -0
  116. ata_coder-2.4.2.dist-info/entry_points.txt +2 -0
  117. ata_coder-2.4.2.dist-info/licenses/LICENSE +21 -0
  118. 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()]