Repository navigation
docs(coding-agents): hook-to-OTLP foundation + Cursor application session tracing (TH-8229) - #912
Merged
Merged
Conversation
approved: Nikhil 2026-10-03 blanket
…8229) Rework the Cursor hook recipe into the coding-agent family foundation: - examples/coding-agents/hook-to-otlp: stdlib-only (Python 3.9+) hook command. One session = one trace (trace id derived from the session id), session -> turn -> tool/subagent/llm/event spans, start/end pairing via a locked per-session state file, vendor durations when no start hook, content off unless FI_HOOK_CAPTURE_CONTENT=1, always exits 0 with the response the host expects, network send in a detached child. - Pluggable adapters (adapters/<app>.py, contract in adapters/base.py); Cursor adapter mapped from https://cursor.com/docs/hooks, observation hooks only (no permission hooks). - replay.py for driving the emitter with recorded payloads; unit, CLI and Cursor contract tests. - Integrations > Coding Agents nav group with the shared page, the Cursor page, and one marker comment per remaining child. The cookbook page is replaced by the integration pages. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ssions A real Cursor 3.24.18 run showed sessionStart/sessionEnd firing for chats that were opened and closed without a message (sessionEnd with duration_ms 0, final_status "none"). Exporting the root at session start would create an empty trace per such chat. The root session span now rides along with every batch that exports a child span (same id and start time, so the latest export wins) and is sent at session end only when the session had activity. Adds the captured Cursor 3.24.18 sessionEnd payload as a contract fixture. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This was referenced Oct 15, 2026
…ng (TH-8229) - Cursor page: Cursor reports model "default" when no model is selected; chats opened and closed without a message are not sent. - run_e2e.sh: space hooks 1s apart so the fixture's reported tool durations fit inside the turn, as in a real session. Co-Authored-By: Claude Opus 5.5 <[email protected]>
…er session
Additive core changes requested by the Kiro/Antigravity/Devin Desktop
children; existing adapters (Cursor) are unaffected.
- A TURN START with an empty key opens turn-1, turn-2, ... per session;
a keyless TURN END/UPDATE applies to the open turn (ignored if none).
- A keyless END of a tool/subagent/llm/event span pairs with the oldest
open keyless START of the same scope and name (FIFO), for pre/post hooks
without call ids.
- A TURN START closes any other open turn of the session (and its
children) as incomplete; a late END for that turn re-sends it complete
with its original start.
- respond(): an adapter returning "" prints nothing (Kiro and Claude Code
add hook stdout to the model context); None still means "{}".
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…e (TH-8229) A real IDE agent session on the free Hobby plan showed payload details the hooks reference does not document: - afterAgentResponse and stop carry input/output/cache-read/cache-write tokens for the turn: recorded once on the "Agent response" LLM span as llm.token_count.*, and on the turn as cursor.usage.*. - afterAgentThought fires twice per thinking block, once with a generation_id suffixed "-<n>-<4 chars>": the suffix is stripped (no phantom turns) and the pair maps to one span id. - sessionStart does not fire for IDE chats; sessionEnd fails on window close. The Cursor page now says so, reports that agent hooks run on the Hobby plan, and covers the free-plan "named model" error turn. Adds the sanitized real turn as a contract fixture. Core: the default FI_HOOK_TIMEOUT is now 10 s (the send runs in a detached child, so the host never waits; 3 s timed out against a loaded collector). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This was referenced Oct 15, 2026
… id (TH-8229) Real Cursor 3.24.18 postToolUse payloads send tool_use_id as "call-<uuid>-<n>\nfc_<id>". The first line becomes the span key and gen_ai.tool.call.id; the provider function-call id is kept as cursor.tool.provider_call_id. Covered by the real-turn fixture test. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This was referenced Oct 15, 2026
…renting, root start (TH-8229)
Hooks never block the host:
- Cursor hooks.json / hooks.user.json use the canonical guarded command
`python3 -B <emit.py> cursor <Event> 2>/dev/null || true`; new
test_cursor_hooks_config runs every shipped command through sh/zsh/bash
with the emitter missing, python3 missing, and installed (exit 0, empty
or expected stdout, no __pycache__).
- A misregistered permission hook now prints nothing (Cursor fails open on
no output) instead of answering {"permission": "allow"}.
Privacy and doc truth:
- Opted-in content keeps paths but replaces the user name in home paths with
<user>; pages now say paths inside prompts/tool input/output are exported
with content on, never as metadata. Test: error text with a path and user
name is absent by default and scrubbed with content on.
- State file keeps content only for open spans and turns closed as
incomplete; a duplicate end of a completed turn is ignored.
- Windows: inline send uses a 3 s default timeout; pages say background send
and state locking are macOS/Linux only.
- Cursor page: cache tokens are captured (removed from "not available").
Tree:
- Tool/subagent/llm/event spans without a turn id go under the open turn and
get coding_agent.turn.inferred=true (pages corrected; PRD R-02 deviation).
- Children never start before the root once its start is fixed.
- Subagent span key includes the turn and duration (no cross-turn overwrite).
- Root model comes from session/turn/llm events only.
- transcript_path test uses an unreadable canary and spies on os.open too.
E2E tooling: replay --replace rewrites substrings (suffixed generation ids),
run_e2e.sh gives any fixture fresh UUID ids.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
…ilures (TH-8229)
Cursor's bundled headless agent host passes the hook payload as a trailing
heredoc (`<command> <<'CURSOR_HOOK_EOF'`, commandHookPayloadTransport
argv_heredoc). With `python3 ... 2>/dev/null || true` the heredoc binds to
`true` and the emitter reads nothing. Every shipped command, the shared page,
the Cursor page and the README now use the subshell form
`(python3 -B <path>/emit.py cursor <Event> 2>/dev/null || true)`, which works
for stdin and heredoc and still exits 0 with no output when the emitter or
python3 is missing.
- tests/test_cursor_hooks_config.py runs every shipped command (project and
user hooks) through sh, zsh, bash and dash with the payload on stdin and as
a trailing heredoc: emitter missing, python3 missing, and installed (exit 0,
expected stdout, payload received verbatim, no __pycache__). A regression
test shows the unparenthesized form loses a heredoc payload.
- emit.py prints nothing (instead of "{}") when the adapter cannot be loaded,
respond() raises, or run() fails, so a misregistered permission hook never
gets schema-invalid "{}".
- Home-path scrub handles user names with spaces (when a separator follows),
~name paths, and this machine's own home directory anywhere in the text.
- replay.py --hooks-json/--cwd/--transport and run_e2e.sh HOOK_TRANSPORT run
the installed hooks.json commands the way Cursor does.
- cursor.mdx: CLI section no longer claims a CLI session was traced (it was
not run); troubleshooting covers tools directly under the session (no
marker) separately from coding_agent.turn.inferred.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
nik13
added a commit
that referenced
this pull request
Oct 15, 2026
Co-Authored-By: Claude Opus 5.5 <[email protected]>
nik13
added a commit
that referenced
this pull request
Oct 15, 2026
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The previous round let a home-path user name run across spaces up to the
next slash, so opted-in Shell command text such as
"cd /Users/alice && ls src/foo" lost "&& ls src". The generic scrub now
replaces only the one directory component after /Users/, /home/ or
C:\Users\: it stops at whitespace, quotes and punctuation no account name
uses, and does not end with a dot.
Names with spaces are handled through this machine's own home directory:
the basename of os.path.expanduser("~") is matched literally after any
home prefix (raw, JSON-escaped or forward-slash Windows form), with a
boundary check so /Users/al does not match inside /Users/alice. This also
fixes "/Users/Jo Ann." and "/Users/Jo Ann," leaking "Ann". Another
account's spaced name keeps the part after the first space; README and
the shared page document that.
Docs: the shared page no longer says every app page uses the guarded
command (hook-based apps do; native-OTel apps have no hook command), and
both pages note that fish reads the subshell parentheses as command
substitution.
Tests: command-text regression cases, own spaced home (POSIX and
Windows forms, end of string, punctuation, al/alice), and the documented
limitation for other accounts.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Keep CLI, cloud agents and other platforms outside supported setup; preserve transport and privacy limitations. Co-Authored-By: Claude Code <[email protected]>
Preserve the generated integration catalog and the coding-agent navigation entries. Co-Authored-By: Claude Code <[email protected]>
Co-Authored-By: Claude Code <[email protected]>
Register Cursor, the shared hook emitter, LangSmith and the Assistants retirement notice in the existing discovery source. Remove the stale Alpha label without changing the unsupported status. Keep all catalog regression checks intact. Co-Authored-By: Claude Code <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Foundation PR for the TH-8103 coding-agent family plus the Cursor child (TH-8229). It replaces Rick's original cookbook recipe; his commit stays in the history and the new commits sit on top.
Shared emitter:
examples/coding-agents/hook-to-otlp/One stdlib-only Python 3.9+ hook command. It reads a hook payload on stdin and sends OTLP/HTTP spans to Future AGI with
X-Api-Key,X-Secret-Keyandproject_name.FI_HOOK_CAPTURE_CONTENT=1turns it on.(python3 -B <dir>/emit.py <app> <Event> 2>/dev/null || true), so a missing emitter, a missingpython3or a crash still exits 0 with empty stdout, and the payload reachespython3whether the host pipes it to stdin or appends it as a trailing heredoc. The emitter itself exits 0 and prints the response the host expects; if its adapter cannot be loaded or it fails, it prints nothing. On macOS/Linux the network send runs in a detached child process; Windows has no fork, so the send is inline with a 3 s default timeout and state files are not locked (documented).adapters/<app>.py, with the contract inadapters/base.pyand Cursor as the reference.replay.pydrives the real emitter with recorded payloads.Cursor:
examples/coding-agents/cursor/hooks.jsonfor project and user installs.install.sh, which never overwrites an existinghooks.json.run_e2e.sh.sessionStart,beforeSubmitPrompt,postToolUse,postToolUseFailure,subagentStop,afterAgentThought,afterAgentResponse,preCompact,stop,sessionEnd), never on permission hooks. If someone wires it to a permission hook by mistake it prints nothing (Cursor fails open on no output); it never answersallow.Docs
integrations/coding-agents/hook-to-otlp(shared) andintegrations/coding-agents/cursor.// coding-agents:<app>marker per child.Sources: the mapping follows https://cursor.com/docs/hooks (read 2026-10-14). The CLI and cloud-agent gaps come from the Cursor docs and staff posts on https://forum.cursor.com/t/cursor-cli-doesnt-send-all-events-defined-in-hooks/148316.
How to run
E2E evidence (local Future AGI, 2026-10-14)
Real Cursor session
install.sh, and the keys came from~/.config/fi-hook-to-otlp/envas the page describes. The Agent panel was driven through Cursor's own UI.beforeSubmitPrompt,afterAgentThought,postToolUse(Read, Grep, Shell),afterAgentResponseandstop. Every send returned HTTP 200.fi_verify.py --project th8103-coding-cursor --expect-type tool --expect-type llm --expect-type agentpassed. The default-privacy chat produced one trace with three turns:Real-payload findings, now handled in the Cursor adapter and covered by a sanitized fixture
tool_use_idarrives as two newline-joined ids; the first is the call id, the second is kept ascursor.tool.provider_call_id.afterAgentResponseandstopcarryinput_tokens,output_tokens,cache_read_tokensandcache_write_tokens. Cursor's docs don't list these. The counts are recorded once, on the Agent response span.afterAgentThoughtfires twice per thinking block, once with a suffixedgeneration_id. The suffix is stripped and the pair collapses to one span.sessionStartdid not fire for IDE chats.sessionEndfires for empty chats, so empty sessions are now never exported.sessionEndfails on window close ("MainThreadShellExec not initialized").Fixture replay (after the review fixes, head 6513a26, 2026-10-15)
run_e2e.shreplays a fixture through the real emitter (oneemit.pyprocess per hook) with fresh UUID ids. The fixture's/Users/fixturewas rewritten to the tester's real home directory, and the failed tool's error text toENOENT: open '<home>/secret-proj/.env'. All 38 sends returned HTTP 200 andfi_verify --project th8103-coding-cursor --expect-type toolpassed:3b2f51f1-7cea-4fe6-509b-96248625f4aa(7 spans)91ad5701-b930-6e94-7e67-046998041257(7 spans)ab779b0a-3b2e-0845-8a43-0924b19540f4(9 spans)ff65f3de-0656-6a79-8a55-c59e0373e09f(9 spans)e99709d2-d0be-0d9e-f224-f1d9e83d86af(9 spans)In ClickHouse every trace has one root, no orphans, and no span starting before its root. The fast replay exercised the clamp: root 3405 ms over turn 3034 ms. The reviewed trace 7b7a4dcb (root 5827 ms under a turn of 7475 ms) is superseded by these traces.
Privacy on the OTLP bodies actually sent (
FI_HOOK_DUMP_FILE), occurrences:input.value/output.value/exception.messageERRPATH_*,secret-proj)/Users/<user>/secret-proj/.env/Users/<username>/Users/<user>/(scrubbed home paths in tool input/output/errors)Not tested: Cursor CLI
Cursor CLI 2026.10.14-941492e was installed with the official installer into an isolated HOME. It needs its own
agent loginorCURSOR_API_KEY, so no CLI session was run. The page now says the CLI was not run, documents it as not at parity, and no longer claims a CLI session produces a trace. Payload transport differs by surface: the IDE pipes the payload to stdin, while the headless agent host bundled with Cursor 3.24.18 (cursor-agent-host/dist/agent-host-daemon) buildsCliHooksExecutorwithout options, so it defaults toargv_heredocand runs<command> <<'CURSOR_HOOK_EOF'with stdin ignored. The subshell commands handle both (round-2 E2E below).Privacy proof (live Cursor chats)
Each chat had its own marker in the prompt.
FI_HOOK_DUMP_FILE)input.value/output.valuesentA unit test points
transcript_pathat an unreadable canary file and spies onopen,io.openandos.open: it is never opened.Review fixes (2026-10-15, commit 6513a26)
python3 -B ... 2>/dev/null || trueinhooks.jsonandhooks.user.jsonand on the page (superseded by the subshell form in round 2 below).tests/test_cursor_hooks_config.pyruns every shipped command through/bin/sh,zshandbashwith the emitter missing, withpython3missing, and installed: exit 0, empty or expected stdout, no__pycache__. Cursor 3.24.18 runs hooks with$SHELL -c(fallback/bin/sh), checked in its bundled extension host.file_path, Shellworking_directory) and error text are exported, and the emitter now replaces the user name in home-directory paths with<user>(/Users/,/home/,C:\Users\). Both pages now say so.failure_type) or a fixed message is exported. A test puts/Users/alice/...in tool and session error text and asserts it is absent by default and scrubbed with content on.generation_id(PRD R-02 deviation): R-02 says to attach such a tool to the session and mark the turn unknown. The emitter attaches it to the turn that is open at the time, because the core's "no turn id means the open turn" rule is what the keyless-turn apps (Kiro, Antigravity, Claude Code) rely on. The span is markedcoding_agent.turn.inferred=true, and if no turn is open it goes under the session. Both pages' troubleshooting now describe this instead of claiming session parenting.{"permission": "allow"}. Per Cursor's docs,{}is schema-invalid and blocks, while no output is a failed hook that fails open.cursor.tool.provider_call_idexplained; the root model comes only from session, turn and LLM events (the live trace 8042e760 above predates this and showsgrok-4.6on the root); the adapter docstring is corrected;replay.pyrewrites suffixed ids andrun_e2e.shgives any fixture fresh UUIDs (an exact-match replace had left the suffixed thought ids unrewritten, which produced orphan thought spans in an earlier replay).emit.py; the existing failures are in those children's work in progress.Round-2 fixes (2026-10-15, commit 9b77d51)
cmd 2>/dev/null || true, a trailing heredoc binds totrue, so Cursor's headless agent host (argv_heredoc) would hand the emitter an empty stdin and nothing would be exported. Every shipped command (hooks.json,hooks.user.json), both pages and the README now use(python3 -B <path>/emit.py cursor <Event> 2>/dev/null || true).tests/test_cursor_hooks_config.pyruns every shipped command (project and user installs) through/bin/sh,zsh,bashanddash, with the payload on stdin and as a trailing quoted heredoc (stdin closed, as Cursor does). With the emitter missing and withpython3missing it gets exit 0 and empty stdout. Installed, it gets exit 0 and the expected stdout, and the emitter receives the payload verbatim (a prompt containing$HOME, backticks and quotes is not expanded). A regression test shows the unparenthesized form exports nothing under heredoc.{}on a broken install:emit.pynow prints nothing, instead of{}, when the adapter cannot be loaded, whenrespond()raises, or whenrun()fails. A test copies the emitter withoutadapters/cursor.pyand runspreToolUse,beforeSubmitPromptandstop: exit 0, empty stdout, empty stderr. The shared page states this, plus the guard's role when Python cannot start the script at all.C:\Users\John Smith\x,/Users/Hank Smith/notes.txt),~name/paths, and this machine's own home directory anywhere in the text, including at the end of a string. A prefix-only match (/Users/alvs/Users/alice) is not rewritten. (Superseded in round 3: letting a name run across spaces swallowed command text.)coding_agent.turn.inferred=true.replay.py --hooks-json/--cwd/--transportandrun_e2e.sh HOOK_TRANSPORT=stdin|heredocrun the installedhooks.jsoncommands through$SHELL -c, the way Cursor does.emit.pyand with this one. Children that ship hook commands must switch to the subshell form; that is FOUNDATION.md's canonical command.Round-2 E2E (local Future AGI, project
th8103-coding-cursor).run_e2e.shwithHOOK_TRANSPORTinstalled the hooks into a temporary project and ran the shipped commands through zsh, giving fresh UUID ids and a unique prompt marker per run. The fixture's/Users/fixturewas rewritten to the tester's real home directory, or to/Users/Zq7 Spacedfor the spaced-name check, and the failed tool's error text toENOENT: open '<home>/secret-proj/.env'. All 34 sends returned HTTP 200.fi_verify --expect-type tool --expect-type llm --expect-type agentpassed, with one root per trace:104fd234-58b3-21e7-52e1-1a7c9a9ed9b1(7 spans)b44aa3a8-f858-0580-6ef5-1e21fe904b51(7 spans)f2850b32-55f2-055c-b219-aa3f7d30d42b(9 spans)/Users/Zq7 Spaced4ef79059-fe4f-6cf4-0f54-4189a46d5592(9 spans)Privacy, counted in the OTLP bodies as sent (
FI_HOOK_DUMP_FILE) and in all ClickHouse columns (input, output, attributes, status message, name):input.value/output.value/exception.messagesentZq7/Spaced(spaced home name)/Users/<user>/)Docs:
npm run buildpasses, andnpm run audit-linksreports 0 broken nav or content links (the 29 orphan pages are pre-existing). Tests: 58 pass on Python 3.14.4, macOS/usr/bin/python33.9.6, and Homebrew 3.9.22 and 3.10.17.Round-3 fixes (2026-10-15, commit 9c6acd6)
cd /Users/alice && ls src/foobecamecd /Users/<user>/foo. The generic scrub now replaces only the one directory component after/Users/,/home/orC:\Users\(raw, JSON-escaped orc:/Users/). The name stops at whitespace, quotes and punctuation no macOS, Linux or Windows account name uses, and does not end with a dot, socd /Users/<user> && ls src/foo,grep ... /home/<user> --include=*.py && cat docs/readme.md,ls /Users/<user>;echo hiand(see /Users/<user>), then /home/<user>.keep the rest of the text.os.path.expanduser("~")is matched literally after any home prefix, with a boundary check (/Users/aldoes not match inside/Users/alice). This machine's spaced name is replaced in full at the end of a string and before.or,(round-2 recheck nit:failed in /Users/Jo Ann.used to leakAnn), in POSIX and Windows forms./Users/<user> Smith/notes.txt). Stated in the README and on the shared page; a test pins it./usr/bin/python33.9.6. The three new scrub tests fail against the round-2emit.py. Every child adapter's own tests (Kiro IDE/CLI, Augment, Antigravity, Devin, Devin Desktop, Grok Build, Copilot, Gemini CLI, Hermes, OpenClaw) give the same pass/fail set (all pass) with theiremit.pyand with this one.hooks.jsoncommands (zsh, heredoc transport, content on,HOME=/Users/Zq7 Spaced, collector unreachable so nothing was sent): 9 OTLP bodies with 0 occurrences ofZq7,Spacedorbob. The Shell span'sinput.valueisls -la ... && cd /home/<user> && ls src/foowithworking_directory/Users/<user>/project, and its error text endsat /Users/<user>.npm run buildpasses;npm run audit-linksreports 0 broken nav or content links (29 pre-existing orphan pages).Limitations / unsupported
agent login). The heredoc transport used by Cursor's headless agent host is covered by the shipped commands; checked with replays through those commands, not with a CLI session.$SHELL -c, so the commands need a POSIX login shell (zsh, bash, sh). fish is not supported (it reads the parentheses as command substitution).sessionStartorsessionEnd.🤖 Generated with Claude Code