gobby 0.2.8__py3-none-any.whl → 0.2.9__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 (63) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/claude_code.py +3 -26
  3. gobby/app_context.py +59 -0
  4. gobby/cli/utils.py +5 -17
  5. gobby/config/features.py +0 -20
  6. gobby/config/tasks.py +4 -0
  7. gobby/hooks/event_handlers/__init__.py +155 -0
  8. gobby/hooks/event_handlers/_agent.py +175 -0
  9. gobby/hooks/event_handlers/_base.py +87 -0
  10. gobby/hooks/event_handlers/_misc.py +66 -0
  11. gobby/hooks/event_handlers/_session.py +573 -0
  12. gobby/hooks/event_handlers/_tool.py +196 -0
  13. gobby/hooks/hook_manager.py +2 -0
  14. gobby/llm/claude.py +377 -42
  15. gobby/mcp_proxy/importer.py +4 -41
  16. gobby/mcp_proxy/manager.py +13 -3
  17. gobby/mcp_proxy/registries.py +14 -0
  18. gobby/mcp_proxy/services/recommendation.py +2 -28
  19. gobby/mcp_proxy/tools/artifacts.py +3 -3
  20. gobby/mcp_proxy/tools/task_readiness.py +27 -4
  21. gobby/mcp_proxy/tools/workflows/__init__.py +266 -0
  22. gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
  23. gobby/mcp_proxy/tools/workflows/_import.py +112 -0
  24. gobby/mcp_proxy/tools/workflows/_lifecycle.py +321 -0
  25. gobby/mcp_proxy/tools/workflows/_query.py +207 -0
  26. gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
  27. gobby/mcp_proxy/tools/workflows/_terminal.py +139 -0
  28. gobby/memory/components/__init__.py +0 -0
  29. gobby/memory/components/ingestion.py +98 -0
  30. gobby/memory/components/search.py +108 -0
  31. gobby/memory/manager.py +16 -25
  32. gobby/paths.py +51 -0
  33. gobby/prompts/loader.py +1 -35
  34. gobby/runner.py +23 -10
  35. gobby/servers/http.py +186 -149
  36. gobby/servers/routes/admin.py +12 -0
  37. gobby/servers/routes/mcp/endpoints/execution.py +15 -7
  38. gobby/servers/routes/mcp/endpoints/registry.py +8 -8
  39. gobby/sessions/analyzer.py +2 -2
  40. gobby/skills/parser.py +23 -0
  41. gobby/skills/sync.py +5 -4
  42. gobby/storage/artifacts.py +19 -0
  43. gobby/storage/migrations.py +25 -2
  44. gobby/storage/skills.py +47 -7
  45. gobby/tasks/external_validator.py +4 -17
  46. gobby/tasks/validation.py +13 -87
  47. gobby/tools/summarizer.py +18 -51
  48. gobby/utils/status.py +13 -0
  49. gobby/workflows/actions.py +5 -0
  50. gobby/workflows/context_actions.py +21 -24
  51. gobby/workflows/enforcement/__init__.py +11 -1
  52. gobby/workflows/enforcement/blocking.py +96 -0
  53. gobby/workflows/enforcement/handlers.py +35 -1
  54. gobby/workflows/engine.py +6 -3
  55. gobby/workflows/lifecycle_evaluator.py +2 -1
  56. {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/METADATA +1 -1
  57. {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/RECORD +61 -45
  58. gobby/hooks/event_handlers.py +0 -1008
  59. gobby/mcp_proxy/tools/workflows.py +0 -1023
  60. {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/WHEEL +0 -0
  61. {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/entry_points.txt +0 -0
  62. {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/licenses/LICENSE.md +0 -0
  63. {gobby-0.2.8.dist-info → gobby-0.2.9.dist-info}/top_level.txt +0 -0
gobby/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Gobby - Local-first daemon for multi-CLI session management."""
2
2
 
3
- __version__ = "0.2.7"
3
+ __version__ = "0.2.9"
@@ -61,13 +61,10 @@ class ClaudeCodeAdapter(BaseAdapter):
61
61
  """Initialize the Claude Code adapter.
62
62
 
63
63
  Args:
64
- hook_manager: Reference to HookManager for strangler fig delegation.
64
+ hook_manager: Reference to HookManager for delegation.
65
65
  If None, the adapter can only translate (not handle events).
66
66
  """
67
67
  self._hook_manager = hook_manager
68
- # Phase 2C: Use new handle() path with unified HookEvent model
69
- # Note: systemMessage handoff notification bug exists in both paths (see plan-multi-cli.md)
70
- self._use_legacy = False
71
68
 
72
69
  def translate_to_hook_event(self, native_event: dict[str, Any]) -> HookEvent:
73
70
  """Convert Claude Code native event to unified HookEvent.
@@ -310,14 +307,6 @@ class ClaudeCodeAdapter(BaseAdapter):
310
307
  ) -> dict[str, Any]:
311
308
  """Main entry point for HTTP endpoint.
312
309
 
313
- Strangler fig pattern:
314
- - Phase 2A-2B: Delegates to existing execute() — validates translation only
315
- - Phase 2C+: Calls new handle() with HookEvent
316
-
317
- Note: This method is synchronous for Phase 2A-2B compatibility with
318
- the existing execute() method. In Phase 2C+, it will become async
319
- when handle() is implemented as async.
320
-
321
310
  Args:
322
311
  native_event: Raw payload from Claude Code's hook_dispatcher.py
323
312
  hook_manager: HookManager instance for processing.
@@ -325,22 +314,10 @@ class ClaudeCodeAdapter(BaseAdapter):
325
314
  Returns:
326
315
  Response dict in Claude Code's expected format.
327
316
  """
328
- # Always translate (validates our mapping is correct)
317
+ # Translate to HookEvent
329
318
  hook_event = self.translate_to_hook_event(native_event)
330
319
 
331
- # Phase 2C+: Use new HookEvent-based handler
332
- # Legacy execute() path removed as HookManager.execute is deprecated/removed.
320
+ # Use HookEvent-based handler
333
321
  hook_type = native_event.get("hook_type", "")
334
322
  hook_response = hook_manager.handle(hook_event)
335
323
  return self.translate_from_hook_response(hook_response, hook_type=hook_type)
336
-
337
- def set_legacy_mode(self, use_legacy: bool) -> None:
338
- """Toggle between legacy and new code paths.
339
-
340
- This method is used during the strangler fig migration to switch
341
- between delegating to execute() and calling handle() directly.
342
-
343
- Args:
344
- use_legacy: If True, use legacy execute() path. If False, use new handle() path.
345
- """
346
- self._use_legacy = use_legacy
gobby/app_context.py ADDED
@@ -0,0 +1,59 @@
1
+ """
2
+ Service container for dependency injection in Gobby daemon.
3
+
4
+ Holds references to singleton services to avoid prop-drilling in HTTPServer
5
+ and other components.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ from gobby.config.app import DaemonConfig
12
+ from gobby.llm import LLMService
13
+ from gobby.memory.manager import MemoryManager
14
+ from gobby.storage.clones import LocalCloneManager
15
+ from gobby.storage.database import DatabaseProtocol
16
+ from gobby.storage.sessions import LocalSessionManager
17
+ from gobby.storage.tasks import LocalTaskManager
18
+ from gobby.storage.worktrees import LocalWorktreeManager
19
+ from gobby.sync.memories import MemorySyncManager
20
+ from gobby.sync.tasks import TaskSyncManager
21
+
22
+
23
+ @dataclass
24
+ class ServiceContainer:
25
+ """Container for daemon services."""
26
+
27
+ # Core Infrastructure
28
+ config: DaemonConfig
29
+ database: DatabaseProtocol
30
+
31
+ # Core Managers
32
+ session_manager: LocalSessionManager
33
+ task_manager: LocalTaskManager
34
+
35
+ # Sync Managers
36
+ task_sync_manager: TaskSyncManager | None = None
37
+ memory_sync_manager: MemorySyncManager | None = None
38
+
39
+ # Advanced Features
40
+ memory_manager: MemoryManager | None = None
41
+ llm_service: LLMService | None = None
42
+
43
+ # MCP & Agents
44
+ mcp_manager: Any | None = None # MCPClientManager
45
+ mcp_db_manager: Any | None = None # LocalMCPManager
46
+ metrics_manager: Any | None = None # ToolMetricsManager
47
+ agent_runner: Any | None = None # AgentRunner
48
+ message_processor: Any | None = None # SessionMessageProcessor
49
+ message_manager: Any | None = None # LocalSessionMessageManager
50
+
51
+ # Validation & Git
52
+ task_validator: Any | None = None # TaskValidator
53
+ worktree_storage: LocalWorktreeManager | None = None
54
+ clone_storage: LocalCloneManager | None = None
55
+ git_manager: Any | None = None # WorktreeGitManager
56
+
57
+ # Context
58
+ project_id: str | None = None
59
+ websocket_server: Any | None = None
gobby/cli/utils.py CHANGED
@@ -394,23 +394,10 @@ def get_install_dir() -> Path:
394
394
  Returns:
395
395
  Path to the install directory
396
396
  """
397
- import gobby
397
+ # Import from centralized paths module to avoid duplication
398
+ from gobby.paths import get_install_dir as _get_install_dir
398
399
 
399
- package_install_dir = Path(gobby.__file__).parent / "install"
400
-
401
- # Try to find source directory (project root)
402
- current = Path(gobby.__file__).resolve()
403
- source_install_dir = None
404
-
405
- for parent in current.parents:
406
- potential_source = parent / "src" / "gobby" / "install"
407
- if potential_source.exists():
408
- source_install_dir = potential_source
409
- break
410
-
411
- if source_install_dir and source_install_dir.exists():
412
- return source_install_dir
413
- return package_install_dir
400
+ return _get_install_dir()
414
401
 
415
402
 
416
403
  def _is_process_alive(pid: int) -> bool:
@@ -472,7 +459,8 @@ def stop_daemon(quiet: bool = False) -> bool:
472
459
  click.echo(f"Sent shutdown signal to Gobby daemon (PID {pid})")
473
460
 
474
461
  # Wait for graceful shutdown
475
- max_wait = 5
462
+ # Match daemon's uvicorn timeout_graceful_shutdown (15s) + buffer
463
+ max_wait = 20
476
464
  for _ in range(max_wait * 10):
477
465
  time.sleep(0.1)
478
466
  if not _is_process_alive(pid):
gobby/config/features.py CHANGED
@@ -23,7 +23,6 @@ __all__ = [
23
23
  "HookStageConfig",
24
24
  "HooksConfig",
25
25
  "TaskDescriptionConfig",
26
- "DEFAULT_IMPORT_MCP_SERVER_PROMPT",
27
26
  ]
28
27
 
29
28
 
@@ -139,25 +138,6 @@ class RecommendToolsConfig(BaseModel):
139
138
  )
140
139
 
141
140
 
142
- DEFAULT_IMPORT_MCP_SERVER_PROMPT = """You are an MCP server configuration extractor. Given documentation for an MCP server, extract the configuration needed to connect to it.
143
-
144
- Return ONLY a valid JSON object (no markdown, no code blocks) with these fields:
145
- - name: Server name (lowercase, no spaces, use hyphens)
146
- - transport: "http", "stdio", or "websocket"
147
- - url: Server URL (required for http/websocket transports)
148
- - command: Command to run (required for stdio, e.g., "npx", "uv", "node")
149
- - args: Array of command arguments (for stdio)
150
- - env: Object of environment variables needed (use placeholder "<YOUR_KEY_NAME>" for secrets)
151
- - headers: Object of HTTP headers needed (use placeholder "<YOUR_KEY_NAME>" for secrets)
152
- - instructions: How to obtain any required API keys or setup steps
153
-
154
- Example stdio server:
155
- {"name": "filesystem", "transport": "stdio", "command": "npx", "args": ["-y", "@anthropic-ai/filesystem-mcp"], "env": {}, "instructions": "No setup required"}
156
-
157
- Example http server with API key:
158
- {"name": "exa", "transport": "http", "url": "https://mcp.exa.ai/mcp", "headers": {"EXA_API_KEY": "<YOUR_EXA_API_KEY>"}, "instructions": "Get your API key from https://exa.ai/dashboard"}"""
159
-
160
-
161
141
  class ImportMCPServerConfig(BaseModel):
162
142
  """MCP server import configuration."""
163
143
 
gobby/config/tasks.py CHANGED
@@ -669,6 +669,10 @@ class WorkflowConfig(BaseModel):
669
669
  default_factory=lambda: ["Edit", "Write", "Update", "NotebookEdit"],
670
670
  description="Tools that require an active task when require_task_before_edit is enabled",
671
671
  )
672
+ debug_echo_context: bool = Field(
673
+ default=False,
674
+ description="Debug: echo additionalContext to system_message for terminal visibility",
675
+ )
672
676
 
673
677
  @field_validator("timeout")
674
678
  @classmethod
@@ -0,0 +1,155 @@
1
+ """
2
+ Event handlers module for hook event processing.
3
+
4
+ This module is extracted from hook_manager.py using Strangler Fig pattern.
5
+ It provides centralized event handler registration and dispatch.
6
+
7
+ Classes:
8
+ EventHandlers: Manages event handler registration and dispatch.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from collections.abc import Callable
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from gobby.hooks.event_handlers._agent import AgentEventHandlerMixin
18
+ from gobby.hooks.event_handlers._misc import MiscEventHandlerMixin
19
+ from gobby.hooks.event_handlers._session import SessionEventHandlerMixin
20
+ from gobby.hooks.event_handlers._tool import EDIT_TOOLS, ToolEventHandlerMixin
21
+ from gobby.hooks.events import HookEvent, HookEventType, HookResponse
22
+
23
+ if TYPE_CHECKING:
24
+ from gobby.config.skills import SkillsConfig
25
+ from gobby.config.tasks import WorkflowConfig
26
+ from gobby.hooks.artifact_capture import ArtifactCaptureHook
27
+ from gobby.hooks.session_coordinator import SessionCoordinator
28
+ from gobby.hooks.skill_manager import HookSkillManager
29
+ from gobby.sessions.manager import SessionManager
30
+ from gobby.sessions.summary import SummaryFileGenerator
31
+ from gobby.storage.session_messages import LocalSessionMessageManager
32
+ from gobby.storage.session_tasks import SessionTaskManager
33
+ from gobby.storage.sessions import LocalSessionManager
34
+ from gobby.storage.tasks import LocalTaskManager
35
+ from gobby.workflows.hooks import WorkflowHookHandler
36
+
37
+
38
+ class EventHandlers(
39
+ SessionEventHandlerMixin,
40
+ AgentEventHandlerMixin,
41
+ ToolEventHandlerMixin,
42
+ MiscEventHandlerMixin,
43
+ ):
44
+ """
45
+ Manages event handler registration and dispatch.
46
+
47
+ Provides handler methods for all HookEventType values and a registration
48
+ mechanism for looking up handlers by event type.
49
+
50
+ Extracted from HookManager to separate event handling concerns.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ session_manager: SessionManager | None = None,
56
+ workflow_handler: WorkflowHookHandler | None = None,
57
+ session_storage: LocalSessionManager | None = None,
58
+ session_task_manager: SessionTaskManager | None = None,
59
+ message_processor: Any | None = None,
60
+ summary_file_generator: SummaryFileGenerator | None = None,
61
+ task_manager: LocalTaskManager | None = None,
62
+ session_coordinator: SessionCoordinator | None = None,
63
+ message_manager: LocalSessionMessageManager | None = None,
64
+ skill_manager: HookSkillManager | None = None,
65
+ skills_config: SkillsConfig | None = None,
66
+ artifact_capture_hook: ArtifactCaptureHook | None = None,
67
+ workflow_config: WorkflowConfig | None = None,
68
+ get_machine_id: Callable[[], str] | None = None,
69
+ resolve_project_id: Callable[[str | None, str | None], str] | None = None,
70
+ logger: logging.Logger | None = None,
71
+ ) -> None:
72
+ """
73
+ Initialize EventHandlers.
74
+
75
+ Args:
76
+ session_manager: SessionManager for session operations
77
+ workflow_handler: WorkflowHookHandler for lifecycle workflows
78
+ session_storage: LocalSessionManager for session storage
79
+ session_task_manager: SessionTaskManager for session-task links
80
+ message_processor: SessionMessageProcessor for message handling
81
+ summary_file_generator: SummaryFileGenerator for summaries
82
+ task_manager: LocalTaskManager for task operations
83
+ session_coordinator: SessionCoordinator for session tracking
84
+ message_manager: LocalSessionMessageManager for messages
85
+ skill_manager: HookSkillManager for skill discovery
86
+ skills_config: SkillsConfig for skill injection settings
87
+ artifact_capture_hook: ArtifactCaptureHook for capturing artifacts
88
+ workflow_config: WorkflowConfig for workflow settings (debug_echo_context)
89
+ get_machine_id: Function to get machine ID
90
+ resolve_project_id: Function to resolve project ID from cwd
91
+ logger: Optional logger instance
92
+ """
93
+ self._session_manager = session_manager
94
+ self._workflow_handler = workflow_handler
95
+ self._session_storage = session_storage
96
+ self._session_task_manager = session_task_manager
97
+ self._message_processor = message_processor
98
+ self._summary_file_generator = summary_file_generator
99
+ self._task_manager = task_manager
100
+ self._session_coordinator = session_coordinator
101
+ self._message_manager = message_manager
102
+ self._skill_manager = skill_manager
103
+ self._skills_config = skills_config
104
+ self._artifact_capture_hook = artifact_capture_hook
105
+ self._workflow_config = workflow_config
106
+ self._get_machine_id = get_machine_id or (lambda: "unknown-machine")
107
+ self._resolve_project_id = resolve_project_id or (lambda p, c: p or "")
108
+ self.logger = logger or logging.getLogger(__name__)
109
+
110
+ # Build handler map
111
+ self._handler_map: dict[HookEventType, Callable[[HookEvent], HookResponse]] = {
112
+ HookEventType.SESSION_START: self.handle_session_start,
113
+ HookEventType.SESSION_END: self.handle_session_end,
114
+ HookEventType.BEFORE_AGENT: self.handle_before_agent,
115
+ HookEventType.AFTER_AGENT: self.handle_after_agent,
116
+ HookEventType.BEFORE_TOOL: self.handle_before_tool,
117
+ HookEventType.AFTER_TOOL: self.handle_after_tool,
118
+ HookEventType.PRE_COMPACT: self.handle_pre_compact,
119
+ HookEventType.SUBAGENT_START: self.handle_subagent_start,
120
+ HookEventType.SUBAGENT_STOP: self.handle_subagent_stop,
121
+ HookEventType.NOTIFICATION: self.handle_notification,
122
+ HookEventType.BEFORE_TOOL_SELECTION: self.handle_before_tool_selection,
123
+ HookEventType.BEFORE_MODEL: self.handle_before_model,
124
+ HookEventType.AFTER_MODEL: self.handle_after_model,
125
+ HookEventType.PERMISSION_REQUEST: self.handle_permission_request,
126
+ HookEventType.STOP: self.handle_stop,
127
+ }
128
+
129
+ def get_handler(
130
+ self, event_type: HookEventType | str
131
+ ) -> Callable[[HookEvent], HookResponse] | None:
132
+ """
133
+ Get handler for an event type.
134
+
135
+ Args:
136
+ event_type: The event type to get handler for
137
+
138
+ Returns:
139
+ Handler callable or None if not found
140
+ """
141
+ if isinstance(event_type, str):
142
+ try:
143
+ event_type = HookEventType(event_type)
144
+ except ValueError:
145
+ return None
146
+ return self._handler_map.get(event_type)
147
+
148
+ def get_handler_map(self) -> dict[HookEventType, Callable[[HookEvent], HookResponse]]:
149
+ """
150
+ Get a copy of the handler map.
151
+
152
+ Returns:
153
+ Copy of handler map (modifications don't affect internal state)
154
+ """
155
+ return dict(self._handler_map)
@@ -0,0 +1,175 @@
1
+ from __future__ import annotations
2
+
3
+ from gobby.hooks.event_handlers._base import EventHandlersBase
4
+ from gobby.hooks.events import HookEvent, HookResponse, SessionSource
5
+
6
+
7
+ class AgentEventHandlerMixin(EventHandlersBase):
8
+ """Mixin for handling agent-related events."""
9
+
10
+ def handle_before_agent(self, event: HookEvent) -> HookResponse:
11
+ """Handle BEFORE_AGENT event (user prompt submit)."""
12
+ input_data = event.data
13
+ prompt = input_data.get("prompt", "")
14
+ transcript_path = input_data.get("transcript_path")
15
+ session_id = event.metadata.get("_platform_session_id")
16
+
17
+ context_parts = []
18
+
19
+ if session_id:
20
+ self.logger.debug(f"BEFORE_AGENT: session {session_id}, prompt_len={len(prompt)}")
21
+
22
+ # Update status to active (unless /clear or /exit)
23
+ prompt_lower = prompt.strip().lower()
24
+ if prompt_lower not in ("/clear", "/exit") and self._session_manager:
25
+ try:
26
+ self._session_manager.update_session_status(session_id, "active")
27
+ if self._session_storage:
28
+ self._session_storage.reset_transcript_processed(session_id)
29
+ except Exception as e:
30
+ self.logger.warning(f"Failed to update session status: {e}")
31
+
32
+ # Handle /clear command - lifecycle workflows handle handoff
33
+ if prompt_lower in ("/clear", "/exit") and transcript_path:
34
+ self.logger.debug(f"Detected {prompt_lower} - lifecycle workflows handle handoff")
35
+
36
+ # Execute lifecycle workflow triggers
37
+ if self._workflow_handler:
38
+ try:
39
+ wf_response = self._workflow_handler.handle_all_lifecycles(event)
40
+ if wf_response.context:
41
+ context_parts.append(wf_response.context)
42
+ if wf_response.decision != "allow":
43
+ return wf_response
44
+ except Exception as e:
45
+ self.logger.error(f"Failed to execute lifecycle workflows: {e}", exc_info=True)
46
+
47
+ return HookResponse(
48
+ decision="allow",
49
+ context="\n\n".join(context_parts) if context_parts else None,
50
+ )
51
+
52
+ def handle_after_agent(self, event: HookEvent) -> HookResponse:
53
+ """Handle AFTER_AGENT event."""
54
+ session_id = event.metadata.get("_platform_session_id")
55
+ cli_source = event.source.value
56
+
57
+ context_parts = []
58
+
59
+ if session_id:
60
+ self.logger.debug(f"AFTER_AGENT: session {session_id}, cli={cli_source}")
61
+ if self._session_manager:
62
+ try:
63
+ self._session_manager.update_session_status(session_id, "paused")
64
+ except Exception as e:
65
+ self.logger.warning(f"Failed to update session status: {e}")
66
+ else:
67
+ self.logger.debug(f"AFTER_AGENT: cli={cli_source}")
68
+
69
+ # Execute lifecycle workflow triggers
70
+ if self._workflow_handler:
71
+ try:
72
+ wf_response = self._workflow_handler.handle_all_lifecycles(event)
73
+ if wf_response.context:
74
+ context_parts.append(wf_response.context)
75
+ if wf_response.decision != "allow":
76
+ return wf_response
77
+ except Exception as e:
78
+ self.logger.error(f"Failed to execute lifecycle workflows: {e}", exc_info=True)
79
+
80
+ return HookResponse(
81
+ decision="allow",
82
+ context="\n\n".join(context_parts) if context_parts else None,
83
+ )
84
+
85
+ def handle_stop(self, event: HookEvent) -> HookResponse:
86
+ """Handle STOP event (Claude Code only)."""
87
+ session_id = event.metadata.get("_platform_session_id")
88
+
89
+ context_parts = []
90
+
91
+ if session_id:
92
+ self.logger.debug(f"STOP: session {session_id}")
93
+ if self._session_manager:
94
+ try:
95
+ self._session_manager.update_session_status(session_id, "paused")
96
+ except Exception as e:
97
+ self.logger.warning(f"Failed to update session status: {e}")
98
+ else:
99
+ self.logger.debug("STOP")
100
+
101
+ # Execute lifecycle workflow triggers
102
+ if self._workflow_handler:
103
+ try:
104
+ wf_response = self._workflow_handler.handle_all_lifecycles(event)
105
+ if wf_response.context:
106
+ context_parts.append(wf_response.context)
107
+ if wf_response.decision != "allow":
108
+ return wf_response
109
+ except Exception as e:
110
+ self.logger.error(f"Failed to execute lifecycle workflows: {e}", exc_info=True)
111
+
112
+ return HookResponse(
113
+ decision="allow",
114
+ context="\n\n".join(context_parts) if context_parts else None,
115
+ )
116
+
117
+ def handle_pre_compact(self, event: HookEvent) -> HookResponse:
118
+ """Handle PRE_COMPACT event.
119
+
120
+ Note: Gemini fires PreCompress constantly during normal operation,
121
+ unlike Claude which fires it only when approaching context limits.
122
+ We skip handoff logic and workflow execution for Gemini to avoid
123
+ excessive state changes and workflow interruptions.
124
+ """
125
+ trigger = event.data.get("trigger", "auto")
126
+ session_id = event.metadata.get("_platform_session_id")
127
+
128
+ # Skip handoff logic for Gemini - it fires PreCompress too frequently
129
+ if event.source == SessionSource.GEMINI:
130
+ self.logger.debug(f"PRE_COMPACT ({trigger}): session {session_id} [Gemini - skipped]")
131
+ return HookResponse(decision="allow")
132
+
133
+ if session_id:
134
+ self.logger.debug(f"PRE_COMPACT ({trigger}): session {session_id}")
135
+ # Mark session as handoff_ready so it can be found as parent after compact
136
+ if self._session_manager:
137
+ self._session_manager.update_session_status(session_id, "handoff_ready")
138
+ else:
139
+ self.logger.debug(f"PRE_COMPACT ({trigger})")
140
+
141
+ # Execute lifecycle workflows
142
+ if self._workflow_handler:
143
+ try:
144
+ return self._workflow_handler.handle_all_lifecycles(event)
145
+ except Exception as e:
146
+ self.logger.error(f"Failed to execute lifecycle workflows: {e}", exc_info=True)
147
+
148
+ return HookResponse(decision="allow")
149
+
150
+ def handle_subagent_start(self, event: HookEvent) -> HookResponse:
151
+ """Handle SUBAGENT_START event."""
152
+ input_data = event.data
153
+ session_id = event.metadata.get("_platform_session_id")
154
+ agent_id = input_data.get("agent_id")
155
+ subagent_id = input_data.get("subagent_id")
156
+
157
+ log_msg = f"SUBAGENT_START: session {session_id}" if session_id else "SUBAGENT_START"
158
+ if agent_id:
159
+ log_msg += f", agent_id={agent_id}"
160
+ if subagent_id:
161
+ log_msg += f", subagent_id={subagent_id}"
162
+ self.logger.debug(log_msg)
163
+
164
+ return HookResponse(decision="allow")
165
+
166
+ def handle_subagent_stop(self, event: HookEvent) -> HookResponse:
167
+ """Handle SUBAGENT_STOP event."""
168
+ session_id = event.metadata.get("_platform_session_id")
169
+
170
+ if session_id:
171
+ self.logger.debug(f"SUBAGENT_STOP: session {session_id}")
172
+ else:
173
+ self.logger.debug("SUBAGENT_STOP")
174
+
175
+ return HookResponse(decision="allow")
@@ -0,0 +1,87 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from collections.abc import Callable
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from gobby.hooks.events import HookEvent, HookEventType, HookResponse
8
+
9
+ if TYPE_CHECKING:
10
+ from gobby.config.skills import SkillsConfig
11
+ from gobby.config.tasks import WorkflowConfig
12
+ from gobby.hooks.artifact_capture import ArtifactCaptureHook
13
+ from gobby.hooks.session_coordinator import SessionCoordinator
14
+ from gobby.hooks.skill_manager import HookSkillManager
15
+ from gobby.sessions.manager import SessionManager
16
+ from gobby.sessions.summary import SummaryFileGenerator
17
+ from gobby.storage.session_messages import LocalSessionMessageManager
18
+ from gobby.storage.session_tasks import SessionTaskManager
19
+ from gobby.storage.sessions import LocalSessionManager
20
+ from gobby.storage.tasks import LocalTaskManager
21
+ from gobby.workflows.hooks import WorkflowHookHandler
22
+
23
+
24
+ class EventHandlersBase:
25
+ """Base class for EventHandlers mixins with type hints for shared state."""
26
+
27
+ _session_manager: SessionManager | None
28
+ _workflow_handler: WorkflowHookHandler | None
29
+ _workflow_config: WorkflowConfig | None
30
+ _session_storage: LocalSessionManager | None
31
+ _session_task_manager: SessionTaskManager | None
32
+ _message_processor: Any | None
33
+ _summary_file_generator: SummaryFileGenerator | None
34
+ _task_manager: LocalTaskManager | None
35
+ _session_coordinator: SessionCoordinator | None
36
+ _message_manager: LocalSessionMessageManager | None
37
+ _skill_manager: HookSkillManager | None
38
+ _skills_config: SkillsConfig | None
39
+ _artifact_capture_hook: ArtifactCaptureHook | None
40
+ _get_machine_id: Callable[[], str]
41
+ _resolve_project_id: Callable[[str | None, str | None], str]
42
+ logger: logging.Logger
43
+ _handler_map: dict[HookEventType, Callable[[HookEvent], HookResponse]]
44
+
45
+ def _auto_activate_workflow(
46
+ self, workflow_name: str, session_id: str, project_path: str | None
47
+ ) -> None:
48
+ """Shared method for auto-activating workflows."""
49
+ if not self._workflow_handler:
50
+ return
51
+
52
+ try:
53
+ result = self._workflow_handler.activate_workflow(
54
+ workflow_name=workflow_name,
55
+ session_id=session_id,
56
+ project_path=project_path,
57
+ )
58
+ if result.get("success"):
59
+ self.logger.info(
60
+ "Auto-activated workflow for session",
61
+ extra={
62
+ "workflow_name": workflow_name,
63
+ "session_id": session_id,
64
+ "project_path": project_path,
65
+ },
66
+ )
67
+ else:
68
+ self.logger.warning(
69
+ "Failed to auto-activate workflow",
70
+ extra={
71
+ "workflow_name": workflow_name,
72
+ "session_id": session_id,
73
+ "project_path": project_path,
74
+ "error": result.get("error"),
75
+ },
76
+ )
77
+ except Exception as e:
78
+ self.logger.warning(
79
+ "Failed to auto-activate workflow",
80
+ extra={
81
+ "workflow_name": workflow_name,
82
+ "session_id": session_id,
83
+ "project_path": project_path,
84
+ "error": str(e),
85
+ },
86
+ exc_info=True,
87
+ )