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.
Files changed (101) hide show
  1. agent.py +363 -0
  2. backend/__init__.py +63 -0
  3. backend/compressor.py +261 -0
  4. backend/context.py +329 -0
  5. backend/githook.py +166 -0
  6. backend/marketplace.py +141 -0
  7. backend/mempalace_bridge.py +182 -0
  8. backend/personas.py +297 -0
  9. backend/plugins.py +222 -0
  10. backend/server.py +411 -0
  11. backend/tasks.py +213 -0
  12. batch_api.py +307 -0
  13. checkpoint/__init__.py +27 -0
  14. checkpoint/hooks.py +90 -0
  15. checkpoint/store.py +314 -0
  16. checkpoint/types.py +80 -0
  17. claude_code_watcher.py +214 -0
  18. clipboard_utils.py +246 -0
  19. cloudsave.py +159 -0
  20. common.py +177 -0
  21. compaction.py +378 -0
  22. config.py +180 -0
  23. context.py +241 -0
  24. dulus-0.2.0.dist-info/METADATA +600 -0
  25. dulus-0.2.0.dist-info/RECORD +101 -0
  26. dulus-0.2.0.dist-info/WHEEL +5 -0
  27. dulus-0.2.0.dist-info/entry_points.txt +2 -0
  28. dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
  29. dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
  30. dulus-0.2.0.dist-info/top_level.txt +36 -0
  31. dulus.py +8455 -0
  32. dulus_gui.py +331 -0
  33. dulus_mcp/__init__.py +43 -0
  34. dulus_mcp/client.py +546 -0
  35. dulus_mcp/config.py +133 -0
  36. dulus_mcp/tools.py +131 -0
  37. dulus_mcp/types.py +124 -0
  38. gui/__init__.py +18 -0
  39. gui/agent_bridge.py +283 -0
  40. gui/chat_widget.py +448 -0
  41. gui/main_window.py +485 -0
  42. gui/personas.py +230 -0
  43. gui/session_utils.py +189 -0
  44. gui/settings_dialog.py +146 -0
  45. gui/sidebar.py +515 -0
  46. gui/tasks_view.py +499 -0
  47. gui/themes.py +256 -0
  48. gui/tool_panel.py +94 -0
  49. input.py +1030 -0
  50. license_manager.py +187 -0
  51. memory/__init__.py +93 -0
  52. memory/audit.py +51 -0
  53. memory/consolidator.py +312 -0
  54. memory/context.py +270 -0
  55. memory/offload.py +148 -0
  56. memory/palace.py +127 -0
  57. memory/scan.py +146 -0
  58. memory/sessions.py +100 -0
  59. memory/store.py +395 -0
  60. memory/tools.py +408 -0
  61. memory/types.py +114 -0
  62. memory/vector_search.py +92 -0
  63. multi_agent/__init__.py +23 -0
  64. multi_agent/subagent.py +501 -0
  65. multi_agent/tools.py +393 -0
  66. offload_helper.py +183 -0
  67. plugin/__init__.py +22 -0
  68. plugin/autoadapter.py +1641 -0
  69. plugin/loader.py +156 -0
  70. plugin/recommend.py +211 -0
  71. plugin/store.py +387 -0
  72. plugin/types.py +147 -0
  73. providers.py +3750 -0
  74. skill/__init__.py +14 -0
  75. skill/builtin.py +100 -0
  76. skill/clawhub.py +270 -0
  77. skill/executor.py +66 -0
  78. skill/loader.py +199 -0
  79. skill/tools.py +110 -0
  80. skills.py +14 -0
  81. spinner.py +42 -0
  82. string_utils.py +42 -0
  83. subagent.py +11 -0
  84. task/__init__.py +12 -0
  85. task/store.py +199 -0
  86. task/tools.py +265 -0
  87. task/types.py +92 -0
  88. tmux_offloader.py +177 -0
  89. tmux_tools.py +410 -0
  90. tool_registry.py +214 -0
  91. tools.py +2694 -0
  92. ui/__init__.py +1 -0
  93. ui/input.py +464 -0
  94. ui/render.py +272 -0
  95. voice/__init__.py +56 -0
  96. voice/keyterms.py +179 -0
  97. voice/recorder.py +263 -0
  98. voice/stt.py +408 -0
  99. voice/tts.py +570 -0
  100. webchat.py +432 -0
  101. 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