aru-code 0.7.0__tar.gz → 0.8.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.8.0}/PKG-INFO +1 -1
  2. aru_code-0.8.0/aru/__init__.py +1 -0
  3. {aru_code-0.7.0 → aru_code-0.8.0}/aru/agent_factory.py +6 -4
  4. {aru_code-0.7.0 → aru_code-0.8.0}/aru/agents/executor.py +3 -2
  5. {aru_code-0.7.0 → aru_code-0.8.0}/aru/agents/planner.py +3 -3
  6. {aru_code-0.7.0 → aru_code-0.8.0}/aru/cli.py +13 -19
  7. {aru_code-0.7.0 → aru_code-0.8.0}/aru/context.py +2 -2
  8. {aru_code-0.7.0 → aru_code-0.8.0}/aru/permissions.py +39 -73
  9. {aru_code-0.7.0 → aru_code-0.8.0}/aru/runner.py +12 -21
  10. aru_code-0.8.0/aru/runtime.py +158 -0
  11. {aru_code-0.7.0 → aru_code-0.8.0}/aru/tools/codebase.py +140 -195
  12. {aru_code-0.7.0 → aru_code-0.8.0}/aru/tools/tasklist.py +20 -75
  13. {aru_code-0.7.0 → aru_code-0.8.0/aru_code.egg-info}/PKG-INFO +1 -1
  14. {aru_code-0.7.0 → aru_code-0.8.0}/aru_code.egg-info/SOURCES.txt +1 -0
  15. {aru_code-0.7.0 → aru_code-0.8.0}/pyproject.toml +1 -1
  16. {aru_code-0.7.0 → aru_code-0.8.0}/tests/test_codebase.py +16 -16
  17. {aru_code-0.7.0 → aru_code-0.8.0}/tests/test_permissions.py +26 -20
  18. aru_code-0.7.0/aru/__init__.py +0 -1
  19. {aru_code-0.7.0 → aru_code-0.8.0}/LICENSE +0 -0
  20. {aru_code-0.7.0 → aru_code-0.8.0}/README.md +0 -0
  21. {aru_code-0.7.0 → aru_code-0.8.0}/aru/agents/__init__.py +0 -0
  22. {aru_code-0.7.0 → aru_code-0.8.0}/aru/agents/base.py +0 -0
  23. {aru_code-0.7.0 → aru_code-0.8.0}/aru/commands.py +0 -0
  24. {aru_code-0.7.0 → aru_code-0.8.0}/aru/completers.py +0 -0
  25. {aru_code-0.7.0 → aru_code-0.8.0}/aru/config.py +0 -0
  26. {aru_code-0.7.0 → aru_code-0.8.0}/aru/display.py +0 -0
  27. {aru_code-0.7.0 → aru_code-0.8.0}/aru/providers.py +0 -0
  28. {aru_code-0.7.0 → aru_code-0.8.0}/aru/session.py +0 -0
  29. {aru_code-0.7.0 → aru_code-0.8.0}/aru/tools/__init__.py +0 -0
  30. {aru_code-0.7.0 → aru_code-0.8.0}/aru/tools/ast_tools.py +0 -0
  31. {aru_code-0.7.0 → aru_code-0.8.0}/aru/tools/gitignore.py +0 -0
  32. {aru_code-0.7.0 → aru_code-0.8.0}/aru/tools/mcp_client.py +0 -0
  33. {aru_code-0.7.0 → aru_code-0.8.0}/aru/tools/ranker.py +0 -0
  34. {aru_code-0.7.0 → aru_code-0.8.0}/aru_code.egg-info/dependency_links.txt +0 -0
  35. {aru_code-0.7.0 → aru_code-0.8.0}/aru_code.egg-info/entry_points.txt +0 -0
  36. {aru_code-0.7.0 → aru_code-0.8.0}/aru_code.egg-info/requires.txt +0 -0
  37. {aru_code-0.7.0 → aru_code-0.8.0}/aru_code.egg-info/top_level.txt +0 -0
  38. {aru_code-0.7.0 → aru_code-0.8.0}/setup.cfg +0 -0
  39. {aru_code-0.7.0 → aru_code-0.8.0}/tests/test_agents_base.py +0 -0
  40. {aru_code-0.7.0 → aru_code-0.8.0}/tests/test_ast_tools.py +0 -0
  41. {aru_code-0.7.0 → aru_code-0.8.0}/tests/test_cli.py +0 -0
  42. {aru_code-0.7.0 → aru_code-0.8.0}/tests/test_cli_advanced.py +0 -0
  43. {aru_code-0.7.0 → aru_code-0.8.0}/tests/test_cli_base.py +0 -0
  44. {aru_code-0.7.0 → aru_code-0.8.0}/tests/test_cli_completers.py +0 -0
  45. {aru_code-0.7.0 → aru_code-0.8.0}/tests/test_cli_new.py +0 -0
  46. {aru_code-0.7.0 → aru_code-0.8.0}/tests/test_cli_run_cli.py +0 -0
  47. {aru_code-0.7.0 → aru_code-0.8.0}/tests/test_cli_session.py +0 -0
  48. {aru_code-0.7.0 → aru_code-0.8.0}/tests/test_cli_shell.py +0 -0
  49. {aru_code-0.7.0 → aru_code-0.8.0}/tests/test_config.py +0 -0
  50. {aru_code-0.7.0 → aru_code-0.8.0}/tests/test_context.py +0 -0
  51. {aru_code-0.7.0 → aru_code-0.8.0}/tests/test_executor.py +0 -0
  52. {aru_code-0.7.0 → aru_code-0.8.0}/tests/test_gitignore.py +0 -0
  53. {aru_code-0.7.0 → aru_code-0.8.0}/tests/test_main.py +0 -0
  54. {aru_code-0.7.0 → aru_code-0.8.0}/tests/test_mcp_client.py +0 -0
  55. {aru_code-0.7.0 → aru_code-0.8.0}/tests/test_planner.py +0 -0
  56. {aru_code-0.7.0 → aru_code-0.8.0}/tests/test_providers.py +0 -0
  57. {aru_code-0.7.0 → aru_code-0.8.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.8.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
@@ -0,0 +1 @@
1
+ __version__ = "0.8.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()
@@ -155,8 +149,7 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
155
149
  from aru.tools.codebase import set_custom_agents
156
150
  set_custom_agents(config.custom_agents)
157
151
  if config.permissions:
158
- perm_config = parse_permission_config(config.permissions)
159
- set_perm_config(perm_config)
152
+ ctx.perm_config = parse_permission_config(config.permissions)
160
153
  console.print("[dim]Loaded permission config[/dim]")
161
154
 
162
155
  extra_instructions = config.get_extra_instructions()
@@ -188,8 +181,9 @@ async def run_cli(skip_permissions: bool = False, resume_id: str | None = None):
188
181
  _sync_model(session)
189
182
  _render_home(session, skip_permissions)
190
183
 
191
- # Wire file-mutation callback
192
- set_on_file_mutation(session.invalidate_context_cache)
184
+ # Wire file-mutation callback and atexit cleanup
185
+ ctx.on_file_mutation = session.invalidate_context_cache
186
+ atexit.register(lambda: cleanup_processes(ctx.tracked_processes))
193
187
 
194
188
  planner = None
195
189
  executor = None
@@ -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
@@ -69,9 +69,7 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
69
69
  _stalled = False
70
70
 
71
71
  try:
72
- from aru.tools.codebase import set_display, set_live
73
- from aru.permissions import set_live as perm_set_live, set_display as perm_set_display
74
- from aru.tools.tasklist import set_live as tasklist_set_live, set_display as tasklist_set_display
72
+ from aru.runtime import get_ctx
75
73
 
76
74
  status = StatusBar(interval=3.0)
77
75
  display = StreamingDisplay(status)
@@ -125,12 +123,9 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
125
123
 
126
124
  run_output = None
127
125
  with Live(display, console=console, refresh_per_second=10) as live:
128
- set_live(live)
129
- set_display(display)
130
- perm_set_live(live)
131
- perm_set_display(display)
132
- tasklist_set_live(live)
133
- tasklist_set_display(display)
126
+ ctx = get_ctx()
127
+ ctx.live = live
128
+ ctx.display = display
134
129
  accumulated = ""
135
130
  _stall_counter = 0
136
131
  _stalled = False
@@ -215,10 +210,8 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
215
210
  )
216
211
  break
217
212
 
218
- set_live(None)
219
- set_display(None)
220
- perm_set_live(None)
221
- perm_set_display(None)
213
+ ctx.live = None
214
+ ctx.display = None
222
215
 
223
216
  if run_output and session and hasattr(run_output, "metrics"):
224
217
  session.track_tokens(run_output.metrics)
@@ -239,16 +232,14 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
239
232
  console.print(Markdown(remaining))
240
233
 
241
234
  except (KeyboardInterrupt, asyncio.CancelledError):
242
- set_live(None)
243
- set_display(None)
244
- perm_set_live(None)
245
- perm_set_display(None)
235
+ ctx = get_ctx()
236
+ ctx.live = None
237
+ ctx.display = None
246
238
  console.print("\n[yellow]Interrupted.[/yellow]")
247
239
  except Exception as e:
248
- set_live(None)
249
- set_display(None)
250
- perm_set_live(None)
251
- perm_set_display(None)
240
+ ctx = get_ctx()
241
+ ctx.live = None
242
+ ctx.display = None
252
243
  from rich.markup import escape
253
244
  console.print(f"[red]Error: {escape(str(e))}[/red]")
254
245
 
@@ -0,0 +1,158 @@
1
+ """Centralised runtime context for Aru.
2
+
3
+ Replaces scattered module-level globals with a single RuntimeContext
4
+ accessible via ``contextvars.ContextVar``. This gives each asyncio task
5
+ (and each ``asyncio.to_thread`` call) its own isolated snapshot, which
6
+ means parallel agent runs and tests never share mutable state.
7
+
8
+ Usage::
9
+
10
+ from aru.runtime import get_ctx, init_ctx
11
+
12
+ # At startup (cli.py):
13
+ ctx = init_ctx(console=console)
14
+
15
+ # In any tool / helper:
16
+ ctx = get_ctx()
17
+ ctx.live # Rich Live instance (or None)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import contextvars
23
+ import copy
24
+ import threading
25
+ from dataclasses import dataclass, field
26
+ from typing import Any, Callable
27
+
28
+ from rich.console import Console
29
+
30
+
31
+ # ── TaskStore (moved from tools/tasklist.py) ─────────────────────────
32
+
33
+ class TaskStore:
34
+ """Thread-safe store for the current step's subtask list."""
35
+
36
+ def __init__(self) -> None:
37
+ self._lock = threading.Lock()
38
+ self._tasks: list[dict] = []
39
+ self._created = False
40
+
41
+ def create(self, tasks: list[str]) -> list[dict]:
42
+ with self._lock:
43
+ self._tasks = [
44
+ {"index": i + 1, "description": desc, "status": "pending"}
45
+ for i, desc in enumerate(tasks)
46
+ ]
47
+ self._created = True
48
+ return list(self._tasks)
49
+
50
+ def update(self, index: int, status: str) -> dict | None:
51
+ with self._lock:
52
+ for task in self._tasks:
53
+ if task["index"] == index:
54
+ task["status"] = status
55
+ return dict(task)
56
+ return None
57
+
58
+ def get_all(self) -> list[dict]:
59
+ with self._lock:
60
+ return list(self._tasks)
61
+
62
+ @property
63
+ def is_created(self) -> bool:
64
+ with self._lock:
65
+ return self._created
66
+
67
+ def reset(self) -> None:
68
+ with self._lock:
69
+ self._tasks = []
70
+ self._created = False
71
+
72
+
73
+ # ── PermissionConfig (imported lazily to avoid circular deps) ────────
74
+
75
+ def _default_perm_config():
76
+ from aru.permissions import PermissionConfig
77
+ return PermissionConfig()
78
+
79
+
80
+ # ── RuntimeContext ───────────────────────────────────────────────────
81
+
82
+ @dataclass
83
+ class RuntimeContext:
84
+ """All mutable runtime state, grouped by domain."""
85
+
86
+ # -- Display --
87
+ console: Console = field(default_factory=Console)
88
+ live: Any = None
89
+ display: Any = None
90
+
91
+ # -- Model --
92
+ model_id: str = "claude-sonnet-4-5-20250929"
93
+ small_model_ref: str = "anthropic/claude-haiku-4-5"
94
+
95
+ # -- File operations --
96
+ on_file_mutation: Callable[[], None] | None = None
97
+ read_cache: dict[tuple, str] = field(default_factory=dict)
98
+
99
+ # -- Process tracking --
100
+ tracked_processes: list = field(default_factory=list)
101
+ subagent_counter: int = 0
102
+ subagent_counter_lock: threading.Lock = field(default_factory=threading.Lock)
103
+
104
+ # -- Custom agents --
105
+ custom_agent_defs: dict = field(default_factory=dict)
106
+
107
+ # -- Permissions --
108
+ perm_config: Any = field(default_factory=_default_perm_config)
109
+ session_allowed: set[tuple[str, str]] = field(default_factory=set)
110
+ skip_permissions: bool = False
111
+ permission_lock: threading.Lock = field(default_factory=threading.Lock)
112
+ config_stack: list = field(default_factory=list)
113
+ session_stack: list[set[tuple[str, str]]] = field(default_factory=list)
114
+
115
+ # -- Tasklist --
116
+ task_store: TaskStore = field(default_factory=TaskStore)
117
+
118
+
119
+ # ── ContextVar plumbing ──────────────────────────────────────────────
120
+
121
+ _runtime_ctx: contextvars.ContextVar[RuntimeContext] = contextvars.ContextVar("aru_runtime")
122
+
123
+
124
+ def get_ctx() -> RuntimeContext:
125
+ """Return the current RuntimeContext. Raises LookupError if not initialised."""
126
+ return _runtime_ctx.get()
127
+
128
+
129
+ def set_ctx(ctx: RuntimeContext) -> contextvars.Token[RuntimeContext]:
130
+ """Set *ctx* as the current RuntimeContext; return a reset token."""
131
+ return _runtime_ctx.set(ctx)
132
+
133
+
134
+ def init_ctx(console: Console | None = None, **kwargs: Any) -> RuntimeContext:
135
+ """Create a new RuntimeContext, install it, and return it."""
136
+ ctx = RuntimeContext(console=console or Console(), **kwargs)
137
+ _runtime_ctx.set(ctx)
138
+ return ctx
139
+
140
+
141
+ def fork_ctx() -> RuntimeContext:
142
+ """Create an isolated copy of the current RuntimeContext for sub-agent use.
143
+
144
+ Permission state is deep-copied to prevent interleaving when multiple
145
+ sub-agents run concurrently via ``asyncio.gather``. Shared resources
146
+ (console, locks, tracked_processes) are kept by reference.
147
+ """
148
+ original = get_ctx()
149
+ forked = copy.copy(original)
150
+ # Deep-copy mutable permission state for isolation
151
+ forked.config_stack = list(original.config_stack)
152
+ forked.session_stack = [s.copy() for s in original.session_stack]
153
+ forked.session_allowed = original.session_allowed.copy()
154
+ # Fresh read cache per sub-agent
155
+ forked.read_cache = {}
156
+ # Fresh task store per sub-agent
157
+ forked.task_store = TaskStore()
158
+ return forked