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
multi_agent/tools.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Multi-agent tool registrations.
|
|
2
|
+
|
|
3
|
+
Registers the following tools into the central tool_registry:
|
|
4
|
+
Agent — spawn a sub-agent (always background)
|
|
5
|
+
SendMessage — send a message to a named background agent
|
|
6
|
+
CheckAgentResult — check status/result of a background agent
|
|
7
|
+
ListAgentTasks — list all active/finished agent tasks
|
|
8
|
+
ListAgentTypes — list available agent type definitions
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from tool_registry import ToolDef, register_tool
|
|
13
|
+
from .subagent import SubAgentManager, get_agent_definition, load_agent_definitions
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# ── Singleton manager ──────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
_agent_manager: SubAgentManager | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_agent_manager() -> SubAgentManager:
|
|
22
|
+
"""Return (and lazily create) the process-wide SubAgentManager."""
|
|
23
|
+
global _agent_manager
|
|
24
|
+
if _agent_manager is None:
|
|
25
|
+
_agent_manager = SubAgentManager()
|
|
26
|
+
return _agent_manager
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── Tool implementations ───────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
def _agent_tool(params: dict, config: dict) -> str:
|
|
32
|
+
"""Spawn a sub-agent.
|
|
33
|
+
|
|
34
|
+
Reads from config:
|
|
35
|
+
_system_prompt — injected by agent.py run(), used as base system prompt
|
|
36
|
+
_depth — current nesting depth (prevents infinite recursion)
|
|
37
|
+
"""
|
|
38
|
+
mgr = get_agent_manager()
|
|
39
|
+
|
|
40
|
+
prompt = params["prompt"]
|
|
41
|
+
# Sub-agents ALWAYS run in background. A sub must never block the main
|
|
42
|
+
# agent's stream — `wait` is no longer accepted from the model.
|
|
43
|
+
wait = False
|
|
44
|
+
isolation = params.get("isolation", "")
|
|
45
|
+
name = params.get("name", "")
|
|
46
|
+
model_override = params.get("model", "")
|
|
47
|
+
subagent_type = params.get("subagent_type", "")
|
|
48
|
+
|
|
49
|
+
system_prompt = config.get("_system_prompt", "You are a helpful assistant.")
|
|
50
|
+
depth = config.get("_depth", 0)
|
|
51
|
+
|
|
52
|
+
# Strip private keys before passing to sub-agent, but preserve the hooks
|
|
53
|
+
# that AskMainAgentQuestion needs to reach back into the main agent.
|
|
54
|
+
eff_config = {k: v for k, v in config.items() if not k.startswith("_")}
|
|
55
|
+
for k in ("_run_query_callback", "_state"):
|
|
56
|
+
if k in config:
|
|
57
|
+
eff_config[k] = config[k]
|
|
58
|
+
if model_override:
|
|
59
|
+
eff_config["model"] = model_override
|
|
60
|
+
|
|
61
|
+
# Resolve agent definition
|
|
62
|
+
agent_def = None
|
|
63
|
+
if subagent_type:
|
|
64
|
+
agent_def = get_agent_definition(subagent_type)
|
|
65
|
+
if agent_def is None:
|
|
66
|
+
return (
|
|
67
|
+
f"Error: unknown subagent_type '{subagent_type}'. "
|
|
68
|
+
"Use ListAgentTypes to see available types."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
task = mgr.spawn(
|
|
72
|
+
prompt, eff_config, system_prompt,
|
|
73
|
+
depth=depth,
|
|
74
|
+
agent_def=agent_def,
|
|
75
|
+
isolation=isolation,
|
|
76
|
+
name=name,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if task.status == "failed":
|
|
80
|
+
return f"Error spawning agent: {task.result}"
|
|
81
|
+
|
|
82
|
+
if wait:
|
|
83
|
+
mgr.wait(task.id, timeout=300)
|
|
84
|
+
result = task.result or f"(no output — status: {task.status})"
|
|
85
|
+
header = f"[Agent: {task.name}"
|
|
86
|
+
if subagent_type:
|
|
87
|
+
header += f" ({subagent_type})"
|
|
88
|
+
if task.worktree_branch:
|
|
89
|
+
header += f", branch: {task.worktree_branch}"
|
|
90
|
+
header += "]"
|
|
91
|
+
return f"{header}\n\n{result}"
|
|
92
|
+
else:
|
|
93
|
+
info_parts = [f"Task ID: {task.id}", f"Name: {task.name}", f"Status: {task.status}"]
|
|
94
|
+
if subagent_type:
|
|
95
|
+
info_parts.append(f"Type: {subagent_type}")
|
|
96
|
+
if task.worktree_branch:
|
|
97
|
+
info_parts.append(f"Worktree branch: {task.worktree_branch}")
|
|
98
|
+
info_parts.append("Use CheckAgentResult or SendMessage to interact with this agent.")
|
|
99
|
+
return "\n".join(info_parts)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _send_message(params: dict, config: dict) -> str:
|
|
103
|
+
mgr = get_agent_manager()
|
|
104
|
+
target = params["to"]
|
|
105
|
+
message = params["message"]
|
|
106
|
+
ok = mgr.send_message(target, message)
|
|
107
|
+
if ok:
|
|
108
|
+
return f"Message queued for agent '{target}'. It will be processed after current work completes."
|
|
109
|
+
task_id = mgr._by_name.get(target, target)
|
|
110
|
+
task = mgr.tasks.get(task_id)
|
|
111
|
+
if task is None:
|
|
112
|
+
return f"Error: no agent found with id or name '{target}'"
|
|
113
|
+
return f"Error: agent '{target}' is not running (status: {task.status}). Cannot send message."
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _check_agent_result(params: dict, config: dict) -> str:
|
|
117
|
+
mgr = get_agent_manager()
|
|
118
|
+
task_id = params["task_id"]
|
|
119
|
+
task = mgr.tasks.get(task_id)
|
|
120
|
+
if task is None:
|
|
121
|
+
return f"Error: no task with id '{task_id}'"
|
|
122
|
+
lines = [f"Status: {task.status}", f"Name: {task.name}"]
|
|
123
|
+
if task.worktree_branch:
|
|
124
|
+
lines.append(f"Worktree branch: {task.worktree_branch}")
|
|
125
|
+
if task.result:
|
|
126
|
+
lines.append(f"\nResult:\n{task.result}")
|
|
127
|
+
return "\n".join(lines)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _list_agent_tasks(params: dict, config: dict) -> str:
|
|
131
|
+
mgr = get_agent_manager()
|
|
132
|
+
tasks = mgr.list_tasks()
|
|
133
|
+
if not tasks:
|
|
134
|
+
return "No sub-agent tasks."
|
|
135
|
+
lines = ["ID | Name | Status | Worktree branch | Prompt"]
|
|
136
|
+
lines.append("-------------|----------|-----------|-----------------|------")
|
|
137
|
+
for t in tasks:
|
|
138
|
+
prompt_short = t.prompt[:50] + ("..." if len(t.prompt) > 50 else "")
|
|
139
|
+
wt = t.worktree_branch[:15] if t.worktree_branch else "-"
|
|
140
|
+
lines.append(f"{t.id} | {t.name[:8]:8s} | {t.status:9s} | {wt:15s} | {prompt_short}")
|
|
141
|
+
return "\n".join(lines)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _ask_main_agent_question(params: dict, config: dict) -> str:
|
|
145
|
+
"""Pause a sub-agent and ask the main agent a question.
|
|
146
|
+
|
|
147
|
+
The sub-agent blocks on a threading.Event in its current turn (preserving
|
|
148
|
+
full context). The main agent receives a system message naming the
|
|
149
|
+
sub-agent and the question; it replies using SendMessage(to=<name>, ...).
|
|
150
|
+
"""
|
|
151
|
+
import threading as _threading
|
|
152
|
+
|
|
153
|
+
question = params.get("question", "").strip()
|
|
154
|
+
if not question:
|
|
155
|
+
return "Error: 'question' parameter is required."
|
|
156
|
+
|
|
157
|
+
task_id = config.get("_subagent_task_id")
|
|
158
|
+
if not task_id:
|
|
159
|
+
return (
|
|
160
|
+
"Error: AskMainAgentQuestion can only be called from within a "
|
|
161
|
+
"sub-agent. If you ARE the main agent, use AskUserQuestion instead."
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
mgr = get_agent_manager()
|
|
165
|
+
task = mgr.tasks.get(task_id)
|
|
166
|
+
if task is None:
|
|
167
|
+
return f"Error: sub-agent task '{task_id}' not found."
|
|
168
|
+
|
|
169
|
+
run_query_cb = config.get("_run_query_callback")
|
|
170
|
+
main_state = config.get("_state")
|
|
171
|
+
if run_query_cb is None or main_state is None:
|
|
172
|
+
return (
|
|
173
|
+
"Error: main-agent hooks unavailable. AskMainAgentQuestion requires "
|
|
174
|
+
"the sub-agent to have been spawned from an active REPL session."
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
# Register the pending question on the task.
|
|
178
|
+
event = _threading.Event()
|
|
179
|
+
holder: list[str] = []
|
|
180
|
+
task._pending_question = {
|
|
181
|
+
"question": question,
|
|
182
|
+
"event": event,
|
|
183
|
+
"result": holder,
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
# Inject a system message into the main agent's state and trigger a turn.
|
|
187
|
+
addr = task.name or task.id
|
|
188
|
+
sys_msg = (
|
|
189
|
+
f"(System Event): Sub-agent '{addr}' is paused and asking you a question:\n\n"
|
|
190
|
+
f" {question}\n\n"
|
|
191
|
+
f"Reply by calling SendMessage(to='{addr}', message='<your answer>'). "
|
|
192
|
+
f"The sub-agent is holding its full context and will resume the same "
|
|
193
|
+
f"turn once you respond."
|
|
194
|
+
)
|
|
195
|
+
try:
|
|
196
|
+
main_state.messages.append({"role": "system", "content": sys_msg})
|
|
197
|
+
except Exception:
|
|
198
|
+
pass
|
|
199
|
+
try:
|
|
200
|
+
run_query_cb(sys_msg)
|
|
201
|
+
except Exception as e:
|
|
202
|
+
task._pending_question = None
|
|
203
|
+
return f"Error: failed to notify main agent: {e}"
|
|
204
|
+
|
|
205
|
+
# Block the sub-agent until the main replies (or we hit the timeout).
|
|
206
|
+
got = event.wait(timeout=600)
|
|
207
|
+
task._pending_question = None
|
|
208
|
+
if not got:
|
|
209
|
+
return "(no answer — timeout after 600s waiting for main agent)"
|
|
210
|
+
return holder[0] if holder else "(no answer)"
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _list_agent_types(params: dict, config: dict) -> str:
|
|
214
|
+
defs = load_agent_definitions()
|
|
215
|
+
if not defs:
|
|
216
|
+
return "No agent types available."
|
|
217
|
+
lines = ["Available agent types:", ""]
|
|
218
|
+
for aname, d in sorted(defs.items()):
|
|
219
|
+
model_info = f" model: {d.model}" if d.model else ""
|
|
220
|
+
tools_info = f" tools: {', '.join(d.tools)}" if d.tools else ""
|
|
221
|
+
lines.append(f" {aname:20s} [{d.source:8s}] {d.description}")
|
|
222
|
+
if model_info:
|
|
223
|
+
lines.append(f" {model_info}")
|
|
224
|
+
if tools_info:
|
|
225
|
+
lines.append(f" {tools_info}")
|
|
226
|
+
lines.append("")
|
|
227
|
+
lines.append(
|
|
228
|
+
"Create custom agents: place .md files in ~/.dulus/agents/ or .dulus/agents/"
|
|
229
|
+
)
|
|
230
|
+
return "\n".join(lines)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ── Tool registrations ─────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
register_tool(ToolDef(
|
|
236
|
+
name="Agent",
|
|
237
|
+
schema={
|
|
238
|
+
"name": "Agent",
|
|
239
|
+
"description": (
|
|
240
|
+
"Spawn a sub-agent to handle a task autonomously. The sub-agent runs in a "
|
|
241
|
+
"separate thread with its own conversation history. Supports specialized agent "
|
|
242
|
+
"types (coder, reviewer, researcher, tester, or custom from .dulus/agents/), "
|
|
243
|
+
"isolated git worktrees for parallel work, and background execution.\n\n"
|
|
244
|
+
"When using isolation='worktree', the agent gets its own git branch and "
|
|
245
|
+
"working copy — ideal for parallel coding tasks that shouldn't interfere."
|
|
246
|
+
),
|
|
247
|
+
"input_schema": {
|
|
248
|
+
"type": "object",
|
|
249
|
+
"properties": {
|
|
250
|
+
"prompt": {
|
|
251
|
+
"type": "string",
|
|
252
|
+
"description": "Task description for the sub-agent",
|
|
253
|
+
},
|
|
254
|
+
"subagent_type": {
|
|
255
|
+
"type": "string",
|
|
256
|
+
"description": (
|
|
257
|
+
"Specialized agent type: 'general-purpose', 'coder', 'reviewer', "
|
|
258
|
+
"'researcher', 'tester', or any custom type. "
|
|
259
|
+
"Use ListAgentTypes to see all available types."
|
|
260
|
+
),
|
|
261
|
+
},
|
|
262
|
+
"name": {
|
|
263
|
+
"type": "string",
|
|
264
|
+
"description": (
|
|
265
|
+
"Human-readable name for this agent instance. "
|
|
266
|
+
"Makes it addressable via SendMessage while running in background."
|
|
267
|
+
),
|
|
268
|
+
},
|
|
269
|
+
"model": {
|
|
270
|
+
"type": "string",
|
|
271
|
+
"description": "Model override for this specific agent (optional)",
|
|
272
|
+
},
|
|
273
|
+
"isolation": {
|
|
274
|
+
"type": "string",
|
|
275
|
+
"enum": ["worktree"],
|
|
276
|
+
"description": (
|
|
277
|
+
"'worktree' creates a temporary git worktree so the agent works "
|
|
278
|
+
"on an isolated copy of the repo. Changes stay on a separate branch "
|
|
279
|
+
"and can be reviewed/merged after completion."
|
|
280
|
+
),
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
"required": ["prompt"],
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
func=_agent_tool,
|
|
287
|
+
read_only=False,
|
|
288
|
+
concurrent_safe=False,
|
|
289
|
+
))
|
|
290
|
+
|
|
291
|
+
register_tool(ToolDef(
|
|
292
|
+
name="SendMessage",
|
|
293
|
+
schema={
|
|
294
|
+
"name": "SendMessage",
|
|
295
|
+
"description": (
|
|
296
|
+
"Send a follow-up message to a running background agent. "
|
|
297
|
+
"The message is queued and processed after the agent finishes its current work. "
|
|
298
|
+
"Reference agents by the name set via Agent(name=...) or by task ID."
|
|
299
|
+
),
|
|
300
|
+
"input_schema": {
|
|
301
|
+
"type": "object",
|
|
302
|
+
"properties": {
|
|
303
|
+
"to": {"type": "string", "description": "Agent name or task ID"},
|
|
304
|
+
"message": {"type": "string", "description": "Message to send to the agent"},
|
|
305
|
+
},
|
|
306
|
+
"required": ["to", "message"],
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
func=_send_message,
|
|
310
|
+
read_only=False,
|
|
311
|
+
concurrent_safe=True,
|
|
312
|
+
))
|
|
313
|
+
|
|
314
|
+
register_tool(ToolDef(
|
|
315
|
+
name="CheckAgentResult",
|
|
316
|
+
schema={
|
|
317
|
+
"name": "CheckAgentResult",
|
|
318
|
+
"description": "Check the status and result of a spawned sub-agent task.",
|
|
319
|
+
"input_schema": {
|
|
320
|
+
"type": "object",
|
|
321
|
+
"properties": {
|
|
322
|
+
"task_id": {"type": "string", "description": "Task ID returned by Agent tool"},
|
|
323
|
+
},
|
|
324
|
+
"required": ["task_id"],
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
func=_check_agent_result,
|
|
328
|
+
read_only=True,
|
|
329
|
+
concurrent_safe=True,
|
|
330
|
+
))
|
|
331
|
+
|
|
332
|
+
register_tool(ToolDef(
|
|
333
|
+
name="ListAgentTasks",
|
|
334
|
+
schema={
|
|
335
|
+
"name": "ListAgentTasks",
|
|
336
|
+
"description": "List all sub-agent tasks and their statuses.",
|
|
337
|
+
"input_schema": {
|
|
338
|
+
"type": "object",
|
|
339
|
+
"properties": {},
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
func=_list_agent_tasks,
|
|
343
|
+
read_only=True,
|
|
344
|
+
concurrent_safe=True,
|
|
345
|
+
))
|
|
346
|
+
|
|
347
|
+
register_tool(ToolDef(
|
|
348
|
+
name="AskMainAgentQuestion",
|
|
349
|
+
schema={
|
|
350
|
+
"name": "AskMainAgentQuestion",
|
|
351
|
+
"description": (
|
|
352
|
+
"Pause this sub-agent and ask the MAIN agent a question (not the user). "
|
|
353
|
+
"The sub-agent blocks in the current turn — full context is preserved — "
|
|
354
|
+
"and resumes once the main agent answers via SendMessage. "
|
|
355
|
+
"Use this when you need a decision, clarification, or permission from "
|
|
356
|
+
"the main agent before continuing. Examples: 'Should I also refactor X?', "
|
|
357
|
+
"'¿Deseas que haga algo más?', 'Plan looks like A or B — which one?'. "
|
|
358
|
+
"Only works when invoked from a sub-agent running in the background; "
|
|
359
|
+
"the main agent itself should use AskUserQuestion for the end user."
|
|
360
|
+
),
|
|
361
|
+
"input_schema": {
|
|
362
|
+
"type": "object",
|
|
363
|
+
"properties": {
|
|
364
|
+
"question": {
|
|
365
|
+
"type": "string",
|
|
366
|
+
"description": "The question to ask the main agent.",
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
"required": ["question"],
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
func=_ask_main_agent_question,
|
|
373
|
+
read_only=False,
|
|
374
|
+
concurrent_safe=True,
|
|
375
|
+
))
|
|
376
|
+
|
|
377
|
+
register_tool(ToolDef(
|
|
378
|
+
name="ListAgentTypes",
|
|
379
|
+
schema={
|
|
380
|
+
"name": "ListAgentTypes",
|
|
381
|
+
"description": (
|
|
382
|
+
"List all available agent types (built-in and custom). "
|
|
383
|
+
"Use the type names as subagent_type when calling Agent."
|
|
384
|
+
),
|
|
385
|
+
"input_schema": {
|
|
386
|
+
"type": "object",
|
|
387
|
+
"properties": {},
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
func=_list_agent_types,
|
|
391
|
+
read_only=True,
|
|
392
|
+
concurrent_safe=True,
|
|
393
|
+
))
|
offload_helper.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Offload Helper - Reemplazo para TmuxOffload
|
|
4
|
+
Funciona con las herramientas tmux que sí funcionan
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Optional, Dict, Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TmuxJob:
|
|
14
|
+
"""Representa un job ejecutado en tmux"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, command: str):
|
|
17
|
+
self.command = command
|
|
18
|
+
self.session = f"dulus_{uuid.uuid4().hex[:8]}"
|
|
19
|
+
self._created = False
|
|
20
|
+
self._start_time = None
|
|
21
|
+
|
|
22
|
+
def start(self) -> str:
|
|
23
|
+
"""Inicia el job en tmux detached. Retorna session ID."""
|
|
24
|
+
# Usar bash -c para soportar pipes y redirects
|
|
25
|
+
full_cmd = f"exec bash -c {repr(self.command)}"
|
|
26
|
+
|
|
27
|
+
result = subprocess.run(
|
|
28
|
+
["tmux", "new-session", "-d", "-s", self.session, full_cmd],
|
|
29
|
+
capture_output=True,
|
|
30
|
+
text=True
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
if result.returncode != 0:
|
|
34
|
+
raise RuntimeError(f"tmux error: {result.stderr}")
|
|
35
|
+
|
|
36
|
+
self._created = True
|
|
37
|
+
self._start_time = time.time()
|
|
38
|
+
return self.session
|
|
39
|
+
|
|
40
|
+
def is_running(self) -> bool:
|
|
41
|
+
"""Verifica si el job sigue corriendo"""
|
|
42
|
+
if not self._created:
|
|
43
|
+
return False
|
|
44
|
+
result = subprocess.run(
|
|
45
|
+
["tmux", "has-session", "-t", self.session],
|
|
46
|
+
capture_output=True
|
|
47
|
+
)
|
|
48
|
+
return result.returncode == 0
|
|
49
|
+
|
|
50
|
+
def capture(self, lines: int = 1000) -> str:
|
|
51
|
+
"""Captura el output del job"""
|
|
52
|
+
if not self._created:
|
|
53
|
+
raise RuntimeError("Job no iniciado")
|
|
54
|
+
|
|
55
|
+
result = subprocess.run(
|
|
56
|
+
["tmux", "capture-pane", "-t", self.session, "-p", "-S", f"-{lines}"],
|
|
57
|
+
capture_output=True,
|
|
58
|
+
text=True
|
|
59
|
+
)
|
|
60
|
+
return result.stdout if result.returncode == 0 else ""
|
|
61
|
+
|
|
62
|
+
def kill(self):
|
|
63
|
+
"""Mata el job y la sesión tmux"""
|
|
64
|
+
if self._created:
|
|
65
|
+
subprocess.run(
|
|
66
|
+
["tmux", "kill-session", "-t", self.session],
|
|
67
|
+
capture_output=True
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def wait(self, timeout: Optional[float] = None, poll_interval: float = 0.5) -> bool:
|
|
71
|
+
"""
|
|
72
|
+
Espera a que termine el job.
|
|
73
|
+
Retorna True si terminó, False si timeout.
|
|
74
|
+
"""
|
|
75
|
+
if not self._created:
|
|
76
|
+
raise RuntimeError("Job no iniciado")
|
|
77
|
+
|
|
78
|
+
start = time.time()
|
|
79
|
+
while self.is_running():
|
|
80
|
+
if timeout and (time.time() - start) > timeout:
|
|
81
|
+
return False
|
|
82
|
+
time.sleep(poll_interval)
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# === API SIMPLE ===
|
|
87
|
+
|
|
88
|
+
def offload(command: str) -> str:
|
|
89
|
+
"""
|
|
90
|
+
Ejecuta un comando en tmux detached (fire-and-forget).
|
|
91
|
+
Retorna el session ID para capturar después.
|
|
92
|
+
|
|
93
|
+
Uso:
|
|
94
|
+
session = offload("sleep 10 && echo listo")
|
|
95
|
+
# ... más tarde ...
|
|
96
|
+
tmux capture-pane -t <session>:0.0 -p
|
|
97
|
+
"""
|
|
98
|
+
job = TmuxJob(command)
|
|
99
|
+
return job.start()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def offload_and_wait(command: str, timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
103
|
+
"""
|
|
104
|
+
Ejecuta comando y espera a que termine.
|
|
105
|
+
|
|
106
|
+
Uso:
|
|
107
|
+
result = offload_and_wait("sleep 5 && date", timeout=10)
|
|
108
|
+
print(result['output']) # stdout del comando
|
|
109
|
+
"""
|
|
110
|
+
job = TmuxJob(command)
|
|
111
|
+
job.start()
|
|
112
|
+
finished = job.wait(timeout=timeout)
|
|
113
|
+
output = job.capture()
|
|
114
|
+
job.kill()
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
'session': job.session,
|
|
118
|
+
'output': output,
|
|
119
|
+
'finished': finished,
|
|
120
|
+
'timeout_reached': not finished
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def list_offloaded():
|
|
125
|
+
"""Lista todas las sesiones dulus activas"""
|
|
126
|
+
result = subprocess.run(
|
|
127
|
+
["tmux", "list-sessions"],
|
|
128
|
+
capture_output=True,
|
|
129
|
+
text=True
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
if result.returncode != 0:
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
sessions = []
|
|
136
|
+
for line in result.stdout.strip().split('\n'):
|
|
137
|
+
if line.startswith('dulus_'):
|
|
138
|
+
sessions.append(line.split(':')[0])
|
|
139
|
+
return sessions
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# === EJEMPLOS ===
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
import sys
|
|
146
|
+
|
|
147
|
+
if len(sys.argv) > 1 and sys.argv[1] == "demo":
|
|
148
|
+
print("🦅 Demo de Offload Helper")
|
|
149
|
+
print("=" * 40)
|
|
150
|
+
|
|
151
|
+
# Demo 1: Fire and forget
|
|
152
|
+
print("\n1️⃣ Fire-and-forget:")
|
|
153
|
+
session = offload("echo 'Hola desde tmux!' && sleep 2 && date")
|
|
154
|
+
print(f" Session: {session}")
|
|
155
|
+
print(f" Para ver: tmux capture-pane -t {session}:0.0 -p")
|
|
156
|
+
|
|
157
|
+
time.sleep(3)
|
|
158
|
+
output = subprocess.run(
|
|
159
|
+
["tmux", "capture-pane", "-t", f"{session}:0.0", "-p"],
|
|
160
|
+
capture_output=True, text=True
|
|
161
|
+
).stdout
|
|
162
|
+
print(f" Output: {output.strip()[:50]}...")
|
|
163
|
+
|
|
164
|
+
# Limpiar
|
|
165
|
+
subprocess.run(["tmux", "kill-session", "-t", session], capture_output=True)
|
|
166
|
+
|
|
167
|
+
# Demo 2: Wait mode
|
|
168
|
+
print("\n2️⃣ Wait mode:")
|
|
169
|
+
result = offload_and_wait("echo 'Esperando...' && sleep 2 && echo 'Listo!' && date")
|
|
170
|
+
print(f" Output: {result['output'].strip()}")
|
|
171
|
+
|
|
172
|
+
# Demo 3: Listar
|
|
173
|
+
print("\n3️⃣ Sesiones activas:")
|
|
174
|
+
sessions = list_offloaded()
|
|
175
|
+
print(f" {len(sessions)} sesiones dulus activas")
|
|
176
|
+
|
|
177
|
+
print("\n✅ Todo funcionando!")
|
|
178
|
+
else:
|
|
179
|
+
print("Uso: python offload_helper.py demo")
|
|
180
|
+
print("\nFunciones:")
|
|
181
|
+
print(" offload(cmd) -> session_id")
|
|
182
|
+
print(" offload_and_wait(cmd, timeout) -> {output, session}")
|
|
183
|
+
print(" list_offloaded() -> [sessions]")
|
plugin/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Plugin system for dulus."""
|
|
2
|
+
from .types import PluginManifest, PluginEntry, PluginScope, parse_plugin_identifier
|
|
3
|
+
from .store import (
|
|
4
|
+
install_plugin, uninstall_plugin,
|
|
5
|
+
enable_plugin, disable_plugin, disable_all_plugins,
|
|
6
|
+
update_plugin, list_plugins, get_plugin,
|
|
7
|
+
)
|
|
8
|
+
from .loader import (
|
|
9
|
+
load_all_plugins, load_plugin_tools, load_plugin_skills,
|
|
10
|
+
load_plugin_mcp_configs, register_plugin_tools, reload_plugins,
|
|
11
|
+
)
|
|
12
|
+
from .recommend import recommend_plugins, recommend_from_files, format_recommendations
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"PluginManifest", "PluginEntry", "PluginScope", "parse_plugin_identifier",
|
|
16
|
+
"install_plugin", "uninstall_plugin",
|
|
17
|
+
"enable_plugin", "disable_plugin", "disable_all_plugins",
|
|
18
|
+
"update_plugin", "list_plugins", "get_plugin",
|
|
19
|
+
"load_all_plugins", "load_plugin_tools", "load_plugin_skills",
|
|
20
|
+
"load_plugin_mcp_configs", "register_plugin_tools", "reload_plugins",
|
|
21
|
+
"recommend_plugins", "recommend_from_files", "format_recommendations",
|
|
22
|
+
]
|