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.
Files changed (93) hide show
  1. hanus/__init__.py +5 -0
  2. hanus/__main__.py +10 -0
  3. hanus/action_handlers.py +76 -0
  4. hanus/action_parser.py +82 -0
  5. hanus/agent_runner.py +1445 -0
  6. hanus/analysis/__init__.py +5 -0
  7. hanus/analysis/debt.py +702 -0
  8. hanus/analysis/dependencies.py +475 -0
  9. hanus/cache/__init__.py +5 -0
  10. hanus/cache/response_cache.py +560 -0
  11. hanus/config.py +401 -0
  12. hanus/connectors/__init__.py +19 -0
  13. hanus/connectors/base.py +114 -0
  14. hanus/connectors/claude_connector.py +146 -0
  15. hanus/connectors/gemini_connector.py +141 -0
  16. hanus/connectors/glm_connector.py +160 -0
  17. hanus/connectors/ollama_connector.py +174 -0
  18. hanus/connectors/openai_connector.py +122 -0
  19. hanus/connectors/registry.py +26 -0
  20. hanus/context/__init__.py +7 -0
  21. hanus/context/manager.py +837 -0
  22. hanus/context/selective.py +626 -0
  23. hanus/error_recovery/__init__.py +5 -0
  24. hanus/error_recovery/auto_fix.py +605 -0
  25. hanus/hooks/__init__.py +5 -0
  26. hanus/hooks/manager.py +247 -0
  27. hanus/instincts/__init__.py +44 -0
  28. hanus/instincts/cli.py +372 -0
  29. hanus/instincts/detector.py +281 -0
  30. hanus/instincts/evolver.py +361 -0
  31. hanus/instincts/manager.py +343 -0
  32. hanus/instincts/types.py +253 -0
  33. hanus/logger.py +81 -0
  34. hanus/memory/__init__.py +8 -0
  35. hanus/memory/manager.py +265 -0
  36. hanus/memory/types.py +119 -0
  37. hanus/monitor.py +341 -0
  38. hanus/parallel/__init__.py +5 -0
  39. hanus/parallel/executor.py +300 -0
  40. hanus/permissions.py +182 -0
  41. hanus/plan/__init__.py +8 -0
  42. hanus/plan/mode.py +267 -0
  43. hanus/plan/models.py +152 -0
  44. hanus/plugin_manager.py +754 -0
  45. hanus/plugin_registry.py +391 -0
  46. hanus/plugins/__init__.py +1 -0
  47. hanus/plugins/arena.py +630 -0
  48. hanus/plugins/code_review.py +123 -0
  49. hanus/plugins/cortex.py +1750 -0
  50. hanus/plugins/deps_check.py +27 -0
  51. hanus/plugins/git_ops.py +33 -0
  52. hanus/plugins/metasploit.py +530 -0
  53. hanus/plugins/notes.py +583 -0
  54. hanus/plugins/search_code.py +59 -0
  55. hanus/plugins/searchsploit.py +495 -0
  56. hanus/plugins/strategist.py +175 -0
  57. hanus/plugins/webui.py +5200 -0
  58. hanus/profiles.py +479 -0
  59. hanus/profiles_builtin/__init__.py +0 -0
  60. hanus/profiles_builtin/architect/profile.yaml +12 -0
  61. hanus/profiles_builtin/architect/system_prompt.txt +71 -0
  62. hanus/profiles_builtin/deep/profile.yaml +12 -0
  63. hanus/profiles_builtin/deep/system_prompt.txt +66 -0
  64. hanus/profiles_builtin/developer/__init__.py +0 -0
  65. hanus/profiles_builtin/developer/profile.yaml +9 -0
  66. hanus/profiles_builtin/developer/system_prompt.txt +176 -0
  67. hanus/profiles_builtin/speed/profile.yaml +12 -0
  68. hanus/profiles_builtin/speed/system_prompt.txt +51 -0
  69. hanus/project_tools.py +177 -0
  70. hanus/query_engine.py +1594 -0
  71. hanus/rules/__init__.py +237 -0
  72. hanus/search/__init__.py +5 -0
  73. hanus/search/semantic.py +596 -0
  74. hanus/session_manager.py +547 -0
  75. hanus/skill_manager.py +702 -0
  76. hanus/skills/__init__.py +4 -0
  77. hanus/subagent/__init__.py +8 -0
  78. hanus/subagent/agents/__init__.py +253 -0
  79. hanus/subagent/manager.py +309 -0
  80. hanus/subagent/types.py +266 -0
  81. hanus/suggestions/__init__.py +5 -0
  82. hanus/suggestions/proactive.py +451 -0
  83. hanus/tasks/__init__.py +8 -0
  84. hanus/tasks/manager.py +330 -0
  85. hanus/tasks/models.py +106 -0
  86. hanus/terminal_prompt.py +166 -0
  87. hanus/tools.py +1849 -0
  88. hanus/ui.py +939 -0
  89. hanuscode-1.0.0.dist-info/METADATA +1151 -0
  90. hanuscode-1.0.0.dist-info/RECORD +93 -0
  91. hanuscode-1.0.0.dist-info/WHEEL +5 -0
  92. hanuscode-1.0.0.dist-info/entry_points.txt +2 -0
  93. hanuscode-1.0.0.dist-info/top_level.txt +1 -0
hanus/agent_runner.py ADDED
@@ -0,0 +1,1445 @@
1
+ # hanus/agent_runner.py
2
+ from __future__ import annotations
3
+ import sys
4
+ import textwrap as _tw
5
+ from pathlib import Path
6
+ from datetime import datetime
7
+
8
+ try:
9
+ from colorama import init as _cinit
10
+ _cinit(autoreset=True)
11
+ except ImportError:
12
+ pass
13
+
14
+ try:
15
+ from hanus.config import HanusConfig
16
+ from hanus.ui import UI, _strip_xml, W, P
17
+ from hanus.permissions import PermissionManager, PermissionMode
18
+ from hanus.session_manager import SessionManager
19
+ from hanus.query_engine import QueryEngine
20
+ from hanus.plugin_manager import PluginManager
21
+ from hanus.tools import ToolExecutor
22
+ from hanus.profiles import ProfileManager
23
+ from hanus.tasks import TaskManager
24
+ from hanus.memory import MemoryManager
25
+ from hanus.context import ContextManager
26
+ from hanus.subagent import SubagentManager
27
+ from hanus.plan import PlanMode
28
+ from hanus.skill_manager import get_skill_manager
29
+ from hanus.monitor import get_monitor_manager
30
+ import hanus.connectors
31
+ import hanus.logger as log
32
+ from hanus.connectors.registry import ConnectorRegistry
33
+ from hanus.project_tools import list_files, build_context_from_folder
34
+ except ImportError as e:
35
+ print(f"\033[91m[ERROR] Import error: {e}\033[0m")
36
+ print()
37
+ print("\033[93mTo install from GitHub:\033[0m")
38
+ print(" git clone https://github.com/hanuscode/hanuscode.git")
39
+ print(" cd hanuscode")
40
+ print(" pip install -e .")
41
+ print()
42
+ print("\033[93mOr if already cloned, from the project directory:\033[0m")
43
+ print(" pip install -e .")
44
+ print()
45
+ print("\033[93mBasic dependencies:\033[0m")
46
+ print(" pip install colorama requests pyyaml")
47
+ sys.exit(1)
48
+
49
+
50
+ # ─── Helpers ──────────────────────────────────────────────────────────────────
51
+
52
+ def _build_context(config):
53
+ return build_context_from_folder(
54
+ str(config.root_dir),
55
+ max_files=config.context_max_files,
56
+ include_content=config.context_include_content,
57
+ content_preview_chars=config.context_preview_chars,
58
+ )
59
+
60
+
61
+ def _make_connector(config, ui):
62
+ try:
63
+ return ConnectorRegistry.get(config.provider, config.get_connector_config())
64
+ except Exception as e:
65
+ ui.error(f"Could not create connector '{config.provider}': {e}")
66
+ ui.info(f"Available: {ConnectorRegistry.available()}")
67
+ sys.exit(1)
68
+
69
+
70
+ def _autosave(config, session_mgr, engine, ui, force=False):
71
+ if (config.auto_save_session or force) and session_mgr.current:
72
+ session_mgr.current.messages = engine.get_messages()
73
+ session_mgr.save()
74
+ if force:
75
+ ui.success("Session saved.")
76
+
77
+
78
+ def _show_response(response, ui):
79
+ if not response.text:
80
+ return
81
+ ui.stream_start()
82
+ clean = _strip_xml(response.text)
83
+ for line in (clean.strip().splitlines() or [""]):
84
+ if line.strip():
85
+ for wl in (_tw.wrap(line, W - 4) or [line]):
86
+ print(f" {wl}")
87
+ else:
88
+ print()
89
+ ui.stream_end()
90
+
91
+
92
+ def _cmd_exact(s: str, target: str) -> bool:
93
+ return s.lower().strip() == target
94
+
95
+
96
+ def _cmd_is(s: str, target: str) -> bool:
97
+ lower = s.lower().strip()
98
+ return lower == target or lower.startswith(target + " ")
99
+
100
+
101
+ def _make_engine(config, connector, executor, session_mgr, plugin_mgr, ui, context_mgr=None):
102
+ return QueryEngine(
103
+ connector=connector,
104
+ tool_executor=executor,
105
+ session_manager=session_mgr,
106
+ permission_manager=executor.perms,
107
+ plugin_manager=plugin_mgr,
108
+ stream_callback=ui.stream_token,
109
+ tool_start_callback=ui.tool_start,
110
+ tool_end_callback=ui.tool_end,
111
+ thinking_callback=ui.thinking,
112
+ budget_usd=config.budget_usd,
113
+ context_manager=context_mgr,
114
+ ui=ui,
115
+ )
116
+
117
+
118
+ # ─── Main ─────────────────────────────────────────────────────────────────────
119
+
120
+ def main():
121
+ """Entry point principal - detecta argumentos CLI."""
122
+ cli_main()
123
+
124
+
125
+ def cli_main():
126
+ """
127
+ CLI entry point with argument handling.
128
+ Supports both interactive and non-interactive modes.
129
+ """
130
+ import argparse
131
+
132
+ parser = argparse.ArgumentParser(
133
+ prog="hanuscode",
134
+ description="Autonomous Programming Agent for Terminal and Web",
135
+ formatter_class=argparse.RawDescriptionHelpFormatter,
136
+ epilog="""
137
+ Examples:
138
+ hanuscode # Interactive mode in current directory
139
+ hanuscode --cmd "Analyze the code" # Execute command and exit
140
+ hanuscode -c "Create a README" # Short form
141
+ hanuscode --path /project --cmd "..." # In specific directory
142
+ """
143
+ )
144
+
145
+ parser.add_argument("--cmd", "-c", type=str, default=None,
146
+ help="Command to execute (exits after completion)")
147
+ parser.add_argument("--path", "-p", type=str, default=None,
148
+ help="Working directory (default: current directory)")
149
+ parser.add_argument("--profile", type=str, default=None,
150
+ help="Profile to use: developer, security, auditor, architect")
151
+ parser.add_argument("--model", type=str, default=None,
152
+ help="Model to use: provider/model")
153
+ parser.add_argument("--mode", type=str, default=None,
154
+ choices=["default", "plan", "bypass"],
155
+ help="Permission mode: default, plan, bypass")
156
+ parser.add_argument("--version", "-v", action="store_true",
157
+ help="Show version")
158
+
159
+ args = parser.parse_args()
160
+
161
+ # Show version
162
+ if args.version:
163
+ print("hanuscode 1.0.0")
164
+ return
165
+
166
+ # Change directory if specified
167
+ if args.path:
168
+ path = Path(args.path).expanduser().resolve()
169
+ if not path.exists():
170
+ print(f"Error: Directory not found: {path}")
171
+ return
172
+ import os
173
+ os.chdir(path)
174
+
175
+ # Execute
176
+ run_with_args(
177
+ cmd=args.cmd,
178
+ profile=args.profile,
179
+ model=args.model,
180
+ mode=args.mode
181
+ )
182
+
183
+
184
+ def run_with_args(cmd: str = None, profile: str = None, model: str = None, mode: str = None):
185
+ """
186
+ Execute hanuscode with CLI arguments.
187
+ If `cmd` is passed, executes the command and exits (non-interactive mode).
188
+ """
189
+ from hanus.terminal_prompt import get_prompt, restore_terminal
190
+
191
+ cwd = Path.cwd()
192
+ config = HanusConfig.load(project_dir=cwd)
193
+ ui = UI()
194
+
195
+ # Aplicar argumentos CLI
196
+ if mode:
197
+ try:
198
+ config.permission_mode = PermissionMode(mode.lower()).value
199
+ except ValueError:
200
+ ui.error(f"Invalid mode: {mode}. Options: default, plan, bypass")
201
+ sys.exit(1)
202
+
203
+ if model:
204
+ parts = model.split("/", 1)
205
+ if len(parts) == 2:
206
+ config.provider = parts[0].lower()
207
+ config.model_id = parts[1]
208
+ else:
209
+ config.model_id = model
210
+
211
+ # ── Profiles ──────────────────────────────────────────────────────────────
212
+ profile_mgr = ProfileManager()
213
+
214
+ # If profile specified via CLI, use it
215
+ if profile:
216
+ requested = profile_mgr.get(profile)
217
+ if requested:
218
+ profile_mgr.set_active(profile)
219
+ active_profile = requested
220
+ profile_mgr.apply_to_config(active_profile, config)
221
+ else:
222
+ ui.error(f"Profile not found: {profile}")
223
+ ui.info(f"Available: {[p.name for p in profile_mgr.list_profiles()]}")
224
+ sys.exit(1)
225
+ else:
226
+ active_profile = profile_mgr.get_active()
227
+ if active_profile:
228
+ profile_mgr.apply_to_config(active_profile, config)
229
+
230
+ # ── Non-interactive mode (--cmd) ────────────────────────────────────────────
231
+ if cmd:
232
+ _run_single_command(cmd, config, ui, active_profile, profile_mgr, cwd)
233
+ return
234
+
235
+ # ── Normal interactive mode ────────────────────────────────────────────────
236
+ tp = get_prompt()
237
+ try:
238
+ _main_loop()
239
+ finally:
240
+ restore_terminal()
241
+
242
+
243
+ def _run_single_command(cmd: str, config, ui, active_profile, profile_mgr, cwd):
244
+ """Execute a single command and exit (non-interactive mode)."""
245
+ # ── Permissions ──────────────────────────────────────────────────────────────
246
+ perm_mode = PermissionMode(config.permission_mode)
247
+ perms = PermissionManager(mode=perm_mode)
248
+ # In CMD mode, approve everything automatically
249
+ perms.set_mode(PermissionMode.BYPASS)
250
+
251
+ # ── Task Manager ──────────────────────────────────────────────────────────
252
+ task_mgr = TaskManager(cwd)
253
+
254
+ # ── Memory Manager ────────────────────────────────────────────────────────
255
+ memory_mgr = MemoryManager(cwd)
256
+
257
+ # ── Context Manager ───────────────────────────────────────────────────────
258
+ # Usar configuración de ventana de contexto, con fallback a 200k tokens
259
+ context_window = config.context_window if config.context_window > 0 else 200000
260
+ context_mgr = ContextManager(
261
+ max_tokens=context_window,
262
+ preserve_recent=max(10, context_window // 5000) # Preservar más mensajes en contextos grandes
263
+ )
264
+
265
+ executor = ToolExecutor(cwd, perms, task_manager=task_mgr)
266
+ executor.perms = perms
267
+ executor.memory = memory_mgr
268
+
269
+ # Callback for ask_user (in non-interactive mode, use default)
270
+ def ask_user_callback(question: str, header: str, options: list, multi_select: bool) -> dict:
271
+ # In CMD mode, select the first option by default
272
+ if options:
273
+ return {"answers": {question: options[0]}}
274
+ return {}
275
+
276
+ executor.ask_user_callback = ask_user_callback
277
+
278
+ session_mgr = SessionManager()
279
+ session_mgr.new_session(str(cwd), config.provider, config.model_id)
280
+
281
+ # ── Plugins ───────────────────────────────────────────────────────────────
282
+ pkg_plugins = Path(__file__).parent / "plugins"
283
+ plugins_local = cwd / "plugins"
284
+ plugin_mgr = PluginManager(pkg_plugins) if pkg_plugins.exists() else PluginManager(plugins_local)
285
+ if plugins_local.exists() and plugins_local.is_dir():
286
+ local_mgr = PluginManager(plugins_local)
287
+ for name, plugin in local_mgr.plugins.items():
288
+ plugin_mgr.plugins[name] = plugin
289
+
290
+ connector = _make_connector(config, ui)
291
+
292
+ # ── Subagent Manager ──────────────────────────────────────────────────────
293
+ subagent_mgr = SubagentManager(
294
+ connector=connector,
295
+ tool_executor=executor,
296
+ session_manager=session_mgr,
297
+ permission_manager=perms,
298
+ root_dir=cwd,
299
+ )
300
+ executor.subagents = subagent_mgr
301
+
302
+ # ── Plan Mode ─────────────────────────────────────────────────────────────
303
+ plan_mode = PlanMode(cwd)
304
+ executor.plan_mode = plan_mode
305
+
306
+ # ── Skill Manager ────────────────────────────────────────────────────────
307
+ skill_mgr = get_skill_manager()
308
+
309
+ # ── Monitor Manager ───────────────────────────────────────────────────────
310
+ monitor_mgr = get_monitor_manager()
311
+
312
+ # ── Engine ────────────────────────────────────────────────────────────────
313
+ engine = QueryEngine(
314
+ connector=connector,
315
+ tool_executor=executor,
316
+ session_manager=session_mgr,
317
+ permission_manager=perms,
318
+ plugin_manager=plugin_mgr,
319
+ stream_callback=ui.stream_token,
320
+ tool_start_callback=ui.tool_start,
321
+ tool_end_callback=ui.tool_end,
322
+ thinking_callback=ui.thinking,
323
+ budget_usd=config.budget_usd,
324
+ context_manager=context_mgr,
325
+ ui=ui,
326
+ )
327
+
328
+ # ── System prompt ─────────────────────────────────────────────────────────
329
+ plugin_docs = plugin_mgr.get_plugin_docs()
330
+ if active_profile:
331
+ system_prompt = active_profile.system_prompt
332
+ if plugin_docs:
333
+ system_prompt += f"\n\n## Available Plugins\n\n{plugin_docs}"
334
+ else:
335
+ system_prompt = config.load_system_prompt(plugin_docs)
336
+
337
+ engine.set_system_prompt(system_prompt)
338
+
339
+ # Load memory
340
+ memory_context = memory_mgr.load_memories()
341
+ if memory_context:
342
+ engine._messages.append({"role": "user", "content": memory_context})
343
+ engine._messages.append({"role": "assistant", "content": "Memory loaded."})
344
+
345
+ # Banner and info
346
+ ui.banner()
347
+ ml = "native" if connector.NATIVE_TOOLS else "XML"
348
+ profile_name = active_profile.meta.display if active_profile else "no profile"
349
+ ui.info(f"Profile: {profile_name} | {connector.provider_name} | Tools: {ml} | Dir: {cwd}")
350
+
351
+ context = _build_context(config)
352
+ engine.inject_context(context)
353
+
354
+ # ── Check if command is a plugin command ─────────────────────────────────────
355
+ if cmd.startswith("/"):
356
+ parts = cmd[1:].split(maxsplit=1)
357
+ plugin_name = parts[0].lower() if parts else ""
358
+ plugin_args = parts[1] if len(parts) > 1 else ""
359
+ if plugin_name in plugin_mgr.plugins:
360
+ result = plugin_mgr.run(plugin_name, plugin_args)
361
+ if result:
362
+ ui.plugin_result(plugin_name, result)
363
+ ui.goodbye()
364
+ sys.exit(0)
365
+
366
+ # ── Execute command ──────────────────────────────────────────────────────
367
+ ui.info(f"Executing: {cmd[:80]}{'...' if len(cmd) > 80 else ''}")
368
+ print()
369
+
370
+ try:
371
+ response = engine.send(cmd)
372
+
373
+ if response.text:
374
+ _show_response(response, ui)
375
+
376
+ if config.show_cost and session_mgr.current:
377
+ ui.show_cost_bar(
378
+ response.cost_usd, response.input_tokens, response.output_tokens,
379
+ session_mgr.current.total_cost_usd, config.budget_usd,
380
+ )
381
+
382
+ # Save session
383
+ if config.auto_save_session and session_mgr.current:
384
+ session_mgr.current.messages = engine.get_messages()
385
+ session_mgr.save()
386
+
387
+ print()
388
+ ui.success("Command completed.")
389
+ ui.goodbye()
390
+ sys.exit(0)
391
+
392
+ except KeyboardInterrupt:
393
+ ui.warning("\nInterrupted by user.")
394
+ sys.exit(130)
395
+
396
+ except Exception as e:
397
+ import traceback
398
+ ui.error(f"Error: {e}")
399
+ log.log_error("cmd mode", e)
400
+ if config.verbose:
401
+ print(traceback.format_exc())
402
+ sys.exit(1)
403
+
404
+
405
+ def _main_loop():
406
+ """Main program logic (interactive mode)."""
407
+ from hanus.terminal_prompt import get_prompt
408
+
409
+ cwd = Path.cwd()
410
+ config = HanusConfig.load(project_dir=cwd)
411
+ ui = UI()
412
+
413
+ # ── Profiles ──────────────────────────────────────────────────────────────
414
+ profile_mgr = ProfileManager()
415
+ active_profile = profile_mgr.get_active()
416
+
417
+ if active_profile:
418
+ # Apply profile overrides to config
419
+ profile_mgr.apply_to_config(active_profile, config)
420
+ log.info(f"Active profile: {active_profile.name}")
421
+
422
+ # ── Permissions ──────────────────────────────────────────────────────────────
423
+ perm_mode = PermissionMode(config.permission_mode)
424
+ perms = PermissionManager(mode=perm_mode)
425
+ perms.set_ask_callback(ui.ask_permission)
426
+
427
+ # ── Task Manager ──────────────────────────────────────────────────────────
428
+ task_mgr = TaskManager(config.root_dir)
429
+
430
+ # ── Memory Manager ────────────────────────────────────────────────────────
431
+ memory_mgr = MemoryManager(config.root_dir)
432
+
433
+ # ── Context Manager (context compression) ──────────────────────────────────
434
+ # Usar configuración de ventana de contexto, con fallback a 200k tokens
435
+ context_window = config.context_window if config.context_window > 0 else 200000
436
+ context_mgr = ContextManager(
437
+ max_tokens=context_window,
438
+ preserve_recent=max(10, context_window // 5000) # Preservar más mensajes en contextos grandes
439
+ )
440
+
441
+ executor = ToolExecutor(config.root_dir, perms, task_manager=task_mgr)
442
+ executor.perms = perms
443
+ executor.memory = memory_mgr
444
+
445
+ # ── Callback for ask_user ────────────────────────────────────────────────
446
+ def ask_user_callback(question: str, header: str, options: list, multi_select: bool) -> dict:
447
+ """Callback for the model to ask the user interactively."""
448
+ return ui.ask_user_question(question, header, options, multi_select)
449
+ executor.ask_user_callback = ask_user_callback
450
+
451
+ session_mgr = SessionManager()
452
+ session_mgr.new_session(str(config.root_dir), config.provider, config.model_id)
453
+
454
+ # ── Plugins: package as base + local on top ───────────────────────────
455
+ pkg_plugins = Path(__file__).parent / "plugins"
456
+ plugins_local = config.root_dir / "plugins"
457
+ plugin_mgr = PluginManager(pkg_plugins) if pkg_plugins.exists() else PluginManager(plugins_local)
458
+ if plugins_local.exists() and plugins_local.is_dir():
459
+ local_mgr = PluginManager(plugins_local)
460
+ for name, plugin in local_mgr.plugins.items():
461
+ plugin_mgr.plugins[name] = plugin
462
+
463
+ connector = _make_connector(config, ui)
464
+
465
+ # ── Progress callbacks for subagents ────────────────────────────────────
466
+ def subagent_progress_callback(agent_id: str, event: str, data: dict):
467
+ """Display subagent progress in UI."""
468
+ if event == "started":
469
+ ui.info(f"🤖 Subagent [{data.get('type', '?')}] started: {data.get('task', '')[:50]}...")
470
+ elif event == "tool_start":
471
+ ui.info(f" ⚡ {data.get('tool', '?')}...")
472
+ elif event == "tool_end":
473
+ status = "✓" if data.get("success", False) else "✗"
474
+ ui.info(f" {status} {data.get('tool', '?')}")
475
+ elif event == "completed":
476
+ tokens = data.get("tokens", 0)
477
+ ui.info(f"✓ Subagent completed ({tokens:,} tokens)")
478
+ elif event == "error":
479
+ ui.error(f"✗ Subagent error: {data.get('error', 'Unknown')}")
480
+
481
+ # ── Subagent Manager (task delegation) ────────────────────────────────
482
+ subagent_mgr = SubagentManager(
483
+ connector=connector,
484
+ tool_executor=executor,
485
+ session_manager=session_mgr,
486
+ permission_manager=perms,
487
+ root_dir=config.root_dir,
488
+ progress_callback=subagent_progress_callback,
489
+ )
490
+ executor.subagents = subagent_mgr
491
+
492
+ # ── Plan Mode (pre-execution planning) ───────────────────────────────
493
+ plan_mode = PlanMode(config.root_dir)
494
+
495
+ # ── Skill Manager ──────────────────────────────────────────────────────────
496
+ skill_mgr = get_skill_manager()
497
+
498
+ # ── Monitor Manager ─────────────────────────────────────────────────────────
499
+ monitor_mgr = get_monitor_manager()
500
+ executor.plan_mode = plan_mode
501
+
502
+ engine = _make_engine(config, connector, executor, session_mgr, plugin_mgr, ui, context_mgr)
503
+
504
+ # ── System prompt from active profile ──────────────────────────────────
505
+ plugin_docs = plugin_mgr.get_plugin_docs()
506
+ if active_profile:
507
+ system_prompt = active_profile.system_prompt
508
+ if plugin_docs:
509
+ system_prompt += f"\n\n## Available Plugins\n\n{plugin_docs}"
510
+ else:
511
+ system_prompt = config.load_system_prompt(plugin_docs)
512
+
513
+ engine.set_system_prompt(system_prompt)
514
+
515
+ # ── Load persistent memory ─────────────────────────────────────────────
516
+ memory_context = memory_mgr.load_memories()
517
+ if memory_context:
518
+ engine._messages.append({"role": "user", "content": memory_context})
519
+ engine._messages.append({"role": "assistant", "content": "Memory loaded. I understand the previous context."})
520
+
521
+ # ── Banner and status ───────────────────────────────────────────────────────
522
+ ui.banner()
523
+ ml = "native" if connector.NATIVE_TOOLS else "XML"
524
+ profile_name = active_profile.meta.display if active_profile else "no profile"
525
+ ui.info(f"Profile: {profile_name} | {connector.provider_name} | Tools: {ml} | Dir: {cwd}")
526
+ context = _build_context(config)
527
+ engine.inject_context(context)
528
+ ui.show_status(config, plugin_mgr, session_mgr.current)
529
+ ui.show_commands(plugin_mgr, skill_mgr)
530
+
531
+ # ═════════════════════════════════════════════════════════════════════════
532
+ # MAIN LOOP
533
+ # ═════════════════════════════════════════════════════════════════════════
534
+ _chrome_input = False # Track if input came from Chrome
535
+ _telegram_input = False # Track if input came from Telegram
536
+ _telegram_chat_id = None # Store Telegram chat ID for responses
537
+ import select
538
+
539
+ # Import Chrome plugin ONCE at the start
540
+ try:
541
+ from hanus.plugins import chrome as chrome_plugin
542
+ except ImportError:
543
+ chrome_plugin = None
544
+
545
+ # Import Telegram plugin ONCE at the start
546
+ try:
547
+ from hanus.plugins import telegram as telegram_plugin
548
+ except ImportError:
549
+ telegram_plugin = None
550
+
551
+ # Register Telegram callbacks to access agent state
552
+ if telegram_plugin:
553
+ def _telegram_status_callback():
554
+ """Get current agent status."""
555
+ return {
556
+ "running": True,
557
+ "model": f"{config.provider}/{config.model_id}" if config else "unknown",
558
+ "project": str(config.root_dir) if config else "none",
559
+ "session": session_mgr.current if session_mgr else None,
560
+ }
561
+
562
+ def _telegram_project_callback(path=None):
563
+ """Get or set current project."""
564
+ if path:
565
+ config.root_dir = Path(path)
566
+ config.save()
567
+ return {"path": str(config.root_dir)}
568
+ return {"path": str(config.root_dir) if config else "none"}
569
+
570
+ def _telegram_model_callback(name=None):
571
+ """Get or set current model."""
572
+ if name:
573
+ parts = name.split("/")
574
+ if len(parts) == 2:
575
+ config.provider = parts[0]
576
+ config.model_id = parts[1]
577
+ config.save()
578
+ return f"{config.provider}/{config.model_id}"
579
+ return f"{config.provider}/{config.model_id}" if config else "unknown"
580
+
581
+ def _telegram_stop_callback():
582
+ """Stop current execution."""
583
+ # Signal stop via interrupt
584
+ import signal
585
+ import threading
586
+ import time
587
+ # This will be handled by the main loop
588
+ pass
589
+
590
+ def _telegram_history_callback(n=10):
591
+ """Get recent message history."""
592
+ if not engine:
593
+ return []
594
+ messages = engine.get_messages()[-n:] if engine else []
595
+ return [{"role": m.get("role", ""), "content": m.get("content", "")[:200]} for m in messages]
596
+
597
+ def _telegram_logs_callback(n=20):
598
+ """Get recent logs."""
599
+ log_file = Path.home() / ".hanus" / "logs" / "hanus.log"
600
+ if not log_file.exists():
601
+ return []
602
+ try:
603
+ lines = log_file.read_text().splitlines()[-n:]
604
+ return lines
605
+ except:
606
+ return []
607
+
608
+ # Register callbacks
609
+ telegram_plugin.register_callbacks(
610
+ status_callback=_telegram_status_callback,
611
+ project_callback=_telegram_project_callback,
612
+ model_callback=_telegram_model_callback,
613
+ stop_callback=_telegram_stop_callback,
614
+ history_callback=_telegram_history_callback,
615
+ logs_callback=_telegram_logs_callback,
616
+ )
617
+
618
+ def _get_input_with_chrome_poll(timeout=0.5):
619
+ """Get input from user, Chrome, or Telegram, with polling."""
620
+ while True:
621
+ # Check Chrome messages first
622
+ if chrome_plugin:
623
+ try:
624
+ pending = chrome_plugin.get_pending_chat_messages()
625
+ if pending:
626
+ return ("chrome", pending)
627
+ except Exception as e:
628
+ pass
629
+
630
+ # Check Telegram messages
631
+ if telegram_plugin:
632
+ try:
633
+ if telegram_plugin.has_pending_messages():
634
+ pending = telegram_plugin.get_pending_messages()
635
+ if pending:
636
+ return ("telegram", pending)
637
+ except Exception as e:
638
+ pass
639
+
640
+ # Check if user input is available
641
+ if select.select([sys.stdin], [], [], timeout)[0]:
642
+ line = sys.stdin.readline().strip()
643
+ return ("user", line)
644
+
645
+ while True:
646
+ try:
647
+ _chrome_input = False
648
+ _telegram_input = False
649
+ _telegram_chat_id = None
650
+ user_input = None
651
+
652
+ # Get input from Chrome, Telegram, or user
653
+ source, data = _get_input_with_chrome_poll()
654
+
655
+ if source == "chrome":
656
+ # Process Chrome message(s)
657
+ user_input = data[-1]['message']
658
+ _chrome_input = True
659
+ for msg in data:
660
+ print(f"\n{P.CYAN}📱 [Chrome] Mensaje:{P.R} {msg['message']}")
661
+ print(f"{P.CYAN}▶ Procesando mensaje de Chrome...{P.R}\n")
662
+ elif source == "telegram":
663
+ # Process Telegram message(s)
664
+ user_input = data[-1]['message']
665
+ _telegram_chat_id = data[-1].get('chat_id')
666
+ _telegram_input = True
667
+ for msg in data:
668
+ username = msg.get('username', 'User')
669
+ print(f"\n{P.CYAN}📱 [Telegram] @{username}:{P.R} {msg['message']}")
670
+ print(f"{P.CYAN}▶ Processing Telegram message...{P.R}\n")
671
+ else:
672
+ # User input
673
+ user_input = data
674
+
675
+ if not user_input:
676
+ continue
677
+
678
+ log.info(f"USER: {user_input[:120]}")
679
+
680
+ # ── Exit ─────────────────────────────────────────────────────────
681
+ # Normalize input to detect exit commands
682
+ normalized = user_input.lower().strip()
683
+ # Also detect with extra spaces or control characters
684
+ normalized = ''.join(c for c in normalized if c.isalnum() or c.isspace()).strip()
685
+ if normalized in ("salir", "q", "exit", "quit"):
686
+ _autosave(config, session_mgr, engine, ui)
687
+ ui.goodbye()
688
+ break
689
+
690
+ # ── /profile — profile management ────────────────────────────────
691
+ if _cmd_is(user_input, "/profile"):
692
+ parts = user_input.strip().split(maxsplit=2)
693
+ sub = parts[1].lower() if len(parts) > 1 else ""
694
+ arg = parts[2].strip() if len(parts) > 2 else ""
695
+
696
+ if not sub or sub == "info":
697
+ # /profile → show active profile
698
+ ui.show_profile(active_profile)
699
+ continue
700
+
701
+ if sub == "list":
702
+ # /profile list → list all
703
+ ui.show_profile(active_profile, all_profiles=profile_mgr.list_profiles())
704
+ continue
705
+
706
+ if sub == "new":
707
+ # /profile new <name> [description]
708
+ if not arg:
709
+ ui.error("Usage: /profile new <name>")
710
+ continue
711
+ name_parts = arg.split(maxsplit=1)
712
+ pname = name_parts[0].lower().replace(" ", "_")
713
+ desc = name_parts[1] if len(name_parts) > 1 else f"Profile {pname}"
714
+ base_input = input(f" Based on profile (developer/security/empty): ").strip()
715
+ new_p = profile_mgr.create(pname, pname.title(), desc,
716
+ base=base_input or None)
717
+ ui.success(f"Profile '{pname}' created.")
718
+ prompt_path = profile_mgr.edit_prompt_path(pname)
719
+ ui.info(f"Edit prompt at: {prompt_path}")
720
+ continue
721
+
722
+ if sub == "edit":
723
+ # /profile edit <name>
724
+ pname = arg or (active_profile.name if active_profile else "")
725
+ prompt_path = profile_mgr.edit_prompt_path(pname)
726
+ if prompt_path:
727
+ ui.info(f"Edit prompt at:\n {prompt_path}")
728
+ # Try to open with system editor
729
+ import os
730
+ editor = os.environ.get("EDITOR", "")
731
+ if editor:
732
+ import subprocess
733
+ subprocess.Popen([editor, str(prompt_path)])
734
+ ui.info(f"Opening with {editor}...")
735
+ else:
736
+ ui.error(f"Profile '{pname}' not found.")
737
+ continue
738
+
739
+ if sub == "reload":
740
+ # /profile reload → reload active profile prompt
741
+ if active_profile:
742
+ active_profile = profile_mgr.get(active_profile.name)
743
+ if active_profile:
744
+ system_prompt = active_profile.system_prompt
745
+ if plugin_docs:
746
+ system_prompt += f"\n\n## Available Plugins\n\n{plugin_docs}"
747
+ engine.set_system_prompt(system_prompt)
748
+ ui.success(f"Profile '{active_profile.name}' reloaded.")
749
+ continue
750
+
751
+ if sub == "reinstall":
752
+ # /profile reinstall → reinstall builtin profiles
753
+ reinstalled = profile_mgr.reinstall_builtins()
754
+ if reinstalled:
755
+ ui.success(f"Profiles reinstalled: {', '.join(reinstalled)}")
756
+ else:
757
+ ui.info("No profiles were reinstalled.")
758
+ continue
759
+
760
+ # /profile <name> → switch profile
761
+ pname = sub # sub is the profile name
762
+ new_p = profile_mgr.get(pname)
763
+ if not new_p:
764
+ available = [m.name for m in profile_mgr.list_profiles()]
765
+ ui.error(f"Profile '{pname}' not found. Available: {available}")
766
+ ui.info("Create one with: /profile new <name>")
767
+ continue
768
+
769
+ # Switch active profile
770
+ profile_mgr.set_active(pname)
771
+ active_profile = new_p
772
+ profile_mgr.apply_to_config(active_profile, config)
773
+
774
+ # Rebuild connector if provider changed
775
+ connector = _make_connector(config, ui)
776
+ engine.connector = connector
777
+
778
+ # Rebuild system prompt with new profile
779
+ system_prompt = active_profile.system_prompt
780
+ if plugin_docs:
781
+ system_prompt += f"\n\n## Available Plugins\n\n{plugin_docs}"
782
+ engine.set_system_prompt(system_prompt)
783
+
784
+ # Adjust permissions according to profile
785
+ if active_profile.meta.permission_mode:
786
+ try:
787
+ nm = PermissionMode(active_profile.meta.permission_mode)
788
+ perms.set_mode(nm)
789
+ except ValueError:
790
+ pass
791
+
792
+ ui.success(f"Profile changed to: {active_profile.meta.display}")
793
+ ui.show_profile(active_profile)
794
+ log.info(f"Profile changed to: {pname}")
795
+
796
+ # Ask if new session
797
+ try:
798
+ ans = input(" Start fresh session with new profile? [Y/n] ").strip().lower()
799
+ except (EOFError, KeyboardInterrupt):
800
+ ans = "y"
801
+ if not ans or ans.startswith("y"):
802
+ engine.clear(system_prompt, _build_context(config))
803
+ session_mgr.new_session(str(config.root_dir), config.provider, config.model_id)
804
+ ui.success("New session started with active profile.")
805
+ continue
806
+
807
+ # ── Rest of commands ──────────────────────────────────────────────
808
+ if _cmd_exact(user_input, "/reload"):
809
+ ui.info("Reloading context...")
810
+ ctx = _build_context(config)
811
+ engine._messages.append({"role": "user", "content": f"[Context updated]\n\n{ctx}"})
812
+ ui.success("Context reloaded.")
813
+ continue
814
+
815
+ if _cmd_exact(user_input, "/stream"):
816
+ enabled = ui.toggle_streaming()
817
+ status = "enabled" if enabled else "disabled"
818
+ ui.success(f"Streaming {status}")
819
+ continue
820
+
821
+ if _cmd_exact(user_input, "/multiline"):
822
+ # Mode for pasting multiple lines
823
+ multiline_input = ui.prompt_multiline("Paste your code/text (empty line to finish)")
824
+ if multiline_input:
825
+ user_input = multiline_input
826
+ # Don't continue, process input normally
827
+ else:
828
+ continue
829
+
830
+ # ── /ask — simple chat without tools ────────────────────────────────────
831
+ if _cmd_is(user_input, "/ask"):
832
+ parts = user_input.split(maxsplit=1)
833
+ if len(parts) < 2:
834
+ ui.info("Usage: /ask <question>")
835
+ ui.info("Send a simple question without executing any tools.")
836
+ continue
837
+
838
+ question = parts[1].strip()
839
+ engine.set_chat_mode(True) # Enable chat mode (no tools)
840
+ try:
841
+ response = engine.send(question)
842
+ if response.text:
843
+ _show_response(response, ui)
844
+ finally:
845
+ engine.set_chat_mode(False) # Disable chat mode
846
+ continue
847
+
848
+ if _cmd_exact(user_input, "/files"):
849
+ ui.show_files(list_files(str(config.root_dir))); continue
850
+
851
+ if _cmd_exact(user_input, "/status"):
852
+ ui.show_status(config, plugin_mgr, session_mgr.current); continue
853
+
854
+ if _cmd_exact(user_input, "/plugins"):
855
+ ui.show_plugins(plugin_mgr); continue
856
+
857
+ if _cmd_exact(user_input, "/history"):
858
+ ui.show_history(engine.get_messages()); continue
859
+
860
+ if _cmd_exact(user_input, "/clear"):
861
+ engine.clear(system_prompt, _build_context(config))
862
+ ui.success("Conversation cleared."); continue
863
+
864
+ if _cmd_exact(user_input, "/sessions"):
865
+ ui.show_sessions(session_mgr.list_sessions()); continue
866
+
867
+ if _cmd_is(user_input, "/resume"):
868
+ parts = user_input.split(maxsplit=1)
869
+ sid = parts[1].strip() if len(parts) > 1 else None
870
+ sd = session_mgr.load_by_id(sid) if sid else session_mgr.load_latest(str(config.root_dir))
871
+ if sd:
872
+ session_mgr.current = sd
873
+ engine.set_messages(sd.messages)
874
+ ui.success(f"Session resumed: {sd.name}")
875
+ else:
876
+ ui.error("Session not found.")
877
+ continue
878
+
879
+ if _cmd_exact(user_input, "/undo"):
880
+ rev = session_mgr.undo_last()
881
+ ui.success(f"Reverted: {rev}") if rev else ui.warning("No changes to undo.")
882
+ continue
883
+
884
+ if _cmd_exact(user_input, "/new"):
885
+ engine.clear(system_prompt, _build_context(config))
886
+ session_mgr.new_session(str(config.root_dir), config.provider, config.model_id)
887
+ ui.success("New session started.")
888
+ log.info("New session started")
889
+ continue
890
+
891
+ if _cmd_is(user_input, "/mode"):
892
+ parts = user_input.strip().split(maxsplit=1)
893
+ if len(parts) > 1:
894
+ try:
895
+ nm = PermissionMode(parts[1].strip().lower())
896
+ perms.set_mode(nm); config.permission_mode = nm.value
897
+ ui.success(f"Mode: {nm.value.upper()}")
898
+ except ValueError:
899
+ ui.error(f"Options: {[m.value for m in PermissionMode]}")
900
+ else:
901
+ ui.info(f"Current mode: {perms.mode.value}")
902
+ continue
903
+
904
+ if _cmd_is(user_input, "/plugins"):
905
+ from hanus.plugin_manager import run_plugin_command
906
+ parts = user_input.strip().split(maxsplit=1)
907
+ args = parts[1] if len(parts) > 1 else ""
908
+ result = run_plugin_command(args, plugin_mgr)
909
+ print(result)
910
+ # Refresh system prompt if plugins changed
911
+ if any(x in args.lower() for x in ["enable", "disable", "toggle", "reload"]):
912
+ plugin_docs = plugin_mgr.get_plugin_docs()
913
+ system_prompt = config.load_system_prompt(plugin_docs)
914
+ engine.set_system_prompt(system_prompt)
915
+ ui.info("System prompt updated.")
916
+ continue
917
+
918
+ if _cmd_exact(user_input, "/models"):
919
+ ui.show_models(connector); continue
920
+
921
+ if _cmd_is(user_input, "/model"):
922
+ parts = user_input.strip().split(maxsplit=2)
923
+ if len(parts) == 1:
924
+ ui.info(f"Current: {config.provider}/{config.model_id}")
925
+ ui.info(f"Providers: {ConnectorRegistry.available()}")
926
+ elif len(parts) == 2:
927
+ arg = parts[1].strip()
928
+ if arg.lower() in ConnectorRegistry.available():
929
+ config.provider = arg.lower()
930
+ else:
931
+ config.model_id = arg
932
+ connector = _make_connector(config, ui)
933
+ engine.connector = connector
934
+ engine.set_system_prompt(system_prompt)
935
+ ml = "native" if connector.NATIVE_TOOLS else "XML"
936
+ ui.success(f"Model: {config.provider}/{config.model_id} ({ml})")
937
+ else:
938
+ config.provider = parts[1].strip().lower()
939
+ config.model_id = parts[2].strip()
940
+ connector = _make_connector(config, ui)
941
+ engine.connector = connector
942
+ engine.set_system_prompt(system_prompt)
943
+ ml = "native" if connector.NATIVE_TOOLS else "XML"
944
+ ui.success(f"Model: {config.provider}/{config.model_id} ({ml})")
945
+ continue
946
+
947
+ if _cmd_is(user_input, "/budget"):
948
+ parts = user_input.strip().split(maxsplit=1)
949
+ if len(parts) > 1:
950
+ try:
951
+ config.budget_usd = float(parts[1]); engine.budget_usd = config.budget_usd
952
+ ui.success(f"Budget: ${config.budget_usd:.2f}")
953
+ except ValueError:
954
+ ui.error("Usage: /budget 20.0")
955
+ else:
956
+ spent = session_mgr.current.total_cost_usd if session_mgr.current else 0
957
+ lim = f"/ ${config.budget_usd:.2f}" if config.budget_usd > 0 else "(no limit)"
958
+ ui.info(f"Spent: ${spent:.4f} {lim}")
959
+ continue
960
+
961
+ if _cmd_is(user_input, "/config"):
962
+ parts = user_input.strip().split(maxsplit=2)
963
+ subcmd = parts[1].lower() if len(parts) > 1 else "show"
964
+
965
+ if subcmd == "show" or subcmd == "list":
966
+ # Show current configuration
967
+ print(f"\n{P.HEADER}── CONFIGURATION ─{P.R}")
968
+ print(f" Provider: {config.provider}")
969
+ print(f" Model: {config.model_id}")
970
+ print(f" Max Tokens: {config.max_tokens}")
971
+ print(f" Context: {config.context_window:,} tokens")
972
+ print(f" Permission: {config.permission_mode}")
973
+ print(f" Budget: ${config.budget_usd:.2f}")
974
+ print(f" Root Dir: {config.root_dir}")
975
+ print(f" Ollama URL: {config.ollama_url}")
976
+ print(f" Auto-save: {config.auto_save_session}")
977
+ print(f" Verbose: {config.verbose}")
978
+ print(f" Show Cost: {config.show_cost}")
979
+ print()
980
+
981
+ elif subcmd == "export":
982
+ # Export configuration to global config file
983
+ from hanus.config import CONFIG_DIR, GLOBAL_CONFIG
984
+ try:
985
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
986
+ config_yaml = f"""# ═══════════════════════════════════════════════════════════════════════════════
987
+ # HANUSCODE CONFIGURATION
988
+ # Generated: {datetime.now().isoformat()}
989
+ # ═══════════════════════════════════════════════════════════════════════════════
990
+
991
+ # AI Model
992
+ provider: {config.provider}
993
+ model_id: {config.model_id}
994
+ max_tokens: {config.max_tokens}
995
+ context_window: {config.context_window}
996
+
997
+ # API Keys (set via environment variables for security)
998
+ # anthropic_api_key: ""
999
+ # openai_api_key: ""
1000
+ # gemini_api_key: ""
1001
+ # glm_api_key: ""
1002
+ ollama_url: "{config.ollama_url}"
1003
+
1004
+ # Permissions
1005
+ permission_mode: {config.permission_mode}
1006
+ max_tool_calls: {config.max_tool_calls}
1007
+
1008
+ # Context
1009
+ context_max_files: {config.context_max_files}
1010
+ context_include_content: {str(config.context_include_content).lower()}
1011
+ context_preview_chars: {config.context_preview_chars}
1012
+ max_file_read_chars: {config.max_file_read_chars}
1013
+ context_compress_threshold: {config.context_compress_threshold}
1014
+
1015
+ # Execution
1016
+ shell_timeout: {config.shell_timeout}
1017
+ python_exec_timeout: {config.python_exec_timeout}
1018
+
1019
+ # Budget
1020
+ budget_usd: {config.budget_usd}
1021
+ budget_warn_pct: {config.budget_warn_pct}
1022
+
1023
+ # Sessions
1024
+ auto_save_session: {str(config.auto_save_session).lower()}
1025
+
1026
+ # UI
1027
+ show_cost: {str(config.show_cost).lower()}
1028
+ verbose: {str(config.verbose).lower()}
1029
+ """
1030
+ GLOBAL_CONFIG.write_text(config_yaml, encoding="utf-8")
1031
+ ui.success(f"Configuration exported to: {GLOBAL_CONFIG}")
1032
+ except Exception as e:
1033
+ ui.error(f"Failed to export config: {e}")
1034
+
1035
+ elif subcmd == "set":
1036
+ if len(parts) < 3:
1037
+ ui.info("Usage: /config set <key> <value>")
1038
+ ui.info("Keys: provider, model_id, permission_mode, budget_usd, max_tokens, ollama_url")
1039
+ continue
1040
+ key_val = parts[2].split(maxsplit=1)
1041
+ if len(key_val) < 2:
1042
+ ui.error("Usage: /config set <key> <value>")
1043
+ continue
1044
+ key, value = key_val[0].lower(), key_val[1]
1045
+ # Apply config change
1046
+ if hasattr(config, key):
1047
+ try:
1048
+ cur = getattr(config, key)
1049
+ if isinstance(cur, bool):
1050
+ setattr(config, key, value.lower() in ("true", "1", "yes"))
1051
+ elif isinstance(cur, int):
1052
+ setattr(config, key, int(value))
1053
+ elif isinstance(cur, float):
1054
+ setattr(config, key, float(value))
1055
+ else:
1056
+ setattr(config, key, value)
1057
+ ui.success(f"Set {key} = {getattr(config, key)}")
1058
+ # Special handling for provider/model
1059
+ if key == "provider":
1060
+ connector = _make_connector(config, ui)
1061
+ engine.connector = connector
1062
+ except Exception as e:
1063
+ ui.error(f"Failed to set {key}: {e}")
1064
+ else:
1065
+ ui.error(f"Unknown config key: {key}")
1066
+
1067
+ elif subcmd == "reset":
1068
+ # Reset to defaults
1069
+ config.permission_mode = "bypass"
1070
+ config.budget_usd = 0.0
1071
+ config.max_tokens = 8096
1072
+ config.context_window = 200000
1073
+ config.show_cost = True
1074
+ config.verbose = False
1075
+ ui.success("Configuration reset to defaults")
1076
+
1077
+ elif subcmd == "path":
1078
+ from hanus.config import CONFIG_DIR, GLOBAL_CONFIG
1079
+ print(f"\n{P.HEADER}── CONFIG PATHS ─{P.R}")
1080
+ print(f" Global config: {GLOBAL_CONFIG}")
1081
+ print(f" Config dir: {CONFIG_DIR}")
1082
+ print(f" Exists: {GLOBAL_CONFIG.exists()}")
1083
+ print()
1084
+
1085
+ else:
1086
+ ui.info("Usage: /config [show|export|set|reset|path]")
1087
+ continue
1088
+
1089
+ if _cmd_exact(user_input, "/audit"):
1090
+ ui.show_audit(perms.get_audit()); continue
1091
+
1092
+ if _cmd_exact(user_input, "/stats"):
1093
+ ui.show_stats(session_mgr.current); continue
1094
+
1095
+ if _cmd_exact(user_input, "/save"):
1096
+ _autosave(config, session_mgr, engine, ui, force=True); continue
1097
+
1098
+ if _cmd_is(user_input, "/tasks"):
1099
+ parts = user_input.strip().split(maxsplit=1)
1100
+ subcmd = parts[1].lower() if len(parts) > 1 else "list"
1101
+
1102
+ if subcmd == "list" or subcmd == "status":
1103
+ tasks = task_mgr.list_tasks()
1104
+ if not tasks:
1105
+ ui.info("No pending tasks.")
1106
+ else:
1107
+ print(f"\n{P.HEADER}── TASKS ─{P.R}")
1108
+ for t in tasks:
1109
+ status_icon = {"pending": "⏳", "in_progress": "🔄", "completed": "✅", "failed": "❌"}.get(t.status.value, "•")
1110
+ print(f" {status_icon} [{t.id}] {t.subject}")
1111
+ if t.description:
1112
+ print(f" {P.MUTED}{t.description[:60]}{'...' if len(t.description) > 60 else ''}{P.R}")
1113
+ print()
1114
+ elif subcmd == "clear":
1115
+ for t in task_mgr.list_tasks():
1116
+ if t.status.value in ("completed", "failed"):
1117
+ task_mgr.delete_task(t.id)
1118
+ ui.success("Completed tasks cleared.")
1119
+ else:
1120
+ ui.info("Commands: /tasks list | /tasks clear")
1121
+ continue
1122
+
1123
+ if _cmd_is(user_input, "/logs"):
1124
+ _show_logs(ui); continue
1125
+
1126
+ if _cmd_is(user_input, "/plugin"):
1127
+ parts = user_input.strip().split(maxsplit=2)
1128
+ name = parts[1] if len(parts) > 1 else ""
1129
+ args = parts[2] if len(parts) > 2 else ""
1130
+ result = plugin_mgr.run(name, args)
1131
+ if result:
1132
+ ui.plugin_result(name, result)
1133
+ engine._messages.append({"role": "user", "content": f"[Plugin {name}]\n{result}"})
1134
+ continue
1135
+
1136
+ # ── /skill — gestión de skills ─────────────────────────────────────────
1137
+ if _cmd_is(user_input, "/skill"):
1138
+ parts = user_input.strip().split(maxsplit=2)
1139
+ subcmd = parts[1].lower() if len(parts) > 1 else "list"
1140
+
1141
+ if subcmd == "list":
1142
+ skills = skill_mgr.list_skills()
1143
+ if not skills:
1144
+ ui.info("No skills installed. Install with: /skill install <url>")
1145
+ else:
1146
+ print(f"\n{P.HEADER}── SKILLS ─{P.R}")
1147
+ for s in sorted(skills, key=lambda x: x.name):
1148
+ icon = "📝" if s.skill_type.value == "markdown" else "🐍"
1149
+ builtin = "builtin" if s.is_builtin else "user"
1150
+ print(f" {icon} /{s.name:<15} {s.description[:40]:<40} [{builtin}]")
1151
+ print()
1152
+
1153
+ elif subcmd == "install":
1154
+ if len(parts) < 3:
1155
+ ui.error("Usage: /skill install <url>")
1156
+ ui.info("Supports: .py, .md files, GitHub Gists")
1157
+ else:
1158
+ url = parts[2]
1159
+ if "gist.github.com" in url:
1160
+ result = skill_mgr.install_from_gist(url)
1161
+ else:
1162
+ result = skill_mgr.install(url)
1163
+ ui.info(result)
1164
+
1165
+ elif subcmd == "create":
1166
+ # /skill create <name> <description>
1167
+ if len(parts) < 3:
1168
+ ui.error("Usage: /skill create <name> <description>")
1169
+ else:
1170
+ create_parts = parts[2].split(maxsplit=1)
1171
+ name = create_parts[0]
1172
+ desc = create_parts[1] if len(create_parts) > 1 else name
1173
+ result = skill_mgr.create_markdown(name, desc)
1174
+ ui.info(result)
1175
+
1176
+ elif subcmd == "remove" or subcmd == "delete":
1177
+ if len(parts) < 3:
1178
+ ui.error("Usage: /skill remove <name>")
1179
+ else:
1180
+ result = skill_mgr.remove(parts[2])
1181
+ ui.info(result)
1182
+
1183
+ elif subcmd == "reload":
1184
+ skill_mgr.reload()
1185
+ ui.success(f"Skills reloaded: {len(skill_mgr.list_skills())} available")
1186
+
1187
+ elif subcmd == "info":
1188
+ if len(parts) < 3:
1189
+ ui.error("Usage: /skill info <name>")
1190
+ else:
1191
+ skill = skill_mgr.get(parts[2])
1192
+ if skill:
1193
+ print(skill.get_doc())
1194
+ else:
1195
+ ui.error(f"Skill '{parts[2]}' not found")
1196
+
1197
+ else:
1198
+ # Try to execute skill: /skill <name> [args]
1199
+ skill_name = subcmd
1200
+ skill_args = parts[2] if len(parts) > 2 else ""
1201
+ skill = skill_mgr.get(skill_name)
1202
+
1203
+ if skill:
1204
+ result = skill.run(skill_args)
1205
+ print(result)
1206
+ # For markdown skills, inject into conversation
1207
+ if skill.skill_type.value == "markdown":
1208
+ engine._messages.append({"role": "user", "content": result})
1209
+ else:
1210
+ ui.error(f"Skill '{skill_name}' not found")
1211
+ ui.info("Commands: list, install, create, remove, info, reload")
1212
+ continue
1213
+
1214
+ # ── /monitor — background process monitoring ───────────────────────────
1215
+ if _cmd_is(user_input, "/monitor"):
1216
+ parts = user_input.strip().split(maxsplit=2)
1217
+ subcmd = parts[1].lower() if len(parts) > 1 else "list"
1218
+
1219
+ if subcmd == "list":
1220
+ monitors = monitor_mgr.list()
1221
+ if not monitors:
1222
+ ui.info("No monitors running. Start with: /monitor start <command>")
1223
+ else:
1224
+ print(f"\n{P.HEADER}── MONITORS ─{P.R}")
1225
+ for m in monitors:
1226
+ print(monitor_mgr.format_status(m))
1227
+ print()
1228
+
1229
+ elif subcmd == "start":
1230
+ if len(parts) < 3:
1231
+ ui.error("Usage: /monitor start <command>")
1232
+ ui.info("Example: /monitor start 'npm run build'")
1233
+ else:
1234
+ command = parts[2]
1235
+ name = command.split()[0] if command else "task"
1236
+
1237
+ def on_event(evt):
1238
+ # Callback for events
1239
+ if evt.event_type == "line":
1240
+ print(f" [{name}] {evt.content}")
1241
+ elif evt.event_type == "complete":
1242
+ print(f"\n✓ Monitor completed: {name}")
1243
+ elif evt.event_type == "error":
1244
+ print(f"\n✗ Monitor failed: {name} - {evt.content}")
1245
+
1246
+ monitor_id = monitor_mgr.start(name, command, on_event=on_event, cwd=config.root_dir)
1247
+ ui.success(f"Monitor started: {monitor_id}")
1248
+ ui.info("Check progress with: /monitor list")
1249
+
1250
+ elif subcmd == "stop":
1251
+ if len(parts) < 3:
1252
+ ui.error("Usage: /monitor stop <id>")
1253
+ else:
1254
+ result = monitor_mgr.stop(parts[2])
1255
+ ui.info(result)
1256
+
1257
+ elif subcmd == "events":
1258
+ if len(parts) < 3:
1259
+ ui.error("Usage: /monitor events <id>")
1260
+ else:
1261
+ events = monitor_mgr.get_events(parts[2])
1262
+ for evt in events:
1263
+ print(f" [{evt.event_type}] {evt.content}")
1264
+
1265
+ elif subcmd == "wait":
1266
+ if len(parts) < 3:
1267
+ ui.error("Usage: /monitor wait <id>")
1268
+ else:
1269
+ ui.info(f"Waiting for {parts[2]} to complete...")
1270
+ task = monitor_mgr.wait(parts[2])
1271
+ if task:
1272
+ print(monitor_mgr.format_status(task))
1273
+
1274
+ else:
1275
+ ui.info("Commands: list, start, stop, events, wait")
1276
+ continue
1277
+
1278
+ # ── Invoke skill or plugin directly: /name [args] ──────────────────────
1279
+ if user_input.startswith("/") and len(user_input) > 1:
1280
+ first_space = user_input.find(" ")
1281
+ if first_space > 0:
1282
+ potential_name = user_input[1:first_space].lower()
1283
+ cmd_args = user_input[first_space:].strip()
1284
+ else:
1285
+ potential_name = user_input[1:].lower()
1286
+ cmd_args = ""
1287
+
1288
+ # First check if it's a plugin
1289
+ if potential_name in plugin_mgr.plugins:
1290
+ result = plugin_mgr.run(potential_name, cmd_args)
1291
+ if result:
1292
+ ui.plugin_result(potential_name, result)
1293
+ continue
1294
+
1295
+ # Then check if it's a skill
1296
+ skill = skill_mgr.get(potential_name)
1297
+ if skill:
1298
+ result = skill.run(cmd_args)
1299
+ print(result)
1300
+ # For markdown skills, inject into conversation
1301
+ if skill.skill_type.value == "markdown":
1302
+ engine._messages.append({"role": "user", "content": result})
1303
+ continue
1304
+
1305
+ # ── Agent query ────────────────────────────────────────────
1306
+ try:
1307
+ # Start background input collection (allows typing while agent works)
1308
+ tp = get_prompt()
1309
+ tp.start_background_input()
1310
+
1311
+ response = engine.send(user_input)
1312
+
1313
+ # Stop background input and collect any typed text
1314
+ background_input = tp.stop_background_input()
1315
+
1316
+ # If agent finished but there's pending input, ask user what to do
1317
+ if background_input and response.stop_reason in ("done", "finished", "max_iterations"):
1318
+ ui.info(f"Agent finished, but you typed: '{background_input[:50]}...'")
1319
+ try:
1320
+ choice = input(" Send this as new message? [Y/n]: ").strip().lower()
1321
+ except (EOFError, KeyboardInterrupt):
1322
+ choice = "n"
1323
+
1324
+ if choice in ("", "y", "yes"):
1325
+ # Send as new message
1326
+ engine.set_pending_input(background_input)
1327
+ user_input = background_input
1328
+ # Re-run with the new input
1329
+ response = engine.send(user_input)
1330
+ else:
1331
+ ui.info("Pending input cancelled.")
1332
+ background_input = ""
1333
+
1334
+ elif background_input:
1335
+ # Agent didn't finish, store input for next iteration
1336
+ engine.set_pending_input(background_input)
1337
+
1338
+ except KeyboardInterrupt:
1339
+ engine.interrupt()
1340
+ ui.warning("Model interrupted. You can continue typing.")
1341
+ log.info("Interrupted by Ctrl+C")
1342
+ # Stop background input on interrupt
1343
+ tp = get_prompt()
1344
+ tp.stop_background_input()
1345
+ continue
1346
+
1347
+ if response.stop_reason == "interrupted":
1348
+ ui.warning("Response interrupted.")
1349
+ else:
1350
+ _show_response(response, ui)
1351
+
1352
+ # Send response back to Chrome extension if input came from there
1353
+ if _chrome_input and response.text:
1354
+ try:
1355
+ from hanus.plugins.chrome import send_chat_response
1356
+ # Send response to Chrome
1357
+ response_text = response.text[:4000] # Limit message size
1358
+ if send_chat_response(response_text):
1359
+ print(f"{P.CYAN}📱 [Chrome] Respuesta enviada a la extensión{P.R}")
1360
+ else:
1361
+ print(f"{P.YELLOW}📱 [Chrome] No se pudo enviar la respuesta (extensión no conectada){P.R}")
1362
+ except Exception as e:
1363
+ log.info(f"[Chrome] Response error: {e}")
1364
+
1365
+ # Send response back to Telegram if input came from there
1366
+ if _telegram_input and response.text and _telegram_chat_id:
1367
+ try:
1368
+ from hanus.plugins.telegram import send_response
1369
+ # Send response to Telegram
1370
+ response_text = response.text[:4000] # Limit message size
1371
+ send_response(_telegram_chat_id, response_text)
1372
+ print(f"{P.CYAN}📱 [Telegram] Response sent to chat {P.R}")
1373
+ except Exception as e:
1374
+ log.info(f"[Telegram] Response error: {e}")
1375
+
1376
+ if config.show_cost and session_mgr.current:
1377
+ ui.show_cost_bar(
1378
+ response.cost_usd, response.input_tokens, response.output_tokens,
1379
+ session_mgr.current.total_cost_usd, config.budget_usd,
1380
+ )
1381
+ if config.auto_save_session:
1382
+ _autosave(config, session_mgr, engine, ui)
1383
+ if config.budget_usd > 0 and session_mgr.current:
1384
+ spent = session_mgr.current.total_cost_usd
1385
+ if spent >= config.budget_usd * config.budget_warn_pct:
1386
+ ui.warning(f"Budget at {spent/config.budget_usd*100:.0f}% "
1387
+ f"(${spent:.4f}/${config.budget_usd:.2f})")
1388
+
1389
+ except KeyboardInterrupt:
1390
+ print()
1391
+ ui.warning("Ctrl+C — type 'exit' to quit or Enter to continue.")
1392
+ continue
1393
+
1394
+ except Exception as e:
1395
+ import traceback
1396
+ ui.error(f"{type(e).__name__}: {e}")
1397
+ log.log_error("main loop", e)
1398
+ if config.verbose:
1399
+ print(traceback.format_exc())
1400
+ try:
1401
+ ans = input(" Continue? [Y/n] ").strip().lower()
1402
+ except Exception:
1403
+ ans = "n"
1404
+ if ans.startswith("n"):
1405
+ break
1406
+
1407
+
1408
+ def _show_logs(ui):
1409
+ import time as _t
1410
+ log_dir = Path.home() / ".hanus" / "logs"
1411
+ log_file = log_dir / f"hanus_{_t.strftime('%Y%m%d')}.log"
1412
+ if not log_file.exists():
1413
+ ui.info("No logs today.")
1414
+ return
1415
+ lines = log_file.read_text(encoding="utf-8", errors="replace").splitlines()[-40:]
1416
+ print()
1417
+ for line in lines:
1418
+ print(f" {line}")
1419
+ print()
1420
+
1421
+
1422
+ if __name__ == "__main__":
1423
+ # If executed directly, use CLI arguments
1424
+ import argparse
1425
+ parser = argparse.ArgumentParser(
1426
+ prog="hanuscode",
1427
+ description="Autonomous Programming Agent"
1428
+ )
1429
+ parser.add_argument("--cmd", "-c", default=None, help="Command to execute")
1430
+ parser.add_argument("--path", "-p", default=None, help="Working directory")
1431
+ parser.add_argument("--profile", default=None, help="Profile to use")
1432
+ parser.add_argument("--model", default=None, help="Model: provider/model")
1433
+ parser.add_argument("--mode", default=None, choices=["default", "plan", "bypass"])
1434
+ args = parser.parse_args()
1435
+
1436
+ if args.path:
1437
+ import os
1438
+ os.chdir(Path(args.path).expanduser().resolve())
1439
+
1440
+ run_with_args(
1441
+ cmd=args.cmd,
1442
+ profile=args.profile,
1443
+ model=args.model,
1444
+ mode=args.mode
1445
+ )