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
@@ -147,6 +147,56 @@ class EventHandlers:
147
147
  """
148
148
  return dict(self._handler_map)
149
149
 
150
+ def _auto_activate_workflow(
151
+ self, workflow_name: str, session_id: str, project_path: str | None
152
+ ) -> None:
153
+ """Auto-activate a workflow for a session.
154
+
155
+ Args:
156
+ workflow_name: Name of the workflow to activate
157
+ session_id: Session ID to activate workflow for
158
+ project_path: Project path for workflow context
159
+ """
160
+ if not self._workflow_handler:
161
+ return
162
+
163
+ try:
164
+ result = self._workflow_handler.activate_workflow(
165
+ workflow_name=workflow_name,
166
+ session_id=session_id,
167
+ project_path=project_path,
168
+ )
169
+ if result.get("success"):
170
+ self.logger.info(
171
+ "Auto-activated workflow for session",
172
+ extra={
173
+ "workflow_name": workflow_name,
174
+ "session_id": session_id,
175
+ "project_path": project_path,
176
+ },
177
+ )
178
+ else:
179
+ self.logger.warning(
180
+ "Failed to auto-activate workflow",
181
+ extra={
182
+ "workflow_name": workflow_name,
183
+ "session_id": session_id,
184
+ "project_path": project_path,
185
+ "error": result.get("error"),
186
+ },
187
+ )
188
+ except Exception as e:
189
+ self.logger.warning(
190
+ "Failed to auto-activate workflow",
191
+ extra={
192
+ "workflow_name": workflow_name,
193
+ "session_id": session_id,
194
+ "project_path": project_path,
195
+ "error": str(e),
196
+ },
197
+ exc_info=True,
198
+ )
199
+
150
200
  # ==================== SESSION HANDLERS ====================
151
201
 
152
202
  def handle_session_start(self, event: HookEvent) -> HookResponse:
@@ -207,6 +257,12 @@ class EventHandlers:
207
257
  except Exception as e:
208
258
  self.logger.warning(f"Failed to start agent run: {e}")
209
259
 
260
+ # Auto-activate workflow if specified for this session
261
+ if existing_session.workflow_name and session_id:
262
+ self._auto_activate_workflow(
263
+ existing_session.workflow_name, session_id, cwd
264
+ )
265
+
210
266
  # Update event metadata
211
267
  event.metadata["_platform_session_id"] = session_id
212
268
 
@@ -235,9 +291,9 @@ class EventHandlers:
235
291
  session_ref = (
236
292
  f"#{existing_session.seq_num}" if existing_session.seq_num else session_id
237
293
  )
238
- system_message = f"\nGobby Session Ref: {session_ref}"
239
- system_message += f"\nGobby Session ID: {session_id}"
240
- system_message += f"\nExternal ID: {external_id}"
294
+ system_message = f"\nGobby Session ID: {session_ref}"
295
+ system_message += " <- Use this for MCP tool calls (session_id parameter)"
296
+ system_message += f"\nExternal ID: {external_id} (CLI-native, rarely needed)"
241
297
  if parent_session_id:
242
298
  context_parts.append(f"Parent session: {parent_session_id}")
243
299
 
@@ -261,6 +317,7 @@ class EventHandlers:
261
317
  system_message=system_message,
262
318
  metadata={
263
319
  "session_id": session_id,
320
+ "session_ref": session_ref,
264
321
  "parent_session_id": parent_session_id,
265
322
  "machine_id": machine_id,
266
323
  "project_id": existing_session.project_id,
@@ -272,9 +329,13 @@ class EventHandlers:
272
329
  except Exception as e:
273
330
  self.logger.debug(f"No pre-created session found: {e}")
274
331
 
275
- # Step 1: Find parent session if this is a handoff (source='clear' only)
276
- parent_session_id = None
277
- if session_source == "clear" and self._session_storage:
332
+ # Step 1: Find parent session
333
+ # Check env vars first (spawned agent case), then handoff (source='clear')
334
+ parent_session_id = input_data.get("parent_session_id")
335
+ workflow_name = input_data.get("workflow_name")
336
+ agent_depth = input_data.get("agent_depth")
337
+
338
+ if not parent_session_id and session_source == "clear" and self._session_storage:
278
339
  try:
279
340
  parent = self._session_storage.find_parent(
280
341
  machine_id=machine_id,
@@ -291,6 +352,14 @@ class EventHandlers:
291
352
  # Step 2: Register new session with parent if found
292
353
  # Extract terminal context (injected by hook_dispatcher for terminal correlation)
293
354
  terminal_context = input_data.get("terminal_context")
355
+ # Parse agent_depth as int if provided
356
+ agent_depth_val = 0
357
+ if agent_depth:
358
+ try:
359
+ agent_depth_val = int(agent_depth)
360
+ except (ValueError, TypeError):
361
+ pass
362
+
294
363
  session_id = None
295
364
  if self._session_manager:
296
365
  session_id = self._session_manager.register_session(
@@ -302,6 +371,8 @@ class EventHandlers:
302
371
  source=cli_source,
303
372
  project_path=cwd,
304
373
  terminal_context=terminal_context,
374
+ workflow_name=workflow_name,
375
+ agent_depth=agent_depth_val,
305
376
  )
306
377
 
307
378
  # Step 2b: Mark parent session as expired after successful handoff
@@ -312,6 +383,10 @@ class EventHandlers:
312
383
  except Exception as e:
313
384
  self.logger.warning(f"Failed to mark parent session as expired: {e}")
314
385
 
386
+ # Step 2c: Auto-activate workflow if specified (for spawned agents)
387
+ if workflow_name and session_id:
388
+ self._auto_activate_workflow(workflow_name, session_id, cwd)
389
+
315
390
  # Step 3: Track registered session
316
391
  if transcript_path and self._session_coordinator:
317
392
  try:
@@ -354,9 +429,13 @@ class EventHandlers:
354
429
  session_obj = self._session_storage.get(session_id)
355
430
  if session_obj and session_obj.seq_num:
356
431
  session_ref = f"#{session_obj.seq_num}"
357
- system_message = f"\nGobby Session Ref: {session_ref}"
358
- system_message += f"\nGobby Session ID: {session_id}"
359
- system_message += f"\nExternal ID: {external_id}"
432
+ # Format: "Gobby Session ID: #N" with usage hint
433
+ if session_ref and session_ref != session_id:
434
+ system_message = f"\nGobby Session ID: {session_ref}"
435
+ else:
436
+ system_message = f"\nGobby Session ID: {session_id}"
437
+ system_message += " <- Use this for MCP tool calls (session_id parameter)"
438
+ system_message += f"\nExternal ID: {external_id} (CLI-native, rarely needed)"
360
439
 
361
440
  # Add active lifecycle workflows
362
441
  if wf_response.metadata and "discovered_workflows" in wf_response.metadata:
@@ -384,6 +463,7 @@ class EventHandlers:
384
463
  # Build metadata with terminal context (filter out nulls)
385
464
  metadata: dict[str, Any] = {
386
465
  "session_id": session_id,
466
+ "session_ref": session_ref,
387
467
  "parent_session_id": parent_session_id,
388
468
  "machine_id": machine_id,
389
469
  "project_id": project_id,
@@ -716,10 +796,16 @@ class EventHandlers:
716
796
 
717
797
  # Track edits for session high-water mark
718
798
  # Only if tool succeeded, matches edit tools, and session has claimed a task
799
+ # Skip .gobby/ internal files (tasks.jsonl, memories.jsonl, etc.)
800
+ tool_input = input_data.get("tool_input", {})
801
+ file_path = tool_input.get("file_path", "")
802
+ is_gobby_internal = "/.gobby/" in file_path or file_path.startswith(".gobby/")
803
+
719
804
  if (
720
805
  not is_failure
721
806
  and tool_name
722
807
  and tool_name.lower() in EDIT_TOOLS
808
+ and not is_gobby_internal
723
809
  and self._session_storage
724
810
  and self._task_manager
725
811
  ):
@@ -780,10 +866,23 @@ class EventHandlers:
780
866
  # ==================== COMPACT HANDLER ====================
781
867
 
782
868
  def handle_pre_compact(self, event: HookEvent) -> HookResponse:
783
- """Handle PRE_COMPACT event."""
869
+ """Handle PRE_COMPACT event.
870
+
871
+ Note: Gemini fires PreCompress constantly during normal operation,
872
+ unlike Claude which fires it only when approaching context limits.
873
+ We skip handoff logic and workflow execution for Gemini to avoid
874
+ excessive state changes and workflow interruptions.
875
+ """
876
+ from gobby.hooks.events import SessionSource
877
+
784
878
  trigger = event.data.get("trigger", "auto")
785
879
  session_id = event.metadata.get("_platform_session_id")
786
880
 
881
+ # Skip handoff logic for Gemini - it fires PreCompress too frequently
882
+ if event.source == SessionSource.GEMINI:
883
+ self.logger.debug(f"PRE_COMPACT ({trigger}): session {session_id} [Gemini - skipped]")
884
+ return HookResponse(decision="allow")
885
+
787
886
  if session_id:
788
887
  self.logger.debug(f"PRE_COMPACT ({trigger}): session {session_id}")
789
888
  # Mark session as handoff_ready so it can be found as parent after compact
@@ -369,6 +369,10 @@ class HookManager:
369
369
  # Skill manager for core skill injection
370
370
  self._skill_manager = HookSkillManager()
371
371
 
372
+ # Track sessions that have received full metadata injection
373
+ # Key: "{platform_session_id}:{source}" - cleared on daemon restart
374
+ self._injected_sessions: set[str] = set()
375
+
372
376
  # Event handlers (delegated to EventHandlers module)
373
377
  self._event_handlers = EventHandlers(
374
378
  session_manager=self._session_manager,
@@ -644,7 +648,21 @@ class HookManager:
644
648
  # Copy session metadata from event to response for adapter injection
645
649
  # The adapter reads response.metadata to inject session info into agent context
646
650
  if event.metadata.get("_platform_session_id"):
647
- response.metadata["session_id"] = event.metadata["_platform_session_id"]
651
+ platform_session_id = event.metadata["_platform_session_id"]
652
+ response.metadata["session_id"] = platform_session_id
653
+ # Look up seq_num for session_ref (#N format)
654
+ if self._session_storage:
655
+ session_obj = self._session_storage.get(platform_session_id)
656
+ if session_obj and session_obj.seq_num:
657
+ response.metadata["session_ref"] = f"#{session_obj.seq_num}"
658
+
659
+ # Track first hook per session for token optimization
660
+ # Adapters use this flag to inject full metadata only on first hook
661
+ session_key = f"{platform_session_id}:{event.source.value}"
662
+ is_first = session_key not in self._injected_sessions
663
+ if is_first:
664
+ self._injected_sessions.add(session_key)
665
+ response.metadata["_first_hook_for_session"] = is_first
648
666
  if event.session_id: # external_id (e.g., Claude Code's session UUID)
649
667
  response.metadata["external_id"] = event.session_id
650
668
  if event.machine_id:
@@ -216,52 +216,111 @@ def main() -> int:
216
216
  # This captures the terminal/process info for session correlation
217
217
  if hook_type == "SessionStart":
218
218
  input_data["terminal_context"] = get_terminal_context()
219
+ # Note: gobby_context (parent_session_id, workflow, etc.) is no longer
220
+ # injected from env vars. For spawned agents, the session is pre-created
221
+ # with all linkage via preflight+resume pattern, so the daemon already
222
+ # has the context when SessionStart fires.
219
223
 
220
224
  # Log what Gemini CLI sends us (for debugging hook data issues)
221
- logger.info(f"[{hook_type}] Received input keys: {list(input_data.keys())}")
225
+ # Extract common context fields for structured logging
226
+ session_id = input_data.get("session_id")
227
+ task_id = input_data.get("task_id")
228
+ project_id = input_data.get("project_id")
229
+ base_context = {
230
+ "hook_type": hook_type,
231
+ "session_id": session_id,
232
+ "task_id": task_id,
233
+ "project_id": project_id,
234
+ }
235
+
236
+ logger.info(
237
+ "[%s] Received input keys: %s",
238
+ hook_type,
239
+ list(input_data.keys()),
240
+ extra=base_context,
241
+ )
222
242
 
223
243
  # Log hook-specific critical fields
224
244
  if hook_type == "SessionStart":
225
- logger.info(f"[SessionStart] session_id={input_data.get('session_id')}")
245
+ logger.info(
246
+ "[SessionStart] session_id=%s",
247
+ session_id,
248
+ extra=base_context,
249
+ )
226
250
  elif hook_type == "SessionEnd":
251
+ reason = input_data.get("reason")
227
252
  logger.info(
228
- f"[SessionEnd] session_id={input_data.get('session_id')}, "
229
- f"reason={input_data.get('reason')}"
253
+ "[SessionEnd] session_id=%s, reason=%s",
254
+ session_id,
255
+ reason,
256
+ extra={**base_context, "reason": reason},
230
257
  )
231
258
  elif hook_type == "BeforeAgent":
232
259
  prompt = input_data.get("prompt", "")
233
260
  prompt_preview = prompt[:100] + "..." if len(prompt) > 100 else prompt
234
261
  logger.info(
235
- f"[BeforeAgent] session_id={input_data.get('session_id')}, prompt={prompt_preview}"
262
+ "[BeforeAgent] session_id=%s, prompt=%s",
263
+ session_id,
264
+ prompt_preview,
265
+ extra={**base_context, "prompt_preview": prompt_preview},
236
266
  )
237
267
  elif hook_type == "BeforeTool":
238
268
  tool_name = input_data.get("tool_name") or input_data.get("function_name", "unknown")
239
269
  logger.info(
240
- f"[BeforeTool] tool_name={tool_name}, session_id={input_data.get('session_id')}"
270
+ "[BeforeTool] tool_name=%s, session_id=%s",
271
+ tool_name,
272
+ session_id,
273
+ extra={**base_context, "tool_name": tool_name},
241
274
  )
242
275
  elif hook_type == "AfterTool":
243
276
  tool_name = input_data.get("tool_name") or input_data.get("function_name", "unknown")
277
+ error = input_data.get("error")
244
278
  logger.info(
245
- f"[AfterTool] tool_name={tool_name}, session_id={input_data.get('session_id')}"
279
+ "[AfterTool] tool_name=%s, session_id=%s",
280
+ tool_name,
281
+ session_id,
282
+ extra={**base_context, "tool_name": tool_name, "error": error},
246
283
  )
247
284
  elif hook_type == "BeforeToolSelection":
248
- logger.info(f"[BeforeToolSelection] session_id={input_data.get('session_id')}")
285
+ logger.info(
286
+ "[BeforeToolSelection] session_id=%s",
287
+ session_id,
288
+ extra=base_context,
289
+ )
249
290
  elif hook_type == "BeforeModel":
291
+ model = input_data.get("model", "unknown")
250
292
  logger.info(
251
- f"[BeforeModel] session_id={input_data.get('session_id')}, "
252
- f"model={input_data.get('model', 'unknown')}"
293
+ "[BeforeModel] session_id=%s, model=%s",
294
+ session_id,
295
+ model,
296
+ extra={**base_context, "model": model},
253
297
  )
254
298
  elif hook_type == "AfterModel":
255
- logger.info(f"[AfterModel] session_id={input_data.get('session_id')}")
299
+ logger.info(
300
+ "[AfterModel] session_id=%s",
301
+ session_id,
302
+ extra=base_context,
303
+ )
256
304
  elif hook_type == "PreCompress":
257
- logger.info(f"[PreCompress] session_id={input_data.get('session_id')}")
305
+ logger.info(
306
+ "[PreCompress] session_id=%s",
307
+ session_id,
308
+ extra=base_context,
309
+ )
258
310
  elif hook_type == "Notification":
311
+ message = input_data.get("message")
259
312
  logger.info(
260
- f"[Notification] session_id={input_data.get('session_id')}, "
261
- f"message={input_data.get('message')}"
313
+ "[Notification] session_id=%s, message=%s",
314
+ session_id,
315
+ message,
316
+ extra={**base_context, "notification_message": message},
262
317
  )
263
318
  elif hook_type == "AfterAgent":
264
- logger.info(f"[AfterAgent] session_id={input_data.get('session_id')}")
319
+ logger.info(
320
+ "[AfterAgent] session_id=%s",
321
+ session_id,
322
+ extra=base_context,
323
+ )
265
324
 
266
325
  if debug_mode:
267
326
  logger.debug(f"Input data: {input_data}")
@@ -26,9 +26,9 @@ def build_gobby_instructions() -> str:
26
26
  At the start of EVERY session:
27
27
  1. `list_mcp_servers()` — Discover available servers
28
28
  2. `list_skills()` — Discover available skills
29
- 3. Session ID: Look for `session_id: <uuid>` in your context.
29
+ 3. Session ID: Look for `Gobby Session Ref:` or `Gobby Session ID:` in your context.
30
30
  If missing, call:
31
- `call_tool("gobby-sessions", "get_current", {"external_id": "<your-session-id>", "source": "claude"})`
31
+ `call_tool("gobby-sessions", "get_current_session", {"external_id": "<your-session-id>", "source": "<cli-name>"})`
32
32
 
33
33
  Session and task references use `#N` format (e.g., `#1`, `#42`) which is project-scoped.
34
34
  </startup>
@@ -168,21 +168,38 @@ def setup_internal_registries(
168
168
 
169
169
  # Initialize agents registry if agent_runner is available
170
170
  if agent_runner is not None:
171
- from gobby.agents.registry import get_running_agent_registry
171
+ from gobby.agents.definitions import AgentDefinitionLoader
172
172
  from gobby.mcp_proxy.tools.agents import create_agents_registry
173
173
 
174
+ # Create clone git manager if we have a git manager
175
+ clone_git_manager = None
176
+ if git_manager is not None:
177
+ try:
178
+ from gobby.clones.git import CloneGitManager
179
+
180
+ clone_git_manager = CloneGitManager(git_manager.repo_path)
181
+ except Exception as e:
182
+ logger.debug(f"CloneGitManager not available for spawn_agent: {e}")
183
+
174
184
  agents_registry = create_agents_registry(
175
185
  runner=agent_runner,
186
+ agent_loader=AgentDefinitionLoader(),
187
+ session_manager=local_session_manager,
188
+ task_manager=task_manager,
189
+ worktree_storage=worktree_storage,
190
+ git_manager=git_manager,
191
+ clone_storage=clone_storage,
192
+ clone_manager=clone_git_manager,
176
193
  )
177
194
 
178
- # Add inter-agent messaging tools if message manager is available
179
- if inter_session_message_manager is not None:
195
+ # Add inter-agent messaging tools if message manager and session manager are available
196
+ if inter_session_message_manager is not None and local_session_manager is not None:
180
197
  from gobby.mcp_proxy.tools.agent_messaging import add_messaging_tools
181
198
 
182
199
  add_messaging_tools(
183
200
  registry=agents_registry,
184
201
  message_manager=inter_session_message_manager,
185
- agent_registry=get_running_agent_registry(),
202
+ session_manager=local_session_manager,
186
203
  )
187
204
  logger.debug("Agent messaging tools added to agents registry")
188
205