@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.
- package/CHANGELOG.md +147 -0
- package/LICENSE +21 -0
- package/README.md +373 -0
- package/dist/src/abi-check.d.ts +19 -0
- package/dist/src/abi-check.js +66 -0
- package/dist/src/bridge-driver.d.ts +90 -0
- package/dist/src/bridge-driver.js +290 -0
- package/dist/src/dap-client.d.ts +80 -0
- package/dist/src/dap-client.js +296 -0
- package/dist/src/dap-driver.d.ts +162 -0
- package/dist/src/dap-driver.js +703 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +175 -0
- package/dist/src/state.d.ts +86 -0
- package/dist/src/state.js +15 -0
- package/dist/src/tools/abi-check.d.ts +3 -0
- package/dist/src/tools/abi-check.js +64 -0
- package/dist/src/tools/breakpoints.d.ts +3 -0
- package/dist/src/tools/breakpoints.js +56 -0
- package/dist/src/tools/execution.d.ts +3 -0
- package/dist/src/tools/execution.js +75 -0
- package/dist/src/tools/helpers.d.ts +27 -0
- package/dist/src/tools/helpers.js +134 -0
- package/dist/src/tools/inspection.d.ts +3 -0
- package/dist/src/tools/inspection.js +141 -0
- package/dist/src/tools/lifecycle.d.ts +3 -0
- package/dist/src/tools/lifecycle.js +103 -0
- package/dist/src/tools/preflight.d.ts +3 -0
- package/dist/src/tools/preflight.js +95 -0
- package/dist/src/tools/registry.d.ts +15 -0
- package/dist/src/tools/registry.js +19 -0
- package/dist/src/tools/snapshot.d.ts +3 -0
- package/dist/src/tools/snapshot.js +117 -0
- package/dist/src/tools/source-maps.d.ts +3 -0
- package/dist/src/tools/source-maps.js +232 -0
- package/dist/src/tools/sync.d.ts +3 -0
- package/dist/src/tools/sync.js +80 -0
- package/dist/src/tools/ui-modal.d.ts +3 -0
- package/dist/src/tools/ui-modal.js +182 -0
- package/package.json +73 -0
- package/src/abi-check.ts +97 -0
- package/src/bridge-driver.ts +328 -0
- package/src/dap-client.ts +336 -0
- package/src/dap-driver.ts +810 -0
- package/src/index.ts +155 -0
- package/src/state.ts +115 -0
- package/src/tools/abi-check.ts +66 -0
- package/src/tools/breakpoints.ts +59 -0
- package/src/tools/execution.ts +105 -0
- package/src/tools/helpers.ts +142 -0
- package/src/tools/inspection.ts +173 -0
- package/src/tools/lifecycle.ts +129 -0
- package/src/tools/preflight.ts +95 -0
- package/src/tools/registry.ts +34 -0
- package/src/tools/snapshot.ts +132 -0
- package/src/tools/source-maps.ts +222 -0
- package/src/tools/sync.ts +90 -0
- 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
|
+
}
|