cct-cli 0.7.9.0__py3-none-any.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 (325) hide show
  1. calc_terminal/__init__.py +14 -0
  2. calc_terminal/__main__.py +14 -0
  3. calc_terminal/activity.py +1334 -0
  4. calc_terminal/agent.py +3387 -0
  5. calc_terminal/agent_runtime.py +519 -0
  6. calc_terminal/ai_context.py +447 -0
  7. calc_terminal/ai_modes.py +752 -0
  8. calc_terminal/ai_personalization.py +286 -0
  9. calc_terminal/ai_preview_feedback.py +213 -0
  10. calc_terminal/aicore.py +2572 -0
  11. calc_terminal/anim.py +367 -0
  12. calc_terminal/app.py +3685 -0
  13. calc_terminal/art.py +639 -0
  14. calc_terminal/atomsim.py +368 -0
  15. calc_terminal/attachments.py +743 -0
  16. calc_terminal/benchmark_system.py +414 -0
  17. calc_terminal/browser/__init__.py +36 -0
  18. calc_terminal/browser/browser_state.py +346 -0
  19. calc_terminal/browser/devserver.py +176 -0
  20. calc_terminal/browser/engine.py +494 -0
  21. calc_terminal/browser/navigation.py +84 -0
  22. calc_terminal/browser/preview.py +429 -0
  23. calc_terminal/browser/preview_entry.py +95 -0
  24. calc_terminal/browser/project_detector.py +144 -0
  25. calc_terminal/browser/server.py +449 -0
  26. calc_terminal/browser/state.py +75 -0
  27. calc_terminal/browser/watcher.py +99 -0
  28. calc_terminal/browser_gui/__init__.py +1 -0
  29. calc_terminal/browser_gui/__main__.py +3 -0
  30. calc_terminal/browser_gui/launcher.py +173 -0
  31. calc_terminal/browser_gui/playwright_browser.py +117 -0
  32. calc_terminal/browser_gui/qt_browser.py +1501 -0
  33. calc_terminal/browser_gui/webview_browser.py +57 -0
  34. calc_terminal/capabilities/__init__.py +35 -0
  35. calc_terminal/capabilities/adapters/__init__.py +33 -0
  36. calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
  37. calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
  38. calc_terminal/capabilities/adapters/filesystem.py +206 -0
  39. calc_terminal/capabilities/adapters/git_adapter.py +202 -0
  40. calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
  41. calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
  42. calc_terminal/capabilities/adapters/platforms.py +200 -0
  43. calc_terminal/capabilities/adapters/python_exec.py +93 -0
  44. calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
  45. calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
  46. calc_terminal/capabilities/adapters/structural_bio.py +161 -0
  47. calc_terminal/capabilities/adapters/terminal.py +99 -0
  48. calc_terminal/capabilities/bus.py +178 -0
  49. calc_terminal/capabilities/discovery.py +207 -0
  50. calc_terminal/capabilities/schema.py +221 -0
  51. calc_terminal/cat.ico +0 -0
  52. calc_terminal/cat_browser.py +2018 -0
  53. calc_terminal/chat_store.py +703 -0
  54. calc_terminal/cli.py +1178 -0
  55. calc_terminal/code_editor.py +640 -0
  56. calc_terminal/collaboration.py +723 -0
  57. calc_terminal/commands_data.py +139 -0
  58. calc_terminal/compatibility_engine.py +352 -0
  59. calc_terminal/compute/__init__.py +31 -0
  60. calc_terminal/compute/fabric.py +350 -0
  61. calc_terminal/config.py +227 -0
  62. calc_terminal/core/__init__.py +41 -0
  63. calc_terminal/core/checkpoint.py +156 -0
  64. calc_terminal/core/input/__init__.py +45 -0
  65. calc_terminal/core/mode_registry.py +300 -0
  66. calc_terminal/core/project_graph.py +172 -0
  67. calc_terminal/core/recovery.py +129 -0
  68. calc_terminal/core/security_layer.py +112 -0
  69. calc_terminal/core/task_graph.py +202 -0
  70. calc_terminal/core/unified_runtime.py +184 -0
  71. calc_terminal/core/verification.py +257 -0
  72. calc_terminal/customization.py +1566 -0
  73. calc_terminal/derivations.py +153 -0
  74. calc_terminal/device_control.py +263 -0
  75. calc_terminal/diagnostics/__init__.py +27 -0
  76. calc_terminal/diagnostics/doctor_engine.py +382 -0
  77. calc_terminal/diagnostics/self_test.py +247 -0
  78. calc_terminal/doctor.py +519 -0
  79. calc_terminal/easter_eggs.py +274 -0
  80. calc_terminal/editor/__init__.py +1 -0
  81. calc_terminal/editor/actions.py +263 -0
  82. calc_terminal/editor/commands.py +160 -0
  83. calc_terminal/editor/shortcuts.py +226 -0
  84. calc_terminal/engine.py +259 -0
  85. calc_terminal/errors.py +120 -0
  86. calc_terminal/event_stream.py +146 -0
  87. calc_terminal/eventbus.py +133 -0
  88. calc_terminal/extensions.py +733 -0
  89. calc_terminal/fallback_cli.py +1321 -0
  90. calc_terminal/first_run.py +265 -0
  91. calc_terminal/fomoji_auth.py +1043 -0
  92. calc_terminal/formulas.py +82 -0
  93. calc_terminal/fs_cache.py +121 -0
  94. calc_terminal/fs_watcher.py +277 -0
  95. calc_terminal/game.py +193 -0
  96. calc_terminal/gen1.py +5 -0
  97. calc_terminal/generators.py +245 -0
  98. calc_terminal/gestures/__init__.py +42 -0
  99. calc_terminal/gestures/bindings.py +175 -0
  100. calc_terminal/gestures/manager.py +477 -0
  101. calc_terminal/goodbye.py +363 -0
  102. calc_terminal/gpu3d.py +290 -0
  103. calc_terminal/graphs.py +358 -0
  104. calc_terminal/hardware_analyzer.py +440 -0
  105. calc_terminal/host/__init__.py +30 -0
  106. calc_terminal/host/browser_manager.py +187 -0
  107. calc_terminal/host/desktop.py +1386 -0
  108. calc_terminal/host/launcher.py +395 -0
  109. calc_terminal/host/terminal.py +279 -0
  110. calc_terminal/identity.py +216 -0
  111. calc_terminal/input/__init__.py +54 -0
  112. calc_terminal/input/capabilities.py +258 -0
  113. calc_terminal/input/focus.py +87 -0
  114. calc_terminal/input/gestures.py +64 -0
  115. calc_terminal/input/pointer.py +114 -0
  116. calc_terminal/input/touch.py +345 -0
  117. calc_terminal/keys.py +84 -0
  118. calc_terminal/live_automation.py +165 -0
  119. calc_terminal/mathtext.py +433 -0
  120. calc_terminal/mcp.py +386 -0
  121. calc_terminal/memory.py +337 -0
  122. calc_terminal/memory_v2.py +479 -0
  123. calc_terminal/metrics.py +333 -0
  124. calc_terminal/mode_detection.py +146 -0
  125. calc_terminal/model.py +2431 -0
  126. calc_terminal/model_router.py +665 -0
  127. calc_terminal/models/__init__.py +0 -0
  128. calc_terminal/models/active_state.py +187 -0
  129. calc_terminal/models/dynamic_registry.py +584 -0
  130. calc_terminal/models/manager.py +781 -0
  131. calc_terminal/models/model_metadata.json +3526 -0
  132. calc_terminal/models/profiles.py +194 -0
  133. calc_terminal/models/registry.py +265 -0
  134. calc_terminal/models/schema.py +197 -0
  135. calc_terminal/models/validator.py +287 -0
  136. calc_terminal/models/verification_engine.py +368 -0
  137. calc_terminal/native_picker.py +215 -0
  138. calc_terminal/ollama_catalog.py +279 -0
  139. calc_terminal/ollama_download.py +233 -0
  140. calc_terminal/orchestrator.py +304 -0
  141. calc_terminal/package_research.py +322 -0
  142. calc_terminal/packages.py +1024 -0
  143. calc_terminal/pc_specs.py +116 -0
  144. calc_terminal/permissions.py +334 -0
  145. calc_terminal/pet.py +106 -0
  146. calc_terminal/pipeline.py +505 -0
  147. calc_terminal/platform/__init__.py +491 -0
  148. calc_terminal/platform/desktop.py +491 -0
  149. calc_terminal/platform/web.py +781 -0
  150. calc_terminal/preview/__init__.py +1 -0
  151. calc_terminal/preview/dev_server.py +303 -0
  152. calc_terminal/preview/diagnostics.py +131 -0
  153. calc_terminal/preview/live_reload.py +66 -0
  154. calc_terminal/preview/manager.py +129 -0
  155. calc_terminal/project_stats.py +209 -0
  156. calc_terminal/projects.py +328 -0
  157. calc_terminal/providers/__init__.py +0 -0
  158. calc_terminal/providers/adapters/__init__.py +80 -0
  159. calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
  160. calc_terminal/providers/adapters/base.py +106 -0
  161. calc_terminal/providers/adapters/chinese_adapters.py +420 -0
  162. calc_terminal/providers/adapters/gemini_adapter.py +101 -0
  163. calc_terminal/providers/adapters/ollama_adapter.py +83 -0
  164. calc_terminal/providers/adapters/openai_adapter.py +159 -0
  165. calc_terminal/providers/adapters/other_adapters.py +246 -0
  166. calc_terminal/providers/anthropic_provider.py +172 -0
  167. calc_terminal/providers/auto_update.py +416 -0
  168. calc_terminal/providers/base_provider.py +105 -0
  169. calc_terminal/providers/discovery_manager.py +207 -0
  170. calc_terminal/providers/gemini_provider.py +178 -0
  171. calc_terminal/providers/lifecycle.py +767 -0
  172. calc_terminal/providers/ollama_adapter.py +707 -0
  173. calc_terminal/providers/openai_provider.py +254 -0
  174. calc_terminal/providers/provider_manager.py +1827 -0
  175. calc_terminal/providers/providers.json +4075 -0
  176. calc_terminal/reactionsim.py +279 -0
  177. calc_terminal/registry.py +337 -0
  178. calc_terminal/report.py +162 -0
  179. calc_terminal/research/__init__.py +45 -0
  180. calc_terminal/research/artifact_intel.py +126 -0
  181. calc_terminal/research/data_lineage.py +123 -0
  182. calc_terminal/research/experiment_ledger.py +303 -0
  183. calc_terminal/research/reproducibility.py +131 -0
  184. calc_terminal/resilience/__init__.py +47 -0
  185. calc_terminal/resilience/agent_state.py +121 -0
  186. calc_terminal/resilience/capability_matcher.py +174 -0
  187. calc_terminal/resilience/circuit_breaker.py +158 -0
  188. calc_terminal/resilience/failover_engine.py +230 -0
  189. calc_terminal/resilience/health_monitor.py +192 -0
  190. calc_terminal/resilience/ollama_adapter.py +125 -0
  191. calc_terminal/resilience/orchestrator.py +312 -0
  192. calc_terminal/resilience/types.py +134 -0
  193. calc_terminal/sandbox.py +98 -0
  194. calc_terminal/scires.py +558 -0
  195. calc_terminal/security_scanner.py +126 -0
  196. calc_terminal/session.py +294 -0
  197. calc_terminal/sim3d.py +206 -0
  198. calc_terminal/solver.py +276 -0
  199. calc_terminal/sound.py +127 -0
  200. calc_terminal/task_reports.py +287 -0
  201. calc_terminal/terminal_host.py +201 -0
  202. calc_terminal/terminal_identity.py +411 -0
  203. calc_terminal/test_ai_mode_reliability.py +344 -0
  204. calc_terminal/test_browser.py +368 -0
  205. calc_terminal/test_code_editor_upgrade.py +485 -0
  206. calc_terminal/test_customization.py +1148 -0
  207. calc_terminal/test_customization_ui.py +612 -0
  208. calc_terminal/test_dynamic_registry.py +304 -0
  209. calc_terminal/test_extensions.py +436 -0
  210. calc_terminal/test_overhaul.py +557 -0
  211. calc_terminal/test_project_detect.py +255 -0
  212. calc_terminal/test_root_cause_fix.py +527 -0
  213. calc_terminal/test_stability.py +532 -0
  214. calc_terminal/test_terminal_identity.py +132 -0
  215. calc_terminal/test_v079_speed.py +460 -0
  216. calc_terminal/theme.py +1107 -0
  217. calc_terminal/timeline.py +139 -0
  218. calc_terminal/todos.py +246 -0
  219. calc_terminal/tool_call_normalizer.py +419 -0
  220. calc_terminal/tui.py +104 -0
  221. calc_terminal/ui/__init__.py +8 -0
  222. calc_terminal/ui/activity_panel.py +231 -0
  223. calc_terminal/ui/activity_stream_panel.py +238 -0
  224. calc_terminal/ui/animations.py +122 -0
  225. calc_terminal/ui/app.py +7271 -0
  226. calc_terminal/ui/attach_panel.py +597 -0
  227. calc_terminal/ui/attachments.py +424 -0
  228. calc_terminal/ui/backup_panel.py +810 -0
  229. calc_terminal/ui/browser_shell.py +887 -0
  230. calc_terminal/ui/cat_agent.py +357 -0
  231. calc_terminal/ui/chats_panel.py +899 -0
  232. calc_terminal/ui/command_palette.py +125 -0
  233. calc_terminal/ui/command_palette_modal.py +166 -0
  234. calc_terminal/ui/composer.py +1141 -0
  235. calc_terminal/ui/context_menu.py +197 -0
  236. calc_terminal/ui/conversation.py +1435 -0
  237. calc_terminal/ui/customization_panel.py +1229 -0
  238. calc_terminal/ui/dashboard.py +404 -0
  239. calc_terminal/ui/design_system.py +557 -0
  240. calc_terminal/ui/diff_panel.py +213 -0
  241. calc_terminal/ui/editor.py +2102 -0
  242. calc_terminal/ui/empty_state.py +302 -0
  243. calc_terminal/ui/events.py +487 -0
  244. calc_terminal/ui/extensions_panel.py +815 -0
  245. calc_terminal/ui/footer.py +166 -0
  246. calc_terminal/ui/gestures_panel.py +383 -0
  247. calc_terminal/ui/goodbye_screen.py +100 -0
  248. calc_terminal/ui/header.py +1034 -0
  249. calc_terminal/ui/help_panel.py +254 -0
  250. calc_terminal/ui/live_activities.py +914 -0
  251. calc_terminal/ui/mcp_panel.py +570 -0
  252. calc_terminal/ui/memory_center.py +524 -0
  253. calc_terminal/ui/mode_colors_panel.py +525 -0
  254. calc_terminal/ui/nav_screens.py +747 -0
  255. calc_terminal/ui/ollama_panel.py +536 -0
  256. calc_terminal/ui/palette.py +221 -0
  257. calc_terminal/ui/permission_panel.py +269 -0
  258. calc_terminal/ui/personalization_panel.py +517 -0
  259. calc_terminal/ui/personalize_center.py +1568 -0
  260. calc_terminal/ui/preview_panel.py +441 -0
  261. calc_terminal/ui/resizers.py +402 -0
  262. calc_terminal/ui/sidebar.py +1285 -0
  263. calc_terminal/ui/statusbar.py +168 -0
  264. calc_terminal/ui/theme_css.py +1396 -0
  265. calc_terminal/ui/thinking.py +226 -0
  266. calc_terminal/ui/timeline_panel.py +102 -0
  267. calc_terminal/ui/todo_panel.py +193 -0
  268. calc_terminal/ui/viewport.py +136 -0
  269. calc_terminal/ui/vision_panel.py +489 -0
  270. calc_terminal/ui/welcome_modal.py +343 -0
  271. calc_terminal/ui/widgets.py +160 -0
  272. calc_terminal/ui/workspace.py +831 -0
  273. calc_terminal/viewers/__init__.py +1 -0
  274. calc_terminal/viewers/document_viewer.py +252 -0
  275. calc_terminal/viewers/image_viewer.py +241 -0
  276. calc_terminal/viewers/pdf_viewer.py +203 -0
  277. calc_terminal/viewers/presentation_viewer.py +164 -0
  278. calc_terminal/viewers/registry.py +120 -0
  279. calc_terminal/viewers/spreadsheet_viewer.py +204 -0
  280. calc_terminal/vision/__init__.py +89 -0
  281. calc_terminal/vision/analysis.py +194 -0
  282. calc_terminal/vision/annotations.py +297 -0
  283. calc_terminal/vision/capture.py +171 -0
  284. calc_terminal/vision/context.py +231 -0
  285. calc_terminal/vision/correlation.py +169 -0
  286. calc_terminal/vision/cursor.py +258 -0
  287. calc_terminal/vision/events.py +66 -0
  288. calc_terminal/vision/frame_pipeline.py +259 -0
  289. calc_terminal/vision/priority.py +218 -0
  290. calc_terminal/vision/provider.py +180 -0
  291. calc_terminal/vision/safety.py +149 -0
  292. calc_terminal/vision/session.py +281 -0
  293. calc_terminal/vision/verify.py +162 -0
  294. calc_terminal/vision.py +514 -0
  295. calc_terminal/vscode_integration.py +113 -0
  296. calc_terminal/web/__init__.py +8 -0
  297. calc_terminal/web/cat_runtime.py +710 -0
  298. calc_terminal/web/server.py +2891 -0
  299. calc_terminal/web/static/css/app.css +3152 -0
  300. calc_terminal/web/static/icons/badge-72.png +0 -0
  301. calc_terminal/web/static/icons/cat.ico +0 -0
  302. calc_terminal/web/static/icons/icon-128.png +0 -0
  303. calc_terminal/web/static/icons/icon-144.png +0 -0
  304. calc_terminal/web/static/icons/icon-152.png +0 -0
  305. calc_terminal/web/static/icons/icon-192.png +0 -0
  306. calc_terminal/web/static/icons/icon-384.png +0 -0
  307. calc_terminal/web/static/icons/icon-512.png +0 -0
  308. calc_terminal/web/static/icons/icon-72.png +0 -0
  309. calc_terminal/web/static/icons/icon-96.png +0 -0
  310. calc_terminal/web/static/icons/icon.svg +34 -0
  311. calc_terminal/web/static/icons/new-project.png +0 -0
  312. calc_terminal/web/static/icons/open-project.png +0 -0
  313. calc_terminal/web/static/index.html +734 -0
  314. calc_terminal/web/static/js/app.js +2403 -0
  315. calc_terminal/web/static/manifest.json +88 -0
  316. calc_terminal/web/static/sw.js +230 -0
  317. calc_terminal/workflow_engine.py +769 -0
  318. calc_terminal/workspace.py +593 -0
  319. calc_terminal/workspace_index.py +385 -0
  320. cct_cli-0.7.9.0.dist-info/METADATA +210 -0
  321. cct_cli-0.7.9.0.dist-info/RECORD +325 -0
  322. cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
  323. cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
  324. cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
  325. cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1334 @@
1
+ """
2
+ CAT Activity System — centralized real-time Activity/Event bus.
3
+
4
+ Every real agent operation, tool call, command, file op, permission check,
5
+ question, or system event publishes a structured Activity here.
6
+ The Textual UI subscribes and renders live — never fake animations.
7
+
8
+ Spec section 1 event shape:
9
+ {
10
+ "id": "unique-activity-id",
11
+ "type": "tool | command | file | permission | question | status | system",
12
+ "action": "search | grep | glob | read | edit | write | create | delete | run | test | build | web | provider | etc.",
13
+ "status": "pending | running | completed | failed | cancelled | paused | waiting",
14
+ "title": "Human-readable activity title",
15
+ "details": "Optional additional information",
16
+ "file": "Optional file path",
17
+ "command": "Optional command",
18
+ "tool": "Optional tool name",
19
+ "result": "Optional summarized result",
20
+ "timestamp": "...",
21
+ "duration_ms": 0
22
+ }
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import os
28
+ import platform
29
+ import shutil
30
+ import subprocess
31
+ import time
32
+ import uuid
33
+ import threading
34
+ import logging
35
+ from dataclasses import dataclass, field, asdict
36
+ from typing import Any, Callable, Dict, List, Optional, Set, Tuple
37
+
38
+ logger = logging.getLogger("cct.activity")
39
+
40
+ # ── diagnostic event debug flag (spec section 31: cat --debug-events)
41
+ _DEBUG_EVENTS = False
42
+
43
+ def set_debug_events(enable: bool = True) -> None:
44
+ global _DEBUG_EVENTS
45
+ _DEBUG_EVENTS = bool(enable)
46
+
47
+ def is_debug_events() -> bool:
48
+ return _DEBUG_EVENTS
49
+
50
+ # ── helper duration formatter ──────────────────────────────────────────────
51
+ def _fmt_duration(ms: int) -> str:
52
+ if ms < 1000:
53
+ return f"{ms}ms"
54
+ s = ms / 1000.0
55
+ if s < 60:
56
+ return f"{s:.1f}s"
57
+ m = int(s // 60)
58
+ r = s % 60
59
+ return f"{m}m {r:.0f}s"
60
+
61
+ # ── thread-local current turn (so any nested tool call without explicit turn_id still binds to the active UI turn)
62
+ _thread_local = threading.local()
63
+
64
+ def set_current_turn(turn_id: str) -> None:
65
+ try:
66
+ _thread_local.current_turn = turn_id or ""
67
+ except Exception:
68
+ pass
69
+
70
+ def get_current_turn() -> str:
71
+ try:
72
+ return getattr(_thread_local, "current_turn", "") or ""
73
+ except Exception:
74
+ return ""
75
+
76
+ def clear_current_turn() -> None:
77
+ try:
78
+ _thread_local.current_turn = ""
79
+ except Exception:
80
+ pass
81
+
82
+ # ── canonical status values (spec section 3) ───────────────────────────────
83
+ STATUS_QUEUED = "queued"
84
+ STATUS_PENDING = "pending"
85
+ STATUS_STARTED = "started"
86
+ STATUS_RUNNING = "running"
87
+ STATUS_COMPLETED = "completed"
88
+ STATUS_FAILED = "failed"
89
+ STATUS_CANCELLED = "cancelled"
90
+ STATUS_WAITING_PERMISSION = "waiting_permission"
91
+ STATUS_PAUSED = "paused"
92
+ STATUS_RETRYING = "retrying"
93
+ STATUS_WAITING = "waiting"
94
+ STATUS_WARNING = "warning"
95
+
96
+ # Backwards compat aliases
97
+ QUEUED = STATUS_QUEUED
98
+ PENDING = STATUS_PENDING
99
+ STARTED = STATUS_STARTED
100
+ RUNNING = STATUS_RUNNING
101
+ COMPLETED = STATUS_COMPLETED
102
+ FAILED = STATUS_FAILED
103
+ CANCELLED = STATUS_CANCELLED
104
+ WAITING_PERMISSION = STATUS_WAITING_PERMISSION
105
+ PAUSED = STATUS_PAUSED
106
+ RETRYING = STATUS_RETRYING
107
+ WAITING = STATUS_WAITING
108
+ WARNING = STATUS_WARNING
109
+
110
+ STATUSES = frozenset({
111
+ STATUS_QUEUED, STATUS_PENDING, STATUS_STARTED, STATUS_RUNNING, STATUS_COMPLETED,
112
+ STATUS_FAILED, STATUS_CANCELLED, STATUS_WAITING_PERMISSION,
113
+ STATUS_PAUSED, STATUS_RETRYING, STATUS_WAITING, STATUS_WARNING
114
+ })
115
+
116
+ # ── canonical event types (spec section 3) ───────────────────────────────────
117
+ EVENT_TASK_STARTED = "task.started"
118
+ EVENT_TASK_COMPLETED = "task.completed"
119
+ EVENT_TASK_FAILED = "task.failed"
120
+
121
+ EVENT_PLAN_CREATED = "plan.created"
122
+ EVENT_PLAN_STEP_STARTED = "plan.step_started"
123
+ EVENT_PLAN_STEP_COMPLETED = "plan.step_completed"
124
+
125
+ EVENT_MODEL_REQUEST_STARTED = "model.request_started"
126
+ EVENT_MODEL_TOKEN_STREAMING = "model.token_streaming"
127
+ EVENT_MODEL_REQUEST_COMPLETED = "model.request_completed"
128
+ EVENT_MODEL_REQUEST_FAILED = "model.request_failed"
129
+
130
+ EVENT_TOOL_STARTED = "tool.started"
131
+ EVENT_TOOL_PROGRESS = "tool.progress"
132
+ EVENT_TOOL_COMPLETED = "tool.completed"
133
+ EVENT_TOOL_FAILED = "tool.failed"
134
+
135
+ EVENT_FILE_READ_STARTED = "file.read_started"
136
+ EVENT_FILE_READ_COMPLETED = "file.read_completed"
137
+ EVENT_FILE_WRITE_STARTED = "file.write_started"
138
+ EVENT_FILE_WRITE_COMPLETED = "file.write_completed"
139
+ EVENT_FILE_DELETE_STARTED = "file.delete_started"
140
+ EVENT_FILE_DELETE_COMPLETED = "file.delete_completed"
141
+
142
+ EVENT_TERMINAL_STARTED = "terminal.started"
143
+ EVENT_TERMINAL_OUTPUT = "terminal.output"
144
+ EVENT_TERMINAL_COMPLETED = "terminal.completed"
145
+ EVENT_TERMINAL_FAILED = "terminal.failed"
146
+
147
+ EVENT_BROWSER_STARTED = "browser.started"
148
+ EVENT_BROWSER_NAVIGATION = "browser.navigation"
149
+ EVENT_BROWSER_CLICK = "browser.click"
150
+ EVENT_BROWSER_INPUT = "browser.input"
151
+ EVENT_BROWSER_SCREENSHOT = "browser.screenshot"
152
+ EVENT_BROWSER_COMPLETED = "browser.completed"
153
+
154
+ EVENT_TEST_STARTED = "test.started"
155
+ EVENT_TEST_PROGRESS = "test.progress"
156
+ EVENT_TEST_PASSED = "test.passed"
157
+ EVENT_TEST_FAILED = "test.failed"
158
+
159
+ EVENT_MEMORY_READ = "memory.read"
160
+ EVENT_MEMORY_WRITE = "memory.write"
161
+
162
+ EVENT_PERMISSION_REQUESTED = "permission.requested"
163
+ EVENT_PERMISSION_GRANTED = "permission.granted"
164
+ EVENT_PERMISSION_DENIED = "permission.denied"
165
+
166
+ EVENT_AGENT_PAUSED = "agent.paused"
167
+ EVENT_AGENT_RESUMED = "agent.resumed"
168
+ EVENT_AGENT_RETRYING = "agent.retrying"
169
+ EVENT_AGENT_CANCELLED = "agent.cancelled"
170
+
171
+ EVENT_RESOURCE_SAMPLE = "resource.sample"
172
+ EVENT_SYSTEM_WARNING = "system.warning"
173
+ EVENT_SYSTEM_ERROR = "system.error"
174
+
175
+ # ── canonical categories (spec section 1) ───────────────────────────────────
176
+ CAT_SEARCH = "SEARCH"
177
+ CAT_FILE_READ = "FILE_READ"
178
+ CAT_EDIT = "EDIT"
179
+ CAT_TERMINAL = "TERMINAL"
180
+ CAT_TEST = "TEST"
181
+ CAT_BUILD = "BUILD"
182
+ CAT_ANALYSIS = "ANALYSIS"
183
+ CAT_GIT = "GIT"
184
+ CAT_PLANNING = "PLANNING"
185
+ CAT_SUBAGENT = "SUBAGENT"
186
+ CAT_SYSTEM = "SYSTEM"
187
+ CAT_MODEL = "MODEL"
188
+
189
+ CATEGORIES = frozenset({
190
+ CAT_SEARCH, CAT_FILE_READ, CAT_EDIT, CAT_TERMINAL, CAT_TEST,
191
+ CAT_BUILD, CAT_ANALYSIS, CAT_GIT, CAT_PLANNING, CAT_SUBAGENT,
192
+ CAT_SYSTEM, CAT_MODEL
193
+ })
194
+
195
+ # ── canonical phases ────────────────────────────────────────────────────────
196
+ PHASE_DISCOVERY = "DISCOVERY"
197
+ PHASE_ANALYSIS = "ANALYSIS"
198
+ PHASE_IMPLEMENTATION = "IMPLEMENTATION"
199
+ PHASE_VALIDATION = "VALIDATION"
200
+ PHASE_COMPLETE = "COMPLETE"
201
+
202
+ PHASE_ICONS = {
203
+ PHASE_DISCOVERY: "⌕",
204
+ PHASE_ANALYSIS: "🧠",
205
+ PHASE_IMPLEMENTATION: "✎",
206
+ PHASE_VALIDATION: "▶",
207
+ PHASE_COMPLETE: "✓",
208
+ }
209
+
210
+ # ── canonical type values (spec section 1) ─────────────────────────────────
211
+ TYPE_TOOL = "tool"
212
+ TYPE_COMMAND = "command"
213
+ TYPE_FILE = "file"
214
+ TYPE_PERMISSION = "permission"
215
+ TYPE_QUESTION = "question"
216
+ TYPE_STATUS = "status"
217
+ TYPE_SYSTEM = "system"
218
+ TYPE_PROVIDER = "provider"
219
+ TYPE_SEARCH = "search"
220
+ TYPE_WORKSPACE = "workspace"
221
+
222
+ TYPES = frozenset({TYPE_TOOL, TYPE_COMMAND, TYPE_FILE, TYPE_PERMISSION, TYPE_QUESTION, TYPE_STATUS, TYPE_SYSTEM, TYPE_PROVIDER, TYPE_SEARCH, TYPE_WORKSPACE})
223
+
224
+ # status -> static icon (spec section 3, accessibility: icon+text not color alone)
225
+ STATUS_ICONS = {
226
+ STATUS_PENDING: "⏳",
227
+ STATUS_STARTED: "◐",
228
+ STATUS_RUNNING: "◐",
229
+ STATUS_COMPLETED: "✓",
230
+ STATUS_FAILED: "✕",
231
+ STATUS_CANCELLED: "⊘",
232
+ STATUS_WAITING_PERMISSION: "⚠",
233
+ STATUS_PAUSED: "⏸",
234
+ STATUS_WAITING: "?",
235
+ STATUS_WARNING: "⚠",
236
+ }
237
+
238
+ # Backwards compat alias for older search of icons
239
+ STATE_ICONS = STATUS_ICONS
240
+
241
+ # throttle: if > N events per second for same turn, coalesce
242
+ _THROTTLE_WINDOW = 0.05 # 50ms
243
+ _MAX_PER_TURN = 120
244
+ _MAX_TOTAL = 500
245
+ _MAX_BUFFER = 500
246
+
247
+
248
+ @dataclass
249
+ class ResourceSample:
250
+ timestamp: float
251
+ system_ram_used_gb: float
252
+ system_ram_total_gb: float
253
+ system_ram_percent: float
254
+ process_ram_mb: float
255
+ cpu_percent: float
256
+ disk_percent: float = 0.0
257
+ gpu_vram_used_gb: Optional[float] = None
258
+ gpu_vram_total_gb: Optional[float] = None
259
+ gpu_name: Optional[str] = None
260
+ warning_level: str = "normal" # normal | elevated | high | critical
261
+
262
+
263
+ class ResourceMonitor:
264
+ """
265
+ Real-time system resource monitor.
266
+ Non-blocking, configurable background daemon sampler.
267
+ Measures actual RAM, CPU, and process RSS without guessing.
268
+ """
269
+
270
+ def __init__(self, interval_sec: float = 1.0, on_sample: Optional[Callable[[ResourceSample], None]] = None):
271
+ self.interval_sec = interval_sec
272
+ self._running = False
273
+ self._thread: Optional[threading.Thread] = None
274
+ self._lock = threading.Lock()
275
+ self._latest_sample: Optional[ResourceSample] = None
276
+ self._on_sample = on_sample
277
+ self._last_warning_time = 0.0
278
+ self._last_warning_level = "normal"
279
+ self._cooldown_sec = 30.0
280
+
281
+ def sample_now(self) -> ResourceSample:
282
+ sys_used_gb = 0.0
283
+ sys_total_gb = 0.0
284
+ sys_pct = 0.0
285
+ proc_mb = 0.0
286
+ cpu_pct = 0.0
287
+ disk_pct = 0.0
288
+ gpu_name = None
289
+ gpu_used_gb = None
290
+ gpu_total_gb = None
291
+
292
+ try:
293
+ import psutil
294
+ vm = psutil.virtual_memory()
295
+ sys_total_gb = round(vm.total / (1024 ** 3), 2)
296
+ sys_used_gb = round(vm.used / (1024 ** 3), 2)
297
+ sys_pct = round(vm.percent, 1)
298
+
299
+ proc = psutil.Process(os.getpid())
300
+ proc_mb = round(proc.memory_info().rss / (1024 ** 2), 1)
301
+
302
+ cpu_pct = round(psutil.cpu_percent(interval=None), 1)
303
+ try:
304
+ disk = psutil.disk_usage(os.getcwd() if os.path.exists(os.getcwd()) else "/")
305
+ disk_pct = round(disk.percent, 1)
306
+ except Exception:
307
+ disk_pct = 0.0
308
+ except Exception:
309
+ try:
310
+ if platform.system() == "Windows":
311
+ import ctypes
312
+ class MEMORYSTATUSEX(ctypes.Structure):
313
+ _fields_ = [
314
+ ("dwLength", ctypes.c_ulong), ("dwMemoryLoad", ctypes.c_ulong),
315
+ ("ullTotalPhys", ctypes.c_ulonglong), ("ullAvailPhys", ctypes.c_ulonglong),
316
+ ("ullTotalPageFile", ctypes.c_ulonglong), ("ullAvailPageFile", ctypes.c_ulonglong),
317
+ ("ullTotalVirtual", ctypes.c_ulonglong), ("ullAvailVirtual", ctypes.c_ulonglong),
318
+ ("ullAvailExtendedVirtual", ctypes.c_ulonglong)
319
+ ]
320
+ stat = MEMORYSTATUSEX(dwLength=ctypes.sizeof(MEMORYSTATUSEX))
321
+ ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(stat))
322
+ sys_total_gb = round(int(stat.ullTotalPhys) / (1024 ** 3), 2)
323
+ avail_gb = round(int(stat.ullAvailPhys) / (1024 ** 3), 2)
324
+ sys_used_gb = round(sys_total_gb - avail_gb, 2)
325
+ sys_pct = round((sys_used_gb / sys_total_gb) * 100, 1) if sys_total_gb > 0 else 0.0
326
+ except Exception:
327
+ pass
328
+
329
+ # Try real GPU query via nvidia-smi if available (never fake!)
330
+ try:
331
+ if shutil.which("nvidia-smi"):
332
+ out = subprocess.check_output(
333
+ ["nvidia-smi", "--query-gpu=name,memory.used,memory.total", "--format=csv,noheader,nounits"],
334
+ timeout=1.5, text=True
335
+ )
336
+ lines = [l.strip() for l in out.splitlines() if l.strip()]
337
+ if lines:
338
+ parts = [p.strip() for p in lines[0].split(",")]
339
+ if len(parts) >= 3:
340
+ gpu_name = parts[0]
341
+ gpu_used_gb = round(float(parts[1]) / 1024.0, 2)
342
+ gpu_total_gb = round(float(parts[2]) / 1024.0, 2)
343
+ except Exception:
344
+ pass
345
+
346
+ # Thresholds per spec:
347
+ # <70% normal, 70-85% elevated, 85-95% high, >95% critical
348
+ warn_level = "normal"
349
+ if sys_pct > 95.0:
350
+ warn_level = "critical"
351
+ elif sys_pct > 85.0:
352
+ warn_level = "high"
353
+ elif sys_pct > 70.0:
354
+ warn_level = "elevated"
355
+
356
+ sample = ResourceSample(
357
+ timestamp=time.time(),
358
+ system_ram_used_gb=sys_used_gb,
359
+ system_ram_total_gb=sys_total_gb,
360
+ system_ram_percent=sys_pct,
361
+ process_ram_mb=proc_mb,
362
+ cpu_percent=cpu_pct,
363
+ disk_percent=disk_pct,
364
+ gpu_vram_used_gb=gpu_used_gb,
365
+ gpu_vram_total_gb=gpu_total_gb,
366
+ gpu_name=gpu_name,
367
+ warning_level=warn_level,
368
+ )
369
+
370
+ with self._lock:
371
+ self._latest_sample = sample
372
+
373
+ # Check threshold warnings with cooldown
374
+ now = time.time()
375
+ if warn_level != "normal":
376
+ if (now - self._last_warning_time > self._cooldown_sec) or (warn_level != self._last_warning_level and warn_level in ("high", "critical")):
377
+ self._last_warning_time = now
378
+ self._last_warning_level = warn_level
379
+ try:
380
+ mgr = globals().get("manager")
381
+ if mgr:
382
+ mgr.create(
383
+ type=TYPE_SYSTEM,
384
+ action="resource_warning",
385
+ title=f"⚠ System RAM {warn_level.upper()}: {sys_used_gb:.1f}/{sys_total_gb:.1f} GB ({sys_pct:.1f}%)",
386
+ status=STATUS_WARNING,
387
+ details=f"System RAM: {sys_used_gb:.1f}/{sys_total_gb:.1f} GB ({sys_pct:.1f}%), CAT Process: {proc_mb:.1f} MB, CPU: {cpu_pct:.1f}%",
388
+ event_type=EVENT_SYSTEM_WARNING,
389
+ category=CAT_SYSTEM,
390
+ phase=PHASE_IMPLEMENTATION,
391
+ )
392
+ except Exception:
393
+ pass
394
+
395
+ return sample
396
+
397
+ def start(self):
398
+ with self._lock:
399
+ if self._running:
400
+ return
401
+ self._running = True
402
+ self._thread = threading.Thread(target=self._run_loop, daemon=True, name="CCT-ResourceMonitor")
403
+ self._thread.start()
404
+
405
+ def stop(self):
406
+ with self._lock:
407
+ self._running = False
408
+
409
+ def _run_loop(self):
410
+ while self._running:
411
+ try:
412
+ sample = self.sample_now()
413
+ if self._on_sample:
414
+ self._on_sample(sample)
415
+ except Exception as e:
416
+ logger.debug("Resource sampler loop error: %s", e)
417
+ time.sleep(self.interval_sec)
418
+
419
+ def get_latest(self) -> ResourceSample:
420
+ with self._lock:
421
+ if self._latest_sample is not None:
422
+ return self._latest_sample
423
+ return self.sample_now()
424
+
425
+
426
+ @dataclass
427
+ class Activity:
428
+ id: str
429
+ type: str # activity_type
430
+ action: str
431
+ status: str
432
+ title: str
433
+ details: str = ""
434
+ file: str = ""
435
+ command: str = ""
436
+ tool: str = ""
437
+ result: str = ""
438
+ timestamp: float = field(default_factory=time.time) # updated_time
439
+ duration_ms: int = 0
440
+ turn_id: str = ""
441
+ # internal helpers not in spec but needed for UI
442
+ start_time: float = field(default_factory=time.time)
443
+ output_lines: List[str] = field(default_factory=list)
444
+ expanded: bool = False
445
+ parent_id: str = ""
446
+ progress: Optional[float] = None # nullable per spec
447
+ # --- canonical category & phase ---
448
+ category: str = ""
449
+ phase: str = ""
450
+ # --- execution details ---
451
+ stdout: str = ""
452
+ stderr: str = ""
453
+ exit_code: Optional[int] = None
454
+ target_path: str = ""
455
+ line_count: Optional[int] = None
456
+ diff_summary: str = ""
457
+ query: str = ""
458
+ match_count: Optional[int] = None
459
+ permission_req: Optional[Dict[str, Any]] = None
460
+ # --- spec-required extended fields (all real, never fake) ---
461
+ description: str = "" # alias for details, human-readable
462
+ updated_time: float = field(default_factory=time.time)
463
+ end_time: Optional[float] = None
464
+ model: str = "" # real provider model, e.g. nvidia/nemotron-3-ultra-550b
465
+ provider: str = "" # real provider id, e.g. nvidia
466
+ workspace: str = "" # real workspace path
467
+ current_step: str = "" # real current step description
468
+ error: str = "" # real error message when FAILED
469
+ cancellable: bool = True
470
+ metadata: Dict[str, Any] = field(default_factory=dict)
471
+ # --- 100% real event schema extensions ---
472
+ event_id: str = ""
473
+ task_id: str = ""
474
+ parent_event_id: str = ""
475
+ event_type: str = ""
476
+ source: str = ""
477
+ duration: float = 0.0
478
+ operation_id: str = ""
479
+ pid: Optional[int] = None
480
+ lines_added: int = 0
481
+ lines_removed: int = 0
482
+
483
+ def __post_init__(self):
484
+ # keep aliases in sync - description/details, timestamp/updated_time
485
+ if self.description and not self.details:
486
+ self.details = self.description
487
+ elif self.details and not self.description:
488
+ self.description = self.details
489
+ if not self.updated_time:
490
+ self.updated_time = self.timestamp
491
+ else:
492
+ self.timestamp = self.updated_time
493
+ if self.file and not self.target_path:
494
+ self.target_path = self.file
495
+ elif self.target_path and not self.file:
496
+ self.file = self.target_path
497
+ # keep event schema aliases in sync
498
+ if not self.event_id:
499
+ self.event_id = self.id
500
+ elif not self.id:
501
+ self.id = self.event_id
502
+ if not self.task_id:
503
+ self.task_id = self.turn_id
504
+ elif not self.turn_id:
505
+ self.turn_id = self.task_id
506
+ if not self.parent_event_id:
507
+ self.parent_event_id = self.parent_id
508
+ elif not self.parent_id:
509
+ self.parent_id = self.parent_event_id
510
+ if not self.event_type:
511
+ self.event_type = f"{self.type}.{self.action}" if self.type and self.action else (self.type or "task.operation")
512
+ if self.duration == 0 and self.duration_ms > 0:
513
+ self.duration = self.duration_ms / 1000.0
514
+ elif self.duration > 0 and self.duration_ms == 0:
515
+ self.duration_ms = int(self.duration * 1000)
516
+ # auto-infer category if not given
517
+ if not self.category:
518
+ if self.tool in ("search_workspace", "list_directory", "web_search", "deep_research") or self.type == TYPE_SEARCH:
519
+ self.category = CAT_SEARCH
520
+ elif self.tool in ("read_file", "read_attachment", "archive_list") or (self.type == TYPE_FILE and self.action in ("read", "list")):
521
+ self.category = CAT_FILE_READ
522
+ elif self.tool in ("write_file", "create_folder", "delete_file", "rename_file", "edit_file") or (self.type == TYPE_FILE and self.action in ("write", "edit", "create", "delete", "rename")):
523
+ self.category = CAT_EDIT
524
+ elif self.tool in ("run_terminal", "install_packages", "device_action") or self.type == TYPE_COMMAND:
525
+ self.category = CAT_TERMINAL
526
+ elif self.tool in ("run_tests",) or self.action == "test":
527
+ self.category = CAT_TEST
528
+ elif self.tool in ("run_build", "archive_validate") or self.action == "build":
529
+ self.category = CAT_BUILD
530
+ elif self.type == TYPE_PROVIDER:
531
+ self.category = CAT_MODEL
532
+ elif self.type == TYPE_PERMISSION:
533
+ self.category = CAT_SYSTEM
534
+ else:
535
+ self.category = CAT_ANALYSIS
536
+ # auto-infer phase if not given
537
+ if not self.phase:
538
+ if self.category in (CAT_SEARCH, CAT_FILE_READ):
539
+ self.phase = PHASE_DISCOVERY
540
+ elif self.category in (CAT_ANALYSIS, CAT_PLANNING, CAT_MODEL):
541
+ self.phase = PHASE_ANALYSIS
542
+ elif self.category in (CAT_EDIT, CAT_TERMINAL, CAT_GIT):
543
+ self.phase = PHASE_IMPLEMENTATION
544
+ elif self.category in (CAT_TEST, CAT_BUILD):
545
+ self.phase = PHASE_VALIDATION
546
+ else:
547
+ self.phase = PHASE_IMPLEMENTATION
548
+
549
+ @property
550
+ def activity_id(self) -> str:
551
+ return self.id
552
+
553
+ @property
554
+ def activity_type(self) -> str:
555
+ return self.type
556
+
557
+ @property
558
+ def elapsed_time(self) -> float:
559
+ return self.elapsed_ms / 1000.0
560
+
561
+ def to_dict(self) -> Dict[str, Any]:
562
+ d = asdict(self)
563
+ # spec expects timestamp as iso or float; keep float for now
564
+ # add computed elapsed
565
+ d["elapsed_time"] = self.elapsed_time
566
+ d["activity_id"] = self.id
567
+ d["activity_type"] = self.type
568
+ return d
569
+
570
+ @property
571
+ def icon(self) -> str:
572
+ return STATUS_ICONS.get(self.status, "·")
573
+
574
+ @property
575
+ def elapsed_ms(self) -> int:
576
+ if self.status in (COMPLETED, FAILED, CANCELLED, WARNING):
577
+ return self.duration_ms
578
+ return int((time.time() - self.start_time) * 1000)
579
+
580
+ @property
581
+ def summary(self) -> str:
582
+ """Human-readable one-liner for collapsed history."""
583
+ base = f"{self.icon} {self.title}"
584
+ if self.result:
585
+ base += f" — {self.result[:80]}"
586
+ elif self.details:
587
+ base += f" — {self.details[:80]}"
588
+ return base
589
+
590
+
591
+ class ActivityManager:
592
+ """
593
+ Central activity bus. Thread-safe. Backend emits, UI subscribes.
594
+ Synchronous delivery (callbacks fire in emit thread) — UI callbacks
595
+ must use call_from_thread if they touch Textual widgets.
596
+ """
597
+
598
+ def __init__(self):
599
+ self._activities: Dict[str, Activity] = {}
600
+ self._turn_order: Dict[str, List[str]] = {} # turn_id -> [activity_id] in creation order
601
+ self._global_order: List[str] = [] # all activity ids in order
602
+ self._subscribers: List[Callable[[Activity, str], None]] = []
603
+ self._lock = threading.RLock()
604
+ self._throttle_last: Dict[str, float] = {} # activity_id -> last notify time
605
+ self._pending_throttle: Dict[str, Activity] = {}
606
+ # deduplication & mapping
607
+ self._operation_map: Dict[str, str] = {} # operation_id -> activity_id
608
+ self._active_dedup_keys: Dict[str, str] = {} # dedup_key -> activity_id
609
+ # real-time resource monitor daemon
610
+ self.resource_monitor = ResourceMonitor(interval_sec=1.0)
611
+ try:
612
+ self.resource_monitor.start()
613
+ except Exception as e:
614
+ logger.debug("Failed to start resource monitor daemon: %s", e)
615
+
616
+ # ── subscription ───────────────────────────────────────────────────
617
+ def subscribe(self, callback: Callable[[Activity, str], None]) -> Callable:
618
+ with self._lock:
619
+ self._subscribers.append(callback)
620
+ return callback
621
+
622
+ def unsubscribe(self, callback: Callable) -> None:
623
+ with self._lock:
624
+ if callback in self._subscribers:
625
+ self._subscribers.remove(callback)
626
+
627
+ def _notify(self, activity: Activity, change: str) -> None:
628
+ # change: "created" | "updated" | "removed"
629
+ subs = list(self._subscribers)
630
+ for cb in subs:
631
+ try:
632
+ cb(activity, change)
633
+ except Exception as e:
634
+ logger.debug("Activity subscriber error: %s", e)
635
+
636
+ # ── creation ───────────────────────────────────────────────────────
637
+ def create(
638
+ self,
639
+ type: str,
640
+ action: str,
641
+ title: str,
642
+ status: str = RUNNING,
643
+ details: str = "",
644
+ file: str = "",
645
+ command: str = "",
646
+ tool: str = "",
647
+ result: str = "",
648
+ turn_id: str = "",
649
+ parent_id: str = "",
650
+ duration_ms: int = 0,
651
+ # spec extended fields - all from real runtime, never fake
652
+ category: str = "",
653
+ phase: str = "",
654
+ stdout: str = "",
655
+ stderr: str = "",
656
+ exit_code: Optional[int] = None,
657
+ target_path: str = "",
658
+ line_count: Optional[int] = None,
659
+ diff_summary: str = "",
660
+ query: str = "",
661
+ match_count: Optional[int] = None,
662
+ permission_req: Optional[Dict[str, Any]] = None,
663
+ description: str = "",
664
+ progress: Optional[float] = None,
665
+ model: str = "",
666
+ provider: str = "",
667
+ workspace: str = "",
668
+ current_step: str = "",
669
+ error: str = "",
670
+ cancellable: bool = True,
671
+ metadata: Optional[Dict[str, Any]] = None,
672
+ id: Optional[str] = "",
673
+ # 100% real event schema extensions
674
+ operation_id: str = "",
675
+ event_id: str = "",
676
+ task_id: str = "",
677
+ parent_event_id: str = "",
678
+ event_type: str = "",
679
+ source: str = "",
680
+ pid: Optional[int] = None,
681
+ lines_added: int = 0,
682
+ lines_removed: int = 0,
683
+ ) -> Activity:
684
+ if not turn_id:
685
+ turn_id = task_id or get_current_turn()
686
+ if not parent_id and parent_event_id:
687
+ parent_id = parent_event_id
688
+ if not id and event_id:
689
+ id = event_id
690
+ if status not in STATUSES:
691
+ status = RUNNING
692
+
693
+ now = time.time()
694
+ # description aliases details
695
+ if description and not details:
696
+ details = description
697
+ elif details and not description:
698
+ description = details
699
+
700
+ # ── Deduplication Guard: Check existing operation or running signature ──
701
+ with self._lock:
702
+ if operation_id and operation_id in self._operation_map:
703
+ existing_id = self._operation_map[operation_id]
704
+ existing = self._activities.get(existing_id)
705
+ if existing:
706
+ # Update existing activity instead of creating duplicate card
707
+ updates: Dict[str, Any] = {"title": title, "status": status}
708
+ if details:
709
+ updates["details"] = details
710
+ if result:
711
+ updates["result"] = result
712
+ if duration_ms > 0:
713
+ updates["duration_ms"] = duration_ms
714
+ if exit_code is not None:
715
+ updates["exit_code"] = exit_code
716
+ if lines_added > 0:
717
+ updates["lines_added"] = lines_added
718
+ if lines_removed > 0:
719
+ updates["lines_removed"] = lines_removed
720
+ return self.update(existing_id, **updates) or existing
721
+
722
+ # Signature deduplication for active/running operations in the same turn
723
+ target_key = target_path or file or command or title
724
+ dedup_key = f"{turn_id}:{type}:{tool}:{target_key}"
725
+ if status in (STATUS_RUNNING, STATUS_PENDING, STATUS_STARTED, STATUS_QUEUED) and dedup_key in self._active_dedup_keys:
726
+ existing_id = self._active_dedup_keys[dedup_key]
727
+ existing = self._activities.get(existing_id)
728
+ if existing and existing.status in (STATUS_RUNNING, STATUS_PENDING, STATUS_STARTED, STATUS_QUEUED):
729
+ if details and details != existing.details:
730
+ existing.details = details
731
+ existing.description = details
732
+ if title and title != existing.title:
733
+ existing.title = title
734
+ if progress is not None:
735
+ existing.progress = progress
736
+ existing.updated_time = now
737
+ self._notify(existing, "updated")
738
+ return existing
739
+
740
+ # Generate canonical EVT-<hex> if not specified
741
+ act_id = id or f"EVT-{uuid.uuid4().hex[:8].upper()}"
742
+
743
+ # try to fill workspace/model/provider from real context if not provided
744
+ if not workspace:
745
+ try:
746
+ from . import workspace as _ws
747
+ workspace = _ws.root_dir() or ""
748
+ except Exception:
749
+ pass
750
+ if not provider or not model:
751
+ try:
752
+ from . import aicore as _ac
753
+ cfg = _ac.load_config()
754
+ if not provider:
755
+ provider = cfg.get("provider") or ""
756
+ if not model:
757
+ model = cfg.get("model") or ""
758
+ except Exception:
759
+ pass
760
+
761
+ act = Activity(
762
+ id=act_id,
763
+ type=type,
764
+ action=action,
765
+ status=status,
766
+ title=title,
767
+ details=details,
768
+ description=description,
769
+ file=file,
770
+ command=command,
771
+ tool=tool,
772
+ result=result,
773
+ timestamp=now,
774
+ updated_time=now,
775
+ start_time=now,
776
+ duration_ms=duration_ms,
777
+ turn_id=turn_id or "",
778
+ parent_id=parent_id or "",
779
+ progress=progress,
780
+ category=category,
781
+ phase=phase,
782
+ stdout=stdout,
783
+ stderr=stderr,
784
+ exit_code=exit_code,
785
+ target_path=target_path or file,
786
+ line_count=line_count,
787
+ diff_summary=diff_summary,
788
+ query=query,
789
+ match_count=match_count,
790
+ permission_req=permission_req,
791
+ model=model,
792
+ provider=provider,
793
+ workspace=workspace,
794
+ current_step=current_step,
795
+ error=error,
796
+ cancellable=cancellable,
797
+ metadata=metadata or {},
798
+ # 100% real event fields
799
+ event_id=act_id,
800
+ task_id=turn_id or "",
801
+ parent_event_id=parent_id or "",
802
+ event_type=event_type or f"{type}.{action}",
803
+ source=source or "cat.core",
804
+ duration=duration_ms / 1000.0 if duration_ms > 0 else 0.0,
805
+ operation_id=operation_id,
806
+ pid=pid,
807
+ lines_added=lines_added,
808
+ lines_removed=lines_removed,
809
+ )
810
+
811
+ with self._lock:
812
+ self._activities[act_id] = act
813
+ self._global_order.append(act_id)
814
+ if operation_id:
815
+ self._operation_map[operation_id] = act_id
816
+ if status in (STATUS_RUNNING, STATUS_PENDING, STATUS_STARTED, STATUS_QUEUED):
817
+ self._active_dedup_keys[dedup_key] = act_id
818
+
819
+ if turn_id:
820
+ self._turn_order.setdefault(turn_id, []).append(act_id)
821
+ # bound per-turn
822
+ if len(self._turn_order[turn_id]) > _MAX_PER_TURN:
823
+ oldest = self._turn_order[turn_id].pop(0)
824
+ self._activities.pop(oldest, None)
825
+ if oldest in self._global_order:
826
+ self._global_order.remove(oldest)
827
+ # bound total
828
+ if len(self._global_order) > _MAX_TOTAL:
829
+ oldest = self._global_order.pop(0)
830
+ self._activities.pop(oldest, None)
831
+ # also remove from turn index if there
832
+ for tid, lst in list(self._turn_order.items()):
833
+ if oldest in lst:
834
+ lst.remove(oldest)
835
+ if not lst:
836
+ self._turn_order.pop(tid, None)
837
+ break
838
+
839
+ self._notify(act, "created")
840
+ # also mirror to legacy event_stream for debugging/logging
841
+ try:
842
+ from . import event_stream as _es
843
+ _es.stream.emit("activity_created", source="activity", activity_id=act_id, title=title, status=status, turn_id=turn_id)
844
+ except Exception:
845
+ pass
846
+ return act
847
+
848
+ # ── update ─────────────────────────────────────────────────────────
849
+ def update(
850
+ self,
851
+ activity_id: str,
852
+ status: Optional[str] = None,
853
+ title: Optional[str] = None,
854
+ details: Optional[str] = None,
855
+ file: Optional[str] = None,
856
+ command: Optional[str] = None,
857
+ tool: Optional[str] = None,
858
+ result: Optional[str] = None,
859
+ duration_ms: Optional[int] = None,
860
+ expanded: Optional[bool] = None,
861
+ progress: Optional[float] = None,
862
+ # extended spec fields
863
+ category: Optional[str] = None,
864
+ phase: Optional[str] = None,
865
+ stdout: Optional[str] = None,
866
+ stderr: Optional[str] = None,
867
+ exit_code: Optional[int] = None,
868
+ target_path: Optional[str] = None,
869
+ line_count: Optional[int] = None,
870
+ diff_summary: Optional[str] = None,
871
+ query: Optional[str] = None,
872
+ match_count: Optional[int] = None,
873
+ permission_req: Optional[Dict[str, Any]] = None,
874
+ description: Optional[str] = None,
875
+ model: Optional[str] = None,
876
+ provider: Optional[str] = None,
877
+ workspace: Optional[str] = None,
878
+ current_step: Optional[str] = None,
879
+ error: Optional[str] = None,
880
+ cancellable: Optional[bool] = None,
881
+ metadata: Optional[Dict[str, Any]] = None,
882
+ # real event fields
883
+ event_type: Optional[str] = None,
884
+ source: Optional[str] = None,
885
+ duration: Optional[float] = None,
886
+ operation_id: Optional[str] = None,
887
+ pid: Optional[int] = None,
888
+ lines_added: Optional[int] = None,
889
+ lines_removed: Optional[int] = None,
890
+ ) -> Optional[Activity]:
891
+ with self._lock:
892
+ act = self._activities.get(activity_id)
893
+ if act is None:
894
+ return None
895
+ if status is not None:
896
+ act.status = status
897
+ # compute duration on terminal states if not supplied
898
+ if status in (COMPLETED, FAILED, CANCELLED, WARNING, PAUSED, WAITING,
899
+ STATUS_COMPLETED, STATUS_FAILED, STATUS_CANCELLED, STATUS_WARNING):
900
+ if duration_ms is not None:
901
+ act.duration_ms = duration_ms
902
+ act.duration = duration_ms / 1000.0
903
+ elif duration is not None:
904
+ act.duration = duration
905
+ act.duration_ms = int(duration * 1000)
906
+ elif act.duration_ms == 0:
907
+ act.duration_ms = int((time.time() - act.start_time) * 1000)
908
+ act.duration = act.duration_ms / 1000.0
909
+ act.end_time = time.time()
910
+ if status in (FAILED, STATUS_FAILED) and error is None and act.error == "" and result:
911
+ # capture real error from result if not provided
912
+ act.error = result[:500]
913
+
914
+ # Release deduplication lock for terminal states
915
+ for k, aid in list(self._active_dedup_keys.items()):
916
+ if aid == activity_id:
917
+ self._active_dedup_keys.pop(k, None)
918
+ else:
919
+ act.end_time = None
920
+ if title is not None:
921
+ act.title = title
922
+ if details is not None:
923
+ act.details = details
924
+ act.description = details
925
+ if description is not None:
926
+ act.description = description
927
+ act.details = description
928
+ if file is not None:
929
+ act.file = file
930
+ if target_path is None:
931
+ act.target_path = file
932
+ if target_path is not None:
933
+ act.target_path = target_path
934
+ act.file = target_path
935
+ if command is not None:
936
+ act.command = command
937
+ if tool is not None:
938
+ act.tool = tool
939
+ if result is not None:
940
+ act.result = result
941
+ if category is not None:
942
+ act.category = category
943
+ if phase is not None:
944
+ act.phase = phase
945
+ if stdout is not None:
946
+ act.stdout = stdout
947
+ if stderr is not None:
948
+ act.stderr = stderr
949
+ if exit_code is not None:
950
+ act.exit_code = exit_code
951
+ if line_count is not None:
952
+ act.line_count = line_count
953
+ if diff_summary is not None:
954
+ act.diff_summary = diff_summary
955
+ if query is not None:
956
+ act.query = query
957
+ if match_count is not None:
958
+ act.match_count = match_count
959
+ if permission_req is not None:
960
+ act.permission_req = permission_req
961
+ if duration_ms is not None and status is None:
962
+ act.duration_ms = duration_ms
963
+ act.duration = duration_ms / 1000.0
964
+ if duration is not None and status is None:
965
+ act.duration = duration
966
+ act.duration_ms = int(duration * 1000)
967
+ if expanded is not None:
968
+ act.expanded = expanded
969
+ if progress is not None:
970
+ try:
971
+ v = float(progress)
972
+ act.progress = max(0.0, min(1.0, v))
973
+ except Exception:
974
+ pass
975
+ if model is not None:
976
+ act.model = model
977
+ if provider is not None:
978
+ act.provider = provider
979
+ if workspace is not None:
980
+ act.workspace = workspace
981
+ if current_step is not None:
982
+ act.current_step = current_step
983
+ if error is not None:
984
+ act.error = error
985
+ if cancellable is not None:
986
+ act.cancellable = cancellable
987
+ if metadata is not None:
988
+ act.metadata.update(metadata)
989
+ if event_type is not None:
990
+ act.event_type = event_type
991
+ if source is not None:
992
+ act.source = source
993
+ if operation_id is not None:
994
+ act.operation_id = operation_id
995
+ self._operation_map[operation_id] = activity_id
996
+ if pid is not None:
997
+ act.pid = pid
998
+ if lines_added is not None:
999
+ act.lines_added = lines_added
1000
+ if lines_removed is not None:
1001
+ act.lines_removed = lines_removed
1002
+
1003
+ act.timestamp = time.time()
1004
+ act.updated_time = act.timestamp
1005
+
1006
+ # throttling: if high-frequency updates for same id, coalesce within window
1007
+ now = time.time()
1008
+ last = self._throttle_last.get(activity_id, 0)
1009
+ if status in (COMPLETED, FAILED, CANCELLED, WARNING, WAITING, PAUSED,
1010
+ STATUS_COMPLETED, STATUS_FAILED, STATUS_CANCELLED, STATUS_WARNING) or (now - last) >= _THROTTLE_WINDOW:
1011
+ self._throttle_last[activity_id] = now
1012
+ self._pending_throttle.pop(activity_id, None)
1013
+ self._notify(act, "updated")
1014
+ else:
1015
+ # store pending and schedule a delayed flush after the throttle window
1016
+ self._pending_throttle[activity_id] = act
1017
+ if not getattr(self, '_flush_timer_active', False):
1018
+ self._flush_timer_active = True
1019
+ def _deferred_flush():
1020
+ self._flush_timer_active = False
1021
+ self.flush_throttled()
1022
+ timer = threading.Timer(_THROTTLE_WINDOW, _deferred_flush)
1023
+ timer.daemon = True
1024
+ timer.start()
1025
+ try:
1026
+ from . import event_stream as _es
1027
+ _es.stream.emit("activity_updated", source="activity", activity_id=activity_id, status=act.status, title=act.title)
1028
+ except Exception:
1029
+ pass
1030
+ return act
1031
+
1032
+ def check_stale_activities(self, stale_threshold_sec: float = 15.0) -> List[Activity]:
1033
+ """
1034
+ Watchdog: detects running/pending activities with no heartbeat or update for
1035
+ longer than stale_threshold_sec. Marks them as WARNING to prevent stuck UI spinners.
1036
+ """
1037
+ now = time.time()
1038
+ stale: List[Activity] = []
1039
+ with self._lock:
1040
+ for act in self._activities.values():
1041
+ if act.status in (STATUS_RUNNING, STATUS_PENDING, STATUS_STARTED, RUNNING, PENDING) and (now - act.updated_time) > stale_threshold_sec:
1042
+ stale.append(act)
1043
+
1044
+ for act in stale:
1045
+ self.update(
1046
+ act.id,
1047
+ status=STATUS_WARNING,
1048
+ result=act.result or f"Operation timed out ({stale_threshold_sec:.0f}s with no updates).",
1049
+ error=act.error or "Execution timed out / unresponsive",
1050
+ )
1051
+ return stale
1052
+
1053
+ def get_resource_sample(self) -> ResourceSample:
1054
+ """Fetch latest actual system/process resource sample."""
1055
+ return self.resource_monitor.get_latest()
1056
+
1057
+ def get_session_summary(self, turn_id: str = "") -> Dict[str, Any]:
1058
+ """
1059
+ Returns an authentic, non-simulated execution summary of all events for a turn or session.
1060
+ """
1061
+ with self._lock:
1062
+ if turn_id:
1063
+ acts = self.get_for_turn(turn_id)
1064
+ else:
1065
+ acts = self.get_all()
1066
+
1067
+ sample = self.get_resource_sample()
1068
+ if not acts:
1069
+ return {
1070
+ "count": 0,
1071
+ "total_events": 0,
1072
+ "duration_ms": 0,
1073
+ "duration_str": "0s",
1074
+ "completed": 0,
1075
+ "failed": 0,
1076
+ "cancelled": 0,
1077
+ "warning": 0,
1078
+ "running": 0,
1079
+ "waiting": 0,
1080
+ "files_read": [],
1081
+ "files_modified": [],
1082
+ "commands_executed": [],
1083
+ "tools_used": [],
1084
+ "errors": [],
1085
+ "ram_used_gb": sample.system_ram_used_gb,
1086
+ "ram_total_gb": sample.system_ram_total_gb,
1087
+ "ram_percent": sample.system_ram_percent,
1088
+ "process_ram_mb": sample.process_ram_mb,
1089
+ "cpu_percent": sample.cpu_percent,
1090
+ "gpu_name": sample.gpu_name,
1091
+ }
1092
+
1093
+ start_time = min(a.start_time for a in acts)
1094
+ end_time = max(a.updated_time for a in acts)
1095
+ total_ms = int((end_time - start_time) * 1000)
1096
+
1097
+ files_read = sorted(list({a.file or a.target_path for a in acts if a.category == CAT_FILE_READ and (a.file or a.target_path)}))
1098
+ files_mod = sorted(list({a.file or a.target_path for a in acts if a.category == CAT_EDIT and (a.file or a.target_path)}))
1099
+ commands = sorted(list({a.command for a in acts if a.command}))
1100
+ tools = sorted(list({a.tool for a in acts if a.tool}))
1101
+ errors = [a.error or a.result for a in acts if a.status in (STATUS_FAILED, STATUS_WARNING, FAILED, WARNING) and (a.error or a.result)]
1102
+
1103
+ return {
1104
+ "count": len(acts),
1105
+ "total_events": len(acts),
1106
+ "duration_ms": total_ms,
1107
+ "duration_str": _fmt_duration(total_ms),
1108
+ "start_time": start_time,
1109
+ "end_time": end_time,
1110
+ "completed": sum(1 for a in acts if a.status in (STATUS_COMPLETED, COMPLETED)),
1111
+ "failed": sum(1 for a in acts if a.status in (STATUS_FAILED, FAILED)),
1112
+ "cancelled": sum(1 for a in acts if a.status in (STATUS_CANCELLED, CANCELLED)),
1113
+ "warning": sum(1 for a in acts if a.status in (STATUS_WARNING, WARNING)),
1114
+ "running": sum(1 for a in acts if a.status in (STATUS_RUNNING, STATUS_PENDING, STATUS_STARTED, RUNNING, PENDING)),
1115
+ "waiting": sum(1 for a in acts if a.status in (STATUS_WAITING, STATUS_WAITING_PERMISSION, WAITING, "waiting")),
1116
+ "files_read": files_read,
1117
+ "files_modified": files_mod,
1118
+ "commands_executed": commands,
1119
+ "tools_used": tools,
1120
+ "errors": errors,
1121
+ "ram_used_gb": sample.system_ram_used_gb,
1122
+ "ram_total_gb": sample.system_ram_total_gb,
1123
+ "ram_percent": sample.system_ram_percent,
1124
+ "process_ram_mb": sample.process_ram_mb,
1125
+ "cpu_percent": sample.cpu_percent,
1126
+ "gpu_name": sample.gpu_name,
1127
+ }
1128
+
1129
+ def append_output(self, activity_id: str, line: str, max_lines: int = 200) -> Optional[Activity]:
1130
+ with self._lock:
1131
+ act = self._activities.get(activity_id)
1132
+ if act is None:
1133
+ return None
1134
+ act.output_lines.append(line)
1135
+ if len(act.output_lines) > max_lines:
1136
+ # keep tail
1137
+ act.output_lines = act.output_lines[-max_lines:]
1138
+ act.timestamp = time.time()
1139
+ # throttled output: directly notify but UI should coalesce
1140
+ self._notify(act, "updated")
1141
+ return act
1142
+
1143
+ def get(self, activity_id: str) -> Optional[Activity]:
1144
+ with self._lock:
1145
+ return self._activities.get(activity_id)
1146
+
1147
+ def get_for_turn(self, turn_id: str, limit: Optional[int] = None) -> List[Activity]:
1148
+ with self._lock:
1149
+ ids = list(self._turn_order.get(turn_id, []))
1150
+ if limit is not None:
1151
+ ids = ids[-limit:]
1152
+ return [self._activities[i] for i in ids if i in self._activities]
1153
+
1154
+ def get_all(self, limit: Optional[int] = None) -> List[Activity]:
1155
+ with self._lock:
1156
+ ids = list(self._global_order)
1157
+ if limit is not None:
1158
+ ids = ids[-limit:]
1159
+ return [self._activities[i] for i in ids if i in self._activities]
1160
+
1161
+ def get_recent(self, limit: int = 50) -> List[Activity]:
1162
+ return self.get_all(limit=limit)
1163
+
1164
+ def turn_summary(self, turn_id: str) -> Dict[str, Any]:
1165
+ return self.get_session_summary(turn_id)
1166
+
1167
+ def cancel_turn(self, turn_id: str) -> int:
1168
+ """Transition all running/pending activities for turn_id to CANCELLED."""
1169
+ count = 0
1170
+ with self._lock:
1171
+ ids = list(self._turn_order.get(turn_id, []))
1172
+ for aid in ids:
1173
+ act = self._activities.get(aid)
1174
+ if act and act.status in (PENDING, RUNNING, PAUSED, STATUS_RUNNING, STATUS_PENDING, STATUS_STARTED):
1175
+ act.status = CANCELLED
1176
+ act.result = act.result or "Cancelled"
1177
+ act.duration_ms = int((time.time() - act.start_time) * 1000)
1178
+ act.duration = act.duration_ms / 1000.0
1179
+ act.timestamp = time.time()
1180
+ count += 1
1181
+ if count:
1182
+ for aid in self._turn_order.get(turn_id, []):
1183
+ act = self._activities.get(aid)
1184
+ if act and act.status in (CANCELLED, STATUS_CANCELLED):
1185
+ self._notify(act, "updated")
1186
+ return count
1187
+
1188
+ def clear_turn(self, turn_id: str) -> int:
1189
+ with self._lock:
1190
+ ids = self._turn_order.pop(turn_id, [])
1191
+ for aid in ids:
1192
+ self._activities.pop(aid, None)
1193
+ if aid in self._global_order:
1194
+ self._global_order.remove(aid)
1195
+ return len(ids)
1196
+
1197
+ def clear_all(self) -> None:
1198
+ with self._lock:
1199
+ self._activities.clear()
1200
+ self._turn_order.clear()
1201
+ self._global_order.clear()
1202
+ self._throttle_last.clear()
1203
+ self._pending_throttle.clear()
1204
+ self._operation_map.clear()
1205
+ self._active_dedup_keys.clear()
1206
+
1207
+ def flush_throttled(self) -> None:
1208
+ pending = list(self._pending_throttle.values())
1209
+ self._pending_throttle.clear()
1210
+ for act in pending:
1211
+ self._notify(act, "updated")
1212
+ self._throttle_last[act.id] = time.time()
1213
+
1214
+ # ── global singleton ───────────────────────────────────────────────────
1215
+ manager = ActivityManager()
1216
+
1217
+ # ── convenience helpers ────────────────────────────────────────────────
1218
+ def create_activity(type: str, action: str, title: str, **kwargs) -> Activity:
1219
+ return manager.create(type=type, action=action, title=title, **kwargs)
1220
+
1221
+ def update_activity(activity_id: str, **kwargs) -> Optional[Activity]:
1222
+ return manager.update(activity_id, **kwargs)
1223
+
1224
+ def append_output(activity_id: str, line: str) -> Optional[Activity]:
1225
+ return manager.append_output(activity_id, line)
1226
+
1227
+ def get_for_turn(turn_id: str) -> List[Activity]:
1228
+ return manager.get_for_turn(turn_id)
1229
+
1230
+ # ── context manager for scoped activities ──────────────────────────────
1231
+ class ActivitySpan:
1232
+ """
1233
+ Context manager that creates a RUNNING activity on entry and
1234
+ completes/fails it on exit. Use when the real operation is bracketed:
1235
+
1236
+ with ActivitySpan(type=TYPE_FILE, action="read", title="Reading x", turn_id=tid) as act:
1237
+ ... do real IO ...
1238
+ act.details = "2,184 lines"
1239
+ On exception, marks FAILED automatically.
1240
+ """
1241
+
1242
+ def __init__(self, type: str, action: str, title: str, turn_id: str = "", **kwargs):
1243
+ self.type = type
1244
+ self.action = action
1245
+ self.title = title
1246
+ self.turn_id = turn_id
1247
+ self.kwargs = kwargs
1248
+ self.activity: Optional[Activity] = None
1249
+
1250
+ def __enter__(self) -> Activity:
1251
+ self.activity = manager.create(
1252
+ type=self.type, action=self.action, title=self.title, status=RUNNING, turn_id=self.turn_id, **self.kwargs
1253
+ )
1254
+ return self.activity
1255
+
1256
+ def __exit__(self, exc_type, exc_val, exc_tb):
1257
+ if self.activity is None:
1258
+ return False
1259
+ if exc_type is not None:
1260
+ manager.update(self.activity.id, status=FAILED, result=str(exc_val)[:400] if exc_val else "Failed")
1261
+ else:
1262
+ # only auto-complete if still running/pending
1263
+ if self.activity.status in (RUNNING, PENDING):
1264
+ dur = int((time.time() - self.activity.start_time) * 1000)
1265
+ manager.update(self.activity.id, status=COMPLETED, duration_ms=dur)
1266
+ return False
1267
+
1268
+ # ── human helpers: map tool name -> activity meta ──────────────────────
1269
+ class ToolMeta(tuple):
1270
+ def __new__(cls, category: str, action: str, title: str, phase: str = PHASE_IMPLEMENTATION, type: str = ""):
1271
+ t = type or category.lower()
1272
+ return super().__new__(cls, (t, action, title))
1273
+
1274
+ def __init__(self, category: str, action: str, title: str, phase: str = PHASE_IMPLEMENTATION, type: str = ""):
1275
+ self.category = category
1276
+ self.action = action
1277
+ self.title = title
1278
+ self.phase = phase
1279
+ self.type = type or category.lower()
1280
+
1281
+ TOOL_ACTIVITY_META: Dict[str, ToolMeta] = {
1282
+ # search / discovery
1283
+ "search_workspace": ToolMeta(CAT_SEARCH, "search", "Searching workspace", PHASE_DISCOVERY, TYPE_SEARCH),
1284
+ "inspect_project": ToolMeta(CAT_ANALYSIS, "inspect", "Inspecting project", PHASE_DISCOVERY, TYPE_WORKSPACE),
1285
+ "list_directory": ToolMeta(CAT_SEARCH, "list", "Listing directory", PHASE_DISCOVERY, TYPE_FILE),
1286
+ "read_file": ToolMeta(CAT_FILE_READ, "read", "Reading file", PHASE_DISCOVERY, TYPE_FILE),
1287
+ "read_attachment": ToolMeta(CAT_FILE_READ, "read", "Reading attachment", PHASE_DISCOVERY, TYPE_FILE),
1288
+ # file mutation
1289
+ "write_file": ToolMeta(CAT_EDIT, "write", "Writing file", PHASE_IMPLEMENTATION, TYPE_FILE),
1290
+ "create_folder": ToolMeta(CAT_EDIT, "create", "Creating folder", PHASE_IMPLEMENTATION, TYPE_FILE),
1291
+ "delete_file": ToolMeta(CAT_EDIT, "delete", "Deleting file", PHASE_IMPLEMENTATION, TYPE_FILE),
1292
+ "rename_file": ToolMeta(CAT_EDIT, "rename", "Renaming file", PHASE_IMPLEMENTATION, TYPE_FILE),
1293
+ "edit_file": ToolMeta(CAT_EDIT, "edit", "Editing file", PHASE_IMPLEMENTATION, TYPE_FILE),
1294
+ "archive_list": ToolMeta(CAT_FILE_READ, "archive", "Listing archive", PHASE_DISCOVERY, TYPE_FILE),
1295
+ "archive_delete_entries": ToolMeta(CAT_EDIT, "archive", "Updating archive", PHASE_IMPLEMENTATION, TYPE_FILE),
1296
+ "archive_extract": ToolMeta(CAT_EDIT, "archive", "Extracting archive", PHASE_IMPLEMENTATION, TYPE_FILE),
1297
+ "archive_add_entries": ToolMeta(CAT_EDIT, "archive", "Adding to archive", PHASE_IMPLEMENTATION, TYPE_FILE),
1298
+ "archive_repack": ToolMeta(CAT_EDIT, "archive", "Repacking archive", PHASE_IMPLEMENTATION, TYPE_FILE),
1299
+ "archive_validate": ToolMeta(CAT_BUILD, "archive", "Validating archive", PHASE_VALIDATION, TYPE_FILE),
1300
+ # execution
1301
+ "run_terminal": ToolMeta(CAT_TERMINAL, "run", "Running command", PHASE_IMPLEMENTATION, TYPE_COMMAND),
1302
+ "run_build": ToolMeta(CAT_BUILD, "build", "Running build", PHASE_VALIDATION, TYPE_COMMAND),
1303
+ "run_tests": ToolMeta(CAT_TEST, "test", "Running tests", PHASE_VALIDATION, TYPE_COMMAND),
1304
+ "install_packages": ToolMeta(CAT_TERMINAL, "install", "Installing packages", PHASE_IMPLEMENTATION, TYPE_COMMAND),
1305
+ "device_action": ToolMeta(CAT_TERMINAL, "device", "Device action", PHASE_IMPLEMENTATION, TYPE_COMMAND),
1306
+ # chemistry / compute
1307
+ "calculate": ToolMeta(CAT_ANALYSIS, "calculate", "Calculating", PHASE_ANALYSIS, TYPE_TOOL),
1308
+ "solve_formula": ToolMeta(CAT_ANALYSIS, "solve", "Solving formula", PHASE_ANALYSIS, TYPE_TOOL),
1309
+ "solve_custom": ToolMeta(CAT_ANALYSIS, "solve", "Solving custom formula", PHASE_ANALYSIS, TYPE_TOOL),
1310
+ "list_formulas": ToolMeta(CAT_ANALYSIS, "list", "Listing formulas", PHASE_ANALYSIS, TYPE_TOOL),
1311
+ "generate_numerical": ToolMeta(CAT_ANALYSIS, "generate", "Generating numerical", PHASE_ANALYSIS, TYPE_TOOL),
1312
+ "plot_preset": ToolMeta(CAT_ANALYSIS, "plot", "Plotting preset", PHASE_ANALYSIS, TYPE_TOOL),
1313
+ "plot_function": ToolMeta(CAT_ANALYSIS, "plot", "Plotting function", PHASE_ANALYSIS, TYPE_TOOL),
1314
+ "plot_surface": ToolMeta(CAT_ANALYSIS, "plot", "Exporting surface", PHASE_ANALYSIS, TYPE_TOOL),
1315
+ "simulate_atom_2d": ToolMeta(CAT_ANALYSIS, "simulate", "Simulating atom (2D)", PHASE_ANALYSIS, TYPE_TOOL),
1316
+ "simulate_atom_3d": ToolMeta(CAT_ANALYSIS, "simulate", "Simulating atom (3D)", PHASE_ANALYSIS, TYPE_TOOL),
1317
+ "simulate_orbital": ToolMeta(CAT_ANALYSIS, "simulate", "Simulating orbital", PHASE_ANALYSIS, TYPE_TOOL),
1318
+ "orbital_grid": ToolMeta(CAT_ANALYSIS, "simulate", "Exporting orbital grid", PHASE_ANALYSIS, TYPE_TOOL),
1319
+ "bonding_map": ToolMeta(CAT_ANALYSIS, "simulate", "Rendering bonding map", PHASE_ANALYSIS, TYPE_TOOL),
1320
+ "bloch_sphere": ToolMeta(CAT_ANALYSIS, "simulate", "Rendering Bloch sphere", PHASE_ANALYSIS, TYPE_TOOL),
1321
+ # web
1322
+ "web_search": ToolMeta(CAT_SEARCH, "web", "Searching web", PHASE_DISCOVERY, TYPE_TOOL),
1323
+ "deep_research": ToolMeta(CAT_SEARCH, "web", "Researching", PHASE_DISCOVERY, TYPE_TOOL),
1324
+ }
1325
+
1326
+ def meta_for_tool(tool_name: str) -> ToolMeta:
1327
+ return TOOL_ACTIVITY_META.get(
1328
+ tool_name,
1329
+ ToolMeta(CAT_TERMINAL, tool_name, f"Running {tool_name}", PHASE_IMPLEMENTATION, TYPE_TOOL)
1330
+ )
1331
+
1332
+ # ── provider / model activity helper ───────────────────────────────────
1333
+ def emit_provider_activity(title: str, status: str = RUNNING, turn_id: str = "", details: str = "", result: str = "") -> Activity:
1334
+ return manager.create(type=TYPE_PROVIDER, action="provider", title=title, status=status, turn_id=turn_id, details=details, result=result)