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,359 @@
|
|
|
1
|
+
"""Lightweight snapshot tracker — bridges tools and SnapshotManager.
|
|
2
|
+
|
|
3
|
+
Provides module-level convenience functions so that tools (apply_patch, Edit, Write, Bash)
|
|
4
|
+
can record file changes without managing a SnapshotManager instance themselves.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import stat
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from crabcode_core.logging_utils import get_logger
|
|
18
|
+
from crabcode_core.snapshot.snapshot import SnapshotManager
|
|
19
|
+
|
|
20
|
+
logger = get_logger(__name__)
|
|
21
|
+
|
|
22
|
+
_CRABCODE_DIR = ".crabcode"
|
|
23
|
+
_SNAPSHOTS_DIR = "snapshots"
|
|
24
|
+
|
|
25
|
+
# A Bash call can be issued from a workspace container such as the user's
|
|
26
|
+
# home directory. Taking a file snapshot there would recursively copy the
|
|
27
|
+
# whole machine-owned workspace and can exhaust disk space before the command
|
|
28
|
+
# even starts. These roots are navigation/workspace containers, not projects.
|
|
29
|
+
def _is_broad_workspace(cwd: str) -> bool:
|
|
30
|
+
"""Return whether *cwd* is too broad for an automatic file snapshot."""
|
|
31
|
+
try:
|
|
32
|
+
path = Path(cwd).expanduser().resolve()
|
|
33
|
+
home = Path.home().resolve()
|
|
34
|
+
if path == home or path == Path(path.anchor):
|
|
35
|
+
return True
|
|
36
|
+
return False
|
|
37
|
+
except (OSError, RuntimeError, ValueError):
|
|
38
|
+
# Snapshotting is best effort. An unresolvable path should not block
|
|
39
|
+
# the command that requested it.
|
|
40
|
+
return True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _workspace_exceeds_limit(cwd: str, max_size_mb: int) -> bool:
|
|
44
|
+
"""Return whether a workspace exceeds the snapshot size ceiling.
|
|
45
|
+
|
|
46
|
+
The walk is bounded: it stops at the first byte over the limit and skips
|
|
47
|
+
generated/dependency trees that SnapshotManager would not copy anyway.
|
|
48
|
+
Permission and stat failures fail closed so a damaged workspace cannot
|
|
49
|
+
trigger an unbounded snapshot.
|
|
50
|
+
"""
|
|
51
|
+
if max_size_mb <= 0:
|
|
52
|
+
return True
|
|
53
|
+
limit = max_size_mb * 1024 * 1024
|
|
54
|
+
try:
|
|
55
|
+
root = Path(cwd).expanduser().resolve()
|
|
56
|
+
except (OSError, RuntimeError, ValueError):
|
|
57
|
+
return True
|
|
58
|
+
if not root.is_dir():
|
|
59
|
+
return True
|
|
60
|
+
skipped = {".git", ".crabcode", "__pycache__", "node_modules", ".venv", "venv"}
|
|
61
|
+
total = 0
|
|
62
|
+
walk_failed = False
|
|
63
|
+
|
|
64
|
+
def _on_walk_error(_error: OSError) -> None:
|
|
65
|
+
nonlocal walk_failed
|
|
66
|
+
walk_failed = True
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
for current, dirs, files in os.walk(
|
|
70
|
+
root,
|
|
71
|
+
topdown=True,
|
|
72
|
+
onerror=_on_walk_error,
|
|
73
|
+
followlinks=False,
|
|
74
|
+
):
|
|
75
|
+
if walk_failed:
|
|
76
|
+
return True
|
|
77
|
+
dirs[:] = [name for name in dirs if name not in skipped]
|
|
78
|
+
for name in files:
|
|
79
|
+
path = Path(current) / name
|
|
80
|
+
try:
|
|
81
|
+
if path.is_symlink():
|
|
82
|
+
continue
|
|
83
|
+
file_stat = path.stat()
|
|
84
|
+
except OSError:
|
|
85
|
+
return True
|
|
86
|
+
if not stat.S_ISREG(file_stat.st_mode):
|
|
87
|
+
continue
|
|
88
|
+
total += file_stat.st_size
|
|
89
|
+
if total > limit:
|
|
90
|
+
return True
|
|
91
|
+
if walk_failed:
|
|
92
|
+
return True
|
|
93
|
+
except OSError:
|
|
94
|
+
return True
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _snapshot_is_allowed(cwd: str, enabled: bool, max_size_mb: int) -> bool:
|
|
99
|
+
if not enabled or _is_broad_workspace(cwd):
|
|
100
|
+
return False
|
|
101
|
+
return not _workspace_exceeds_limit(cwd, max_size_mb)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class SnapshotInfo:
|
|
106
|
+
"""A recorded snapshot entry for a session."""
|
|
107
|
+
snapshot_id: str
|
|
108
|
+
session_id: str
|
|
109
|
+
timestamp: float
|
|
110
|
+
tool_name: str
|
|
111
|
+
files: list[str] = field(default_factory=list)
|
|
112
|
+
action: str = "" # "modify" | "create" | "delete" | "patch" | "bash"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _session_log_path(cwd: str, session_id: str) -> Path:
|
|
116
|
+
return Path(cwd) / _CRABCODE_DIR / _SNAPSHOTS_DIR / f"{session_id}.jsonl"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _ensure_dir(cwd: str) -> Path:
|
|
120
|
+
d = Path(cwd) / _CRABCODE_DIR / _SNAPSHOTS_DIR
|
|
121
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
return d
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _append_log(cwd: str, session_id: str, entry: dict[str, Any]) -> None:
|
|
126
|
+
"""Append a snapshot entry to the session's JSONL log."""
|
|
127
|
+
_ensure_dir(cwd)
|
|
128
|
+
log_path = _session_log_path(cwd, session_id)
|
|
129
|
+
line = json.dumps(entry, ensure_ascii=False) + "\n"
|
|
130
|
+
try:
|
|
131
|
+
with open(log_path, "a", encoding="utf-8") as f:
|
|
132
|
+
f.write(line)
|
|
133
|
+
except Exception:
|
|
134
|
+
logger.debug("Failed to append snapshot log: %s", log_path, exc_info=True)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Public API — called from tools
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def track_snapshot_for_file(
|
|
142
|
+
cwd: str,
|
|
143
|
+
session_id: str,
|
|
144
|
+
file_path: str,
|
|
145
|
+
old_content: str | None = None,
|
|
146
|
+
action: str = "modify",
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Record that a file is about to be changed.
|
|
149
|
+
|
|
150
|
+
If *old_content* is provided, it is saved to the snapshot store so
|
|
151
|
+
the file can be restored later even without git.
|
|
152
|
+
"""
|
|
153
|
+
# Create a quick snapshot ID from the file path + timestamp
|
|
154
|
+
snap_id = _make_file_snapshot_id(file_path, action)
|
|
155
|
+
|
|
156
|
+
# If old_content is given, persist it for non-git restore
|
|
157
|
+
if old_content is not None:
|
|
158
|
+
_save_file_backup(cwd, session_id, snap_id, file_path, old_content)
|
|
159
|
+
|
|
160
|
+
_append_log(cwd, session_id, {
|
|
161
|
+
"snapshot_id": snap_id,
|
|
162
|
+
"session_id": session_id,
|
|
163
|
+
"timestamp": time.time(),
|
|
164
|
+
"tool_name": "file_edit",
|
|
165
|
+
"files": [file_path],
|
|
166
|
+
"action": action,
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def pre_bash_snapshot(
|
|
171
|
+
cwd: str,
|
|
172
|
+
session_id: str,
|
|
173
|
+
*,
|
|
174
|
+
enabled: bool = True,
|
|
175
|
+
max_size_mb: int = 1024,
|
|
176
|
+
tool_name: str = "Bash",
|
|
177
|
+
action: str = "bash",
|
|
178
|
+
) -> str | None:
|
|
179
|
+
"""Create a full working-directory snapshot before a bash command.
|
|
180
|
+
|
|
181
|
+
Returns the snapshot ID, or ``None`` on failure.
|
|
182
|
+
"""
|
|
183
|
+
if not _snapshot_is_allowed(cwd, enabled, max_size_mb):
|
|
184
|
+
logger.info("Skipping Bash snapshot for workspace or size policy: %s", cwd)
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
mgr = SnapshotManager(cwd)
|
|
189
|
+
mgr.init()
|
|
190
|
+
snap_id = mgr.track()
|
|
191
|
+
if snap_id:
|
|
192
|
+
_append_log(cwd, session_id, {
|
|
193
|
+
"snapshot_id": snap_id,
|
|
194
|
+
"session_id": session_id,
|
|
195
|
+
"timestamp": time.time(),
|
|
196
|
+
"tool_name": tool_name,
|
|
197
|
+
"files": [],
|
|
198
|
+
"action": action,
|
|
199
|
+
})
|
|
200
|
+
return snap_id
|
|
201
|
+
except Exception:
|
|
202
|
+
logger.debug("pre_bash_snapshot failed", exc_info=True)
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def get_session_snapshots(cwd: str, session_id: str) -> list[SnapshotInfo]:
|
|
207
|
+
"""Return all snapshot entries recorded for a session."""
|
|
208
|
+
log_path = _session_log_path(cwd, session_id)
|
|
209
|
+
if not log_path.exists():
|
|
210
|
+
return []
|
|
211
|
+
entries: list[SnapshotInfo] = []
|
|
212
|
+
try:
|
|
213
|
+
with open(log_path, encoding="utf-8") as f:
|
|
214
|
+
for line in f:
|
|
215
|
+
line = line.strip()
|
|
216
|
+
if not line:
|
|
217
|
+
continue
|
|
218
|
+
try:
|
|
219
|
+
d = json.loads(line)
|
|
220
|
+
except json.JSONDecodeError:
|
|
221
|
+
continue
|
|
222
|
+
entries.append(SnapshotInfo(
|
|
223
|
+
snapshot_id=d.get("snapshot_id", ""),
|
|
224
|
+
session_id=d.get("session_id", ""),
|
|
225
|
+
timestamp=d.get("timestamp", 0),
|
|
226
|
+
tool_name=d.get("tool_name", ""),
|
|
227
|
+
files=d.get("files", []),
|
|
228
|
+
action=d.get("action", ""),
|
|
229
|
+
))
|
|
230
|
+
except Exception:
|
|
231
|
+
logger.debug("Failed to read session snapshots: %s", log_path, exc_info=True)
|
|
232
|
+
return entries
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def restore_snapshot(cwd: str, snapshot_id: str) -> list[str]:
|
|
236
|
+
"""Restore the working directory to a given snapshot.
|
|
237
|
+
|
|
238
|
+
Returns the list of files restored.
|
|
239
|
+
"""
|
|
240
|
+
# First try git-based restore
|
|
241
|
+
try:
|
|
242
|
+
mgr = SnapshotManager(cwd)
|
|
243
|
+
mgr.init()
|
|
244
|
+
files = mgr.restore(snapshot_id)
|
|
245
|
+
if files:
|
|
246
|
+
return files
|
|
247
|
+
except Exception:
|
|
248
|
+
logger.debug("Git restore failed for %s, trying file backup", snapshot_id, exc_info=True)
|
|
249
|
+
|
|
250
|
+
# Fallback: restore from file backups stored by track_snapshot_for_file
|
|
251
|
+
return _restore_file_backups(cwd, snapshot_id)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def create_full_snapshot(
|
|
255
|
+
cwd: str,
|
|
256
|
+
session_id: str,
|
|
257
|
+
label: str = "",
|
|
258
|
+
*,
|
|
259
|
+
enabled: bool = True,
|
|
260
|
+
max_size_mb: int = 1024,
|
|
261
|
+
) -> str | None:
|
|
262
|
+
"""Create a full working-directory snapshot and return its ID.
|
|
263
|
+
|
|
264
|
+
This is used by /checkpoint to create a file-system-level snapshot
|
|
265
|
+
alongside the conversation checkpoint.
|
|
266
|
+
"""
|
|
267
|
+
if not _snapshot_is_allowed(cwd, enabled, max_size_mb):
|
|
268
|
+
logger.info("Skipping full snapshot for workspace or size policy: %s", cwd)
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
mgr = SnapshotManager(cwd)
|
|
273
|
+
mgr.init()
|
|
274
|
+
snap_id = mgr.track()
|
|
275
|
+
if snap_id:
|
|
276
|
+
_append_log(cwd, session_id, {
|
|
277
|
+
"snapshot_id": snap_id,
|
|
278
|
+
"session_id": session_id,
|
|
279
|
+
"timestamp": time.time(),
|
|
280
|
+
"tool_name": "checkpoint",
|
|
281
|
+
"files": [],
|
|
282
|
+
"action": "checkpoint",
|
|
283
|
+
"label": label,
|
|
284
|
+
})
|
|
285
|
+
return snap_id
|
|
286
|
+
except Exception:
|
|
287
|
+
logger.warning("Failed to create full snapshot for checkpoint", exc_info=True)
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
# File-backup helpers (for non-git or per-file granularity)
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
def _file_backup_dir(cwd: str, session_id: str) -> Path:
|
|
296
|
+
return Path(cwd) / _CRABCODE_DIR / _SNAPSHOTS_DIR / "files" / session_id
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _make_file_snapshot_id(file_path: str, action: str) -> str:
|
|
300
|
+
import hashlib
|
|
301
|
+
h = hashlib.sha256(f"{file_path}:{action}:{time.time()}".encode()).hexdigest()
|
|
302
|
+
return h[:16]
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _save_file_backup(
|
|
306
|
+
cwd: str,
|
|
307
|
+
session_id: str,
|
|
308
|
+
snap_id: str,
|
|
309
|
+
file_path: str,
|
|
310
|
+
content: str,
|
|
311
|
+
) -> None:
|
|
312
|
+
d = _file_backup_dir(cwd, session_id) / snap_id
|
|
313
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
314
|
+
# Store with the file's basename; original path is in the log
|
|
315
|
+
backup_file = d / "content"
|
|
316
|
+
try:
|
|
317
|
+
original = Path(file_path)
|
|
318
|
+
if not original.is_absolute():
|
|
319
|
+
original = Path(cwd) / original
|
|
320
|
+
# The UTF-8 tool view has already stripped the BOM. Back up the actual
|
|
321
|
+
# bytes so rollback preserves encoding and every newline exactly.
|
|
322
|
+
raw = original.read_bytes() if original.is_file() else content.encode("utf-8")
|
|
323
|
+
backup_file.write_bytes(raw)
|
|
324
|
+
(d / "path").write_text(file_path, encoding="utf-8")
|
|
325
|
+
except Exception:
|
|
326
|
+
logger.debug("Failed to save file backup: %s/%s", snap_id, file_path, exc_info=True)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _restore_file_backups(cwd: str, snapshot_id: str) -> list[str]:
|
|
330
|
+
"""Restore files from file backups for a given snapshot ID.
|
|
331
|
+
|
|
332
|
+
This is a best-effort fallback — it scans all sessions' backup dirs
|
|
333
|
+
for the snapshot_id.
|
|
334
|
+
"""
|
|
335
|
+
files_root = Path(cwd) / _CRABCODE_DIR / _SNAPSHOTS_DIR / "files"
|
|
336
|
+
if not files_root.exists():
|
|
337
|
+
return []
|
|
338
|
+
restored: list[str] = []
|
|
339
|
+
for session_dir in files_root.iterdir():
|
|
340
|
+
if not session_dir.is_dir():
|
|
341
|
+
continue
|
|
342
|
+
snap_dir = session_dir / snapshot_id
|
|
343
|
+
if not snap_dir.exists():
|
|
344
|
+
continue
|
|
345
|
+
content_file = snap_dir / "content"
|
|
346
|
+
path_file = snap_dir / "path"
|
|
347
|
+
if content_file.exists() and path_file.exists():
|
|
348
|
+
try:
|
|
349
|
+
file_path = path_file.read_text(encoding="utf-8").strip()
|
|
350
|
+
content = content_file.read_bytes()
|
|
351
|
+
target = Path(file_path)
|
|
352
|
+
if not target.is_absolute():
|
|
353
|
+
target = Path(cwd) / file_path
|
|
354
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
355
|
+
target.write_bytes(content)
|
|
356
|
+
restored.append(str(target))
|
|
357
|
+
except Exception:
|
|
358
|
+
logger.debug("Failed to restore backup %s", snap_dir, exc_info=True)
|
|
359
|
+
return restored
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Cross-platform subprocess helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import codecs
|
|
7
|
+
import locale
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import re
|
|
11
|
+
import shutil
|
|
12
|
+
import signal
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
_POWERSHELL_UTF8_SETUP = (
|
|
19
|
+
"$utf8 = [System.Text.UTF8Encoding]::new($false); "
|
|
20
|
+
"[Console]::InputEncoding = $utf8; "
|
|
21
|
+
"[Console]::OutputEncoding = $utf8; "
|
|
22
|
+
"$OutputEncoding = $utf8; "
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def resolve_executable_command(
|
|
27
|
+
command: list[str], *, env: dict[str, str] | None = None, cwd: str | None = None,
|
|
28
|
+
) -> list[str]:
|
|
29
|
+
"""Resolve using the launch environment, without interpreting batch arguments."""
|
|
30
|
+
if not command:
|
|
31
|
+
raise ValueError("command must not be empty")
|
|
32
|
+
if os.name != "nt":
|
|
33
|
+
return list(command)
|
|
34
|
+
environment = {key.upper(): value for key, value in (env if env is not None else os.environ).items()}
|
|
35
|
+
search_path = environment.get("PATH", "")
|
|
36
|
+
base = Path(cwd or os.getcwd()).resolve()
|
|
37
|
+
search_path = os.pathsep.join(str(base / part.strip('"')) for part in search_path.split(os.pathsep))
|
|
38
|
+
token = command[0]
|
|
39
|
+
if os.path.dirname(token):
|
|
40
|
+
token = str(base / token)
|
|
41
|
+
resolved = shutil.which(token, path=search_path)
|
|
42
|
+
# which() takes PATHEXT from the parent process, not its path argument.
|
|
43
|
+
if env is not None:
|
|
44
|
+
roots = [Path()] if os.path.dirname(token) else [Path(p) for p in search_path.split(os.pathsep)]
|
|
45
|
+
extensions = environment.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";")
|
|
46
|
+
names = [token] if Path(token).suffix.upper() in {e.upper() for e in extensions} else [token + ext for ext in extensions]
|
|
47
|
+
resolved = next((str(root / name) for root in roots for name in names if (root / name).is_file()), None)
|
|
48
|
+
if not resolved:
|
|
49
|
+
raise FileNotFoundError(f"Executable not found in the launch environment: {command[0]}")
|
|
50
|
+
result = [resolved, *command[1:]]
|
|
51
|
+
if Path(result[0]).suffix.lower() in {".cmd", ".bat"}:
|
|
52
|
+
# npm's cmd-shim template has a fixed Node invocation. Bypass cmd.exe
|
|
53
|
+
# for this template so filenames such as a&b.js remain literal argv.
|
|
54
|
+
shim = Path(result[0])
|
|
55
|
+
try:
|
|
56
|
+
body = shim.read_text(encoding="utf-8")
|
|
57
|
+
except (OSError, UnicodeError):
|
|
58
|
+
body = ""
|
|
59
|
+
match = re.fullmatch(
|
|
60
|
+
r'@ECHO off\s+GOTO start\s+:find_dp0\s+SET dp0=%~dp0\s+EXIT /b\s+'
|
|
61
|
+
r':start\s+SETLOCAL\s+CALL :find_dp0\s+'
|
|
62
|
+
r'IF EXIST "%dp0%\\node.exe" \(\s+SET "_prog=%dp0%\\node.exe"\s+'
|
|
63
|
+
r'\) ELSE \(\s+SET "_prog=node"\s+SET PATHEXT=%PATHEXT:;.JS;=;%\s+\)\s+'
|
|
64
|
+
r'endLocal & goto #_undefined_# 2>NUL \|\| title %COMSPEC% & "%_prog%"\s+'
|
|
65
|
+
r'"%dp0%[\\/]([^"\r\n]+)" %\*\s*', body,
|
|
66
|
+
)
|
|
67
|
+
if match:
|
|
68
|
+
script = shim.parent / match.group(1).replace("\\", "/")
|
|
69
|
+
node = shim.parent / "node.exe"
|
|
70
|
+
executable = str(node) if node.is_file() else shutil.which("node", path=search_path)
|
|
71
|
+
if executable and script.is_file():
|
|
72
|
+
return [executable, str(script), *command[1:]]
|
|
73
|
+
if any(re.search(r'[&|<>^%!"()\r\n]', value) for value in result):
|
|
74
|
+
raise ValueError(
|
|
75
|
+
"Cannot safely pass shell metacharacters to a Windows batch launcher. "
|
|
76
|
+
"Configure the native executable (for Node tools: node.exe and its JS entry point)."
|
|
77
|
+
)
|
|
78
|
+
return result
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def shell_command(command: str) -> list[str]:
|
|
82
|
+
"""Return an explicit platform shell invocation with UTF-8 output."""
|
|
83
|
+
if os.name == "nt":
|
|
84
|
+
powershell = shutil.which("pwsh") or shutil.which("powershell")
|
|
85
|
+
if powershell:
|
|
86
|
+
return powershell_command(powershell, command)
|
|
87
|
+
comspec = os.environ.get("COMSPEC") or "cmd.exe"
|
|
88
|
+
return [comspec, "/d", "/s", "/c", f"chcp 65001>nul & {command}"]
|
|
89
|
+
|
|
90
|
+
bash = shutil.which("bash")
|
|
91
|
+
if bash:
|
|
92
|
+
return [bash, "-c", command]
|
|
93
|
+
return ["/bin/sh", "-c", command]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def powershell_command(executable: str, command: str) -> list[str]:
|
|
97
|
+
"""Build a non-interactive PowerShell command that emits UTF-8."""
|
|
98
|
+
return [
|
|
99
|
+
executable,
|
|
100
|
+
"-NoLogo",
|
|
101
|
+
"-NoProfile",
|
|
102
|
+
"-NonInteractive",
|
|
103
|
+
"-Command",
|
|
104
|
+
_POWERSHELL_UTF8_SETUP + command,
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def managed_process_command(command: list[str]) -> list[str]:
|
|
109
|
+
"""Wrap a Windows command in a job whose lifetime includes all descendants."""
|
|
110
|
+
if os.name != "nt":
|
|
111
|
+
return command
|
|
112
|
+
return [sys.executable, "-I", str(Path(__file__).with_name("_windows_process.py")), *command]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def subprocess_group_options() -> dict[str, Any]:
|
|
116
|
+
"""Options used for processes that may need tree-wide cancellation."""
|
|
117
|
+
if os.name != "nt":
|
|
118
|
+
return {"start_new_session": True}
|
|
119
|
+
# CrabCode subprocesses are non-interactive and communicate through pipes.
|
|
120
|
+
# Prevent console programs spawned by the GUI/Gateway from flashing a new
|
|
121
|
+
# terminal window on Windows.
|
|
122
|
+
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
123
|
+
return {"creationflags": creation_flags} if creation_flags else {}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def decode_subprocess_output(data: bytes) -> str:
|
|
127
|
+
"""Decode subprocess output without assuming every Windows tool uses UTF-8."""
|
|
128
|
+
if not data:
|
|
129
|
+
return ""
|
|
130
|
+
if data.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
|
|
131
|
+
return data.decode("utf-16", errors="replace")
|
|
132
|
+
try:
|
|
133
|
+
return data.decode("utf-8")
|
|
134
|
+
except UnicodeDecodeError:
|
|
135
|
+
encodings = [locale.getpreferredencoding(False) or "utf-8"]
|
|
136
|
+
if os.name == "nt":
|
|
137
|
+
encodings.append("mbcs")
|
|
138
|
+
for encoding in dict.fromkeys(encodings):
|
|
139
|
+
try:
|
|
140
|
+
return data.decode(encoding)
|
|
141
|
+
except (LookupError, UnicodeDecodeError):
|
|
142
|
+
continue
|
|
143
|
+
return data.decode(encodings[-1], errors="replace")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def is_process_running(pid: int) -> bool:
|
|
147
|
+
"""Return whether *pid* identifies a live process without signalling it."""
|
|
148
|
+
if pid <= 0:
|
|
149
|
+
return False
|
|
150
|
+
if os.name != "nt":
|
|
151
|
+
try:
|
|
152
|
+
os.kill(pid, 0)
|
|
153
|
+
except ProcessLookupError:
|
|
154
|
+
return False
|
|
155
|
+
except PermissionError:
|
|
156
|
+
return True
|
|
157
|
+
except OSError:
|
|
158
|
+
return False
|
|
159
|
+
return True
|
|
160
|
+
|
|
161
|
+
import ctypes
|
|
162
|
+
from ctypes import wintypes
|
|
163
|
+
|
|
164
|
+
process_query_limited_information = 0x1000
|
|
165
|
+
still_active = 259
|
|
166
|
+
access_denied = 5
|
|
167
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
168
|
+
kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
169
|
+
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
170
|
+
kernel32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
|
|
171
|
+
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
|
|
172
|
+
kernel32.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
173
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
174
|
+
|
|
175
|
+
handle = kernel32.OpenProcess(process_query_limited_information, False, pid)
|
|
176
|
+
if not handle:
|
|
177
|
+
return ctypes.get_last_error() == access_denied
|
|
178
|
+
try:
|
|
179
|
+
exit_code = wintypes.DWORD()
|
|
180
|
+
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
|
181
|
+
return False
|
|
182
|
+
return exit_code.value == still_active
|
|
183
|
+
finally:
|
|
184
|
+
kernel32.CloseHandle(handle)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
async def terminate_process_tree(process: Any, *, timeout: float = 5.0) -> None:
|
|
188
|
+
"""Terminate a subprocess and all descendants created beneath it."""
|
|
189
|
+
if process is None or process.returncode is not None:
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
if os.name == "nt":
|
|
193
|
+
taskkill = shutil.which("taskkill")
|
|
194
|
+
tree_killed = False
|
|
195
|
+
if taskkill:
|
|
196
|
+
options: dict[str, Any] = {}
|
|
197
|
+
if hasattr(subprocess, "CREATE_NO_WINDOW"):
|
|
198
|
+
options["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
199
|
+
try:
|
|
200
|
+
killer = await asyncio.create_subprocess_exec(
|
|
201
|
+
taskkill,
|
|
202
|
+
"/PID",
|
|
203
|
+
str(process.pid),
|
|
204
|
+
"/T",
|
|
205
|
+
"/F",
|
|
206
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
207
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
208
|
+
**options,
|
|
209
|
+
)
|
|
210
|
+
await asyncio.wait_for(killer.wait(), timeout=timeout)
|
|
211
|
+
tree_killed = killer.returncode == 0
|
|
212
|
+
except asyncio.TimeoutError:
|
|
213
|
+
killer.kill()
|
|
214
|
+
except OSError:
|
|
215
|
+
pass
|
|
216
|
+
if not tree_killed and process.returncode is None:
|
|
217
|
+
try:
|
|
218
|
+
process.kill()
|
|
219
|
+
except ProcessLookupError:
|
|
220
|
+
pass
|
|
221
|
+
if process.returncode is None:
|
|
222
|
+
try:
|
|
223
|
+
await asyncio.wait_for(process.wait(), timeout=timeout)
|
|
224
|
+
return
|
|
225
|
+
except asyncio.TimeoutError:
|
|
226
|
+
try:
|
|
227
|
+
process.kill()
|
|
228
|
+
except ProcessLookupError:
|
|
229
|
+
pass
|
|
230
|
+
try:
|
|
231
|
+
await asyncio.wait_for(process.wait(), timeout=timeout)
|
|
232
|
+
except asyncio.TimeoutError:
|
|
233
|
+
pass # A kernel-blocked process must not hold its caller forever.
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
238
|
+
except ProcessLookupError:
|
|
239
|
+
return
|
|
240
|
+
except OSError:
|
|
241
|
+
try:
|
|
242
|
+
process.terminate()
|
|
243
|
+
except ProcessLookupError:
|
|
244
|
+
return
|
|
245
|
+
try:
|
|
246
|
+
await asyncio.wait_for(process.wait(), timeout=timeout)
|
|
247
|
+
except asyncio.TimeoutError:
|
|
248
|
+
try:
|
|
249
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
250
|
+
except ProcessLookupError:
|
|
251
|
+
return
|
|
252
|
+
except OSError:
|
|
253
|
+
try:
|
|
254
|
+
process.kill()
|
|
255
|
+
except ProcessLookupError:
|
|
256
|
+
return
|
|
257
|
+
try:
|
|
258
|
+
await asyncio.wait_for(process.wait(), timeout=timeout)
|
|
259
|
+
except asyncio.TimeoutError:
|
|
260
|
+
pass # SIGKILL does not guarantee an immediate reap (e.g. D state).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Agent Teams — flat teams with named messaging and shared task boards."""
|
|
2
|
+
|
|
3
|
+
from crabcode_core.team.models import (
|
|
4
|
+
BridgePolicy,
|
|
5
|
+
CrossTeamMessage,
|
|
6
|
+
TaskItem,
|
|
7
|
+
TaskStatus,
|
|
8
|
+
TeamConfig,
|
|
9
|
+
TeamMessage,
|
|
10
|
+
TeamState,
|
|
11
|
+
TeammateInfo,
|
|
12
|
+
TeammateRole,
|
|
13
|
+
TeammateState,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"BridgePolicy",
|
|
18
|
+
"CrossTeamMessage",
|
|
19
|
+
"TaskItem",
|
|
20
|
+
"TaskStatus",
|
|
21
|
+
"TeamConfig",
|
|
22
|
+
"TeamMessage",
|
|
23
|
+
"TeamState",
|
|
24
|
+
"TeammateInfo",
|
|
25
|
+
"TeammateRole",
|
|
26
|
+
"TeammateState",
|
|
27
|
+
]
|