gobby 0.2.5__py3-none-any.whl → 0.2.7__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 (244) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +13 -4
  4. gobby/adapters/codex_impl/__init__.py +28 -0
  5. gobby/adapters/codex_impl/adapter.py +722 -0
  6. gobby/adapters/codex_impl/client.py +679 -0
  7. gobby/adapters/codex_impl/protocol.py +20 -0
  8. gobby/adapters/codex_impl/types.py +68 -0
  9. gobby/agents/definitions.py +11 -1
  10. gobby/agents/isolation.py +395 -0
  11. gobby/agents/runner.py +8 -0
  12. gobby/agents/sandbox.py +261 -0
  13. gobby/agents/spawn.py +42 -287
  14. gobby/agents/spawn_executor.py +385 -0
  15. gobby/agents/spawners/__init__.py +24 -0
  16. gobby/agents/spawners/command_builder.py +189 -0
  17. gobby/agents/spawners/embedded.py +21 -2
  18. gobby/agents/spawners/headless.py +21 -2
  19. gobby/agents/spawners/prompt_manager.py +125 -0
  20. gobby/cli/__init__.py +6 -0
  21. gobby/cli/clones.py +419 -0
  22. gobby/cli/conductor.py +266 -0
  23. gobby/cli/install.py +4 -4
  24. gobby/cli/installers/antigravity.py +3 -9
  25. gobby/cli/installers/claude.py +15 -9
  26. gobby/cli/installers/codex.py +2 -8
  27. gobby/cli/installers/gemini.py +8 -8
  28. gobby/cli/installers/shared.py +175 -13
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/skills.py +858 -0
  31. gobby/cli/tasks/ai.py +0 -440
  32. gobby/cli/tasks/crud.py +44 -6
  33. gobby/cli/tasks/main.py +0 -4
  34. gobby/cli/tui.py +2 -2
  35. gobby/cli/utils.py +12 -5
  36. gobby/clones/__init__.py +13 -0
  37. gobby/clones/git.py +547 -0
  38. gobby/conductor/__init__.py +16 -0
  39. gobby/conductor/alerts.py +135 -0
  40. gobby/conductor/loop.py +164 -0
  41. gobby/conductor/monitors/__init__.py +11 -0
  42. gobby/conductor/monitors/agents.py +116 -0
  43. gobby/conductor/monitors/tasks.py +155 -0
  44. gobby/conductor/pricing.py +234 -0
  45. gobby/conductor/token_tracker.py +160 -0
  46. gobby/config/__init__.py +12 -97
  47. gobby/config/app.py +69 -91
  48. gobby/config/extensions.py +2 -2
  49. gobby/config/features.py +7 -130
  50. gobby/config/search.py +110 -0
  51. gobby/config/servers.py +1 -1
  52. gobby/config/skills.py +43 -0
  53. gobby/config/tasks.py +9 -41
  54. gobby/hooks/__init__.py +0 -13
  55. gobby/hooks/event_handlers.py +188 -2
  56. gobby/hooks/hook_manager.py +50 -4
  57. gobby/hooks/plugins.py +1 -1
  58. gobby/hooks/skill_manager.py +130 -0
  59. gobby/hooks/webhooks.py +1 -1
  60. gobby/install/claude/hooks/hook_dispatcher.py +4 -4
  61. gobby/install/codex/hooks/hook_dispatcher.py +1 -1
  62. gobby/install/gemini/hooks/hook_dispatcher.py +87 -12
  63. gobby/llm/claude.py +22 -34
  64. gobby/llm/claude_executor.py +46 -256
  65. gobby/llm/codex_executor.py +59 -291
  66. gobby/llm/executor.py +21 -0
  67. gobby/llm/gemini.py +134 -110
  68. gobby/llm/litellm_executor.py +143 -6
  69. gobby/llm/resolver.py +98 -35
  70. gobby/mcp_proxy/importer.py +62 -4
  71. gobby/mcp_proxy/instructions.py +56 -0
  72. gobby/mcp_proxy/models.py +15 -0
  73. gobby/mcp_proxy/registries.py +68 -8
  74. gobby/mcp_proxy/server.py +33 -3
  75. gobby/mcp_proxy/services/recommendation.py +43 -11
  76. gobby/mcp_proxy/services/tool_proxy.py +81 -1
  77. gobby/mcp_proxy/stdio.py +2 -1
  78. gobby/mcp_proxy/tools/__init__.py +0 -2
  79. gobby/mcp_proxy/tools/agent_messaging.py +317 -0
  80. gobby/mcp_proxy/tools/agents.py +31 -731
  81. gobby/mcp_proxy/tools/clones.py +518 -0
  82. gobby/mcp_proxy/tools/memory.py +3 -26
  83. gobby/mcp_proxy/tools/metrics.py +65 -1
  84. gobby/mcp_proxy/tools/orchestration/__init__.py +3 -0
  85. gobby/mcp_proxy/tools/orchestration/cleanup.py +151 -0
  86. gobby/mcp_proxy/tools/orchestration/wait.py +467 -0
  87. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  88. gobby/mcp_proxy/tools/sessions/_commits.py +232 -0
  89. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  90. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  91. gobby/mcp_proxy/tools/sessions/_handoff.py +499 -0
  92. gobby/mcp_proxy/tools/sessions/_messages.py +138 -0
  93. gobby/mcp_proxy/tools/skills/__init__.py +616 -0
  94. gobby/mcp_proxy/tools/spawn_agent.py +417 -0
  95. gobby/mcp_proxy/tools/task_orchestration.py +7 -0
  96. gobby/mcp_proxy/tools/task_readiness.py +14 -0
  97. gobby/mcp_proxy/tools/task_sync.py +1 -1
  98. gobby/mcp_proxy/tools/tasks/_context.py +0 -20
  99. gobby/mcp_proxy/tools/tasks/_crud.py +91 -4
  100. gobby/mcp_proxy/tools/tasks/_expansion.py +348 -0
  101. gobby/mcp_proxy/tools/tasks/_factory.py +6 -16
  102. gobby/mcp_proxy/tools/tasks/_lifecycle.py +110 -45
  103. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +18 -29
  104. gobby/mcp_proxy/tools/workflows.py +1 -1
  105. gobby/mcp_proxy/tools/worktrees.py +0 -338
  106. gobby/memory/backends/__init__.py +6 -1
  107. gobby/memory/backends/mem0.py +6 -1
  108. gobby/memory/extractor.py +477 -0
  109. gobby/memory/ingestion/__init__.py +5 -0
  110. gobby/memory/ingestion/multimodal.py +221 -0
  111. gobby/memory/manager.py +73 -285
  112. gobby/memory/search/__init__.py +10 -0
  113. gobby/memory/search/coordinator.py +248 -0
  114. gobby/memory/services/__init__.py +5 -0
  115. gobby/memory/services/crossref.py +142 -0
  116. gobby/prompts/loader.py +5 -2
  117. gobby/runner.py +37 -16
  118. gobby/search/__init__.py +48 -6
  119. gobby/search/backends/__init__.py +159 -0
  120. gobby/search/backends/embedding.py +225 -0
  121. gobby/search/embeddings.py +238 -0
  122. gobby/search/models.py +148 -0
  123. gobby/search/unified.py +496 -0
  124. gobby/servers/http.py +24 -12
  125. gobby/servers/routes/admin.py +294 -0
  126. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  127. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  128. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  129. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  130. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  131. gobby/servers/routes/mcp/hooks.py +1 -1
  132. gobby/servers/routes/mcp/tools.py +48 -1317
  133. gobby/servers/websocket.py +2 -2
  134. gobby/sessions/analyzer.py +2 -0
  135. gobby/sessions/lifecycle.py +1 -1
  136. gobby/sessions/processor.py +10 -0
  137. gobby/sessions/transcripts/base.py +2 -0
  138. gobby/sessions/transcripts/claude.py +79 -10
  139. gobby/skills/__init__.py +91 -0
  140. gobby/skills/loader.py +685 -0
  141. gobby/skills/manager.py +384 -0
  142. gobby/skills/parser.py +286 -0
  143. gobby/skills/search.py +463 -0
  144. gobby/skills/sync.py +119 -0
  145. gobby/skills/updater.py +385 -0
  146. gobby/skills/validator.py +368 -0
  147. gobby/storage/clones.py +378 -0
  148. gobby/storage/database.py +1 -1
  149. gobby/storage/memories.py +43 -13
  150. gobby/storage/migrations.py +162 -201
  151. gobby/storage/sessions.py +116 -7
  152. gobby/storage/skills.py +782 -0
  153. gobby/storage/tasks/_crud.py +4 -4
  154. gobby/storage/tasks/_lifecycle.py +57 -7
  155. gobby/storage/tasks/_manager.py +14 -5
  156. gobby/storage/tasks/_models.py +8 -3
  157. gobby/sync/memories.py +40 -5
  158. gobby/sync/tasks.py +83 -6
  159. gobby/tasks/__init__.py +1 -2
  160. gobby/tasks/external_validator.py +1 -1
  161. gobby/tasks/validation.py +46 -35
  162. gobby/tools/summarizer.py +91 -10
  163. gobby/tui/api_client.py +4 -7
  164. gobby/tui/app.py +5 -3
  165. gobby/tui/screens/orchestrator.py +1 -2
  166. gobby/tui/screens/tasks.py +2 -4
  167. gobby/tui/ws_client.py +1 -1
  168. gobby/utils/daemon_client.py +2 -2
  169. gobby/utils/project_context.py +2 -3
  170. gobby/utils/status.py +13 -0
  171. gobby/workflows/actions.py +221 -1135
  172. gobby/workflows/artifact_actions.py +31 -0
  173. gobby/workflows/autonomous_actions.py +11 -0
  174. gobby/workflows/context_actions.py +93 -1
  175. gobby/workflows/detection_helpers.py +115 -31
  176. gobby/workflows/enforcement/__init__.py +47 -0
  177. gobby/workflows/enforcement/blocking.py +269 -0
  178. gobby/workflows/enforcement/commit_policy.py +283 -0
  179. gobby/workflows/enforcement/handlers.py +269 -0
  180. gobby/workflows/{task_enforcement_actions.py → enforcement/task_policy.py} +29 -388
  181. gobby/workflows/engine.py +13 -2
  182. gobby/workflows/git_utils.py +106 -0
  183. gobby/workflows/lifecycle_evaluator.py +29 -1
  184. gobby/workflows/llm_actions.py +30 -0
  185. gobby/workflows/loader.py +19 -6
  186. gobby/workflows/mcp_actions.py +20 -1
  187. gobby/workflows/memory_actions.py +154 -0
  188. gobby/workflows/safe_evaluator.py +183 -0
  189. gobby/workflows/session_actions.py +44 -0
  190. gobby/workflows/state_actions.py +60 -1
  191. gobby/workflows/stop_signal_actions.py +55 -0
  192. gobby/workflows/summary_actions.py +111 -1
  193. gobby/workflows/task_sync_actions.py +347 -0
  194. gobby/workflows/todo_actions.py +34 -1
  195. gobby/workflows/webhook_actions.py +185 -0
  196. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/METADATA +87 -21
  197. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/RECORD +201 -172
  198. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/WHEEL +1 -1
  199. gobby/adapters/codex.py +0 -1292
  200. gobby/install/claude/commands/gobby/bug.md +0 -51
  201. gobby/install/claude/commands/gobby/chore.md +0 -51
  202. gobby/install/claude/commands/gobby/epic.md +0 -52
  203. gobby/install/claude/commands/gobby/eval.md +0 -235
  204. gobby/install/claude/commands/gobby/feat.md +0 -49
  205. gobby/install/claude/commands/gobby/nit.md +0 -52
  206. gobby/install/claude/commands/gobby/ref.md +0 -52
  207. gobby/install/codex/prompts/forget.md +0 -7
  208. gobby/install/codex/prompts/memories.md +0 -7
  209. gobby/install/codex/prompts/recall.md +0 -7
  210. gobby/install/codex/prompts/remember.md +0 -13
  211. gobby/llm/gemini_executor.py +0 -339
  212. gobby/mcp_proxy/tools/session_messages.py +0 -1056
  213. gobby/mcp_proxy/tools/task_expansion.py +0 -591
  214. gobby/prompts/defaults/expansion/system.md +0 -119
  215. gobby/prompts/defaults/expansion/user.md +0 -48
  216. gobby/prompts/defaults/external_validation/agent.md +0 -72
  217. gobby/prompts/defaults/external_validation/external.md +0 -63
  218. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  219. gobby/prompts/defaults/external_validation/system.md +0 -6
  220. gobby/prompts/defaults/features/import_mcp.md +0 -22
  221. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  222. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  223. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  224. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  225. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  226. gobby/prompts/defaults/features/server_description.md +0 -20
  227. gobby/prompts/defaults/features/server_description_system.md +0 -6
  228. gobby/prompts/defaults/features/task_description.md +0 -31
  229. gobby/prompts/defaults/features/task_description_system.md +0 -6
  230. gobby/prompts/defaults/features/tool_summary.md +0 -17
  231. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  232. gobby/prompts/defaults/research/step.md +0 -58
  233. gobby/prompts/defaults/validation/criteria.md +0 -47
  234. gobby/prompts/defaults/validation/validate.md +0 -38
  235. gobby/storage/migrations_legacy.py +0 -1359
  236. gobby/tasks/context.py +0 -747
  237. gobby/tasks/criteria.py +0 -342
  238. gobby/tasks/expansion.py +0 -626
  239. gobby/tasks/prompts/expand.py +0 -327
  240. gobby/tasks/research.py +0 -421
  241. gobby/tasks/tdd.py +0 -352
  242. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/entry_points.txt +0 -0
  243. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/licenses/LICENSE.md +0 -0
  244. {gobby-0.2.5.dist-info → gobby-0.2.7.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,261 @@
1
+ """
2
+ Sandbox Configuration Models.
3
+
4
+ This module defines configuration models for sandbox/isolation settings
5
+ when spawning agents. The actual sandboxing is handled by each CLI's
6
+ built-in sandbox implementation - Gobby just passes the right flags.
7
+ """
8
+
9
+ from abc import ABC, abstractmethod
10
+ from typing import Literal
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+
15
+ class SandboxConfig(BaseModel):
16
+ """
17
+ Configuration for sandbox/isolation when spawning agents.
18
+
19
+ This is opt-in - by default sandboxing is disabled to preserve
20
+ existing behavior. When enabled, the appropriate CLI flags are
21
+ passed to enable the CLI's built-in sandbox.
22
+
23
+ Attributes:
24
+ enabled: Whether to enable sandboxing. Default False.
25
+ mode: Sandbox strictness level.
26
+ - "permissive": Allow more operations (easier debugging)
27
+ - "restrictive": Stricter isolation (more secure)
28
+ allow_network: Whether to allow network access (except localhost:60887
29
+ which is always allowed for Gobby daemon communication).
30
+ extra_read_paths: Additional paths to allow read access.
31
+ extra_write_paths: Additional paths to allow write access
32
+ (worktree paths are always allowed).
33
+ """
34
+
35
+ enabled: bool = False
36
+ mode: Literal["permissive", "restrictive"] = "permissive"
37
+ allow_network: bool = True
38
+ extra_read_paths: list[str] = Field(default_factory=list)
39
+ extra_write_paths: list[str] = Field(default_factory=list)
40
+
41
+
42
+ class ResolvedSandboxPaths(BaseModel):
43
+ """
44
+ Resolved paths and settings for sandbox execution.
45
+
46
+ This is the computed result after resolving a SandboxConfig against
47
+ the actual workspace and daemon configuration. It contains the concrete
48
+ paths and settings that will be passed to CLI sandbox flags.
49
+
50
+ Attributes:
51
+ workspace_path: The primary workspace/worktree path for the agent.
52
+ gobby_daemon_port: Port where Gobby daemon is running (for network allowlist).
53
+ read_paths: All paths the sandbox should allow read access to.
54
+ write_paths: All paths the sandbox should allow write access to.
55
+ allow_external_network: Whether to allow network access beyond localhost.
56
+ """
57
+
58
+ workspace_path: str
59
+ gobby_daemon_port: int = 60887
60
+ read_paths: list[str]
61
+ write_paths: list[str]
62
+ allow_external_network: bool
63
+
64
+
65
+ class SandboxResolver(ABC):
66
+ """
67
+ Abstract base class for CLI-specific sandbox configuration resolution.
68
+
69
+ Each CLI (Claude Code, Codex, Gemini) has different mechanisms for
70
+ enabling sandboxing. Subclasses implement the resolve() method to
71
+ convert a SandboxConfig and ResolvedSandboxPaths into CLI-specific
72
+ arguments and environment variables.
73
+ """
74
+
75
+ @property
76
+ @abstractmethod
77
+ def cli_name(self) -> str:
78
+ """Return the name of the CLI this resolver handles."""
79
+ ...
80
+
81
+ @abstractmethod
82
+ def resolve(
83
+ self, config: SandboxConfig, paths: ResolvedSandboxPaths
84
+ ) -> tuple[list[str], dict[str, str]]:
85
+ """
86
+ Resolve sandbox configuration to CLI-specific args and env vars.
87
+
88
+ Args:
89
+ config: The sandbox configuration from the agent definition.
90
+ paths: The resolved paths for the sandbox environment.
91
+
92
+ Returns:
93
+ A tuple of (cli_args, env_vars) where:
94
+ - cli_args: List of command-line arguments to pass to the CLI
95
+ - env_vars: Dict of environment variables to set
96
+ """
97
+ ...
98
+
99
+
100
+ class ClaudeSandboxResolver(SandboxResolver):
101
+ """
102
+ Sandbox resolver for Claude Code CLI.
103
+
104
+ Claude Code uses --settings with a JSON object containing sandbox config.
105
+ See: https://code.claude.com/docs/en/sandboxing
106
+ """
107
+
108
+ @property
109
+ def cli_name(self) -> str:
110
+ return "claude"
111
+
112
+ def resolve(
113
+ self, config: SandboxConfig, paths: ResolvedSandboxPaths
114
+ ) -> tuple[list[str], dict[str, str]]:
115
+ if not config.enabled:
116
+ return ([], {})
117
+
118
+ import json
119
+
120
+ # Build settings JSON for Claude Code
121
+ settings = {
122
+ "sandbox": {
123
+ "enabled": True,
124
+ "autoAllowBashIfSandboxed": True,
125
+ # Network config - allow localhost for Gobby daemon
126
+ "network": {
127
+ "allowLocalBinding": True,
128
+ },
129
+ }
130
+ }
131
+
132
+ return (["--settings", json.dumps(settings)], {})
133
+
134
+
135
+ class CodexSandboxResolver(SandboxResolver):
136
+ """
137
+ Sandbox resolver for OpenAI Codex CLI.
138
+
139
+ Codex uses --sandbox flag with mode (read-only, workspace-write, danger-full-access)
140
+ and --add-dir for additional writable paths.
141
+ See: https://developers.openai.com/codex/cli/reference/
142
+ """
143
+
144
+ @property
145
+ def cli_name(self) -> str:
146
+ return "codex"
147
+
148
+ def resolve(
149
+ self, config: SandboxConfig, paths: ResolvedSandboxPaths
150
+ ) -> tuple[list[str], dict[str, str]]:
151
+ if not config.enabled:
152
+ return ([], {})
153
+
154
+ args: list[str] = []
155
+
156
+ # Sandbox mode
157
+ if config.mode == "restrictive":
158
+ args.extend(["--sandbox", "read-only"])
159
+ else:
160
+ args.extend(["--sandbox", "workspace-write"])
161
+
162
+ # Add extra write paths (workspace is implicit in workspace-write mode)
163
+ for path in paths.write_paths:
164
+ if path != paths.workspace_path:
165
+ args.extend(["--add-dir", path])
166
+
167
+ return (args, {})
168
+
169
+
170
+ class GeminiSandboxResolver(SandboxResolver):
171
+ """
172
+ Sandbox resolver for Google Gemini CLI.
173
+
174
+ Gemini uses -s/--sandbox flag and SEATBELT_PROFILE env var for macOS.
175
+ See: https://geminicli.com/docs/cli/sandbox/
176
+ """
177
+
178
+ @property
179
+ def cli_name(self) -> str:
180
+ return "gemini"
181
+
182
+ def resolve(
183
+ self, config: SandboxConfig, paths: ResolvedSandboxPaths
184
+ ) -> tuple[list[str], dict[str, str]]:
185
+ if not config.enabled:
186
+ return ([], {})
187
+
188
+ args = ["-s"]
189
+ env: dict[str, str] = {}
190
+
191
+ # Set SEATBELT_PROFILE based on mode (macOS)
192
+ if config.mode == "restrictive":
193
+ env["SEATBELT_PROFILE"] = "restrictive-closed"
194
+ else:
195
+ env["SEATBELT_PROFILE"] = "permissive-open"
196
+
197
+ return (args, env)
198
+
199
+
200
+ def get_sandbox_resolver(cli: str) -> SandboxResolver:
201
+ """
202
+ Factory function to get the appropriate sandbox resolver for a CLI.
203
+
204
+ Args:
205
+ cli: The CLI name ("claude", "codex", or "gemini")
206
+
207
+ Returns:
208
+ The appropriate SandboxResolver subclass instance.
209
+
210
+ Raises:
211
+ ValueError: If the CLI is not recognized.
212
+ """
213
+ resolvers: dict[str, type[SandboxResolver]] = {
214
+ "claude": ClaudeSandboxResolver,
215
+ "codex": CodexSandboxResolver,
216
+ "gemini": GeminiSandboxResolver,
217
+ }
218
+
219
+ if cli not in resolvers:
220
+ raise ValueError(f"Unknown CLI: {cli}. Must be one of: {list(resolvers.keys())}")
221
+
222
+ return resolvers[cli]()
223
+
224
+
225
+ def compute_sandbox_paths(
226
+ config: SandboxConfig,
227
+ workspace_path: str,
228
+ gobby_daemon_port: int = 60887,
229
+ ) -> ResolvedSandboxPaths:
230
+ """
231
+ Compute resolved sandbox paths from a SandboxConfig.
232
+
233
+ This helper function combines the workspace path with extra paths
234
+ from the config to produce the final ResolvedSandboxPaths.
235
+
236
+ Args:
237
+ config: The sandbox configuration.
238
+ workspace_path: The primary workspace/worktree path.
239
+ gobby_daemon_port: Port where Gobby daemon is running.
240
+
241
+ Returns:
242
+ ResolvedSandboxPaths with all paths computed.
243
+ """
244
+ # Start with workspace in write paths
245
+ write_paths = [workspace_path]
246
+
247
+ # Add extra write paths
248
+ for path in config.extra_write_paths:
249
+ if path not in write_paths:
250
+ write_paths.append(path)
251
+
252
+ # Collect read paths
253
+ read_paths = list(config.extra_read_paths)
254
+
255
+ return ResolvedSandboxPaths(
256
+ workspace_path=workspace_path,
257
+ gobby_daemon_port=gobby_daemon_port,
258
+ read_paths=read_paths,
259
+ write_paths=write_paths,
260
+ allow_external_network=config.allow_network,
261
+ )
gobby/agents/spawn.py CHANGED
@@ -1,17 +1,25 @@
1
- """Terminal spawning for agent execution."""
1
+ """Terminal spawning for agent execution.
2
+
3
+ This module provides the TerminalSpawner orchestrator and PreparedSpawn helpers
4
+ for spawning CLI agents in terminal windows.
5
+
6
+ Implementation is split across submodules:
7
+ - spawners/prompt_manager.py: Prompt file creation and cleanup
8
+ - spawners/command_builder.py: CLI command construction
9
+ - spawners/: Platform-specific terminal spawners
10
+ """
2
11
 
3
12
  from __future__ import annotations
4
13
 
5
- import atexit
6
14
  import logging
7
- import os
8
- import tempfile
9
15
  from dataclasses import dataclass
10
16
  from pathlib import Path
11
17
 
12
18
  from gobby.agents.constants import get_terminal_env_vars
19
+ from gobby.agents.sandbox import SandboxConfig, compute_sandbox_paths, get_sandbox_resolver
13
20
  from gobby.agents.session import ChildSessionConfig, ChildSessionManager
14
21
  from gobby.agents.spawners import (
22
+ MAX_ENV_PROMPT_LENGTH,
15
23
  AlacrittySpawner,
16
24
  CmdSpawner,
17
25
  EmbeddedSpawner,
@@ -30,6 +38,11 @@ from gobby.agents.spawners import (
30
38
  TmuxSpawner,
31
39
  WindowsTerminalSpawner,
32
40
  WSLSpawner,
41
+ build_cli_command,
42
+ build_codex_command_with_resume,
43
+ build_gemini_command_with_resume,
44
+ create_prompt_file,
45
+ read_prompt_from_env,
33
46
  )
34
47
  from gobby.agents.spawners.base import EmbeddedPTYResult, HeadlessResult
35
48
  from gobby.agents.tty_config import get_tty_config
@@ -71,161 +84,12 @@ __all__ = [
71
84
  "build_cli_command",
72
85
  "build_gemini_command_with_resume",
73
86
  "build_codex_command_with_resume",
87
+ "create_prompt_file",
74
88
  "MAX_ENV_PROMPT_LENGTH",
75
89
  ]
76
90
 
77
- # Maximum prompt length to pass via environment variable
78
- # Longer prompts will be written to a temp file
79
- MAX_ENV_PROMPT_LENGTH = 4096
80
-
81
91
  logger = logging.getLogger(__name__)
82
92
 
83
- # Module-level set for tracking prompt files to clean up on exit
84
- # This avoids registering a new atexit handler for each prompt file
85
- _prompt_files_to_cleanup: set[Path] = set()
86
- _atexit_registered = False
87
-
88
-
89
- def _cleanup_all_prompt_files() -> None:
90
- """Clean up all tracked prompt files on process exit."""
91
- for prompt_path in list(_prompt_files_to_cleanup):
92
- try:
93
- if prompt_path.exists():
94
- prompt_path.unlink()
95
- except OSError:
96
- pass
97
- _prompt_files_to_cleanup.clear()
98
-
99
-
100
- def _create_prompt_file(prompt: str, session_id: str) -> str:
101
- """
102
- Create a prompt file with secure permissions.
103
-
104
- The file is created in the system temp directory with restrictive
105
- permissions (owner read/write only) and tracked for cleanup on exit.
106
-
107
- Args:
108
- prompt: The prompt content to write
109
- session_id: Session ID for naming the file
110
-
111
- Returns:
112
- Path to the created temp file
113
- """
114
- global _atexit_registered
115
-
116
- # Create temp directory with restrictive permissions
117
- temp_dir = Path(tempfile.gettempdir()) / "gobby-prompts"
118
- temp_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
119
-
120
- # Create the prompt file path
121
- prompt_path = temp_dir / f"prompt-{session_id}.txt"
122
-
123
- # Write with secure permissions atomically - create with mode 0o600 from the start
124
- # This avoids the TOCTOU window between write_text and chmod
125
- fd = os.open(str(prompt_path), os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o600)
126
- try:
127
- with os.fdopen(fd, "w", encoding="utf-8") as f:
128
- f.write(prompt)
129
- f.flush()
130
- os.fsync(f.fileno())
131
- except Exception:
132
- # fd is closed by fdopen, but if fdopen fails we need to close it
133
- try:
134
- os.close(fd)
135
- except OSError:
136
- pass
137
- raise
138
-
139
- # Track for cleanup
140
- _prompt_files_to_cleanup.add(prompt_path)
141
-
142
- # Register cleanup handler once
143
- if not _atexit_registered:
144
- atexit.register(_cleanup_all_prompt_files)
145
- _atexit_registered = True
146
-
147
- logger.debug(f"Created secure prompt file: {prompt_path}")
148
- return str(prompt_path)
149
-
150
-
151
- def build_cli_command(
152
- cli: str,
153
- prompt: str | None = None,
154
- session_id: str | None = None,
155
- auto_approve: bool = False,
156
- working_directory: str | None = None,
157
- mode: str = "terminal",
158
- ) -> list[str]:
159
- """
160
- Build the CLI command with proper prompt passing and permission flags.
161
-
162
- Each CLI has different syntax for passing prompts and handling permissions:
163
-
164
- Claude Code:
165
- - claude --session-id <uuid> --dangerously-skip-permissions [prompt]
166
- - Use --dangerously-skip-permissions for autonomous subagent operation
167
-
168
- Gemini CLI:
169
- - gemini -i "prompt" (interactive mode with initial prompt)
170
- - gemini --approval-mode yolo -i "prompt" (YOLO + interactive)
171
- - gemini "prompt" (one-shot non-interactive for headless)
172
-
173
- Codex CLI:
174
- - codex --full-auto -C <dir> [PROMPT]
175
- - Or: codex -c 'sandbox_permissions=["disk-full-read-access"]' -a never [PROMPT]
176
-
177
- Args:
178
- cli: CLI name (claude, gemini, codex)
179
- prompt: Optional prompt to pass
180
- session_id: Optional session ID (used by Claude CLI)
181
- auto_approve: If True, add flags to auto-approve actions/permissions
182
- working_directory: Optional working directory (used by Codex -C flag)
183
- mode: Execution mode - "terminal" (interactive) or "headless" (non-interactive)
184
-
185
- Returns:
186
- Command list for subprocess execution
187
- """
188
- command = [cli]
189
-
190
- if cli == "claude":
191
- # Claude CLI flags
192
- if session_id:
193
- command.extend(["--session-id", session_id])
194
- if auto_approve:
195
- # Skip all permission prompts for autonomous subagent operation
196
- command.append("--dangerously-skip-permissions")
197
- if prompt:
198
- # Use -p (print mode) for non-interactive execution.
199
- # NOTE: Print mode bypasses hooks - headless spawner manually tracks status.
200
- command.append("-p")
201
-
202
- elif cli == "gemini":
203
- # Gemini CLI flags
204
- if auto_approve:
205
- command.extend(["--approval-mode", "yolo"])
206
- # For terminal mode, use -i (prompt-interactive) to execute prompt and stay interactive
207
- # For headless mode, use positional prompt for one-shot execution
208
- if prompt:
209
- if mode == "terminal":
210
- command.extend(["-i", prompt])
211
- return command # Don't add prompt again as positional
212
- # else: fall through to add as positional for headless
213
-
214
- elif cli == "codex":
215
- # Codex CLI flags
216
- if auto_approve:
217
- # --full-auto: low-friction sandboxed automatic execution
218
- command.append("--full-auto")
219
- if working_directory:
220
- command.extend(["-C", working_directory])
221
-
222
- # All three CLIs accept prompt as positional argument (must come last)
223
- # For Gemini terminal mode, this is skipped (handled above with -i flag)
224
- if prompt:
225
- command.append(prompt)
226
-
227
- return command
228
-
229
93
 
230
94
  class TerminalSpawner:
231
95
  """
@@ -369,6 +233,7 @@ class TerminalSpawner:
369
233
  max_agent_depth: int = 3,
370
234
  terminal: TerminalType | str = TerminalType.AUTO,
371
235
  prompt: str | None = None,
236
+ sandbox_config: SandboxConfig | None = None,
372
237
  ) -> SpawnResult:
373
238
  """
374
239
  Spawn a CLI agent in a new terminal with Gobby environment variables.
@@ -385,10 +250,22 @@ class TerminalSpawner:
385
250
  max_agent_depth: Maximum allowed depth
386
251
  terminal: Terminal type or "auto"
387
252
  prompt: Optional initial prompt
253
+ sandbox_config: Optional sandbox configuration
388
254
 
389
255
  Returns:
390
256
  SpawnResult with success status
391
257
  """
258
+ # Resolve sandbox configuration if enabled
259
+ sandbox_args: list[str] | None = None
260
+ sandbox_env: dict[str, str] = {}
261
+
262
+ if sandbox_config and sandbox_config.enabled:
263
+ # Compute sandbox paths based on cwd (workspace)
264
+ resolved_paths = compute_sandbox_paths(sandbox_config, str(cwd))
265
+ # Get CLI-specific resolver and generate args/env
266
+ resolver = get_sandbox_resolver(cli)
267
+ sandbox_args, sandbox_env = resolver.resolve(sandbox_config, resolved_paths)
268
+
392
269
  # Build command with prompt as CLI argument and auto-approve for autonomous work
393
270
  command = build_cli_command(
394
271
  cli,
@@ -397,6 +274,7 @@ class TerminalSpawner:
397
274
  auto_approve=True, # Subagents need to work autonomously
398
275
  working_directory=str(cwd) if cli == "codex" else None,
399
276
  mode="terminal", # Interactive terminal mode
277
+ sandbox_args=sandbox_args,
400
278
  )
401
279
 
402
280
  # Handle prompt for environment variables (backup for hooks/context)
@@ -422,6 +300,10 @@ class TerminalSpawner:
422
300
  prompt_file=prompt_file,
423
301
  )
424
302
 
303
+ # Merge sandbox environment variables if present
304
+ if sandbox_env:
305
+ env.update(sandbox_env)
306
+
425
307
  # Set title (avoid colons/parentheses which Ghostty interprets as config syntax)
426
308
  title = f"gobby-{cli}-d{agent_depth}"
427
309
 
@@ -437,8 +319,8 @@ class TerminalSpawner:
437
319
  """
438
320
  Write prompt to a temp file for passing to spawned agent.
439
321
 
440
- Delegates to the module-level _create_prompt_file helper which
441
- handles secure permissions and cleanup tracking.
322
+ Delegates to the create_prompt_file helper which handles
323
+ secure permissions and cleanup tracking.
442
324
 
443
325
  Args:
444
326
  prompt: The prompt content
@@ -447,7 +329,7 @@ class TerminalSpawner:
447
329
  Returns:
448
330
  Path to the created temp file
449
331
  """
450
- return _create_prompt_file(prompt, session_id)
332
+ return create_prompt_file(prompt, session_id)
451
333
 
452
334
 
453
335
  @dataclass
@@ -545,7 +427,7 @@ def prepare_terminal_spawn(
545
427
  prompt_env = prompt
546
428
  else:
547
429
  # Write to temp file with secure permissions
548
- prompt_file = _create_prompt_file(prompt, child_session.id)
430
+ prompt_file = create_prompt_file(prompt, child_session.id)
549
431
 
550
432
  # Build environment variables
551
433
  env_vars = get_terminal_env_vars(
@@ -571,34 +453,6 @@ def prepare_terminal_spawn(
571
453
  )
572
454
 
573
455
 
574
- def read_prompt_from_env() -> str | None:
575
- """
576
- Read initial prompt from environment variables.
577
-
578
- Checks GOBBY_PROMPT_FILE first (for long prompts),
579
- then falls back to GOBBY_PROMPT (for short prompts).
580
-
581
- Returns:
582
- Prompt string or None if not set
583
- """
584
- from gobby.agents.constants import GOBBY_PROMPT, GOBBY_PROMPT_FILE
585
-
586
- # Check for prompt file first
587
- prompt_file = os.environ.get(GOBBY_PROMPT_FILE)
588
- if prompt_file:
589
- try:
590
- prompt_path = Path(prompt_file)
591
- if prompt_path.exists():
592
- return prompt_path.read_text(encoding="utf-8")
593
- else:
594
- logger.warning(f"Prompt file not found: {prompt_file}")
595
- except Exception as e:
596
- logger.error(f"Error reading prompt file: {e}")
597
-
598
- # Fall back to inline prompt
599
- return os.environ.get(GOBBY_PROMPT)
600
-
601
-
602
456
  async def prepare_gemini_spawn_with_preflight(
603
457
  session_manager: ChildSessionManager,
604
458
  parent_session_id: str,
@@ -677,7 +531,7 @@ async def prepare_gemini_spawn_with_preflight(
677
531
  if len(prompt) <= MAX_ENV_PROMPT_LENGTH:
678
532
  prompt_env = prompt
679
533
  else:
680
- prompt_file = _create_prompt_file(prompt, child_session.id)
534
+ prompt_file = create_prompt_file(prompt, child_session.id)
681
535
 
682
536
  # Build environment variables
683
537
  env_vars = get_terminal_env_vars(
@@ -708,57 +562,6 @@ async def prepare_gemini_spawn_with_preflight(
708
562
  )
709
563
 
710
564
 
711
- def build_gemini_command_with_resume(
712
- gemini_external_id: str,
713
- prompt: str | None = None,
714
- auto_approve: bool = False,
715
- gobby_session_id: str | None = None,
716
- ) -> list[str]:
717
- """
718
- Build Gemini CLI command with session resume.
719
-
720
- Uses -r flag to resume a preflight-captured session, with session context
721
- injected into the initial prompt.
722
-
723
- Args:
724
- gemini_external_id: Gemini's session_id from preflight capture
725
- prompt: Optional user prompt
726
- auto_approve: If True, add --approval-mode yolo
727
- gobby_session_id: Gobby session ID to inject into context
728
-
729
- Returns:
730
- Command list for subprocess execution
731
- """
732
- command = ["gemini"]
733
-
734
- # Resume the preflight session
735
- command.extend(["-r", gemini_external_id])
736
-
737
- if auto_approve:
738
- command.extend(["--approval-mode", "yolo"])
739
-
740
- # Build prompt with session context
741
- if gobby_session_id:
742
- context_prefix = (
743
- f"Your Gobby session_id is: {gobby_session_id}\n"
744
- f"Use this when calling Gobby MCP tools.\n\n"
745
- )
746
- full_prompt = context_prefix + (prompt or "")
747
- else:
748
- full_prompt = prompt or ""
749
-
750
- # Use -i for interactive mode with initial prompt
751
- if full_prompt:
752
- command.extend(["-i", full_prompt])
753
-
754
- return command
755
-
756
-
757
- # =============================================================================
758
- # Codex Preflight Capture
759
- # =============================================================================
760
-
761
-
762
565
  async def prepare_codex_spawn_with_preflight(
763
566
  session_manager: ChildSessionManager,
764
567
  parent_session_id: str,
@@ -837,7 +640,7 @@ async def prepare_codex_spawn_with_preflight(
837
640
  if len(prompt) <= MAX_ENV_PROMPT_LENGTH:
838
641
  prompt_env = prompt
839
642
  else:
840
- prompt_file = _create_prompt_file(prompt, child_session.id)
643
+ prompt_file = create_prompt_file(prompt, child_session.id)
841
644
 
842
645
  # Build environment variables
843
646
  env_vars = get_terminal_env_vars(
@@ -866,51 +669,3 @@ async def prepare_codex_spawn_with_preflight(
866
669
  agent_depth=child_session.agent_depth,
867
670
  env_vars=env_vars,
868
671
  )
869
-
870
-
871
- def build_codex_command_with_resume(
872
- codex_external_id: str,
873
- prompt: str | None = None,
874
- auto_approve: bool = False,
875
- gobby_session_id: str | None = None,
876
- working_directory: str | None = None,
877
- ) -> list[str]:
878
- """
879
- Build Codex CLI command with session resume.
880
-
881
- Uses `codex resume {session_id}` to resume a preflight-captured session,
882
- with session context injected into the prompt.
883
-
884
- Args:
885
- codex_external_id: Codex's session_id from preflight capture
886
- prompt: Optional user prompt
887
- auto_approve: If True, add --full-auto flag
888
- gobby_session_id: Gobby session ID to inject into context
889
- working_directory: Optional working directory override
890
-
891
- Returns:
892
- Command list for subprocess execution
893
- """
894
- command = ["codex", "resume", codex_external_id]
895
-
896
- if auto_approve:
897
- command.append("--full-auto")
898
-
899
- if working_directory:
900
- command.extend(["-C", working_directory])
901
-
902
- # Build prompt with session context
903
- if gobby_session_id:
904
- context_prefix = (
905
- f"Your Gobby session_id is: {gobby_session_id}\n"
906
- f"Use this when calling Gobby MCP tools.\n\n"
907
- )
908
- full_prompt = context_prefix + (prompt or "")
909
- else:
910
- full_prompt = prompt or ""
911
-
912
- # Prompt is a positional argument after session_id
913
- if full_prompt:
914
- command.append(full_prompt)
915
-
916
- return command