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,412 +0,0 @@
1
- """Sessions screen with list and search."""
2
-
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- import logging
7
- from datetime import datetime
8
- from typing import Any
9
-
10
- from textual.app import ComposeResult
11
- from textual.containers import Container, Horizontal, Vertical
12
- from textual.reactive import reactive
13
- from textual.widget import Widget
14
- from textual.widgets import (
15
- Button,
16
- DataTable,
17
- Input,
18
- LoadingIndicator,
19
- Select,
20
- Static,
21
- )
22
-
23
- from gobby.tui.api_client import GobbyAPIClient
24
- from gobby.tui.ws_client import GobbyWebSocketClient
25
-
26
- logger = logging.getLogger(__name__)
27
-
28
-
29
- class SessionListPanel(Widget):
30
- """Panel displaying session list."""
31
-
32
- DEFAULT_CSS = """
33
- SessionListPanel {
34
- width: 1fr;
35
- height: 1fr;
36
- border-right: solid #45475a;
37
- }
38
-
39
- SessionListPanel .panel-header {
40
- height: auto;
41
- padding: 1;
42
- background: #313244;
43
- }
44
-
45
- SessionListPanel .search-row {
46
- layout: horizontal;
47
- height: 3;
48
- }
49
-
50
- SessionListPanel #session-search {
51
- width: 1fr;
52
- margin-right: 1;
53
- }
54
-
55
- SessionListPanel #status-filter {
56
- width: 20;
57
- }
58
-
59
- SessionListPanel #sessions-table {
60
- height: 1fr;
61
- }
62
- """
63
-
64
- def compose(self) -> ComposeResult:
65
- with Vertical(classes="panel-header"):
66
- yield Static("📂 Sessions", classes="panel-title")
67
- with Horizontal(classes="search-row"):
68
- yield Input(placeholder="Search sessions...", id="session-search")
69
- yield Select(
70
- [
71
- (label, value)
72
- for label, value in [
73
- ("All", "all"),
74
- ("Active", "active"),
75
- ("Paused", "paused"),
76
- ("Handoff Ready", "handoff_ready"),
77
- ]
78
- ],
79
- value="all",
80
- id="status-filter",
81
- )
82
- yield DataTable(id="sessions-table")
83
-
84
- def on_mount(self) -> None:
85
- """Set up the data table."""
86
- table = self.query_one("#sessions-table", DataTable)
87
- table.add_columns("ID", "Source", "Status", "Branch", "Age")
88
- table.cursor_type = "row"
89
-
90
-
91
- class SessionDetailPanel(Widget):
92
- """Panel displaying session details."""
93
-
94
- DEFAULT_CSS = """
95
- SessionDetailPanel {
96
- width: 1fr;
97
- height: 1fr;
98
- padding: 1;
99
- }
100
-
101
- SessionDetailPanel .detail-header {
102
- height: auto;
103
- padding-bottom: 1;
104
- }
105
-
106
- SessionDetailPanel .detail-title {
107
- text-style: bold;
108
- color: #a78bfa;
109
- }
110
-
111
- SessionDetailPanel .detail-section {
112
- padding: 1 0;
113
- }
114
-
115
- SessionDetailPanel .detail-row {
116
- layout: horizontal;
117
- height: 1;
118
- }
119
-
120
- SessionDetailPanel .detail-label {
121
- color: #a6adc8;
122
- width: 14;
123
- }
124
-
125
- SessionDetailPanel .detail-value {
126
- width: 1fr;
127
- }
128
-
129
- SessionDetailPanel .context-section {
130
- padding: 1;
131
- border: round #45475a;
132
- height: auto;
133
- max-height: 15;
134
- overflow-y: auto;
135
- }
136
-
137
- SessionDetailPanel .action-buttons {
138
- layout: horizontal;
139
- height: 3;
140
- padding-top: 1;
141
- }
142
-
143
- SessionDetailPanel .action-buttons Button {
144
- margin-right: 1;
145
- }
146
-
147
- SessionDetailPanel .empty-state {
148
- content-align: center middle;
149
- height: 1fr;
150
- color: #a6adc8;
151
- }
152
- """
153
-
154
- session: reactive[dict[str, Any] | None] = reactive(None)
155
-
156
- def compose(self) -> ComposeResult:
157
- if self.session is None:
158
- yield Static("Select a session to view details", classes="empty-state")
159
- else:
160
- with Vertical(classes="detail-header"):
161
- yield Static(
162
- self.session.get("title", "Untitled Session"),
163
- classes="detail-title",
164
- )
165
-
166
- with Vertical(classes="detail-section"):
167
- details = [
168
- ("ID", self.session.get("id", "")[:12] + "..."),
169
- ("Source", self.session.get("source", "Unknown")),
170
- ("Status", self.session.get("status", "unknown")),
171
- ("Branch", self.session.get("git_branch", "N/A")),
172
- (
173
- "Project",
174
- self.session.get("project_id", "N/A")[:12]
175
- if self.session.get("project_id")
176
- else "N/A",
177
- ),
178
- (
179
- "Machine",
180
- self.session.get("machine_id", "N/A")[:12]
181
- if self.session.get("machine_id")
182
- else "N/A",
183
- ),
184
- ]
185
- for label, value in details:
186
- with Horizontal(classes="detail-row"):
187
- yield Static(f"{label}:", classes="detail-label")
188
- yield Static(str(value), classes="detail-value")
189
-
190
- # Show compact context if available
191
- context = self.session.get("compact_markdown", "")
192
- if context:
193
- yield Static("Context:", classes="detail-label")
194
- yield Static(
195
- context[:500] + "..." if len(context) > 500 else context,
196
- classes="context-section",
197
- )
198
-
199
- with Horizontal(classes="action-buttons"):
200
- yield Button("Pickup", variant="primary", id="btn-pickup")
201
- yield Button("View Handoff", id="btn-handoff")
202
-
203
- def watch_session(self, session: dict[str, Any] | None) -> None:
204
- """Recompose when session changes."""
205
-
206
- def _handle_recompose_error(task: asyncio.Task[None]) -> None:
207
- if not task.cancelled() and task.exception():
208
- logger.error(f"Recompose failed: {task.exception()}", exc_info=task.exception())
209
-
210
- task = asyncio.create_task(self.recompose())
211
- task.add_done_callback(_handle_recompose_error)
212
-
213
- def update_session(self, session: dict[str, Any] | None) -> None:
214
- """Update the displayed session."""
215
- self.session = session
216
-
217
-
218
- class SessionsScreen(Widget):
219
- """Sessions screen with list and detail view."""
220
-
221
- DEFAULT_CSS = """
222
- SessionsScreen {
223
- width: 1fr;
224
- height: 1fr;
225
- }
226
-
227
- SessionsScreen #sessions-container {
228
- layout: horizontal;
229
- height: 1fr;
230
- }
231
-
232
- SessionsScreen #list-panel {
233
- width: 55%;
234
- }
235
-
236
- SessionsScreen #detail-panel {
237
- width: 45%;
238
- }
239
-
240
- SessionsScreen .loading-container {
241
- width: 1fr;
242
- height: 1fr;
243
- content-align: center middle;
244
- }
245
- """
246
-
247
- loading = reactive(True)
248
- sessions: reactive[list[dict[str, Any]]] = reactive(list)
249
- selected_session_id: reactive[str | None] = reactive(None)
250
- current_filter = "all"
251
- search_query = ""
252
-
253
- def __init__(
254
- self,
255
- api_client: GobbyAPIClient,
256
- ws_client: GobbyWebSocketClient,
257
- **kwargs: Any,
258
- ) -> None:
259
- super().__init__(**kwargs)
260
- self.api_client = api_client
261
- self.ws_client = ws_client
262
- self._session_map: dict[str, dict[str, Any]] = {}
263
-
264
- def compose(self) -> ComposeResult:
265
- if self.loading:
266
- with Container(classes="loading-container"):
267
- yield LoadingIndicator()
268
- else:
269
- with Horizontal(id="sessions-container"):
270
- yield SessionListPanel(id="list-panel")
271
- yield SessionDetailPanel(id="detail-panel")
272
-
273
- async def on_mount(self) -> None:
274
- """Load data when mounted."""
275
- await self.refresh_data()
276
-
277
- async def refresh_data(self) -> None:
278
- """Refresh session list."""
279
- self.loading = True
280
- await self.recompose()
281
-
282
- try:
283
- status = None if self.current_filter == "all" else self.current_filter
284
- sessions = await self.api_client.list_sessions(status=status, limit=100)
285
- self.sessions = sessions
286
- self._session_map = {s.get("id", ""): s for s in sessions}
287
-
288
- except Exception as e:
289
- self.notify(f"Failed to load sessions: {e}", severity="error")
290
- finally:
291
- self.loading = False
292
- await self.recompose()
293
- self._populate_table()
294
-
295
- def _populate_table(self) -> None:
296
- """Populate the sessions table."""
297
- try:
298
- table = self.query_one("#sessions-table", DataTable)
299
- table.clear()
300
-
301
- # Filter by search query
302
- filtered = self.sessions
303
- if self.search_query:
304
- query = self.search_query.lower()
305
- filtered = [
306
- s
307
- for s in self.sessions
308
- if query in s.get("id", "").lower()
309
- or query in s.get("source", "").lower()
310
- or query in s.get("title", "").lower()
311
- or query in s.get("git_branch", "").lower()
312
- ]
313
-
314
- for session in filtered:
315
- session_id = session.get("id", "")[:12]
316
- source = session.get("source", "Unknown")[:12]
317
- status = session.get("status", "unknown")
318
- branch = session.get("git_branch", "N/A")[:15]
319
-
320
- # Calculate age
321
- created = session.get("created_at", "")
322
- if created:
323
- try:
324
- created_dt = datetime.fromisoformat(created.replace("Z", "+00:00"))
325
- age = datetime.now(created_dt.tzinfo) - created_dt
326
- if age.days > 0:
327
- age_str = f"{age.days}d"
328
- elif age.seconds > 3600:
329
- age_str = f"{age.seconds // 3600}h"
330
- else:
331
- age_str = f"{age.seconds // 60}m"
332
- except Exception:
333
- age_str = "?"
334
- else:
335
- age_str = "?"
336
-
337
- table.add_row(session_id, source, status, branch, age_str, key=session.get("id"))
338
-
339
- except Exception as e:
340
- logger.debug(f"Widget query failed (may not be mounted): {e}")
341
-
342
- def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
343
- """Handle session selection."""
344
- session_id = str(event.row_key.value) if event.row_key else None
345
- if session_id and session_id in self._session_map:
346
- self.selected_session_id = session_id
347
- session = self._session_map[session_id]
348
- try:
349
- detail_panel = self.query_one("#detail-panel", SessionDetailPanel)
350
- detail_panel.update_session(session)
351
- except Exception as e:
352
- logger.debug(f"Detail panel query failed (may not be mounted): {e}")
353
-
354
- def on_input_changed(self, event: Input.Changed) -> None:
355
- """Handle search input changes."""
356
- if event.input.id == "session-search":
357
- self.search_query = event.value
358
- self._populate_table()
359
-
360
- def on_select_changed(self, event: Select.Changed) -> None:
361
- """Handle filter changes."""
362
-
363
- def _handle_refresh_error(task: asyncio.Task[None]) -> None:
364
- if not task.cancelled() and task.exception():
365
- logger.error(f"Refresh failed: {task.exception()}", exc_info=task.exception())
366
-
367
- if event.select.id == "status-filter":
368
- self.current_filter = str(event.value)
369
- task = asyncio.create_task(self.refresh_data())
370
- task.add_done_callback(_handle_refresh_error)
371
-
372
- async def on_button_pressed(self, event: Button.Pressed) -> None:
373
- """Handle action button presses."""
374
- if not self.selected_session_id:
375
- return
376
-
377
- button_id = event.button.id
378
-
379
- try:
380
- if button_id == "btn-pickup":
381
- await self.api_client.call_tool(
382
- "gobby-sessions",
383
- "pickup",
384
- {"session_id": self.selected_session_id},
385
- )
386
- self.notify(f"Session picked up: {self.selected_session_id[:12]}")
387
-
388
- elif button_id == "btn-handoff":
389
- result = await self.api_client.call_tool(
390
- "gobby-sessions",
391
- "get_handoff_context",
392
- {"session_id": self.selected_session_id},
393
- )
394
- context = result.get("context", "No context available")
395
- self.notify(f"Handoff context: {len(context)} chars")
396
-
397
- except Exception as e:
398
- self.notify(f"Action failed: {e}", severity="error")
399
-
400
- def on_ws_event(self, event_type: str, data: dict[str, Any]) -> None:
401
- """Handle WebSocket events."""
402
- if event_type == "session_message" or event_type == "hook_event":
403
- # Refresh on session events
404
- asyncio.create_task(self.refresh_data())
405
-
406
- def activate_search(self) -> None:
407
- """Focus the search input."""
408
- try:
409
- search = self.query_one("#session-search", Input)
410
- search.focus()
411
- except Exception:
412
- pass # nosec B110 - widget may not be mounted yet