Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
### Added
- **Claude Code auto-registration** - Claude Code is now offered in the agent selection popup and configured via `~/.claude.json`'s user-scope `mcpServers` field. Claude Desktop connects via its Custom Connector UI instead of a static config file; the README's manual configuration section covers both.

## [2.4.1] - 2026-09-17

### Fixed
- Track stopped/continued debugger events so `get_debug_status` recognizes paused targets even when source or stack frames are unavailable. `pause_execution` now returns immediately for an already-paused session instead of waiting for another step or the operation timeout (#157).
- Complete step operations on a fresh stopped event even when frame IDs and source locations are unchanged or the stack is empty, avoiding hangs until a manual step or timeout in VS Code and the standalone CLI (#157).

## [2.3.5] - 2026-09-09

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Let AI agents debug your code inside VS Code - set breakpoints, step through exe

[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![VS Code](https://img.shields.io/badge/VS%20Code-1.104.0+-blue.svg)](https://code.visualstudio.com/)
[![Version](https://img.shields.io/badge/version-2.4.0-green.svg)](https://github.com/microsoft/DebugMCP)
[![Version](https://img.shields.io/badge/version-2.4.1-green.svg)](https://github.com/microsoft/DebugMCP)
[![VS Marketplace](https://img.shields.io/badge/VS%20Marketplace-Install-blue.svg)](https://marketplace.visualstudio.com/items?itemName=ozzafar.debugmcpextension)


Expand Down
20 changes: 17 additions & 3 deletions docs/architecture/debugState.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ Debugging operations are asynchronous - the debugger takes time to execute and u

| Property | Type | Description |
|----------|------|-------------|
| `sessionActive` | `boolean` | Whether a debug session is running |
| `sessionActive` | `boolean` | Whether a debug session exists, running or stopped |
| `paused` | `boolean \| null` | Observed execution state, or `null` when unobserved |
| `stopSequence` | `number \| null` | Internal executor-local stopped-event marker, independent of frames and source |
| `fileFullPath` | `string \| null` | Full path to current file |
| `fileName` | `string \| null` | Just the filename |
| `currentLine` | `number \| null` | 1-based line number |
Expand All @@ -33,7 +35,8 @@ Debugging operations are asynchronous - the debugger takes time to execute and u

| Method | Purpose |
|--------|---------|
| `hasValidContext()` | Check if frame/thread IDs are set |
| `isPaused()` | Check observed stopped state, falling back to frame context only when unknown |
| `hasValidContext()` | Check for an active session with frame/thread IDs for inspection |
| `hasLocationInfo()` | Check if file/line info is available |
| `hasFrameName()` | Check if frame name is available |
| `clone()` | Create a deep copy for comparison |
Expand All @@ -52,11 +55,22 @@ Debugging operations are asynchronous - the debugger takes time to execute and u
1. Capture before state: beforeState = executor.getCurrentDebugState()
2. Execute debug command
3. Poll for changes: compare beforeState with currentState
4. State changed when: file, line, frame, or session status differs
4. Step completed when a fresh stop is observed or the session ends; unobserved sessions fall back to location/context comparison
```

## Design Notes

- **Immutable by convention**: Use `clone()` when you need a snapshot
- **Incremental building**: State is built via multiple update calls during retrieval
- **Null-safe**: All optional fields default to null, with helper methods to check validity
- **Independent execution state**: A stopped target need not provide a stack frame,
source path, or readable source. Explicit running state overrides stale frame IDs;
missing frames are never replaced by fabricated identifiers.
- **Compatibility fallback**: Sessions that predate DAP observation use available
frame/thread context to infer a pause. A selected thread alone is insufficient.
- **Snapshot lifecycle**: Cloning preserves observed execution state; resetting
returns it to unknown. JSON output includes the computed `isPaused()` boolean,
so an inactive session is never serialized as paused.
- **Stop identity**: A new stopped event advances `stopSequence` even if the
adapter reuses the frame ID and location or returns no frames. This internal
comparison marker is cloned/reset with the snapshot but omitted from tool JSON.
39 changes: 36 additions & 3 deletions docs/architecture/debuggingExecutor.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,19 +109,52 @@ text that Cortex-Debug does not include in its `evaluate` response:

A session is considered "ready" when:
1. `vscode.debug.activeDebugSession` exists
2. Location info is available (file name and line number)
2. An active stack frame is available (frame and thread IDs), even without source

This handles cases where the debugger is still initializing (common with Python).
This startup readiness check is intentionally stronger than paused status: a
frameless stop can be reported by status without claiming frame inspection is
ready. Observed running state rejects stale UI frames. Attach sessions can also
be ready while the target remains running.
`waitForDebugSessionReady()` accepts cancellation so a failed startup does not
leave its readiness timeout and event subscriptions behind.

### State Retrieval

`getCurrentDebugState()` queries multiple VS Code APIs:
- `vscode.debug.activeDebugSession` - Session existence
- The injected `DebugSessionTracker` - Observed DAP execution state
- `vscode.debug.activeStackItem` - Frame/thread context
- `vscode.window.activeTextEditor` - Current file and line
- DAP `stackTrace` request - Frame name
- DAP `stackTrace` request - Frame name, call stack, and source location
- Source document lookup - Optional line contents and lookahead

Frame/thread context is populated before source lookup. A missing or unreadable
source does not discard that context or mean that execution is running. Paused
status is also independent of frames: an observed stop with an empty stack still
reports paused, while an observed continue overrides stale UI frame information.
Unobserved sessions retain the frame-based fallback; a selected thread alone does
not establish a pause.

`src/utils/debugSessionTracker.ts` is a read-only observer of standard DAP
`stopped`, `continued`, `exited`, and `terminated` events. It tracks sessions and
thread-specific versus all-thread transitions separately. A selected thread uses
its observed state; without a selected thread, any known stop establishes paused
status. Activation creates one tracker and owns its disposal; test-created
executors can omit it. Session termination and adapter shutdown clear tracked
execution state without retaining terminated session objects.

Snapshots also carry an internal stopped-event sequence. Unlike the revision used
to reject stale asynchronous reads, it advances only on a stop, so an unchanged
source location or an empty stack cannot hide a completed step. Selected-thread
markers are unaffected by other threads' partial transitions; all-thread and
unscoped stops remain observable without a selected frame. The CLI supplies the
same marker from its stopped events. These markers are not added to tool output.

After asynchronous stack/source lookups, execution transitions, a changed or
cleared frame, or an ended session invalidate the old frame snapshot. The latest
observed stopped/running state is retained even when frame information is
discarded. The CLI likewise reports its DAP-backed execution state independently
of its stack and rejects frame data spanning execution transitions.

## Key Code Locations

Expand Down
28 changes: 25 additions & 3 deletions docs/architecture/debuggingHandler.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,10 +45,10 @@ Debugging is inherently asynchronous - when you step over a line, the debugger t
After executing a debug command (step over, continue, etc.), the handler:
1. Captures "before" state
2. Executes the command via executor
3. Polls for state changes using exponential backoff
3. Polls for state changes at short bounded intervals
4. Returns the "after" state when a meaningful change is detected

### Exponential Backoff
### Bounded Polling

State-change polling uses short bounded intervals so either executor can expose
new stopped/running state without the handler depending on host-specific event
Expand All @@ -57,12 +57,34 @@ native VS Code or DAP events.

### Meaningful State Changes

A state change is considered meaningful when any of these change:
A fresh observed stopped-event sequence completes a step, even at the same source
line with reused frame IDs, or with no source/stack at all. Resuming or refreshing
the UI alone does not complete a step. Session termination also completes the
wait. The VS Code and CLI executors supply the same internal snapshot marker.

When neither snapshot has an observed stopped-event sequence, the compatibility
fallback considers changes to:
- Session active status
- Current file path
- Current line number
- Frame name (function/method)
- Frame ID
- Thread ID

### Paused State and Pause Requests

Paused status uses the executor's observed stopped/running state, independently
of source and stack availability. If execution state has not been observed (for
example, a session that predates tracking), an active frame/thread context is the
fallback. A bare selected thread alone is not proof of a stop.
Native/disassembly frames, unavailable local files, and empty stacks can all
occur while stopped. Status waits and navigation use this same distinction;
losing source information alone is not a resume.

`handlePause()` is idempotent for an already-paused session: it returns the current
state without issuing another pause or waiting for a location change. For a
running session, it dispatches pause and waits for a stopped state or session
termination, bounded by the operation timeout.

### Root Cause Analysis

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "debugmcpextension",
"displayName": "DebugMCP — Agentic Debugging for VS Code, Cursor & More",
"description": "Your AI agent debugs for you — right inside VS Code, Cursor & other VS Code-based editors. Let Copilot, Cline, Cursor, Codex & any MCP agent set breakpoints, step through code, and inspect variables live instead of guessing from logs.",
"version": "2.4.0",
"version": "2.4.1",
"publisher": "ozzafar",
"author": {
"name": "Oz Zafar",
Expand Down
46 changes: 34 additions & 12 deletions src/cli/cliDebuggingExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export class CliDebuggingExecutor implements IDebuggingExecutor {
private frameId?: number;
private capabilities: Record<string, unknown> = {};
private initialized = false;
private stateRevision = 0;
private stopSequence = 0;

public async startDebugging(
workingDirectory: string,
Expand Down Expand Up @@ -212,30 +214,31 @@ export class CliDebuggingExecutor implements IDebuggingExecutor {
}

public async getCurrentDebugState(numNextLines = 3): Promise<DebugState> {
const result = new DebugState();
result.sessionActive = this.state !== 'none' && this.state !== 'terminated';
result.updateConfigurationName(this.session?.name ?? null);
result.updateBreakpoints(this.breakpoints.map(item => {
const suffix = item.condition ? ` [when: ${item.condition}]` : '';
return `${path.basename(item.fileFullPath)}:${item.line}${suffix}`;
}));

const result = this.createStateSnapshot();
if (!result.sessionActive || this.state !== 'stopped' || this.threadId === undefined) {
return result;
}

const response = await this.requireClient().request('stackTrace', {
threadId: this.threadId,
const client = this.requireClient();
const threadId = this.threadId;
const revision = this.stateRevision;
const isCurrent = () => this.client === client && this.state === 'stopped' &&
this.threadId === threadId && this.stateRevision === revision;
const response = await client.request('stackTrace', {
threadId,
startFrame: 0,
levels: 50
});
if (!isCurrent()) {
return this.createStateSnapshot();
}
const frames = Array.isArray(response?.stackFrames) ? response.stackFrames : [];
if (frames.length === 0) {
this.frameId = undefined;
return result;
}
const current = frames[0];
this.frameId = current.id;
result.updateContext(current.id, this.threadId);
result.updateContext(current.id, threadId);
result.updateFrameName(current.name ?? null);
result.updateStackTrace(frames.map((frame: any): StackFrame => ({
name: frame.name ?? 'unknown',
Expand All @@ -247,6 +250,23 @@ export class CliDebuggingExecutor implements IDebuggingExecutor {
if (typeof current.source?.path === 'string' && typeof current.line === 'number') {
await this.populateSource(result, current.source.path, current.line, numNextLines);
}
if (!isCurrent()) {
return this.createStateSnapshot();
}
this.frameId = current.id;
return result;
}

private createStateSnapshot(): DebugState {
const result = new DebugState();
result.sessionActive = this.state !== 'none' && this.state !== 'terminated';
result.paused = this.state === 'stopped';
result.stopSequence = result.paused && this.stopSequence > 0 ? this.stopSequence : null;
result.updateConfigurationName(this.session?.name ?? null);
result.updateBreakpoints(this.breakpoints.map(item => {
const suffix = item.condition ? ` [when: ${item.condition}]` : '';
return `${path.basename(item.fileFullPath)}:${item.line}${suffix}`;
}));
return result;
}

Expand Down Expand Up @@ -351,6 +371,7 @@ export class CliDebuggingExecutor implements IDebuggingExecutor {

private registerClientEvents(client: DapClient): void {
client.on('stopped', body => {
this.stopSequence++;
this.threadId = typeof body.threadId === 'number' ? body.threadId : this.threadId;
this.frameId = undefined;
this.state = 'stopped';
Expand Down Expand Up @@ -391,6 +412,7 @@ export class CliDebuggingExecutor implements IDebuggingExecutor {
}

private emitState(): void {
this.stateRevision++;
this.events.emit('state');
}

Expand Down
17 changes: 17 additions & 0 deletions src/debugState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface StackFrame {
*/
export class DebugState {
public sessionActive: boolean;
public paused: boolean | null;
public stopSequence: number | null;
public fileFullPath: string | null;
public fileName: string | null;
public currentLine: number | null;
Expand All @@ -29,6 +31,8 @@ export class DebugState {

constructor() {
this.sessionActive = false;
this.paused = null;
this.stopSequence = null;
this.fileFullPath = null;
this.fileName = null;
this.currentLine = null;
Expand All @@ -47,6 +51,8 @@ export class DebugState {
*/
public reset(): void {
this.sessionActive = false;
this.paused = null;
this.stopSequence = null;
this.fileFullPath = null;
this.fileName = null;
this.currentLine = null;
Expand All @@ -69,6 +75,13 @@ export class DebugState {
this.threadId !== null;
}

/**
* Prefer observed execution state; frames are a fallback for unobserved sessions.
*/
public isPaused(): boolean {
return this.sessionActive && (this.paused ?? this.hasValidContext());
}

/**
* Check if location information is available
*/
Expand Down Expand Up @@ -140,6 +153,8 @@ export class DebugState {
public clone(): DebugState {
const cloned = new DebugState();
cloned.sessionActive = this.sessionActive;
cloned.paused = this.paused;
cloned.stopSequence = this.stopSequence;
cloned.fileFullPath = this.fileFullPath;
cloned.fileName = this.fileName;
cloned.currentLine = this.currentLine;
Expand All @@ -160,6 +175,7 @@ export class DebugState {
public toString(): string {
const stateObject: {
sessionActive: boolean;
paused: boolean;
configurationName?: string | null;
stackTrace?: string[];
breakpoints?: string[];
Expand All @@ -173,6 +189,7 @@ export class DebugState {
frameName?: string | null;
} = {
sessionActive: this.sessionActive,
paused: this.isPaused(),
};

if (this.sessionActive) {
Expand Down
Loading
Loading