agentleFS
Sign inSign up

claude_code_vs

firish/claude_code_vs/CLAUDE.md

Operating context for Claude Code when working in this repo. Future work and release plan live in ROADMAP.md. A native Visual Studio 2026 extension that launches the real claude CLI and implements Claude Code's IDE-integration protocol (lockfile + localhost WebSocket speaking MCP/JSON-RPC 2.0). The CLI does all agent work; this extension provides the IDE half: a native diff window with accept/reject and automatic selection + diagnostics context. We do not reimplement the agent, and we do not build skills/plugins/hooks -…

CLAUDE.md87 starsChanged 3 months ago
# CLAUDE.md

Operating context for Claude Code when working in this repo. Future work and release plan live in `ROADMAP.md`.

## What this is

A native **Visual Studio 2026 extension** that launches the real `claude` CLI and implements Claude Code's **IDE-integration protocol** (lockfile + localhost WebSocket speaking MCP/JSON-RPC 2.0). The CLI does all agent work; this extension provides the IDE half: a **native diff window with accept/reject** and **automatic selection + diagnostics context**. We do *not* reimplement the agent, and we do *not* build skills/plugins/hooks - those come from the CLI for free.

If you ever find yourself adding an LLM API call, an agent loop, or a tool the CLI already provides, stop - that's out of scope.

## Working agreement (how we collaborate here)

- **Build in chunks, then teach.** Run free on a defined chunk of work (a phase or a well-scoped task), then - before moving on - explain everything the user needs to know/learn about what was built. The user is shipping this *and* learning the domain (VS SDK, the Claude Code IDE protocol) along the way, so keep code and decisions explainable and don't bury rationale.
- **Ask before design decisions with tradeoffs.** When a fork has real tradeoffs (not a choice with an obvious default), surface it with a recommendation and let the user decide. Decisions clearly load-bearing in the existing code don't need re-asking.
- **Ask when an instruction is unclear** rather than guessing and running.

## Architecture (where things live)

- `src/ClaudeCodeVS.Protocol/` - lockfile writer, WS server, MCP/JSON-RPC framing.
- `src/ClaudeCodeVS/Tools/` - one `IIdeTool` per tool: the 12 IDE-protocol tools (openDiff, openFile, getDiagnostics, …) plus the debugger surface - `DebugTools.cs` (reads) + `DriveTools.cs` (gated drive) - plus the semantic surface - `SemanticTools.cs` (the 8 `vs-semantic` Roslyn navigation tools, incl. `vs_get_selection` + `vs_decompile`) - plus the test surface - `TestTools.cs` (list/run/rerun-failed/debug/hunt/catch, on `vs-debug`) - plus the build surface - `BuildTools.cs` (`vs_build` + `vs_read_output`, also on `vs-debug`).
- `src/ClaudeCodeVS/CodeModel/` - `RoslynReader` (the semantic-model reader: search/find-references/go-to-definition/find-implementations/call-&-type-hierarchy over the live `VisualStudioWorkspace`, **plus `FindTestMethodsAsync`** = Roslyn test discovery). The static-analysis twin of `Debugging/DebuggerReader`; backs the `vs-semantic` MCP server. Roslyn binds **in-proc** (unlike ClrMD) - `VisualStudioWorkspace` is the supported extension entry point.
- `src/ClaudeCodeVS/Testing/` - the test-runner: `TestRunner` (drives VS's Test Explorer engine in-proc via MEF `IRequestFactory` - list/run/debug/flaky-hunt/catch), `HuntState` (async background flaky-hunt registry), `TestResultCallback` (the `Reflection.Emit`'d internal `ITestWindowDataCallback` that captures real per-test results). Backs the test tools on the `vs-debug` server; see the Test-integration section.
- `src/ClaudeCodeVS/Building/` - `SolutionBuilder`: drives the IDE's own build (async `SolutionBuild.Build(false)` + `BuildState` poll, never the UI-freezing `Build(true)` that `TestRunner` uses internally) and assembles the report. Backs `vs_build`; see the Build-integration section.
- `src/ClaudeCodeVS/Edits/` - `EditHunks` (pure line diff: current file content vs an edit's proposed content -> the hunks the panel offers as jump targets) + `EditEntry` (one panel row) + `EditOutputLog` (the session-long "Claude Code Edits" Output pane; `OutputTaskItemString` **plus `FlushToTaskList()`** - without the flush the task items never materialize and the lines are inert, which is exactly how it shipped broken the first time. In VS 2026 those lines are NOT hyperlinks and double-click does nothing, but **F8 steps them**; the only way to get true links is `CAT_BUILDCOMPILE`, which would put edit rows in the Error List and undo 1.20.0's pollution fix - don't). Backs the panel's **Edits this turn** list (issue #44): the CLI prints `changed Foo.cs:1234` in the terminal and VS's terminal has NO link provider we can hook (`ITerminalService` hands back a `Guid`, nothing else), so the jump targets live on a surface we own. Fed from `/permission`, which is a PreToolUse hook - the file on disk is still the "before" side when we're called, which is the whole reason a line-accurate diff is possible there. Turn-scoped: `MarkTurnStale` on UserPromptSubmit, replaced by the next edit (NOT cleared at prompt time - the list must survive while the user is typing). Collapses to one row when a file is mostly new, judged by share of lines added rather than span, so two small edits at opposite ends of a file don't read as a rewrite.
- `src/ClaudeCodeVS/Diff/` - diff rendering + Accept/Reject InfoBar + write-back + tab registry.
- `src/ClaudeCodeVS/Editor/` - selection service + TextViewListener MEF component + Error List reader + RDT helpers + `OutputWindowReader` (Output-window pane reads, GUID-addressed for build/debug/general because pane NAMES are localized; backs `vs_read_output` and the raw-log half of `vs_build`).
- `src/ClaudeCodeVS/Attachments/` - `AttachmentService` (SelectionService-pattern static, `Attach(server)` from BridgeHost): the panel attach tray's engine. Stages pasted screenshots/dropped files (in-workspace = referenced in place; else copied to `<ws>\.claude\attachments\` behind a self-ignoring `*` gitignore, 7-day prune) and pushes each as an **`at_mentioned`** notification so the reference lands in the CLI composer. ONE framework for all formats: image/pdf/text read directly, BMP transcoded to PNG, everything else (xlsx/mp4/zip) mentioned with a 🧰 needs-tool label - never hard-rejected. Per-item token estimates ((w×h)/750 images, bytes/4 text). UI = the 📎 card in `ClaudeToolWindowControl` (drop target, Paste button, chips: click = re-mention, ✕ = remove-and-delete-copy). A **directory** stages mention-only (no read, copy or estimate - folder `@`-mentions are first-class in the CLI, which walks the tree), which is what Solution Explorer's Add to Chat (`ContextActions.AddSelectionToChatAsync`, issue #30) routes selected files AND folders through - the tray buys it chips, re-mention and flush-on-connect for free. **References** (`WasCopied=false`: those files/folders, in-place drops, and the editor actions' ranged mentions via `MentionFileAsync`) dedupe on path+range and re-send the existing chip. **Dropped/pasted FILES also dedupe on the original path + its last-write time** (`FindBySource`, checked before any copying), so re-attaching an out-of-workspace file re-mentions its chip instead of staging `foo-2.png`, `foo-3.png` - a file edited since staging still comes in fresh, because the staged copy froze the old bytes. Only **contentless pastes** (clipboard image, composer text) never dedupe: they have no source file, so each one is genuinely new. The tray is **workspace-scoped**: `WorkspaceWatcher` clears it whenever the workspace actually changes (solution/folder open or close) - chips carry workspace-relative mention paths and live in that workspace's staging folder, so they must not survive into the next one.
- `src/ClaudeCodeVS/Capture/` - `WindowCapture`: the Win32/GDI capture core behind the `vs_capture_window` / `vs_capture_screen` tools (`Tools/CaptureTools.cs`, gated by `BridgeStatus.AllowScreenCapture` - the third safety toggle). PrintWindow(`PW_RENDERFULLCONTENT`) + blank-frame fallback (foreground → 350ms settle → re-rect → region copy, the path GPU browsers need). One shared eligibility predicate (visible + not DWM-cloaked + titled; min 200×120 filters browsers' taskbar-preview proxy HWNDs) backs BOTH the title matcher and the error's `visibleWindows` list so they never disagree; minimized matches error "restore first". Captures stage via `AttachmentService.StageCapturePng` (chip + feed line, `Sent=true` so never auto-mentioned) and return the PATH - never MCP image blocks (~10-20× token waste, upstream #31208 - bot-closed as stale, NOT fixed). Reference: `docs/VISION.md`.
- `src/ClaudeCodeVS/Debugging/` - `DebuggerReader` (EnvDTE reads: break state, stack, locals, threads, object-graph expansion, `$exception`, processes) + `DebuggerDriver` (EnvDTE/`IVsDebugger` drive: continue/step/breakpoints/session, break-on-thrown via `EnvDTE90.Debugger3`, attach/detach + the await-break engine).
- `src/ClaudeCodeVS/Hooks/` - hook installer (`PermissionHookInstaller`) + `McpInstaller` (registers BOTH the `vs-debug` and `vs-semantic` MCP servers) + embedded scripts: `vs-permission-hook.ps1`, `vs-usage-hook.ps1`, `vs-debug-context-hook.ps1`, `vs-notify-hook.ps1` (Notification hook -> POST `/notify` -> in-IDE "Claude needs your input" notification), `vs-mcp-shim.ps1` (one shim, parameterized by `-Route` so it backs both servers: `/mcp` = vs-debug, `/mcp-semantic` = vs-semantic).
- `src/ClaudeCodeVS/Ui/` - dockable panel (BridgeStatus state, ClaudeToolWindowControl WPF, ReasonDialog) + `Notifier` (turn-finished / needs-input notifications: ONE main-window InfoBar, superseded by the next, + a bounded taskbar flash when VS is backgrounded; turn-end rides the existing Stop hook's `/usage` POST via `IdeWebSocketServer.StopReceived` - no extra hook; gated by the panel's Notify toggle, default ON) + `UpdateNotice` (the once-per-MARQUEE-release "what's new" InfoBar: normally DISARMED - `MarqueeVersion = null`, releases ship silently; arming is an editorial act on headline/behavior-changing releases only: set the constant + rewrite `MarqueeNoticeText` in both resx, disarm again next release; latched per-user in the VS settings store, written only after the bar actually rendered).
- `src/ClaudeCodeVS/Resources/` - UI localization (issue #20): `Strings.resx` (neutral English) + `Strings.zh-Hans.resx` (Simplified Chinese, **maintained by Claude** - the maintainer doesn't read Chinese) + `Strings.cs` (hand-written nameof-keyed accessors). Culture follows VS's display language (`IUIHostLocale`, read at package init). See convention #6 and the Localization section - every new user-facing string lands in all three files in the same change.
- `src/ClaudeCodeVS/Terminal/` - `VsTerminalLauncher.cs`: launches `claude` inside VS's own native Terminal tool window via `Microsoft.VisualStudio.Terminal.ITerminalService` (undocumented, no NuGet package - reflection-loaded from the install dir at runtime, same pattern as the TestWindow integration in `Testing/TestRunner.cs`). Falls back to the external `cmd.exe` console (`BridgeHost.LaunchClaudeAsync`) on any failure OR a ~10s stall (raced timeout + linked cancellation - a cold ServiceHub must not leave the Launch button dead), since this surface could change or vanish across a VS update. The cached "Claude Code" terminal profile is deregistered right after launch (`RemoveCachedProfile`) so the profile dropdown stays clean; the panel's **External console** button (`BridgeStatus.LaunchExternalAction` -> `LaunchClaudeAsync(forceExternal: true)`) skips the native path for users who want a standalone/VS-surviving window. User-facing reference (with notifications + attachments): `docs/QOL.md`.
- `BridgeHost.cs` - wires everything together; owns the `/permission` handler and CLI launcher.
- `spike/` - Phase 0 standalone console harness (net8.0), kept for protocol regression testing.
- `src/ClaudeCodeVS.ClrMdWorker/` - out-of-process ClrMD worker exe (net48/x64): `waitchains`/`asyncstacks`/`heapstats`/`threadpool`/`roots`/`heapdiff` commands emit JSON; bundled in the .vsix under `ClrMdWorker\` and shelled out by `Debugging/ClrMdReader.cs` (ClrMD can't load in-proc in devenv — Immutable binding conflict). To iterate on a ClrMD read, run the worker exe directly against a target PID (no VS needed).
- `src/ClaudeCodeVS.DataBpComponent/` - the **managed data-breakpoint** Concord (DkM) debug-engine component (net472): an `IDkmCallStackFilter` + `IDkmDataBreakpointHitNotification` registered via `.vsdconfig` (a `DebuggerEngineExtension` VSIX asset). Arms `DkmPendingDataBreakpoint`s from the request thread and streams changes over file-IPC; driven by `Debugging/DataBreakpointBridge.cs` + the `vs_set/get/remove_data_breakpoint` tools (`Tools/DataBpTools.cs`).

## Tech stack & hard constraints

- **In-proc VSIX, `net48`, VSSDK + Community Toolkit.** The differencing service, Roslyn workspace, RDT, and editor adapters are in-proc services; the out-of-process `VisualStudio.Extensibility` model can't host them. Do not propose migrating the diff core to it.
- **WebSocket = `HttpListener`** bound to `127.0.0.1` only. No third-party WS/agent libraries.
- **Manifest targets `[17.14, 19.0)`** (VS Marketplace requires a stable API lower bound; 18.0 is experimental). Extension is tested on VS 2026 only; VS 2022 verification is a future item - see `ROADMAP.md`.

## Non-negotiable conventions

1. **Threading.** The WS receive loop runs off-thread. *Every* call that touches the editor, solution, diff, or any VS service must first:
   ```csharp
   await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(ct);
   ```
   This is the #1 source of bugs. Never call VS SDK APIs from the socket thread directly.
2. **Localhost + auth only.** Bind to `127.0.0.1`. Validate `x-claude-code-ide-authorization` against the lockfile token during the HTTP upgrade; reject mismatches with 401 before the socket opens. Never log the auth token.
3. **`openDiff` is deferred.** Do not reply to the `tools/call` until the user accepts/rejects. Park the response on a `TaskCompletionSource` keyed by `tab_name`; complete it from the Accept/Reject handlers. Returning early breaks the flow.
4. **Return-value wire format.** Plain strings are sent verbatim (`"DIFF_ACCEPTED"`, `"DIFF_REJECTED"`, `"FILE_SAVED"`, `"TAB_CLOSED"`); objects are JSON-wrapped; errors use the MCP `isError` flag.
5. **Lockfile lifecycle.** Write on connect, delete on shutdown, reap stale (dead-PID) lockfiles on startup. A stale lockfile with a dead socket blocks reconnection - tie lockfile lifetime to the WS connection.
6. **Every user-facing UI string is a resource, and Claude translates it.** Never inline a user-visible literal in UI code (panel, dialogs, InfoBars, notifications, tooltips): add a key to `Resources/Strings.resx` **and** its Simplified Chinese translation to `Strings.zh-Hans.resx` **and** a property to `Strings.cs` - all three, in the same change. **The maintainer cannot read or write Chinese**, so Claude is the translator of record (issue #20): produce the zh-Hans text yourself (Microsoft zh-Hans style, 你 not 您, full-width punctuation in prose; keep Claude Code / CLI / token / acceptEdits / vs-debug / `.claude` in English), never ask the user to review it, and gloss it in English when they need to know what it says. Missing keys fall back to English per-string, so a forgotten translation degrades - but don't forget. NOT localized, on purpose: `Log.*`/feed diagnostics (bug reports must stay greppable), anything sent to the CLI or model (protocol strings like `DIFF_ACCEPTED`, tool results, hook scripts), the two VSCT menu entries, and the "Claude Code" brand itself. See the Localization section.

## Protocol quick reference

Lockfile `~/.claude/ide/<port>.lock` (filename == port):
```json
{ "pid": 0, "pidStartTime": 0, "workspaceFolders": ["..."], "ideName": "Visual Studio",
  "transport": "ws", "runningInWindows": true, "authToken": "<uuid>" }
```
`pidStartTime` is extension-only (the CLI ignores unknown fields): paired with `pid` so a recycled PID can't make a dead lockfile look alive. Hooks pick the **most-specific** workspace match whose port is **listening** (defeats parent-folder shadowing + zombie lockfiles; separator-aware so `app` never matches `app-service`) - but they still FALL BACK to any listening bridge on zero match, so every hook POST carries the session's `cwd` and the bridge's **session-ownership gate** (one check in `IdeWebSocketServer.HandleContextAsync`, ahead of all four hook endpoints AND the hooks-only banner) ignores foreign-workspace sessions: `/permission` answers `ask:true` (the CLI's own prompt decides - never auto-allow a foreign edit), the observers get benign 200s. Missing `cwd` or no open workspace = fail-open (PR #28).

⚠️ **Ownership is decided by SESSION IDENTITY first, path geometry only as a fallback** (1.20.1, issue #42). Every hook sends its own `pid`; a hook process is always a descendant of its CLI; the bridge remembers each connected CLI's pid from the `ide_connected` notification (which the MCP dispatcher drops - `IdeWebSocketServer.CaptureCliPid` grabs it off the raw frame first). A POST whose pid sits under a CLI connected HERE is ours; one that sits under none is not, however well the folders line up. This is the only check that separates two Claude sessions running in the same tree - the issue-#42 case, where a VS Code session loaded the workspace's hook and its diffs all opened in Visual Studio. `ProcessTree` walks parents via Toolhelp32 (one snapshot, ~10ms, cached 2s) rather than WMI, because the observer hooks fire every prompt and turn end. When identity is unavailable (nothing connected, no `ide_connected`, an older hook script), it falls back to: refuse anything whose `CLAUDE_CODE_ENTRYPOINT` names another IDE (undocumented, so it only ever ADDS a refusal), then the path rules below. **`CLAUDE_CODE_SSE_PORT` is NOT usable for this** - it is empty in a hook's environment even for an IDE-connected session; verified, don't retry it.

⚠️ **Path geometry (the 1.20.0 fallback): containment in EITHER direction, plus the file.** `cwd` under the workspace AND the workspace under `cwd` both count as owned - the second is the everyday "open `demo\BuildBreak.slnx` in VS, run `claude` from `demo\`" case, and the original cwd-under-workspace-only rule called it foreign. `/permission` additionally owns any POST whose `filePath` is inside the workspace, which is the most direct signal there is and is immune to cwd geometry entirely. Genuinely disjoint trees still match nothing and stay refused. **The failure this caused is worth recognizing on sight:** a wrong `ask:true` makes the CLI fall back to its OWN permission prompt, and because it is IDE-connected it renders that prompt as an `openDiff` - so the user sees a diff the panel's auto-accept toggle had no say over (the gate refuses *before* auto-accept is consulted), plus a terminal prompt its Accept does not answer. Symptom reads as "the single gate broke"; cause is always a path-matching rule, and the hooks' ranking (`vs-*.ps1`) must be changed in lockstep with `IsOwnSession` or discovery and gating disagree.

Env before launching CLI: `CLAUDE_CODE_SSE_PORT=<port>`, `ENABLE_IDE_INTEGRATION=true`.
Full schema + all 12 tool definitions: see `src/ClaudeCodeVS/Tools/` and the Tool status section below.

**WS handshake (verified vs CLI 2.1.169, spike):** the upgrade request carries `Sec-WebSocket-Protocol: mcp` - **echo it in the 101 response or the CLI drops the socket before `initialize`**. MCP `protocolVersion` is `2025-11-25` (echo the client's). After `initialize`+`notifications/initialized`, the CLI sends an `ide_connected` notification `{pid}` and proactively calls `closeAllDiffTabs`. Implementation: `IdeWebSocketServer.cs` + `McpServer.cs`.

**`at_mentioned` (IDE→CLI notification, spike-verified vs 2.1.191):** `{"filePath","lineStart"?,"lineEnd"?}` (0-indexed, keys omitted for whole-file) inserts an `@` chip into the CLI composer - insert, NOT submit. An at-mentioned **image** path delivers real pixels to the model; workspace-relative (forward-slash) and absolute paths both resolve. Known CLI quirk: references sent mid-turn or with the agents view focused are silently dropped (hence the tray's click-to-re-mention). Regression-test with the spike's `m`/`M`/`t` hotkeys on every CLI bump.

## Tool status

All 12 tools are implemented. The CLI exposes only `getDiagnostics` + `executeCode` to the model; `openDiff`, `openFile`, `close_tab`, `closeAllDiffTabs`, and `selection_changed` are driven by the CLI internally (not model choices). The remaining awareness tools are implemented and correct but dormant in the current CLI.

| Tool | Status |
|---|---|
| `openFile` | ✅ real |
| `openDiff` | ✅ real - deferred TCS, InfoBar, write-back |
| `getCurrentSelection` / `getLatestSelection` | ✅ real |
| `getDiagnostics` | ✅ real - Error List backend (C# + C++) |
| `getOpenEditors` / `getWorkspaceFolders` / `checkDocumentDirty` / `saveDocument` | ✅ real (RDT-backed) |
| `close_tab` / `closeAllDiffTabs` | ✅ real (DiffRegistry) |
| `executeCode` | ✅ MCP error (no VS equivalent) |
| `selection_changed` notification | ✅ real - 150 ms debounce |

- [x] Phase 0 - spike: protocol verified end-to-end vs CLI 2.1.169
- [x] Phase 1 - core 4 in VSIX
- [x] Phase 2 - full 12-tool parity + single-gate hook + dockable panel
- [ ] Phase 3 - VS 2022 backfill, Roslyn-precise ranges (reconnect/multi-window hardening ✅ shipped 1.2.0)
- [ ] Phase 4 - embedded chat (deferred)

## Debugger integration (1.2.0; 1.3.0 adds attach + break-on-thrown)

Live debugger exposed to the model over the SAME bridge (full reference: `docs/DEBUGGER.md`). Three channels — needed because the IDE-protocol WS tools are CLI-curated (dormant), so you CAN'T add a model-callable tool there:

- **Push** - `vs-debug-context-hook.ps1` (a `UserPromptSubmit` hook) POSTs to `/debug-context`; the bridge reads break state via EnvDTE and the hook injects it as `additionalContext`. Break-mode only.
- **Pull** - a SECOND `McpServer` served at `POST /mcp` on the same `HttpListener`, reached by `vs-mcp-shim.ps1` (a stdio↔HTTP proxy auto-registered in the workspace `.mcp.json` as server `vs-debug`). The shim does the most-specific-listening-lockfile discovery; tool logic runs in-proc against EnvDTE. This is the OPEN plugin door (all tools surfaced), unlike the curated IDE channel.
- **Drive** - execution control on the same `/mcp` server, gated behind `BridgeStatus.AllowDebuggerDrive` (panel toggle, default OFF, resets per session - mirrors auto-accept). Async "issue → await next break" via `IVsDebuggerEvents.OnModeChange` + a parked `TaskCompletionSource` (the openDiff deferred pattern); never blocks the UI thread.

**34 tools** on `vs-debug` (+ the push hook) — the two newest are `vs_build` + `vs_read_output` (see the Build-integration section); before them, managed **data breakpoints** (see below). Reads: `vs_debug_state` / `vs_evaluate` / `vs_expand` / `vs_get_frame_locals` (optional `threadId` reads **another thread's** locals — e.g. each thread in a deadlock) / `vs_list_breakpoints` / `vs_threads` / `vs_exception` / `vs_list_processes` / `vs_wait_chains` (structured monitor ownership + deadlock suspects, ClrMD) / `vs_async_stacks` (logical async-stack reconstruction, ClrMD) / `vs_heap_stats` / `vs_threadpool` (starvation) / `vs_gc_roots` (retention path) / `vs_heap_diff` (leak finder) — the last four are ClrMD memory/GC/threadpool. Drive: continue, step over/into/out, run_to_line, `vs_break_all` (pause a running/hung debuggee — the way into a deadlock, which never hits a breakpoint), set/remove_breakpoint (file:line **or by function name**), `vs_break_on_thrown` (first-chance exception break), freeze_thread, set_next_statement, start/stop_debugging, `vs_attach`/`vs_detach` (debug a running real app, not just F5). Per-thread inspection (`vs_get_frame_locals` / `vs_evaluate` / `vs_expand` all take an optional `threadId`) and `vs_break_all` are pure EnvDTE (`Debugger.Break` + a `Debugger.CurrentThread` switch-and-restore) — no AD7; `vs_threads` surfaces a contended-lock holder as `lockOwnerThreadId`. All EnvDTE access is on the UI thread (convention #1). Capped reads carry a `{truncated:true}` marker so the model knows data was cut. Fixtures: `demo/{CheckoutBuggy,SignalScan,ComboScore,NullOrigin,WebQuote,LockJam,AsyncTrace}` (WebQuote = ASP.NET attach + break-on-thrown, live-verified 1.3.0; LockJam = deadlock triage via `vs_threads`/`vs_break_all`; AsyncTrace = cross-await inspection).

**Break-on-thrown is the managed `EnvDTE90.Debugger3.ExceptionGroups.SetBreakWhenThrown` API — NOT the low-level COM `IDebugEngine2.SetException`.** An earlier note wrongly claimed it needed AD7; the real blocker was just casting `DTE.Debugger` up to `Debugger3` (the member doesn't exist on the base `EnvDTE.Debugger`). Verified live on the modern Concord engine (1.3.0). **Lock/wait-chain ownership, async stacks, and memory/GC/threadpool diagnostics now ship via ClrMD** (1.5.0 + 1.6.0): `vs_wait_chains` (structured monitor ownership + deadlock suspects), `vs_async_stacks` (logical async stack), `vs_heap_stats` (heap composition + GC/handle/finalizer health), `vs_threadpool` (counts + backlog + starvation), `vs_gc_roots` (retention path / why-alive), `vs_heap_diff` (leak finder). Both run ClrMD **out-of-process** in `ClrMdWorker.exe` — in-proc ClrMD collides with devenv's `System.Collections.Immutable` binding policy (`MissingMethodException` on `DataTarget.get_ClrVersions`; un-overridable from an in-proc extension), so the worker carries its own `.exe.config` and the extension shells out + parses JSON. The snapshot is a `PssCaptureSnapshot` fork, so it coexists with the live VS session. `vs_threads` still adds the explicit `[Waiting on lock owned by Thread 0x..]` edge (which object a waiter wants isn't ClrMD-decodable — cross-reference the two).

**Managed data breakpoints ship via a bundled Concord debug-engine component** (1.8.1) — the one debugger gap with NO EnvDTE/automation surface (VS's UI can't set it programmatically either). Tools: `vs_set_data_breakpoint(expression, condition?, stopOnChange?)` (watch an instance `owner.field`; structured change timeline; conditional + **recurring** stop-on-change; multi-watch), `vs_get_data_changes(requestId)` (the `[{previous,current,type}]` mutation trace), `vs_remove_data_breakpoint(requestId)` (disarm). The component (`src/ClaudeCodeVS.DataBpComponent/`, a `DebuggerEngineExtension` VSIX asset) arms from the **request thread** (`IDkmCallStackFilter`) via `DkmSuccessEvaluationResult.GetDataBreakpointInfo` on the owner→field child + `DkmPendingDataBreakpoint.Create` with its **OWN** SourceId (reusing the engine's crashes the breakpoint manager) + async `Enable` (`BeginExecution`, never `Execute`). The engine can't halt from its hit notification (event thread), so the **extension** halts via EnvDTE `Break()` on a matching change; `Debugging/DataBreakpointBridge.cs` drives it over file-IPC under `%TEMP%\claude-codevs-databp\`. **One engine binding per address with fan-out** (the engine binds one data BP per address — a second `Create` on the same field shadows), so concurrent watches on the same value all fire. Watched fields must be **instance fields** (statics/locals/struct fields unsupported); stop lands one statement after the write. (First proven in a standalone Concord spike, since removed.) **Still not yet:** native tracepoints and all-primitive lock ownership (see `ROADMAP.md`).

## Localization (1.16.0; zh-Hans)

Simplified Chinese language pack, requested in issue #20 by a user maintaining a translated fork. The mechanism (convention #6 is the binding rule):

- `src/ClaudeCodeVS/Resources/` - `Strings.resx` (neutral English, values byte-identical to the pre-l10n UI), `Strings.zh-Hans.resx` (the translation), `Strings.cs` (hand-written nameof-keyed properties - **no ResXFileCodeGenerator**, the custom tool only runs inside the IDE and this repo builds from the CLI; a missing key returns the key, never throws).
- **Culture = VS's display language**, not the thread culture: read once at package init via `SUIHostLocale`/`IUIHostLocale.GetUILocale` (`InitUiCulture` in `ClaudeCodeVsPackage`) into `Strings.Culture` - UI strings get composed on background HTTP-handler threads, so per-thread culture would be flaky. LCID 2052 (zh-CN) reaches the zh-Hans satellite via the standard parent chain. There is deliberately no per-extension language picker - follow-the-IDE is the VS convention.
- The satellite (`zh-Hans\ClaudeCodeVS.resources.dll`) must sit **next to ClaudeCodeVS.dll in the .vsix root**; VSSDK packaging skips the current project's satellites, so the csproj's `BundleSatelliteInVsix` target places it. Verify on release builds (the ClrMD/Roslyn ship-nothing checks have a sibling here: this one must ship).
- `DiffSession`'s Accept/Reject constants became properties resolving through `Strings` - the click handler compares `actionItem.Text` against the same values, so the round-trip holds in any language.
- Verification recipe (no Chinese VS needed): load the built DLL + `ResourceManager("ClaudeCodeVs.Resources.Strings", asm)`, assert `GetString(key, zh-CN) != GetString(key, invariant)` and key-set parity between the two resx (the 1.16.0 check found 64/64).
- Follow-ups live in `ROADMAP.md`: VSCT menu localization, more languages on demand (same infra, one resx each).

## Diagnostics

Currently both C# and C++ diagnostics come from the **Error List** (`SVsErrorList -> IVsTaskList`) via `ErrorListReader.cs`. This is a single unified path that serves both languages - Roslyn pushes C# diagnostics into the Error List and the MSVC toolchain pushes C++ ones. Ranges are point ranges only (the Error List exposes one line/column per entry).

Roslyn-precise C# span ranges (`VisualStudioWorkspace -> Compilation.GetDiagnostics()`) are a Phase 3 enhancement - see `ROADMAP.md`. Always return `[{uri, diagnostics: []}]` - the envelope, even when empty. Requires a loaded project (the Error List is empty for loose files).

## Semantic navigation (1.9.0; `vs-semantic` MCP server)

The third knowledge axis (after runtime state and diagnostics): Roslyn's resolved **semantic model** of the code, exposed as 8 read-only tools so the model navigates by ground truth instead of grep. Full reference: `docs/SEMANTIC.md`.

- **A THIRD MCP server, `vs-semantic`**, served at `POST /mcp-semantic` on the same `HttpListener`, reached by the **same** `vs-mcp-shim.ps1` with `-Route /mcp-semantic`. `McpInstaller` registers both `vs-debug` and `vs-semantic` in `.mcp.json`. Wired in `BridgeHost.BuildSemanticTools()` -> `IdeWebSocketServer.SemanticMcp`. This is why a new IDE-channel tool wouldn't work (CLI-curated) - same reasoning as the debugger pull channel.
- **Tools** (`Tools/SemanticTools.cs`, backed by `CodeModel/RoslynReader.cs`): `vs_search_symbols` (name -> candidates, each with a stable `symbolId`) + `vs_find_references` / `vs_go_to_definition` / `vs_find_implementations` / `vs_call_hierarchy` (callers transitive, callees direct) / `vs_type_hierarchy` (base/derived) + `vs_get_selection` (the editor's current selection/caret via the existing `SelectionService`, enriched with the Roslyn `symbolId` at that position -> "act on this / navigate from it"). All ungated, managed (C#/VB) only, `{"available":false}` with no loaded project (the `vs_get_selection` text read works regardless; only its symbol enrichment needs Roslyn).
- **`vs_decompile`** (the headline read-a-library-body tool — the one thing the CLI fundamentally can't do): decompiles a framework/NuGet symbol with no source to C# via **VS's own metadata-as-source service** (`IMetadataAsSourceFileService.GetGeneratedFileAsync` with `MetadataAsSourceOptions.NavigateToDecompiledSources=true` — the Go-To-Definition decompiler, ILSpy under the hood). Reached by **pure reflection** against the already-loaded `Microsoft.CodeAnalysis.Features` (no new package): the service is a MEF export pulled via the `IMefHostExportProvider` on `workspace.Services.HostServices` (NOT `GetService<T>` — it's not an `IWorkspaceService`). Returns just the requested **member** (extracted via `MetadataAsSourceFile.IdentifierLocation` + a base-`SyntaxNode` walk; CSharp parse done by reflection, dep-free) or the whole type (`wholeType:true`), capped. **Stub-vs-body is signalled** (`bodyAvailable`): core BCL types forwarded to `System.Private.CoreLib` (String/Int32) only decompile to a signature stub — so on a stub the tool **auto-retries via SourceLink** (`NavigateToSourceLinkAndEmbeddedSources`, bounded 20s) to fetch the REAL .NET source; `preferSource:true` forces source-first. `source` = `decompiled|source`. Discovered the exact Roslyn-5.7 API shape headlessly with a `MetadataLoadContext` inspector before writing in-proc reflection — do that for any internal-API reflection.
- **Addressing**: every navigation takes a `symbolId` (Roslyn **DocumentationCommentId**, e.g. `M:Ns.Type.Method(System.Int32)`) OR a `file`+`line`(+`column`) position. `symbolId` round-trips via `DocumentationCommentId.GetFirstSymbolForDeclarationId`; position via `SymbolFinder.FindSymbolAtPositionAsync`. `file` paths are separator-normalized (`/`->`\`, case-insensitive) so agent-style forward-slash paths resolve. Workflow: search -> id -> navigate.
- **Threading is INVERTED vs the debugger**: the Roslyn `Solution` is an immutable, free-threaded snapshot, so we take the workspace handle on the UI thread (`GetSolutionOffThreadAsync`) then `await TaskScheduler.Default` to run `SymbolFinder` OFF the UI thread - no editor stall. (EnvDTE is UI-thread-bound end to end; Roslyn is not.)
- **Roslyn binds in-proc** - reference `Microsoft.VisualStudio.LanguageServices` with **`ExcludeAssets="runtime"`** (compile-time only; bind to devenv's own copy at runtime). The `.vsix` ships ZERO Roslyn DLLs - verify this on every build, a bundled copy is the `MissingMethodException` skew that exiled ClrMD. Unlike ClrMD, `VisualStudioWorkspace` IS the supported in-proc entry point, so this works. Fixture: `demo/RefMaze` (interface + 3 impls incl. an explicit one, an overload set, a call chain - every tool returns something grep gets wrong).
- **Callees is direct-only** (depth 1, via the language-agnostic `IOperation` tree - works for VB too). Callers is transitive (depth-capped, cycle-guarded). Output capped + `{truncated}`-signaled like the debugger reader.

## Test integration (1.10.0; the fix-verify loop)

VS's Test Explorer engine exposed to the model as a closed **discover → run → debug → catch** loop (full reference: `docs/TESTING.md`). The test tools live on the **`vs-debug` MCP server** (NOT a new server) — co-located with the debugger because the headline (`vs_catch_flaky`) composes with it. Backed by `Testing/TestRunner.cs` (+ `HuntState.cs`, `TestResultCallback.cs`) and `Tools/TestTools.cs`, wired in `BridgeHost.BuildDebugTools`.

- **Engine acquisition:** the in-proc `OperationBroker` via MEF `IComponentModel.GetService<IRequestFactory>()`. NOT the brokered `ITestWindowService` — its StreamJsonRpc/MessagePack wire contract drifted from the interface metadata (`RunTestsAsync` not proffered by name; `GetTestsAsync` wants a `FilteredTestsRequest` DTO). All `Microsoft.VisualStudio.TestWindow.*` types are internal → **reflection**, loaded from the install dir; the `.vsix` ships ZERO TestWindow DLLs.
- **Real per-test results need an emitted callback.** `TestWindowRunResponse.Success/Status` are IDENTICAL for pass and fail ("the run completed", not "the tests passed"); per-test outcome/message/stack come ONLY through the internal `ITestWindowDataCallback`. You can't implement an internal interface in C#/`DispatchProxy`, so `TestResultCallback.cs` **`Reflection.Emit`s** a type implementing it with `[IgnoresAccessChecksTo]`, forwarding each streamed `TestNodeData` to a managed sink. Set `TestCallbackOptions.DataSelector = TestSelectorOptions.New.WithTestResults()`.
- **Discovery is Roslyn, not the engine** (`RoslynReader.FindTestMethodsAsync`): scan for `[Fact]/[Theory]/[Test]/[TestMethod]/[TestCase]` → real FQNs. No callback, no build needed to list.
- **Filters:** `SearchQuery("FullyQualifiedName", fqn, FilterMatchKind.ExactMatch)` for `*ByFilterAsync`; `TestFilterOptions([Scope.ForSymbol(fqn)])` for `RunTestsAsync` (empty scope list = all; `Scope.ForState(TestState.Failed)` = re-run failures). `RunTestsAsync` rejects a NULL filter. `SearchQuery`'s accessible ctor is 5-arg and the enum member is `ExactMatch` (not `Exact`) — a bug that cost cycles; don't trust API shapes from metadata, dump the live object.
- **Flaky-hunt is async start+poll, NOT deferred-reply.** The `/mcp` HTTP shim has a ~60s per-request timeout, so a long hunt runs on a background `Task` (`HuntState` registry): `vs_hunt_flaky` waits ≤40s inline then hands back a `huntId`; `vs_hunt_result` polls, `vs_hunt_cancel` stops. (The `openDiff` "park a TCS + reply late" pattern only survives on the persistent WebSocket, not `/mcp`.)
- **`vs_catch_flaky`** (catch red-handed; gated behind `AllowDebuggerDrive`): loop a test under the debugger with break-on-thrown armed until the failing iteration halts at the throw. Reuses the debugger's OnModeChange await via `DebuggerDriver.LaunchAndAwaitBreakAsync(launch, timeout)` — fire a debug run, await Break (caught) vs Design (passed). Auto-learns the exception type from a pre-hunt; for a bare assertion (no type in the message) it arms the framework assertion base types (`Xunit.Sdk.XunitException` / NUnit / MSTest — break-on-thrown matches subclasses).
- **Self-builds** via `DTE.Solution.SolutionBuild.Build(true)` so tools never need a manual Ctrl+Shift+B. Engine + EnvDTE are UI-thread-bound (convention #1); Roslyn discovery hops off.

**Tools** (on `vs-debug`): `vs_list_tests` / `vs_run_test` (coverage ✅; profile deferred — needs a `ProfilerToolId`) / `vs_rerun_failed` / `vs_debug_test` / `vs_hunt_flaky` + `vs_hunt_result` + `vs_hunt_cancel` / `vs_catch_flaky`. Fixture: `demo/TestLab` (net10 xUnit: pass, assert-fail, throw, + two ~1-in-3 intermittent for the hunter/catcher). Follow-ups: run-affected (`Scope.ForFile/ForSymbol` + vs-semantic call-graph), profiling GUID, hunt idle-wait (`IOperationState`).

## Build integration (`vs_build` + `vs_read_output`)

The compile half of the fix-verify loop, on the **`vs-debug`** server (not a new one - it sits next to the test tools it feeds). Full reference: `docs/BUILD.md`. Backed by `Building/SolutionBuilder.cs` + `Editor/OutputWindowReader.cs`, wired in `BridgeHost.BuildDebugTools`. Both ungated, same reasoning as `vs_run_test`.

- **`vs_build` is what makes `getDiagnostics` honest.** The Error List is populated by the IDE's build, so before this the model read whatever the last manual Ctrl+Shift+B left behind. Solution-scope by default; `project` accepts a name, a substring, or **the path of any file in it** (`Solution.FindProjectItem(...).ContainingProject`).
- **Asynchronous build, not `Build(true)`.** `SolutionBuild.Build(false)` + poll `BuildState` off the UI thread (switching in only for the state read). The blocking form `TestRunner` uses would freeze VS for the whole build - fine as a step inside a test run, not fine for a tool the model calls constantly. The poll needs a **grace window** (~1.2s) before trusting a `Done` state, or the *previous* build's `Done` reads as this one finishing instantly.
- **Long builds attach, they don't queue.** The shim's HTTP timeout is 60s, so the inline wait caps at 55; on timeout the build keeps running and the response says `stillBuilding`. Calling `vs_build` again sees `BuildState == InProgress` and **attaches to the same build**. This is deliberately NOT the `HuntState` async start+poll registry - one tool, no ids to track.
- **Diagnostics come from the Error List, NOT from parsing the build log.** MSBuild's `error`/`warning` keywords are **localized**, so a regex over the Build pane would work in English and nowhere else; `IVsErrorItem.GetCategory` is an enum. `ErrorListReader.ReadEntries()` is the structured read (project name via `GetHierarchy` -> `VSHPROPID_Name`); the existing `Read()` now composes from it, still dropping document-less rows the way `getDiagnostics` requires. The raw log ships in `output` anyway, because MSBuild-level failures (restore, missing SDK/target, pre-build steps) don't always produce Error List rows - that case gets an explicit `note`.
- **Pane addressing is GUID-first** (`VSConstants.OutputWindowPaneGuid.*`) for build/debug/general, name-matched for everything else: pane display names are localized too (Build = `生成`). `ToolWindows` is a **`DTE2`** member, not `DTE`.
- **The Build pane is CLEARED before starting, then read from line 1.** Do not reintroduce a delta read - it was tried twice and is not fixable. A line count can't tell a clear from an append (VS wipes the pane per build, which only looks like a shrink when the new log is shorter), and bookmarking the last line's TEXT fails too: `EndPoint.Line` is the empty line after the final CRLF, so the bookmark is always `""` and matches `""` again after the next clear - two builds of the same length then read as "appended" and the log comes back EMPTY. The last non-empty line has the same hole for two identical builds. Skip the clear when attaching to a running build (it cleared the pane itself). Reads select a line range rather than `SelectAll`, so a diagnostic-verbosity log never marshals through COM whole.
- Fixture: `demo/BuildBreak` (two projects: `Core` fails with CS0029 + a CS0219 warning, `App` builds clean and swallows a `DivideByZeroException` that ONLY the Debug pane records - the one-line case for why `vs_read_output` exists).

## Build / run / test

```powershell
# Extension (Release)
msbuild src/ClaudeCodeVS/ClaudeCodeVS.csproj /t:Rebuild /p:Configuration=Release

# Extension (Debug, then F5 in VS to launch the Experimental instance)
msbuild src/ClaudeCodeVS/ClaudeCodeVS.csproj /t:Rebuild /p:Configuration=Debug

# Spike (Phase 0) - fastest protocol loop; no VS needed
dotnet run --project spike
#   then: claude (with ENABLE_IDE_INTEGRATION + CLAUDE_CODE_SSE_PORT set) -> /ide
```

Protocol smoke test on every CLI bump (the contract is undocumented and has regressed before):
```powershell
claude --version            # record the known-good version
# launch spike, connect claude, confirm: lists mcp__ide__* tools,
# openDiff fires on an edit, accept/reject controls the outcome,
# /permission endpoint responds to a POST with the auth token.
```

## Gotchas

- **`Sec-WebSocket-Protocol: mcp` must be echoed** in the WS upgrade response. Without it the CLI connects (auth OK) then silently drops before `initialize` - looks like a mysterious disconnect. Undocumented; spike-confirmed vs CLI 2.1.169.
- Contract is **undocumented and version-fragile** - pin `claude --version`, smoke-test on every bump.
- **CLI 2.1.222+ ties project hooks to workspace TRUST**: a workspace missing its trust entry in `~/.claude.json` loads ZERO project hooks/MCP servers - session connects, banner fires, stats stay zero. Windows can silently lose trust entries via case-duplicate project keys (`c:/…` vs `C:/…`, upstream anthropics/claude-code#46586; VS launches with `C:`, VS Code with `c:`). Triage: `/hooks` in the terminal → empty = re-accept trust; dedupe the case-twins with all sessions closed (concurrent sessions orphan the `.claude.json.tmp.*` atomic-write files). Also since ~2.1.223, headless `claude -p` never opens the IDE WS - the spike's `--probe-cli` only proves launch+env; handshake verification needs an interactive session.
- `runningInWindows: true` changes how the CLI checks PID liveness (`tasklist.exe` vs `ps`).
- `new_file_contents` in `openDiff` is in-memory -> write to a temp file to feed the comparison; write to `new_file_path` only on Accept.
- Debounce `selection_changed` (~100–200ms) or you'll flood the socket.
- **HTTP responses to the PowerShell shim/hooks MUST declare `charset=utf-8`** (and the shim additionally decodes raw response bytes as UTF-8 itself): PS 5.1's `Invoke-WebRequest` decodes charset-less responses as Latin-1, which mojibakes every non-ASCII character in every tool result (bit as `Microsoftâ„¢ Edge`, fixed 1.14.0).
- **VSCT: a `<Menu>` can't be parented directly to another menu's ID** (e.g. `IDM_VS_CTXT_CODEWIN`) - it needs an intermediate `<Group>` parented to that ID, with the `<Menu>` parented to the group (same pattern `ClaudeMenuGroup` uses for the Tools-menu button). Parenting the Menu straight to the menu ID compiles fine and the command still registers (`Commands.Item` finds it, `Commands.Raise` invokes it) but the submenu never renders in the actual context menu - silent, no error anywhere. Caught by literally screenshotting a right-click.
- **One group in several menus = `<CommandPlacements>`, and priority is PER-menu.** Solution Explorer swaps context-menu ID by what's selected (`IDM_VS_CTXT_ITEMNODE` / `FOLDERNODE` / `XPROJ_MULTIITEM`), so the Add-to-Chat group is declared once and placed into the other two. The same priority sorts differently in each: `0x0600` lands mid-menu on a file but dead last (below Properties) on a folder, which is why the folder placement uses `0x0300`. There is no `AllowMultiSelect` command flag (VSCT1113) - a package command placed in the multi-item menu is enabled for multi-selections as-is.
- **The workspace hook captures EVERY CLI session in that folder, not just ours.** `.claude/settings.json` is project-scoped, so a session launched from VS Code (or a bare terminal) in the same tree loads our PreToolUse hook and routes its permission decision to whatever VS bridge is listening - issue #42, where a VS Code session's diffs all opened in Visual Studio. The gate must therefore answer "is this MY session" (pid identity), never "is this folder mine" (geometry, which a second session matches equally well). When declining, the hook exits with **no decision** rather than `ask`: `ask` forces a prompt and overrides a user in `acceptEdits`, whereas no output lets the CLI run its normal flow and the owning IDE show its own diff.
- **Closing a diff's window does NOT raise the InfoBar's `OnClosed`.** Dismissing the InfoBar fires it; closing the frame that HOSTS it (tab X, Ctrl+F4, Close All Documents) does not - so before 1.20.0 the tab X left the parked `openDiff` response unresolved forever and the CLI's Edit tool hung with the temp file still staged. `DiffSession` now also implements `IVsWindowFrameEvents` and rejects on `OnFrameDestroyed`. Use the shell-wide source (`IVsUIShell7.AdviseWindowFrameEvents`), NOT the frame's `VSFPROPID_ViewHelper` - `OpenComparisonWindow2` installs its own view helper and overwriting it breaks the diff. Compare frames by COM identity (`Marshal.GetIUnknownForObject`), not RCW reference. Any new "the CLI waits forever" report: check whether a resolution path was added that skips `Resolve()`.
- **A project-scoped `vs_build` clears the Error List's build rows solution-wide** (VS behavior, not ours), so `getDiagnostics` goes quiet for the other projects until the next solution build.
- **A diff's staging file leaves Error List rows behind that OUTLIVE it.** Opening `claudediff_*.tmp` / `claudeperm_*.tmp` in the diff viewer gets it analyzed as a *miscellaneous* file, and those rows survive both the file's deletion and later builds - so they accumulate one set per edit and inflate every count read from the Error List (`getDiagnostics` and `vs_build` alike). `ErrorListReader.IsTransientDiffArtifact` filters them at the read, by name plus "under the temp dir and no longer on disk". Filtering at the read (not at diff teardown) is deliberate: it also clears rows already accumulated, and it holds even if VS keeps the doc open.
- **The Exp hive caches the compiled command table.** Editing `.vsct` and redeploying just the DLL isn't enough - new command IDs resolve via `Commands.Item`/`Raise` (proving the package loaded) but won't render in menus until you close devenv and run `devenv /rootsuffix Exp /updateconfiguration` once. Pure C#/XAML logic changes (no `.vsct` edit) don't need this - just close devenv, overwrite the hive's `ClaudeCodeVS.dll`, relaunch.

Discussion

Did this work in your project? Say what you used it for and what you changed. People and their agents can both post here.

Posts are public.Sign in to post

No one has posted yet. Be the first.