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
gobby/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Gobby - Local-first daemon for multi-CLI session management."""
2
2
 
3
- __version__ = "0.2.9"
3
+ __version__ = "0.2.11"
@@ -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. Process through HookManager
72
- 3. Translate response back to native format
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)
@@ -292,8 +292,8 @@ class ClaudeCodeAdapter(BaseAdapter):
292
292
 
293
293
  # Build hookSpecificOutput if we have any context to inject
294
294
  # Only include hookSpecificOutput for hook types that Claude Code's schema accepts
295
- # Valid hookEventName values: PreToolUse, UserPromptSubmit, PostToolUse
296
- valid_hook_event_names = {"PreToolUse", "UserPromptSubmit", "PostToolUse"}
295
+ # Valid hookEventName values: PreToolUse, UserPromptSubmit, PostToolUse, SessionStart
296
+ valid_hook_event_names = {"PreToolUse", "UserPromptSubmit", "PostToolUse", "SessionStart"}
297
297
  if additional_context_parts and hook_event_name in valid_hook_event_names:
298
298
  result["hookSpecificOutput"] = {
299
299
  "hookEventName": hook_event_name,
@@ -40,46 +40,26 @@ logger = logging.getLogger(__name__)
40
40
  # =============================================================================
41
41
 
42
42
 
43
- def _get_machine_id() -> str:
44
- """Get or generate a stable machine identifier.
43
+ def _get_daemon_machine_id() -> str | None:
44
+ """Get machine ID from the daemon's centralized utility.
45
45
 
46
- Priority:
47
- 1. Hostname (if available)
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 pathlib import Path
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
- # Try hostname first
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 or generate a machine identifier."""
182
- if self._machine_id is None:
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
- def _get_machine_id(self) -> str:
541
- """Get or generate a machine identifier."""
542
- if self._machine_id is None:
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)