gobby 0.2.5__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 (383) hide show
  1. gobby/__init__.py +3 -0
  2. gobby/adapters/__init__.py +30 -0
  3. gobby/adapters/base.py +93 -0
  4. gobby/adapters/claude_code.py +276 -0
  5. gobby/adapters/codex.py +1292 -0
  6. gobby/adapters/gemini.py +343 -0
  7. gobby/agents/__init__.py +37 -0
  8. gobby/agents/codex_session.py +120 -0
  9. gobby/agents/constants.py +112 -0
  10. gobby/agents/context.py +362 -0
  11. gobby/agents/definitions.py +133 -0
  12. gobby/agents/gemini_session.py +111 -0
  13. gobby/agents/registry.py +618 -0
  14. gobby/agents/runner.py +968 -0
  15. gobby/agents/session.py +259 -0
  16. gobby/agents/spawn.py +916 -0
  17. gobby/agents/spawners/__init__.py +77 -0
  18. gobby/agents/spawners/base.py +142 -0
  19. gobby/agents/spawners/cross_platform.py +266 -0
  20. gobby/agents/spawners/embedded.py +225 -0
  21. gobby/agents/spawners/headless.py +226 -0
  22. gobby/agents/spawners/linux.py +125 -0
  23. gobby/agents/spawners/macos.py +277 -0
  24. gobby/agents/spawners/windows.py +308 -0
  25. gobby/agents/tty_config.py +319 -0
  26. gobby/autonomous/__init__.py +32 -0
  27. gobby/autonomous/progress_tracker.py +447 -0
  28. gobby/autonomous/stop_registry.py +269 -0
  29. gobby/autonomous/stuck_detector.py +383 -0
  30. gobby/cli/__init__.py +67 -0
  31. gobby/cli/__main__.py +8 -0
  32. gobby/cli/agents.py +529 -0
  33. gobby/cli/artifacts.py +266 -0
  34. gobby/cli/daemon.py +329 -0
  35. gobby/cli/extensions.py +526 -0
  36. gobby/cli/github.py +263 -0
  37. gobby/cli/init.py +53 -0
  38. gobby/cli/install.py +614 -0
  39. gobby/cli/installers/__init__.py +37 -0
  40. gobby/cli/installers/antigravity.py +65 -0
  41. gobby/cli/installers/claude.py +363 -0
  42. gobby/cli/installers/codex.py +192 -0
  43. gobby/cli/installers/gemini.py +294 -0
  44. gobby/cli/installers/git_hooks.py +377 -0
  45. gobby/cli/installers/shared.py +737 -0
  46. gobby/cli/linear.py +250 -0
  47. gobby/cli/mcp.py +30 -0
  48. gobby/cli/mcp_proxy.py +698 -0
  49. gobby/cli/memory.py +304 -0
  50. gobby/cli/merge.py +384 -0
  51. gobby/cli/projects.py +79 -0
  52. gobby/cli/sessions.py +622 -0
  53. gobby/cli/tasks/__init__.py +30 -0
  54. gobby/cli/tasks/_utils.py +658 -0
  55. gobby/cli/tasks/ai.py +1025 -0
  56. gobby/cli/tasks/commits.py +169 -0
  57. gobby/cli/tasks/crud.py +685 -0
  58. gobby/cli/tasks/deps.py +135 -0
  59. gobby/cli/tasks/labels.py +63 -0
  60. gobby/cli/tasks/main.py +273 -0
  61. gobby/cli/tasks/search.py +178 -0
  62. gobby/cli/tui.py +34 -0
  63. gobby/cli/utils.py +513 -0
  64. gobby/cli/workflows.py +927 -0
  65. gobby/cli/worktrees.py +481 -0
  66. gobby/config/__init__.py +129 -0
  67. gobby/config/app.py +551 -0
  68. gobby/config/extensions.py +167 -0
  69. gobby/config/features.py +472 -0
  70. gobby/config/llm_providers.py +98 -0
  71. gobby/config/logging.py +66 -0
  72. gobby/config/mcp.py +346 -0
  73. gobby/config/persistence.py +247 -0
  74. gobby/config/servers.py +141 -0
  75. gobby/config/sessions.py +250 -0
  76. gobby/config/tasks.py +784 -0
  77. gobby/hooks/__init__.py +104 -0
  78. gobby/hooks/artifact_capture.py +213 -0
  79. gobby/hooks/broadcaster.py +243 -0
  80. gobby/hooks/event_handlers.py +723 -0
  81. gobby/hooks/events.py +218 -0
  82. gobby/hooks/git.py +169 -0
  83. gobby/hooks/health_monitor.py +171 -0
  84. gobby/hooks/hook_manager.py +856 -0
  85. gobby/hooks/hook_types.py +575 -0
  86. gobby/hooks/plugins.py +813 -0
  87. gobby/hooks/session_coordinator.py +396 -0
  88. gobby/hooks/verification_runner.py +268 -0
  89. gobby/hooks/webhooks.py +339 -0
  90. gobby/install/claude/commands/gobby/bug.md +51 -0
  91. gobby/install/claude/commands/gobby/chore.md +51 -0
  92. gobby/install/claude/commands/gobby/epic.md +52 -0
  93. gobby/install/claude/commands/gobby/eval.md +235 -0
  94. gobby/install/claude/commands/gobby/feat.md +49 -0
  95. gobby/install/claude/commands/gobby/nit.md +52 -0
  96. gobby/install/claude/commands/gobby/ref.md +52 -0
  97. gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
  98. gobby/install/claude/hooks/hook_dispatcher.py +364 -0
  99. gobby/install/claude/hooks/validate_settings.py +102 -0
  100. gobby/install/claude/hooks-template.json +118 -0
  101. gobby/install/codex/hooks/hook_dispatcher.py +153 -0
  102. gobby/install/codex/prompts/forget.md +7 -0
  103. gobby/install/codex/prompts/memories.md +7 -0
  104. gobby/install/codex/prompts/recall.md +7 -0
  105. gobby/install/codex/prompts/remember.md +13 -0
  106. gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
  107. gobby/install/gemini/hooks-template.json +138 -0
  108. gobby/install/shared/plugins/code_guardian.py +456 -0
  109. gobby/install/shared/plugins/example_notify.py +331 -0
  110. gobby/integrations/__init__.py +10 -0
  111. gobby/integrations/github.py +145 -0
  112. gobby/integrations/linear.py +145 -0
  113. gobby/llm/__init__.py +40 -0
  114. gobby/llm/base.py +120 -0
  115. gobby/llm/claude.py +578 -0
  116. gobby/llm/claude_executor.py +503 -0
  117. gobby/llm/codex.py +322 -0
  118. gobby/llm/codex_executor.py +513 -0
  119. gobby/llm/executor.py +316 -0
  120. gobby/llm/factory.py +34 -0
  121. gobby/llm/gemini.py +258 -0
  122. gobby/llm/gemini_executor.py +339 -0
  123. gobby/llm/litellm.py +287 -0
  124. gobby/llm/litellm_executor.py +303 -0
  125. gobby/llm/resolver.py +499 -0
  126. gobby/llm/service.py +236 -0
  127. gobby/mcp_proxy/__init__.py +29 -0
  128. gobby/mcp_proxy/actions.py +175 -0
  129. gobby/mcp_proxy/daemon_control.py +198 -0
  130. gobby/mcp_proxy/importer.py +436 -0
  131. gobby/mcp_proxy/lazy.py +325 -0
  132. gobby/mcp_proxy/manager.py +798 -0
  133. gobby/mcp_proxy/metrics.py +609 -0
  134. gobby/mcp_proxy/models.py +139 -0
  135. gobby/mcp_proxy/registries.py +215 -0
  136. gobby/mcp_proxy/schema_hash.py +381 -0
  137. gobby/mcp_proxy/semantic_search.py +706 -0
  138. gobby/mcp_proxy/server.py +549 -0
  139. gobby/mcp_proxy/services/__init__.py +0 -0
  140. gobby/mcp_proxy/services/fallback.py +306 -0
  141. gobby/mcp_proxy/services/recommendation.py +224 -0
  142. gobby/mcp_proxy/services/server_mgmt.py +214 -0
  143. gobby/mcp_proxy/services/system.py +72 -0
  144. gobby/mcp_proxy/services/tool_filter.py +231 -0
  145. gobby/mcp_proxy/services/tool_proxy.py +309 -0
  146. gobby/mcp_proxy/stdio.py +565 -0
  147. gobby/mcp_proxy/tools/__init__.py +27 -0
  148. gobby/mcp_proxy/tools/agents.py +1103 -0
  149. gobby/mcp_proxy/tools/artifacts.py +207 -0
  150. gobby/mcp_proxy/tools/hub.py +335 -0
  151. gobby/mcp_proxy/tools/internal.py +337 -0
  152. gobby/mcp_proxy/tools/memory.py +543 -0
  153. gobby/mcp_proxy/tools/merge.py +422 -0
  154. gobby/mcp_proxy/tools/metrics.py +283 -0
  155. gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
  156. gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
  157. gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
  158. gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
  159. gobby/mcp_proxy/tools/orchestration/review.py +736 -0
  160. gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
  161. gobby/mcp_proxy/tools/session_messages.py +1056 -0
  162. gobby/mcp_proxy/tools/task_dependencies.py +219 -0
  163. gobby/mcp_proxy/tools/task_expansion.py +591 -0
  164. gobby/mcp_proxy/tools/task_github.py +393 -0
  165. gobby/mcp_proxy/tools/task_linear.py +379 -0
  166. gobby/mcp_proxy/tools/task_orchestration.py +77 -0
  167. gobby/mcp_proxy/tools/task_readiness.py +522 -0
  168. gobby/mcp_proxy/tools/task_sync.py +351 -0
  169. gobby/mcp_proxy/tools/task_validation.py +843 -0
  170. gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
  171. gobby/mcp_proxy/tools/tasks/_context.py +112 -0
  172. gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
  173. gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
  174. gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
  175. gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
  176. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
  177. gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
  178. gobby/mcp_proxy/tools/tasks/_search.py +215 -0
  179. gobby/mcp_proxy/tools/tasks/_session.py +125 -0
  180. gobby/mcp_proxy/tools/workflows.py +973 -0
  181. gobby/mcp_proxy/tools/worktrees.py +1264 -0
  182. gobby/mcp_proxy/transports/__init__.py +0 -0
  183. gobby/mcp_proxy/transports/base.py +95 -0
  184. gobby/mcp_proxy/transports/factory.py +44 -0
  185. gobby/mcp_proxy/transports/http.py +139 -0
  186. gobby/mcp_proxy/transports/stdio.py +213 -0
  187. gobby/mcp_proxy/transports/websocket.py +136 -0
  188. gobby/memory/backends/__init__.py +116 -0
  189. gobby/memory/backends/mem0.py +408 -0
  190. gobby/memory/backends/memu.py +485 -0
  191. gobby/memory/backends/null.py +111 -0
  192. gobby/memory/backends/openmemory.py +537 -0
  193. gobby/memory/backends/sqlite.py +304 -0
  194. gobby/memory/context.py +87 -0
  195. gobby/memory/manager.py +1001 -0
  196. gobby/memory/protocol.py +451 -0
  197. gobby/memory/search/__init__.py +66 -0
  198. gobby/memory/search/text.py +127 -0
  199. gobby/memory/viz.py +258 -0
  200. gobby/prompts/__init__.py +13 -0
  201. gobby/prompts/defaults/expansion/system.md +119 -0
  202. gobby/prompts/defaults/expansion/user.md +48 -0
  203. gobby/prompts/defaults/external_validation/agent.md +72 -0
  204. gobby/prompts/defaults/external_validation/external.md +63 -0
  205. gobby/prompts/defaults/external_validation/spawn.md +83 -0
  206. gobby/prompts/defaults/external_validation/system.md +6 -0
  207. gobby/prompts/defaults/features/import_mcp.md +22 -0
  208. gobby/prompts/defaults/features/import_mcp_github.md +17 -0
  209. gobby/prompts/defaults/features/import_mcp_search.md +16 -0
  210. gobby/prompts/defaults/features/recommend_tools.md +32 -0
  211. gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
  212. gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
  213. gobby/prompts/defaults/features/server_description.md +20 -0
  214. gobby/prompts/defaults/features/server_description_system.md +6 -0
  215. gobby/prompts/defaults/features/task_description.md +31 -0
  216. gobby/prompts/defaults/features/task_description_system.md +6 -0
  217. gobby/prompts/defaults/features/tool_summary.md +17 -0
  218. gobby/prompts/defaults/features/tool_summary_system.md +6 -0
  219. gobby/prompts/defaults/research/step.md +58 -0
  220. gobby/prompts/defaults/validation/criteria.md +47 -0
  221. gobby/prompts/defaults/validation/validate.md +38 -0
  222. gobby/prompts/loader.py +346 -0
  223. gobby/prompts/models.py +113 -0
  224. gobby/py.typed +0 -0
  225. gobby/runner.py +488 -0
  226. gobby/search/__init__.py +23 -0
  227. gobby/search/protocol.py +104 -0
  228. gobby/search/tfidf.py +232 -0
  229. gobby/servers/__init__.py +7 -0
  230. gobby/servers/http.py +636 -0
  231. gobby/servers/models.py +31 -0
  232. gobby/servers/routes/__init__.py +23 -0
  233. gobby/servers/routes/admin.py +416 -0
  234. gobby/servers/routes/dependencies.py +118 -0
  235. gobby/servers/routes/mcp/__init__.py +24 -0
  236. gobby/servers/routes/mcp/hooks.py +135 -0
  237. gobby/servers/routes/mcp/plugins.py +121 -0
  238. gobby/servers/routes/mcp/tools.py +1337 -0
  239. gobby/servers/routes/mcp/webhooks.py +159 -0
  240. gobby/servers/routes/sessions.py +582 -0
  241. gobby/servers/websocket.py +766 -0
  242. gobby/sessions/__init__.py +13 -0
  243. gobby/sessions/analyzer.py +322 -0
  244. gobby/sessions/lifecycle.py +240 -0
  245. gobby/sessions/manager.py +563 -0
  246. gobby/sessions/processor.py +225 -0
  247. gobby/sessions/summary.py +532 -0
  248. gobby/sessions/transcripts/__init__.py +41 -0
  249. gobby/sessions/transcripts/base.py +125 -0
  250. gobby/sessions/transcripts/claude.py +386 -0
  251. gobby/sessions/transcripts/codex.py +143 -0
  252. gobby/sessions/transcripts/gemini.py +195 -0
  253. gobby/storage/__init__.py +21 -0
  254. gobby/storage/agents.py +409 -0
  255. gobby/storage/artifact_classifier.py +341 -0
  256. gobby/storage/artifacts.py +285 -0
  257. gobby/storage/compaction.py +67 -0
  258. gobby/storage/database.py +357 -0
  259. gobby/storage/inter_session_messages.py +194 -0
  260. gobby/storage/mcp.py +680 -0
  261. gobby/storage/memories.py +562 -0
  262. gobby/storage/merge_resolutions.py +550 -0
  263. gobby/storage/migrations.py +860 -0
  264. gobby/storage/migrations_legacy.py +1359 -0
  265. gobby/storage/projects.py +166 -0
  266. gobby/storage/session_messages.py +251 -0
  267. gobby/storage/session_tasks.py +97 -0
  268. gobby/storage/sessions.py +817 -0
  269. gobby/storage/task_dependencies.py +223 -0
  270. gobby/storage/tasks/__init__.py +42 -0
  271. gobby/storage/tasks/_aggregates.py +180 -0
  272. gobby/storage/tasks/_crud.py +449 -0
  273. gobby/storage/tasks/_id.py +104 -0
  274. gobby/storage/tasks/_lifecycle.py +311 -0
  275. gobby/storage/tasks/_manager.py +889 -0
  276. gobby/storage/tasks/_models.py +300 -0
  277. gobby/storage/tasks/_ordering.py +119 -0
  278. gobby/storage/tasks/_path_cache.py +110 -0
  279. gobby/storage/tasks/_queries.py +343 -0
  280. gobby/storage/tasks/_search.py +143 -0
  281. gobby/storage/workflow_audit.py +393 -0
  282. gobby/storage/worktrees.py +547 -0
  283. gobby/sync/__init__.py +29 -0
  284. gobby/sync/github.py +333 -0
  285. gobby/sync/linear.py +304 -0
  286. gobby/sync/memories.py +284 -0
  287. gobby/sync/tasks.py +641 -0
  288. gobby/tasks/__init__.py +8 -0
  289. gobby/tasks/build_verification.py +193 -0
  290. gobby/tasks/commits.py +633 -0
  291. gobby/tasks/context.py +747 -0
  292. gobby/tasks/criteria.py +342 -0
  293. gobby/tasks/enhanced_validator.py +226 -0
  294. gobby/tasks/escalation.py +263 -0
  295. gobby/tasks/expansion.py +626 -0
  296. gobby/tasks/external_validator.py +764 -0
  297. gobby/tasks/issue_extraction.py +171 -0
  298. gobby/tasks/prompts/expand.py +327 -0
  299. gobby/tasks/research.py +421 -0
  300. gobby/tasks/tdd.py +352 -0
  301. gobby/tasks/tree_builder.py +263 -0
  302. gobby/tasks/validation.py +712 -0
  303. gobby/tasks/validation_history.py +357 -0
  304. gobby/tasks/validation_models.py +89 -0
  305. gobby/tools/__init__.py +0 -0
  306. gobby/tools/summarizer.py +170 -0
  307. gobby/tui/__init__.py +5 -0
  308. gobby/tui/api_client.py +281 -0
  309. gobby/tui/app.py +327 -0
  310. gobby/tui/screens/__init__.py +25 -0
  311. gobby/tui/screens/agents.py +333 -0
  312. gobby/tui/screens/chat.py +450 -0
  313. gobby/tui/screens/dashboard.py +377 -0
  314. gobby/tui/screens/memory.py +305 -0
  315. gobby/tui/screens/metrics.py +231 -0
  316. gobby/tui/screens/orchestrator.py +904 -0
  317. gobby/tui/screens/sessions.py +412 -0
  318. gobby/tui/screens/tasks.py +442 -0
  319. gobby/tui/screens/workflows.py +289 -0
  320. gobby/tui/screens/worktrees.py +174 -0
  321. gobby/tui/widgets/__init__.py +21 -0
  322. gobby/tui/widgets/chat.py +210 -0
  323. gobby/tui/widgets/conductor.py +104 -0
  324. gobby/tui/widgets/menu.py +132 -0
  325. gobby/tui/widgets/message_panel.py +160 -0
  326. gobby/tui/widgets/review_gate.py +224 -0
  327. gobby/tui/widgets/task_tree.py +99 -0
  328. gobby/tui/widgets/token_budget.py +166 -0
  329. gobby/tui/ws_client.py +258 -0
  330. gobby/utils/__init__.py +3 -0
  331. gobby/utils/daemon_client.py +235 -0
  332. gobby/utils/git.py +222 -0
  333. gobby/utils/id.py +38 -0
  334. gobby/utils/json_helpers.py +161 -0
  335. gobby/utils/logging.py +376 -0
  336. gobby/utils/machine_id.py +135 -0
  337. gobby/utils/metrics.py +589 -0
  338. gobby/utils/project_context.py +182 -0
  339. gobby/utils/project_init.py +263 -0
  340. gobby/utils/status.py +256 -0
  341. gobby/utils/validation.py +80 -0
  342. gobby/utils/version.py +23 -0
  343. gobby/workflows/__init__.py +4 -0
  344. gobby/workflows/actions.py +1310 -0
  345. gobby/workflows/approval_flow.py +138 -0
  346. gobby/workflows/artifact_actions.py +103 -0
  347. gobby/workflows/audit_helpers.py +110 -0
  348. gobby/workflows/autonomous_actions.py +286 -0
  349. gobby/workflows/context_actions.py +394 -0
  350. gobby/workflows/definitions.py +130 -0
  351. gobby/workflows/detection_helpers.py +208 -0
  352. gobby/workflows/engine.py +485 -0
  353. gobby/workflows/evaluator.py +669 -0
  354. gobby/workflows/git_utils.py +96 -0
  355. gobby/workflows/hooks.py +169 -0
  356. gobby/workflows/lifecycle_evaluator.py +613 -0
  357. gobby/workflows/llm_actions.py +70 -0
  358. gobby/workflows/loader.py +333 -0
  359. gobby/workflows/mcp_actions.py +60 -0
  360. gobby/workflows/memory_actions.py +272 -0
  361. gobby/workflows/premature_stop.py +164 -0
  362. gobby/workflows/session_actions.py +139 -0
  363. gobby/workflows/state_actions.py +123 -0
  364. gobby/workflows/state_manager.py +104 -0
  365. gobby/workflows/stop_signal_actions.py +163 -0
  366. gobby/workflows/summary_actions.py +344 -0
  367. gobby/workflows/task_actions.py +249 -0
  368. gobby/workflows/task_enforcement_actions.py +901 -0
  369. gobby/workflows/templates.py +52 -0
  370. gobby/workflows/todo_actions.py +84 -0
  371. gobby/workflows/webhook.py +223 -0
  372. gobby/workflows/webhook_executor.py +399 -0
  373. gobby/worktrees/__init__.py +5 -0
  374. gobby/worktrees/git.py +690 -0
  375. gobby/worktrees/merge/__init__.py +20 -0
  376. gobby/worktrees/merge/conflict_parser.py +177 -0
  377. gobby/worktrees/merge/resolver.py +485 -0
  378. gobby-0.2.5.dist-info/METADATA +351 -0
  379. gobby-0.2.5.dist-info/RECORD +383 -0
  380. gobby-0.2.5.dist-info/WHEEL +5 -0
  381. gobby-0.2.5.dist-info/entry_points.txt +2 -0
  382. gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
  383. gobby-0.2.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,856 @@
1
+ """
2
+ Hook Manager - Clean Coordinator for Claude Code Hooks.
3
+
4
+ This is the refactored HookManager that serves purely as a coordinator,
5
+ delegating all work to focused subsystems. It replaces the 5,774-line
6
+ God Object with a ~300-line routing layer.
7
+
8
+ Architecture:
9
+ HookManager creates and coordinates subsystems:
10
+ - Session-agnostic: DaemonClient, TranscriptProcessor
11
+ - Session-scoped: SessionManager
12
+ - Workflow-driven: WorkflowEngine handles session handoff via generate_handoff action
13
+
14
+ Example:
15
+ ```python
16
+ from gobby.hooks.hook_manager import HookManager
17
+
18
+ manager = HookManager(
19
+ daemon_host="localhost",
20
+ daemon_port=8765
21
+ )
22
+
23
+ result = manager.execute(
24
+ hook_type="session-start",
25
+ input_data={"external_id": "abc123", ...}
26
+ )
27
+ ```
28
+ """
29
+
30
+ import asyncio
31
+ import logging
32
+ import time
33
+ from logging.handlers import RotatingFileHandler
34
+ from pathlib import Path
35
+ from typing import TYPE_CHECKING, Any, cast
36
+
37
+ from gobby.autonomous.progress_tracker import ProgressTracker
38
+ from gobby.autonomous.stop_registry import StopRegistry
39
+ from gobby.autonomous.stuck_detector import StuckDetector
40
+ from gobby.hooks.event_handlers import EventHandlers
41
+ from gobby.hooks.events import HookEvent, HookEventType, HookResponse
42
+ from gobby.hooks.health_monitor import HealthMonitor
43
+ from gobby.hooks.plugins import PluginLoader, run_plugin_handlers
44
+ from gobby.hooks.session_coordinator import SessionCoordinator
45
+ from gobby.hooks.webhooks import WebhookDispatcher
46
+ from gobby.memory.manager import MemoryManager
47
+ from gobby.sessions.manager import SessionManager
48
+ from gobby.sessions.summary import SummaryFileGenerator
49
+ from gobby.sessions.transcripts.claude import ClaudeTranscriptParser
50
+ from gobby.storage.agents import LocalAgentRunManager
51
+ from gobby.storage.database import LocalDatabase
52
+ from gobby.storage.memories import LocalMemoryManager
53
+ from gobby.storage.session_messages import LocalSessionMessageManager
54
+ from gobby.storage.session_tasks import SessionTaskManager
55
+ from gobby.storage.sessions import LocalSessionManager
56
+ from gobby.storage.tasks import LocalTaskManager
57
+ from gobby.storage.worktrees import LocalWorktreeManager
58
+ from gobby.utils.daemon_client import DaemonClient
59
+ from gobby.workflows.hooks import WorkflowHookHandler
60
+ from gobby.workflows.loader import WorkflowLoader
61
+ from gobby.workflows.state_manager import WorkflowStateManager
62
+
63
+ if TYPE_CHECKING:
64
+ pass
65
+
66
+ # Backward-compatible alias
67
+ TranscriptProcessor = ClaudeTranscriptParser
68
+
69
+ if TYPE_CHECKING:
70
+ from gobby.llm.service import LLMService
71
+
72
+
73
+ class HookManager:
74
+ """
75
+ Session-scoped coordinator for Claude Code hooks.
76
+
77
+ Delegates all work to subsystems:
78
+ - DaemonClient: HTTP communication with Gobby daemon
79
+ - TranscriptProcessor: JSONL parsing and analysis
80
+ - WorkflowEngine: Handles session handoff and LLM-powered summaries
81
+
82
+ Attributes:
83
+ daemon_host: Host for daemon communication
84
+ daemon_port: Port for daemon communication
85
+ log_file: Full path to log file
86
+ logger: Configured logger instance
87
+ """
88
+
89
+ def __init__(
90
+ self,
91
+ daemon_host: str = "localhost",
92
+ daemon_port: int = 8765,
93
+ llm_service: "LLMService | None" = None,
94
+ config: Any | None = None,
95
+ log_file: str | None = None,
96
+ log_max_bytes: int = 10 * 1024 * 1024, # 10MB
97
+ log_backup_count: int = 5,
98
+ broadcaster: Any | None = None,
99
+ mcp_manager: Any | None = None,
100
+ message_processor: Any | None = None,
101
+ memory_sync_manager: Any | None = None,
102
+ task_sync_manager: Any | None = None,
103
+ ):
104
+ """
105
+ Initialize HookManager with subsystems.
106
+
107
+ Args:
108
+ daemon_host: Daemon host for communication
109
+ daemon_port: Daemon port for communication
110
+ llm_service: Optional LLMService for multi-provider support
111
+ config: Optional DaemonConfig instance for feature configuration
112
+ log_file: Full path to log file (default: ~/.gobby/logs/hook-manager.log)
113
+ log_max_bytes: Max log file size before rotation
114
+ log_backup_count: Number of backup log files
115
+ broadcaster: Optional HookEventBroadcaster instance
116
+ mcp_manager: Optional MCPClientManager instance
117
+ message_processor: SessionMessageProcessor instance
118
+ memory_sync_manager: Optional MemorySyncManager instance
119
+ task_sync_manager: Optional TaskSyncManager instance
120
+ """
121
+ self.daemon_host = daemon_host
122
+ self.daemon_port = daemon_port
123
+ self.daemon_url = f"http://{daemon_host}:{daemon_port}"
124
+ self.log_file = log_file or str(Path.home() / ".gobby" / "logs" / "hook-manager.log")
125
+ self.log_max_bytes = log_max_bytes
126
+ self.log_backup_count = log_backup_count
127
+ self.broadcaster = broadcaster
128
+ self.mcp_manager = mcp_manager
129
+ self._message_processor = message_processor
130
+ self.memory_sync_manager = memory_sync_manager
131
+ self.task_sync_manager = task_sync_manager
132
+
133
+ # Capture event loop for thread-safe broadcasting (if running in async context)
134
+ self._loop: asyncio.AbstractEventLoop | None
135
+ try:
136
+ self._loop = asyncio.get_running_loop()
137
+ except RuntimeError:
138
+ self._loop = None
139
+
140
+ # Setup logging first
141
+ self.logger = self._setup_logging()
142
+
143
+ # Store LLM service
144
+ self._llm_service = llm_service
145
+
146
+ # Load configuration - prefer passed config over loading new one
147
+ self._config = config
148
+ if not self._config:
149
+ try:
150
+ from gobby.config.app import load_config
151
+
152
+ self._config = load_config()
153
+ except Exception as e:
154
+ self.logger.error(
155
+ f"Failed to load config in HookManager, using defaults: {e}",
156
+ exc_info=True,
157
+ )
158
+
159
+ # Extract config values
160
+ if self._config:
161
+ health_check_interval = self._config.daemon_health_check_interval
162
+
163
+ else:
164
+ health_check_interval = 10.0
165
+
166
+ # Initialize Database - use config's database_path if available
167
+ if self._config and self._config.database_path:
168
+ db_path = Path(self._config.database_path).expanduser()
169
+ self._database = LocalDatabase(db_path)
170
+ else:
171
+ self._database = LocalDatabase()
172
+
173
+ # Create session-agnostic subsystems (shared across all sessions)
174
+ self._daemon_client = DaemonClient(
175
+ host=daemon_host,
176
+ port=daemon_port,
177
+ timeout=5.0,
178
+ logger=self.logger,
179
+ )
180
+ self._transcript_processor = TranscriptProcessor(logger_instance=self.logger)
181
+
182
+ # Create local storage for sessions
183
+ self._session_storage = LocalSessionManager(self._database)
184
+ self._session_task_manager = SessionTaskManager(self._database)
185
+
186
+ # Initialize Memory storage
187
+ self._memory_storage = LocalMemoryManager(self._database)
188
+ self._message_manager = LocalSessionMessageManager(self._database)
189
+ self._task_manager = LocalTaskManager(self._database)
190
+
191
+ # Initialize Agent Run and Worktree managers (for terminal mode result capture)
192
+ self._agent_run_manager = LocalAgentRunManager(self._database)
193
+ self._worktree_manager = LocalWorktreeManager(self._database)
194
+
195
+ # Initialize Artifact storage and capture hook
196
+ from gobby.hooks.artifact_capture import ArtifactCaptureHook
197
+ from gobby.storage.artifacts import LocalArtifactManager
198
+
199
+ self._artifact_manager = LocalArtifactManager(self._database)
200
+ self._artifact_capture_hook = ArtifactCaptureHook(artifact_manager=self._artifact_manager)
201
+
202
+ # Initialize autonomous execution components
203
+ self._stop_registry = StopRegistry(self._database)
204
+ self._progress_tracker = ProgressTracker(self._database)
205
+ self._stuck_detector = StuckDetector(
206
+ self._database, progress_tracker=self._progress_tracker
207
+ )
208
+
209
+ # Use config or defaults
210
+ memory_config = (
211
+ self._config.memory if self._config and hasattr(self._config, "memory") else None
212
+ )
213
+
214
+ if not memory_config:
215
+ from gobby.config.app import MemoryConfig
216
+
217
+ memory_config = MemoryConfig()
218
+
219
+ self._memory_manager = MemoryManager(self._database, memory_config)
220
+
221
+ # Initialize Workflow Engine (Phase 0-2 + 3 Integration)
222
+ # Import WorkflowEngine here to avoid circular import (hooks -> hook_manager -> engine -> hooks)
223
+ from gobby.workflows.actions import ActionExecutor
224
+ from gobby.workflows.engine import WorkflowEngine
225
+ from gobby.workflows.templates import TemplateEngine
226
+
227
+ # Workflow loader handles project-specific paths dynamically via project_path parameter
228
+ # Global workflows are loaded from ~/.gobby/workflows/
229
+ # Project-specific workflows are loaded from {project_path}/.gobby/workflows/
230
+ # Workflows are installed via `gobby install` from install/shared/workflows/
231
+ self._workflow_loader = WorkflowLoader(workflow_dirs=[Path.home() / ".gobby" / "workflows"])
232
+ self._workflow_state_manager = WorkflowStateManager(self._database)
233
+
234
+ # Initialize Template Engine
235
+ # We can pass template directory from package templates or user templates
236
+ # For now, let's include the built-in templates dir if we can find it
237
+ # Assuming templates are in package 'gobby.templates.workflows'?
238
+ # Or just use the one we are about to create in project root?
239
+ # Ideally, we should look for templates in typical locations.
240
+ # But 'TemplateEngine' constructor takes optional dirs.
241
+ self._template_engine = TemplateEngine()
242
+
243
+ # Get websocket_server from broadcaster if available
244
+ websocket_server = None
245
+ if self.broadcaster and hasattr(self.broadcaster, "websocket_server"):
246
+ websocket_server = self.broadcaster.websocket_server
247
+
248
+ self._action_executor = ActionExecutor(
249
+ db=self._database,
250
+ session_manager=self._session_storage,
251
+ template_engine=self._template_engine,
252
+ llm_service=self._llm_service,
253
+ transcript_processor=self._transcript_processor,
254
+ config=self._config,
255
+ mcp_manager=self.mcp_manager,
256
+ memory_manager=self._memory_manager,
257
+ memory_sync_manager=self.memory_sync_manager,
258
+ task_manager=self._task_manager,
259
+ task_sync_manager=self.task_sync_manager,
260
+ session_task_manager=self._session_task_manager,
261
+ stop_registry=self._stop_registry,
262
+ progress_tracker=self._progress_tracker,
263
+ stuck_detector=self._stuck_detector,
264
+ websocket_server=websocket_server,
265
+ )
266
+ self._workflow_engine = WorkflowEngine(
267
+ loader=self._workflow_loader,
268
+ state_manager=self._workflow_state_manager,
269
+ action_executor=self._action_executor,
270
+ )
271
+ # Register task_manager with evaluator for task_tree_complete() condition helper
272
+ if self._task_manager and self._workflow_engine.evaluator:
273
+ self._workflow_engine.evaluator.register_task_manager(self._task_manager)
274
+ # Register stop_registry with evaluator for has_stop_signal() condition helper
275
+ if self._stop_registry and self._workflow_engine.evaluator:
276
+ self._workflow_engine.evaluator.register_stop_registry(self._stop_registry)
277
+ workflow_timeout: float = 0.0 # 0 = no timeout
278
+ workflow_enabled = True
279
+ if self._config:
280
+ workflow_timeout = self._config.workflow.timeout
281
+ workflow_enabled = self._config.workflow.enabled
282
+
283
+ self._workflow_handler = WorkflowHookHandler(
284
+ engine=self._workflow_engine,
285
+ loop=self._loop,
286
+ timeout=workflow_timeout,
287
+ enabled=workflow_enabled,
288
+ )
289
+
290
+ # Initialize Failover Summary Generator
291
+ self._summary_file_generator = SummaryFileGenerator(
292
+ transcript_processor=self._transcript_processor,
293
+ logger_instance=self.logger,
294
+ llm_service=self._llm_service,
295
+ config=self._config,
296
+ )
297
+
298
+ # Initialize Webhook Dispatcher (Sprint 8: Webhooks)
299
+ webhooks_config = None
300
+ if self._config and hasattr(self._config, "hook_extensions"):
301
+ webhooks_config = self._config.hook_extensions.webhooks
302
+ if not webhooks_config:
303
+ from gobby.config.app import WebhooksConfig
304
+
305
+ webhooks_config = WebhooksConfig()
306
+ self._webhook_dispatcher = WebhookDispatcher(webhooks_config)
307
+
308
+ # Initialize Plugin Loader (Sprint 9: Python Plugins)
309
+ self._plugin_loader: PluginLoader | None = None
310
+ plugins_config = None
311
+ if self._config and hasattr(self._config, "hook_extensions"):
312
+ plugins_config = self._config.hook_extensions.plugins
313
+ if plugins_config is not None and plugins_config.enabled:
314
+ self._plugin_loader = PluginLoader(plugins_config)
315
+ try:
316
+ loaded = self._plugin_loader.load_all()
317
+ if loaded:
318
+ self.logger.info(
319
+ f"Loaded {len(loaded)} plugin(s): {', '.join(p.name for p in loaded)}"
320
+ )
321
+ # Register plugin actions and conditions with workflow system
322
+ self._action_executor.register_plugin_actions(self._plugin_loader.registry)
323
+ self._workflow_engine.evaluator.register_plugin_conditions(
324
+ self._plugin_loader.registry
325
+ )
326
+ except Exception as e:
327
+ self.logger.error(f"Failed to load plugins: {e}", exc_info=True)
328
+
329
+ # Session manager handles registration, lookup, and status updates
330
+ # Note: source is passed explicitly per call (Phase 2C+), not stored in manager
331
+ self._session_manager = SessionManager(
332
+ session_storage=self._session_storage,
333
+ logger_instance=self.logger,
334
+ config=self._config,
335
+ )
336
+
337
+ # Session coordination (delegated to SessionCoordinator)
338
+ self._session_coordinator = SessionCoordinator(
339
+ session_storage=self._session_storage,
340
+ message_processor=self._message_processor,
341
+ agent_run_manager=self._agent_run_manager,
342
+ worktree_manager=self._worktree_manager,
343
+ logger=self.logger,
344
+ )
345
+
346
+ # Daemon health check monitoring (delegated to HealthMonitor)
347
+ self._health_monitor = HealthMonitor(
348
+ daemon_client=self._daemon_client,
349
+ health_check_interval=health_check_interval,
350
+ logger=self.logger,
351
+ )
352
+
353
+ # Event handlers (delegated to EventHandlers module)
354
+ self._event_handlers = EventHandlers(
355
+ session_manager=self._session_manager,
356
+ workflow_handler=self._workflow_handler,
357
+ session_storage=self._session_storage,
358
+ session_task_manager=self._session_task_manager,
359
+ message_processor=self._message_processor,
360
+ summary_file_generator=self._summary_file_generator,
361
+ task_manager=self._task_manager,
362
+ session_coordinator=self._session_coordinator,
363
+ message_manager=self._message_manager,
364
+ get_machine_id=self.get_machine_id,
365
+ resolve_project_id=self._resolve_project_id,
366
+ logger=self.logger,
367
+ )
368
+
369
+ # Start background health check monitoring
370
+ self._start_health_check_monitoring()
371
+
372
+ # Re-register active sessions with message processor (after daemon restart)
373
+ self._reregister_active_sessions()
374
+
375
+ self.logger.debug("HookManager initialized")
376
+
377
+ def _setup_logging(self) -> logging.Logger:
378
+ """
379
+ Setup structured logging with rotation.
380
+
381
+ Returns:
382
+ Configured logger instance
383
+ """
384
+ # Create logger
385
+ logger = logging.getLogger("gobby.hooks")
386
+ logger.setLevel(logging.DEBUG)
387
+
388
+ # Avoid duplicate handlers if logger already configured
389
+ if logger.handlers:
390
+ return logger
391
+
392
+ # File handler with rotation - use full path from config
393
+ # Expand ~ to home directory before creating directories
394
+ log_file_path = Path(self.log_file).expanduser()
395
+ log_file_path.parent.mkdir(parents=True, exist_ok=True)
396
+ file_handler = RotatingFileHandler(
397
+ log_file_path,
398
+ maxBytes=self.log_max_bytes,
399
+ backupCount=self.log_backup_count,
400
+ )
401
+ file_handler.setLevel(logging.DEBUG)
402
+
403
+ # Formatter with context
404
+ formatter = logging.Formatter(
405
+ "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
406
+ datefmt="%Y-%m-%d %H:%M:%S",
407
+ )
408
+ file_handler.setFormatter(formatter)
409
+
410
+ logger.addHandler(file_handler)
411
+
412
+ return logger
413
+
414
+ def _reregister_active_sessions(self) -> None:
415
+ """
416
+ Re-register active sessions with the message processor.
417
+
418
+ Called during HookManager initialization to restore message processing
419
+ for sessions that were active before a daemon restart.
420
+ """
421
+ self._session_coordinator.reregister_active_sessions()
422
+
423
+ def _start_health_check_monitoring(self) -> None:
424
+ """Start background daemon health check monitoring."""
425
+ self._health_monitor.start()
426
+
427
+ def _get_cached_daemon_status(self) -> tuple[bool, str | None, str, str | None]:
428
+ """
429
+ Get cached daemon status without making HTTP call.
430
+
431
+ Returns:
432
+ Tuple of (is_ready, message, status, error)
433
+ """
434
+ return self._health_monitor.get_cached_status()
435
+
436
+ def handle(self, event: HookEvent) -> HookResponse:
437
+ """
438
+ Handle unified HookEvent from any CLI source.
439
+
440
+ This is the main entry point for hook handling. Adapters translate
441
+ CLI-specific payloads to HookEvent and call this method.
442
+
443
+ Args:
444
+ event: Unified HookEvent with event_type, session_id, source, and data.
445
+
446
+ Returns:
447
+ HookResponse with decision, context, and reason fields.
448
+
449
+ Raises:
450
+ ValueError: If event_type has no registered handler.
451
+ """
452
+ # Check daemon status (cached)
453
+ is_ready, _, daemon_status, error_reason = self._get_cached_daemon_status()
454
+
455
+ # Critical hooks that should retry before giving up
456
+ # These hooks are essential for session context preservation
457
+ critical_hooks = {
458
+ HookEventType.SESSION_START,
459
+ HookEventType.SESSION_END,
460
+ HookEventType.PRE_COMPACT,
461
+ }
462
+ retry_delays = [0.5, 1.0, 2.0] # Exponential backoff
463
+
464
+ # Retry with fresh health checks for critical hooks
465
+ if not is_ready and event.event_type in critical_hooks:
466
+ for attempt, delay in enumerate(retry_delays, 1):
467
+ time.sleep(delay)
468
+ is_ready = self._health_monitor.check_now()
469
+ if is_ready:
470
+ self.logger.info(
471
+ f"Daemon recovered after {attempt} retry(ies) for {event.event_type}"
472
+ )
473
+ break
474
+ self.logger.debug(
475
+ f"Daemon still unavailable, retry {attempt}/{len(retry_delays)} "
476
+ f"for {event.event_type}"
477
+ )
478
+
479
+ if not is_ready:
480
+ self.logger.warning(
481
+ f"Daemon not available after retries, skipping hook execution: {event.event_type}. "
482
+ f"Status: {daemon_status}, Error: {error_reason}"
483
+ )
484
+ return HookResponse(
485
+ decision="allow", # Fail-open
486
+ reason=f"Daemon {daemon_status}: {error_reason or 'Unknown'}",
487
+ )
488
+
489
+ # Look up platform session_id from cli_key (event.session_id is the cli_key)
490
+ external_id = event.session_id
491
+ platform_session_id = None
492
+
493
+ if external_id:
494
+ # Check SessionManager's cache first (keyed by (external_id, source))
495
+ platform_session_id = self._session_manager.get_session_id(
496
+ external_id, event.source.value
497
+ )
498
+
499
+ # If not in mapping and not session-start, try to query database
500
+ if not platform_session_id and event.event_type != HookEventType.SESSION_START:
501
+ with self._session_coordinator.get_lookup_lock():
502
+ # Double check in case another thread finished lookup
503
+ platform_session_id = self._session_manager.get_session_id(
504
+ external_id, event.source.value
505
+ )
506
+
507
+ if not platform_session_id:
508
+ self.logger.debug(
509
+ f"Session not in mapping, querying database for external_id={external_id}"
510
+ )
511
+ # Resolve context for lookup
512
+ machine_id = event.machine_id or self.get_machine_id()
513
+ cwd = event.data.get("cwd")
514
+ project_id = self._resolve_project_id(event.data.get("project_id"), cwd)
515
+
516
+ # Lookup with full composite key
517
+ platform_session_id = self._session_manager.lookup_session_id(
518
+ external_id,
519
+ source=event.source.value,
520
+ machine_id=machine_id,
521
+ project_id=project_id,
522
+ )
523
+ if platform_session_id:
524
+ self.logger.debug(
525
+ f"Found session_id {platform_session_id} for external_id {external_id}"
526
+ )
527
+ else:
528
+ # Auto-register session if not found
529
+ self.logger.debug(
530
+ f"Session not found for external_id={external_id}, auto-registering"
531
+ )
532
+ platform_session_id = self._session_manager.register_session(
533
+ external_id=external_id,
534
+ machine_id=machine_id,
535
+ project_id=project_id,
536
+ parent_session_id=None,
537
+ jsonl_path=event.data.get("transcript_path"),
538
+ source=event.source.value,
539
+ project_path=cwd,
540
+ )
541
+
542
+ # Resolve active task for this session if we have a platform session ID
543
+ if platform_session_id:
544
+ try:
545
+ # Get tasks linked with 'worked_on' action which implies active focus
546
+ session_tasks = self._session_task_manager.get_session_tasks(
547
+ platform_session_id
548
+ )
549
+ # Filter for active 'worked_on' tasks - taking the most recent one
550
+ active_tasks = [t for t in session_tasks if t.get("action") == "worked_on"]
551
+ if active_tasks:
552
+ # Use the most recent task - populate full task context
553
+ task = active_tasks[0]["task"]
554
+ event.task_id = task.id
555
+ event.metadata["_task_context"] = {
556
+ "id": task.id,
557
+ "title": task.title,
558
+ "status": task.status,
559
+ }
560
+ # Keep legacy field for backwards compatibility
561
+ event.metadata["_task_title"] = task.title
562
+ except Exception as e:
563
+ self.logger.warning(f"Failed to resolve active task: {e}")
564
+
565
+ # Store platform session_id in event metadata for handlers
566
+ event.metadata["_platform_session_id"] = platform_session_id
567
+
568
+ # Get handler for this event type
569
+ handler = self._get_event_handler(event.event_type)
570
+ if handler is None:
571
+ self.logger.warning(f"No handler for event type: {event.event_type}")
572
+ return HookResponse(decision="allow") # Fail-open for unknown events
573
+
574
+ # --- Workflow Engine Evaluation (Phase 3) ---
575
+ # Evalute workflow rules before executing specific handlers
576
+ workflow_context = None
577
+ try:
578
+ workflow_response = self._workflow_handler.handle(event)
579
+
580
+ # If workflow blocks or asks, return immediately
581
+ if workflow_response.decision != "allow":
582
+ self.logger.info(f"Workflow blocked/modified event: {workflow_response.decision}")
583
+ return workflow_response
584
+
585
+ # Capture context to merge later
586
+ if workflow_response.context:
587
+ workflow_context = workflow_response.context
588
+
589
+ except Exception as e:
590
+ self.logger.error(f"Workflow evaluation failed: {e}", exc_info=True)
591
+ # Fail-open for workflow errors
592
+ # --------------------------------------------
593
+
594
+ # --- Blocking Webhooks Evaluation (Sprint 8) ---
595
+ # Dispatch to blocking webhooks BEFORE handler execution
596
+ try:
597
+ webhook_results = self._dispatch_webhooks_sync(event, blocking_only=True)
598
+ decision, reason = self._webhook_dispatcher.get_blocking_decision(webhook_results)
599
+ if decision == "block":
600
+ self.logger.info(f"Webhook blocked event: {reason}")
601
+ return HookResponse(decision="block", reason=reason or "Blocked by webhook")
602
+ except Exception as e:
603
+ self.logger.error(f"Blocking webhook dispatch failed: {e}", exc_info=True)
604
+ # Fail-open for webhook errors
605
+ # -----------------------------------------------
606
+
607
+ # --- Plugin Pre-Handlers (Sprint 9: can block) ---
608
+ if self._plugin_loader:
609
+ try:
610
+ pre_response = run_plugin_handlers(self._plugin_loader.registry, event, pre=True)
611
+ if pre_response and pre_response.decision in ("deny", "block"):
612
+ self.logger.info(f"Plugin blocked event: {pre_response.reason}")
613
+ return pre_response
614
+ except Exception as e:
615
+ self.logger.error(f"Plugin pre-handler failed: {e}", exc_info=True)
616
+ # Fail-open for plugin errors
617
+ # -------------------------------------------------
618
+
619
+ # Execute handler
620
+ try:
621
+ response = handler(event)
622
+
623
+ # Merge workflow context if present
624
+ if workflow_context:
625
+ if response.context:
626
+ response.context = f"{response.context}\n\n{workflow_context}"
627
+ else:
628
+ response.context = workflow_context
629
+
630
+ # Broadcast event (fire-and-forget)
631
+ if self.broadcaster:
632
+ try:
633
+ # Case 1: Running in an event loop (e.g. from app-server client)
634
+ loop = asyncio.get_running_loop()
635
+ loop.create_task(self.broadcaster.broadcast_event(event, response))
636
+ except RuntimeError:
637
+ # Case 2: Running in a thread (e.g. from HTTP endpoint via to_thread)
638
+ if self._loop:
639
+ try:
640
+ # Use the main loop captured at init
641
+ asyncio.run_coroutine_threadsafe(
642
+ self.broadcaster.broadcast_event(event, response),
643
+ self._loop,
644
+ )
645
+ except Exception as e:
646
+ self.logger.warning(f"Failed to schedule broadcast threadsafe: {e}")
647
+ else:
648
+ self.logger.debug("No event loop available for broadcasting")
649
+
650
+ # Dispatch non-blocking webhooks (fire-and-forget)
651
+ try:
652
+ self._dispatch_webhooks_async(event)
653
+ except Exception as e:
654
+ self.logger.warning(f"Non-blocking webhook dispatch failed: {e}")
655
+
656
+ # --- Plugin Post-Handlers (Sprint 9: observe only) ---
657
+ if self._plugin_loader:
658
+ try:
659
+ run_plugin_handlers(
660
+ self._plugin_loader.registry,
661
+ event,
662
+ pre=False,
663
+ core_response=response,
664
+ )
665
+ except Exception as e:
666
+ self.logger.error(f"Plugin post-handler failed: {e}", exc_info=True)
667
+ # Continue - post-handlers are observe-only
668
+ # -----------------------------------------------------
669
+
670
+ return cast(HookResponse, response)
671
+ except Exception as e:
672
+ self.logger.error(f"Event handler {event.event_type} failed: {e}", exc_info=True)
673
+ # Fail-open on handler errors
674
+ return HookResponse(
675
+ decision="allow",
676
+ reason=f"Handler error: {e}",
677
+ )
678
+
679
+ def _get_event_handler(self, event_type: HookEventType) -> Any | None:
680
+ """
681
+ Get the handler method for a given HookEventType.
682
+
683
+ Args:
684
+ event_type: The unified event type enum value.
685
+
686
+ Returns:
687
+ Handler method or None if not found.
688
+ """
689
+ return self._event_handlers.get_handler(event_type)
690
+
691
+ def _dispatch_webhooks_sync(self, event: HookEvent, blocking_only: bool = False) -> list[Any]:
692
+ """
693
+ Dispatch webhooks synchronously (for blocking webhooks).
694
+
695
+ Args:
696
+ event: The hook event to dispatch.
697
+ blocking_only: If True, only dispatch to blocking (can_block=True) endpoints.
698
+
699
+ Returns:
700
+ List of WebhookResult objects.
701
+ """
702
+ from gobby.hooks.webhooks import WebhookResult
703
+
704
+ if not self._webhook_dispatcher.config.enabled:
705
+ return []
706
+
707
+ # Filter endpoints if blocking_only
708
+ matching_endpoints = [
709
+ ep
710
+ for ep in self._webhook_dispatcher.config.endpoints
711
+ if ep.enabled
712
+ and self._webhook_dispatcher._matches_event(ep, event.event_type.value)
713
+ and (not blocking_only or ep.can_block)
714
+ ]
715
+
716
+ if not matching_endpoints:
717
+ return []
718
+
719
+ # Build payload once
720
+ payload = self._webhook_dispatcher._build_payload(event)
721
+
722
+ # Run async dispatch in sync context
723
+ async def dispatch_all() -> list[WebhookResult]:
724
+ results: list[WebhookResult] = []
725
+ for endpoint in matching_endpoints:
726
+ result = await self._webhook_dispatcher._dispatch_single(endpoint, payload)
727
+ results.append(result)
728
+ return results
729
+
730
+ # Execute in event loop
731
+ try:
732
+ asyncio.get_running_loop()
733
+ # Already in async context - this method shouldn't be called here
734
+ # Fall back to creating a new thread to run the coroutine synchronously
735
+ import concurrent.futures
736
+
737
+ with concurrent.futures.ThreadPoolExecutor() as executor:
738
+ future = executor.submit(asyncio.run, dispatch_all())
739
+ return future.result()
740
+ except RuntimeError:
741
+ # Not in async context, run synchronously
742
+ return asyncio.run(dispatch_all())
743
+
744
+ def _dispatch_webhooks_async(self, event: HookEvent) -> None:
745
+ """
746
+ Dispatch non-blocking webhooks asynchronously (fire-and-forget).
747
+
748
+ Args:
749
+ event: The hook event to dispatch.
750
+ """
751
+ if not self._webhook_dispatcher.config.enabled:
752
+ return
753
+
754
+ # Filter to non-blocking endpoints only
755
+ matching_endpoints = [
756
+ ep
757
+ for ep in self._webhook_dispatcher.config.endpoints
758
+ if ep.enabled
759
+ and self._webhook_dispatcher._matches_event(ep, event.event_type.value)
760
+ and not ep.can_block
761
+ ]
762
+
763
+ if not matching_endpoints:
764
+ return
765
+
766
+ # Build payload
767
+ payload = self._webhook_dispatcher._build_payload(event)
768
+
769
+ async def dispatch_all() -> None:
770
+ tasks = [
771
+ self._webhook_dispatcher._dispatch_single(ep, payload) for ep in matching_endpoints
772
+ ]
773
+ await asyncio.gather(*tasks, return_exceptions=True)
774
+
775
+ # Fire and forget
776
+ try:
777
+ loop = asyncio.get_running_loop()
778
+ loop.create_task(dispatch_all())
779
+ except RuntimeError:
780
+ # No event loop, try using captured loop
781
+ if self._loop:
782
+ try:
783
+ asyncio.run_coroutine_threadsafe(dispatch_all(), self._loop)
784
+ except Exception as e:
785
+ self.logger.warning(f"Failed to schedule async webhook: {e}")
786
+
787
+ def shutdown(self) -> None:
788
+ """
789
+ Clean up HookManager resources on daemon shutdown.
790
+
791
+ Stops background health check monitoring and transcript watchers.
792
+ """
793
+ self.logger.debug("HookManager shutting down")
794
+
795
+ # Stop health check monitoring (delegated to HealthMonitor)
796
+ self._health_monitor.stop()
797
+
798
+ # Close webhook dispatcher HTTP client
799
+ try:
800
+ if self._loop:
801
+ asyncio.run_coroutine_threadsafe(
802
+ self._webhook_dispatcher.close(), self._loop
803
+ ).result(timeout=5.0)
804
+ else:
805
+ asyncio.run(self._webhook_dispatcher.close())
806
+ except Exception as e:
807
+ self.logger.warning(f"Failed to close webhook dispatcher: {e}")
808
+
809
+ if hasattr(self, "_database"):
810
+ self._database.close()
811
+
812
+ self.logger.debug("HookManager shutdown complete")
813
+
814
+ # ==================== HELPER METHODS ====================
815
+
816
+ def get_machine_id(self) -> str:
817
+ """Get unique machine identifier."""
818
+ from gobby.utils.machine_id import get_machine_id as _get_machine_id
819
+
820
+ result = _get_machine_id()
821
+ return result or "unknown-machine"
822
+
823
+ def _resolve_project_id(self, project_id: str | None, cwd: str | None) -> str:
824
+ """
825
+ Resolve project_id from cwd if not provided.
826
+
827
+ If project_id is given, returns it directly.
828
+ Otherwise, looks up project from .gobby/project.json in the cwd.
829
+ If no project.json exists, automatically initializes the project.
830
+
831
+ Args:
832
+ project_id: Optional explicit project ID
833
+ cwd: Current working directory path
834
+
835
+ Returns:
836
+ Project ID (existing or newly created)
837
+ """
838
+ if project_id:
839
+ return project_id
840
+
841
+ # Get cwd or use current directory
842
+ working_dir = Path(cwd) if cwd else Path.cwd()
843
+
844
+ # Look up project from .gobby/project.json
845
+ from gobby.utils.project_context import get_project_context
846
+
847
+ project_context = get_project_context(working_dir)
848
+ if project_context and project_context.get("id"):
849
+ return str(project_context["id"])
850
+
851
+ # No project.json found - auto-initialize the project
852
+ from gobby.utils.project_init import initialize_project
853
+
854
+ result = initialize_project(cwd=working_dir)
855
+ self.logger.info(f"Auto-initialized project '{result.project_name}' in {working_dir}")
856
+ return result.project_id