@i-scope/mcp-server 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +147 -0
  2. package/LICENSE +21 -0
  3. package/README.md +373 -0
  4. package/dist/src/abi-check.d.ts +19 -0
  5. package/dist/src/abi-check.js +66 -0
  6. package/dist/src/bridge-driver.d.ts +90 -0
  7. package/dist/src/bridge-driver.js +290 -0
  8. package/dist/src/dap-client.d.ts +80 -0
  9. package/dist/src/dap-client.js +296 -0
  10. package/dist/src/dap-driver.d.ts +162 -0
  11. package/dist/src/dap-driver.js +703 -0
  12. package/dist/src/index.d.ts +3 -0
  13. package/dist/src/index.js +175 -0
  14. package/dist/src/state.d.ts +86 -0
  15. package/dist/src/state.js +15 -0
  16. package/dist/src/tools/abi-check.d.ts +3 -0
  17. package/dist/src/tools/abi-check.js +64 -0
  18. package/dist/src/tools/breakpoints.d.ts +3 -0
  19. package/dist/src/tools/breakpoints.js +56 -0
  20. package/dist/src/tools/execution.d.ts +3 -0
  21. package/dist/src/tools/execution.js +75 -0
  22. package/dist/src/tools/helpers.d.ts +27 -0
  23. package/dist/src/tools/helpers.js +134 -0
  24. package/dist/src/tools/inspection.d.ts +3 -0
  25. package/dist/src/tools/inspection.js +141 -0
  26. package/dist/src/tools/lifecycle.d.ts +3 -0
  27. package/dist/src/tools/lifecycle.js +103 -0
  28. package/dist/src/tools/preflight.d.ts +3 -0
  29. package/dist/src/tools/preflight.js +95 -0
  30. package/dist/src/tools/registry.d.ts +15 -0
  31. package/dist/src/tools/registry.js +19 -0
  32. package/dist/src/tools/snapshot.d.ts +3 -0
  33. package/dist/src/tools/snapshot.js +117 -0
  34. package/dist/src/tools/source-maps.d.ts +3 -0
  35. package/dist/src/tools/source-maps.js +232 -0
  36. package/dist/src/tools/sync.d.ts +3 -0
  37. package/dist/src/tools/sync.js +80 -0
  38. package/dist/src/tools/ui-modal.d.ts +3 -0
  39. package/dist/src/tools/ui-modal.js +182 -0
  40. package/package.json +73 -0
  41. package/src/abi-check.ts +97 -0
  42. package/src/bridge-driver.ts +328 -0
  43. package/src/dap-client.ts +336 -0
  44. package/src/dap-driver.ts +810 -0
  45. package/src/index.ts +155 -0
  46. package/src/state.ts +115 -0
  47. package/src/tools/abi-check.ts +66 -0
  48. package/src/tools/breakpoints.ts +59 -0
  49. package/src/tools/execution.ts +105 -0
  50. package/src/tools/helpers.ts +142 -0
  51. package/src/tools/inspection.ts +173 -0
  52. package/src/tools/lifecycle.ts +129 -0
  53. package/src/tools/preflight.ts +95 -0
  54. package/src/tools/registry.ts +34 -0
  55. package/src/tools/snapshot.ts +132 -0
  56. package/src/tools/source-maps.ts +222 -0
  57. package/src/tools/sync.ts +90 -0
  58. package/src/tools/ui-modal.ts +201 -0
@@ -0,0 +1,328 @@
1
+ // BridgeDriver — direct (non-DAP) RPC access to iScopeBridge.exe.
2
+ //
3
+ // Live home of:
4
+ // - ui_modal_list / click / fill / dismiss (MCP UI plan)
5
+ // - system_preflight_check (Phase 1, MDM detection)
6
+ // - system_abi_check (wire ABI via protocol/version)
7
+ //
8
+ // Reserved for Phase 2/3 expansion:
9
+ // - control_open_file (BridgeDriver.openFile)
10
+ // - control_get_status (BridgeDriver.getStatus)
11
+ // - control_run_script (BridgeDriver.executeFile without debug)
12
+ // - control_close_application
13
+ // - data_* (TBD — needs helper-cpp extensions)
14
+ //
15
+ // Why a separate class from DapDriver:
16
+ // DapDriver OWNS a `debug-server.js` child which in turn owns its
17
+ // own iScopeBridge.exe (via `extension/.../com/bridge.ts`). For
18
+ // non-debug operations we don't want to pay the cost of a full DAP
19
+ // session — a direct helper child is plenty. The two drivers may
20
+ // coexist: COM lets multiple clients attach to Oscilloscope, so an
21
+ // AI agent can simultaneously hold a DAP session AND poll modal
22
+ // state via `ui_modal_list`.
23
+ //
24
+ // Lifecycle (two-stage state machine):
25
+ //
26
+ // ensureSpawned ensureReady
27
+ // ────────────► ────────────►
28
+ // 'idle' ─────────────► 'spawned' ──────────► 'ready'
29
+ // ▲ │ │
30
+ // │ disconnect() │ disconnect() │
31
+ // └──────────────────────┴─────────────────────┘
32
+ //
33
+ // - `ensureSpawned()` — guarantees the helper child is running and its
34
+ // stdio JSON-RPC loop is alive. Required for the `preflight/check`
35
+ // method which is registry-only and does NOT need a live COM
36
+ // connection (the MCP `system_preflight_check` tool runs even on a
37
+ // box where Oscilloscope is not installed).
38
+ // - `ensureReady()` — additionally guarantees `bridge.initialize()`
39
+ // has succeeded, i.e. the helper is connected to Oscilloscope's COM
40
+ // server. Required for every ui_modal_* method. Internally calls
41
+ // `ensureSpawned()` first so it works from a cold start AND from a
42
+ // warm state where preflight already spawned the helper.
43
+ // - `disconnect()` — full teardown. Resets state to `idle`.
44
+ //
45
+ // Both methods are idempotent and serialise concurrent callers via
46
+ // a single `lifecyclePromise` mutex — two parallel `ensureReady()`
47
+ // calls (one from a `ui_modal_list` tool, one from `ui_modal_click`
48
+ // chained right behind it) share the same spawn AND the same
49
+ // initialize. Failed transitions reset the relevant boolean so the
50
+ // next caller retries from the appropriate stage.
51
+ //
52
+ // `attachPolicy='attachOnly'` by default — we never auto-launch
53
+ // Oscilloscope just because the agent asked to list modals; if
54
+ // osc isn't running, `initialize()` returns connected=false and the
55
+ // calling tool surfaces a friendly "Oscilloscope is not running"
56
+ // error to the AI.
57
+
58
+ import {
59
+ IScopeBridge,
60
+ resolveHelperPath,
61
+ type AbiPolicy,
62
+ type PreflightCheckResponse,
63
+ type UIModalClickRequest,
64
+ type UIModalClickResponse,
65
+ type UIModalDismissRequest,
66
+ type UIModalDismissResponse,
67
+ type UIModalFillRequest,
68
+ type UIModalFillResponse,
69
+ type UIModalListResponse,
70
+ } from '@i-scope/iscope-bridge-client';
71
+
72
+ import { evaluateAbiCheck, type AbiCheckResult } from './abi-check.js';
73
+
74
+ export type { AbiCheckResult } from './abi-check.js';
75
+
76
+ export interface BridgeDriverOptions {
77
+ /** Explicit override; otherwise resolveHelperPath() runs. */
78
+ helperPath?: string;
79
+ /** Optional bundledRoot forwarded to resolveHelperPath. */
80
+ bundledRoot?: string;
81
+ /** Per-RPC timeout. Defaults to 10 000 ms. */
82
+ requestTimeoutMs?: number;
83
+ /** Diagnostic line sink (typically stderr writer). */
84
+ log?: (line: string) => void;
85
+ }
86
+
87
+ /**
88
+ * Lightweight wrapper around `IScopeBridge` that exposes the
89
+ * UI-modal-control + pre-flight surface to MCP tools. Spawns its own
90
+ * helper child on demand; safe to coexist with an active DAP session
91
+ * that also holds an iScopeBridge.exe.
92
+ */
93
+ export class BridgeDriver {
94
+ private readonly opts: BridgeDriverOptions;
95
+
96
+ // ---- State-machine fields ------------------------------------------------
97
+ //
98
+ // Two booleans rather than a single discriminated enum because the
99
+ // transitions are linear (idle → spawned → ready) and we frequently
100
+ // need to ask "are we at-or-past stage X?" — booleans answer that
101
+ // in one read. Invariant: `initialized` implies `spawned`, which
102
+ // implies `bridge !== null`.
103
+ private bridge: IScopeBridge | null = null;
104
+ private spawned = false;
105
+ private initialized = false;
106
+ /**
107
+ * Single mutex protecting any in-flight transition (spawn or
108
+ * initialize). Non-null while a transition is running. Concurrent
109
+ * callers await the same promise instead of starting a second
110
+ * transition, which would race against the first (e.g. two spawn
111
+ * calls would leak a second helper child; a spawn racing an init
112
+ * could initialize a stale handle). `disconnect()` also awaits
113
+ * this so we never tear the helper down mid-transition.
114
+ */
115
+ private lifecyclePromise: Promise<void> | null = null;
116
+
117
+ constructor(opts: BridgeDriverOptions = {}) {
118
+ this.opts = opts;
119
+ }
120
+
121
+ /**
122
+ * Ensure the helper child is spawned + its JSON-RPC loop alive.
123
+ * Does NOT call `initialize()`. Idempotent. Use this for the
124
+ * `preflight/check` path which is registry-only and must work on
125
+ * machines where Oscilloscope is not installed or not running.
126
+ */
127
+ private async ensureSpawned(): Promise<IScopeBridge> {
128
+ // Wait until any in-flight transition (spawn or init) settles
129
+ // so our state read below is consistent.
130
+ await this.awaitLifecycleSettled();
131
+ if (this.spawned && this.bridge) return this.bridge;
132
+
133
+ // Serialise the spawn behind the mutex so a parallel caller
134
+ // does not start a SECOND helper child. Re-check inside the
135
+ // critical section in case another caller spawned while we
136
+ // were queued.
137
+ const transition = (async (): Promise<void> => {
138
+ if (this.spawned && this.bridge) return;
139
+ const br = this.spawnHelper();
140
+ br.start();
141
+ this.bridge = br;
142
+ this.spawned = true;
143
+ })();
144
+ await this.runTransition(transition);
145
+
146
+ if (!this.bridge) throw new Error('BridgeDriver spawn failed');
147
+ return this.bridge;
148
+ }
149
+
150
+ /**
151
+ * Ensure the helper child is spawned AND `initialize()`d (i.e.
152
+ * connected to Oscilloscope's COM server). Idempotent. Calls
153
+ * `ensureSpawned()` internally so it works from a cold start AND
154
+ * from a warm state where `preflightCheck()` already spawned the
155
+ * helper.
156
+ */
157
+ private async ensureReady(): Promise<IScopeBridge> {
158
+ const br = await this.ensureSpawned();
159
+ await this.awaitLifecycleSettled();
160
+ if (this.initialized && this.bridge) return this.bridge;
161
+
162
+ // Serialise the initialize call behind the mutex. Failure
163
+ // leaves `spawned=true, initialized=false` so the next caller
164
+ // can retry the initialize step without re-spawning.
165
+ const transition = (async (): Promise<void> => {
166
+ if (this.initialized && this.bridge) return;
167
+ if (!this.spawned || !this.bridge) {
168
+ // disconnect() ran between ensureSpawned and the mutex
169
+ // becoming available. Surface a clear error so the
170
+ // caller knows to retry from scratch.
171
+ throw new Error(
172
+ 'BridgeDriver: helper not spawned (lost race with disconnect)',
173
+ );
174
+ }
175
+ // autoLaunch=false: we never auto-spawn Oscilloscope from a
176
+ // UI tool. If osc is not running, `initialize` will throw
177
+ // (or return connected=false depending on helper config)
178
+ // and the calling tool surfaces a friendly "Oscilloscope
179
+ // is not running" error to the AI.
180
+ await this.bridge.initialize({ autoLaunch: false });
181
+ this.initialized = true;
182
+ })();
183
+ await this.runTransition(transition);
184
+
185
+ if (!this.bridge) throw new Error('BridgeDriver init failed');
186
+ return br;
187
+ }
188
+
189
+ /**
190
+ * Block until any in-flight `lifecyclePromise` resolves or rejects.
191
+ * Loops because a fresh transition may start while we await the
192
+ * current one (e.g. parallel callers chained through the mutex).
193
+ * Swallows rejections — the caller re-reads the boolean state and
194
+ * decides whether to retry. */
195
+ private async awaitLifecycleSettled(): Promise<void> {
196
+ while (this.lifecyclePromise) {
197
+ const p = this.lifecyclePromise;
198
+ try {
199
+ await p;
200
+ } catch {
201
+ /* let the caller re-inspect spawned/initialized */
202
+ }
203
+ if (this.lifecyclePromise === p) this.lifecyclePromise = null;
204
+ }
205
+ }
206
+
207
+ /** Install `promise` as the current `lifecyclePromise`, await it,
208
+ * and clear the slot on completion. The caller has already
209
+ * awaited the previous transition via `awaitLifecycleSettled()`,
210
+ * so installing here is race-free. */
211
+ private async runTransition(promise: Promise<void>): Promise<void> {
212
+ // Guard form — never store a rejected promise in the slot.
213
+ // Without this, a concurrent `await this.lifecyclePromise`
214
+ // would synchronously re-throw the same rejection.
215
+ const guard = promise.then(() => {}, () => {});
216
+ this.lifecyclePromise = guard;
217
+ try {
218
+ await promise;
219
+ } finally {
220
+ if (this.lifecyclePromise === guard) this.lifecyclePromise = null;
221
+ }
222
+ }
223
+
224
+ /** Construct an IScopeBridge with our standard wiring. Does NOT
225
+ * start the helper child — the caller decides between
226
+ * `start()` only (preflight path) and `start() + initialize()`
227
+ * (full ready path). */
228
+ private spawnHelper(): IScopeBridge {
229
+ const helperPath =
230
+ this.opts.helperPath
231
+ ?? resolveHelperPath({ bundledRoot: this.opts.bundledRoot });
232
+ this.log(`[BridgeDriver] spawning helper: ${helperPath}`);
233
+ return new IScopeBridge({
234
+ helperPath,
235
+ requestTimeoutMs: this.opts.requestTimeoutMs ?? 10_000,
236
+ onStderr: (chunk) => this.log(`[helper-stderr] ${chunk.trimEnd()}`),
237
+ });
238
+ }
239
+
240
+ private log(line: string): void {
241
+ if (this.opts.log) this.opts.log(line);
242
+ }
243
+
244
+ // ---- UI modal control ----------------------------------------------------
245
+
246
+ async uiModalList(): Promise<UIModalListResponse> {
247
+ const br = await this.ensureReady();
248
+ return br.uiModalList();
249
+ }
250
+
251
+ async uiModalClick(args: UIModalClickRequest): Promise<UIModalClickResponse> {
252
+ const br = await this.ensureReady();
253
+ return br.uiModalClick(args);
254
+ }
255
+
256
+ async uiModalFill(args: UIModalFillRequest): Promise<UIModalFillResponse> {
257
+ const br = await this.ensureReady();
258
+ return br.uiModalFill(args);
259
+ }
260
+
261
+ async uiModalDismiss(args: UIModalDismissRequest): Promise<UIModalDismissResponse> {
262
+ const br = await this.ensureReady();
263
+ return br.uiModalDismiss(args);
264
+ }
265
+
266
+ // ---- Pre-flight check (Phase 1, MDM detection) -------------------------
267
+ // Uses `ensureSpawned` rather than `ensureReady` because the C++
268
+ // `preflight/check` handler is registry-only and must work on a
269
+ // machine where Oscilloscope is not installed or not running.
270
+ // The state-machine contract guarantees that any subsequent
271
+ // `ui_modal_*` call from the same MCP server session will then
272
+ // promote the helper from `spawned` to `ready` via `ensureReady`,
273
+ // running `initialize()` exactly once — without this two-stage
274
+ // design, `preflightCheck` would set `bridge !== null` and a
275
+ // single-flag `ensureReady` would short-circuit, leaving the
276
+ // helper un-initialised when the first ui_modal_* call arrives
277
+ // (B1 regression, fixed 2026-05-19; see test 23-preflight-then-ui).
278
+
279
+ async preflightCheck(): Promise<PreflightCheckResponse> {
280
+ const br = await this.ensureSpawned();
281
+ return br.preflightCheck();
282
+ }
283
+
284
+ /** Wire ABI probe via `protocol/version` — no `initialize()`, no COM. */
285
+ async abiCheck(policy?: AbiPolicy): Promise<AbiCheckResult> {
286
+ const br = await this.ensureSpawned();
287
+ const version = await br.protocolVersion();
288
+ return evaluateAbiCheck(version, policy ?? 'strict');
289
+ }
290
+
291
+ // ---- Phase 2/3 placeholders ---------------------------------------------
292
+
293
+ async openFile(_path: string): Promise<void> {
294
+ throw new Error('BridgeDriver.openFile not yet implemented (Phase 2)');
295
+ }
296
+
297
+ async getStatus(): Promise<{ status: number; name: string }> {
298
+ throw new Error('BridgeDriver.getStatus not yet implemented (Phase 2)');
299
+ }
300
+
301
+ async runScript(_args: { file: string; mwfFile?: string }): Promise<void> {
302
+ throw new Error('BridgeDriver.runScript not yet implemented (Phase 2)');
303
+ }
304
+
305
+ // ---- Lifecycle -----------------------------------------------------------
306
+
307
+ /** Tear down the helper child. Idempotent. Resets state to `idle`.
308
+ * Waits for any in-flight transition (spawn or initialize) to
309
+ * settle first so we never `forceStop` a helper mid-handshake. */
310
+ async disconnect(): Promise<void> {
311
+ await this.awaitLifecycleSettled();
312
+ const br = this.bridge;
313
+ this.bridge = null;
314
+ this.spawned = false;
315
+ this.initialized = false;
316
+ if (!br) return;
317
+ try {
318
+ await br.shutdown({ closePolicy: 'ifWeLaunched' });
319
+ } catch (e) {
320
+ this.log(`[BridgeDriver] shutdown error: ${e instanceof Error ? e.message : String(e)}`);
321
+ }
322
+ try {
323
+ br.forceStop();
324
+ } catch {
325
+ /* helper might already be dead */
326
+ }
327
+ }
328
+ }
@@ -0,0 +1,336 @@
1
+ // Minimal stand-alone Debug Adapter Protocol client.
2
+ //
3
+ // Why we don't reuse @vscode/debugadapter-testsupport:
4
+ // That package is Microsoft's own test harness — fine for the
5
+ // adapter author, BUT (a) the name screams "test only" and scares
6
+ // anyone reading our published package.json, and (b) it's a
7
+ // *testsupport* artefact: Microsoft do not promise API stability
8
+ // across minor versions. A breaking change in a 1.69.x → 1.70.x
9
+ // bump would silently break every npm install of our MCP server.
10
+ //
11
+ // The wire is just JSON-over-stdio with a `Content-Length: N\r\n
12
+ // \r\n<body>` framing — too small to justify a runtime dependency
13
+ // for a public package. Hence: ~250 LOC of our own, tailored to
14
+ // the surface DapDriver actually needs.
15
+ //
16
+ // Surface (intentionally narrower than DebugClient's):
17
+ // - `start()` / `stop()` lifecycle.
18
+ // - `request<T>(command, args?, timeoutMs?)` → resolves with
19
+ // `response.body` (or rejects with the response.message).
20
+ // - `waitForEvent<T>(name, timeoutMs?)` → resolves with the next
21
+ // event of that name.
22
+ // - EventEmitter semantics for unsolicited events (`output`,
23
+ // `stopped`, `continued`, `terminated`, `exited`, ...) — callers
24
+ // attach `.on(name, handler)` like they would on any Node
25
+ // EventEmitter.
26
+ //
27
+ // Out of scope (versus DebugClient):
28
+ // - Convenience wrappers like `initializeRequest(args)` —
29
+ // `dc.request('initialize', args)` is enough.
30
+ // - Reverse requests (the server's `request` direction, e.g.
31
+ // `runInTerminal`). The iScope adapter never issues those.
32
+ // - `hitBreakpoint()` / `assertStoppedLocation()` test helpers.
33
+
34
+ import { spawn, type ChildProcess } from 'node:child_process';
35
+ import { EventEmitter } from 'node:events';
36
+
37
+ import type { DebugProtocol } from '@vscode/debugprotocol';
38
+
39
+ export interface DapClientOptions {
40
+ /** Runtime to spawn (typically 'node'). Defaults to 'node'. */
41
+ runtime?: string;
42
+ /** Absolute path to the .js / .cjs adapter entry point. */
43
+ executable: string;
44
+ /** Extra argv for the runtime, AFTER `executable`. Rarely used —
45
+ * DAP adapters typically take no argv. */
46
+ args?: string[];
47
+ /** Environment for the child. Defaults to `process.env`. */
48
+ env?: NodeJS.ProcessEnv;
49
+ /** Working directory for the child. */
50
+ cwd?: string;
51
+ /** Per-request default timeout. Defaults to 30 000 ms. */
52
+ defaultTimeoutMs?: number;
53
+ /** Inherit the child's stderr into the parent's stderr. true is
54
+ * the right default for production (we surface adapter logs).
55
+ * Set false in unit tests if the log noise bothers you. */
56
+ inheritStderr?: boolean;
57
+ /** Diagnostic logger. Defaults to no-op. */
58
+ log?: (line: string) => void;
59
+ }
60
+
61
+ /** Internal record for an in-flight DAP request, keyed by `seq`. */
62
+ interface PendingRequest {
63
+ resolve: (body: unknown) => void;
64
+ reject: (err: Error) => void;
65
+ timer: NodeJS.Timeout;
66
+ command: string;
67
+ }
68
+
69
+ const NO_LOG = (_: string): void => { /* no-op */ };
70
+
71
+ /**
72
+ * Stand-alone DAP client. Inherits from EventEmitter for event
73
+ * subscription. Type-safe `request<T>` and `waitForEvent<T>` give
74
+ * callers the response body / event payload directly.
75
+ */
76
+ export class DapClient extends EventEmitter {
77
+ private readonly runtime: string;
78
+ private readonly executable: string;
79
+ private readonly args: string[];
80
+ private readonly env: NodeJS.ProcessEnv;
81
+ private readonly cwd: string | undefined;
82
+ private readonly inheritStderr: boolean;
83
+ private readonly log: (line: string) => void;
84
+
85
+ private child: ChildProcess | null = null;
86
+ private childExited = false;
87
+
88
+ /** Monotonic outgoing message id. DAP spec: `seq` is per-sender
89
+ * and increases by one per message; the OTHER side has its own
90
+ * `seq` axis we ignore. */
91
+ private nextSeq = 1;
92
+
93
+ /** Accumulating stdout buffer. We pull complete frames out of
94
+ * it as they arrive. Type widened to ArrayBufferLike to match
95
+ * what `Buffer.concat` and `Buffer#subarray` return in
96
+ * @types/node 22+. */
97
+ private rxBuffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
98
+ /** Once we see `Content-Length: N\r\n\r\n` we remember N here
99
+ * until we've consumed exactly N bytes of body. -1 = "still
100
+ * parsing the header". */
101
+ private rxContentLength = -1;
102
+
103
+ /** seq → outstanding request. We never await a response by
104
+ * `command` alone — two simultaneous `stackTrace`s would
105
+ * collide. `seq` is the only safe key. */
106
+ private readonly pending = new Map<number, PendingRequest>();
107
+
108
+ public defaultTimeoutMs: number;
109
+
110
+ constructor(opts: DapClientOptions) {
111
+ super();
112
+ this.runtime = opts.runtime ?? 'node';
113
+ this.executable = opts.executable;
114
+ this.args = opts.args ?? [];
115
+ this.env = opts.env ?? process.env;
116
+ this.cwd = opts.cwd;
117
+ this.inheritStderr = opts.inheritStderr ?? true;
118
+ this.log = opts.log ?? NO_LOG;
119
+ this.defaultTimeoutMs = opts.defaultTimeoutMs ?? 30_000;
120
+ }
121
+
122
+ /** Spawn the adapter child. Returns once `spawn()` has been
123
+ * called — the child may still be initialising. Callers
124
+ * immediately issue `request('initialize', ...)` and the DAP
125
+ * request/response framing handles the rest. */
126
+ async start(): Promise<void> {
127
+ if (this.child) {
128
+ throw new Error('DapClient.start: already started');
129
+ }
130
+ const argv = [this.executable, ...this.args];
131
+ const child = spawn(this.runtime, argv, {
132
+ env: this.env,
133
+ cwd: this.cwd,
134
+ stdio: ['pipe', 'pipe', this.inheritStderr ? 'inherit' : 'pipe'],
135
+ windowsHide: true,
136
+ });
137
+ this.child = child;
138
+ this.childExited = false;
139
+
140
+ // Stdout is the DAP wire. Stderr (if piped) is diagnostic
141
+ // chatter — we forward it via `log` so it doesn't poison the
142
+ // wire by accident.
143
+ const stdout = child.stdout;
144
+ const stdin = child.stdin;
145
+ if (!stdout || !stdin) {
146
+ throw new Error('DapClient.start: child has no stdio pipes');
147
+ }
148
+ stdout.on('data', (chunk: Buffer) => this.onStdoutChunk(chunk));
149
+ stdout.on('error', (e: Error) => this.log(`[dap-client] stdout error: ${e.message}`));
150
+ stdin .on('error', (e: Error) => this.log(`[dap-client] stdin error: ${e.message}`));
151
+
152
+ if (!this.inheritStderr && child.stderr) {
153
+ child.stderr.on('data', (chunk: Buffer) => {
154
+ this.log(`[dap-child stderr] ${chunk.toString('utf8').replace(/\n$/, '')}`);
155
+ });
156
+ }
157
+
158
+ child.on('exit', (code, signal) => {
159
+ this.childExited = true;
160
+ this.log(`[dap-client] child exited code=${code} signal=${signal ?? '-'}`);
161
+ // Fail any in-flight requests. Without this they would
162
+ // hang on the timeout — bad UX when the child crashed
163
+ // 200 ms ago.
164
+ const reason = `child process exited (code=${code} signal=${signal ?? '-'})`;
165
+ for (const p of this.pending.values()) {
166
+ clearTimeout(p.timer);
167
+ p.reject(new Error(`DAP request '${p.command}' failed: ${reason}`));
168
+ }
169
+ this.pending.clear();
170
+ this.emit('exited', { code, signal });
171
+ });
172
+ child.on('error', (err) => {
173
+ this.log(`[dap-client] spawn error: ${err.message}`);
174
+ this.emit('error', err);
175
+ });
176
+ }
177
+
178
+ /** Send a request and resolve with `response.body`. Rejects on
179
+ * - timeout (after `timeoutMs ?? this.defaultTimeoutMs`),
180
+ * - protocol-level failure (`response.success === false`),
181
+ * - child process exit (the exit handler nukes pending). */
182
+ request<T = unknown>(command: string, args?: unknown, timeoutMs?: number): Promise<T> {
183
+ if (!this.child || this.childExited || !this.child.stdin) {
184
+ return Promise.reject(new Error(`DAP request '${command}': no live child`));
185
+ }
186
+ const seq = this.nextSeq++;
187
+ const message = { seq, type: 'request', command, arguments: args };
188
+ const json = JSON.stringify(message);
189
+ const header = `Content-Length: ${Buffer.byteLength(json, 'utf8')}\r\n\r\n`;
190
+ try {
191
+ this.child.stdin.write(header + json, 'utf8');
192
+ } catch (e) {
193
+ const msg = e instanceof Error ? e.message : String(e);
194
+ return Promise.reject(new Error(`DAP request '${command}' write failed: ${msg}`));
195
+ }
196
+ const wait = timeoutMs ?? this.defaultTimeoutMs;
197
+ return new Promise<T>((resolve, reject) => {
198
+ const timer = setTimeout(() => {
199
+ this.pending.delete(seq);
200
+ reject(new Error(`DAP request '${command}' timed out after ${wait}ms`));
201
+ }, wait);
202
+ this.pending.set(seq, {
203
+ resolve: (body) => resolve(body as T),
204
+ reject,
205
+ timer,
206
+ command,
207
+ });
208
+ });
209
+ }
210
+
211
+ /** Resolve with the next event of the given name. Honours a
212
+ * timeout to avoid hanging on tests that never fire the event
213
+ * they expect. */
214
+ waitForEvent<T extends DebugProtocol.Event = DebugProtocol.Event>(name: string, timeoutMs?: number): Promise<T> {
215
+ const wait = timeoutMs ?? this.defaultTimeoutMs;
216
+ return new Promise<T>((resolve, reject) => {
217
+ const handler = (ev: T): void => {
218
+ clearTimeout(timer);
219
+ resolve(ev);
220
+ };
221
+ const timer = setTimeout(() => {
222
+ this.off(name, handler);
223
+ reject(new Error(`DAP event '${name}' did not arrive within ${wait}ms`));
224
+ }, wait);
225
+ this.once(name, handler);
226
+ });
227
+ }
228
+
229
+ /** Tear the child down. Graceful path: close stdin → child sees
230
+ * EOF → exits → we resolve. Fallback path: after `gracefulMs`,
231
+ * SIGTERM; after another second, SIGKILL. Either way the
232
+ * Promise always resolves; we never throw from cleanup. */
233
+ async stop(gracefulMs = 2_000): Promise<void> {
234
+ const child = this.child;
235
+ if (!child) return;
236
+ if (this.childExited) { this.child = null; return; }
237
+
238
+ try { child.stdin?.end(); } catch { /* already closed */ }
239
+
240
+ await new Promise<void>((resolve) => {
241
+ const onExit = (): void => {
242
+ clearTimeout(termTimer);
243
+ clearTimeout(killTimer);
244
+ resolve();
245
+ };
246
+ const termTimer = setTimeout(() => {
247
+ try { child.kill('SIGTERM'); } catch { /* ignore */ }
248
+ }, gracefulMs);
249
+ const killTimer = setTimeout(() => {
250
+ try { child.kill('SIGKILL'); } catch { /* ignore */ }
251
+ // Whether or not SIGKILL got through, give Node one
252
+ // more tick to fire 'exit'; otherwise resolve anyway.
253
+ setTimeout(resolve, 100);
254
+ }, gracefulMs + 1_000);
255
+ child.once('exit', onExit);
256
+ });
257
+ this.child = null;
258
+ }
259
+
260
+ // ---- internals --------------------------------------------------
261
+
262
+ private onStdoutChunk(chunk: Buffer): void {
263
+ this.rxBuffer = this.rxBuffer.length === 0
264
+ ? chunk
265
+ : Buffer.concat([this.rxBuffer, chunk]);
266
+
267
+ // Drain as many complete frames as fit in the buffer right
268
+ // now. One stdout chunk can carry many frames (e.g. a flood
269
+ // of output events after launch); equally one frame can
270
+ // arrive in several chunks (when the adapter writes a big
271
+ // variables[] body).
272
+ while (true) {
273
+ if (this.rxContentLength < 0) {
274
+ const headerEnd = this.rxBuffer.indexOf('\r\n\r\n');
275
+ if (headerEnd < 0) break;
276
+ const headers = this.rxBuffer.subarray(0, headerEnd).toString('ascii');
277
+ let length = -1;
278
+ for (const line of headers.split('\r\n')) {
279
+ const m = /^Content-Length:\s*(\d+)$/i.exec(line);
280
+ if (m) length = parseInt(m[1] as string, 10);
281
+ }
282
+ if (length < 0) {
283
+ this.log(`[dap-client] missing Content-Length in headers; dropping ${headerEnd + 4} bytes and resyncing`);
284
+ this.rxBuffer = this.rxBuffer.subarray(headerEnd + 4);
285
+ continue;
286
+ }
287
+ this.rxContentLength = length;
288
+ this.rxBuffer = this.rxBuffer.subarray(headerEnd + 4);
289
+ }
290
+ if (this.rxBuffer.length < this.rxContentLength) break;
291
+ const body = this.rxBuffer.subarray(0, this.rxContentLength).toString('utf8');
292
+ this.rxBuffer = this.rxBuffer.subarray(this.rxContentLength);
293
+ this.rxContentLength = -1;
294
+ try {
295
+ this.dispatch(JSON.parse(body) as DapAnyMessage);
296
+ } catch (e) {
297
+ const msg = e instanceof Error ? e.message : String(e);
298
+ this.log(`[dap-client] failed to parse incoming message (${body.length}B): ${msg}`);
299
+ }
300
+ }
301
+ }
302
+
303
+ private dispatch(msg: DapAnyMessage): void {
304
+ if (msg.type === 'response') {
305
+ const seq = msg.request_seq ?? -1;
306
+ const p = this.pending.get(seq);
307
+ if (!p) return;
308
+ this.pending.delete(seq);
309
+ clearTimeout(p.timer);
310
+ if (msg.success === false) {
311
+ p.reject(new Error(msg.message || `DAP request '${p.command}' failed`));
312
+ } else {
313
+ p.resolve(msg.body);
314
+ }
315
+ return;
316
+ }
317
+ if (msg.type === 'event' && typeof msg.event === 'string') {
318
+ this.emit(msg.event, msg);
319
+ return;
320
+ }
321
+ // type='request' from the server (e.g. runInTerminal) is the
322
+ // only other DAP message kind. The iScope adapter never sends
323
+ // those; ignore.
324
+ }
325
+ }
326
+
327
+ /** Internal: all three DAP message shapes flattened into one. */
328
+ interface DapAnyMessage {
329
+ type?: 'request' | 'response' | 'event';
330
+ command?: string;
331
+ event?: string;
332
+ success?: boolean;
333
+ request_seq?: number;
334
+ body?: unknown;
335
+ message?: string;
336
+ }