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
@@ -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 MCPToolResult, ToolCall
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 "unknown"
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,