dulus 0.2.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.
- agent.py +363 -0
- backend/__init__.py +63 -0
- backend/compressor.py +261 -0
- backend/context.py +329 -0
- backend/githook.py +166 -0
- backend/marketplace.py +141 -0
- backend/mempalace_bridge.py +182 -0
- backend/personas.py +297 -0
- backend/plugins.py +222 -0
- backend/server.py +411 -0
- backend/tasks.py +213 -0
- batch_api.py +307 -0
- checkpoint/__init__.py +27 -0
- checkpoint/hooks.py +90 -0
- checkpoint/store.py +314 -0
- checkpoint/types.py +80 -0
- claude_code_watcher.py +214 -0
- clipboard_utils.py +246 -0
- cloudsave.py +159 -0
- common.py +177 -0
- compaction.py +378 -0
- config.py +180 -0
- context.py +241 -0
- dulus-0.2.0.dist-info/METADATA +600 -0
- dulus-0.2.0.dist-info/RECORD +101 -0
- dulus-0.2.0.dist-info/WHEEL +5 -0
- dulus-0.2.0.dist-info/entry_points.txt +2 -0
- dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
- dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
- dulus-0.2.0.dist-info/top_level.txt +36 -0
- dulus.py +8455 -0
- dulus_gui.py +331 -0
- dulus_mcp/__init__.py +43 -0
- dulus_mcp/client.py +546 -0
- dulus_mcp/config.py +133 -0
- dulus_mcp/tools.py +131 -0
- dulus_mcp/types.py +124 -0
- gui/__init__.py +18 -0
- gui/agent_bridge.py +283 -0
- gui/chat_widget.py +448 -0
- gui/main_window.py +485 -0
- gui/personas.py +230 -0
- gui/session_utils.py +189 -0
- gui/settings_dialog.py +146 -0
- gui/sidebar.py +515 -0
- gui/tasks_view.py +499 -0
- gui/themes.py +256 -0
- gui/tool_panel.py +94 -0
- input.py +1030 -0
- license_manager.py +187 -0
- memory/__init__.py +93 -0
- memory/audit.py +51 -0
- memory/consolidator.py +312 -0
- memory/context.py +270 -0
- memory/offload.py +148 -0
- memory/palace.py +127 -0
- memory/scan.py +146 -0
- memory/sessions.py +100 -0
- memory/store.py +395 -0
- memory/tools.py +408 -0
- memory/types.py +114 -0
- memory/vector_search.py +92 -0
- multi_agent/__init__.py +23 -0
- multi_agent/subagent.py +501 -0
- multi_agent/tools.py +393 -0
- offload_helper.py +183 -0
- plugin/__init__.py +22 -0
- plugin/autoadapter.py +1641 -0
- plugin/loader.py +156 -0
- plugin/recommend.py +211 -0
- plugin/store.py +387 -0
- plugin/types.py +147 -0
- providers.py +3750 -0
- skill/__init__.py +14 -0
- skill/builtin.py +100 -0
- skill/clawhub.py +270 -0
- skill/executor.py +66 -0
- skill/loader.py +199 -0
- skill/tools.py +110 -0
- skills.py +14 -0
- spinner.py +42 -0
- string_utils.py +42 -0
- subagent.py +11 -0
- task/__init__.py +12 -0
- task/store.py +199 -0
- task/tools.py +265 -0
- task/types.py +92 -0
- tmux_offloader.py +177 -0
- tmux_tools.py +410 -0
- tool_registry.py +214 -0
- tools.py +2694 -0
- ui/__init__.py +1 -0
- ui/input.py +464 -0
- ui/render.py +272 -0
- voice/__init__.py +56 -0
- voice/keyterms.py +179 -0
- voice/recorder.py +263 -0
- voice/stt.py +408 -0
- voice/tts.py +570 -0
- webchat.py +432 -0
- webchat_server.py +1761 -0
task/types.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Task system types: Task dataclass, TaskStatus enum."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TaskStatus(str, Enum):
|
|
11
|
+
PENDING = "pending"
|
|
12
|
+
IN_PROGRESS = "in_progress"
|
|
13
|
+
COMPLETED = "completed"
|
|
14
|
+
CANCELLED = "cancelled"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
VALID_STATUSES = {s.value for s in TaskStatus}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Task:
|
|
22
|
+
id: str
|
|
23
|
+
subject: str
|
|
24
|
+
description: str
|
|
25
|
+
status: TaskStatus = TaskStatus.PENDING
|
|
26
|
+
active_form: str = "" # e.g. "Running tests"
|
|
27
|
+
owner: str = ""
|
|
28
|
+
blocks: list[str] = field(default_factory=list) # IDs this task blocks
|
|
29
|
+
blocked_by: list[str] = field(default_factory=list) # IDs that block this task
|
|
30
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
31
|
+
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
32
|
+
updated_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
|
33
|
+
|
|
34
|
+
# ── serialization ──────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def to_dict(self) -> dict:
|
|
37
|
+
return {
|
|
38
|
+
"id": self.id,
|
|
39
|
+
"subject": self.subject,
|
|
40
|
+
"description": self.description,
|
|
41
|
+
"status": self.status.value if isinstance(self.status, TaskStatus) else self.status,
|
|
42
|
+
"active_form": self.active_form,
|
|
43
|
+
"owner": self.owner,
|
|
44
|
+
"blocks": self.blocks,
|
|
45
|
+
"blocked_by": self.blocked_by,
|
|
46
|
+
"metadata": self.metadata,
|
|
47
|
+
"created_at": self.created_at,
|
|
48
|
+
"updated_at": self.updated_at,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def from_dict(cls, data: dict) -> "Task":
|
|
53
|
+
status_raw = data.get("status", "pending")
|
|
54
|
+
try:
|
|
55
|
+
status = TaskStatus(status_raw)
|
|
56
|
+
except ValueError:
|
|
57
|
+
status = TaskStatus.PENDING
|
|
58
|
+
return cls(
|
|
59
|
+
id=data["id"],
|
|
60
|
+
subject=data.get("subject", ""),
|
|
61
|
+
description=data.get("description", ""),
|
|
62
|
+
status=status,
|
|
63
|
+
active_form=data.get("active_form", ""),
|
|
64
|
+
owner=data.get("owner", ""),
|
|
65
|
+
blocks=data.get("blocks", []),
|
|
66
|
+
blocked_by=data.get("blocked_by", []),
|
|
67
|
+
metadata=data.get("metadata", {}),
|
|
68
|
+
created_at=data.get("created_at", datetime.now().isoformat()),
|
|
69
|
+
updated_at=data.get("updated_at", datetime.now().isoformat()),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# ── display ────────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
def status_icon(self) -> str:
|
|
75
|
+
return {
|
|
76
|
+
TaskStatus.PENDING: "○",
|
|
77
|
+
TaskStatus.IN_PROGRESS: "●",
|
|
78
|
+
TaskStatus.COMPLETED: "✓",
|
|
79
|
+
TaskStatus.CANCELLED: "✗",
|
|
80
|
+
}.get(self.status, "?")
|
|
81
|
+
|
|
82
|
+
def one_line(self, resolved_ids: set[str] | None = None) -> str:
|
|
83
|
+
owner_str = f" ({self.owner})" if self.owner else ""
|
|
84
|
+
pending_blockers = [
|
|
85
|
+
b for b in self.blocked_by
|
|
86
|
+
if resolved_ids is None or b not in resolved_ids
|
|
87
|
+
]
|
|
88
|
+
blocked_str = (
|
|
89
|
+
f" [blocked by #{', #'.join(pending_blockers)}]"
|
|
90
|
+
if pending_blockers else ""
|
|
91
|
+
)
|
|
92
|
+
return f"#{self.id} [{self.status.value}] {self.status_icon()} {self.subject}{owner_str}{blocked_str}"
|
tmux_offloader.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
TmuxOffloader - Wrapper alternativo a TmuxOffload
|
|
4
|
+
Usa tmux directamente ya que TmuxOffload tiene bugs
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
import random
|
|
10
|
+
import string
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def generate_session_name(prefix="job"):
|
|
15
|
+
"""Genera nombre único de sesión"""
|
|
16
|
+
suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=6))
|
|
17
|
+
return f"{prefix}_{suffix}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run_in_tmux(command, session_name=None, wait=False, timeout=None):
|
|
21
|
+
"""
|
|
22
|
+
Ejecuta un comando en una sesión tmux detached.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
command: Comando a ejecutar (string)
|
|
26
|
+
session_name: Nombre de sesión (auto-generado si None)
|
|
27
|
+
wait: Si True, espera a que termine y retorna output
|
|
28
|
+
timeout: Segundos máximos de espera (si wait=True)
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Si wait=False: session_name (para capturar después)
|
|
32
|
+
Si wait=True: dict con {'stdout', 'stderr', 'returncode', 'session_name'}
|
|
33
|
+
"""
|
|
34
|
+
if session_name is None:
|
|
35
|
+
session_name = generate_session_name()
|
|
36
|
+
|
|
37
|
+
# Crear sesión detached con el comando
|
|
38
|
+
full_cmd = f"{command}; echo '___TMUX_EXITCODE___$?'"
|
|
39
|
+
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["tmux", "new-session", "-d", "-s", session_name, full_cmd],
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if result.returncode != 0:
|
|
47
|
+
raise RuntimeError(f"Failed to create tmux session: {result.stderr}")
|
|
48
|
+
|
|
49
|
+
if not wait:
|
|
50
|
+
return session_name
|
|
51
|
+
|
|
52
|
+
# Modo wait: esperar a que termine
|
|
53
|
+
max_wait = timeout or 300 # default 5 min
|
|
54
|
+
waited = 0
|
|
55
|
+
poll_interval = 0.5
|
|
56
|
+
|
|
57
|
+
while waited < max_wait:
|
|
58
|
+
# Verificar si la sesión sigue activa
|
|
59
|
+
check = subprocess.run(
|
|
60
|
+
["tmux", "has-session", "-t", session_name],
|
|
61
|
+
capture_output=True
|
|
62
|
+
)
|
|
63
|
+
if check.returncode != 0:
|
|
64
|
+
# Sesión terminó
|
|
65
|
+
break
|
|
66
|
+
time.sleep(poll_interval)
|
|
67
|
+
waited += poll_interval
|
|
68
|
+
|
|
69
|
+
# Capturar output
|
|
70
|
+
capture = subprocess.run(
|
|
71
|
+
["tmux", "capture-pane", "-t", f"{session_name}:0.0", "-p"],
|
|
72
|
+
capture_output=True,
|
|
73
|
+
text=True
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
output = capture.stdout
|
|
77
|
+
|
|
78
|
+
# Extraer exit code
|
|
79
|
+
exit_code = 0
|
|
80
|
+
if "___TMUX_EXITCODE___" in output:
|
|
81
|
+
parts = output.rsplit("___TMUX_EXITCODE___", 1)
|
|
82
|
+
output = parts[0].strip()
|
|
83
|
+
try:
|
|
84
|
+
exit_code = int(parts[1].strip().split()[0])
|
|
85
|
+
except:
|
|
86
|
+
exit_code = 0
|
|
87
|
+
|
|
88
|
+
# Limpiar sesión
|
|
89
|
+
subprocess.run(["tmux", "kill-session", "-t", session_name], capture_output=True)
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
'stdout': output,
|
|
93
|
+
'stderr': '', # tmux no separa stderr fácilmente
|
|
94
|
+
'returncode': exit_code,
|
|
95
|
+
'session_name': session_name
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_session_output(session_name):
|
|
100
|
+
"""
|
|
101
|
+
Captura el output de una sesión tmux existente.
|
|
102
|
+
Retorna el output o None si la sesión no existe.
|
|
103
|
+
"""
|
|
104
|
+
result = subprocess.run(
|
|
105
|
+
["tmux", "capture-pane", "-t", f"{session_name}:0.0", "-p"],
|
|
106
|
+
capture_output=True,
|
|
107
|
+
text=True
|
|
108
|
+
)
|
|
109
|
+
if result.returncode == 0:
|
|
110
|
+
return result.stdout
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def is_session_active(session_name):
|
|
115
|
+
"""Verifica si una sesión tmux sigue activa"""
|
|
116
|
+
result = subprocess.run(
|
|
117
|
+
["tmux", "has-session", "-t", session_name],
|
|
118
|
+
capture_output=True
|
|
119
|
+
)
|
|
120
|
+
return result.returncode == 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def kill_session(session_name):
|
|
124
|
+
"""Mata una sesión tmux"""
|
|
125
|
+
subprocess.run(
|
|
126
|
+
["tmux", "kill-session", "-t", session_name],
|
|
127
|
+
capture_output=True
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def list_sessions():
|
|
132
|
+
"""Lista todas las sesiones tmux activas"""
|
|
133
|
+
result = subprocess.run(
|
|
134
|
+
["tmux", "list-sessions"],
|
|
135
|
+
capture_output=True,
|
|
136
|
+
text=True
|
|
137
|
+
)
|
|
138
|
+
if result.returncode == 0:
|
|
139
|
+
return [line.split(':')[0] for line in result.stdout.strip().split('\n') if line]
|
|
140
|
+
return []
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# === EJEMPLO DE USO ===
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
import sys
|
|
146
|
+
|
|
147
|
+
if len(sys.argv) > 1 and sys.argv[1] == "test":
|
|
148
|
+
print("🧪 Probando TmuxOffloader...")
|
|
149
|
+
|
|
150
|
+
# Test 1: Modo fire-and-forget
|
|
151
|
+
print("\n[Test 1] Fire-and-forget:")
|
|
152
|
+
session = run_in_tmux("echo 'Hola desde tmux' && sleep 2 && date")
|
|
153
|
+
print(f" Sesión creada: {session}")
|
|
154
|
+
time.sleep(3)
|
|
155
|
+
output = get_session_output(session)
|
|
156
|
+
if output:
|
|
157
|
+
print(f" Output capturado: {output.strip()[:50]}...")
|
|
158
|
+
kill_session(session)
|
|
159
|
+
print(" ✅ Test 1 pasado")
|
|
160
|
+
|
|
161
|
+
# Test 2: Modo wait
|
|
162
|
+
print("\n[Test 2] Modo wait:")
|
|
163
|
+
result = run_in_tmux("echo 'Esperando...' && sleep 2 && echo 'Listo!'", wait=True)
|
|
164
|
+
print(f" Output: {result['stdout'].strip()}")
|
|
165
|
+
print(f" Exit code: {result['returncode']}")
|
|
166
|
+
print(" ✅ Test 2 pasado")
|
|
167
|
+
|
|
168
|
+
print("\n🎉 Todo funcionando!")
|
|
169
|
+
else:
|
|
170
|
+
print("Uso: python tmux_offloader.py test")
|
|
171
|
+
print("")
|
|
172
|
+
print("Funciones disponibles:")
|
|
173
|
+
print(" run_in_tmux(command, wait=False) - Ejecuta comando en tmux")
|
|
174
|
+
print(" get_session_output(session) - Captura output de sesión")
|
|
175
|
+
print(" is_session_active(session) - Verifica si sesión existe")
|
|
176
|
+
print(" kill_session(session) - Mata sesión")
|
|
177
|
+
print(" list_sessions() - Lista sesiones activas")
|
tmux_tools.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""Tmux integration tools for Dulus.
|
|
2
|
+
|
|
3
|
+
Gives the AI model direct control over tmux sessions: create panes,
|
|
4
|
+
send commands, read output, and manage layouts. Auto-detected at
|
|
5
|
+
startup — tools are only registered when tmux is available on the host.
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import subprocess
|
|
11
|
+
import shlex
|
|
12
|
+
import shutil
|
|
13
|
+
from tool_registry import ToolDef, register_tool
|
|
14
|
+
|
|
15
|
+
# ── Detection ────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
def _find_tmux() -> str | None:
|
|
18
|
+
"""Locate a tmux binary."""
|
|
19
|
+
found = shutil.which("tmux")
|
|
20
|
+
if found:
|
|
21
|
+
return found
|
|
22
|
+
if sys.platform == "win32":
|
|
23
|
+
candidates = [
|
|
24
|
+
os.path.expanduser(r"~\.cargo\bin\tmux.exe"),
|
|
25
|
+
]
|
|
26
|
+
# Search common install locations
|
|
27
|
+
for base in [os.path.expanduser("~\\Desktop"), os.path.expanduser("~")]:
|
|
28
|
+
p = os.path.join(base, "tmux.exe")
|
|
29
|
+
candidates.append(p)
|
|
30
|
+
for c in candidates:
|
|
31
|
+
if os.path.isfile(c):
|
|
32
|
+
return c
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_TMUX_BIN: str | None = _find_tmux()
|
|
37
|
+
|
|
38
|
+
# Sanitize pattern: only allow alphanumerics, underscores, hyphens, dots, colons
|
|
39
|
+
_SAFE_NAME = re.compile(r'^[a-zA-Z0-9_.:-]+$')
|
|
40
|
+
|
|
41
|
+
# Direction flag constants
|
|
42
|
+
_RESIZE_FLAGS = {"up": "-U", "down": "-D", "left": "-L", "right": "-R"}
|
|
43
|
+
_READ_ONLY_TOOLS = frozenset(("TmuxListSessions", "TmuxCapture", "TmuxListPanes", "TmuxListWindows"))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def tmux_available() -> bool:
|
|
47
|
+
"""Return True if a tmux-compatible binary exists on the system."""
|
|
48
|
+
return _TMUX_BIN is not None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _safe(value: str) -> str:
|
|
52
|
+
"""Sanitize a tmux target/session name to prevent shell injection."""
|
|
53
|
+
if not value or not _SAFE_NAME.match(value):
|
|
54
|
+
raise ValueError(f"Invalid tmux identifier: {value!r}")
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _t(params: dict, key: str = "target") -> str:
|
|
59
|
+
"""Build a -t flag from params, or empty string if absent."""
|
|
60
|
+
val = params.get(key, "")
|
|
61
|
+
return f" -t {_safe(val)}" if val else ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _run(cmd: str, timeout: int = 10) -> str:
|
|
65
|
+
"""Run a tmux command and return combined stdout+stderr.
|
|
66
|
+
|
|
67
|
+
Replaces bare 'tmux' prefix with the detected binary path.
|
|
68
|
+
Unsets nesting guards ($TMUX / $PSMUX_SESSION) so commands work
|
|
69
|
+
from inside an existing session.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
if cmd.startswith("tmux "):
|
|
73
|
+
cmd = f'"{_TMUX_BIN}" {cmd[5:]}'
|
|
74
|
+
|
|
75
|
+
# Save and temporarily remove nesting guards from os.environ
|
|
76
|
+
# (Don't pass custom env to subprocess - tmux needs full parent env)
|
|
77
|
+
saved_vars = {}
|
|
78
|
+
for k in list(os.environ.keys()):
|
|
79
|
+
if k == "TMUX" or "MUX_SESSION" in k or k.startswith("PSMUX"):
|
|
80
|
+
saved_vars[k] = os.environ.pop(k)
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
r = subprocess.run(
|
|
84
|
+
cmd, shell=True, capture_output=True, text=True,
|
|
85
|
+
encoding='utf-8', errors='replace', timeout=timeout,
|
|
86
|
+
)
|
|
87
|
+
finally:
|
|
88
|
+
# Restore removed vars
|
|
89
|
+
for k, v in saved_vars.items():
|
|
90
|
+
os.environ[k] = v
|
|
91
|
+
stdout = r.stdout.strip()
|
|
92
|
+
stderr = r.stderr.strip()
|
|
93
|
+
if r.returncode != 0 and stderr:
|
|
94
|
+
err_msg = f"FAILED (exit {r.returncode}): {stderr}"
|
|
95
|
+
return err_msg.replace("psmux", "tmux").replace("pmux", "tmux")
|
|
96
|
+
out = (stdout + ("\n" + stderr if stderr else "")).strip()
|
|
97
|
+
out = out.replace("psmux", "tmux").replace("pmux", "tmux")
|
|
98
|
+
return out if out else "(ok)"
|
|
99
|
+
except subprocess.TimeoutExpired:
|
|
100
|
+
return "Error: tmux command timed out"
|
|
101
|
+
except Exception as e:
|
|
102
|
+
return f"Error: {e}"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ── Tool implementations ────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
def _tmux_list_sessions(params: dict, config: dict) -> str:
|
|
108
|
+
return _run("tmux list-sessions")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _tmux_new_session(params: dict, config: dict) -> str:
|
|
112
|
+
# Fix for tmuxoffload: use "session" as default on Unix, "dulus" on Windows
|
|
113
|
+
platform = sys.platform
|
|
114
|
+
default_name = "dulus" if platform == "win32" else "session"
|
|
115
|
+
name = _safe(params.get("session_name", default_name))
|
|
116
|
+
detach = params.get("detached", True)
|
|
117
|
+
cmd = params.get("command", "")
|
|
118
|
+
|
|
119
|
+
if sys.platform == "win32":
|
|
120
|
+
# Windows: usar lista de args sin shell=True
|
|
121
|
+
import subprocess
|
|
122
|
+
args = ["tmux", "new-session"]
|
|
123
|
+
if detach:
|
|
124
|
+
args.append("-d")
|
|
125
|
+
args.extend(["-s", name])
|
|
126
|
+
if cmd:
|
|
127
|
+
args.append(cmd)
|
|
128
|
+
try:
|
|
129
|
+
r = subprocess.run(args, capture_output=True, text=True, timeout=10)
|
|
130
|
+
if r.returncode != 0 and r.stderr:
|
|
131
|
+
return f"FAILED (exit {r.returncode}): {r.stderr}"
|
|
132
|
+
return r.stdout.strip() if r.stdout.strip() else "(ok)"
|
|
133
|
+
except Exception as e:
|
|
134
|
+
return f"Error: {e}"
|
|
135
|
+
else:
|
|
136
|
+
# Unix: seguir usando shell con shlex.quote
|
|
137
|
+
detach_flag = "-d" if detach else ""
|
|
138
|
+
shell_part = f" {shlex.quote(cmd)}" if cmd else ""
|
|
139
|
+
return _run(f"tmux new-session {detach_flag} -s {name}{shell_part}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _tmux_split_window(params: dict, config: dict) -> str:
|
|
143
|
+
direction = "-v" if params.get("direction", "vertical") == "vertical" else "-h"
|
|
144
|
+
percent = params.get("percent")
|
|
145
|
+
p_flag = f" -p {percent}" if percent else ""
|
|
146
|
+
cmd = params.get("command", "")
|
|
147
|
+
shell_part = f" {shlex.quote(cmd)}" if cmd else ""
|
|
148
|
+
return _run(f"tmux split-window {direction}{p_flag}{_t(params)}{shell_part}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _tmux_send_keys(params: dict, config: dict) -> str:
|
|
152
|
+
keys = params["keys"]
|
|
153
|
+
enter = " Enter" if params.get("press_enter", True) else ""
|
|
154
|
+
|
|
155
|
+
# Get target - handle both "session:window.pane" and ":pane" formats
|
|
156
|
+
target = params.get("target", ":0.0")
|
|
157
|
+
|
|
158
|
+
if sys.platform == "win32":
|
|
159
|
+
# Windows: tmux/psmux has issues with complex escaping
|
|
160
|
+
# Solution: Write command to a temp batch file and execute that
|
|
161
|
+
import subprocess
|
|
162
|
+
import tempfile
|
|
163
|
+
import os
|
|
164
|
+
try:
|
|
165
|
+
# Create a temp batch file with the command
|
|
166
|
+
# This avoids all the quoting nightmares with cmd.exe
|
|
167
|
+
fd, batch_path = tempfile.mkstemp(suffix='.bat', prefix='dulus_cmd_')
|
|
168
|
+
try:
|
|
169
|
+
# Write the command to the batch file
|
|
170
|
+
os.write(fd, keys.encode('utf-8'))
|
|
171
|
+
os.close(fd)
|
|
172
|
+
|
|
173
|
+
# Send the batch file execution command to tmux
|
|
174
|
+
# Use call to execute the batch file
|
|
175
|
+
batch_cmd = f'call "{batch_path}"'
|
|
176
|
+
safe_batch = batch_cmd.replace('\\', '\\\\')
|
|
177
|
+
|
|
178
|
+
cmd_parts = ['tmux', 'send-keys', '-t', target, '-l', safe_batch]
|
|
179
|
+
subprocess.run(cmd_parts, capture_output=True, text=True, check=True)
|
|
180
|
+
|
|
181
|
+
# Send Enter
|
|
182
|
+
subprocess.run(['tmux', 'send-keys', '-t', target, 'Enter'],
|
|
183
|
+
capture_output=True, text=True, check=True)
|
|
184
|
+
|
|
185
|
+
# Schedule cleanup of the batch file after a delay
|
|
186
|
+
# (tmux needs time to execute it)
|
|
187
|
+
cleanup_cmd = f'"{batch_path}"'
|
|
188
|
+
subprocess.Popen(['tmux', 'send-keys', '-t', target, '-l',
|
|
189
|
+
f' && del "{batch_path}"', 'Enter'],
|
|
190
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
191
|
+
|
|
192
|
+
return "ok"
|
|
193
|
+
except Exception:
|
|
194
|
+
# Cleanup on error
|
|
195
|
+
try:
|
|
196
|
+
os.close(fd)
|
|
197
|
+
except:
|
|
198
|
+
pass
|
|
199
|
+
try:
|
|
200
|
+
os.unlink(batch_path)
|
|
201
|
+
except:
|
|
202
|
+
pass
|
|
203
|
+
raise
|
|
204
|
+
except subprocess.CalledProcessError as e:
|
|
205
|
+
return f"failed: {e.stderr if e.stderr else str(e)}"
|
|
206
|
+
except Exception as e:
|
|
207
|
+
return f"failed: {str(e)}"
|
|
208
|
+
else:
|
|
209
|
+
# Unix: For tmux targets, we can't use shlex.quote because it wraps
|
|
210
|
+
# "session:window.pane" in quotes which tmux doesn't understand.
|
|
211
|
+
# Targets with : or . should NOT be quoted as a whole.
|
|
212
|
+
safe_keys = keys.replace("'", "'\\''")
|
|
213
|
+
return _run(f"tmux send-keys -t {target} '{safe_keys}'{enter}")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _tmux_capture_pane(params: dict, config: dict) -> str:
|
|
217
|
+
lines = params.get("lines", 50)
|
|
218
|
+
return _run(f"tmux capture-pane{_t(params)} -p -S -{int(lines)}")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _tmux_list_panes(params: dict, config: dict) -> str:
|
|
222
|
+
return _run(f"tmux list-panes{_t(params)} -F '#{{pane_index}}: #{{pane_current_command}} [#{{pane_width}}x#{{pane_height}}] #{{?pane_active,(active),}}'")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _tmux_select_pane(params: dict, config: dict) -> str:
|
|
226
|
+
return _run(f"tmux select-pane -t {_safe(params['target'])}")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _tmux_kill_pane(params: dict, config: dict) -> str:
|
|
230
|
+
return _run(f"tmux kill-pane{_t(params)}")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _tmux_new_window(params: dict, config: dict) -> str:
|
|
234
|
+
t_flag = _t(params, "target_session")
|
|
235
|
+
name = params.get("window_name", "")
|
|
236
|
+
n_flag = f" -n {_safe(name)}" if name else ""
|
|
237
|
+
cmd = params.get("command", "")
|
|
238
|
+
shell_part = f" {shlex.quote(cmd)}" if cmd else ""
|
|
239
|
+
return _run(f"tmux new-window{t_flag}{n_flag}{shell_part}")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _tmux_list_windows(params: dict, config: dict) -> str:
|
|
243
|
+
return _run(f"tmux list-windows{_t(params, 'target_session')} -F '#{{window_index}}: #{{window_name}} [#{{window_width}}x#{{window_height}}] #{{?window_active,(active),}}'")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _tmux_resize_pane(params: dict, config: dict) -> str:
|
|
247
|
+
direction = params.get("direction", "down")
|
|
248
|
+
amount = int(params.get("amount", 10))
|
|
249
|
+
d_flag = _RESIZE_FLAGS.get(direction, "-D")
|
|
250
|
+
return _run(f"tmux resize-pane{_t(params)} {d_flag} {amount}")
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ── Schemas ──────────────────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
TMUX_TOOL_SCHEMAS = [
|
|
256
|
+
{
|
|
257
|
+
"name": "TmuxListSessions",
|
|
258
|
+
"description": "List all active tmux sessions.",
|
|
259
|
+
"input_schema": {"type": "object", "properties": {}},
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
"name": "TmuxNewSession",
|
|
263
|
+
"description": "Create a new tmux session. Use detached=true (default) to keep it in the background.",
|
|
264
|
+
"input_schema": {
|
|
265
|
+
"type": "object",
|
|
266
|
+
"properties": {
|
|
267
|
+
"session_name": {"type": "string", "description": "Session name (default: dulus)"},
|
|
268
|
+
"detached": {"type": "boolean", "description": "Start detached (default: true)"},
|
|
269
|
+
"command": {"type": "string", "description": "Optional command to run in the new session"},
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
"name": "TmuxSplitWindow",
|
|
275
|
+
"description": "Split the current tmux pane into two. Creates a new visible terminal pane. (Hint: to run a command and keep the pane open, omit 'command' here and use TmuxSendKeys afterwards).",
|
|
276
|
+
"input_schema": {
|
|
277
|
+
"type": "object",
|
|
278
|
+
"properties": {
|
|
279
|
+
"target": {"type": "string", "description": "Target pane (e.g. session:window.pane)"},
|
|
280
|
+
"direction": {"type": "string", "enum": ["vertical", "horizontal"], "description": "Split direction (default: vertical)"},
|
|
281
|
+
"percent": {"type": "integer", "description": "Percentage of the original pane to take (e.g. 35)"},
|
|
282
|
+
"command": {"type": "string", "description": "Optional command to run in the new pane"},
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
"name": "TmuxSendKeys",
|
|
288
|
+
"description": "Send keystrokes/commands to a tmux pane. The command runs visibly in that pane.",
|
|
289
|
+
"input_schema": {
|
|
290
|
+
"type": "object",
|
|
291
|
+
"properties": {
|
|
292
|
+
"keys": {"type": "string", "description": "The text or command to send"},
|
|
293
|
+
"target": {"type": "string", "description": "Target pane (e.g. session:window.pane)"},
|
|
294
|
+
"press_enter": {"type": "boolean", "description": "Press Enter after sending keys (default: true)"},
|
|
295
|
+
},
|
|
296
|
+
"required": ["keys"],
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
"name": "TmuxCapture",
|
|
301
|
+
"description": "Capture and return the visible text content of a tmux pane. Use this to read command output.",
|
|
302
|
+
"input_schema": {
|
|
303
|
+
"type": "object",
|
|
304
|
+
"properties": {
|
|
305
|
+
"target": {"type": "string", "description": "Target pane (e.g. session:window.pane)"},
|
|
306
|
+
"lines": {"type": "integer", "description": "Number of history lines to capture (default: 50)"},
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
"name": "TmuxListPanes",
|
|
312
|
+
"description": "List all panes in the current session/window with their index, command, and size.",
|
|
313
|
+
"input_schema": {
|
|
314
|
+
"type": "object",
|
|
315
|
+
"properties": {
|
|
316
|
+
"target": {"type": "string", "description": "Target session or window"},
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
"name": "TmuxSelectPane",
|
|
322
|
+
"description": "Switch focus to a specific tmux pane.",
|
|
323
|
+
"input_schema": {
|
|
324
|
+
"type": "object",
|
|
325
|
+
"properties": {
|
|
326
|
+
"target": {"type": "string", "description": "Target pane (e.g. 0, 1, or session:window.pane)"},
|
|
327
|
+
},
|
|
328
|
+
"required": ["target"],
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
"name": "TmuxKillPane",
|
|
333
|
+
"description": "Close/kill a tmux pane.",
|
|
334
|
+
"input_schema": {
|
|
335
|
+
"type": "object",
|
|
336
|
+
"properties": {
|
|
337
|
+
"target": {"type": "string", "description": "Target pane to kill"},
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
"name": "TmuxNewWindow",
|
|
343
|
+
"description": "Create a new tmux window (tab) in a session. (Hint: to run a command and keep the window open, omit 'command' here and use TmuxSendKeys afterwards).",
|
|
344
|
+
"input_schema": {
|
|
345
|
+
"type": "object",
|
|
346
|
+
"properties": {
|
|
347
|
+
"target_session": {"type": "string", "description": "Session to add the window to"},
|
|
348
|
+
"window_name": {"type": "string", "description": "Name for the new window"},
|
|
349
|
+
"command": {"type": "string", "description": "Optional command to run"},
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
"name": "TmuxListWindows",
|
|
355
|
+
"description": "List all windows in a tmux session.",
|
|
356
|
+
"input_schema": {
|
|
357
|
+
"type": "object",
|
|
358
|
+
"properties": {
|
|
359
|
+
"target_session": {"type": "string", "description": "Session name"},
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
"name": "TmuxResizePane",
|
|
365
|
+
"description": "Resize a tmux pane in a given direction.",
|
|
366
|
+
"input_schema": {
|
|
367
|
+
"type": "object",
|
|
368
|
+
"properties": {
|
|
369
|
+
"target": {"type": "string", "description": "Target pane"},
|
|
370
|
+
"direction": {"type": "string", "enum": ["up", "down", "left", "right"], "description": "Resize direction"},
|
|
371
|
+
"amount": {"type": "integer", "description": "Number of cells to resize (default: 10)"},
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
]
|
|
376
|
+
|
|
377
|
+
# ── Registration ─────────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
_TOOL_FUNCS = {
|
|
380
|
+
"TmuxListSessions": _tmux_list_sessions,
|
|
381
|
+
"TmuxNewSession": _tmux_new_session,
|
|
382
|
+
"TmuxSplitWindow": _tmux_split_window,
|
|
383
|
+
"TmuxSendKeys": _tmux_send_keys,
|
|
384
|
+
"TmuxCapture": _tmux_capture_pane,
|
|
385
|
+
"TmuxListPanes": _tmux_list_panes,
|
|
386
|
+
"TmuxSelectPane": _tmux_select_pane,
|
|
387
|
+
"TmuxKillPane": _tmux_kill_pane,
|
|
388
|
+
"TmuxNewWindow": _tmux_new_window,
|
|
389
|
+
"TmuxListWindows": _tmux_list_windows,
|
|
390
|
+
"TmuxResizePane": _tmux_resize_pane,
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def register_tmux_tools() -> int:
|
|
395
|
+
"""Register all tmux tools. Returns number of tools registered."""
|
|
396
|
+
if not tmux_available():
|
|
397
|
+
return 0
|
|
398
|
+
|
|
399
|
+
schema_map = {s["name"]: s for s in TMUX_TOOL_SCHEMAS}
|
|
400
|
+
count = 0
|
|
401
|
+
for name, func in _TOOL_FUNCS.items():
|
|
402
|
+
register_tool(ToolDef(
|
|
403
|
+
name=name,
|
|
404
|
+
schema=schema_map[name],
|
|
405
|
+
func=func,
|
|
406
|
+
read_only=name in _READ_ONLY_TOOLS,
|
|
407
|
+
concurrent_safe=True,
|
|
408
|
+
))
|
|
409
|
+
count += 1
|
|
410
|
+
return count
|