flowly-code 1.0.0__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 (86) hide show
  1. flowly_code/__init__.py +30 -0
  2. flowly_code/__main__.py +8 -0
  3. flowly_code/activity/__init__.py +1 -0
  4. flowly_code/activity/bus.py +91 -0
  5. flowly_code/activity/events.py +40 -0
  6. flowly_code/agent/__init__.py +8 -0
  7. flowly_code/agent/context.py +485 -0
  8. flowly_code/agent/loop.py +1349 -0
  9. flowly_code/agent/memory.py +109 -0
  10. flowly_code/agent/skills.py +259 -0
  11. flowly_code/agent/subagent.py +249 -0
  12. flowly_code/agent/tools/__init__.py +6 -0
  13. flowly_code/agent/tools/base.py +55 -0
  14. flowly_code/agent/tools/delegate.py +194 -0
  15. flowly_code/agent/tools/dispatch.py +840 -0
  16. flowly_code/agent/tools/docker.py +609 -0
  17. flowly_code/agent/tools/filesystem.py +280 -0
  18. flowly_code/agent/tools/mcp.py +85 -0
  19. flowly_code/agent/tools/message.py +235 -0
  20. flowly_code/agent/tools/registry.py +257 -0
  21. flowly_code/agent/tools/screenshot.py +444 -0
  22. flowly_code/agent/tools/shell.py +166 -0
  23. flowly_code/agent/tools/spawn.py +65 -0
  24. flowly_code/agent/tools/system.py +917 -0
  25. flowly_code/agent/tools/trello.py +420 -0
  26. flowly_code/agent/tools/web.py +139 -0
  27. flowly_code/agent/tools/x.py +399 -0
  28. flowly_code/bus/__init__.py +6 -0
  29. flowly_code/bus/events.py +37 -0
  30. flowly_code/bus/queue.py +81 -0
  31. flowly_code/channels/__init__.py +6 -0
  32. flowly_code/channels/base.py +121 -0
  33. flowly_code/channels/manager.py +135 -0
  34. flowly_code/channels/telegram.py +1132 -0
  35. flowly_code/cli/__init__.py +1 -0
  36. flowly_code/cli/commands.py +1831 -0
  37. flowly_code/cli/setup.py +1356 -0
  38. flowly_code/compaction/__init__.py +39 -0
  39. flowly_code/compaction/estimator.py +88 -0
  40. flowly_code/compaction/pruning.py +223 -0
  41. flowly_code/compaction/service.py +297 -0
  42. flowly_code/compaction/summarizer.py +384 -0
  43. flowly_code/compaction/types.py +71 -0
  44. flowly_code/config/__init__.py +6 -0
  45. flowly_code/config/loader.py +102 -0
  46. flowly_code/config/schema.py +324 -0
  47. flowly_code/exec/__init__.py +39 -0
  48. flowly_code/exec/approvals.py +288 -0
  49. flowly_code/exec/executor.py +184 -0
  50. flowly_code/exec/safety.py +247 -0
  51. flowly_code/exec/types.py +88 -0
  52. flowly_code/gateway/__init__.py +5 -0
  53. flowly_code/gateway/server.py +103 -0
  54. flowly_code/heartbeat/__init__.py +5 -0
  55. flowly_code/heartbeat/service.py +130 -0
  56. flowly_code/multiagent/README.md +248 -0
  57. flowly_code/multiagent/__init__.py +1 -0
  58. flowly_code/multiagent/invoke.py +210 -0
  59. flowly_code/multiagent/orchestrator.py +156 -0
  60. flowly_code/multiagent/router.py +156 -0
  61. flowly_code/multiagent/setup.py +171 -0
  62. flowly_code/pairing/__init__.py +21 -0
  63. flowly_code/pairing/store.py +343 -0
  64. flowly_code/providers/__init__.py +6 -0
  65. flowly_code/providers/base.py +69 -0
  66. flowly_code/providers/litellm_provider.py +178 -0
  67. flowly_code/providers/transcription.py +64 -0
  68. flowly_code/session/__init__.py +5 -0
  69. flowly_code/session/manager.py +249 -0
  70. flowly_code/skills/README.md +24 -0
  71. flowly_code/skills/compact/SKILL.md +27 -0
  72. flowly_code/skills/github/SKILL.md +48 -0
  73. flowly_code/skills/skill-creator/SKILL.md +371 -0
  74. flowly_code/skills/summarize/SKILL.md +67 -0
  75. flowly_code/skills/tmux/SKILL.md +121 -0
  76. flowly_code/skills/tmux/scripts/find-sessions.sh +112 -0
  77. flowly_code/skills/tmux/scripts/wait-for-text.sh +83 -0
  78. flowly_code/skills/weather/SKILL.md +49 -0
  79. flowly_code/utils/__init__.py +5 -0
  80. flowly_code/utils/helpers.py +91 -0
  81. flowly_code-1.0.0.dist-info/METADATA +724 -0
  82. flowly_code-1.0.0.dist-info/RECORD +86 -0
  83. flowly_code-1.0.0.dist-info/WHEEL +4 -0
  84. flowly_code-1.0.0.dist-info/entry_points.txt +2 -0
  85. flowly_code-1.0.0.dist-info/licenses/LICENSE +191 -0
  86. flowly_code-1.0.0.dist-info/licenses/NOTICE +74 -0
@@ -0,0 +1,840 @@
1
+ """Dispatch App integration tools.
2
+
3
+ These tools allow the Flowly agent to interact with Dispatch's
4
+ Rust backend (Axum) via HTTP API, giving access to projects,
5
+ tasks, and Ralph AI workers.
6
+ """
7
+
8
+ import json
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from flowly_code.agent.tools.base import Tool
14
+
15
+
16
+ class DispatchListProjectsTool(Tool):
17
+ """List all Dispatch projects."""
18
+
19
+ def __init__(self, port: int = 8080):
20
+ self.base_url = f"http://127.0.0.1:{port}/api"
21
+
22
+ @property
23
+ def name(self) -> str:
24
+ return "dispatch_list_projects"
25
+
26
+ @property
27
+ def description(self) -> str:
28
+ return (
29
+ "List all projects in Dispatch. "
30
+ "Returns project IDs, names, and creation dates."
31
+ )
32
+
33
+ @property
34
+ def parameters(self) -> dict[str, Any]:
35
+ return {
36
+ "type": "object",
37
+ "properties": {},
38
+ "required": [],
39
+ }
40
+
41
+ async def execute(self, **kwargs: Any) -> str:
42
+ try:
43
+ async with httpx.AsyncClient(timeout=10.0) as client:
44
+ resp = await client.get(f"{self.base_url}/projects")
45
+ resp.raise_for_status()
46
+ body = resp.json()
47
+ projects = body.get("data", body)
48
+
49
+ if not projects:
50
+ return "No Dispatch projects found."
51
+
52
+ lines = ["Dispatch Projects:"]
53
+ for p in projects:
54
+ name = p.get("name", "Unnamed")
55
+ pid = p.get("id", "?")
56
+ lines.append(f" - {name} (id: {pid})")
57
+ return "\n".join(lines)
58
+ except httpx.ConnectError:
59
+ return "Error: Cannot connect to Dispatch backend. Is Dispatch running?"
60
+ except Exception as e:
61
+ return f"Error listing projects: {e}"
62
+
63
+
64
+ class DispatchGetProjectTool(Tool):
65
+ """Get detailed info about a single project."""
66
+
67
+ def __init__(self, port: int = 8080):
68
+ self.base_url = f"http://127.0.0.1:{port}/api"
69
+
70
+ @property
71
+ def name(self) -> str:
72
+ return "dispatch_get_project"
73
+
74
+ @property
75
+ def description(self) -> str:
76
+ return "Get detailed information about a specific Dispatch project by ID."
77
+
78
+ @property
79
+ def parameters(self) -> dict[str, Any]:
80
+ return {
81
+ "type": "object",
82
+ "properties": {
83
+ "project_id": {
84
+ "type": "string",
85
+ "description": "The Dispatch project UUID",
86
+ },
87
+ },
88
+ "required": ["project_id"],
89
+ }
90
+
91
+ async def execute(self, project_id: str, **kwargs: Any) -> str:
92
+ try:
93
+ async with httpx.AsyncClient(timeout=10.0) as client:
94
+ resp = await client.get(f"{self.base_url}/projects/{project_id}")
95
+ resp.raise_for_status()
96
+ body = resp.json()
97
+ project = body.get("data", body)
98
+ return json.dumps(project, indent=2, default=str)
99
+ except httpx.HTTPStatusError as e:
100
+ if e.response.status_code == 404:
101
+ return f"Project not found: {project_id}"
102
+ return f"Error: {e}"
103
+ except httpx.ConnectError:
104
+ return "Error: Cannot connect to Dispatch backend."
105
+ except Exception as e:
106
+ return f"Error getting project: {e}"
107
+
108
+
109
+ class DispatchListTasksTool(Tool):
110
+ """List tasks (kanban items) for a project."""
111
+
112
+ def __init__(self, port: int = 8080):
113
+ self.base_url = f"http://127.0.0.1:{port}/api"
114
+
115
+ @property
116
+ def name(self) -> str:
117
+ return "dispatch_list_tasks"
118
+
119
+ @property
120
+ def description(self) -> str:
121
+ return (
122
+ "List tasks in a Dispatch project. "
123
+ "Shows task title, status, and description. "
124
+ "IMPORTANT: Use a valid project_id from dispatch_list_projects."
125
+ )
126
+
127
+ @property
128
+ def parameters(self) -> dict[str, Any]:
129
+ return {
130
+ "type": "object",
131
+ "properties": {
132
+ "project_id": {
133
+ "type": "string",
134
+ "description": "The Dispatch project UUID",
135
+ },
136
+ },
137
+ "required": ["project_id"],
138
+ }
139
+
140
+ async def execute(self, project_id: str, **kwargs: Any) -> str:
141
+ try:
142
+ async with httpx.AsyncClient(timeout=10.0) as client:
143
+ resp = await client.get(
144
+ f"{self.base_url}/tasks",
145
+ params={"project_id": project_id},
146
+ )
147
+ resp.raise_for_status()
148
+ body = resp.json()
149
+ tasks = body.get("data", body)
150
+
151
+ if not tasks:
152
+ return f"No tasks found for project {project_id}."
153
+
154
+ lines = [f"Tasks for project {project_id}:"]
155
+ for t in tasks:
156
+ status = t.get("status", "unknown")
157
+ title = t.get("title", "Untitled")
158
+ tid = t.get("id", "?")
159
+ desc = t.get("description") or ""
160
+ desc_preview = (desc[:80] + "...") if len(desc) > 80 else desc
161
+ lines.append(f" [{status}] {title} (id: {tid})")
162
+ if desc_preview:
163
+ lines.append(f" {desc_preview}")
164
+ return "\n".join(lines)
165
+ except httpx.ConnectError:
166
+ return "Error: Cannot connect to Dispatch backend."
167
+ except Exception as e:
168
+ return f"Error listing tasks: {e}"
169
+
170
+
171
+ class DispatchCreateTaskTool(Tool):
172
+ """Create a new task in a Dispatch project."""
173
+
174
+ def __init__(self, port: int = 8080):
175
+ self.base_url = f"http://127.0.0.1:{port}/api"
176
+
177
+ @property
178
+ def name(self) -> str:
179
+ return "dispatch_create_task"
180
+
181
+ @property
182
+ def description(self) -> str:
183
+ return (
184
+ "Create a new task in a Dispatch project's task board. "
185
+ "IMPORTANT: You must use a valid project_id from dispatch_list_projects. "
186
+ "Do NOT guess or make up project IDs."
187
+ )
188
+
189
+ @property
190
+ def parameters(self) -> dict[str, Any]:
191
+ return {
192
+ "type": "object",
193
+ "properties": {
194
+ "project_id": {
195
+ "type": "string",
196
+ "description": "The Dispatch project UUID",
197
+ },
198
+ "title": {
199
+ "type": "string",
200
+ "description": "Task title",
201
+ },
202
+ "description": {
203
+ "type": "string",
204
+ "description": "Task description (markdown supported)",
205
+ },
206
+ },
207
+ "required": ["project_id", "title"],
208
+ }
209
+
210
+ async def execute(
211
+ self,
212
+ project_id: str,
213
+ title: str,
214
+ description: str = "",
215
+ **kwargs: Any,
216
+ ) -> str:
217
+ try:
218
+ payload: dict[str, Any] = {
219
+ "project_id": project_id,
220
+ "title": title,
221
+ }
222
+ if description:
223
+ payload["description"] = description
224
+
225
+ async with httpx.AsyncClient(timeout=10.0) as client:
226
+ resp = await client.post(
227
+ f"{self.base_url}/tasks",
228
+ json=payload,
229
+ )
230
+ resp.raise_for_status()
231
+ body = resp.json()
232
+ task = body.get("data", body)
233
+ tid = task.get("id", "?")
234
+ return f"Created task '{title}' (id: {tid}) in project {project_id}."
235
+ except httpx.ConnectError:
236
+ return "Error: Cannot connect to Dispatch backend."
237
+ except Exception as e:
238
+ return f"Error creating task: {e}"
239
+
240
+
241
+ class DispatchUpdateTaskTool(Tool):
242
+ """Update an existing task."""
243
+
244
+ def __init__(self, port: int = 8080):
245
+ self.base_url = f"http://127.0.0.1:{port}/api"
246
+
247
+ @property
248
+ def name(self) -> str:
249
+ return "dispatch_update_task"
250
+
251
+ @property
252
+ def description(self) -> str:
253
+ return (
254
+ "Update an existing task in Dispatch. "
255
+ "Can change title, description, or status."
256
+ )
257
+
258
+ @property
259
+ def parameters(self) -> dict[str, Any]:
260
+ return {
261
+ "type": "object",
262
+ "properties": {
263
+ "task_id": {
264
+ "type": "string",
265
+ "description": "The task UUID to update",
266
+ },
267
+ "title": {
268
+ "type": "string",
269
+ "description": "New title (optional)",
270
+ },
271
+ "description": {
272
+ "type": "string",
273
+ "description": "New description (optional)",
274
+ },
275
+ "status": {
276
+ "type": "string",
277
+ "description": "New status",
278
+ "enum": ["open", "in_progress", "in_review", "done", "cancelled"],
279
+ },
280
+ },
281
+ "required": ["task_id"],
282
+ }
283
+
284
+ async def execute(
285
+ self,
286
+ task_id: str,
287
+ title: str | None = None,
288
+ description: str | None = None,
289
+ status: str | None = None,
290
+ **kwargs: Any,
291
+ ) -> str:
292
+ try:
293
+ payload: dict[str, Any] = {}
294
+ if title is not None:
295
+ payload["title"] = title
296
+ if description is not None:
297
+ payload["description"] = description
298
+ if status is not None:
299
+ payload["status"] = status
300
+
301
+ if not payload:
302
+ return "No fields to update. Provide at least one of: title, description, status."
303
+
304
+ async with httpx.AsyncClient(timeout=10.0) as client:
305
+ resp = await client.put(
306
+ f"{self.base_url}/tasks/{task_id}",
307
+ json=payload,
308
+ )
309
+ resp.raise_for_status()
310
+ return f"Updated task {task_id}."
311
+ except httpx.HTTPStatusError as e:
312
+ if e.response.status_code == 404:
313
+ return f"Task not found: {task_id}"
314
+ return f"Error: {e}"
315
+ except httpx.ConnectError:
316
+ return "Error: Cannot connect to Dispatch backend."
317
+ except Exception as e:
318
+ return f"Error updating task: {e}"
319
+
320
+
321
+ class DispatchRalphStatusTool(Tool):
322
+ """Check Ralph AI worker status for a task."""
323
+
324
+ def __init__(self, port: int = 8080):
325
+ self.base_url = f"http://127.0.0.1:{port}/api"
326
+
327
+ @property
328
+ def name(self) -> str:
329
+ return "dispatch_ralph_status"
330
+
331
+ @property
332
+ def description(self) -> str:
333
+ return (
334
+ "Check the status of Ralph AI coding workspaces for a task. "
335
+ "Shows whether workers are running, completed, or failed."
336
+ )
337
+
338
+ @property
339
+ def parameters(self) -> dict[str, Any]:
340
+ return {
341
+ "type": "object",
342
+ "properties": {
343
+ "task_id": {
344
+ "type": "string",
345
+ "description": "The task UUID to check Ralph status for",
346
+ },
347
+ },
348
+ "required": ["task_id"],
349
+ }
350
+
351
+ async def execute(self, task_id: str, **kwargs: Any) -> str:
352
+ try:
353
+ async with httpx.AsyncClient(timeout=10.0) as client:
354
+ resp = await client.get(
355
+ f"{self.base_url}/tasks/{task_id}/workspaces",
356
+ )
357
+ resp.raise_for_status()
358
+ body = resp.json()
359
+ workspaces = body.get("data", body)
360
+
361
+ if not workspaces:
362
+ return f"No Ralph workspaces found for task {task_id}."
363
+
364
+ lines = [f"Ralph workspaces for task {task_id}:"]
365
+ for ws in workspaces:
366
+ ws_id = ws.get("id", "?")
367
+ status = ws.get("status", "unknown")
368
+ lines.append(f" - Workspace {ws_id}: {status}")
369
+ return "\n".join(lines)
370
+ except httpx.HTTPStatusError as e:
371
+ if e.response.status_code == 404:
372
+ return f"Task not found: {task_id}"
373
+ return f"Error: {e}"
374
+ except httpx.ConnectError:
375
+ return "Error: Cannot connect to Dispatch backend."
376
+ except Exception as e:
377
+ return f"Error checking Ralph status: {e}"
378
+
379
+
380
+ class DispatchStartRalphTool(Tool):
381
+ """Start a Ralph AI coding session for a task."""
382
+
383
+ def __init__(self, port: int = 8080):
384
+ self.base_url = f"http://127.0.0.1:{port}/api"
385
+
386
+ @property
387
+ def name(self) -> str:
388
+ return "dispatch_start_ralph"
389
+
390
+ @property
391
+ def description(self) -> str:
392
+ return (
393
+ "Start a Ralph AI coding session for a project. "
394
+ "Ralph is an autonomous AI coding agent that works on tasks."
395
+ )
396
+
397
+ @property
398
+ def parameters(self) -> dict[str, Any]:
399
+ return {
400
+ "type": "object",
401
+ "properties": {
402
+ "project_id": {
403
+ "type": "string",
404
+ "description": "The Dispatch project UUID",
405
+ },
406
+ },
407
+ "required": ["project_id"],
408
+ }
409
+
410
+ async def execute(self, project_id: str, **kwargs: Any) -> str:
411
+ try:
412
+ async with httpx.AsyncClient(timeout=30.0) as client:
413
+ resp = await client.post(
414
+ f"{self.base_url}/projects/{project_id}/ralph/start",
415
+ )
416
+ resp.raise_for_status()
417
+ return f"Ralph session started for project {project_id}."
418
+ except httpx.HTTPStatusError as e:
419
+ if e.response.status_code == 404:
420
+ return f"Project not found: {project_id}"
421
+ return f"Error: {e}"
422
+ except httpx.ConnectError:
423
+ return "Error: Cannot connect to Dispatch backend."
424
+ except Exception as e:
425
+ return f"Error starting Ralph: {e}"
426
+
427
+
428
+ class DispatchDeleteTaskTool(Tool):
429
+ """Delete a task from a Dispatch project."""
430
+
431
+ def __init__(self, port: int = 8080):
432
+ self.base_url = f"http://127.0.0.1:{port}/api"
433
+
434
+ @property
435
+ def name(self) -> str:
436
+ return "dispatch_delete_task"
437
+
438
+ @property
439
+ def description(self) -> str:
440
+ return (
441
+ "Delete a task from Dispatch. "
442
+ "IMPORTANT: Use a valid task_id from dispatch_list_tasks."
443
+ )
444
+
445
+ @property
446
+ def parameters(self) -> dict[str, Any]:
447
+ return {
448
+ "type": "object",
449
+ "properties": {
450
+ "task_id": {
451
+ "type": "string",
452
+ "description": "The task UUID to delete",
453
+ },
454
+ },
455
+ "required": ["task_id"],
456
+ }
457
+
458
+ async def execute(self, task_id: str, **kwargs: Any) -> str:
459
+ try:
460
+ async with httpx.AsyncClient(timeout=10.0) as client:
461
+ resp = await client.delete(f"{self.base_url}/tasks/{task_id}")
462
+ resp.raise_for_status()
463
+ return f"Deleted task {task_id}."
464
+ except httpx.HTTPStatusError as e:
465
+ if e.response.status_code == 404:
466
+ return f"Task not found: {task_id}"
467
+ return f"Error: {e}"
468
+ except httpx.ConnectError:
469
+ return "Error: Cannot connect to Dispatch backend."
470
+ except Exception as e:
471
+ return f"Error deleting task: {e}"
472
+
473
+
474
+ class DispatchGetTaskTool(Tool):
475
+ """Get detailed info about a single task."""
476
+
477
+ def __init__(self, port: int = 8080):
478
+ self.base_url = f"http://127.0.0.1:{port}/api"
479
+
480
+ @property
481
+ def name(self) -> str:
482
+ return "dispatch_get_task"
483
+
484
+ @property
485
+ def description(self) -> str:
486
+ return (
487
+ "Get detailed information about a specific task by ID. "
488
+ "Returns title, description, status, priority, labels, and more."
489
+ )
490
+
491
+ @property
492
+ def parameters(self) -> dict[str, Any]:
493
+ return {
494
+ "type": "object",
495
+ "properties": {
496
+ "task_id": {
497
+ "type": "string",
498
+ "description": "The task UUID",
499
+ },
500
+ },
501
+ "required": ["task_id"],
502
+ }
503
+
504
+ async def execute(self, task_id: str, **kwargs: Any) -> str:
505
+ try:
506
+ async with httpx.AsyncClient(timeout=10.0) as client:
507
+ resp = await client.get(f"{self.base_url}/tasks/{task_id}")
508
+ resp.raise_for_status()
509
+ body = resp.json()
510
+ task = body.get("data", body)
511
+ return json.dumps(task, indent=2, default=str)
512
+ except httpx.HTTPStatusError as e:
513
+ if e.response.status_code == 404:
514
+ return f"Task not found: {task_id}"
515
+ return f"Error: {e}"
516
+ except httpx.ConnectError:
517
+ return "Error: Cannot connect to Dispatch backend."
518
+ except Exception as e:
519
+ return f"Error getting task: {e}"
520
+
521
+
522
+ class DispatchListTaskAttemptsTool(Tool):
523
+ """List task attempts (workspaces) for a task."""
524
+
525
+ def __init__(self, port: int = 8080):
526
+ self.base_url = f"http://127.0.0.1:{port}/api"
527
+
528
+ @property
529
+ def name(self) -> str:
530
+ return "dispatch_list_task_attempts"
531
+
532
+ @property
533
+ def description(self) -> str:
534
+ return (
535
+ "List all task attempts (workspaces) for a task. "
536
+ "Shows attempt IDs, status, branch names, and executor info. "
537
+ "Use the attempt ID with dispatch_stop_ralph to stop a running session."
538
+ )
539
+
540
+ @property
541
+ def parameters(self) -> dict[str, Any]:
542
+ return {
543
+ "type": "object",
544
+ "properties": {
545
+ "task_id": {
546
+ "type": "string",
547
+ "description": "The task UUID",
548
+ },
549
+ },
550
+ "required": ["task_id"],
551
+ }
552
+
553
+ async def execute(self, task_id: str, **kwargs: Any) -> str:
554
+ try:
555
+ async with httpx.AsyncClient(timeout=10.0) as client:
556
+ resp = await client.get(
557
+ f"{self.base_url}/task-attempts",
558
+ params={"task_id": task_id},
559
+ )
560
+ resp.raise_for_status()
561
+ body = resp.json()
562
+ attempts = body.get("data", body)
563
+
564
+ if not attempts:
565
+ return f"No task attempts found for task {task_id}."
566
+
567
+ lines = [f"Task attempts for {task_id}:"]
568
+ for a in attempts:
569
+ aid = a.get("id", "?")
570
+ status = a.get("status", "unknown")
571
+ branch = a.get("branch", "")
572
+ lines.append(f" - {aid}: [{status}] branch: {branch}")
573
+ return "\n".join(lines)
574
+ except httpx.ConnectError:
575
+ return "Error: Cannot connect to Dispatch backend."
576
+ except Exception as e:
577
+ return f"Error listing task attempts: {e}"
578
+
579
+
580
+ class DispatchStartRalphSessionTool(Tool):
581
+ """Start a Ralph AI coding session on a workspace."""
582
+
583
+ def __init__(self, port: int = 8080):
584
+ self.base_url = f"http://127.0.0.1:{port}/api"
585
+
586
+ @property
587
+ def name(self) -> str:
588
+ return "dispatch_start_ralph_session"
589
+
590
+ @property
591
+ def description(self) -> str:
592
+ return (
593
+ "Start a Ralph AI coding session on a specific task attempt (workspace). "
594
+ "First use dispatch_list_task_attempts to get the attempt ID. "
595
+ "Ralph is an autonomous AI coding agent that works on the task."
596
+ )
597
+
598
+ @property
599
+ def parameters(self) -> dict[str, Any]:
600
+ return {
601
+ "type": "object",
602
+ "properties": {
603
+ "attempt_id": {
604
+ "type": "string",
605
+ "description": "The task attempt (workspace) UUID",
606
+ },
607
+ },
608
+ "required": ["attempt_id"],
609
+ }
610
+
611
+ async def execute(self, attempt_id: str, **kwargs: Any) -> str:
612
+ try:
613
+ async with httpx.AsyncClient(timeout=30.0) as client:
614
+ resp = await client.post(
615
+ f"{self.base_url}/task-attempts/{attempt_id}/ralph/start",
616
+ )
617
+ resp.raise_for_status()
618
+ return f"Ralph session started for workspace {attempt_id}."
619
+ except httpx.HTTPStatusError as e:
620
+ if e.response.status_code == 404:
621
+ return f"Workspace not found: {attempt_id}"
622
+ return f"Error: {e}"
623
+ except httpx.ConnectError:
624
+ return "Error: Cannot connect to Dispatch backend."
625
+ except Exception as e:
626
+ return f"Error starting Ralph session: {e}"
627
+
628
+
629
+ class DispatchStopRalphSessionTool(Tool):
630
+ """Stop a Ralph AI coding session."""
631
+
632
+ def __init__(self, port: int = 8080):
633
+ self.base_url = f"http://127.0.0.1:{port}/api"
634
+
635
+ @property
636
+ def name(self) -> str:
637
+ return "dispatch_stop_ralph_session"
638
+
639
+ @property
640
+ def description(self) -> str:
641
+ return (
642
+ "Stop a running Ralph AI coding session on a task attempt. "
643
+ "Use dispatch_list_task_attempts to find the attempt ID."
644
+ )
645
+
646
+ @property
647
+ def parameters(self) -> dict[str, Any]:
648
+ return {
649
+ "type": "object",
650
+ "properties": {
651
+ "attempt_id": {
652
+ "type": "string",
653
+ "description": "The task attempt (workspace) UUID to stop",
654
+ },
655
+ },
656
+ "required": ["attempt_id"],
657
+ }
658
+
659
+ async def execute(self, attempt_id: str, **kwargs: Any) -> str:
660
+ try:
661
+ async with httpx.AsyncClient(timeout=10.0) as client:
662
+ resp = await client.post(
663
+ f"{self.base_url}/task-attempts/{attempt_id}/stop",
664
+ )
665
+ resp.raise_for_status()
666
+ return f"Stopped Ralph session for workspace {attempt_id}."
667
+ except httpx.HTTPStatusError as e:
668
+ if e.response.status_code == 404:
669
+ return f"Workspace not found: {attempt_id}"
670
+ return f"Error: {e}"
671
+ except httpx.ConnectError:
672
+ return "Error: Cannot connect to Dispatch backend."
673
+ except Exception as e:
674
+ return f"Error stopping Ralph session: {e}"
675
+
676
+
677
+ class DispatchKanbanSummaryTool(Tool):
678
+ """Get a kanban board summary for a project."""
679
+
680
+ def __init__(self, port: int = 8080):
681
+ self.base_url = f"http://127.0.0.1:{port}/api"
682
+
683
+ @property
684
+ def name(self) -> str:
685
+ return "dispatch_kanban_summary"
686
+
687
+ @property
688
+ def description(self) -> str:
689
+ return (
690
+ "Get a summary of the kanban board for a project. "
691
+ "Shows task counts grouped by status. "
692
+ "IMPORTANT: Use a valid project_id from dispatch_list_projects."
693
+ )
694
+
695
+ @property
696
+ def parameters(self) -> dict[str, Any]:
697
+ return {
698
+ "type": "object",
699
+ "properties": {
700
+ "project_id": {
701
+ "type": "string",
702
+ "description": "The Dispatch project UUID",
703
+ },
704
+ },
705
+ "required": ["project_id"],
706
+ }
707
+
708
+ async def execute(self, project_id: str, **kwargs: Any) -> str:
709
+ try:
710
+ async with httpx.AsyncClient(timeout=10.0) as client:
711
+ resp = await client.get(
712
+ f"{self.base_url}/tasks",
713
+ params={"project_id": project_id},
714
+ )
715
+ resp.raise_for_status()
716
+ body = resp.json()
717
+ tasks = body.get("data", body)
718
+
719
+ if not tasks:
720
+ return f"No tasks in project {project_id}."
721
+
722
+ # Group by status
723
+ counts: dict[str, int] = {}
724
+ for t in tasks:
725
+ status = t.get("status", "unknown")
726
+ counts[status] = counts.get(status, 0) + 1
727
+
728
+ total = len(tasks)
729
+ lines = [f"Kanban Summary for project {project_id} ({total} tasks):"]
730
+ for status, count in sorted(counts.items()):
731
+ lines.append(f" {status}: {count}")
732
+ return "\n".join(lines)
733
+ except httpx.ConnectError:
734
+ return "Error: Cannot connect to Dispatch backend."
735
+ except Exception as e:
736
+ return f"Error getting kanban summary: {e}"
737
+
738
+
739
+ class DispatchGetRalphSessionTool(Tool):
740
+ """Get full Ralph session info including iterations."""
741
+
742
+ def __init__(self, port: int = 8080):
743
+ self.base_url = f"http://127.0.0.1:{port}/api"
744
+
745
+ @property
746
+ def name(self) -> str:
747
+ return "dispatch_get_ralph_session"
748
+
749
+ @property
750
+ def description(self) -> str:
751
+ return (
752
+ "Get detailed Ralph session info for a workspace, including all iterations. "
753
+ "Use dispatch_list_task_attempts to get the attempt ID first."
754
+ )
755
+
756
+ @property
757
+ def parameters(self) -> dict[str, Any]:
758
+ return {
759
+ "type": "object",
760
+ "properties": {
761
+ "attempt_id": {
762
+ "type": "string",
763
+ "description": "The task attempt (workspace) UUID",
764
+ },
765
+ },
766
+ "required": ["attempt_id"],
767
+ }
768
+
769
+ async def execute(self, attempt_id: str, **kwargs: Any) -> str:
770
+ try:
771
+ async with httpx.AsyncClient(timeout=10.0) as client:
772
+ resp = await client.get(
773
+ f"{self.base_url}/task-attempts/{attempt_id}/ralph/full",
774
+ )
775
+ resp.raise_for_status()
776
+ body = resp.json()
777
+ session = body.get("data", body)
778
+ return json.dumps(session, indent=2, default=str)
779
+ except httpx.HTTPStatusError as e:
780
+ if e.response.status_code == 404:
781
+ return f"No Ralph session found for workspace {attempt_id}"
782
+ return f"Error: {e}"
783
+ except httpx.ConnectError:
784
+ return "Error: Cannot connect to Dispatch backend."
785
+ except Exception as e:
786
+ return f"Error getting Ralph session: {e}"
787
+
788
+
789
+ class DispatchGetRalphPrdTool(Tool):
790
+ """Get Ralph PRD tasks for a project."""
791
+
792
+ def __init__(self, port: int = 8080):
793
+ self.base_url = f"http://127.0.0.1:{port}/api"
794
+
795
+ @property
796
+ def name(self) -> str:
797
+ return "dispatch_get_ralph_prd"
798
+
799
+ @property
800
+ def description(self) -> str:
801
+ return (
802
+ "Get Ralph PRD (Product Requirements Document) tasks for a project. "
803
+ "Shows PRD task details including title, description, and status."
804
+ )
805
+
806
+ @property
807
+ def parameters(self) -> dict[str, Any]:
808
+ return {
809
+ "type": "object",
810
+ "properties": {
811
+ "project_id": {
812
+ "type": "string",
813
+ "description": "The Dispatch project UUID",
814
+ },
815
+ },
816
+ "required": ["project_id"],
817
+ }
818
+
819
+ async def execute(self, project_id: str, **kwargs: Any) -> str:
820
+ try:
821
+ async with httpx.AsyncClient(timeout=10.0) as client:
822
+ resp = await client.get(
823
+ f"{self.base_url}/projects/{project_id}/ralph/prd",
824
+ )
825
+ resp.raise_for_status()
826
+ body = resp.json()
827
+ prd_tasks = body.get("data", body)
828
+
829
+ if not prd_tasks:
830
+ return f"No PRD tasks found for project {project_id}."
831
+
832
+ return json.dumps(prd_tasks, indent=2, default=str)
833
+ except httpx.HTTPStatusError as e:
834
+ if e.response.status_code == 404:
835
+ return f"Project not found: {project_id}"
836
+ return f"Error: {e}"
837
+ except httpx.ConnectError:
838
+ return "Error: Cannot connect to Dispatch backend."
839
+ except Exception as e:
840
+ return f"Error getting PRD: {e}"