gobby 0.2.7__py3-none-any.whl → 0.2.9__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/claude_code.py +99 -61
- gobby/adapters/gemini.py +140 -38
- gobby/agents/isolation.py +130 -0
- gobby/agents/registry.py +11 -0
- gobby/agents/session.py +1 -0
- gobby/agents/spawn_executor.py +43 -13
- gobby/agents/spawners/macos.py +26 -1
- gobby/app_context.py +59 -0
- gobby/cli/__init__.py +0 -2
- gobby/cli/memory.py +185 -0
- gobby/cli/utils.py +5 -17
- gobby/clones/git.py +177 -0
- gobby/config/features.py +0 -20
- gobby/config/skills.py +31 -0
- gobby/config/tasks.py +4 -0
- gobby/hooks/event_handlers/__init__.py +155 -0
- gobby/hooks/event_handlers/_agent.py +175 -0
- gobby/hooks/event_handlers/_base.py +87 -0
- gobby/hooks/event_handlers/_misc.py +66 -0
- gobby/hooks/event_handlers/_session.py +573 -0
- gobby/hooks/event_handlers/_tool.py +196 -0
- gobby/hooks/hook_manager.py +21 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
- gobby/llm/claude.py +377 -42
- gobby/mcp_proxy/importer.py +4 -41
- gobby/mcp_proxy/instructions.py +2 -2
- gobby/mcp_proxy/manager.py +13 -3
- gobby/mcp_proxy/registries.py +35 -4
- gobby/mcp_proxy/services/recommendation.py +2 -28
- gobby/mcp_proxy/tools/agent_messaging.py +93 -44
- gobby/mcp_proxy/tools/agents.py +45 -9
- gobby/mcp_proxy/tools/artifacts.py +46 -12
- gobby/mcp_proxy/tools/sessions/_commits.py +31 -24
- gobby/mcp_proxy/tools/sessions/_crud.py +5 -5
- gobby/mcp_proxy/tools/sessions/_handoff.py +45 -41
- gobby/mcp_proxy/tools/sessions/_messages.py +35 -7
- gobby/mcp_proxy/tools/spawn_agent.py +44 -6
- gobby/mcp_proxy/tools/task_readiness.py +27 -4
- gobby/mcp_proxy/tools/tasks/_context.py +18 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +29 -14
- gobby/mcp_proxy/tools/tasks/_session.py +22 -7
- gobby/mcp_proxy/tools/workflows/__init__.py +266 -0
- gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
- gobby/mcp_proxy/tools/workflows/_import.py +112 -0
- gobby/mcp_proxy/tools/workflows/_lifecycle.py +321 -0
- gobby/mcp_proxy/tools/workflows/_query.py +207 -0
- gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
- gobby/mcp_proxy/tools/workflows/_terminal.py +139 -0
- gobby/mcp_proxy/tools/worktrees.py +32 -7
- gobby/memory/components/__init__.py +0 -0
- gobby/memory/components/ingestion.py +98 -0
- gobby/memory/components/search.py +108 -0
- gobby/memory/extractor.py +15 -1
- gobby/memory/manager.py +16 -25
- gobby/paths.py +51 -0
- gobby/prompts/loader.py +1 -35
- gobby/runner.py +36 -10
- gobby/servers/http.py +186 -149
- gobby/servers/routes/admin.py +12 -0
- gobby/servers/routes/mcp/endpoints/execution.py +15 -7
- gobby/servers/routes/mcp/endpoints/registry.py +8 -8
- gobby/servers/routes/mcp/hooks.py +50 -3
- gobby/servers/websocket.py +57 -1
- gobby/sessions/analyzer.py +4 -4
- gobby/sessions/manager.py +9 -0
- gobby/sessions/transcripts/gemini.py +100 -34
- gobby/skills/parser.py +23 -0
- gobby/skills/sync.py +5 -4
- gobby/storage/artifacts.py +19 -0
- gobby/storage/database.py +9 -2
- gobby/storage/memories.py +32 -21
- gobby/storage/migrations.py +46 -4
- gobby/storage/sessions.py +4 -2
- gobby/storage/skills.py +87 -7
- gobby/tasks/external_validator.py +4 -17
- gobby/tasks/validation.py +13 -87
- gobby/tools/summarizer.py +18 -51
- gobby/utils/status.py +13 -0
- gobby/workflows/actions.py +5 -0
- gobby/workflows/context_actions.py +21 -24
- gobby/workflows/detection_helpers.py +38 -24
- gobby/workflows/enforcement/__init__.py +11 -1
- gobby/workflows/enforcement/blocking.py +109 -1
- gobby/workflows/enforcement/handlers.py +35 -1
- gobby/workflows/engine.py +96 -0
- gobby/workflows/evaluator.py +110 -0
- gobby/workflows/hooks.py +41 -0
- gobby/workflows/lifecycle_evaluator.py +2 -1
- gobby/workflows/memory_actions.py +11 -0
- gobby/workflows/safe_evaluator.py +8 -0
- gobby/workflows/summary_actions.py +123 -50
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/METADATA +1 -1
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/RECORD +99 -107
- gobby/cli/tui.py +0 -34
- gobby/hooks/event_handlers.py +0 -909
- gobby/mcp_proxy/tools/workflows.py +0 -973
- gobby/tui/__init__.py +0 -5
- gobby/tui/api_client.py +0 -278
- gobby/tui/app.py +0 -329
- gobby/tui/screens/__init__.py +0 -25
- gobby/tui/screens/agents.py +0 -333
- gobby/tui/screens/chat.py +0 -450
- gobby/tui/screens/dashboard.py +0 -377
- gobby/tui/screens/memory.py +0 -305
- gobby/tui/screens/metrics.py +0 -231
- gobby/tui/screens/orchestrator.py +0 -903
- gobby/tui/screens/sessions.py +0 -412
- gobby/tui/screens/tasks.py +0 -440
- gobby/tui/screens/workflows.py +0 -289
- gobby/tui/screens/worktrees.py +0 -174
- gobby/tui/widgets/__init__.py +0 -21
- gobby/tui/widgets/chat.py +0 -210
- gobby/tui/widgets/conductor.py +0 -104
- gobby/tui/widgets/menu.py +0 -132
- gobby/tui/widgets/message_panel.py +0 -160
- gobby/tui/widgets/review_gate.py +0 -224
- gobby/tui/widgets/task_tree.py +0 -99
- gobby/tui/widgets/token_budget.py +0 -166
- gobby/tui/ws_client.py +0 -258
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/WHEEL +0 -0
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/top_level.txt +0 -0
gobby/tui/widgets/task_tree.py
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|