@oh-my-pi/pi-coding-agent 17.0.4 → 17.0.6

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 (143) hide show
  1. package/CHANGELOG.md +209 -0
  2. package/dist/cli.js +3616 -3676
  3. package/dist/types/advisor/advise-tool.d.ts +3 -0
  4. package/dist/types/advisor/runtime.d.ts +7 -1
  5. package/dist/types/async/job-manager.d.ts +2 -0
  6. package/dist/types/config/model-registry.d.ts +7 -0
  7. package/dist/types/config/model-resolver.d.ts +8 -0
  8. package/dist/types/config/models-config-schema.d.ts +10 -0
  9. package/dist/types/config/models-config.d.ts +6 -0
  10. package/dist/types/dap/client.d.ts +10 -0
  11. package/dist/types/dap/types.d.ts +6 -5
  12. package/dist/types/exec/bash-executor.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/load-errors.d.ts +3 -0
  14. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  15. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +16 -0
  16. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
  17. package/dist/types/launch/spawn-options.d.ts +10 -0
  18. package/dist/types/launch/spawn-options.test.d.ts +1 -0
  19. package/dist/types/modes/components/status-line/component.d.ts +10 -3
  20. package/dist/types/modes/components/status-line/types.d.ts +3 -1
  21. package/dist/types/modes/components/tool-execution.d.ts +2 -0
  22. package/dist/types/modes/components/transcript-container.d.ts +4 -3
  23. package/dist/types/modes/components/tree-selector.d.ts +6 -2
  24. package/dist/types/modes/components/usage-row.d.ts +1 -1
  25. package/dist/types/modes/interactive-mode.d.ts +2 -0
  26. package/dist/types/modes/print-mode.d.ts +4 -0
  27. package/dist/types/modes/rpc/rpc-client.d.ts +1 -1
  28. package/dist/types/modes/rpc/rpc-frame.d.ts +9 -0
  29. package/dist/types/modes/rpc/rpc-input.d.ts +5 -0
  30. package/dist/types/modes/rpc/rpc-mode.d.ts +1 -1
  31. package/dist/types/modes/setup-wizard/index.d.ts +1 -1
  32. package/dist/types/modes/setup-wizard/scenes/model.d.ts +3 -0
  33. package/dist/types/modes/types.d.ts +2 -0
  34. package/dist/types/sdk.d.ts +2 -0
  35. package/dist/types/session/agent-session.d.ts +42 -6
  36. package/dist/types/session/messages.d.ts +6 -0
  37. package/dist/types/session/session-paths.d.ts +21 -4
  38. package/dist/types/tools/browser/aria/aria-snapshot.d.ts +17 -7
  39. package/dist/types/tools/browser/cmux/socket-client.d.ts +2 -0
  40. package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
  41. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  42. package/dist/types/tools/gh.d.ts +5 -3
  43. package/dist/types/tools/hub/index.d.ts +1 -1
  44. package/dist/types/tools/hub/jobs.d.ts +1 -0
  45. package/dist/types/tools/hub/types.d.ts +2 -0
  46. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  47. package/dist/types/tools/xdev.d.ts +5 -3
  48. package/dist/types/utils/git.d.ts +33 -0
  49. package/dist/types/vibe/__tests__/token-rate.test.d.ts +1 -0
  50. package/dist/types/vibe/runtime.d.ts +23 -0
  51. package/dist/types/web/search/providers/codex.d.ts +1 -1
  52. package/package.json +12 -12
  53. package/src/advisor/__tests__/advisor.test.ts +150 -0
  54. package/src/advisor/advise-tool.ts +4 -0
  55. package/src/advisor/runtime.ts +38 -14
  56. package/src/async/job-manager.ts +3 -0
  57. package/src/cli/bench-cli.ts +8 -2
  58. package/src/cli/dry-balance-cli.ts +1 -0
  59. package/src/config/model-registry.ts +89 -8
  60. package/src/config/model-resolver.ts +78 -14
  61. package/src/config/models-config-schema.ts +1 -0
  62. package/src/config/settings.ts +3 -1
  63. package/src/dap/client.ts +168 -1
  64. package/src/dap/config.ts +51 -1
  65. package/src/dap/session.ts +575 -234
  66. package/src/dap/types.ts +6 -5
  67. package/src/discovery/agents.ts +2 -2
  68. package/src/exec/bash-executor.ts +68 -8
  69. package/src/extensibility/extensions/load-errors.ts +13 -0
  70. package/src/extensibility/extensions/runner.ts +6 -4
  71. package/src/extensibility/legacy-pi-coding-agent-shim.ts +26 -0
  72. package/src/extensibility/plugins/legacy-pi-compat.ts +41 -6
  73. package/src/launch/broker.ts +34 -31
  74. package/src/launch/client.ts +7 -1
  75. package/src/launch/spawn-options.test.ts +31 -0
  76. package/src/launch/spawn-options.ts +17 -0
  77. package/src/lsp/types.ts +5 -1
  78. package/src/main.ts +25 -4
  79. package/src/mcp/transports/stdio.test.ts +9 -1
  80. package/src/modes/components/ask-dialog.ts +137 -73
  81. package/src/modes/components/chat-transcript-builder.ts +10 -1
  82. package/src/modes/components/status-line/component.ts +57 -3
  83. package/src/modes/components/status-line/segments.ts +20 -3
  84. package/src/modes/components/status-line/types.ts +3 -1
  85. package/src/modes/components/tool-execution.ts +5 -0
  86. package/src/modes/components/transcript-container.ts +23 -122
  87. package/src/modes/components/tree-selector.ts +10 -4
  88. package/src/modes/components/usage-row.ts +17 -1
  89. package/src/modes/controllers/command-controller.ts +41 -1
  90. package/src/modes/controllers/event-controller.ts +7 -3
  91. package/src/modes/controllers/input-controller.ts +1 -1
  92. package/src/modes/controllers/selector-controller.ts +29 -8
  93. package/src/modes/interactive-mode.ts +102 -60
  94. package/src/modes/noninteractive-dispose.test.ts +14 -1
  95. package/src/modes/print-mode.ts +81 -9
  96. package/src/modes/rpc/rpc-client.ts +76 -35
  97. package/src/modes/rpc/rpc-frame.ts +156 -0
  98. package/src/modes/rpc/rpc-input.ts +38 -0
  99. package/src/modes/rpc/rpc-mode.ts +11 -4
  100. package/src/modes/setup-wizard/index.ts +2 -0
  101. package/src/modes/setup-wizard/scenes/model.ts +134 -0
  102. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -1
  103. package/src/modes/types.ts +2 -0
  104. package/src/modes/utils/ui-helpers.ts +9 -3
  105. package/src/prompts/system/workflow-notice.md +89 -74
  106. package/src/prompts/tools/browser.md +3 -2
  107. package/src/prompts/tools/debug.md +2 -7
  108. package/src/prompts/tools/github.md +6 -1
  109. package/src/prompts/tools/hub.md +4 -2
  110. package/src/prompts/tools/task-async-contract.md +1 -0
  111. package/src/prompts/tools/task.md +9 -2
  112. package/src/sdk.ts +38 -6
  113. package/src/session/agent-session.ts +484 -188
  114. package/src/session/messages.test.ts +91 -0
  115. package/src/session/messages.ts +248 -110
  116. package/src/session/session-manager.ts +34 -3
  117. package/src/session/session-paths.ts +38 -9
  118. package/src/slash-commands/builtin-registry.ts +1 -0
  119. package/src/task/executor.ts +104 -33
  120. package/src/task/index.ts +46 -9
  121. package/src/task/worktree.ts +10 -0
  122. package/src/tools/browser/aria/aria-snapshot.ts +36 -8
  123. package/src/tools/browser/cmux/cmux-tab.ts +2 -1
  124. package/src/tools/browser/cmux/socket-client.ts +139 -3
  125. package/src/tools/browser/run-cancellation.ts +4 -0
  126. package/src/tools/browser/tab-protocol.ts +2 -0
  127. package/src/tools/browser/tab-supervisor.ts +21 -11
  128. package/src/tools/browser/tab-worker.ts +199 -33
  129. package/src/tools/debug.ts +3 -0
  130. package/src/tools/gh.ts +40 -2
  131. package/src/tools/hub/index.ts +4 -1
  132. package/src/tools/hub/jobs.ts +42 -1
  133. package/src/tools/hub/types.ts +2 -0
  134. package/src/tools/tool-timeouts.ts +1 -1
  135. package/src/tools/xdev.ts +11 -4
  136. package/src/tools/yield.ts +4 -1
  137. package/src/utils/git.ts +237 -0
  138. package/src/{modes/components/status-line → utils}/token-rate.ts +6 -0
  139. package/src/vibe/__tests__/token-rate.test.ts +96 -0
  140. package/src/vibe/runtime.ts +50 -0
  141. package/src/web/search/index.ts +9 -5
  142. package/src/web/search/providers/codex.ts +195 -99
  143. /package/dist/types/{modes/components/status-line → utils}/token-rate.d.ts +0 -0
@@ -93,6 +93,15 @@ interface DapSession {
93
93
  initializedSeen: boolean;
94
94
  needsConfigurationDone: boolean;
95
95
  configurationDoneSent: boolean;
96
+ parentSessionId?: string;
97
+ childSessionIds: Set<string>;
98
+ port?: number;
99
+ }
100
+
101
+ interface DapTreeOutcomeWaiter {
102
+ rootSessionId: string;
103
+ resolve(value: unknown): void;
104
+ reject(reason: unknown): void;
96
105
  }
97
106
 
98
107
  export interface DapOutputSnapshot {
@@ -243,6 +252,8 @@ function buildSummary(session: DapSession): DapSessionSummary {
243
252
  outputTruncated: session.outputTruncated,
244
253
  exitCode: session.exitCode,
245
254
  needsConfigurationDone: session.needsConfigurationDone && !session.configurationDoneSent,
255
+ parentSessionId: session.parentSessionId,
256
+ childSessionIds: session.childSessionIds.size > 0 ? [...session.childSessionIds] : undefined,
246
257
  };
247
258
  }
248
259
 
@@ -251,6 +262,7 @@ export class DapSessionManager {
251
262
  #activeSessionId: string | null = null;
252
263
  #cleanupLoopPromise?: Promise<void>;
253
264
  #nextId = 0;
265
+ #treeOutcomeWaiters = new Set<DapTreeOutcomeWaiter>();
254
266
 
255
267
  constructor() {
256
268
  this.#startCleanupTimer();
@@ -318,17 +330,22 @@ export class DapSessionManager {
318
330
  await launchPromise;
319
331
  // Try to capture initial stopped state (e.g. stopOnEntry).
320
332
  // Timeout is acceptable — the program may simply be running.
333
+ let resultSession = session;
321
334
  try {
322
335
  await untilAborted(signal, initialStopPromise);
323
- if (session.status === "stopped") {
324
- await this.#fetchTopFrame(session, signal, Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS));
336
+ const active = this.#getActiveSessionOrNull();
337
+ if (active && this.#getRootSession(active).id === session.id) {
338
+ resultSession = active;
339
+ }
340
+ if (resultSession.status === "stopped") {
341
+ await this.#fetchTopFrame(resultSession, signal, Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS));
325
342
  }
326
343
  } catch {
327
344
  if (session.initializedSeen && session.status === "launching") {
328
345
  session.status = session.configurationDoneSent ? "running" : "configuring";
329
346
  }
330
347
  }
331
- return buildSummary(session);
348
+ return buildSummary(resultSession);
332
349
  } catch (error) {
333
350
  await this.#disposeSession(session);
334
351
  const mapped = mapDebugpyMissingModule(options.adapter.name, error);
@@ -376,17 +393,22 @@ export class DapSessionManager {
376
393
  await throwPreferredDapStartError("attach", attachFailure, error);
377
394
  }
378
395
  await attachPromise;
396
+ let resultSession = session;
379
397
  try {
380
398
  await untilAborted(signal, initialStopPromise);
381
- if (session.status === "stopped") {
382
- await this.#fetchTopFrame(session, signal, Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS));
399
+ const active = this.#getActiveSessionOrNull();
400
+ if (active && this.#getRootSession(active).id === session.id) {
401
+ resultSession = active;
402
+ }
403
+ if (resultSession.status === "stopped") {
404
+ await this.#fetchTopFrame(resultSession, signal, Math.min(timeoutMs, STOP_CAPTURE_TIMEOUT_MS));
383
405
  }
384
406
  } catch {
385
407
  if (session.initializedSeen && session.status === "launching") {
386
408
  session.status = session.configurationDoneSent ? "running" : "configuring";
387
409
  }
388
410
  }
389
- return buildSummary(session);
411
+ return buildSummary(resultSession);
390
412
  } catch (error) {
391
413
  await this.#disposeSession(session);
392
414
  const mapped = mapDebugpyMissingModule(options.adapter.name, error);
@@ -414,6 +436,62 @@ export class DapSessionManager {
414
436
  );
415
437
  return run;
416
438
  }
439
+ async #syncBreakpointTree(
440
+ origin: DapSession,
441
+ command: string,
442
+ args: unknown,
443
+ prepare: (session: DapSession) => void,
444
+ apply: (session: DapSession, breakpoints: DapBreakpoint[] | undefined) => void,
445
+ signal?: AbortSignal,
446
+ timeoutMs: number = 30_000,
447
+ ): Promise<void> {
448
+ const sessions = this.#getTreeSessions(origin).filter(
449
+ session => session.status !== "terminated" && session.client.isAlive(),
450
+ );
451
+ for (const session of sessions) prepare(session);
452
+ await this.#serializeBreakpointMutation(
453
+ origin,
454
+ async () => {
455
+ const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
456
+ origin,
457
+ command,
458
+ args,
459
+ signal,
460
+ timeoutMs,
461
+ );
462
+ apply(origin, response?.breakpoints);
463
+ },
464
+ signal,
465
+ );
466
+ await Promise.all(
467
+ sessions
468
+ .filter(session => session !== origin)
469
+ .map(async session => {
470
+ try {
471
+ await this.#serializeBreakpointMutation(
472
+ session,
473
+ async () => {
474
+ const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
475
+ session,
476
+ command,
477
+ args,
478
+ signal,
479
+ timeoutMs,
480
+ );
481
+ apply(session, response?.breakpoints);
482
+ },
483
+ signal,
484
+ );
485
+ } catch (error) {
486
+ logger.warn("Failed to synchronize breakpoint request with child debug session", {
487
+ sessionId: session.id,
488
+ command,
489
+ error: toErrorMessage(error),
490
+ });
491
+ }
492
+ }),
493
+ );
494
+ }
417
495
 
418
496
  async setBreakpoint(
419
497
  file: string,
@@ -423,123 +501,127 @@ export class DapSessionManager {
423
501
  timeoutMs: number = 30_000,
424
502
  ) {
425
503
  const session = this.#touchActiveSession();
426
- return this.#serializeBreakpointMutation(
504
+ const sourcePath = normalizePath(file);
505
+ const root = this.#getRootSession(session);
506
+ const current = [...(root.breakpoints.get(sourcePath) ?? [])].filter(entry => entry.line !== line);
507
+ current.push({ verified: false, line, condition });
508
+ current.sort((left, right) => left.line - right.line);
509
+ const args = {
510
+ source: { path: sourcePath, name: path.basename(sourcePath) },
511
+ breakpoints: current.map<DapSourceBreakpoint>(entry => ({
512
+ line: entry.line,
513
+ ...(entry.condition ? { condition: entry.condition } : {}),
514
+ })),
515
+ };
516
+ await this.#syncBreakpointTree(
427
517
  session,
428
- async () => {
429
- const sourcePath = normalizePath(file);
430
- const current = [...(session.breakpoints.get(sourcePath) ?? [])];
431
- const deduped = current.filter(entry => entry.line !== line);
432
- deduped.push({ verified: false, line, condition });
433
- deduped.sort((left, right) => left.line - right.line);
434
- const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
435
- session,
436
- "setBreakpoints",
437
- {
438
- source: { path: sourcePath, name: path.basename(sourcePath) },
439
- breakpoints: deduped.map<DapSourceBreakpoint>(entry => ({
440
- line: entry.line,
441
- ...(entry.condition ? { condition: entry.condition } : {}),
442
- })),
443
- },
444
- signal,
445
- timeoutMs,
446
- );
447
- session.breakpoints.set(sourcePath, this.#mapSourceBreakpoints(deduped, response?.breakpoints));
448
- return {
449
- snapshot: buildSummary(session),
450
- breakpoints: session.breakpoints.get(sourcePath) ?? [],
518
+ "setBreakpoints",
519
+ args,
520
+ target =>
521
+ target.breakpoints.set(
451
522
  sourcePath,
452
- };
453
- },
523
+ current.map(entry => ({ ...entry, verified: false })),
524
+ ),
525
+ (target, response) => target.breakpoints.set(sourcePath, this.#mapSourceBreakpoints(current, response)),
454
526
  signal,
527
+ timeoutMs,
455
528
  );
529
+ return {
530
+ snapshot: buildSummary(session),
531
+ breakpoints: session.breakpoints.get(sourcePath) ?? [],
532
+ sourcePath,
533
+ };
456
534
  }
457
535
 
458
536
  async removeBreakpoint(file: string, line: number, signal?: AbortSignal, timeoutMs: number = 30_000) {
459
537
  const session = this.#touchActiveSession();
460
- return this.#serializeBreakpointMutation(
461
- session,
462
- async () => {
463
- const sourcePath = normalizePath(file);
464
- const current = [...(session.breakpoints.get(sourcePath) ?? [])].filter(entry => entry.line !== line);
465
- const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
466
- session,
467
- "setBreakpoints",
468
- {
469
- source: { path: sourcePath, name: path.basename(sourcePath) },
470
- breakpoints: current.map<DapSourceBreakpoint>(entry => ({
471
- line: entry.line,
472
- ...(entry.condition ? { condition: entry.condition } : {}),
473
- })),
474
- },
475
- signal,
476
- timeoutMs,
477
- );
478
- if (current.length === 0) {
479
- session.breakpoints.delete(sourcePath);
480
- } else {
481
- session.breakpoints.set(sourcePath, this.#mapSourceBreakpoints(current, response?.breakpoints));
482
- }
483
- return {
484
- snapshot: buildSummary(session),
485
- breakpoints: session.breakpoints.get(sourcePath) ?? [],
538
+ const sourcePath = normalizePath(file);
539
+ const root = this.#getRootSession(session);
540
+ const current = [...(root.breakpoints.get(sourcePath) ?? [])].filter(entry => entry.line !== line);
541
+ const args = {
542
+ source: { path: sourcePath, name: path.basename(sourcePath) },
543
+ breakpoints: current.map<DapSourceBreakpoint>(entry => ({
544
+ line: entry.line,
545
+ ...(entry.condition ? { condition: entry.condition } : {}),
546
+ })),
547
+ };
548
+ const prepare = (target: DapSession) => {
549
+ if (current.length === 0) target.breakpoints.delete(sourcePath);
550
+ else
551
+ target.breakpoints.set(
486
552
  sourcePath,
487
- };
553
+ current.map(entry => ({ ...entry, verified: false })),
554
+ );
555
+ };
556
+ await this.#syncBreakpointTree(
557
+ session,
558
+ "setBreakpoints",
559
+ args,
560
+ prepare,
561
+ (target, response) => {
562
+ if (current.length === 0) target.breakpoints.delete(sourcePath);
563
+ else target.breakpoints.set(sourcePath, this.#mapSourceBreakpoints(current, response));
488
564
  },
489
565
  signal,
566
+ timeoutMs,
490
567
  );
568
+ return {
569
+ snapshot: buildSummary(session),
570
+ breakpoints: session.breakpoints.get(sourcePath) ?? [],
571
+ sourcePath,
572
+ };
491
573
  }
492
574
 
493
575
  async setFunctionBreakpoint(name: string, condition?: string, signal?: AbortSignal, timeoutMs: number = 30_000) {
494
576
  const session = this.#touchActiveSession();
495
- return this.#serializeBreakpointMutation(
577
+ const current = this.#getRootSession(session).functionBreakpoints.filter(entry => entry.name !== name);
578
+ current.push({ verified: false, name, condition });
579
+ current.sort((left, right) => left.name.localeCompare(right.name));
580
+ const args = {
581
+ breakpoints: current.map<DapFunctionBreakpoint>(entry => ({
582
+ name: entry.name,
583
+ ...(entry.condition ? { condition: entry.condition } : {}),
584
+ })),
585
+ };
586
+ await this.#syncBreakpointTree(
496
587
  session,
497
- async () => {
498
- const current = session.functionBreakpoints.filter(entry => entry.name !== name);
499
- current.push({ verified: false, name, condition });
500
- current.sort((left, right) => left.name.localeCompare(right.name));
501
- const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
502
- session,
503
- "setFunctionBreakpoints",
504
- {
505
- breakpoints: current.map<DapFunctionBreakpoint>(entry => ({
506
- name: entry.name,
507
- ...(entry.condition ? { condition: entry.condition } : {}),
508
- })),
509
- },
510
- signal,
511
- timeoutMs,
512
- );
513
- session.functionBreakpoints = this.#mapFunctionBreakpoints(current, response?.breakpoints);
514
- return { snapshot: buildSummary(session), breakpoints: session.functionBreakpoints };
588
+ "setFunctionBreakpoints",
589
+ args,
590
+ target => {
591
+ target.functionBreakpoints = current.map(entry => ({ ...entry, verified: false }));
592
+ },
593
+ (target, response) => {
594
+ target.functionBreakpoints = this.#mapFunctionBreakpoints(current, response);
515
595
  },
516
596
  signal,
597
+ timeoutMs,
517
598
  );
599
+ return { snapshot: buildSummary(session), breakpoints: session.functionBreakpoints };
518
600
  }
519
601
 
520
602
  async removeFunctionBreakpoint(name: string, signal?: AbortSignal, timeoutMs: number = 30_000) {
521
603
  const session = this.#touchActiveSession();
522
- return this.#serializeBreakpointMutation(
604
+ const current = this.#getRootSession(session).functionBreakpoints.filter(entry => entry.name !== name);
605
+ const args = {
606
+ breakpoints: current.map<DapFunctionBreakpoint>(entry => ({
607
+ name: entry.name,
608
+ ...(entry.condition ? { condition: entry.condition } : {}),
609
+ })),
610
+ };
611
+ await this.#syncBreakpointTree(
523
612
  session,
524
- async () => {
525
- const current = session.functionBreakpoints.filter(entry => entry.name !== name);
526
- const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
527
- session,
528
- "setFunctionBreakpoints",
529
- {
530
- breakpoints: current.map<DapFunctionBreakpoint>(entry => ({
531
- name: entry.name,
532
- ...(entry.condition ? { condition: entry.condition } : {}),
533
- })),
534
- },
535
- signal,
536
- timeoutMs,
537
- );
538
- session.functionBreakpoints = this.#mapFunctionBreakpoints(current, response?.breakpoints);
539
- return { snapshot: buildSummary(session), breakpoints: session.functionBreakpoints };
613
+ "setFunctionBreakpoints",
614
+ args,
615
+ target => {
616
+ target.functionBreakpoints = current.map(entry => ({ ...entry, verified: false }));
617
+ },
618
+ (target, response) => {
619
+ target.functionBreakpoints = this.#mapFunctionBreakpoints(current, response);
540
620
  },
541
621
  signal,
622
+ timeoutMs,
542
623
  );
624
+ return { snapshot: buildSummary(session), breakpoints: session.functionBreakpoints };
543
625
  }
544
626
 
545
627
  async setInstructionBreakpoint(
@@ -551,37 +633,33 @@ export class DapSessionManager {
551
633
  timeoutMs: number = 30_000,
552
634
  ) {
553
635
  const session = this.#touchActiveSession();
554
- return this.#serializeBreakpointMutation(
636
+ const current = this.#getRootSession(session).instructionBreakpoints.filter(
637
+ entry => entry.instructionReference !== instructionReference || entry.offset !== offset,
638
+ );
639
+ current.push({ instructionReference, offset, condition, hitCondition });
640
+ current.sort((left, right) => {
641
+ const referenceOrder = left.instructionReference.localeCompare(right.instructionReference);
642
+ return referenceOrder !== 0 ? referenceOrder : (left.offset ?? 0) - (right.offset ?? 0);
643
+ });
644
+ const args = { breakpoints: current } satisfies DapSetInstructionBreakpointsArguments;
645
+ let responseBreakpoints: DapBreakpoint[] | undefined;
646
+ await this.#syncBreakpointTree(
555
647
  session,
556
- async () => {
557
- const current = session.instructionBreakpoints.filter(
558
- entry => entry.instructionReference !== instructionReference || entry.offset !== offset,
559
- );
560
- current.push({ instructionReference, offset, condition, hitCondition });
561
- current.sort((left, right) => {
562
- const referenceOrder = left.instructionReference.localeCompare(right.instructionReference);
563
- if (referenceOrder !== 0) {
564
- return referenceOrder;
565
- }
566
- return (left.offset ?? 0) - (right.offset ?? 0);
567
- });
568
- const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
569
- session,
570
- "setInstructionBreakpoints",
571
- {
572
- breakpoints: current,
573
- } satisfies DapSetInstructionBreakpointsArguments,
574
- signal,
575
- timeoutMs,
576
- );
577
- session.instructionBreakpoints = current;
578
- return {
579
- snapshot: buildSummary(session),
580
- breakpoints: this.#mapInstructionBreakpoints(current, response?.breakpoints),
581
- };
648
+ "setInstructionBreakpoints",
649
+ args,
650
+ target => {
651
+ target.instructionBreakpoints = current.map(entry => ({ ...entry }));
652
+ },
653
+ (target, response) => {
654
+ if (target === session) responseBreakpoints = response;
582
655
  },
583
656
  signal,
657
+ timeoutMs,
584
658
  );
659
+ return {
660
+ snapshot: buildSummary(session),
661
+ breakpoints: this.#mapInstructionBreakpoints(current, responseBreakpoints),
662
+ };
585
663
  }
586
664
 
587
665
  async removeInstructionBreakpoint(
@@ -591,35 +669,29 @@ export class DapSessionManager {
591
669
  timeoutMs: number = 30_000,
592
670
  ) {
593
671
  const session = this.#touchActiveSession();
594
- return this.#serializeBreakpointMutation(
672
+ const current = this.#getRootSession(session).instructionBreakpoints.filter(entry => {
673
+ if (entry.instructionReference !== instructionReference) return true;
674
+ return offset !== undefined && entry.offset !== offset;
675
+ });
676
+ const args = { breakpoints: current } satisfies DapSetInstructionBreakpointsArguments;
677
+ let responseBreakpoints: DapBreakpoint[] | undefined;
678
+ await this.#syncBreakpointTree(
595
679
  session,
596
- async () => {
597
- const current = session.instructionBreakpoints.filter(entry => {
598
- if (entry.instructionReference !== instructionReference) {
599
- return true;
600
- }
601
- if (offset === undefined) {
602
- return false;
603
- }
604
- return entry.offset !== offset;
605
- });
606
- const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
607
- session,
608
- "setInstructionBreakpoints",
609
- {
610
- breakpoints: current,
611
- } satisfies DapSetInstructionBreakpointsArguments,
612
- signal,
613
- timeoutMs,
614
- );
615
- session.instructionBreakpoints = current;
616
- return {
617
- snapshot: buildSummary(session),
618
- breakpoints: this.#mapInstructionBreakpoints(current, response?.breakpoints),
619
- };
680
+ "setInstructionBreakpoints",
681
+ args,
682
+ target => {
683
+ target.instructionBreakpoints = current.map(entry => ({ ...entry }));
684
+ },
685
+ (target, response) => {
686
+ if (target === session) responseBreakpoints = response;
620
687
  },
621
688
  signal,
689
+ timeoutMs,
622
690
  );
691
+ return {
692
+ snapshot: buildSummary(session),
693
+ breakpoints: this.#mapInstructionBreakpoints(current, responseBreakpoints),
694
+ };
623
695
  }
624
696
 
625
697
  async dataBreakpointInfo(
@@ -653,54 +725,52 @@ export class DapSessionManager {
653
725
  timeoutMs: number = 30_000,
654
726
  ) {
655
727
  const session = this.#touchActiveSession();
656
- return this.#serializeBreakpointMutation(
728
+ const current = this.#getRootSession(session).dataBreakpoints.filter(entry => entry.dataId !== dataId);
729
+ current.push({ dataId, accessType, condition, hitCondition });
730
+ current.sort((left, right) => left.dataId.localeCompare(right.dataId));
731
+ const args = { breakpoints: current } satisfies DapSetDataBreakpointsArguments;
732
+ let responseBreakpoints: DapBreakpoint[] | undefined;
733
+ await this.#syncBreakpointTree(
657
734
  session,
658
- async () => {
659
- const current = session.dataBreakpoints.filter(entry => entry.dataId !== dataId);
660
- current.push({ dataId, accessType, condition, hitCondition });
661
- current.sort((left, right) => left.dataId.localeCompare(right.dataId));
662
- const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
663
- session,
664
- "setDataBreakpoints",
665
- {
666
- breakpoints: current,
667
- } satisfies DapSetDataBreakpointsArguments,
668
- signal,
669
- timeoutMs,
670
- );
671
- session.dataBreakpoints = current;
672
- return {
673
- snapshot: buildSummary(session),
674
- breakpoints: this.#mapDataBreakpoints(current, response?.breakpoints),
675
- };
735
+ "setDataBreakpoints",
736
+ args,
737
+ target => {
738
+ target.dataBreakpoints = current.map(entry => ({ ...entry }));
739
+ },
740
+ (target, response) => {
741
+ if (target === session) responseBreakpoints = response;
676
742
  },
677
743
  signal,
744
+ timeoutMs,
678
745
  );
746
+ return {
747
+ snapshot: buildSummary(session),
748
+ breakpoints: this.#mapDataBreakpoints(current, responseBreakpoints),
749
+ };
679
750
  }
680
751
 
681
752
  async removeDataBreakpoint(dataId: string, signal?: AbortSignal, timeoutMs: number = 30_000) {
682
753
  const session = this.#touchActiveSession();
683
- return this.#serializeBreakpointMutation(
754
+ const current = this.#getRootSession(session).dataBreakpoints.filter(entry => entry.dataId !== dataId);
755
+ const args = { breakpoints: current } satisfies DapSetDataBreakpointsArguments;
756
+ let responseBreakpoints: DapBreakpoint[] | undefined;
757
+ await this.#syncBreakpointTree(
684
758
  session,
685
- async () => {
686
- const current = session.dataBreakpoints.filter(entry => entry.dataId !== dataId);
687
- const response = await this.#sendRequestWithConfig<{ breakpoints?: DapBreakpoint[] }>(
688
- session,
689
- "setDataBreakpoints",
690
- {
691
- breakpoints: current,
692
- } satisfies DapSetDataBreakpointsArguments,
693
- signal,
694
- timeoutMs,
695
- );
696
- session.dataBreakpoints = current;
697
- return {
698
- snapshot: buildSummary(session),
699
- breakpoints: this.#mapDataBreakpoints(current, response?.breakpoints),
700
- };
759
+ "setDataBreakpoints",
760
+ args,
761
+ target => {
762
+ target.dataBreakpoints = current.map(entry => ({ ...entry }));
763
+ },
764
+ (target, response) => {
765
+ if (target === session) responseBreakpoints = response;
701
766
  },
702
767
  signal,
768
+ timeoutMs,
703
769
  );
770
+ return {
771
+ snapshot: buildSummary(session),
772
+ breakpoints: this.#mapDataBreakpoints(current, responseBreakpoints),
773
+ };
704
774
  }
705
775
 
706
776
  async disassemble(
@@ -998,25 +1068,33 @@ export class DapSessionManager {
998
1068
  async terminate(signal?: AbortSignal, timeoutMs: number = 30_000): Promise<DapSessionSummary | null> {
999
1069
  const session = this.#getActiveSessionOrNull();
1000
1070
  if (!session) return null;
1001
- session.lastUsedAt = Date.now();
1002
- if (session.status !== "terminated") {
1071
+ this.#touchSessionAndAncestors(session);
1072
+ const root = this.#getRootSession(session);
1073
+ const summary = buildSummary(session);
1074
+ await this.#terminateSessionTree(root, signal, timeoutMs);
1075
+ return summary;
1076
+ }
1077
+
1078
+ async #terminateSessionTree(session: DapSession, signal?: AbortSignal, timeoutMs: number = 30_000): Promise<void> {
1079
+ session.status = "terminated";
1080
+ try {
1081
+ for (const childId of [...session.childSessionIds]) {
1082
+ const child = this.#sessions.get(childId);
1083
+ if (child) {
1084
+ await this.#terminateSessionTree(child, signal, timeoutMs);
1085
+ }
1086
+ }
1003
1087
  if (session.capabilities?.supportsTerminateRequest) {
1004
- await untilAborted(
1005
- signal,
1006
- session.client.sendRequest("terminate", undefined, signal, timeoutMs).catch(() => undefined),
1007
- );
1088
+ await session.client.sendRequest("terminate", undefined, signal, timeoutMs).catch(() => undefined);
1008
1089
  }
1009
- await untilAborted(
1010
- signal,
1011
- session.client
1012
- .sendRequest("disconnect", { terminateDebuggee: true }, signal, timeoutMs)
1013
- .catch(() => undefined),
1014
- );
1090
+ await session.client
1091
+ .sendRequest("disconnect", { terminateDebuggee: true }, signal, timeoutMs)
1092
+ .catch(() => undefined);
1093
+ } catch {
1094
+ /* Disposal remains mandatory when a caller aborts best-effort DAP shutdown. */
1095
+ } finally {
1096
+ this.#disposeSession(session);
1015
1097
  }
1016
- session.status = "terminated";
1017
- const summary = buildSummary(session);
1018
- await this.#disposeSession(session);
1019
- return summary;
1020
1098
  }
1021
1099
 
1022
1100
  #startCleanupTimer(): void {
@@ -1048,17 +1126,156 @@ export class DapSessionManager {
1048
1126
  }
1049
1127
  }
1050
1128
 
1129
+ async #startChildSession(
1130
+ parent: DapSession,
1131
+ request: "launch" | "attach",
1132
+ configuration: Record<string, unknown>,
1133
+ timeoutMs: number = 30_000,
1134
+ ): Promise<void> {
1135
+ if (parent.adapter.connectMode !== "tcp" || parent.port === undefined) {
1136
+ throw new Error(`DAP adapter ${parent.adapter.name} cannot accept child session connections`);
1137
+ }
1138
+ const cwd = path.resolve(parent.cwd, typeof configuration.cwd === "string" ? configuration.cwd : ".");
1139
+ const client = await DapClient.connect({
1140
+ adapter: parent.adapter,
1141
+ cwd,
1142
+ host: "127.0.0.1",
1143
+ port: parent.port,
1144
+ });
1145
+ const child = this.#registerSession(
1146
+ client,
1147
+ parent.adapter,
1148
+ cwd,
1149
+ typeof configuration.program === "string" ? configuration.program : undefined,
1150
+ parent.id,
1151
+ );
1152
+ try {
1153
+ child.capabilities = await client.initialize(
1154
+ this.#buildInitializeArguments(parent.adapter),
1155
+ undefined,
1156
+ timeoutMs,
1157
+ );
1158
+ child.needsConfigurationDone = child.capabilities.supportsConfigurationDoneRequest === true;
1159
+ const startFailure: DapStartRequestFailure = { rejected: false };
1160
+ const startPromise = trackDapStartRequest(
1161
+ client.sendRequest(request, { ...configuration, cwd }, undefined, timeoutMs),
1162
+ startFailure,
1163
+ );
1164
+ startPromise.catch(() => {});
1165
+ try {
1166
+ await this.#completeConfigurationHandshake(child, undefined, timeoutMs);
1167
+ } catch (error) {
1168
+ await throwPreferredDapStartError(request, startFailure, error);
1169
+ }
1170
+ await startPromise;
1171
+ } catch (error) {
1172
+ await this.#disposeSession(child);
1173
+ throw error;
1174
+ }
1175
+ }
1176
+
1177
+ async #applyRootBreakpointsToSession(
1178
+ session: DapSession,
1179
+ signal?: AbortSignal,
1180
+ timeoutMs: number = 30_000,
1181
+ ): Promise<void> {
1182
+ const root = this.#getRootSession(session);
1183
+ for (const [sourcePath, entries] of root.breakpoints) {
1184
+ try {
1185
+ const response = await session.client.sendRequest<{ breakpoints?: DapBreakpoint[] }>(
1186
+ "setBreakpoints",
1187
+ {
1188
+ source: { path: sourcePath, name: path.basename(sourcePath) },
1189
+ breakpoints: entries.map<DapSourceBreakpoint>(entry => ({
1190
+ line: entry.line,
1191
+ ...(entry.condition ? { condition: entry.condition } : {}),
1192
+ })),
1193
+ },
1194
+ signal,
1195
+ timeoutMs,
1196
+ );
1197
+ session.breakpoints.set(sourcePath, this.#mapSourceBreakpoints(entries, response?.breakpoints));
1198
+ } catch (error) {
1199
+ logger.warn("Failed to bind source breakpoints in child debug session", {
1200
+ sessionId: session.id,
1201
+ sourcePath,
1202
+ error: toErrorMessage(error),
1203
+ });
1204
+ }
1205
+ }
1206
+ if (root.functionBreakpoints.length > 0) {
1207
+ try {
1208
+ const response = await session.client.sendRequest<{ breakpoints?: DapBreakpoint[] }>(
1209
+ "setFunctionBreakpoints",
1210
+ {
1211
+ breakpoints: root.functionBreakpoints.map<DapFunctionBreakpoint>(entry => ({
1212
+ name: entry.name,
1213
+ ...(entry.condition ? { condition: entry.condition } : {}),
1214
+ })),
1215
+ },
1216
+ signal,
1217
+ timeoutMs,
1218
+ );
1219
+ session.functionBreakpoints = this.#mapFunctionBreakpoints(root.functionBreakpoints, response?.breakpoints);
1220
+ } catch (error) {
1221
+ logger.warn("Failed to bind function breakpoints in child debug session", {
1222
+ sessionId: session.id,
1223
+ error: toErrorMessage(error),
1224
+ });
1225
+ }
1226
+ }
1227
+ if (root.instructionBreakpoints.length > 0) {
1228
+ try {
1229
+ await session.client.sendRequest(
1230
+ "setInstructionBreakpoints",
1231
+ { breakpoints: root.instructionBreakpoints } satisfies DapSetInstructionBreakpointsArguments,
1232
+ signal,
1233
+ timeoutMs,
1234
+ );
1235
+ session.instructionBreakpoints = root.instructionBreakpoints.map(entry => ({ ...entry }));
1236
+ } catch (error) {
1237
+ logger.warn("Failed to bind instruction breakpoints in child debug session", {
1238
+ sessionId: session.id,
1239
+ error: toErrorMessage(error),
1240
+ });
1241
+ }
1242
+ }
1243
+ if (root.dataBreakpoints.length > 0) {
1244
+ try {
1245
+ await session.client.sendRequest(
1246
+ "setDataBreakpoints",
1247
+ { breakpoints: root.dataBreakpoints } satisfies DapSetDataBreakpointsArguments,
1248
+ signal,
1249
+ timeoutMs,
1250
+ );
1251
+ session.dataBreakpoints = root.dataBreakpoints.map(entry => ({ ...entry }));
1252
+ } catch (error) {
1253
+ logger.debug("Failed to bind data breakpoints in child debug session", {
1254
+ sessionId: session.id,
1255
+ error: toErrorMessage(error),
1256
+ });
1257
+ }
1258
+ }
1259
+ }
1260
+
1051
1261
  async #ensureLaunchSlot(): Promise<void> {
1052
- const active = this.#getActiveSessionOrNull();
1053
- if (!active) return;
1054
- if (active.status === "terminated" || !active.client.isAlive()) {
1055
- await this.#disposeSession(active);
1056
- return;
1262
+ for (const session of [...this.#sessions.values()]) {
1263
+ if (session.status === "terminated" || !session.client.isAlive()) {
1264
+ this.#disposeSession(session);
1265
+ }
1057
1266
  }
1058
- throw new Error(`Debug session ${active.id} is still active. Terminate it before launching another.`);
1267
+ const root = [...this.#sessions.values()].find(session => !session.parentSessionId);
1268
+ if (!root) return;
1269
+ throw new Error(`Debug session ${root.id} is still active. Terminate it before launching another.`);
1059
1270
  }
1060
1271
 
1061
- #registerSession(client: DapClient, adapter: DapResolvedAdapter, cwd: string, program?: string): DapSession {
1272
+ #registerSession(
1273
+ client: DapClient,
1274
+ adapter: DapResolvedAdapter,
1275
+ cwd: string,
1276
+ program?: string,
1277
+ parentSessionId?: string,
1278
+ ): DapSession {
1062
1279
  const session: DapSession = {
1063
1280
  id: `debug-${++this.#nextId}`,
1064
1281
  adapter,
@@ -1083,6 +1300,9 @@ export class DapSessionManager {
1083
1300
  initializedSeen: false,
1084
1301
  needsConfigurationDone: false,
1085
1302
  configurationDoneSent: false,
1303
+ parentSessionId,
1304
+ childSessionIds: new Set(),
1305
+ port: client.port,
1086
1306
  };
1087
1307
  client.onReverseRequest("runInTerminal", async rawArgs => {
1088
1308
  const args = (rawArgs ?? {}) as DapRunInTerminalArguments;
@@ -1093,7 +1313,7 @@ export class DapSessionManager {
1093
1313
  Object.entries(args.env ?? {}).filter((entry): entry is [string, string] => entry[1] !== null),
1094
1314
  );
1095
1315
  const proc = ptree.spawn(args.args, {
1096
- cwd: args.cwd ?? session.cwd,
1316
+ cwd: path.resolve(session.cwd, args.cwd ?? "."),
1097
1317
  stdin: "pipe",
1098
1318
  env: {
1099
1319
  ...Bun.env,
@@ -1115,6 +1335,7 @@ export class DapSessionManager {
1115
1335
  request,
1116
1336
  name: typeof configuration.name === "string" ? configuration.name : undefined,
1117
1337
  });
1338
+ await this.#startChildSession(session, request, configuration);
1118
1339
  return {};
1119
1340
  });
1120
1341
  client.onEvent("output", body => {
@@ -1126,6 +1347,8 @@ export class DapSessionManager {
1126
1347
  });
1127
1348
  client.onEvent("stopped", body => {
1128
1349
  this.#handleStoppedEvent(session, body as DapStoppedEventBody);
1350
+ this.#activeSessionId = session.id;
1351
+ this.#resolveTreeOutcome(session);
1129
1352
  });
1130
1353
  client.onEvent("continued", body => {
1131
1354
  const continued = body as { threadId?: number } | undefined;
@@ -1135,11 +1358,19 @@ export class DapSessionManager {
1135
1358
  });
1136
1359
  client.onEvent("exited", body => {
1137
1360
  session.exitCode = (body as DapExitedEventBody | undefined)?.exitCode;
1361
+ session.status = "terminated";
1362
+ this.#reactivateAfterTermination(session);
1363
+ this.#resolveTreeOutcome(session);
1138
1364
  });
1139
1365
  client.onEvent("terminated", () => {
1140
1366
  session.status = "terminated";
1367
+ this.#reactivateAfterTermination(session);
1368
+ this.#resolveTreeOutcome(session);
1141
1369
  });
1142
1370
  this.#sessions.set(session.id, session);
1371
+ if (parentSessionId) {
1372
+ this.#sessions.get(parentSessionId)?.childSessionIds.add(session.id);
1373
+ }
1143
1374
  this.#activeSessionId = session.id;
1144
1375
  const heartbeat = setInterval(() => {
1145
1376
  if (!client.isAlive()) {
@@ -1147,7 +1378,12 @@ export class DapSessionManager {
1147
1378
  }
1148
1379
  }, HEARTBEAT_INTERVAL_MS);
1149
1380
  heartbeat.unref?.();
1150
- client.proc.exited.finally(() => clearInterval(heartbeat));
1381
+ void client.proc.exited.finally(() => {
1382
+ clearInterval(heartbeat);
1383
+ session.status = "terminated";
1384
+ this.#reactivateAfterTermination(session);
1385
+ this.#resolveTreeOutcome(session);
1386
+ });
1151
1387
  return session;
1152
1388
  }
1153
1389
 
@@ -1178,7 +1414,11 @@ export class DapSessionManager {
1178
1414
  signal?: AbortSignal,
1179
1415
  timeoutMs: number = 30_000,
1180
1416
  ): Promise<void> {
1181
- if (!session.needsConfigurationDone || session.configurationDoneSent) {
1417
+ if (session.configurationDoneSent) return;
1418
+ if (!session.needsConfigurationDone) {
1419
+ if (session.parentSessionId) {
1420
+ await this.#applyRootBreakpointsToSession(session, signal, timeoutMs);
1421
+ }
1182
1422
  return;
1183
1423
  }
1184
1424
  // Wait for the initialized event if we haven't seen it yet.
@@ -1191,6 +1431,9 @@ export class DapSessionManager {
1191
1431
  return;
1192
1432
  }
1193
1433
  }
1434
+ if (session.parentSessionId) {
1435
+ await this.#applyRootBreakpointsToSession(session, signal, timeoutMs);
1436
+ }
1194
1437
  await session.client.sendRequest("configurationDone", {}, signal, timeoutMs);
1195
1438
  session.configurationDoneSent = true;
1196
1439
  if (session.status === "configuring") {
@@ -1261,19 +1504,39 @@ export class DapSessionManager {
1261
1504
  * MUST be called before the command that triggers the event.
1262
1505
  */
1263
1506
  #prepareStopOutcome(session: DapSession, signal?: AbortSignal, timeoutMs: number = 30_000): Promise<unknown> {
1264
- const promises = [
1265
- session.client.waitForEvent("stopped", undefined, signal, timeoutMs),
1266
- session.client.waitForEvent("terminated", undefined, signal, timeoutMs),
1267
- session.client.waitForEvent("exited", undefined, signal, timeoutMs),
1268
- ];
1269
- // Promise.race leaves the losing waiters pending; their timeouts would
1270
- // otherwise surface as unhandled rejections once they fire.
1271
- for (const p of promises) {
1272
- p.catch(() => {});
1507
+ const { promise, resolve, reject } = Promise.withResolvers<unknown>();
1508
+ const rootSessionId = this.#getRootSession(session).id;
1509
+ let timeout: NodeJS.Timeout | undefined;
1510
+ let abortHandler: (() => void) | undefined;
1511
+ const cleanup = () => {
1512
+ clearTimeout(timeout);
1513
+ if (signal && abortHandler) signal.removeEventListener("abort", abortHandler);
1514
+ this.#treeOutcomeWaiters.delete(waiter);
1515
+ };
1516
+ const waiter: DapTreeOutcomeWaiter = {
1517
+ rootSessionId,
1518
+ resolve: value => {
1519
+ cleanup();
1520
+ resolve(value);
1521
+ },
1522
+ reject: reason => {
1523
+ cleanup();
1524
+ reject(reason);
1525
+ },
1526
+ };
1527
+ this.#treeOutcomeWaiters.add(waiter);
1528
+ timeout = setTimeout(
1529
+ () => waiter.reject(new Error(`DAP session tree outcome timed out after ${timeoutMs}ms`)),
1530
+ timeoutMs,
1531
+ );
1532
+ if (signal) {
1533
+ abortHandler = () =>
1534
+ waiter.reject(signal.reason instanceof Error ? signal.reason : new Error("Debug operation aborted"));
1535
+ if (signal.aborted) abortHandler();
1536
+ else signal.addEventListener("abort", abortHandler, { once: true });
1273
1537
  }
1274
- const outcome = Promise.race(promises);
1275
- outcome.catch(() => {});
1276
- return outcome;
1538
+ promise.catch(() => {});
1539
+ return promise;
1277
1540
  }
1278
1541
 
1279
1542
  /**
@@ -1287,17 +1550,29 @@ export class DapSessionManager {
1287
1550
  ): Promise<DapContinueOutcome> {
1288
1551
  try {
1289
1552
  await untilAborted(signal, outcomePromise);
1290
- if (session.status === "stopped") {
1291
- await this.#fetchTopFrame(session, signal, Math.min(timeoutMs, 5_000));
1553
+ const active = this.#getActiveSessionOrNull();
1554
+ const resultSession =
1555
+ active && this.#getRootSession(active).id === this.#getRootSession(session).id ? active : session;
1556
+ if (resultSession.status === "stopped") {
1557
+ await this.#fetchTopFrame(resultSession, signal, Math.min(timeoutMs, 5_000));
1292
1558
  }
1293
1559
  const state =
1294
- session.status === "stopped" ? "stopped" : session.status === "terminated" ? "terminated" : "running";
1295
- return { snapshot: buildSummary(session), state, timedOut: false };
1560
+ resultSession.status === "stopped"
1561
+ ? "stopped"
1562
+ : resultSession.status === "terminated"
1563
+ ? "terminated"
1564
+ : "running";
1565
+ return { snapshot: buildSummary(resultSession), state, timedOut: false };
1296
1566
  } catch (error) {
1297
- if (signal?.aborted) {
1298
- throw error;
1299
- }
1300
- return { snapshot: buildSummary(session), state: "running", timedOut: session.status === "running" };
1567
+ if (signal?.aborted) throw error;
1568
+ const active = this.#getActiveSessionOrNull();
1569
+ const resultSession =
1570
+ active && this.#getRootSession(active).id === this.#getRootSession(session).id ? active : session;
1571
+ return {
1572
+ snapshot: buildSummary(resultSession),
1573
+ state: "running",
1574
+ timedOut: resultSession.status === "running",
1575
+ };
1301
1576
  }
1302
1577
  }
1303
1578
 
@@ -1326,7 +1601,7 @@ export class DapSessionManager {
1326
1601
  ): Promise<TBody> {
1327
1602
  await this.#ensureConfigurationDone(session, signal, timeoutMs);
1328
1603
  const body = await session.client.sendRequest<TBody>(command, args, signal, timeoutMs);
1329
- session.lastUsedAt = Date.now();
1604
+ this.#touchSessionAndAncestors(session);
1330
1605
  return body;
1331
1606
  }
1332
1607
 
@@ -1403,7 +1678,7 @@ export class DapSessionManager {
1403
1678
 
1404
1679
  #touchActiveSession(): DapSession {
1405
1680
  const session = this.#getActiveSessionOrThrow();
1406
- session.lastUsedAt = Date.now();
1681
+ this.#touchSessionAndAncestors(session);
1407
1682
  if (session.status !== "terminated" && !session.client.isAlive()) {
1408
1683
  session.status = "terminated";
1409
1684
  }
@@ -1429,11 +1704,77 @@ export class DapSessionManager {
1429
1704
  return session;
1430
1705
  }
1431
1706
 
1432
- #disposeSession(session: DapSession) {
1433
- if (this.#activeSessionId === session.id) {
1434
- this.#activeSessionId = null;
1707
+ #getRootSession(session: DapSession): DapSession {
1708
+ let root = session;
1709
+ while (root.parentSessionId) {
1710
+ const parent = this.#sessions.get(root.parentSessionId);
1711
+ if (!parent) break;
1712
+ root = parent;
1713
+ }
1714
+ return root;
1715
+ }
1716
+
1717
+ #getTreeSessions(session: DapSession): DapSession[] {
1718
+ const sessions: DapSession[] = [];
1719
+ const pending = [this.#getRootSession(session)];
1720
+ while (pending.length > 0) {
1721
+ const current = pending.pop();
1722
+ if (!current) continue;
1723
+ sessions.push(current);
1724
+ for (const childId of current.childSessionIds) {
1725
+ const child = this.#sessions.get(childId);
1726
+ if (child) pending.push(child);
1727
+ }
1728
+ }
1729
+ return sessions;
1730
+ }
1731
+
1732
+ #touchSessionAndAncestors(session: DapSession): void {
1733
+ const now = Date.now();
1734
+ let current: DapSession | undefined = session;
1735
+ while (current) {
1736
+ current.lastUsedAt = now;
1737
+ current = current.parentSessionId ? this.#sessions.get(current.parentSessionId) : undefined;
1738
+ }
1739
+ }
1740
+
1741
+ /** Point the active session at a live tree member when the active one terminates. */
1742
+ #reactivateAfterTermination(session: DapSession): void {
1743
+ if (this.#activeSessionId !== session.id) return;
1744
+ const live = this.#getTreeSessions(session).filter(
1745
+ candidate => candidate.status !== "terminated" && candidate.client.isAlive(),
1746
+ );
1747
+ if (live.length === 0) return;
1748
+ const replacement =
1749
+ live.find(candidate => candidate.status === "stopped") ??
1750
+ live.find(candidate => candidate.parentSessionId !== undefined) ??
1751
+ live[0];
1752
+ this.#activeSessionId = replacement.id;
1753
+ }
1754
+
1755
+ #resolveTreeOutcome(session: DapSession): void {
1756
+ const rootId = this.#getRootSession(session).id;
1757
+ for (const waiter of [...this.#treeOutcomeWaiters]) {
1758
+ if (waiter.rootSessionId === rootId) {
1759
+ waiter.resolve(undefined);
1760
+ }
1761
+ }
1762
+ }
1763
+
1764
+ #disposeSession(session: DapSession): void {
1765
+ if (!this.#sessions.has(session.id)) return;
1766
+ for (const childId of [...session.childSessionIds]) {
1767
+ const child = this.#sessions.get(childId);
1768
+ if (child) this.#disposeSession(child);
1435
1769
  }
1436
1770
  this.#sessions.delete(session.id);
1771
+ if (session.parentSessionId) {
1772
+ this.#sessions.get(session.parentSessionId)?.childSessionIds.delete(session.id);
1773
+ }
1774
+ if (this.#activeSessionId === session.id) {
1775
+ const parent = session.parentSessionId ? this.#sessions.get(session.parentSessionId) : undefined;
1776
+ this.#activeSessionId = parent?.id ?? this.#sessions.values().next().value?.id ?? null;
1777
+ }
1437
1778
  void session.client.dispose().catch(() => {});
1438
1779
  }
1439
1780
  }