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.
Files changed (125) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/claude_code.py +99 -61
  3. gobby/adapters/gemini.py +140 -38
  4. gobby/agents/isolation.py +130 -0
  5. gobby/agents/registry.py +11 -0
  6. gobby/agents/session.py +1 -0
  7. gobby/agents/spawn_executor.py +43 -13
  8. gobby/agents/spawners/macos.py +26 -1
  9. gobby/app_context.py +59 -0
  10. gobby/cli/__init__.py +0 -2
  11. gobby/cli/memory.py +185 -0
  12. gobby/cli/utils.py +5 -17
  13. gobby/clones/git.py +177 -0
  14. gobby/config/features.py +0 -20
  15. gobby/config/skills.py +31 -0
  16. gobby/config/tasks.py +4 -0
  17. gobby/hooks/event_handlers/__init__.py +155 -0
  18. gobby/hooks/event_handlers/_agent.py +175 -0
  19. gobby/hooks/event_handlers/_base.py +87 -0
  20. gobby/hooks/event_handlers/_misc.py +66 -0
  21. gobby/hooks/event_handlers/_session.py +573 -0
  22. gobby/hooks/event_handlers/_tool.py +196 -0
  23. gobby/hooks/hook_manager.py +21 -1
  24. gobby/install/gemini/hooks/hook_dispatcher.py +74 -15
  25. gobby/llm/claude.py +377 -42
  26. gobby/mcp_proxy/importer.py +4 -41
  27. gobby/mcp_proxy/instructions.py +2 -2
  28. gobby/mcp_proxy/manager.py +13 -3
  29. gobby/mcp_proxy/registries.py +35 -4
  30. gobby/mcp_proxy/services/recommendation.py +2 -28
  31. gobby/mcp_proxy/tools/agent_messaging.py +93 -44
  32. gobby/mcp_proxy/tools/agents.py +45 -9
  33. gobby/mcp_proxy/tools/artifacts.py +46 -12
  34. gobby/mcp_proxy/tools/sessions/_commits.py +31 -24
  35. gobby/mcp_proxy/tools/sessions/_crud.py +5 -5
  36. gobby/mcp_proxy/tools/sessions/_handoff.py +45 -41
  37. gobby/mcp_proxy/tools/sessions/_messages.py +35 -7
  38. gobby/mcp_proxy/tools/spawn_agent.py +44 -6
  39. gobby/mcp_proxy/tools/task_readiness.py +27 -4
  40. gobby/mcp_proxy/tools/tasks/_context.py +18 -0
  41. gobby/mcp_proxy/tools/tasks/_crud.py +13 -6
  42. gobby/mcp_proxy/tools/tasks/_lifecycle.py +29 -14
  43. gobby/mcp_proxy/tools/tasks/_session.py +22 -7
  44. gobby/mcp_proxy/tools/workflows/__init__.py +266 -0
  45. gobby/mcp_proxy/tools/workflows/_artifacts.py +225 -0
  46. gobby/mcp_proxy/tools/workflows/_import.py +112 -0
  47. gobby/mcp_proxy/tools/workflows/_lifecycle.py +321 -0
  48. gobby/mcp_proxy/tools/workflows/_query.py +207 -0
  49. gobby/mcp_proxy/tools/workflows/_resolution.py +78 -0
  50. gobby/mcp_proxy/tools/workflows/_terminal.py +139 -0
  51. gobby/mcp_proxy/tools/worktrees.py +32 -7
  52. gobby/memory/components/__init__.py +0 -0
  53. gobby/memory/components/ingestion.py +98 -0
  54. gobby/memory/components/search.py +108 -0
  55. gobby/memory/extractor.py +15 -1
  56. gobby/memory/manager.py +16 -25
  57. gobby/paths.py +51 -0
  58. gobby/prompts/loader.py +1 -35
  59. gobby/runner.py +36 -10
  60. gobby/servers/http.py +186 -149
  61. gobby/servers/routes/admin.py +12 -0
  62. gobby/servers/routes/mcp/endpoints/execution.py +15 -7
  63. gobby/servers/routes/mcp/endpoints/registry.py +8 -8
  64. gobby/servers/routes/mcp/hooks.py +50 -3
  65. gobby/servers/websocket.py +57 -1
  66. gobby/sessions/analyzer.py +4 -4
  67. gobby/sessions/manager.py +9 -0
  68. gobby/sessions/transcripts/gemini.py +100 -34
  69. gobby/skills/parser.py +23 -0
  70. gobby/skills/sync.py +5 -4
  71. gobby/storage/artifacts.py +19 -0
  72. gobby/storage/database.py +9 -2
  73. gobby/storage/memories.py +32 -21
  74. gobby/storage/migrations.py +46 -4
  75. gobby/storage/sessions.py +4 -2
  76. gobby/storage/skills.py +87 -7
  77. gobby/tasks/external_validator.py +4 -17
  78. gobby/tasks/validation.py +13 -87
  79. gobby/tools/summarizer.py +18 -51
  80. gobby/utils/status.py +13 -0
  81. gobby/workflows/actions.py +5 -0
  82. gobby/workflows/context_actions.py +21 -24
  83. gobby/workflows/detection_helpers.py +38 -24
  84. gobby/workflows/enforcement/__init__.py +11 -1
  85. gobby/workflows/enforcement/blocking.py +109 -1
  86. gobby/workflows/enforcement/handlers.py +35 -1
  87. gobby/workflows/engine.py +96 -0
  88. gobby/workflows/evaluator.py +110 -0
  89. gobby/workflows/hooks.py +41 -0
  90. gobby/workflows/lifecycle_evaluator.py +2 -1
  91. gobby/workflows/memory_actions.py +11 -0
  92. gobby/workflows/safe_evaluator.py +8 -0
  93. gobby/workflows/summary_actions.py +123 -50
  94. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/METADATA +1 -1
  95. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/RECORD +99 -107
  96. gobby/cli/tui.py +0 -34
  97. gobby/hooks/event_handlers.py +0 -909
  98. gobby/mcp_proxy/tools/workflows.py +0 -973
  99. gobby/tui/__init__.py +0 -5
  100. gobby/tui/api_client.py +0 -278
  101. gobby/tui/app.py +0 -329
  102. gobby/tui/screens/__init__.py +0 -25
  103. gobby/tui/screens/agents.py +0 -333
  104. gobby/tui/screens/chat.py +0 -450
  105. gobby/tui/screens/dashboard.py +0 -377
  106. gobby/tui/screens/memory.py +0 -305
  107. gobby/tui/screens/metrics.py +0 -231
  108. gobby/tui/screens/orchestrator.py +0 -903
  109. gobby/tui/screens/sessions.py +0 -412
  110. gobby/tui/screens/tasks.py +0 -440
  111. gobby/tui/screens/workflows.py +0 -289
  112. gobby/tui/screens/worktrees.py +0 -174
  113. gobby/tui/widgets/__init__.py +0 -21
  114. gobby/tui/widgets/chat.py +0 -210
  115. gobby/tui/widgets/conductor.py +0 -104
  116. gobby/tui/widgets/menu.py +0 -132
  117. gobby/tui/widgets/message_panel.py +0 -160
  118. gobby/tui/widgets/review_gate.py +0 -224
  119. gobby/tui/widgets/task_tree.py +0 -99
  120. gobby/tui/widgets/token_budget.py +0 -166
  121. gobby/tui/ws_client.py +0 -258
  122. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/WHEEL +0 -0
  123. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/entry_points.txt +0 -0
  124. {gobby-0.2.7.dist-info → gobby-0.2.9.dist-info}/licenses/LICENSE.md +0 -0
  125. {gobby-0.2.7.dist-info → gobby-0.2.9.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