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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ uvx agent-strace replay
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 gemini # Gemini CLI extension under ~/.gemini/extensions
agent-strace setup --cli cursor # Cursor project hooks in .cursor/hooks.json
agent-strace list # list sessions
agent-strace replay # replay the latest
```
Expand Down Expand Up @@ -147,7 +148,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 or --cli gemini for other CLIs
agent-strace setup # 2. add hooks to Claude Code; use --cli codex, gemini, or cursor 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|all] [--no-redact] [--global]
agent-strace setup [--cli claude|codex|gemini|cursor|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 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` 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.

### `import`
```
Expand Down
1 change: 1 addition & 0 deletions docs/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Use setup-generated hooks when the agent CLI has its own lifecycle hook system.
| Claude Code | `agent-strace setup --cli claude` | Session start/end, user prompts, assistant responses, tool calls/results |
| 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 |

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
33 changes: 32 additions & 1 deletion docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Three ways to capture agent sessions. Pick the one that matches your agent.

## Option 1: CLI hooks (recommended)

Captures everything: user prompts, assistant responses, and every tool call (Bash, Edit, Write, Read, Agent, Grep, Glob, WebFetch, WebSearch, all MCP tools).
Captures the lifecycle events exposed by each CLI: user prompts, assistant responses, and hook-visible tool calls or edits. Claude Code exposes broad tool coverage; Cursor coverage depends on the native events Cursor emits.

```bash
# Generate Claude Code hooks config
Expand All @@ -18,6 +18,9 @@ agent-strace setup --cli codex
# Install Gemini CLI extension hooks
agent-strace setup --cli gemini

# Install Cursor project hooks
agent-strace setup --cli cursor

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

Expand Down Expand Up @@ -98,6 +101,34 @@ Set `GEMINI_CONFIG_DIR` to install into a different Gemini config directory. The

Gemini sends one JSON object to each command hook on stdin. agent-strace records `session_id`, `tool_name`, `tool_input`, `tool_response`, `prompt`, and `prompt_response` into the same `.agent-traces/` session store used by Claude Code and Codex.

### Cursor hooks

`agent-strace setup --cli cursor` writes a project-local Cursor hooks file:

```
.cursor/
└── hooks.json
```

Set `CURSOR_CONFIG_DIR` to write the file somewhere else. The generated config registers Cursor-native prompt, shell, file-edit, assistant-response, session-start, and session-end command hooks:

```json
{
"version": 1,
"hooks": {
"sessionStart": [{ "type": "command", "command": "agent-strace hook --provider cursor session-start" }],
"beforeSubmitPrompt": [{ "type": "command", "command": "agent-strace hook --provider cursor before-submit-prompt" }],
"beforeShellExecution": [{ "type": "command", "command": "agent-strace hook --provider cursor before-shell-execution" }],
"afterShellExecution": [{ "type": "command", "command": "agent-strace hook --provider cursor after-shell-execution" }],
"afterFileEdit": [{ "type": "command", "command": "agent-strace hook --provider cursor after-file-edit" }],
"afterAgentResponse": [{ "type": "command", "command": "agent-strace hook --provider cursor after-agent-response" }],
"sessionEnd": [{ "type": "command", "command": "agent-strace hook --provider cursor session-end" }]
}
}
```

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.

### 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.78.0"
__version__ = "0.79.0"
64 changes: 60 additions & 4 deletions src/agent_trace/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,43 @@ def _gemini_hooks_config(args: argparse.Namespace) -> dict:
}


def _cursor_hooks_config(args: argparse.Namespace) -> dict:
cmd_prefix = _hook_command_prefix(args, provider="cursor")
return {
"version": 1,
"hooks": {
"sessionStart": [{
"type": "command",
"command": f"{cmd_prefix} session-start",
}],
"beforeSubmitPrompt": [{
"type": "command",
"command": f"{cmd_prefix} before-submit-prompt",
}],
"beforeShellExecution": [{
"type": "command",
"command": f"{cmd_prefix} before-shell-execution",
}],
"afterShellExecution": [{
"type": "command",
"command": f"{cmd_prefix} after-shell-execution",
}],
"afterFileEdit": [{
"type": "command",
"command": f"{cmd_prefix} after-file-edit",
}],
"afterAgentResponse": [{
"type": "command",
"command": f"{cmd_prefix} after-agent-response",
}],
"sessionEnd": [{
"type": "command",
"command": f"{cmd_prefix} session-end",
}],
},
}


def _gemini_extension_manifest() -> dict:
return {
"name": "agent-strace",
Expand All @@ -705,6 +742,18 @@ def _write_gemini_extension(args: argparse.Namespace) -> tuple[Path, Path]:
return manifest_path, hooks_path


def _cursor_config_dir() -> Path:
return Path(os.environ.get("CURSOR_CONFIG_DIR", ".cursor")).expanduser()


def _write_cursor_hooks_config(args: argparse.Namespace) -> Path:
config_dir = _cursor_config_dir()
config_dir.mkdir(parents=True, exist_ok=True)
hooks_path = config_dir / "hooks.json"
hooks_path.write_text(json.dumps(_cursor_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 @@ -720,6 +769,9 @@ def cmd_setup(args: argparse.Namespace) -> None:
f"Wrote Gemini CLI extension manifest: {manifest_path}\n"
f"Wrote Gemini CLI hooks config: {hooks_path}\n"
)
if cli in ("cursor", "all"):
hooks_path = _write_cursor_hooks_config(args)
sys.stderr.write(f"Wrote Cursor hooks config: {hooks_path}\n")

for idx, (name, path, config) in enumerate(configs):
if idx:
Expand All @@ -729,10 +781,14 @@ def cmd_setup(args: argparse.Namespace) -> None:

if cli == "gemini":
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")

sys.stderr.write(
"\nThis captures full agent sessions: user prompts, assistant "
"responses, and hook-visible tool calls.\n"
"\nThis captures hook-visible agent sessions: user prompts, assistant "
"responses, and tool calls or edits exposed by the provider.\n"
"For Cursor, MCP proxy tracing still captures MCP calls; native hooks "
"capture only the prompt, shell, file-edit, and response events Cursor emits.\n"
"Replay with: agent-strace replay\n"
)

Expand Down Expand Up @@ -873,7 +929,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"], default="claude",
p_hook.add_argument("--provider", choices=["claude", "codex", "gemini", "cursor"], 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 @@ -891,7 +947,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", "all"], default="claude",
p_setup.add_argument("--cli", choices=["claude", "codex", "gemini", "cursor", "all"], default="claude",
help="agent CLI to configure (default: claude)")

# import (Claude Code JSONL session logs)
Expand Down
58 changes: 53 additions & 5 deletions src/agent_trace/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,17 +62,20 @@
_CLAUDE_SESSION_ID_ENV = "AGENT_TRACE_CLAUDE_SESSION_ID"
_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"

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

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


Expand Down Expand Up @@ -203,7 +206,7 @@ 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"):
if provider not in ("codex", "gemini", "cursor"):
return data

if event in {"pre-tool", "post-tool", "post-tool-failure"}:
Expand All @@ -212,14 +215,25 @@ def _normalise_payload(input_data: dict, provider: str, event: str) -> 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 "")
command = data.get("command")
if command and not data.get("tool_name"):
data.setdefault("tool_name", "shell")
data.setdefault("tool_input", {"command": command})
if data.get("file_path") or data.get("path"):
data.setdefault("tool_name", data.get("tool_name") or "file_edit")
data.setdefault("tool_input", {
"file_path": data.get("file_path") or data.get("path"),
})
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 == "gemini":
if provider in ("gemini", "cursor"):
if event == "session-start":
data.setdefault("source", data.get("hook_event_name", "startup"))
if event == "user-prompt":
data.setdefault("prompt", data.get("user_prompt") or data.get("input", {}).get("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 "")
if event == "stop":
data.setdefault("last_assistant_message", data.get("prompt_response", ""))

Expand Down Expand Up @@ -408,6 +422,34 @@ def handle_stop(input_data: dict, provider: str = "claude") -> None:
)


def handle_file_write(input_data: dict, provider: str = "claude") -> None:
"""Handle provider file-edit hooks as file_write events."""
store = _get_store()
session_id = _resolve_session_id(input_data, provider=provider)
if not session_id:
return

redact = _should_redact()
data = {
"path": input_data.get("file_path") or input_data.get("path") or input_data.get("uri") or "",
}
for key in ("diff", "patch", "change_summary", "turn_id", "permission_mode"):
if input_data.get(key) not in (None, ""):
data[key] = input_data.get(key)
if redact:
data = redact_data(data)

_write_event(
store,
session_id,
TraceEvent(
event_type=EventType.FILE_WRITE,
session_id=session_id,
data=data,
),
)


def handle_post_tool(input_data: dict, failed: bool = False, provider: str = "claude") -> None:
"""Handle PostToolUse / PostToolUseFailure hook event."""
store = _get_store()
Expand All @@ -419,7 +461,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") and not failed:
if provider in ("codex", "gemini", "cursor") and not failed:
if isinstance(tool_output, dict):
exit_code = tool_output.get("exit_code")
failed = (
Expand Down Expand Up @@ -509,7 +551,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] <event>\n")
sys.stderr.write("Usage: agent-strace hook [--provider claude|codex|gemini|cursor] <event>\n")
sys.exit(1)

aliases = {
Expand All @@ -520,6 +562,11 @@ def hook_main(args: list[str]) -> None:
"before-agent": "user-prompt",
"before-prompt": "user-prompt",
"after-agent": "stop",
"before-submit-prompt": "user-prompt",
"before-shell-execution": "pre-tool",
"after-shell-execution": "post-tool",
"after-file-edit": "file-write",
"after-agent-response": "stop",
}
event = aliases.get(rest[0], rest[0])
input_data = _normalise_payload(_read_stdin(), provider, event)
Expand All @@ -531,6 +578,7 @@ def hook_main(args: list[str]) -> None:
"post-tool": lambda d: handle_post_tool(d, failed=False, provider=provider),
"post-tool-failure": lambda d: handle_post_tool(d, failed=True, provider=provider),
"user-prompt": lambda d: handle_user_prompt(d, provider=provider),
"file-write": lambda d: handle_file_write(d, provider=provider),
"stop": lambda d: handle_stop(d, provider=provider),
}

Expand Down
Loading
Loading