aru-code 0.7.0__tar.gz → 0.9.0__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 (57) hide show
  1. {aru_code-0.7.0/aru_code.egg-info → aru_code-0.9.0}/PKG-INFO +26 -1
  2. {aru_code-0.7.0 → aru_code-0.9.0}/README.md +25 -0
  3. aru_code-0.9.0/aru/__init__.py +1 -0
  4. {aru_code-0.7.0 → aru_code-0.9.0}/aru/agent_factory.py +6 -4
  5. {aru_code-0.7.0 → aru_code-0.9.0}/aru/agents/executor.py +3 -2
  6. {aru_code-0.7.0 → aru_code-0.9.0}/aru/agents/planner.py +3 -3
  7. {aru_code-0.7.0 → aru_code-0.9.0}/aru/cli.py +15 -19
  8. {aru_code-0.7.0 → aru_code-0.9.0}/aru/config.py +97 -1
  9. {aru_code-0.7.0 → aru_code-0.9.0}/aru/context.py +2 -2
  10. {aru_code-0.7.0 → aru_code-0.9.0}/aru/permissions.py +39 -73
  11. {aru_code-0.7.0 → aru_code-0.9.0}/aru/runner.py +12 -21
  12. aru_code-0.9.0/aru/runtime.py +158 -0
  13. {aru_code-0.7.0 → aru_code-0.9.0}/aru/tools/codebase.py +140 -195
  14. {aru_code-0.7.0 → aru_code-0.9.0}/aru/tools/tasklist.py +20 -75
  15. {aru_code-0.7.0 → aru_code-0.9.0/aru_code.egg-info}/PKG-INFO +26 -1
  16. {aru_code-0.7.0 → aru_code-0.9.0}/aru_code.egg-info/SOURCES.txt +1 -0
  17. {aru_code-0.7.0 → aru_code-0.9.0}/pyproject.toml +1 -1
  18. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_codebase.py +16 -16
  19. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_config.py +153 -1
  20. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_permissions.py +26 -20
  21. aru_code-0.7.0/aru/__init__.py +0 -1
  22. {aru_code-0.7.0 → aru_code-0.9.0}/LICENSE +0 -0
  23. {aru_code-0.7.0 → aru_code-0.9.0}/aru/agents/__init__.py +0 -0
  24. {aru_code-0.7.0 → aru_code-0.9.0}/aru/agents/base.py +0 -0
  25. {aru_code-0.7.0 → aru_code-0.9.0}/aru/commands.py +0 -0
  26. {aru_code-0.7.0 → aru_code-0.9.0}/aru/completers.py +0 -0
  27. {aru_code-0.7.0 → aru_code-0.9.0}/aru/display.py +0 -0
  28. {aru_code-0.7.0 → aru_code-0.9.0}/aru/providers.py +0 -0
  29. {aru_code-0.7.0 → aru_code-0.9.0}/aru/session.py +0 -0
  30. {aru_code-0.7.0 → aru_code-0.9.0}/aru/tools/__init__.py +0 -0
  31. {aru_code-0.7.0 → aru_code-0.9.0}/aru/tools/ast_tools.py +0 -0
  32. {aru_code-0.7.0 → aru_code-0.9.0}/aru/tools/gitignore.py +0 -0
  33. {aru_code-0.7.0 → aru_code-0.9.0}/aru/tools/mcp_client.py +0 -0
  34. {aru_code-0.7.0 → aru_code-0.9.0}/aru/tools/ranker.py +0 -0
  35. {aru_code-0.7.0 → aru_code-0.9.0}/aru_code.egg-info/dependency_links.txt +0 -0
  36. {aru_code-0.7.0 → aru_code-0.9.0}/aru_code.egg-info/entry_points.txt +0 -0
  37. {aru_code-0.7.0 → aru_code-0.9.0}/aru_code.egg-info/requires.txt +0 -0
  38. {aru_code-0.7.0 → aru_code-0.9.0}/aru_code.egg-info/top_level.txt +0 -0
  39. {aru_code-0.7.0 → aru_code-0.9.0}/setup.cfg +0 -0
  40. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_agents_base.py +0 -0
  41. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_ast_tools.py +0 -0
  42. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_cli.py +0 -0
  43. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_cli_advanced.py +0 -0
  44. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_cli_base.py +0 -0
  45. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_cli_completers.py +0 -0
  46. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_cli_new.py +0 -0
  47. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_cli_run_cli.py +0 -0
  48. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_cli_session.py +0 -0
  49. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_cli_shell.py +0 -0
  50. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_context.py +0 -0
  51. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_executor.py +0 -0
  52. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_gitignore.py +0 -0
  53. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_main.py +0 -0
  54. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_mcp_client.py +0 -0
  55. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_planner.py +0 -0
  56. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_providers.py +0 -0
  57. {aru_code-0.7.0 → aru_code-0.9.0}/tests/test_ranker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aru-code
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: A Claude Code clone built with Agno agents
5
5
  Author-email: Estevao <estevaofon@gmail.com>
6
6
  License-Expression: MIT
@@ -302,6 +302,31 @@ Without any `aru.json` config, aru applies safe defaults:
302
302
 
303
303
  Place an `AGENTS.md` file in your project root with custom instructions that will be appended to all agent system prompts.
304
304
 
305
+ ### Instructions (Rules)
306
+
307
+ You can load additional instructions from local files, glob patterns, or remote URLs via the `instructions` field in `aru.json`:
308
+
309
+ ```json
310
+ {
311
+ "instructions": [
312
+ "CONTRIBUTING.md",
313
+ "docs/coding-standards.md",
314
+ "packages/*/AGENTS.md",
315
+ "https://raw.githubusercontent.com/my-org/shared-rules/main/style.md"
316
+ ]
317
+ }
318
+ ```
319
+
320
+ Each entry is resolved as follows:
321
+
322
+ | Format | Example | Behavior |
323
+ |--------|---------|----------|
324
+ | **Local file** | `"CONTRIBUTING.md"` | Reads the file relative to the project root |
325
+ | **Glob pattern** | `"docs/**/*.md"` | Expands the pattern, respects `.gitignore` |
326
+ | **Remote URL** | `"https://example.com/rules.md"` | Fetches via HTTP (5s timeout, cached per session) |
327
+
328
+ All resolved content is combined and appended to the agent's system prompt alongside `AGENTS.md`. Individual files are capped at 10KB, and the total combined size is capped at 50KB to prevent context bloat. Missing files and failed URL fetches are skipped with a warning.
329
+
305
330
  ### `.agents/` Directory
306
331
 
307
332
  ```
@@ -255,6 +255,31 @@ Without any `aru.json` config, aru applies safe defaults:
255
255
 
256
256
  Place an `AGENTS.md` file in your project root with custom instructions that will be appended to all agent system prompts.
257
257
 
258
+ ### Instructions (Rules)
259
+
260
+ You can load additional instructions from local files, glob patterns, or remote URLs via the `instructions` field in `aru.json`:
261
+
262
+ ```json
263
+ {
264
+ "instructions": [
265
+ "CONTRIBUTING.md",
266
+ "docs/coding-standards.md",
267
+ "packages/*/AGENTS.md",
268
+ "https://raw.githubusercontent.com/my-org/shared-rules/main/style.md"
269
+ ]
270
+ }
271
+ ```
272
+
273
+ Each entry is resolved as follows:
274
+
275
+ | Format | Example | Behavior |
276
+ |--------|---------|----------|
277
+ | **Local file** | `"CONTRIBUTING.md"` | Reads the file relative to the project root |
278
+ | **Glob pattern** | `"docs/**/*.md"` | Expands the pattern, respects `.gitignore` |
279
+ | **Remote URL** | `"https://example.com/rules.md"` | Fetches via HTTP (5s timeout, cached per session) |
280
+
281
+ All resolved content is combined and appended to the agent's system prompt alongside `AGENTS.md`. Individual files are capped at 10KB, and the total combined size is capped at 50KB to prevent context bloat. Missing files and failed URL fetches are skipped with a warning.
282
+
258
283
  ### `.agents/` Directory
259
284
 
260
285
  ```
@@ -0,0 +1 @@
1
+ __version__ = "0.9.0"
@@ -13,7 +13,8 @@ def create_general_agent(session: Session, config: AgentConfig | None = None):
13
13
  from agno.agent import Agent
14
14
  from agno.compression.manager import CompressionManager
15
15
 
16
- from aru.tools.codebase import GENERAL_TOOLS, _get_small_model_ref
16
+ from aru.tools.codebase import GENERAL_TOOLS
17
+ from aru.runtime import get_ctx
17
18
 
18
19
  extra = config.get_extra_instructions() if config else ""
19
20
 
@@ -25,7 +26,7 @@ def create_general_agent(session: Session, config: AgentConfig | None = None):
25
26
  markdown=True,
26
27
  compress_tool_results=True,
27
28
  compression_manager=CompressionManager(
28
- model=create_model(_get_small_model_ref(), max_tokens=1024),
29
+ model=create_model(get_ctx().small_model_ref, max_tokens=1024),
29
30
  compress_tool_results=True,
30
31
  compress_tool_results_limit=15,
31
32
  ),
@@ -39,7 +40,8 @@ def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
39
40
  from agno.agent import Agent
40
41
  from agno.compression.manager import CompressionManager
41
42
  from aru.agents.base import BASE_INSTRUCTIONS
42
- from aru.tools.codebase import resolve_tools, _get_small_model_ref
43
+ from aru.tools.codebase import resolve_tools
44
+ from aru.runtime import get_ctx
43
45
 
44
46
  model_ref = agent_def.model or session.model_ref
45
47
  tools = resolve_tools(agent_def.tools)
@@ -58,7 +60,7 @@ def create_custom_agent_instance(agent_def: CustomAgent, session: Session,
58
60
  markdown=True,
59
61
  compress_tool_results=True,
60
62
  compression_manager=CompressionManager(
61
- model=create_model(_get_small_model_ref(), max_tokens=1024),
63
+ model=create_model(get_ctx().small_model_ref, max_tokens=1024),
62
64
  compress_tool_results=True,
63
65
  compress_tool_results_limit=15,
64
66
  ),
@@ -6,7 +6,8 @@ from agno.utils.log import log_warning
6
6
 
7
7
  from aru.agents.base import build_instructions
8
8
  from aru.providers import create_model
9
- from aru.tools.codebase import EXECUTOR_TOOLS, _get_small_model_ref
9
+ from aru.tools.codebase import EXECUTOR_TOOLS
10
+ from aru.runtime import get_ctx
10
11
 
11
12
  # Max chars for truncation fallback when compression fails
12
13
  _TRUNCATE_FALLBACK = 3000
@@ -50,7 +51,7 @@ def create_executor(model_ref: str = "anthropic/claude-sonnet-4-5", extra_instru
50
51
  # Compress tool results after 5 uncompressed tool calls to save tokens
51
52
  compress_tool_results=True,
52
53
  compression_manager=_SafeCompressionManager(
53
- model=create_model(_get_small_model_ref(), max_tokens=2048),
54
+ model=create_model(get_ctx().small_model_ref, max_tokens=2048),
54
55
  compress_tool_results=True,
55
56
  compress_tool_results_limit=15,
56
57
  ),
@@ -6,9 +6,9 @@ from agno.compression.manager import CompressionManager
6
6
  from aru.agents.base import build_instructions
7
7
  from aru.providers import create_model
8
8
  from aru.tools.codebase import (
9
- _get_small_model_ref,
10
9
  glob_search, grep_search, list_directory, read_file, read_file_smart,
11
10
  )
11
+ from aru.runtime import get_ctx
12
12
 
13
13
  REVIEWER_INSTRUCTIONS = """\
14
14
  You are a plan scope reviewer. You receive a user request and a generated implementation plan.
@@ -47,7 +47,7 @@ async def review_plan(request: str, plan: str) -> str:
47
47
  """
48
48
  reviewer = Agent(
49
49
  name="Reviewer",
50
- model=create_model(_get_small_model_ref(), max_tokens=2048),
50
+ model=create_model(get_ctx().small_model_ref, max_tokens=2048),
51
51
  instructions=REVIEWER_INSTRUCTIONS,
52
52
  markdown=True,
53
53
  )
@@ -77,7 +77,7 @@ def create_planner(model_ref: str = "anthropic/claude-sonnet-4-5", extra_instruc
77
77
  # Compress tool results after 6 uncompressed tool calls to save tokens
78
78
  compress_tool_results=True,
79
79
  compression_manager=CompressionManager(
80
- model=create_model(_get_small_model_ref(), max_tokens=1024),
80
+ model=create_model(get_ctx().small_model_ref, max_tokens=1024),
81
81
  compress_tool_results=True,
82
82
  compress_tool_results_limit=15,
83
83
  ),
@@ -104,24 +104,18 @@ from aru.providers import (
104
104
 
105
105
  async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
106
106
  """Main REPL loop."""
107
- from aru.tools.codebase import set_model_id, set_small_model_ref, set_on_file_mutation
108
- from aru.permissions import (
109
- set_config as set_perm_config,
110
- set_skip_permissions,
111
- set_console as perm_set_console,
112
- reset_session as perm_reset_session,
113
- parse_permission_config,
114
- )
115
- from aru.tools.codebase import set_console
116
- set_console(console)
117
- perm_set_console(console)
118
- set_skip_permissions(skip_permissions)
107
+ import atexit
108
+ from aru.runtime import init_ctx, get_ctx
109
+ from aru.permissions import parse_permission_config, reset_session as perm_reset_session
110
+ from aru.tools.codebase import cleanup_processes
111
+
112
+ ctx = init_ctx(console=console, skip_permissions=skip_permissions)
119
113
 
120
114
  store = SessionStore()
121
115
 
122
116
  def _sync_model(sess: Session):
123
- """Sync the model IDs to the tools module from the session's model_ref."""
124
- set_model_id(sess.model_id)
117
+ """Sync the model IDs to the RuntimeContext from the session's model_ref."""
118
+ ctx.model_id = sess.model_id
125
119
  small_ref = config.model_aliases.get("small") if config else None
126
120
  if not small_ref:
127
121
  provider_key, _ = resolve_model_ref(sess.model_ref)
@@ -133,7 +127,7 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
133
127
  "ollama": "ollama/llama3.1",
134
128
  }
135
129
  small_ref = _small_defaults.get(provider_key, sess.model_ref)
136
- set_small_model_ref(small_ref)
130
+ ctx.small_model_ref = small_ref
137
131
 
138
132
  # Load project configuration
139
133
  config = load_config()
@@ -143,6 +137,8 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
143
137
  console.print(f"[dim]Loaded {len(config.commands)} custom command(s): {', '.join(f'/{k}' for k in config.commands)}[/dim]")
144
138
  if config.skills:
145
139
  console.print(f"[dim]Loaded {len(config.skills)} skill(s): {', '.join(config.skills.keys())}[/dim]")
140
+ if config.rules_instructions:
141
+ console.print("[dim]Loaded custom instructions from aru.json[/dim]")
146
142
  if config.custom_agents:
147
143
  primary = [k for k, v in config.custom_agents.items() if v.mode == "primary"]
148
144
  subagents = [k for k, v in config.custom_agents.items() if v.mode == "subagent"]
@@ -155,8 +151,7 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
155
151
  from aru.tools.codebase import set_custom_agents
156
152
  set_custom_agents(config.custom_agents)
157
153
  if config.permissions:
158
- perm_config = parse_permission_config(config.permissions)
159
- set_perm_config(perm_config)
154
+ ctx.perm_config = parse_permission_config(config.permissions)
160
155
  console.print("[dim]Loaded permission config[/dim]")
161
156
 
162
157
  extra_instructions = config.get_extra_instructions()
@@ -188,8 +183,9 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
188
183
  _sync_model(session)
189
184
  _render_home(session, skip_permissions)
190
185
 
191
- # Wire file-mutation callback
192
- set_on_file_mutation(session.invalidate_context_cache)
186
+ # Wire file-mutation callback and atexit cleanup
187
+ ctx.on_file_mutation = session.invalidate_context_cache
188
+ atexit.register(lambda: cleanup_processes(ctx.tracked_processes))
193
189
 
194
190
  planner = None
195
191
  executor = None
@@ -10,12 +10,15 @@ Supports:
10
10
  from __future__ import annotations
11
11
 
12
12
  import json
13
+ import logging
13
14
  import os
14
15
  import re
15
16
  from dataclasses import dataclass, field
16
17
  from pathlib import Path
17
18
  from typing import Any
18
19
 
20
+ logger = logging.getLogger("aru.config")
21
+
19
22
 
20
23
  @dataclass
21
24
  class CustomCommand:
@@ -54,6 +57,92 @@ class CustomAgent:
54
57
 
55
58
 
56
59
  MAX_README_CHARS = 2000 # Reduced from 8000 to save ~1.7K tokens per request
60
+ MAX_RULE_FILE_SIZE = 10_000 # 10KB per rule file
61
+ URL_FETCH_TIMEOUT = 5 # seconds
62
+ MAX_TOTAL_RULES_SIZE = 50_000 # 50KB combined cap
63
+
64
+ # Module-level URL cache (session-scoped, persists for process lifetime)
65
+ _url_cache: dict[str, str | None] = {}
66
+
67
+
68
+ def _resolve_instructions(entries: list[str], root: Path) -> str:
69
+ """Resolve instruction entries (local files, glob patterns, URLs) into combined text.
70
+
71
+ Each entry is classified as:
72
+ - URL: starts with http:// or https://
73
+ - Glob: contains *, ?, or [
74
+ - File path: everything else (resolved relative to root)
75
+ """
76
+ from aru.tools.gitignore import is_ignored
77
+
78
+ parts: list[str] = []
79
+ total_size = 0
80
+
81
+ def _add_content(source: str, content: str) -> None:
82
+ nonlocal total_size
83
+ if not content.strip():
84
+ return
85
+ truncated = content[:MAX_RULE_FILE_SIZE]
86
+ if total_size + len(truncated) > MAX_TOTAL_RULES_SIZE:
87
+ remaining = MAX_TOTAL_RULES_SIZE - total_size
88
+ if remaining <= 0:
89
+ logger.warning("Total rules size cap reached, skipping: %s", source)
90
+ return
91
+ truncated = truncated[:remaining]
92
+ logger.warning("Total rules size cap reached, truncating: %s", source)
93
+ parts.append(f"## Rules: {source}\n\n{truncated}")
94
+ total_size += len(truncated)
95
+
96
+ def _read_file(filepath: Path, source_label: str) -> None:
97
+ try:
98
+ content = filepath.read_text(encoding="utf-8")
99
+ _add_content(source_label, content)
100
+ except (OSError, UnicodeDecodeError) as exc:
101
+ logger.warning("Failed to read instruction file %s: %s", filepath, exc)
102
+
103
+ for entry in entries:
104
+ if entry.startswith("http://") or entry.startswith("https://"):
105
+ # Remote URL
106
+ if entry in _url_cache:
107
+ cached = _url_cache[entry]
108
+ if cached is not None:
109
+ _add_content(entry, cached)
110
+ continue
111
+ try:
112
+ import httpx
113
+ with httpx.Client(timeout=URL_FETCH_TIMEOUT, follow_redirects=True) as client:
114
+ resp = client.get(entry)
115
+ resp.raise_for_status()
116
+ text = resp.text
117
+ _url_cache[entry] = text
118
+ _add_content(entry, text)
119
+ except Exception as exc:
120
+ _url_cache[entry] = None
121
+ logger.warning("Failed to fetch instruction URL %s: %s", entry, exc)
122
+
123
+ elif any(c in entry for c in ("*", "?", "[")):
124
+ # Glob pattern
125
+ matched = sorted(root.glob(entry))
126
+ for filepath in matched:
127
+ if not filepath.is_file():
128
+ continue
129
+ try:
130
+ rel = filepath.relative_to(root)
131
+ except ValueError:
132
+ continue
133
+ if is_ignored(str(rel), str(root)):
134
+ continue
135
+ _read_file(filepath, str(rel))
136
+
137
+ else:
138
+ # Local file path
139
+ filepath = root / entry
140
+ if filepath.is_file():
141
+ _read_file(filepath, entry)
142
+ else:
143
+ logger.warning("Instruction file not found: %s", filepath)
144
+
145
+ return "\n\n".join(parts)
57
146
 
58
147
 
59
148
  @dataclass
@@ -61,6 +150,7 @@ class AgentConfig:
61
150
  """Loaded configuration from AGENTS.md, README.md, and .agents/ directory."""
62
151
  readme_md: str = ""
63
152
  agents_md: str = ""
153
+ rules_instructions: str = ""
64
154
  commands: dict[str, CustomCommand] = field(default_factory=dict)
65
155
  skills: dict[str, Skill] = field(default_factory=dict)
66
156
  permissions: dict[str, Any] = field(default_factory=dict)
@@ -71,7 +161,7 @@ class AgentConfig:
71
161
 
72
162
  @property
73
163
  def has_instructions(self) -> bool:
74
- return bool(self.agents_md) or bool(self.skills)
164
+ return bool(self.agents_md) or bool(self.skills) or bool(self.rules_instructions)
75
165
 
76
166
  def get_extra_instructions(self, active_skills: list[str] | None = None, lightweight: bool = False) -> str:
77
167
  """Build extra instructions from README.md, AGENTS.md, and active skills.
@@ -85,6 +175,8 @@ class AgentConfig:
85
175
  parts.append(f"## Project Overview (README.md)\n\n{self.readme_md}")
86
176
  if self.agents_md:
87
177
  parts.append(f"## Project Instructions (AGENTS.md)\n\n{self.agents_md}")
178
+ if self.rules_instructions:
179
+ parts.append(self.rules_instructions)
88
180
  if active_skills:
89
181
  for name in active_skills:
90
182
  if name in self.skills:
@@ -409,6 +501,10 @@ def load_config(cwd: str | None = None) -> AgentConfig:
409
501
  config.model_aliases = data["model_aliases"]
410
502
  if "plan_reviewer" in data:
411
503
  config.plan_reviewer = bool(data["plan_reviewer"])
504
+ # Resolve instructions (local files, globs, URLs)
505
+ if "instructions" in data and isinstance(data["instructions"], list):
506
+ entries = [str(e) for e in data["instructions"] if isinstance(e, str)]
507
+ config.rules_instructions = _resolve_instructions(entries, root)
412
508
  # Agent-level permission overrides from aru.json
413
509
  if "agent" in data and isinstance(data["agent"], dict):
414
510
  for agent_name, agent_data in data["agent"].items():
@@ -204,7 +204,7 @@ async def compact_conversation(
204
204
  Uses a small/fast model for the summarization to minimize cost.
205
205
  Falls back to simple truncation if the agent call fails.
206
206
  """
207
- from aru.tools.codebase import _get_small_model_ref
207
+ from aru.runtime import get_ctx
208
208
  from aru.providers import create_model
209
209
 
210
210
  prompt = build_compaction_prompt(history, plan_task)
@@ -212,7 +212,7 @@ async def compact_conversation(
212
212
  try:
213
213
  from agno.agent import Agent
214
214
 
215
- small_ref = _get_small_model_ref()
215
+ small_ref = get_ctx().small_model_ref
216
216
  compactor = Agent(
217
217
  name="Compactor",
218
218
  model=create_model(small_ref, max_tokens=2048),
@@ -19,15 +19,16 @@ from __future__ import annotations
19
19
 
20
20
  import fnmatch
21
21
  import os
22
- import threading
23
22
  from contextlib import contextmanager
24
23
  from dataclasses import dataclass, field
25
24
  from typing import Any, Generator, Literal
26
25
 
27
- from rich.console import Console, Group
26
+ from rich.console import Group
28
27
  from rich.panel import Panel
29
28
  from rich.text import Text
30
29
 
30
+ from aru.runtime import get_ctx
31
+
31
32
  PermissionAction = Literal["allow", "ask", "deny"]
32
33
 
33
34
  VALID_ACTIONS: set[str] = {"allow", "ask", "deny"}
@@ -96,62 +97,24 @@ for _prefix in SAFE_COMMAND_PREFIXES:
96
97
 
97
98
 
98
99
  # ---------------------------------------------------------------------------
99
- # Module-level state
100
+ # Thin wrappers over RuntimeContext (preserve public API for callers)
100
101
  # ---------------------------------------------------------------------------
101
102
 
102
- _config: PermissionConfig = PermissionConfig()
103
- _session_allowed: set[tuple[str, str]] = set() # (category, pattern) approved via "always"
104
- _skip_permissions: bool = False
105
- _permission_lock = threading.Lock()
106
- _live = None
107
- _display = None
108
- _console = Console()
109
-
110
-
111
- # ---------------------------------------------------------------------------
112
- # Setters
113
- # ---------------------------------------------------------------------------
103
+ def set_config(config: PermissionConfig) -> None:
104
+ get_ctx().perm_config = config
114
105
 
115
- def set_config(config: PermissionConfig):
116
- global _config
117
- _config = config
118
106
 
119
-
120
- def set_skip_permissions(value: bool):
121
- global _skip_permissions
122
- _skip_permissions = value
107
+ def set_skip_permissions(value: bool) -> None:
108
+ get_ctx().skip_permissions = value
123
109
 
124
110
 
125
111
  def get_skip_permissions() -> bool:
126
- return _skip_permissions
127
-
128
-
129
- def set_live(live):
130
- global _live
131
- _live = live
132
-
112
+ return get_ctx().skip_permissions
133
113
 
134
- def set_display(display):
135
- global _display
136
- _display = display
137
114
 
138
-
139
- def set_console(console: Console):
140
- global _console
141
- _console = console
142
-
143
-
144
- def reset_session():
115
+ def reset_session() -> None:
145
116
  """Reset session-level permission state (call between conversations)."""
146
- _session_allowed.clear()
147
-
148
-
149
- # ---------------------------------------------------------------------------
150
- # Agent-level permission scoping
151
- # ---------------------------------------------------------------------------
152
-
153
- _config_stack: list[PermissionConfig] = []
154
- _session_stack: list[set[tuple[str, str]]] = []
117
+ get_ctx().session_allowed.clear()
155
118
 
156
119
 
157
120
  def merge_configs(base: PermissionConfig, overlay: PermissionConfig) -> PermissionConfig:
@@ -175,22 +138,22 @@ def permission_scope(overlay_raw: dict[str, Any] | None) -> Generator[None, None
175
138
  Each scope gets its own fresh "always" session memory, so agent approvals
176
139
  don't leak to the global scope or other agents.
177
140
  """
178
- global _config, _session_allowed
179
141
  if not overlay_raw:
180
142
  yield
181
143
  return
182
144
 
183
- _config_stack.append(_config)
184
- _session_stack.append(_session_allowed)
185
- _session_allowed = set()
145
+ ctx = get_ctx()
146
+ ctx.config_stack.append(ctx.perm_config)
147
+ ctx.session_stack.append(ctx.session_allowed)
148
+ ctx.session_allowed = set()
186
149
 
187
150
  overlay = parse_permission_config(overlay_raw)
188
- _config = merge_configs(_config, overlay)
151
+ ctx.perm_config = merge_configs(ctx.perm_config, overlay)
189
152
  try:
190
153
  yield
191
154
  finally:
192
- _config = _config_stack.pop()
193
- _session_allowed = _session_stack.pop()
155
+ ctx.perm_config = ctx.config_stack.pop()
156
+ ctx.session_allowed = ctx.session_stack.pop()
194
157
 
195
158
 
196
159
  # ---------------------------------------------------------------------------
@@ -304,8 +267,9 @@ def _build_rules(category: str) -> list[PermissionRule]:
304
267
  rules.extend(_SENSITIVE_FILE_RULES)
305
268
 
306
269
  # Add user-configured rules
307
- if category in _config.categories:
308
- rules.extend(_config.categories[category])
270
+ ctx = get_ctx()
271
+ if category in ctx.perm_config.categories:
272
+ rules.extend(ctx.perm_config.categories[category])
309
273
 
310
274
  return rules
311
275
 
@@ -382,7 +346,7 @@ def _resolve_bash_compound(command: str) -> tuple[PermissionAction, str]:
382
346
  def _resolve_bash_single(command: str) -> tuple[PermissionAction, str]:
383
347
  """Resolve permission for a single (non-compound) bash command."""
384
348
  rules = _build_rules("bash")
385
- result: PermissionAction = CATEGORY_DEFAULTS.get("bash", _config.default)
349
+ result: PermissionAction = CATEGORY_DEFAULTS.get("bash", get_ctx().perm_config.default)
386
350
  matched_pattern = "*"
387
351
 
388
352
  for rule in rules:
@@ -419,11 +383,12 @@ def resolve_permission(
419
383
  4. For others: walk rules (defaults + user config), last-match-wins
420
384
  5. Fallback: category default, then global default
421
385
  """
422
- if _skip_permissions:
386
+ ctx = get_ctx()
387
+ if ctx.skip_permissions:
423
388
  return ("allow", "*")
424
389
 
425
390
  # Check session memory
426
- for cat, pattern in _session_allowed:
391
+ for cat, pattern in ctx.session_allowed:
427
392
  if cat == category and _match_rule(pattern, subject):
428
393
  return ("allow", pattern)
429
394
 
@@ -433,7 +398,7 @@ def resolve_permission(
433
398
 
434
399
  # All other categories
435
400
  rules = _build_rules(category)
436
- result: PermissionAction = CATEGORY_DEFAULTS.get(category, _config.default)
401
+ result: PermissionAction = CATEGORY_DEFAULTS.get(category, ctx.perm_config.default)
437
402
  matched_pattern = "*"
438
403
 
439
404
  for rule in rules:
@@ -465,7 +430,8 @@ def check_permission(
465
430
  return False
466
431
 
467
432
  # action == "ask" -> prompt user
468
- with _permission_lock:
433
+ ctx = get_ctx()
434
+ with ctx.permission_lock:
469
435
  # Re-check after acquiring lock (another thread may have resolved it)
470
436
  action2, pattern2 = resolve_permission(category, subject)
471
437
  if action2 == "allow":
@@ -474,25 +440,25 @@ def check_permission(
474
440
  return False
475
441
 
476
442
  # Pause Live and flush already-streamed content
477
- if _live:
478
- _live.stop()
479
- if _display:
480
- _display.flush()
443
+ if ctx.live:
444
+ ctx.live.stop()
445
+ if ctx.display:
446
+ ctx.display.flush()
481
447
 
482
448
  title = f"{category}: {subject}" if subject else category
483
- _console.print()
484
- _console.print(Panel(
449
+ ctx.console.print()
450
+ ctx.console.print(Panel(
485
451
  display_details,
486
452
  title=f"[bold yellow]{title}[/bold yellow]",
487
453
  border_style="yellow",
488
454
  expand=False,
489
455
  ))
490
456
  try:
491
- answer = _console.input(
457
+ answer = ctx.console.input(
492
458
  "[bold yellow]Allow? (y)es once / (a)lways / (n)o:[/bold yellow] "
493
459
  ).strip().lower()
494
460
  if answer in ("a", "always", "all"):
495
- _session_allowed.add((category, matched_pattern))
461
+ ctx.session_allowed.add((category, matched_pattern))
496
462
  allowed = True
497
463
  else:
498
464
  allowed = answer in ("y", "yes", "s", "sim")
@@ -500,8 +466,8 @@ def check_permission(
500
466
  allowed = False
501
467
 
502
468
  # Resume Live display
503
- if _live:
504
- _live.start()
505
- _live._live_render._shape = None
469
+ if ctx.live:
470
+ ctx.live.start()
471
+ ctx.live._live_render._shape = None
506
472
 
507
473
  return allowed