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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +6 -0
- gobby/adapters/base.py +11 -2
- gobby/adapters/claude_code.py +5 -28
- gobby/adapters/codex_impl/adapter.py +38 -43
- gobby/adapters/copilot.py +324 -0
- gobby/adapters/cursor.py +373 -0
- gobby/adapters/gemini.py +2 -26
- gobby/adapters/windsurf.py +359 -0
- gobby/agents/definitions.py +162 -2
- gobby/agents/isolation.py +33 -1
- gobby/agents/pty_reader.py +192 -0
- gobby/agents/registry.py +10 -1
- gobby/agents/runner.py +24 -8
- gobby/agents/sandbox.py +8 -3
- gobby/agents/session.py +4 -0
- gobby/agents/spawn.py +9 -2
- gobby/agents/spawn_executor.py +49 -61
- gobby/agents/spawners/command_builder.py +4 -4
- gobby/app_context.py +64 -0
- gobby/cli/__init__.py +4 -0
- gobby/cli/install.py +259 -4
- gobby/cli/installers/__init__.py +12 -0
- gobby/cli/installers/copilot.py +242 -0
- gobby/cli/installers/cursor.py +244 -0
- gobby/cli/installers/shared.py +3 -0
- gobby/cli/installers/windsurf.py +242 -0
- gobby/cli/pipelines.py +639 -0
- gobby/cli/sessions.py +3 -1
- gobby/cli/skills.py +209 -0
- gobby/cli/tasks/crud.py +6 -5
- gobby/cli/tasks/search.py +1 -1
- gobby/cli/ui.py +116 -0
- gobby/cli/utils.py +5 -17
- gobby/cli/workflows.py +38 -17
- gobby/config/app.py +5 -0
- gobby/config/features.py +0 -20
- gobby/config/skills.py +23 -2
- gobby/config/tasks.py +4 -0
- gobby/hooks/broadcaster.py +9 -0
- gobby/hooks/event_handlers/__init__.py +155 -0
- gobby/hooks/event_handlers/_agent.py +175 -0
- gobby/hooks/event_handlers/_base.py +92 -0
- gobby/hooks/event_handlers/_misc.py +66 -0
- gobby/hooks/event_handlers/_session.py +487 -0
- gobby/hooks/event_handlers/_tool.py +196 -0
- gobby/hooks/events.py +48 -0
- gobby/hooks/hook_manager.py +27 -3
- gobby/install/copilot/hooks/hook_dispatcher.py +203 -0
- gobby/install/cursor/hooks/hook_dispatcher.py +203 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +8 -0
- gobby/install/windsurf/hooks/hook_dispatcher.py +205 -0
- gobby/llm/__init__.py +14 -1
- gobby/llm/claude.py +594 -43
- gobby/llm/service.py +149 -0
- gobby/mcp_proxy/importer.py +4 -41
- gobby/mcp_proxy/instructions.py +9 -27
- gobby/mcp_proxy/manager.py +13 -3
- gobby/mcp_proxy/models.py +1 -0
- gobby/mcp_proxy/registries.py +66 -5
- gobby/mcp_proxy/server.py +6 -2
- gobby/mcp_proxy/services/recommendation.py +2 -28
- gobby/mcp_proxy/services/tool_filter.py +7 -0
- gobby/mcp_proxy/services/tool_proxy.py +19 -1
- gobby/mcp_proxy/stdio.py +37 -21
- gobby/mcp_proxy/tools/agents.py +7 -0
- gobby/mcp_proxy/tools/artifacts.py +3 -3
- gobby/mcp_proxy/tools/hub.py +30 -1
- gobby/mcp_proxy/tools/orchestration/cleanup.py +5 -5
- gobby/mcp_proxy/tools/orchestration/monitor.py +1 -1
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +8 -3
- gobby/mcp_proxy/tools/orchestration/review.py +17 -4
- gobby/mcp_proxy/tools/orchestration/wait.py +7 -7
- gobby/mcp_proxy/tools/pipelines/__init__.py +254 -0
- gobby/mcp_proxy/tools/pipelines/_discovery.py +67 -0
- gobby/mcp_proxy/tools/pipelines/_execution.py +281 -0
- gobby/mcp_proxy/tools/sessions/_crud.py +4 -4
- gobby/mcp_proxy/tools/sessions/_handoff.py +1 -1
- gobby/mcp_proxy/tools/skills/__init__.py +184 -30
- gobby/mcp_proxy/tools/spawn_agent.py +229 -14
- gobby/mcp_proxy/tools/task_readiness.py +27 -4
- gobby/mcp_proxy/tools/tasks/_context.py +8 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +27 -1
- gobby/mcp_proxy/tools/tasks/_helpers.py +1 -1
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +125 -8
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +2 -1
- gobby/mcp_proxy/tools/tasks/_search.py +1 -1
- gobby/mcp_proxy/tools/workflows/__init__.py +273 -0
- gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
- gobby/mcp_proxy/tools/workflows/_import.py +112 -0
- gobby/mcp_proxy/tools/workflows/_lifecycle.py +332 -0
- gobby/mcp_proxy/tools/workflows/_query.py +226 -0
- gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
- gobby/mcp_proxy/tools/workflows/_terminal.py +175 -0
- gobby/mcp_proxy/tools/worktrees.py +54 -15
- gobby/memory/components/__init__.py +0 -0
- gobby/memory/components/ingestion.py +98 -0
- gobby/memory/components/search.py +108 -0
- gobby/memory/context.py +5 -5
- gobby/memory/manager.py +16 -25
- gobby/paths.py +51 -0
- gobby/prompts/loader.py +1 -35
- gobby/runner.py +131 -16
- gobby/servers/http.py +193 -150
- gobby/servers/routes/__init__.py +2 -0
- gobby/servers/routes/admin.py +56 -0
- gobby/servers/routes/mcp/endpoints/execution.py +33 -32
- gobby/servers/routes/mcp/endpoints/registry.py +8 -8
- gobby/servers/routes/mcp/hooks.py +10 -1
- gobby/servers/routes/pipelines.py +227 -0
- gobby/servers/websocket.py +314 -1
- gobby/sessions/analyzer.py +89 -3
- gobby/sessions/manager.py +5 -5
- gobby/sessions/transcripts/__init__.py +3 -0
- gobby/sessions/transcripts/claude.py +5 -0
- gobby/sessions/transcripts/codex.py +5 -0
- gobby/sessions/transcripts/gemini.py +5 -0
- gobby/skills/hubs/__init__.py +25 -0
- gobby/skills/hubs/base.py +234 -0
- gobby/skills/hubs/claude_plugins.py +328 -0
- gobby/skills/hubs/clawdhub.py +289 -0
- gobby/skills/hubs/github_collection.py +465 -0
- gobby/skills/hubs/manager.py +263 -0
- gobby/skills/hubs/skillhub.py +342 -0
- gobby/skills/parser.py +23 -0
- gobby/skills/sync.py +5 -4
- gobby/storage/artifacts.py +19 -0
- gobby/storage/memories.py +4 -4
- gobby/storage/migrations.py +118 -3
- gobby/storage/pipelines.py +367 -0
- gobby/storage/sessions.py +23 -4
- gobby/storage/skills.py +48 -8
- gobby/storage/tasks/_aggregates.py +2 -2
- gobby/storage/tasks/_lifecycle.py +4 -4
- gobby/storage/tasks/_models.py +7 -1
- gobby/storage/tasks/_queries.py +3 -3
- gobby/sync/memories.py +4 -3
- gobby/tasks/commits.py +48 -17
- gobby/tasks/external_validator.py +4 -17
- gobby/tasks/validation.py +13 -87
- gobby/tools/summarizer.py +18 -51
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +80 -0
- gobby/workflows/context_actions.py +265 -27
- gobby/workflows/definitions.py +119 -1
- gobby/workflows/detection_helpers.py +23 -11
- gobby/workflows/enforcement/__init__.py +11 -1
- gobby/workflows/enforcement/blocking.py +96 -0
- gobby/workflows/enforcement/handlers.py +35 -1
- gobby/workflows/enforcement/task_policy.py +18 -0
- gobby/workflows/engine.py +26 -4
- gobby/workflows/evaluator.py +8 -5
- gobby/workflows/lifecycle_evaluator.py +59 -27
- gobby/workflows/loader.py +567 -30
- gobby/workflows/lobster_compat.py +147 -0
- gobby/workflows/pipeline_executor.py +801 -0
- gobby/workflows/pipeline_state.py +172 -0
- gobby/workflows/pipeline_webhooks.py +206 -0
- gobby/workflows/premature_stop.py +5 -0
- gobby/worktrees/git.py +135 -20
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/METADATA +56 -22
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/RECORD +166 -122
- gobby/hooks/event_handlers.py +0 -1008
- gobby/mcp_proxy/tools/workflows.py +0 -1023
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/WHEEL +0 -0
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.8.dist-info → gobby-0.2.11.dist-info}/licenses/LICENSE.md +0 -0
- {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
|
}
|
gobby/hooks/hook_manager.py
CHANGED
|
@@ -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
|
|