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.
- gobby/adapters/claude_code.py +96 -35
- 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/cli/__init__.py +0 -2
- gobby/cli/memory.py +185 -0
- gobby/clones/git.py +177 -0
- gobby/config/skills.py +31 -0
- gobby/hooks/event_handlers.py +109 -10
- gobby/hooks/hook_manager.py +19 -1
- gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
- gobby/mcp_proxy/instructions.py +2 -2
- gobby/mcp_proxy/registries.py +21 -4
- gobby/mcp_proxy/tools/agent_messaging.py +93 -44
- gobby/mcp_proxy/tools/agents.py +45 -9
- gobby/mcp_proxy/tools/artifacts.py +43 -9
- 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/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.py +84 -34
- gobby/mcp_proxy/tools/worktrees.py +32 -7
- gobby/memory/extractor.py +15 -1
- gobby/runner.py +13 -0
- gobby/servers/routes/mcp/hooks.py +50 -3
- gobby/servers/websocket.py +57 -1
- gobby/sessions/analyzer.py +2 -2
- gobby/sessions/manager.py +9 -0
- gobby/sessions/transcripts/gemini.py +100 -34
- gobby/storage/database.py +9 -2
- gobby/storage/memories.py +32 -21
- gobby/storage/migrations.py +23 -4
- gobby/storage/sessions.py +4 -2
- gobby/storage/skills.py +43 -3
- gobby/workflows/detection_helpers.py +38 -24
- gobby/workflows/enforcement/blocking.py +13 -1
- gobby/workflows/engine.py +93 -0
- gobby/workflows/evaluator.py +110 -0
- gobby/workflows/hooks.py +41 -0
- 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.8.dist-info}/METADATA +1 -1
- {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/RECORD +56 -80
- gobby/cli/tui.py +0 -34
- 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.8.dist-info}/WHEEL +0 -0
- {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/licenses/LICENSE.md +0 -0
- {gobby-0.2.7.dist-info → gobby-0.2.8.dist-info}/top_level.txt +0 -0
|
@@ -1,903 +0,0 @@
|
|
|
1
|
-
"""Orchestrator screen with conductor dashboard."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import asyncio
|
|
6
|
-
from datetime import datetime
|
|
7
|
-
from typing import Any
|
|
8
|
-
|
|
9
|
-
from textual.app import ComposeResult
|
|
10
|
-
from textual.containers import Container, Grid, Horizontal, Vertical, VerticalScroll
|
|
11
|
-
from textual.reactive import reactive
|
|
12
|
-
from textual.widget import Widget
|
|
13
|
-
from textual.widgets import (
|
|
14
|
-
Button,
|
|
15
|
-
LoadingIndicator,
|
|
16
|
-
ProgressBar,
|
|
17
|
-
Static,
|
|
18
|
-
)
|
|
19
|
-
|
|
20
|
-
from gobby.tui.api_client import GobbyAPIClient
|
|
21
|
-
from gobby.tui.ws_client import GobbyWebSocketClient
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class ConductorPanel(Widget):
|
|
25
|
-
"""Panel displaying conductor haiku and status."""
|
|
26
|
-
|
|
27
|
-
DEFAULT_CSS = """
|
|
28
|
-
ConductorPanel {
|
|
29
|
-
border: round #7c3aed;
|
|
30
|
-
padding: 1 2;
|
|
31
|
-
height: auto;
|
|
32
|
-
min-height: 10;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
ConductorPanel .conductor-header {
|
|
36
|
-
layout: horizontal;
|
|
37
|
-
height: 1;
|
|
38
|
-
margin-bottom: 1;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
ConductorPanel .conductor-icon {
|
|
42
|
-
width: 3;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
ConductorPanel .conductor-title {
|
|
46
|
-
text-style: bold;
|
|
47
|
-
color: #a78bfa;
|
|
48
|
-
width: 1fr;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
ConductorPanel .haiku-container {
|
|
52
|
-
padding: 1 2;
|
|
53
|
-
margin: 1 0;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
ConductorPanel .haiku-line {
|
|
57
|
-
text-align: center;
|
|
58
|
-
color: #cdd6f4;
|
|
59
|
-
}
|
|
60
|
-
"""
|
|
61
|
-
|
|
62
|
-
haiku_lines = reactive(["Ready to conduct", "Agents await your command", "Begin the journey"])
|
|
63
|
-
|
|
64
|
-
def compose(self) -> ComposeResult:
|
|
65
|
-
with Horizontal(classes="conductor-header"):
|
|
66
|
-
yield Static("🎭", classes="conductor-icon")
|
|
67
|
-
yield Static("Gobby Conductor", classes="conductor-title")
|
|
68
|
-
|
|
69
|
-
with Vertical(classes="haiku-container"):
|
|
70
|
-
for i, line in enumerate(self.haiku_lines):
|
|
71
|
-
yield Static(line, classes="haiku-line", id=f"haiku-{i}")
|
|
72
|
-
|
|
73
|
-
def update_haiku(self, lines: list[str]) -> None:
|
|
74
|
-
"""Update the haiku display."""
|
|
75
|
-
self.haiku_lines = lines[:3] if len(lines) >= 3 else self.haiku_lines
|
|
76
|
-
for i, line in enumerate(self.haiku_lines):
|
|
77
|
-
try:
|
|
78
|
-
widget = self.query_one(f"#haiku-{i}", Static)
|
|
79
|
-
widget.update(line)
|
|
80
|
-
except Exception:
|
|
81
|
-
pass # nosec B110 - widget may not be mounted yet
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
class TokenBudgetPanel(Widget):
|
|
85
|
-
"""Panel showing token budget and usage."""
|
|
86
|
-
|
|
87
|
-
DEFAULT_CSS = """
|
|
88
|
-
TokenBudgetPanel {
|
|
89
|
-
border: round #45475a;
|
|
90
|
-
padding: 1;
|
|
91
|
-
height: auto;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
TokenBudgetPanel .budget-header {
|
|
95
|
-
layout: horizontal;
|
|
96
|
-
height: 1;
|
|
97
|
-
margin-bottom: 1;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
TokenBudgetPanel .budget-title {
|
|
101
|
-
text-style: bold;
|
|
102
|
-
color: #a78bfa;
|
|
103
|
-
width: 1fr;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
TokenBudgetPanel .budget-period {
|
|
107
|
-
color: #6c7086;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
TokenBudgetPanel .budget-bar-container {
|
|
111
|
-
height: 2;
|
|
112
|
-
margin: 1 0;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
TokenBudgetPanel .budget-bar {
|
|
116
|
-
height: 1;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
TokenBudgetPanel .budget-details {
|
|
120
|
-
layout: horizontal;
|
|
121
|
-
height: 1;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
TokenBudgetPanel .budget-spent {
|
|
125
|
-
width: 1fr;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
TokenBudgetPanel .budget-limit {
|
|
129
|
-
width: auto;
|
|
130
|
-
color: #a6adc8;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
TokenBudgetPanel .budget-normal {
|
|
134
|
-
color: #22c55e;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
TokenBudgetPanel .budget-warning {
|
|
138
|
-
color: #f59e0b;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
TokenBudgetPanel .budget-critical {
|
|
142
|
-
color: #ef4444;
|
|
143
|
-
}
|
|
144
|
-
"""
|
|
145
|
-
|
|
146
|
-
spent = reactive(0.0)
|
|
147
|
-
limit = reactive(50.0)
|
|
148
|
-
period = reactive("7d")
|
|
149
|
-
|
|
150
|
-
def compose(self) -> ComposeResult:
|
|
151
|
-
with Horizontal(classes="budget-header"):
|
|
152
|
-
yield Static("💰 Token Budget", classes="budget-title")
|
|
153
|
-
yield Static(f"({self.period})", classes="budget-period")
|
|
154
|
-
|
|
155
|
-
with Vertical(classes="budget-bar-container"):
|
|
156
|
-
yield ProgressBar(total=100, show_eta=False, id="budget-progress")
|
|
157
|
-
|
|
158
|
-
with Horizontal(classes="budget-details"):
|
|
159
|
-
yield Static(f"${self.spent:.2f}", classes="budget-spent", id="spent-value")
|
|
160
|
-
yield Static(f"/ ${self.limit:.2f}", classes="budget-limit")
|
|
161
|
-
|
|
162
|
-
def watch_spent(self, spent: float) -> None:
|
|
163
|
-
"""Update budget display when spent changes."""
|
|
164
|
-
self._update_display()
|
|
165
|
-
|
|
166
|
-
def watch_limit(self, limit: float) -> None:
|
|
167
|
-
"""Update budget display when limit changes."""
|
|
168
|
-
self._update_display()
|
|
169
|
-
|
|
170
|
-
def _update_display(self) -> None:
|
|
171
|
-
"""Update the progress bar and values."""
|
|
172
|
-
try:
|
|
173
|
-
progress = self.query_one("#budget-progress", ProgressBar)
|
|
174
|
-
spent_widget = self.query_one("#spent-value", Static)
|
|
175
|
-
|
|
176
|
-
percentage = (self.spent / self.limit * 100) if self.limit > 0 else 0
|
|
177
|
-
progress.update(progress=percentage)
|
|
178
|
-
|
|
179
|
-
# Update colors based on usage
|
|
180
|
-
spent_widget.update(f"${self.spent:.2f}")
|
|
181
|
-
spent_widget.remove_class("budget-normal", "budget-warning", "budget-critical")
|
|
182
|
-
|
|
183
|
-
if percentage >= 90:
|
|
184
|
-
spent_widget.add_class("budget-critical")
|
|
185
|
-
elif percentage >= 80:
|
|
186
|
-
spent_widget.add_class("budget-warning")
|
|
187
|
-
else:
|
|
188
|
-
spent_widget.add_class("budget-normal")
|
|
189
|
-
|
|
190
|
-
except Exception:
|
|
191
|
-
pass # nosec B110 - widget may not be mounted yet
|
|
192
|
-
|
|
193
|
-
def update_budget(self, spent: float, limit: float, period: str = "7d") -> None:
|
|
194
|
-
"""Update budget values."""
|
|
195
|
-
self.spent = spent
|
|
196
|
-
self.limit = limit
|
|
197
|
-
self.period = period
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
class ModeIndicatorPanel(Widget):
|
|
201
|
-
"""Panel showing and controlling conductor mode."""
|
|
202
|
-
|
|
203
|
-
DEFAULT_CSS = """
|
|
204
|
-
ModeIndicatorPanel {
|
|
205
|
-
height: auto;
|
|
206
|
-
padding: 1;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
ModeIndicatorPanel .mode-header {
|
|
210
|
-
text-style: bold;
|
|
211
|
-
color: #a6adc8;
|
|
212
|
-
margin-bottom: 1;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
ModeIndicatorPanel .mode-button {
|
|
216
|
-
width: 100%;
|
|
217
|
-
height: 3;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
ModeIndicatorPanel .mode-interactive {
|
|
221
|
-
border: round #22c55e;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
ModeIndicatorPanel .mode-autonomous {
|
|
225
|
-
border: round #f59e0b;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
ModeIndicatorPanel .mode-paused {
|
|
229
|
-
border: round #6c7086;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
ModeIndicatorPanel .mode-hint {
|
|
233
|
-
color: #6c7086;
|
|
234
|
-
margin-top: 1;
|
|
235
|
-
}
|
|
236
|
-
"""
|
|
237
|
-
|
|
238
|
-
mode = reactive("interactive")
|
|
239
|
-
|
|
240
|
-
def compose(self) -> ComposeResult:
|
|
241
|
-
yield Static("Mode:", classes="mode-header")
|
|
242
|
-
|
|
243
|
-
mode_text = {
|
|
244
|
-
"interactive": "INTERACTIVE",
|
|
245
|
-
"autonomous": "AUTONOMOUS",
|
|
246
|
-
"paused": "PAUSED",
|
|
247
|
-
}.get(self.mode, "UNKNOWN")
|
|
248
|
-
|
|
249
|
-
button_class = f"mode-{self.mode}"
|
|
250
|
-
yield Button(mode_text, id="mode-toggle", classes=f"mode-button {button_class}")
|
|
251
|
-
yield Static("[Space] to toggle", classes="mode-hint")
|
|
252
|
-
|
|
253
|
-
def watch_mode(self, mode: str) -> None:
|
|
254
|
-
"""Update display when mode changes."""
|
|
255
|
-
asyncio.create_task(self.recompose())
|
|
256
|
-
|
|
257
|
-
def set_mode(self, mode: str) -> None:
|
|
258
|
-
"""Set the current mode."""
|
|
259
|
-
self.mode = mode
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
class ActiveAgentsPanel(Widget):
|
|
263
|
-
"""Panel showing running agents."""
|
|
264
|
-
|
|
265
|
-
DEFAULT_CSS = """
|
|
266
|
-
ActiveAgentsPanel {
|
|
267
|
-
border: round #45475a;
|
|
268
|
-
padding: 1;
|
|
269
|
-
height: 1fr;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
ActiveAgentsPanel .panel-header {
|
|
273
|
-
layout: horizontal;
|
|
274
|
-
height: 1;
|
|
275
|
-
margin-bottom: 1;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
ActiveAgentsPanel .panel-title {
|
|
279
|
-
text-style: bold;
|
|
280
|
-
color: #a78bfa;
|
|
281
|
-
width: 1fr;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
ActiveAgentsPanel .agent-count {
|
|
285
|
-
color: #06b6d4;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
ActiveAgentsPanel .agents-list {
|
|
289
|
-
height: 1fr;
|
|
290
|
-
overflow-y: auto;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
ActiveAgentsPanel .agent-item {
|
|
294
|
-
height: 2;
|
|
295
|
-
padding: 0 1;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
ActiveAgentsPanel .agent-status {
|
|
299
|
-
color: #22c55e;
|
|
300
|
-
width: 2;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
ActiveAgentsPanel .agent-name {
|
|
304
|
-
width: 1fr;
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
ActiveAgentsPanel .agent-duration {
|
|
308
|
-
color: #6c7086;
|
|
309
|
-
width: 6;
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
ActiveAgentsPanel .empty-state {
|
|
313
|
-
color: #6c7086;
|
|
314
|
-
content-align: center middle;
|
|
315
|
-
height: 1fr;
|
|
316
|
-
}
|
|
317
|
-
"""
|
|
318
|
-
|
|
319
|
-
agents: reactive[list[dict[str, Any]]] = reactive(list)
|
|
320
|
-
|
|
321
|
-
def compose(self) -> ComposeResult:
|
|
322
|
-
running = [a for a in self.agents if a.get("status") == "running"]
|
|
323
|
-
|
|
324
|
-
with Horizontal(classes="panel-header"):
|
|
325
|
-
yield Static("🤖 Active Agents", classes="panel-title")
|
|
326
|
-
yield Static(f"({len(running)})", classes="agent-count")
|
|
327
|
-
|
|
328
|
-
if not running:
|
|
329
|
-
yield Static("No agents running", classes="empty-state")
|
|
330
|
-
else:
|
|
331
|
-
with VerticalScroll(classes="agents-list"):
|
|
332
|
-
for agent in running:
|
|
333
|
-
yield self._agent_item(agent)
|
|
334
|
-
|
|
335
|
-
def _agent_item(self, agent: dict[str, Any]) -> Widget:
|
|
336
|
-
"""Create an agent item widget."""
|
|
337
|
-
run_id = agent.get("run_id", "")[:8]
|
|
338
|
-
branch = agent.get("branch", "")[:15] or "main"
|
|
339
|
-
started = agent.get("started_at", "")
|
|
340
|
-
|
|
341
|
-
# Calculate duration
|
|
342
|
-
if started:
|
|
343
|
-
try:
|
|
344
|
-
started_dt = datetime.fromisoformat(started.replace("Z", "+00:00"))
|
|
345
|
-
duration = datetime.now(started_dt.tzinfo) - started_dt
|
|
346
|
-
total_minutes = int(duration.total_seconds() // 60)
|
|
347
|
-
duration_str = f"{total_minutes}m"
|
|
348
|
-
except Exception:
|
|
349
|
-
duration_str = "?"
|
|
350
|
-
else:
|
|
351
|
-
duration_str = "?"
|
|
352
|
-
|
|
353
|
-
return Static(f"● {run_id} ({branch}) {duration_str}", classes="agent-item")
|
|
354
|
-
|
|
355
|
-
def watch_agents(self, agents: list[dict[str, Any]]) -> None:
|
|
356
|
-
"""Recompose when agents change."""
|
|
357
|
-
asyncio.create_task(self.recompose())
|
|
358
|
-
|
|
359
|
-
def update_agents(self, agents: list[dict[str, Any]]) -> None:
|
|
360
|
-
"""Update the agents list."""
|
|
361
|
-
self.agents = agents
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
class ReviewQueuePanel(Widget):
|
|
365
|
-
"""Panel showing tasks in review status."""
|
|
366
|
-
|
|
367
|
-
DEFAULT_CSS = """
|
|
368
|
-
ReviewQueuePanel {
|
|
369
|
-
border: round #45475a;
|
|
370
|
-
padding: 1;
|
|
371
|
-
height: 1fr;
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
ReviewQueuePanel .panel-header {
|
|
375
|
-
layout: horizontal;
|
|
376
|
-
height: 1;
|
|
377
|
-
margin-bottom: 1;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
ReviewQueuePanel .panel-title {
|
|
381
|
-
text-style: bold;
|
|
382
|
-
color: #a78bfa;
|
|
383
|
-
width: 1fr;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
ReviewQueuePanel .queue-count {
|
|
387
|
-
color: #a855f7;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
ReviewQueuePanel .review-list {
|
|
391
|
-
height: 1fr;
|
|
392
|
-
overflow-y: auto;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
ReviewQueuePanel .review-item {
|
|
396
|
-
height: 2;
|
|
397
|
-
padding: 0 1;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
ReviewQueuePanel .review-item:hover {
|
|
401
|
-
background: #45475a;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
ReviewQueuePanel .review-item.--selected {
|
|
405
|
-
background: #6d28d9;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
ReviewQueuePanel .review-id {
|
|
409
|
-
color: #a855f7;
|
|
410
|
-
width: 16;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
ReviewQueuePanel .review-title {
|
|
414
|
-
width: 1fr;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
ReviewQueuePanel .review-time {
|
|
418
|
-
color: #6c7086;
|
|
419
|
-
width: 6;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
ReviewQueuePanel .action-row {
|
|
423
|
-
layout: horizontal;
|
|
424
|
-
height: 3;
|
|
425
|
-
margin-top: 1;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
ReviewQueuePanel .action-row Button {
|
|
429
|
-
margin-right: 1;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
ReviewQueuePanel .empty-state {
|
|
433
|
-
color: #6c7086;
|
|
434
|
-
content-align: center middle;
|
|
435
|
-
height: 1fr;
|
|
436
|
-
}
|
|
437
|
-
"""
|
|
438
|
-
|
|
439
|
-
tasks: reactive[list[dict[str, Any]]] = reactive(list)
|
|
440
|
-
selected_index = reactive(0)
|
|
441
|
-
|
|
442
|
-
def compose(self) -> ComposeResult:
|
|
443
|
-
review_tasks = [t for t in self.tasks if t.get("status") == "review"]
|
|
444
|
-
|
|
445
|
-
with Horizontal(classes="panel-header"):
|
|
446
|
-
yield Static("📋 Review Queue", classes="panel-title")
|
|
447
|
-
yield Static(f"({len(review_tasks)})", classes="queue-count")
|
|
448
|
-
|
|
449
|
-
if not review_tasks:
|
|
450
|
-
yield Static("No tasks awaiting review", classes="empty-state")
|
|
451
|
-
else:
|
|
452
|
-
with VerticalScroll(classes="review-list"):
|
|
453
|
-
for i, task in enumerate(review_tasks):
|
|
454
|
-
ref = task.get("ref", "")
|
|
455
|
-
title = task.get("title", "Untitled")[:25]
|
|
456
|
-
if len(task.get("title", "")) > 25:
|
|
457
|
-
title += "..."
|
|
458
|
-
|
|
459
|
-
# Calculate wait time
|
|
460
|
-
updated = task.get("updated_at", "")
|
|
461
|
-
if updated:
|
|
462
|
-
try:
|
|
463
|
-
updated_dt = datetime.fromisoformat(updated.replace("Z", "+00:00"))
|
|
464
|
-
wait = datetime.now(updated_dt.tzinfo) - updated_dt
|
|
465
|
-
minutes = int(wait.total_seconds() // 60)
|
|
466
|
-
wait_str = f"⏳{minutes}m"
|
|
467
|
-
except Exception:
|
|
468
|
-
wait_str = "⏳?"
|
|
469
|
-
else:
|
|
470
|
-
wait_str = "⏳?"
|
|
471
|
-
|
|
472
|
-
classes = "review-item"
|
|
473
|
-
if i == self.selected_index:
|
|
474
|
-
classes += " --selected"
|
|
475
|
-
|
|
476
|
-
yield Static(f"{ref} {title} {wait_str}", classes=classes, id=f"review-{i}")
|
|
477
|
-
|
|
478
|
-
with Horizontal(classes="action-row"):
|
|
479
|
-
yield Button("[Y] Approve", variant="success", id="btn-approve")
|
|
480
|
-
yield Button("[N] Reject", variant="error", id="btn-reject")
|
|
481
|
-
|
|
482
|
-
def watch_tasks(self, tasks: list[dict[str, Any]]) -> None:
|
|
483
|
-
"""Recompose when tasks change."""
|
|
484
|
-
asyncio.create_task(self.recompose())
|
|
485
|
-
|
|
486
|
-
def update_tasks(self, tasks: list[dict[str, Any]]) -> None:
|
|
487
|
-
"""Update the tasks list."""
|
|
488
|
-
self.tasks = tasks
|
|
489
|
-
|
|
490
|
-
def get_selected_task(self) -> dict[str, Any] | None:
|
|
491
|
-
"""Get the currently selected task."""
|
|
492
|
-
review_tasks = [t for t in self.tasks if t.get("status") == "review"]
|
|
493
|
-
if 0 <= self.selected_index < len(review_tasks):
|
|
494
|
-
return review_tasks[self.selected_index]
|
|
495
|
-
return None
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
class InterAgentMessagePanel(Widget):
|
|
499
|
-
"""Panel showing inter-agent messages."""
|
|
500
|
-
|
|
501
|
-
DEFAULT_CSS = """
|
|
502
|
-
InterAgentMessagePanel {
|
|
503
|
-
border: round #45475a;
|
|
504
|
-
padding: 1;
|
|
505
|
-
height: 1fr;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
InterAgentMessagePanel .panel-title {
|
|
509
|
-
text-style: bold;
|
|
510
|
-
color: #a78bfa;
|
|
511
|
-
margin-bottom: 1;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
InterAgentMessagePanel .messages-list {
|
|
515
|
-
height: 1fr;
|
|
516
|
-
overflow-y: auto;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
InterAgentMessagePanel .message-item {
|
|
520
|
-
height: 1;
|
|
521
|
-
padding: 0 1;
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
InterAgentMessagePanel .message-outgoing {
|
|
525
|
-
color: #06b6d4;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
InterAgentMessagePanel .message-incoming {
|
|
529
|
-
color: #cdd6f4;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
InterAgentMessagePanel .message-sender {
|
|
533
|
-
color: #a6adc8;
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
InterAgentMessagePanel .empty-state {
|
|
537
|
-
color: #6c7086;
|
|
538
|
-
content-align: center middle;
|
|
539
|
-
height: 1fr;
|
|
540
|
-
}
|
|
541
|
-
"""
|
|
542
|
-
|
|
543
|
-
messages: reactive[list[dict[str, Any]]] = reactive(list)
|
|
544
|
-
|
|
545
|
-
def compose(self) -> ComposeResult:
|
|
546
|
-
yield Static("💬 Inter-Agent Messages", classes="panel-title")
|
|
547
|
-
|
|
548
|
-
if not self.messages:
|
|
549
|
-
yield Static("No messages yet", classes="empty-state")
|
|
550
|
-
else:
|
|
551
|
-
with VerticalScroll(classes="messages-list"):
|
|
552
|
-
for msg in self.messages[-20:]: # Show last 20
|
|
553
|
-
direction = msg.get("direction", "outgoing")
|
|
554
|
-
sender = msg.get("sender", "unknown")[:8]
|
|
555
|
-
content = msg.get("content", "")[:60]
|
|
556
|
-
|
|
557
|
-
arrow = "→" if direction == "outgoing" else "←"
|
|
558
|
-
css_class = f"message-{direction}"
|
|
559
|
-
|
|
560
|
-
yield Static(
|
|
561
|
-
f"{arrow} [{sender}] {content}", classes=f"message-item {css_class}"
|
|
562
|
-
)
|
|
563
|
-
|
|
564
|
-
def watch_messages(self, messages: list[dict[str, Any]]) -> None:
|
|
565
|
-
"""Recompose when messages change."""
|
|
566
|
-
asyncio.create_task(self.recompose())
|
|
567
|
-
|
|
568
|
-
def add_message(self, sender: str, content: str, direction: str = "incoming") -> None:
|
|
569
|
-
"""Add a new message."""
|
|
570
|
-
new_messages = list(self.messages)
|
|
571
|
-
new_messages.append(
|
|
572
|
-
{
|
|
573
|
-
"sender": sender,
|
|
574
|
-
"content": content,
|
|
575
|
-
"direction": direction,
|
|
576
|
-
"timestamp": datetime.now().isoformat(),
|
|
577
|
-
}
|
|
578
|
-
)
|
|
579
|
-
# Keep last 100 messages
|
|
580
|
-
self.messages = new_messages[-100:]
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
class OrchestratorScreen(Widget):
|
|
584
|
-
"""Orchestrator screen with conductor dashboard."""
|
|
585
|
-
|
|
586
|
-
DEFAULT_CSS = """
|
|
587
|
-
OrchestratorScreen {
|
|
588
|
-
width: 1fr;
|
|
589
|
-
height: 1fr;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
OrchestratorScreen #orchestrator-grid {
|
|
593
|
-
layout: grid;
|
|
594
|
-
grid-size: 2 3;
|
|
595
|
-
grid-gutter: 1;
|
|
596
|
-
padding: 1;
|
|
597
|
-
height: 1fr;
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
OrchestratorScreen #conductor-panel {
|
|
601
|
-
row-span: 1;
|
|
602
|
-
column-span: 1;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
OrchestratorScreen #budget-mode-container {
|
|
606
|
-
row-span: 1;
|
|
607
|
-
column-span: 1;
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
OrchestratorScreen #active-agents-panel {
|
|
611
|
-
row-span: 1;
|
|
612
|
-
column-span: 1;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
OrchestratorScreen #review-queue-panel {
|
|
616
|
-
row-span: 1;
|
|
617
|
-
column-span: 1;
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
OrchestratorScreen #messages-panel {
|
|
621
|
-
row-span: 1;
|
|
622
|
-
column-span: 2;
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
OrchestratorScreen .loading-container {
|
|
626
|
-
width: 1fr;
|
|
627
|
-
height: 1fr;
|
|
628
|
-
content-align: center middle;
|
|
629
|
-
}
|
|
630
|
-
"""
|
|
631
|
-
|
|
632
|
-
loading = reactive(True)
|
|
633
|
-
mode = reactive("interactive")
|
|
634
|
-
|
|
635
|
-
def __init__(
|
|
636
|
-
self,
|
|
637
|
-
api_client: GobbyAPIClient,
|
|
638
|
-
ws_client: GobbyWebSocketClient,
|
|
639
|
-
**kwargs: Any,
|
|
640
|
-
) -> None:
|
|
641
|
-
super().__init__(**kwargs)
|
|
642
|
-
self.api_client = api_client
|
|
643
|
-
self.ws_client = ws_client
|
|
644
|
-
|
|
645
|
-
def compose(self) -> ComposeResult:
|
|
646
|
-
if self.loading:
|
|
647
|
-
with Container(classes="loading-container"):
|
|
648
|
-
yield LoadingIndicator()
|
|
649
|
-
else:
|
|
650
|
-
with Grid(id="orchestrator-grid"):
|
|
651
|
-
yield ConductorPanel(id="conductor-panel")
|
|
652
|
-
|
|
653
|
-
with Vertical(id="budget-mode-container"):
|
|
654
|
-
yield TokenBudgetPanel(id="budget-panel")
|
|
655
|
-
yield ModeIndicatorPanel(id="mode-panel")
|
|
656
|
-
|
|
657
|
-
yield ActiveAgentsPanel(id="active-agents-panel")
|
|
658
|
-
yield ReviewQueuePanel(id="review-queue-panel")
|
|
659
|
-
yield InterAgentMessagePanel(id="messages-panel")
|
|
660
|
-
|
|
661
|
-
async def on_mount(self) -> None:
|
|
662
|
-
"""Load data when mounted."""
|
|
663
|
-
await self.refresh_data()
|
|
664
|
-
|
|
665
|
-
async def refresh_data(self) -> None:
|
|
666
|
-
"""Refresh orchestrator data."""
|
|
667
|
-
try:
|
|
668
|
-
status = await self.api_client.get_status()
|
|
669
|
-
tasks = await self.api_client.list_tasks(status="review")
|
|
670
|
-
agents = await self.api_client.list_agents()
|
|
671
|
-
await self._update_panels(status, tasks, agents)
|
|
672
|
-
|
|
673
|
-
except Exception as e:
|
|
674
|
-
self.notify(f"Failed to load orchestrator data: {e}", severity="error")
|
|
675
|
-
finally:
|
|
676
|
-
self.loading = False
|
|
677
|
-
await self.recompose()
|
|
678
|
-
|
|
679
|
-
async def _update_panels(
|
|
680
|
-
self,
|
|
681
|
-
status: dict[str, Any],
|
|
682
|
-
tasks: list[dict[str, Any]],
|
|
683
|
-
agents: list[dict[str, Any]],
|
|
684
|
-
) -> None:
|
|
685
|
-
"""Update all orchestrator panels."""
|
|
686
|
-
try:
|
|
687
|
-
# Update conductor haiku based on status
|
|
688
|
-
conductor = self.query_one("#conductor-panel", ConductorPanel)
|
|
689
|
-
haiku = self._generate_conductor_haiku(status, tasks, agents)
|
|
690
|
-
conductor.update_haiku(haiku)
|
|
691
|
-
|
|
692
|
-
# Update budget (placeholder values for now)
|
|
693
|
-
budget = self.query_one("#budget-panel", TokenBudgetPanel)
|
|
694
|
-
budget.update_budget(spent=33.50, limit=50.00, period="7d")
|
|
695
|
-
|
|
696
|
-
# Update mode
|
|
697
|
-
mode_panel = self.query_one("#mode-panel", ModeIndicatorPanel)
|
|
698
|
-
mode_panel.set_mode(self.mode)
|
|
699
|
-
|
|
700
|
-
# Update agents
|
|
701
|
-
agents_panel = self.query_one("#active-agents-panel", ActiveAgentsPanel)
|
|
702
|
-
agents_panel.update_agents(agents)
|
|
703
|
-
|
|
704
|
-
# Update review queue
|
|
705
|
-
review_panel = self.query_one("#review-queue-panel", ReviewQueuePanel)
|
|
706
|
-
review_panel.update_tasks(tasks)
|
|
707
|
-
|
|
708
|
-
except Exception:
|
|
709
|
-
pass # nosec B110 - TUI update failure is non-critical
|
|
710
|
-
|
|
711
|
-
def _generate_conductor_haiku(
|
|
712
|
-
self,
|
|
713
|
-
status: dict[str, Any],
|
|
714
|
-
tasks: list[dict[str, Any]],
|
|
715
|
-
agents: list[dict[str, Any]],
|
|
716
|
-
) -> list[str]:
|
|
717
|
-
"""Generate a haiku based on current state."""
|
|
718
|
-
review_count = len([t for t in tasks if t.get("status") == "review"])
|
|
719
|
-
running_agents = len([a for a in agents if a.get("status") == "running"])
|
|
720
|
-
tasks_info = status.get("tasks", {})
|
|
721
|
-
open_count = tasks_info.get("open", 0)
|
|
722
|
-
|
|
723
|
-
if review_count > 0:
|
|
724
|
-
return [
|
|
725
|
-
f"{review_count} await review",
|
|
726
|
-
"Code written, tests complete",
|
|
727
|
-
"Your approval waits",
|
|
728
|
-
]
|
|
729
|
-
elif running_agents > 0:
|
|
730
|
-
return [
|
|
731
|
-
f"{running_agents} agent{'s' if running_agents != 1 else ''} at work",
|
|
732
|
-
"Code flows through busy hands",
|
|
733
|
-
"Progress unfolds",
|
|
734
|
-
]
|
|
735
|
-
elif open_count > 0:
|
|
736
|
-
return [
|
|
737
|
-
f"{open_count} tasks await you",
|
|
738
|
-
"Ready for your attention",
|
|
739
|
-
"Choose and begin",
|
|
740
|
-
]
|
|
741
|
-
else:
|
|
742
|
-
return [
|
|
743
|
-
"All is quiet now",
|
|
744
|
-
"No tasks need attention here",
|
|
745
|
-
"Peace in the system",
|
|
746
|
-
]
|
|
747
|
-
|
|
748
|
-
async def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
749
|
-
"""Handle button presses."""
|
|
750
|
-
button_id = event.button.id
|
|
751
|
-
|
|
752
|
-
if button_id == "mode-toggle":
|
|
753
|
-
await self._toggle_mode()
|
|
754
|
-
|
|
755
|
-
elif button_id == "btn-approve":
|
|
756
|
-
await self._approve_task()
|
|
757
|
-
|
|
758
|
-
elif button_id == "btn-reject":
|
|
759
|
-
await self._reject_task()
|
|
760
|
-
|
|
761
|
-
async def on_key(self, event: Any) -> None:
|
|
762
|
-
"""Handle key events for orchestrator shortcuts."""
|
|
763
|
-
key = event.key
|
|
764
|
-
|
|
765
|
-
if key == "space":
|
|
766
|
-
await self._toggle_mode()
|
|
767
|
-
elif key == "y":
|
|
768
|
-
await self._approve_task()
|
|
769
|
-
elif key == "n":
|
|
770
|
-
await self._reject_task()
|
|
771
|
-
elif key == "p":
|
|
772
|
-
self.mode = "paused"
|
|
773
|
-
await self._update_mode_panel()
|
|
774
|
-
elif key in ("j", "down"):
|
|
775
|
-
await self._navigate_review_queue(1)
|
|
776
|
-
elif key in ("k", "up"):
|
|
777
|
-
await self._navigate_review_queue(-1)
|
|
778
|
-
|
|
779
|
-
async def _navigate_review_queue(self, delta: int) -> None:
|
|
780
|
-
"""Navigate review queue selection."""
|
|
781
|
-
try:
|
|
782
|
-
review_panel = self.query_one("#review-queue-panel", ReviewQueuePanel)
|
|
783
|
-
review_tasks = [t for t in review_panel.tasks if t.get("status") == "review"]
|
|
784
|
-
if not review_tasks:
|
|
785
|
-
return
|
|
786
|
-
new_index = review_panel.selected_index + delta
|
|
787
|
-
review_panel.selected_index = max(0, min(new_index, len(review_tasks) - 1))
|
|
788
|
-
except Exception:
|
|
789
|
-
pass # nosec B110 - navigation failure is non-critical
|
|
790
|
-
|
|
791
|
-
async def _toggle_mode(self) -> None:
|
|
792
|
-
"""Toggle between interactive and autonomous modes."""
|
|
793
|
-
if self.mode == "interactive":
|
|
794
|
-
self.mode = "autonomous"
|
|
795
|
-
self.notify("Autonomous mode enabled")
|
|
796
|
-
elif self.mode == "autonomous":
|
|
797
|
-
self.mode = "interactive"
|
|
798
|
-
self.notify("Interactive mode enabled")
|
|
799
|
-
else:
|
|
800
|
-
self.mode = "interactive"
|
|
801
|
-
self.notify("Resumed to interactive mode")
|
|
802
|
-
|
|
803
|
-
await self._update_mode_panel()
|
|
804
|
-
|
|
805
|
-
async def _update_mode_panel(self) -> None:
|
|
806
|
-
"""Update the mode panel display."""
|
|
807
|
-
try:
|
|
808
|
-
mode_panel = self.query_one("#mode-panel", ModeIndicatorPanel)
|
|
809
|
-
mode_panel.set_mode(self.mode)
|
|
810
|
-
except Exception:
|
|
811
|
-
pass # nosec B110 - widget may not be mounted yet
|
|
812
|
-
|
|
813
|
-
async def _approve_task(self) -> None:
|
|
814
|
-
"""Approve the selected review task."""
|
|
815
|
-
try:
|
|
816
|
-
review_panel = self.query_one("#review-queue-panel", ReviewQueuePanel)
|
|
817
|
-
task = review_panel.get_selected_task()
|
|
818
|
-
|
|
819
|
-
if not task:
|
|
820
|
-
self.notify("No task selected", severity="warning")
|
|
821
|
-
return
|
|
822
|
-
|
|
823
|
-
task_id = task.get("id")
|
|
824
|
-
if task_id is None:
|
|
825
|
-
self.notify("Task has no ID", severity="error")
|
|
826
|
-
return
|
|
827
|
-
await self.api_client.close_task(
|
|
828
|
-
task_id,
|
|
829
|
-
reason="completed",
|
|
830
|
-
)
|
|
831
|
-
self.notify(f"Approved: {task.get('ref', task_id)}")
|
|
832
|
-
|
|
833
|
-
messages_panel = self.query_one("#messages-panel", InterAgentMessagePanel)
|
|
834
|
-
messages_panel.add_message(
|
|
835
|
-
"conductor", f"Approved task {task.get('ref', '')}", "outgoing"
|
|
836
|
-
)
|
|
837
|
-
|
|
838
|
-
await self.refresh_data()
|
|
839
|
-
|
|
840
|
-
except Exception as e:
|
|
841
|
-
self.notify(f"Failed to approve: {e}", severity="error")
|
|
842
|
-
|
|
843
|
-
async def _reject_task(self) -> None:
|
|
844
|
-
"""Reject/reopen the selected review task."""
|
|
845
|
-
try:
|
|
846
|
-
review_panel = self.query_one("#review-queue-panel", ReviewQueuePanel)
|
|
847
|
-
task = review_panel.get_selected_task()
|
|
848
|
-
|
|
849
|
-
if not task:
|
|
850
|
-
self.notify("No task selected", severity="warning")
|
|
851
|
-
return
|
|
852
|
-
|
|
853
|
-
task_id = task.get("id")
|
|
854
|
-
await self.api_client.call_tool(
|
|
855
|
-
"gobby-tasks",
|
|
856
|
-
"reopen_task",
|
|
857
|
-
{"task_id": task_id},
|
|
858
|
-
)
|
|
859
|
-
self.notify(f"Rejected: {task.get('ref', task_id)}")
|
|
860
|
-
|
|
861
|
-
messages_panel = self.query_one("#messages-panel", InterAgentMessagePanel)
|
|
862
|
-
messages_panel.add_message(
|
|
863
|
-
"conductor", f"Rejected task {task.get('ref', '')}", "outgoing"
|
|
864
|
-
)
|
|
865
|
-
|
|
866
|
-
await self.refresh_data()
|
|
867
|
-
|
|
868
|
-
except Exception as e:
|
|
869
|
-
self.notify(f"Failed to reject: {e}", severity="error")
|
|
870
|
-
|
|
871
|
-
def on_ws_event(self, event_type: str, data: dict[str, Any]) -> None:
|
|
872
|
-
"""Handle WebSocket events."""
|
|
873
|
-
try:
|
|
874
|
-
# Handle agent events
|
|
875
|
-
if event_type == "agent_event":
|
|
876
|
-
event = data.get("event", "")
|
|
877
|
-
run_id = data.get("run_id", "")[:8]
|
|
878
|
-
|
|
879
|
-
messages_panel = self.query_one("#messages-panel", InterAgentMessagePanel)
|
|
880
|
-
messages_panel.add_message(run_id, f"Agent {event}", "incoming")
|
|
881
|
-
|
|
882
|
-
asyncio.create_task(self.refresh_data())
|
|
883
|
-
|
|
884
|
-
# Handle autonomous events
|
|
885
|
-
elif event_type == "autonomous_event":
|
|
886
|
-
event = data.get("event", "")
|
|
887
|
-
task_id = data.get("task_id", "")
|
|
888
|
-
|
|
889
|
-
messages_panel = self.query_one("#messages-panel", InterAgentMessagePanel)
|
|
890
|
-
messages_panel.add_message("system", f"{event}: {task_id}", "incoming")
|
|
891
|
-
|
|
892
|
-
asyncio.create_task(self.refresh_data())
|
|
893
|
-
|
|
894
|
-
# Handle session messages (inter-agent)
|
|
895
|
-
elif event_type == "session_message":
|
|
896
|
-
session_id = data.get("session_id", "")[:8]
|
|
897
|
-
content = data.get("message", {}).get("content", "")[:60]
|
|
898
|
-
|
|
899
|
-
messages_panel = self.query_one("#messages-panel", InterAgentMessagePanel)
|
|
900
|
-
messages_panel.add_message(session_id, content, "incoming")
|
|
901
|
-
|
|
902
|
-
except Exception:
|
|
903
|
-
pass # nosec B110 - TUI event handling failure is non-critical
|