From 776546e09d435f7809d6868cdcb6ce2c58d187cc Mon Sep 17 00:00:00 2001 From: can1357 Date: Wed, 8 Apr 2026 05:20:37 +0200 Subject: [PATCH] feat(coding-agent): implemented advanced DAP debugging with breakpoints and memory introspection - Added advanced DAP debugging capabilities including instruction breakpoints, data breakpoints, disassembly, memory introspection, and module inspection. - Added reverse request handling in DAP client for runInTerminal and startDebugging to support child process debugging. - Replaced Bun.which() with $which() utility providing flexible cache control and macOS Xcode toolchain fallback. - Expanded debug tool schema with 20+ new parameters and 11 new action handlers for low-level debugging operations. - Added instructionPointerReference field to debug session snapshots for enhanced low-level debugging context. --- packages/coding-agent/CHANGELOG.md | 15 + packages/coding-agent/src/dap/client.ts | 37 +- packages/coding-agent/src/dap/session.ts | 367 ++++++++++++++++++- packages/coding-agent/src/dap/types.ts | 158 +++++++++ packages/coding-agent/src/lsp/config.ts | 4 +- packages/coding-agent/src/tools/debug.ts | 434 +++++++++++++++++++++++ packages/utils/src/index.ts | 1 + packages/utils/src/which.ts | 92 +++++ 8 files changed, 1102 insertions(+), 6 deletions(-) create mode 100644 packages/utils/src/which.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 167428d99..552523c08 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Breaking Changes - Simplified chunk edit operations: removed `append_child`, `prepend_child`, `append_sibling`, `prepend_sibling`, and `replace_body` ops in favor of unified `replace`, `before`, `after`, `prepend`, and `append` with region targeting (`@container`, `@prologue`, `@body`, `@epilogue`) @@ -9,6 +10,16 @@ ### Added +- Instruction breakpoints support: `set_instruction_breakpoint` and `remove_instruction_breakpoint` debug actions for setting breakpoints at specific instruction addresses +- Data breakpoints support: `data_breakpoint_info`, `set_data_breakpoint`, and `remove_data_breakpoint` debug actions for monitoring variable/memory access +- Memory introspection: `read_memory` and `write_memory` debug actions for inspecting and modifying debugger memory +- Disassembly support: `disassemble` debug action for viewing assembly instructions with symbol resolution +- Module and source introspection: `modules` and `loaded_sources` debug actions for querying loaded modules and source files +- Custom DAP requests: `custom_request` debug action for sending arbitrary Debug Adapter Protocol commands +- Reverse request handling in DAP client: `onReverseRequest()` method for responding to adapter-initiated requests like `runInTerminal` and `startDebugging` +- DAP reverse request support: adapters can now request terminal spawning and child debug sessions via `runInTerminal` and `startDebugging` reverse requests +- Instruction pointer reference in debug snapshots: `instructionPointerReference` field in session summaries for low-level debugging +- Hit condition support for instruction and data breakpoints: `hit_condition` parameter for conditional breakpoint triggering - RPC `set_todos` command and `todoPhases` in `get_state`, allowing hosts to pre-seed and inspect session todo state over the protocol - Deferred diagnostics support in LSP writethrough: `onDeferredDiagnostics` callback and `deferredSignal` in `WritethroughOptions` allow callers to receive diagnostics that arrive after the main 5-second timeout - Language detection for `.pm` (Perl modules), `.astro` (Astro framework), and special filenames `containerfile` and `justfile` @@ -30,6 +41,10 @@ ### Changed +- DAP initialization now advertises support for `runInTerminal` and `startDebugging` reverse requests, and memory references +- Debug tool schema expanded with new parameters for instruction/data breakpoints, memory operations, and custom requests +- DAP session state now tracks instruction and data breakpoints separately from source breakpoints +- Replaced `Bun.which()` with `$which()` from pi-utils for command resolution - Chunk edit tool documentation restructured: replaced operation-specific examples with region-based guidance and canonical indentation rules - Chunk read documentation updated: selectors now support region syntax (e.g., `class_Foo.fn_bar#ABCD@body`) and canonical target listings show supported regions per chunk - Chunk edit schema simplified: `target` description now documents region format; `op` and `content` descriptions clarified for region-aware operations diff --git a/packages/coding-agent/src/dap/client.ts b/packages/coding-agent/src/dap/client.ts index 6e0297813..59490a8f4 100644 --- a/packages/coding-agent/src/dap/client.ts +++ b/packages/coding-agent/src/dap/client.ts @@ -24,6 +24,7 @@ interface DapWriteSink { } type DapEventHandler = (body: unknown, event: DapEventMessage) => void | Promise; +type DapReverseRequestHandler = (args: unknown) => unknown | Promise; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; @@ -86,6 +87,7 @@ export class DapClient { #capabilities?: DapCapabilities; #eventHandlers = new Map>(); #anyEventHandlers = new Set(); + #reverseRequestHandlers = new Map(); constructor( adapter: DapResolvedAdapter, @@ -276,6 +278,15 @@ export class DapClient { }; } + onReverseRequest(command: string, handler: DapReverseRequestHandler): () => void { + this.#reverseRequestHandlers.set(command, handler); + return () => { + if (this.#reverseRequestHandlers.get(command) === handler) { + this.#reverseRequestHandlers.delete(command); + } + }; + } + async waitForEvent( event: string, predicate?: (body: TBody) => boolean, @@ -479,16 +490,38 @@ export class DapClient { async #handleAdapterRequest(message: DapRequestMessage): Promise { try { + const handler = this.#reverseRequestHandlers.get(message.command); + if (handler) { + try { + const body = await handler(message.arguments); + await this.sendResponse(message, true, body); + } catch (error) { + const errorMessage = toErrorMessage(error); + await this.sendResponse( + message, + false, + { + error: { + id: 1, + format: errorMessage, + }, + }, + errorMessage, + ); + } + return; + } + const errorMessage = `Unsupported DAP request: ${message.command}`; await this.sendResponse( message, false, { error: { id: 1, - format: `Unsupported DAP request: ${message.command}`, + format: errorMessage, }, }, - `Unsupported DAP request: ${message.command}`, + errorMessage, ); } catch (error) { logger.warn("Failed to answer DAP adapter request", { diff --git a/packages/coding-agent/src/dap/session.ts b/packages/coding-agent/src/dap/session.ts index be2ecd472..b6001fe27 100644 --- a/packages/coding-agent/src/dap/session.ts +++ b/packages/coding-agent/src/dap/session.ts @@ -1,5 +1,6 @@ import * as path from "node:path"; -import { logger } from "@oh-my-pi/pi-utils"; +import { logger, ptree } from "@oh-my-pi/pi-utils"; +import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env"; import { DapClient } from "./client"; import type { DapAttachArguments, @@ -10,25 +11,46 @@ import type { DapContinueArguments, DapContinueOutcome, DapContinueResponse, + DapDataBreakpoint, + DapDataBreakpointInfoArguments, + DapDataBreakpointInfoResponse, + DapDataBreakpointRecord, + DapDisassembleArguments, + DapDisassembledInstruction, + DapDisassembleResponse, DapEvaluateArguments, DapEvaluateResponse, DapExitedEventBody, DapFunctionBreakpoint, DapFunctionBreakpointRecord, DapInitializeArguments, + DapInstructionBreakpoint, + DapInstructionBreakpointRecord, DapLaunchArguments, DapLaunchSessionOptions, + DapLoadedSourcesResponse, + DapModule, + DapModulesArguments, + DapModulesResponse, DapOutputEventBody, DapPauseArguments, + DapReadMemoryArguments, + DapReadMemoryResponse, DapResolvedAdapter, + DapRunInTerminalArguments, + DapRunInTerminalResponse, DapScopesArguments, DapScopesResponse, DapSessionStatus, DapSessionSummary, + DapSetDataBreakpointsArguments, + DapSetInstructionBreakpointsArguments, + DapSource, DapSourceBreakpoint, DapStackFrame, DapStackTraceArguments, DapStackTraceResponse, + DapStartDebuggingArguments, DapStepArguments, DapStopLocation, DapStoppedEventBody, @@ -36,6 +58,8 @@ import type { DapThreadsResponse, DapVariablesArguments, DapVariablesResponse, + DapWriteMemoryArguments, + DapWriteMemoryResponse, } from "./types"; interface DapSession { @@ -49,6 +73,8 @@ interface DapSession { lastUsedAt: number; breakpoints: Map; functionBreakpoints: DapFunctionBreakpointRecord[]; + instructionBreakpoints: DapInstructionBreakpoint[]; + dataBreakpoints: DapDataBreakpoint[]; output: string; outputBytes: number; outputTruncated: boolean; @@ -114,6 +140,7 @@ function buildSummary(session: DapSession): DapSessionSummary { stopReason: session.stop.reason, stopDescription: session.stop.description ?? session.stop.text, frameName: session.stop.frameName, + instructionPointerReference: session.stop.instructionPointerReference, source: session.stop.source, line: session.stop.line, column: session.stop.column, @@ -163,6 +190,10 @@ export class DapSessionManager { return Array.from(this.#sessions.values()).map(buildSummary); } + getCapabilities(): DapCapabilities | null { + return this.#getActiveSessionOrNull()?.capabilities ?? null; + } + async launch( options: DapLaunchSessionOptions, signal?: AbortSignal, @@ -371,6 +402,270 @@ export class DapSessionManager { return { snapshot: buildSummary(session), breakpoints: session.functionBreakpoints }; } + async setInstructionBreakpoint( + instructionReference: string, + offset?: number, + condition?: string, + hitCondition?: string, + signal?: AbortSignal, + timeoutMs: number = 30_000, + ) { + const session = this.#touchActiveSession(); + const current = session.instructionBreakpoints.filter( + entry => entry.instructionReference !== instructionReference || entry.offset !== offset, + ); + current.push({ instructionReference, offset, condition, hitCondition }); + current.sort((left, right) => { + const referenceOrder = left.instructionReference.localeCompare(right.instructionReference); + if (referenceOrder !== 0) { + return referenceOrder; + } + return (left.offset ?? 0) - (right.offset ?? 0); + }); + const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>( + session, + "setInstructionBreakpoints", + { + breakpoints: current, + } satisfies DapSetInstructionBreakpointsArguments, + signal, + timeoutMs, + ); + session.instructionBreakpoints = current; + return { + snapshot: buildSummary(session), + breakpoints: this.#mapInstructionBreakpoints(current, response?.breakpoints), + }; + } + + async removeInstructionBreakpoint( + instructionReference: string, + offset?: number, + signal?: AbortSignal, + timeoutMs: number = 30_000, + ) { + const session = this.#touchActiveSession(); + const current = session.instructionBreakpoints.filter(entry => { + if (entry.instructionReference !== instructionReference) { + return true; + } + if (offset === undefined) { + return false; + } + return entry.offset !== offset; + }); + const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>( + session, + "setInstructionBreakpoints", + { + breakpoints: current, + } satisfies DapSetInstructionBreakpointsArguments, + signal, + timeoutMs, + ); + session.instructionBreakpoints = current; + return { + snapshot: buildSummary(session), + breakpoints: this.#mapInstructionBreakpoints(current, response?.breakpoints), + }; + } + + async dataBreakpointInfo( + name: string, + variablesReference?: number, + frameId?: number, + signal?: AbortSignal, + timeoutMs: number = 30_000, + ): Promise<{ snapshot: DapSessionSummary; info: DapDataBreakpointInfoResponse }> { + const session = this.#touchActiveSession(); + const info = await this.#sendRequestWithConfig( + session, + "dataBreakpointInfo", + { + name, + ...(variablesReference !== undefined ? { variablesReference } : {}), + ...(frameId !== undefined ? { frameId } : {}), + } satisfies DapDataBreakpointInfoArguments, + signal, + timeoutMs, + ); + return { snapshot: buildSummary(session), info }; + } + + async setDataBreakpoint( + dataId: string, + accessType?: "read" | "write" | "readWrite", + condition?: string, + hitCondition?: string, + signal?: AbortSignal, + timeoutMs: number = 30_000, + ) { + const session = this.#touchActiveSession(); + const current = session.dataBreakpoints.filter(entry => entry.dataId !== dataId); + current.push({ dataId, accessType, condition, hitCondition }); + current.sort((left, right) => left.dataId.localeCompare(right.dataId)); + const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>( + session, + "setDataBreakpoints", + { + breakpoints: current, + } satisfies DapSetDataBreakpointsArguments, + signal, + timeoutMs, + ); + session.dataBreakpoints = current; + return { + snapshot: buildSummary(session), + breakpoints: this.#mapDataBreakpoints(current, response?.breakpoints), + }; + } + + async removeDataBreakpoint(dataId: string, signal?: AbortSignal, timeoutMs: number = 30_000) { + const session = this.#touchActiveSession(); + const current = session.dataBreakpoints.filter(entry => entry.dataId !== dataId); + const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>( + session, + "setDataBreakpoints", + { + breakpoints: current, + } satisfies DapSetDataBreakpointsArguments, + signal, + timeoutMs, + ); + session.dataBreakpoints = current; + return { + snapshot: buildSummary(session), + breakpoints: this.#mapDataBreakpoints(current, response?.breakpoints), + }; + } + + async disassemble( + memoryReference: string, + instructionCount: number, + offset?: number, + instructionOffset?: number, + resolveSymbols?: boolean, + signal?: AbortSignal, + timeoutMs: number = 30_000, + ): Promise<{ snapshot: DapSessionSummary; instructions: DapDisassembledInstruction[] }> { + const session = this.#touchActiveSession(); + const response = await this.#sendRequestWithConfig( + session, + "disassemble", + { + memoryReference, + instructionCount, + ...(offset !== undefined ? { offset } : {}), + ...(instructionOffset !== undefined ? { instructionOffset } : {}), + ...(resolveSymbols !== undefined ? { resolveSymbols } : {}), + } satisfies DapDisassembleArguments, + signal, + timeoutMs, + ); + return { snapshot: buildSummary(session), instructions: response?.instructions ?? [] }; + } + + async readMemory( + memoryReference: string, + count: number, + offset?: number, + signal?: AbortSignal, + timeoutMs: number = 30_000, + ): Promise<{ snapshot: DapSessionSummary; address: string; data?: string; unreadableBytes?: number }> { + const session = this.#touchActiveSession(); + const response = await this.#sendRequestWithConfig( + session, + "readMemory", + { + memoryReference, + count, + ...(offset !== undefined ? { offset } : {}), + } satisfies DapReadMemoryArguments, + signal, + timeoutMs, + ); + return { + snapshot: buildSummary(session), + address: response?.address ?? memoryReference, + data: response?.data, + unreadableBytes: response?.unreadableBytes, + }; + } + + async writeMemory( + memoryReference: string, + data: string, + offset?: number, + allowPartial?: boolean, + signal?: AbortSignal, + timeoutMs: number = 30_000, + ): Promise<{ snapshot: DapSessionSummary; offset?: number; bytesWritten?: number }> { + const session = this.#touchActiveSession(); + const response = await this.#sendRequestWithConfig( + session, + "writeMemory", + { + memoryReference, + data, + ...(offset !== undefined ? { offset } : {}), + ...(allowPartial !== undefined ? { allowPartial } : {}), + } satisfies DapWriteMemoryArguments, + signal, + timeoutMs, + ); + return { + snapshot: buildSummary(session), + offset: response?.offset, + bytesWritten: response?.bytesWritten, + }; + } + + async modules( + startModule?: number, + moduleCount?: number, + signal?: AbortSignal, + timeoutMs: number = 30_000, + ): Promise<{ snapshot: DapSessionSummary; modules: DapModule[] }> { + const session = this.#touchActiveSession(); + const response = await this.#sendRequestWithConfig( + session, + "modules", + { + ...(startModule !== undefined ? { startModule } : {}), + ...(moduleCount !== undefined ? { moduleCount } : {}), + } satisfies DapModulesArguments, + signal, + timeoutMs, + ); + return { snapshot: buildSummary(session), modules: response?.modules ?? [] }; + } + + async loadedSources( + signal?: AbortSignal, + timeoutMs: number = 30_000, + ): Promise<{ snapshot: DapSessionSummary; sources: DapSource[] }> { + const session = this.#touchActiveSession(); + const response = await this.#sendRequestWithConfig( + session, + "loadedSources", + {}, + signal, + timeoutMs, + ); + return { snapshot: buildSummary(session), sources: response?.sources ?? [] }; + } + + async customRequest( + command: string, + args?: Record, + signal?: AbortSignal, + timeoutMs: number = 30_000, + ): Promise<{ snapshot: DapSessionSummary; body: unknown }> { + const session = this.#touchActiveSession(); + const body = await this.#sendRequestWithConfig(session, command, args, signal, timeoutMs); + return { snapshot: buildSummary(session), body }; + } + async continue(signal?: AbortSignal, timeoutMs: number = 30_000): Promise { const session = this.#touchActiveSession(); const threadId = await this.#resolveThreadId(session, signal, timeoutMs); @@ -599,6 +894,8 @@ export class DapSessionManager { lastUsedAt: Date.now(), breakpoints: new Map(), functionBreakpoints: [], + instructionBreakpoints: [], + dataBreakpoints: [], output: "", outputBytes: 0, outputTruncated: false, @@ -609,6 +906,39 @@ export class DapSessionManager { needsConfigurationDone: false, configurationDoneSent: false, }; + client.onReverseRequest("runInTerminal", async rawArgs => { + const args = (rawArgs ?? {}) as DapRunInTerminalArguments; + if (!Array.isArray(args.args) || args.args.length === 0) { + throw new Error("runInTerminal request did not include a command"); + } + const env = Object.fromEntries( + Object.entries(args.env ?? {}).filter((entry): entry is [string, string] => entry[1] !== null), + ); + const proc = ptree.spawn(args.args, { + cwd: args.cwd ?? session.cwd, + stdin: "pipe", + env: { + ...Bun.env, + ...NON_INTERACTIVE_ENV, + ...env, + }, + detached: true, + }); + return { processId: proc.pid } satisfies DapRunInTerminalResponse; + }); + client.onReverseRequest("startDebugging", async rawArgs => { + const startArgs = (rawArgs ?? {}) as Partial; + const request = startArgs.request === "attach" ? "attach" : "launch"; + const configuration = + startArgs.configuration && typeof startArgs.configuration === "object" ? startArgs.configuration : {}; + logger.debug("Adapter requested child debug session", { + adapter: session.adapter.name, + sessionId: session.id, + request, + name: typeof configuration.name === "string" ? configuration.name : undefined, + }); + return {}; + }); client.onEvent("output", body => { truncateOutput(session, (body as DapOutputEventBody | undefined)?.output ?? ""); }); @@ -652,7 +982,9 @@ export class DapSessionManager { linesStartAt1: true, columnsStartAt1: true, pathFormat: "path", - supportsRunInTerminalRequest: false, + supportsRunInTerminalRequest: true, + supportsStartDebuggingRequest: true, + supportsMemoryReferences: true, supportsVariableType: true, supportsInvalidatedEvent: true, }; @@ -703,6 +1035,7 @@ export class DapSessionManager { if (!frame) return; session.stop.frameId = frame.id; session.stop.frameName = frame.name; + session.stop.instructionPointerReference = frame.instructionPointerReference; session.stop.source = frame.source; session.stop.line = frame.line; session.stop.column = frame.column; @@ -852,6 +1185,36 @@ export class DapSessionManager { })); } + #mapInstructionBreakpoints( + input: DapInstructionBreakpoint[], + responseBreakpoints: DapBreakpoint[] | undefined, + ): DapInstructionBreakpointRecord[] { + return input.map((entry, index) => ({ + instructionReference: responseBreakpoints?.[index]?.instructionReference ?? entry.instructionReference, + offset: responseBreakpoints?.[index]?.offset ?? entry.offset, + condition: entry.condition, + hitCondition: entry.hitCondition, + id: responseBreakpoints?.[index]?.id, + verified: responseBreakpoints?.[index]?.verified ?? false, + message: responseBreakpoints?.[index]?.message, + })); + } + + #mapDataBreakpoints( + input: DapDataBreakpoint[], + responseBreakpoints: DapBreakpoint[] | undefined, + ): DapDataBreakpointRecord[] { + return input.map((entry, index) => ({ + dataId: entry.dataId, + accessType: entry.accessType, + condition: entry.condition, + hitCondition: entry.hitCondition, + id: responseBreakpoints?.[index]?.id, + verified: responseBreakpoints?.[index]?.verified ?? false, + message: responseBreakpoints?.[index]?.message, + })); + } + #touchActiveSession(): DapSession { const session = this.#getActiveSessionOrThrow(); session.lastUsedAt = Date.now(); diff --git a/packages/coding-agent/src/dap/types.ts b/packages/coding-agent/src/dap/types.ts index b1ffbf5ee..eb4659de9 100644 --- a/packages/coding-agent/src/dap/types.ts +++ b/packages/coding-agent/src/dap/types.ts @@ -86,6 +86,7 @@ export interface DapInitializeArguments { supportsVariableType?: boolean; supportsVariablePaging?: boolean; supportsRunInTerminalRequest?: boolean; + supportsStartDebuggingRequest?: boolean; supportsMemoryReferences?: boolean; supportsProgressReporting?: boolean; supportsInvalidatedEvent?: boolean; @@ -103,8 +104,15 @@ export interface DapCapabilities { supportsRestartRequest?: boolean; supportsCompletionsRequest?: boolean; supportsLogPoints?: boolean; + supportsDisassembleRequest?: boolean; + supportsReadMemoryRequest?: boolean; + supportsWriteMemoryRequest?: boolean; + supportsModulesRequest?: boolean; supportsLoadedSourcesRequest?: boolean; supportsExceptionInfoRequest?: boolean; + supportsInstructionBreakpoints?: boolean; + supportsDataBreakpoints?: boolean; + supportsSteppingGranularity?: boolean; supportsClipboardContext?: boolean; [key: string]: unknown; } @@ -151,6 +159,41 @@ export interface DapSetFunctionBreakpointsResponse { breakpoints: DapBreakpoint[]; } +export interface DapInstructionBreakpoint { + instructionReference: string; + offset?: number; + condition?: string; + hitCondition?: string; +} + +export interface DapSetInstructionBreakpointsArguments { + breakpoints: DapInstructionBreakpoint[]; +} + +export interface DapDataBreakpointInfoArguments { + variablesReference?: number; + name: string; + frameId?: number; +} + +export interface DapDataBreakpointInfoResponse { + dataId: string | null; + description: string; + accessTypes?: Array<"read" | "write" | "readWrite">; + canPersist?: boolean; +} + +export interface DapDataBreakpoint { + dataId: string; + accessType?: "read" | "write" | "readWrite"; + condition?: string; + hitCondition?: string; +} + +export interface DapSetDataBreakpointsArguments { + breakpoints: DapDataBreakpoint[]; +} + export interface DapContinueArguments { threadId: number; singleThread?: boolean; @@ -254,6 +297,81 @@ export interface DapVariablesResponse { variables: DapVariable[]; } +export interface DapDisassembleArguments { + memoryReference: string; + offset?: number; + instructionOffset?: number; + instructionCount: number; + resolveSymbols?: boolean; +} + +export interface DapDisassembledInstruction { + address: string; + instructionBytes?: string; + instruction: string; + symbol?: string; + location?: DapSource; + line?: number; + column?: number; + endLine?: number; + endColumn?: number; +} + +export interface DapDisassembleResponse { + instructions: DapDisassembledInstruction[]; +} + +export interface DapReadMemoryArguments { + memoryReference: string; + offset?: number; + count: number; +} + +export interface DapReadMemoryResponse { + address: string; + unreadableBytes?: number; + data?: string; +} + +export interface DapWriteMemoryArguments { + memoryReference: string; + offset?: number; + data: string; + allowPartial?: boolean; +} + +export interface DapWriteMemoryResponse { + offset?: number; + bytesWritten?: number; +} + +export interface DapModule { + id: number | string; + name: string; + path?: string; + isOptimized?: boolean; + isUserCode?: boolean; + version?: string; + symbolStatus?: string; + symbolFilePath?: string; + dateTimeStamp?: string; + addressRange?: string; +} + +export interface DapModulesArguments { + startModule?: number; + moduleCount?: number; +} + +export interface DapModulesResponse { + modules: DapModule[]; + totalModules?: number; +} + +export interface DapLoadedSourcesResponse { + sources: DapSource[]; +} + export interface DapEvaluateArguments { expression: string; frameId?: number; @@ -321,6 +439,24 @@ export interface DapTerminatedEventBody { export interface DapInitializedEventBody {} +export interface DapRunInTerminalArguments { + kind?: "integrated" | "external"; + title?: string; + cwd?: string; + args: string[]; + env?: Record; +} + +export interface DapRunInTerminalResponse { + processId?: number; + shellProcessId?: number; +} + +export interface DapStartDebuggingArguments { + request: "launch" | "attach"; + configuration: Record; +} + export interface DapPendingRequest { resolve: (body: unknown) => void; reject: (error: Error) => void; @@ -375,6 +511,26 @@ export interface DapBreakpointRecord { message?: string; } +export interface DapInstructionBreakpointRecord { + id?: number; + verified: boolean; + instructionReference: string; + offset?: number; + condition?: string; + hitCondition?: string; + message?: string; +} + +export interface DapDataBreakpointRecord { + id?: number; + verified: boolean; + dataId: string; + accessType?: "read" | "write" | "readWrite"; + condition?: string; + hitCondition?: string; + message?: string; +} + export interface DapFunctionBreakpointRecord { id?: number; verified: boolean; @@ -390,6 +546,7 @@ export interface DapStopLocation { description?: string; text?: string; frameName?: string; + instructionPointerReference?: string; source?: DapSource; line?: number; column?: number; @@ -408,6 +565,7 @@ export interface DapSessionSummary { stopReason?: string; stopDescription?: string; frameName?: string; + instructionPointerReference?: string; source?: DapSource; line?: number; column?: number; diff --git a/packages/coding-agent/src/lsp/config.ts b/packages/coding-agent/src/lsp/config.ts index 782f4c00b..282671ec8 100644 --- a/packages/coding-agent/src/lsp/config.ts +++ b/packages/coding-agent/src/lsp/config.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { isRecord, logger } from "@oh-my-pi/pi-utils"; +import { $which, isRecord, logger } from "@oh-my-pi/pi-utils"; import { YAML } from "bun"; import { getConfigDirPaths } from "../config"; import { getPreloadedPluginRoots } from "../discovery/helpers"; @@ -217,7 +217,7 @@ export function resolveCommand(command: string, cwd: string): string | null { } // Fall back to $PATH - return Bun.which(command); + return $which(command); } /** diff --git a/packages/coding-agent/src/tools/debug.ts b/packages/coding-agent/src/tools/debug.ts index 2a23bcd4d..e74ddb350 100644 --- a/packages/coding-agent/src/tools/debug.ts +++ b/packages/coding-agent/src/tools/debug.ts @@ -11,12 +11,19 @@ import { type Static, Type } from "@sinclair/typebox"; import { renderPromptTemplate } from "../config/prompt-templates"; import { type DapBreakpointRecord, + type DapCapabilities, type DapContinueOutcome, + type DapDataBreakpointInfoResponse, + type DapDataBreakpointRecord, + type DapDisassembledInstruction, type DapEvaluateArguments, type DapEvaluateResponse, type DapFunctionBreakpointRecord, + type DapInstructionBreakpointRecord, + type DapModule, type DapScope, type DapSessionSummary, + type DapSource, type DapStackFrame, type DapThread, type DapVariable, @@ -51,6 +58,11 @@ const debugSchema = Type.Object({ "attach", "set_breakpoint", "remove_breakpoint", + "set_instruction_breakpoint", + "remove_instruction_breakpoint", + "data_breakpoint_info", + "set_data_breakpoint", + "remove_data_breakpoint", "continue", "step_over", "step_in", @@ -61,6 +73,12 @@ const debugSchema = Type.Object({ "threads", "scopes", "variables", + "disassemble", + "read_memory", + "write_memory", + "modules", + "loaded_sources", + "custom_request", "output", "terminate", "sessions", @@ -74,7 +92,9 @@ const debugSchema = Type.Object({ file: Type.Optional(Type.String({ description: "Source file for source breakpoints" })), line: Type.Optional(Type.Number({ description: "1-indexed source line for source breakpoints" })), function: Type.Optional(Type.String({ description: "Function name for function breakpoints" })), + name: Type.Optional(Type.String({ description: "Variable or data name for data breakpoint info" })), condition: Type.Optional(Type.String({ description: "Breakpoint condition expression" })), + hit_condition: Type.Optional(Type.String({ description: "Breakpoint hit condition expression" })), expression: Type.Optional(Type.String({ description: "Expression to evaluate in debugger context" })), context: Type.Optional(Type.String({ description: "Evaluate context (watch, repl, hover, variables, clipboard)" })), frame_id: Type.Optional(Type.Number({ description: "Stack frame ID for scopes/evaluate" })), @@ -84,6 +104,29 @@ const debugSchema = Type.Object({ port: Type.Optional(Type.Number({ description: "Port for remote attach when adapter supports it" })), host: Type.Optional(Type.String({ description: "Host for remote attach when adapter supports it" })), levels: Type.Optional(Type.Number({ description: "Maximum stack frames to fetch" })), + memory_reference: Type.Optional(Type.String({ description: "Memory reference or address" })), + instruction_reference: Type.Optional( + Type.String({ description: "Instruction address/reference for instruction breakpoints" }), + ), + instruction_count: Type.Optional(Type.Number({ description: "Number of instructions to disassemble" })), + instruction_offset: Type.Optional(Type.Number({ description: "Instruction offset for disassembly" })), + count: Type.Optional(Type.Number({ description: "Number of bytes to read from memory" })), + data: Type.Optional(Type.String({ description: "Base64-encoded memory payload for write_memory" })), + data_id: Type.Optional(Type.String({ description: "DAP data breakpoint identifier" })), + access_type: Type.Optional( + StringEnum(["read", "write", "readWrite"], { description: "Data breakpoint access type" }), + ), + command: Type.Optional(Type.String({ description: "Custom DAP request command" })), + arguments: Type.Optional( + Type.Record(Type.String(), Type.Any(), { + description: "Arguments object for custom_request", + }), + ), + offset: Type.Optional(Type.Number({ description: "Memory or instruction offset" })), + resolve_symbols: Type.Optional(Type.Boolean({ description: "Resolve symbols during disassembly" })), + allow_partial: Type.Optional(Type.Boolean({ description: "Allow partial writes for write_memory" })), + start_module: Type.Optional(Type.Number({ description: "Modules request start index" })), + module_count: Type.Optional(Type.Number({ description: "Maximum modules to fetch" })), timeout: Type.Optional(Type.Number({ description: "Per-request timeout in seconds" })), }); @@ -99,9 +142,20 @@ interface DebugToolDetails { threads?: DapThread[]; scopes?: DapScope[]; variables?: DapVariable[]; + sources?: DapSource[]; + modules?: DapModule[]; evaluation?: DapEvaluateResponse; breakpoints?: DapBreakpointRecord[]; functionBreakpoints?: DapFunctionBreakpointRecord[]; + instructionBreakpoints?: DapInstructionBreakpointRecord[]; + dataBreakpoints?: DapDataBreakpointRecord[]; + dataBreakpointInfo?: DapDataBreakpointInfoResponse; + disassembly?: DapDisassembledInstruction[]; + memoryAddress?: string; + memoryData?: string; + unreadableBytes?: number; + bytesWritten?: number; + customBody?: unknown; output?: string; adapter?: string; state?: DapContinueOutcome["state"]; @@ -126,6 +180,9 @@ function formatSessionSnapshot(snapshot: DapSessionSummary): string[] { if (snapshot.program) lines.push(`Program: ${snapshot.program}`); if (snapshot.stopReason) lines.push(`Stop reason: ${snapshot.stopReason}`); if (snapshot.frameName) lines.push(`Frame: ${snapshot.frameName}`); + if (snapshot.instructionPointerReference) { + lines.push(`Instruction pointer: ${snapshot.instructionPointerReference}`); + } const location = formatLocation(snapshot); if (location) lines.push(`Location: ${location}`); if (snapshot.needsConfigurationDone) { @@ -218,6 +275,160 @@ function formatVariables(variables: DapVariable[]): string { return lines.join("\n"); } +function formatSourceLabel(source: DapSource | undefined, line?: number, column?: number): string | null { + if (!source?.path && !source?.name) { + return null; + } + const base = source.path ?? source.name ?? ""; + if (line === undefined) { + return base; + } + return `${base}:${line}${column !== undefined ? `:${column}` : ""}`; +} + +function formatDisassembly(instructions: DapDisassembledInstruction[]): string { + const lines = ["Disassembly:"]; + if (instructions.length === 0) { + lines.push("(empty)"); + return lines.join("\n"); + } + const addressWidth = Math.max(...instructions.map(instruction => instruction.address.length)); + const bytesWidth = Math.max(...instructions.map(instruction => instruction.instructionBytes?.length ?? 0), 2); + for (const instruction of instructions) { + const location = formatSourceLabel(instruction.location, instruction.line, instruction.column); + const parts = [ + instruction.address.padEnd(addressWidth), + (instruction.instructionBytes ?? "").padEnd(bytesWidth), + instruction.instruction, + ]; + if (instruction.symbol) { + parts.push(`<${instruction.symbol}>`); + } + if (location) { + parts.push(`[${location}]`); + } + lines.push( + parts + .filter(part => part.length > 0) + .join(" ") + .trimEnd(), + ); + } + return lines.join("\n"); +} + +function formatMemoryRead(address: string, data: string | undefined, unreadableBytes?: number): string { + const lines = [`Memory at ${address}:`]; + const buffer = data ? Buffer.from(data, "base64") : Buffer.alloc(0); + if (buffer.length === 0) { + lines.push("(no readable bytes)"); + } else { + for (let offset = 0; offset < buffer.length; offset += 16) { + const chunk = buffer.subarray(offset, offset + 16); + const hex = Array.from(chunk, byte => byte.toString(16).padStart(2, "0")).join(" "); + const ascii = Array.from(chunk, byte => (byte >= 32 && byte < 127 ? String.fromCharCode(byte) : ".")).join(""); + lines.push( + `${(offset === 0 ? address : `+0x${offset.toString(16)}`).padEnd(18)} ${hex.padEnd(47)} |${ascii}|`, + ); + } + } + if (unreadableBytes !== undefined && unreadableBytes > 0) { + lines.push(`Unreadable bytes: ${unreadableBytes}`); + } + return lines.join("\n"); +} + +function formatTable(headers: string[], rows: string[][]): string { + const widths = headers.map((header, index) => + Math.max(header.length, ...rows.map(row => (row[index] ?? "").length)), + ); + const formatRow = (row: string[]) => row.map((cell, index) => (cell ?? "").padEnd(widths[index])).join(" "); + return [formatRow(headers), formatRow(widths.map(width => "-".repeat(width))), ...rows.map(formatRow)].join("\n"); +} + +function formatModules(modules: DapModule[]): string { + if (modules.length === 0) { + return "Modules:\n(none)"; + } + return [ + "Modules:", + formatTable( + ["ID", "Name", "Path", "Symbols", "Range"], + modules.map(module => [ + String(module.id), + module.name, + module.path ?? "", + module.symbolStatus ?? "", + module.addressRange ?? "", + ]), + ), + ].join("\n"); +} + +function formatLoadedSources(sources: DapSource[]): string { + const lines = ["Loaded sources:"]; + if (sources.length === 0) { + lines.push("(none)"); + return lines.join("\n"); + } + for (const source of sources) { + const label = source.path ?? source.name ?? ""; + lines.push(`- ${label}${source.sourceReference !== undefined ? ` [ref=${source.sourceReference}]` : ""}`); + } + return lines.join("\n"); +} + +function formatInstructionBreakpoints(breakpoints: DapInstructionBreakpointRecord[]): string { + const lines = ["Instruction breakpoints:"]; + if (breakpoints.length === 0) { + lines.push("(none)"); + return lines.join("\n"); + } + for (const breakpoint of breakpoints) { + const location = `${breakpoint.instructionReference}${breakpoint.offset !== undefined ? `+${breakpoint.offset}` : ""}`; + lines.push( + `- ${location}: ${breakpoint.verified ? "verified" : "pending"}${breakpoint.condition ? ` if ${breakpoint.condition}` : ""}${breakpoint.hitCondition ? ` after ${breakpoint.hitCondition}` : ""}${breakpoint.message ? ` (${breakpoint.message})` : ""}`, + ); + } + return lines.join("\n"); +} + +function formatDataBreakpointInfo(info: DapDataBreakpointInfoResponse): string { + const lines = [`Data breakpoint info: ${info.description}`]; + lines.push(`Data ID: ${info.dataId ?? "(not available)"}`); + if (info.accessTypes && info.accessTypes.length > 0) { + lines.push(`Access types: ${info.accessTypes.join(", ")}`); + } + if (info.canPersist !== undefined) { + lines.push(`Persistent: ${info.canPersist ? "yes" : "no"}`); + } + return lines.join("\n"); +} + +function formatDataBreakpoints(breakpoints: DapDataBreakpointRecord[]): string { + const lines = ["Data breakpoints:"]; + if (breakpoints.length === 0) { + lines.push("(none)"); + return lines.join("\n"); + } + for (const breakpoint of breakpoints) { + lines.push( + `- ${breakpoint.dataId}: ${breakpoint.verified ? "verified" : "pending"}${breakpoint.accessType ? ` (${breakpoint.accessType})` : ""}${breakpoint.condition ? ` if ${breakpoint.condition}` : ""}${breakpoint.hitCondition ? ` after ${breakpoint.hitCondition}` : ""}${breakpoint.message ? ` (${breakpoint.message})` : ""}`, + ); + } + return lines.join("\n"); +} + +function formatCustomResponse(command: string, body: unknown): string { + let serialized = ""; + try { + serialized = JSON.stringify(body, null, 2) ?? "null"; + } catch { + serialized = Bun.inspect(body); + } + return `${command} response:\n${serialized}`; +} + function formatSessions(sessions: DapSessionSummary[]): string { if (sessions.length === 0) { return "No debug sessions."; @@ -273,6 +484,35 @@ function getConfiguredAdapters(cwd: string): string { interface DebugRenderArgs extends Partial {} +function getActiveSessionSnapshot(): DapSessionSummary { + const snapshot = dapSessionManager.getActiveSession(); + if (!snapshot) { + throw new ToolError("No active debug session. Launch or attach first."); + } + return snapshot; +} + +function requireCapability(capability: keyof DapCapabilities, description: string): DapSessionSummary { + const snapshot = getActiveSessionSnapshot(); + if (dapSessionManager.getCapabilities()?.[capability] !== true) { + throw new ToolError(`Current adapter does not support ${description}`); + } + return snapshot; +} + +function resolveDisassemblyReference(memoryReference: string | undefined): string { + if (memoryReference) { + return memoryReference; + } + const snapshot = getActiveSessionSnapshot(); + if (snapshot.instructionPointerReference) { + return snapshot.instructionPointerReference; + } + throw new ToolError( + "disassemble requires memory_reference unless the current stop location has an instruction pointer reference", + ); +} + function summarizeDebugCall(args: DebugRenderArgs): string { const action = args.action ? args.action.replaceAll("_", " ") : "request"; if (args.program) { @@ -287,6 +527,21 @@ function summarizeDebugCall(args: DebugRenderArgs): string { if (args.expression) { return `${action} ${truncateToWidth(args.expression, TRUNCATE_LENGTHS.TITLE)}`; } + if (args.command) { + return `${action} ${truncateToWidth(args.command, TRUNCATE_LENGTHS.TITLE)}`; + } + if (args.memory_reference) { + return `${action} ${truncateToWidth(args.memory_reference, TRUNCATE_LENGTHS.TITLE)}`; + } + if (args.instruction_reference) { + return `${action} ${truncateToWidth(args.instruction_reference, TRUNCATE_LENGTHS.TITLE)}`; + } + if (args.data_id) { + return `${action} ${truncateToWidth(args.data_id, TRUNCATE_LENGTHS.TITLE)}`; + } + if (args.name) { + return `${action} ${truncateToWidth(args.name, TRUNCATE_LENGTHS.TITLE)}`; + } return action; } @@ -470,6 +725,85 @@ export class DebugTool implements AgentTool resolved binary path or null (not found) +const toolCache = new Map(); + +// Extension: additional cache policy for tool path lookup +export interface WhichOptions extends Bun.WhichOptions { + /** Controls cache usage. + * + * - "none": disables cache, always perform a new lookup + * - "fresh": always update cache (default) + * - "ro": read-only, serves from cache if present, but doesn't write + * - "cached": prefers cache if present, otherwise lookup and populate + */ + cache?: "none" | "fresh" | "ro" | "cached"; +} + +// Darwin-specific "which" shim: consult extra Xcode locations, then fallback to xcrun +function darwinWhich(command: string, _options?: Bun.WhichOptions): string | null { + const regular = Bun.which(command); + if (regular) return regular; + for (const toolPath of MACOS_TOOL_PATHS) { + const candidate = path.join(toolPath, command); + if (fs.existsSync(candidate)) return candidate; + } + const xcrun = Bun.which("xcrun"); + if (!xcrun) return null; + const result = Bun.spawnSync([xcrun, "-f", command], { + stdout: "pipe", + stderr: "ignore", + }); + if (result.exitCode !== 0) return null; + // xcrun -f returns path or empty string on failure + const resolved = Buffer.from(result.stdout).toString("utf-8").trim(); + const candidate = resolved.length > 0 && fs.existsSync(resolved) ? resolved : null; + return candidate; +} + +// Which function that incorporates Darwin Xcode logic if platform reports as 'darwin' +export const whichFresh = os.platform() === "darwin" ? darwinWhich : Bun.which; + +// Derive stable cache key from command and lookup options +function cacheKey(command: string, options?: Bun.WhichOptions): string | bigint { + if (!options) return command; + if (!options.cwd && !options.PATH) return command; + let h = Bun.hash.xxHash64(command); + if (options.cwd) h = Bun.hash.xxHash64(options.cwd, h); + if (options.PATH) h = Bun.hash.xxHash64(options.PATH, h); + return h; +} + +/** + * Locate binary on PATH (with flexible caching). + * + * @param command - Binary name to resolve + * @param options - Bun.WhichOptions plus `cache` control + * @returns Filesystem path if found, else null + */ +export function $which(command: string, options?: WhichOptions): string | null { + const cachePolicy = options?.cache ?? "fresh"; + let key: string | bigint | undefined; + + if (cachePolicy !== "none") { + key = cacheKey(command, options); + if (cachePolicy !== "fresh") { + const cached = toolCache.get(key); + if (cached !== undefined) return cached; + } + } + + const result = whichFresh(command, options); + if (key != null && cachePolicy !== "ro") { + toolCache.set(key, result); + } + return result; +}