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,618 @@
1
+ """
2
+ In-memory registry for tracking running agent processes.
3
+
4
+ This module provides thread-safe tracking of running agents that complements
5
+ the database storage. It tracks runtime information like PIDs and process handles
6
+ that shouldn't be persisted.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import re
13
+ import threading
14
+ from collections.abc import Callable
15
+ from dataclasses import dataclass, field
16
+ from datetime import UTC, datetime
17
+ from typing import Any
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Event callback type - (event_type, run_id, data)
22
+ EventCallback = Callable[[str, str, dict[str, Any]], None]
23
+
24
+
25
+ @dataclass
26
+ class RunningAgent:
27
+ """
28
+ In-memory record of a running agent process.
29
+
30
+ Tracks runtime state that isn't appropriate for database storage.
31
+ """
32
+
33
+ run_id: str
34
+ """Agent run ID (matches database record)."""
35
+
36
+ session_id: str
37
+ """Child session ID for this agent."""
38
+
39
+ parent_session_id: str
40
+ """Parent session that spawned this agent."""
41
+
42
+ mode: str
43
+ """Execution mode: in_process, terminal, embedded, headless."""
44
+
45
+ started_at: datetime = field(default_factory=lambda: datetime.now(UTC))
46
+ """When the agent started running."""
47
+
48
+ # Process tracking (for terminal/embedded/headless modes)
49
+ pid: int | None = None
50
+ """Process ID if running externally."""
51
+
52
+ master_fd: int | None = None
53
+ """PTY master file descriptor (embedded mode only)."""
54
+
55
+ terminal_type: str | None = None
56
+ """Terminal type (ghostty, iterm, etc.) for terminal mode."""
57
+
58
+ # State tracking
59
+ provider: str = "claude"
60
+ """LLM provider being used."""
61
+
62
+ workflow_name: str | None = None
63
+ """Workflow being executed, if any."""
64
+
65
+ worktree_id: str | None = None
66
+ """Associated worktree, if any."""
67
+
68
+ # In-process agent tracking
69
+ task: Any | None = None
70
+ """Async task object for in-process agents (asyncio.Task)."""
71
+
72
+ monitor_task: Any | None = None
73
+ """Background monitoring task for headless agents (asyncio.Task)."""
74
+
75
+ def to_dict(self) -> dict[str, Any]:
76
+ """Convert to dictionary for serialization."""
77
+ return {
78
+ "run_id": self.run_id,
79
+ "session_id": self.session_id,
80
+ "parent_session_id": self.parent_session_id,
81
+ "mode": self.mode,
82
+ "started_at": self.started_at.isoformat(),
83
+ "pid": self.pid,
84
+ "master_fd": self.master_fd,
85
+ "terminal_type": self.terminal_type,
86
+ "provider": self.provider,
87
+ "workflow_name": self.workflow_name,
88
+ "worktree_id": self.worktree_id,
89
+ "has_task": self.task is not None,
90
+ }
91
+
92
+
93
+ class RunningAgentRegistry:
94
+ """
95
+ Thread-safe registry for tracking running agents.
96
+
97
+ This registry tracks agents that are currently executing, whether
98
+ in-process or in external processes (terminal/headless). It provides:
99
+
100
+ - Thread-safe add/get/remove operations
101
+ - Lookup by run_id, session_id, or parent_session_id
102
+ - PID-based lookup for process management
103
+ - Cleanup of stale entries
104
+
105
+ Example:
106
+ >>> registry = RunningAgentRegistry()
107
+ >>> agent = RunningAgent(
108
+ ... run_id="ar-123",
109
+ ... session_id="sess-456",
110
+ ... parent_session_id="sess-parent",
111
+ ... mode="terminal",
112
+ ... pid=12345,
113
+ ... )
114
+ >>> registry.add(agent)
115
+ >>> registry.get("ar-123")
116
+ RunningAgent(...)
117
+ >>> registry.remove("ar-123")
118
+ """
119
+
120
+ def __init__(self) -> None:
121
+ """Initialize the registry with an empty agents dict and lock."""
122
+ self._agents: dict[str, RunningAgent] = {}
123
+ self._lock = threading.RLock()
124
+ self._logger = logger
125
+ self._event_callbacks: list[EventCallback] = []
126
+ self._event_callbacks_lock = threading.Lock()
127
+
128
+ def add_event_callback(self, callback: EventCallback) -> None:
129
+ """
130
+ Add an event callback for agent lifecycle events.
131
+
132
+ Callbacks are invoked when agents are added or removed.
133
+
134
+ Args:
135
+ callback: Function that receives (event_type, run_id, data)
136
+ """
137
+ with self._event_callbacks_lock:
138
+ self._event_callbacks.append(callback)
139
+
140
+ def _emit_event(self, event_type: str, run_id: str, data: dict[str, Any]) -> None:
141
+ """
142
+ Emit an event to all registered callbacks.
143
+
144
+ Args:
145
+ event_type: Type of event (agent_started, agent_completed, etc.)
146
+ run_id: Agent run ID
147
+ data: Additional event data
148
+ """
149
+ # Take a snapshot of callbacks under lock, then iterate outside lock
150
+ with self._event_callbacks_lock:
151
+ callbacks = list(self._event_callbacks)
152
+ for callback in callbacks:
153
+ try:
154
+ callback(event_type, run_id, data)
155
+ except Exception as e:
156
+ self._logger.warning(f"Event callback error: {e}")
157
+
158
+ def add(self, agent: RunningAgent) -> None:
159
+ """
160
+ Add a running agent to the registry.
161
+
162
+ Args:
163
+ agent: The running agent to track.
164
+ """
165
+ with self._lock:
166
+ self._agents[agent.run_id] = agent
167
+ self._logger.debug(
168
+ f"Registered running agent {agent.run_id} (mode={agent.mode}, pid={agent.pid})"
169
+ )
170
+ # Emit event outside lock
171
+ self._emit_event(
172
+ "agent_started",
173
+ agent.run_id,
174
+ {
175
+ "session_id": agent.session_id,
176
+ "parent_session_id": agent.parent_session_id,
177
+ "mode": agent.mode,
178
+ "provider": agent.provider,
179
+ "pid": agent.pid,
180
+ },
181
+ )
182
+
183
+ def get(self, run_id: str) -> RunningAgent | None:
184
+ """
185
+ Get a running agent by run ID.
186
+
187
+ Args:
188
+ run_id: The agent run ID.
189
+
190
+ Returns:
191
+ The RunningAgent if found, None otherwise.
192
+ """
193
+ with self._lock:
194
+ return self._agents.get(run_id)
195
+
196
+ def remove(self, run_id: str, status: str = "completed") -> RunningAgent | None:
197
+ """
198
+ Remove a running agent from the registry.
199
+
200
+ Args:
201
+ run_id: The agent run ID to remove.
202
+ status: Final status (completed, failed, cancelled, timeout).
203
+
204
+ Returns:
205
+ The removed RunningAgent if found, None otherwise.
206
+ """
207
+ with self._lock:
208
+ agent = self._agents.pop(run_id, None)
209
+ if agent:
210
+ self._logger.debug(f"Unregistered running agent {run_id}")
211
+ # Emit event outside lock
212
+ if agent:
213
+ self._emit_event(
214
+ f"agent_{status}",
215
+ run_id,
216
+ {
217
+ "session_id": agent.session_id,
218
+ "parent_session_id": agent.parent_session_id,
219
+ "mode": agent.mode,
220
+ "provider": agent.provider,
221
+ },
222
+ )
223
+ return agent
224
+
225
+ def kill(
226
+ self,
227
+ run_id: str,
228
+ signal_name: str = "TERM",
229
+ timeout: float = 5.0,
230
+ ) -> dict[str, Any]:
231
+ """
232
+ Kill a running agent process.
233
+
234
+ Strategy varies by mode:
235
+ - headless: Direct signal to tracked PID
236
+ - terminal: Check terminal_context for PID, fallback to pgrep
237
+ - embedded: Close PTY fd + signal
238
+ - in_process: Cancel asyncio task
239
+
240
+ Args:
241
+ run_id: Agent run ID
242
+ signal_name: Signal without SIG prefix (TERM, KILL)
243
+ timeout: Seconds before escalating TERM → KILL
244
+
245
+ Returns:
246
+ Dict with success status and details
247
+ """
248
+ import os
249
+ import signal
250
+ import subprocess # nosec B404 - subprocess needed for process management
251
+ import time
252
+
253
+ agent = self.get(run_id)
254
+ if not agent:
255
+ return {"success": False, "error": "Agent not found in registry"}
256
+
257
+ # Handle in_process mode (asyncio.Task)
258
+ if agent.mode == "in_process" and agent.task:
259
+ agent.task.cancel()
260
+ self.remove(run_id, status="cancelled")
261
+ return {"success": True, "message": "Cancelled in-process task"}
262
+
263
+ # For terminal mode, find PID via multiple strategies
264
+ target_pid = agent.pid
265
+ found_via = "registry"
266
+
267
+ if agent.mode == "terminal" and agent.session_id:
268
+ # Strategy 1: Check session's terminal_context (Claude hooks)
269
+ try:
270
+ from gobby.storage.database import LocalDatabase
271
+ from gobby.storage.sessions import LocalSessionManager
272
+
273
+ db = LocalDatabase()
274
+ session_mgr = LocalSessionManager(db)
275
+ session = session_mgr.get(agent.session_id)
276
+ if session and session.terminal_context:
277
+ ctx_pid = session.terminal_context.get("parent_pid")
278
+ if ctx_pid:
279
+ target_pid = int(ctx_pid)
280
+ found_via = "terminal_context"
281
+ self._logger.info(f"Found PID from session terminal_context: {target_pid}")
282
+ except Exception as e:
283
+ self._logger.debug(f"terminal_context lookup failed: {e}")
284
+
285
+ # Strategy 2: pgrep fallback (for Codex/Gemini without hooks)
286
+ if found_via == "registry" or not target_pid:
287
+ # Validate session_id format (UUID or safe identifier) to prevent injection
288
+ session_id_pattern = re.compile(r"^[a-zA-Z0-9_-]+$")
289
+ if not session_id_pattern.match(agent.session_id):
290
+ self._logger.warning(
291
+ f"Invalid session_id format, skipping pgrep: {agent.session_id}"
292
+ )
293
+ else:
294
+ try:
295
+ # Use -- to prevent pgrep from interpreting pattern as options
296
+ result = subprocess.run( # nosec B603 B607 - pgrep with validated session_id
297
+ ["pgrep", "-f", "--", f"session-id {agent.session_id}"],
298
+ capture_output=True,
299
+ text=True,
300
+ timeout=5.0,
301
+ )
302
+ if result.returncode == 0 and result.stdout.strip():
303
+ pids = result.stdout.strip().split("\n")
304
+ if len(pids) == 1:
305
+ target_pid = int(pids[0])
306
+ found_via = "pgrep"
307
+ self._logger.info(f"Found PID via pgrep: {target_pid}")
308
+ else:
309
+ # Multiple PIDs found - need to disambiguate
310
+ self._logger.warning(
311
+ f"pgrep returned {len(pids)} PIDs for session "
312
+ f"{agent.session_id}: {pids}"
313
+ )
314
+ # Inspect each candidate to find the correct one
315
+ matched_pid = None
316
+ for pid_str in pids:
317
+ try:
318
+ candidate_pid = int(pid_str)
319
+ # Query the process command line to verify
320
+ ps_result = subprocess.run( # nosec B603 B607 - ps with numeric PID
321
+ ["ps", "-p", str(candidate_pid), "-o", "args="],
322
+ capture_output=True,
323
+ text=True,
324
+ timeout=2.0,
325
+ )
326
+ if ps_result.returncode == 0:
327
+ cmdline = ps_result.stdout.strip()
328
+ # Verify it's actually the agent process
329
+ # (contains session-id and matches expected CLI)
330
+ if (
331
+ f"session-id {agent.session_id}" in cmdline
332
+ and agent.provider in cmdline.lower()
333
+ ):
334
+ if matched_pid is not None:
335
+ # Multiple matches - ambiguous
336
+ self._logger.error(
337
+ f"Ambiguous PID match: both {matched_pid} "
338
+ f"and {candidate_pid} match session "
339
+ f"{agent.session_id}"
340
+ )
341
+ matched_pid = None
342
+ break
343
+ matched_pid = candidate_pid
344
+ except (ValueError, subprocess.TimeoutExpired):
345
+ continue
346
+ if matched_pid is not None:
347
+ target_pid = matched_pid
348
+ found_via = "pgrep_disambiguated"
349
+ self._logger.info(
350
+ f"Disambiguated PID via ps inspection: {target_pid}"
351
+ )
352
+ else:
353
+ self._logger.error(
354
+ f"Could not disambiguate PIDs for session "
355
+ f"{agent.session_id}: {pids}"
356
+ )
357
+ except Exception as e:
358
+ self._logger.warning(f"pgrep fallback failed: {e}")
359
+
360
+ if not target_pid:
361
+ return {"success": False, "error": "No target PID found"}
362
+
363
+ # Check if process is alive
364
+ try:
365
+ os.kill(target_pid, 0)
366
+ except ProcessLookupError:
367
+ self.remove(run_id, status="completed")
368
+ return {
369
+ "success": True,
370
+ "message": f"Process {target_pid} already dead",
371
+ "already_dead": True,
372
+ }
373
+ except PermissionError:
374
+ return {"success": False, "error": f"No permission to signal PID {target_pid}"}
375
+
376
+ # Close PTY if embedded mode
377
+ if agent.master_fd is not None:
378
+ try:
379
+ os.close(agent.master_fd)
380
+ except OSError:
381
+ pass
382
+
383
+ # Send signal
384
+ sig = getattr(signal, f"SIG{signal_name}", signal.SIGTERM)
385
+ try:
386
+ os.kill(target_pid, sig)
387
+ except ProcessLookupError:
388
+ self.remove(run_id, status="completed")
389
+ return {
390
+ "success": True,
391
+ "message": "Process died during signal",
392
+ "already_dead": True,
393
+ }
394
+
395
+ # Wait for termination with optional SIGKILL escalation
396
+ if signal_name == "TERM" and timeout > 0:
397
+ deadline = time.time() + timeout
398
+ while time.time() < deadline:
399
+ try:
400
+ os.kill(target_pid, 0)
401
+ time.sleep(0.1)
402
+ except ProcessLookupError:
403
+ break
404
+ else:
405
+ # Still alive - escalate to SIGKILL
406
+ try:
407
+ os.kill(target_pid, signal.SIGKILL)
408
+ self._logger.info(f"Escalated to SIGKILL for PID {target_pid}")
409
+ except ProcessLookupError:
410
+ pass
411
+
412
+ self.remove(run_id, status="killed")
413
+ return {
414
+ "success": True,
415
+ "message": f"Sent SIG{signal_name} to PID {target_pid}",
416
+ "pid": target_pid,
417
+ "signal": signal_name,
418
+ "found_via": found_via,
419
+ }
420
+
421
+ def get_by_session(self, session_id: str) -> RunningAgent | None:
422
+ """
423
+ Get a running agent by its child session ID.
424
+
425
+ Args:
426
+ session_id: The child session ID.
427
+
428
+ Returns:
429
+ The RunningAgent if found, None otherwise.
430
+ """
431
+ with self._lock:
432
+ for agent in self._agents.values():
433
+ if agent.session_id == session_id:
434
+ return agent
435
+ return None
436
+
437
+ def get_by_pid(self, pid: int) -> RunningAgent | None:
438
+ """
439
+ Get a running agent by its process ID.
440
+
441
+ Args:
442
+ pid: The process ID.
443
+
444
+ Returns:
445
+ The RunningAgent if found, None otherwise.
446
+ """
447
+ with self._lock:
448
+ for agent in self._agents.values():
449
+ if agent.pid == pid:
450
+ return agent
451
+ return None
452
+
453
+ def list_by_parent(self, parent_session_id: str) -> list[RunningAgent]:
454
+ """
455
+ List all running agents for a parent session.
456
+
457
+ Args:
458
+ parent_session_id: The parent session ID.
459
+
460
+ Returns:
461
+ List of running agents spawned by this parent.
462
+ """
463
+ with self._lock:
464
+ return [
465
+ agent
466
+ for agent in self._agents.values()
467
+ if agent.parent_session_id == parent_session_id
468
+ ]
469
+
470
+ def list_by_mode(self, mode: str) -> list[RunningAgent]:
471
+ """
472
+ List all running agents by execution mode.
473
+
474
+ Args:
475
+ mode: Execution mode (in_process, terminal, embedded, headless).
476
+
477
+ Returns:
478
+ List of running agents with this mode.
479
+ """
480
+ with self._lock:
481
+ return [agent for agent in self._agents.values() if agent.mode == mode]
482
+
483
+ def list_all(self) -> list[RunningAgent]:
484
+ """
485
+ List all running agents.
486
+
487
+ Returns:
488
+ List of all running agents (copy of current state).
489
+ """
490
+ with self._lock:
491
+ return list(self._agents.values())
492
+
493
+ def count(self) -> int:
494
+ """
495
+ Get the number of running agents.
496
+
497
+ Returns:
498
+ Count of running agents.
499
+ """
500
+ with self._lock:
501
+ return len(self._agents)
502
+
503
+ def count_by_parent(self, parent_session_id: str) -> int:
504
+ """
505
+ Count running agents for a parent session.
506
+
507
+ Args:
508
+ parent_session_id: The parent session ID.
509
+
510
+ Returns:
511
+ Count of running agents for this parent.
512
+ """
513
+ with self._lock:
514
+ return sum(
515
+ 1 for agent in self._agents.values() if agent.parent_session_id == parent_session_id
516
+ )
517
+
518
+ def cleanup_by_pids(self, dead_pids: set[int]) -> list[RunningAgent]:
519
+ """
520
+ Remove agents whose PIDs are no longer running.
521
+
522
+ This should be called periodically by a cleanup process that
523
+ checks which PIDs are still alive.
524
+
525
+ Args:
526
+ dead_pids: Set of PIDs that are no longer running.
527
+
528
+ Returns:
529
+ List of agents that were removed.
530
+ """
531
+ removed: list[RunningAgent] = []
532
+ with self._lock:
533
+ for run_id, agent in list(self._agents.items()):
534
+ if agent.pid and agent.pid in dead_pids:
535
+ self._agents.pop(run_id)
536
+ removed.append(agent)
537
+ self._logger.info(f"Cleaned up agent {run_id} with dead PID {agent.pid}")
538
+ # Emit events outside lock for each removed agent
539
+ for agent in removed:
540
+ self._emit_event(
541
+ "agent_completed",
542
+ agent.run_id,
543
+ {
544
+ "session_id": agent.session_id,
545
+ "parent_session_id": agent.parent_session_id,
546
+ "mode": agent.mode,
547
+ "provider": agent.provider,
548
+ "cleanup_reason": "dead_pid",
549
+ },
550
+ )
551
+ return removed
552
+
553
+ def cleanup_stale(self, max_age_seconds: float = 3600.0) -> list[RunningAgent]:
554
+ """
555
+ Remove agents that have been running longer than max_age.
556
+
557
+ Args:
558
+ max_age_seconds: Maximum age in seconds before cleanup (default: 1 hour).
559
+
560
+ Returns:
561
+ List of agents that were removed.
562
+ """
563
+ now = datetime.now(UTC)
564
+ removed: list[RunningAgent] = []
565
+ with self._lock:
566
+ for run_id, agent in list(self._agents.items()):
567
+ age = (now - agent.started_at).total_seconds()
568
+ if age > max_age_seconds:
569
+ self._agents.pop(run_id)
570
+ removed.append(agent)
571
+ self._logger.info(f"Cleaned up stale agent {run_id} (age={age:.0f}s)")
572
+ # Emit events outside lock for each removed agent
573
+ for agent in removed:
574
+ self._emit_event(
575
+ "agent_timeout",
576
+ agent.run_id,
577
+ {
578
+ "session_id": agent.session_id,
579
+ "parent_session_id": agent.parent_session_id,
580
+ "mode": agent.mode,
581
+ "provider": agent.provider,
582
+ "cleanup_reason": "stale",
583
+ },
584
+ )
585
+ return removed
586
+
587
+ def clear(self) -> int:
588
+ """
589
+ Clear all running agents from the registry.
590
+
591
+ Returns:
592
+ Number of agents that were cleared.
593
+ """
594
+ with self._lock:
595
+ count = len(self._agents)
596
+ self._agents.clear()
597
+ self._logger.info(f"Cleared {count} running agents from registry")
598
+ return count
599
+
600
+
601
+ # Global singleton instance
602
+ _default_registry: RunningAgentRegistry | None = None
603
+ _registry_lock = threading.Lock()
604
+
605
+
606
+ def get_running_agent_registry() -> RunningAgentRegistry:
607
+ """
608
+ Get the global running agent registry singleton.
609
+
610
+ Returns:
611
+ The shared RunningAgentRegistry instance.
612
+ """
613
+ global _default_registry
614
+ if _default_registry is None:
615
+ with _registry_lock:
616
+ if _default_registry is None:
617
+ _default_registry = RunningAgentRegistry()
618
+ return _default_registry