<?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>VS Code on Welcome to Christophe Nasarre's Blog</title><link>https://chrisnas.github.io/tags/vs-code/</link><description>Recent content in VS Code on Welcome to Christophe Nasarre's Blog</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Sun, 27 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://chrisnas.github.io/tags/vs-code/index.xml" rel="self" type="application/rss+xml"/><item><title>Spying on GitHub Copilot twice: CLI hooks versus VS Code</title><link>https://chrisnas.github.io/posts/2026-09-27_spying-on-github-copilot-twice/</link><pubDate>Sun, 27 Sep 2026 00:00:00 +0000</pubDate><guid>https://chrisnas.github.io/posts/2026-09-27_spying-on-github-copilot-twice/</guid><description>GitHub Copilot exposes two very different hook surfaces: a rich Copilot CLI stream with weak identity and derived turns, and a thin VS Code agent-hooks Preview with exact tool and agent IDs.</description><content:encoded><![CDATA[<p>The <a href="/posts/2026-08-23_spying-on-cursor-hooks/">first post of this series</a> captured Cursor&rsquo;s hook payloads, the <a href="/posts/2026-09-14_rebuilding-cursor-conversation/">second</a> rebuilt them into a session/turn/tool tree, and the <a href="/posts/2026-09-20_spying-on-claude-code-hooks/">third</a> extended everything to Claude Code. Adding GitHub Copilot looked like the easy third harness. It turned out to be two harnesses wearing the same name.</p>
<p>GitHub Copilot exposes hooks in <strong>two completely different places</strong>: the <strong>Copilot CLI</strong> and <strong>Visual Studio Code</strong> (its agent-hooks Preview). Same provider identity, same <code>CopilotSpy.App</code>, same named pipe—but two payload dialects with almost opposite strengths. The CLI has a broad, chatty lifecycle (permissions, notifications, errors, compaction, a transformed-prompt event) yet almost no stable identifiers in its <strong>hook payloads</strong>: no turn ID, no tool-call ID, and MCP tool names flattened into an ambiguous string. VS Code is the mirror image: it restores exact <code>tool_use_id</code> and <code>agent_id</code>, but on a thin eight-event surface with no permissions, no errors and no transcript pointer.</p>
<p>This post focuses on those hook contracts. The current <code>CopilotSpy.App</code> can also use the CLI&rsquo;s late <code>transcriptPath</code> to tail <code>events.jsonl</code>, preserve a sidecar and enrich the hook tree but it will be covered in Part 5; VS Code remains hooks-only.</p>
<p>This is the fourth 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><a href="/posts/2026-09-14_rebuilding-cursor-conversation/">Rebuilding the Agent conversation: sessions, turns, thoughts, tools, MCP, skills and summaries</a></li>
<li><a href="/posts/2026-09-20_spying-on-claude-code-hooks/">Spying on Claude Code: more lifecycle events, different blind spots</a></li>
<li><strong>Spying on GitHub Copilot twice: CLI hooks versus VS Code</strong> (this post)</li>
<li>Beyond hooks: enriching live sessions with undocumented transcript logs</li>
</ol>
<p>CopilotSpy reuses the same architecture as in the previous posts: a short-lived console hook forwards each observation to a long-lived WPF viewer over a named pipe. I will not repeat the stdin, pipe and envelope mechanics; this post is about what changes when one provider speaks two dialects.</p>
<h2 id="one-provider-two-runtime-engines">One provider, two runtime engines</h2>
<p>In the first posts, one harness meant one runtime engine—the small class that turns a native payload into HarnessSpy&rsquo;s neutral traits. Copilot breaks that assumption. <code>CopilotSpy.App</code> listens to a single pipe, but behind the scenes the registry has to resolve <strong>two</strong> engines for the same provider:</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></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="k">if</span> <span class="p">(</span><span class="n">harnessId</span> <span class="p">==</span> <span class="n">HarnessIds</span><span class="p">.</span><span class="n">GitHubCopilot</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="c1">// An actual VS Code Local observation uses the VS Code engine; the</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// CLI (including its VS Code-compatible dialect) keeps CLI identity.</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">surfaceId</span> <span class="p">==</span> <span class="n">SurfaceIds</span><span class="p">.</span><span class="n">VsCodeAgentHooks</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="n">VsCodeLocal</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="k">return</span> <span class="n">CopilotCli</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>The provider is <code>github-copilot</code> in both cases. What distinguishes them is the <strong>surface</strong>: <code>CopilotCli</code> or <code>VsCodeAgentHooks</code>. Everything downstream—the tree, the inspector, the summaries—stays provider-neutral, exactly as in the earlier posts. Only the engine that interprets the raw JSON payloads is different.</p>
<p>The two surfaces do not even agree on how many events exist, or how they are spelled. The Copilot CLI v1 contract has <strong>14 camelCase events</strong>; the VS Code Local Preview has <strong>8 PascalCase events</strong>:</p>
<table>
  <thead>
      <tr>
          <th>Copilot CLI (14, camelCase)</th>
          <th>VS Code Local (8, PascalCase)</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>sessionStart</code></td>
          <td><code>SessionStart</code></td>
      </tr>
      <tr>
          <td><code>sessionEnd</code></td>
          <td>—</td>
      </tr>
      <tr>
          <td><code>userPromptSubmitted</code></td>
          <td><code>UserPromptSubmit</code></td>
      </tr>
      <tr>
          <td><code>userPromptTransformed</code></td>
          <td>—</td>
      </tr>
      <tr>
          <td><code>preToolUse</code></td>
          <td><code>PreToolUse</code></td>
      </tr>
      <tr>
          <td><code>postToolUse</code></td>
          <td><code>PostToolUse</code></td>
      </tr>
      <tr>
          <td><code>postToolUseFailure</code></td>
          <td>—</td>
      </tr>
      <tr>
          <td><code>permissionRequest</code></td>
          <td>—</td>
      </tr>
      <tr>
          <td><code>notification</code></td>
          <td>—</td>
      </tr>
      <tr>
          <td><code>agentStop</code></td>
          <td><code>Stop</code></td>
      </tr>
      <tr>
          <td><code>subagentStart</code></td>
          <td><code>SubagentStart</code></td>
      </tr>
      <tr>
          <td><code>subagentStop</code></td>
          <td><code>SubagentStop</code></td>
      </tr>
      <tr>
          <td><code>errorOccurred</code></td>
          <td>—</td>
      </tr>
      <tr>
          <td><code>preCompact</code></td>
          <td><code>PreCompact</code></td>
      </tr>
  </tbody>
</table>
<p>Reading the right-handside column top to bottom already tells you what VS Code will not let you see: no session end, no transformed prompt, no tool failure, no permission request, and no notification. It even renames the turn terminator from <code>agentStop</code> to <code>Stop</code>.</p>
<p>I keep those two catalogs deliberately separate rather than merging them into one case-insensitive dictionary, because the CLI can <em>also</em> emit a VS Code-compatible dialect while still being the CLI.</p>
<h2 id="the-vs-code-configuration-nightmare">The VS Code configuration nightmare</h2>
<p>Creating the CLI json file is a one-liner:</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-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="n">CopilotSpy</span><span class="p">.</span><span class="py">Hook</span><span class="p">.</span><span class="py">exe</span> <span class="p">-</span><span class="n">-generate-settings</span> <span class="n">C:</span><span class="p">\</span><span class="n">temp</span><span class="p">\</span><span class="nb">harness-spy</span><span class="p">.</span><span class="py">json</span> <span class="n">C:</span><span class="p">\</span><span class="n">tools</span><span class="p">\</span><span class="n">CopilotSpy</span><span class="p">.</span><span class="py">Hook</span><span class="p">.</span><span class="py">exe</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>That generates the 14 events <code>harness-spy.json</code>, with the real executable path, a five-second timeout, the dialect and an explicit HarnessSpy identity for every event:</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-json" data-lang="json"><span class="line"><span class="cl"><span class="s2">&#34;preToolUse&#34;</span><span class="err">:</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="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;command&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;powershell&#34;</span><span class="p">:</span> <span class="s2">&#34;&amp; &#39;C:\\tools\\CopilotSpy.Hook.exe&#39; --event preToolUse --source copilot-cli --hook preToolUse --runtime github-copilot --dialect copilot-cli-camel&#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;timeoutSec&#34;</span><span class="p">:</span> <span class="mi">5</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;env&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;HARNESS_SPY_HOST&#34;</span><span class="p">:</span> <span class="s2">&#34;github-copilot&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;HARNESS_SPY_RUNTIME_ID&#34;</span><span class="p">:</span> <span class="s2">&#34;github-copilot&#34;</span>
</span></span><span class="line"><span class="cl">    <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="p">]</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>This file can be stored in the <code>.github\hooks</code> folder of the repository root for per repository trigger, in <code>~/.copilot/hooks</code> or <code>%USERPROFILE%\.copilot\hooks</code> for user level triggering (look at <a href="https://docs.github.com/en/copilot/reference/hooks-reference#hooks-locations">the documentation</a> for more configuration).</p>
<p>Same for the VS Code profile:</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-powershell" data-lang="powershell"><span class="line"><span class="cl"><span class="n">CopilotSpy</span><span class="p">.</span><span class="py">Hook</span><span class="p">.</span><span class="py">exe</span> <span class="p">-</span><span class="n">-generate-vs-settings</span> <span class="n">C:</span><span class="p">\</span><span class="n">temp</span><span class="p">\</span><span class="nb">vscode-hooks</span><span class="p">.</span><span class="py">json</span> <span class="n">C:</span><span class="p">\</span><span class="n">tools</span><span class="p">\</span><span class="n">CopilotSpy</span><span class="p">.</span><span class="py">Hook</span><span class="p">.</span><span class="py">exe</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>That generates the eight events VS Code profile. It looks like a cousin of the CLI file, with a few differences:</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-json" data-lang="json"><span class="line"><span class="cl"><span class="s2">&#34;PreToolUse&#34;</span><span class="err">:</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="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;command&#34;</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;&amp; &#39;C:\\tools\\CopilotSpy.Hook.exe&#39; --event PreToolUse --source vscode-local --hook PreToolUse --runtime github-copilot --dialect vscode-local&#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">5</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;env&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;HARNESS_SPY_HOST&#34;</span><span class="p">:</span> <span class="s2">&#34;github-copilot&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;HARNESS_SPY_RUNTIME_ID&#34;</span><span class="p">:</span> <span class="s2">&#34;vscode-agent-hooks&#34;</span>
</span></span><span class="line"><span class="cl">    <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="p">]</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Let&rsquo;s compare the two files: PascalCase <code>PreToolUse</code> instead of camelCase <code>preToolUse</code>, a generic <code>command</code> field instead of <code>powershell</code>, <code>timeout</code> in plain seconds instead of <code>timeoutSec</code>, and—decisively—<code>HARNESS_SPY_RUNTIME_ID</code> set to <code>vscode-agent-hooks</code>. That last value is the authoritative routing key: it is what sends these observations to the VS Code engine instead of the CLI one, no matter how the payload is cased.</p>
<p>The <code>command</code> value hides the sharpest trap. VS Code runs that string <strong>through PowerShell on Windows</strong>, so it has to begin with the call operator <code>&amp; '&lt;exe&gt;'</code>. Try the natural-looking <code>&quot;C:\\tools\\CopilotSpy.Hook.exe&quot; --event ...</code> instead and PowerShell parses the quoted path as a <em>string literal</em>, echoes it, and executes nothing—the hook silently never fires and you get zero events with no error to explain the silence. It is the same reason the CLI&rsquo;s <code>powershell</code> field already uses <code>&amp; '...'</code>; VS Code just buries the requirement under a generic <code>command</code> key, where the mistake is much easier to make; I can tell you that I burnt a few tokens to help me understand why the UI of CopilotSpy was not updated&hellip;</p>
<p>A different VS Code generator was also added. Why not merging the 14+8 hooks definitions in the same .json file? Long story short, VS Code tries to understand both. So, you would get duplicated events that would be complicated to unentangle. So, the recommendation is to store this hooks definition file in <code>.vscode\hooks</code> folder and disable the <code>.github/hooks</code> folder. It means that you have to create/update your <code>.vscode/settings.json</code> with the following <code>chat.hookFilesLocations</code> enabled/disabled folders:</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;chat.hookFilesLocations&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;.vscode/hooks&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;.github/hooks&#34;</span><span class="p">:</span> <span class="kc">false</span>
</span></span><span class="line"><span class="cl">  <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>Note that you should manually check the hooks within VS Code via SHIFT+CTRL+P | hooks:</p>
<p><img alt="VSCodeHooksSettingsAccess" loading="lazy" src="/posts/2026-09-27_spying-on-github-copilot-twice/VSCodeHooksSettingsAccess.png"></p>
<p>and the dedicated UI summary appears:</p>
<p><img alt="VSCodeHookSettingsUI" loading="lazy" src="/posts/2026-09-27_spying-on-github-copilot-twice/VSCodeHookSettingsUI.png"></p>
<p>Ensure that only the expected 8 hooks are decorated with (1). If the last two hooks are decorated with (1), it means that a Copilot CLI hook definition file has been loaded by VS Code.  The two reference documents live in different places too: the <a href="https://docs.github.com/en/copilot/reference/hooks-reference">GitHub Copilot hooks reference</a> and the <a href="https://code.visualstudio.com/docs/agents/reference/hooks-reference">VS Code agent hooks reference</a>.</p>
<p>Like the Cursor hook, the Copilot hook stays strictly passive: it writes <code>{}</code> to stdout, never returns <code>permission</code>, <code>additional_context</code> or any other guiding data, and swallows its own failures so a monitoring bug can never block a real Copilot session.</p>
<h2 id="how-to-figure-out-the-triggered-hook-name">How to figure out the triggered hook name?</h2>
<p>Cursor and Claude put the event name inside the payload (<code>hook_event_name</code>). The Copilot CLI does <strong>not</strong>: it omits any event discriminator and relies on the configured hook key instead. So for the CLI engine, the configured <code>--event</code> key is the authoritative identity, and the payload&rsquo;s name is only a fallback:</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></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="c1">// CopilotCliRuntimeEngine</span>
</span></span><span class="line"><span class="cl"><span class="kt">string</span> <span class="n">name</span> <span class="p">=</span>
</span></span><span class="line"><span class="cl">    <span class="n">context</span><span class="p">.</span><span class="n">ConfiguredEventName</span> <span class="p">??</span>
</span></span><span class="line"><span class="cl">    <span class="n">context</span><span class="p">.</span><span class="n">PayloadEventName</span> <span class="p">??</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;unknownHook&#34;</span><span class="p">;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>The VS Code engine flips the priority, because VS Code <em>does</em> send a payload name and its configuration is hand-written and less trustworthy:</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></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="c1">// VsCodeLocalRuntimeEngine</span>
</span></span><span class="line"><span class="cl"><span class="kt">string</span> <span class="n">name</span> <span class="p">=</span>
</span></span><span class="line"><span class="cl">    <span class="n">context</span><span class="p">.</span><span class="n">PayloadEventName</span> <span class="p">??</span>
</span></span><span class="line"><span class="cl">    <span class="n">context</span><span class="p">.</span><span class="n">ConfiguredEventName</span> <span class="p">??</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;unknownHook&#34;</span><span class="p">;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>This is why the generated CLI config passes <code>--event &lt;name&gt;</code> and <code>--hook &lt;name&gt;</code> for every hook: without it, a payload with no <code>hook_event_name</code> would be an anonymous blob.</p>
<h2 id="casing-is-not-identity">Casing is not identity</h2>
<p>Here is the trap that justifies keeping two catalogs: the Copilot CLI can emit its own camelCase dialect <strong>or</strong> a VS Code-compatible PascalCase/snake_case dialect. If HarnessSpy inferred &ldquo;this is VS Code&rdquo; purely from <code>PascalCase</code> keys, a CLI session speaking the compatible dialect would be misrouted to the wrong engine and lose its CLI identity.</p>
<p>The rule, stated in the architecture, is blunt: <strong>do not infer a runtime from casing alone.</strong> The generated value for <code>HARNESS_SPY_RUNTIME_ID</code> is authoritative; payload shape is only a fallback:</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></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="k">return</span> <span class="n">runtimeId</span> <span class="k">switch</span>
</span></span><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;cursor&#34;</span>             <span class="p">=&gt;</span> <span class="n">HookSurface</span><span class="p">.</span><span class="n">CursorIde</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;claude-code&#34;</span>        <span class="p">=&gt;</span> <span class="n">HookSurface</span><span class="p">.</span><span class="n">ClaudeCode</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;github-copilot&#34;</span> <span class="n">or</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;copilot-cli&#34;</span>        <span class="p">=&gt;</span> <span class="n">HookSurface</span><span class="p">.</span><span class="n">CopilotCli</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;vscode-agent-hooks&#34;</span> <span class="p">=&gt;</span> <span class="n">HookSurface</span><span class="p">.</span><span class="n">VsCodeAgentHooks</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">_</span>                    <span class="p">=&gt;</span> <span class="kc">null</span>
</span></span><span class="line"><span class="cl"><span class="p">};</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>Only when that environment variable is missing does the detector fall back to payload evidence, and even then it leans on the timestamp <em>type</em> rather than on casing:</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></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="c1">// Copilot CLI camelCase: session id plus a numeric epoch timestamp.</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="n">Has</span><span class="p">(</span><span class="n">payload</span><span class="p">,</span> <span class="s">&#34;sessionId&#34;</span><span class="p">)</span> <span class="p">&amp;&amp;</span>
</span></span><span class="line"><span class="cl">    <span class="n">payload</span><span class="p">.</span><span class="n">TryGetProperty</span><span class="p">(</span><span class="s">&#34;timestamp&#34;</span><span class="p">,</span> <span class="k">out</span> <span class="n">JsonElement</span> <span class="n">nativeTimestamp</span><span class="p">)</span> <span class="p">&amp;&amp;</span>
</span></span><span class="line"><span class="cl">    <span class="n">nativeTimestamp</span><span class="p">.</span><span class="n">ValueKind</span> <span class="p">==</span> <span class="n">JsonValueKind</span><span class="p">.</span><span class="n">Number</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="n">HookSurface</span><span class="p">.</span><span class="n">CopilotCli</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>The CLI sets <code>timestamp</code> as <strong>epoch milliseconds</strong> (a JSON number); VS Code set it as an <strong>ISO-8601 string</strong>. HarnessSpy reads either without rewriting the payload, but treats both as weak confirmation, never as the primary signal. The registry then enforces the final rule: only a  <code>VsCodeAgentHooks</code> surface uses the VS Code engine; a CLI PascalCase dialect still resolves to the CLI engine.</p>
<h2 id="inferring-turns-without-a-turn-id">Inferring turns without a turn ID</h2>
<p>Cursor grouped a turn by <code>generation_id</code>; Claude by <code>prompt_id</code>. The Copilot CLI gives me <strong>neither</strong>. There is no native turn key at all. So turns are <em>inferred</em> from the prompt/stop boundary. <code>userPromptSubmitted</code> opens a turn, everything until <code>agentStop</code> belongs to it, and the viewer assigns a synthetic <code>derived-N</code> label per session. A <code>userPromptTransformed</code> event—the CLI&rsquo;s rewritten version of your prompt—nests neatly under the <code>userPromptSubmitted</code> that opened the turn.</p>
<p>There is one ordering quirk worth mentioning: the CLI emits <code>sessionStart</code> a couple of seconds <strong>after</strong> the first prompt, so its native epoch timestamp is <em>later</em> than the turn it should precede. Sorting purely by timestamp would drop the &ldquo;new session&rdquo; node to the bottom of the session. The result reads the way a human expects, even though the raw timestamps disagree:</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></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Session (sessionId)
</span></span><span class="line"><span class="cl">├── sessionStart              (emitted late, pinned to the top)
</span></span><span class="line"><span class="cl">└── Turn derived-1 · &#34;look for duplicated strings...&#34;
</span></span><span class="line"><span class="cl">    ├── userPromptSubmitted
</span></span><span class="line"><span class="cl">    │   └── userPromptTransformed
</span></span><span class="line"><span class="cl">    ├── preToolUse (grep)
</span></span><span class="line"><span class="cl">    │   └── postToolUse (grep)
</span></span><span class="line"><span class="cl">    ├── preToolUse (dotnet-dstrings · MCP)
</span></span><span class="line"><span class="cl">    │   ├── permissionRequest        (attached by shared arguments)
</span></span><span class="line"><span class="cl">    │   └── postToolUse (dotnet-dstrings · MCP)
</span></span><span class="line"><span class="cl">    └── agentStop
</span></span></code></pre></td></tr></table>
</div>
</div><p>Every relationship in that tree is <strong>inferred</strong>, not captured. That is the honest label for a turn built from boundaries rather than from an ID.</p>
<h2 id="pairing-tools-without-an-id">Pairing tools without an ID</h2>
<p>The same &ldquo;missing identifier&rdquo; problem happens for tools. For the CLI, <code>preToolUse</code> and <code>postToolUse</code> carry <strong>no <code>tool_use_id</code></strong>. I cannot use the exact -ID matcher that worked for Cursor and Claude.</p>
<p>Instead, the completion is paired to its request by native tool name plus  <code>toolArgs</code>. Native names are preserved exactly, just like Claude&rsquo;s <code>Bash</code> stayed <code>Bash</code>: the CLI&rsquo;s <code>bash</code>, <code>powershell</code>, <code>view</code>, <code>create</code>, <code>edit</code>, <code>grep</code>, <code>glob</code> and <code>task</code> are never renamed to a canonical vocabulary. One tool does earn a little extra processing: <code>skill</code>. Copilot invokes a skill through a <code>skill</code> tool that carries the activated skill&rsquo;s id in its native <code>toolArgs.skill</code> (sometimes as a JSON-encoded string), mirroring Claude&rsquo;s <code>Skill</code> tool that keeps the id under <code>tool_input</code>. HarnessSpy uses that id and renders the call with the shared skill styling, so a Copilot skill run reads as a skill rather than one more opaque tool. The computation of <code>toolArgs</code> reuses the structural JSON normalization from the second post (sorted keys, preserved array order, parsed nested JSON strings), so a completion matches its request even when the JSON is re-serialized.</p>
<p>The unavoidable weakness appears when two calls are identical—same tool, same arguments—inside one turn:</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">preToolUse   glob **/*.cs
</span></span><span class="line"><span class="cl">preToolUse   glob **/*.cs
</span></span><span class="line"><span class="cl">postToolUse  glob **/*.cs
</span></span><span class="line"><span class="cl">postToolUse  glob **/*.cs
</span></span></code></pre></td></tr></table>
</div>
</div><p>but it should never happen with &ldquo;smart&rdquo; models  :^)</p>
<p>With no ID and identical signatures, there is nothing left to distinguish them, so HarnessSpy pairs them in <strong>FIFO/arrival order</strong>: the first completion closes the first request. Every request still ends up with exactly one completion and none is orphaned, but this last step is explicitly a heuristic.</p>
<h2 id="the-mcp-names-resolution">The MCP names resolution</h2>
<p>Copilot&rsquo;s most interesting blind spot is related to MCP. Remember how the other harnesses mark an MCP call:</p>
<ul>
<li><strong>Cursor</strong>: a <code>MCP:</code> prefix on the tool name <em>and</em> a dedicated <code>mcp_server_name</code> field.</li>
<li><strong>Claude</strong>: the unambiguous <code>mcp__&lt;server&gt;__&lt;tool&gt;</code> convention.</li>
</ul>
<p>The Copilot CLI does neither. It flattens an MCP call to <code>&lt;server&gt;-&lt;tool&gt;</code> with <strong>no marker at all</strong>, and the hyphen boundary is ambiguous because both the server name and the tool name can themselves contain hyphens and underscores. Faced with <code>dotnet-dstrings-get_duplicated_strings</code>, where does the server end? Splitting blindly on the first hyphen would give the server <code>dotnet</code>, which is wrong—it is my <code>dotnet-dstrings</code> MCP server from the first post.</p>
<p>HarnessSpy never splits blindly on hyphens. Instead the classifier combines three complementary, hooks-only signals.</p>
<p><strong>Signal 1 — learn the boundary from events.</strong> Two events use an unambiguous <code>&lt;server&gt;/&lt;tool&gt;</code> form with a slash. The <code>permissionRequest</code> names the tool as <code>dotnet-dstrings/get_duplicated_strings</code>, and the permission <code>notification</code> message reads <code>Use MCP tool: dotnet-dstrings/get_duplicated_strings</code>. A slash never appears inside a server name, so the first slash <em>is</em> the boundary:</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-csharp" data-lang="csharp"><span class="line"><span class="cl"><span class="c1">// The server name never contains a slash, so the first one is the</span>
</span></span><span class="line"><span class="cl"><span class="c1">// boundary. The CLI flattens the same call by replacing it with a hyphen.</span>
</span></span><span class="line"><span class="cl"><span class="kt">int</span> <span class="n">separator</span> <span class="p">=</span> <span class="n">slashToolName</span><span class="p">.</span><span class="n">IndexOf</span><span class="p">(</span><span class="sc">&#39;/&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kt">string</span> <span class="n">server</span> <span class="p">=</span> <span class="n">slashToolName</span><span class="p">[..</span><span class="n">separator</span><span class="p">];</span>
</span></span><span class="line"><span class="cl"><span class="kt">string</span> <span class="n">tool</span>   <span class="p">=</span> <span class="n">slashToolName</span><span class="p">[(</span><span class="n">separator</span> <span class="p">+</span> <span class="m">1</span><span class="p">)..];</span>
</span></span><span class="line"><span class="cl"><span class="k">return</span> <span class="k">new</span> <span class="n">CopilotMcpIdentity</span><span class="p">(</span><span class="n">server</span><span class="p">,</span> <span class="n">tool</span><span class="p">,</span> <span class="s">$&#34;{server}-{tool}&#34;</span><span class="p">);</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p><strong>Signal 2 — a negative allowlist of built-in tools.</strong> Even before any permission event, the classifier knows the CLI&rsquo;s built-in tools. Anything outside that set is treated as MCP, with the server still unknown:</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></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">private</span> <span class="kd">static</span> <span class="k">readonly</span> <span class="n">HashSet</span><span class="p">&lt;</span><span class="kt">string</span><span class="p">&gt;</span> <span class="n">_builtInTools</span> <span class="p">=</span> <span class="k">new</span><span class="p">(</span><span class="n">StringComparer</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="s">&#34;shell&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;bash&#34;</span><span class="p">,</span> <span class="s">&#34;read_bash&#34;</span><span class="p">,</span> <span class="s">&#34;write_bash&#34;</span><span class="p">,</span> <span class="s">&#34;stop_bash&#34;</span><span class="p">,</span> <span class="s">&#34;list_bash&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;powershell&#34;</span><span class="p">,</span> <span class="s">&#34;read_powershell&#34;</span><span class="p">,</span> <span class="s">&#34;write_powershell&#34;</span><span class="p">,</span> <span class="s">&#34;stop_powershell&#34;</span><span class="p">,</span> <span class="s">&#34;list_powershell&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;view&#34;</span><span class="p">,</span> <span class="s">&#34;create&#34;</span><span class="p">,</span> <span class="s">&#34;edit&#34;</span><span class="p">,</span> <span class="s">&#34;apply_patch&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;grep&#34;</span><span class="p">,</span> <span class="s">&#34;rg&#34;</span><span class="p">,</span> <span class="s">&#34;glob&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;web_fetch&#34;</span><span class="p">,</span> <span class="s">&#34;web_search&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="s">&#34;ask_user&#34;</span><span class="p">,</span> <span class="s">&#34;skill&#34;</span><span class="p">,</span> <span class="s">&#34;task&#34;</span><span class="p">,</span> <span class="s">&#34;report_intent&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// ... plus the CLI&#39;s own documentation/search/sql/agent tools</span>
</span></span><span class="line"><span class="cl"><span class="p">};</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p><strong>Signal 3 — a per-session cache.</strong> The <code>&lt;server&gt;/&lt;tool&gt;</code> pairs learned from signal 1 are remembered per session, so the flattened <code>preToolUse</code>/<code>postToolUse</code> names can later be split back into server and tool. Put together, the evidence flows like this within one session:</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></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-dstrings-get_duplicated_strings   → allowlist says &#34;not built-in&#34; → MCP, server unknown
</span></span><span class="line"><span class="cl">permissionRequest  dotnet-dstrings/get_duplicated_strings → slash boundary → server = dotnet-dstrings (cached)
</span></span><span class="line"><span class="cl">postToolUse dotnet-dstrings-get_duplicated_strings   → cache split → server = dotnet-dstrings, tool = get_duplicated_strings
</span></span></code></pre></td></tr></table>
</div>
</div><p>Notice the order: in real traces, the request often arrives <em>before</em> the permission that defines the boundary. The completion still pairs with its request by signature, and the whole thing is summarized as <strong>one MCP call</strong>, not as a native tool—even though the server name was only learned in the middle. The cache is reset on <code>sessionStart</code> and <code>sessionEnd</code>, so a reused session id never inherits an inconsistent mapping.</p>
<p>This is learning by observation. It is good enough to color the call as MCP and, once a permission or notification appears, to attribute the server correctly. But it is inference, and it is labeled as such in the UI.</p>
<h2 id="attaching-a-permission-prompt-to-the-call-it-is-related-to">Attaching a permission prompt to the call it is related to</h2>
<p>Detecting the MCP boundary is not the only job those permission events do. A <code>permissionRequest</code>—and the <code>permission_prompt</code> <code>notification</code> that mirrors it—describes a tool call that is <em>still in flight</em>, so it appears <strong>under</strong> that request in the tree, not floating loose at the turn level. One issue is that Copilot spells the very same call three different ways across the three events:</p>
<ul>
<li>the <strong>request</strong> is <code>apply_patch</code> with the path buried inside a raw patch string, or <code>&lt;server&gt;-&lt;tool&gt;</code> for an MCP call, and carries its input under <code>toolArgs</code>;</li>
<li>the <strong>permission</strong> is <code>edit</code> with a structured <code>file_path</code>, or <code>&lt;server&gt;/&lt;tool&gt;</code>, and carries its input under <code>toolInput</code>;</li>
<li>the <strong>notification</strong> has no structured input at all—only free text such as <code>Edit file: &lt;path&gt;</code> or <code>Run command: &lt;cmd&gt;</code>.</li>
</ul>
<p>Tool name equality is useless here, so HarnessSpy matches name by the strongest signal the two events share in the following order: <strong>target file name, then shell command, then  arguments</strong>.</p>
<p>The file name is inferred out of a structured <code>file_path</code>/<code>path</code>, an <code>apply_patch</code> header (<code>*** Update File: …</code>), or the notification&rsquo;s message text; a <code>Run command:</code> message that embeds a Windows path is bound by its command and never mistaken for a file. Two in-flight MCP calls that share a name are differentiated by their arguments alone, so permissions that arrive out of order still land on the right request.</p>
<p>Hooks alone do not reveal whether the user granted the permission. CLI transcript enrichment can later attach <code>permission.completed</code> to the same tool request as the next post will explain.</p>
<h2 id="what-only-the-cli-tells-you">What only the CLI tells you</h2>
<p>For all its identifier weakness, the CLI is nevertheless the most verbose. Beyond prompts and tools, it exposes lifecycle events that VS Code simply does not have:</p>
<table>
  <thead>
      <tr>
          <th>CLI event</th>
          <th>What it adds</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>userPromptTransformed</code></td>
          <td>The harness-rewritten prompt, nested under the submitted prompt</td>
      </tr>
      <tr>
          <td><code>permissionRequest</code></td>
          <td>The tool (and MCP <code>&lt;server&gt;/&lt;tool&gt;</code>) awaiting approval, nested under its corresponding request</td>
      </tr>
      <tr>
          <td><code>notification</code></td>
          <td>User-visible messages; a <code>permission_prompt</code> type is well identified and attaches to its request</td>
      </tr>
      <tr>
          <td><code>postToolUseFailure</code></td>
          <td>A failed tool call with its <code>error</code>, paired by signature</td>
      </tr>
      <tr>
          <td><code>errorOccurred</code></td>
          <td>A runtime error with <code>errorContext</code> and a <code>recoverable</code> flag</td>
      </tr>
      <tr>
          <td><code>preCompact</code></td>
          <td>Context compaction is starting, with its <code>trigger</code></td>
      </tr>
      <tr>
          <td><code>agentStop</code></td>
          <td>Normally carries the late <code>transcriptPath</code> (more on that below)</td>
      </tr>
  </tbody>
</table>
<p>The permission and notification events are the same ones that feed the MCP classifier, so they now pull triple duty: they highlight an approval prompt in the tree, teach the session its MCP boundaries, and nest under the in-flight request they guard (see the previous section). As with Claude, HarnessSpy only <em>observes</em> a <code>permissionRequest</code>; it never answers it. If you were interested in speeding up your work by accepting all actions except blacklisted one, feel free to change the hook empty return to stdout for <a href="https://docs.github.com/en/copilot/reference/hooks-reference#permissionrequest-decision-control"><code>permissionRequest</code></a> or <a href="https://docs.github.com/en/copilot/reference/hooks-reference#pretooluse-decision-control"><code>preToolUse</code></a>.</p>
<p>Subagents expose the CLI&rsquo;s identifier problem in miniature. The <code>subagentStart</code> payload does <strong>not</strong> carry the <code>agentId</code> that <code>subagentStop</code> later provides—start only has an <code>agentName</code>. The two events describe the same subagent with different keys, so they can only be paired heuristically, by name.</p>
<h2 id="vs-code-exact-ids-but-for-much-less-details">VS Code: exact IDs but for much less details</h2>
<p>When you switch to the VS Code engine, the trade-off inverts completely. Everything the CLI lacks in identity, VS Code provides. Its <code>PreToolUse</code> and <code>PostToolUse</code> carry a real <code>tool_use_id</code>, so tool pairing is exact rather than by signature.</p>
<p>Subagents get the same treatment: <code>agent_id</code> is present on both <code>SubagentStart</code> and <code>SubagentStop</code>, so their lifecycle is exact—no name-matching guess. VS Code also models edits as a first-class multi-file operation. Its <code>editFiles</code> tool passes an array of files:</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="p">{</span> <span class="nt">&#34;tool_name&#34;</span><span class="p">:</span> <span class="s2">&#34;editFiles&#34;</span><span class="p">,</span> <span class="nt">&#34;tool_use_id&#34;</span><span class="p">:</span> <span class="s2">&#34;t1&#34;</span><span class="p">,</span> <span class="nt">&#34;tool_input&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;files&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;src/App.cs&#34;</span><span class="p">]</span> <span class="p">}</span> <span class="p">}</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>HarnessSpy reads the whole <code>tool_input.files</code> array and feeds every entry into the written-files summary, instead of chasing one path at a time.</p>
<p>The price for that precision is reach. VS Code gives me only eight events. There is no <code>permissionRequest</code>, no <code>notification</code>, no <code>postToolUseFailure</code>, no <code>errorOccurred</code>, no <code>userPromptTransformed</code>, and no <code>sessionEnd</code> bracket—just <code>Stop</code>. And, crucially for the next post, <strong>no transcript pointer at all</strong>. VS Code is, and remains, hooks-only; but it is still a preview so who knows what will be available in the future.</p>
<h2 id="what-copilot-does-not-give-you">What Copilot does not give you</h2>
<p>Laying the two Copilot surfaces beside Cursor and Claude makes the <strong>hook contracts</strong> precise. This table deliberately excludes what Copilot CLI transcript enrichment and the separate SessionViewer can recover later:</p>
<table>
  <thead>
      <tr>
          <th>Capability</th>
          <th>Cursor</th>
          <th>Claude Code</th>
          <th>Copilot CLI</th>
          <th>Copilot VS Code</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Native turn key</td>
          <td><code>generation_id</code></td>
          <td><code>prompt_id</code></td>
          <td>none (derived)</td>
          <td>none (derived)</td>
      </tr>
      <tr>
          <td>Tool-call id</td>
          <td><code>tool_use_id</code> (reusable)</td>
          <td><code>tool_use_id</code></td>
          <td>none (signature/FIFO)</td>
          <td><code>tool_use_id</code> (exact)</td>
      </tr>
      <tr>
          <td>Subagent id</td>
          <td><code>subagent_id</code></td>
          <td><code>agent_id</code></td>
          <td><code>agentName</code> only at start</td>
          <td><code>agent_id</code> (exact)</td>
      </tr>
      <tr>
          <td>Inner shell/MCP span</td>
          <td>before/after hooks</td>
          <td>none</td>
          <td>none</td>
          <td>none</td>
      </tr>
      <tr>
          <td>Parallel batch event</td>
          <td>inferred from overlap</td>
          <td>native <code>PostToolBatch</code></td>
          <td>inferred from overlap</td>
          <td>inferred from overlap</td>
      </tr>
      <tr>
          <td>Permission lifecycle</td>
          <td>indirect (tool failure)</td>
          <td>request + denial + notif.</td>
          <td>request + notification</td>
          <td>none</td>
      </tr>
      <tr>
          <td>Runtime error event</td>
          <td><code>postToolUseFailure</code></td>
          <td><code>StopFailure</code> and failures</td>
          <td><code>postToolUseFailure</code> + <code>errorOccurred</code></td>
          <td>none</td>
      </tr>
      <tr>
          <td>MCP identity in the hook</td>
          <td><code>MCP:</code> prefix + server</td>
          <td><code>mcp__server__tool</code></td>
          <td>flattened <code>&lt;server&gt;-&lt;tool&gt;</code> (inferred)</td>
          <td>not observed</td>
      </tr>
      <tr>
          <td>Transcript pointer</td>
          <td><code>transcript_path</code></td>
          <td>main + <code>agent_transcript_path</code></td>
          <td>verified on <code>agentStop</code>; runtime accepts <code>sessionEnd</code> if it carries the field</td>
          <td>none</td>
      </tr>
      <tr>
          <td>Token/cache counters</td>
          <td>yes</td>
          <td>none</td>
          <td>none in hooks</td>
          <td>none</td>
      </tr>
  </tbody>
</table>
<p>Three absences should be pointed out:</p>
<ul>
<li>
<p>Copilot has <strong>no native hook turn ID</strong> on either surface.</p>
</li>
<li>
<p>It has <strong>no inner execution span</strong>—nothing like Cursor&rsquo;s dedicated <code>beforeShellExecution</code>/<code>beforeMCPExecution</code> pair that timed the transport layer separately from the model&rsquo;s tool call.</p>
</li>
<li>
<p>And it has <strong>no batch event</strong>: Claude&rsquo;s <code>PostToolBatch</code> gives an authoritative parallel grouping, whereas Copilot parallelism is only inferred from overlapping intervals, exactly as in the second post. Finally, VS Code has no transcript pointer, which limits how much the next post can add to it.</p>
</li>
</ul>
<h2 id="conclusion">Conclusion</h2>
<p>GitHub Copilot taught me that a provider name is not a unique observability contract:</p>
<ol>
<li>
<p><strong>One provider can have two surfaces.</strong> The CLI and VS Code share <code>github-copilot</code> but disagree on event count, casing, identity and even the timestamp type—so HarnessSpy routes them to two engines and never guesses the surface from casing.</p>
</li>
<li>
<p><strong>Weak hook identity forces more guesses.</strong> Without a hook turn ID or tool-call ID, the CLI derives turns from prompt/stop boundaries and pairs hook completions by signature, then by arrival order.</p>
</li>
<li>
<p><strong>Ambiguity can be narrowed, not erased.</strong> Flattened MCP names have no marker, but permission and notification events, a built-in allowlist and a per-session cache recover the server name boundary without ever splitting blindly on a hyphen character.</p>
</li>
<li>
<p><strong>Precision and reach trade off.</strong> VS Code&rsquo;s exact <code>tool_use_id</code> and <code>agent_id</code> come on a thin eight-event surface with no permissions, no errors and no transcript; the CLI&rsquo;s rich lifecycle comes without stable IDs.</p>
</li>
</ol>
<p>The next post leaves hooks behind—well, almost—to explain how the undocumented transcript files could enrich each harness spy tool live tree.</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="/posts/2026-09-14_rebuilding-cursor-conversation/">Part 2: Rebuilding the Agent conversation</a></li>
<li><a href="/posts/2026-09-20_spying-on-claude-code-hooks/">Part 3: Spying on Claude Code: more lifecycle events, different blind spots</a></li>
<li><a href="https://github.com/chrisnas/HarnessSpy">HarnessSpy source code</a></li>
<li><a href="https://docs.github.com/en/copilot/reference/hooks-reference">GitHub Copilot hooks reference</a></li>
<li><a href="https://code.visualstudio.com/docs/agents/reference/hooks-reference">VS Code agent hooks reference</a></li>
</ul>
]]></content:encoded></item></channel></rss>