gobby 0.2.8__py3-none-any.whl → 0.2.11__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 (168) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +6 -0
  3. gobby/adapters/base.py +11 -2
  4. gobby/adapters/claude_code.py +5 -28
  5. gobby/adapters/codex_impl/adapter.py +38 -43
  6. gobby/adapters/copilot.py +324 -0
  7. gobby/adapters/cursor.py +373 -0
  8. gobby/adapters/gemini.py +2 -26
  9. gobby/adapters/windsurf.py +359 -0
  10. gobby/agents/definitions.py +162 -2
  11. gobby/agents/isolation.py +33 -1
  12. gobby/agents/pty_reader.py +192 -0
  13. gobby/agents/registry.py +10 -1
  14. gobby/agents/runner.py +24 -8
  15. gobby/agents/sandbox.py +8 -3
  16. gobby/agents/session.py +4 -0
  17. gobby/agents/spawn.py +9 -2
  18. gobby/agents/spawn_executor.py +49 -61
  19. gobby/agents/spawners/command_builder.py +4 -4
  20. gobby/app_context.py +64 -0
  21. gobby/cli/__init__.py +4 -0
  22. gobby/cli/install.py +259 -4
  23. gobby/cli/installers/__init__.py +12 -0
  24. gobby/cli/installers/copilot.py +242 -0
  25. gobby/cli/installers/cursor.py +244 -0
  26. gobby/cli/installers/shared.py +3 -0
  27. gobby/cli/installers/windsurf.py +242 -0
  28. gobby/cli/pipelines.py +639 -0
  29. gobby/cli/sessions.py +3 -1
  30. gobby/cli/skills.py +209 -0
  31. gobby/cli/tasks/crud.py +6 -5
  32. gobby/cli/tasks/search.py +1 -1
  33. gobby/cli/ui.py +116 -0
  34. gobby/cli/utils.py +5 -17
  35. gobby/cli/workflows.py +38 -17
  36. gobby/config/app.py +5 -0
  37. gobby/config/features.py +0 -20
  38. gobby/config/skills.py +23 -2
  39. gobby/config/tasks.py +4 -0
  40. gobby/hooks/broadcaster.py +9 -0
  41. gobby/hooks/event_handlers/__init__.py +155 -0
  42. gobby/hooks/event_handlers/_agent.py +175 -0
  43. gobby/hooks/event_handlers/_base.py +92 -0
  44. gobby/hooks/event_handlers/_misc.py +66 -0
  45. gobby/hooks/event_handlers/_session.py +487 -0
  46. gobby/hooks/event_handlers/_tool.py +196 -0
  47. gobby/hooks/events.py +48 -0
  48. gobby/hooks/hook_manager.py +27 -3
  49. gobby/install/copilot/hooks/hook_dispatcher.py +203 -0
  50. gobby/install/cursor/hooks/hook_dispatcher.py +203 -0
  51. gobby/install/gemini/hooks/hook_dispatcher.py +8 -0
  52. gobby/install/windsurf/hooks/hook_dispatcher.py +205 -0
  53. gobby/llm/__init__.py +14 -1
  54. gobby/llm/claude.py +594 -43
  55. gobby/llm/service.py +149 -0
  56. gobby/mcp_proxy/importer.py +4 -41
  57. gobby/mcp_proxy/instructions.py +9 -27
  58. gobby/mcp_proxy/manager.py +13 -3
  59. gobby/mcp_proxy/models.py +1 -0
  60. gobby/mcp_proxy/registries.py +66 -5
  61. gobby/mcp_proxy/server.py +6 -2
  62. gobby/mcp_proxy/services/recommendation.py +2 -28
  63. gobby/mcp_proxy/services/tool_filter.py +7 -0
  64. gobby/mcp_proxy/services/tool_proxy.py +19 -1
  65. gobby/mcp_proxy/stdio.py +37 -21
  66. gobby/mcp_proxy/tools/agents.py +7 -0
  67. gobby/mcp_proxy/tools/artifacts.py +3 -3
  68. gobby/mcp_proxy/tools/hub.py +30 -1
  69. gobby/mcp_proxy/tools/orchestration/cleanup.py +5 -5
  70. gobby/mcp_proxy/tools/orchestration/monitor.py +1 -1
  71. gobby/mcp_proxy/tools/orchestration/orchestrate.py +8 -3
  72. gobby/mcp_proxy/tools/orchestration/review.py +17 -4
  73. gobby/mcp_proxy/tools/orchestration/wait.py +7 -7
  74. gobby/mcp_proxy/tools/pipelines/__init__.py +254 -0
  75. gobby/mcp_proxy/tools/pipelines/_discovery.py +67 -0
  76. gobby/mcp_proxy/tools/pipelines/_execution.py +281 -0
  77. gobby/mcp_proxy/tools/sessions/_crud.py +4 -4
  78. gobby/mcp_proxy/tools/sessions/_handoff.py +1 -1
  79. gobby/mcp_proxy/tools/skills/__init__.py +184 -30
  80. gobby/mcp_proxy/tools/spawn_agent.py +229 -14
  81. gobby/mcp_proxy/tools/task_readiness.py +27 -4
  82. gobby/mcp_proxy/tools/tasks/_context.py +8 -0
  83. gobby/mcp_proxy/tools/tasks/_crud.py +27 -1
  84. gobby/mcp_proxy/tools/tasks/_helpers.py +1 -1
  85. gobby/mcp_proxy/tools/tasks/_lifecycle.py +125 -8
  86. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +2 -1
  87. gobby/mcp_proxy/tools/tasks/_search.py +1 -1
  88. gobby/mcp_proxy/tools/workflows/__init__.py +273 -0
  89. gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
  90. gobby/mcp_proxy/tools/workflows/_import.py +112 -0
  91. gobby/mcp_proxy/tools/workflows/_lifecycle.py +332 -0
  92. gobby/mcp_proxy/tools/workflows/_query.py +226 -0
  93. gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
  94. gobby/mcp_proxy/tools/workflows/_terminal.py +175 -0
  95. gobby/mcp_proxy/tools/worktrees.py +54 -15
  96. gobby/memory/components/__init__.py +0 -0
  97. gobby/memory/components/ingestion.py +98 -0
  98. gobby/memory/components/search.py +108 -0
  99. gobby/memory/context.py +5 -5
  100. gobby/memory/manager.py +16 -25
  101. gobby/paths.py +51 -0
  102. gobby/prompts/loader.py +1 -35
  103. gobby/runner.py +131 -16
  104. gobby/servers/http.py +193 -150
  105. gobby/servers/routes/__init__.py +2 -0
  106. gobby/servers/routes/admin.py +56 -0
  107. gobby/servers/routes/mcp/endpoints/execution.py +33 -32
  108. gobby/servers/routes/mcp/endpoints/registry.py +8 -8
  109. gobby/servers/routes/mcp/hooks.py +10 -1
  110. gobby/servers/routes/pipelines.py +227 -0
  111. gobby/servers/websocket.py +314 -1
  112. gobby/sessions/analyzer.py +89 -3
  113. gobby/sessions/manager.py +5 -5
  114. gobby/sessions/transcripts/__init__.py +3 -0
  115. gobby/sessions/transcripts/claude.py +5 -0
  116. gobby/sessions/transcripts/codex.py +5 -0
  117. gobby/sessions/transcripts/gemini.py +5 -0
  118. gobby/skills/hubs/__init__.py +25 -0
  119. gobby/skills/hubs/base.py +234 -0
  120. gobby/skills/hubs/claude_plugins.py +328 -0
  121. gobby/skills/hubs/clawdhub.py +289 -0
  122. gobby/skills/hubs/github_collection.py +465 -0
  123. gobby/skills/hubs/manager.py +263 -0
  124. gobby/skills/hubs/skillhub.py +342 -0
  125. gobby/skills/parser.py +23 -0
  126. gobby/skills/sync.py +5 -4
  127. gobby/storage/artifacts.py +19 -0
  128. gobby/storage/memories.py +4 -4
  129. gobby/storage/migrations.py +118 -3
  130. gobby/storage/pipelines.py +367 -0
  131. gobby/storage/sessions.py +23 -4
  132. gobby/storage/skills.py +48 -8
  133. gobby/storage/tasks/_aggregates.py +2 -2
  134. gobby/storage/tasks/_lifecycle.py +4 -4
  135. gobby/storage/tasks/_models.py +7 -1
  136. gobby/storage/tasks/_queries.py +3 -3
  137. gobby/sync/memories.py +4 -3
  138. gobby/tasks/commits.py +48 -17
  139. gobby/tasks/external_validator.py +4 -17
  140. gobby/tasks/validation.py +13 -87
  141. gobby/tools/summarizer.py +18 -51
  142. gobby/utils/status.py +13 -0
  143. gobby/workflows/actions.py +80 -0
  144. gobby/workflows/context_actions.py +265 -27
  145. gobby/workflows/definitions.py +119 -1
  146. gobby/workflows/detection_helpers.py +23 -11
  147. gobby/workflows/enforcement/__init__.py +11 -1
  148. gobby/workflows/enforcement/blocking.py +96 -0
  149. gobby/workflows/enforcement/handlers.py +35 -1
  150. gobby/workflows/enforcement/task_policy.py +18 -0
  151. gobby/workflows/engine.py +26 -4
  152. gobby/workflows/evaluator.py +8 -5
  153. gobby/workflows/lifecycle_evaluator.py +59 -27
  154. gobby/workflows/loader.py +567 -30
  155. gobby/workflows/lobster_compat.py +147 -0
  156. gobby/workflows/pipeline_executor.py +801 -0
  157. gobby/workflows/pipeline_state.py +172 -0
  158. gobby/workflows/pipeline_webhooks.py +206 -0
  159. gobby/workflows/premature_stop.py +5 -0
  160. gobby/worktrees/git.py +135 -20
  161. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/METADATA +56 -22
  162. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/RECORD +166 -122
  163. gobby/hooks/event_handlers.py +0 -1008
  164. gobby/mcp_proxy/tools/workflows.py +0 -1023
  165. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/WHEEL +0 -0
  166. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/entry_points.txt +0 -0
  167. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/licenses/LICENSE.md +0 -0
  168. {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/top_level.txt +0 -0
gobby/hooks/events.py CHANGED
@@ -63,6 +63,9 @@ class SessionSource(str, Enum):
63
63
  CODEX = "codex"
64
64
  CLAUDE_SDK = "claude_sdk"
65
65
  ANTIGRAVITY = "antigravity" # Antigravity IDE (uses Claude Code format)
66
+ CURSOR = "cursor"
67
+ WINDSURF = "windsurf"
68
+ COPILOT = "copilot"
66
69
 
67
70
 
68
71
  @dataclass
@@ -144,75 +147,120 @@ EVENT_TYPE_CLI_SUPPORT: dict[HookEventType, dict[str, str | None]] = {
144
147
  "claude": "SessionStart",
145
148
  "gemini": "SessionStart",
146
149
  "codex": "thread/started",
150
+ "cursor": "SessionStart",
151
+ "windsurf": "SessionStart",
152
+ "copilot": "SessionStart",
147
153
  },
148
154
  HookEventType.SESSION_END: {
149
155
  "claude": "SessionEnd",
150
156
  "gemini": "SessionEnd",
151
157
  "codex": "thread/archive",
158
+ "cursor": "SessionEnd",
159
+ "windsurf": "SessionEnd",
160
+ "copilot": "SessionEnd",
152
161
  },
153
162
  HookEventType.BEFORE_AGENT: {
154
163
  "claude": "UserPromptSubmit",
155
164
  "gemini": "BeforeAgent",
156
165
  "codex": "turn/started",
166
+ "cursor": "UserPromptSubmit",
167
+ "windsurf": "UserPromptSubmit",
168
+ "copilot": "UserPromptSubmit",
157
169
  },
158
170
  HookEventType.AFTER_AGENT: {
159
171
  "claude": "Stop",
160
172
  "gemini": "AfterAgent",
161
173
  "codex": "turn/completed",
174
+ "cursor": "Stop",
175
+ "windsurf": "Stop",
176
+ "copilot": "Stop",
162
177
  },
163
178
  HookEventType.STOP: {
164
179
  "claude": "Stop",
165
180
  "gemini": None,
166
181
  "codex": None,
182
+ "cursor": "Stop",
183
+ "windsurf": "Stop",
184
+ "copilot": "Stop",
167
185
  },
168
186
  HookEventType.BEFORE_TOOL: {
169
187
  "claude": "PreToolUse",
170
188
  "gemini": "BeforeTool",
171
189
  "codex": "requestApproval",
190
+ "cursor": "PreToolUse",
191
+ "windsurf": "PreToolUse",
192
+ "copilot": "PreToolUse",
172
193
  },
173
194
  HookEventType.AFTER_TOOL: {
174
195
  "claude": "PostToolUse",
175
196
  "gemini": "AfterTool",
176
197
  "codex": "item/completed",
198
+ "cursor": "PostToolUse",
199
+ "windsurf": "PostToolUse",
200
+ "copilot": "PostToolUse",
177
201
  },
178
202
  HookEventType.BEFORE_TOOL_SELECTION: {
179
203
  "claude": None,
180
204
  "gemini": "BeforeToolSelection",
181
205
  "codex": None,
206
+ "cursor": None,
207
+ "windsurf": None,
208
+ "copilot": None,
182
209
  },
183
210
  HookEventType.BEFORE_MODEL: {
184
211
  "claude": None,
185
212
  "gemini": "BeforeModel",
186
213
  "codex": None,
214
+ "cursor": None,
215
+ "windsurf": None,
216
+ "copilot": None,
187
217
  },
188
218
  HookEventType.AFTER_MODEL: {
189
219
  "claude": None,
190
220
  "gemini": "AfterModel",
191
221
  "codex": None,
222
+ "cursor": None,
223
+ "windsurf": None,
224
+ "copilot": None,
192
225
  },
193
226
  HookEventType.PRE_COMPACT: {
194
227
  "claude": "PreCompact",
195
228
  "gemini": "PreCompress",
196
229
  "codex": None,
230
+ "cursor": "PreCompact",
231
+ "windsurf": "PreCompact",
232
+ "copilot": "PreCompact",
197
233
  },
198
234
  HookEventType.SUBAGENT_START: {
199
235
  "claude": "SubagentStart",
200
236
  "gemini": None,
201
237
  "codex": None,
238
+ "cursor": "SubagentStart",
239
+ "windsurf": "SubagentStart",
240
+ "copilot": "SubagentStart",
202
241
  },
203
242
  HookEventType.SUBAGENT_STOP: {
204
243
  "claude": "SubagentStop",
205
244
  "gemini": None,
206
245
  "codex": None,
246
+ "cursor": "SubagentStop",
247
+ "windsurf": "SubagentStop",
248
+ "copilot": "SubagentStop",
207
249
  },
208
250
  HookEventType.PERMISSION_REQUEST: {
209
251
  "claude": "PermissionRequest",
210
252
  "gemini": None,
211
253
  "codex": None,
254
+ "cursor": "PermissionRequest",
255
+ "windsurf": "PermissionRequest",
256
+ "copilot": "PermissionRequest",
212
257
  },
213
258
  HookEventType.NOTIFICATION: {
214
259
  "claude": "Notification",
215
260
  "gemini": "Notification",
216
261
  "codex": None,
262
+ "cursor": "Notification",
263
+ "windsurf": "Notification",
264
+ "copilot": "Notification",
217
265
  },
218
266
  }
@@ -256,11 +256,33 @@ class HookManager:
256
256
  # But 'TemplateEngine' constructor takes optional dirs.
257
257
  self._template_engine = TemplateEngine()
258
258
 
259
+ # Skill manager for core skill injection
260
+ # Initialized before ActionExecutor so it can be passed through
261
+ self._skill_manager = HookSkillManager()
262
+
259
263
  # Get websocket_server from broadcaster if available
260
264
  websocket_server = None
261
265
  if self.broadcaster and hasattr(self.broadcaster, "websocket_server"):
262
266
  websocket_server = self.broadcaster.websocket_server
263
267
 
268
+ # Initialize pipeline executor for run_pipeline action support
269
+ self._pipeline_executor = None
270
+ try:
271
+ from gobby.storage.pipelines import LocalPipelineExecutionManager
272
+ from gobby.workflows.pipeline_executor import PipelineExecutor
273
+
274
+ # Resolve project_id dynamically since it's not stored on the instance
275
+ project_id = self._resolve_project_id(None, None)
276
+ pipeline_execution_manager = LocalPipelineExecutionManager(self._database, project_id)
277
+ self._pipeline_executor = PipelineExecutor(
278
+ db=self._database,
279
+ execution_manager=pipeline_execution_manager,
280
+ llm_service=self._llm_service,
281
+ loader=self._workflow_loader,
282
+ )
283
+ except Exception as e:
284
+ logging.getLogger(__name__).debug(f"Pipeline executor not available: {e}")
285
+
264
286
  self._action_executor = ActionExecutor(
265
287
  db=self._database,
266
288
  session_manager=self._session_storage,
@@ -278,6 +300,9 @@ class HookManager:
278
300
  progress_tracker=self._progress_tracker,
279
301
  stuck_detector=self._stuck_detector,
280
302
  websocket_server=websocket_server,
303
+ skill_manager=self._skill_manager,
304
+ pipeline_executor=self._pipeline_executor,
305
+ workflow_loader=self._workflow_loader,
281
306
  )
282
307
  self._workflow_engine = WorkflowEngine(
283
308
  loader=self._workflow_loader,
@@ -366,9 +391,6 @@ class HookManager:
366
391
  logger=self.logger,
367
392
  )
368
393
 
369
- # Skill manager for core skill injection
370
- self._skill_manager = HookSkillManager()
371
-
372
394
  # Track sessions that have received full metadata injection
373
395
  # Key: "{platform_session_id}:{source}" - cleared on daemon restart
374
396
  self._injected_sessions: set[str] = set()
@@ -386,6 +408,8 @@ class HookManager:
386
408
  message_manager=self._message_manager,
387
409
  skill_manager=self._skill_manager,
388
410
  skills_config=self._config.skills if self._config else None,
411
+ artifact_capture_hook=self._artifact_capture_hook,
412
+ workflow_config=self._config.workflow if self._config else None,
389
413
  get_machine_id=self.get_machine_id,
390
414
  resolve_project_id=self._resolve_project_id,
391
415
  logger=self.logger,
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env python3
2
+ """Hook Dispatcher - Routes GitHub Copilot CLI hooks to HookManager.
3
+
4
+ This is a thin wrapper script that receives hook calls from Copilot CLI
5
+ and routes them to the appropriate handler via HookManager.
6
+
7
+ Usage:
8
+ hook_dispatcher.py --type sessionStart < input.json > output.json
9
+ hook_dispatcher.py --type preToolUse --debug < input.json > output.json
10
+
11
+ Exit Codes:
12
+ 0 - Success
13
+ 1 - General error (logged, continues)
14
+ 2 - Block action (Copilot interprets as deny)
15
+ """
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ # Default daemon configuration
24
+ DEFAULT_DAEMON_PORT = 60887
25
+ DEFAULT_CONFIG_PATH = "~/.gobby/config.yaml"
26
+
27
+
28
+ def get_daemon_url() -> str:
29
+ """Get the daemon HTTP URL from config file."""
30
+ config_path = Path(DEFAULT_CONFIG_PATH).expanduser()
31
+
32
+ if config_path.exists():
33
+ try:
34
+ import yaml
35
+
36
+ with open(config_path) as f:
37
+ config = yaml.safe_load(f) or {}
38
+ port = config.get("daemon_port", DEFAULT_DAEMON_PORT)
39
+ except Exception:
40
+ port = DEFAULT_DAEMON_PORT
41
+ else:
42
+ port = DEFAULT_DAEMON_PORT
43
+
44
+ return f"http://localhost:{port}"
45
+
46
+
47
+ def get_terminal_context() -> dict[str, str | int | None]:
48
+ """Capture terminal/process context for session correlation."""
49
+ context: dict[str, str | int | None] = {}
50
+
51
+ try:
52
+ context["parent_pid"] = os.getppid()
53
+ except Exception:
54
+ context["parent_pid"] = None
55
+
56
+ try:
57
+ context["tty"] = os.ttyname(0)
58
+ except Exception:
59
+ context["tty"] = None
60
+
61
+ context["term_session_id"] = os.environ.get("TERM_SESSION_ID")
62
+ context["iterm_session_id"] = os.environ.get("ITERM_SESSION_ID")
63
+ context["vscode_terminal_id"] = os.environ.get("VSCODE_GIT_ASKPASS_NODE")
64
+ context["tmux_pane"] = os.environ.get("TMUX_PANE")
65
+ context["kitty_window_id"] = os.environ.get("KITTY_WINDOW_ID")
66
+ context["alacritty_socket"] = os.environ.get("ALACRITTY_SOCKET")
67
+ context["term_program"] = os.environ.get("TERM_PROGRAM")
68
+
69
+ return context
70
+
71
+
72
+ def parse_arguments() -> argparse.Namespace:
73
+ """Parse command line arguments."""
74
+ parser = argparse.ArgumentParser(description="Copilot CLI Hook Dispatcher")
75
+ parser.add_argument(
76
+ "--type",
77
+ required=True,
78
+ help="Hook type (e.g., sessionStart, preToolUse)",
79
+ )
80
+ parser.add_argument(
81
+ "--debug",
82
+ action="store_true",
83
+ help="Enable debug logging",
84
+ )
85
+ return parser.parse_args()
86
+
87
+
88
+ def check_daemon_running(timeout: float = 0.5) -> bool:
89
+ """Check if gobby daemon is active and responding."""
90
+ try:
91
+ import httpx
92
+
93
+ daemon_url = get_daemon_url()
94
+ response = httpx.get(
95
+ f"{daemon_url}/admin/status",
96
+ timeout=timeout,
97
+ follow_redirects=False,
98
+ )
99
+ return response.status_code == 200
100
+ except Exception:
101
+ return False
102
+
103
+
104
+ def main() -> int:
105
+ """Main dispatcher execution."""
106
+ try:
107
+ args = parse_arguments()
108
+ except (argparse.ArgumentError, SystemExit):
109
+ print(json.dumps({}))
110
+ return 2
111
+
112
+ hook_type = args.type
113
+ debug_mode = args.debug
114
+
115
+ # Check if daemon is running
116
+ if not check_daemon_running():
117
+ critical_hooks = {"sessionStart", "sessionEnd"}
118
+ if hook_type in critical_hooks:
119
+ print(
120
+ f"Gobby daemon is not running. Start with 'gobby start' before continuing. "
121
+ f"({hook_type} requires daemon for session state management)",
122
+ file=sys.stderr,
123
+ )
124
+ return 2
125
+ else:
126
+ print(
127
+ json.dumps(
128
+ {"status": "daemon_not_running", "message": "gobby daemon is not running"}
129
+ )
130
+ )
131
+ return 0
132
+
133
+ import logging
134
+
135
+ logger = logging.getLogger("gobby.hooks.dispatcher.copilot")
136
+ if debug_mode:
137
+ logging.basicConfig(level=logging.DEBUG)
138
+ else:
139
+ logging.basicConfig(level=logging.WARNING, handlers=[])
140
+
141
+ try:
142
+ input_data = json.load(sys.stdin)
143
+
144
+ if hook_type == "sessionStart":
145
+ input_data["terminal_context"] = get_terminal_context()
146
+
147
+ logger.info(f"[{hook_type}] Received input keys: {list(input_data.keys())}")
148
+
149
+ if debug_mode:
150
+ logger.debug(f"Input data: {input_data}")
151
+
152
+ except json.JSONDecodeError as e:
153
+ if debug_mode:
154
+ logger.error(f"JSON decode error: {e}")
155
+ print(json.dumps({}))
156
+ return 2
157
+
158
+ import httpx
159
+
160
+ daemon_url = get_daemon_url()
161
+ try:
162
+ response = httpx.post(
163
+ f"{daemon_url}/hooks/execute",
164
+ json={
165
+ "hook_type": hook_type,
166
+ "input_data": input_data,
167
+ "source": "copilot",
168
+ },
169
+ timeout=90.0,
170
+ )
171
+
172
+ if response.status_code == 200:
173
+ result = response.json()
174
+
175
+ if debug_mode:
176
+ logger.debug(f"Output data: {result}")
177
+
178
+ # Check for block decision
179
+ if result.get("continue") is False or result.get("permissionDecision") == "deny":
180
+ reason = result.get("reason") or "Blocked by hook"
181
+ print(reason, file=sys.stderr)
182
+ return 2
183
+
184
+ if result and result != {}:
185
+ print(json.dumps(result))
186
+
187
+ return 0
188
+ else:
189
+ error_detail = response.text
190
+ logger.error(
191
+ f"Daemon returned error: status={response.status_code}, detail={error_detail}"
192
+ )
193
+ print(json.dumps({"status": "error", "message": f"Daemon error: {error_detail}"}))
194
+ return 1
195
+
196
+ except Exception as e:
197
+ logger.error(f"Hook execution failed: {e}", exc_info=True)
198
+ print(json.dumps({"status": "error", "message": str(e)}))
199
+ return 1
200
+
201
+
202
+ if __name__ == "__main__":
203
+ sys.exit(main())
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env python3
2
+ """Hook Dispatcher - Routes Cursor hooks to HookManager.
3
+
4
+ This is a thin wrapper script that receives hook calls from Cursor
5
+ and routes them to the appropriate handler via HookManager.
6
+
7
+ Usage:
8
+ hook_dispatcher.py --type sessionStart < input.json > output.json
9
+ hook_dispatcher.py --type preToolUse --debug < input.json > output.json
10
+
11
+ Exit Codes:
12
+ 0 - Success
13
+ 1 - General error (logged, continues)
14
+ 2 - Block action (Cursor interprets as deny)
15
+ """
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ # Default daemon configuration
24
+ DEFAULT_DAEMON_PORT = 60887
25
+ DEFAULT_CONFIG_PATH = "~/.gobby/config.yaml"
26
+
27
+
28
+ def get_daemon_url() -> str:
29
+ """Get the daemon HTTP URL from config file."""
30
+ config_path = Path(DEFAULT_CONFIG_PATH).expanduser()
31
+
32
+ if config_path.exists():
33
+ try:
34
+ import yaml
35
+
36
+ with open(config_path) as f:
37
+ config = yaml.safe_load(f) or {}
38
+ port = config.get("daemon_port", DEFAULT_DAEMON_PORT)
39
+ except Exception:
40
+ port = DEFAULT_DAEMON_PORT
41
+ else:
42
+ port = DEFAULT_DAEMON_PORT
43
+
44
+ return f"http://localhost:{port}"
45
+
46
+
47
+ def get_terminal_context() -> dict[str, str | int | None]:
48
+ """Capture terminal/process context for session correlation."""
49
+ context: dict[str, str | int | None] = {}
50
+
51
+ try:
52
+ context["parent_pid"] = os.getppid()
53
+ except Exception:
54
+ context["parent_pid"] = None
55
+
56
+ try:
57
+ context["tty"] = os.ttyname(0)
58
+ except Exception:
59
+ context["tty"] = None
60
+
61
+ context["term_session_id"] = os.environ.get("TERM_SESSION_ID")
62
+ context["iterm_session_id"] = os.environ.get("ITERM_SESSION_ID")
63
+ context["vscode_terminal_id"] = os.environ.get("VSCODE_GIT_ASKPASS_NODE")
64
+ context["tmux_pane"] = os.environ.get("TMUX_PANE")
65
+ context["kitty_window_id"] = os.environ.get("KITTY_WINDOW_ID")
66
+ context["alacritty_socket"] = os.environ.get("ALACRITTY_SOCKET")
67
+ context["term_program"] = os.environ.get("TERM_PROGRAM")
68
+
69
+ return context
70
+
71
+
72
+ def parse_arguments() -> argparse.Namespace:
73
+ """Parse command line arguments."""
74
+ parser = argparse.ArgumentParser(description="Cursor Hook Dispatcher")
75
+ parser.add_argument(
76
+ "--type",
77
+ required=True,
78
+ help="Hook type (e.g., sessionStart, preToolUse)",
79
+ )
80
+ parser.add_argument(
81
+ "--debug",
82
+ action="store_true",
83
+ help="Enable debug logging",
84
+ )
85
+ return parser.parse_args()
86
+
87
+
88
+ def check_daemon_running(timeout: float = 0.5) -> bool:
89
+ """Check if gobby daemon is active and responding."""
90
+ try:
91
+ import httpx
92
+
93
+ daemon_url = get_daemon_url()
94
+ response = httpx.get(
95
+ f"{daemon_url}/admin/status",
96
+ timeout=timeout,
97
+ follow_redirects=False,
98
+ )
99
+ return response.status_code == 200
100
+ except Exception:
101
+ return False
102
+
103
+
104
+ def main() -> int:
105
+ """Main dispatcher execution."""
106
+ try:
107
+ args = parse_arguments()
108
+ except (argparse.ArgumentError, SystemExit):
109
+ print(json.dumps({}))
110
+ return 2
111
+
112
+ hook_type = args.type
113
+ debug_mode = args.debug
114
+
115
+ # Check if daemon is running
116
+ if not check_daemon_running():
117
+ critical_hooks = {"sessionStart", "sessionEnd", "preCompact"}
118
+ if hook_type in critical_hooks:
119
+ print(
120
+ f"Gobby daemon is not running. Start with 'gobby start' before continuing. "
121
+ f"({hook_type} requires daemon for session state management)",
122
+ file=sys.stderr,
123
+ )
124
+ return 2
125
+ else:
126
+ print(
127
+ json.dumps(
128
+ {"status": "daemon_not_running", "message": "gobby daemon is not running"}
129
+ )
130
+ )
131
+ return 0
132
+
133
+ import logging
134
+
135
+ logger = logging.getLogger("gobby.hooks.dispatcher.cursor")
136
+ if debug_mode:
137
+ logging.basicConfig(level=logging.DEBUG)
138
+ else:
139
+ logging.basicConfig(level=logging.WARNING, handlers=[])
140
+
141
+ try:
142
+ input_data = json.load(sys.stdin)
143
+
144
+ if hook_type == "sessionStart":
145
+ input_data["terminal_context"] = get_terminal_context()
146
+
147
+ logger.info(f"[{hook_type}] Received input keys: {list(input_data.keys())}")
148
+
149
+ if debug_mode:
150
+ logger.debug(f"Input data: {input_data}")
151
+
152
+ except json.JSONDecodeError as e:
153
+ if debug_mode:
154
+ logger.error(f"JSON decode error: {e}")
155
+ print(json.dumps({}))
156
+ return 2
157
+
158
+ import httpx
159
+
160
+ daemon_url = get_daemon_url()
161
+ try:
162
+ response = httpx.post(
163
+ f"{daemon_url}/hooks/execute",
164
+ json={
165
+ "hook_type": hook_type,
166
+ "input_data": input_data,
167
+ "source": "cursor",
168
+ },
169
+ timeout=90.0,
170
+ )
171
+
172
+ if response.status_code == 200:
173
+ result = response.json()
174
+
175
+ if debug_mode:
176
+ logger.debug(f"Output data: {result}")
177
+
178
+ # Check for block decision
179
+ if result.get("continue") is False or result.get("decision") == "deny":
180
+ reason = result.get("user_message") or result.get("reason") or "Blocked by hook"
181
+ print(reason, file=sys.stderr)
182
+ return 2
183
+
184
+ if result and result != {}:
185
+ print(json.dumps(result))
186
+
187
+ return 0
188
+ else:
189
+ error_detail = response.text
190
+ logger.error(
191
+ f"Daemon returned error: status={response.status_code}, detail={error_detail}"
192
+ )
193
+ print(json.dumps({"status": "error", "message": f"Daemon error: {error_detail}"}))
194
+ return 1
195
+
196
+ except Exception as e:
197
+ logger.error(f"Hook execution failed: {e}", exc_info=True)
198
+ print(json.dumps({"status": "error", "message": str(e)}))
199
+ return 1
200
+
201
+
202
+ if __name__ == "__main__":
203
+ sys.exit(main())
@@ -108,6 +108,14 @@ def get_terminal_context() -> dict[str, str | int | bool | None]:
108
108
  # Generic terminal program identifier (set by many terminals)
109
109
  context["term_program"] = os.environ.get("TERM_PROGRAM")
110
110
 
111
+ # Gobby session context (set when spawned by Gobby)
112
+ # These allow the daemon to link this Gemini session to a pre-created Gobby session
113
+ context["gobby_session_id"] = os.environ.get("GOBBY_SESSION_ID")
114
+ context["gobby_parent_session_id"] = os.environ.get("GOBBY_PARENT_SESSION_ID")
115
+ context["gobby_agent_run_id"] = os.environ.get("GOBBY_AGENT_RUN_ID")
116
+ context["gobby_project_id"] = os.environ.get("GOBBY_PROJECT_ID")
117
+ context["gobby_workflow_name"] = os.environ.get("GOBBY_WORKFLOW_NAME")
118
+
111
119
  return context
112
120
 
113
121