§1–6: DAP architecture, adapter lifecycle, breakpoints, stepping, variable inspection, and agent-driven debugging workflows.
The Debug Adapter Protocol (DAP) is to debuggers what LSP is to language servers: a JSON-RPC protocol over stdio that decouples agents from debugger implementations. One DAP client can control GDB, LLDB, Python debugpy, Java debugger, Node.js inspector, etc.
Agent ──JSON-RPC──► DAP Adapter (per language) ──► Runtime (GDB/LLDB/Python/JVM/Node)
◄──────────── ◄───
Adapters:
Python → debugpy (pip install debugpy)
Node.js → node --inspect (built-in)
Rust → lldb-vscode (llvm package)
Go → dlv (Delve)
Java → com.microsoft.java.debug
C/C++ → cpptools (codelldb)
DAP messages flow over stdin/stdout JSON-RPC (same transport as LSP) or TCP socket.
interface DapClient {
// Lifecycle
launch(config: LaunchConfig): Promise<void>;
attach(config: AttachConfig): Promise<void>;
disconnect(): Promise<void>;
// Breakpoint control
setBreakpoints(file: string, lines: number[]): Promise<Breakpoint[]>;
setFunctionBreakpoint(name: string): Promise<Breakpoint>;
removeBreakpoints(file: string): Promise<void>;
// Execution control
continue(threadId: number): Promise<void>;
stepOver(threadId: number): Promise<void>;
stepInto(threadId: number): Promise<void>;
stepOut(threadId: number): Promise<void>;
pause(threadId: number): Promise<void>;
// State inspection
getStackTrace(threadId: number): Promise<StackFrame[]>;
getScopes(frameId: number): Promise<Scope[]>;
getVariables(variablesReference: number): Promise<Variable[]>;
evaluate(expr: string, frameId?: number): Promise<EvaluateResult>;
}import { ChildProcess, spawn } from 'child_process';
import { createInterface } from 'readline';
export class DapClient {
private proc: ChildProcess;
private seq = 0;
private pending = new Map<number, { resolve: Function; reject: Function }>();
private eventHandlers = new Map<string, Function[]>();
constructor(private adapterPath: string, private args: string[] = []) {}
async start(): Promise<void> {
this.proc = spawn(this.adapterPath, this.args, {
stdio: ['pipe', 'pipe', 'pipe'],
});
this.proc.stdout!.on('data', this.handleData.bind(this));
this.proc.on('exit', () => this.emit('exit', {}));
// Initialize session
await this.send('initialize', {
clientID: 'agent-dap-client',
clientName: 'Agent DAP',
adapterID: 'unknown',
pathFormat: 'path',
linesStartAt1: true,
columnsStartAt1: true,
supportsVariableType: true,
supportsEvaluateForHovers: true,
supportsRunInTerminalRequest: false,
});
}
// DAP uses Content-Length headers (same as LSP)
private buffer = '';
private handleData(chunk: Buffer): void {
this.buffer += chunk.toString('utf-8');
while (true) {
const headerEnd = this.buffer.indexOf('\r\n\r\n');
if (headerEnd === -1) break;
const header = this.buffer.slice(0, headerEnd);
const lenMatch = header.match(/Content-Length: (\d+)/);
if (!lenMatch) break;
const len = parseInt(lenMatch[1]);
const msgStart = headerEnd + 4;
if (this.buffer.length < msgStart + len) break;
const msgStr = this.buffer.slice(msgStart, msgStart + len);
this.buffer = this.buffer.slice(msgStart + len);
this.handleMessage(JSON.parse(msgStr));
}
}
private handleMessage(msg: DapMessage): void {
if (msg.type === 'response') {
const pending = this.pending.get(msg.request_seq!);
if (!pending) return;
this.pending.delete(msg.request_seq!);
if (msg.success) pending.resolve(msg.body);
else pending.reject(new Error(msg.message ?? 'DAP request failed'));
} else if (msg.type === 'event') {
this.emit(msg.event!, msg.body);
}
}
async send<T = unknown>(command: string, args?: unknown): Promise<T> {
const seq = ++this.seq;
const msg = { type: 'request', seq, command, arguments: args };
const body = JSON.stringify(msg);
const header = `Content-Length: ${Buffer.byteLength(body)}\r\n\r\n`;
this.proc.stdin!.write(header + body);
return new Promise<T>((resolve, reject) => {
this.pending.set(seq, { resolve, reject });
// Timeout: DAP requests should respond in <10s
setTimeout(() => {
if (this.pending.has(seq)) {
this.pending.delete(seq);
reject(new Error(`DAP timeout: ${command}`));
}
}, 10_000);
});
}
on(event: string, handler: Function): void {
this.eventHandlers.set(event, [
...(this.eventHandlers.get(event) ?? []),
handler,
]);
}
private emit(event: string, body: unknown): void {
for (const h of this.eventHandlers.get(event) ?? []) h(body);
}
async disconnect(): Promise<void> {
await this.send('disconnect', { terminateDebuggee: true });
this.proc.kill();
}
}// Launch: start a new process under the debugger
async function launchDebugSession(
client: DapClient,
program: string,
args: string[],
cwd: string,
): Promise<void> {
await client.send('launch', {
type: 'node', // adapter-specific
request: 'launch',
program,
args,
cwd,
stopOnEntry: false, // stop at first line
env: {},
});
// Wait for 'initialized' event before setting breakpoints
await new Promise<void>((resolve) => client.on('initialized', () => resolve()));
}
// Attach: connect to a running process
async function attachDebugSession(
client: DapClient,
pid: number,
): Promise<void> {
await client.send('attach', {
request: 'attach',
processId: pid,
});
await new Promise<void>((resolve) => client.on('initialized', () => resolve()));
}1. Client: initialize request
2. Adapter: initialize response (capabilities)
3. Client: launch or attach request
4. Adapter: initialized event
5. Client: setBreakpoints requests (for each file)
6. Client: setExceptionBreakpoints request
7. Client: configurationDone request ← signals "ready to run"
8. Adapter: stopped / continued events
export class DebugSession {
private breakpointsByFile = new Map<string, number[]>();
async setBreakpoints(file: string, lines: number[]): Promise<Breakpoint[]> {
this.breakpointsByFile.set(file, lines);
const result = await this.client.send<{ breakpoints: Breakpoint[] }>(
'setBreakpoints',
{
source: { path: file },
breakpoints: lines.map(line => ({ line })),
},
);
return result.breakpoints;
}
// Listen for stop events and route to the agent
onStop(cb: (event: StoppedEvent) => void): void {
this.client.on('stopped', (body: StoppedEvent) => {
cb(body);
});
}
async getFullState(threadId: number): Promise<DebugState> {
const frames = await this.client.send<{ stackFrames: StackFrame[] }>(
'stackTrace',
{ threadId, startFrame: 0, levels: 20 },
);
const stateByFrame: FrameState[] = [];
for (const frame of frames.stackFrames) {
const scopesResp = await this.client.send<{ scopes: Scope[] }>(
'scopes',
{ frameId: frame.id },
);
const variables = await Promise.all(
scopesResp.scopes.map(async (scope) => {
const vars = await this.client.send<{ variables: Variable[] }>(
'variables',
{ variablesReference: scope.variablesReference },
);
return { scope: scope.name, variables: vars.variables };
}),
);
stateByFrame.push({ frame, variables });
}
return { threadId, frames: stateByFrame };
}
async evaluate(expr: string, frameId: number): Promise<string> {
const result = await this.client.send<{ result: string; type?: string }>(
'evaluate',
{ expression: expr, frameId, context: 'watch' },
);
return result.type ? `(${result.type}) ${result.result}` : result.result;
}
}Expose the debugger as a single debug tool that encapsulates the full DAP lifecycle.
// Tool definition
const debugTool = defineTool({
name: 'debug',
description: 'Control a debugger: launch, set breakpoints, step, inspect variables',
inputSchema: z.discriminatedUnion('operation', [
z.object({ operation: z.literal('launch'), program: z.string(), args: z.array(z.string()).optional(), cwd: z.string() }),
z.object({ operation: z.literal('attach'), pid: z.number() }),
z.object({ operation: z.literal('set_breakpoint'), file: z.string(), line: z.number() }),
z.object({ operation: z.literal('remove_breakpoints'), file: z.string() }),
z.object({ operation: z.literal('continue'), threadId: z.number().optional() }),
z.object({ operation: z.literal('step_over'), threadId: z.number().optional() }),
z.object({ operation: z.literal('step_into'), threadId: z.number().optional() }),
z.object({ operation: z.literal('step_out'), threadId: z.number().optional() }),
z.object({ operation: z.literal('state') }),
z.object({ operation: z.literal('evaluate'), expression: z.string(), frameId: z.number().optional() }),
z.object({ operation: z.literal('stop') }),
]),
execute: async (input, ctx) => {
const session = ctx.debugSessions.getOrCreate(ctx.sessionId);
switch (input.operation) {
case 'launch': {
const adapter = adapterForLanguage(detectLanguage(input.program));
await session.launch(adapter, input.program, input.args ?? [], input.cwd);
return toolResult('Debug session started. Use set_breakpoint then continue.');
}
case 'set_breakpoint': {
const bps = await session.setBreakpoints(input.file, [input.line]);
const verified = bps.filter(b => b.verified).length;
return toolResult(`Set ${verified}/${bps.length} breakpoints verified`);
}
case 'state': {
const threads = await session.getThreads();
const states = await Promise.all(threads.map(t => session.getFullState(t.id)));
return toolResult(formatDebugState(states));
}
case 'evaluate': {
const result = await session.evaluate(input.expression, input.frameId ?? 0);
return toolResult(result);
}
case 'stop': {
await session.disconnect();
return toolResult('Debug session terminated');
}
default:
return toolResult(await session.executeControl(input));
}
},
});
// Adapter selection by language/file extension
function adapterForLanguage(lang: string): AdapterConfig {
const adapters: Record<string, AdapterConfig> = {
python: { path: 'python', args: ['-m', 'debugpy.adapter'], port: 5678 },
javascript: { path: 'node', args: ['--inspect-brk'], protocol: 'inspector' },
typescript: { path: 'node', args: ['--inspect-brk', '-r', 'ts-node/register'] },
rust: { path: 'lldb-vscode', args: [] },
go: { path: 'dlv', args: ['dap', '--listen', ':38697'] },
};
return adapters[lang] ?? adapters['javascript'];
}1. agent: debug({ operation: "launch", program: "main.py", cwd: "/repo" })
2. agent: debug({ operation: "set_breakpoint", file: "main.py", line: 42 })
3. agent: debug({ operation: "continue" })
← stopped event fires when breakpoint hit
4. agent: debug({ operation: "state" }) → reads stack + variables
5. agent: debug({ operation: "evaluate", expression: "user.email" })
6. agent: debug({ operation: "step_over" })
7. agent: debug({ operation: "state" }) → reads updated state
8. agent: debug({ operation: "stop" })
class DebugSessionManager {
private sessions = new Map<string, DebugSession>();
getOrCreate(sessionId: string): DebugSession {
if (!this.sessions.has(sessionId)) {
this.sessions.set(sessionId, new DebugSession());
}
return this.sessions.get(sessionId)!;
}
async cleanup(sessionId: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
try { await session.disconnect(); } catch {}
this.sessions.delete(sessionId);
}
}
// Error categories for DAP
enum DapErrorKind {
AdapterNotFound = 'ADAPTER_NOT_FOUND', // executable missing
LaunchFailed = 'LAUNCH_FAILED', // process couldn't start
AttachFailed = 'ATTACH_FAILED', // PID not found / permission
Timeout = 'TIMEOUT', // request exceeded 10s
ProtocolError = 'PROTOCOL_ERROR', // malformed DAP message
RuntimeError = 'RUNTIME_ERROR', // debuggee crashed
SessionNotActive = 'SESSION_NOT_ACTIVE', // command sent before launch
}
// Error handling in tool executor
async function withDapErrorHandling<T>(
op: () => Promise<T>,
fallback: string,
): Promise<T | string> {
try {
return await op();
} catch (e) {
const msg = (e as Error).message;
if (msg.includes('ENOENT')) return `${fallback}: adapter executable not found`;
if (msg.includes('timeout')) return `${fallback}: debugger did not respond within 10s`;
if (msg.includes('ESRCH')) return `${fallback}: target process no longer exists`;
return `${fallback}: ${msg}`;
}
}Not all adapters support all features — check capabilities from initialize response:
interface DapCapabilities {
supportsConfigurationDoneRequest?: boolean;
supportsFunctionBreakpoints?: boolean;
supportsConditionalBreakpoints?: boolean;
supportsHitConditionalBreakpoints?: boolean;
supportsEvaluateForHovers?: boolean;
supportsSetVariable?: boolean;
supportsGotoTargetsRequest?: boolean;
supportsCompletionsRequest?: boolean;
supportsRestartRequest?: boolean;
supportsExceptionOptions?: boolean;
}
// Always check before using optional features
if (caps.supportsConditionalBreakpoints) {
await client.send('setBreakpoints', {
source: { path: file },
breakpoints: [{ line, condition: 'user.role === "admin"' }],
});
}- Choose DAP adapter per language (debugpy, dlv, lldb-vscode, node inspect)
- Implement Content-Length header framing (same as LSP)
- Follow init sequence: initialize → launch/attach → wait for
initializedevent → setBreakpoints → configurationDone - Scope debug sessions to agent session ID; clean up on session end
- Check
DapCapabilitiesbefore using optional features - Expose as a single
debugtool with discriminated union for operations - Handle
stoppedevents asynchronously — inject as context for the agent's next turn - Timeout all DAP requests at 10s; return error, don't block agent loop
- Graceful disconnect with
terminateDebuggee: trueon session end
Source-verified analysis of
/home/deck/Projects/claude-code/src(2026-05-03). Maps the internal hook points where a DAP server would attach if you were building it inside Claude Code. Use this as a blueprint when modifying the source, or as architecture context when building external plugins (see05-dap-claude-code-plugin.md).
┌─────────────────────────────────────────────────────────────────┐
│ entrypoints/ │
│ mcp.ts ← DAP server entry point would go here │
│ cli.tsx ← Add --dap-port flag here │
├─────────────────────────────────────────────────────────────────┤
│ services/ │
│ tools/toolExecution.ts ← inject breakpoint checks here │
│ lsp/LSPClient.ts ← exact structural template for DAP │
├─────────────────────────────────────────────────────────────────┤
│ Tool.ts │
│ ToolUseContext (line 158) ← maps to DAP scopes/variables │
│ ToolProgress (line 307) ← maps to DAP output/stopped events│
├─────────────────────────────────────────────────────────────────┤
│ bridge/ │
│ sessionRunner.ts (line 335) ← subprocess spawn pattern │
│ bridgeMain.ts ← DAP-over-bridge option │
├─────────────────────────────────────────────────────────────────┤
│ QueryEngine.ts (line 209) │
│ submitMessage() generator ← main agent loop, tool dispatch │
└─────────────────────────────────────────────────────────────────┘
services/lsp/LSPClient.ts already uses the exact same protocol stack as DAP
(JSON-RPC over stdio via vscode-jsonrpc). DAP is a structural clone:
LSPClient.ts DAPClient.ts (proposed)
─────────────────────────────────────────────────────────
spawn(lspBin, args, {stdio}) → spawn(adapterBin, args, {stdio})
createMessageConnection( → createMessageConnection(
StreamMessageReader(stdout), StreamMessageReader(stdout),
StreamMessageWriter(stdin) StreamMessageWriter(stdin)
) )
connection.sendRequest(method) → connection.sendRequest('launch', ...)
connection.onNotification(...) → connection.onNotification('stopped', ...)
The vscode-languageserver-protocol package (already a transitive dep via LSP)
ships DAP type definitions. No new transport code needed.
QueryEngine.submitMessage() [QueryEngine.ts:209]
↓
Claude returns tool_use blocks
↓
toolExecution.ts:150+ ← BREAKPOINT CHECK here (PreToolUse)
├─ canUseTool() permission gate
├─ pre-tool hooks run
├─ tool.call(input, ToolUseContext) ← ToolUseContext = DAP variables scope
│ ↓
│ progress callbacks fire ← emit DAP `output` / `stopped` events
│ ↓
│ ToolResult returned
├─ post-tool hooks run ← BREAKPOINT CHECK here (PostToolUse)
└─ result injected into messages
| DAP Concept | Claude Code equivalent | File:line |
|---|---|---|
| Launch/attach | sessionRunner.spawn() |
bridge/sessionRunner.ts:335 |
| Scope | ToolUseContext fields |
Tool.ts:158-300 |
| Variable | ToolUseContext.readFileState, getAppState() |
Tool.ts:175-210 |
| Stopped event | ToolCallProgress callback |
Tool.ts:307-320 |
| Output event | toolCallProgress({type:'bash_progress'}) |
Tool.ts:315 |
| Thread | tool_use_id (parallel tool calls) |
toolExecution.ts:150 |
| Stack frame | Sequence of tool calls in QueryEngine turn |
QueryEngine.ts:209 |
| Evaluate | canUseTool + ad-hoc tool.call() |
services/tools/toolExecution.ts |
src/
entrypoints/dap.ts ← DAPServer entry (mirror mcp.ts)
services/dap/
DAPServer.ts ← protocol handler (mirror LSPClient.ts)
breakpointRegistry.ts ← per-session breakpoint storage
variableInspector.ts ← ToolUseContext → DAP Variable serializer
tools/DAPDebugTool/
DAPDebugTool.ts ← expose debug ops as Claude tool
Files to modify:
| File | Change | Why |
|---|---|---|
services/tools/toolExecution.ts:150 |
inject checkBreakpoint() before/after tool.call() |
pause on breakpoint hits |
Tool.ts:158 |
add debugMeta?: DebugContext to ToolUseContext |
carry DAP frame ID |
bridge/sessionRunner.ts:335 |
add --dap-port passthrough to spawned process |
remote session debugging |
entrypoints/cli.tsx |
add --dap-port N flag |
activate DAP server mode |
tools.ts:193 |
register DAPDebugTool when DAP_MODE=1 |
expose tool to Claude |
If modifying the source is not an option, all of the above can be approximated using only MCP + hooks from outside. See 05-dap-claude-code-plugin.md for the complete zero-source-change implementation guide.