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,226 @@
1
+ """Headless spawner for agent execution with output capture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ import subprocess # nosec B404 - subprocess needed for headless agent spawning
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING
10
+
11
+ from gobby.agents.constants import get_terminal_env_vars
12
+ from gobby.agents.spawners.base import HeadlessResult
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Callable
16
+
17
+ __all__ = ["HeadlessSpawner"]
18
+
19
+
20
+ # Import these from spawn.py to avoid duplication
21
+ def _get_spawn_utils() -> tuple[
22
+ Callable[..., list[str]],
23
+ Callable[[str, str], str],
24
+ int,
25
+ ]:
26
+ """Lazy import to avoid circular dependencies."""
27
+ from gobby.agents.spawn import (
28
+ MAX_ENV_PROMPT_LENGTH,
29
+ _create_prompt_file,
30
+ build_cli_command,
31
+ )
32
+
33
+ return build_cli_command, _create_prompt_file, MAX_ENV_PROMPT_LENGTH
34
+
35
+
36
+ class HeadlessSpawner:
37
+ """
38
+ Spawner for headless mode with output capture.
39
+
40
+ Runs the process without a visible terminal, capturing all output
41
+ to a buffer that can be stored in the session transcript.
42
+ """
43
+
44
+ def spawn(
45
+ self,
46
+ command: list[str],
47
+ cwd: str | Path,
48
+ env: dict[str, str] | None = None,
49
+ ) -> HeadlessResult:
50
+ """
51
+ Spawn a headless process with output capture.
52
+
53
+ Args:
54
+ command: Command to run
55
+ cwd: Working directory
56
+ env: Environment variables to set
57
+
58
+ Returns:
59
+ HeadlessResult with process handle for output capture
60
+ """
61
+ try:
62
+ # Merge environment
63
+ spawn_env = os.environ.copy()
64
+ if env:
65
+ spawn_env.update(env)
66
+
67
+ # Spawn process with captured output
68
+ # Use DEVNULL for stdin since headless mode uses -p flag (print mode)
69
+ # which reads prompt from CLI args, not stdin. A pipe stdin would hang.
70
+ process = subprocess.Popen( # nosec B603 - command built from config
71
+ command,
72
+ cwd=cwd,
73
+ env=spawn_env,
74
+ stdout=subprocess.PIPE,
75
+ stderr=subprocess.STDOUT,
76
+ stdin=subprocess.DEVNULL,
77
+ text=True,
78
+ bufsize=1, # Line buffered
79
+ )
80
+
81
+ return HeadlessResult(
82
+ success=True,
83
+ message=f"Spawned headless process with PID {process.pid}",
84
+ pid=process.pid,
85
+ process=process,
86
+ )
87
+
88
+ except Exception as e:
89
+ return HeadlessResult(
90
+ success=False,
91
+ message=f"Failed to spawn headless process: {e}",
92
+ error=str(e),
93
+ )
94
+
95
+ async def spawn_and_capture(
96
+ self,
97
+ command: list[str],
98
+ cwd: str | Path,
99
+ env: dict[str, str] | None = None,
100
+ timeout: float | None = None,
101
+ on_output: Callable[[str], None] | None = None,
102
+ ) -> HeadlessResult:
103
+ """
104
+ Spawn a headless process and capture output asynchronously.
105
+
106
+ Args:
107
+ command: Command to run
108
+ cwd: Working directory
109
+ env: Environment variables to set
110
+ timeout: Optional timeout in seconds
111
+ on_output: Optional callback for each line of output
112
+
113
+ Returns:
114
+ HeadlessResult with captured output
115
+ """
116
+ result = self.spawn(command, cwd, env)
117
+ if not result.success or result.process is None:
118
+ return result
119
+
120
+ try:
121
+ # Read output asynchronously
122
+ async def read_output() -> None:
123
+ if result.process and result.process.stdout:
124
+ loop = asyncio.get_running_loop()
125
+ while True:
126
+ line = await loop.run_in_executor(None, result.process.stdout.readline)
127
+ if not line:
128
+ break
129
+ line = line.rstrip("\n")
130
+ result.output_buffer.append(line)
131
+ if on_output:
132
+ on_output(line)
133
+
134
+ if timeout:
135
+ await asyncio.wait_for(read_output(), timeout=timeout)
136
+ else:
137
+ await read_output()
138
+
139
+ # Wait for process to complete without blocking the event loop
140
+ if result.process:
141
+ loop = asyncio.get_running_loop()
142
+ await loop.run_in_executor(None, result.process.wait)
143
+
144
+ except TimeoutError:
145
+ if result.process:
146
+ result.process.terminate()
147
+ # Also wait for termination to complete (non-blocking)
148
+ try:
149
+ loop = asyncio.get_running_loop()
150
+ await loop.run_in_executor(None, result.process.wait)
151
+ except Exception:
152
+ pass # nosec B110 - Best-effort process cleanup
153
+ result.error = "Process timed out"
154
+
155
+ except Exception as e:
156
+ result.error = str(e)
157
+
158
+ return result
159
+
160
+ def spawn_agent(
161
+ self,
162
+ cli: str,
163
+ cwd: str | Path,
164
+ session_id: str,
165
+ parent_session_id: str,
166
+ agent_run_id: str,
167
+ project_id: str,
168
+ workflow_name: str | None = None,
169
+ agent_depth: int = 1,
170
+ max_agent_depth: int = 3,
171
+ prompt: str | None = None,
172
+ ) -> HeadlessResult:
173
+ """
174
+ Spawn a CLI agent in headless mode.
175
+
176
+ Args:
177
+ cli: CLI to run
178
+ cwd: Working directory
179
+ session_id: Pre-created child session ID
180
+ parent_session_id: Parent session ID
181
+ agent_run_id: Agent run record ID
182
+ project_id: Project ID
183
+ workflow_name: Optional workflow to activate
184
+ agent_depth: Current nesting depth
185
+ max_agent_depth: Maximum allowed depth
186
+ prompt: Optional initial prompt
187
+
188
+ Returns:
189
+ HeadlessResult with process handle
190
+ """
191
+ build_cli_command, _create_prompt_file, max_env_prompt_length = _get_spawn_utils()
192
+
193
+ # Build command with prompt as CLI argument and auto-approve for autonomous work
194
+ command = build_cli_command(
195
+ cli,
196
+ prompt=prompt,
197
+ session_id=session_id,
198
+ auto_approve=True, # Subagents need to work autonomously
199
+ working_directory=str(cwd) if cli == "codex" else None,
200
+ mode="headless", # Non-interactive headless mode
201
+ )
202
+
203
+ # Handle prompt for environment variables (backup for hooks/context)
204
+ prompt_env: str | None = None
205
+ prompt_file: str | None = None
206
+
207
+ if prompt:
208
+ if len(prompt) <= max_env_prompt_length:
209
+ prompt_env = prompt
210
+ else:
211
+ # Write to temp file with secure permissions
212
+ prompt_file = _create_prompt_file(prompt, session_id)
213
+
214
+ env = get_terminal_env_vars(
215
+ session_id=session_id,
216
+ parent_session_id=parent_session_id,
217
+ agent_run_id=agent_run_id,
218
+ project_id=project_id,
219
+ workflow_name=workflow_name,
220
+ agent_depth=agent_depth,
221
+ max_agent_depth=max_agent_depth,
222
+ prompt=prompt_env,
223
+ prompt_file=prompt_file,
224
+ )
225
+
226
+ return self.spawn(command, cwd, env)
@@ -0,0 +1,125 @@
1
+ """Linux terminal spawners: GNOME Terminal and Konsole."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import subprocess # nosec B404 - subprocess needed for terminal spawning
8
+ from pathlib import Path
9
+
10
+ from gobby.agents.spawners.base import SpawnResult, TerminalSpawnerBase, TerminalType
11
+ from gobby.agents.tty_config import get_tty_config
12
+
13
+ __all__ = ["GnomeTerminalSpawner", "KonsoleSpawner"]
14
+
15
+
16
+ class GnomeTerminalSpawner(TerminalSpawnerBase):
17
+ """Spawner for GNOME Terminal."""
18
+
19
+ @property
20
+ def terminal_type(self) -> TerminalType:
21
+ return TerminalType.GNOME_TERMINAL
22
+
23
+ def is_available(self) -> bool:
24
+ config = get_tty_config().get_terminal_config("gnome-terminal")
25
+ if not config.enabled:
26
+ return False
27
+ command = config.command or "gnome-terminal"
28
+ return shutil.which(command) is not None
29
+
30
+ def spawn(
31
+ self,
32
+ command: list[str],
33
+ cwd: str | Path,
34
+ env: dict[str, str] | None = None,
35
+ title: str | None = None,
36
+ ) -> SpawnResult:
37
+ try:
38
+ tty_config = get_tty_config().get_terminal_config("gnome-terminal")
39
+ cli_command = tty_config.command or "gnome-terminal"
40
+ args = [cli_command, f"--working-directory={cwd}"]
41
+ # Add extra options from config
42
+ args.extend(tty_config.options)
43
+ if title:
44
+ args.extend(["--title", title])
45
+ args.extend(["--", *command])
46
+
47
+ spawn_env = os.environ.copy()
48
+ if env:
49
+ spawn_env.update(env)
50
+
51
+ process = subprocess.Popen( # nosec B603 - args built from config
52
+ args,
53
+ env=spawn_env,
54
+ start_new_session=True,
55
+ )
56
+
57
+ return SpawnResult(
58
+ success=True,
59
+ message=f"Spawned GNOME Terminal with PID {process.pid}",
60
+ pid=process.pid,
61
+ terminal_type=self.terminal_type.value,
62
+ )
63
+
64
+ except Exception as e:
65
+ return SpawnResult(
66
+ success=False,
67
+ message=f"Failed to spawn GNOME Terminal: {e}",
68
+ error=str(e),
69
+ )
70
+
71
+
72
+ class KonsoleSpawner(TerminalSpawnerBase):
73
+ """Spawner for KDE Konsole."""
74
+
75
+ @property
76
+ def terminal_type(self) -> TerminalType:
77
+ return TerminalType.KONSOLE
78
+
79
+ def is_available(self) -> bool:
80
+ config = get_tty_config().get_terminal_config("konsole")
81
+ if not config.enabled:
82
+ return False
83
+ command = config.command or "konsole"
84
+ return shutil.which(command) is not None
85
+
86
+ def spawn(
87
+ self,
88
+ command: list[str],
89
+ cwd: str | Path,
90
+ env: dict[str, str] | None = None,
91
+ title: str | None = None,
92
+ ) -> SpawnResult:
93
+ try:
94
+ tty_config = get_tty_config().get_terminal_config("konsole")
95
+ cli_command = tty_config.command or "konsole"
96
+ args = [cli_command, "--workdir", str(cwd)]
97
+ # Add extra options from config
98
+ args.extend(tty_config.options)
99
+ if title:
100
+ args.extend(["-p", f"tabtitle={title}"])
101
+ args.extend(["-e", *command])
102
+
103
+ spawn_env = os.environ.copy()
104
+ if env:
105
+ spawn_env.update(env)
106
+
107
+ process = subprocess.Popen( # nosec B603 - args built from config
108
+ args,
109
+ env=spawn_env,
110
+ start_new_session=True,
111
+ )
112
+
113
+ return SpawnResult(
114
+ success=True,
115
+ message=f"Spawned Konsole with PID {process.pid}",
116
+ pid=process.pid,
117
+ terminal_type=self.terminal_type.value,
118
+ )
119
+
120
+ except Exception as e:
121
+ return SpawnResult(
122
+ success=False,
123
+ message=f"Failed to spawn Konsole: {e}",
124
+ error=str(e),
125
+ )
@@ -0,0 +1,277 @@
1
+ """macOS terminal spawners: Ghostty, iTerm2, and Terminal.app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import platform
7
+ import shlex
8
+ import shutil
9
+ import subprocess # nosec B404 - subprocess needed for terminal spawning
10
+ import tempfile
11
+ from pathlib import Path
12
+
13
+ from gobby.agents.spawners.base import SpawnResult, TerminalSpawnerBase, TerminalType
14
+ from gobby.agents.tty_config import get_tty_config
15
+
16
+ __all__ = ["GhosttySpawner", "ITermSpawner", "TerminalAppSpawner"]
17
+
18
+
19
+ def escape_applescript(s: str) -> str:
20
+ """Escape special characters for embedding in AppleScript strings.
21
+
22
+ AppleScript uses backslash escaping for quotes and backslashes.
23
+ """
24
+ return s.replace("\\", "\\\\").replace('"', '\\"')
25
+
26
+
27
+ class GhosttySpawner(TerminalSpawnerBase):
28
+ """Spawner for Ghostty terminal."""
29
+
30
+ @property
31
+ def terminal_type(self) -> TerminalType:
32
+ return TerminalType.GHOSTTY
33
+
34
+ def is_available(self) -> bool:
35
+ config = get_tty_config().get_terminal_config("ghostty")
36
+ if not config.enabled:
37
+ return False
38
+ # On macOS, check for the app bundle; on other platforms check CLI
39
+ if platform.system() == "Darwin":
40
+ app_path = config.app_path or "/Applications/Ghostty.app"
41
+ return Path(app_path).exists()
42
+ command = config.command or "ghostty"
43
+ return shutil.which(command) is not None
44
+
45
+ def spawn(
46
+ self,
47
+ command: list[str],
48
+ cwd: str | Path,
49
+ env: dict[str, str] | None = None,
50
+ title: str | None = None,
51
+ ) -> SpawnResult:
52
+ try:
53
+ tty_config = get_tty_config().get_terminal_config("ghostty")
54
+ # On macOS, ghostty CLI doesn't support launching the emulator directly
55
+ # Must use 'open -na Ghostty.app --args' instead
56
+ # Note: Ghostty requires --key=value syntax, not --key value
57
+ if platform.system() == "Darwin":
58
+ app_path = tty_config.app_path or "/Applications/Ghostty.app"
59
+ # Build args for open command
60
+ # open -na /path/to/Ghostty.app --args [ghostty-options] -e [command]
61
+ # Note: 'open' doesn't pass cwd, so we must use --working-directory
62
+ ghostty_args = [f"--working-directory={cwd}"]
63
+ if title:
64
+ ghostty_args.append(f"--title={title}")
65
+ # Add any extra options from config
66
+ ghostty_args.extend(tty_config.options)
67
+ ghostty_args.extend(["-e"] + command)
68
+
69
+ args = ["open", "-na", app_path, "--args"] + ghostty_args
70
+ else:
71
+ # On Linux/other platforms, use ghostty CLI directly
72
+ cli_command = tty_config.command or "ghostty"
73
+ args = [cli_command]
74
+ if title:
75
+ args.append(f"--title={title}")
76
+ # Add any extra options from config
77
+ args.extend(tty_config.options)
78
+ args.extend(["-e"] + command)
79
+
80
+ # Merge environment
81
+ spawn_env = os.environ.copy()
82
+ if env:
83
+ spawn_env.update(env)
84
+
85
+ # Spawn process
86
+ process = subprocess.Popen( # nosec B603 - args built from config
87
+ args,
88
+ cwd=cwd,
89
+ env=spawn_env,
90
+ start_new_session=True,
91
+ )
92
+
93
+ return SpawnResult(
94
+ success=True,
95
+ message=f"Spawned Ghostty with PID {process.pid}",
96
+ pid=process.pid,
97
+ terminal_type=self.terminal_type.value,
98
+ )
99
+
100
+ except Exception as e:
101
+ return SpawnResult(
102
+ success=False,
103
+ message=f"Failed to spawn Ghostty: {e}",
104
+ error=str(e),
105
+ )
106
+
107
+
108
+ class ITermSpawner(TerminalSpawnerBase):
109
+ """Spawner for iTerm2 on macOS."""
110
+
111
+ @property
112
+ def terminal_type(self) -> TerminalType:
113
+ return TerminalType.ITERM
114
+
115
+ def is_available(self) -> bool:
116
+ if platform.system() != "Darwin":
117
+ return False
118
+ config = get_tty_config().get_terminal_config("iterm")
119
+ if not config.enabled:
120
+ return False
121
+ app_path = config.app_path or "/Applications/iTerm.app"
122
+ return Path(app_path).exists()
123
+
124
+ def spawn(
125
+ self,
126
+ command: list[str],
127
+ cwd: str | Path,
128
+ env: dict[str, str] | None = None,
129
+ title: str | None = None,
130
+ ) -> SpawnResult:
131
+ try:
132
+ # Write command to a temp script to avoid escaping issues
133
+ # This is the most reliable way to pass complex commands to iTerm
134
+ script_content = "#!/bin/bash\n"
135
+ script_content += f"cd {shlex.quote(str(cwd))}\n"
136
+ if env:
137
+ for k, v in env.items():
138
+ if k.isidentifier():
139
+ script_content += f"export {k}={shlex.quote(v)}\n"
140
+ script_content += shlex.join(command) + "\n"
141
+ script_content += "exit\n" # Exit shell so terminal window closes
142
+
143
+ # Create temp script file
144
+ script_dir = Path(tempfile.gettempdir()) / "gobby-scripts"
145
+ script_dir.mkdir(parents=True, exist_ok=True)
146
+ script_path = script_dir / f"iterm-{os.getpid()}-{id(command)}.sh"
147
+ script_path.write_text(script_content)
148
+ script_path.chmod(0o755)
149
+
150
+ # Check if iTerm was running before we launch it
151
+ # If running: create new window with command
152
+ # If not running: use the default window that gets auto-created
153
+ # Escape script_path to prevent AppleScript injection
154
+ safe_script_path = escape_applescript(str(script_path))
155
+ applescript = f"""
156
+ set iTermWasRunning to application "iTerm" is running
157
+ tell application "iTerm"
158
+ activate
159
+ if iTermWasRunning then
160
+ -- iTerm already running, create a new window
161
+ create window with default profile command "{safe_script_path}"
162
+ else
163
+ -- iTerm just launched, use the default window
164
+ -- Wait for shell to be ready, then exec script (replaces shell so it closes when done)
165
+ delay 0.5
166
+ tell current session of current window
167
+ write text "exec {safe_script_path}"
168
+ end tell
169
+ end if
170
+ end tell
171
+ """
172
+
173
+ process = subprocess.Popen( # nosec B603 - osascript with internal AppleScript
174
+ ["/usr/bin/osascript", "-e", applescript],
175
+ start_new_session=True,
176
+ )
177
+
178
+ return SpawnResult(
179
+ success=True,
180
+ message="Spawned iTerm window",
181
+ pid=process.pid,
182
+ terminal_type=self.terminal_type.value,
183
+ )
184
+
185
+ except Exception as e:
186
+ return SpawnResult(
187
+ success=False,
188
+ message=f"Failed to spawn iTerm: {e}",
189
+ error=str(e),
190
+ )
191
+
192
+
193
+ class TerminalAppSpawner(TerminalSpawnerBase):
194
+ """Spawner for Terminal.app on macOS."""
195
+
196
+ @property
197
+ def terminal_type(self) -> TerminalType:
198
+ return TerminalType.TERMINAL_APP
199
+
200
+ def is_available(self) -> bool:
201
+ if platform.system() != "Darwin":
202
+ return False
203
+ config = get_tty_config().get_terminal_config("terminal.app")
204
+ if not config.enabled:
205
+ return False
206
+ app_path = config.app_path or "/System/Applications/Utilities/Terminal.app"
207
+ return Path(app_path).exists()
208
+
209
+ def spawn(
210
+ self,
211
+ command: list[str],
212
+ cwd: str | Path,
213
+ env: dict[str, str] | None = None,
214
+ title: str | None = None,
215
+ ) -> SpawnResult:
216
+ try:
217
+ # Build AppleScript for Terminal.app with proper escaping
218
+ # Shell-quote the command to prevent injection
219
+ cmd_str = shlex.join(command)
220
+
221
+ # Shell-quote environment variable assignments
222
+ env_exports = ""
223
+ if env:
224
+ # Quote both keys and values to prevent injection
225
+ exports = []
226
+ for k, v in env.items():
227
+ # Validate key is a valid shell variable name
228
+ if k.isidentifier():
229
+ exports.append(f"export {k}={shlex.quote(v)};")
230
+ env_exports = " ".join(exports)
231
+
232
+ # Quote cwd for shell
233
+ safe_cwd = shlex.quote(str(cwd))
234
+
235
+ shell_command = f"cd {safe_cwd} && {env_exports} {cmd_str}"
236
+ safe_shell_command = escape_applescript(shell_command)
237
+
238
+ # Check if Terminal was running before we launch it
239
+ # If running: do script creates a new window (correct behavior)
240
+ # If not running: Terminal opens a default window on launch, so we use that
241
+ # instead of calling do script (which would open a second window)
242
+ script = f"""
243
+ set termWasRunning to application "Terminal" is running
244
+ tell application "Terminal"
245
+ activate
246
+ if termWasRunning then
247
+ -- Terminal already running, create a new window
248
+ do script "{safe_shell_command}"
249
+ else
250
+ -- Terminal just launched, use the default window
251
+ -- Wait for shell to be ready, then exec our command
252
+ delay 0.3
253
+ tell front window
254
+ do script "{safe_shell_command}" in selected tab
255
+ end tell
256
+ end if
257
+ end tell
258
+ """
259
+
260
+ process = subprocess.Popen( # nosec B603 - osascript with internal AppleScript
261
+ ["/usr/bin/osascript", "-e", script],
262
+ start_new_session=True,
263
+ )
264
+
265
+ return SpawnResult(
266
+ success=True,
267
+ message="Spawned Terminal.app window",
268
+ pid=process.pid,
269
+ terminal_type=self.terminal_type.value,
270
+ )
271
+
272
+ except Exception as e:
273
+ return SpawnResult(
274
+ success=False,
275
+ message=f"Failed to spawn Terminal.app: {e}",
276
+ error=str(e),
277
+ )