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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ uvx agent-strace replay

```bash
agent-strace setup # Claude Code hooks for ~/.claude/settings.json
agent-strace setup --cli codex # OpenAI Codex hooks for ~/.codex/hooks.json
agent-strace setup --cli codex # OpenAI Codex hooks in ~/.codex/hooks.json
agent-strace setup --cli gemini # Gemini CLI extension under ~/.gemini/extensions
agent-strace setup --cli cursor # Cursor project hooks in .cursor/hooks.json
agent-strace setup --cli copilot # GitHub Copilot CLI hooks in ~/.copilot/hooks
agent-strace list # list sessions
agent-strace replay # replay the latest
```
Expand Down Expand Up @@ -148,7 +149,7 @@ Install **agent-strace** from the [Extensions panel](https://open-vsx.org/extens

```bash
pip install agent-strace # 1. install
agent-strace setup # 2. add hooks to Claude Code; use --cli codex, gemini, or cursor for other CLIs
agent-strace setup # 2. add hooks to Claude Code; use --cli codex, gemini, cursor, or copilot for other CLIs
# 3. open project in VS Code — extension activates when .agent-traces/ exists
# 4. start Claude Code — status bar appears immediately
```
Expand Down
4 changes: 2 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ Capture an MCP HTTP/SSE server session. Listens on `--port` (default: 3100) and

### `setup`
```
agent-strace setup [--cli claude|codex|gemini|cursor|all] [--no-redact] [--global]
agent-strace setup [--cli claude|codex|gemini|cursor|copilot|all] [--no-redact] [--global]
```
Print or install hooks config for supported agent CLIs. `--cli claude` prints Claude Code settings JSON for `~/.claude/settings.json`; `--cli codex` prints OpenAI Codex hooks JSON for `~/.codex/hooks.json`; `--cli gemini` writes a Gemini CLI extension under `$GEMINI_CONFIG_DIR/extensions/agent-strace` or `~/.gemini/extensions/agent-strace`; `--cli cursor` writes `.cursor/hooks.json` or `$CURSOR_CONFIG_DIR/hooks.json`; `--cli all` configures all supported CLIs. Secret redaction is enabled by default; use `--no-redact` only for trusted local traces.
Print or install hooks config for supported agent CLIs. `--cli claude` prints Claude Code settings JSON for `~/.claude/settings.json`; `--cli codex` writes `$CODEX_CONFIG_DIR/hooks.json` or `~/.codex/hooks.json`; `--cli gemini` writes a Gemini CLI extension under `$GEMINI_CONFIG_DIR/extensions/agent-strace` or `~/.gemini/extensions/agent-strace`; `--cli cursor` writes `.cursor/hooks.json` or `$CURSOR_CONFIG_DIR/hooks.json`; `--cli copilot` writes `$COPILOT_HOME/hooks/agent-strace.json` or `~/.copilot/hooks/agent-strace.json`; `--cli all` configures all supported CLIs. Secret redaction is enabled by default; use `--no-redact` only for trusted local traces.

### `import`
```
Expand Down
1 change: 1 addition & 0 deletions docs/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ Use setup-generated hooks when the agent CLI has its own lifecycle hook system.
| OpenAI Codex | `agent-strace setup --cli codex` | Session start, user prompts, assistant responses, `PreToolUse`/`PostToolUse` tools |
| Gemini CLI | `agent-strace setup --cli gemini` | Session start/end, prompts, assistant responses, `BeforeTool`/`AfterTool` tools |
| Cursor | `agent-strace setup --cli cursor` | Session start/end, prompts, shell execution, file edits, assistant responses when emitted by Cursor hooks |
| GitHub Copilot CLI | `agent-strace setup --cli copilot` | Session starts, prompts, hook-visible tool calls/results, and stop payloads when emitted by Copilot hooks |

All paths write the same event stream under `.agent-traces/`, so replay, timeline, explain, why, watch, export, and audit commands work the same way after capture.

Expand Down
35 changes: 33 additions & 2 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Captures the lifecycle events exposed by each CLI: user prompts, assistant respo
# Generate Claude Code hooks config
agent-strace setup

# Generate OpenAI Codex hooks config
# Install OpenAI Codex user-level hooks
agent-strace setup --cli codex

# Install Gemini CLI extension hooks
Expand All @@ -21,6 +21,9 @@ agent-strace setup --cli gemini
# Install Cursor project hooks
agent-strace setup --cli cursor

# Install GitHub Copilot CLI user-level hooks
agent-strace setup --cli copilot

# Configure all supported hook integrations
agent-strace setup --cli all

Expand Down Expand Up @@ -57,7 +60,7 @@ agent-strace explain # plain-English summary

### OpenAI Codex hooks

`agent-strace setup --cli codex` prints hooks JSON for `~/.codex/hooks.json`:
`agent-strace setup --cli codex` writes user-level hooks to `$CODEX_CONFIG_DIR/hooks.json` or `~/.codex/hooks.json`, and also prints the JSON:

```json
{
Expand Down Expand Up @@ -135,6 +138,34 @@ Set `CURSOR_CONFIG_DIR` to write the file somewhere else. The generated config r

Cursor hook coverage depends on the events Cursor emits. Native hooks capture prompts, shell commands, file edits, and agent responses when available. MCP server tool calls are still captured most reliably through the MCP proxy configuration below.

### GitHub Copilot CLI hooks

`agent-strace setup --cli copilot` writes user-level Copilot hooks:

```
~/.copilot/
└── hooks/
└── agent-strace.json
```

Set `COPILOT_HOME` to install into a different Copilot config directory. The generated config registers Copilot lifecycle hooks using the VS Code-compatible event names:

```json
{
"version": 1,
"hooks": {
"SessionStart": [{ "hooks": [{ "type": "command", "command": "agent-strace hook --provider copilot session-start" }] }],
"UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "agent-strace hook --provider copilot user-prompt" }] }],
"PreToolUse": [{ "matcher": ".*", "hooks": [{ "type": "command", "command": "agent-strace hook --provider copilot pre-tool" }] }],
"PostToolUse": [{ "matcher": ".*", "hooks": [{ "type": "command", "command": "agent-strace hook --provider copilot post-tool" }] }],
"PostToolUseFailure": [{ "matcher": ".*", "hooks": [{ "type": "command", "command": "agent-strace hook --provider copilot post-tool-failure" }] }],
"AgentStop": [{ "hooks": [{ "type": "command", "command": "agent-strace hook --provider copilot stop" }] }]
}
}
```

Copilot sends hook payloads on stdin. agent-strace records session starts, user prompts, and hook-visible tool calls/results. `AgentStop` is registered so sessions can receive stop events when Copilot emits useful payload data; assistant text capture depends on the fields Copilot includes.

### Import existing sessions

Already ran sessions without hooks? Import from Claude Code's native JSONL logs:
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.79.1"
__version__ = "0.80.0"
90 changes: 86 additions & 4 deletions src/agent_trace/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -619,6 +619,54 @@ def _codex_hooks_config(args: argparse.Namespace) -> dict:
}


def _copilot_hooks_config(args: argparse.Namespace) -> dict:
cmd_prefix = _hook_command_prefix(args, provider="copilot")
return {
"version": 1,
"hooks": {
"SessionStart": [{
"hooks": [{
"type": "command",
"command": f"{cmd_prefix} session-start",
}],
}],
"UserPromptSubmit": [{
"hooks": [{
"type": "command",
"command": f"{cmd_prefix} user-prompt",
}],
}],
"PreToolUse": [{
"matcher": ".*",
"hooks": [{
"type": "command",
"command": f"{cmd_prefix} pre-tool",
}],
}],
"PostToolUse": [{
"matcher": ".*",
"hooks": [{
"type": "command",
"command": f"{cmd_prefix} post-tool",
}],
}],
"PostToolUseFailure": [{
"matcher": ".*",
"hooks": [{
"type": "command",
"command": f"{cmd_prefix} post-tool-failure",
}],
}],
"AgentStop": [{
"hooks": [{
"type": "command",
"command": f"{cmd_prefix} stop",
}],
}],
}
}


def _gemini_hooks_config(args: argparse.Namespace) -> dict:
cmd_prefix = _hook_command_prefix(args, provider="gemini")
return {
Expand Down Expand Up @@ -730,6 +778,18 @@ def _gemini_config_dir() -> Path:
return Path(os.environ.get("GEMINI_CONFIG_DIR", "~/.gemini")).expanduser()


def _codex_config_dir() -> Path:
return Path(os.environ.get("CODEX_CONFIG_DIR", "~/.codex")).expanduser()


def _write_codex_hooks_config(args: argparse.Namespace) -> Path:
config_dir = _codex_config_dir()
config_dir.mkdir(parents=True, exist_ok=True)
hooks_path = config_dir / "hooks.json"
hooks_path.write_text(json.dumps(_codex_hooks_config(args), indent=2) + "\n")
return hooks_path


def _write_gemini_extension(args: argparse.Namespace) -> tuple[Path, Path]:
extension_dir = _gemini_config_dir() / "extensions" / "agent-strace"
hooks_dir = extension_dir / "hooks"
Expand All @@ -746,6 +806,10 @@ def _cursor_config_dir() -> Path:
return Path(os.environ.get("CURSOR_CONFIG_DIR", ".cursor")).expanduser()


def _copilot_config_dir() -> Path:
return Path(os.environ.get("COPILOT_HOME", "~/.copilot")).expanduser()


def _write_cursor_hooks_config(args: argparse.Namespace) -> Path:
config_dir = _cursor_config_dir()
config_dir.mkdir(parents=True, exist_ok=True)
Expand All @@ -754,6 +818,14 @@ def _write_cursor_hooks_config(args: argparse.Namespace) -> Path:
return hooks_path


def _write_copilot_hooks_config(args: argparse.Namespace) -> Path:
hooks_dir = _copilot_config_dir() / "hooks"
hooks_dir.mkdir(parents=True, exist_ok=True)
hooks_path = hooks_dir / "agent-strace.json"
hooks_path.write_text(json.dumps(_copilot_hooks_config(args), indent=2) + "\n")
return hooks_path


def cmd_setup(args: argparse.Namespace) -> None:
"""Generate hooks configuration for supported agent CLIs."""
cli = getattr(args, "cli", "claude") or "claude"
Expand All @@ -762,7 +834,9 @@ def cmd_setup(args: argparse.Namespace) -> None:
if cli in ("claude", "all"):
configs.append(("Claude Code", "~/.claude/settings.json", _claude_hooks_config(args)))
if cli in ("codex", "all"):
configs.append(("OpenAI Codex", "~/.codex/hooks.json", _codex_hooks_config(args)))
hooks_path = _write_codex_hooks_config(args)
sys.stderr.write(f"Wrote OpenAI Codex hooks config: {hooks_path}\n")
configs.append(("OpenAI Codex", str(hooks_path), _codex_hooks_config(args)))
if cli in ("gemini", "all"):
manifest_path, hooks_path = _write_gemini_extension(args)
sys.stderr.write(
Expand All @@ -772,11 +846,17 @@ def cmd_setup(args: argparse.Namespace) -> None:
if cli in ("cursor", "all"):
hooks_path = _write_cursor_hooks_config(args)
sys.stderr.write(f"Wrote Cursor hooks config: {hooks_path}\n")
if cli in ("copilot", "all"):
hooks_path = _write_copilot_hooks_config(args)
sys.stderr.write(f"Wrote GitHub Copilot hooks config: {hooks_path}\n")

for idx, (name, path, config) in enumerate(configs):
if idx:
sys.stdout.write("\n")
sys.stderr.write(f"Add this to {path} for {name}:\n\n")
if name == "OpenAI Codex":
sys.stderr.write(f"Installed {name} hooks at {path}; JSON:\n\n")
else:
sys.stderr.write(f"Add this to {path} for {name}:\n\n")
sys.stdout.write(json.dumps(config, indent=2) + "\n")

if cli in ("codex", "all"):
Expand All @@ -791,6 +871,8 @@ def cmd_setup(args: argparse.Namespace) -> None:
sys.stdout.write(json.dumps(_gemini_hooks_config(args), indent=2) + "\n")
if cli == "cursor":
sys.stdout.write(json.dumps(_cursor_hooks_config(args), indent=2) + "\n")
if cli == "copilot":
sys.stdout.write(json.dumps(_copilot_hooks_config(args), indent=2) + "\n")

sys.stderr.write(
"\nThis captures hook-visible agent sessions: user prompts, assistant "
Expand Down Expand Up @@ -937,7 +1019,7 @@ def build_parser() -> argparse.ArgumentParser:

# hook (called by agent CLI hooks systems)
p_hook = sub.add_parser("hook", help="handle an agent CLI hook event (internal)")
p_hook.add_argument("--provider", choices=["claude", "codex", "gemini", "cursor"], default="claude",
p_hook.add_argument("--provider", choices=["claude", "codex", "gemini", "cursor", "copilot"], default="claude",
help="hook provider (default: claude)")
p_hook.add_argument("event", nargs="?", help="hook event: session-start, session-end, pre-tool, post-tool, post-tool-failure")

Expand All @@ -955,7 +1037,7 @@ def build_parser() -> argparse.ArgumentParser:
help="disable automatic secret redaction in generated hooks",
)
p_setup.add_argument("--global", dest="global_config", action="store_true", help="output config for ~/.claude/settings.json (all projects)")
p_setup.add_argument("--cli", choices=["claude", "codex", "gemini", "cursor", "all"], default="claude",
p_setup.add_argument("--cli", choices=["claude", "codex", "gemini", "cursor", "copilot", "all"], default="claude",
help="agent CLI to configure (default: claude)")

# import (Claude Code JSONL session logs)
Expand Down
37 changes: 29 additions & 8 deletions src/agent_trace/hooks.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Agent CLI hooks integration.

Captures every tool call supported CLIs make, not just MCP calls. Uses the
Claude Code and OpenAI Codex hooks systems to trace Bash, Edit, Write, Read,
Agent, apply_patch, and all other hook-visible tools.
Captures hook-visible tool calls from supported agent CLIs, not just MCP
calls. Uses provider hook systems such as Claude Code, OpenAI Codex, Gemini,
Cursor, and GitHub Copilot.

Usage:
# In .claude/settings.json or ~/.claude/settings.json:
Expand Down Expand Up @@ -63,19 +63,22 @@
_CODEX_SESSION_ID_ENV = "AGENT_TRACE_CODEX_SESSION_ID"
_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"

_PROVIDER_ENV = {
"claude": _CLAUDE_SESSION_ID_ENV,
"codex": _CODEX_SESSION_ID_ENV,
"gemini": _GEMINI_SESSION_ID_ENV,
"cursor": _CURSOR_SESSION_ID_ENV,
"copilot": _COPILOT_SESSION_ID_ENV,
}

_PROVIDER_AGENT = {
"claude": "claude-code",
"codex": "openai-codex",
"gemini": "gemini-cli",
"cursor": "cursor-agent",
"copilot": "github-copilot",
}


Expand Down Expand Up @@ -206,15 +209,25 @@ def _should_redact() -> bool:
def _normalise_payload(input_data: dict, provider: str, event: str) -> dict:
"""Map provider-specific hook payloads to the Claude-shaped fields."""
data = dict(input_data)
if provider not in ("codex", "gemini", "cursor"):
if provider not in ("codex", "gemini", "cursor", "copilot"):
return data

data.setdefault("session_id", data.get("sessionId") or "")
data.setdefault("turn_id", data.get("turnId") or "")
data.setdefault("tool_use_id", data.get("toolUseId") or "")

if event in {"pre-tool", "post-tool", "post-tool-failure"}:
tool = data.get("tool")
if isinstance(tool, dict):
data.setdefault("tool_name", tool.get("name") or tool.get("tool_name") or "")
data.setdefault("tool_input", tool.get("input") or tool.get("arguments") or {})
data.setdefault("tool_output", tool.get("output") or tool.get("response") or "")
if data.get("toolName") and not data.get("tool_name"):
data["tool_name"] = data.get("toolName")
if data.get("toolArgs") is not None and "tool_input" not in data:
data["tool_input"] = data.get("toolArgs")
if (data.get("toolResult") is not None or data.get("textResultForLlm") is not None) and "tool_output" not in data:
data["tool_output"] = data.get("toolResult", data.get("textResultForLlm", ""))
command = data.get("command")
if command and not data.get("tool_name"):
data.setdefault("tool_name", "shell")
Expand All @@ -227,13 +240,13 @@ def _normalise_payload(input_data: dict, provider: str, event: str) -> dict:
data.setdefault("tool_input", data.get("input") or data.get("arguments") or {})
data.setdefault("tool_output", data.get("tool_response", data.get("output", "")))

if provider in ("gemini", "cursor"):
if provider in ("gemini", "cursor", "copilot"):
if event == "session-start":
data.setdefault("source", data.get("hook_event_name", "startup"))
if event == "user-prompt":
input_value = data.get("input", {})
prompt = input_value.get("prompt", "") if isinstance(input_value, dict) else input_value
data.setdefault("prompt", data.get("user_prompt") or data.get("prompt") or prompt or "")
data.setdefault("prompt", data.get("user_prompt") or data.get("prompt") or data.get("initialPrompt") or prompt or "")
if event == "stop":
data.setdefault("last_assistant_message", data.get("prompt_response", ""))

Expand Down Expand Up @@ -461,7 +474,7 @@ def handle_post_tool(input_data: dict, failed: bool = False, provider: str = "cl
tool_name = input_data.get("tool_name", "unknown")
tool_output = input_data.get("tool_output", input_data.get("tool_response", ""))

if provider in ("codex", "gemini", "cursor") and not failed:
if provider in ("codex", "gemini", "cursor", "copilot") and not failed:
if isinstance(tool_output, dict):
exit_code = tool_output.get("exit_code")
failed = (
Expand Down Expand Up @@ -551,7 +564,7 @@ def hook_main(args: list[str]) -> None:
sys.exit(1)

if not rest:
sys.stderr.write("Usage: agent-strace hook [--provider claude|codex|gemini|cursor] <event>\n")
sys.stderr.write("Usage: agent-strace hook [--provider claude|codex|gemini|cursor|copilot] <event>\n")
sys.exit(1)

aliases = {
Expand All @@ -567,6 +580,14 @@ def hook_main(args: list[str]) -> None:
"after-shell-execution": "post-tool",
"after-file-edit": "file-write",
"after-agent-response": "stop",
"SessionStart": "session-start",
"SessionEnd": "session-end",
"UserPromptSubmit": "user-prompt",
"PreToolUse": "pre-tool",
"PostToolUse": "post-tool",
"PostToolUseFailure": "post-tool-failure",
"AgentStop": "stop",
"agent-stop": "stop",
}
event = aliases.get(rest[0], rest[0])
input_data = _normalise_payload(_read_stdin(), provider, event)
Expand Down
Loading
Loading