<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Skills on Welcome to Christophe Nasarre's Blog</title><link>https://chrisnas.github.io/tags/skills/</link><description>Recent content in Skills on Welcome to Christophe Nasarre's Blog</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Mon, 14 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://chrisnas.github.io/tags/skills/index.xml" rel="self" type="application/rss+xml"/><item><title>Rebuilding the Agent conversation: sessions, turns, thoughts, tools, MCP, skills and summaries</title><link>https://chrisnas.github.io/posts/2026-09-14_rebuilding-cursor-conversation/</link><pubDate>Mon, 14 Sep 2026 00:00:00 +0000</pubDate><guid>https://chrisnas.github.io/posts/2026-09-14_rebuilding-cursor-conversation/</guid><description>How to correlate Cursor hook events into sessions, turns, concurrent tool and MCP calls, detect skill usage, and build useful summaries without pretending the reconstruction is perfect.</description><content:encoded><![CDATA[<p>In the <a href="/posts/2026-08-23_spying-on-cursor-hooks/">first post of this series</a>, I registered Cursor&rsquo;s 21 hooks and built a passive .NET observer that saves their JSON payloads and forwards them to a WPF viewer. At that point, I knew <strong>which hooks were triggered</strong> and <strong>what each payload contained</strong>.</p>
<p>Unfortunately, a folder full of JSON files is not a conversation.</p>
<p>For one user prompt I could receive thoughts, generic tool hooks, specialized Shell or MCP hooks, file events, a final response and a stop notification. Some tool calls were sequential, others overlapped. To answer the questions that started this research—<em>did the agent load my skill, which MCP did it call, and in which context?</em>—I had to reconstruct the relationships between all these events.</p>
<p>This is the second post in the series:</p>
<ol>
<li><a href="/posts/2026-08-23_spying-on-cursor-hooks/">Spying on Cursor: agent hooks, payloads and a simple observer</a></li>
<li><strong>Rebuilding the conversation: sessions, turns, thoughts, tools, MCP, skills and summaries</strong> (this post)</li>
<li>Spying on Claude Code: more lifecycle events, different blind spots</li>
<li>Spying on GitHub Copilot twice: CLI hooks versus VS Code</li>
<li>Beyond hooks: mining undocumented agent transcript logs</li>
</ol>
<p>The POC from the first post has since evolved into the shared <a href="https://github.com/chrisnas/HarnessSpy">HarnessSpy</a> implementation used here. The capture remains passive; this post focuses on what happens <strong>after</strong> a payload reaches the viewer.</p>
<h2 id="from-a-bag-of-events-to-a-conversation">From a bag of events to a conversation</h2>
<p>The two easiest relationships come directly from fields introduced in the first post:</p>
<ul>
<li><code>conversation_id</code> identifies the session,</li>
<li><code>generation_id</code> identifies everything triggered by one user prompt—what I call a <strong>turn</strong>.</li>
</ul>
<p>The initial projection is therefore:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Workspace
</span></span><span class="line"><span class="cl">└── Session (conversation_id)
</span></span><span class="line"><span class="cl">    ├── sessionStart
</span></span><span class="line"><span class="cl">    ├── Turn 1 (generation_id)
</span></span><span class="line"><span class="cl">    │   ├── beforeSubmitPrompt
</span></span><span class="line"><span class="cl">    │   ├── afterAgentThought
</span></span><span class="line"><span class="cl">    │   ├── ...
</span></span><span class="line"><span class="cl">    │   ├── afterAgentResponse
</span></span><span class="line"><span class="cl">    │   └── stop
</span></span><span class="line"><span class="cl">    ├── Turn 2 (generation_id)
</span></span><span class="line"><span class="cl">    │   └── ...
</span></span><span class="line"><span class="cl">    └── sessionEnd
</span></span></code></pre></div><p><code>workspaceOpen</code> belongs directly to the workspace. <code>sessionStart</code> and <code>sessionEnd</code> bracket the entire conversation, so they stay at session level even if Cursor stamps them with a <code>generation_id</code>. Tab-completion hooks also stay at session level because they describe editor activity, not an Agent turn.</p>
<p>The turn label starts as <code>Turn 1</code>, then becomes <code>Turn 1 · &lt;prompt preview&gt;</code> when <code>beforeSubmitPrompt</code> arrives. The session itself uses the first prompt as a readable title instead of displaying an opaque GUID.</p>
<p>So far, nothing is really heuristic: I am mostly grouping by native IDs. Tools are where this gets more interesting.</p>
<h2 id="the-id-that-was-not-quite-an-id">The ID that was not quite an ID</h2>
<p>My first implementation matched <code>preToolUse</code> and <code>postToolUse</code> using <code>tool_use_id</code>. It looked like the obvious correlation key provided by the payload:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;hook_event_name&#34;</span><span class="p">:</span> <span class="s2">&#34;preToolUse&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;tool_name&#34;</span><span class="p">:</span> <span class="s2">&#34;Shell&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;tool_use_id&#34;</span><span class="p">:</span> <span class="s2">&#34;1234abcd-...&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;tool_input&#34;</span><span class="p">:</span> <span class="err">...</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Then I found several calls in one turn sharing the same tool ID. Timestamps are not a safe replacement. Imagine two calls starting before either one completes:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">pre A
</span></span><span class="line"><span class="cl">pre B
</span></span><span class="line"><span class="cl">post B
</span></span><span class="line"><span class="cl">post A
</span></span></code></pre></td></tr></table>
</div>
</div><p>Matching by arrival order links <code>post B</code> to <code>pre A</code>, precisely the opposite of what happened.</p>
<p>I had made a classic observability mistake: assuming that a field named “ID” was necessarily unique. HarnessSpy now narrows unfinished <code>preToolUse</code> candidates by:</p>
<ol>
<li><code>tool_use_id</code>,</li>
<li><code>tool_name</code>.</li>
</ol>
<p>It then requires one unique best match based on the value of <code>tool_input</code>. Only one best candidate is accepted. If two unfinished calls have the same ID, name and input, the code does <strong>not</strong> pick the first one and pretend to know. The <code>postToolUse</code> stays as an orphan directly under the turn node. An incomplete tree is better than a convincing but false one.<code>tool_use_id</code> is still the mandatory first gate. If a post event does not contain one, HarnessSpy leaves it at turn level even when its tool name or input looks familiar.</p>
<h2 id="even-more-json">Even more JSON</h2>
<p>In pre/post payloads,<code>tool_input</code> value is a json element containing the tool parameters as fields:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl">  <span class="s2">&#34;tool_input&#34;</span><span class="err">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;dumpPath&#34;</span><span class="p">:</span> <span class="s2">&#34;C:\dev\research\AI\HarnessSpy\CursorSpy\POC\dump\Investigation.dmp&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;threadIdLimit&#34;</span><span class="p">:</span> <span class="mi">-1</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span><span class="err">,</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>MCP inputs add another variation: the value may be an object in <code>preToolUse</code> but is a <strong>JSON-encoded string</strong> in <code>beforeMCPExecution</code>:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="s2">&#34;tool_input&#34;</span><span class="err">:</span> <span class="s2">&#34;{&#34;</span><span class="err">dumpPath</span><span class="s2">&#34;:&#34;</span><span class="err">C:\\dev\\research\\AI\\HarnessSpy\\CursorSpy\\POC\\dump\\Investigation.dmp</span><span class="s2">&#34;,&#34;</span><span class="err">threadIdLimit</span><span class="s2">&#34;:-1}&#34;</span><span class="err">,</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Before comparing an input, <code>ToolCorrelationMatcher</code>:</p>
<ol>
<li>parses it if it is a JSON object/array encoded as a string,</li>
<li>sorts object properties recursively,</li>
<li>preserves array order,</li>
<li>serializes the result into one canonical representation.</li>
</ol>
<h2 id="shells-inside-tools-inside-turns">Shells inside tools inside turns</h2>
<p>A single shell command usually appears through two hook layers:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">preToolUse (Shell)
</span></span><span class="line"><span class="cl">├── beforeShellExecution
</span></span><span class="line"><span class="cl">│   └── afterShellExecution
</span></span><span class="line"><span class="cl">└── postToolUse (Shell)
</span></span></code></pre></div><p>Only the <code>pre</code>/<code>post</code> payloads contain a <code>tool_use_id</code> field. So it is not possible to use it to figure out which shell events will match. Let&rsquo;s see what else can be used.</p>
<p>The generic <code>preToolUse</code>/<code>postToolUse</code> pair describes the model&rsquo;s tool invocation</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="s2">&#34;tool_name&#34;</span><span class="err">:</span> <span class="s2">&#34;Shell&#34;</span><span class="err">,</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;tool_input&#34;</span><span class="err">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;command&#34;</span><span class="p">:</span> <span class="s2">&#34;Get-ChildItem &#34;</span><span class="err">C</span><span class="p">:</span><span class="err">\dev\research\AI\HarnessSpy\CursorSpy\POC\dump</span><span class="s2">&#34; | Select-Object Name, Length, LastWriteTime&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;cwd&#34;</span><span class="p">:</span> <span class="s2">&#34;&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;timeout&#34;</span><span class="p">:</span> <span class="mi">30000</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span><span class="err">,</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>The specialized shell pair provides the full command, working directory, sandbox state in <code>before</code>, and output plus execution duration in <code>after</code>:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="s2">&#34;command&#34;</span><span class="err">:</span> <span class="s2">&#34;Get-ChildItem &#34;</span><span class="err">C:\dev\research\AI\HarnessSpy\CursorSpy\POC\dump</span><span class="s2">&#34; | Select-Object Name, Length, LastWriteTime&#34;</span><span class="err">,</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;cwd&#34;</span><span class="err">:</span> <span class="s2">&#34;&#34;</span><span class="err">,</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;sandbox&#34;</span><span class="err">:</span> <span class="kc">false</span><span class="err">,</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="s2">&#34;output&#34;</span><span class="err">:</span> <span class="s2">&#34;\r\n&#34;</span><span class="err">,</span>
</span></span><span class="line"><span class="cl"><span class="s2">&#34;duration&#34;</span><span class="err">:</span> <span class="mf">6872.467</span><span class="err">,</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Originally, the code generated by Cursor linked <code>beforeShellExecution</code> to the most recent unfinished Shell tool, then linked <code>afterShellExecution</code> using a LIFO stack. This works until commands overlap and finish in a different order:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">preToolUse              dotnet test
</span></span><span class="line"><span class="cl">preToolUse              git status
</span></span><span class="line"><span class="cl">beforeShellExecution    dotnet test
</span></span><span class="line"><span class="cl">beforeShellExecution    git status
</span></span><span class="line"><span class="cl">afterShellExecution     git status
</span></span><span class="line"><span class="cl">afterShellExecution     dotnet test
</span></span></code></pre></td></tr></table>
</div>
</div><p>The stronger matcher scores shared evidence instead:</p>
<table>
  <thead>
      <tr>
          <th>Evidence</th>
          <th style="text-align: right">Weight</th>
          <th>Normalization</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>command</code></td>
          <td style="text-align: right">100</td>
          <td>Line endings normalized; command otherwise kept case-sensitive</td>
      </tr>
      <tr>
          <td><code>cwd</code> / <code>working_directory</code></td>
          <td style="text-align: right">20</td>
          <td>Slash direction normalized; compared case-insensitively</td>
      </tr>
      <tr>
          <td><code>sandbox</code></td>
          <td style="text-align: right">5</td>
          <td>Exact Boolean value</td>
      </tr>
  </tbody>
</table>
<p>A shared field with a different value rejects the candidate. Missing fields neither match nor reject; they simply add no evidence. The highest <strong>unique</strong> score wins. A tie remains uncorrelated.</p>
<p>The same matcher is used twice: first to attach <code>beforeShellExecution</code> to the generic <code>Shell</code> call, then to attach <code>afterShellExecution</code> to its matching <code>before</code> node. Arrival order is no longer used to define identity.</p>
<p>If the specialized <code>before</code> event is missing but one generic Shell candidate can still be identified, the <code>afterShellExecution</code> event is attached directly beneath it. This keeps partial captures useful without inventing an intermediate event.</p>
<h2 id="mcp-the-same-puzzle-with-another-json-layer">MCP: the same puzzle with another JSON layer</h2>
<p>MCP calls follow the same shape as shell calls, with slightly different names:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">preToolUse (MCP:get_parallel_stacks)
</span></span><span class="line"><span class="cl">├── beforeMCPExecution (get_parallel_stacks)
</span></span><span class="line"><span class="cl">│   └── afterMCPExecution
</span></span><span class="line"><span class="cl">└── postToolUse (MCP:get_parallel_stacks)
</span></span></code></pre></div><p>Again, the generic hook payloads contain the same <code>tool_use_id</code> but it does not appear in the specialized before/after. This time, the <code>tool_name</code> field prefixes the tool name with <code>MCP:</code></p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="s2">&#34;tool_name&#34;</span><span class="err">:</span> <span class="s2">&#34;MCP:get_parallel_stacks&#34;</span><span class="err">,</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>The specialized MCP hook payload uses the exposed tool name (without <code>MCP:</code> prefix):</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="s2">&#34;tool_name&#34;</span><span class="err">:</span> <span class="s2">&#34;get_parallel_stacks&#34;</span><span class="err">,</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>It may also add <code>mcp_server_name</code>, <code>url</code> or the server command as fields. As already mention at the beginning of this section, its <code>tool_input</code> is a JSON string containing the MCP tool parameters.</p>
<p>So, for MCP tool calls, the matcher uses:</p>
<table>
  <thead>
      <tr>
          <th>Evidence</th>
          <th style="text-align: right">Weight</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>tool_input</code></td>
          <td style="text-align: right">100</td>
      </tr>
      <tr>
          <td><code>mcp_server_name</code></td>
          <td style="text-align: right">40</td>
      </tr>
      <tr>
          <td>Server URL</td>
          <td style="text-align: right">30</td>
      </tr>
      <tr>
          <td>Tool name, after removing <code>MCP:</code></td>
          <td style="text-align: right">20</td>
      </tr>
      <tr>
          <td>Server command</td>
          <td style="text-align: right">20</td>
      </tr>
  </tbody>
</table>
<p>This lets two concurrent calls to <code>get_duplicated_strings</code> share the same <code>tool_use_id</code> and still be separated by their inputs:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">get_duplicated_strings(dumpPath = A.dmp, countThreshold = 10)
</span></span><span class="line"><span class="cl">get_duplicated_strings(dumpPath = B.dmp, countThreshold = 20)
</span></span></code></pre></div><p>Again, this is evidence, not certainty. If both calls use the same server, tool and input, the observable payloads do not contain enough information to distinguish them. However, a model would not be very smart to ask for the exact same MCP tool calls in a row&hellip;</p>
<h2 id="file-hooks-use-a-path-fingerprint-not-an-id">File hooks use a path fingerprint, not an ID</h2>
<p>The file read/write hooks are following a similar pattern:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">    preToolUse (Read - foo.txt)
</span></span><span class="line"><span class="cl">    ├── beforeReadFile (foo.txt)
</span></span><span class="line"><span class="cl">    └── postToolUse (Read - foo.txt)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    preToolUse (Write - bar.md)
</span></span><span class="line"><span class="cl">    ├── AfterFileEdit (bar.md)
</span></span><span class="line"><span class="cl">    └── postToolUse (Write - bar.md)
</span></span></code></pre></div><p>Like for shell and MCP, <code>beforeReadFile</code> and <code>afterFileEdit</code> carry no <code>tool_use_id</code>, so they cannot use the exact ID matcher that pairs generic <code>preToolUse</code>/<code>postToolUse</code> events. They do, as detailed in the <a href="/posts/2026-08-23_spying-on-cursor-hooks/">first post of the series</a>, carry a <code>file_path</code>, and the owning <code>Read</code>/<code>Write</code>/<code>StrReplace</code>/<code>EditNotebook</code> call exposes the same path inside its <code>tool_input</code>. That shared path becomes the correlation fingerprint: <code>beforeReadFile</code> attaches to the <code>Read</code> targeting the same file; <code>afterFileEdit</code> attaches to the <code>Write</code>, <code>StrReplace</code> or <code>EditNotebook</code> targeting it. The top-level <code>file_path</code> and the <code>tool_input</code> path can differ in slash direction and casing, so both are normalized before comparison.</p>
<h2 id="and-what-about-failures">And what about failures?</h2>
<p>The <code>postToolUseFailure</code>  hook is the unsuccessful equivalent of <code>postToolUse</code>. According to Cursor&rsquo;s hook schema, its payload includes:</p>
<ul>
<li><code>failure_type</code>: <code>error</code>, <code>timeout</code> or <code>permission_denied</code>,</li>
<li><code>error_message</code>: text describing the issue leading to the error,</li>
<li><code>is_interrupt</code> as a boolean to indicate if it was cancelled by the user,</li>
<li><code>duration</code> for the time spent before failure.</li>
</ul>
<p>It goes through the same ID/name/input matcher.</p>
<p>Failures are expanded in the tree rather than collapsed, making the error payload immediately visible. it is interesting to note that the failures hooks are following the same pattern as postToolUse hooks; i.e. the <code>afterShellExecution</code> is still present with the same duration as what is provided by the failure payload:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">    preToolUse (shell)
</span></span><span class="line"><span class="cl">    ├── beforeShellExecution (dotnet --version...)
</span></span><span class="line"><span class="cl">    │   └── afterShellExecution (dotnet --version...)
</span></span><span class="line"><span class="cl">    └── postToolUseFailure (shell)
</span></span></code></pre></div><p>leads to the following nodes in the spy UI:</p>
<p><img alt="ShellFailureUI" loading="lazy" src="/posts/2026-09-14_rebuilding-cursor-conversation/ShellFailureUI.png"></p>
<h2 id="parallel-is-not-the-same-as-unordered">Parallel is not the same as unordered</h2>
<p>Once calls have a start and end, overlapping intervals can be grouped under a visual node:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Turn 1
</span></span><span class="line"><span class="cl">└── ∥ Parallel · 3 calls
</span></span><span class="line"><span class="cl">    ├── Grep
</span></span><span class="line"><span class="cl">    ├── Read
</span></span><span class="line"><span class="cl">    └── WebSearch
</span></span></code></pre></div><p>The interval starts at <code>preToolUse</code>. Its end comes from the matching post event; native <code>duration</code> is used for the call badge when available, otherwise the viewer falls back to the difference between observed timestamps.</p>
<p>Two calls belong to the same parallel wave when their intervals overlap. That is a <strong>viewer heuristic</strong>, not a batch identifier supplied by Cursor.</p>
<p>It also explains why durations must not simply be added. Three one-second calls running together have roughly one second of wall time, not three.</p>
<h2 id="subagents-have-a-better-key">Subagents have a better key</h2>
<p>Within a turn, subagent pairing is simple. <code>subagentStart</code> is kept in an in-flight dictionary by <code>subagent_id</code>; a matching <code>subagentStop</code> becomes its child and supplies the final status and duration:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">subagentStart (explore)
</span></span><span class="line"><span class="cl">└── subagentStop (completed)
</span></span></code></pre></div><p>This solves start/stop nesting inside the observed turn. It does not solve the harder problem mentioned in the first post: linking every separate subagent conversation or transcript back to its parent session when the emitted payloads do not expose a reliable relationship.</p>
<h2 id="which-skills-were-actually-used">Which skills were actually used?</h2>
<p>I was surprised to find no <code>skillLoaded</code> or <code>skillUsed</code> hook. Since a skill is defined in a <code>SKILL.md</code> file, HarnessSpy looks for observable evidence around that file name.</p>
<h3 id="the-strongest-signal-reading-skillmd">The strongest signal: reading <code>SKILL.md</code></h3>
<p><code>TargetFilePath</code> is extracted from a <code>file_path</code>  field or from <code>tool_input.file_path</code> / <code>tool_input.path</code>. If the filename is <code>SKILL.md</code>, its parent folder becomes the skill ID:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-csharp" data-lang="csharp"><span class="line"><span class="cl"><span class="kd">public</span> <span class="kd">static</span> <span class="kt">string?</span> <span class="n">TryGetSkillName</span><span class="p">(</span><span class="kt">string?</span> <span class="n">filePath</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="kt">string</span><span class="p">.</span><span class="n">IsNullOrWhiteSpace</span><span class="p">(</span><span class="n">filePath</span><span class="p">)</span> <span class="p">||</span>
</span></span><span class="line"><span class="cl">        <span class="p">!</span><span class="n">filePath</span><span class="p">.</span><span class="n">EndsWith</span><span class="p">(</span><span class="s">&#34;SKILL.md&#34;</span><span class="p">,</span> <span class="n">StringComparison</span><span class="p">.</span><span class="n">OrdinalIgnoreCase</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kt">string?</span> <span class="n">directory</span> <span class="p">=</span> <span class="n">Path</span><span class="p">.</span><span class="n">GetDirectoryName</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">filePath</span><span class="p">.</span><span class="n">Replace</span><span class="p">(</span><span class="sc">&#39;/&#39;</span><span class="p">,</span> <span class="n">Path</span><span class="p">.</span><span class="n">DirectorySeparatorChar</span><span class="p">));</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">Path</span><span class="p">.</span><span class="n">GetFileName</span><span class="p">(</span><span class="n">directory</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>For example:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">C:\Users\me\.cursor\skills\dotnet-memory-analysis\SKILL.md
</span></span></code></pre></div><p>becomes:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">dotnet-memory-analysis
</span></span></code></pre></div><p>Repeated reads are deduplicated in the turn/session summary, although they still count as separate Read tool calls.</p>
<h3 id="slash-commands-show-intent">Slash commands show intent</h3>
<p>In <code>beforeSubmitPrompt</code>, you can find the <code>/action</code> commands used in a prompt. So, HarnessSpy keeps those that looks &ldquo;good&rdquo; such as <code>/dotnet-memory-analysis</code> while rejecting fragments of URLs and Windows paths.</p>
<p>These values appear under <strong>Commands</strong>, not Skills. A slash invocation proves what the user requested, but it does not prove that the corresponding <code>SKILL.md</code> was read.</p>
<p>This distinction catches an interesting situation:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Command: /dotnet-memory-analysis
</span></span><span class="line"><span class="cl">Skill read: dotnet-threads-analysis
</span></span></code></pre></div><p>The user asked for one workflow; the harness actually loaded another.</p>
<h3 id="thought-mentions-are-only-supporting-evidence">Thought mentions are only supporting evidence</h3>
<p>The <code>afterAgentThought</code> payload text field contains what is displayed by Cursor when <em>thinking</em>:
<img alt="ChainOfThoughtCorrelation" loading="lazy" src="/posts/2026-09-14_rebuilding-cursor-conversation/ChainOfThoughtCorrelation.png"></p>
<p>From the same <code>afterAgentThought</code>, HarnessSpy also analyzes phrases to identify skills such as:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">follow the dotnet-memory-analysis skill
</span></span></code></pre></div><p>The matcher deliberately requires a hyphenated ID followed by the word <code>skill</code>, avoiding generic phrases such as “this skill”. This is obviously a Cursor-specific heuristic, and certainly not proof that the file was loaded or followed correctly.</p>
<p>The three signals have different meanings:</p>
<table>
  <thead>
      <tr>
          <th>Signal</th>
          <th>What it supports</th>
          <th>What it does not prove</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Read of <code>&lt;id&gt;/SKILL.md</code></td>
          <td>The harness accessed that skill file</td>
          <td>Every instruction was retained or followed</td>
      </tr>
      <tr>
          <td><code>/id</code> in the user prompt</td>
          <td>Explicit command/invocation intent</td>
          <td>The skill was loaded</td>
      </tr>
      <tr>
          <td><code>&lt;id&gt; skill</code> in a thought</td>
          <td>The visible reasoning mentioned it</td>
          <td>The skill was read or executed</td>
      </tr>
  </tbody>
</table>
<p>There are unavoidable false positives and negatives. Any unrelated file named <code>SKILL.md</code> matches the path rule, while a skill injected implicitly into context without a <code>Read</code> event remains invisible.</p>
<p>The summary dashboard currently merges <code>SKILL.md</code> reads and thought mentions into the same <strong>Skills</strong> list; it does not make their provenance explicit. <strong>Commands</strong> remains separate because it represents invocation intent rather than observed loading.</p>
<h2 id="from-tree-nodes-to-a-summary-dashboard">From tree nodes to a summary dashboard</h2>
<p>Once the events are nested, <code>NodeSummaryBuilder</code> recursively walks a turn or a complete session and accumulates:</p>
<table>
  <thead>
      <tr>
          <th>Summary</th>
          <th>Evidence</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Wall time</td>
          <td>First to last observed event</td>
      </tr>
      <tr>
          <td>Tools</td>
          <td>Count on <code>preToolUse</code>, duration on matching <code>postToolUse</code></td>
      </tr>
      <tr>
          <td>MCP</td>
          <td>Count/duration from Cursor&rsquo;s dedicated MCP hooks, grouped by <code>server/tool</code></td>
      </tr>
      <tr>
          <td>Thoughts</td>
          <td>Number of blocks, character count and duration</td>
      </tr>
      <tr>
          <td>Skills</td>
          <td>Distinct <code>SKILL.md</code> reads and supported thought mentions</td>
      </tr>
      <tr>
          <td>Slash commands</td>
          <td>Distinct <code>/name</code> from submitted prompts</td>
      </tr>
      <tr>
          <td>Files</td>
          <td>Distinct paths from <code>afterFileEdit</code></td>
      </tr>
      <tr>
          <td>Subagents</td>
          <td>Type, task preview, status and duration</td>
      </tr>
      <tr>
          <td>Tokens</td>
          <td>Input, output, cache-read and cache-write snapshots</td>
      </tr>
      <tr>
          <td>Status badges</td>
          <td>Tool failures and aborted turns</td>
      </tr>
  </tbody>
</table>
<p>For one turn, this gives a compact answer to “what happened?” For a session, skills and commands become a union across turns, output/cache-write tokens are summed, and the latest input/cache-read snapshot represents the current accumulated context. I have to admit that I don&rsquo;t really know what these number represent&hellip;</p>
<p>There is a deliberate separation between normal tools and MCP tools. A generic tool named <code>MCP:&lt;name&gt;</code> is excluded from the normal tool count; Cursor&rsquo;s dedicated <code>beforeMCPExecution</code>/<code>afterMCPExecution</code> pair feeds the MCP table instead, avoiding double counting.</p>
<p>The resulting summary is useful, but it should not be mistaken for billing data or a provider trace. It is a summary of the events that reached this observer.</p>
<p>It also contains sensitive prompts, commands, paths, tool results and skill names. The plaintext-capture warning from the first post still applies to the reconstructed view based on the same json files.</p>
<h2 id="four-kinds-of-truth">Four kinds of truth</h2>
<p>The reconstructed tree mixes several qualities of information:</p>
<table>
  <thead>
      <tr>
          <th>Kind</th>
          <th>Example</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Captured</strong></td>
          <td>Cursor supplied <code>command</code>, <code>tool_input</code>, <code>duration</code> or <code>failure_type</code></td>
      </tr>
      <tr>
          <td><strong>Derived</strong></td>
          <td>Events sharing a <code>generation_id</code> are displayed as one turn</td>
      </tr>
      <tr>
          <td><strong>Heuristic</strong></td>
          <td>Overlapping intervals are grouped as a parallel wave</td>
      </tr>
      <tr>
          <td><strong>Ambiguous</strong></td>
          <td>Two calls have identical observable IDs, names and inputs, so no parent is selected</td>
      </tr>
  </tbody>
</table>
<p>It is important to understand these kinds instead of blindly trusting the UI.</p>
<p>The blind spots from the first post still apply:</p>
<ul>
<li>the assembled system prompt and final context sent to the model are not exposed,</li>
<li><code>afterAgentThought</code> provides rendered reasoning, not guaranteed private chain-of-thought,</li>
<li>Cursor does not expose a complete user-question/answer or approval-resolution pair,</li>
<li>hooks can fail, time out or arrive incomplete/empty,</li>
<li>absence of a hook is not proof that an internal operation did not occur.</li>
</ul>
<p>The reconstruction adds one more lesson: correlation itself has limits. When the harness does not emit enough discriminating evidence, the honest result is an orphan node.</p>
<h2 id="conclusion">Conclusion</h2>
<p>Here are the important lessons learnt while trying  to rebuild a Cursor conversation:</p>
<ol>
<li><strong>An ID is only the first clue.</strong> <code>tool_use_id</code> can be reused, so tool name and canonical input are needed to separate calls.</li>
<li><strong>Arrival order is not causality.</strong> Shell and MCP calls can overlap and complete non-LIFO; commands, server identity and inputs are stronger evidence.</li>
<li><strong>Failures participate in correlation.</strong> They close calls and must clean pending state before retries.</li>
<li><strong>Skill usage is only inferred from evidences.</strong> A <code>SKILL.md</code> read, slash command and thought mention do not mean the same thing.</li>
<li><strong>A summary is a projection, not an oracle.</strong> It is only as complete as what was extracted from the existing triggered hooks.</li>
</ol>
<p>The next posts will show what happens when trying to feed Claude Code, GitHub Copilot CLI, and VS Code events through this same model. As we will see, using one tree does not mean that every harness provides the same level of visibility on what it is doing.</p>
<h2 id="references">References</h2>
<ul>
<li><a href="/posts/2026-08-23_spying-on-cursor-hooks/">Part 1: Spying on Cursor: agent hooks, payloads and a simple observer</a></li>
<li><a href="https://github.com/chrisnas/HarnessSpy">HarnessSpy source code</a></li>
<li><a href="https://cursor.com/docs/hooks">Cursor hooks documentation</a></li>
</ul>
]]></content:encoded></item></channel></rss>