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,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
+ ]