crabcode 0.1.6__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,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,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
|