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
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Hook Dispatcher - Routes Cursor hooks to HookManager.
|
|
3
|
+
|
|
4
|
+
This is a thin wrapper script that receives hook calls from Cursor
|
|
5
|
+
and routes them to the appropriate handler via HookManager.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
hook_dispatcher.py --type sessionStart < input.json > output.json
|
|
9
|
+
hook_dispatcher.py --type preToolUse --debug < input.json > output.json
|
|
10
|
+
|
|
11
|
+
Exit Codes:
|
|
12
|
+
0 - Success
|
|
13
|
+
1 - General error (logged, continues)
|
|
14
|
+
2 - Block action (Cursor interprets as deny)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
# Default daemon configuration
|
|
24
|
+
DEFAULT_DAEMON_PORT = 60887
|
|
25
|
+
DEFAULT_CONFIG_PATH = "~/.gobby/config.yaml"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_daemon_url() -> str:
|
|
29
|
+
"""Get the daemon HTTP URL from config file."""
|
|
30
|
+
config_path = Path(DEFAULT_CONFIG_PATH).expanduser()
|
|
31
|
+
|
|
32
|
+
if config_path.exists():
|
|
33
|
+
try:
|
|
34
|
+
import yaml
|
|
35
|
+
|
|
36
|
+
with open(config_path) as f:
|
|
37
|
+
config = yaml.safe_load(f) or {}
|
|
38
|
+
port = config.get("daemon_port", DEFAULT_DAEMON_PORT)
|
|
39
|
+
except Exception:
|
|
40
|
+
port = DEFAULT_DAEMON_PORT
|
|
41
|
+
else:
|
|
42
|
+
port = DEFAULT_DAEMON_PORT
|
|
43
|
+
|
|
44
|
+
return f"http://localhost:{port}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_terminal_context() -> dict[str, str | int | None]:
|
|
48
|
+
"""Capture terminal/process context for session correlation."""
|
|
49
|
+
context: dict[str, str | int | None] = {}
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
context["parent_pid"] = os.getppid()
|
|
53
|
+
except Exception:
|
|
54
|
+
context["parent_pid"] = None
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
context["tty"] = os.ttyname(0)
|
|
58
|
+
except Exception:
|
|
59
|
+
context["tty"] = None
|
|
60
|
+
|
|
61
|
+
context["term_session_id"] = os.environ.get("TERM_SESSION_ID")
|
|
62
|
+
context["iterm_session_id"] = os.environ.get("ITERM_SESSION_ID")
|
|
63
|
+
context["vscode_terminal_id"] = os.environ.get("VSCODE_GIT_ASKPASS_NODE")
|
|
64
|
+
context["tmux_pane"] = os.environ.get("TMUX_PANE")
|
|
65
|
+
context["kitty_window_id"] = os.environ.get("KITTY_WINDOW_ID")
|
|
66
|
+
context["alacritty_socket"] = os.environ.get("ALACRITTY_SOCKET")
|
|
67
|
+
context["term_program"] = os.environ.get("TERM_PROGRAM")
|
|
68
|
+
|
|
69
|
+
return context
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def parse_arguments() -> argparse.Namespace:
|
|
73
|
+
"""Parse command line arguments."""
|
|
74
|
+
parser = argparse.ArgumentParser(description="Cursor Hook Dispatcher")
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--type",
|
|
77
|
+
required=True,
|
|
78
|
+
help="Hook type (e.g., sessionStart, preToolUse)",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--debug",
|
|
82
|
+
action="store_true",
|
|
83
|
+
help="Enable debug logging",
|
|
84
|
+
)
|
|
85
|
+
return parser.parse_args()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def check_daemon_running(timeout: float = 0.5) -> bool:
|
|
89
|
+
"""Check if gobby daemon is active and responding."""
|
|
90
|
+
try:
|
|
91
|
+
import httpx
|
|
92
|
+
|
|
93
|
+
daemon_url = get_daemon_url()
|
|
94
|
+
response = httpx.get(
|
|
95
|
+
f"{daemon_url}/admin/status",
|
|
96
|
+
timeout=timeout,
|
|
97
|
+
follow_redirects=False,
|
|
98
|
+
)
|
|
99
|
+
return response.status_code == 200
|
|
100
|
+
except Exception:
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main() -> int:
|
|
105
|
+
"""Main dispatcher execution."""
|
|
106
|
+
try:
|
|
107
|
+
args = parse_arguments()
|
|
108
|
+
except (argparse.ArgumentError, SystemExit):
|
|
109
|
+
print(json.dumps({}))
|
|
110
|
+
return 2
|
|
111
|
+
|
|
112
|
+
hook_type = args.type
|
|
113
|
+
debug_mode = args.debug
|
|
114
|
+
|
|
115
|
+
# Check if daemon is running
|
|
116
|
+
if not check_daemon_running():
|
|
117
|
+
critical_hooks = {"sessionStart", "sessionEnd", "preCompact"}
|
|
118
|
+
if hook_type in critical_hooks:
|
|
119
|
+
print(
|
|
120
|
+
f"Gobby daemon is not running. Start with 'gobby start' before continuing. "
|
|
121
|
+
f"({hook_type} requires daemon for session state management)",
|
|
122
|
+
file=sys.stderr,
|
|
123
|
+
)
|
|
124
|
+
return 2
|
|
125
|
+
else:
|
|
126
|
+
print(
|
|
127
|
+
json.dumps(
|
|
128
|
+
{"status": "daemon_not_running", "message": "gobby daemon is not running"}
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
import logging
|
|
134
|
+
|
|
135
|
+
logger = logging.getLogger("gobby.hooks.dispatcher.cursor")
|
|
136
|
+
if debug_mode:
|
|
137
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
138
|
+
else:
|
|
139
|
+
logging.basicConfig(level=logging.WARNING, handlers=[])
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
input_data = json.load(sys.stdin)
|
|
143
|
+
|
|
144
|
+
if hook_type == "sessionStart":
|
|
145
|
+
input_data["terminal_context"] = get_terminal_context()
|
|
146
|
+
|
|
147
|
+
logger.info(f"[{hook_type}] Received input keys: {list(input_data.keys())}")
|
|
148
|
+
|
|
149
|
+
if debug_mode:
|
|
150
|
+
logger.debug(f"Input data: {input_data}")
|
|
151
|
+
|
|
152
|
+
except json.JSONDecodeError as e:
|
|
153
|
+
if debug_mode:
|
|
154
|
+
logger.error(f"JSON decode error: {e}")
|
|
155
|
+
print(json.dumps({}))
|
|
156
|
+
return 2
|
|
157
|
+
|
|
158
|
+
import httpx
|
|
159
|
+
|
|
160
|
+
daemon_url = get_daemon_url()
|
|
161
|
+
try:
|
|
162
|
+
response = httpx.post(
|
|
163
|
+
f"{daemon_url}/hooks/execute",
|
|
164
|
+
json={
|
|
165
|
+
"hook_type": hook_type,
|
|
166
|
+
"input_data": input_data,
|
|
167
|
+
"source": "cursor",
|
|
168
|
+
},
|
|
169
|
+
timeout=90.0,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
if response.status_code == 200:
|
|
173
|
+
result = response.json()
|
|
174
|
+
|
|
175
|
+
if debug_mode:
|
|
176
|
+
logger.debug(f"Output data: {result}")
|
|
177
|
+
|
|
178
|
+
# Check for block decision
|
|
179
|
+
if result.get("continue") is False or result.get("decision") == "deny":
|
|
180
|
+
reason = result.get("user_message") or result.get("reason") or "Blocked by hook"
|
|
181
|
+
print(reason, file=sys.stderr)
|
|
182
|
+
return 2
|
|
183
|
+
|
|
184
|
+
if result and result != {}:
|
|
185
|
+
print(json.dumps(result))
|
|
186
|
+
|
|
187
|
+
return 0
|
|
188
|
+
else:
|
|
189
|
+
error_detail = response.text
|
|
190
|
+
logger.error(
|
|
191
|
+
f"Daemon returned error: status={response.status_code}, detail={error_detail}"
|
|
192
|
+
)
|
|
193
|
+
print(json.dumps({"status": "error", "message": f"Daemon error: {error_detail}"}))
|
|
194
|
+
return 1
|
|
195
|
+
|
|
196
|
+
except Exception as e:
|
|
197
|
+
logger.error(f"Hook execution failed: {e}", exc_info=True)
|
|
198
|
+
print(json.dumps({"status": "error", "message": str(e)}))
|
|
199
|
+
return 1
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
sys.exit(main())
|
|
@@ -108,6 +108,14 @@ def get_terminal_context() -> dict[str, str | int | bool | None]:
|
|
|
108
108
|
# Generic terminal program identifier (set by many terminals)
|
|
109
109
|
context["term_program"] = os.environ.get("TERM_PROGRAM")
|
|
110
110
|
|
|
111
|
+
# Gobby session context (set when spawned by Gobby)
|
|
112
|
+
# These allow the daemon to link this Gemini session to a pre-created Gobby session
|
|
113
|
+
context["gobby_session_id"] = os.environ.get("GOBBY_SESSION_ID")
|
|
114
|
+
context["gobby_parent_session_id"] = os.environ.get("GOBBY_PARENT_SESSION_ID")
|
|
115
|
+
context["gobby_agent_run_id"] = os.environ.get("GOBBY_AGENT_RUN_ID")
|
|
116
|
+
context["gobby_project_id"] = os.environ.get("GOBBY_PROJECT_ID")
|
|
117
|
+
context["gobby_workflow_name"] = os.environ.get("GOBBY_WORKFLOW_NAME")
|
|
118
|
+
|
|
111
119
|
return context
|
|
112
120
|
|
|
113
121
|
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Hook Dispatcher - Routes Windsurf (Cascade) hooks to HookManager.
|
|
3
|
+
|
|
4
|
+
This is a thin wrapper script that receives hook calls from Windsurf
|
|
5
|
+
and routes them to the appropriate handler via HookManager.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
hook_dispatcher.py --type pre_user_prompt < input.json > output.json
|
|
9
|
+
hook_dispatcher.py --type pre_run_command --debug < input.json > output.json
|
|
10
|
+
|
|
11
|
+
Exit Codes:
|
|
12
|
+
0 - Success
|
|
13
|
+
1 - General error (logged, continues)
|
|
14
|
+
2 - Block action (Windsurf 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="Windsurf Hook Dispatcher")
|
|
75
|
+
parser.add_argument(
|
|
76
|
+
"--type",
|
|
77
|
+
required=True,
|
|
78
|
+
help="Hook type (e.g., pre_user_prompt, pre_run_command)",
|
|
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
|
+
# Note: Windsurf doesn't have explicit session start/end, so pre_user_prompt is critical
|
|
117
|
+
if not check_daemon_running():
|
|
118
|
+
critical_hooks = {"pre_user_prompt"}
|
|
119
|
+
if hook_type in critical_hooks:
|
|
120
|
+
print(
|
|
121
|
+
f"Gobby daemon is not running. Start with 'gobby start' before continuing. "
|
|
122
|
+
f"({hook_type} requires daemon for session state management)",
|
|
123
|
+
file=sys.stderr,
|
|
124
|
+
)
|
|
125
|
+
return 2
|
|
126
|
+
else:
|
|
127
|
+
print(
|
|
128
|
+
json.dumps(
|
|
129
|
+
{"status": "daemon_not_running", "message": "gobby daemon is not running"}
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
import logging
|
|
135
|
+
|
|
136
|
+
logger = logging.getLogger("gobby.hooks.dispatcher.windsurf")
|
|
137
|
+
if debug_mode:
|
|
138
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
139
|
+
else:
|
|
140
|
+
logging.basicConfig(level=logging.WARNING, handlers=[])
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
input_data = json.load(sys.stdin)
|
|
144
|
+
|
|
145
|
+
# Inject terminal context for first prompt (acts as session start)
|
|
146
|
+
if hook_type == "pre_user_prompt":
|
|
147
|
+
input_data["terminal_context"] = get_terminal_context()
|
|
148
|
+
|
|
149
|
+
logger.info(f"[{hook_type}] Received input keys: {list(input_data.keys())}")
|
|
150
|
+
|
|
151
|
+
if debug_mode:
|
|
152
|
+
logger.debug(f"Input data: {input_data}")
|
|
153
|
+
|
|
154
|
+
except json.JSONDecodeError as e:
|
|
155
|
+
if debug_mode:
|
|
156
|
+
logger.error(f"JSON decode error: {e}")
|
|
157
|
+
print(json.dumps({}))
|
|
158
|
+
return 2
|
|
159
|
+
|
|
160
|
+
import httpx
|
|
161
|
+
|
|
162
|
+
daemon_url = get_daemon_url()
|
|
163
|
+
try:
|
|
164
|
+
response = httpx.post(
|
|
165
|
+
f"{daemon_url}/hooks/execute",
|
|
166
|
+
json={
|
|
167
|
+
"hook_type": hook_type,
|
|
168
|
+
"input_data": input_data,
|
|
169
|
+
"source": "windsurf",
|
|
170
|
+
},
|
|
171
|
+
timeout=90.0,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if response.status_code == 200:
|
|
175
|
+
result = response.json()
|
|
176
|
+
|
|
177
|
+
if debug_mode:
|
|
178
|
+
logger.debug(f"Output data: {result}")
|
|
179
|
+
|
|
180
|
+
# Check for block decision
|
|
181
|
+
if result.get("decision") == "deny":
|
|
182
|
+
reason = result.get("reason") or "Blocked by hook"
|
|
183
|
+
print(reason, file=sys.stderr)
|
|
184
|
+
return 2
|
|
185
|
+
|
|
186
|
+
if result and result != {}:
|
|
187
|
+
print(json.dumps(result))
|
|
188
|
+
|
|
189
|
+
return 0
|
|
190
|
+
else:
|
|
191
|
+
error_detail = response.text
|
|
192
|
+
logger.error(
|
|
193
|
+
f"Daemon returned error: status={response.status_code}, detail={error_detail}"
|
|
194
|
+
)
|
|
195
|
+
print(json.dumps({"status": "error", "message": f"Daemon error: {error_detail}"}))
|
|
196
|
+
return 1
|
|
197
|
+
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.error(f"Hook execution failed: {e}", exc_info=True)
|
|
200
|
+
print(json.dumps({"status": "error", "message": str(e)}))
|
|
201
|
+
return 1
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
sys.exit(main())
|
gobby/llm/__init__.py
CHANGED
|
@@ -10,7 +10,15 @@ Usage:
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
from gobby.llm.base import AuthMode, LLMProvider
|
|
13
|
-
from gobby.llm.claude import
|
|
13
|
+
from gobby.llm.claude import (
|
|
14
|
+
ChatEvent,
|
|
15
|
+
DoneEvent,
|
|
16
|
+
MCPToolResult,
|
|
17
|
+
TextChunk,
|
|
18
|
+
ToolCall,
|
|
19
|
+
ToolCallEvent,
|
|
20
|
+
ToolResultEvent,
|
|
21
|
+
)
|
|
14
22
|
from gobby.llm.claude_executor import ClaudeExecutor
|
|
15
23
|
from gobby.llm.executor import (
|
|
16
24
|
AgentExecutor,
|
|
@@ -27,14 +35,19 @@ __all__ = [
|
|
|
27
35
|
"AgentExecutor",
|
|
28
36
|
"AgentResult",
|
|
29
37
|
"AuthMode",
|
|
38
|
+
"ChatEvent",
|
|
30
39
|
"ClaudeExecutor",
|
|
40
|
+
"DoneEvent",
|
|
31
41
|
"LLMProvider",
|
|
32
42
|
"LLMService",
|
|
33
43
|
"MCPToolResult",
|
|
44
|
+
"TextChunk",
|
|
34
45
|
"ToolCall",
|
|
46
|
+
"ToolCallEvent",
|
|
35
47
|
"ToolCallRecord",
|
|
36
48
|
"ToolHandler",
|
|
37
49
|
"ToolResult",
|
|
50
|
+
"ToolResultEvent",
|
|
38
51
|
"ToolSchema",
|
|
39
52
|
"create_llm_service",
|
|
40
53
|
]
|
gobby/llm/claude.py
CHANGED
|
@@ -12,6 +12,7 @@ import logging
|
|
|
12
12
|
import os
|
|
13
13
|
import shutil
|
|
14
14
|
import time
|
|
15
|
+
from collections.abc import AsyncIterator
|
|
15
16
|
from dataclasses import dataclass, field
|
|
16
17
|
from typing import Any, Literal, cast
|
|
17
18
|
|
|
@@ -62,6 +63,67 @@ class MCPToolResult:
|
|
|
62
63
|
"""List of tool calls made during generation."""
|
|
63
64
|
|
|
64
65
|
|
|
66
|
+
# Streaming event types for stream_with_mcp_tools
|
|
67
|
+
@dataclass
|
|
68
|
+
class TextChunk:
|
|
69
|
+
"""A chunk of text from the streaming response."""
|
|
70
|
+
|
|
71
|
+
content: str
|
|
72
|
+
"""The text content."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class ToolCallEvent:
|
|
77
|
+
"""Event when a tool is being called."""
|
|
78
|
+
|
|
79
|
+
tool_call_id: str
|
|
80
|
+
"""Unique ID for this tool call."""
|
|
81
|
+
|
|
82
|
+
tool_name: str
|
|
83
|
+
"""Full tool name (e.g., mcp__gobby-tasks__create_task)."""
|
|
84
|
+
|
|
85
|
+
server_name: str
|
|
86
|
+
"""Extracted server name (e.g., gobby-tasks)."""
|
|
87
|
+
|
|
88
|
+
arguments: dict[str, Any]
|
|
89
|
+
"""Arguments passed to the tool."""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class ToolResultEvent:
|
|
94
|
+
"""Event when a tool call completes."""
|
|
95
|
+
|
|
96
|
+
tool_call_id: str
|
|
97
|
+
"""ID matching the original ToolCallEvent."""
|
|
98
|
+
|
|
99
|
+
success: bool
|
|
100
|
+
"""Whether the tool call succeeded."""
|
|
101
|
+
|
|
102
|
+
result: Any = None
|
|
103
|
+
"""Result data if successful."""
|
|
104
|
+
|
|
105
|
+
error: str | None = None
|
|
106
|
+
"""Error message if failed."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class DoneEvent:
|
|
111
|
+
"""Event when streaming is complete."""
|
|
112
|
+
|
|
113
|
+
tool_calls_count: int
|
|
114
|
+
"""Total number of tool calls made."""
|
|
115
|
+
|
|
116
|
+
cost_usd: float | None = None
|
|
117
|
+
"""Cost in USD if available."""
|
|
118
|
+
|
|
119
|
+
duration_ms: float | None = None
|
|
120
|
+
"""Duration in milliseconds if available."""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# Union type for all streaming events
|
|
124
|
+
ChatEvent = TextChunk | ToolCallEvent | ToolResultEvent | DoneEvent
|
|
125
|
+
|
|
126
|
+
|
|
65
127
|
logger = logging.getLogger(__name__)
|
|
66
128
|
|
|
67
129
|
|
|
@@ -661,7 +723,7 @@ class ClaudeLLMProvider(LLMProvider):
|
|
|
661
723
|
parts = full_tool_name.split("__")
|
|
662
724
|
if len(parts) >= 2:
|
|
663
725
|
return parts[1]
|
|
664
|
-
return "
|
|
726
|
+
return "builtin"
|
|
665
727
|
|
|
666
728
|
# Run async query
|
|
667
729
|
async def _run_query() -> str:
|
|
@@ -727,6 +789,160 @@ class ClaudeLLMProvider(LLMProvider):
|
|
|
727
789
|
tool_calls=tool_calls,
|
|
728
790
|
)
|
|
729
791
|
|
|
792
|
+
async def stream_with_mcp_tools(
|
|
793
|
+
self,
|
|
794
|
+
prompt: str,
|
|
795
|
+
allowed_tools: list[str],
|
|
796
|
+
system_prompt: str | None = None,
|
|
797
|
+
model: str | None = None,
|
|
798
|
+
max_turns: int = 10,
|
|
799
|
+
) -> AsyncIterator[ChatEvent]:
|
|
800
|
+
"""
|
|
801
|
+
Stream generation with MCP tools, yielding events as they occur.
|
|
802
|
+
|
|
803
|
+
This method enables real-time streaming of text and tool call events
|
|
804
|
+
during multi-turn agent conversations. Unlike generate_with_mcp_tools(),
|
|
805
|
+
this yields events incrementally rather than waiting for completion.
|
|
806
|
+
|
|
807
|
+
Note: This method requires subscription mode (Claude Agent SDK).
|
|
808
|
+
|
|
809
|
+
Args:
|
|
810
|
+
prompt: User prompt to process.
|
|
811
|
+
allowed_tools: List of allowed MCP tool patterns.
|
|
812
|
+
Tools should be in format "mcp__{server}__{tool}" or patterns
|
|
813
|
+
like "mcp__gobby-tasks__*" for all tools from a server.
|
|
814
|
+
system_prompt: Optional system prompt.
|
|
815
|
+
model: Optional model override (default: claude-sonnet-4-5).
|
|
816
|
+
max_turns: Maximum number of agentic turns (default: 10).
|
|
817
|
+
|
|
818
|
+
Yields:
|
|
819
|
+
ChatEvent: One of TextChunk, ToolCallEvent, ToolResultEvent, or DoneEvent.
|
|
820
|
+
|
|
821
|
+
Example:
|
|
822
|
+
>>> async for event in provider.stream_with_mcp_tools(
|
|
823
|
+
... prompt="Create a task called 'Fix bug'",
|
|
824
|
+
... allowed_tools=["mcp__gobby-tasks__*"],
|
|
825
|
+
... ):
|
|
826
|
+
... if isinstance(event, TextChunk):
|
|
827
|
+
... print(event.content, end="")
|
|
828
|
+
... elif isinstance(event, ToolCallEvent):
|
|
829
|
+
... print(f"Calling {event.tool_name}...")
|
|
830
|
+
"""
|
|
831
|
+
# MCP tools require subscription mode (Claude Agent SDK)
|
|
832
|
+
if self._auth_mode == "api_key":
|
|
833
|
+
yield TextChunk(
|
|
834
|
+
content="MCP tools require subscription mode. "
|
|
835
|
+
"Set auth_mode: subscription in llm_providers.claude config."
|
|
836
|
+
)
|
|
837
|
+
yield DoneEvent(tool_calls_count=0)
|
|
838
|
+
return
|
|
839
|
+
|
|
840
|
+
cli_path = self._verify_cli_path()
|
|
841
|
+
if not cli_path:
|
|
842
|
+
yield TextChunk(content="Generation unavailable (Claude CLI not found)")
|
|
843
|
+
yield DoneEvent(tool_calls_count=0)
|
|
844
|
+
return
|
|
845
|
+
|
|
846
|
+
# Build mcp_servers config - use .mcp.json if gobby tools requested
|
|
847
|
+
from pathlib import Path
|
|
848
|
+
|
|
849
|
+
mcp_servers_config: dict[str, Any] | str | None = None
|
|
850
|
+
|
|
851
|
+
if any("gobby" in t for t in allowed_tools):
|
|
852
|
+
cwd_config = Path.cwd() / ".mcp.json"
|
|
853
|
+
if cwd_config.exists():
|
|
854
|
+
mcp_servers_config = str(cwd_config)
|
|
855
|
+
else:
|
|
856
|
+
gobby_root = Path(__file__).parent.parent.parent.parent
|
|
857
|
+
gobby_config = gobby_root / ".mcp.json"
|
|
858
|
+
if gobby_config.exists():
|
|
859
|
+
mcp_servers_config = str(gobby_config)
|
|
860
|
+
|
|
861
|
+
# Configure Claude Agent SDK with MCP tools
|
|
862
|
+
options = ClaudeAgentOptions(
|
|
863
|
+
system_prompt=system_prompt
|
|
864
|
+
or "You are Gobby, a helpful assistant with access to tools.",
|
|
865
|
+
max_turns=max_turns,
|
|
866
|
+
model=model or "claude-sonnet-4-5",
|
|
867
|
+
allowed_tools=allowed_tools,
|
|
868
|
+
permission_mode="bypassPermissions",
|
|
869
|
+
cli_path=cli_path,
|
|
870
|
+
mcp_servers=mcp_servers_config if mcp_servers_config is not None else {},
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
def _parse_server_name(full_tool_name: str) -> str:
|
|
874
|
+
"""Extract server name from mcp__{server}__{tool} format."""
|
|
875
|
+
if full_tool_name.startswith("mcp__"):
|
|
876
|
+
parts = full_tool_name.split("__")
|
|
877
|
+
if len(parts) >= 2:
|
|
878
|
+
return parts[1]
|
|
879
|
+
return "builtin"
|
|
880
|
+
|
|
881
|
+
tool_calls_count = 0
|
|
882
|
+
pending_tool_calls: dict[str, str] = {} # Map tool_use_id -> tool_name
|
|
883
|
+
needs_spacing_before_text = False # Track if we need spacing before text
|
|
884
|
+
|
|
885
|
+
try:
|
|
886
|
+
async for message in query(prompt=prompt, options=options):
|
|
887
|
+
if isinstance(message, ResultMessage):
|
|
888
|
+
# Final result - extract metadata
|
|
889
|
+
cost_usd = getattr(message, "total_cost_usd", None)
|
|
890
|
+
duration_ms = getattr(message, "duration_ms", None)
|
|
891
|
+
yield DoneEvent(
|
|
892
|
+
tool_calls_count=tool_calls_count,
|
|
893
|
+
cost_usd=cost_usd,
|
|
894
|
+
duration_ms=duration_ms,
|
|
895
|
+
)
|
|
896
|
+
|
|
897
|
+
elif isinstance(message, AssistantMessage):
|
|
898
|
+
for block in message.content:
|
|
899
|
+
if isinstance(block, TextBlock):
|
|
900
|
+
# Add spacing before text that follows tool calls/results
|
|
901
|
+
# This ensures proper paragraph separation in the UI
|
|
902
|
+
text = block.text
|
|
903
|
+
if needs_spacing_before_text and text:
|
|
904
|
+
# Ensure we have a proper paragraph break (double newline)
|
|
905
|
+
# even if the text starts with a single newline
|
|
906
|
+
text = text.lstrip("\n")
|
|
907
|
+
if text:
|
|
908
|
+
text = "\n\n" + text
|
|
909
|
+
yield TextChunk(content=text)
|
|
910
|
+
needs_spacing_before_text = False
|
|
911
|
+
elif isinstance(block, ToolUseBlock):
|
|
912
|
+
tool_calls_count += 1
|
|
913
|
+
server_name = _parse_server_name(block.name)
|
|
914
|
+
pending_tool_calls[block.id] = block.name
|
|
915
|
+
yield ToolCallEvent(
|
|
916
|
+
tool_call_id=block.id,
|
|
917
|
+
tool_name=block.name,
|
|
918
|
+
server_name=server_name,
|
|
919
|
+
arguments=block.input if isinstance(block.input, dict) else {},
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
elif isinstance(message, UserMessage):
|
|
923
|
+
# UserMessage may contain tool results
|
|
924
|
+
if isinstance(message.content, list):
|
|
925
|
+
for block in message.content:
|
|
926
|
+
if isinstance(block, ToolResultBlock):
|
|
927
|
+
# Determine success based on is_error attribute
|
|
928
|
+
is_error = getattr(block, "is_error", False)
|
|
929
|
+
yield ToolResultEvent(
|
|
930
|
+
tool_call_id=block.tool_use_id,
|
|
931
|
+
success=not is_error,
|
|
932
|
+
result=block.content if not is_error else None,
|
|
933
|
+
error=str(block.content) if is_error else None,
|
|
934
|
+
)
|
|
935
|
+
needs_spacing_before_text = True
|
|
936
|
+
|
|
937
|
+
except ExceptionGroup as eg:
|
|
938
|
+
errors = [f"{type(exc).__name__}: {exc}" for exc in eg.exceptions]
|
|
939
|
+
yield TextChunk(content=f"Generation failed: {'; '.join(errors)}")
|
|
940
|
+
yield DoneEvent(tool_calls_count=tool_calls_count)
|
|
941
|
+
except Exception as e:
|
|
942
|
+
self.logger.error(f"Failed to stream with MCP tools: {e}", exc_info=True)
|
|
943
|
+
yield TextChunk(content=f"Generation failed: {e}")
|
|
944
|
+
yield DoneEvent(tool_calls_count=tool_calls_count)
|
|
945
|
+
|
|
730
946
|
async def describe_image(
|
|
731
947
|
self,
|
|
732
948
|
image_path: str,
|