aru-code 0.19.2__tar.gz → 0.20.1__tar.gz

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 (65) hide show
  1. {aru_code-0.19.2/aru_code.egg-info → aru_code-0.20.1}/PKG-INFO +42 -3
  2. {aru_code-0.19.2 → aru_code-0.20.1}/README.md +41 -2
  3. aru_code-0.20.1/aru/__init__.py +1 -0
  4. {aru_code-0.19.2 → aru_code-0.20.1}/aru/agents/base.py +6 -0
  5. {aru_code-0.19.2 → aru_code-0.20.1}/aru/cli.py +32 -0
  6. {aru_code-0.19.2 → aru_code-0.20.1}/aru/config.py +80 -38
  7. {aru_code-0.19.2 → aru_code-0.20.1}/aru/context.py +42 -13
  8. {aru_code-0.19.2 → aru_code-0.20.1}/aru/permissions.py +49 -1
  9. aru_code-0.20.1/aru/plugins/__init__.py +12 -0
  10. aru_code-0.20.1/aru/plugins/custom_tools.py +350 -0
  11. aru_code-0.20.1/aru/plugins/hooks.py +134 -0
  12. aru_code-0.20.1/aru/plugins/manager.py +330 -0
  13. aru_code-0.20.1/aru/plugins/tool_api.py +54 -0
  14. {aru_code-0.19.2 → aru_code-0.20.1}/aru/runner.py +5 -0
  15. {aru_code-0.19.2 → aru_code-0.20.1}/aru/runtime.py +3 -0
  16. {aru_code-0.19.2 → aru_code-0.20.1}/aru/tools/codebase.py +35 -7
  17. {aru_code-0.19.2 → aru_code-0.20.1/aru_code.egg-info}/PKG-INFO +42 -3
  18. {aru_code-0.19.2 → aru_code-0.20.1}/aru_code.egg-info/SOURCES.txt +6 -0
  19. {aru_code-0.19.2 → aru_code-0.20.1}/pyproject.toml +1 -1
  20. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_cli_advanced.py +5 -3
  21. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_context.py +20 -17
  22. aru_code-0.20.1/tests/test_plugins.py +447 -0
  23. aru_code-0.19.2/aru/__init__.py +0 -1
  24. {aru_code-0.19.2 → aru_code-0.20.1}/LICENSE +0 -0
  25. {aru_code-0.19.2 → aru_code-0.20.1}/aru/agent_factory.py +0 -0
  26. {aru_code-0.19.2 → aru_code-0.20.1}/aru/agents/__init__.py +0 -0
  27. {aru_code-0.19.2 → aru_code-0.20.1}/aru/agents/executor.py +0 -0
  28. {aru_code-0.19.2 → aru_code-0.20.1}/aru/agents/planner.py +0 -0
  29. {aru_code-0.19.2 → aru_code-0.20.1}/aru/cache_patch.py +0 -0
  30. {aru_code-0.19.2 → aru_code-0.20.1}/aru/commands.py +0 -0
  31. {aru_code-0.19.2 → aru_code-0.20.1}/aru/completers.py +0 -0
  32. {aru_code-0.19.2 → aru_code-0.20.1}/aru/display.py +0 -0
  33. {aru_code-0.19.2 → aru_code-0.20.1}/aru/history_blocks.py +0 -0
  34. {aru_code-0.19.2 → aru_code-0.20.1}/aru/providers.py +0 -0
  35. {aru_code-0.19.2 → aru_code-0.20.1}/aru/session.py +0 -0
  36. {aru_code-0.19.2 → aru_code-0.20.1}/aru/tools/__init__.py +0 -0
  37. {aru_code-0.19.2 → aru_code-0.20.1}/aru/tools/ast_tools.py +0 -0
  38. {aru_code-0.19.2 → aru_code-0.20.1}/aru/tools/gitignore.py +0 -0
  39. {aru_code-0.19.2 → aru_code-0.20.1}/aru/tools/mcp_client.py +0 -0
  40. {aru_code-0.19.2 → aru_code-0.20.1}/aru/tools/ranker.py +0 -0
  41. {aru_code-0.19.2 → aru_code-0.20.1}/aru/tools/tasklist.py +0 -0
  42. {aru_code-0.19.2 → aru_code-0.20.1}/aru_code.egg-info/dependency_links.txt +0 -0
  43. {aru_code-0.19.2 → aru_code-0.20.1}/aru_code.egg-info/entry_points.txt +0 -0
  44. {aru_code-0.19.2 → aru_code-0.20.1}/aru_code.egg-info/requires.txt +0 -0
  45. {aru_code-0.19.2 → aru_code-0.20.1}/aru_code.egg-info/top_level.txt +0 -0
  46. {aru_code-0.19.2 → aru_code-0.20.1}/setup.cfg +0 -0
  47. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_agents_base.py +0 -0
  48. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_cli.py +0 -0
  49. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_cli_base.py +0 -0
  50. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_cli_completers.py +0 -0
  51. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_cli_new.py +0 -0
  52. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_cli_run_cli.py +0 -0
  53. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_cli_session.py +0 -0
  54. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_cli_shell.py +0 -0
  55. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_codebase.py +0 -0
  56. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_confabulation_regression.py +0 -0
  57. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_config.py +0 -0
  58. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_executor.py +0 -0
  59. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_gitignore.py +0 -0
  60. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_main.py +0 -0
  61. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_mcp_client.py +0 -0
  62. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_permissions.py +0 -0
  63. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_planner.py +0 -0
  64. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_providers.py +0 -0
  65. {aru_code-0.19.2 → aru_code-0.20.1}/tests/test_ranker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.19.2
3
+ Version: 0.20.1
4
4
  Summary: A Claude Code clone built with Agno agents
5
5
  Author-email: Estevao <estevaofon@gmail.com>
6
6
  License-Expression: MIT
@@ -312,8 +312,47 @@ Without any `aru.json` config, aru applies safe defaults:
312
312
  - Bash → ~40 safe command prefixes auto-allowed (`ls`, `git status`, `grep`, etc.), rest → `ask`
313
313
  - Sensitive files (`*.env`, `*.env.*`) → `deny` for read/edit/write (except `*.env.example`)
314
314
 
315
- > `aru.json` can also be placed at `.aru/config.json`.
316
- >
315
+ #### Config file locations
316
+
317
+ Aru loads configuration from two levels, with project settings overriding global ones:
318
+
319
+ | Level | Path | Purpose |
320
+ |-------|------|---------|
321
+ | **Global (user)** | `~/.aru/config.json` | Defaults that apply to all projects (model, aliases, permissions, providers) |
322
+ | **Project** | `aru.json` or `.aru/config.json` | Project-specific overrides |
323
+
324
+ Global config is loaded first, then the project config is **deep-merged** on top — scalar values and lists are replaced, nested objects (like `permission`, `providers`, `model_aliases`) are merged recursively. This means you can set your preferred model and aliases globally and only override what's different per project.
325
+
326
+ **Example `~/.aru/config.json`:**
327
+
328
+ ```json
329
+ {
330
+ "default_model": "anthropic/claude-sonnet-4-6",
331
+ "model_aliases": {
332
+ "sonnet": "anthropic/claude-sonnet-4-6",
333
+ "opus": "anthropic/claude-opus-4-6"
334
+ },
335
+ "permission": {
336
+ "read": "allow",
337
+ "glob": "allow",
338
+ "grep": "allow"
339
+ }
340
+ }
341
+ ```
342
+
343
+ Then a project `aru.json` only needs project-specific settings:
344
+
345
+ ```json
346
+ {
347
+ "default_model": "ollama/codellama",
348
+ "permission": {
349
+ "bash": { "pytest *": "allow" }
350
+ }
351
+ }
352
+ ```
353
+
354
+ The result: `default_model` becomes `ollama/codellama`, `model_aliases` come from global, and `permission` merges both levels (`read`, `glob`, `grep` from global + `bash` from project).
355
+
317
356
  > A full `aru.json` config reference here: [`aru.json`](./aru.json)
318
357
 
319
358
  ### AGENTS.md
@@ -265,8 +265,47 @@ Without any `aru.json` config, aru applies safe defaults:
265
265
  - Bash → ~40 safe command prefixes auto-allowed (`ls`, `git status`, `grep`, etc.), rest → `ask`
266
266
  - Sensitive files (`*.env`, `*.env.*`) → `deny` for read/edit/write (except `*.env.example`)
267
267
 
268
- > `aru.json` can also be placed at `.aru/config.json`.
269
- >
268
+ #### Config file locations
269
+
270
+ Aru loads configuration from two levels, with project settings overriding global ones:
271
+
272
+ | Level | Path | Purpose |
273
+ |-------|------|---------|
274
+ | **Global (user)** | `~/.aru/config.json` | Defaults that apply to all projects (model, aliases, permissions, providers) |
275
+ | **Project** | `aru.json` or `.aru/config.json` | Project-specific overrides |
276
+
277
+ Global config is loaded first, then the project config is **deep-merged** on top — scalar values and lists are replaced, nested objects (like `permission`, `providers`, `model_aliases`) are merged recursively. This means you can set your preferred model and aliases globally and only override what's different per project.
278
+
279
+ **Example `~/.aru/config.json`:**
280
+
281
+ ```json
282
+ {
283
+ "default_model": "anthropic/claude-sonnet-4-6",
284
+ "model_aliases": {
285
+ "sonnet": "anthropic/claude-sonnet-4-6",
286
+ "opus": "anthropic/claude-opus-4-6"
287
+ },
288
+ "permission": {
289
+ "read": "allow",
290
+ "glob": "allow",
291
+ "grep": "allow"
292
+ }
293
+ }
294
+ ```
295
+
296
+ Then a project `aru.json` only needs project-specific settings:
297
+
298
+ ```json
299
+ {
300
+ "default_model": "ollama/codellama",
301
+ "permission": {
302
+ "bash": { "pytest *": "allow" }
303
+ }
304
+ }
305
+ ```
306
+
307
+ The result: `default_model` becomes `ollama/codellama`, `model_aliases` come from global, and `permission` merges both levels (`read`, `glob`, `grep` from global + `bash` from project).
308
+
270
309
  > A full `aru.json` config reference here: [`aru.json`](./aru.json)
271
310
 
272
311
  ### AGENTS.md
@@ -0,0 +1 @@
1
+ __version__ = "0.20.1"
@@ -21,6 +21,12 @@ Examples of ideal responses:
21
21
  - user: "what command lists files?" → assistant: "ls"
22
22
  - user: "fix the typo in line 5" → [call edit_file immediately, no narration]
23
23
 
24
+ ## Permission denials — CRITICAL
25
+
26
+ When a tool returns "PERMISSION DENIED", the user intentionally refused the action. \
27
+ NEVER retry the same operation. Do NOT try alternative approaches to achieve the same edit. \
28
+ Instead, stop immediately and ask the user what they would like you to do instead.
29
+
24
30
  ## Scope rules
25
31
 
26
32
  NEVER create documentation files (*.md) unless the user explicitly asks for them.
@@ -208,6 +208,38 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
208
208
  paste_state = PasteState()
209
209
  prompt_session = _create_prompt_session(paste_state, config)
210
210
 
211
+ # Load custom tools (synchronous — fast, no network)
212
+ from aru.plugins.custom_tools import discover_custom_tools, register_custom_tools
213
+ _disabled_tools = config.disabled_tools if hasattr(config, "disabled_tools") else []
214
+ _custom_tool_descs = discover_custom_tools(disabled=_disabled_tools)
215
+ if _custom_tool_descs:
216
+ _ct_count = register_custom_tools(_custom_tool_descs)
217
+ console.print(f"[dim]Loaded {_ct_count} custom tool(s): {', '.join(d['name'] for d in _custom_tool_descs)}[/dim]")
218
+
219
+ # Load plugins (local imports only, no network)
220
+ from aru.plugins.manager import PluginManager
221
+ from aru.plugins.hooks import PluginInput
222
+ _plugin_mgr = PluginManager()
223
+ ctx.plugin_manager = _plugin_mgr
224
+
225
+ try:
226
+ _p_input = PluginInput(
227
+ directory=os.getcwd(),
228
+ config_path="aru.json" if os.path.isfile("aru.json") else "",
229
+ model_ref=session.model_ref,
230
+ )
231
+ _plugin_specs = config.plugin_specs if hasattr(config, "plugin_specs") else []
232
+ _plugin_count = await _plugin_mgr.load_all(_p_input, plugin_specs=_plugin_specs)
233
+ if _plugin_count:
234
+ plugin_tools = _plugin_mgr.get_plugin_tools()
235
+ if plugin_tools:
236
+ _pt_count = register_custom_tools(plugin_tools)
237
+ console.print(f"[dim]Loaded {_plugin_count} plugin(s): {', '.join(_plugin_mgr.plugin_names)} ({_pt_count} tool(s))[/dim]")
238
+ else:
239
+ console.print(f"[dim]Loaded {_plugin_count} plugin(s): {', '.join(_plugin_mgr.plugin_names)}[/dim]")
240
+ except Exception as exc:
241
+ console.print(f"[dim yellow]Warning: plugin loading failed: {exc}[/dim yellow]")
242
+
211
243
  # Startup: load MCP tools in background (don't block REPL)
212
244
  async def _load_mcp_background():
213
245
  from aru.tools.codebase import load_mcp_tools
@@ -161,6 +161,8 @@ class AgentConfig:
161
161
  custom_agents: dict[str, CustomAgent] = field(default_factory=dict)
162
162
  plan_reviewer: bool = True
163
163
  tree_depth: int = 2 # max depth for directory tree in system prompt
164
+ disabled_tools: list[str] = field(default_factory=list) # tools to skip loading
165
+ plugin_specs: list = field(default_factory=list) # plugin specs from aru.json
164
166
 
165
167
  @property
166
168
  def has_instructions(self) -> bool:
@@ -434,6 +436,63 @@ def _discover_agents(search_roots: list[Path]) -> dict[str, CustomAgent]:
434
436
  return agents
435
437
 
436
438
 
439
+ def _load_json_file(path: Path) -> dict | None:
440
+ """Read and parse a JSON file, returning None on any error."""
441
+ if not path.is_file():
442
+ return None
443
+ try:
444
+ content = path.read_text(encoding="utf-8")
445
+ data = json.loads(content)
446
+ return data if isinstance(data, dict) else None
447
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError):
448
+ return None
449
+
450
+
451
+ def _deep_merge(base: dict, override: dict) -> dict:
452
+ """Recursively merge override into base. Override values win for scalars;
453
+ dicts are merged recursively; lists are replaced (not concatenated)."""
454
+ result = base.copy()
455
+ for key, value in override.items():
456
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
457
+ result[key] = _deep_merge(result[key], value)
458
+ else:
459
+ result[key] = value
460
+ return result
461
+
462
+
463
+ def _apply_config_data(config: AgentConfig, data: dict, root: Path) -> None:
464
+ """Apply a merged config dict to an AgentConfig object."""
465
+ if "permission" in data:
466
+ config.permissions = data["permission"]
467
+ if "providers" in data:
468
+ from aru.providers import load_providers_from_config
469
+ load_providers_from_config(data)
470
+ if "default_model" in data:
471
+ config.default_model = data["default_model"]
472
+ if "model_aliases" in data and isinstance(data["model_aliases"], dict):
473
+ config.model_aliases = data["model_aliases"]
474
+ if "plan_reviewer" in data:
475
+ config.plan_reviewer = bool(data["plan_reviewer"])
476
+ if "tree_depth" in data:
477
+ td = data["tree_depth"]
478
+ if isinstance(td, int) and 0 <= td <= 5:
479
+ config.tree_depth = td
480
+ if "plugins" in data and isinstance(data["plugins"], list):
481
+ config.plugin_specs = data["plugins"]
482
+ if "tools" in data and isinstance(data["tools"], dict):
483
+ tools_cfg = data["tools"]
484
+ if "disabled" in tools_cfg and isinstance(tools_cfg["disabled"], list):
485
+ config.disabled_tools = [str(t) for t in tools_cfg["disabled"]]
486
+ if "instructions" in data and isinstance(data["instructions"], list):
487
+ entries = [str(e) for e in data["instructions"] if isinstance(e, str)]
488
+ config.rules_instructions = _resolve_instructions(entries, root)
489
+ if "agent" in data and isinstance(data["agent"], dict):
490
+ for agent_name, agent_data in data["agent"].items():
491
+ if agent_name in config.custom_agents and isinstance(agent_data, dict):
492
+ if "permission" in agent_data:
493
+ config.custom_agents[agent_name].permission = agent_data["permission"]
494
+
495
+
437
496
  def load_config(cwd: str | None = None) -> AgentConfig:
438
497
  """Load agent configuration from AGENTS.md and .agents/ directory.
439
498
 
@@ -490,44 +549,27 @@ def load_config(cwd: str | None = None) -> AgentConfig:
490
549
  config.skills = _discover_skills(skill_roots)
491
550
  config.custom_agents = _discover_agents(skill_roots)
492
551
 
493
- # Load opencode-style config (aru.json or .aru/config.json)
494
- config_paths = [root / "aru.json", root / ".aru" / "config.json"]
495
- for config_path in config_paths:
496
- if config_path.is_file():
497
- try:
498
- content = config_path.read_text(encoding="utf-8")
499
- data = json.loads(content)
500
- if isinstance(data, dict):
501
- if "permission" in data:
502
- config.permissions = data["permission"]
503
- # Load provider configuration
504
- if "providers" in data:
505
- from aru.providers import load_providers_from_config
506
- load_providers_from_config(data)
507
- # Store default model and aliases for CLI
508
- if "default_model" in data:
509
- config.default_model = data["default_model"]
510
- if "model_aliases" in data and isinstance(data["model_aliases"], dict):
511
- config.model_aliases = data["model_aliases"]
512
- if "plan_reviewer" in data:
513
- config.plan_reviewer = bool(data["plan_reviewer"])
514
- if "tree_depth" in data:
515
- td = data["tree_depth"]
516
- if isinstance(td, int) and 0 <= td <= 5:
517
- config.tree_depth = td
518
- # Resolve instructions (local files, globs, URLs)
519
- if "instructions" in data and isinstance(data["instructions"], list):
520
- entries = [str(e) for e in data["instructions"] if isinstance(e, str)]
521
- config.rules_instructions = _resolve_instructions(entries, root)
522
- # Agent-level permission overrides from aru.json
523
- if "agent" in data and isinstance(data["agent"], dict):
524
- for agent_name, agent_data in data["agent"].items():
525
- if agent_name in config.custom_agents and isinstance(agent_data, dict):
526
- if "permission" in agent_data:
527
- config.custom_agents[agent_name].permission = agent_data["permission"]
528
- break
529
- except (OSError, UnicodeDecodeError, json.JSONDecodeError):
530
- pass
552
+ # Load config: global (~/.aru/config.json) first, then project-level on top.
553
+ # Project values override global values via deep merge.
554
+ home = Path.home()
555
+ global_config_paths = [home / ".aru" / "config.json"]
556
+ project_config_paths = [root / "aru.json", root / ".aru" / "config.json"]
557
+
558
+ merged_data: dict = {}
559
+ for config_path in global_config_paths:
560
+ data = _load_json_file(config_path)
561
+ if data is not None:
562
+ merged_data = data
563
+ break
564
+
565
+ for config_path in project_config_paths:
566
+ data = _load_json_file(config_path)
567
+ if data is not None:
568
+ merged_data = _deep_merge(merged_data, data)
569
+ break
570
+
571
+ if merged_data:
572
+ _apply_config_data(config, merged_data, root)
531
573
 
532
574
  return config
533
575
 
@@ -420,34 +420,52 @@ def truncate_output(
420
420
  # Save full output to disk before truncating (like OpenCode)
421
421
  saved_path = _save_truncated_output(text)
422
422
 
423
- # Truncate by lines
423
+ # Truncate by lines — keep head + tail so summaries at the end are visible
424
424
  if line_count > TRUNCATE_MAX_LINES:
425
425
  head = lines[:TRUNCATE_KEEP_START]
426
- omitted = line_count - TRUNCATE_KEEP_START
426
+ tail = lines[-TRUNCATE_KEEP_END:]
427
+ omitted = line_count - TRUNCATE_KEEP_START - TRUNCATE_KEEP_END
427
428
  hint = _build_truncation_hint(source_file, source_tool, TRUNCATE_KEEP_START, saved_path)
428
429
  return (
429
430
  "".join(head)
430
431
  + f"\n\n[... {omitted:,} lines omitted ({line_count:,} total)]\n"
431
- + hint + "\n"
432
+ + hint + "\n\n"
433
+ + "".join(tail)
432
434
  )
433
435
 
434
436
  # Truncate by bytes (lines fit but total bytes too large)
435
- kept_lines: list[str] = []
436
- total = 0
437
+ # Reserve ~20% of budget for tail lines
438
+ head_budget = int(TRUNCATE_MAX_BYTES * 0.75)
439
+ tail_budget = TRUNCATE_MAX_BYTES - head_budget
440
+
441
+ head_lines: list[str] = []
442
+ head_bytes = 0
437
443
  for line in lines:
438
444
  line_bytes = len(line.encode("utf-8", errors="replace"))
439
- if total + line_bytes > TRUNCATE_MAX_BYTES:
445
+ if head_bytes + line_bytes > head_budget:
440
446
  break
441
- kept_lines.append(line)
442
- total += line_bytes
447
+ head_lines.append(line)
448
+ head_bytes += line_bytes
443
449
 
444
- remaining = line_count - len(kept_lines)
445
- hint = _build_truncation_hint(source_file, source_tool, len(kept_lines), saved_path)
450
+ # Collect tail lines within tail budget
451
+ tail_lines: list[str] = []
452
+ tail_bytes = 0
453
+ for line in reversed(lines[len(head_lines):]):
454
+ line_bytes = len(line.encode("utf-8", errors="replace"))
455
+ if tail_bytes + line_bytes > tail_budget:
456
+ break
457
+ tail_lines.append(line)
458
+ tail_bytes += line_bytes
459
+ tail_lines.reverse()
460
+
461
+ omitted = line_count - len(head_lines) - len(tail_lines)
462
+ hint = _build_truncation_hint(source_file, source_tool, len(head_lines), saved_path)
446
463
  return (
447
- "".join(kept_lines)
464
+ "".join(head_lines)
448
465
  + f"\n\n[... truncated at ~{TRUNCATE_MAX_BYTES // 1024}KB — "
449
- f"{remaining:,} more lines]\n"
450
- + hint + "\n"
466
+ f"{omitted:,} lines omitted]\n"
467
+ + hint + "\n\n"
468
+ + "".join(tail_lines)
451
469
  )
452
470
 
453
471
 
@@ -660,6 +678,17 @@ async def compact_conversation(
660
678
  """
661
679
  from aru.providers import create_model
662
680
 
681
+ # Fire session.compact hook — plugins can pre-process history.
682
+ # Import is lazy here to avoid circular dependency (context ← runtime).
683
+ try:
684
+ from aru.runtime import get_ctx # noqa: lazy to avoid circular dep
685
+ mgr = get_ctx().plugin_manager
686
+ if mgr is not None and mgr.loaded:
687
+ event = await mgr.fire("session.compact", {"history": history})
688
+ history = event.data.get("history", history)
689
+ except (LookupError, AttributeError, ImportError):
690
+ pass # no plugin manager available — proceed without hooks
691
+
663
692
  prompt = build_compaction_prompt(history, plan_task, model_id=model_id)
664
693
 
665
694
  try:
@@ -413,6 +413,42 @@ def resolve_permission(
413
413
  # Permission gate (user-facing prompt)
414
414
  # ---------------------------------------------------------------------------
415
415
 
416
+ def _fire_permission_hook(mgr, category: str, subject: str) -> bool | None:
417
+ """Fire permission.ask hook through all plugin handlers.
418
+
419
+ Supports both sync and async handlers. Returns True/False if a handler
420
+ sets event.data["allow"], or None if no handler overrode the decision.
421
+ """
422
+ import asyncio
423
+ from aru.plugins.hooks import HookEvent
424
+
425
+ evt = HookEvent(hook="permission.ask", data={"category": category, "subject": subject})
426
+
427
+ for hooks_obj in mgr._hooks:
428
+ for handler in hooks_obj.get_handlers("permission.ask"):
429
+ try:
430
+ if asyncio.iscoroutinefunction(handler):
431
+ # Async handler — run via the event loop
432
+ loop = asyncio.get_event_loop()
433
+ if loop.is_running():
434
+ # Schedule as a task and wait with run_until_complete
435
+ # won't work, so use a new loop in a thread
436
+ import concurrent.futures
437
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
438
+ pool.submit(asyncio.run, handler(evt)).result(timeout=5)
439
+ else:
440
+ loop.run_until_complete(handler(evt))
441
+ else:
442
+ handler(evt)
443
+ except Exception:
444
+ continue # skip broken handlers
445
+
446
+ if "allow" in evt.data:
447
+ return bool(evt.data["allow"])
448
+
449
+ return None # no handler overrode
450
+
451
+
416
452
  def check_permission(
417
453
  category: str,
418
454
  subject: str,
@@ -429,8 +465,20 @@ def check_permission(
429
465
  if action == "deny":
430
466
  return False
431
467
 
432
- # action == "ask" -> prompt user
468
+ # Fire permission.ask hook plugins can override the decision.
469
+ # check_permission runs in a sync context (called from tool threads),
470
+ # so we fire sync handlers directly and async handlers via the event loop.
433
471
  ctx = get_ctx()
472
+ mgr = getattr(ctx, "plugin_manager", None)
473
+ if mgr is not None and getattr(mgr, "loaded", False):
474
+ try:
475
+ override = _fire_permission_hook(mgr, category, subject)
476
+ if override is not None:
477
+ return override
478
+ except Exception:
479
+ pass # never let plugin errors block permissions
480
+
481
+ # action == "ask" -> prompt user
434
482
  with ctx.permission_lock:
435
483
  # Re-check after acquiring lock (another thread may have resolved it)
436
484
  action2, pattern2 = resolve_permission(category, subject)
@@ -0,0 +1,12 @@
1
+ """Aru plugin system — custom tools, hooks, and OpenCode TS bridge.
2
+
3
+ Public API for plugin authors:
4
+
5
+ from aru.plugins import tool # @tool decorator for custom tools
6
+ from aru.plugins import PluginInput, Hooks # Full plugin API (Phase 2)
7
+ """
8
+
9
+ from aru.plugins.tool_api import tool
10
+ from aru.plugins.hooks import Hooks, HookEvent, PluginInput
11
+
12
+ __all__ = ["tool", "Hooks", "HookEvent", "PluginInput"]