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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +6 -0
- gobby/adapters/base.py +11 -2
- gobby/adapters/claude_code.py +2 -2
- 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 +5 -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/workflows.py +38 -17
- gobby/config/app.py +5 -0
- gobby/config/skills.py +23 -2
- gobby/hooks/broadcaster.py +9 -0
- gobby/hooks/event_handlers/_base.py +6 -1
- gobby/hooks/event_handlers/_session.py +44 -130
- gobby/hooks/events.py +48 -0
- gobby/hooks/hook_manager.py +25 -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 +217 -1
- gobby/llm/service.py +149 -0
- gobby/mcp_proxy/instructions.py +9 -27
- gobby/mcp_proxy/models.py +1 -0
- gobby/mcp_proxy/registries.py +56 -9
- gobby/mcp_proxy/server.py +6 -2
- 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/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/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 +9 -2
- gobby/mcp_proxy/tools/workflows/_lifecycle.py +12 -1
- gobby/mcp_proxy/tools/workflows/_query.py +45 -26
- gobby/mcp_proxy/tools/workflows/_terminal.py +39 -3
- gobby/mcp_proxy/tools/worktrees.py +54 -15
- gobby/memory/context.py +5 -5
- gobby/runner.py +108 -6
- gobby/servers/http.py +7 -1
- gobby/servers/routes/__init__.py +2 -0
- gobby/servers/routes/admin.py +44 -0
- gobby/servers/routes/mcp/endpoints/execution.py +18 -25
- 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 +87 -1
- 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/storage/memories.py +4 -4
- gobby/storage/migrations.py +95 -3
- gobby/storage/pipelines.py +367 -0
- gobby/storage/sessions.py +23 -4
- gobby/storage/skills.py +1 -1
- 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/workflows/actions.py +75 -0
- gobby/workflows/context_actions.py +246 -5
- gobby/workflows/definitions.py +119 -1
- gobby/workflows/detection_helpers.py +23 -11
- gobby/workflows/enforcement/task_policy.py +18 -0
- gobby/workflows/engine.py +20 -1
- gobby/workflows/evaluator.py +8 -5
- gobby/workflows/lifecycle_evaluator.py +57 -26
- 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.9.dist-info → gobby-0.2.11.dist-info}/METADATA +56 -22
- {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/RECORD +134 -106
- {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/WHEEL +0 -0
- {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/licenses/LICENSE.md +0 -0
- {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
|
-
#
|
|
40
|
-
#
|
|
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 (
|
|
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
|
-
#
|
|
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
|
|
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(
|
|
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
|
}
|
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()
|
|
@@ -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())
|