@oh-my-pi/pi-coding-agent 13.18.0 → 14.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/CHANGELOG.md +316 -1
  2. package/package.json +86 -24
  3. package/scripts/format-prompts.ts +2 -2
  4. package/src/autoresearch/apply-contract-to-state.ts +24 -0
  5. package/src/autoresearch/contract.ts +0 -44
  6. package/src/autoresearch/dashboard.ts +1 -2
  7. package/src/autoresearch/git.ts +116 -30
  8. package/src/autoresearch/helpers.ts +49 -0
  9. package/src/autoresearch/index.ts +28 -187
  10. package/src/autoresearch/prompt.md +26 -9
  11. package/src/autoresearch/state.ts +0 -6
  12. package/src/autoresearch/tools/init-experiment.ts +202 -117
  13. package/src/autoresearch/tools/log-experiment.ts +123 -178
  14. package/src/autoresearch/tools/run-experiment.ts +48 -10
  15. package/src/autoresearch/types.ts +2 -2
  16. package/src/capability/index.ts +4 -2
  17. package/src/cli/file-processor.ts +3 -3
  18. package/src/cli/grep-cli.ts +8 -8
  19. package/src/cli/grievances-cli.ts +78 -0
  20. package/src/cli/read-cli.ts +67 -0
  21. package/src/cli/setup-cli.ts +4 -4
  22. package/src/cli/update-cli.ts +3 -3
  23. package/src/cli.ts +2 -0
  24. package/src/commands/grep.ts +6 -1
  25. package/src/commands/grievances.ts +20 -0
  26. package/src/commands/read.ts +33 -0
  27. package/src/commit/agentic/agent.ts +5 -8
  28. package/src/commit/agentic/index.ts +22 -26
  29. package/src/commit/agentic/tools/analyze-file.ts +3 -3
  30. package/src/commit/agentic/tools/git-file-diff.ts +3 -6
  31. package/src/commit/agentic/tools/git-hunk.ts +3 -3
  32. package/src/commit/agentic/tools/git-overview.ts +6 -9
  33. package/src/commit/agentic/tools/index.ts +6 -8
  34. package/src/commit/agentic/tools/propose-commit.ts +4 -7
  35. package/src/commit/agentic/tools/recent-commits.ts +3 -3
  36. package/src/commit/agentic/tools/split-commit.ts +4 -4
  37. package/src/commit/agentic/validation.ts +1 -1
  38. package/src/commit/analysis/conventional.ts +4 -4
  39. package/src/commit/analysis/summary.ts +3 -3
  40. package/src/commit/changelog/generate.ts +4 -4
  41. package/src/commit/changelog/index.ts +5 -9
  42. package/src/commit/map-reduce/map-phase.ts +4 -4
  43. package/src/commit/map-reduce/reduce-phase.ts +4 -4
  44. package/src/commit/pipeline.ts +13 -16
  45. package/src/config/keybindings.ts +7 -6
  46. package/src/config/prompt-templates.ts +44 -226
  47. package/src/config/resolve-config-value.ts +4 -2
  48. package/src/config/settings-schema.ts +98 -2
  49. package/src/config/settings.ts +25 -26
  50. package/src/dap/client.ts +674 -0
  51. package/src/dap/config.ts +150 -0
  52. package/src/dap/defaults.json +211 -0
  53. package/src/dap/index.ts +4 -0
  54. package/src/dap/session.ts +1255 -0
  55. package/src/dap/types.ts +600 -0
  56. package/src/debug/log-viewer.ts +3 -2
  57. package/src/discovery/builtin.ts +1 -2
  58. package/src/discovery/codex.ts +2 -2
  59. package/src/discovery/github.ts +2 -1
  60. package/src/discovery/helpers.ts +2 -2
  61. package/src/discovery/opencode.ts +2 -2
  62. package/src/edit/diff.ts +818 -0
  63. package/src/edit/index.ts +309 -0
  64. package/src/edit/line-hash.ts +67 -0
  65. package/src/edit/modes/chunk.ts +454 -0
  66. package/src/{patch → edit/modes}/hashline.ts +741 -361
  67. package/src/{patch/applicator.ts → edit/modes/patch.ts} +420 -117
  68. package/src/{patch/fuzzy.ts → edit/modes/replace.ts} +519 -197
  69. package/src/{patch → edit}/normalize.ts +97 -76
  70. package/src/{patch/shared.ts → edit/renderer.ts} +181 -108
  71. package/src/exec/bash-executor.ts +4 -2
  72. package/src/exec/idle-timeout-watchdog.ts +126 -0
  73. package/src/exec/non-interactive-env.ts +5 -0
  74. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +6 -18
  75. package/src/extensibility/custom-commands/bundled/review/index.ts +45 -43
  76. package/src/extensibility/custom-commands/loader.ts +1 -2
  77. package/src/extensibility/custom-tools/loader.ts +34 -11
  78. package/src/extensibility/custom-tools/types.ts +1 -1
  79. package/src/extensibility/extensions/loader.ts +9 -4
  80. package/src/extensibility/extensions/runner.ts +24 -1
  81. package/src/extensibility/extensions/types.ts +4 -2
  82. package/src/extensibility/hooks/loader.ts +5 -6
  83. package/src/extensibility/hooks/types.ts +2 -2
  84. package/src/extensibility/plugins/doctor.ts +2 -1
  85. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -57
  86. package/src/extensibility/plugins/marketplace/source-resolver.ts +4 -4
  87. package/src/extensibility/slash-commands.ts +3 -7
  88. package/src/index.ts +3 -1
  89. package/src/internal-urls/docs-index.generated.ts +11 -11
  90. package/src/ipy/executor.ts +58 -17
  91. package/src/ipy/gateway-coordinator.ts +6 -4
  92. package/src/ipy/kernel.ts +45 -22
  93. package/src/ipy/runtime.ts +2 -2
  94. package/src/lsp/client.ts +7 -4
  95. package/src/lsp/clients/lsp-linter-client.ts +4 -4
  96. package/src/lsp/config.ts +2 -2
  97. package/src/lsp/defaults.json +688 -154
  98. package/src/lsp/index.ts +234 -45
  99. package/src/lsp/lspmux.ts +2 -2
  100. package/src/lsp/startup-events.ts +13 -0
  101. package/src/lsp/types.ts +12 -1
  102. package/src/lsp/utils.ts +8 -1
  103. package/src/main.ts +125 -47
  104. package/src/memories/index.ts +4 -5
  105. package/src/modes/acp/acp-agent.ts +563 -163
  106. package/src/modes/acp/acp-event-mapper.ts +9 -1
  107. package/src/modes/acp/acp-mode.ts +4 -2
  108. package/src/modes/components/agent-dashboard.ts +3 -4
  109. package/src/modes/components/diff.ts +6 -7
  110. package/src/modes/components/footer.ts +9 -29
  111. package/src/modes/components/hook-editor.ts +3 -3
  112. package/src/modes/components/hook-selector.ts +6 -1
  113. package/src/modes/components/read-tool-group.ts +6 -12
  114. package/src/modes/components/session-observer-overlay.ts +472 -0
  115. package/src/modes/components/settings-defs.ts +24 -0
  116. package/src/modes/components/status-line.ts +15 -61
  117. package/src/modes/components/tool-execution.ts +1 -1
  118. package/src/modes/components/welcome.ts +1 -1
  119. package/src/modes/controllers/btw-controller.ts +2 -2
  120. package/src/modes/controllers/command-controller.ts +4 -2
  121. package/src/modes/controllers/event-controller.ts +59 -2
  122. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  123. package/src/modes/controllers/input-controller.ts +15 -8
  124. package/src/modes/controllers/selector-controller.ts +26 -0
  125. package/src/modes/index.ts +20 -2
  126. package/src/modes/interactive-mode.ts +278 -69
  127. package/src/modes/rpc/host-tools.ts +186 -0
  128. package/src/modes/rpc/rpc-client.ts +178 -13
  129. package/src/modes/rpc/rpc-mode.ts +73 -3
  130. package/src/modes/rpc/rpc-types.ts +53 -1
  131. package/src/modes/session-observer-registry.ts +146 -0
  132. package/src/modes/shared.ts +0 -42
  133. package/src/modes/theme/theme.ts +80 -8
  134. package/src/modes/types.ts +4 -2
  135. package/src/modes/utils/keybinding-matchers.ts +9 -0
  136. package/src/prompts/system/custom-system-prompt.md +5 -0
  137. package/src/prompts/system/system-prompt.md +8 -1
  138. package/src/prompts/tools/chunk-edit.md +219 -0
  139. package/src/prompts/tools/debug.md +43 -0
  140. package/src/prompts/tools/grep.md +3 -0
  141. package/src/prompts/tools/lsp.md +5 -5
  142. package/src/prompts/tools/read-chunk.md +17 -0
  143. package/src/prompts/tools/read.md +19 -5
  144. package/src/sdk.ts +216 -165
  145. package/src/secrets/index.ts +1 -1
  146. package/src/secrets/obfuscator.ts +25 -17
  147. package/src/session/agent-session.ts +381 -286
  148. package/src/session/agent-storage.ts +12 -12
  149. package/src/session/compaction/branch-summarization.ts +3 -3
  150. package/src/session/compaction/compaction.ts +5 -6
  151. package/src/session/compaction/utils.ts +3 -3
  152. package/src/session/history-storage.ts +62 -19
  153. package/src/session/messages.ts +3 -3
  154. package/src/session/session-dump-format.ts +203 -0
  155. package/src/session/session-manager.ts +15 -5
  156. package/src/session/session-storage.ts +4 -2
  157. package/src/session/streaming-output.ts +1 -1
  158. package/src/session/tool-choice-queue.ts +213 -0
  159. package/src/slash-commands/builtin-registry.ts +56 -8
  160. package/src/ssh/connection-manager.ts +2 -2
  161. package/src/ssh/sshfs-mount.ts +5 -5
  162. package/src/stt/downloader.ts +4 -4
  163. package/src/stt/recorder.ts +4 -4
  164. package/src/stt/transcriber.ts +2 -2
  165. package/src/system-prompt.ts +25 -13
  166. package/src/task/agents.ts +5 -6
  167. package/src/task/commands.ts +2 -5
  168. package/src/task/executor.ts +32 -4
  169. package/src/task/index.ts +91 -82
  170. package/src/task/template.ts +2 -2
  171. package/src/task/types.ts +25 -0
  172. package/src/task/worktree.ts +131 -149
  173. package/src/tools/ask.ts +2 -3
  174. package/src/tools/ast-edit.ts +7 -7
  175. package/src/tools/ast-grep.ts +7 -7
  176. package/src/tools/auto-generated-guard.ts +36 -41
  177. package/src/tools/await-tool.ts +2 -2
  178. package/src/tools/bash.ts +5 -23
  179. package/src/tools/browser.ts +4 -5
  180. package/src/tools/calculator.ts +2 -3
  181. package/src/tools/cancel-job.ts +2 -2
  182. package/src/tools/checkpoint.ts +3 -3
  183. package/src/tools/debug.ts +1007 -0
  184. package/src/tools/exit-plan-mode.ts +3 -3
  185. package/src/tools/fetch.ts +67 -3
  186. package/src/tools/find.ts +4 -5
  187. package/src/tools/fs-cache-invalidation.ts +5 -0
  188. package/src/tools/gemini-image.ts +13 -5
  189. package/src/tools/gh.ts +130 -308
  190. package/src/tools/grep.ts +57 -9
  191. package/src/tools/index.ts +44 -22
  192. package/src/tools/inspect-image.ts +4 -4
  193. package/src/tools/output-meta.ts +1 -1
  194. package/src/tools/python.ts +19 -6
  195. package/src/tools/read.ts +211 -146
  196. package/src/tools/render-mermaid.ts +2 -3
  197. package/src/tools/render-utils.ts +20 -6
  198. package/src/tools/renderers.ts +3 -1
  199. package/src/tools/report-tool-issue.ts +80 -0
  200. package/src/tools/resolve.ts +70 -39
  201. package/src/tools/search-tool-bm25.ts +2 -2
  202. package/src/tools/ssh.ts +2 -2
  203. package/src/tools/todo-write.ts +2 -2
  204. package/src/tools/tool-timeouts.ts +1 -0
  205. package/src/tools/write.ts +5 -6
  206. package/src/tui/tree-list.ts +3 -1
  207. package/src/utils/clipboard.ts +80 -0
  208. package/src/utils/commit-message-generator.ts +2 -3
  209. package/src/utils/edit-mode.ts +49 -0
  210. package/src/utils/external-editor.ts +11 -5
  211. package/src/utils/file-display-mode.ts +6 -5
  212. package/src/utils/file-mentions.ts +8 -7
  213. package/src/utils/git.ts +1400 -0
  214. package/src/utils/image-loading.ts +98 -0
  215. package/src/utils/title-generator.ts +2 -3
  216. package/src/utils/tools-manager.ts +6 -6
  217. package/src/web/scrapers/choosealicense.ts +1 -1
  218. package/src/web/search/index.ts +3 -3
  219. package/src/web/search/render.ts +6 -4
  220. package/src/autoresearch/command-initialize.md +0 -34
  221. package/src/commit/git/errors.ts +0 -9
  222. package/src/commit/git/index.ts +0 -210
  223. package/src/commit/git/operations.ts +0 -54
  224. package/src/patch/diff.ts +0 -433
  225. package/src/patch/index.ts +0 -888
  226. package/src/patch/parser.ts +0 -532
  227. package/src/patch/types.ts +0 -292
  228. package/src/prompts/agents/oracle.md +0 -77
  229. package/src/tools/gh-cli.ts +0 -125
  230. package/src/tools/pending-action.ts +0 -49
  231. package/src/utils/child-process.ts +0 -88
  232. package/src/utils/frontmatter.ts +0 -117
  233. package/src/utils/image-input.ts +0 -274
  234. package/src/utils/mime.ts +0 -53
  235. package/src/utils/prompt-format.ts +0 -170
@@ -0,0 +1,1255 @@
1
+ import * as path from "node:path";
2
+ import { logger, ptree } from "@oh-my-pi/pi-utils";
3
+ import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
4
+ import { DapClient } from "./client";
5
+ import type {
6
+ DapAttachArguments,
7
+ DapAttachSessionOptions,
8
+ DapBreakpoint,
9
+ DapBreakpointRecord,
10
+ DapCapabilities,
11
+ DapContinueArguments,
12
+ DapContinueOutcome,
13
+ DapContinueResponse,
14
+ DapDataBreakpoint,
15
+ DapDataBreakpointInfoArguments,
16
+ DapDataBreakpointInfoResponse,
17
+ DapDataBreakpointRecord,
18
+ DapDisassembleArguments,
19
+ DapDisassembledInstruction,
20
+ DapDisassembleResponse,
21
+ DapEvaluateArguments,
22
+ DapEvaluateResponse,
23
+ DapExitedEventBody,
24
+ DapFunctionBreakpoint,
25
+ DapFunctionBreakpointRecord,
26
+ DapInitializeArguments,
27
+ DapInstructionBreakpoint,
28
+ DapInstructionBreakpointRecord,
29
+ DapLaunchArguments,
30
+ DapLaunchSessionOptions,
31
+ DapLoadedSourcesResponse,
32
+ DapModule,
33
+ DapModulesArguments,
34
+ DapModulesResponse,
35
+ DapOutputEventBody,
36
+ DapPauseArguments,
37
+ DapReadMemoryArguments,
38
+ DapReadMemoryResponse,
39
+ DapResolvedAdapter,
40
+ DapRunInTerminalArguments,
41
+ DapRunInTerminalResponse,
42
+ DapScopesArguments,
43
+ DapScopesResponse,
44
+ DapSessionStatus,
45
+ DapSessionSummary,
46
+ DapSetDataBreakpointsArguments,
47
+ DapSetInstructionBreakpointsArguments,
48
+ DapSource,
49
+ DapSourceBreakpoint,
50
+ DapStackFrame,
51
+ DapStackTraceArguments,
52
+ DapStackTraceResponse,
53
+ DapStartDebuggingArguments,
54
+ DapStepArguments,
55
+ DapStopLocation,
56
+ DapStoppedEventBody,
57
+ DapThread,
58
+ DapThreadsResponse,
59
+ DapVariablesArguments,
60
+ DapVariablesResponse,
61
+ DapWriteMemoryArguments,
62
+ DapWriteMemoryResponse,
63
+ } from "./types";
64
+
65
+ interface DapSession {
66
+ id: string;
67
+ adapter: DapResolvedAdapter;
68
+ cwd: string;
69
+ program?: string;
70
+ client: DapClient;
71
+ status: DapSessionStatus;
72
+ launchedAt: number;
73
+ lastUsedAt: number;
74
+ breakpoints: Map<string, DapBreakpointRecord[]>;
75
+ functionBreakpoints: DapFunctionBreakpointRecord[];
76
+ instructionBreakpoints: DapInstructionBreakpoint[];
77
+ dataBreakpoints: DapDataBreakpoint[];
78
+ output: string;
79
+ outputBytes: number;
80
+ outputTruncated: boolean;
81
+ stop: DapStopLocation;
82
+ threads: DapThread[];
83
+ lastStackFrames: DapStackFrame[];
84
+ exitCode?: number;
85
+ capabilities?: DapCapabilities;
86
+ initializedSeen: boolean;
87
+ needsConfigurationDone: boolean;
88
+ configurationDoneSent: boolean;
89
+ }
90
+
91
+ export interface DapOutputSnapshot {
92
+ snapshot: DapSessionSummary;
93
+ output: string;
94
+ }
95
+
96
+ const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
97
+ const CLEANUP_INTERVAL_MS = 30 * 1000;
98
+ const HEARTBEAT_INTERVAL_MS = 5 * 1000;
99
+ const MAX_OUTPUT_BYTES = 128 * 1024;
100
+ const STOP_CAPTURE_TIMEOUT_MS = 5_000;
101
+
102
+ function toErrorMessage(value: unknown): string {
103
+ if (value instanceof Error) return value.message;
104
+ return String(value);
105
+ }
106
+
107
+ function normalizePath(filePath: string): string {
108
+ return path.resolve(filePath);
109
+ }
110
+
111
+ function truncateOutput(session: DapSession, output: string): void {
112
+ if (!output) return;
113
+ session.output += output;
114
+ session.outputBytes += Buffer.byteLength(output, "utf-8");
115
+ while (Buffer.byteLength(session.output, "utf-8") > MAX_OUTPUT_BYTES) {
116
+ session.output = session.output.slice(Math.min(1024, session.output.length));
117
+ session.outputTruncated = true;
118
+ }
119
+ }
120
+
121
+ function summarizeBreakpointCount(breakpoints: Map<string, DapBreakpointRecord[]>): number {
122
+ let total = 0;
123
+ for (const entries of breakpoints.values()) {
124
+ total += entries.length;
125
+ }
126
+ return total;
127
+ }
128
+
129
+ function buildSummary(session: DapSession): DapSessionSummary {
130
+ return {
131
+ id: session.id,
132
+ adapter: session.adapter.name,
133
+ cwd: session.cwd,
134
+ program: session.program,
135
+ status: session.status,
136
+ launchedAt: new Date(session.launchedAt).toISOString(),
137
+ lastUsedAt: new Date(session.lastUsedAt).toISOString(),
138
+ threadId: session.stop.threadId,
139
+ frameId: session.stop.frameId,
140
+ stopReason: session.stop.reason,
141
+ stopDescription: session.stop.description ?? session.stop.text,
142
+ frameName: session.stop.frameName,
143
+ instructionPointerReference: session.stop.instructionPointerReference,
144
+ source: session.stop.source,
145
+ line: session.stop.line,
146
+ column: session.stop.column,
147
+ breakpointFiles: session.breakpoints.size,
148
+ breakpointCount: summarizeBreakpointCount(session.breakpoints),
149
+ functionBreakpointCount: session.functionBreakpoints.length,
150
+ outputBytes: session.outputBytes,
151
+ outputTruncated: session.outputTruncated,
152
+ exitCode: session.exitCode,
153
+ needsConfigurationDone: session.needsConfigurationDone && !session.configurationDoneSent,
154
+ };
155
+ }
156
+
157
+ async function raceAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
158
+ if (!signal) return promise;
159
+ if (signal.aborted) {
160
+ throw signal.reason instanceof Error ? signal.reason : new Error("Operation aborted");
161
+ }
162
+ const { promise: abortPromise, reject } = Promise.withResolvers<never>();
163
+ const onAbort = () => {
164
+ reject(signal.reason instanceof Error ? signal.reason : new Error("Operation aborted"));
165
+ };
166
+ signal.addEventListener("abort", onAbort, { once: true });
167
+ try {
168
+ return await Promise.race([promise, abortPromise]);
169
+ } finally {
170
+ signal.removeEventListener("abort", onAbort);
171
+ }
172
+ }
173
+
174
+ export class DapSessionManager {
175
+ #sessions = new Map<string, DapSession>();
176
+ #activeSessionId: string | null = null;
177
+ #cleanupTimer?: NodeJS.Timeout;
178
+ #nextId = 0;
179
+
180
+ constructor() {
181
+ this.#startCleanupTimer();
182
+ }
183
+
184
+ getActiveSession(): DapSessionSummary | null {
185
+ const session = this.#getActiveSessionOrNull();
186
+ return session ? buildSummary(session) : null;
187
+ }
188
+
189
+ listSessions(): DapSessionSummary[] {
190
+ return Array.from(this.#sessions.values()).map(buildSummary);
191
+ }
192
+
193
+ getCapabilities(): DapCapabilities | null {
194
+ return this.#getActiveSessionOrNull()?.capabilities ?? null;
195
+ }
196
+
197
+ async launch(
198
+ options: DapLaunchSessionOptions,
199
+ signal?: AbortSignal,
200
+ timeoutMs: number = 30_000,
201
+ ): Promise<DapSessionSummary> {
202
+ await this.#ensureLaunchSlot();
203
+ const client = await DapClient.spawn({ adapter: options.adapter, cwd: options.cwd });
204
+ const session = this.#registerSession(client, options.adapter, options.cwd, options.program);
205
+ try {
206
+ session.capabilities = await client.initialize(
207
+ this.#buildInitializeArguments(options.adapter),
208
+ signal,
209
+ timeoutMs,
210
+ );
211
+ session.needsConfigurationDone = session.capabilities.supportsConfigurationDoneRequest === true;
212
+ const launchArguments: DapLaunchArguments = {
213
+ ...options.adapter.launchDefaults,
214
+ program: options.program,
215
+ cwd: options.cwd,
216
+ args: options.args,
217
+ };
218
+ // Subscribe to stop events BEFORE launching so we don't miss
219
+ // stopOnEntry events that arrive before we start listening.
220
+ const initialStopPromise = this.#prepareStopOutcome(
221
+ session,
222
+ signal,
223
+ Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS),
224
+ );
225
+ // DAP spec: many adapters do not respond to launch until after
226
+ // configurationDone. Fire launch, complete the config handshake,
227
+ // then await the launch response.
228
+ const launchPromise = client.sendRequest("launch", launchArguments, signal, timeoutMs);
229
+ // Mark handled so a fast error response doesn't become an unhandled
230
+ // rejection while we await the config handshake. The actual error
231
+ // still propagates when we await launchPromise below.
232
+ launchPromise.catch(() => {});
233
+ await this.#completeConfigurationHandshake(session, signal, timeoutMs);
234
+ await launchPromise;
235
+ // Try to capture initial stopped state (e.g. stopOnEntry).
236
+ // Timeout is acceptable — the program may simply be running.
237
+ try {
238
+ await raceAbort(initialStopPromise, signal);
239
+ if (session.status === "stopped") {
240
+ await this.#fetchTopFrame(session, signal, Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS));
241
+ }
242
+ } catch {
243
+ if (session.initializedSeen && session.status === "launching") {
244
+ session.status = session.configurationDoneSent ? "running" : "configuring";
245
+ }
246
+ }
247
+ return buildSummary(session);
248
+ } catch (error) {
249
+ await this.#disposeSession(session);
250
+ throw error;
251
+ }
252
+ }
253
+
254
+ async attach(
255
+ options: DapAttachSessionOptions,
256
+ signal?: AbortSignal,
257
+ timeoutMs: number = 30_000,
258
+ ): Promise<DapSessionSummary> {
259
+ await this.#ensureLaunchSlot();
260
+ const client = await DapClient.spawn({ adapter: options.adapter, cwd: options.cwd });
261
+ const session = this.#registerSession(client, options.adapter, options.cwd);
262
+ try {
263
+ session.capabilities = await client.initialize(
264
+ this.#buildInitializeArguments(options.adapter),
265
+ signal,
266
+ timeoutMs,
267
+ );
268
+ session.needsConfigurationDone = session.capabilities.supportsConfigurationDoneRequest === true;
269
+ const attachArguments: DapAttachArguments = {
270
+ ...options.adapter.attachDefaults,
271
+ cwd: options.cwd,
272
+ ...(options.pid !== undefined ? { pid: options.pid, processId: options.pid } : {}),
273
+ ...(options.port !== undefined ? { port: options.port } : {}),
274
+ ...(options.host ? { host: options.host } : {}),
275
+ };
276
+ const initialStopPromise = this.#prepareStopOutcome(
277
+ session,
278
+ signal,
279
+ Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS),
280
+ );
281
+ const attachPromise = client.sendRequest("attach", attachArguments, signal, timeoutMs);
282
+ attachPromise.catch(() => {});
283
+ await this.#completeConfigurationHandshake(session, signal, timeoutMs);
284
+ await attachPromise;
285
+ try {
286
+ await raceAbort(initialStopPromise, signal);
287
+ if (session.status === "stopped") {
288
+ await this.#fetchTopFrame(session, signal, Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS));
289
+ }
290
+ } catch {
291
+ if (session.initializedSeen && session.status === "launching") {
292
+ session.status = session.configurationDoneSent ? "running" : "configuring";
293
+ }
294
+ }
295
+ return buildSummary(session);
296
+ } catch (error) {
297
+ await this.#disposeSession(session);
298
+ throw error;
299
+ }
300
+ }
301
+
302
+ async setBreakpoint(
303
+ file: string,
304
+ line: number,
305
+ condition?: string,
306
+ signal?: AbortSignal,
307
+ timeoutMs: number = 30_000,
308
+ ) {
309
+ const session = this.#touchActiveSession();
310
+ const sourcePath = normalizePath(file);
311
+ const current = [...(session.breakpoints.get(sourcePath) ?? [])];
312
+ const deduped = current.filter(entry => entry.line !== line);
313
+ deduped.push({ verified: false, line, condition });
314
+ deduped.sort((left, right) => left.line - right.line);
315
+ const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
316
+ session,
317
+ "setBreakpoints",
318
+ {
319
+ source: { path: sourcePath, name: path.basename(sourcePath) },
320
+ breakpoints: deduped.map<DapSourceBreakpoint>(entry => ({
321
+ line: entry.line,
322
+ ...(entry.condition ? { condition: entry.condition } : {}),
323
+ })),
324
+ },
325
+ signal,
326
+ timeoutMs,
327
+ );
328
+ session.breakpoints.set(sourcePath, this.#mapSourceBreakpoints(deduped, response?.breakpoints));
329
+ return {
330
+ snapshot: buildSummary(session),
331
+ breakpoints: session.breakpoints.get(sourcePath) ?? [],
332
+ sourcePath,
333
+ };
334
+ }
335
+
336
+ async removeBreakpoint(file: string, line: number, signal?: AbortSignal, timeoutMs: number = 30_000) {
337
+ const session = this.#touchActiveSession();
338
+ const sourcePath = normalizePath(file);
339
+ const current = [...(session.breakpoints.get(sourcePath) ?? [])].filter(entry => entry.line !== line);
340
+ const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
341
+ session,
342
+ "setBreakpoints",
343
+ {
344
+ source: { path: sourcePath, name: path.basename(sourcePath) },
345
+ breakpoints: current.map<DapSourceBreakpoint>(entry => ({
346
+ line: entry.line,
347
+ ...(entry.condition ? { condition: entry.condition } : {}),
348
+ })),
349
+ },
350
+ signal,
351
+ timeoutMs,
352
+ );
353
+ if (current.length === 0) {
354
+ session.breakpoints.delete(sourcePath);
355
+ } else {
356
+ session.breakpoints.set(sourcePath, this.#mapSourceBreakpoints(current, response?.breakpoints));
357
+ }
358
+ return {
359
+ snapshot: buildSummary(session),
360
+ breakpoints: session.breakpoints.get(sourcePath) ?? [],
361
+ sourcePath,
362
+ };
363
+ }
364
+
365
+ async setFunctionBreakpoint(name: string, condition?: string, signal?: AbortSignal, timeoutMs: number = 30_000) {
366
+ const session = this.#touchActiveSession();
367
+ const current = session.functionBreakpoints.filter(entry => entry.name !== name);
368
+ current.push({ verified: false, name, condition });
369
+ current.sort((left, right) => left.name.localeCompare(right.name));
370
+ const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
371
+ session,
372
+ "setFunctionBreakpoints",
373
+ {
374
+ breakpoints: current.map<DapFunctionBreakpoint>(entry => ({
375
+ name: entry.name,
376
+ ...(entry.condition ? { condition: entry.condition } : {}),
377
+ })),
378
+ },
379
+ signal,
380
+ timeoutMs,
381
+ );
382
+ session.functionBreakpoints = this.#mapFunctionBreakpoints(current, response?.breakpoints);
383
+ return { snapshot: buildSummary(session), breakpoints: session.functionBreakpoints };
384
+ }
385
+
386
+ async removeFunctionBreakpoint(name: string, signal?: AbortSignal, timeoutMs: number = 30_000) {
387
+ const session = this.#touchActiveSession();
388
+ const current = session.functionBreakpoints.filter(entry => entry.name !== name);
389
+ const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
390
+ session,
391
+ "setFunctionBreakpoints",
392
+ {
393
+ breakpoints: current.map<DapFunctionBreakpoint>(entry => ({
394
+ name: entry.name,
395
+ ...(entry.condition ? { condition: entry.condition } : {}),
396
+ })),
397
+ },
398
+ signal,
399
+ timeoutMs,
400
+ );
401
+ session.functionBreakpoints = this.#mapFunctionBreakpoints(current, response?.breakpoints);
402
+ return { snapshot: buildSummary(session), breakpoints: session.functionBreakpoints };
403
+ }
404
+
405
+ async setInstructionBreakpoint(
406
+ instructionReference: string,
407
+ offset?: number,
408
+ condition?: string,
409
+ hitCondition?: string,
410
+ signal?: AbortSignal,
411
+ timeoutMs: number = 30_000,
412
+ ) {
413
+ const session = this.#touchActiveSession();
414
+ const current = session.instructionBreakpoints.filter(
415
+ entry => entry.instructionReference !== instructionReference || entry.offset !== offset,
416
+ );
417
+ current.push({ instructionReference, offset, condition, hitCondition });
418
+ current.sort((left, right) => {
419
+ const referenceOrder = left.instructionReference.localeCompare(right.instructionReference);
420
+ if (referenceOrder !== 0) {
421
+ return referenceOrder;
422
+ }
423
+ return (left.offset ?? 0) - (right.offset ?? 0);
424
+ });
425
+ const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
426
+ session,
427
+ "setInstructionBreakpoints",
428
+ {
429
+ breakpoints: current,
430
+ } satisfies DapSetInstructionBreakpointsArguments,
431
+ signal,
432
+ timeoutMs,
433
+ );
434
+ session.instructionBreakpoints = current;
435
+ return {
436
+ snapshot: buildSummary(session),
437
+ breakpoints: this.#mapInstructionBreakpoints(current, response?.breakpoints),
438
+ };
439
+ }
440
+
441
+ async removeInstructionBreakpoint(
442
+ instructionReference: string,
443
+ offset?: number,
444
+ signal?: AbortSignal,
445
+ timeoutMs: number = 30_000,
446
+ ) {
447
+ const session = this.#touchActiveSession();
448
+ const current = session.instructionBreakpoints.filter(entry => {
449
+ if (entry.instructionReference !== instructionReference) {
450
+ return true;
451
+ }
452
+ if (offset === undefined) {
453
+ return false;
454
+ }
455
+ return entry.offset !== offset;
456
+ });
457
+ const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
458
+ session,
459
+ "setInstructionBreakpoints",
460
+ {
461
+ breakpoints: current,
462
+ } satisfies DapSetInstructionBreakpointsArguments,
463
+ signal,
464
+ timeoutMs,
465
+ );
466
+ session.instructionBreakpoints = current;
467
+ return {
468
+ snapshot: buildSummary(session),
469
+ breakpoints: this.#mapInstructionBreakpoints(current, response?.breakpoints),
470
+ };
471
+ }
472
+
473
+ async dataBreakpointInfo(
474
+ name: string,
475
+ variablesReference?: number,
476
+ frameId?: number,
477
+ signal?: AbortSignal,
478
+ timeoutMs: number = 30_000,
479
+ ): Promise<{ snapshot: DapSessionSummary; info: DapDataBreakpointInfoResponse }> {
480
+ const session = this.#touchActiveSession();
481
+ const info = await this.#sendRequestWithConfig<DapDataBreakpointInfoResponse>(
482
+ session,
483
+ "dataBreakpointInfo",
484
+ {
485
+ name,
486
+ ...(variablesReference !== undefined ? { variablesReference } : {}),
487
+ ...(frameId !== undefined ? { frameId } : {}),
488
+ } satisfies DapDataBreakpointInfoArguments,
489
+ signal,
490
+ timeoutMs,
491
+ );
492
+ return { snapshot: buildSummary(session), info };
493
+ }
494
+
495
+ async setDataBreakpoint(
496
+ dataId: string,
497
+ accessType?: "read" | "write" | "readWrite",
498
+ condition?: string,
499
+ hitCondition?: string,
500
+ signal?: AbortSignal,
501
+ timeoutMs: number = 30_000,
502
+ ) {
503
+ const session = this.#touchActiveSession();
504
+ const current = session.dataBreakpoints.filter(entry => entry.dataId !== dataId);
505
+ current.push({ dataId, accessType, condition, hitCondition });
506
+ current.sort((left, right) => left.dataId.localeCompare(right.dataId));
507
+ const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
508
+ session,
509
+ "setDataBreakpoints",
510
+ {
511
+ breakpoints: current,
512
+ } satisfies DapSetDataBreakpointsArguments,
513
+ signal,
514
+ timeoutMs,
515
+ );
516
+ session.dataBreakpoints = current;
517
+ return {
518
+ snapshot: buildSummary(session),
519
+ breakpoints: this.#mapDataBreakpoints(current, response?.breakpoints),
520
+ };
521
+ }
522
+
523
+ async removeDataBreakpoint(dataId: string, signal?: AbortSignal, timeoutMs: number = 30_000) {
524
+ const session = this.#touchActiveSession();
525
+ const current = session.dataBreakpoints.filter(entry => entry.dataId !== dataId);
526
+ const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
527
+ session,
528
+ "setDataBreakpoints",
529
+ {
530
+ breakpoints: current,
531
+ } satisfies DapSetDataBreakpointsArguments,
532
+ signal,
533
+ timeoutMs,
534
+ );
535
+ session.dataBreakpoints = current;
536
+ return {
537
+ snapshot: buildSummary(session),
538
+ breakpoints: this.#mapDataBreakpoints(current, response?.breakpoints),
539
+ };
540
+ }
541
+
542
+ async disassemble(
543
+ memoryReference: string,
544
+ instructionCount: number,
545
+ offset?: number,
546
+ instructionOffset?: number,
547
+ resolveSymbols?: boolean,
548
+ signal?: AbortSignal,
549
+ timeoutMs: number = 30_000,
550
+ ): Promise<{ snapshot: DapSessionSummary; instructions: DapDisassembledInstruction[] }> {
551
+ const session = this.#touchActiveSession();
552
+ const response = await this.#sendRequestWithConfig<DapDisassembleResponse>(
553
+ session,
554
+ "disassemble",
555
+ {
556
+ memoryReference,
557
+ instructionCount,
558
+ ...(offset !== undefined ? { offset } : {}),
559
+ ...(instructionOffset !== undefined ? { instructionOffset } : {}),
560
+ ...(resolveSymbols !== undefined ? { resolveSymbols } : {}),
561
+ } satisfies DapDisassembleArguments,
562
+ signal,
563
+ timeoutMs,
564
+ );
565
+ return { snapshot: buildSummary(session), instructions: response?.instructions ?? [] };
566
+ }
567
+
568
+ async readMemory(
569
+ memoryReference: string,
570
+ count: number,
571
+ offset?: number,
572
+ signal?: AbortSignal,
573
+ timeoutMs: number = 30_000,
574
+ ): Promise<{ snapshot: DapSessionSummary; address: string; data?: string; unreadableBytes?: number }> {
575
+ const session = this.#touchActiveSession();
576
+ const response = await this.#sendRequestWithConfig<DapReadMemoryResponse>(
577
+ session,
578
+ "readMemory",
579
+ {
580
+ memoryReference,
581
+ count,
582
+ ...(offset !== undefined ? { offset } : {}),
583
+ } satisfies DapReadMemoryArguments,
584
+ signal,
585
+ timeoutMs,
586
+ );
587
+ return {
588
+ snapshot: buildSummary(session),
589
+ address: response?.address ?? memoryReference,
590
+ data: response?.data,
591
+ unreadableBytes: response?.unreadableBytes,
592
+ };
593
+ }
594
+
595
+ async writeMemory(
596
+ memoryReference: string,
597
+ data: string,
598
+ offset?: number,
599
+ allowPartial?: boolean,
600
+ signal?: AbortSignal,
601
+ timeoutMs: number = 30_000,
602
+ ): Promise<{ snapshot: DapSessionSummary; offset?: number; bytesWritten?: number }> {
603
+ const session = this.#touchActiveSession();
604
+ const response = await this.#sendRequestWithConfig<DapWriteMemoryResponse>(
605
+ session,
606
+ "writeMemory",
607
+ {
608
+ memoryReference,
609
+ data,
610
+ ...(offset !== undefined ? { offset } : {}),
611
+ ...(allowPartial !== undefined ? { allowPartial } : {}),
612
+ } satisfies DapWriteMemoryArguments,
613
+ signal,
614
+ timeoutMs,
615
+ );
616
+ return {
617
+ snapshot: buildSummary(session),
618
+ offset: response?.offset,
619
+ bytesWritten: response?.bytesWritten,
620
+ };
621
+ }
622
+
623
+ async modules(
624
+ startModule?: number,
625
+ moduleCount?: number,
626
+ signal?: AbortSignal,
627
+ timeoutMs: number = 30_000,
628
+ ): Promise<{ snapshot: DapSessionSummary; modules: DapModule[] }> {
629
+ const session = this.#touchActiveSession();
630
+ const response = await this.#sendRequestWithConfig<DapModulesResponse>(
631
+ session,
632
+ "modules",
633
+ {
634
+ ...(startModule !== undefined ? { startModule } : {}),
635
+ ...(moduleCount !== undefined ? { moduleCount } : {}),
636
+ } satisfies DapModulesArguments,
637
+ signal,
638
+ timeoutMs,
639
+ );
640
+ return { snapshot: buildSummary(session), modules: response?.modules ?? [] };
641
+ }
642
+
643
+ async loadedSources(
644
+ signal?: AbortSignal,
645
+ timeoutMs: number = 30_000,
646
+ ): Promise<{ snapshot: DapSessionSummary; sources: DapSource[] }> {
647
+ const session = this.#touchActiveSession();
648
+ const response = await this.#sendRequestWithConfig<DapLoadedSourcesResponse>(
649
+ session,
650
+ "loadedSources",
651
+ {},
652
+ signal,
653
+ timeoutMs,
654
+ );
655
+ return { snapshot: buildSummary(session), sources: response?.sources ?? [] };
656
+ }
657
+
658
+ async customRequest(
659
+ command: string,
660
+ args?: Record<string, unknown>,
661
+ signal?: AbortSignal,
662
+ timeoutMs: number = 30_000,
663
+ ): Promise<{ snapshot: DapSessionSummary; body: unknown }> {
664
+ const session = this.#touchActiveSession();
665
+ const body = await this.#sendRequestWithConfig<unknown>(session, command, args, signal, timeoutMs);
666
+ return { snapshot: buildSummary(session), body };
667
+ }
668
+
669
+ async continue(signal?: AbortSignal, timeoutMs: number = 30_000): Promise<DapContinueOutcome> {
670
+ const session = this.#touchActiveSession();
671
+ const threadId = await this.#resolveThreadId(session, signal, timeoutMs);
672
+ // Reset state and subscribe BEFORE sending continue to avoid missing
673
+ // events that arrive in the same buffer as the response.
674
+ session.stop = {};
675
+ session.lastStackFrames = [];
676
+ session.status = "running";
677
+ const outcomePromise = this.#prepareStopOutcome(session, signal, timeoutMs);
678
+ await this.#sendRequestWithConfig<DapContinueResponse>(
679
+ session,
680
+ "continue",
681
+ { threadId } satisfies DapContinueArguments,
682
+ signal,
683
+ timeoutMs,
684
+ );
685
+ return this.#awaitStopOutcome(session, outcomePromise, signal, timeoutMs);
686
+ }
687
+
688
+ async pause(signal?: AbortSignal, timeoutMs: number = 30_000): Promise<DapSessionSummary> {
689
+ const session = this.#touchActiveSession();
690
+ if (session.status === "stopped") {
691
+ return buildSummary(session);
692
+ }
693
+ const threadId = await this.#resolveThreadId(session, signal, timeoutMs);
694
+ await this.#sendRequestWithConfig(session, "pause", { threadId } satisfies DapPauseArguments, signal, timeoutMs);
695
+ // The stopped event may already have been processed by #handleStoppedEvent
696
+ // between the request and here. Wait for it, but tolerate timeout if the
697
+ // session already transitioned.
698
+ try {
699
+ await raceAbort(
700
+ session.client.waitForEvent<DapStoppedEventBody>("stopped", undefined, signal, timeoutMs),
701
+ signal,
702
+ );
703
+ } catch {
704
+ // Timeout or abort — report current state regardless
705
+ }
706
+ return buildSummary(session);
707
+ }
708
+
709
+ async stepIn(signal?: AbortSignal, timeoutMs: number = 30_000): Promise<DapContinueOutcome> {
710
+ return this.#step("stepIn", signal, timeoutMs);
711
+ }
712
+
713
+ async stepOut(signal?: AbortSignal, timeoutMs: number = 30_000): Promise<DapContinueOutcome> {
714
+ return this.#step("stepOut", signal, timeoutMs);
715
+ }
716
+
717
+ async stepOver(signal?: AbortSignal, timeoutMs: number = 30_000): Promise<DapContinueOutcome> {
718
+ return this.#step("next", signal, timeoutMs);
719
+ }
720
+
721
+ async threads(
722
+ signal?: AbortSignal,
723
+ timeoutMs: number = 30_000,
724
+ ): Promise<{ snapshot: DapSessionSummary; threads: DapThread[] }> {
725
+ const session = this.#touchActiveSession();
726
+ const response = await this.#sendRequestWithConfig<DapThreadsResponse>(
727
+ session,
728
+ "threads",
729
+ undefined,
730
+ signal,
731
+ timeoutMs,
732
+ );
733
+ session.threads = response?.threads ?? [];
734
+ return { snapshot: buildSummary(session), threads: session.threads };
735
+ }
736
+
737
+ async stackTrace(
738
+ frameCount: number | undefined,
739
+ signal?: AbortSignal,
740
+ timeoutMs: number = 30_000,
741
+ ): Promise<{ snapshot: DapSessionSummary; stackFrames: DapStackFrame[]; totalFrames?: number }> {
742
+ const session = this.#touchActiveSession();
743
+ const threadId = await this.#resolveThreadId(session, signal, timeoutMs);
744
+ const response = await this.#sendRequestWithConfig<DapStackTraceResponse>(
745
+ session,
746
+ "stackTrace",
747
+ {
748
+ threadId,
749
+ ...(frameCount !== undefined ? { levels: frameCount } : {}),
750
+ } satisfies DapStackTraceArguments,
751
+ signal,
752
+ timeoutMs,
753
+ );
754
+ session.lastStackFrames = response?.stackFrames ?? [];
755
+ this.#applyTopFrame(session, session.lastStackFrames[0]);
756
+ return {
757
+ snapshot: buildSummary(session),
758
+ stackFrames: session.lastStackFrames,
759
+ totalFrames: response?.totalFrames,
760
+ };
761
+ }
762
+
763
+ async scopes(frameId: number | undefined, signal?: AbortSignal, timeoutMs: number = 30_000) {
764
+ const session = this.#touchActiveSession();
765
+ const resolvedFrameId = frameId ?? session.stop.frameId;
766
+ if (resolvedFrameId === undefined) {
767
+ throw new Error("No active stack frame. Run stack_trace first or supply frame_id.");
768
+ }
769
+ const response = await this.#sendRequestWithConfig<DapScopesResponse>(
770
+ session,
771
+ "scopes",
772
+ { frameId: resolvedFrameId } satisfies DapScopesArguments,
773
+ signal,
774
+ timeoutMs,
775
+ );
776
+ return { snapshot: buildSummary(session), scopes: response?.scopes ?? [] };
777
+ }
778
+
779
+ async variables(variableReference: number, signal?: AbortSignal, timeoutMs: number = 30_000) {
780
+ const session = this.#touchActiveSession();
781
+ const response = await this.#sendRequestWithConfig<DapVariablesResponse>(
782
+ session,
783
+ "variables",
784
+ { variablesReference: variableReference } satisfies DapVariablesArguments,
785
+ signal,
786
+ timeoutMs,
787
+ );
788
+ return { snapshot: buildSummary(session), variables: response?.variables ?? [] };
789
+ }
790
+
791
+ async evaluate(
792
+ expression: string,
793
+ context: DapEvaluateArguments["context"],
794
+ frameId: number | undefined,
795
+ signal?: AbortSignal,
796
+ timeoutMs: number = 30_000,
797
+ ) {
798
+ const session = this.#touchActiveSession();
799
+ // Default to the top stopped frame so callers don't need to pass
800
+ // frame_id explicitly for the common case.
801
+ const effectiveFrameId = frameId ?? session.stop.frameId;
802
+ const response = await this.#sendRequestWithConfig<DapEvaluateResponse>(
803
+ session,
804
+ "evaluate",
805
+ {
806
+ expression,
807
+ context,
808
+ ...(effectiveFrameId !== undefined ? { frameId: effectiveFrameId } : {}),
809
+ } satisfies DapEvaluateArguments,
810
+ signal,
811
+ timeoutMs,
812
+ );
813
+ return { snapshot: buildSummary(session), evaluation: response };
814
+ }
815
+
816
+ getOutput(limitBytes?: number): DapOutputSnapshot {
817
+ const session = this.#touchActiveSession();
818
+ if (!limitBytes || limitBytes <= 0 || Buffer.byteLength(session.output, "utf-8") <= limitBytes) {
819
+ return { snapshot: buildSummary(session), output: session.output };
820
+ }
821
+ let sliceStart = session.output.length;
822
+ let remaining = limitBytes;
823
+ while (sliceStart > 0 && remaining > 0) {
824
+ sliceStart -= 1;
825
+ remaining -= Buffer.byteLength(session.output[sliceStart] ?? "", "utf-8");
826
+ }
827
+ return { snapshot: buildSummary(session), output: session.output.slice(sliceStart) };
828
+ }
829
+
830
+ async terminate(signal?: AbortSignal, timeoutMs: number = 30_000): Promise<DapSessionSummary | null> {
831
+ const session = this.#getActiveSessionOrNull();
832
+ if (!session) return null;
833
+ session.lastUsedAt = Date.now();
834
+ if (session.status !== "terminated") {
835
+ if (session.capabilities?.supportsTerminateRequest) {
836
+ await raceAbort(
837
+ session.client.sendRequest("terminate", undefined, signal, timeoutMs).catch(() => undefined),
838
+ signal,
839
+ );
840
+ }
841
+ await raceAbort(
842
+ session.client
843
+ .sendRequest("disconnect", { terminateDebuggee: true }, signal, timeoutMs)
844
+ .catch(() => undefined),
845
+ signal,
846
+ );
847
+ }
848
+ session.status = "terminated";
849
+ const summary = buildSummary(session);
850
+ await this.#disposeSession(session);
851
+ return summary;
852
+ }
853
+
854
+ #startCleanupTimer(): void {
855
+ if (this.#cleanupTimer) return;
856
+ this.#cleanupTimer = setInterval(() => {
857
+ void this.#cleanupIdleSessions();
858
+ }, CLEANUP_INTERVAL_MS);
859
+ this.#cleanupTimer.unref?.();
860
+ }
861
+
862
+ async #cleanupIdleSessions(): Promise<void> {
863
+ const now = Date.now();
864
+ for (const session of Array.from(this.#sessions.values())) {
865
+ if (
866
+ session.status === "terminated" ||
867
+ now - session.lastUsedAt > IDLE_TIMEOUT_MS ||
868
+ !session.client.isAlive()
869
+ ) {
870
+ await this.#disposeSession(session);
871
+ }
872
+ }
873
+ }
874
+
875
+ async #ensureLaunchSlot(): Promise<void> {
876
+ const active = this.#getActiveSessionOrNull();
877
+ if (!active) return;
878
+ if (active.status === "terminated" || !active.client.isAlive()) {
879
+ await this.#disposeSession(active);
880
+ return;
881
+ }
882
+ throw new Error(`Debug session ${active.id} is still active. Terminate it before launching another.`);
883
+ }
884
+
885
+ #registerSession(client: DapClient, adapter: DapResolvedAdapter, cwd: string, program?: string): DapSession {
886
+ const session: DapSession = {
887
+ id: `debug-${++this.#nextId}`,
888
+ adapter,
889
+ cwd,
890
+ program,
891
+ client,
892
+ status: "launching",
893
+ launchedAt: Date.now(),
894
+ lastUsedAt: Date.now(),
895
+ breakpoints: new Map(),
896
+ functionBreakpoints: [],
897
+ instructionBreakpoints: [],
898
+ dataBreakpoints: [],
899
+ output: "",
900
+ outputBytes: 0,
901
+ outputTruncated: false,
902
+ stop: {},
903
+ threads: [],
904
+ lastStackFrames: [],
905
+ initializedSeen: false,
906
+ needsConfigurationDone: false,
907
+ configurationDoneSent: false,
908
+ };
909
+ client.onReverseRequest("runInTerminal", async rawArgs => {
910
+ const args = (rawArgs ?? {}) as DapRunInTerminalArguments;
911
+ if (!Array.isArray(args.args) || args.args.length === 0) {
912
+ throw new Error("runInTerminal request did not include a command");
913
+ }
914
+ const env = Object.fromEntries(
915
+ Object.entries(args.env ?? {}).filter((entry): entry is [string, string] => entry[1] !== null),
916
+ );
917
+ const proc = ptree.spawn(args.args, {
918
+ cwd: args.cwd ?? session.cwd,
919
+ stdin: "pipe",
920
+ env: {
921
+ ...Bun.env,
922
+ ...NON_INTERACTIVE_ENV,
923
+ ...env,
924
+ },
925
+ detached: true,
926
+ });
927
+ return { processId: proc.pid } satisfies DapRunInTerminalResponse;
928
+ });
929
+ client.onReverseRequest("startDebugging", async rawArgs => {
930
+ const startArgs = (rawArgs ?? {}) as Partial<DapStartDebuggingArguments>;
931
+ const request = startArgs.request === "attach" ? "attach" : "launch";
932
+ const configuration =
933
+ startArgs.configuration && typeof startArgs.configuration === "object" ? startArgs.configuration : {};
934
+ logger.debug("Adapter requested child debug session", {
935
+ adapter: session.adapter.name,
936
+ sessionId: session.id,
937
+ request,
938
+ name: typeof configuration.name === "string" ? configuration.name : undefined,
939
+ });
940
+ return {};
941
+ });
942
+ client.onEvent("output", body => {
943
+ truncateOutput(session, (body as DapOutputEventBody | undefined)?.output ?? "");
944
+ });
945
+ client.onEvent("initialized", () => {
946
+ session.initializedSeen = true;
947
+ session.status = session.configurationDoneSent ? session.status : "configuring";
948
+ });
949
+ client.onEvent("stopped", body => {
950
+ this.#handleStoppedEvent(session, body as DapStoppedEventBody);
951
+ });
952
+ client.onEvent("continued", body => {
953
+ const continued = body as { threadId?: number } | undefined;
954
+ session.status = "running";
955
+ session.stop = { threadId: continued?.threadId };
956
+ session.lastStackFrames = [];
957
+ });
958
+ client.onEvent("exited", body => {
959
+ session.exitCode = (body as DapExitedEventBody | undefined)?.exitCode;
960
+ });
961
+ client.onEvent("terminated", () => {
962
+ session.status = "terminated";
963
+ });
964
+ this.#sessions.set(session.id, session);
965
+ this.#activeSessionId = session.id;
966
+ const heartbeat = setInterval(() => {
967
+ if (!client.isAlive()) {
968
+ session.status = "terminated";
969
+ }
970
+ }, HEARTBEAT_INTERVAL_MS);
971
+ heartbeat.unref?.();
972
+ client.proc.exited.finally(() => clearInterval(heartbeat));
973
+ return session;
974
+ }
975
+
976
+ #buildInitializeArguments(adapter: DapResolvedAdapter): DapInitializeArguments {
977
+ return {
978
+ clientID: "omp",
979
+ clientName: "Oh My Pi",
980
+ adapterID: adapter.name,
981
+ locale: "en-US",
982
+ linesStartAt1: true,
983
+ columnsStartAt1: true,
984
+ pathFormat: "path",
985
+ supportsRunInTerminalRequest: true,
986
+ supportsStartDebuggingRequest: true,
987
+ supportsMemoryReferences: true,
988
+ supportsVariableType: true,
989
+ supportsInvalidatedEvent: true,
990
+ };
991
+ }
992
+
993
+ /**
994
+ * Wait for the adapter's `initialized` event (if not already received),
995
+ * then send `configurationDone`. Many adapters block the `launch`/`attach`
996
+ * response until this handshake completes.
997
+ */
998
+ async #completeConfigurationHandshake(
999
+ session: DapSession,
1000
+ signal?: AbortSignal,
1001
+ timeoutMs: number = 30_000,
1002
+ ): Promise<void> {
1003
+ if (!session.needsConfigurationDone || session.configurationDoneSent) {
1004
+ return;
1005
+ }
1006
+ // Wait for the initialized event if we haven't seen it yet.
1007
+ if (!session.initializedSeen) {
1008
+ try {
1009
+ await raceAbort(session.client.waitForEvent("initialized", undefined, signal, timeoutMs), signal);
1010
+ } catch {
1011
+ // Adapter may not send initialized (e.g. it already terminated).
1012
+ // Proceed anyway — the launch/attach response will surface any real error.
1013
+ return;
1014
+ }
1015
+ }
1016
+ await session.client.sendRequest("configurationDone", {}, signal, timeoutMs);
1017
+ session.configurationDoneSent = true;
1018
+ if (session.status === "configuring") {
1019
+ session.status = "running";
1020
+ }
1021
+ }
1022
+
1023
+ #handleStoppedEvent(session: DapSession, stopped: DapStoppedEventBody): void {
1024
+ session.status = "stopped";
1025
+ session.stop = {
1026
+ threadId: stopped.threadId,
1027
+ reason: stopped.reason,
1028
+ description: stopped.description,
1029
+ text: stopped.text,
1030
+ };
1031
+ session.lastStackFrames = [];
1032
+ }
1033
+
1034
+ #applyTopFrame(session: DapSession, frame: DapStackFrame | undefined): void {
1035
+ if (!frame) return;
1036
+ session.stop.frameId = frame.id;
1037
+ session.stop.frameName = frame.name;
1038
+ session.stop.instructionPointerReference = frame.instructionPointerReference;
1039
+ session.stop.source = frame.source;
1040
+ session.stop.line = frame.line;
1041
+ session.stop.column = frame.column;
1042
+ }
1043
+
1044
+ /**
1045
+ * Fetch the top stack frame from the adapter and apply it to the session's
1046
+ * stop location. Called outside the event dispatch loop to avoid deadlocking
1047
+ * the message reader.
1048
+ */
1049
+ async #fetchTopFrame(session: DapSession, signal?: AbortSignal, timeoutMs: number = 5_000): Promise<void> {
1050
+ if (session.stop.threadId === undefined) return;
1051
+ try {
1052
+ const response = await session.client.sendRequest<DapStackTraceResponse>(
1053
+ "stackTrace",
1054
+ { threadId: session.stop.threadId, levels: 1 } satisfies DapStackTraceArguments,
1055
+ signal,
1056
+ timeoutMs,
1057
+ );
1058
+ session.lastStackFrames = response?.stackFrames ?? [];
1059
+ this.#applyTopFrame(session, session.lastStackFrames[0]);
1060
+ } catch (error) {
1061
+ logger.debug("Failed to capture stopped frame", {
1062
+ sessionId: session.id,
1063
+ error: toErrorMessage(error),
1064
+ });
1065
+ }
1066
+ }
1067
+
1068
+ async #step(command: "stepIn" | "stepOut" | "next", signal?: AbortSignal, timeoutMs: number = 30_000) {
1069
+ const session = this.#touchActiveSession();
1070
+ const threadId = await this.#resolveThreadId(session, signal, timeoutMs);
1071
+ // Reset state and subscribe BEFORE sending the step command to avoid
1072
+ // missing events that arrive in the same buffer as the response.
1073
+ session.stop = {};
1074
+ session.lastStackFrames = [];
1075
+ session.status = "running";
1076
+ const outcomePromise = this.#prepareStopOutcome(session, signal, timeoutMs);
1077
+ await this.#sendRequestWithConfig(session, command, { threadId } satisfies DapStepArguments, signal, timeoutMs);
1078
+ return this.#awaitStopOutcome(session, outcomePromise, signal, timeoutMs);
1079
+ }
1080
+
1081
+ /**
1082
+ * Create a promise that resolves when the session stops, terminates, or exits.
1083
+ * MUST be called before the command that triggers the event.
1084
+ */
1085
+ #prepareStopOutcome(session: DapSession, signal?: AbortSignal, timeoutMs: number = 30_000): Promise<unknown> {
1086
+ return Promise.race([
1087
+ session.client.waitForEvent("stopped", undefined, signal, timeoutMs),
1088
+ session.client.waitForEvent("terminated", undefined, signal, timeoutMs),
1089
+ session.client.waitForEvent("exited", undefined, signal, timeoutMs),
1090
+ ]);
1091
+ }
1092
+
1093
+ /**
1094
+ * Await a pre-subscribed stop outcome, then fetch the top frame if stopped.
1095
+ */
1096
+ async #awaitStopOutcome(
1097
+ session: DapSession,
1098
+ outcomePromise: Promise<unknown>,
1099
+ signal?: AbortSignal,
1100
+ timeoutMs: number = 30_000,
1101
+ ): Promise<DapContinueOutcome> {
1102
+ try {
1103
+ await raceAbort(outcomePromise, signal);
1104
+ if (session.status === "stopped") {
1105
+ await this.#fetchTopFrame(session, signal, Math.min(timeoutMs, 5_000));
1106
+ }
1107
+ const state =
1108
+ session.status === "stopped" ? "stopped" : session.status === "terminated" ? "terminated" : "running";
1109
+ return { snapshot: buildSummary(session), state, timedOut: false };
1110
+ } catch (error) {
1111
+ if (signal?.aborted) {
1112
+ throw error;
1113
+ }
1114
+ return { snapshot: buildSummary(session), state: "running", timedOut: session.status === "running" };
1115
+ }
1116
+ }
1117
+
1118
+ async #resolveThreadId(session: DapSession, signal?: AbortSignal, timeoutMs: number = 30_000): Promise<number> {
1119
+ if (session.stop.threadId !== undefined) {
1120
+ return session.stop.threadId;
1121
+ }
1122
+ if (session.threads.length > 0) {
1123
+ return session.threads[0].id;
1124
+ }
1125
+ const response = await session.client.sendRequest<DapThreadsResponse>("threads", undefined, signal, timeoutMs);
1126
+ session.threads = response?.threads ?? [];
1127
+ const threadId = session.threads[0]?.id;
1128
+ if (threadId === undefined) {
1129
+ throw new Error("Debugger reported no threads.");
1130
+ }
1131
+ return threadId;
1132
+ }
1133
+
1134
+ async #sendRequestWithConfig<TBody>(
1135
+ session: DapSession,
1136
+ command: string,
1137
+ args: unknown,
1138
+ signal?: AbortSignal,
1139
+ timeoutMs: number = 30_000,
1140
+ ): Promise<TBody> {
1141
+ await this.#ensureConfigurationDone(session, signal, timeoutMs);
1142
+ const body = await session.client.sendRequest<TBody>(command, args, signal, timeoutMs);
1143
+ session.lastUsedAt = Date.now();
1144
+ return body;
1145
+ }
1146
+
1147
+ async #ensureConfigurationDone(
1148
+ session: DapSession,
1149
+ signal?: AbortSignal,
1150
+ timeoutMs: number = 30_000,
1151
+ ): Promise<void> {
1152
+ if (!session.needsConfigurationDone || session.configurationDoneSent) {
1153
+ return;
1154
+ }
1155
+ await session.client.sendRequest("configurationDone", {}, signal, timeoutMs);
1156
+ session.configurationDoneSent = true;
1157
+ if (session.status === "configuring") {
1158
+ session.status = "running";
1159
+ }
1160
+ }
1161
+
1162
+ #mapSourceBreakpoints(
1163
+ input: DapBreakpointRecord[],
1164
+ responseBreakpoints: DapBreakpoint[] | undefined,
1165
+ ): DapBreakpointRecord[] {
1166
+ return input.map((entry, index) => ({
1167
+ line: entry.line,
1168
+ condition: entry.condition,
1169
+ id: responseBreakpoints?.[index]?.id,
1170
+ verified: responseBreakpoints?.[index]?.verified ?? false,
1171
+ message: responseBreakpoints?.[index]?.message,
1172
+ }));
1173
+ }
1174
+
1175
+ #mapFunctionBreakpoints(
1176
+ input: DapFunctionBreakpointRecord[],
1177
+ responseBreakpoints: DapBreakpoint[] | undefined,
1178
+ ): DapFunctionBreakpointRecord[] {
1179
+ return input.map((entry, index) => ({
1180
+ name: entry.name,
1181
+ condition: entry.condition,
1182
+ id: responseBreakpoints?.[index]?.id,
1183
+ verified: responseBreakpoints?.[index]?.verified ?? false,
1184
+ message: responseBreakpoints?.[index]?.message,
1185
+ }));
1186
+ }
1187
+
1188
+ #mapInstructionBreakpoints(
1189
+ input: DapInstructionBreakpoint[],
1190
+ responseBreakpoints: DapBreakpoint[] | undefined,
1191
+ ): DapInstructionBreakpointRecord[] {
1192
+ return input.map((entry, index) => ({
1193
+ instructionReference: responseBreakpoints?.[index]?.instructionReference ?? entry.instructionReference,
1194
+ offset: responseBreakpoints?.[index]?.offset ?? entry.offset,
1195
+ condition: entry.condition,
1196
+ hitCondition: entry.hitCondition,
1197
+ id: responseBreakpoints?.[index]?.id,
1198
+ verified: responseBreakpoints?.[index]?.verified ?? false,
1199
+ message: responseBreakpoints?.[index]?.message,
1200
+ }));
1201
+ }
1202
+
1203
+ #mapDataBreakpoints(
1204
+ input: DapDataBreakpoint[],
1205
+ responseBreakpoints: DapBreakpoint[] | undefined,
1206
+ ): DapDataBreakpointRecord[] {
1207
+ return input.map((entry, index) => ({
1208
+ dataId: entry.dataId,
1209
+ accessType: entry.accessType,
1210
+ condition: entry.condition,
1211
+ hitCondition: entry.hitCondition,
1212
+ id: responseBreakpoints?.[index]?.id,
1213
+ verified: responseBreakpoints?.[index]?.verified ?? false,
1214
+ message: responseBreakpoints?.[index]?.message,
1215
+ }));
1216
+ }
1217
+
1218
+ #touchActiveSession(): DapSession {
1219
+ const session = this.#getActiveSessionOrThrow();
1220
+ session.lastUsedAt = Date.now();
1221
+ if (session.status !== "terminated" && !session.client.isAlive()) {
1222
+ session.status = "terminated";
1223
+ }
1224
+ return session;
1225
+ }
1226
+
1227
+ #getActiveSessionOrNull(): DapSession | null {
1228
+ if (!this.#activeSessionId) {
1229
+ return null;
1230
+ }
1231
+ const session = this.#sessions.get(this.#activeSessionId) ?? null;
1232
+ if (!session) {
1233
+ this.#activeSessionId = null;
1234
+ }
1235
+ return session;
1236
+ }
1237
+
1238
+ #getActiveSessionOrThrow(): DapSession {
1239
+ const session = this.#getActiveSessionOrNull();
1240
+ if (!session) {
1241
+ throw new Error("No active debug session. Launch or attach first.");
1242
+ }
1243
+ return session;
1244
+ }
1245
+
1246
+ async #disposeSession(session: DapSession): Promise<void> {
1247
+ if (this.#activeSessionId === session.id) {
1248
+ this.#activeSessionId = null;
1249
+ }
1250
+ this.#sessions.delete(session.id);
1251
+ await session.client.dispose().catch(() => {});
1252
+ }
1253
+ }
1254
+
1255
+ export const dapSessionManager = new DapSessionManager();