gobby 0.2.7__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 (80) hide show
  1. gobby/adapters/claude_code.py +96 -35
  2. gobby/adapters/gemini.py +140 -38
  3. gobby/agents/isolation.py +130 -0
  4. gobby/agents/registry.py +11 -0
  5. gobby/agents/session.py +1 -0
  6. gobby/agents/spawn_executor.py +43 -13
  7. gobby/agents/spawners/macos.py +26 -1
  8. gobby/cli/__init__.py +0 -2
  9. gobby/cli/memory.py +185 -0
  10. gobby/clones/git.py +177 -0
  11. gobby/config/skills.py +31 -0
  12. gobby/hooks/event_handlers.py +109 -10
  13. gobby/hooks/hook_manager.py +19 -1
  14. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  15. gobby/mcp_proxy/instructions.py +2 -2
  16. gobby/mcp_proxy/registries.py +21 -4
  17. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  18. gobby/mcp_proxy/tools/agents.py +45 -9
  19. gobby/mcp_proxy/tools/artifacts.py +43 -9
  20. gobby/mcp_proxy/tools/sessions/_commits.py +31 -24
  21. gobby/mcp_proxy/tools/sessions/_crud.py +5 -5
  22. gobby/mcp_proxy/tools/sessions/_handoff.py +45 -41
  23. gobby/mcp_proxy/tools/sessions/_messages.py +35 -7
  24. gobby/mcp_proxy/tools/spawn_agent.py +44 -6
  25. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  26. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  27. gobby/mcp_proxy/tools/tasks/_lifecycle.py +29 -14
  28. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  29. gobby/mcp_proxy/tools/workflows.py +84 -34
  30. gobby/mcp_proxy/tools/worktrees.py +32 -7
  31. gobby/memory/extractor.py +15 -1
  32. gobby/runner.py +13 -0
  33. gobby/servers/routes/mcp/hooks.py +50 -3
  34. gobby/servers/websocket.py +57 -1
  35. gobby/sessions/analyzer.py +2 -2
  36. gobby/sessions/manager.py +9 -0
  37. gobby/sessions/transcripts/gemini.py +100 -34
  38. gobby/storage/database.py +9 -2
  39. gobby/storage/memories.py +32 -21
  40. gobby/storage/migrations.py +23 -4
  41. gobby/storage/sessions.py +4 -2
  42. gobby/storage/skills.py +43 -3
  43. gobby/workflows/detection_helpers.py +38 -24
  44. gobby/workflows/enforcement/blocking.py +13 -1
  45. gobby/workflows/engine.py +93 -0
  46. gobby/workflows/evaluator.py +110 -0
  47. gobby/workflows/hooks.py +41 -0
  48. gobby/workflows/memory_actions.py +11 -0
  49. gobby/workflows/safe_evaluator.py +8 -0
  50. gobby/workflows/summary_actions.py +123 -50
  51. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/METADATA +1 -1
  52. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/RECORD +56 -80
  53. gobby/cli/tui.py +0 -34
  54. gobby/tui/__init__.py +0 -5
  55. gobby/tui/api_client.py +0 -278
  56. gobby/tui/app.py +0 -329
  57. gobby/tui/screens/__init__.py +0 -25
  58. gobby/tui/screens/agents.py +0 -333
  59. gobby/tui/screens/chat.py +0 -450
  60. gobby/tui/screens/dashboard.py +0 -377
  61. gobby/tui/screens/memory.py +0 -305
  62. gobby/tui/screens/metrics.py +0 -231
  63. gobby/tui/screens/orchestrator.py +0 -903
  64. gobby/tui/screens/sessions.py +0 -412
  65. gobby/tui/screens/tasks.py +0 -440
  66. gobby/tui/screens/workflows.py +0 -289
  67. gobby/tui/screens/worktrees.py +0 -174
  68. gobby/tui/widgets/__init__.py +0 -21
  69. gobby/tui/widgets/chat.py +0 -210
  70. gobby/tui/widgets/conductor.py +0 -104
  71. gobby/tui/widgets/menu.py +0 -132
  72. gobby/tui/widgets/message_panel.py +0 -160
  73. gobby/tui/widgets/review_gate.py +0 -224
  74. gobby/tui/widgets/task_tree.py +0 -99
  75. gobby/tui/widgets/token_budget.py +0 -166
  76. gobby/tui/ws_client.py +0 -258
  77. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/WHEEL +0 -0
  78. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
  79. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
  80. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
@@ -104,6 +104,10 @@ class ClaudeCodeAdapter(BaseAdapter):
104
104
  is_failure = hook_type == "post-tool-use-failure"
105
105
  metadata = {"is_failure": is_failure} if is_failure else {}
106
106
 
107
+ # Normalize event data for CLI-agnostic processing
108
+ # This allows downstream code to use consistent field names
109
+ normalized_data = self._normalize_event_data(input_data)
110
+
107
111
  return HookEvent(
108
112
  event_type=event_type,
109
113
  session_id=session_id,
@@ -111,10 +115,46 @@ class ClaudeCodeAdapter(BaseAdapter):
111
115
  timestamp=datetime.now(UTC),
112
116
  machine_id=input_data.get("machine_id"),
113
117
  cwd=input_data.get("cwd"),
114
- data=input_data,
118
+ data=normalized_data,
115
119
  metadata=metadata,
116
120
  )
117
121
 
122
+ def _normalize_event_data(self, input_data: dict[str, Any]) -> dict[str, Any]:
123
+ """Normalize Claude Code event data for CLI-agnostic processing.
124
+
125
+ This method enriches the input_data with normalized fields so downstream
126
+ code doesn't need to handle Claude-specific formats.
127
+
128
+ Normalizations performed:
129
+ 1. tool_input.server_name/tool_name → mcp_server/mcp_tool (for MCP calls)
130
+ 2. tool_result → tool_output
131
+
132
+ Args:
133
+ input_data: Raw input data from Claude Code
134
+
135
+ Returns:
136
+ Enriched data dict with normalized fields added
137
+ """
138
+ # Start with a copy to avoid mutating original
139
+ data = dict(input_data)
140
+
141
+ # Get tool info
142
+ tool_name = data.get("tool_name", "")
143
+ tool_input = data.get("tool_input", {}) or {}
144
+
145
+ # 1. Extract MCP info from nested tool_input for call_tool calls
146
+ if tool_name in ("call_tool", "mcp__gobby__call_tool"):
147
+ if "mcp_server" not in data:
148
+ data["mcp_server"] = tool_input.get("server_name")
149
+ if "mcp_tool" not in data:
150
+ data["mcp_tool"] = tool_input.get("tool_name")
151
+
152
+ # 2. Normalize tool_result → tool_output
153
+ if "tool_result" in data and "tool_output" not in data:
154
+ data["tool_output"] = data["tool_result"]
155
+
156
+ return data
157
+
118
158
  # Map Claude Code hook types to hookEventName for hookSpecificOutput
119
159
  HOOK_EVENT_NAME_MAP: dict[str, str] = {
120
160
  "session-start": "SessionStart",
@@ -193,44 +233,65 @@ class ClaudeCodeAdapter(BaseAdapter):
193
233
  # Add session identifiers from metadata
194
234
  # Note: "session_id" in metadata is Gobby's internal platform session ID
195
235
  # "external_id" in metadata is the CLI's session UUID
236
+ # "session_ref" is the short #N format for easier reference
237
+ # Token optimization: Only inject full metadata on first hook per session
196
238
  if response.metadata:
197
239
  gobby_session_id = response.metadata.get("session_id")
240
+ session_ref = response.metadata.get("session_ref")
198
241
  external_id = response.metadata.get("external_id")
242
+ is_first_hook = response.metadata.get("_first_hook_for_session", False)
243
+
199
244
  if gobby_session_id:
200
- # Build context with all available identifiers
201
- # Use clear naming: Gobby Session ID for MCP calls, External ID for transcripts
202
- context_lines = [f"Gobby Session ID: {gobby_session_id}"]
203
- if external_id:
204
- context_lines.append(f"External ID: {external_id}")
205
- if response.metadata.get("parent_session_id"):
206
- context_lines.append(
207
- f"parent_session_id: {response.metadata['parent_session_id']}"
208
- )
209
- if response.metadata.get("machine_id"):
210
- context_lines.append(f"machine_id: {response.metadata['machine_id']}")
211
- if response.metadata.get("project_id"):
212
- context_lines.append(f"project_id: {response.metadata['project_id']}")
213
- # Add terminal context (non-null values only)
214
- if response.metadata.get("terminal_term_program"):
215
- context_lines.append(f"terminal: {response.metadata['terminal_term_program']}")
216
- if response.metadata.get("terminal_tty"):
217
- context_lines.append(f"tty: {response.metadata['terminal_tty']}")
218
- if response.metadata.get("terminal_parent_pid"):
219
- context_lines.append(f"parent_pid: {response.metadata['terminal_parent_pid']}")
220
- # Add terminal-specific session IDs (only one will be present)
221
- for key in [
222
- "terminal_iterm_session_id",
223
- "terminal_term_session_id",
224
- "terminal_kitty_window_id",
225
- "terminal_tmux_pane",
226
- "terminal_vscode_terminal_id",
227
- "terminal_alacritty_socket",
228
- ]:
229
- if response.metadata.get(key):
230
- # Use friendlier names in output
231
- friendly_name = key.replace("terminal_", "").replace("_", " ")
232
- context_lines.append(f"{friendly_name}: {response.metadata[key]}")
233
- additional_context_parts.append("\n".join(context_lines))
245
+ if is_first_hook:
246
+ # First hook: inject full metadata (~60-100 tokens)
247
+ context_lines = []
248
+ if session_ref:
249
+ context_lines.append(
250
+ f"Gobby Session ID: {session_ref} (or {gobby_session_id})"
251
+ )
252
+ else:
253
+ context_lines.append(f"Gobby Session ID: {gobby_session_id}")
254
+ if external_id:
255
+ context_lines.append(
256
+ f"CLI-Specific Session ID (external_id): {external_id}"
257
+ )
258
+ if response.metadata.get("parent_session_id"):
259
+ context_lines.append(
260
+ f"parent_session_id: {response.metadata['parent_session_id']}"
261
+ )
262
+ if response.metadata.get("machine_id"):
263
+ context_lines.append(f"machine_id: {response.metadata['machine_id']}")
264
+ if response.metadata.get("project_id"):
265
+ context_lines.append(f"project_id: {response.metadata['project_id']}")
266
+ # Add terminal context (non-null values only)
267
+ if response.metadata.get("terminal_term_program"):
268
+ context_lines.append(
269
+ f"terminal: {response.metadata['terminal_term_program']}"
270
+ )
271
+ if response.metadata.get("terminal_tty"):
272
+ context_lines.append(f"tty: {response.metadata['terminal_tty']}")
273
+ if response.metadata.get("terminal_parent_pid"):
274
+ context_lines.append(
275
+ f"parent_pid: {response.metadata['terminal_parent_pid']}"
276
+ )
277
+ # Add terminal-specific session IDs (only one will be present)
278
+ for key in [
279
+ "terminal_iterm_session_id",
280
+ "terminal_term_session_id",
281
+ "terminal_kitty_window_id",
282
+ "terminal_tmux_pane",
283
+ "terminal_vscode_terminal_id",
284
+ "terminal_alacritty_socket",
285
+ ]:
286
+ if response.metadata.get(key):
287
+ # Use friendlier names in output
288
+ friendly_name = key.replace("terminal_", "").replace("_", " ")
289
+ context_lines.append(f"{friendly_name}: {response.metadata[key]}")
290
+ additional_context_parts.append("\n".join(context_lines))
291
+ else:
292
+ # Subsequent hooks: inject minimal session ref only (~8 tokens)
293
+ if session_ref:
294
+ additional_context_parts.append(f"Gobby Session ID: {session_ref}")
234
295
 
235
296
  # Build hookSpecificOutput if we have any context to inject
236
297
  # Only include hookSpecificOutput for hook types that Claude Code's schema accepts
gobby/adapters/gemini.py CHANGED
@@ -76,21 +76,39 @@ class GeminiAdapter(BaseAdapter):
76
76
 
77
77
  # Tool name mapping: Gemini tool names -> normalized names
78
78
  # Gemini uses different tool names than Claude Code
79
+ # This enables workflows to use Claude Code naming conventions
79
80
  TOOL_MAP: dict[str, str] = {
81
+ # Shell/Bash
80
82
  "run_shell_command": "Bash",
81
83
  "RunShellCommand": "Bash",
84
+ "ShellTool": "Bash",
85
+ # File read
82
86
  "read_file": "Read",
83
87
  "ReadFile": "Read",
84
88
  "ReadFileTool": "Read",
89
+ # File write
85
90
  "write_file": "Write",
86
91
  "WriteFile": "Write",
87
92
  "WriteFileTool": "Write",
93
+ # File edit
88
94
  "edit_file": "Edit",
89
95
  "EditFile": "Edit",
90
96
  "EditFileTool": "Edit",
97
+ # Search/Glob/Grep
91
98
  "GlobTool": "Glob",
92
99
  "GrepTool": "Grep",
93
- "ShellTool": "Bash",
100
+ "search_file_content": "Grep",
101
+ "SearchText": "Grep",
102
+ # MCP tools (Gobby MCP server)
103
+ "call_tool": "mcp__gobby__call_tool",
104
+ "list_mcp_servers": "mcp__gobby__list_mcp_servers",
105
+ "list_tools": "mcp__gobby__list_tools",
106
+ "get_tool_schema": "mcp__gobby__get_tool_schema",
107
+ "search_tools": "mcp__gobby__search_tools",
108
+ "recommend_tools": "mcp__gobby__recommend_tools",
109
+ # Skill and agent tools
110
+ "activate_skill": "Skill",
111
+ "delegate_to_agent": "Task",
94
112
  }
95
113
 
96
114
  def __init__(self, hook_manager: "HookManager | None" = None):
@@ -135,6 +153,55 @@ class GeminiAdapter(BaseAdapter):
135
153
  """
136
154
  return self.TOOL_MAP.get(gemini_tool_name, gemini_tool_name)
137
155
 
156
+ def _normalize_event_data(self, input_data: dict[str, Any]) -> dict[str, Any]:
157
+ """Normalize Gemini event data for CLI-agnostic processing.
158
+
159
+ This method enriches the input_data with normalized fields so downstream
160
+ code doesn't need to handle Gemini-specific formats.
161
+
162
+ Normalizations performed:
163
+ 1. mcp_context.server_name/tool_name → mcp_server/mcp_tool (top-level)
164
+ 2. tool_response → tool_output
165
+ 3. function_name → tool_name (if not already present)
166
+ 4. parameters/args → tool_input (if not already present)
167
+
168
+ Args:
169
+ input_data: Raw input data from Gemini CLI
170
+
171
+ Returns:
172
+ Enriched data dict with normalized fields added
173
+ """
174
+ # Start with a copy to avoid mutating original
175
+ data = dict(input_data)
176
+
177
+ # 1. Flatten mcp_context to top-level mcp_server/mcp_tool
178
+ mcp_context = data.get("mcp_context")
179
+ if mcp_context and isinstance(mcp_context, dict):
180
+ if "mcp_server" not in data:
181
+ data["mcp_server"] = mcp_context.get("server_name")
182
+ if "mcp_tool" not in data:
183
+ data["mcp_tool"] = mcp_context.get("tool_name")
184
+
185
+ # 2. Normalize tool_response → tool_output
186
+ if "tool_response" in data and "tool_output" not in data:
187
+ data["tool_output"] = data["tool_response"]
188
+
189
+ # 3. Normalize function_name → tool_name
190
+ if "function_name" in data and "tool_name" not in data:
191
+ data["tool_name"] = self.normalize_tool_name(data["function_name"])
192
+ elif "tool_name" in data:
193
+ # Normalize existing tool_name
194
+ data["tool_name"] = self.normalize_tool_name(data["tool_name"])
195
+
196
+ # 4. Normalize parameters/args → tool_input
197
+ if "tool_input" not in data:
198
+ if "parameters" in data:
199
+ data["tool_input"] = data["parameters"]
200
+ elif "args" in data:
201
+ data["tool_input"] = data["args"]
202
+
203
+ return data
204
+
138
205
  def translate_to_hook_event(self, native_event: dict[str, Any]) -> HookEvent:
139
206
  """Convert Gemini CLI native event to unified HookEvent.
140
207
 
@@ -202,6 +269,10 @@ class GeminiAdapter(BaseAdapter):
202
269
  else:
203
270
  metadata = {}
204
271
 
272
+ # Normalize event data for CLI-agnostic processing
273
+ # This allows downstream code to use consistent field names
274
+ normalized_data = self._normalize_event_data(input_data)
275
+
205
276
  return HookEvent(
206
277
  event_type=event_type,
207
278
  session_id=session_id,
@@ -209,7 +280,7 @@ class GeminiAdapter(BaseAdapter):
209
280
  timestamp=timestamp,
210
281
  machine_id=machine_id,
211
282
  cwd=input_data.get("cwd"),
212
- data=input_data,
283
+ data=normalized_data,
213
284
  metadata=metadata,
214
285
  )
215
286
 
@@ -254,46 +325,77 @@ class GeminiAdapter(BaseAdapter):
254
325
  if response.context:
255
326
  hook_specific["additionalContext"] = response.context
256
327
 
257
- # Add session/terminal context for SessionStart only
258
- if hook_type == "SessionStart" and response.metadata:
328
+ # Add session/terminal context for hooks that support additionalContext
329
+ # Parity with Claude Code: inject on SessionStart, BeforeAgent, BeforeTool, AfterTool
330
+ hooks_with_context = {"SessionStart", "BeforeAgent", "BeforeTool", "AfterTool"}
331
+ if hook_type in hooks_with_context and response.metadata:
259
332
  session_id = response.metadata.get("session_id")
333
+ session_ref = response.metadata.get("session_ref")
334
+ external_id = response.metadata.get("external_id")
335
+ is_first_hook = response.metadata.get("_first_hook_for_session", False)
336
+
260
337
  if session_id:
261
338
  hook_event_name = self.HOOK_EVENT_NAME_MAP.get(hook_type, "Unknown")
262
- context_lines = [f"session_id: {session_id}"]
263
- if response.metadata.get("parent_session_id"):
264
- context_lines.append(
265
- f"parent_session_id: {response.metadata['parent_session_id']}"
339
+
340
+ if is_first_hook:
341
+ # First hook: inject full metadata (~60-100 tokens)
342
+ context_lines = []
343
+ if session_ref:
344
+ context_lines.append(f"Gobby Session ID: {session_ref} (or {session_id})")
345
+ else:
346
+ context_lines.append(f"Gobby Session ID: {session_id}")
347
+ if external_id:
348
+ context_lines.append(
349
+ f"CLI-Specific Session ID (external_id): {external_id}"
350
+ )
351
+ if response.metadata.get("parent_session_id"):
352
+ context_lines.append(
353
+ f"parent_session_id: {response.metadata['parent_session_id']}"
354
+ )
355
+ if response.metadata.get("machine_id"):
356
+ context_lines.append(f"machine_id: {response.metadata['machine_id']}")
357
+ if response.metadata.get("project_id"):
358
+ context_lines.append(f"project_id: {response.metadata['project_id']}")
359
+ # Add terminal context (non-null values only)
360
+ if response.metadata.get("terminal_term_program"):
361
+ context_lines.append(
362
+ f"terminal: {response.metadata['terminal_term_program']}"
363
+ )
364
+ if response.metadata.get("terminal_tty"):
365
+ context_lines.append(f"tty: {response.metadata['terminal_tty']}")
366
+ if response.metadata.get("terminal_parent_pid"):
367
+ context_lines.append(
368
+ f"parent_pid: {response.metadata['terminal_parent_pid']}"
369
+ )
370
+ # Add terminal-specific session IDs
371
+ for key in [
372
+ "terminal_iterm_session_id",
373
+ "terminal_term_session_id",
374
+ "terminal_kitty_window_id",
375
+ "terminal_tmux_pane",
376
+ "terminal_vscode_terminal_id",
377
+ "terminal_alacritty_socket",
378
+ ]:
379
+ if response.metadata.get(key):
380
+ friendly_name = key.replace("terminal_", "").replace("_", " ")
381
+ context_lines.append(f"{friendly_name}: {response.metadata[key]}")
382
+
383
+ hook_specific["hookEventName"] = hook_event_name
384
+ # Append to existing additionalContext if present
385
+ existing = hook_specific.get("additionalContext", "")
386
+ new_context = "\n".join(context_lines)
387
+ hook_specific["additionalContext"] = (
388
+ f"{existing}\n{new_context}" if existing else new_context
266
389
  )
267
- if response.metadata.get("machine_id"):
268
- context_lines.append(f"machine_id: {response.metadata['machine_id']}")
269
- if response.metadata.get("project_id"):
270
- context_lines.append(f"project_id: {response.metadata['project_id']}")
271
- # Add terminal context (non-null values only)
272
- if response.metadata.get("terminal_term_program"):
273
- context_lines.append(f"terminal: {response.metadata['terminal_term_program']}")
274
- if response.metadata.get("terminal_tty"):
275
- context_lines.append(f"tty: {response.metadata['terminal_tty']}")
276
- if response.metadata.get("terminal_parent_pid"):
277
- context_lines.append(f"parent_pid: {response.metadata['terminal_parent_pid']}")
278
- # Add terminal-specific session IDs
279
- for key in [
280
- "terminal_iterm_session_id",
281
- "terminal_term_session_id",
282
- "terminal_kitty_window_id",
283
- "terminal_tmux_pane",
284
- "terminal_vscode_terminal_id",
285
- "terminal_alacritty_socket",
286
- ]:
287
- if response.metadata.get(key):
288
- friendly_name = key.replace("terminal_", "").replace("_", " ")
289
- context_lines.append(f"{friendly_name}: {response.metadata[key]}")
290
- hook_specific["hookEventName"] = hook_event_name
291
- # Append to existing additionalContext if present
292
- existing = hook_specific.get("additionalContext", "")
293
- new_context = "\n".join(context_lines)
294
- hook_specific["additionalContext"] = (
295
- f"{existing}\n{new_context}" if existing else new_context
296
- )
390
+ else:
391
+ # Subsequent hooks: inject minimal session ref only (~8 tokens)
392
+ if session_ref:
393
+ hook_specific["hookEventName"] = hook_event_name
394
+ existing = hook_specific.get("additionalContext", "")
395
+ minimal_context = f"Gobby Session ID: {session_ref}"
396
+ hook_specific["additionalContext"] = (
397
+ f"{existing}\n{minimal_context}" if existing else minimal_context
398
+ )
297
399
 
298
400
  # Handle BeforeModel-specific output (llm_request modification)
299
401
  if hook_type == "BeforeModel" and response.modify_args:
gobby/agents/isolation.py CHANGED
@@ -197,6 +197,13 @@ class WorktreeIsolationHandler(IsolationHandler):
197
197
  task_id=config.task_id,
198
198
  )
199
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
+
200
207
  return IsolationContext(
201
208
  cwd=worktree.worktree_path,
202
209
  branch_name=worktree.branch_name,
@@ -235,6 +242,64 @@ Commit your changes to the worktree branch when done.
235
242
  worktree_dir = tempfile.gettempdir()
236
243
  return f"{worktree_dir}/gobby-worktrees/{project_name}/{safe_branch}"
237
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
+
238
303
 
239
304
  class CloneIsolationHandler(IsolationHandler):
240
305
  """
@@ -310,6 +375,13 @@ class CloneIsolationHandler(IsolationHandler):
310
375
  task_id=config.task_id,
311
376
  )
312
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
+
313
385
  return IsolationContext(
314
386
  cwd=clone.clone_path,
315
387
  branch_name=clone.branch_name,
@@ -348,6 +420,64 @@ Push your changes when ready to share with the original.
348
420
  clone_dir = tempfile.gettempdir()
349
421
  return f"{clone_dir}/gobby-clones/{project_name}/{safe_branch}"
350
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
+
351
481
 
352
482
  def get_isolation_handler(
353
483
  mode: Literal["current", "worktree", "clone"],
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.
gobby/agents/session.py CHANGED
@@ -196,6 +196,7 @@ class ChildSessionManager:
196
196
  parent_session_id=config.parent_session_id,
197
197
  agent_depth=child_depth,
198
198
  spawned_by_agent_id=config.agent_id,
199
+ workflow_name=config.workflow_name,
199
200
  )
200
201
 
201
202
  child_id = child.id