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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +2 -1
- gobby/adapters/claude_code.py +96 -35
- gobby/adapters/codex_impl/__init__.py +28 -0
- gobby/adapters/codex_impl/adapter.py +722 -0
- gobby/adapters/codex_impl/client.py +679 -0
- gobby/adapters/codex_impl/protocol.py +20 -0
- gobby/adapters/codex_impl/types.py +68 -0
- gobby/adapters/gemini.py +140 -38
- gobby/agents/definitions.py +11 -1
- gobby/agents/isolation.py +525 -0
- gobby/agents/registry.py +11 -0
- gobby/agents/sandbox.py +261 -0
- gobby/agents/session.py +1 -0
- gobby/agents/spawn.py +42 -287
- gobby/agents/spawn_executor.py +415 -0
- gobby/agents/spawners/__init__.py +24 -0
- gobby/agents/spawners/command_builder.py +189 -0
- gobby/agents/spawners/embedded.py +21 -2
- gobby/agents/spawners/headless.py +21 -2
- gobby/agents/spawners/macos.py +26 -1
- gobby/agents/spawners/prompt_manager.py +125 -0
- gobby/cli/__init__.py +0 -2
- gobby/cli/install.py +4 -4
- gobby/cli/installers/claude.py +6 -0
- gobby/cli/installers/gemini.py +6 -0
- gobby/cli/installers/shared.py +103 -4
- gobby/cli/memory.py +185 -0
- gobby/cli/sessions.py +1 -1
- gobby/cli/utils.py +9 -2
- gobby/clones/git.py +177 -0
- gobby/config/__init__.py +12 -97
- gobby/config/app.py +10 -94
- gobby/config/extensions.py +2 -2
- gobby/config/features.py +7 -130
- gobby/config/skills.py +31 -0
- gobby/config/tasks.py +4 -28
- gobby/hooks/__init__.py +0 -13
- gobby/hooks/event_handlers.py +150 -8
- gobby/hooks/hook_manager.py +21 -3
- gobby/hooks/plugins.py +1 -1
- gobby/hooks/webhooks.py +1 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
- gobby/llm/resolver.py +3 -2
- gobby/mcp_proxy/importer.py +62 -4
- gobby/mcp_proxy/instructions.py +4 -2
- gobby/mcp_proxy/registries.py +22 -8
- gobby/mcp_proxy/services/recommendation.py +43 -11
- gobby/mcp_proxy/tools/agent_messaging.py +93 -44
- gobby/mcp_proxy/tools/agents.py +76 -740
- gobby/mcp_proxy/tools/artifacts.py +43 -9
- gobby/mcp_proxy/tools/clones.py +0 -385
- gobby/mcp_proxy/tools/memory.py +2 -2
- gobby/mcp_proxy/tools/sessions/__init__.py +14 -0
- gobby/mcp_proxy/tools/sessions/_commits.py +239 -0
- gobby/mcp_proxy/tools/sessions/_crud.py +253 -0
- gobby/mcp_proxy/tools/sessions/_factory.py +63 -0
- gobby/mcp_proxy/tools/sessions/_handoff.py +503 -0
- gobby/mcp_proxy/tools/sessions/_messages.py +166 -0
- gobby/mcp_proxy/tools/skills/__init__.py +14 -29
- gobby/mcp_proxy/tools/spawn_agent.py +455 -0
- gobby/mcp_proxy/tools/tasks/_context.py +18 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +79 -30
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +1 -1
- gobby/mcp_proxy/tools/tasks/_session.py +22 -7
- gobby/mcp_proxy/tools/workflows.py +84 -34
- gobby/mcp_proxy/tools/worktrees.py +32 -350
- gobby/memory/extractor.py +15 -1
- gobby/memory/ingestion/__init__.py +5 -0
- gobby/memory/ingestion/multimodal.py +221 -0
- gobby/memory/manager.py +62 -283
- gobby/memory/search/__init__.py +10 -0
- gobby/memory/search/coordinator.py +248 -0
- gobby/memory/services/__init__.py +5 -0
- gobby/memory/services/crossref.py +142 -0
- gobby/prompts/loader.py +5 -2
- gobby/runner.py +13 -0
- gobby/servers/http.py +1 -4
- gobby/servers/routes/admin.py +14 -0
- gobby/servers/routes/mcp/endpoints/__init__.py +61 -0
- gobby/servers/routes/mcp/endpoints/discovery.py +405 -0
- gobby/servers/routes/mcp/endpoints/execution.py +568 -0
- gobby/servers/routes/mcp/endpoints/registry.py +378 -0
- gobby/servers/routes/mcp/endpoints/server.py +304 -0
- gobby/servers/routes/mcp/hooks.py +51 -4
- gobby/servers/routes/mcp/tools.py +48 -1506
- gobby/servers/websocket.py +57 -1
- gobby/sessions/analyzer.py +2 -2
- gobby/sessions/lifecycle.py +1 -1
- gobby/sessions/manager.py +9 -0
- gobby/sessions/processor.py +10 -0
- gobby/sessions/transcripts/base.py +1 -0
- gobby/sessions/transcripts/claude.py +15 -5
- gobby/sessions/transcripts/gemini.py +100 -34
- gobby/skills/parser.py +30 -2
- gobby/storage/database.py +9 -2
- gobby/storage/memories.py +32 -21
- gobby/storage/migrations.py +174 -368
- gobby/storage/sessions.py +45 -7
- gobby/storage/skills.py +80 -7
- gobby/storage/tasks/_lifecycle.py +18 -3
- gobby/sync/memories.py +1 -1
- gobby/tasks/external_validator.py +1 -1
- gobby/tasks/validation.py +22 -20
- gobby/tools/summarizer.py +91 -10
- gobby/utils/project_context.py +2 -3
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +221 -1217
- gobby/workflows/artifact_actions.py +31 -0
- gobby/workflows/autonomous_actions.py +11 -0
- gobby/workflows/context_actions.py +50 -1
- gobby/workflows/detection_helpers.py +38 -24
- gobby/workflows/enforcement/__init__.py +47 -0
- gobby/workflows/enforcement/blocking.py +281 -0
- gobby/workflows/enforcement/commit_policy.py +283 -0
- gobby/workflows/enforcement/handlers.py +269 -0
- gobby/workflows/enforcement/task_policy.py +542 -0
- gobby/workflows/engine.py +93 -0
- gobby/workflows/evaluator.py +110 -0
- gobby/workflows/git_utils.py +106 -0
- gobby/workflows/hooks.py +41 -0
- gobby/workflows/llm_actions.py +30 -0
- gobby/workflows/mcp_actions.py +20 -1
- gobby/workflows/memory_actions.py +91 -0
- gobby/workflows/safe_evaluator.py +191 -0
- gobby/workflows/session_actions.py +44 -0
- gobby/workflows/state_actions.py +60 -1
- gobby/workflows/stop_signal_actions.py +55 -0
- gobby/workflows/summary_actions.py +217 -51
- gobby/workflows/task_sync_actions.py +347 -0
- gobby/workflows/todo_actions.py +34 -1
- gobby/workflows/webhook_actions.py +185 -0
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/METADATA +6 -1
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/RECORD +139 -163
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/WHEEL +1 -1
- gobby/adapters/codex.py +0 -1332
- gobby/cli/tui.py +0 -34
- gobby/install/claude/commands/gobby/bug.md +0 -51
- gobby/install/claude/commands/gobby/chore.md +0 -51
- gobby/install/claude/commands/gobby/epic.md +0 -52
- gobby/install/claude/commands/gobby/eval.md +0 -235
- gobby/install/claude/commands/gobby/feat.md +0 -49
- gobby/install/claude/commands/gobby/nit.md +0 -52
- gobby/install/claude/commands/gobby/ref.md +0 -52
- gobby/mcp_proxy/tools/session_messages.py +0 -1055
- gobby/prompts/defaults/expansion/system.md +0 -119
- gobby/prompts/defaults/expansion/user.md +0 -48
- gobby/prompts/defaults/external_validation/agent.md +0 -72
- gobby/prompts/defaults/external_validation/external.md +0 -63
- gobby/prompts/defaults/external_validation/spawn.md +0 -83
- gobby/prompts/defaults/external_validation/system.md +0 -6
- gobby/prompts/defaults/features/import_mcp.md +0 -22
- gobby/prompts/defaults/features/import_mcp_github.md +0 -17
- gobby/prompts/defaults/features/import_mcp_search.md +0 -16
- gobby/prompts/defaults/features/recommend_tools.md +0 -32
- gobby/prompts/defaults/features/recommend_tools_hybrid.md +0 -35
- gobby/prompts/defaults/features/recommend_tools_llm.md +0 -30
- gobby/prompts/defaults/features/server_description.md +0 -20
- gobby/prompts/defaults/features/server_description_system.md +0 -6
- gobby/prompts/defaults/features/task_description.md +0 -31
- gobby/prompts/defaults/features/task_description_system.md +0 -6
- gobby/prompts/defaults/features/tool_summary.md +0 -17
- gobby/prompts/defaults/features/tool_summary_system.md +0 -6
- gobby/prompts/defaults/handoff/compact.md +0 -63
- gobby/prompts/defaults/handoff/session_end.md +0 -57
- gobby/prompts/defaults/memory/extract.md +0 -61
- gobby/prompts/defaults/research/step.md +0 -58
- gobby/prompts/defaults/validation/criteria.md +0 -47
- gobby/prompts/defaults/validation/validate.md +0 -38
- gobby/storage/migrations_legacy.py +0 -1359
- gobby/tui/__init__.py +0 -5
- gobby/tui/api_client.py +0 -278
- gobby/tui/app.py +0 -329
- gobby/tui/screens/__init__.py +0 -25
- gobby/tui/screens/agents.py +0 -333
- gobby/tui/screens/chat.py +0 -450
- gobby/tui/screens/dashboard.py +0 -377
- gobby/tui/screens/memory.py +0 -305
- gobby/tui/screens/metrics.py +0 -231
- gobby/tui/screens/orchestrator.py +0 -903
- gobby/tui/screens/sessions.py +0 -412
- gobby/tui/screens/tasks.py +0 -440
- gobby/tui/screens/workflows.py +0 -289
- gobby/tui/screens/worktrees.py +0 -174
- gobby/tui/widgets/__init__.py +0 -21
- gobby/tui/widgets/chat.py +0 -210
- gobby/tui/widgets/conductor.py +0 -104
- gobby/tui/widgets/menu.py +0 -132
- gobby/tui/widgets/message_panel.py +0 -160
- gobby/tui/widgets/review_gate.py +0 -224
- gobby/tui/widgets/task_tree.py +0 -99
- gobby/tui/widgets/token_budget.py +0 -166
- gobby/tui/ws_client.py +0 -258
- gobby/workflows/task_enforcement_actions.py +0 -1343
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.6.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
- {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.
|