gobby 0.2.9__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 (134) 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 +2 -2
  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 +5 -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/workflows.py +38 -17
  35. gobby/config/app.py +5 -0
  36. gobby/config/skills.py +23 -2
  37. gobby/hooks/broadcaster.py +9 -0
  38. gobby/hooks/event_handlers/_base.py +6 -1
  39. gobby/hooks/event_handlers/_session.py +44 -130
  40. gobby/hooks/events.py +48 -0
  41. gobby/hooks/hook_manager.py +25 -3
  42. gobby/install/copilot/hooks/hook_dispatcher.py +203 -0
  43. gobby/install/cursor/hooks/hook_dispatcher.py +203 -0
  44. gobby/install/gemini/hooks/hook_dispatcher.py +8 -0
  45. gobby/install/windsurf/hooks/hook_dispatcher.py +205 -0
  46. gobby/llm/__init__.py +14 -1
  47. gobby/llm/claude.py +217 -1
  48. gobby/llm/service.py +149 -0
  49. gobby/mcp_proxy/instructions.py +9 -27
  50. gobby/mcp_proxy/models.py +1 -0
  51. gobby/mcp_proxy/registries.py +56 -9
  52. gobby/mcp_proxy/server.py +6 -2
  53. gobby/mcp_proxy/services/tool_filter.py +7 -0
  54. gobby/mcp_proxy/services/tool_proxy.py +19 -1
  55. gobby/mcp_proxy/stdio.py +37 -21
  56. gobby/mcp_proxy/tools/agents.py +7 -0
  57. gobby/mcp_proxy/tools/hub.py +30 -1
  58. gobby/mcp_proxy/tools/orchestration/cleanup.py +5 -5
  59. gobby/mcp_proxy/tools/orchestration/monitor.py +1 -1
  60. gobby/mcp_proxy/tools/orchestration/orchestrate.py +8 -3
  61. gobby/mcp_proxy/tools/orchestration/review.py +17 -4
  62. gobby/mcp_proxy/tools/orchestration/wait.py +7 -7
  63. gobby/mcp_proxy/tools/pipelines/__init__.py +254 -0
  64. gobby/mcp_proxy/tools/pipelines/_discovery.py +67 -0
  65. gobby/mcp_proxy/tools/pipelines/_execution.py +281 -0
  66. gobby/mcp_proxy/tools/sessions/_crud.py +4 -4
  67. gobby/mcp_proxy/tools/sessions/_handoff.py +1 -1
  68. gobby/mcp_proxy/tools/skills/__init__.py +184 -30
  69. gobby/mcp_proxy/tools/spawn_agent.py +229 -14
  70. gobby/mcp_proxy/tools/tasks/_context.py +8 -0
  71. gobby/mcp_proxy/tools/tasks/_crud.py +27 -1
  72. gobby/mcp_proxy/tools/tasks/_helpers.py +1 -1
  73. gobby/mcp_proxy/tools/tasks/_lifecycle.py +125 -8
  74. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +2 -1
  75. gobby/mcp_proxy/tools/tasks/_search.py +1 -1
  76. gobby/mcp_proxy/tools/workflows/__init__.py +9 -2
  77. gobby/mcp_proxy/tools/workflows/_lifecycle.py +12 -1
  78. gobby/mcp_proxy/tools/workflows/_query.py +45 -26
  79. gobby/mcp_proxy/tools/workflows/_terminal.py +39 -3
  80. gobby/mcp_proxy/tools/worktrees.py +54 -15
  81. gobby/memory/context.py +5 -5
  82. gobby/runner.py +108 -6
  83. gobby/servers/http.py +7 -1
  84. gobby/servers/routes/__init__.py +2 -0
  85. gobby/servers/routes/admin.py +44 -0
  86. gobby/servers/routes/mcp/endpoints/execution.py +18 -25
  87. gobby/servers/routes/mcp/hooks.py +10 -1
  88. gobby/servers/routes/pipelines.py +227 -0
  89. gobby/servers/websocket.py +314 -1
  90. gobby/sessions/analyzer.py +87 -1
  91. gobby/sessions/manager.py +5 -5
  92. gobby/sessions/transcripts/__init__.py +3 -0
  93. gobby/sessions/transcripts/claude.py +5 -0
  94. gobby/sessions/transcripts/codex.py +5 -0
  95. gobby/sessions/transcripts/gemini.py +5 -0
  96. gobby/skills/hubs/__init__.py +25 -0
  97. gobby/skills/hubs/base.py +234 -0
  98. gobby/skills/hubs/claude_plugins.py +328 -0
  99. gobby/skills/hubs/clawdhub.py +289 -0
  100. gobby/skills/hubs/github_collection.py +465 -0
  101. gobby/skills/hubs/manager.py +263 -0
  102. gobby/skills/hubs/skillhub.py +342 -0
  103. gobby/storage/memories.py +4 -4
  104. gobby/storage/migrations.py +95 -3
  105. gobby/storage/pipelines.py +367 -0
  106. gobby/storage/sessions.py +23 -4
  107. gobby/storage/skills.py +1 -1
  108. gobby/storage/tasks/_aggregates.py +2 -2
  109. gobby/storage/tasks/_lifecycle.py +4 -4
  110. gobby/storage/tasks/_models.py +7 -1
  111. gobby/storage/tasks/_queries.py +3 -3
  112. gobby/sync/memories.py +4 -3
  113. gobby/tasks/commits.py +48 -17
  114. gobby/workflows/actions.py +75 -0
  115. gobby/workflows/context_actions.py +246 -5
  116. gobby/workflows/definitions.py +119 -1
  117. gobby/workflows/detection_helpers.py +23 -11
  118. gobby/workflows/enforcement/task_policy.py +18 -0
  119. gobby/workflows/engine.py +20 -1
  120. gobby/workflows/evaluator.py +8 -5
  121. gobby/workflows/lifecycle_evaluator.py +57 -26
  122. gobby/workflows/loader.py +567 -30
  123. gobby/workflows/lobster_compat.py +147 -0
  124. gobby/workflows/pipeline_executor.py +801 -0
  125. gobby/workflows/pipeline_state.py +172 -0
  126. gobby/workflows/pipeline_webhooks.py +206 -0
  127. gobby/workflows/premature_stop.py +5 -0
  128. gobby/worktrees/git.py +135 -20
  129. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/METADATA +56 -22
  130. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/RECORD +134 -106
  131. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/WHEEL +0 -0
  132. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/entry_points.txt +0 -0
  133. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/licenses/LICENSE.md +0 -0
  134. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/top_level.txt +0 -0
@@ -1,6 +1,5 @@
1
1
  from __future__ import annotations
2
2
 
3
- import re
4
3
  from typing import TYPE_CHECKING, Any
5
4
 
6
5
  from gobby.hooks.event_handlers._base import EventHandlersBase
@@ -36,12 +35,18 @@ class SessionEventHandlerMixin(EventHandlersBase):
36
35
  )
37
36
 
38
37
  # Step 0: Check if this is a pre-created session (terminal mode agent)
39
- # When we spawn an agent in terminal mode, we pass --session-id <internal_id>
40
- # to Claude, so external_id here might actually be our internal session ID
38
+ # Two cases:
39
+ # 1. Claude: We pass --session-id <internal_id>, so external_id IS our internal ID
40
+ # 2. Gemini: We pass GOBBY_SESSION_ID env var, hook_dispatcher includes it in terminal_context
41
41
  existing_session = None
42
+ terminal_context = input_data.get("terminal_context")
43
+ gobby_session_id_from_env = (
44
+ terminal_context.get("gobby_session_id") if terminal_context else None
45
+ )
46
+
42
47
  if self._session_storage:
43
48
  try:
44
- # Try to find by internal ID first (terminal mode case)
49
+ # Try to find by internal ID first (Claude case - external_id IS internal_id)
45
50
  existing_session = self._session_storage.get(external_id)
46
51
  if existing_session:
47
52
  return self._handle_pre_created_session(
@@ -53,7 +58,32 @@ class SessionEventHandlerMixin(EventHandlersBase):
53
58
  cwd=cwd,
54
59
  )
55
60
  except Exception as e:
56
- self.logger.debug(f"No pre-created session found: {e}")
61
+ self.logger.debug(f"No pre-created session found by external_id: {e}")
62
+
63
+ # Gemini case: Look up by gobby_session_id from terminal_context
64
+ if gobby_session_id_from_env and not existing_session:
65
+ try:
66
+ existing_session = self._session_storage.get(gobby_session_id_from_env)
67
+ if existing_session:
68
+ self.logger.info(
69
+ f"Found pre-created session {gobby_session_id_from_env} via "
70
+ f"terminal_context, updating external_id to {external_id}"
71
+ )
72
+ # Update the session's external_id with CLI's native session_id
73
+ self._session_storage.update(
74
+ gobby_session_id_from_env,
75
+ external_id=external_id,
76
+ )
77
+ return self._handle_pre_created_session(
78
+ existing_session=existing_session,
79
+ external_id=external_id,
80
+ transcript_path=transcript_path,
81
+ cli_source=cli_source,
82
+ event=event,
83
+ cwd=cwd,
84
+ )
85
+ except Exception as e:
86
+ self.logger.debug(f"No pre-created session found by gobby_session_id: {e}")
57
87
 
58
88
  # Step 1: Find parent session
59
89
  # Check env vars first (spawned agent case), then handoff (source='clear')
@@ -76,8 +106,7 @@ class SessionEventHandlerMixin(EventHandlersBase):
76
106
  self.logger.warning(f"Error finding parent session: {e}")
77
107
 
78
108
  # Step 2: Register new session with parent if found
79
- # Extract terminal context (injected by hook_dispatcher for terminal correlation)
80
- terminal_context = input_data.get("terminal_context")
109
+ # terminal_context already extracted in Step 0
81
110
  # Parse agent_depth as int if provided
82
111
  agent_depth_val = 0
83
112
  if agent_depth:
@@ -142,17 +171,14 @@ class SessionEventHandlerMixin(EventHandlersBase):
142
171
  except Exception as e:
143
172
  self.logger.warning(f"Workflow error: {e}")
144
173
 
145
- # Build additional context (task and skill injection)
174
+ # Build additional context (task context)
175
+ # Note: Skill injection is now handled by workflows via inject_context action
146
176
  additional_context: list[str] = []
147
177
  if event.task_id:
148
178
  task_title = event.metadata.get("_task_title", "Unknown Task")
149
179
  additional_context.append("\n## Active Task Context\n")
150
180
  additional_context.append(f"You are working on task: {task_title} ({event.task_id})")
151
181
 
152
- skill_context = self._build_skill_injection_context(parent_session_id)
153
- if skill_context:
154
- additional_context.append(skill_context)
155
-
156
182
  # Fetch session to get seq_num for #N display
157
183
  session_obj = None
158
184
  if session_id and self._session_storage:
@@ -313,7 +339,12 @@ class SessionEventHandlerMixin(EventHandlersBase):
313
339
 
314
340
  # Auto-activate workflow if specified for this session
315
341
  if existing_session.workflow_name and session_id:
316
- self._auto_activate_workflow(existing_session.workflow_name, session_id, cwd)
342
+ self._auto_activate_workflow(
343
+ existing_session.workflow_name,
344
+ session_id,
345
+ cwd,
346
+ variables=existing_session.step_variables,
347
+ )
317
348
 
318
349
  # Update event metadata
319
350
  event.metadata["_platform_session_id"] = session_id
@@ -454,120 +485,3 @@ class SessionEventHandlerMixin(EventHandlersBase):
454
485
  system_message=system_message,
455
486
  metadata=metadata,
456
487
  )
457
-
458
- def _build_skill_injection_context(self, parent_session_id: str | None = None) -> str | None:
459
- """Build skill injection context for session-start.
460
-
461
- Combines alwaysApply skills with skills restored from parent session.
462
- Uses per-skill injection_format to control how each skill is injected:
463
- - "summary": name + description only
464
- - "full" or "content": name + description + full content
465
-
466
- Args:
467
- parent_session_id: Optional parent session ID to restore skills from
468
-
469
- Returns context string with available skills if injection is enabled,
470
- or None if disabled.
471
- """
472
- # Skip if no skill manager or config
473
- if not self._skill_manager or not self._skills_config:
474
- return None
475
-
476
- # Check if injection is enabled
477
- if not self._skills_config.inject_core_skills:
478
- return None
479
-
480
- # Check injection format (global config level)
481
- if self._skills_config.injection_format == "none":
482
- return None
483
-
484
- # Get alwaysApply skills (efficiently via column query)
485
- try:
486
- always_apply_skills = self._skill_manager.discover_core_skills()
487
-
488
- # Get restored skills from parent session
489
- restored_skills = self._restore_skills_from_parent(parent_session_id)
490
-
491
- # Build a map of always_apply skills for quick lookup
492
- always_apply_map = {s.name: s for s in always_apply_skills}
493
-
494
- # Combine: alwaysApply skills + any additional restored skills
495
- skill_names = [s.name for s in always_apply_skills]
496
- for skill_name in restored_skills:
497
- if skill_name not in skill_names:
498
- skill_names.append(skill_name)
499
-
500
- if not skill_names:
501
- return None
502
-
503
- # Build context with per-skill injection format
504
- parts = ["\n## Available Skills\n"]
505
-
506
- for skill_name in skill_names:
507
- skill = always_apply_map.get(skill_name)
508
- if not skill:
509
- # Restored skill not in always_apply - just list the name
510
- parts.append(f"- **{skill_name}**")
511
- continue
512
-
513
- # Determine injection format for this skill
514
- # Use per-skill injection_format, fallback to global config
515
- skill_format = skill.injection_format or self._skills_config.injection_format
516
-
517
- if skill_format in ("full", "content"):
518
- # Full injection: name + description + content
519
- parts.append(f"### {skill_name}")
520
- if skill.description:
521
- parts.append(f"*{skill.description}*\n")
522
- if skill.content:
523
- parts.append(skill.content)
524
- parts.append("")
525
- else:
526
- # Summary injection: name + description only
527
- if skill.description:
528
- parts.append(f"- **{skill_name}**: {skill.description}")
529
- else:
530
- parts.append(f"- **{skill_name}**")
531
-
532
- return "\n".join(parts)
533
-
534
- except Exception as e:
535
- self.logger.warning(f"Failed to build skill injection context: {e}")
536
- return None
537
-
538
- def _restore_skills_from_parent(self, parent_session_id: str | None) -> list[str]:
539
- """Restore active skills from parent session's handoff context.
540
-
541
- Args:
542
- parent_session_id: Parent session ID to restore from
543
-
544
- Returns:
545
- List of skill names from the parent session
546
- """
547
- if not parent_session_id or not self._session_storage:
548
- return []
549
-
550
- try:
551
- parent = self._session_storage.get(parent_session_id)
552
- if not parent:
553
- return []
554
-
555
- compact_md = getattr(parent, "compact_markdown", None)
556
- if not compact_md:
557
- return []
558
-
559
- # Parse active skills from markdown
560
- # Format: "### Active Skills\nSkills available: skill1, skill2, skill3"
561
-
562
- match = re.search(r"### Active Skills\s*\nSkills available:\s*([^\n]+)", compact_md)
563
- if match:
564
- skills_str = match.group(1).strip()
565
- skills = [s.strip() for s in skills_str.split(",") if s.strip()]
566
- self.logger.debug(f"Restored {len(skills)} skills from parent session")
567
- return skills
568
-
569
- return []
570
-
571
- except Exception as e:
572
- self.logger.warning(f"Failed to restore skills from parent: {e}")
573
- return []
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()
@@ -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())