tau-coding-agent 0.1.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 (283) hide show
  1. tau/__init__.py +0 -0
  2. tau/agent/__init__.py +11 -0
  3. tau/agent/prompt/__init__.py +10 -0
  4. tau/agent/prompt/builder.py +302 -0
  5. tau/agent/prompt/types.py +33 -0
  6. tau/agent/service.py +369 -0
  7. tau/agent/types.py +61 -0
  8. tau/auth/manager.py +247 -0
  9. tau/auth/storage.py +82 -0
  10. tau/auth/types.py +41 -0
  11. tau/builtins/__init__.py +4 -0
  12. tau/builtins/__pycache__/__init__.cpython-313.pyc +0 -0
  13. tau/builtins/__pycache__/__init__.cpython-314.pyc +0 -0
  14. tau/builtins/commands/__init__.py +41 -0
  15. tau/builtins/commands/__pycache__/__init__.cpython-313.pyc +0 -0
  16. tau/builtins/commands/__pycache__/__init__.cpython-314.pyc +0 -0
  17. tau/builtins/commands/__pycache__/clear.cpython-313.pyc +0 -0
  18. tau/builtins/commands/__pycache__/clear.cpython-314.pyc +0 -0
  19. tau/builtins/commands/__pycache__/compact.cpython-313.pyc +0 -0
  20. tau/builtins/commands/__pycache__/compact.cpython-314.pyc +0 -0
  21. tau/builtins/commands/__pycache__/reload.cpython-313.pyc +0 -0
  22. tau/builtins/commands/__pycache__/reload.cpython-314.pyc +0 -0
  23. tau/builtins/commands/__pycache__/session.cpython-313.pyc +0 -0
  24. tau/builtins/commands/__pycache__/session.cpython-314.pyc +0 -0
  25. tau/builtins/commands/clear.py +16 -0
  26. tau/builtins/commands/compact.py +28 -0
  27. tau/builtins/commands/reload.py +27 -0
  28. tau/builtins/commands/session.py +19 -0
  29. tau/builtins/extensions/footer/__init__.py +76 -0
  30. tau/builtins/extensions/footer/__pycache__/__init__.cpython-313.pyc +0 -0
  31. tau/builtins/extensions/footer/__pycache__/git.cpython-313.pyc +0 -0
  32. tau/builtins/extensions/footer/__pycache__/model.cpython-313.pyc +0 -0
  33. tau/builtins/extensions/footer/__pycache__/utils.cpython-313.pyc +0 -0
  34. tau/builtins/extensions/footer/git.py +26 -0
  35. tau/builtins/extensions/footer/model.py +69 -0
  36. tau/builtins/extensions/footer/utils.py +44 -0
  37. tau/builtins/extensions/header/__init__.py +18 -0
  38. tau/builtins/extensions/header/__pycache__/__init__.cpython-313.pyc +0 -0
  39. tau/builtins/models/__init__.py +0 -0
  40. tau/builtins/models/__pycache__/__init__.cpython-313.pyc +0 -0
  41. tau/builtins/models/__pycache__/text.cpython-313.pyc +0 -0
  42. tau/builtins/models/audio.py +43 -0
  43. tau/builtins/models/image.py +43 -0
  44. tau/builtins/models/text.py +482 -0
  45. tau/builtins/models/video.py +40 -0
  46. tau/builtins/prompts/commit.md +7 -0
  47. tau/builtins/prompts/docs.md +7 -0
  48. tau/builtins/prompts/explain.md +7 -0
  49. tau/builtins/prompts/fix.md +7 -0
  50. tau/builtins/prompts/refactor.md +7 -0
  51. tau/builtins/prompts/review.md +7 -0
  52. tau/builtins/prompts/test.md +7 -0
  53. tau/builtins/providers/__init__.py +0 -0
  54. tau/builtins/providers/__pycache__/__init__.cpython-313.pyc +0 -0
  55. tau/builtins/providers/__pycache__/text.cpython-313.pyc +0 -0
  56. tau/builtins/providers/audio.py +10 -0
  57. tau/builtins/providers/image.py +9 -0
  58. tau/builtins/providers/text.py +33 -0
  59. tau/builtins/providers/video.py +6 -0
  60. tau/builtins/skills/code-review/SKILL.md +4 -0
  61. tau/builtins/skills/debug/SKILL.md +4 -0
  62. tau/builtins/skills/git-commit/SKILL.md +4 -0
  63. tau/builtins/themes/dark.yaml +1 -0
  64. tau/builtins/themes/light.yaml +46 -0
  65. tau/builtins/tools/__init__.py +73 -0
  66. tau/builtins/tools/__pycache__/__init__.cpython-313.pyc +0 -0
  67. tau/builtins/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  68. tau/builtins/tools/__pycache__/bash.cpython-313.pyc +0 -0
  69. tau/builtins/tools/__pycache__/bash.cpython-314.pyc +0 -0
  70. tau/builtins/tools/__pycache__/edit.cpython-313.pyc +0 -0
  71. tau/builtins/tools/__pycache__/edit.cpython-314.pyc +0 -0
  72. tau/builtins/tools/__pycache__/glob.cpython-313.pyc +0 -0
  73. tau/builtins/tools/__pycache__/glob.cpython-314.pyc +0 -0
  74. tau/builtins/tools/__pycache__/grep.cpython-313.pyc +0 -0
  75. tau/builtins/tools/__pycache__/grep.cpython-314.pyc +0 -0
  76. tau/builtins/tools/__pycache__/ls.cpython-313.pyc +0 -0
  77. tau/builtins/tools/__pycache__/ls.cpython-314.pyc +0 -0
  78. tau/builtins/tools/__pycache__/read.cpython-313.pyc +0 -0
  79. tau/builtins/tools/__pycache__/read.cpython-314.pyc +0 -0
  80. tau/builtins/tools/__pycache__/terminal.cpython-313.pyc +0 -0
  81. tau/builtins/tools/__pycache__/terminal.cpython-314.pyc +0 -0
  82. tau/builtins/tools/__pycache__/write.cpython-313.pyc +0 -0
  83. tau/builtins/tools/__pycache__/write.cpython-314.pyc +0 -0
  84. tau/builtins/tools/edit.py +215 -0
  85. tau/builtins/tools/glob.py +112 -0
  86. tau/builtins/tools/grep.py +146 -0
  87. tau/builtins/tools/ls.py +135 -0
  88. tau/builtins/tools/read.py +122 -0
  89. tau/builtins/tools/terminal.py +150 -0
  90. tau/builtins/tools/write.py +105 -0
  91. tau/commands/__init__.py +10 -0
  92. tau/commands/registry.py +71 -0
  93. tau/commands/types.py +33 -0
  94. tau/console/__init__.py +0 -0
  95. tau/console/cli.py +266 -0
  96. tau/console/commands/__init__.py +0 -0
  97. tau/console/commands/auth.py +193 -0
  98. tau/console/commands/packages.py +104 -0
  99. tau/console/commands/update.py +76 -0
  100. tau/core/__init__.py +0 -0
  101. tau/core/registry.py +102 -0
  102. tau/engine/__init__.py +47 -0
  103. tau/engine/service.py +768 -0
  104. tau/engine/types.py +163 -0
  105. tau/extensions/__init__.py +28 -0
  106. tau/extensions/api.py +928 -0
  107. tau/extensions/context.py +462 -0
  108. tau/extensions/events.py +70 -0
  109. tau/extensions/loader.py +386 -0
  110. tau/extensions/runtime.py +184 -0
  111. tau/extensions/settings.py +137 -0
  112. tau/hooks/__init__.py +112 -0
  113. tau/hooks/engine.py +237 -0
  114. tau/hooks/inference.py +21 -0
  115. tau/hooks/runtime.py +126 -0
  116. tau/hooks/service.py +121 -0
  117. tau/hooks/session.py +117 -0
  118. tau/hooks/tui.py +61 -0
  119. tau/hooks/types.py +72 -0
  120. tau/inference/__init__.py +180 -0
  121. tau/inference/api/__init__.py +0 -0
  122. tau/inference/api/audio/__init__.py +0 -0
  123. tau/inference/api/audio/base.py +29 -0
  124. tau/inference/api/audio/builtins.py +15 -0
  125. tau/inference/api/audio/elevenlabs_audio.py +183 -0
  126. tau/inference/api/audio/gemini_audio.py +95 -0
  127. tau/inference/api/audio/openai_audio.py +159 -0
  128. tau/inference/api/audio/registry.py +15 -0
  129. tau/inference/api/audio/sarvam_audio.py +163 -0
  130. tau/inference/api/audio/service.py +103 -0
  131. tau/inference/api/audio/utils.py +47 -0
  132. tau/inference/api/image/__init__.py +0 -0
  133. tau/inference/api/image/base.py +17 -0
  134. tau/inference/api/image/builtins.py +8 -0
  135. tau/inference/api/image/gemini_image.py +77 -0
  136. tau/inference/api/image/openai_image.py +103 -0
  137. tau/inference/api/image/openrouter.py +144 -0
  138. tau/inference/api/image/registry.py +15 -0
  139. tau/inference/api/image/service.py +71 -0
  140. tau/inference/api/registry.py +82 -0
  141. tau/inference/api/text/__init__.py +0 -0
  142. tau/inference/api/text/anthropic_claude_code.py +222 -0
  143. tau/inference/api/text/anthropic_messages.py +196 -0
  144. tau/inference/api/text/base.py +40 -0
  145. tau/inference/api/text/builtins.py +19 -0
  146. tau/inference/api/text/gemini_generate.py +234 -0
  147. tau/inference/api/text/github_copilot_chat.py +172 -0
  148. tau/inference/api/text/google_antigravity.py +522 -0
  149. tau/inference/api/text/mistral_chat.py +284 -0
  150. tau/inference/api/text/ollama_chat.py +200 -0
  151. tau/inference/api/text/openai_codex_responses.py +497 -0
  152. tau/inference/api/text/openai_completions.py +227 -0
  153. tau/inference/api/text/openai_responses.py +235 -0
  154. tau/inference/api/text/registry.py +50 -0
  155. tau/inference/api/text/service.py +297 -0
  156. tau/inference/api/text/types.py +7 -0
  157. tau/inference/api/text/utils.py +228 -0
  158. tau/inference/api/video/__init__.py +0 -0
  159. tau/inference/api/video/base.py +26 -0
  160. tau/inference/api/video/builtins.py +7 -0
  161. tau/inference/api/video/fal_video.py +119 -0
  162. tau/inference/api/video/openrouter_video.py +142 -0
  163. tau/inference/api/video/registry.py +15 -0
  164. tau/inference/api/video/service.py +72 -0
  165. tau/inference/model/__init__.py +0 -0
  166. tau/inference/model/registry.py +102 -0
  167. tau/inference/model/types.py +65 -0
  168. tau/inference/provider/__init__.py +0 -0
  169. tau/inference/provider/oauth/__init__.py +35 -0
  170. tau/inference/provider/oauth/anthropic_claude_code.py +286 -0
  171. tau/inference/provider/oauth/github_copilot.py +333 -0
  172. tau/inference/provider/oauth/google_antigravity.py +258 -0
  173. tau/inference/provider/oauth/openai_codex.py +309 -0
  174. tau/inference/provider/oauth/pkce.py +14 -0
  175. tau/inference/provider/oauth/types.py +46 -0
  176. tau/inference/provider/oauth/utils.py +154 -0
  177. tau/inference/provider/registry.py +141 -0
  178. tau/inference/provider/types.py +114 -0
  179. tau/inference/types.py +549 -0
  180. tau/inference/utils.py +219 -0
  181. tau/message/__init__.py +0 -0
  182. tau/message/types.py +482 -0
  183. tau/message/utils.py +178 -0
  184. tau/packages/__init__.py +11 -0
  185. tau/packages/manager.py +190 -0
  186. tau/packages/types.py +20 -0
  187. tau/packages/utils.py +67 -0
  188. tau/prompts/expand.py +58 -0
  189. tau/prompts/loader.py +69 -0
  190. tau/prompts/registry.py +45 -0
  191. tau/prompts/types.py +24 -0
  192. tau/rpc/__init__.py +8 -0
  193. tau/rpc/mode.py +783 -0
  194. tau/rpc/types.py +252 -0
  195. tau/runtime/service.py +759 -0
  196. tau/runtime/types.py +303 -0
  197. tau/session/branch_summarization.py +312 -0
  198. tau/session/compaction.py +646 -0
  199. tau/session/manager.py +652 -0
  200. tau/session/types.py +188 -0
  201. tau/session/utils.py +233 -0
  202. tau/settings/manager.py +1077 -0
  203. tau/settings/paths.py +150 -0
  204. tau/settings/storage.py +63 -0
  205. tau/settings/types.py +173 -0
  206. tau/settings/utils.py +25 -0
  207. tau/skills/loader.py +91 -0
  208. tau/skills/registry.py +70 -0
  209. tau/skills/types.py +25 -0
  210. tau/themes/loader.py +238 -0
  211. tau/themes/registry.py +108 -0
  212. tau/themes/types.py +19 -0
  213. tau/tool/__init__.py +3 -0
  214. tau/tool/registry.py +117 -0
  215. tau/tool/render.py +21 -0
  216. tau/tool/types.py +244 -0
  217. tau/trust/__init__.py +13 -0
  218. tau/trust/manager.py +80 -0
  219. tau/trust/types.py +14 -0
  220. tau/trust/utils.py +72 -0
  221. tau/tui/__init__.py +54 -0
  222. tau/tui/agent_hooks.py +346 -0
  223. tau/tui/ansi.py +330 -0
  224. tau/tui/app.py +540 -0
  225. tau/tui/autocomplete.py +33 -0
  226. tau/tui/capabilities.py +119 -0
  227. tau/tui/commands/__init__.py +3 -0
  228. tau/tui/commands/appearance.py +498 -0
  229. tau/tui/commands/auth.py +232 -0
  230. tau/tui/commands/context.py +38 -0
  231. tau/tui/commands/misc.py +82 -0
  232. tau/tui/commands/model.py +118 -0
  233. tau/tui/commands/session.py +464 -0
  234. tau/tui/component.py +268 -0
  235. tau/tui/components/__init__.py +0 -0
  236. tau/tui/components/autocomplete_manager.py +267 -0
  237. tau/tui/components/autocomplete_picker.py +143 -0
  238. tau/tui/components/box.py +90 -0
  239. tau/tui/components/command_palette.py +144 -0
  240. tau/tui/components/dynamic_border.py +19 -0
  241. tau/tui/components/file_picker.py +233 -0
  242. tau/tui/components/image.py +181 -0
  243. tau/tui/components/inline_selector.py +71 -0
  244. tau/tui/components/layout.py +1194 -0
  245. tau/tui/components/message_list.py +692 -0
  246. tau/tui/components/modal.py +97 -0
  247. tau/tui/components/model_palette.py +204 -0
  248. tau/tui/components/picker_overlay.py +174 -0
  249. tau/tui/components/prompt_overlay.py +236 -0
  250. tau/tui/components/resume_modal.py +372 -0
  251. tau/tui/components/select_list.py +222 -0
  252. tau/tui/components/settings_modal.py +274 -0
  253. tau/tui/components/settings_schema.py +203 -0
  254. tau/tui/components/spinner.py +119 -0
  255. tau/tui/components/text_input.py +396 -0
  256. tau/tui/components/text_prompt.py +82 -0
  257. tau/tui/components/tree_select_list.py +580 -0
  258. tau/tui/components/trust_screen.py +97 -0
  259. tau/tui/diff.py +114 -0
  260. tau/tui/fuzzy.py +99 -0
  261. tau/tui/input.py +496 -0
  262. tau/tui/input_handler.py +716 -0
  263. tau/tui/keybindings.py +87 -0
  264. tau/tui/markdown.py +286 -0
  265. tau/tui/message_renderers.py +31 -0
  266. tau/tui/overlay.py +326 -0
  267. tau/tui/renderer.py +378 -0
  268. tau/tui/terminal.py +499 -0
  269. tau/tui/theme.py +148 -0
  270. tau/tui/tui.py +544 -0
  271. tau/tui/ui_context.py +768 -0
  272. tau/tui/utils.py +20 -0
  273. tau/utils/__init__.py +0 -0
  274. tau/utils/http_proxy.py +221 -0
  275. tau/utils/image_processing.py +172 -0
  276. tau/utils/secrets.py +59 -0
  277. tau/utils/version_check.py +60 -0
  278. tau_coding_agent-0.1.0.dist-info/METADATA +177 -0
  279. tau_coding_agent-0.1.0.dist-info/RECORD +283 -0
  280. tau_coding_agent-0.1.0.dist-info/WHEEL +5 -0
  281. tau_coding_agent-0.1.0.dist-info/entry_points.txt +2 -0
  282. tau_coding_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  283. tau_coding_agent-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,386 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hashlib
5
+ import importlib.util
6
+ import inspect
7
+ import json
8
+ import logging
9
+ import sys
10
+ import traceback
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from tau.extensions.api import (
15
+ Extension, ExtensionAPI, ExtensionError,
16
+ LoadExtensionsResult, _RuntimeRef,
17
+ )
18
+ from tau.extensions.events import EventBus
19
+
20
+ if TYPE_CHECKING:
21
+ from tau.inference.api.text.service import TextLLM
22
+ from tau.settings.manager import SettingsManager
23
+ from tau.settings.types import ExtensionEntry
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _ENTRY_POINT = "register"
28
+
29
+
30
+ class ExtensionLoader:
31
+ """
32
+ Discovers and loads Python extension files from configured directories.
33
+
34
+ Discovery order (project first, then global, then explicit paths):
35
+ - <cwd>/.tau/extensions/
36
+ - ~/.tau/extensions/
37
+ - Any entries in settings.extensions.list that are enabled
38
+
39
+ Within each directory, files and subdirectories are sorted alphabetically.
40
+ Files whose names start with ``_`` are skipped. Files whose stem appears
41
+ in ``disabled_stems`` are also skipped. Duplicate resolved paths are
42
+ skipped silently. A failing extension is logged and recorded in the
43
+ returned ``LoadExtensionsResult`` — it never crashes startup.
44
+
45
+ Subdirectory support:
46
+ Given ``extensions/my_pkg/``:
47
+ 1. ``my_pkg/manifest.json`` with ``{"tau": {"extensions": ["./main.py"]}}``
48
+ → load the declared paths.
49
+ 2. ``my_pkg/__init__.py``
50
+ → load as a Python package entry point.
51
+ If neither is present the subdirectory is ignored.
52
+
53
+ Dependency installation:
54
+ ``manifest.json`` may also declare ``{"tau": {"dependencies": ["pkg>=1.0"]}}``.
55
+ Before the entry file is executed, these specs are installed (once — cached by
56
+ a hash of the dependency list) via ``uv pip install``, falling back to the
57
+ venv's own ``pip`` when ``uv`` is not on PATH. No dedicated venv is created
58
+ under ``.tau/``: project extensions install into the project's own ``.venv``
59
+ when one already exists, otherwise everything falls back to the global
60
+ packages venv at ``~/.tau/venv``. The resolved venv's site-packages directory
61
+ is then added to ``sys.path`` so the extension's own imports resolve.
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ project_dir: Path | None = None,
67
+ global_dir: Path | None = None,
68
+ extra_entries: list[ExtensionEntry] | None = None,
69
+ extra_sources: dict[str, str] | None = None,
70
+ disabled_stems: set[str] | None = None,
71
+ entry_configs: dict[str, dict] | None = None,
72
+ builtins_dir: Path | None = None,
73
+ # Dependencies injected for per-extension ExtensionAPI creation
74
+ llm: TextLLM | None = None,
75
+ settings: SettingsManager | None = None,
76
+ cwd: Path | None = None,
77
+ runtime_ref: _RuntimeRef | None = None,
78
+ events: EventBus | None = None,
79
+ ) -> None:
80
+ self._builtins_dir: Path | None = builtins_dir if (builtins_dir is not None and builtins_dir.is_dir()) else None
81
+ self._dirs: list[Path] = []
82
+ self._dir_sources: dict[Path, str] = {}
83
+ self._extra_paths: list[Path] = []
84
+ self._extra_sources: dict[str, str] = extra_sources or {}
85
+ self._disabled_stems: set[str] = disabled_stems or set()
86
+ self._entry_configs: dict[str, dict] = entry_configs or {}
87
+ self._subdir_deps: dict[Path, list[str]] = {}
88
+ self._subdir_settings: dict[Path, dict] = {}
89
+ self._llm = llm
90
+ self._settings = settings
91
+ self._cwd = cwd or Path(".")
92
+ self._runtime_ref = runtime_ref
93
+ self._events = events or EventBus()
94
+
95
+ # Project first, then global
96
+ if project_dir is not None and project_dir.is_dir():
97
+ self._dirs.append(project_dir)
98
+ self._dir_sources[project_dir] = "project"
99
+ if global_dir is not None and global_dir.is_dir():
100
+ self._dirs.append(global_dir)
101
+ self._dir_sources[global_dir] = "global"
102
+
103
+ for entry in (extra_entries or []):
104
+ resolved = Path(entry.path).expanduser().resolve()
105
+ if resolved.exists():
106
+ self._extra_paths.append(resolved)
107
+ if entry.settings:
108
+ self._entry_configs.setdefault(resolved.stem, entry.settings)
109
+
110
+ # ── Discovery ──────────────────────────────────────────────────────────────
111
+
112
+ def _subdir_entries(self, subdir: Path) -> list[Path]:
113
+ """Return extension entry files for a subdirectory, or empty list."""
114
+ manifest = subdir / "manifest.json"
115
+ if manifest.is_file():
116
+ try:
117
+ data = json.loads(manifest.read_text(encoding="utf-8"))
118
+ from tau.settings.paths import get_app_name
119
+ app_data = data.get(get_app_name().lower(), {})
120
+
121
+ deps = app_data.get("dependencies", [])
122
+ if deps:
123
+ self._subdir_deps[subdir.resolve()] = list(deps)
124
+
125
+ settings_schema = app_data.get("settings")
126
+ if isinstance(settings_schema, dict):
127
+ self._subdir_settings[subdir.resolve()] = settings_schema
128
+
129
+ declared = app_data.get("extensions", [])
130
+ if declared:
131
+ entries = [(subdir / p).resolve() for p in declared]
132
+ valid = [e for e in entries if e.is_file()]
133
+ if valid:
134
+ return valid
135
+ except (json.JSONDecodeError, OSError):
136
+ pass
137
+
138
+ init = subdir / "__init__.py"
139
+ if init.is_file():
140
+ return [init]
141
+
142
+ return []
143
+
144
+ def _discover_in_dir(self, d: Path, seen: set[Path]) -> list[Path]:
145
+ """Collect extension files from one directory (one level deep)."""
146
+ found: list[Path] = []
147
+ for entry in sorted(d.iterdir(), key=lambda e: e.name):
148
+ if entry.name.startswith("_"):
149
+ continue
150
+ if entry.is_file() and entry.suffix == ".py":
151
+ if entry.stem in self._disabled_stems:
152
+ continue
153
+ r = entry.resolve()
154
+ if r not in seen:
155
+ seen.add(r)
156
+ found.append(entry)
157
+ elif entry.is_dir():
158
+ if entry.name in self._disabled_stems:
159
+ continue
160
+ for sub_entry in self._subdir_entries(entry):
161
+ r = sub_entry.resolve()
162
+ if r not in seen:
163
+ seen.add(r)
164
+ found.append(sub_entry)
165
+ return found
166
+
167
+ def _discover(self) -> list[tuple[Path, str]]:
168
+ """Return deduplicated list of (extension file, source label) pairs to load."""
169
+ found: list[tuple[Path, str]] = []
170
+ seen: set[Path] = set()
171
+
172
+ # Builtins always first, before any user dirs
173
+ if self._builtins_dir is not None:
174
+ for path in self._discover_in_dir(self._builtins_dir, seen):
175
+ found.append((path, "builtin"))
176
+
177
+ for d in self._dirs:
178
+ source = self._dir_sources.get(d, "unknown")
179
+ for path in self._discover_in_dir(d, seen):
180
+ found.append((path, source))
181
+
182
+ for p in self._extra_paths:
183
+ source = self._extra_sources.get(str(p.resolve()), "explicit")
184
+ if p.is_file() and p.suffix == ".py":
185
+ r = p.resolve()
186
+ if r not in seen:
187
+ seen.add(r)
188
+ found.append((p, source))
189
+ elif p.is_dir():
190
+ entries = self._subdir_entries(p)
191
+ if entries:
192
+ for e in entries:
193
+ r = e.resolve()
194
+ if r not in seen:
195
+ seen.add(r)
196
+ found.append((e, source))
197
+ else:
198
+ for path in self._discover_in_dir(p, seen):
199
+ found.append((path, source))
200
+
201
+ return found
202
+
203
+ # ── Dependency installation ──────────────────────────────────────────────────
204
+
205
+ def _resolve_venv_dir(self, source: str) -> Path:
206
+ """Pick a venv to install extension dependencies into.
207
+
208
+ Project extensions always use the project's own ``.venv`` — reusing it
209
+ if it already exists, creating it at ``<cwd>/.venv`` if not. No dedicated
210
+ venv is ever created under ``.tau/``. Everything else (global, explicit,
211
+ unknown sources) uses the global packages venv at ``~/.tau/venv``.
212
+ """
213
+ from tau.settings.paths import get_packages_venv
214
+
215
+ if source == "project":
216
+ return self._cwd / ".venv"
217
+
218
+ return get_packages_venv(None)
219
+
220
+ def _ensure_dependencies(self, subdir: Path, deps: list[str], source: str) -> None:
221
+ """Install an extension's declared dependencies, once per dependency set.
222
+
223
+ Runs synchronously (called via ``asyncio.to_thread``) so the blocking
224
+ subprocess install doesn't stall the event loop.
225
+ """
226
+ from tau.packages.manager import PackageManager
227
+
228
+ venv_dir = self._resolve_venv_dir(source)
229
+ pkg_mgr = PackageManager(venv_dir)
230
+
231
+ digest = hashlib.sha256("\n".join(sorted(deps)).encode("utf-8")).hexdigest()
232
+ cache_file = venv_dir / ".tau_ext_deps.json"
233
+ cache: dict[str, str] = {}
234
+ if cache_file.is_file():
235
+ try:
236
+ cache = json.loads(cache_file.read_text(encoding="utf-8"))
237
+ except (json.JSONDecodeError, OSError):
238
+ cache = {}
239
+
240
+ key = str(subdir.resolve())
241
+ if cache.get(key) != digest:
242
+ logger.info("Installing dependencies for extension %r: %s", subdir.name, ", ".join(deps))
243
+ pkg_mgr.install_requirements(deps)
244
+ cache[key] = digest
245
+ cache_file.parent.mkdir(parents=True, exist_ok=True)
246
+ cache_file.write_text(json.dumps(cache, indent=2), encoding="utf-8")
247
+
248
+ site = pkg_mgr.site_packages()
249
+ if site and str(site) not in sys.path:
250
+ sys.path.insert(0, str(site))
251
+
252
+ # ── Loading ────────────────────────────────────────────────────────────────
253
+
254
+ async def load(self) -> LoadExtensionsResult:
255
+ """Discover and load all extensions in parallel.
256
+
257
+ Returns a ``LoadExtensionsResult`` with loaded ``Extension`` objects and
258
+ any ``ExtensionError`` entries for files that failed to load.
259
+ """
260
+ discovered = self._discover()
261
+
262
+ async def _load(path: Path, source: str) -> tuple[Extension | None, list[ExtensionError]]:
263
+ config_key = path.parent.name if path.name == "__init__.py" else path.stem
264
+ config = self._entry_configs.get(config_key, {})
265
+ ext, errs = await self._load_one(path, config, source=source)
266
+ if ext is not None:
267
+ logger.debug("Loaded extension: %s (%s)", path, source)
268
+ return ext, errs
269
+
270
+ results = await asyncio.gather(*[_load(p, s) for p, s in discovered])
271
+
272
+ extensions: list[Extension] = []
273
+ errors: list[ExtensionError] = []
274
+ for ext, errs in results:
275
+ if ext is not None:
276
+ extensions.append(ext)
277
+ errors.extend(errs)
278
+
279
+ return LoadExtensionsResult(extensions=extensions, errors=errors)
280
+
281
+ async def _load_one(
282
+ self, path: Path, config: dict, *, source: str = "unknown"
283
+ ) -> tuple[Extension | None, list[ExtensionError]]:
284
+ """Load a single extension file and call its ``register(tau)`` factory."""
285
+ str_path = str(path)
286
+ errors: list[ExtensionError] = []
287
+
288
+ try:
289
+ deps = self._subdir_deps.get(path.parent.resolve())
290
+ if deps and source != "builtin":
291
+ await asyncio.to_thread(self._ensure_dependencies, path.parent, deps, source)
292
+
293
+ module_name = f"_tau_ext_{hashlib.sha1(str(path.resolve()).encode()).hexdigest()[:16]}"
294
+ spec = importlib.util.spec_from_file_location(module_name, path)
295
+ if spec is None or spec.loader is None:
296
+ errors.append(ExtensionError(
297
+ extension_path=str_path, event="load",
298
+ error=f"Cannot create module spec for {path}",
299
+ ))
300
+ return None, errors
301
+
302
+ module = importlib.util.module_from_spec(spec)
303
+ sys.modules[module_name] = module
304
+ spec.loader.exec_module(module) # type: ignore[union-attr]
305
+
306
+ register_fn = getattr(module, _ENTRY_POINT, None)
307
+ if register_fn is None or not callable(register_fn):
308
+ errors.append(ExtensionError(
309
+ extension_path=str_path, event="load",
310
+ error=f"No '{_ENTRY_POINT}(tau)' function in {path.name}",
311
+ ))
312
+ return None, errors
313
+
314
+ ext = Extension(path=str_path, config=config, source=source)
315
+ api = ExtensionAPI(
316
+ extension=ext,
317
+ llm=self._llm, # type: ignore[arg-type]
318
+ settings=self._settings, # type: ignore[arg-type]
319
+ cwd=self._cwd,
320
+ runtime_ref=self._runtime_ref,
321
+ events=self._events,
322
+ )
323
+
324
+ result = register_fn(api)
325
+ if inspect.isawaitable(result):
326
+ await result
327
+
328
+ self._attach_manifest_panel(ext, path)
329
+
330
+ return ext, errors
331
+
332
+ except Exception:
333
+ tb = traceback.format_exc()
334
+ errors.append(ExtensionError(
335
+ extension_path=str_path, event="load",
336
+ error=tb.strip().splitlines()[-1],
337
+ stack=tb,
338
+ ))
339
+ return None, errors
340
+
341
+ def _attach_manifest_panel(self, ext: Extension, path: Path) -> None:
342
+ """Auto-build a /settings sub-panel from the manifest ``settings`` schema.
343
+
344
+ Skipped when the extension already registered a panel itself (manual
345
+ ``register_settings`` wins) or when no schema is declared.
346
+ """
347
+ if ext.settings_registrations:
348
+ return
349
+ if self._settings is None:
350
+ return # no settings manager → nothing to persist changes to
351
+ settings = self._settings
352
+ schema = self._subdir_settings.get(path.parent.resolve())
353
+ if not schema:
354
+ return
355
+
356
+ from tau.tui.components.settings_schema import build_manifest_panel
357
+
358
+ ext_dir = path.parent
359
+ try:
360
+ settings_path = str(ext_dir.relative_to(self._cwd))
361
+ except ValueError:
362
+ settings_path = str(ext_dir)
363
+ module_path = str(path) # absolute path of the loaded entry file
364
+
365
+ def _apply(key: str, value: Any) -> None:
366
+ # Persist to settings.json, then reload just this extension so the
367
+ # change takes effect live without re-running other extensions.
368
+ settings.set_extension_config_key(settings_path, key, value)
369
+ runtime = self._runtime_ref.runtime if self._runtime_ref is not None else None
370
+ if runtime is None:
371
+ return
372
+ reload_one = getattr(runtime, "reload_extension", None)
373
+ if reload_one is not None:
374
+ asyncio.ensure_future(reload_one(module_path))
375
+ else: # fall back to full reload if single-extension reload is unavailable
376
+ reload_all = getattr(runtime, "reload_extensions", None)
377
+ if reload_all is not None:
378
+ asyncio.ensure_future(reload_all())
379
+
380
+ reg = build_manifest_panel(
381
+ schema, ext.config,
382
+ default_title=ext_dir.name,
383
+ apply=_apply,
384
+ )
385
+ if reg is not None:
386
+ ext.settings_registrations.append(reg)
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import traceback
5
+ from typing import TYPE_CHECKING, Any, Callable
6
+
7
+ from tau.extensions.api import (
8
+ Extension, ExtensionError, LoadExtensionsResult,
9
+ ShortcutRegistration, _RuntimeRef,
10
+ )
11
+
12
+ if TYPE_CHECKING:
13
+ from tau.commands.types import CommandInfo
14
+ from tau.hooks.service import Hooks
15
+ from tau.tool.types import Tool
16
+
17
+
18
+ # Events where handler return values matter for interception.
19
+ # These are registered directly on the hooks bus (not via the catch-all subscriber)
20
+ # so that Hooks.emit() collects their results.
21
+ _INTERCEPTABLE_EVENTS: frozenset[str] = frozenset({
22
+ "before_compaction",
23
+ "user_terminal",
24
+ "resources_discover",
25
+ "project_trust",
26
+ "input",
27
+ "tool_result",
28
+ "context",
29
+ })
30
+
31
+
32
+ class ExtensionRuntime:
33
+ """
34
+ Owns all loaded extensions and dispatches lifecycle events to their handlers.
35
+
36
+ Subscribed as a catch-all listener on the hooks bus so that every event
37
+ emitted by the agent or runtime automatically reaches extension handlers —
38
+ no changes to the agent or engine are required.
39
+
40
+ Each handler is always called as ``handler(event, ctx)`` where ``ctx`` is a
41
+ fresh ``ExtensionContext`` snapshot built from the live runtime at dispatch
42
+ time. Handler exceptions are caught per-handler and appended to ``errors``.
43
+
44
+ Interceptable events (``_INTERCEPTABLE_EVENTS``) are registered directly on
45
+ the hooks bus rather than going through the catch-all subscriber, so their
46
+ return values are collected by ``Hooks.emit()`` and available for inspection
47
+ by the caller (e.g. ``before_compaction`` handlers that return
48
+ ``BeforeCompactionResult``).
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ load_result: LoadExtensionsResult,
54
+ hooks: Hooks,
55
+ runtime_ref: _RuntimeRef,
56
+ ) -> None:
57
+ self._extensions: list[Extension] = load_result.extensions
58
+ self._errors: list[ExtensionError] = list(load_result.errors)
59
+ self.runtime_ref: _RuntimeRef = runtime_ref
60
+ self._unsub = hooks.subscribe(self._dispatch)
61
+
62
+ # Register interceptable handlers directly so their results flow back
63
+ # through Hooks.emit() rather than being discarded by the subscriber path.
64
+ self._interceptable_unsubs: list[Callable[[], None]] = []
65
+ for ext in self._extensions:
66
+ for event_type in _INTERCEPTABLE_EVENTS:
67
+ for handler in ext.handlers.get(event_type, []):
68
+ wrapped = self._make_interceptable_handler(ext, handler)
69
+ self._interceptable_unsubs.append(hooks.register(event_type, wrapped))
70
+
71
+ # ── Dispatch ──────────────────────────────────────────────────────────────
72
+
73
+ def _make_interceptable_handler(self, ext: Extension, handler: Callable) -> Callable:
74
+ """Return a hooks-compatible wrapper that injects ctx and propagates the return value."""
75
+ async def wrapped(event: Any) -> Any:
76
+ """Invoke handler with extension context."""
77
+ from tau.extensions.context import ExtensionContext
78
+ runtime = self.runtime_ref.runtime
79
+ ctx = ExtensionContext.from_runtime(runtime) if runtime is not None else None
80
+ try:
81
+ result = handler(event, ctx)
82
+ if inspect.isawaitable(result):
83
+ result = await result
84
+ return result
85
+ except Exception:
86
+ tb = traceback.format_exc()
87
+ self._errors.append(ExtensionError(
88
+ extension_path=ext.path,
89
+ event=getattr(event, "type", "unknown"),
90
+ error=tb.strip().splitlines()[-1],
91
+ stack=tb,
92
+ ))
93
+ return None
94
+
95
+ return wrapped
96
+
97
+ async def _dispatch(self, event: Any) -> None:
98
+ """Catch-all hooks subscriber — re-dispatches every event to extension handlers.
99
+
100
+ Interceptable events are skipped here; they are already handled by
101
+ directly-registered hooks so their return values reach Hooks.emit().
102
+ """
103
+ event_type: str | None = getattr(event, "type", None)
104
+ if not event_type:
105
+ return
106
+ if event_type in _INTERCEPTABLE_EVENTS:
107
+ return
108
+
109
+ from tau.extensions.context import ExtensionContext
110
+ runtime = self.runtime_ref.runtime
111
+ ctx = ExtensionContext.from_runtime(runtime) if runtime is not None else None
112
+
113
+ for ext in self._extensions:
114
+ for handler in ext.handlers.get(event_type, []):
115
+ try:
116
+ result = handler(event, ctx)
117
+ if inspect.isawaitable(result):
118
+ await result
119
+ except Exception:
120
+ tb = traceback.format_exc()
121
+ self._errors.append(ExtensionError(
122
+ extension_path=ext.path,
123
+ event=event_type,
124
+ error=tb.strip().splitlines()[-1],
125
+ stack=tb,
126
+ ))
127
+
128
+ def unsubscribe(self) -> None:
129
+ """Detach from the hooks bus (called before hot-reload replaces this runtime)."""
130
+ self._unsub()
131
+ for unsub in self._interceptable_unsubs:
132
+ unsub()
133
+ self._interceptable_unsubs.clear()
134
+
135
+ # ── Errors ────────────────────────────────────────────────────────────────
136
+
137
+ @property
138
+ def errors(self) -> list[ExtensionError]:
139
+ """All accumulated load and dispatch errors."""
140
+ return self._errors
141
+
142
+ # ── Accessors ─────────────────────────────────────────────────────────────
143
+
144
+ def get_tools(self) -> list[Tool]:
145
+ """Return all tools registered by extensions (last-writer-wins on name)."""
146
+ tools: dict[str, Any] = {}
147
+ for ext in self._extensions:
148
+ tools.update(ext.tools)
149
+ return list(tools.values())
150
+
151
+ def get_commands(self) -> list[CommandInfo]:
152
+ """Return all slash commands registered by extensions (last-writer-wins on name)."""
153
+ commands: dict[str, Any] = {}
154
+ for ext in self._extensions:
155
+ commands.update(ext.commands)
156
+ return list(commands.values())
157
+
158
+ def get_shortcuts(self) -> list[ShortcutRegistration]:
159
+ """Return all keyboard shortcuts registered by extensions."""
160
+ result: list[ShortcutRegistration] = []
161
+ for ext in self._extensions:
162
+ result.extend(ext.shortcuts)
163
+ return result
164
+
165
+ def get_prompt_appends(self) -> list[str]:
166
+ """Return all system-prompt additions registered by extensions."""
167
+ result: list[str] = []
168
+ for ext in self._extensions:
169
+ result.extend(ext.prompt_appends)
170
+ return result
171
+
172
+ def get_message_renderers(self) -> dict[str, Any]:
173
+ """Return merged message renderer registry (last-registered wins per type)."""
174
+ result: dict[str, Any] = {}
175
+ for ext in self._extensions:
176
+ result.update(ext.message_renderers)
177
+ return result
178
+
179
+ def get_autocomplete_providers(self) -> list[Any]:
180
+ """Return all autocomplete providers registered by extensions."""
181
+ result: list[Any] = []
182
+ for ext in self._extensions:
183
+ result.extend(ext.autocomplete_providers)
184
+ return result