hanuscode 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.
- hanus/__init__.py +5 -0
- hanus/__main__.py +10 -0
- hanus/action_handlers.py +76 -0
- hanus/action_parser.py +82 -0
- hanus/agent_runner.py +1445 -0
- hanus/analysis/__init__.py +5 -0
- hanus/analysis/debt.py +702 -0
- hanus/analysis/dependencies.py +475 -0
- hanus/cache/__init__.py +5 -0
- hanus/cache/response_cache.py +560 -0
- hanus/config.py +401 -0
- hanus/connectors/__init__.py +19 -0
- hanus/connectors/base.py +114 -0
- hanus/connectors/claude_connector.py +146 -0
- hanus/connectors/gemini_connector.py +141 -0
- hanus/connectors/glm_connector.py +160 -0
- hanus/connectors/ollama_connector.py +174 -0
- hanus/connectors/openai_connector.py +122 -0
- hanus/connectors/registry.py +26 -0
- hanus/context/__init__.py +7 -0
- hanus/context/manager.py +837 -0
- hanus/context/selective.py +626 -0
- hanus/error_recovery/__init__.py +5 -0
- hanus/error_recovery/auto_fix.py +605 -0
- hanus/hooks/__init__.py +5 -0
- hanus/hooks/manager.py +247 -0
- hanus/instincts/__init__.py +44 -0
- hanus/instincts/cli.py +372 -0
- hanus/instincts/detector.py +281 -0
- hanus/instincts/evolver.py +361 -0
- hanus/instincts/manager.py +343 -0
- hanus/instincts/types.py +253 -0
- hanus/logger.py +81 -0
- hanus/memory/__init__.py +8 -0
- hanus/memory/manager.py +265 -0
- hanus/memory/types.py +119 -0
- hanus/monitor.py +341 -0
- hanus/parallel/__init__.py +5 -0
- hanus/parallel/executor.py +300 -0
- hanus/permissions.py +182 -0
- hanus/plan/__init__.py +8 -0
- hanus/plan/mode.py +267 -0
- hanus/plan/models.py +152 -0
- hanus/plugin_manager.py +754 -0
- hanus/plugin_registry.py +391 -0
- hanus/plugins/__init__.py +1 -0
- hanus/plugins/arena.py +630 -0
- hanus/plugins/code_review.py +123 -0
- hanus/plugins/cortex.py +1750 -0
- hanus/plugins/deps_check.py +27 -0
- hanus/plugins/git_ops.py +33 -0
- hanus/plugins/metasploit.py +530 -0
- hanus/plugins/notes.py +583 -0
- hanus/plugins/search_code.py +59 -0
- hanus/plugins/searchsploit.py +495 -0
- hanus/plugins/strategist.py +175 -0
- hanus/plugins/webui.py +5200 -0
- hanus/profiles.py +479 -0
- hanus/profiles_builtin/__init__.py +0 -0
- hanus/profiles_builtin/architect/profile.yaml +12 -0
- hanus/profiles_builtin/architect/system_prompt.txt +71 -0
- hanus/profiles_builtin/deep/profile.yaml +12 -0
- hanus/profiles_builtin/deep/system_prompt.txt +66 -0
- hanus/profiles_builtin/developer/__init__.py +0 -0
- hanus/profiles_builtin/developer/profile.yaml +9 -0
- hanus/profiles_builtin/developer/system_prompt.txt +176 -0
- hanus/profiles_builtin/speed/profile.yaml +12 -0
- hanus/profiles_builtin/speed/system_prompt.txt +51 -0
- hanus/project_tools.py +177 -0
- hanus/query_engine.py +1594 -0
- hanus/rules/__init__.py +237 -0
- hanus/search/__init__.py +5 -0
- hanus/search/semantic.py +596 -0
- hanus/session_manager.py +547 -0
- hanus/skill_manager.py +702 -0
- hanus/skills/__init__.py +4 -0
- hanus/subagent/__init__.py +8 -0
- hanus/subagent/agents/__init__.py +253 -0
- hanus/subagent/manager.py +309 -0
- hanus/subagent/types.py +266 -0
- hanus/suggestions/__init__.py +5 -0
- hanus/suggestions/proactive.py +451 -0
- hanus/tasks/__init__.py +8 -0
- hanus/tasks/manager.py +330 -0
- hanus/tasks/models.py +106 -0
- hanus/terminal_prompt.py +166 -0
- hanus/tools.py +1849 -0
- hanus/ui.py +939 -0
- hanuscode-1.0.0.dist-info/METADATA +1151 -0
- hanuscode-1.0.0.dist-info/RECORD +93 -0
- hanuscode-1.0.0.dist-info/WHEEL +5 -0
- hanuscode-1.0.0.dist-info/entry_points.txt +2 -0
- hanuscode-1.0.0.dist-info/top_level.txt +1 -0
hanus/tasks/manager.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
# hanus/tasks/manager.py
|
|
2
|
+
"""
|
|
3
|
+
Gestor de tareas integrado en el motor del agente.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Optional
|
|
10
|
+
from hanus.tasks.models import Task, TaskStatus
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TaskManager:
|
|
14
|
+
"""
|
|
15
|
+
Gestiona el ciclo de vida de las tareas del agente.
|
|
16
|
+
|
|
17
|
+
Features:
|
|
18
|
+
- Crear/actualizar/listar/obtener tareas
|
|
19
|
+
- Tracking de dependencias (blocked_by/blocks)
|
|
20
|
+
- Tracking de progreso por tarea
|
|
21
|
+
- Persistencia en archivo JSON
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
TASKS_FILE = ".hanus_tasks.json"
|
|
25
|
+
|
|
26
|
+
def __init__(self, root_dir: Path):
|
|
27
|
+
self.root_dir = Path(root_dir)
|
|
28
|
+
self._tasks: Dict[str, Task] = {}
|
|
29
|
+
self._next_id: int = 1
|
|
30
|
+
self._load()
|
|
31
|
+
|
|
32
|
+
def _load(self):
|
|
33
|
+
"""Carga tareas desde archivo JSON."""
|
|
34
|
+
tasks_file = self.root_dir / self.TASKS_FILE
|
|
35
|
+
if not tasks_file.exists():
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
data = json.loads(tasks_file.read_text(encoding="utf-8"))
|
|
40
|
+
for task_data in data.get("tasks", []):
|
|
41
|
+
task = Task.from_dict(task_data)
|
|
42
|
+
self._tasks[task.id] = task
|
|
43
|
+
# Actualizar next_id
|
|
44
|
+
try:
|
|
45
|
+
task_id_int = int(task.id)
|
|
46
|
+
self._next_id = max(self._next_id, task_id_int + 1)
|
|
47
|
+
except ValueError:
|
|
48
|
+
pass
|
|
49
|
+
except Exception:
|
|
50
|
+
# Si hay error, empezar con tareas vacías
|
|
51
|
+
self._tasks = {}
|
|
52
|
+
|
|
53
|
+
def _save(self):
|
|
54
|
+
"""Guarda tareas a archivo JSON."""
|
|
55
|
+
tasks_file = self.root_dir / self.TASKS_FILE
|
|
56
|
+
data = {
|
|
57
|
+
"tasks": [t.to_dict() for t in self._tasks.values()],
|
|
58
|
+
"updated_at": time.time(),
|
|
59
|
+
}
|
|
60
|
+
tasks_file.write_text(
|
|
61
|
+
json.dumps(data, indent=2, ensure_ascii=False),
|
|
62
|
+
encoding="utf-8"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def create(
|
|
66
|
+
self,
|
|
67
|
+
subject: str,
|
|
68
|
+
description: str = "",
|
|
69
|
+
activeForm: str = "",
|
|
70
|
+
metadata: Optional[Dict] = None
|
|
71
|
+
) -> Task:
|
|
72
|
+
"""
|
|
73
|
+
Crea una nueva tarea.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
subject: Título breve de la tarea
|
|
77
|
+
description: Descripción completa
|
|
78
|
+
activeForm: Forma presente continuo (ej: "Implementando login")
|
|
79
|
+
metadata: Datos adicionales
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
La tarea creada
|
|
83
|
+
"""
|
|
84
|
+
task_id = str(self._next_id)
|
|
85
|
+
self._next_id += 1
|
|
86
|
+
|
|
87
|
+
task = Task(
|
|
88
|
+
id=task_id,
|
|
89
|
+
subject=subject,
|
|
90
|
+
description=description or subject,
|
|
91
|
+
activeForm=activeForm or f"{subject}...",
|
|
92
|
+
metadata=metadata or {},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
self._tasks[task_id] = task
|
|
96
|
+
self._save()
|
|
97
|
+
return task
|
|
98
|
+
|
|
99
|
+
def update(self, task_id: str, **kwargs) -> Optional[Task]:
|
|
100
|
+
"""
|
|
101
|
+
Actualiza campos de una tarea existente.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
task_id: ID de la tarea
|
|
105
|
+
**kwargs: Campos a actualizar (status, subject, description, etc.)
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
La tarea actualizada o None si no existe
|
|
109
|
+
"""
|
|
110
|
+
task = self._tasks.get(task_id)
|
|
111
|
+
if not task:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
# Campos permitidos para actualizar
|
|
115
|
+
allowed = {
|
|
116
|
+
"status", "subject", "description", "owner",
|
|
117
|
+
"blocked_by", "blocks", "metadata", "activeForm"
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for key, value in kwargs.items():
|
|
121
|
+
if key in allowed:
|
|
122
|
+
if key == "status" and isinstance(value, str):
|
|
123
|
+
value = TaskStatus(value)
|
|
124
|
+
setattr(task, key, value)
|
|
125
|
+
|
|
126
|
+
task.updated_at = time.time()
|
|
127
|
+
|
|
128
|
+
# Si se marca como completada, registrar timestamp
|
|
129
|
+
if task.status == TaskStatus.COMPLETED and not task.completed_at:
|
|
130
|
+
task.completed_at = time.time()
|
|
131
|
+
|
|
132
|
+
self._save()
|
|
133
|
+
return task
|
|
134
|
+
|
|
135
|
+
def get(self, task_id: str) -> Optional[Task]:
|
|
136
|
+
"""Obtiene una tarea por ID."""
|
|
137
|
+
return self._tasks.get(task_id)
|
|
138
|
+
|
|
139
|
+
def list(
|
|
140
|
+
self,
|
|
141
|
+
status: Optional[TaskStatus] = None,
|
|
142
|
+
owner: Optional[str] = None
|
|
143
|
+
) -> List[Task]:
|
|
144
|
+
"""
|
|
145
|
+
Lista tareas con filtros opcionales.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
status: Filtrar por estado
|
|
149
|
+
owner: Filtrar por owner
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Lista de tareas ordenadas por ID
|
|
153
|
+
"""
|
|
154
|
+
tasks = list(self._tasks.values())
|
|
155
|
+
|
|
156
|
+
if status:
|
|
157
|
+
tasks = [t for t in tasks if t.status == status]
|
|
158
|
+
if owner:
|
|
159
|
+
tasks = [t for t in tasks if t.owner == owner]
|
|
160
|
+
|
|
161
|
+
# Ordenar por ID numérico
|
|
162
|
+
def sort_key(t: Task) -> int:
|
|
163
|
+
try:
|
|
164
|
+
return int(t.id)
|
|
165
|
+
except ValueError:
|
|
166
|
+
return 999999
|
|
167
|
+
|
|
168
|
+
return sorted(tasks, key=sort_key)
|
|
169
|
+
|
|
170
|
+
def claim(self, task_id: str, owner: str) -> Optional[Task]:
|
|
171
|
+
"""
|
|
172
|
+
Reclama una tarea (la marca como in_progress y asigna owner).
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
La tarea reclamada o None si no está disponible
|
|
176
|
+
"""
|
|
177
|
+
task = self._tasks.get(task_id)
|
|
178
|
+
if not task:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
if task.status != TaskStatus.PENDING:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
# Verificar que no está bloqueada
|
|
185
|
+
if self._is_blocked(task):
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
task.status = TaskStatus.IN_PROGRESS
|
|
189
|
+
task.owner = owner
|
|
190
|
+
task.updated_at = time.time()
|
|
191
|
+
self._save()
|
|
192
|
+
return task
|
|
193
|
+
|
|
194
|
+
def complete(self, task_id: str) -> Optional[Task]:
|
|
195
|
+
"""Marca una tarea como completada."""
|
|
196
|
+
return self.update(task_id, status=TaskStatus.COMPLETED)
|
|
197
|
+
|
|
198
|
+
def fail(self, task_id: str, reason: str = "") -> Optional[Task]:
|
|
199
|
+
"""Marca una tarea como fallida."""
|
|
200
|
+
task = self._tasks.get(task_id)
|
|
201
|
+
if not task:
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
task.status = TaskStatus.FAILED
|
|
205
|
+
task.updated_at = time.time()
|
|
206
|
+
if reason:
|
|
207
|
+
task.metadata["fail_reason"] = reason
|
|
208
|
+
|
|
209
|
+
self._save()
|
|
210
|
+
return task
|
|
211
|
+
|
|
212
|
+
def get_next_available(self) -> Optional[Task]:
|
|
213
|
+
"""
|
|
214
|
+
Obtiene la siguiente tarea disponible para trabajar.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
La siguiente tarea pending y no bloqueada, o None
|
|
218
|
+
"""
|
|
219
|
+
# Primero, tareas que ya están in_progress
|
|
220
|
+
for task in self.list(status=TaskStatus.IN_PROGRESS):
|
|
221
|
+
return task
|
|
222
|
+
|
|
223
|
+
# Luego, tareas pending ordenadas por ID
|
|
224
|
+
for task in self.list(status=TaskStatus.PENDING):
|
|
225
|
+
if not self._is_blocked(task):
|
|
226
|
+
return task
|
|
227
|
+
|
|
228
|
+
return None
|
|
229
|
+
|
|
230
|
+
def _is_blocked(self, task: Task) -> bool:
|
|
231
|
+
"""Verifica si una tarea está bloqueada por tareas incompletas."""
|
|
232
|
+
for blocker_id in task.blocked_by:
|
|
233
|
+
blocker = self._tasks.get(blocker_id)
|
|
234
|
+
if blocker and blocker.status != TaskStatus.COMPLETED:
|
|
235
|
+
return True
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
def resolve_dependencies(self) -> List[str]:
|
|
239
|
+
"""
|
|
240
|
+
Resuelve el orden de ejecución basado en dependencias.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Lista de IDs de tareas en orden de ejecución
|
|
244
|
+
"""
|
|
245
|
+
# Topological sort
|
|
246
|
+
result = []
|
|
247
|
+
visited = set()
|
|
248
|
+
temp_mark = set()
|
|
249
|
+
|
|
250
|
+
def visit(task_id: str):
|
|
251
|
+
if task_id in temp_mark:
|
|
252
|
+
# Ciclo detectado - ignorar
|
|
253
|
+
return
|
|
254
|
+
if task_id in visited:
|
|
255
|
+
return
|
|
256
|
+
|
|
257
|
+
temp_mark.add(task_id)
|
|
258
|
+
task = self._tasks.get(task_id)
|
|
259
|
+
if task:
|
|
260
|
+
for dep_id in task.blocked_by:
|
|
261
|
+
visit(dep_id)
|
|
262
|
+
|
|
263
|
+
temp_mark.remove(task_id)
|
|
264
|
+
visited.add(task_id)
|
|
265
|
+
result.append(task_id)
|
|
266
|
+
|
|
267
|
+
for task_id in self._tasks:
|
|
268
|
+
if task_id not in visited:
|
|
269
|
+
visit(task_id)
|
|
270
|
+
|
|
271
|
+
return result
|
|
272
|
+
|
|
273
|
+
def clear_completed(self) -> int:
|
|
274
|
+
"""Elimina las tareas completadas. Retorna el número eliminadas."""
|
|
275
|
+
to_remove = [
|
|
276
|
+
tid for tid, t in self._tasks.items()
|
|
277
|
+
if t.status == TaskStatus.COMPLETED
|
|
278
|
+
]
|
|
279
|
+
for tid in to_remove:
|
|
280
|
+
del self._tasks[tid]
|
|
281
|
+
if to_remove:
|
|
282
|
+
self._save()
|
|
283
|
+
return len(to_remove)
|
|
284
|
+
|
|
285
|
+
def reset(self) -> int:
|
|
286
|
+
"""Elimina todas las tareas. Retorna el número eliminadas."""
|
|
287
|
+
count = len(self._tasks)
|
|
288
|
+
self._tasks.clear()
|
|
289
|
+
self._next_id = 1
|
|
290
|
+
if count:
|
|
291
|
+
self._save()
|
|
292
|
+
return count
|
|
293
|
+
|
|
294
|
+
def status_summary(self) -> Dict[str, int]:
|
|
295
|
+
"""Retorna resumen de estados de tareas."""
|
|
296
|
+
summary = {s.value: 0 for s in TaskStatus}
|
|
297
|
+
for task in self._tasks.values():
|
|
298
|
+
summary[task.status.value] += 1
|
|
299
|
+
return summary
|
|
300
|
+
|
|
301
|
+
def format_status(self) -> str:
|
|
302
|
+
"""Format current task status for display."""
|
|
303
|
+
summary = self.status_summary()
|
|
304
|
+
total = sum(summary.values())
|
|
305
|
+
if total == 0:
|
|
306
|
+
return "No tasks registered."
|
|
307
|
+
|
|
308
|
+
lines = [f"Tasks: {summary['pending']} pending, "
|
|
309
|
+
f"{summary['in_progress']} in progress, "
|
|
310
|
+
f"{summary['completed']} completed, "
|
|
311
|
+
f"{summary['failed']} failed"]
|
|
312
|
+
|
|
313
|
+
# Progress bar
|
|
314
|
+
done = summary['completed']
|
|
315
|
+
pct = done / total * 100 if total > 0 else 0
|
|
316
|
+
bar_len = 20
|
|
317
|
+
filled = int(bar_len * done / total) if total > 0 else 0
|
|
318
|
+
bar = "█" * filled + "░" * (bar_len - filled)
|
|
319
|
+
lines.append(f"[{bar}] {pct:.0f}%")
|
|
320
|
+
|
|
321
|
+
# Tasks in progress and pending
|
|
322
|
+
for task in self.list():
|
|
323
|
+
if task.status == TaskStatus.IN_PROGRESS:
|
|
324
|
+
blocked = " (blocked)" if self._is_blocked(task) else ""
|
|
325
|
+
lines.append(f" ● {task.id}: {task.subject}{blocked}")
|
|
326
|
+
elif task.status == TaskStatus.PENDING:
|
|
327
|
+
blocked = " ◎ blocked" if self._is_blocked(task) else ""
|
|
328
|
+
lines.append(f" ○ {task.id}: {task.subject}{blocked}")
|
|
329
|
+
|
|
330
|
+
return "\n".join(lines)
|
hanus/tasks/models.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# hanus/tasks/models.py
|
|
2
|
+
"""
|
|
3
|
+
Modelos de datos para el sistema de tareas.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Dict, List, Any, Optional
|
|
9
|
+
from enum import Enum
|
|
10
|
+
import json
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TaskStatus(Enum):
|
|
14
|
+
"""Estados posibles de una tarea."""
|
|
15
|
+
PENDING = "pending"
|
|
16
|
+
IN_PROGRESS = "in_progress"
|
|
17
|
+
COMPLETED = "completed"
|
|
18
|
+
FAILED = "failed"
|
|
19
|
+
BLOCKED = "blocked"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Task:
|
|
24
|
+
"""
|
|
25
|
+
Representa una tarea de seguimiento para el agente.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
id: Identificador único de la tarea (string como "1", "2", etc.)
|
|
29
|
+
subject: Título breve de la tarea (imperativo, ej: "Implementar login")
|
|
30
|
+
description: Descripción completa de lo que se necesita hacer
|
|
31
|
+
status: Estado actual de la tarea
|
|
32
|
+
owner: ID del agente que tiene la tarea asignada (opcional)
|
|
33
|
+
created_at: Timestamp de creación
|
|
34
|
+
updated_at: Timestamp de última actualización
|
|
35
|
+
completed_at: Timestamp de completado (opcional)
|
|
36
|
+
blocked_by: Lista de IDs de tareas que deben completarse primero
|
|
37
|
+
blocks: Lista de IDs de tareas que dependen de esta
|
|
38
|
+
metadata: Datos adicionales arbitrarios
|
|
39
|
+
activeForm: Forma presente continuo para mostrar en UI (ej: "Implementando login")
|
|
40
|
+
"""
|
|
41
|
+
id: str
|
|
42
|
+
subject: str
|
|
43
|
+
description: str
|
|
44
|
+
status: TaskStatus = TaskStatus.PENDING
|
|
45
|
+
owner: Optional[str] = None
|
|
46
|
+
created_at: float = field(default_factory=time.time)
|
|
47
|
+
updated_at: float = field(default_factory=time.time)
|
|
48
|
+
completed_at: Optional[float] = None
|
|
49
|
+
blocked_by: List[str] = field(default_factory=list)
|
|
50
|
+
blocks: List[str] = field(default_factory=list)
|
|
51
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
52
|
+
activeForm: str = ""
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
55
|
+
"""Serializa la tarea a diccionario para persistencia."""
|
|
56
|
+
return {
|
|
57
|
+
"id": self.id,
|
|
58
|
+
"subject": self.subject,
|
|
59
|
+
"description": self.description,
|
|
60
|
+
"status": self.status.value,
|
|
61
|
+
"owner": self.owner,
|
|
62
|
+
"created_at": self.created_at,
|
|
63
|
+
"updated_at": self.updated_at,
|
|
64
|
+
"completed_at": self.completed_at,
|
|
65
|
+
"blocked_by": self.blocked_by,
|
|
66
|
+
"blocks": self.blocks,
|
|
67
|
+
"metadata": self.metadata,
|
|
68
|
+
"activeForm": self.activeForm,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Task":
|
|
73
|
+
"""Deserializa una tarea desde diccionario."""
|
|
74
|
+
return cls(
|
|
75
|
+
id=d["id"],
|
|
76
|
+
subject=d["subject"],
|
|
77
|
+
description=d["description"],
|
|
78
|
+
status=TaskStatus(d.get("status", "pending")),
|
|
79
|
+
owner=d.get("owner"),
|
|
80
|
+
created_at=d.get("created_at", time.time()),
|
|
81
|
+
updated_at=d.get("updated_at", time.time()),
|
|
82
|
+
completed_at=d.get("completed_at"),
|
|
83
|
+
blocked_by=d.get("blocked_by", []),
|
|
84
|
+
blocks=d.get("blocks", []),
|
|
85
|
+
metadata=d.get("metadata", {}),
|
|
86
|
+
activeForm=d.get("activeForm", ""),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def is_available(self) -> bool:
|
|
90
|
+
"""Retorna True si la tarea puede ser empezada (pending y no bloqueada)."""
|
|
91
|
+
if self.status != TaskStatus.PENDING:
|
|
92
|
+
return False
|
|
93
|
+
# Las tareas bloqueadas no están disponibles
|
|
94
|
+
# (el manager debe verificar que las blocked_by estén completadas)
|
|
95
|
+
return True
|
|
96
|
+
|
|
97
|
+
def __str__(self) -> str:
|
|
98
|
+
status_icons = {
|
|
99
|
+
TaskStatus.PENDING: "○",
|
|
100
|
+
TaskStatus.IN_PROGRESS: "●",
|
|
101
|
+
TaskStatus.COMPLETED: "✓",
|
|
102
|
+
TaskStatus.FAILED: "✗",
|
|
103
|
+
TaskStatus.BLOCKED: "◎",
|
|
104
|
+
}
|
|
105
|
+
icon = status_icons.get(self.status, "?")
|
|
106
|
+
return f"[{icon}] {self.id}: {self.subject}"
|
hanus/terminal_prompt.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# hanus/terminal_prompt.py — Prompt de terminal robusto
|
|
2
|
+
"""
|
|
3
|
+
Maneja input del usuario con soporte para:
|
|
4
|
+
- Multiline (pegar código)
|
|
5
|
+
- Streaming toggle
|
|
6
|
+
- Caracteres de control correctos
|
|
7
|
+
- Background input (typing while agent works)
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
import sys
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
import select
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TerminalPrompt:
|
|
17
|
+
"""
|
|
18
|
+
Prompt de terminal que maneja correctamente el input del usuario.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.streaming_enabled = False # Streaming DESACTIVADO por defecto
|
|
23
|
+
self._background_buffer: str = ""
|
|
24
|
+
self._background_thread: threading.Thread | None = None
|
|
25
|
+
self._background_stop = threading.Event()
|
|
26
|
+
self._background_active = False
|
|
27
|
+
self._buffer_lock = threading.Lock()
|
|
28
|
+
|
|
29
|
+
def read_line(self, prompt_text: str = "▶") -> str:
|
|
30
|
+
"""
|
|
31
|
+
Lee una línea del usuario.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
# Usar input() estándar - más confiable
|
|
35
|
+
line = input(f"\033[96m\033[1m{prompt_text}\033[0m ")
|
|
36
|
+
return line.strip()
|
|
37
|
+
|
|
38
|
+
except KeyboardInterrupt:
|
|
39
|
+
print("^C")
|
|
40
|
+
return ""
|
|
41
|
+
except EOFError:
|
|
42
|
+
return ""
|
|
43
|
+
|
|
44
|
+
def read_multiline(self, prompt_text: str = "") -> str:
|
|
45
|
+
"""
|
|
46
|
+
Lee múltiples líneas hasta recibir una línea vacía.
|
|
47
|
+
Útil para pegar código.
|
|
48
|
+
"""
|
|
49
|
+
if prompt_text:
|
|
50
|
+
print(f"\n\033[96m▶ {prompt_text}\033[0m")
|
|
51
|
+
|
|
52
|
+
print("\033[90m (línea vacía para terminar)\033[0m")
|
|
53
|
+
print("\033[96m│\033[0m ", end="", flush=True)
|
|
54
|
+
|
|
55
|
+
lines = []
|
|
56
|
+
try:
|
|
57
|
+
while True:
|
|
58
|
+
line = input()
|
|
59
|
+
if not line: # Línea vacía termina
|
|
60
|
+
break
|
|
61
|
+
lines.append(line)
|
|
62
|
+
print("\033[96m│\033[0m ", end="", flush=True)
|
|
63
|
+
|
|
64
|
+
except KeyboardInterrupt:
|
|
65
|
+
print("^C")
|
|
66
|
+
except EOFError:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
return "\n".join(lines)
|
|
70
|
+
|
|
71
|
+
def toggle_streaming(self) -> bool:
|
|
72
|
+
"""Activa/desactiva streaming. Retorna el nuevo estado."""
|
|
73
|
+
self.streaming_enabled = not self.streaming_enabled
|
|
74
|
+
return self.streaming_enabled
|
|
75
|
+
|
|
76
|
+
def is_streaming(self) -> bool:
|
|
77
|
+
"""Retorna si el streaming está activado."""
|
|
78
|
+
return self.streaming_enabled
|
|
79
|
+
|
|
80
|
+
def start_background_input(self):
|
|
81
|
+
"""
|
|
82
|
+
Start collecting input in the background while agent works.
|
|
83
|
+
User can type while the agent is processing.
|
|
84
|
+
Input is collected silently (blind typing) and shown after agent finishes.
|
|
85
|
+
"""
|
|
86
|
+
if self._background_active:
|
|
87
|
+
return
|
|
88
|
+
|
|
89
|
+
self._background_active = True
|
|
90
|
+
self._background_stop.clear()
|
|
91
|
+
with self._buffer_lock:
|
|
92
|
+
self._background_buffer = ""
|
|
93
|
+
|
|
94
|
+
def collect_input():
|
|
95
|
+
"""Background thread that collects stdin silently."""
|
|
96
|
+
try:
|
|
97
|
+
while not self._background_stop.is_set():
|
|
98
|
+
try:
|
|
99
|
+
if hasattr(sys.stdin, 'fileno') and os.isatty(sys.stdin.fileno()):
|
|
100
|
+
# Use select for non-blocking stdin check (Unix only)
|
|
101
|
+
ready, _, _ = select.select([sys.stdin], [], [], 0.1)
|
|
102
|
+
if ready:
|
|
103
|
+
line = sys.stdin.readline()
|
|
104
|
+
if line:
|
|
105
|
+
with self._buffer_lock:
|
|
106
|
+
self._background_buffer += line
|
|
107
|
+
except (ValueError, OSError):
|
|
108
|
+
# stdin might be closed or not a tty
|
|
109
|
+
break
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
except Exception:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
# Only start background thread on Unix-like systems with a TTY
|
|
116
|
+
if hasattr(sys.stdin, 'fileno'):
|
|
117
|
+
try:
|
|
118
|
+
if os.isatty(sys.stdin.fileno()):
|
|
119
|
+
self._background_thread = threading.Thread(target=collect_input, daemon=True)
|
|
120
|
+
self._background_thread.start()
|
|
121
|
+
else:
|
|
122
|
+
self._background_active = False
|
|
123
|
+
except Exception:
|
|
124
|
+
self._background_active = False
|
|
125
|
+
|
|
126
|
+
def stop_background_input(self) -> str:
|
|
127
|
+
"""
|
|
128
|
+
Stop background input collection and return collected input.
|
|
129
|
+
"""
|
|
130
|
+
self._background_stop.set()
|
|
131
|
+
self._background_active = False
|
|
132
|
+
|
|
133
|
+
if self._background_thread:
|
|
134
|
+
self._background_thread.join(timeout=0.5)
|
|
135
|
+
self._background_thread = None
|
|
136
|
+
|
|
137
|
+
with self._buffer_lock:
|
|
138
|
+
collected = self._background_buffer
|
|
139
|
+
self._background_buffer = ""
|
|
140
|
+
|
|
141
|
+
# Show what was collected if any
|
|
142
|
+
if collected:
|
|
143
|
+
print(f"\n\033[90m 📝 Queued input ({len(collected)} chars): {collected[:50]}{'...' if len(collected) > 50 else ''}\033[0m")
|
|
144
|
+
|
|
145
|
+
return collected
|
|
146
|
+
|
|
147
|
+
def restore_terminal(self):
|
|
148
|
+
"""No-op, mantenemos por compatibilidad."""
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Singleton global
|
|
153
|
+
_prompt_instance: TerminalPrompt | None = None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def get_prompt() -> TerminalPrompt:
|
|
157
|
+
"""Obtiene la instancia global del prompt."""
|
|
158
|
+
global _prompt_instance
|
|
159
|
+
if _prompt_instance is None:
|
|
160
|
+
_prompt_instance = TerminalPrompt()
|
|
161
|
+
return _prompt_instance
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def restore_terminal():
|
|
165
|
+
"""Restaura el terminal al salir."""
|
|
166
|
+
pass
|