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,211 @@
1
+ """Snapshot and revert routes — /snapshot/*."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from typing import Any
7
+
8
+ from fastapi import APIRouter, HTTPException, Request
9
+
10
+ from crabcode_gateway.schemas import CheckpointRequest, RevertRequest
11
+ from crabcode_gateway.task_registry import SessionOperationRejected, run_session_operation
12
+
13
+ router = APIRouter(prefix="/snapshot", tags=["snapshot"])
14
+
15
+
16
+ async def _checkpoint(session: Any, label: str) -> tuple[str | None, bool]:
17
+ checkpoint_id = await asyncio.to_thread(session.checkpoint, label=label)
18
+ # Read the status in the same coroutine immediately after the worker
19
+ # returns, so concurrent checkpoint requests cannot overwrite the status
20
+ # before this request packages its response.
21
+ return checkpoint_id, bool(
22
+ getattr(session, "last_checkpoint_snapshot_included", False)
23
+ )
24
+
25
+
26
+ async def _list_checkpoints(session: Any) -> list[dict[str, Any]]:
27
+ return session.list_checkpoints()
28
+
29
+
30
+ def _resolve_checkpoint_id(checkpoints: list[dict[str, Any]], selector: str) -> str | None:
31
+ """Resolve a full ID, unique prefix, or one-based checkpoint index."""
32
+ value = selector.strip()
33
+ if not value:
34
+ return None
35
+ try:
36
+ index = int(value) - 1
37
+ except ValueError:
38
+ index = -1
39
+ if 0 <= index < len(checkpoints):
40
+ return str(checkpoints[index].get("id") or "") or None
41
+ exact = [str(cp.get("id") or "") for cp in checkpoints if cp.get("id") == value]
42
+ if exact:
43
+ return exact[0]
44
+ matches = [str(cp.get("id") or "") for cp in checkpoints if str(cp.get("id") or "").startswith(value)]
45
+ return matches[0] if len(matches) == 1 else None
46
+
47
+
48
+ async def _resolve_and_revert(
49
+ session: Any,
50
+ selector: str,
51
+ ) -> tuple[str, dict[str, Any]] | None:
52
+ checkpoint_id = _resolve_checkpoint_id(session.list_checkpoints(), selector)
53
+ if checkpoint_id is None:
54
+ return None
55
+ return checkpoint_id, session.revert(checkpoint_id)
56
+
57
+
58
+ async def _resolve_and_rollback(
59
+ session: Any,
60
+ selector: str,
61
+ ) -> tuple[str, bool, int] | None:
62
+ checkpoint_id = _resolve_checkpoint_id(session.list_checkpoints(), selector)
63
+ if checkpoint_id is None:
64
+ return None
65
+ ok = bool(session.rollback(checkpoint_id))
66
+ return checkpoint_id, ok, len(getattr(session, "messages", ()))
67
+
68
+
69
+ async def _undo_latest(session: Any) -> tuple[str, dict[str, Any]] | None:
70
+ checkpoints = session.list_checkpoints()
71
+ if not checkpoints:
72
+ return None
73
+ checkpoint_id = str(checkpoints[0].get("id") or "")
74
+ if not checkpoint_id:
75
+ return None
76
+ return checkpoint_id, session.revert(checkpoint_id)
77
+
78
+
79
+ def _get_session(request: Request, session_id: str):
80
+ """Retrieve a CoreSession from app state."""
81
+ sessions: dict = request.app.state.sessions
82
+ if session_id is None or session_id not in sessions:
83
+ return None
84
+ if session_id in getattr(request.app.state, "closing_sessions", set()):
85
+ return None
86
+ return sessions[session_id]
87
+
88
+
89
+ @router.post("/checkpoint")
90
+ async def create_checkpoint(req: CheckpointRequest, request: Request) -> dict[str, Any]:
91
+ """Create a checkpoint with a file-system snapshot."""
92
+ session = _get_session(request, req.session_id)
93
+ if not session:
94
+ raise HTTPException(status_code=404, detail="Session not found")
95
+ try:
96
+ cp_id, snapshot_included = await run_session_operation(
97
+ request.app.state,
98
+ session,
99
+ lambda: _checkpoint(session, req.label),
100
+ )
101
+ except SessionOperationRejected as exc:
102
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
103
+ if not cp_id:
104
+ raise HTTPException(status_code=400, detail="Failed to create checkpoint")
105
+ return {
106
+ "checkpoint_id": cp_id,
107
+ "snapshot_included": snapshot_included,
108
+ }
109
+
110
+
111
+ @router.get("/list")
112
+ async def list_checkpoints(session_id: str, request: Request) -> list[dict[str, Any]]:
113
+ """List checkpoints for a session, including file snapshot info."""
114
+ session = _get_session(request, session_id)
115
+ if not session:
116
+ raise HTTPException(status_code=404, detail="Session not found")
117
+ try:
118
+ return await run_session_operation(
119
+ request.app.state,
120
+ session,
121
+ lambda: _list_checkpoints(session),
122
+ )
123
+ except SessionOperationRejected as exc:
124
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
125
+
126
+
127
+ @router.post("/revert")
128
+ async def revert_checkpoint(req: RevertRequest, request: Request) -> dict[str, Any]:
129
+ """Revert both files and conversation to a checkpoint."""
130
+ session = _get_session(request, req.session_id)
131
+ if not session:
132
+ raise HTTPException(status_code=404, detail="Session not found")
133
+ try:
134
+ resolved = await run_session_operation(
135
+ request.app.state,
136
+ session,
137
+ lambda: _resolve_and_revert(session, req.checkpoint_id),
138
+ )
139
+ except SessionOperationRejected as exc:
140
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
141
+ if resolved is None:
142
+ raise HTTPException(status_code=400, detail="Checkpoint not found or prefix is ambiguous")
143
+ _checkpoint_id, result = resolved
144
+ if not result.get("success"):
145
+ raise HTTPException(status_code=400, detail="Revert failed or checkpoint not found")
146
+ return result
147
+
148
+
149
+ @router.post("/rollback")
150
+ async def rollback_checkpoint(req: RevertRequest, request: Request) -> dict[str, Any]:
151
+ """Rollback conversation only (no file restore) to a checkpoint."""
152
+ session = _get_session(request, req.session_id)
153
+ if not session:
154
+ raise HTTPException(status_code=404, detail="Session not found")
155
+ try:
156
+ resolved = await run_session_operation(
157
+ request.app.state,
158
+ session,
159
+ lambda: _resolve_and_rollback(session, req.checkpoint_id),
160
+ )
161
+ except SessionOperationRejected as exc:
162
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
163
+ if resolved is None:
164
+ raise HTTPException(status_code=400, detail="Checkpoint not found or prefix is ambiguous")
165
+ _checkpoint_id, ok, message_count = resolved
166
+ if not ok:
167
+ raise HTTPException(status_code=400, detail="Rollback failed or checkpoint not found")
168
+ return {"success": True, "messages_count": message_count}
169
+
170
+
171
+ @router.post("/undo")
172
+ async def undo_last_checkpoint(request: Request) -> dict[str, Any]:
173
+ """Revert files and conversation to the most recent checkpoint."""
174
+ try:
175
+ body = await request.json()
176
+ except (ValueError, TypeError) as exc:
177
+ raise HTTPException(status_code=400, detail="Request body must be valid JSON") from exc
178
+ if body is None:
179
+ body = {}
180
+ if not isinstance(body, dict):
181
+ raise HTTPException(status_code=400, detail="Request body must be a JSON object")
182
+ session_id = body.get("session_id") if body else None
183
+
184
+ sessions: dict = request.app.state.sessions
185
+ sid = (
186
+ request.app.state.default_session_id
187
+ if session_id is None
188
+ else session_id
189
+ )
190
+ if (
191
+ not sid
192
+ or sid not in sessions
193
+ or sid in getattr(request.app.state, "closing_sessions", set())
194
+ ):
195
+ raise HTTPException(status_code=404, detail="Session not found")
196
+ session = sessions[sid]
197
+
198
+ try:
199
+ resolved = await run_session_operation(
200
+ request.app.state,
201
+ session,
202
+ lambda: _undo_latest(session),
203
+ )
204
+ except SessionOperationRejected as exc:
205
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
206
+ if resolved is None:
207
+ raise HTTPException(status_code=400, detail="No checkpoints to undo")
208
+ checkpoint_id, result = resolved
209
+ if not result.get("success"):
210
+ raise HTTPException(status_code=400, detail="Undo failed")
211
+ return {"checkpoint_id": checkpoint_id, **result}
@@ -0,0 +1,318 @@
1
+ """Background monitor and managed-agent task routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from fastapi import APIRouter, HTTPException, Request
8
+
9
+ from crabcode_gateway.schemas import BackgroundTaskInfo, TaskStopRequest
10
+ from crabcode_gateway.safe_file import read_regular_file_tail
11
+ from crabcode_gateway.session_registry import get_session_lock
12
+ from crabcode_gateway.task_registry import SessionOperationRejected, run_session_operation
13
+
14
+
15
+ router = APIRouter(tags=["tasks"])
16
+
17
+
18
+ def _task_dicts(session: Any) -> list[dict[str, Any]]:
19
+ """Return one stable representation for monitors and managed agents."""
20
+ result: list[dict[str, Any]] = []
21
+ cwd = str(getattr(session, "cwd", "") or "")
22
+ for snapshot in session.list_monitor_tasks():
23
+ data = snapshot.to_dict() if hasattr(snapshot, "to_dict") else dict(snapshot)
24
+ data.setdefault("cwd", cwd)
25
+ result.append(data)
26
+ for snapshot in session.list_agents():
27
+ result.append(
28
+ {
29
+ "task_id": snapshot.agent_id,
30
+ "agent_id": snapshot.agent_id,
31
+ "session_id": snapshot.session_id,
32
+ "cwd": cwd,
33
+ "description": snapshot.title,
34
+ "task_type": "local_agent",
35
+ "source": "agent",
36
+ "status": snapshot.status,
37
+ "output_file": snapshot.transcript_path,
38
+ "created_at": snapshot.created_at,
39
+ "started_at": snapshot.started_at,
40
+ "finished_at": snapshot.finished_at,
41
+ "updated_at": snapshot.updated_at,
42
+ "error": snapshot.error,
43
+ }
44
+ )
45
+ return result
46
+
47
+
48
+ def _task_info(data: dict[str, Any]) -> BackgroundTaskInfo:
49
+ return BackgroundTaskInfo(
50
+ task_id=str(data.get("task_id") or data.get("agent_id") or ""),
51
+ agent_id=data.get("agent_id"),
52
+ session_id=str(data.get("session_id") or ""),
53
+ cwd=str(data.get("cwd") or ""),
54
+ description=str(data.get("description") or ""),
55
+ task_type=str(data.get("task_type") or ""),
56
+ source=str(data.get("source") or ""),
57
+ status=str(data.get("status") or ""),
58
+ output_file=data.get("output_file"),
59
+ created_at=str(data.get("created_at") or ""),
60
+ started_at=data.get("started_at"),
61
+ finished_at=data.get("finished_at"),
62
+ updated_at=str(data.get("updated_at") or ""),
63
+ error=str(data.get("error") or ""),
64
+ exit_code=data.get("exit_code"),
65
+ )
66
+
67
+
68
+ def _task_matches(session: Any, selector: str) -> bool:
69
+ value = str(selector or "").strip()
70
+ if not value:
71
+ return False
72
+ return any(
73
+ item.get("task_id", "") == value
74
+ or str(item.get("task_id", "")).startswith(value)
75
+ for item in _task_dicts(session)
76
+ )
77
+
78
+
79
+ def _get_session(request: Request, session_id: str | None = None):
80
+ sessions: dict[str, Any] = request.app.state.sessions
81
+ closing = getattr(request.app.state, "closing_sessions", set())
82
+
83
+ def usable(session: Any) -> bool:
84
+ return session is not None and getattr(session, "session_id", None) not in closing
85
+
86
+ if session_id is not None:
87
+ session = sessions.get(session_id)
88
+ return session if usable(session) else None
89
+
90
+ default = sessions.get(getattr(request.app.state, "default_session_id", None))
91
+ return default if usable(default) else None
92
+
93
+
94
+ async def _get_task_session(
95
+ request: Request,
96
+ session_id: str | None,
97
+ task_id: str,
98
+ ):
99
+ """Resolve cross-session task selectors without inspecting cold sessions."""
100
+ if session_id is not None:
101
+ return _get_session(request, session_id)
102
+
103
+ sessions: dict[str, Any] = request.app.state.sessions
104
+ closing = getattr(request.app.state, "closing_sessions", set())
105
+ default = _get_session(request)
106
+ candidates = ([default] if default is not None else []) + [
107
+ session
108
+ for session in sessions.values()
109
+ if session is not default
110
+ and getattr(session, "session_id", None) not in closing
111
+ ]
112
+
113
+ async def _matches(candidate: Any) -> bool:
114
+ initializer = getattr(candidate, "initialize", None)
115
+ if callable(initializer):
116
+ await initializer()
117
+ return _task_matches(candidate, task_id)
118
+
119
+ for candidate in candidates:
120
+ try:
121
+ if await run_session_operation(request.app.state, candidate, lambda: _matches(candidate)):
122
+ return candidate
123
+ except SessionOperationRejected:
124
+ continue
125
+ # Keep historical default-session behavior so callers get a task 404 rather
126
+ # than a misleading session 404 when the selector simply does not exist.
127
+ return default
128
+
129
+
130
+ def _matching_tasks(session: Any, task_id: str) -> list[dict[str, Any]]:
131
+ tasks = _task_dicts(session)
132
+ matches = [item for item in tasks if item.get("task_id") == task_id]
133
+ if not matches:
134
+ matches = [
135
+ item for item in tasks
136
+ if str(item.get("task_id") or "").startswith(task_id)
137
+ ]
138
+ return matches
139
+
140
+
141
+ @router.get("/tasks", response_model=list[BackgroundTaskInfo])
142
+ @router.get("/tasks/list", response_model=list[BackgroundTaskInfo])
143
+ @router.get("/task/list", response_model=list[BackgroundTaskInfo])
144
+ async def list_tasks(
145
+ request: Request,
146
+ session_id: str | None = None,
147
+ scope: str | None = None,
148
+ status: str | None = None,
149
+ ) -> list[BackgroundTaskInfo]:
150
+ if scope == "global":
151
+ # The automation deck needs one stable view across every currently
152
+ # loaded project instead of following whichever chat is the default.
153
+ async with get_session_lock(request.app.state):
154
+ closing = getattr(request.app.state, "closing_sessions", set())
155
+ sessions = [
156
+ session
157
+ for session in request.app.state.sessions.values()
158
+ if getattr(session, "session_id", None) not in closing
159
+ ]
160
+
161
+ result: list[BackgroundTaskInfo] = []
162
+ for session in sessions:
163
+ async def _list_global() -> list[BackgroundTaskInfo]:
164
+ initializer = getattr(session, "initialize", None)
165
+ if callable(initializer):
166
+ await initializer()
167
+ return [
168
+ _task_info(item)
169
+ for item in _task_dicts(session)
170
+ if status is None or str(item.get("status") or "") == status
171
+ ]
172
+
173
+ try:
174
+ result.extend(
175
+ await run_session_operation(
176
+ request.app.state,
177
+ session,
178
+ _list_global,
179
+ )
180
+ )
181
+ except SessionOperationRejected:
182
+ # A session can be closing while the deck refreshes; the next
183
+ # poll will reconcile it without failing the whole dashboard.
184
+ continue
185
+ return sorted(result, key=lambda item: item.updated_at, reverse=True)
186
+
187
+ session = _get_session(request, session_id)
188
+ if not session:
189
+ raise HTTPException(status_code=404, detail="Session not found")
190
+
191
+ async def _list() -> list[BackgroundTaskInfo]:
192
+ initializer = getattr(session, "initialize", None)
193
+ if callable(initializer):
194
+ await initializer()
195
+ return [
196
+ _task_info(item)
197
+ for item in _task_dicts(session)
198
+ if status is None or str(item.get("status") or "") == status
199
+ ]
200
+
201
+ try:
202
+ return await run_session_operation(request.app.state, session, _list)
203
+ except SessionOperationRejected as exc:
204
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
205
+
206
+
207
+ @router.get("/tasks/{task_id}", response_model=BackgroundTaskInfo)
208
+ async def get_task(task_id: str, request: Request, session_id: str | None = None) -> BackgroundTaskInfo:
209
+ session = await _get_task_session(request, session_id, task_id)
210
+ if not session:
211
+ raise HTTPException(status_code=404, detail="Task not found")
212
+
213
+ async def _get() -> list[dict[str, Any]]:
214
+ initializer = getattr(session, "initialize", None)
215
+ if callable(initializer):
216
+ await initializer()
217
+ return _matching_tasks(session, task_id)
218
+
219
+ try:
220
+ matches = await run_session_operation(request.app.state, session, _get)
221
+ except SessionOperationRejected as exc:
222
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
223
+ if len(matches) != 1:
224
+ raise HTTPException(status_code=404, detail="Task not found or selector is ambiguous")
225
+ return _task_info(matches[0])
226
+
227
+
228
+ @router.post("/tasks/stop")
229
+ @router.post("/task/stop")
230
+ async def stop_task(req: TaskStopRequest, request: Request) -> dict[str, Any]:
231
+ session = await _get_task_session(request, req.session_id, req.task_id)
232
+ if not session:
233
+ raise HTTPException(status_code=404, detail="Task not found")
234
+ try:
235
+ stopped = await run_session_operation(
236
+ request.app.state,
237
+ session,
238
+ lambda: session.stop_background_task(req.task_id),
239
+ )
240
+ except SessionOperationRejected as exc:
241
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
242
+ if not stopped:
243
+ raise HTTPException(status_code=404, detail=f"No running background task: {req.task_id}")
244
+ return {"status": "ok", "task_id": req.task_id, "stopped": True}
245
+
246
+
247
+ @router.get("/tasks/{task_id}/output")
248
+ async def task_output(task_id: str, request: Request, session_id: str | None = None) -> dict[str, Any]:
249
+ """Read a bounded tail of a monitor/agent output file."""
250
+ session = await _get_task_session(request, session_id, task_id)
251
+ if not session:
252
+ raise HTTPException(status_code=404, detail="Task not found")
253
+ try:
254
+ limit = max(1, min(10_000, int(request.query_params.get("lines", "200"))))
255
+ except (TypeError, ValueError):
256
+ raise HTTPException(status_code=400, detail="lines must be an integer")
257
+ async def _read_output() -> dict[str, Any] | None:
258
+ initializer = getattr(session, "initialize", None)
259
+ if callable(initializer):
260
+ await initializer()
261
+ matches = _matching_tasks(session, task_id)
262
+ if len(matches) != 1:
263
+ return None
264
+ path_value = matches[0].get("output_file")
265
+ if not path_value:
266
+ return {
267
+ "task_id": matches[0].get("task_id"),
268
+ "path": None,
269
+ "lines": [],
270
+ "truncated": False,
271
+ }
272
+ task = matches[0]
273
+ resolved_task_id = str(task.get("task_id") or "")
274
+ try:
275
+ from crabcode_core.session.storage import (
276
+ get_agent_transcript_path,
277
+ get_task_output_path,
278
+ )
279
+
280
+ if task.get("agent_id") or task.get("source") == "agent":
281
+ expected_path = get_agent_transcript_path(
282
+ session.cwd,
283
+ session.session_id,
284
+ str(task.get("agent_id") or resolved_task_id),
285
+ )
286
+ else:
287
+ expected_path = get_task_output_path(
288
+ session.cwd,
289
+ session.session_id,
290
+ resolved_task_id,
291
+ )
292
+ output_lines, truncated = read_regular_file_tail(
293
+ expected_path,
294
+ max_lines=limit,
295
+ )
296
+ except (OSError, TypeError, ValueError) as exc:
297
+ raise HTTPException(
298
+ status_code=404,
299
+ detail=f"Task output unavailable: {exc}",
300
+ ) from exc
301
+ return {
302
+ "task_id": resolved_task_id,
303
+ "path": str(expected_path),
304
+ "lines": output_lines[-limit:],
305
+ "truncated": truncated,
306
+ }
307
+
308
+ try:
309
+ result = await run_session_operation(
310
+ request.app.state,
311
+ session,
312
+ _read_output,
313
+ )
314
+ except SessionOperationRejected as exc:
315
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
316
+ if result is None:
317
+ raise HTTPException(status_code=404, detail="Task not found or selector is ambiguous")
318
+ return result