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/__init__.py
CHANGED
gobby/adapters/__init__.py
CHANGED
|
@@ -19,7 +19,10 @@ from gobby.adapters.base import BaseAdapter
|
|
|
19
19
|
from gobby.adapters.claude_code import ClaudeCodeAdapter
|
|
20
20
|
from gobby.adapters.codex_impl.adapter import CodexAdapter, CodexNotifyAdapter
|
|
21
21
|
from gobby.adapters.codex_impl.client import CodexAppServerClient
|
|
22
|
+
from gobby.adapters.copilot import CopilotAdapter
|
|
23
|
+
from gobby.adapters.cursor import CursorAdapter
|
|
22
24
|
from gobby.adapters.gemini import GeminiAdapter
|
|
25
|
+
from gobby.adapters.windsurf import WindsurfAdapter
|
|
23
26
|
|
|
24
27
|
__all__ = [
|
|
25
28
|
"BaseAdapter",
|
|
@@ -27,5 +30,8 @@ __all__ = [
|
|
|
27
30
|
"CodexAdapter",
|
|
28
31
|
"CodexAppServerClient",
|
|
29
32
|
"CodexNotifyAdapter",
|
|
33
|
+
"CopilotAdapter",
|
|
34
|
+
"CursorAdapter",
|
|
30
35
|
"GeminiAdapter",
|
|
36
|
+
"WindsurfAdapter",
|
|
31
37
|
]
|
gobby/adapters/base.py
CHANGED
|
@@ -68,8 +68,9 @@ class BaseAdapter(ABC):
|
|
|
68
68
|
|
|
69
69
|
This method handles the full round-trip:
|
|
70
70
|
1. Translate native event to HookEvent
|
|
71
|
-
2.
|
|
72
|
-
3.
|
|
71
|
+
2. Inject daemon's machine_id if not provided by CLI
|
|
72
|
+
3. Process through HookManager
|
|
73
|
+
4. Translate response back to native format
|
|
73
74
|
|
|
74
75
|
Note: This method is synchronous for Phase 2A-2B compatibility.
|
|
75
76
|
In Phase 2C+, when HookManager.handle() is async, subclasses may
|
|
@@ -89,5 +90,13 @@ class BaseAdapter(ABC):
|
|
|
89
90
|
if hook_event is None:
|
|
90
91
|
# Event ignored by adapter
|
|
91
92
|
return {}
|
|
93
|
+
|
|
94
|
+
# Inject daemon's machine_id if CLI didn't provide it
|
|
95
|
+
# This centralizes machine_id handling - adapters don't generate IDs
|
|
96
|
+
if not hook_event.machine_id:
|
|
97
|
+
from gobby.utils.machine_id import get_machine_id
|
|
98
|
+
|
|
99
|
+
hook_event.machine_id = get_machine_id()
|
|
100
|
+
|
|
92
101
|
hook_response = hook_manager.handle(hook_event)
|
|
93
102
|
return self.translate_from_hook_response(hook_response)
|
gobby/adapters/claude_code.py
CHANGED
|
@@ -61,13 +61,10 @@ class ClaudeCodeAdapter(BaseAdapter):
|
|
|
61
61
|
"""Initialize the Claude Code adapter.
|
|
62
62
|
|
|
63
63
|
Args:
|
|
64
|
-
hook_manager: Reference to HookManager for
|
|
64
|
+
hook_manager: Reference to HookManager for delegation.
|
|
65
65
|
If None, the adapter can only translate (not handle events).
|
|
66
66
|
"""
|
|
67
67
|
self._hook_manager = hook_manager
|
|
68
|
-
# Phase 2C: Use new handle() path with unified HookEvent model
|
|
69
|
-
# Note: systemMessage handoff notification bug exists in both paths (see plan-multi-cli.md)
|
|
70
|
-
self._use_legacy = False
|
|
71
68
|
|
|
72
69
|
def translate_to_hook_event(self, native_event: dict[str, Any]) -> HookEvent:
|
|
73
70
|
"""Convert Claude Code native event to unified HookEvent.
|
|
@@ -295,8 +292,8 @@ class ClaudeCodeAdapter(BaseAdapter):
|
|
|
295
292
|
|
|
296
293
|
# Build hookSpecificOutput if we have any context to inject
|
|
297
294
|
# Only include hookSpecificOutput for hook types that Claude Code's schema accepts
|
|
298
|
-
# Valid hookEventName values: PreToolUse, UserPromptSubmit, PostToolUse
|
|
299
|
-
valid_hook_event_names = {"PreToolUse", "UserPromptSubmit", "PostToolUse"}
|
|
295
|
+
# Valid hookEventName values: PreToolUse, UserPromptSubmit, PostToolUse, SessionStart
|
|
296
|
+
valid_hook_event_names = {"PreToolUse", "UserPromptSubmit", "PostToolUse", "SessionStart"}
|
|
300
297
|
if additional_context_parts and hook_event_name in valid_hook_event_names:
|
|
301
298
|
result["hookSpecificOutput"] = {
|
|
302
299
|
"hookEventName": hook_event_name,
|
|
@@ -310,14 +307,6 @@ class ClaudeCodeAdapter(BaseAdapter):
|
|
|
310
307
|
) -> dict[str, Any]:
|
|
311
308
|
"""Main entry point for HTTP endpoint.
|
|
312
309
|
|
|
313
|
-
Strangler fig pattern:
|
|
314
|
-
- Phase 2A-2B: Delegates to existing execute() — validates translation only
|
|
315
|
-
- Phase 2C+: Calls new handle() with HookEvent
|
|
316
|
-
|
|
317
|
-
Note: This method is synchronous for Phase 2A-2B compatibility with
|
|
318
|
-
the existing execute() method. In Phase 2C+, it will become async
|
|
319
|
-
when handle() is implemented as async.
|
|
320
|
-
|
|
321
310
|
Args:
|
|
322
311
|
native_event: Raw payload from Claude Code's hook_dispatcher.py
|
|
323
312
|
hook_manager: HookManager instance for processing.
|
|
@@ -325,22 +314,10 @@ class ClaudeCodeAdapter(BaseAdapter):
|
|
|
325
314
|
Returns:
|
|
326
315
|
Response dict in Claude Code's expected format.
|
|
327
316
|
"""
|
|
328
|
-
#
|
|
317
|
+
# Translate to HookEvent
|
|
329
318
|
hook_event = self.translate_to_hook_event(native_event)
|
|
330
319
|
|
|
331
|
-
#
|
|
332
|
-
# Legacy execute() path removed as HookManager.execute is deprecated/removed.
|
|
320
|
+
# Use HookEvent-based handler
|
|
333
321
|
hook_type = native_event.get("hook_type", "")
|
|
334
322
|
hook_response = hook_manager.handle(hook_event)
|
|
335
323
|
return self.translate_from_hook_response(hook_response, hook_type=hook_type)
|
|
336
|
-
|
|
337
|
-
def set_legacy_mode(self, use_legacy: bool) -> None:
|
|
338
|
-
"""Toggle between legacy and new code paths.
|
|
339
|
-
|
|
340
|
-
This method is used during the strangler fig migration to switch
|
|
341
|
-
between delegating to execute() and calling handle() directly.
|
|
342
|
-
|
|
343
|
-
Args:
|
|
344
|
-
use_legacy: If True, use legacy execute() path. If False, use new handle() path.
|
|
345
|
-
"""
|
|
346
|
-
self._use_legacy = use_legacy
|
|
@@ -40,46 +40,26 @@ logger = logging.getLogger(__name__)
|
|
|
40
40
|
# =============================================================================
|
|
41
41
|
|
|
42
42
|
|
|
43
|
-
def
|
|
44
|
-
"""Get
|
|
43
|
+
def _get_daemon_machine_id() -> str | None:
|
|
44
|
+
"""Get machine ID from the daemon's centralized utility.
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
2. MAC address (if real, not random)
|
|
49
|
-
3. Persisted UUID file (created on first run)
|
|
46
|
+
This adapter runs in the daemon process, so we use the centralized
|
|
47
|
+
machine_id management from utils.machine_id.
|
|
50
48
|
"""
|
|
51
|
-
from
|
|
49
|
+
from gobby.utils.machine_id import get_machine_id
|
|
50
|
+
|
|
51
|
+
return get_machine_id()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _get_machine_id() -> str:
|
|
55
|
+
"""Generate a machine identifier.
|
|
52
56
|
|
|
53
|
-
|
|
57
|
+
Used by Codex adapters when no machine_id is provided.
|
|
58
|
+
"""
|
|
54
59
|
node = platform.node()
|
|
55
60
|
if node:
|
|
56
61
|
return str(uuid.uuid5(uuid.NAMESPACE_DNS, node))
|
|
57
|
-
|
|
58
|
-
# Try MAC address - getnode() returns random value with multicast bit set if unavailable
|
|
59
|
-
mac = uuid.getnode()
|
|
60
|
-
# Check if MAC is real (multicast bit / bit 0 of first octet is 0)
|
|
61
|
-
if not (mac >> 40) & 1:
|
|
62
|
-
return str(uuid.uuid5(uuid.NAMESPACE_DNS, str(mac)))
|
|
63
|
-
|
|
64
|
-
# Fall back to persisted ID file for stability across restarts
|
|
65
|
-
machine_id_file = Path.home() / ".gobby" / ".machine_id"
|
|
66
|
-
try:
|
|
67
|
-
if machine_id_file.exists():
|
|
68
|
-
stored_id = machine_id_file.read_text().strip()
|
|
69
|
-
if stored_id:
|
|
70
|
-
return stored_id
|
|
71
|
-
except OSError:
|
|
72
|
-
pass # Fall through to generate new ID
|
|
73
|
-
|
|
74
|
-
# Generate and persist a new ID
|
|
75
|
-
new_id = str(uuid.uuid4())
|
|
76
|
-
try:
|
|
77
|
-
machine_id_file.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
-
machine_id_file.write_text(new_id)
|
|
79
|
-
except OSError:
|
|
80
|
-
pass # Use the generated ID even if we can't persist it
|
|
81
|
-
|
|
82
|
-
return new_id
|
|
62
|
+
return str(uuid.uuid4())
|
|
83
63
|
|
|
84
64
|
|
|
85
65
|
# =============================================================================
|
|
@@ -163,8 +143,8 @@ class CodexAdapter(BaseAdapter):
|
|
|
163
143
|
"""
|
|
164
144
|
self._hook_manager = hook_manager
|
|
165
145
|
self._codex_client: CodexAppServerClient | None = None
|
|
166
|
-
self._machine_id: str | None = None
|
|
167
146
|
self._attached = False
|
|
147
|
+
self._machine_id: str | None = None
|
|
168
148
|
|
|
169
149
|
@staticmethod
|
|
170
150
|
def is_codex_available() -> bool:
|
|
@@ -177,10 +157,18 @@ class CodexAdapter(BaseAdapter):
|
|
|
177
157
|
|
|
178
158
|
return shutil.which("codex") is not None
|
|
179
159
|
|
|
180
|
-
def _get_machine_id(self) -> str:
|
|
181
|
-
"""Get
|
|
182
|
-
if self._machine_id
|
|
160
|
+
def _get_machine_id(self) -> str | None:
|
|
161
|
+
"""Get machine ID with caching and daemon fallback."""
|
|
162
|
+
if self._machine_id:
|
|
163
|
+
return self._machine_id
|
|
164
|
+
|
|
165
|
+
# Try daemon first
|
|
166
|
+
self._machine_id = _get_daemon_machine_id()
|
|
167
|
+
|
|
168
|
+
# Fallback to generated if daemon not available
|
|
169
|
+
if not self._machine_id:
|
|
183
170
|
self._machine_id = _get_machine_id()
|
|
171
|
+
|
|
184
172
|
return self._machine_id
|
|
185
173
|
|
|
186
174
|
def normalize_tool_name(self, codex_tool_name: str) -> str:
|
|
@@ -532,15 +520,23 @@ class CodexNotifyAdapter(BaseAdapter):
|
|
|
532
520
|
max_seen_threads: Max threads to track (default 1000). Oldest evicted when full.
|
|
533
521
|
"""
|
|
534
522
|
self._hook_manager = hook_manager
|
|
535
|
-
self._machine_id: str | None = None
|
|
536
523
|
# Track threads we've seen using LRU cache to avoid unbounded growth
|
|
537
524
|
self._max_seen_threads = max_seen_threads or self.DEFAULT_MAX_SEEN_THREADS
|
|
538
525
|
self._seen_threads: OrderedDict[str, bool] = OrderedDict()
|
|
526
|
+
self._machine_id: str | None = None
|
|
527
|
+
|
|
528
|
+
def _get_machine_id(self) -> str | None:
|
|
529
|
+
"""Get machine ID with caching and daemon fallback."""
|
|
530
|
+
if self._machine_id:
|
|
531
|
+
return self._machine_id
|
|
539
532
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
533
|
+
# Try daemon first
|
|
534
|
+
self._machine_id = _get_daemon_machine_id()
|
|
535
|
+
|
|
536
|
+
# Fallback to generated if daemon not available
|
|
537
|
+
if not self._machine_id:
|
|
543
538
|
self._machine_id = _get_machine_id()
|
|
539
|
+
|
|
544
540
|
return self._machine_id
|
|
545
541
|
|
|
546
542
|
def _mark_thread_seen(self, thread_id: str) -> None:
|
|
@@ -716,7 +712,6 @@ class CodexNotifyAdapter(BaseAdapter):
|
|
|
716
712
|
|
|
717
713
|
|
|
718
714
|
__all__ = [
|
|
719
|
-
"_get_machine_id",
|
|
720
715
|
"CodexAdapter",
|
|
721
716
|
"CodexNotifyAdapter",
|
|
722
717
|
]
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Copilot adapter for hook translation.
|
|
2
|
+
|
|
3
|
+
This adapter translates between GitHub Copilot CLI's hook format and the unified
|
|
4
|
+
HookEvent/HookResponse models.
|
|
5
|
+
|
|
6
|
+
Copilot Hook Types (similar to Claude Code but with camelCase):
|
|
7
|
+
- sessionStart, sessionEnd: Session lifecycle
|
|
8
|
+
- userPromptSubmitted: Before user prompt validation
|
|
9
|
+
- preToolUse, postToolUse: Tool execution lifecycle
|
|
10
|
+
- errorOccurred: Error notifications
|
|
11
|
+
|
|
12
|
+
Key differences from Claude Code:
|
|
13
|
+
- Uses camelCase hook names (preToolUse vs pre-tool-use)
|
|
14
|
+
- Uses `toolName` instead of `tool_name`
|
|
15
|
+
- Uses `toolArgs` instead of `tool_input`
|
|
16
|
+
- Uses `toolResult.textResultForLlm` for tool output
|
|
17
|
+
- Response uses `permissionDecision` (allow/deny) instead of continue/decision
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
from gobby.adapters.base import BaseAdapter
|
|
24
|
+
from gobby.hooks.events import HookEvent, HookEventType, HookResponse, SessionSource
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from gobby.hooks.hook_manager import HookManager
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CopilotAdapter(BaseAdapter):
|
|
31
|
+
"""Adapter for GitHub Copilot CLI hook translation.
|
|
32
|
+
|
|
33
|
+
This adapter:
|
|
34
|
+
1. Translates Copilot's camelCase hook payloads to unified HookEvent
|
|
35
|
+
2. Translates HookResponse back to Copilot's expected format
|
|
36
|
+
3. Normalizes tool names and arguments to standard format
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
source = SessionSource.COPILOT
|
|
40
|
+
|
|
41
|
+
# Event type mapping: Copilot hook names -> unified HookEventType
|
|
42
|
+
# Copilot uses camelCase hook names in the payload's "hook_type" field
|
|
43
|
+
EVENT_MAP: dict[str, HookEventType] = {
|
|
44
|
+
"sessionStart": HookEventType.SESSION_START,
|
|
45
|
+
"sessionEnd": HookEventType.SESSION_END,
|
|
46
|
+
"userPromptSubmitted": HookEventType.BEFORE_AGENT,
|
|
47
|
+
"preToolUse": HookEventType.BEFORE_TOOL,
|
|
48
|
+
"postToolUse": HookEventType.AFTER_TOOL,
|
|
49
|
+
"errorOccurred": HookEventType.NOTIFICATION,
|
|
50
|
+
"stop": HookEventType.STOP,
|
|
51
|
+
"preCompact": HookEventType.PRE_COMPACT,
|
|
52
|
+
"notification": HookEventType.NOTIFICATION,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Map Copilot hook types to PascalCase event names for response
|
|
56
|
+
# Uses incoming camelCase hook_type (e.g., "preToolUse" -> "PreToolUse")
|
|
57
|
+
HOOK_EVENT_NAME_MAP: dict[str, str] = {
|
|
58
|
+
"sessionStart": "SessionStart",
|
|
59
|
+
"sessionEnd": "SessionEnd",
|
|
60
|
+
"userPromptSubmitted": "UserPromptSubmitted",
|
|
61
|
+
"stop": "Stop",
|
|
62
|
+
"preToolUse": "PreToolUse",
|
|
63
|
+
"postToolUse": "PostToolUse",
|
|
64
|
+
"preCompact": "PreCompact",
|
|
65
|
+
"notification": "Notification",
|
|
66
|
+
"errorOccurred": "Notification",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
def __init__(self, hook_manager: "HookManager | None" = None):
|
|
70
|
+
"""Initialize the Copilot adapter.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
hook_manager: Reference to HookManager for handling events.
|
|
74
|
+
If None, the adapter can only translate (not handle events).
|
|
75
|
+
"""
|
|
76
|
+
self._hook_manager = hook_manager
|
|
77
|
+
|
|
78
|
+
def _normalize_event_data(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
|
79
|
+
"""Normalize Copilot event data for CLI-agnostic processing.
|
|
80
|
+
|
|
81
|
+
Copilot uses camelCase field names which need to be translated to
|
|
82
|
+
snake_case for unified processing.
|
|
83
|
+
|
|
84
|
+
Normalizations performed:
|
|
85
|
+
1. toolName → tool_name
|
|
86
|
+
2. toolArgs → tool_input
|
|
87
|
+
3. toolResult.textResultForLlm → tool_output
|
|
88
|
+
4. sessionId → session_id (if present at top level)
|
|
89
|
+
5. Extract MCP info from toolArgs for call_tool calls
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
input_data: Raw input data from Copilot CLI
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Enriched data dict with normalized fields added
|
|
96
|
+
"""
|
|
97
|
+
# Start with a copy to avoid mutating original
|
|
98
|
+
data = dict(input_data)
|
|
99
|
+
|
|
100
|
+
# 1. Normalize toolName → tool_name
|
|
101
|
+
if "toolName" in data and "tool_name" not in data:
|
|
102
|
+
data["tool_name"] = data["toolName"]
|
|
103
|
+
|
|
104
|
+
# 2. Normalize toolArgs → tool_input
|
|
105
|
+
if "toolArgs" in data and "tool_input" not in data:
|
|
106
|
+
data["tool_input"] = data["toolArgs"]
|
|
107
|
+
|
|
108
|
+
# 3. Normalize toolResult → tool_output
|
|
109
|
+
tool_result = data.get("toolResult", {})
|
|
110
|
+
if tool_result and "tool_output" not in data:
|
|
111
|
+
# Copilot nests result in textResultForLlm
|
|
112
|
+
if isinstance(tool_result, dict):
|
|
113
|
+
text_result = tool_result.get("textResultForLlm")
|
|
114
|
+
if text_result:
|
|
115
|
+
data["tool_output"] = text_result
|
|
116
|
+
# Also check for resultType to detect failures
|
|
117
|
+
result_type = tool_result.get("resultType")
|
|
118
|
+
if result_type == "error":
|
|
119
|
+
data["is_error"] = True
|
|
120
|
+
else:
|
|
121
|
+
data["tool_output"] = tool_result
|
|
122
|
+
|
|
123
|
+
# 4. Extract MCP info from nested toolArgs for call_tool calls
|
|
124
|
+
tool_name = data.get("tool_name", "")
|
|
125
|
+
tool_input = data.get("tool_input", {}) or {}
|
|
126
|
+
if tool_name in ("call_tool", "mcp__gobby__call_tool"):
|
|
127
|
+
if "mcp_server" not in data:
|
|
128
|
+
data["mcp_server"] = tool_input.get("server_name")
|
|
129
|
+
if "mcp_tool" not in data:
|
|
130
|
+
data["mcp_tool"] = tool_input.get("tool_name")
|
|
131
|
+
|
|
132
|
+
return data
|
|
133
|
+
|
|
134
|
+
def translate_to_hook_event(self, native_event: dict[str, Any]) -> HookEvent:
|
|
135
|
+
"""Convert Copilot native event to unified HookEvent.
|
|
136
|
+
|
|
137
|
+
Copilot payloads have the structure:
|
|
138
|
+
{
|
|
139
|
+
"hook_type": "preToolUse", # camelCase hook name
|
|
140
|
+
"input_data": {
|
|
141
|
+
"session_id": "abc123",
|
|
142
|
+
"cwd": "/path/to/project",
|
|
143
|
+
"toolName": "Read",
|
|
144
|
+
"toolArgs": {"path": "/file.py"},
|
|
145
|
+
# For post-tool:
|
|
146
|
+
"toolResult": {
|
|
147
|
+
"resultType": "success",
|
|
148
|
+
"textResultForLlm": "file contents..."
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
native_event: Raw payload from Copilot hook dispatcher
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
Unified HookEvent with normalized fields.
|
|
158
|
+
"""
|
|
159
|
+
hook_type = native_event.get("hook_type", "")
|
|
160
|
+
input_data = native_event.get("input_data", {})
|
|
161
|
+
|
|
162
|
+
# Map Copilot hook type to unified event type
|
|
163
|
+
# Fall back to NOTIFICATION for unknown types (fail-open)
|
|
164
|
+
event_type = self.EVENT_MAP.get(hook_type, HookEventType.NOTIFICATION)
|
|
165
|
+
|
|
166
|
+
# Extract session_id
|
|
167
|
+
session_id = input_data.get("session_id", "")
|
|
168
|
+
|
|
169
|
+
# Check for error in tool result
|
|
170
|
+
tool_result = input_data.get("toolResult", {})
|
|
171
|
+
is_error = False
|
|
172
|
+
if isinstance(tool_result, dict):
|
|
173
|
+
is_error = tool_result.get("resultType") == "error"
|
|
174
|
+
|
|
175
|
+
metadata = {"is_failure": is_error} if is_error else {}
|
|
176
|
+
|
|
177
|
+
# Normalize event data for CLI-agnostic processing
|
|
178
|
+
normalized_data = self._normalize_event_data(input_data)
|
|
179
|
+
|
|
180
|
+
return HookEvent(
|
|
181
|
+
event_type=event_type,
|
|
182
|
+
session_id=session_id,
|
|
183
|
+
source=self.source,
|
|
184
|
+
timestamp=datetime.now(UTC),
|
|
185
|
+
machine_id=input_data.get("machine_id"),
|
|
186
|
+
cwd=input_data.get("cwd"),
|
|
187
|
+
data=normalized_data,
|
|
188
|
+
metadata=metadata,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
def translate_from_hook_response(
|
|
192
|
+
self, response: HookResponse, hook_type: str | None = None
|
|
193
|
+
) -> dict[str, Any]:
|
|
194
|
+
"""Convert HookResponse to Copilot's expected format.
|
|
195
|
+
|
|
196
|
+
Copilot expects responses in this format:
|
|
197
|
+
{
|
|
198
|
+
"permissionDecision": "allow" | "deny",
|
|
199
|
+
"permissionDecisionReason": "...", # Optional reason
|
|
200
|
+
"hookSpecificOutput": {
|
|
201
|
+
"additionalContext": "..." # Context to inject
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
response: Unified HookResponse from HookManager.
|
|
207
|
+
hook_type: Original Copilot hook type (e.g., "preToolUse")
|
|
208
|
+
Used to format hookSpecificOutput appropriately.
|
|
209
|
+
|
|
210
|
+
Returns:
|
|
211
|
+
Dict in Copilot's expected format.
|
|
212
|
+
"""
|
|
213
|
+
# Map decision to Copilot's permissionDecision format
|
|
214
|
+
# Copilot uses "allow"/"deny" directly
|
|
215
|
+
if response.decision in ("deny", "block"):
|
|
216
|
+
permission_decision = "deny"
|
|
217
|
+
else:
|
|
218
|
+
permission_decision = "allow"
|
|
219
|
+
|
|
220
|
+
result: dict[str, Any] = {
|
|
221
|
+
"permissionDecision": permission_decision,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
# Add reason if present
|
|
225
|
+
if response.reason:
|
|
226
|
+
result["permissionDecisionReason"] = response.reason
|
|
227
|
+
|
|
228
|
+
# Add system message if present
|
|
229
|
+
if response.system_message:
|
|
230
|
+
result["systemMessage"] = response.system_message
|
|
231
|
+
|
|
232
|
+
# Build hookSpecificOutput with additionalContext for model context injection
|
|
233
|
+
hook_event_name = self.HOOK_EVENT_NAME_MAP.get(hook_type or "", "Unknown")
|
|
234
|
+
additional_context_parts: list[str] = []
|
|
235
|
+
|
|
236
|
+
# Add workflow-injected context
|
|
237
|
+
if response.context:
|
|
238
|
+
additional_context_parts.append(response.context)
|
|
239
|
+
|
|
240
|
+
# Add session identifiers from metadata
|
|
241
|
+
if response.metadata:
|
|
242
|
+
gobby_session_id = response.metadata.get("session_id")
|
|
243
|
+
session_ref = response.metadata.get("session_ref")
|
|
244
|
+
external_id = response.metadata.get("external_id")
|
|
245
|
+
is_first_hook = response.metadata.get("_first_hook_for_session", False)
|
|
246
|
+
|
|
247
|
+
if gobby_session_id:
|
|
248
|
+
if is_first_hook:
|
|
249
|
+
# First hook: inject full metadata
|
|
250
|
+
context_lines = []
|
|
251
|
+
if session_ref:
|
|
252
|
+
context_lines.append(
|
|
253
|
+
f"Gobby Session ID: {session_ref} (or {gobby_session_id})"
|
|
254
|
+
)
|
|
255
|
+
else:
|
|
256
|
+
context_lines.append(f"Gobby Session ID: {gobby_session_id}")
|
|
257
|
+
if external_id:
|
|
258
|
+
context_lines.append(
|
|
259
|
+
f"CLI-Specific Session ID (external_id): {external_id}"
|
|
260
|
+
)
|
|
261
|
+
if response.metadata.get("parent_session_id"):
|
|
262
|
+
context_lines.append(
|
|
263
|
+
f"parent_session_id: {response.metadata['parent_session_id']}"
|
|
264
|
+
)
|
|
265
|
+
if response.metadata.get("machine_id"):
|
|
266
|
+
context_lines.append(f"machine_id: {response.metadata['machine_id']}")
|
|
267
|
+
if response.metadata.get("project_id"):
|
|
268
|
+
context_lines.append(f"project_id: {response.metadata['project_id']}")
|
|
269
|
+
# Add terminal context
|
|
270
|
+
if response.metadata.get("terminal_term_program"):
|
|
271
|
+
context_lines.append(
|
|
272
|
+
f"terminal: {response.metadata['terminal_term_program']}"
|
|
273
|
+
)
|
|
274
|
+
if response.metadata.get("terminal_parent_pid"):
|
|
275
|
+
context_lines.append(
|
|
276
|
+
f"parent_pid: {response.metadata['terminal_parent_pid']}"
|
|
277
|
+
)
|
|
278
|
+
additional_context_parts.append("\n".join(context_lines))
|
|
279
|
+
else:
|
|
280
|
+
# Subsequent hooks: inject minimal session ref only
|
|
281
|
+
if session_ref:
|
|
282
|
+
additional_context_parts.append(f"Gobby Session ID: {session_ref}")
|
|
283
|
+
|
|
284
|
+
# Build hookSpecificOutput if we have any context to inject
|
|
285
|
+
valid_hook_event_names = {
|
|
286
|
+
"PreToolUse",
|
|
287
|
+
"UserPromptSubmitted",
|
|
288
|
+
"PostToolUse",
|
|
289
|
+
"SessionStart",
|
|
290
|
+
}
|
|
291
|
+
if additional_context_parts and hook_event_name in valid_hook_event_names:
|
|
292
|
+
result["hookSpecificOutput"] = {
|
|
293
|
+
"hookEventName": hook_event_name,
|
|
294
|
+
"additionalContext": "\n\n".join(additional_context_parts),
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return result
|
|
298
|
+
|
|
299
|
+
def handle_native(
|
|
300
|
+
self, native_event: dict[str, Any], hook_manager: "HookManager"
|
|
301
|
+
) -> dict[str, Any]:
|
|
302
|
+
"""Main entry point for HTTP endpoint.
|
|
303
|
+
|
|
304
|
+
Translates native Copilot event, processes through HookManager,
|
|
305
|
+
and returns response in Copilot's expected format.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
native_event: Raw payload from Copilot hook dispatcher
|
|
309
|
+
hook_manager: HookManager instance for processing.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
Response dict in Copilot's expected format.
|
|
313
|
+
"""
|
|
314
|
+
# Translate to unified HookEvent
|
|
315
|
+
hook_event = self.translate_to_hook_event(native_event)
|
|
316
|
+
|
|
317
|
+
# Get original hook type for response formatting
|
|
318
|
+
hook_type = native_event.get("hook_type", "")
|
|
319
|
+
|
|
320
|
+
# Process through HookManager
|
|
321
|
+
hook_response = hook_manager.handle(hook_event)
|
|
322
|
+
|
|
323
|
+
# Translate response back to Copilot format
|
|
324
|
+
return self.translate_from_hook_response(hook_response, hook_type=hook_type)
|