gobby 0.2.6__py3-none-any.whl → 0.2.8__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 (198) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +2 -1
  3. gobby/adapters/claude_code.py +96 -35
  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/adapters/gemini.py +140 -38
  10. gobby/agents/definitions.py +11 -1
  11. gobby/agents/isolation.py +525 -0
  12. gobby/agents/registry.py +11 -0
  13. gobby/agents/sandbox.py +261 -0
  14. gobby/agents/session.py +1 -0
  15. gobby/agents/spawn.py +42 -287
  16. gobby/agents/spawn_executor.py +415 -0
  17. gobby/agents/spawners/__init__.py +24 -0
  18. gobby/agents/spawners/command_builder.py +189 -0
  19. gobby/agents/spawners/embedded.py +21 -2
  20. gobby/agents/spawners/headless.py +21 -2
  21. gobby/agents/spawners/macos.py +26 -1
  22. gobby/agents/spawners/prompt_manager.py +125 -0
  23. gobby/cli/__init__.py +0 -2
  24. gobby/cli/install.py +4 -4
  25. gobby/cli/installers/claude.py +6 -0
  26. gobby/cli/installers/gemini.py +6 -0
  27. gobby/cli/installers/shared.py +103 -4
  28. gobby/cli/memory.py +185 -0
  29. gobby/cli/sessions.py +1 -1
  30. gobby/cli/utils.py +9 -2
  31. gobby/clones/git.py +177 -0
  32. gobby/config/__init__.py +12 -97
  33. gobby/config/app.py +10 -94
  34. gobby/config/extensions.py +2 -2
  35. gobby/config/features.py +7 -130
  36. gobby/config/skills.py +31 -0
  37. gobby/config/tasks.py +4 -28
  38. gobby/hooks/__init__.py +0 -13
  39. gobby/hooks/event_handlers.py +150 -8
  40. gobby/hooks/hook_manager.py +21 -3
  41. gobby/hooks/plugins.py +1 -1
  42. gobby/hooks/webhooks.py +1 -1
  43. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  44. gobby/llm/resolver.py +3 -2
  45. gobby/mcp_proxy/importer.py +62 -4
  46. gobby/mcp_proxy/instructions.py +4 -2
  47. gobby/mcp_proxy/registries.py +22 -8
  48. gobby/mcp_proxy/services/recommendation.py +43 -11
  49. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  50. gobby/mcp_proxy/tools/agents.py +76 -740
  51. gobby/mcp_proxy/tools/artifacts.py +43 -9
  52. gobby/mcp_proxy/tools/clones.py +0 -385
  53. gobby/mcp_proxy/tools/memory.py +2 -2
  54. gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
  55. gobby/mcp_proxy/tools/sessions/_commits.py +239 -0
  56. gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
  57. gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
  58. gobby/mcp_proxy/tools/sessions/_handoff.py +503 -0
  59. gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
  60. gobby/mcp_proxy/tools/skills/__init__.py +14 -29
  61. gobby/mcp_proxy/tools/spawn_agent.py +455 -0
  62. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  63. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  64. gobby/mcp_proxy/tools/tasks/_lifecycle.py +79 -30
  65. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
  66. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  67. gobby/mcp_proxy/tools/workflows.py +84 -34
  68. gobby/mcp_proxy/tools/worktrees.py +32 -350
  69. gobby/memory/extractor.py +15 -1
  70. gobby/memory/ingestion/__init__.py +5 -0
  71. gobby/memory/ingestion/multimodal.py +221 -0
  72. gobby/memory/manager.py +62 -283
  73. gobby/memory/search/__init__.py +10 -0
  74. gobby/memory/search/coordinator.py +248 -0
  75. gobby/memory/services/__init__.py +5 -0
  76. gobby/memory/services/crossref.py +142 -0
  77. gobby/prompts/loader.py +5 -2
  78. gobby/runner.py +13 -0
  79. gobby/servers/http.py +1 -4
  80. gobby/servers/routes/admin.py +14 -0
  81. gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
  82. gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
  83. gobby/servers/routes/mcp/endpoints/execution.py +568 -0
  84. gobby/servers/routes/mcp/endpoints/registry.py +378 -0
  85. gobby/servers/routes/mcp/endpoints/server.py +304 -0
  86. gobby/servers/routes/mcp/hooks.py +51 -4
  87. gobby/servers/routes/mcp/tools.py +48 -1506
  88. gobby/servers/websocket.py +57 -1
  89. gobby/sessions/analyzer.py +2 -2
  90. gobby/sessions/lifecycle.py +1 -1
  91. gobby/sessions/manager.py +9 -0
  92. gobby/sessions/processor.py +10 -0
  93. gobby/sessions/transcripts/base.py +1 -0
  94. gobby/sessions/transcripts/claude.py +15 -5
  95. gobby/sessions/transcripts/gemini.py +100 -34
  96. gobby/skills/parser.py +30 -2
  97. gobby/storage/database.py +9 -2
  98. gobby/storage/memories.py +32 -21
  99. gobby/storage/migrations.py +174 -368
  100. gobby/storage/sessions.py +45 -7
  101. gobby/storage/skills.py +80 -7
  102. gobby/storage/tasks/_lifecycle.py +18 -3
  103. gobby/sync/memories.py +1 -1
  104. gobby/tasks/external_validator.py +1 -1
  105. gobby/tasks/validation.py +22 -20
  106. gobby/tools/summarizer.py +91 -10
  107. gobby/utils/project_context.py +2 -3
  108. gobby/utils/status.py +13 -0
  109. gobby/workflows/actions.py +221 -1217
  110. gobby/workflows/artifact_actions.py +31 -0
  111. gobby/workflows/autonomous_actions.py +11 -0
  112. gobby/workflows/context_actions.py +50 -1
  113. gobby/workflows/detection_helpers.py +38 -24
  114. gobby/workflows/enforcement/__init__.py +47 -0
  115. gobby/workflows/enforcement/blocking.py +281 -0
  116. gobby/workflows/enforcement/commit_policy.py +283 -0
  117. gobby/workflows/enforcement/handlers.py +269 -0
  118. gobby/workflows/enforcement/task_policy.py +542 -0
  119. gobby/workflows/engine.py +93 -0
  120. gobby/workflows/evaluator.py +110 -0
  121. gobby/workflows/git_utils.py +106 -0
  122. gobby/workflows/hooks.py +41 -0
  123. gobby/workflows/llm_actions.py +30 -0
  124. gobby/workflows/mcp_actions.py +20 -1
  125. gobby/workflows/memory_actions.py +91 -0
  126. gobby/workflows/safe_evaluator.py +191 -0
  127. gobby/workflows/session_actions.py +44 -0
  128. gobby/workflows/state_actions.py +60 -1
  129. gobby/workflows/stop_signal_actions.py +55 -0
  130. gobby/workflows/summary_actions.py +217 -51
  131. gobby/workflows/task_sync_actions.py +347 -0
  132. gobby/workflows/todo_actions.py +34 -1
  133. gobby/workflows/webhook_actions.py +185 -0
  134. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/METADATA +6 -1
  135. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
  136. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
  137. gobby/adapters/codex.py +0 -1332
  138. gobby/cli/tui.py +0 -34
  139. gobby/install/claude/commands/gobby/bug.md +0 -51
  140. gobby/install/claude/commands/gobby/chore.md +0 -51
  141. gobby/install/claude/commands/gobby/epic.md +0 -52
  142. gobby/install/claude/commands/gobby/eval.md +0 -235
  143. gobby/install/claude/commands/gobby/feat.md +0 -49
  144. gobby/install/claude/commands/gobby/nit.md +0 -52
  145. gobby/install/claude/commands/gobby/ref.md +0 -52
  146. gobby/mcp_proxy/tools/session_messages.py +0 -1055
  147. gobby/prompts/defaults/expansion/system.md +0 -119
  148. gobby/prompts/defaults/expansion/user.md +0 -48
  149. gobby/prompts/defaults/external_validation/agent.md +0 -72
  150. gobby/prompts/defaults/external_validation/external.md +0 -63
  151. gobby/prompts/defaults/external_validation/spawn.md +0 -83
  152. gobby/prompts/defaults/external_validation/system.md +0 -6
  153. gobby/prompts/defaults/features/import_mcp.md +0 -22
  154. gobby/prompts/defaults/features/import_mcp_github.md +0 -17
  155. gobby/prompts/defaults/features/import_mcp_search.md +0 -16
  156. gobby/prompts/defaults/features/recommend_tools.md +0 -32
  157. gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
  158. gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
  159. gobby/prompts/defaults/features/server_description.md +0 -20
  160. gobby/prompts/defaults/features/server_description_system.md +0 -6
  161. gobby/prompts/defaults/features/task_description.md +0 -31
  162. gobby/prompts/defaults/features/task_description_system.md +0 -6
  163. gobby/prompts/defaults/features/tool_summary.md +0 -17
  164. gobby/prompts/defaults/features/tool_summary_system.md +0 -6
  165. gobby/prompts/defaults/handoff/compact.md +0 -63
  166. gobby/prompts/defaults/handoff/session_end.md +0 -57
  167. gobby/prompts/defaults/memory/extract.md +0 -61
  168. gobby/prompts/defaults/research/step.md +0 -58
  169. gobby/prompts/defaults/validation/criteria.md +0 -47
  170. gobby/prompts/defaults/validation/validate.md +0 -38
  171. gobby/storage/migrations_legacy.py +0 -1359
  172. gobby/tui/__init__.py +0 -5
  173. gobby/tui/api_client.py +0 -278
  174. gobby/tui/app.py +0 -329
  175. gobby/tui/screens/__init__.py +0 -25
  176. gobby/tui/screens/agents.py +0 -333
  177. gobby/tui/screens/chat.py +0 -450
  178. gobby/tui/screens/dashboard.py +0 -377
  179. gobby/tui/screens/memory.py +0 -305
  180. gobby/tui/screens/metrics.py +0 -231
  181. gobby/tui/screens/orchestrator.py +0 -903
  182. gobby/tui/screens/sessions.py +0 -412
  183. gobby/tui/screens/tasks.py +0 -440
  184. gobby/tui/screens/workflows.py +0 -289
  185. gobby/tui/screens/worktrees.py +0 -174
  186. gobby/tui/widgets/__init__.py +0 -21
  187. gobby/tui/widgets/chat.py +0 -210
  188. gobby/tui/widgets/conductor.py +0 -104
  189. gobby/tui/widgets/menu.py +0 -132
  190. gobby/tui/widgets/message_panel.py +0 -160
  191. gobby/tui/widgets/review_gate.py +0 -224
  192. gobby/tui/widgets/task_tree.py +0 -99
  193. gobby/tui/widgets/token_budget.py +0 -166
  194. gobby/tui/ws_client.py +0 -258
  195. gobby/workflows/task_enforcement_actions.py +0 -1343
  196. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
  197. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
  198. {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,525 @@
1
+ """
2
+ Isolation Handlers for Unified spawn_agent API.
3
+
4
+ This module provides the abstraction layer for different isolation modes:
5
+ - current: Work in the current directory (no isolation)
6
+ - worktree: Create/reuse a git worktree for isolated work
7
+ - clone: Create a shallow clone for full isolation
8
+
9
+ Each handler implements the IsolationHandler ABC to provide:
10
+ - Environment preparation (worktree/clone creation)
11
+ - Context prompt building (adding isolation warnings)
12
+ - Branch name generation
13
+ """
14
+
15
+ import time
16
+ from abc import ABC, abstractmethod
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, Literal
19
+
20
+
21
+ @dataclass
22
+ class IsolationContext:
23
+ """Result of environment preparation."""
24
+
25
+ cwd: str
26
+ branch_name: str | None = None
27
+ worktree_id: str | None = None
28
+ clone_id: str | None = None
29
+ isolation_type: Literal["current", "worktree", "clone"] = "current"
30
+ extra: dict[str, Any] = field(default_factory=dict)
31
+
32
+
33
+ @dataclass
34
+ class SpawnConfig:
35
+ """Configuration passed to isolation handlers."""
36
+
37
+ prompt: str
38
+ task_id: str | None
39
+ task_title: str | None
40
+ task_seq_num: int | None
41
+ branch_name: str | None
42
+ branch_prefix: str | None
43
+ base_branch: str
44
+ project_id: str
45
+ project_path: str
46
+ provider: str
47
+ parent_session_id: str
48
+
49
+
50
+ def generate_branch_name(config: SpawnConfig) -> str:
51
+ """
52
+ Auto-generate branch name from task or fallback to prefix+timestamp.
53
+
54
+ Priority:
55
+ 1. Explicit branch_name if provided
56
+ 2. task-{seq_num}-{slugified_title} if task info available
57
+ 3. {branch_prefix}{timestamp} as fallback (default prefix: "agent/")
58
+ """
59
+ if config.branch_name:
60
+ return config.branch_name
61
+
62
+ if config.task_seq_num and config.task_title:
63
+ # Generate slug from task title
64
+ slug = config.task_title.lower().replace(" ", "-")
65
+ # Keep only alphanumeric and hyphens
66
+ slug = "".join(c for c in slug if c.isalnum() or c == "-")
67
+ # Truncate to 40 chars
68
+ slug = slug[:40]
69
+ return f"task-{config.task_seq_num}-{slug}"
70
+
71
+ # Fallback to prefix + timestamp
72
+ prefix = config.branch_prefix or "agent/"
73
+ return f"{prefix}{int(time.time())}"
74
+
75
+
76
+ class IsolationHandler(ABC):
77
+ """Abstract base class for isolation handlers."""
78
+
79
+ @abstractmethod
80
+ async def prepare_environment(self, config: SpawnConfig) -> IsolationContext:
81
+ """
82
+ Prepare isolated environment (worktree/clone creation).
83
+
84
+ Args:
85
+ config: Spawn configuration with project and task info
86
+
87
+ Returns:
88
+ IsolationContext with cwd and isolation metadata
89
+ """
90
+
91
+ @abstractmethod
92
+ def build_context_prompt(self, original_prompt: str, ctx: IsolationContext) -> str:
93
+ """
94
+ Build prompt with isolation context warnings.
95
+
96
+ Args:
97
+ original_prompt: The original user prompt
98
+ ctx: Isolation context from prepare_environment
99
+
100
+ Returns:
101
+ Enhanced prompt with isolation context (or unchanged for current)
102
+ """
103
+
104
+
105
+ class CurrentIsolationHandler(IsolationHandler):
106
+ """
107
+ No isolation - work in current directory.
108
+
109
+ This is the simplest handler that just returns the project path
110
+ as the working directory without any git branch changes.
111
+ """
112
+
113
+ async def prepare_environment(self, config: SpawnConfig) -> IsolationContext:
114
+ """Return project path as working directory."""
115
+ return IsolationContext(
116
+ cwd=config.project_path,
117
+ isolation_type="current",
118
+ )
119
+
120
+ def build_context_prompt(self, original_prompt: str, ctx: IsolationContext) -> str:
121
+ """Return prompt unchanged - no additional context needed."""
122
+ return original_prompt
123
+
124
+
125
+ class WorktreeIsolationHandler(IsolationHandler):
126
+ """
127
+ Worktree isolation - create/reuse a git worktree for isolated work.
128
+
129
+ This handler:
130
+ - Checks for existing worktrees by branch name
131
+ - Creates new worktrees if needed
132
+ - Copies project.json and installs hooks
133
+ - Adds CRITICAL context warning to prompt
134
+ """
135
+
136
+ def __init__(
137
+ self,
138
+ git_manager: Any, # WorktreeGitManager
139
+ worktree_storage: Any, # LocalWorktreeManager
140
+ ) -> None:
141
+ """
142
+ Initialize WorktreeIsolationHandler with dependencies.
143
+
144
+ Args:
145
+ git_manager: Git manager for worktree operations
146
+ worktree_storage: Storage for worktree records
147
+ """
148
+ self._git_manager = git_manager
149
+ self._worktree_storage = worktree_storage
150
+
151
+ async def prepare_environment(self, config: SpawnConfig) -> IsolationContext:
152
+ """
153
+ Prepare worktree environment.
154
+
155
+ - Generate branch name if not provided
156
+ - Check for existing worktree for the branch
157
+ - Create new worktree if needed
158
+ - Return IsolationContext with worktree info
159
+ """
160
+ branch_name = generate_branch_name(config)
161
+
162
+ # Check if worktree already exists for this branch
163
+ existing = self._worktree_storage.get_by_branch(config.project_id, branch_name)
164
+ if existing:
165
+ # Use existing worktree
166
+ return IsolationContext(
167
+ cwd=existing.worktree_path,
168
+ branch_name=existing.branch_name,
169
+ worktree_id=existing.id,
170
+ isolation_type="worktree",
171
+ extra={"main_repo_path": self._git_manager.repo_path},
172
+ )
173
+
174
+ # Generate worktree path
175
+ from pathlib import Path
176
+
177
+ project_name = Path(self._git_manager.repo_path).name
178
+ worktree_path = self._generate_worktree_path(branch_name, project_name)
179
+
180
+ # Create git worktree
181
+ result = self._git_manager.create_worktree(
182
+ worktree_path=worktree_path,
183
+ branch_name=branch_name,
184
+ base_branch=config.base_branch,
185
+ create_branch=True,
186
+ )
187
+
188
+ if not result.success:
189
+ raise RuntimeError(f"Failed to create worktree: {result.error}")
190
+
191
+ # Record in storage
192
+ worktree = self._worktree_storage.create(
193
+ project_id=config.project_id,
194
+ branch_name=branch_name,
195
+ worktree_path=worktree_path,
196
+ base_branch=config.base_branch,
197
+ task_id=config.task_id,
198
+ )
199
+
200
+ # Copy CLI hooks to worktree so hooks fire correctly
201
+ await self._copy_cli_hooks(
202
+ main_repo_path=self._git_manager.repo_path,
203
+ worktree_path=worktree_path,
204
+ provider=config.provider,
205
+ )
206
+
207
+ return IsolationContext(
208
+ cwd=worktree.worktree_path,
209
+ branch_name=worktree.branch_name,
210
+ worktree_id=worktree.id,
211
+ isolation_type="worktree",
212
+ extra={"main_repo_path": self._git_manager.repo_path},
213
+ )
214
+
215
+ def build_context_prompt(self, original_prompt: str, ctx: IsolationContext) -> str:
216
+ """
217
+ Build prompt with CRITICAL worktree context warning.
218
+
219
+ Prepends isolation context to help the agent understand it's
220
+ working in a worktree, not the main repository.
221
+ """
222
+ warning = f"""CRITICAL: Worktree Context
223
+ You are working in a git worktree, NOT the main repository.
224
+ - Branch: {ctx.branch_name}
225
+ - Worktree path: {ctx.cwd}
226
+ - Main repo: {ctx.extra.get("main_repo_path", "unknown")}
227
+
228
+ Changes in this worktree are isolated from the main repository.
229
+ Commit your changes to the worktree branch when done.
230
+
231
+ ---
232
+
233
+ """
234
+ return warning + original_prompt
235
+
236
+ def _generate_worktree_path(self, branch_name: str, project_name: str) -> str:
237
+ """Generate a unique worktree path in temp directory."""
238
+ import tempfile
239
+
240
+ # Sanitize branch name for use in path
241
+ safe_branch = branch_name.replace("/", "-").replace("\\", "-")
242
+ worktree_dir = tempfile.gettempdir()
243
+ return f"{worktree_dir}/gobby-worktrees/{project_name}/{safe_branch}"
244
+
245
+ async def _copy_cli_hooks(
246
+ self,
247
+ main_repo_path: str,
248
+ worktree_path: str,
249
+ provider: str,
250
+ ) -> None:
251
+ """
252
+ Copy CLI-specific hooks to the worktree.
253
+
254
+ Without these hooks, the spawned agent won't trigger SessionStart
255
+ and other lifecycle hooks, breaking Gobby integration.
256
+
257
+ Args:
258
+ main_repo_path: Path to the main repository
259
+ worktree_path: Path to the newly created worktree
260
+ provider: CLI provider (gemini, claude, codex)
261
+ """
262
+ import asyncio
263
+ import logging
264
+ import shutil
265
+ from pathlib import Path
266
+
267
+ logger = logging.getLogger(__name__)
268
+
269
+ # Map provider to CLI hook directory
270
+ cli_dirs = {
271
+ "gemini": ".gemini",
272
+ "claude": ".claude",
273
+ "codex": ".codex",
274
+ }
275
+
276
+ cli_dir = cli_dirs.get(provider)
277
+ if not cli_dir:
278
+ logger.debug(f"No CLI hooks directory defined for provider: {provider}")
279
+ return
280
+
281
+ src_path = Path(main_repo_path) / cli_dir
282
+ dst_path = Path(worktree_path) / cli_dir
283
+
284
+ if not src_path.exists():
285
+ logger.debug(f"CLI hooks directory not found in main repo: {src_path}")
286
+ return
287
+
288
+ try:
289
+ # Copy entire CLI hooks directory (non-blocking)
290
+ await asyncio.to_thread(shutil.copytree, src_path, dst_path, dirs_exist_ok=True)
291
+ logger.info(f"Copied CLI hooks from {src_path} to {dst_path}")
292
+ except shutil.Error:
293
+ logger.warning(
294
+ f"Failed to copy CLI hooks: provider={provider}, src={src_path}, dst={dst_path}",
295
+ exc_info=True,
296
+ )
297
+ except OSError:
298
+ logger.warning(
299
+ f"Filesystem error copying CLI hooks: provider={provider}, src={src_path}, dst={dst_path}",
300
+ exc_info=True,
301
+ )
302
+
303
+
304
+ class CloneIsolationHandler(IsolationHandler):
305
+ """
306
+ Clone isolation - create a shallow clone for full isolation.
307
+
308
+ This handler:
309
+ - Checks for existing clones by branch name
310
+ - Creates new shallow clones if needed
311
+ - Adds CRITICAL context warning to prompt
312
+ """
313
+
314
+ def __init__(
315
+ self,
316
+ clone_manager: Any, # CloneGitManager
317
+ clone_storage: Any, # LocalCloneManager
318
+ ) -> None:
319
+ """
320
+ Initialize CloneIsolationHandler with dependencies.
321
+
322
+ Args:
323
+ clone_manager: Git manager for clone operations
324
+ clone_storage: Storage for clone records
325
+ """
326
+ self._clone_manager = clone_manager
327
+ self._clone_storage = clone_storage
328
+
329
+ async def prepare_environment(self, config: SpawnConfig) -> IsolationContext:
330
+ """
331
+ Prepare clone environment.
332
+
333
+ - Generate branch name if not provided
334
+ - Check for existing clone for the branch
335
+ - Create new shallow clone if needed
336
+ - Return IsolationContext with clone info
337
+ """
338
+ branch_name = generate_branch_name(config)
339
+
340
+ # Check if clone already exists for this branch
341
+ existing = self._clone_storage.get_by_branch(config.project_id, branch_name)
342
+ if existing:
343
+ # Use existing clone
344
+ return IsolationContext(
345
+ cwd=existing.clone_path,
346
+ branch_name=existing.branch_name,
347
+ clone_id=existing.id,
348
+ isolation_type="clone",
349
+ extra={"source_repo": config.project_path},
350
+ )
351
+
352
+ # Generate clone path
353
+ from pathlib import Path
354
+
355
+ project_name = Path(config.project_path).name
356
+ clone_path = self._generate_clone_path(branch_name, project_name)
357
+
358
+ # Create shallow clone
359
+ result = self._clone_manager.create_clone(
360
+ clone_path=clone_path,
361
+ branch_name=branch_name,
362
+ base_branch=config.base_branch,
363
+ shallow=True,
364
+ )
365
+
366
+ if not result.success:
367
+ raise RuntimeError(f"Failed to create clone: {result.error}")
368
+
369
+ # Record in storage
370
+ clone = self._clone_storage.create(
371
+ project_id=config.project_id,
372
+ branch_name=branch_name,
373
+ clone_path=clone_path,
374
+ base_branch=config.base_branch,
375
+ task_id=config.task_id,
376
+ )
377
+
378
+ # Copy CLI hooks to clone so hooks fire correctly
379
+ await self._copy_cli_hooks(
380
+ source_repo_path=config.project_path,
381
+ clone_path=clone_path,
382
+ provider=config.provider,
383
+ )
384
+
385
+ return IsolationContext(
386
+ cwd=clone.clone_path,
387
+ branch_name=clone.branch_name,
388
+ clone_id=clone.id,
389
+ isolation_type="clone",
390
+ extra={"source_repo": config.project_path},
391
+ )
392
+
393
+ def build_context_prompt(self, original_prompt: str, ctx: IsolationContext) -> str:
394
+ """
395
+ Build prompt with CRITICAL clone context warning.
396
+
397
+ Prepends isolation context to help the agent understand it's
398
+ working in a clone, not the original repository.
399
+ """
400
+ warning = f"""CRITICAL: Clone Context
401
+ You are working in a shallow clone, NOT the original repository.
402
+ - Branch: {ctx.branch_name}
403
+ - Clone path: {ctx.cwd}
404
+ - Source repo: {ctx.extra.get("source_repo", "unknown")}
405
+
406
+ Changes in this clone are fully isolated from the original repository.
407
+ Push your changes when ready to share with the original.
408
+
409
+ ---
410
+
411
+ """
412
+ return warning + original_prompt
413
+
414
+ def _generate_clone_path(self, branch_name: str, project_name: str) -> str:
415
+ """Generate a unique clone path in temp directory."""
416
+ import tempfile
417
+
418
+ # Sanitize branch name for use in path
419
+ safe_branch = branch_name.replace("/", "-").replace("\\", "-")
420
+ clone_dir = tempfile.gettempdir()
421
+ return f"{clone_dir}/gobby-clones/{project_name}/{safe_branch}"
422
+
423
+ async def _copy_cli_hooks(
424
+ self,
425
+ source_repo_path: str,
426
+ clone_path: str,
427
+ provider: str,
428
+ ) -> None:
429
+ """
430
+ Copy CLI-specific hooks to the clone.
431
+
432
+ Without these hooks, the spawned agent won't trigger SessionStart
433
+ and other lifecycle hooks, breaking Gobby integration.
434
+
435
+ Args:
436
+ source_repo_path: Path to the source repository
437
+ clone_path: Path to the newly created clone
438
+ provider: CLI provider (gemini, claude, codex)
439
+ """
440
+ import asyncio
441
+ import logging
442
+ import shutil
443
+ from pathlib import Path
444
+
445
+ logger = logging.getLogger(__name__)
446
+
447
+ # Map provider to CLI hook directory
448
+ cli_dirs = {
449
+ "gemini": ".gemini",
450
+ "claude": ".claude",
451
+ "codex": ".codex",
452
+ }
453
+
454
+ cli_dir = cli_dirs.get(provider)
455
+ if not cli_dir:
456
+ logger.debug(f"No CLI hooks directory defined for provider: {provider}")
457
+ return
458
+
459
+ src_path = Path(source_repo_path) / cli_dir
460
+ dst_path = Path(clone_path) / cli_dir
461
+
462
+ if not src_path.exists():
463
+ logger.debug(f"CLI hooks directory not found in source repo: {src_path}")
464
+ return
465
+
466
+ try:
467
+ # Copy entire CLI hooks directory (non-blocking)
468
+ await asyncio.to_thread(shutil.copytree, src_path, dst_path, dirs_exist_ok=True)
469
+ logger.info(f"Copied CLI hooks from {src_path} to {dst_path}")
470
+ except shutil.Error:
471
+ logger.warning(
472
+ f"Failed to copy CLI hooks: provider={provider}, src={src_path}, dst={dst_path}",
473
+ exc_info=True,
474
+ )
475
+ except OSError:
476
+ logger.warning(
477
+ f"Filesystem error copying CLI hooks: provider={provider}, src={src_path}, dst={dst_path}",
478
+ exc_info=True,
479
+ )
480
+
481
+
482
+ def get_isolation_handler(
483
+ mode: Literal["current", "worktree", "clone"],
484
+ *,
485
+ git_manager: Any | None = None,
486
+ worktree_storage: Any | None = None,
487
+ clone_manager: Any | None = None,
488
+ clone_storage: Any | None = None,
489
+ ) -> IsolationHandler:
490
+ """
491
+ Factory function to get the appropriate isolation handler.
492
+
493
+ Args:
494
+ mode: Isolation mode - 'current', 'worktree', or 'clone'
495
+ git_manager: Git manager for worktree operations (required for 'worktree')
496
+ worktree_storage: Storage for worktree records (required for 'worktree')
497
+ clone_manager: Git manager for clone operations (required for 'clone')
498
+ clone_storage: Storage for clone records (required for 'clone')
499
+
500
+ Returns:
501
+ IsolationHandler instance for the specified mode
502
+
503
+ Raises:
504
+ ValueError: If mode is unknown or required dependencies are missing
505
+ """
506
+ if mode == "current":
507
+ return CurrentIsolationHandler()
508
+
509
+ if mode == "worktree":
510
+ if git_manager is None or worktree_storage is None:
511
+ raise ValueError("git_manager and worktree_storage are required for worktree isolation")
512
+ return WorktreeIsolationHandler(
513
+ git_manager=git_manager,
514
+ worktree_storage=worktree_storage,
515
+ )
516
+
517
+ if mode == "clone":
518
+ if clone_manager is None or clone_storage is None:
519
+ raise ValueError("clone_manager and clone_storage are required for clone isolation")
520
+ return CloneIsolationHandler(
521
+ clone_manager=clone_manager,
522
+ clone_storage=clone_storage,
523
+ )
524
+
525
+ raise ValueError(f"Unknown isolation mode: {mode}")
gobby/agents/registry.py CHANGED
@@ -137,6 +137,17 @@ class RunningAgentRegistry:
137
137
  with self._event_callbacks_lock:
138
138
  self._event_callbacks.append(callback)
139
139
 
140
+ def emit_event(self, event_type: str, run_id: str, data: dict[str, Any]) -> None:
141
+ """
142
+ Emit a custom event to all registered callbacks.
143
+
144
+ Args:
145
+ event_type: Type of event (e.g., terminal_output)
146
+ run_id: Agent run ID
147
+ data: Additional event data
148
+ """
149
+ self._emit_event(event_type, run_id, data)
150
+
140
151
  def _emit_event(self, event_type: str, run_id: str, data: dict[str, Any]) -> None:
141
152
  """
142
153
  Emit an event to all registered callbacks.