From 5f8688558ef3759932a887d7d1aadd63b560f317 Mon Sep 17 00:00:00 2001 From: Amir Fathi Date: Fri, 21 Aug 2026 04:44:28 +0000 Subject: [PATCH 1/3] Add ExtensionContext.paths to expose resolved TauPaths ExtensionRuntime.load() received a TauResourcePaths carrying the resolved TauPaths but never stored it, so ExtensionContext had no way to hand extensions the same home directory a host may have moved with TauPaths(home=...). Extensions had to hardcode "~/.tau". Store the resolved paths on the runtime with the same fallback used in session.py (paths.paths or TauPaths(home=paths.root)) and expose them through a new ExtensionContext.paths property, following the same generation check as the other context properties. Fixes #598 --- src/tau_coding/extensions/api.py | 7 +++++++ src/tau_coding/extensions/runtime.py | 7 +++++++ tests/test_extensions.py | 31 ++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/src/tau_coding/extensions/api.py b/src/tau_coding/extensions/api.py index 192e5f415..9b3ed6431 100644 --- a/src/tau_coding/extensions/api.py +++ b/src/tau_coding/extensions/api.py @@ -20,6 +20,7 @@ from tau_coding.extensions.providers import DynamicProvider from tau_coding.extensions.runtime import ExtensionRuntime from tau_coding.local_backends import LocalBackend + from tau_coding.paths import TauPaths from tau_coding.tui.config import TuiTheme AGENT_EVENT_TYPES: frozenset[str] = frozenset( @@ -890,6 +891,12 @@ def cwd(self) -> Path: self._generation.assert_active() return self._runtime.session_view.cwd + @property + def paths(self) -> TauPaths: + """Return the resolved Tau filesystem paths for this session.""" + self._generation.assert_active() + return self._runtime.paths + @property def model(self) -> str: """Return the active model name.""" diff --git a/src/tau_coding/extensions/runtime.py b/src/tau_coding/extensions/runtime.py index adbc4c590..41bd13938 100644 --- a/src/tau_coding/extensions/runtime.py +++ b/src/tau_coding/extensions/runtime.py @@ -248,6 +248,7 @@ def __init__( self._renderer_failures_reported: set[str] = set() self._load_diagnostics: list[ResourceDiagnostic] = [] self._runtime_diagnostics: list[ResourceDiagnostic] = [] + self._paths: TauPaths = TauPaths() self._session: BoundSession | None = None self._ui: UiBridge = ui or NullUiBridge() self._turn_requested: TurnRequestedCallback | None = None @@ -266,6 +267,7 @@ def load( include_user_dir: bool = True, ) -> None: """Load built-ins, then discover extensions and run isolated setup.""" + self._paths = paths.paths or TauPaths(home=paths.root) self._load_built_ins() result = load_extensions( paths, @@ -851,6 +853,11 @@ def local_backend_registry(self) -> LocalBackendRegistry: """Return this staged runtime generation's local-backend registry.""" return self._local_backend_registry + @property + def paths(self) -> TauPaths: + """Return the resolved Tau filesystem paths for this runtime.""" + return self._paths + @property def extension_names(self) -> tuple[str, ...]: """Return visible extension names in load order.""" diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 3b770efff..26723fd7e 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1939,6 +1939,25 @@ async def executor( return AgentTool(name=name, label=name, description="d", parameters={}, execute_fn=executor) +def test_context_paths_exposes_resolved_paths_from_load(tmp_path: Path) -> None: + runtime = ExtensionRuntime() + resolved = TauPaths(home=tmp_path / "custom-home") + runtime.load(TauResourcePaths(root=tmp_path / "unused-root", paths=resolved)) + api = cast(ExtensionAPI, _register_inline_extension(runtime, "reader")) + runtime.bind(RecordingSession(tmp_path)) + + assert api.context.paths is resolved + + +def test_context_paths_falls_back_to_resource_root_home(tmp_path: Path) -> None: + runtime = ExtensionRuntime() + runtime.load(TauResourcePaths(root=tmp_path / "home-tau")) + api = cast(ExtensionAPI, _register_inline_extension(runtime, "reader")) + runtime.bind(RecordingSession(tmp_path)) + + assert api.context.paths.home == tmp_path / "home-tau" + + # -- coding-session integration --------------------------------------------------- @@ -2060,6 +2079,16 @@ async def test_session_lifecycle_recreates_built_in_in_a_fresh_generation( await session.aclose() +async def test_session_exposes_extension_paths_at_moved_home(tmp_path: Path) -> None: + await CodingSession.load( + _session_config(tmp_path, FakeProvider([]), extension_body=API_CAPTURING_EXTENSION) + ) + module = _loaded_extension_module("integration") + api = cast(ExtensionAPI, module.APIS[-1]) # type: ignore[attr-defined] + + assert api.context.paths.home == tmp_path / "home-tau" + + async def test_session_exposes_extension_tools_and_commands(tmp_path: Path) -> None: body = HELLO_TOOL_EXTENSION + ( "\n\ndef _cmd(args, context):\n" @@ -2871,6 +2900,8 @@ async def test_reset_for_reload_invalidates_prior_context_and_ui(tmp_path: Path) with pytest.raises(ExtensionError, match="stale after reload"): _ = context.cwd + with pytest.raises(ExtensionError, match="stale after reload"): + _ = context.paths with pytest.raises(ExtensionError, match="stale after reload"): _ = context.transcript with pytest.raises(ExtensionError, match="stale after reload"): From cc310eb90a7c5ddeab8a25d75c4fbecc1db79b2f Mon Sep 17 00:00:00 2001 From: Alejandro Date: Sun, 6 Sep 2026 18:18:57 +0200 Subject: [PATCH 2/3] Fix extension context path resolution --- dev-notes/architecture/phase-21-extensions.md | 17 ++++++++++++++ src/tau_coding/data/docs/extensions.md | 18 +++++++++++++++ src/tau_coding/extensions/runtime.py | 9 ++++++-- tests/test_extensions.py | 23 +++++++++++++++---- website/content/guides/extensions.md | 18 +++++++++++++++ 5 files changed, 78 insertions(+), 7 deletions(-) diff --git a/dev-notes/architecture/phase-21-extensions.md b/dev-notes/architecture/phase-21-extensions.md index 09c27036d..861b42bb7 100644 --- a/dev-notes/architecture/phase-21-extensions.md +++ b/dev-notes/architecture/phase-21-extensions.md @@ -183,6 +183,23 @@ class ExtensionAPI: over the bound `CodingSession`; action methods raise `ExtensionError` if called before binding (Pi's throwing-stubs-then-`bindCore` model). +`context.paths -> TauPaths` is the resolved, read-only filesystem-path snapshot +for the active extension generation. A host-supplied `TauResourcePaths.paths` +is authoritative, preserving custom `TauPaths.home` and `TauPaths.agents_home`. +When it is absent, the runtime derives `TauPaths(home=resource_paths.root, +agents_home=resource_paths.agents_root or ~/.agents)`. This keeps Tau's +`root`/`home` (user data and extension discovery) distinct from +`agents_root`/`agents_home` (`.agents` resources) and from project `cwd`. +`ExtensionRuntime(paths=custom_paths)` exposes its constructor value immediately; +`load` then replaces it with the loaded resource snapshot. Consequently, a +custom setup can intentionally use separate Tau and `.agents` roots without a +path architecture rewrite. + +The snapshot is generation-scoped. `/reload` and fresh-generation session +replacement invalidate the old context, so even reading `context.paths` from a +captured old context raises `ExtensionError`; handlers must read the new +context's paths after the replacement. + `transcript -> tuple[AgentMessage, ...]` gives read access to the active-path parent conversation (`CodingSession.messages`). It is the Tau analogue of the only conversation surface Pi hands extensions — `ctx.sessionManager.getBranch()` diff --git a/src/tau_coding/data/docs/extensions.md b/src/tau_coding/data/docs/extensions.md index 4ea727763..aa460d1b7 100644 --- a/src/tau_coding/data/docs/extensions.md +++ b/src/tau_coding/data/docs/extensions.md @@ -80,6 +80,24 @@ Project extensions cannot approve themselves. They execute arbitrary Python and remain disabled without both approval and the explicit code opt-in. Trust is not a process/filesystem/network/tool/model sandbox. +## Resolved filesystem paths + +`tau.context.paths` is a read-only `TauPaths` snapshot for the active session. +If `TauResourcePaths.paths` was supplied by the host, it is authoritative and +preserves custom `TauPaths.home` and `TauPaths.agents_home` locations. Without +an explicit `TauPaths`, Tau derives one as +`TauPaths(home=resource_paths.root, agents_home=resource_paths.agents_root or ~/.agents)`. +Thus `root`/`home` controls Tau's user data and extension directory, while +`agents_root`/`agents_home` controls `.agents` resources; the project `cwd` +remains separate. `ExtensionRuntime(paths=custom_paths)` exposes those +constructor paths immediately, before `load`; a later `load` makes its +`TauResourcePaths` snapshot authoritative. + +The snapshot belongs to the extension generation. After `/reload` (and other +fresh-generation replacement flows), a context captured from the outgoing +generation is stale: even reading `context.paths` raises `ExtensionError`. Read +`context.paths` again from the new generation's context. + ## Dynamic providers `tau.register_provider(DynamicProvider(...))` adds a frontend-free provider layer diff --git a/src/tau_coding/extensions/runtime.py b/src/tau_coding/extensions/runtime.py index 41bd13938..04662d232 100644 --- a/src/tau_coding/extensions/runtime.py +++ b/src/tau_coding/extensions/runtime.py @@ -248,7 +248,9 @@ def __init__( self._renderer_failures_reported: set[str] = set() self._load_diagnostics: list[ResourceDiagnostic] = [] self._runtime_diagnostics: list[ResourceDiagnostic] = [] - self._paths: TauPaths = TauPaths() + # Keep constructor-provided paths visible until ``load`` installs the + # authoritative resource-path snapshot. + self._paths: TauPaths = paths or TauPaths() self._session: BoundSession | None = None self._ui: UiBridge = ui or NullUiBridge() self._turn_requested: TurnRequestedCallback | None = None @@ -267,7 +269,10 @@ def load( include_user_dir: bool = True, ) -> None: """Load built-ins, then discover extensions and run isolated setup.""" - self._paths = paths.paths or TauPaths(home=paths.root) + self._paths = paths.paths or TauPaths( + home=paths.root, + agents_home=paths.agents_root or Path.home() / ".agents", + ) self._load_built_ins() result = load_extensions( paths, diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 26723fd7e..c7b0d9bee 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1939,9 +1939,16 @@ async def executor( return AgentTool(name=name, label=name, description="d", parameters={}, execute_fn=executor) -def test_context_paths_exposes_resolved_paths_from_load(tmp_path: Path) -> None: - runtime = ExtensionRuntime() - resolved = TauPaths(home=tmp_path / "custom-home") +def test_context_paths_exposes_constructor_paths_during_extension_use(tmp_path: Path) -> None: + resolved = TauPaths( + home=tmp_path / "custom-home", + agents_home=tmp_path / "custom-agents", + ) + runtime = ExtensionRuntime(paths=resolved) + + # Constructor paths are usable before loading and remain authoritative when + # the resource snapshot explicitly carries the same custom paths. + assert runtime.paths is resolved runtime.load(TauResourcePaths(root=tmp_path / "unused-root", paths=resolved)) api = cast(ExtensionAPI, _register_inline_extension(runtime, "reader")) runtime.bind(RecordingSession(tmp_path)) @@ -1949,13 +1956,19 @@ def test_context_paths_exposes_resolved_paths_from_load(tmp_path: Path) -> None: assert api.context.paths is resolved -def test_context_paths_falls_back_to_resource_root_home(tmp_path: Path) -> None: +def test_context_paths_falls_back_to_resource_root_and_agents_root(tmp_path: Path) -> None: runtime = ExtensionRuntime() - runtime.load(TauResourcePaths(root=tmp_path / "home-tau")) + runtime.load( + TauResourcePaths( + root=tmp_path / "home-tau", + agents_root=tmp_path / "home-agents", + ) + ) api = cast(ExtensionAPI, _register_inline_extension(runtime, "reader")) runtime.bind(RecordingSession(tmp_path)) assert api.context.paths.home == tmp_path / "home-tau" + assert api.context.paths.agents_home == tmp_path / "home-agents" # -- coding-session integration --------------------------------------------------- diff --git a/website/content/guides/extensions.md b/website/content/guides/extensions.md index 3def63dbc..78907c8eb 100644 --- a/website/content/guides/extensions.md +++ b/website/content/guides/extensions.md @@ -213,6 +213,24 @@ or async and always receive `(event, context)`; the context is freshly created for each dispatch. Action methods raise `ExtensionError` if called before the session is bound — register handlers in `setup` and act on events instead. +#### Resolved filesystem paths + +`tau.context.paths` is a read-only `TauPaths` snapshot for the active session. +If the host supplies `TauResourcePaths.paths`, that object is authoritative and +preserves custom `TauPaths.home` and `TauPaths.agents_home` locations. Otherwise +Tau derives one as +`TauPaths(home=resource_paths.root, agents_home=resource_paths.agents_root or ~/.agents)`. +In other words, `root`/`home` controls Tau's user data and extension directory, +while `agents_root`/`agents_home` controls `.agents` resources; the project +`cwd` remains separate. `ExtensionRuntime(paths=custom_paths)` exposes its +constructor paths immediately, before `load`; a later `load` makes its +`TauResourcePaths` snapshot authoritative. + +The snapshot belongs to the extension generation. After `/reload` (and other +fresh-generation replacement flows), a context captured from the outgoing +generation is stale: even reading `context.paths` raises `ExtensionError`. Read +`context.paths` again from the new generation's context. + ### Local-backend registrations An extension can pair a provider layer with a provider-neutral local backend: From 2393889d7cb0202526af4492774d1d59bdcc254a Mon Sep 17 00:00:00 2001 From: Alejandro Date: Sun, 6 Sep 2026 18:18:57 +0200 Subject: [PATCH 3/3] Fix extension context path resolution --- dev-notes/architecture/phase-21-extensions.md | 17 ++++++++++++++ src/tau_coding/data/docs/extensions.md | 18 +++++++++++++++ src/tau_coding/extensions/runtime.py | 9 ++++++-- tests/test_extensions.py | 23 +++++++++++++++---- website/content/guides/extensions.md | 19 +++++++++++++++ 5 files changed, 79 insertions(+), 7 deletions(-) diff --git a/dev-notes/architecture/phase-21-extensions.md b/dev-notes/architecture/phase-21-extensions.md index 09c27036d..861b42bb7 100644 --- a/dev-notes/architecture/phase-21-extensions.md +++ b/dev-notes/architecture/phase-21-extensions.md @@ -183,6 +183,23 @@ class ExtensionAPI: over the bound `CodingSession`; action methods raise `ExtensionError` if called before binding (Pi's throwing-stubs-then-`bindCore` model). +`context.paths -> TauPaths` is the resolved, read-only filesystem-path snapshot +for the active extension generation. A host-supplied `TauResourcePaths.paths` +is authoritative, preserving custom `TauPaths.home` and `TauPaths.agents_home`. +When it is absent, the runtime derives `TauPaths(home=resource_paths.root, +agents_home=resource_paths.agents_root or ~/.agents)`. This keeps Tau's +`root`/`home` (user data and extension discovery) distinct from +`agents_root`/`agents_home` (`.agents` resources) and from project `cwd`. +`ExtensionRuntime(paths=custom_paths)` exposes its constructor value immediately; +`load` then replaces it with the loaded resource snapshot. Consequently, a +custom setup can intentionally use separate Tau and `.agents` roots without a +path architecture rewrite. + +The snapshot is generation-scoped. `/reload` and fresh-generation session +replacement invalidate the old context, so even reading `context.paths` from a +captured old context raises `ExtensionError`; handlers must read the new +context's paths after the replacement. + `transcript -> tuple[AgentMessage, ...]` gives read access to the active-path parent conversation (`CodingSession.messages`). It is the Tau analogue of the only conversation surface Pi hands extensions — `ctx.sessionManager.getBranch()` diff --git a/src/tau_coding/data/docs/extensions.md b/src/tau_coding/data/docs/extensions.md index 4ea727763..aa460d1b7 100644 --- a/src/tau_coding/data/docs/extensions.md +++ b/src/tau_coding/data/docs/extensions.md @@ -80,6 +80,24 @@ Project extensions cannot approve themselves. They execute arbitrary Python and remain disabled without both approval and the explicit code opt-in. Trust is not a process/filesystem/network/tool/model sandbox. +## Resolved filesystem paths + +`tau.context.paths` is a read-only `TauPaths` snapshot for the active session. +If `TauResourcePaths.paths` was supplied by the host, it is authoritative and +preserves custom `TauPaths.home` and `TauPaths.agents_home` locations. Without +an explicit `TauPaths`, Tau derives one as +`TauPaths(home=resource_paths.root, agents_home=resource_paths.agents_root or ~/.agents)`. +Thus `root`/`home` controls Tau's user data and extension directory, while +`agents_root`/`agents_home` controls `.agents` resources; the project `cwd` +remains separate. `ExtensionRuntime(paths=custom_paths)` exposes those +constructor paths immediately, before `load`; a later `load` makes its +`TauResourcePaths` snapshot authoritative. + +The snapshot belongs to the extension generation. After `/reload` (and other +fresh-generation replacement flows), a context captured from the outgoing +generation is stale: even reading `context.paths` raises `ExtensionError`. Read +`context.paths` again from the new generation's context. + ## Dynamic providers `tau.register_provider(DynamicProvider(...))` adds a frontend-free provider layer diff --git a/src/tau_coding/extensions/runtime.py b/src/tau_coding/extensions/runtime.py index 41bd13938..04662d232 100644 --- a/src/tau_coding/extensions/runtime.py +++ b/src/tau_coding/extensions/runtime.py @@ -248,7 +248,9 @@ def __init__( self._renderer_failures_reported: set[str] = set() self._load_diagnostics: list[ResourceDiagnostic] = [] self._runtime_diagnostics: list[ResourceDiagnostic] = [] - self._paths: TauPaths = TauPaths() + # Keep constructor-provided paths visible until ``load`` installs the + # authoritative resource-path snapshot. + self._paths: TauPaths = paths or TauPaths() self._session: BoundSession | None = None self._ui: UiBridge = ui or NullUiBridge() self._turn_requested: TurnRequestedCallback | None = None @@ -267,7 +269,10 @@ def load( include_user_dir: bool = True, ) -> None: """Load built-ins, then discover extensions and run isolated setup.""" - self._paths = paths.paths or TauPaths(home=paths.root) + self._paths = paths.paths or TauPaths( + home=paths.root, + agents_home=paths.agents_root or Path.home() / ".agents", + ) self._load_built_ins() result = load_extensions( paths, diff --git a/tests/test_extensions.py b/tests/test_extensions.py index 26723fd7e..c7b0d9bee 100644 --- a/tests/test_extensions.py +++ b/tests/test_extensions.py @@ -1939,9 +1939,16 @@ async def executor( return AgentTool(name=name, label=name, description="d", parameters={}, execute_fn=executor) -def test_context_paths_exposes_resolved_paths_from_load(tmp_path: Path) -> None: - runtime = ExtensionRuntime() - resolved = TauPaths(home=tmp_path / "custom-home") +def test_context_paths_exposes_constructor_paths_during_extension_use(tmp_path: Path) -> None: + resolved = TauPaths( + home=tmp_path / "custom-home", + agents_home=tmp_path / "custom-agents", + ) + runtime = ExtensionRuntime(paths=resolved) + + # Constructor paths are usable before loading and remain authoritative when + # the resource snapshot explicitly carries the same custom paths. + assert runtime.paths is resolved runtime.load(TauResourcePaths(root=tmp_path / "unused-root", paths=resolved)) api = cast(ExtensionAPI, _register_inline_extension(runtime, "reader")) runtime.bind(RecordingSession(tmp_path)) @@ -1949,13 +1956,19 @@ def test_context_paths_exposes_resolved_paths_from_load(tmp_path: Path) -> None: assert api.context.paths is resolved -def test_context_paths_falls_back_to_resource_root_home(tmp_path: Path) -> None: +def test_context_paths_falls_back_to_resource_root_and_agents_root(tmp_path: Path) -> None: runtime = ExtensionRuntime() - runtime.load(TauResourcePaths(root=tmp_path / "home-tau")) + runtime.load( + TauResourcePaths( + root=tmp_path / "home-tau", + agents_root=tmp_path / "home-agents", + ) + ) api = cast(ExtensionAPI, _register_inline_extension(runtime, "reader")) runtime.bind(RecordingSession(tmp_path)) assert api.context.paths.home == tmp_path / "home-tau" + assert api.context.paths.agents_home == tmp_path / "home-agents" # -- coding-session integration --------------------------------------------------- diff --git a/website/content/guides/extensions.md b/website/content/guides/extensions.md index 3def63dbc..22b448f30 100644 --- a/website/content/guides/extensions.md +++ b/website/content/guides/extensions.md @@ -184,6 +184,7 @@ def setup(tau): tau.context.inference_provider_mode # "automatic" or "fixed" tau.context.session_id, tau.context.session_name tau.context.thinking_level, tau.context.system_prompt + tau.context.paths # resolved TauPaths snapshot tau.context.is_running, tau.context.has_ui tau.context.transcript # parent conversation, deep-copied AgentMessages @@ -213,6 +214,24 @@ or async and always receive `(event, context)`; the context is freshly created for each dispatch. Action methods raise `ExtensionError` if called before the session is bound — register handlers in `setup` and act on events instead. +#### Resolved filesystem paths + +`tau.context.paths` is a read-only `TauPaths` snapshot for the active session. +If the host supplies `TauResourcePaths.paths`, that object is authoritative and +preserves custom `TauPaths.home` and `TauPaths.agents_home` locations. Otherwise +Tau derives one as +`TauPaths(home=resource_paths.root, agents_home=resource_paths.agents_root or ~/.agents)`. +In other words, `root`/`home` controls Tau's user data and extension directory, +while `agents_root`/`agents_home` controls `.agents` resources; the project +`cwd` remains separate. `ExtensionRuntime(paths=custom_paths)` exposes its +constructor paths immediately, before `load`; a later `load` makes its +`TauResourcePaths` snapshot authoritative. + +The snapshot belongs to the extension generation. After `/reload` (and other +fresh-generation replacement flows), a context captured from the outgoing +generation is stale: even reading `context.paths` raises `ExtensionError`. Read +`context.paths` again from the new generation's context. + ### Local-backend registrations An extension can pair a provider layer with a provider-neutral local backend: