agent-dbg 0.1.2 → 0.1.4

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 (36) hide show
  1. package/.claude/settings.local.json +29 -1
  2. package/.claude/skills/agent-dbg/SKILL.md +2 -0
  3. package/.claude/skills/agent-dbg/references/commands.md +2 -1
  4. package/TODO.md +299 -0
  5. package/demo/DEMO.md +71 -0
  6. package/demo/order-processor.js +35 -0
  7. package/dist/main.js +1480 -256
  8. package/package.json +3 -1
  9. package/src/commands/attach.ts +6 -5
  10. package/src/commands/break-fn.ts +41 -0
  11. package/src/commands/launch.ts +5 -6
  12. package/src/commands/logs.ts +58 -16
  13. package/src/daemon/client.ts +27 -5
  14. package/src/daemon/entry.ts +94 -46
  15. package/src/daemon/logger.ts +51 -0
  16. package/src/daemon/paths.ts +4 -0
  17. package/src/daemon/server.ts +76 -35
  18. package/src/daemon/session-breakpoints.ts +2 -1
  19. package/src/daemon/session-mutation.ts +1 -0
  20. package/src/daemon/session.ts +50 -10
  21. package/src/daemon/spawn.ts +47 -8
  22. package/src/dap/client.ts +252 -0
  23. package/src/dap/session.ts +1151 -0
  24. package/src/formatter/logs.ts +15 -0
  25. package/src/main.ts +1 -0
  26. package/src/protocol/messages.ts +12 -0
  27. package/tests/fixtures/dap/hello +0 -0
  28. package/tests/fixtures/dap/hello.c +8 -0
  29. package/tests/fixtures/dap/hello.dSYM/Contents/Info.plist +20 -0
  30. package/tests/fixtures/dap/hello.dSYM/Contents/Resources/DWARF/hello +0 -0
  31. package/tests/fixtures/dap/hello.dSYM/Contents/Resources/Relocations/aarch64/hello.yml +5 -0
  32. package/tests/fixtures/hotpatch-active-fn.js +7 -0
  33. package/tests/integration/daemon-logging.test.ts +155 -0
  34. package/tests/integration/mutation.test.ts +33 -0
  35. package/tests/unit/daemon-logger.test.ts +117 -0
  36. package/tests/unit/daemon.test.ts +60 -0
@@ -0,0 +1,1151 @@
1
+ import type { DebugProtocol } from "@vscode/debugprotocol";
2
+ import type {
3
+ ConsoleMessage,
4
+ ExceptionEntry,
5
+ LaunchResult,
6
+ PauseInfo,
7
+ SessionStatus,
8
+ StateOptions,
9
+ StateSnapshot,
10
+ } from "../daemon/session.ts";
11
+ import { RefTable } from "../refs/ref-table.ts";
12
+ import { DapClient } from "./client.ts";
13
+
14
+ /**
15
+ * Resolves the path to a DAP adapter binary for a given runtime.
16
+ * Returns the command array to spawn (e.g. ["lldb-dap"] or ["/opt/homebrew/opt/llvm/bin/lldb-dap"]).
17
+ */
18
+ function resolveAdapterCommand(runtime: string): string[] {
19
+ switch (runtime) {
20
+ case "lldb":
21
+ case "lldb-dap": {
22
+ // Try homebrew LLVM path first, fall back to PATH
23
+ const brewPath = "/opt/homebrew/opt/llvm/bin/lldb-dap";
24
+ return [brewPath];
25
+ }
26
+ case "codelldb":
27
+ return ["codelldb", "--port", "0"];
28
+ default:
29
+ // Assume the runtime string is the adapter binary name or path
30
+ return [runtime];
31
+ }
32
+ }
33
+
34
+ interface DapBreakpointEntry {
35
+ ref: string;
36
+ dapId?: number;
37
+ file: string;
38
+ line: number;
39
+ condition?: string;
40
+ hitCondition?: string;
41
+ verified: boolean;
42
+ actualLine?: number;
43
+ }
44
+
45
+ interface DapFunctionBreakpointEntry {
46
+ ref: string;
47
+ name: string;
48
+ condition?: string;
49
+ hitCondition?: string;
50
+ verified: boolean;
51
+ }
52
+
53
+ interface DapStackFrame {
54
+ id: number;
55
+ name: string;
56
+ file?: string;
57
+ line: number;
58
+ column: number;
59
+ }
60
+
61
+ /**
62
+ * DapSession implements the same public interface as DebugSession, but communicates
63
+ * with a DAP debug adapter (e.g. lldb-dap) instead of CDP/V8. This allows agent-dbg
64
+ * to debug native code (C/C++/Rust via LLDB), Python, Ruby, etc.
65
+ */
66
+ export class DapSession {
67
+ private dap: DapClient | null = null;
68
+ private refs: RefTable = new RefTable();
69
+ private _state: "idle" | "running" | "paused" = "idle";
70
+ private _pauseInfo: PauseInfo | null = null;
71
+ private _session: string;
72
+ private _runtime: string;
73
+ private _startTime: number = Date.now();
74
+ private _threadId = 1; // Most adapters use thread 1; updated on "stopped" event
75
+ private _stackFrames: DapStackFrame[] = [];
76
+ private _consoleMessages: ConsoleMessage[] = [];
77
+ private _exceptionEntries: ExceptionEntry[] = [];
78
+ private capabilities: DebugProtocol.Capabilities = {};
79
+
80
+ // Breakpoints: DAP requires sending ALL breakpoints per file at once
81
+ private breakpoints = new Map<string, DapBreakpointEntry[]>();
82
+ private allBreakpoints: DapBreakpointEntry[] = [];
83
+ private functionBreakpoints: DapFunctionBreakpointEntry[] = [];
84
+
85
+ // Promise that resolves when the adapter stops (for step/continue/pause)
86
+ private stoppedWaiter: { resolve: () => void; reject: (e: Error) => void } | null = null;
87
+ // Deduplicates concurrent fetchStackTrace calls
88
+ private _stackFetchPromise: Promise<void> | null = null;
89
+
90
+ constructor(session: string, runtime: string) {
91
+ this._session = session;
92
+ this._runtime = runtime;
93
+ }
94
+
95
+ // ── Lifecycle ─────────────────────────────────────────────────────
96
+
97
+ async launch(
98
+ command: string[],
99
+ options: { brk?: boolean; port?: number; program?: string; args?: string[] } = {},
100
+ ): Promise<LaunchResult> {
101
+ if (this._state !== "idle") {
102
+ throw new Error("Session already has an active debug target");
103
+ }
104
+
105
+ const adapterCmd = resolveAdapterCommand(this._runtime);
106
+ this.dap = DapClient.spawn(adapterCmd);
107
+
108
+ this.setupEventHandlers();
109
+ await this.initializeAdapter();
110
+
111
+ // Build launch arguments. The exact schema depends on the adapter.
112
+ // For lldb-dap: { program, args, stopOnEntry, ... }
113
+ const program = options.program ?? command[0];
114
+ const programArgs = options.args ?? command.slice(1);
115
+ const launchArgs: Record<string, unknown> = {
116
+ program,
117
+ args: programArgs,
118
+ stopOnEntry: options.brk ?? true,
119
+ cwd: process.cwd(),
120
+ };
121
+
122
+ await this.dap.send("launch", launchArgs);
123
+ await this.dap.send("configurationDone");
124
+
125
+ // Wait briefly for a stopped event if stopOnEntry
126
+ if (options.brk !== false) {
127
+ await this.waitForStop(5_000);
128
+ }
129
+
130
+ const result: LaunchResult = {
131
+ pid: this.dap.pid,
132
+ wsUrl: `dap://${this._runtime}`,
133
+ paused: this.isPaused(),
134
+ };
135
+
136
+ if (this._pauseInfo) {
137
+ result.pauseInfo = this._pauseInfo;
138
+ }
139
+
140
+ return result;
141
+ }
142
+
143
+ async attach(target: string): Promise<{ wsUrl: string }> {
144
+ if (this._state !== "idle") {
145
+ throw new Error("Session already has an active debug target");
146
+ }
147
+
148
+ const adapterCmd = resolveAdapterCommand(this._runtime);
149
+ this.dap = DapClient.spawn(adapterCmd);
150
+
151
+ this.setupEventHandlers();
152
+ await this.initializeAdapter();
153
+
154
+ // Parse target: could be a PID or a process name
155
+ const pid = parseInt(target, 10);
156
+ const attachArgs: Record<string, unknown> = Number.isNaN(pid)
157
+ ? { program: target, waitFor: true }
158
+ : { pid };
159
+
160
+ await this.dap.send("attach", attachArgs);
161
+ await this.dap.send("configurationDone");
162
+
163
+ // Wait briefly for initial stop
164
+ await this.waitForStop(5_000).catch(() => {
165
+ // Some adapters don't stop immediately on attach
166
+ });
167
+
168
+ // If we're not paused after waiting, the target is running
169
+ if (this._state === "idle") {
170
+ this._state = "running";
171
+ }
172
+
173
+ return { wsUrl: `dap://${this._runtime}/${target}` };
174
+ }
175
+
176
+ getStatus(): SessionStatus {
177
+ return {
178
+ session: this._session,
179
+ state: this._state,
180
+ pid: this.dap?.pid,
181
+ wsUrl: this.dap ? `dap://${this._runtime}` : undefined,
182
+ pauseInfo: this._pauseInfo ?? undefined,
183
+ uptime: Math.floor((Date.now() - this._startTime) / 1000),
184
+ scriptCount: 0,
185
+ };
186
+ }
187
+
188
+ async stop(): Promise<void> {
189
+ if (this.dap) {
190
+ try {
191
+ await this.dap.send("disconnect", { terminateDebuggee: true });
192
+ } catch {
193
+ // Adapter may already be dead
194
+ }
195
+ this.dap.disconnect();
196
+ this.dap = null;
197
+ }
198
+
199
+ this._state = "idle";
200
+ this._pauseInfo = null;
201
+ this._stackFrames = [];
202
+ this.refs.clearAll();
203
+ this.breakpoints.clear();
204
+ this.allBreakpoints = [];
205
+ this.functionBreakpoints = [];
206
+ this._consoleMessages = [];
207
+ this._exceptionEntries = [];
208
+ }
209
+
210
+ // ── Execution control ─────────────────────────────────────────────
211
+
212
+ async continue(): Promise<void> {
213
+ this.requireConnected();
214
+ this.requirePaused();
215
+
216
+ const waiter = this.createStoppedWaiter(30_000);
217
+ await this.getDap().send("continue", { threadId: this._threadId });
218
+ this._state = "running";
219
+ this._pauseInfo = null;
220
+ this._stackFrames = [];
221
+ this.refs.clearVolatile();
222
+ await waiter;
223
+ if (this.isPaused()) await this.fetchStackTrace();
224
+ }
225
+
226
+ async step(mode: "over" | "into" | "out"): Promise<void> {
227
+ this.requireConnected();
228
+ this.requirePaused();
229
+
230
+ const waiter = this.createStoppedWaiter(30_000);
231
+
232
+ const command = mode === "into" ? "stepIn" : mode === "out" ? "stepOut" : "next";
233
+ await this.getDap().send(command, { threadId: this._threadId });
234
+ this._state = "running";
235
+ this._pauseInfo = null;
236
+ this.refs.clearVolatile();
237
+ await waiter;
238
+ if (this.isPaused()) await this.fetchStackTrace();
239
+ }
240
+
241
+ async pause(): Promise<void> {
242
+ this.requireConnected();
243
+ if (this._state !== "running") {
244
+ throw new Error("Cannot pause: target is not running");
245
+ }
246
+
247
+ const waiter = this.createStoppedWaiter(5_000);
248
+ await this.getDap().send("pause", { threadId: this._threadId });
249
+ await waiter;
250
+ if (this.isPaused()) await this.fetchStackTrace();
251
+ }
252
+
253
+ // ── Breakpoints ───────────────────────────────────────────────────
254
+
255
+ async setBreakpoint(
256
+ file: string,
257
+ line: number,
258
+ options?: { condition?: string; hitCount?: number; urlRegex?: string; column?: number },
259
+ ): Promise<{ ref: string; location: { url: string; line: number; column?: number } }> {
260
+ this.requireConnected();
261
+
262
+ const entry: DapBreakpointEntry = {
263
+ ref: "", // will be set by RefTable
264
+ file,
265
+ line,
266
+ condition: options?.condition,
267
+ hitCondition: options?.hitCount ? String(options.hitCount) : undefined,
268
+ verified: false,
269
+ };
270
+
271
+ // Add to per-file tracking
272
+ let fileBreakpoints = this.breakpoints.get(file);
273
+ if (!fileBreakpoints) {
274
+ fileBreakpoints = [];
275
+ this.breakpoints.set(file, fileBreakpoints);
276
+ }
277
+ fileBreakpoints.push(entry);
278
+ this.allBreakpoints.push(entry);
279
+
280
+ // Register ref
281
+ const ref = this.refs.addBreakpoint(`dap-bp:${file}:${line}`, {
282
+ file,
283
+ line,
284
+ });
285
+ entry.ref = ref;
286
+
287
+ // Send full set of breakpoints for this file to adapter
288
+ await this.syncFileBreakpoints(file);
289
+
290
+ return {
291
+ ref,
292
+ location: { url: file, line: entry.actualLine ?? line },
293
+ };
294
+ }
295
+
296
+ async removeBreakpoint(ref: string): Promise<void> {
297
+ this.requireConnected();
298
+
299
+ const entry = this.allBreakpoints.find((bp) => bp.ref === ref);
300
+ if (!entry) {
301
+ throw new Error(`Unknown breakpoint ref: ${ref}`);
302
+ }
303
+
304
+ // Remove from per-file tracking
305
+ const fileBreakpoints = this.breakpoints.get(entry.file);
306
+ if (fileBreakpoints) {
307
+ const idx = fileBreakpoints.indexOf(entry);
308
+ if (idx !== -1) fileBreakpoints.splice(idx, 1);
309
+ if (fileBreakpoints.length === 0) {
310
+ this.breakpoints.delete(entry.file);
311
+ }
312
+ }
313
+
314
+ // Remove from all-breakpoints list
315
+ const allIdx = this.allBreakpoints.indexOf(entry);
316
+ if (allIdx !== -1) this.allBreakpoints.splice(allIdx, 1);
317
+
318
+ // Remove from ref table
319
+ this.refs.remove(ref);
320
+
321
+ // Re-sync file breakpoints (or clear them if none left)
322
+ await this.syncFileBreakpoints(entry.file);
323
+ }
324
+
325
+ async removeAllBreakpoints(): Promise<void> {
326
+ this.requireConnected();
327
+
328
+ // Clear all files
329
+ const files = [...this.breakpoints.keys()];
330
+ this.breakpoints.clear();
331
+ this.allBreakpoints = [];
332
+ this.functionBreakpoints = [];
333
+
334
+ // Remove all BP refs
335
+ for (const entry of this.refs.list("BP")) {
336
+ this.refs.remove(entry.ref);
337
+ }
338
+
339
+ // Send empty breakpoints for each file
340
+ for (const file of files) {
341
+ await this.getDap().send("setBreakpoints", {
342
+ source: { path: file },
343
+ breakpoints: [],
344
+ });
345
+ }
346
+
347
+ // Clear function breakpoints
348
+ await this.getDap().send("setFunctionBreakpoints", { breakpoints: [] });
349
+ }
350
+
351
+ listBreakpoints(): Array<{
352
+ ref: string;
353
+ type: "BP" | "LP";
354
+ url: string;
355
+ line: number;
356
+ condition?: string;
357
+ }> {
358
+ const fileBps = this.allBreakpoints.map((bp) => ({
359
+ ref: bp.ref,
360
+ type: "BP" as const,
361
+ url: bp.file,
362
+ line: bp.actualLine ?? bp.line,
363
+ condition: bp.condition,
364
+ }));
365
+ const fnBps = this.functionBreakpoints.map((bp) => ({
366
+ ref: bp.ref,
367
+ type: "BP" as const,
368
+ url: bp.name,
369
+ line: 0,
370
+ condition: bp.condition,
371
+ }));
372
+ return [...fileBps, ...fnBps];
373
+ }
374
+
375
+ /**
376
+ * Set a breakpoint on a function by name (e.g. "__assert_rtn", "yoga::Style::operator==").
377
+ * DAP's setFunctionBreakpoints replaces the full set, so we track and re-send all.
378
+ */
379
+ async setFunctionBreakpoint(
380
+ name: string,
381
+ options?: { condition?: string; hitCount?: number },
382
+ ): Promise<{ ref: string }> {
383
+ this.requireConnected();
384
+
385
+ const entry: DapFunctionBreakpointEntry = {
386
+ ref: "",
387
+ name,
388
+ condition: options?.condition,
389
+ hitCondition: options?.hitCount ? String(options.hitCount) : undefined,
390
+ verified: false,
391
+ };
392
+
393
+ this.functionBreakpoints.push(entry);
394
+
395
+ const ref = this.refs.addBreakpoint(`dap-fn:${name}`, {
396
+ file: name,
397
+ line: 0,
398
+ });
399
+ entry.ref = ref;
400
+
401
+ await this.syncFunctionBreakpoints();
402
+ return { ref };
403
+ }
404
+
405
+ async removeFunctionBreakpoint(ref: string): Promise<void> {
406
+ this.requireConnected();
407
+
408
+ const idx = this.functionBreakpoints.findIndex((bp) => bp.ref === ref);
409
+ if (idx === -1) {
410
+ throw new Error(`Unknown function breakpoint ref: ${ref}`);
411
+ }
412
+
413
+ this.functionBreakpoints.splice(idx, 1);
414
+ this.refs.remove(ref);
415
+ await this.syncFunctionBreakpoints();
416
+ }
417
+
418
+ private async syncFunctionBreakpoints(): Promise<void> {
419
+ const dapBps = this.functionBreakpoints.map((bp) => ({
420
+ name: bp.name,
421
+ condition: bp.condition,
422
+ hitCondition: bp.hitCondition,
423
+ }));
424
+
425
+ const response = await this.getDap().send("setFunctionBreakpoints", {
426
+ breakpoints: dapBps,
427
+ });
428
+
429
+ const body = response.body as
430
+ | { breakpoints?: Array<{ id?: number; verified?: boolean }> }
431
+ | undefined;
432
+ const resultBps = body?.breakpoints ?? [];
433
+ for (let i = 0; i < this.functionBreakpoints.length; i++) {
434
+ const entry = this.functionBreakpoints[i];
435
+ const result = resultBps[i];
436
+ if (entry && result) {
437
+ entry.verified = result.verified ?? false;
438
+ }
439
+ }
440
+ }
441
+
442
+ // ── Inspection ────────────────────────────────────────────────────
443
+
444
+ async eval(
445
+ expression: string,
446
+ options: { frame?: string } = {},
447
+ ): Promise<{ ref: string; type: string; value: string; objectId?: string }> {
448
+ this.requireConnected();
449
+ this.requirePaused();
450
+ await this.ensureStack();
451
+
452
+ const frameId = this.resolveFrameId(options.frame);
453
+
454
+ const response = await this.getDap().send("evaluate", {
455
+ expression,
456
+ frameId,
457
+ context: "repl",
458
+ });
459
+
460
+ const body = response.body as {
461
+ result: string;
462
+ type?: string;
463
+ variablesReference: number;
464
+ };
465
+
466
+ const remoteId =
467
+ body.variablesReference > 0 ? String(body.variablesReference) : `eval:${Date.now()}`;
468
+ const ref = this.refs.addVar(remoteId, expression);
469
+
470
+ return {
471
+ ref,
472
+ type: body.type ?? "unknown",
473
+ value: body.result,
474
+ objectId: body.variablesReference > 0 ? String(body.variablesReference) : undefined,
475
+ };
476
+ }
477
+
478
+ async getVars(
479
+ options: { frame?: string; names?: string[]; allScopes?: boolean } = {},
480
+ ): Promise<Array<{ ref: string; name: string; type: string; value: string }>> {
481
+ this.requireConnected();
482
+ this.requirePaused();
483
+ await this.ensureStack();
484
+
485
+ const frameId = this.resolveFrameId(options.frame);
486
+
487
+ // Get scopes for the frame
488
+ const scopesResponse = await this.getDap().send("scopes", { frameId });
489
+ const scopes = (
490
+ scopesResponse.body as {
491
+ scopes: Array<{ name: string; variablesReference: number; expensive: boolean }>;
492
+ }
493
+ ).scopes;
494
+
495
+ const result: Array<{ ref: string; name: string; type: string; value: string }> = [];
496
+
497
+ // Fetch variables from each non-expensive scope (or all if allScopes)
498
+ const scopesToFetch = options.allScopes
499
+ ? scopes
500
+ : scopes.filter((s) => !s.expensive).slice(0, 2); // locals + args typically
501
+
502
+ for (const scope of scopesToFetch) {
503
+ const varsResponse = await this.getDap().send("variables", {
504
+ variablesReference: scope.variablesReference,
505
+ });
506
+
507
+ const variables = (
508
+ varsResponse.body as {
509
+ variables: Array<{
510
+ name: string;
511
+ value: string;
512
+ type?: string;
513
+ variablesReference: number;
514
+ }>;
515
+ }
516
+ ).variables;
517
+
518
+ for (const v of variables) {
519
+ if (options.names && !options.names.includes(v.name)) continue;
520
+
521
+ const remoteId =
522
+ v.variablesReference > 0 ? String(v.variablesReference) : `var:${v.name}:${Date.now()}`;
523
+ const ref = this.refs.addVar(remoteId, v.name);
524
+ result.push({
525
+ ref,
526
+ name: v.name,
527
+ type: v.type ?? "unknown",
528
+ value: v.value,
529
+ });
530
+ }
531
+ }
532
+
533
+ return result;
534
+ }
535
+
536
+ async getProps(
537
+ ref: string,
538
+ _options: { own?: boolean; internal?: boolean; depth?: number } = {},
539
+ ): Promise<
540
+ Array<{
541
+ ref?: string;
542
+ name: string;
543
+ type: string;
544
+ value: string;
545
+ isOwn?: boolean;
546
+ }>
547
+ > {
548
+ this.requireConnected();
549
+
550
+ const remoteId = this.refs.resolveId(ref);
551
+ if (!remoteId) {
552
+ throw new Error(`Unknown ref: ${ref}`);
553
+ }
554
+
555
+ const variablesReference = parseInt(remoteId, 10);
556
+ if (Number.isNaN(variablesReference) || variablesReference <= 0) {
557
+ return [];
558
+ }
559
+
560
+ const response = await this.getDap().send("variables", { variablesReference });
561
+ const variables = (
562
+ response.body as {
563
+ variables: Array<{
564
+ name: string;
565
+ value: string;
566
+ type?: string;
567
+ variablesReference: number;
568
+ }>;
569
+ }
570
+ ).variables;
571
+
572
+ return variables.map((v) => {
573
+ const childRemoteId =
574
+ v.variablesReference > 0 ? String(v.variablesReference) : `prop:${v.name}:${Date.now()}`;
575
+ const childRef =
576
+ v.variablesReference > 0 ? this.refs.addVar(childRemoteId, v.name) : undefined;
577
+ return {
578
+ ref: childRef,
579
+ name: v.name,
580
+ type: v.type ?? "unknown",
581
+ value: v.value,
582
+ isOwn: true,
583
+ };
584
+ });
585
+ }
586
+
587
+ getStack(_options: { asyncDepth?: number; generated?: boolean } = {}): Array<{
588
+ ref: string;
589
+ functionName: string;
590
+ file: string;
591
+ line: number;
592
+ column?: number;
593
+ }> {
594
+ // Return cached stack frames from last stopped event
595
+ return this._stackFrames.map((frame) => {
596
+ const ref = this.refs.addFrame(String(frame.id), frame.name);
597
+ return {
598
+ ref,
599
+ functionName: frame.name,
600
+ file: frame.file ?? "<unknown>",
601
+ line: frame.line,
602
+ column: frame.column > 0 ? frame.column : undefined,
603
+ };
604
+ });
605
+ }
606
+
607
+ async getSource(options: { file?: string; lines?: number; all?: boolean } = {}): Promise<{
608
+ url: string;
609
+ lines: Array<{ line: number; text: string; current?: boolean }>;
610
+ }> {
611
+ // For native debuggers, read source from the filesystem
612
+ const file = options.file ?? this._pauseInfo?.url;
613
+ if (!file) {
614
+ throw new Error("No source file available. Specify a file path.");
615
+ }
616
+
617
+ let content: string;
618
+ try {
619
+ content = await Bun.file(file).text();
620
+ } catch {
621
+ throw new Error(`Cannot read source file: ${file}`);
622
+ }
623
+
624
+ const allLines = content.split("\n");
625
+ const currentLine = this._pauseInfo?.line;
626
+ const windowSize = options.lines ?? 10;
627
+
628
+ let startLine: number;
629
+ let endLine: number;
630
+
631
+ if (options.all) {
632
+ startLine = 1;
633
+ endLine = allLines.length;
634
+ } else if (currentLine !== undefined) {
635
+ startLine = Math.max(1, currentLine - windowSize);
636
+ endLine = Math.min(allLines.length, currentLine + windowSize);
637
+ } else {
638
+ startLine = 1;
639
+ endLine = Math.min(allLines.length, windowSize * 2);
640
+ }
641
+
642
+ const lines: Array<{ line: number; text: string; current?: boolean }> = [];
643
+ for (let i = startLine; i <= endLine; i++) {
644
+ const lineObj: { line: number; text: string; current?: boolean } = {
645
+ line: i,
646
+ text: allLines[i - 1] ?? "",
647
+ };
648
+ if (currentLine !== undefined && i === currentLine) {
649
+ lineObj.current = true;
650
+ }
651
+ lines.push(lineObj);
652
+ }
653
+
654
+ return { url: file, lines };
655
+ }
656
+
657
+ async buildState(options: StateOptions = {}): Promise<StateSnapshot> {
658
+ if (this.isPaused()) await this.ensureStack();
659
+
660
+ const snapshot: StateSnapshot = {
661
+ status: this._state,
662
+ };
663
+
664
+ if (this._state === "paused" && this._pauseInfo) {
665
+ snapshot.reason = this._pauseInfo.reason;
666
+ if (this._pauseInfo.url && this._pauseInfo.line !== undefined) {
667
+ snapshot.location = {
668
+ url: this._pauseInfo.url,
669
+ line: this._pauseInfo.line,
670
+ column: this._pauseInfo.column,
671
+ };
672
+ }
673
+ }
674
+
675
+ // Include source code if paused and code not explicitly disabled
676
+ if (this._state === "paused" && options.code !== false) {
677
+ try {
678
+ const source = await this.getSource({ lines: options.lines });
679
+ snapshot.source = source;
680
+ } catch {
681
+ // Source may not be available
682
+ }
683
+ }
684
+
685
+ // Include variables if requested or if not compact
686
+ if (this._state === "paused" && (options.vars !== false || !options.compact)) {
687
+ try {
688
+ const vars = await this.getVars({ frame: options.frame, allScopes: options.allScopes });
689
+ snapshot.vars = vars.map((v) => ({
690
+ ref: v.ref,
691
+ name: v.name,
692
+ value: v.value,
693
+ scope: "local",
694
+ }));
695
+ } catch {
696
+ // Variables may not be available
697
+ }
698
+ }
699
+
700
+ // Include stack if requested
701
+ if (this._state === "paused" && options.stack !== false) {
702
+ try {
703
+ snapshot.stack = this.getStack();
704
+ } catch {
705
+ // Stack may not be available
706
+ }
707
+ }
708
+
709
+ if (options.breakpoints !== false) {
710
+ snapshot.breakpointCount = this.allBreakpoints.length;
711
+ }
712
+
713
+ return snapshot;
714
+ }
715
+
716
+ // Console & exceptions
717
+ getConsoleMessages(
718
+ options: { level?: string; since?: number; clear?: boolean } = {},
719
+ ): ConsoleMessage[] {
720
+ let msgs = this._consoleMessages;
721
+ if (options.level) {
722
+ msgs = msgs.filter((m) => m.level === options.level);
723
+ }
724
+ if (options.since !== undefined) {
725
+ const since = options.since;
726
+ msgs = msgs.filter((m) => m.timestamp >= since);
727
+ }
728
+ if (options.clear) {
729
+ this._consoleMessages = [];
730
+ }
731
+ return msgs;
732
+ }
733
+
734
+ getExceptions(options: { since?: number } = {}): ExceptionEntry[] {
735
+ let entries = this._exceptionEntries;
736
+ if (options.since !== undefined) {
737
+ const since = options.since;
738
+ entries = entries.filter((e) => e.timestamp >= since);
739
+ }
740
+ return entries;
741
+ }
742
+
743
+ // ── Unsupported methods (throw descriptive errors) ────────────────
744
+
745
+ async setLogpoint(
746
+ _file: string,
747
+ _line: number,
748
+ _template: string,
749
+ _options?: { condition?: string; maxEmissions?: number },
750
+ ): Promise<never> {
751
+ throw new Error(
752
+ "Logpoints are not supported in DAP mode. Use breakpoints with conditions instead.",
753
+ );
754
+ }
755
+
756
+ async setExceptionPause(mode: "all" | "uncaught" | "caught" | "none"): Promise<void> {
757
+ this.requireConnected();
758
+ // DAP supports exception breakpoints through setExceptionBreakpoints.
759
+ // Use the adapter's declared exception breakpoint filters if available.
760
+ const available = this.capabilities.exceptionBreakpointFilters ?? [];
761
+ const filterIds = available.map((f) => f.filter);
762
+ let filters: string[];
763
+ if (mode === "none") {
764
+ filters = [];
765
+ } else if (mode === "all") {
766
+ filters = filterIds; // enable all supported filters
767
+ } else {
768
+ // Best-effort: look for filters containing the mode keyword
769
+ filters = filterIds.filter((id) => id.includes(mode));
770
+ if (filters.length === 0) filters = filterIds; // fallback to all
771
+ }
772
+ await this.getDap().send("setExceptionBreakpoints", { filters });
773
+ }
774
+
775
+ async toggleBreakpoint(_ref: string): Promise<never> {
776
+ throw new Error(
777
+ "Breakpoint toggling is not yet supported in DAP mode. Use break-rm and break.",
778
+ );
779
+ }
780
+
781
+ async getBreakableLocations(_file: string, _startLine: number, _endLine: number): Promise<never> {
782
+ throw new Error("Breakable locations are not supported in DAP mode.");
783
+ }
784
+
785
+ async hotpatch(_file: string, _source: string, _options?: { dryRun?: boolean }): Promise<never> {
786
+ throw new Error("Hot-patching is not supported in DAP mode.");
787
+ }
788
+
789
+ async searchInScripts(_query: string, _options?: Record<string, unknown>): Promise<never> {
790
+ throw new Error(
791
+ "Script search is not supported in DAP mode. Use your shell to search source files.",
792
+ );
793
+ }
794
+
795
+ async setVariable(
796
+ varName: string,
797
+ value: string,
798
+ options: { frame?: string } = {},
799
+ ): Promise<{ name: string; newValue: string; type: string }> {
800
+ this.requireConnected();
801
+ this.requirePaused();
802
+ await this.ensureStack();
803
+
804
+ const frameId = this.resolveFrameId(options.frame);
805
+ // Get the scopes to find the variable
806
+ const scopesResponse = await this.getDap().send("scopes", { frameId });
807
+ const scopes = (scopesResponse.body as { scopes: Array<{ variablesReference: number }> })
808
+ .scopes;
809
+
810
+ // Try setting in each scope
811
+ for (const scope of scopes) {
812
+ try {
813
+ const response = await this.getDap().send("setVariable", {
814
+ variablesReference: scope.variablesReference,
815
+ name: varName,
816
+ value,
817
+ });
818
+ const body = response.body as { value: string; type?: string };
819
+ return { name: varName, newValue: body.value, type: body.type ?? "unknown" };
820
+ } catch {
821
+ // Variable not in this scope, try next
822
+ }
823
+ }
824
+
825
+ throw new Error(`Variable "${varName}" not found in any scope`);
826
+ }
827
+
828
+ async setReturnValue(_value: string): Promise<never> {
829
+ throw new Error("Setting return values is not supported in DAP mode.");
830
+ }
831
+
832
+ async restartFrame(_frameRef?: string): Promise<never> {
833
+ throw new Error("Frame restart is not supported in DAP mode.");
834
+ }
835
+
836
+ async runTo(_file: string, _line: number): Promise<never> {
837
+ throw new Error(
838
+ "Run-to-location is not yet supported in DAP mode. Set a breakpoint and continue.",
839
+ );
840
+ }
841
+
842
+ getScripts(_filter?: string): Array<{ scriptId: string; url: string }> {
843
+ // DAP doesn't have a script list concept like CDP
844
+ return [];
845
+ }
846
+
847
+ async addBlackbox(_patterns: string[]): Promise<never> {
848
+ throw new Error("Blackboxing is not supported in DAP mode.");
849
+ }
850
+
851
+ listBlackbox(): string[] {
852
+ return [];
853
+ }
854
+
855
+ async removeBlackbox(_patterns: string[]): Promise<never> {
856
+ throw new Error("Blackboxing is not supported in DAP mode.");
857
+ }
858
+
859
+ async restart(): Promise<never> {
860
+ throw new Error("Restart is not yet supported in DAP mode. Use stop + launch.");
861
+ }
862
+
863
+ // Expose a no-op sourceMapResolver-like object so entry.ts doesn't crash
864
+ get sourceMapResolver(): {
865
+ findScriptForSource: (_: string) => null;
866
+ getInfo: (_: string) => null;
867
+ getAllInfos: () => [];
868
+ setDisabled: (_: boolean) => void;
869
+ } {
870
+ return {
871
+ findScriptForSource: () => null,
872
+ getInfo: () => null,
873
+ getAllInfos: () => [],
874
+ setDisabled: () => {},
875
+ };
876
+ }
877
+
878
+ // ── Private helpers ───────────────────────────────────────────────
879
+
880
+ private isPaused(): boolean {
881
+ return this._state === "paused";
882
+ }
883
+
884
+ /** Ensure stack frames are loaded if we're paused. */
885
+ private async ensureStack(): Promise<void> {
886
+ if (this.isPaused() && this._stackFrames.length === 0) {
887
+ await this.fetchStackTrace();
888
+ }
889
+ }
890
+
891
+ /** Returns the DAP client, throwing if not connected. Call after requireConnected(). */
892
+ private getDap(): DapClient {
893
+ if (!this.dap || !this.dap.connected) {
894
+ throw new Error("Not connected to a debug adapter. Use launch or attach first.");
895
+ }
896
+ return this.dap;
897
+ }
898
+
899
+ private requireConnected(): void {
900
+ this.getDap();
901
+ }
902
+
903
+ private requirePaused(): void {
904
+ if (!this.isPaused()) {
905
+ throw new Error("Target is not paused. Use pause or wait for a breakpoint.");
906
+ }
907
+ }
908
+
909
+ private async initializeAdapter(): Promise<void> {
910
+ const response = await this.getDap().send("initialize", {
911
+ adapterID: this._runtime,
912
+ clientID: "agent-dbg",
913
+ clientName: "agent-dbg",
914
+ linesStartAt1: true,
915
+ columnsStartAt1: true,
916
+ pathFormat: "path",
917
+ supportsVariableType: true,
918
+ });
919
+
920
+ this.capabilities = (response.body ?? {}) as DebugProtocol.Capabilities;
921
+ }
922
+
923
+ private setupEventHandlers(): void {
924
+ const dap = this.getDap();
925
+
926
+ dap.on("stopped", (body: unknown) => {
927
+ const event = body as {
928
+ reason: string;
929
+ threadId?: number;
930
+ description?: string;
931
+ text?: string;
932
+ allThreadsStopped?: boolean;
933
+ };
934
+
935
+ this._state = "paused";
936
+ if (event.threadId !== undefined) {
937
+ this._threadId = event.threadId;
938
+ }
939
+
940
+ this._pauseInfo = {
941
+ reason: event.reason,
942
+ };
943
+
944
+ if (this.stoppedWaiter) {
945
+ // Waiter exists: caller (continue/step/pause) will fetch stack after resolve
946
+ this.stoppedWaiter.resolve();
947
+ this.stoppedWaiter = null;
948
+ } else {
949
+ // No waiter: external polling will see paused state, eagerly fetch stack
950
+ this.fetchStackTrace().catch(() => {});
951
+ }
952
+ });
953
+
954
+ dap.on("continued", (_body: unknown) => {
955
+ this._state = "running";
956
+ this._pauseInfo = null;
957
+ this._stackFrames = [];
958
+ this.refs.clearVolatile();
959
+ });
960
+
961
+ dap.on("terminated", (_body: unknown) => {
962
+ this._state = "idle";
963
+ this._pauseInfo = null;
964
+ this._stackFrames = [];
965
+
966
+ // Resolve any waiting promise
967
+ this.stoppedWaiter?.resolve();
968
+ this.stoppedWaiter = null;
969
+ });
970
+
971
+ dap.on("exited", (_body: unknown) => {
972
+ this._state = "idle";
973
+ this._pauseInfo = null;
974
+
975
+ this.stoppedWaiter?.resolve();
976
+ this.stoppedWaiter = null;
977
+ });
978
+
979
+ dap.on("output", (body: unknown) => {
980
+ const event = body as {
981
+ category?: string;
982
+ output: string;
983
+ source?: { path?: string };
984
+ line?: number;
985
+ };
986
+
987
+ const category = event.category ?? "console";
988
+ if (category === "stdout" || category === "console") {
989
+ this._consoleMessages.push({
990
+ timestamp: Date.now(),
991
+ level: "log",
992
+ text: event.output.trimEnd(),
993
+ url: event.source?.path,
994
+ line: event.line,
995
+ });
996
+ } else if (category === "stderr") {
997
+ this._consoleMessages.push({
998
+ timestamp: Date.now(),
999
+ level: "error",
1000
+ text: event.output.trimEnd(),
1001
+ url: event.source?.path,
1002
+ line: event.line,
1003
+ });
1004
+ }
1005
+
1006
+ if (this._consoleMessages.length > 1000) {
1007
+ this._consoleMessages.shift();
1008
+ }
1009
+ });
1010
+ }
1011
+
1012
+ private async fetchStackTrace(): Promise<void> {
1013
+ // Deduplicate: if a fetch is already in progress, just await it
1014
+ if (this._stackFetchPromise) {
1015
+ await this._stackFetchPromise;
1016
+ return;
1017
+ }
1018
+ this._stackFetchPromise = this._fetchStackTraceImpl();
1019
+ try {
1020
+ await this._stackFetchPromise;
1021
+ } finally {
1022
+ this._stackFetchPromise = null;
1023
+ }
1024
+ }
1025
+
1026
+ private async _fetchStackTraceImpl(): Promise<void> {
1027
+ if (!this.dap || this._state !== "paused") return;
1028
+
1029
+ try {
1030
+ const response = await this.dap.send("stackTrace", {
1031
+ threadId: this._threadId,
1032
+ startFrame: 0,
1033
+ levels: 50,
1034
+ });
1035
+
1036
+ const body = response.body as {
1037
+ stackFrames: Array<{
1038
+ id: number;
1039
+ name: string;
1040
+ source?: { path?: string; name?: string };
1041
+ line: number;
1042
+ column: number;
1043
+ }>;
1044
+ };
1045
+
1046
+ this._stackFrames = body.stackFrames.map((f) => ({
1047
+ id: f.id,
1048
+ name: f.name,
1049
+ file: f.source?.path ?? f.source?.name,
1050
+ line: f.line,
1051
+ column: f.column,
1052
+ }));
1053
+
1054
+ // Update pauseInfo with top-of-stack location
1055
+ const topFrame = this._stackFrames[0];
1056
+ if (topFrame && this._pauseInfo) {
1057
+ this._pauseInfo.url = topFrame.file;
1058
+ this._pauseInfo.line = topFrame.line;
1059
+ this._pauseInfo.column = topFrame.column > 0 ? topFrame.column : undefined;
1060
+ this._pauseInfo.callFrameCount = this._stackFrames.length;
1061
+ }
1062
+ } catch {
1063
+ // Stack trace may not be available
1064
+ }
1065
+ }
1066
+
1067
+ private resolveFrameId(frameRef?: string): number {
1068
+ if (!frameRef) {
1069
+ // Default to top frame
1070
+ const topFrame = this._stackFrames[0];
1071
+ if (!topFrame) {
1072
+ throw new Error("No stack frames available");
1073
+ }
1074
+ return topFrame.id;
1075
+ }
1076
+
1077
+ const remoteId = this.refs.resolveId(frameRef);
1078
+ if (!remoteId) {
1079
+ throw new Error(`Unknown frame ref: ${frameRef}`);
1080
+ }
1081
+ return parseInt(remoteId, 10);
1082
+ }
1083
+
1084
+ private async syncFileBreakpoints(file: string): Promise<void> {
1085
+ const entries = this.breakpoints.get(file) ?? [];
1086
+
1087
+ const dapBreakpoints = entries.map((bp) => {
1088
+ const sbp: Record<string, unknown> = { line: bp.line };
1089
+ if (bp.condition) sbp.condition = bp.condition;
1090
+ if (bp.hitCondition) sbp.hitCondition = bp.hitCondition;
1091
+ return sbp;
1092
+ });
1093
+
1094
+ const response = await this.getDap().send("setBreakpoints", {
1095
+ source: { path: file },
1096
+ breakpoints: dapBreakpoints,
1097
+ });
1098
+
1099
+ // Update entries with actual verified locations
1100
+ const body = response.body as {
1101
+ breakpoints: Array<{
1102
+ id?: number;
1103
+ verified: boolean;
1104
+ line?: number;
1105
+ }>;
1106
+ };
1107
+
1108
+ for (let i = 0; i < entries.length && i < body.breakpoints.length; i++) {
1109
+ const bp = body.breakpoints[i];
1110
+ const entry = entries[i];
1111
+ if (bp && entry) {
1112
+ entry.dapId = bp.id;
1113
+ entry.verified = bp.verified;
1114
+ entry.actualLine = bp.line ?? entry.line;
1115
+ }
1116
+ }
1117
+ }
1118
+
1119
+ private createStoppedWaiter(timeoutMs: number): Promise<void> {
1120
+ return new Promise<void>((resolve, reject) => {
1121
+ const timer = setTimeout(() => {
1122
+ this.stoppedWaiter = null;
1123
+ // Don't reject — the process is still running, just resolve
1124
+ resolve();
1125
+ }, timeoutMs);
1126
+
1127
+ this.stoppedWaiter = {
1128
+ resolve: () => {
1129
+ clearTimeout(timer);
1130
+ this.stoppedWaiter = null;
1131
+ resolve();
1132
+ },
1133
+ reject: (e: Error) => {
1134
+ clearTimeout(timer);
1135
+ this.stoppedWaiter = null;
1136
+ reject(e);
1137
+ },
1138
+ };
1139
+ });
1140
+ }
1141
+
1142
+ private async waitForStop(timeoutMs: number): Promise<void> {
1143
+ if (!this.isPaused()) {
1144
+ await this.createStoppedWaiter(timeoutMs);
1145
+ }
1146
+ // Fetch the stack trace if paused and not yet loaded
1147
+ if (this.isPaused() && this._stackFrames.length === 0) {
1148
+ await this.fetchStackTrace();
1149
+ }
1150
+ }
1151
+ }