Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 4 additions & 3 deletions docs/copilot-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion plugin.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/agent_trace/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""agent-trace: strace for AI agents."""

__version__ = "0.94.0"
__version__ = "0.94.1"
28 changes: 27 additions & 1 deletion src/agent_trace/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
import os
import sys
import time
from contextvars import ContextVar
from pathlib import Path

from .models import EventType, SessionMeta, TraceEvent
Expand All @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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)
89 changes: 89 additions & 0 deletions tests/test_copilot_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading