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,81 @@
1
+ """Cross-platform advisory file locking for cooperating CrabCode processes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import errno
6
+ import os
7
+ import threading
8
+ import time
9
+ from contextlib import contextmanager
10
+ from pathlib import Path
11
+ from typing import Iterator
12
+
13
+ try: # POSIX whole-file locking.
14
+ import fcntl # type: ignore
15
+ except ImportError: # pragma: no cover - exercised only on Windows
16
+ fcntl = None # type: ignore[assignment]
17
+
18
+ try: # Windows byte-range locking.
19
+ import msvcrt # type: ignore
20
+ except ImportError: # pragma: no cover - exercised only on POSIX
21
+ msvcrt = None # type: ignore[assignment]
22
+
23
+
24
+ _LOCK_GUARD = threading.Lock()
25
+ _PROCESS_LOCKS: dict[str, threading.Lock] = {}
26
+ _WINDOWS_LOCK_RETRY_SECONDS = 0.05
27
+
28
+
29
+ def _process_lock(path: Path) -> threading.Lock:
30
+ key = os.path.normcase(str(path.resolve()))
31
+ with _LOCK_GUARD:
32
+ return _PROCESS_LOCKS.setdefault(key, threading.Lock())
33
+
34
+
35
+ def _lock_windows_byte(lock_file: object, *, exclusive: bool) -> None:
36
+ """Acquire byte zero until it is available instead of timing out after 10s."""
37
+ if msvcrt is None: # pragma: no cover - guarded by the caller
38
+ return
39
+ handle = lock_file # Keep the file-like protocol local for type checkers.
40
+ handle.seek(0, os.SEEK_END) # type: ignore[attr-defined]
41
+ if handle.tell() == 0: # type: ignore[attr-defined]
42
+ handle.write(b"\0") # type: ignore[attr-defined]
43
+ handle.flush() # type: ignore[attr-defined]
44
+ mode = msvcrt.LK_NBLCK if exclusive else msvcrt.LK_NBRLCK
45
+ while True:
46
+ handle.seek(0) # type: ignore[attr-defined]
47
+ try:
48
+ msvcrt.locking(handle.fileno(), mode, 1) # type: ignore[attr-defined]
49
+ return
50
+ except OSError as exc:
51
+ if exc.errno not in {errno.EACCES, errno.EAGAIN, errno.EDEADLK}:
52
+ raise
53
+ time.sleep(_WINDOWS_LOCK_RETRY_SECONDS)
54
+
55
+
56
+ @contextmanager
57
+ def file_lock(path: Path, *, exclusive: bool = True) -> Iterator[None]:
58
+ """Lock *path* across threads and processes until the context exits.
59
+
60
+ All callers must use the same sidecar path. POSIX uses ``flock`` while
61
+ Windows locks byte zero with ``msvcrt``. The in-process lock also prevents
62
+ same-process threads from racing on platforms whose OS lock semantics are
63
+ process- or handle-oriented.
64
+ """
65
+ thread_lock = _process_lock(path)
66
+ with thread_lock:
67
+ path.parent.mkdir(parents=True, exist_ok=True)
68
+ with open(path, "a+b") as lock_file:
69
+ if fcntl is not None:
70
+ mode = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
71
+ fcntl.flock(lock_file.fileno(), mode)
72
+ elif msvcrt is not None:
73
+ _lock_windows_byte(lock_file, exclusive=exclusive)
74
+ try:
75
+ yield
76
+ finally:
77
+ if fcntl is not None:
78
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
79
+ elif msvcrt is not None:
80
+ lock_file.seek(0)
81
+ msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
@@ -0,0 +1,22 @@
1
+ """Small filesystem operations shared by atomic writers."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+ import time
6
+
7
+
8
+ def read_text_file(path: str) -> str:
9
+ """Worker entry point for LSP text synchronization."""
10
+ return Path(path).read_text(encoding="utf-8")
11
+
12
+
13
+ def replace_with_retry(source: Path, target: Path) -> None:
14
+ """Keep atomic replacement, tolerating brief Windows sharing violations."""
15
+ for attempt in range(5):
16
+ try:
17
+ os.replace(source, target)
18
+ return
19
+ except OSError as exc:
20
+ if os.name != "nt" or getattr(exc, "winerror", None) not in {5, 32, 33} or attempt == 4:
21
+ raise
22
+ time.sleep(0.05 * 2**attempt)
crabcode_core/goal.py ADDED
@@ -0,0 +1,149 @@
1
+ """Durable session goals for long-running tasks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Literal
8
+ from xml.sax.saxutils import escape
9
+
10
+
11
+ GoalStatus = Literal["active", "paused", "complete", "blocked"]
12
+ GOAL_STATUSES = frozenset({"active", "paused", "complete", "blocked"})
13
+
14
+
15
+ def _now_iso() -> str:
16
+ return datetime.now(timezone.utc).isoformat()
17
+
18
+
19
+ @dataclass
20
+ class Goal:
21
+ """One session-scoped objective and its lifecycle state."""
22
+
23
+ objective: str
24
+ status: GoalStatus = "active"
25
+ token_budget: int | None = None
26
+ tokens_used: int = 0
27
+ created_at: str = ""
28
+ updated_at: str = ""
29
+ completed_at: str | None = None
30
+
31
+ def __post_init__(self) -> None:
32
+ self.objective = self.objective.strip()
33
+ if not self.objective:
34
+ raise ValueError("Goal objective cannot be empty")
35
+ if self.status not in GOAL_STATUSES:
36
+ raise ValueError(f"Invalid goal status: {self.status}")
37
+
38
+ if self.token_budget is not None:
39
+ self.token_budget = int(self.token_budget)
40
+ if self.token_budget <= 0:
41
+ raise ValueError("Goal token budget must be positive")
42
+ self.tokens_used = int(self.tokens_used)
43
+ if self.tokens_used < 0:
44
+ raise ValueError("Goal token usage cannot be negative")
45
+
46
+ timestamp = _now_iso()
47
+ if not self.created_at:
48
+ self.created_at = timestamp
49
+ if not self.updated_at:
50
+ self.updated_at = self.created_at
51
+ if self.status == "complete" and not self.completed_at:
52
+ self.completed_at = self.updated_at
53
+
54
+ @property
55
+ def is_terminal(self) -> bool:
56
+ return self.status in {"complete", "blocked"}
57
+
58
+ @property
59
+ def remaining_tokens(self) -> int | None:
60
+ if self.token_budget is None:
61
+ return None
62
+ return max(0, self.token_budget - self.tokens_used)
63
+
64
+ def to_dict(self) -> dict[str, Any]:
65
+ return {
66
+ "objective": self.objective,
67
+ "status": self.status,
68
+ "token_budget": self.token_budget,
69
+ "tokens_used": self.tokens_used,
70
+ "created_at": self.created_at,
71
+ "updated_at": self.updated_at,
72
+ "completed_at": self.completed_at,
73
+ }
74
+
75
+ @classmethod
76
+ def from_dict(cls, data: dict[str, Any]) -> Goal:
77
+ """Restore a goal from session metadata."""
78
+ objective = data.get("objective")
79
+ if not isinstance(objective, str):
80
+ raise ValueError("Persisted goal has no objective")
81
+ status = data.get("status", "active")
82
+ if status not in GOAL_STATUSES:
83
+ raise ValueError(f"Invalid persisted goal status: {status}")
84
+ return cls(
85
+ objective=objective,
86
+ status=status,
87
+ token_budget=data.get("token_budget"),
88
+ tokens_used=data.get("tokens_used", 0),
89
+ created_at=str(data.get("created_at") or ""),
90
+ updated_at=str(data.get("updated_at") or ""),
91
+ completed_at=(
92
+ str(data["completed_at"]) if data.get("completed_at") else None
93
+ ),
94
+ )
95
+
96
+ def with_status(self, status: GoalStatus) -> Goal:
97
+ """Return a copy with an updated lifecycle status."""
98
+ if status not in GOAL_STATUSES:
99
+ raise ValueError(f"Invalid goal status: {status}")
100
+ timestamp = _now_iso()
101
+ return Goal(
102
+ objective=self.objective,
103
+ status=status,
104
+ token_budget=self.token_budget,
105
+ tokens_used=self.tokens_used,
106
+ created_at=self.created_at,
107
+ updated_at=timestamp,
108
+ completed_at=timestamp if status == "complete" else None,
109
+ )
110
+
111
+ def with_added_usage(self, tokens: int) -> Goal:
112
+ """Return a copy with additional model token usage."""
113
+ tokens = int(tokens)
114
+ if tokens <= 0:
115
+ return self
116
+ return Goal(
117
+ objective=self.objective,
118
+ status=self.status,
119
+ token_budget=self.token_budget,
120
+ tokens_used=self.tokens_used + tokens,
121
+ created_at=self.created_at,
122
+ updated_at=_now_iso(),
123
+ completed_at=self.completed_at,
124
+ )
125
+
126
+ def prompt_context(self) -> str:
127
+ """Format the active goal as stable model context."""
128
+ lines = [
129
+ "<active-goal>",
130
+ "The user has set a persistent goal for this session.",
131
+ f"Objective: {escape(self.objective)}",
132
+ ]
133
+ if self.token_budget is not None:
134
+ lines.extend(
135
+ [
136
+ f"Token budget: {self.token_budget}",
137
+ f"Tokens used: {self.tokens_used}",
138
+ f"Tokens remaining: {self.remaining_tokens}",
139
+ ]
140
+ )
141
+ lines.extend(
142
+ [
143
+ "Keep this objective as the primary success criterion.",
144
+ "Do not claim completion until the requested outcome is verified.",
145
+ "Use update_goal only when the goal is genuinely complete or blocked.",
146
+ "</active-goal>",
147
+ ]
148
+ )
149
+ return "\n".join(lines)
@@ -0,0 +1,5 @@
1
+ """Hook runtime for settings-defined shell hooks."""
2
+
3
+ from crabcode_core.hooks.manager import HookManager, HookRunResult
4
+
5
+ __all__ = ["HookManager", "HookRunResult"]
@@ -0,0 +1,290 @@
1
+ """Hooks manager for running settings-defined shell hooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ from dataclasses import dataclass
9
+ from fnmatch import fnmatch
10
+ from typing import Any
11
+
12
+ from crabcode_core.subprocess_utils import (
13
+ decode_subprocess_output,
14
+ managed_process_command,
15
+ shell_command,
16
+ subprocess_group_options,
17
+ terminate_process_tree,
18
+ )
19
+
20
+
21
+ @dataclass
22
+ class HookRunResult:
23
+ blocked: bool = False
24
+ feedback: list[str] | None = None
25
+ details: list[str] | None = None
26
+
27
+
28
+ class HookManager:
29
+ """Execute hooks from settings for supported lifecycle events."""
30
+
31
+ _ALIASES: dict[str, tuple[str, ...]] = {
32
+ "user_prompt_submit": (
33
+ "user_prompt_submit",
34
+ "userPromptSubmit",
35
+ "UserPromptSubmit",
36
+ "user-prompt-submit",
37
+ "user-prompt-submit-hook",
38
+ ),
39
+ "pre_tool_call": (
40
+ "pre_tool_call",
41
+ "preToolCall",
42
+ "PreToolCall",
43
+ "pre-tool-call",
44
+ "pre_tool_use",
45
+ "preToolUse",
46
+ "PreToolUse",
47
+ "pre-tool-use",
48
+ ),
49
+ "post_tool_call": (
50
+ "post_tool_call",
51
+ "postToolCall",
52
+ "PostToolCall",
53
+ "post-tool-call",
54
+ "post_tool_use",
55
+ "postToolUse",
56
+ "PostToolUse",
57
+ "post-tool-use",
58
+ ),
59
+ "pre_compact": (
60
+ "pre_compact",
61
+ "preCompact",
62
+ "PreCompact",
63
+ "pre-compact",
64
+ ),
65
+ "post_compact": (
66
+ "post_compact",
67
+ "postCompact",
68
+ "PostCompact",
69
+ "post-compact",
70
+ ),
71
+ }
72
+
73
+ def __init__(self, hooks: dict[str, list[dict[str, Any]]] | None = None):
74
+ self._hooks = hooks or {}
75
+
76
+ def set_hooks(self, hooks: dict[str, list[dict[str, Any]]] | None) -> None:
77
+ self._hooks = hooks or {}
78
+
79
+ async def run(
80
+ self,
81
+ event: str,
82
+ payload: dict[str, Any],
83
+ *,
84
+ cwd: str,
85
+ env: dict[str, str] | None = None,
86
+ ) -> HookRunResult:
87
+ canonical = self._canonical_event_name(event)
88
+ raw_entries = self._gather_hooks(canonical)
89
+ hooks = self._expand_entries(raw_entries)
90
+ if not hooks:
91
+ return HookRunResult(blocked=False, feedback=[], details=[])
92
+
93
+ merged_env = {**os.environ, **(env or {})}
94
+ blocked = False
95
+ feedback: list[str] = []
96
+ details: list[str] = []
97
+
98
+ for cfg in hooks:
99
+ command = self._extract_command(cfg)
100
+ if not command:
101
+ continue
102
+ if not self._matches(cfg, payload):
103
+ continue
104
+ timeout = self._extract_timeout(cfg)
105
+ continue_on_error = self._extract_continue_on_error(cfg)
106
+ result = await self._run_command(
107
+ command,
108
+ payload=payload,
109
+ event=canonical,
110
+ cwd=cwd,
111
+ env=merged_env,
112
+ timeout=timeout,
113
+ )
114
+ details.append(result["detail"])
115
+ if result["feedback"]:
116
+ feedback.append(result["feedback"])
117
+ if result["blocked"] and not continue_on_error:
118
+ blocked = True
119
+ # Mirror Claude behavior: stop execution on first blocking hook.
120
+ break
121
+
122
+ return HookRunResult(
123
+ blocked=blocked,
124
+ feedback=feedback,
125
+ details=details,
126
+ )
127
+
128
+ def _canonical_event_name(self, event: str) -> str:
129
+ for canonical, aliases in self._ALIASES.items():
130
+ if event == canonical or event in aliases:
131
+ return canonical
132
+ return event
133
+
134
+ def _gather_hooks(self, canonical_event: str) -> list[dict[str, Any]]:
135
+ keys = set(self._ALIASES.get(canonical_event, (canonical_event,)))
136
+ hooks: list[dict[str, Any]] = []
137
+ for key in keys:
138
+ entries = self._hooks.get(key, [])
139
+ if isinstance(entries, list):
140
+ hooks.extend(item for item in entries if isinstance(item, dict))
141
+ return hooks
142
+
143
+ def _extract_command(self, cfg: dict[str, Any]) -> str:
144
+ cmd = cfg.get("command") or cfg.get("cmd") or cfg.get("run")
145
+ return cmd if isinstance(cmd, str) else ""
146
+
147
+ def _extract_timeout(self, cfg: dict[str, Any]) -> int:
148
+ raw = cfg.get("timeout")
149
+ if isinstance(raw, int) and raw > 0:
150
+ return raw
151
+ return 60
152
+
153
+ def _extract_continue_on_error(self, cfg: dict[str, Any]) -> bool:
154
+ value = cfg.get("continue_on_error")
155
+ if value is None:
156
+ value = cfg.get("continueOnError")
157
+ return bool(value)
158
+
159
+ def _expand_entries(self, entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
160
+ expanded: list[dict[str, Any]] = []
161
+ for entry in entries:
162
+ nested = entry.get("hooks")
163
+ if isinstance(nested, list):
164
+ for item in nested:
165
+ if not isinstance(item, dict):
166
+ continue
167
+ merged = dict(item)
168
+ if "matcher" not in merged and "matcher" in entry:
169
+ merged["matcher"] = entry["matcher"]
170
+ if "timeout" not in merged and "timeout" in entry:
171
+ merged["timeout"] = entry["timeout"]
172
+ if (
173
+ "continue_on_error" not in merged
174
+ and "continue_on_error" in entry
175
+ ):
176
+ merged["continue_on_error"] = entry["continue_on_error"]
177
+ if (
178
+ "continueOnError" not in merged
179
+ and "continueOnError" in entry
180
+ ):
181
+ merged["continueOnError"] = entry["continueOnError"]
182
+ hook_type = str(merged.get("type", "command")).strip().lower()
183
+ if hook_type in {"", "command"}:
184
+ expanded.append(merged)
185
+ else:
186
+ expanded.append(entry)
187
+ return expanded
188
+
189
+ def _matches(self, cfg: dict[str, Any], payload: dict[str, Any]) -> bool:
190
+ matcher = cfg.get("matcher")
191
+ if not matcher:
192
+ return True
193
+
194
+ tool_name = str(payload.get("tool_name") or payload.get("trigger") or "")
195
+ if isinstance(matcher, str):
196
+ if not tool_name:
197
+ return False
198
+ return tool_name == matcher or fnmatch(tool_name, matcher)
199
+ if isinstance(matcher, list):
200
+ if not tool_name:
201
+ return False
202
+ return any(
203
+ isinstance(item, str) and (tool_name == item or fnmatch(tool_name, item))
204
+ for item in matcher
205
+ )
206
+ if isinstance(matcher, dict):
207
+ for key in ("tool_name", "tool", "name", "trigger"):
208
+ pattern = matcher.get(key)
209
+ if pattern is None:
210
+ continue
211
+ if isinstance(pattern, str):
212
+ if tool_name != pattern and not fnmatch(tool_name, pattern):
213
+ return False
214
+ elif isinstance(pattern, list):
215
+ found = False
216
+ for item in pattern:
217
+ if not isinstance(item, str):
218
+ continue
219
+ if tool_name == item or fnmatch(tool_name, item):
220
+ found = True
221
+ break
222
+ if not found:
223
+ return False
224
+ return True
225
+ return True
226
+
227
+ async def _run_command(
228
+ self,
229
+ command: str,
230
+ *,
231
+ payload: dict[str, Any],
232
+ event: str,
233
+ cwd: str,
234
+ env: dict[str, str],
235
+ timeout: int,
236
+ ) -> dict[str, Any]:
237
+ encoded = json.dumps(payload, ensure_ascii=False)
238
+ proc_env = {
239
+ **env,
240
+ "CRABCODE_HOOK_EVENT": event,
241
+ "CRABCODE_HOOK_PAYLOAD": encoded,
242
+ "CRABCODE_HOOK_TOOL_NAME": str(payload.get("tool_name", "")),
243
+ "CRABCODE_HOOK_TOOL_USE_ID": str(payload.get("tool_use_id", "")),
244
+ "CRABCODE_HOOK_AGENT_ID": str(payload.get("agent_id", "")),
245
+ }
246
+
247
+ try:
248
+ proc = await asyncio.create_subprocess_exec(
249
+ *managed_process_command(shell_command(command)),
250
+ stdout=asyncio.subprocess.PIPE,
251
+ stderr=asyncio.subprocess.PIPE,
252
+ cwd=cwd,
253
+ env=proc_env,
254
+ **subprocess_group_options(),
255
+ )
256
+ try:
257
+ stdout_b, stderr_b = await asyncio.wait_for(proc.communicate(), timeout=timeout)
258
+ timed_out = False
259
+ except asyncio.TimeoutError:
260
+ await terminate_process_tree(proc)
261
+ stdout_b = b""
262
+ stderr_b = f"Hook timed out after {timeout}s".encode()
263
+ timed_out = True
264
+ except asyncio.CancelledError:
265
+ await terminate_process_tree(proc)
266
+ raise
267
+ except Exception as exc:
268
+ return {
269
+ "blocked": True,
270
+ "feedback": f"Hook failed to start: {exc}",
271
+ "detail": f"hook-start-error: {exc}",
272
+ }
273
+
274
+ stdout = decode_subprocess_output(stdout_b).strip()
275
+ stderr = decode_subprocess_output(stderr_b).strip()
276
+ exit_code = proc.returncode if proc.returncode is not None else 1
277
+
278
+ if timed_out:
279
+ return {
280
+ "blocked": True,
281
+ "feedback": stderr or "Hook timed out.",
282
+ "detail": f"hook timeout; command={command}",
283
+ }
284
+
285
+ feedback = stdout or stderr
286
+ blocked = exit_code != 0
287
+ detail = f"hook exit={exit_code}; command={command}"
288
+ if stderr and stderr != feedback:
289
+ detail += f"; stderr={stderr}"
290
+ return {"blocked": blocked, "feedback": feedback, "detail": detail}
@@ -0,0 +1,111 @@
1
+ """Decode local raster images into image formats accepted by vision models.
2
+
3
+ Call from a filesystem worker: image decoding can be slow or block on storage.
4
+ PSD reads use the saved merged image, not an individual layer.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from io import BytesIO
11
+ from pathlib import Path
12
+
13
+ from PIL import Image, ImageOps
14
+
15
+ from crabcode_core.types import tool as tool_types
16
+
17
+
18
+ _IMAGE_EXTENSIONS = {
19
+ ".png", ".jpg", ".jpeg", ".jpe", ".webp", ".gif", ".bmp", ".dib",
20
+ ".tif", ".tiff", ".ico", ".psd", ".psb", ".ppm", ".pgm", ".pbm",
21
+ ".tga", ".jp2", ".j2k", ".avif", ".heic", ".heif",
22
+ }
23
+ # Restrict decoding to raster plugins; do not invoke external renderers (EPS/PDF).
24
+ _FORMATS = ("PNG", "JPEG", "WEBP", "GIF", "BMP", "TIFF", "ICO", "PSD",
25
+ "PPM", "TGA", "JPEG2000", "AVIF")
26
+ _NATIVE_MIME = {"PNG": "image/png", "JPEG": "image/jpeg", "WEBP": "image/webp"}
27
+ MAX_PREVIEW_DIMENSION = 4096
28
+ MAX_DECODE_PIXELS = 40_000_000
29
+
30
+
31
+ def is_image_file(path: Path) -> bool:
32
+ """Recognize common raster files, including files without an image suffix.
33
+
34
+ SVG and other text-based graphics remain readable as source with Read.
35
+ """
36
+ if path.suffix.lower() in _IMAGE_EXTENSIONS:
37
+ return True
38
+ with path.open("rb") as stream:
39
+ header = stream.read(16)
40
+ return (
41
+ header.startswith((b"\x89PNG\r\n\x1a\n", b"\xff\xd8\xff", b"GIF87a", b"GIF89a",
42
+ b"II*\0", b"MM\0*", b"II+\0", b"MM\0+", b"8BPS",
43
+ b"\0\0\1\0"))
44
+ or (header.startswith(b"RIFF") and header[8:12] == b"WEBP")
45
+ or (header.startswith(b"BM") and header[6:10] == b"\0\0\0\0")
46
+ or (header[4:8] == b"ftyp" and header[8:12] in {b"avif", b"avis", b"heic", b"mif1"})
47
+ )
48
+
49
+
50
+ @dataclass
51
+ class ImagePreview:
52
+ data: bytes
53
+ media_type: str
54
+ width: int
55
+ height: int
56
+ source_format: str
57
+ original_size: tuple[int, int]
58
+ first_frame: bool
59
+
60
+ def summary(self) -> str:
61
+ details = f"{self.width}x{self.height}, {self.media_type}, {len(self.data)} bytes"
62
+ if self.source_format == "PSD":
63
+ details += "; saved PSD composite preview (layers are not included)"
64
+ elif self.first_frame:
65
+ details += "; first frame/page only"
66
+ if self.original_size != (self.width, self.height):
67
+ details += f"; original {self.original_size[0]}x{self.original_size[1]}"
68
+ return details
69
+
70
+
71
+ def load_image_preview(path: Path) -> ImagePreview:
72
+ """Validate pixels and normalize non-native, animated, or oversized images."""
73
+ try:
74
+ with Image.open(path, formats=_FORMATS) as source:
75
+ source_format = source.format or "unknown"
76
+ original_size = source.size
77
+ if source.width * source.height > MAX_DECODE_PIXELS:
78
+ raise ValueError(f"image exceeds the {MAX_DECODE_PIXELS:,}-pixel decode limit")
79
+ # PSD exposes layers as frames; frame 0 is its saved merged image.
80
+ first_frame = source_format != "PSD" and getattr(source, "is_animated", False)
81
+ orientation = source.getexif().get(274, 1)
82
+ source.load()
83
+ native_mime = _NATIVE_MIME.get(source_format)
84
+ if (native_mime and not first_frame and orientation == 1
85
+ and source.mode in {"RGB", "RGBA", "L", "LA", "P"}
86
+ and max(source.size) <= MAX_PREVIEW_DIMENSION
87
+ and path.stat().st_size <= tool_types.MAX_INLINE_IMAGE_BYTES):
88
+ return ImagePreview(path.read_bytes(), native_mime, *source.size,
89
+ source_format, original_size, False)
90
+
91
+ preview = ImageOps.exif_transpose(source)
92
+ preview = preview.convert("RGBA" if "A" in preview.getbands()
93
+ or "transparency" in preview.info else "RGB")
94
+ preview.thumbnail((MAX_PREVIEW_DIMENSION, MAX_PREVIEW_DIMENSION), Image.Resampling.LANCZOS)
95
+ # Do not copy arbitrary source metadata into a model attachment.
96
+ preview.info.clear()
97
+ while True:
98
+ output = BytesIO()
99
+ preview.save(output, format="PNG")
100
+ data = output.getvalue()
101
+ if len(data) <= tool_types.MAX_INLINE_IMAGE_BYTES:
102
+ return ImagePreview(data, "image/png", *preview.size, source_format,
103
+ original_size, bool(first_frame))
104
+ if max(preview.size) <= 1:
105
+ raise ValueError("image preview exceeds the attachment byte limit")
106
+ preview = preview.resize((max(1, preview.width * 3 // 4),
107
+ max(1, preview.height * 3 // 4)), Image.Resampling.LANCZOS)
108
+ except (OSError, ValueError, SyntaxError, Image.DecompressionBombError) as exc:
109
+ raise ValueError(
110
+ f"Cannot decode image {path}: {exc}. Export a flattened PNG or JPEG and read that file."
111
+ ) from exc