agent-dbg 0.1.0

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 (99) hide show
  1. package/.bin/ndbg +0 -0
  2. package/.claude/settings.local.json +21 -0
  3. package/.claude/skills/ndbg-debugger/ndbg-debugger/SKILL.md +116 -0
  4. package/.claude/skills/ndbg-debugger/ndbg-debugger/references/commands.md +173 -0
  5. package/CLAUDE.md +43 -0
  6. package/PROGRESS.md +261 -0
  7. package/README.md +67 -0
  8. package/biome.json +41 -0
  9. package/ndbg-spec.md +958 -0
  10. package/package.json +30 -0
  11. package/src/cdp/client.ts +198 -0
  12. package/src/cdp/types.ts +16 -0
  13. package/src/cli/parser.ts +287 -0
  14. package/src/cli/registry.ts +7 -0
  15. package/src/cli/types.ts +24 -0
  16. package/src/commands/attach.ts +47 -0
  17. package/src/commands/blackbox-ls.ts +38 -0
  18. package/src/commands/blackbox-rm.ts +57 -0
  19. package/src/commands/blackbox.ts +48 -0
  20. package/src/commands/break-ls.ts +57 -0
  21. package/src/commands/break-rm.ts +40 -0
  22. package/src/commands/break-toggle.ts +42 -0
  23. package/src/commands/break.ts +145 -0
  24. package/src/commands/breakable.ts +69 -0
  25. package/src/commands/catch.ts +38 -0
  26. package/src/commands/console.ts +61 -0
  27. package/src/commands/continue.ts +46 -0
  28. package/src/commands/eval.ts +70 -0
  29. package/src/commands/exceptions.ts +61 -0
  30. package/src/commands/hotpatch.ts +67 -0
  31. package/src/commands/launch.ts +69 -0
  32. package/src/commands/logpoint.ts +78 -0
  33. package/src/commands/pause.ts +46 -0
  34. package/src/commands/props.ts +77 -0
  35. package/src/commands/restart-frame.ts +36 -0
  36. package/src/commands/run-to.ts +70 -0
  37. package/src/commands/scripts.ts +57 -0
  38. package/src/commands/search.ts +73 -0
  39. package/src/commands/sessions.ts +71 -0
  40. package/src/commands/set-return.ts +49 -0
  41. package/src/commands/set.ts +61 -0
  42. package/src/commands/source.ts +59 -0
  43. package/src/commands/sourcemap.ts +66 -0
  44. package/src/commands/stack.ts +64 -0
  45. package/src/commands/state.ts +124 -0
  46. package/src/commands/status.ts +57 -0
  47. package/src/commands/step.ts +50 -0
  48. package/src/commands/stop.ts +27 -0
  49. package/src/commands/vars.ts +71 -0
  50. package/src/daemon/client.ts +147 -0
  51. package/src/daemon/entry.ts +242 -0
  52. package/src/daemon/paths.ts +26 -0
  53. package/src/daemon/server.ts +185 -0
  54. package/src/daemon/session-blackbox.ts +41 -0
  55. package/src/daemon/session-breakpoints.ts +492 -0
  56. package/src/daemon/session-execution.ts +121 -0
  57. package/src/daemon/session-inspection.ts +701 -0
  58. package/src/daemon/session-mutation.ts +197 -0
  59. package/src/daemon/session-state.ts +258 -0
  60. package/src/daemon/session.ts +938 -0
  61. package/src/daemon/spawn.ts +53 -0
  62. package/src/formatter/errors.ts +15 -0
  63. package/src/formatter/source.ts +74 -0
  64. package/src/formatter/stack.ts +70 -0
  65. package/src/formatter/values.ts +269 -0
  66. package/src/formatter/variables.ts +20 -0
  67. package/src/main.ts +45 -0
  68. package/src/protocol/messages.ts +316 -0
  69. package/src/refs/ref-table.ts +120 -0
  70. package/src/refs/resolver.ts +24 -0
  71. package/src/sourcemap/resolver.ts +318 -0
  72. package/tests/fixtures/async-app.js +34 -0
  73. package/tests/fixtures/console-app.js +12 -0
  74. package/tests/fixtures/error-app.js +28 -0
  75. package/tests/fixtures/exception-app.js +6 -0
  76. package/tests/fixtures/inspect-app.js +10 -0
  77. package/tests/fixtures/mutation-app.js +9 -0
  78. package/tests/fixtures/simple-app.js +50 -0
  79. package/tests/fixtures/step-app.js +13 -0
  80. package/tests/fixtures/ts-app/src/app.ts +21 -0
  81. package/tests/fixtures/ts-app/tsconfig.json +14 -0
  82. package/tests/integration/blackbox.test.ts +135 -0
  83. package/tests/integration/break-extras.test.ts +241 -0
  84. package/tests/integration/breakpoint.test.ts +217 -0
  85. package/tests/integration/console.test.ts +275 -0
  86. package/tests/integration/execution.test.ts +247 -0
  87. package/tests/integration/inspection.test.ts +311 -0
  88. package/tests/integration/mutation.test.ts +178 -0
  89. package/tests/integration/session.test.ts +223 -0
  90. package/tests/integration/source.test.ts +209 -0
  91. package/tests/integration/sourcemap.test.ts +214 -0
  92. package/tests/integration/state.test.ts +208 -0
  93. package/tests/unit/cdp-client.test.ts +422 -0
  94. package/tests/unit/daemon.test.ts +286 -0
  95. package/tests/unit/formatter.test.ts +716 -0
  96. package/tests/unit/parser.test.ts +105 -0
  97. package/tests/unit/refs.test.ts +383 -0
  98. package/tests/unit/sourcemap.test.ts +236 -0
  99. package/tsconfig.json +32 -0
@@ -0,0 +1,938 @@
1
+ import type { Subprocess } from "bun";
2
+ import type Protocol from "devtools-protocol/types/protocol.js";
3
+ import { CdpClient } from "../cdp/client.ts";
4
+ import type { RemoteObject } from "../formatter/values.ts";
5
+ import { formatValue } from "../formatter/values.ts";
6
+ import { RefTable } from "../refs/ref-table.ts";
7
+ import { SourceMapResolver } from "../sourcemap/resolver.ts";
8
+ import {
9
+ addBlackbox as addBlackboxImpl,
10
+ listBlackbox as listBlackboxImpl,
11
+ removeBlackbox as removeBlackboxImpl,
12
+ } from "./session-blackbox.ts";
13
+ import {
14
+ getBreakableLocations as getBreakableLocationsImpl,
15
+ listBreakpoints as listBreakpointsImpl,
16
+ removeAllBreakpoints as removeAllBreakpointsImpl,
17
+ removeBreakpoint as removeBreakpointImpl,
18
+ setBreakpoint as setBreakpointImpl,
19
+ setExceptionPause as setExceptionPauseImpl,
20
+ setLogpoint as setLogpointImpl,
21
+ toggleBreakpoint as toggleBreakpointImpl,
22
+ } from "./session-breakpoints.ts";
23
+ import {
24
+ continueExecution,
25
+ pauseExecution,
26
+ restartFrameExecution,
27
+ runToLocation,
28
+ stepExecution,
29
+ } from "./session-execution.ts";
30
+ import {
31
+ clearConsole as clearConsoleImpl,
32
+ evalExpression,
33
+ getConsoleMessages as getConsoleMessagesImpl,
34
+ getExceptions as getExceptionsImpl,
35
+ getProps as getPropsImpl,
36
+ getScripts as getScriptsImpl,
37
+ getSource as getSourceImpl,
38
+ getStack as getStackImpl,
39
+ getVars as getVarsImpl,
40
+ searchInScripts as searchInScriptsImpl,
41
+ } from "./session-inspection.ts";
42
+ import {
43
+ hotpatch as hotpatchImpl,
44
+ setReturnValue as setReturnValueImpl,
45
+ setVariable as setVariableImpl,
46
+ } from "./session-mutation.ts";
47
+ import { buildState as buildStateImpl } from "./session-state.ts";
48
+
49
+ export interface PauseInfo {
50
+ reason: string;
51
+ scriptId?: string;
52
+ url?: string;
53
+ line?: number;
54
+ column?: number;
55
+ callFrameCount?: number;
56
+ }
57
+
58
+ export interface StateOptions {
59
+ vars?: boolean;
60
+ stack?: boolean;
61
+ breakpoints?: boolean;
62
+ code?: boolean;
63
+ compact?: boolean;
64
+ depth?: number;
65
+ lines?: number;
66
+ frame?: string; // @fN ref
67
+ allScopes?: boolean;
68
+ generated?: boolean;
69
+ }
70
+
71
+ export interface StateSnapshot {
72
+ status: string; // "paused" | "running" | "idle"
73
+ reason?: string;
74
+ location?: { url: string; line: number; column?: number };
75
+ source?: { lines: Array<{ line: number; text: string; current?: boolean }> };
76
+ locals?: Array<{ ref: string; name: string; value: string }>;
77
+ stack?: Array<{
78
+ ref: string;
79
+ functionName: string;
80
+ file: string;
81
+ line: number;
82
+ column?: number;
83
+ isAsync?: boolean;
84
+ }>;
85
+ breakpointCount?: number;
86
+ }
87
+
88
+ export interface ConsoleMessage {
89
+ timestamp: number;
90
+ level: string; // "log" | "warn" | "error" | "info" | "debug" | "trace"
91
+ text: string;
92
+ args?: string[]; // formatted args
93
+ url?: string;
94
+ line?: number;
95
+ }
96
+
97
+ export interface ExceptionEntry {
98
+ timestamp: number;
99
+ text: string;
100
+ description?: string;
101
+ url?: string;
102
+ line?: number;
103
+ column?: number;
104
+ stackTrace?: string;
105
+ }
106
+
107
+ export interface ScriptInfo {
108
+ scriptId: string;
109
+ url: string;
110
+ sourceMapURL?: string;
111
+ }
112
+
113
+ export interface LaunchResult {
114
+ pid: number;
115
+ wsUrl: string;
116
+ paused: boolean;
117
+ pauseInfo?: PauseInfo;
118
+ }
119
+
120
+ export interface AttachResult {
121
+ wsUrl: string;
122
+ }
123
+
124
+ export interface SessionStatus {
125
+ session: string;
126
+ state: "idle" | "running" | "paused";
127
+ pid?: number;
128
+ wsUrl?: string;
129
+ pauseInfo?: PauseInfo;
130
+ uptime: number;
131
+ scriptCount: number;
132
+ }
133
+
134
+ const INSPECTOR_URL_REGEX = /Debugger listening on (wss?:\/\/\S+)/;
135
+ const INSPECTOR_TIMEOUT_MS = 5_000;
136
+
137
+ export class DebugSession {
138
+ cdp: CdpClient | null = null;
139
+ refs: RefTable = new RefTable();
140
+ sourceMapResolver: SourceMapResolver = new SourceMapResolver();
141
+ childProcess: Subprocess<"ignore", "pipe", "pipe"> | null = null;
142
+ state: "idle" | "running" | "paused" = "idle";
143
+ pauseInfo: PauseInfo | null = null;
144
+ pausedCallFrames: Protocol.Debugger.CallFrame[] = [];
145
+ scripts: Map<string, ScriptInfo> = new Map();
146
+ wsUrl: string | null = null;
147
+ startTime: number = Date.now();
148
+ session: string;
149
+ onProcessExit: (() => void) | null = null;
150
+ consoleMessages: Array<ConsoleMessage> = [];
151
+ exceptionEntries: Array<ExceptionEntry> = [];
152
+ blackboxPatterns: string[] = [];
153
+ disabledBreakpoints: Map<string, { breakpointId: string; meta: Record<string, unknown> }> =
154
+ new Map();
155
+
156
+ constructor(session: string) {
157
+ this.session = session;
158
+ }
159
+
160
+ // ── Session lifecycle ─────────────────────────────────────────────
161
+
162
+ async launch(
163
+ command: string[],
164
+ options: { brk?: boolean; port?: number } = {},
165
+ ): Promise<LaunchResult> {
166
+ if (this.state !== "idle") {
167
+ throw new Error("Session already has an active debug target");
168
+ }
169
+
170
+ if (command.length === 0) {
171
+ throw new Error("Command array must not be empty");
172
+ }
173
+
174
+ const brk = options.brk ?? true;
175
+ const port = options.port ?? 0;
176
+ const inspectFlag = brk ? `--inspect-brk=${port}` : `--inspect=${port}`;
177
+
178
+ // Build the args: inject inspect flag after the runtime (first element)
179
+ const runtime = command[0] as string;
180
+ const rest = command.slice(1);
181
+ const spawnArgs = [runtime, inspectFlag, ...rest];
182
+
183
+ const proc = Bun.spawn(spawnArgs, {
184
+ stdin: "ignore",
185
+ stdout: "pipe",
186
+ stderr: "pipe",
187
+ });
188
+ this.childProcess = proc;
189
+
190
+ // Monitor child process exit in the background
191
+ this.monitorProcessExit(proc);
192
+
193
+ // Read stderr to find the inspector URL
194
+ const wsUrl = await this.readInspectorUrl(proc.stderr);
195
+ this.wsUrl = wsUrl;
196
+
197
+ // Connect CDP
198
+ await this.connectCdp(wsUrl);
199
+
200
+ // If brk mode, ensure the session enters "paused" state.
201
+ // On older Node.js versions, Debugger.paused fires automatically after
202
+ // Debugger.enable. On newer versions (v24+), the initial --inspect-brk
203
+ // pause does not emit the event, so we request an explicit pause and then
204
+ // signal Runtime.runIfWaitingForDebugger so the process starts execution
205
+ // and immediately hits our pause request.
206
+ if (brk) {
207
+ await this.waitForBrkPause();
208
+ }
209
+
210
+ const result: LaunchResult = {
211
+ pid: proc.pid,
212
+ wsUrl,
213
+ paused: this.sessionState === "paused",
214
+ };
215
+
216
+ if (this.pauseInfo) {
217
+ result.pauseInfo = this.pauseInfo;
218
+ }
219
+
220
+ return result;
221
+ }
222
+
223
+ async attach(target: string): Promise<AttachResult> {
224
+ if (this.state !== "idle" && !this.cdp) {
225
+ throw new Error("Session already has an active debug target");
226
+ }
227
+
228
+ let wsUrl: string;
229
+
230
+ if (target.startsWith("ws://") || target.startsWith("wss://")) {
231
+ wsUrl = target;
232
+ } else {
233
+ // Treat as a port number
234
+ const port = parseInt(target, 10);
235
+ if (Number.isNaN(port) || port <= 0 || port > 65535) {
236
+ throw new Error(
237
+ `Invalid attach target: "${target}". Provide a ws:// URL or a port number.`,
238
+ );
239
+ }
240
+ wsUrl = await this.discoverWsUrl(port);
241
+ }
242
+
243
+ this.wsUrl = wsUrl;
244
+ await this.connectCdp(wsUrl);
245
+
246
+ return { wsUrl };
247
+ }
248
+
249
+ getStatus(): SessionStatus {
250
+ const status: SessionStatus = {
251
+ session: this.session,
252
+ state: this.state,
253
+ uptime: Math.floor((Date.now() - this.startTime) / 1000),
254
+ scriptCount: this.scripts.size,
255
+ };
256
+
257
+ if (this.childProcess) {
258
+ status.pid = this.childProcess.pid;
259
+ }
260
+
261
+ if (this.wsUrl) {
262
+ status.wsUrl = this.wsUrl;
263
+ }
264
+
265
+ if (this.pauseInfo) {
266
+ // Source-map translate pauseInfo for display
267
+ const translated = { ...this.pauseInfo };
268
+ if (translated.scriptId && translated.line !== undefined) {
269
+ const resolved = this.resolveOriginalLocation(
270
+ translated.scriptId,
271
+ translated.line + 1, // pauseInfo.line is 0-based
272
+ translated.column ?? 0,
273
+ );
274
+ if (resolved) {
275
+ translated.url = resolved.url;
276
+ translated.line = resolved.line - 1; // back to 0-based for pauseInfo
277
+ if (resolved.column !== undefined) {
278
+ translated.column = resolved.column - 1;
279
+ }
280
+ }
281
+ }
282
+ status.pauseInfo = translated;
283
+ }
284
+
285
+ return status;
286
+ }
287
+
288
+ async stop(): Promise<void> {
289
+ if (this.cdp) {
290
+ this.cdp.disconnect();
291
+ this.cdp = null;
292
+ }
293
+
294
+ if (this.childProcess) {
295
+ try {
296
+ this.childProcess.kill();
297
+ } catch {
298
+ // Process may already be dead
299
+ }
300
+ this.childProcess = null;
301
+ }
302
+
303
+ this.state = "idle";
304
+ this.pauseInfo = null;
305
+ this.wsUrl = null;
306
+ this.scripts.clear();
307
+ this.refs.clearAll();
308
+ this.consoleMessages = [];
309
+ this.exceptionEntries = [];
310
+ this.disabledBreakpoints.clear();
311
+ this.sourceMapResolver.clear();
312
+ }
313
+
314
+ get sessionState(): "idle" | "running" | "paused" {
315
+ return this.state;
316
+ }
317
+
318
+ get targetPid(): number | null {
319
+ return this.childProcess?.pid ?? null;
320
+ }
321
+
322
+ // ── Delegated methods ─────────────────────────────────────────────
323
+
324
+ // State snapshot
325
+ async buildState(options: StateOptions = {}): Promise<StateSnapshot> {
326
+ return buildStateImpl(this, options);
327
+ }
328
+
329
+ // Breakpoints
330
+ async setBreakpoint(
331
+ file: string,
332
+ line: number,
333
+ options?: { condition?: string; hitCount?: number; urlRegex?: string },
334
+ ): Promise<{ ref: string; location: { url: string; line: number; column?: number } }> {
335
+ return setBreakpointImpl(this, file, line, options);
336
+ }
337
+
338
+ async removeBreakpoint(ref: string): Promise<void> {
339
+ return removeBreakpointImpl(this, ref);
340
+ }
341
+
342
+ async removeAllBreakpoints(): Promise<void> {
343
+ return removeAllBreakpointsImpl(this);
344
+ }
345
+
346
+ listBreakpoints(): Array<{
347
+ ref: string;
348
+ type: "BP" | "LP";
349
+ url: string;
350
+ line: number;
351
+ column?: number;
352
+ condition?: string;
353
+ hitCount?: number;
354
+ template?: string;
355
+ disabled?: boolean;
356
+ originalUrl?: string;
357
+ originalLine?: number;
358
+ }> {
359
+ return listBreakpointsImpl(this);
360
+ }
361
+
362
+ async toggleBreakpoint(ref: string): Promise<{ ref: string; state: "enabled" | "disabled" }> {
363
+ return toggleBreakpointImpl(this, ref);
364
+ }
365
+
366
+ async getBreakableLocations(
367
+ file: string,
368
+ startLine: number,
369
+ endLine: number,
370
+ ): Promise<Array<{ line: number; column: number }>> {
371
+ return getBreakableLocationsImpl(this, file, startLine, endLine);
372
+ }
373
+
374
+ async setLogpoint(
375
+ file: string,
376
+ line: number,
377
+ template: string,
378
+ options?: { condition?: string; maxEmissions?: number },
379
+ ): Promise<{ ref: string; location: { url: string; line: number; column?: number } }> {
380
+ return setLogpointImpl(this, file, line, template, options);
381
+ }
382
+
383
+ async setExceptionPause(mode: "all" | "uncaught" | "caught" | "none"): Promise<void> {
384
+ return setExceptionPauseImpl(this, mode);
385
+ }
386
+
387
+ // Inspection
388
+ async eval(
389
+ expression: string,
390
+ options: {
391
+ frame?: string;
392
+ awaitPromise?: boolean;
393
+ throwOnSideEffect?: boolean;
394
+ timeout?: number;
395
+ } = {},
396
+ ): Promise<{
397
+ ref: string;
398
+ type: string;
399
+ value: string;
400
+ objectId?: string;
401
+ }> {
402
+ return evalExpression(this, expression, options);
403
+ }
404
+
405
+ async getVars(
406
+ options: { frame?: string; names?: string[]; allScopes?: boolean } = {},
407
+ ): Promise<Array<{ ref: string; name: string; type: string; value: string }>> {
408
+ return getVarsImpl(this, options);
409
+ }
410
+
411
+ async getProps(
412
+ ref: string,
413
+ options: {
414
+ own?: boolean;
415
+ internal?: boolean;
416
+ depth?: number;
417
+ } = {},
418
+ ): Promise<
419
+ Array<{
420
+ ref?: string;
421
+ name: string;
422
+ type: string;
423
+ value: string;
424
+ isOwn?: boolean;
425
+ isAccessor?: boolean;
426
+ }>
427
+ > {
428
+ return getPropsImpl(this, ref, options);
429
+ }
430
+
431
+ async getSource(
432
+ options: { file?: string; lines?: number; all?: boolean; generated?: boolean } = {},
433
+ ): Promise<{
434
+ url: string;
435
+ lines: Array<{ line: number; text: string; current?: boolean }>;
436
+ }> {
437
+ return getSourceImpl(this, options);
438
+ }
439
+
440
+ getScripts(filter?: string): Array<{ scriptId: string; url: string; sourceMapURL?: string }> {
441
+ return getScriptsImpl(this, filter);
442
+ }
443
+
444
+ getStack(options: { asyncDepth?: number; generated?: boolean } = {}): Array<{
445
+ ref: string;
446
+ functionName: string;
447
+ file: string;
448
+ line: number;
449
+ column?: number;
450
+ isAsync?: boolean;
451
+ }> {
452
+ return getStackImpl(this, options);
453
+ }
454
+
455
+ async searchInScripts(
456
+ query: string,
457
+ options: {
458
+ scriptId?: string;
459
+ isRegex?: boolean;
460
+ caseSensitive?: boolean;
461
+ } = {},
462
+ ): Promise<Array<{ url: string; line: number; column: number; content: string }>> {
463
+ return searchInScriptsImpl(this, query, options);
464
+ }
465
+
466
+ getConsoleMessages(
467
+ options: { level?: string; since?: number; clear?: boolean } = {},
468
+ ): ConsoleMessage[] {
469
+ return getConsoleMessagesImpl(this, options);
470
+ }
471
+
472
+ getExceptions(options: { since?: number } = {}): ExceptionEntry[] {
473
+ return getExceptionsImpl(this, options);
474
+ }
475
+
476
+ clearConsole(): void {
477
+ clearConsoleImpl(this);
478
+ }
479
+
480
+ // Mutation
481
+ async setVariable(
482
+ varName: string,
483
+ value: string,
484
+ options: { frame?: string } = {},
485
+ ): Promise<{ name: string; oldValue?: string; newValue: string; type: string }> {
486
+ return setVariableImpl(this, varName, value, options);
487
+ }
488
+
489
+ async setReturnValue(value: string): Promise<{ value: string; type: string }> {
490
+ return setReturnValueImpl(this, value);
491
+ }
492
+
493
+ async hotpatch(
494
+ file: string,
495
+ newSource: string,
496
+ options: { dryRun?: boolean } = {},
497
+ ): Promise<{ status: string; callFrames?: unknown[]; exceptionDetails?: unknown }> {
498
+ return hotpatchImpl(this, file, newSource, options);
499
+ }
500
+
501
+ // Execution control
502
+ async continue(): Promise<void> {
503
+ return continueExecution(this);
504
+ }
505
+
506
+ async step(mode: "over" | "into" | "out"): Promise<void> {
507
+ return stepExecution(this, mode);
508
+ }
509
+
510
+ async pause(): Promise<void> {
511
+ return pauseExecution(this);
512
+ }
513
+
514
+ async runTo(file: string, line: number): Promise<void> {
515
+ return runToLocation(this, file, line);
516
+ }
517
+
518
+ async restartFrame(frameRef?: string): Promise<{ status: string }> {
519
+ return restartFrameExecution(this, frameRef);
520
+ }
521
+
522
+ // Blackboxing
523
+ async addBlackbox(patterns: string[]): Promise<string[]> {
524
+ return addBlackboxImpl(this, patterns);
525
+ }
526
+
527
+ listBlackbox(): string[] {
528
+ return listBlackboxImpl(this);
529
+ }
530
+
531
+ async removeBlackbox(patterns: string[]): Promise<string[]> {
532
+ return removeBlackboxImpl(this, patterns);
533
+ }
534
+
535
+ // ── Public helpers (used by extracted modules) ─────────────────────
536
+
537
+ processEvalResult(
538
+ result: {
539
+ result: Protocol.Runtime.RemoteObject;
540
+ exceptionDetails?: Protocol.Runtime.ExceptionDetails;
541
+ },
542
+ expression: string,
543
+ ): { ref: string; type: string; value: string; objectId?: string } {
544
+ const evalResult = result.result as RemoteObject | undefined;
545
+ const exceptionDetails = result.exceptionDetails;
546
+
547
+ if (exceptionDetails) {
548
+ const exception = exceptionDetails.exception as RemoteObject | undefined;
549
+ const errorText = exception
550
+ ? formatValue(exception)
551
+ : (exceptionDetails.text ?? "Evaluation error");
552
+ throw new Error(errorText);
553
+ }
554
+
555
+ if (!evalResult) {
556
+ throw new Error("No result from evaluation");
557
+ }
558
+
559
+ const remoteId = (evalResult.objectId as string) ?? `eval:${Date.now()}`;
560
+ const ref = this.refs.addVar(remoteId, expression);
561
+ const resultData: {
562
+ ref: string;
563
+ type: string;
564
+ value: string;
565
+ objectId?: string;
566
+ } = {
567
+ ref,
568
+ type: evalResult.type,
569
+ value: formatValue(evalResult),
570
+ };
571
+ if (evalResult.objectId) {
572
+ resultData.objectId = evalResult.objectId;
573
+ }
574
+ return resultData;
575
+ }
576
+
577
+ findScriptUrl(file: string): string | null {
578
+ // Try exact suffix match first
579
+ for (const script of this.scripts.values()) {
580
+ if (script.url && script.url.endsWith(file)) {
581
+ return script.url;
582
+ }
583
+ }
584
+ // Try matching after stripping file:// prefix
585
+ for (const script of this.scripts.values()) {
586
+ if (!script.url) continue;
587
+ const stripped = script.url.startsWith("file://") ? script.url.slice(7) : script.url;
588
+ if (stripped.endsWith(file)) {
589
+ return script.url;
590
+ }
591
+ }
592
+ // Try matching just the basename
593
+ const needle = file.includes("/") ? file : `/${file}`;
594
+ for (const script of this.scripts.values()) {
595
+ if (!script.url) continue;
596
+ const stripped = script.url.startsWith("file://") ? script.url.slice(7) : script.url;
597
+ if (stripped.endsWith(needle)) {
598
+ return script.url;
599
+ }
600
+ }
601
+ // Fallback: try source map resolver for .ts files etc.
602
+ const smMatch = this.sourceMapResolver.findScriptForSource(file);
603
+ if (smMatch) {
604
+ return smMatch.url;
605
+ }
606
+ return null;
607
+ }
608
+
609
+ /**
610
+ * Creates a promise that resolves when the next `Debugger.paused` event
611
+ * fires, the process exits, or the timeout expires. Must be created
612
+ * BEFORE sending the CDP command that triggers execution so we don't
613
+ * miss events. Does NOT check current state — the caller is about to
614
+ * send a resume/step command.
615
+ */
616
+ createPauseWaiter(timeoutMs = 30_000): Promise<void> {
617
+ return new Promise<void>((resolve) => {
618
+ let settled = false;
619
+
620
+ const settle = () => {
621
+ if (settled) return;
622
+ settled = true;
623
+ clearTimeout(timer);
624
+ clearInterval(pollTimer);
625
+ this.cdp?.off("Debugger.paused", handler);
626
+ this.onProcessExit = null;
627
+ resolve();
628
+ };
629
+
630
+ const timer = setTimeout(() => {
631
+ // Don't reject — the process is still running, just not paused yet
632
+ settle();
633
+ }, timeoutMs);
634
+
635
+ const handler = () => {
636
+ settle();
637
+ };
638
+
639
+ // Poll as a fallback in case the event/callback is missed
640
+ // (e.g., process exits and monitorProcessExit runs before
641
+ // onProcessExit is set, or CDP disconnects clearing listeners)
642
+ const pollTimer = setInterval(() => {
643
+ if (this.isPaused() || this.state === "idle") {
644
+ settle();
645
+ }
646
+ }, 100);
647
+
648
+ this.cdp?.on("Debugger.paused", handler);
649
+ // Also resolve if the process exits during execution
650
+ this.onProcessExit = settle;
651
+ });
652
+ }
653
+
654
+ buildBreakpointCondition(condition?: string, hitCount?: number): string | undefined {
655
+ if (hitCount && hitCount > 0) {
656
+ const countVar = `__ndbg_bp_count_${Date.now()}`;
657
+ const hitExpr = `(typeof ${countVar} === "undefined" ? (${countVar} = 1) : ++${countVar}) >= ${hitCount}`;
658
+ if (condition) {
659
+ return `(${hitExpr}) && (${condition})`;
660
+ }
661
+ return hitExpr;
662
+ }
663
+ return condition;
664
+ }
665
+
666
+ /**
667
+ * Resolve a generated location to its original source-mapped location.
668
+ * Option A: when toOriginal returns null but the script has a source map,
669
+ * still return the original source URL (with the generated line number).
670
+ */
671
+ resolveOriginalLocation(
672
+ scriptId: string,
673
+ line1Based: number,
674
+ column: number,
675
+ ): { url: string; line: number; column?: number } | null {
676
+ const original = this.sourceMapResolver.toOriginal(scriptId, line1Based, column);
677
+ if (original) {
678
+ return { url: original.source, line: original.line, column: original.column + 1 };
679
+ }
680
+ // Fallback: script has a source map but this line has no mapping
681
+ const primaryUrl = this.sourceMapResolver.getScriptOriginalUrl(scriptId);
682
+ if (primaryUrl) {
683
+ return { url: primaryUrl, line: line1Based };
684
+ }
685
+ return null;
686
+ }
687
+
688
+ isPaused(): boolean {
689
+ return this.state === "paused";
690
+ }
691
+
692
+ // ── Private helpers ───────────────────────────────────────────────
693
+
694
+ private async waitForBrkPause(): Promise<void> {
695
+ // Give the Debugger.paused event a moment to arrive (older Node.js)
696
+ if (!this.isPaused()) {
697
+ await Bun.sleep(100);
698
+ }
699
+ // On Node.js v24+, --inspect-brk does not emit Debugger.paused when the
700
+ // debugger connects after the process is already paused. We request an
701
+ // explicit pause and then signal Runtime.runIfWaitingForDebugger so the
702
+ // process starts execution and immediately hits our pause request.
703
+ if (!this.isPaused() && this.cdp) {
704
+ await this.cdp.send("Debugger.pause");
705
+ await this.cdp.send("Runtime.runIfWaitingForDebugger");
706
+ const deadline = Date.now() + 2_000;
707
+ while (!this.isPaused() && Date.now() < deadline) {
708
+ await Bun.sleep(50);
709
+ }
710
+ }
711
+ // On Node.js v24+, the initial --inspect-brk pause lands in an internal
712
+ // bootstrap module (node:internal/...) rather than the user script.
713
+ // Resume past internal pauses until we reach user code.
714
+ let skips = 0;
715
+ while (this.isPaused() && this.cdp && this.pauseInfo?.url?.startsWith("node:") && skips < 5) {
716
+ skips++;
717
+ const waiter = this.createPauseWaiter(5_000);
718
+ await this.cdp.send("Debugger.resume");
719
+ await waiter;
720
+ }
721
+ }
722
+
723
+ private async connectCdp(wsUrl: string): Promise<void> {
724
+ const cdp = await CdpClient.connect(wsUrl);
725
+ this.cdp = cdp;
726
+
727
+ // Set up event handlers before enabling domains so we don't miss any events
728
+ this.setupCdpEventHandlers(cdp);
729
+
730
+ await cdp.enableDomains();
731
+
732
+ // Re-apply blackbox patterns if any exist
733
+ if (this.blackboxPatterns.length > 0) {
734
+ await cdp.send("Debugger.setBlackboxPatterns", {
735
+ patterns: this.blackboxPatterns,
736
+ });
737
+ }
738
+
739
+ // Update state to running if not already paused
740
+ if (this.state === "idle") {
741
+ this.state = "running";
742
+ }
743
+ }
744
+
745
+ private setupCdpEventHandlers(cdp: CdpClient): void {
746
+ cdp.on("Debugger.paused", (p) => {
747
+ this.state = "paused";
748
+ const callFrames = p.callFrames;
749
+ this.pausedCallFrames = callFrames ?? [];
750
+ const topFrame = callFrames?.[0];
751
+ const location = topFrame?.location;
752
+ const scriptId = location?.scriptId;
753
+ const url = scriptId ? this.scripts.get(scriptId)?.url : undefined;
754
+
755
+ this.pauseInfo = {
756
+ reason: p.reason ?? "unknown",
757
+ scriptId,
758
+ url,
759
+ line: location?.lineNumber,
760
+ column: location?.columnNumber,
761
+ callFrameCount: callFrames?.length,
762
+ };
763
+ });
764
+
765
+ cdp.on("Debugger.resumed", () => {
766
+ this.state = "running";
767
+ this.pauseInfo = null;
768
+ this.pausedCallFrames = [];
769
+ this.refs.clearVolatile();
770
+ });
771
+
772
+ cdp.on("Debugger.scriptParsed", (p) => {
773
+ const scriptId = p.scriptId;
774
+ if (scriptId) {
775
+ const info: ScriptInfo = {
776
+ scriptId,
777
+ url: p.url ?? "",
778
+ };
779
+ const sourceMapURL = p.sourceMapURL;
780
+ if (sourceMapURL) {
781
+ info.sourceMapURL = sourceMapURL;
782
+ // Load source map asynchronously (fire-and-forget)
783
+ this.sourceMapResolver.loadSourceMap(scriptId, info.url, sourceMapURL).catch(() => {});
784
+ }
785
+ this.scripts.set(scriptId, info);
786
+ }
787
+ });
788
+
789
+ cdp.on("Runtime.executionContextDestroyed", () => {
790
+ // The main execution context has been destroyed — the script has
791
+ // finished. The Node.js process may stay alive because the
792
+ // inspector connection keeps the event loop running, but debugging
793
+ // is effectively over.
794
+ this.state = "idle";
795
+ this.pauseInfo = null;
796
+ });
797
+
798
+ cdp.on("Runtime.consoleAPICalled", (p) => {
799
+ const type = p.type ?? "log";
800
+ const args = p.args ?? [];
801
+ // Format each arg using formatValue
802
+ const formattedArgs = args.map((a) => formatValue(a as unknown as RemoteObject));
803
+ const text = formattedArgs.join(" ");
804
+ // Get stack trace info if available
805
+ const stackTrace = p.stackTrace;
806
+ const eventCallFrames = stackTrace?.callFrames;
807
+ const topFrame = eventCallFrames?.[0];
808
+ const msg: ConsoleMessage = {
809
+ timestamp: Date.now(),
810
+ level: type,
811
+ text,
812
+ args: formattedArgs,
813
+ url: topFrame?.url,
814
+ line: topFrame?.lineNumber !== undefined ? topFrame.lineNumber + 1 : undefined,
815
+ };
816
+ this.consoleMessages.push(msg);
817
+ if (this.consoleMessages.length > 1000) {
818
+ this.consoleMessages.shift();
819
+ }
820
+ });
821
+
822
+ cdp.on("Runtime.exceptionThrown", (p) => {
823
+ const details = p.exceptionDetails;
824
+ if (!details) return;
825
+ const exception = details.exception;
826
+ const entry: ExceptionEntry = {
827
+ timestamp: Date.now(),
828
+ text: details.text ?? "Exception",
829
+ description: exception?.description,
830
+ url: details.url,
831
+ line: details.lineNumber !== undefined ? details.lineNumber + 1 : undefined,
832
+ column: details.columnNumber !== undefined ? details.columnNumber + 1 : undefined,
833
+ };
834
+ // Extract stack trace string
835
+ const stackTrace = details.stackTrace;
836
+ if (stackTrace?.callFrames) {
837
+ const frames = stackTrace.callFrames;
838
+ entry.stackTrace = frames
839
+ .map((f) => {
840
+ const fn = f.functionName || "(anonymous)";
841
+ const frameUrl = f.url;
842
+ const frameLine = f.lineNumber + 1;
843
+ return ` at ${fn} (${frameUrl}:${frameLine})`;
844
+ })
845
+ .join("\n");
846
+ }
847
+ this.exceptionEntries.push(entry);
848
+ if (this.exceptionEntries.length > 1000) {
849
+ this.exceptionEntries.shift();
850
+ }
851
+ });
852
+ }
853
+
854
+ private monitorProcessExit(proc: Subprocess<"ignore", "pipe", "pipe">): void {
855
+ proc.exited
856
+ .then(() => {
857
+ // Child process has exited
858
+ this.childProcess = null;
859
+ if (this.cdp) {
860
+ this.cdp.disconnect();
861
+ this.cdp = null;
862
+ }
863
+ this.state = "idle";
864
+ this.pauseInfo = null;
865
+ this.onProcessExit?.();
866
+ })
867
+ .catch(() => {
868
+ // Error waiting for exit, treat as exited
869
+ this.childProcess = null;
870
+ this.state = "idle";
871
+ this.pauseInfo = null;
872
+ });
873
+ }
874
+
875
+ private async readInspectorUrl(stderr: ReadableStream<Uint8Array>): Promise<string> {
876
+ const reader = stderr.getReader();
877
+ const decoder = new TextDecoder();
878
+ let accumulated = "";
879
+
880
+ const timeout = setTimeout(() => {
881
+ reader.cancel().catch(() => {});
882
+ }, INSPECTOR_TIMEOUT_MS);
883
+
884
+ try {
885
+ while (true) {
886
+ const { done, value } = await reader.read();
887
+ if (done) {
888
+ break;
889
+ }
890
+ accumulated += decoder.decode(value, { stream: true });
891
+
892
+ const match = INSPECTOR_URL_REGEX.exec(accumulated);
893
+ if (match?.[1]) {
894
+ clearTimeout(timeout);
895
+ // Release the reader so the stream is not locked
896
+ reader.releaseLock();
897
+ return match[1];
898
+ }
899
+ }
900
+ } catch {
901
+ // Reader was cancelled (timeout) or stream errored
902
+ }
903
+
904
+ clearTimeout(timeout);
905
+ throw new Error(
906
+ `Failed to detect inspector URL within ${INSPECTOR_TIMEOUT_MS}ms. Stderr: ${accumulated.slice(0, 500)}`,
907
+ );
908
+ }
909
+
910
+ private async discoverWsUrl(port: number): Promise<string> {
911
+ const url = `http://127.0.0.1:${port}/json`;
912
+ let response: Response;
913
+ try {
914
+ response = await fetch(url);
915
+ } catch (err) {
916
+ throw new Error(
917
+ `Cannot connect to inspector at port ${port}: ${err instanceof Error ? err.message : String(err)}`,
918
+ );
919
+ }
920
+
921
+ if (!response.ok) {
922
+ throw new Error(`Inspector at port ${port} returned HTTP ${response.status}`);
923
+ }
924
+
925
+ const targets = (await response.json()) as Array<Record<string, unknown>>;
926
+ const target = targets[0];
927
+ if (!target) {
928
+ throw new Error(`No debug targets found at port ${port}`);
929
+ }
930
+
931
+ const wsUrl = target.webSocketDebuggerUrl as string | undefined;
932
+ if (!wsUrl) {
933
+ throw new Error(`Debug target at port ${port} has no webSocketDebuggerUrl`);
934
+ }
935
+
936
+ return wsUrl;
937
+ }
938
+ }