From 7f6ced9976388d3b11d2865cc15ccf06d26eb38a Mon Sep 17 00:00:00 2001 From: Siddhant Khare Date: Sun, 23 Aug 2026 00:30:41 +0530 Subject: [PATCH] fix: store plugin traces in payload workspace Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/plugin/marketplace.json | 4 +- docs/copilot-plugin.md | 7 +-- plugin.json | 2 +- src/agent_trace/__init__.py | 2 +- src/agent_trace/hooks.py | 28 ++++++++++- tests/test_copilot_hooks.py | 89 +++++++++++++++++++++++++++++++++ 6 files changed, 124 insertions(+), 8 deletions(-) diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 987947d..49557ab 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -6,13 +6,13 @@ }, "metadata": { "description": "GitHub Copilot CLI plugins for agent observability.", - "version": "0.94.0" + "version": "0.94.1" }, "plugins": [ { "name": "agent-strace", "description": "Capture, replay, and analyze GitHub Copilot CLI agent sessions.", - "version": "0.94.0", + "version": "0.94.1", "source": ".", "author": { "name": "Siddhant Khare", diff --git a/docs/copilot-plugin.md b/docs/copilot-plugin.md index d01c63a..87842d4 100644 --- a/docs/copilot-plugin.md +++ b/docs/copilot-plugin.md @@ -30,9 +30,10 @@ copilot plugin list agent-strace --version ``` -In an interactive Copilot CLI session, use `/agent` to select `trace-analyst` or -run `/skills list` to confirm the `agent-strace` skill loaded. New sessions are -written to `.agent-traces/` in the working directory. +In an interactive Copilot CLI session, use `/agent` to select +`agent-strace:trace-analyst` or run `/skills list` to confirm the `agent-strace` +skill loaded. New sessions are written to `.agent-traces/` in the working +directory. ## Use diff --git a/plugin.json b/plugin.json index 4c17591..e16483d 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "name": "agent-strace", "description": "Capture, replay, and analyze GitHub Copilot CLI agent sessions.", - "version": "0.94.0", + "version": "0.94.1", "author": { "name": "Siddhant Khare", "email": "siddhantkhare2694@gmail.com" diff --git a/src/agent_trace/__init__.py b/src/agent_trace/__init__.py index 38e217c..d536bdc 100644 --- a/src/agent_trace/__init__.py +++ b/src/agent_trace/__init__.py @@ -1,3 +1,3 @@ """agent-trace: strace for AI agents.""" -__version__ = "0.94.0" +__version__ = "0.94.1" diff --git a/src/agent_trace/hooks.py b/src/agent_trace/hooks.py index 371a5ba..430764a 100644 --- a/src/agent_trace/hooks.py +++ b/src/agent_trace/hooks.py @@ -44,6 +44,7 @@ import os import sys import time +from contextvars import ContextVar from pathlib import Path from .models import EventType, SessionMeta, TraceEvent @@ -66,6 +67,10 @@ _GEMINI_SESSION_ID_ENV = "AGENT_TRACE_GEMINI_SESSION_ID" _CURSOR_SESSION_ID_ENV = "AGENT_TRACE_CURSOR_SESSION_ID" _COPILOT_SESSION_ID_ENV = "AGENT_TRACE_COPILOT_SESSION_ID" +_HOOK_STORE_DIR: ContextVar[str | None] = ContextVar( + "agent_trace_hook_store_dir", + default=None, +) _PROVIDER_ENV = { "claude": _CLAUDE_SESSION_ID_ENV, @@ -85,7 +90,25 @@ def _get_store_dir() -> str: - return os.environ.get("AGENT_TRACE_DIR", ".agent-traces") + if "AGENT_TRACE_DIR" in os.environ: + return os.environ["AGENT_TRACE_DIR"] + return _HOOK_STORE_DIR.get() or ".agent-traces" + + +def _payload_store_dir(input_data: dict) -> str | None: + """Resolve a safe workspace-local trace directory from a hook payload.""" + cwd = input_data.get("cwd") + if not isinstance(cwd, str) or not cwd: + return None + + try: + workspace = Path(cwd) + if not workspace.is_absolute() or not workspace.is_dir(): + return None + except (OSError, ValueError): + return None + + return str(workspace / ".agent-traces") def _get_store() -> TraceStore: @@ -735,9 +758,12 @@ def hook_main(args: list[str]) -> None: sys.stderr.write(f"Valid events: {', '.join(handlers.keys())}\n") sys.exit(1) + store_dir_token = _HOOK_STORE_DIR.set(_payload_store_dir(input_data)) try: handler(input_data) except Exception as e: # Hooks must not crash Claude Code. Log and exit cleanly. sys.stderr.write(f"agent-strace hook error: {e}\n") sys.exit(0) + finally: + _HOOK_STORE_DIR.reset(store_dir_token) diff --git a/tests/test_copilot_hooks.py b/tests/test_copilot_hooks.py index 6ed1b5e..94f3b93 100644 --- a/tests/test_copilot_hooks.py +++ b/tests/test_copilot_hooks.py @@ -187,6 +187,95 @@ def test_copilot_resume_preserves_existing_session_metadata(self): self.assertEqual(starts[-1].data["source"], "resume") +class TestHookPayloadStoreDirectory(unittest.TestCase): + def setUp(self): + self.tempdir = tempfile.TemporaryDirectory() + self.root = Path(self.tempdir.name) + self.process_cwd = self.root / "installed-plugin" + self.payload_cwd = self.root / "repository" + self.process_cwd.mkdir() + self.payload_cwd.mkdir() + self.original_cwd = Path.cwd() + os.environ.pop("AGENT_TRACE_DIR", None) + os.environ.pop("AGENT_TRACE_CLAUDE_SESSION_ID", None) + os.environ.pop("AGENT_TRACE_COPILOT_SESSION_ID", None) + os.chdir(self.process_cwd) + + def tearDown(self): + os.chdir(self.original_cwd) + os.environ.pop("AGENT_TRACE_DIR", None) + os.environ.pop("AGENT_TRACE_CLAUDE_SESSION_ID", None) + os.environ.pop("AGENT_TRACE_COPILOT_SESSION_ID", None) + self.tempdir.cleanup() + + def _dispatch_start(self, provider, payload): + with patch.object(sys, "stdin", io.StringIO(json.dumps(payload))): + hook_main(["--provider", provider, "session-start"]) + + def test_hook_main_uses_payload_cwd_for_camel_and_snake_payloads(self): + copilot_session = "copilot-cwd-session" + claude_session = "claude-cwd-session" + + self._dispatch_start("copilot", { + "sessionId": copilot_session, + "cwd": str(self.payload_cwd), + "source": "startup", + }) + self._dispatch_start("claude", { + "session_id": claude_session, + "cwd": str(self.payload_cwd), + "source": "startup", + }) + + store = TraceStore(self.payload_cwd / ".agent-traces") + self.assertIsNotNone(store.load_meta(copilot_session[:16])) + self.assertIsNotNone(store.load_meta(claude_session[:16])) + self.assertFalse((self.process_cwd / ".agent-traces").exists()) + + def test_explicit_trace_dir_takes_precedence_over_payload_cwd(self): + explicit_dir = self.root / "explicit-traces" + os.environ["AGENT_TRACE_DIR"] = str(explicit_dir) + session = "explicit-dir-session" + + self._dispatch_start("copilot", { + "sessionId": session, + "cwd": str(self.payload_cwd), + "source": "startup", + }) + + self.assertIsNotNone(TraceStore(explicit_dir).load_meta(session[:16])) + self.assertFalse((self.payload_cwd / ".agent-traces").exists()) + self.assertFalse((self.process_cwd / ".agent-traces").exists()) + + def test_invalid_payload_cwd_falls_back_without_using_invalid_path(self): + non_directory = self.root / "not-a-directory" + non_directory.write_text("not a directory") + relative_directory = self.process_cwd / "relative-directory" + relative_directory.mkdir() + invalid_cwds = ( + ("non-string", [str(self.payload_cwd)]), + ("non-directory", str(non_directory)), + ("relative", relative_directory.name), + ) + + for index, (case, cwd) in enumerate(invalid_cwds): + session = f"invalid-cwd-{index}-session" + with self.subTest(case=case): + self._dispatch_start("copilot", { + "sessionId": session, + "cwd": cwd, + "source": "startup", + }) + self.assertIsNotNone( + TraceStore(self.process_cwd / ".agent-traces").load_meta( + session[:16] + ) + ) + + self.assertFalse((non_directory / ".agent-traces").exists()) + self.assertFalse((relative_directory / ".agent-traces").exists()) + + class TestCopilotSetup(unittest.TestCase): def setUp(self): self.tmpdir = tempfile.mkdtemp()