gobby 0.2.7__py3-none-any.whl → 0.2.8__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 (80) hide show
  1. gobby/adapters/claude_code.py +96 -35
  2. gobby/adapters/gemini.py +140 -38
  3. gobby/agents/isolation.py +130 -0
  4. gobby/agents/registry.py +11 -0
  5. gobby/agents/session.py +1 -0
  6. gobby/agents/spawn_executor.py +43 -13
  7. gobby/agents/spawners/macos.py +26 -1
  8. gobby/cli/__init__.py +0 -2
  9. gobby/cli/memory.py +185 -0
  10. gobby/clones/git.py +177 -0
  11. gobby/config/skills.py +31 -0
  12. gobby/hooks/event_handlers.py +109 -10
  13. gobby/hooks/hook_manager.py +19 -1
  14. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  15. gobby/mcp_proxy/instructions.py +2 -2
  16. gobby/mcp_proxy/registries.py +21 -4
  17. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  18. gobby/mcp_proxy/tools/agents.py +45 -9
  19. gobby/mcp_proxy/tools/artifacts.py +43 -9
  20. gobby/mcp_proxy/tools/sessions/_commits.py +31 -24
  21. gobby/mcp_proxy/tools/sessions/_crud.py +5 -5
  22. gobby/mcp_proxy/tools/sessions/_handoff.py +45 -41
  23. gobby/mcp_proxy/tools/sessions/_messages.py +35 -7
  24. gobby/mcp_proxy/tools/spawn_agent.py +44 -6
  25. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  26. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  27. gobby/mcp_proxy/tools/tasks/_lifecycle.py +29 -14
  28. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  29. gobby/mcp_proxy/tools/workflows.py +84 -34
  30. gobby/mcp_proxy/tools/worktrees.py +32 -7
  31. gobby/memory/extractor.py +15 -1
  32. gobby/runner.py +13 -0
  33. gobby/servers/routes/mcp/hooks.py +50 -3
  34. gobby/servers/websocket.py +57 -1
  35. gobby/sessions/analyzer.py +2 -2
  36. gobby/sessions/manager.py +9 -0
  37. gobby/sessions/transcripts/gemini.py +100 -34
  38. gobby/storage/database.py +9 -2
  39. gobby/storage/memories.py +32 -21
  40. gobby/storage/migrations.py +23 -4
  41. gobby/storage/sessions.py +4 -2
  42. gobby/storage/skills.py +43 -3
  43. gobby/workflows/detection_helpers.py +38 -24
  44. gobby/workflows/enforcement/blocking.py +13 -1
  45. gobby/workflows/engine.py +93 -0
  46. gobby/workflows/evaluator.py +110 -0
  47. gobby/workflows/hooks.py +41 -0
  48. gobby/workflows/memory_actions.py +11 -0
  49. gobby/workflows/safe_evaluator.py +8 -0
  50. gobby/workflows/summary_actions.py +123 -50
  51. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/METADATA +1 -1
  52. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/RECORD +56 -80
  53. gobby/cli/tui.py +0 -34
  54. gobby/tui/__init__.py +0 -5
  55. gobby/tui/api_client.py +0 -278
  56. gobby/tui/app.py +0 -329
  57. gobby/tui/screens/__init__.py +0 -25
  58. gobby/tui/screens/agents.py +0 -333
  59. gobby/tui/screens/chat.py +0 -450
  60. gobby/tui/screens/dashboard.py +0 -377
  61. gobby/tui/screens/memory.py +0 -305
  62. gobby/tui/screens/metrics.py +0 -231
  63. gobby/tui/screens/orchestrator.py +0 -903
  64. gobby/tui/screens/sessions.py +0 -412
  65. gobby/tui/screens/tasks.py +0 -440
  66. gobby/tui/screens/workflows.py +0 -289
  67. gobby/tui/screens/worktrees.py +0 -174
  68. gobby/tui/widgets/__init__.py +0 -21
  69. gobby/tui/widgets/chat.py +0 -210
  70. gobby/tui/widgets/conductor.py +0 -104
  71. gobby/tui/widgets/menu.py +0 -132
  72. gobby/tui/widgets/message_panel.py +0 -160
  73. gobby/tui/widgets/review_gate.py +0 -224
  74. gobby/tui/widgets/task_tree.py +0 -99
  75. gobby/tui/widgets/token_budget.py +0 -166
  76. gobby/tui/ws_client.py +0 -258
  77. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/WHEEL +0 -0
  78. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
  79. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
  80. {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
@@ -1,99 +0,0 @@
1
- """Task tree widget for hierarchical task display."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import Any
6
-
7
- from textual.widgets import Tree
8
- from textual.widgets.tree import TreeNode
9
-
10
-
11
- class TaskTree(Tree[str]):
12
- """Hierarchical tree view for tasks."""
13
-
14
- DEFAULT_CSS = """
15
- TaskTree {
16
- background: transparent;
17
- }
18
-
19
- TaskTree > .tree--cursor {
20
- background: #6d28d9;
21
- }
22
-
23
- TaskTree > .tree--guides {
24
- color: #45475a;
25
- }
26
- """
27
-
28
- STATUS_ICONS = {
29
- "open": "○",
30
- "in_progress": "◐",
31
- "review": "◑",
32
- "closed": "●",
33
- "blocked": "⊘",
34
- }
35
-
36
- TYPE_COLORS = {
37
- "task": "",
38
- "bug": "🐛 ",
39
- "feature": "✨ ",
40
- "epic": "🏔️ ",
41
- }
42
-
43
- def __init__(self, label: str = "Tasks", **kwargs: Any) -> None:
44
- super().__init__(label, **kwargs)
45
- self._task_map: dict[str, dict[str, Any]] = {}
46
-
47
- def populate(self, tasks: list[dict[str, Any]]) -> None:
48
- """Populate the tree with tasks."""
49
- self.clear()
50
- self._task_map = {task_id: t for t in tasks if (task_id := t.get("id"))}
51
-
52
- # Build parent -> children mapping
53
- children_map: dict[str | None, list[dict[str, Any]]] = {}
54
- for task in tasks:
55
- parent_id = task.get("parent_id")
56
- if parent_id not in children_map:
57
- children_map[parent_id] = []
58
- children_map[parent_id].append(task)
59
-
60
- # Add root level tasks
61
- root_tasks = children_map.get(None, [])
62
- for task in sorted(root_tasks, key=lambda t: t.get("priority", 3)):
63
- self._add_task_node(self.root, task, children_map)
64
-
65
- self.root.expand()
66
-
67
- def _add_task_node(
68
- self,
69
- parent: TreeNode[str],
70
- task: dict[str, Any],
71
- children_map: dict[str | None, list[dict[str, Any]]],
72
- ) -> None:
73
- """Add a task and its children to the tree."""
74
- status = task.get("status", "open")
75
- task_type = task.get("task_type", "task")
76
-
77
- icon = self.STATUS_ICONS.get(status, "○")
78
- type_prefix = self.TYPE_COLORS.get(task_type, "")
79
- ref = task.get("ref", "")
80
- title = task.get("title", "Untitled")
81
-
82
- label = f"{icon} {type_prefix}{ref} {title}"
83
- node = parent.add(label, data=task.get("id"))
84
-
85
- # Add children
86
- task_id = task.get("id")
87
- children = children_map.get(task_id, [])
88
- for child in sorted(children, key=lambda t: t.get("priority", 3)):
89
- self._add_task_node(node, child, children_map)
90
-
91
- def get_task(self, task_id: str) -> dict[str, Any] | None:
92
- """Get task data by ID."""
93
- return self._task_map.get(task_id)
94
-
95
- def get_selected_task_id(self) -> str | None:
96
- """Get the ID of the currently selected task."""
97
- if self.cursor_node:
98
- return self.cursor_node.data
99
- return None
@@ -1,166 +0,0 @@
1
- """Token budget meter widget."""
2
-
3
- from __future__ import annotations
4
-
5
- import logging
6
-
7
- from textual.app import ComposeResult
8
- from textual.containers import Horizontal
9
- from textual.reactive import reactive
10
- from textual.widget import Widget
11
- from textual.widgets import ProgressBar, Static
12
-
13
- logger = logging.getLogger(__name__)
14
-
15
-
16
- class TokenBudgetMeter(Widget):
17
- """Widget showing token budget usage with warning thresholds."""
18
-
19
- DEFAULT_CSS = """
20
- TokenBudgetMeter {
21
- height: auto;
22
- padding: 1;
23
- }
24
-
25
- TokenBudgetMeter .budget-header {
26
- layout: horizontal;
27
- height: 1;
28
- margin-bottom: 1;
29
- }
30
-
31
- TokenBudgetMeter .budget-title {
32
- width: 1fr;
33
- color: #a6adc8;
34
- }
35
-
36
- TokenBudgetMeter .budget-percentage {
37
- width: auto;
38
- text-style: bold;
39
- }
40
-
41
- TokenBudgetMeter .budget-bar {
42
- height: 1;
43
- margin: 1 0;
44
- }
45
-
46
- TokenBudgetMeter .budget-details {
47
- layout: horizontal;
48
- height: 1;
49
- }
50
-
51
- TokenBudgetMeter .budget-spent {
52
- width: 1fr;
53
- }
54
-
55
- TokenBudgetMeter .budget-limit {
56
- width: auto;
57
- color: #a6adc8;
58
- }
59
-
60
- TokenBudgetMeter .--normal {
61
- color: #22c55e;
62
- }
63
-
64
- TokenBudgetMeter .--warning {
65
- color: #f59e0b;
66
- }
67
-
68
- TokenBudgetMeter .--critical {
69
- color: #ef4444;
70
- }
71
- """
72
-
73
- spent = reactive(0.0)
74
- limit = reactive(50.0)
75
- warning_threshold = reactive(0.8)
76
- critical_threshold = reactive(0.9)
77
-
78
- def compose(self) -> ComposeResult:
79
- percentage = self._get_percentage()
80
-
81
- with Horizontal(classes="budget-header"):
82
- yield Static("Token Budget", classes="budget-title")
83
- yield Static(
84
- f"{percentage:.0%}",
85
- classes=f"budget-percentage {self._get_status_class()}",
86
- id="percentage",
87
- )
88
-
89
- bar = ProgressBar(
90
- total=100,
91
- show_eta=False,
92
- id="budget-bar",
93
- classes="budget-bar",
94
- )
95
- bar.advance(percentage * 100)
96
- yield bar
97
-
98
- with Horizontal(classes="budget-details"):
99
- yield Static(
100
- f"${self.spent:.2f}",
101
- classes=f"budget-spent {self._get_status_class()}",
102
- id="spent",
103
- )
104
- yield Static(f"/ ${self.limit:.2f}", classes="budget-limit")
105
-
106
- def _get_percentage(self) -> float:
107
- """Get the current percentage used."""
108
- if self.limit <= 0:
109
- return 0.0
110
- return min(self.spent / self.limit, 1.0)
111
-
112
- def _get_status_class(self) -> str:
113
- """Get the CSS class based on usage level."""
114
- percentage = self._get_percentage()
115
- if percentage >= self.critical_threshold:
116
- return "--critical"
117
- elif percentage >= self.warning_threshold:
118
- return "--warning"
119
- return "--normal"
120
-
121
- def watch_spent(self, spent: float) -> None:
122
- """Update display when spent changes."""
123
- self._update_display()
124
-
125
- def watch_limit(self, limit: float) -> None:
126
- """Update display when limit changes."""
127
- self._update_display()
128
-
129
- def _update_display(self) -> None:
130
- """Update all display elements."""
131
- try:
132
- percentage = self._get_percentage()
133
- status_class = self._get_status_class()
134
-
135
- # Update percentage
136
- pct_widget = self.query_one("#percentage", Static)
137
- pct_widget.update(f"{percentage:.0%}")
138
- pct_widget.remove_class("--normal", "--warning", "--critical")
139
- pct_widget.add_class(status_class)
140
-
141
- # Update progress bar
142
- bar = self.query_one("#budget-bar", ProgressBar)
143
- bar.update(progress=percentage * 100)
144
-
145
- # Update spent
146
- spent_widget = self.query_one("#spent", Static)
147
- spent_widget.update(f"${self.spent:.2f}")
148
- spent_widget.remove_class("--normal", "--warning", "--critical")
149
- spent_widget.add_class(status_class)
150
-
151
- except Exception as e:
152
- # Widget may not be composed yet during initialization
153
- logger.debug(f"Token budget display update skipped: {e}")
154
-
155
- def update_budget(self, spent: float, limit: float) -> None:
156
- """Update budget values."""
157
- self.spent = spent
158
- self.limit = limit
159
-
160
- def is_throttled(self) -> bool:
161
- """Check if usage is at throttle level."""
162
- return self._get_percentage() >= self.critical_threshold
163
-
164
- def is_warning(self) -> bool:
165
- """Check if usage is at warning level."""
166
- return self._get_percentage() >= self.warning_threshold
gobby/tui/ws_client.py DELETED
@@ -1,258 +0,0 @@
1
- """WebSocket client for real-time Gobby events."""
2
-
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- import json
7
- import logging
8
- from collections.abc import Callable
9
- from typing import Any
10
-
11
- import websockets
12
- from websockets.asyncio.client import ClientConnection
13
-
14
- logger = logging.getLogger(__name__)
15
-
16
-
17
- class GobbyWebSocketClient:
18
- """WebSocket client with automatic reconnection for real-time updates."""
19
-
20
- def __init__(
21
- self,
22
- ws_url: str = "ws://localhost:60888",
23
- reconnect_interval: float = 5.0,
24
- max_reconnect_attempts: int = 10,
25
- ) -> None:
26
- self.ws_url = ws_url
27
- self.reconnect_interval = reconnect_interval
28
- self.max_reconnect_attempts = max_reconnect_attempts
29
-
30
- self._ws: ClientConnection | None = None
31
- self._running = False
32
- self._connected = False
33
- self._client_id: str | None = None
34
- self._subscriptions: set[str] = set()
35
-
36
- # Event handlers: event_type -> list of callbacks
37
- self._handlers: dict[str, list[Callable[[dict[str, Any]], None]]] = {}
38
-
39
- # Connection state callbacks
40
- self._on_connect_callbacks: list[Callable[[], None]] = []
41
- self._on_disconnect_callbacks: list[Callable[[], None]] = []
42
-
43
- @property
44
- def connected(self) -> bool:
45
- """Check if currently connected."""
46
- return self._connected
47
-
48
- @property
49
- def client_id(self) -> str | None:
50
- """Get the client ID assigned by server."""
51
- return self._client_id
52
-
53
- def on_event(
54
- self,
55
- event_type: str,
56
- handler: Callable[[dict[str, Any]], None],
57
- ) -> None:
58
- """Register a handler for a specific event type."""
59
- if event_type not in self._handlers:
60
- self._handlers[event_type] = []
61
- self._handlers[event_type].append(handler)
62
-
63
- def on_connect(self, callback: Callable[[], None]) -> None:
64
- """Register a callback for when connection is established."""
65
- self._on_connect_callbacks.append(callback)
66
-
67
- def on_disconnect(self, callback: Callable[[], None]) -> None:
68
- """Register a callback for when connection is lost."""
69
- self._on_disconnect_callbacks.append(callback)
70
-
71
- async def subscribe(self, events: list[str]) -> None:
72
- """Subscribe to specific event types."""
73
- self._subscriptions.update(events)
74
- if self._ws and self._connected:
75
- await self._send({"type": "subscribe", "events": events})
76
-
77
- async def unsubscribe(self, events: list[str]) -> None:
78
- """Unsubscribe from specific event types."""
79
- self._subscriptions.difference_update(events)
80
- if self._ws and self._connected:
81
- await self._send({"type": "unsubscribe", "events": events})
82
-
83
- async def _send(self, message: dict[str, Any]) -> None:
84
- """Send a message to the server."""
85
- if self._ws and self._connected:
86
- await self._ws.send(json.dumps(message))
87
-
88
- async def call_tool(
89
- self,
90
- server_name: str,
91
- tool_name: str,
92
- arguments: dict[str, Any] | None = None,
93
- request_id: str | None = None,
94
- ) -> None:
95
- """Send a tool call request via WebSocket."""
96
- import uuid
97
-
98
- message = {
99
- "type": "tool_call",
100
- "request_id": request_id or str(uuid.uuid4()),
101
- "mcp": server_name,
102
- "tool": tool_name,
103
- "args": arguments or {},
104
- }
105
- await self._send(message)
106
-
107
- async def ping(self) -> None:
108
- """Send a ping to measure latency."""
109
- await self._send({"type": "ping"})
110
-
111
- async def connect(self) -> None:
112
- """Establish WebSocket connection with automatic reconnection."""
113
- self._running = True
114
- attempt = 0
115
-
116
- while self._running and attempt < self.max_reconnect_attempts:
117
- try:
118
- logger.info(f"Connecting to WebSocket at {self.ws_url}")
119
- async with websockets.connect(self.ws_url) as ws:
120
- self._ws = ws
121
- self._connected = True
122
- attempt = 0 # Reset attempts on successful connection
123
-
124
- # Notify connection callbacks
125
- for callback in self._on_connect_callbacks:
126
- try:
127
- callback()
128
- except Exception as e:
129
- logger.error(f"Connection callback error: {e}")
130
-
131
- # Resubscribe to events
132
- if self._subscriptions:
133
- await self._send(
134
- {
135
- "type": "subscribe",
136
- "events": list(self._subscriptions),
137
- }
138
- )
139
-
140
- # Listen for messages
141
- await self._listen()
142
-
143
- except websockets.ConnectionClosed:
144
- logger.warning("WebSocket connection closed")
145
- except Exception as e:
146
- logger.error(f"WebSocket error: {e}")
147
- finally:
148
- self._connected = False
149
- self._ws = None
150
-
151
- # Notify disconnect callbacks
152
- for callback in self._on_disconnect_callbacks:
153
- try:
154
- callback()
155
- except Exception as e:
156
- logger.error(f"Disconnect callback error: {e}")
157
-
158
- if self._running:
159
- attempt += 1
160
- logger.info(
161
- f"Reconnecting in {self.reconnect_interval}s "
162
- f"(attempt {attempt}/{self.max_reconnect_attempts})"
163
- )
164
- await asyncio.sleep(self.reconnect_interval)
165
-
166
- async def _listen(self) -> None:
167
- """Listen for incoming messages."""
168
- if not self._ws:
169
- return
170
-
171
- async for message in self._ws:
172
- try:
173
- data = json.loads(message)
174
- await self._handle_message(data)
175
- except json.JSONDecodeError:
176
- logger.warning(f"Invalid JSON received: {message!r}")
177
- except Exception as e:
178
- logger.error(f"Error handling message: {e}")
179
-
180
- async def _handle_message(self, data: dict[str, Any]) -> None:
181
- """Route incoming message to appropriate handlers."""
182
- msg_type = data.get("type", "unknown")
183
-
184
- # Handle connection established
185
- if msg_type == "connection_established":
186
- self._client_id = data.get("client_id")
187
- logger.info(f"Connected with client_id: {self._client_id}")
188
- return
189
-
190
- # Handle pong
191
- if msg_type == "pong":
192
- latency = data.get("latency", 0)
193
- logger.debug(f"Pong received, latency: {latency:.3f}s")
194
- return
195
-
196
- # Handle tool results
197
- if msg_type == "tool_result":
198
- request_id = data.get("request_id")
199
- logger.debug(f"Tool result for {request_id}")
200
-
201
- # Handle errors
202
- if msg_type == "error":
203
- logger.error(f"Server error: {data.get('message')}")
204
-
205
- # Dispatch to registered handlers (combine without mutating originals)
206
- handlers = self._handlers.get(msg_type, []) + self._handlers.get("*", [])
207
-
208
- for handler in handlers:
209
- try:
210
- handler(data)
211
- except Exception as e:
212
- logger.error(f"Handler error for {msg_type}: {e}")
213
-
214
- async def disconnect(self) -> None:
215
- """Close the WebSocket connection."""
216
- self._running = False
217
- if self._ws:
218
- await self._ws.close()
219
- self._ws = None
220
- self._connected = False
221
-
222
-
223
- class WebSocketEventBridge:
224
- """Bridge WebSocket events to Textual message system."""
225
-
226
- def __init__(self, ws_client: GobbyWebSocketClient) -> None:
227
- self.ws_client = ws_client
228
- self._app: Any = None
229
-
230
- def bind_app(self, app: Any) -> None:
231
- """Bind to a Textual app for posting messages."""
232
- self._app = app
233
-
234
- def setup_handlers(self) -> None:
235
- """Register handlers that will post Textual messages."""
236
- # These will be implemented when we create custom Textual messages
237
- event_types = [
238
- "hook_event",
239
- "agent_event",
240
- "autonomous_event",
241
- "session_message",
242
- "worktree_event",
243
- "tool_result",
244
- ]
245
-
246
- for event_type in event_types:
247
- self.ws_client.on_event(event_type, self._make_handler(event_type))
248
-
249
- def _make_handler(self, event_type: str) -> Callable[[dict[str, Any]], None]:
250
- """Create a handler that posts to the Textual app."""
251
-
252
- def handler(data: dict[str, Any]) -> None:
253
- if self._app:
254
- # Post custom message to Textual app
255
- # The app will define how to handle these
256
- self._app.call_from_thread(self._app.post_ws_event, event_type, data)
257
-
258
- return handler
File without changes