@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,703 @@
1
+ "use strict";
2
+ // DapDriver — the heart of the MCP server.
3
+ //
4
+ // Responsibilities:
5
+ // 1. Spawn the iScope DAP adapter (the `server.js` shipped inside
6
+ // `@i-scope/dap-adapter`) as a Node child process via our own
7
+ // minimal `DapClient` (see `./dap-client.ts`). We avoid
8
+ // `@vscode/debugadapter-testsupport` so the published package
9
+ // doesn't depend on a "testsupport" artefact with no API stability
10
+ // guarantees across minor versions.
11
+ // 2. Translate one debug session's DAP wire into a small, stable
12
+ // surface for the MCP tools (launch / set-breakpoints / step /
13
+ // inspect / disconnect).
14
+ // 3. Hold the session state machine
15
+ // (`idle → launching → running ⇄ paused → terminated`) and gate
16
+ // tool calls against it (you cannot `variables` while running).
17
+ // 4. Buffer asynchronous DAP events:
18
+ // - `output` into a circular log with monotonic ids (consumed
19
+ // by `debug_read_output`),
20
+ // - `stopped` / `terminated` into a "pending-wait" Promise that
21
+ // step/continue tools `await` on.
22
+ // 5. Serialise DAP requests through an internal mutex so two MCP
23
+ // tools called concurrently from the client don't trample each
24
+ // other on the wire (`stack_trace` issued while a `continue`
25
+ // response is still in flight would corrupt the state machine).
26
+ // 6. Clean up the child process tree on `disconnect` and on hard
27
+ // shutdown — DAP disconnectRequest → debug-server exits → helper
28
+ // shuts down with closePolicy='ifWeLaunched' → Oscilloscope
29
+ // either closes (we launched it) or stays alive (user did). Same
30
+ // contract as Phase B'-6.
31
+ //
32
+ // Concurrency contract:
33
+ // - All exclusive DAP operations go through `runExclusive()`.
34
+ // - Snapshot-only operations (`getState`, `getStoppedSnapshot`,
35
+ // `readOutput`) are lock-free; they read fields synchronously.
36
+ // - Events arrive on the event-loop microtask, so anything we do
37
+ // inside a handler must be O(small). We push transitions into the
38
+ // state and notify pending waiters; the actual heavy work
39
+ // (mapping frames, fetching scopes) happens lazily on the next
40
+ // tool call.
41
+ Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.DapDriver = exports.Mutex = void 0;
43
+ const node_path_1 = require("node:path");
44
+ const node_fs_1 = require("node:fs");
45
+ const source_map_bridge_1 = require("@i-scope/source-map-bridge");
46
+ const dap_adapter_1 = require("@i-scope/dap-adapter");
47
+ const dap_client_js_1 = require("./dap-client.js");
48
+ // ---- Internal mutex --------------------------------------------------------
49
+ /** Promise-chain mutex. Each `runExclusive(fn)` waits for the prior
50
+ * one to settle, then runs `fn`. No re-entrancy — if a tool handler
51
+ * re-enters runExclusive from inside its own callback it WILL
52
+ * deadlock. (Tools are designed to never do that.)
53
+ *
54
+ * Exported for unit testing. The MCP server itself doesn't need to
55
+ * re-export it.
56
+ */
57
+ class Mutex {
58
+ tail = Promise.resolve();
59
+ runExclusive(fn) {
60
+ const prev = this.tail;
61
+ let resolveCurrent;
62
+ let rejectCurrent;
63
+ const current = new Promise((res, rej) => {
64
+ resolveCurrent = res;
65
+ rejectCurrent = rej;
66
+ });
67
+ this.tail = current.catch(() => undefined);
68
+ prev.then(async () => {
69
+ try {
70
+ resolveCurrent(await fn());
71
+ }
72
+ catch (e) {
73
+ rejectCurrent(e);
74
+ }
75
+ },
76
+ // The prior call rejected; we still want to run.
77
+ async () => {
78
+ try {
79
+ resolveCurrent(await fn());
80
+ }
81
+ catch (e) {
82
+ rejectCurrent(e);
83
+ }
84
+ });
85
+ return current;
86
+ }
87
+ }
88
+ exports.Mutex = Mutex;
89
+ class DapDriver {
90
+ extensionPath;
91
+ debugServerJs;
92
+ defaultTimeoutMs;
93
+ outputBufferSize;
94
+ log;
95
+ client = null;
96
+ state = 'idle';
97
+ /** Source-map manager used to fill `generatedSource`/`generatedLine`
98
+ * on mapped frames (and to surface unmapped-fallback warnings).
99
+ * Lazily created on first launch and reset on disconnect so a
100
+ * fresh session always parses the current `.ajs.map` from disk. */
101
+ sourceMaps = null;
102
+ /** Absolute path to the generated `.ajs` for the current launch.
103
+ * Used by `classifyFrame` to ask the manager for reverse mappings
104
+ * without re-resolving the sibling each call. Empty when launch
105
+ * was against a `.ajs` (then `generatedAjs === program`). */
106
+ generatedAjs = null;
107
+ /** Once-per-launch dedup for unmapped-fallback warning messages.
108
+ * Without it the same warning would land in the output buffer on
109
+ * every stack_trace call. Key: `<source>:<line>:<col>`. */
110
+ unmappedWarned = new Set();
111
+ /** Args from the most recent launch. Used to classify
112
+ * `sourceOrigin` on frames returned by stackTrace. */
113
+ launchArgs = null;
114
+ /** Lower-case basename extension of `launchArgs.program`, e.g.
115
+ * `.ts` or `.ajs`. Cached so frame-mapping doesn't repeat
116
+ * `path.extname()` per frame. */
117
+ programIsTs = false;
118
+ /** Circular output buffer; oldest entries dropped first when full. */
119
+ outputBuffer = [];
120
+ outputNextId = 1;
121
+ /** Last `stopped` snapshot — refreshed on every StoppedEvent.
122
+ * Used by `current_state` tool and by step/continue tools that
123
+ * want to read frames straight from cache before calling
124
+ * stack_trace explicitly. */
125
+ lastStopped = null;
126
+ /** Termination metadata captured when DAP emits `terminated`. */
127
+ terminationInfo = null;
128
+ /** Pending step/continue/wait_for_paused awaiters. Resolved on
129
+ * the next state transition. */
130
+ pendingWaits = [];
131
+ mutex = new Mutex();
132
+ constructor(opts) {
133
+ this.extensionPath = opts.extensionPath ? (0, node_path_1.resolve)(opts.extensionPath) : undefined;
134
+ // The DAP entry-point lives inside `@i-scope/dap-adapter`. Use
135
+ // Node's resolver so the same code path works in published
136
+ // installs (npm node_modules) AND inside this monorepo
137
+ // (workspaces symlink the package into place). No file-system
138
+ // path arithmetic — that is now the adapter package's
139
+ // responsibility.
140
+ this.debugServerJs = require.resolve('@i-scope/dap-adapter/dist/src/server.js');
141
+ this.defaultTimeoutMs = opts.defaultTimeoutMs ?? 30_000;
142
+ this.outputBufferSize = opts.outputBufferSize ?? 2000;
143
+ this.log = opts.log ?? ((line) => process.stderr.write(line + '\n'));
144
+ }
145
+ // ---- Public read-only API (lock-free, snapshot) ----------------------
146
+ getState() { return this.state; }
147
+ getStoppedSnapshot() { return this.lastStopped; }
148
+ getTerminationInfo() { return this.terminationInfo; }
149
+ /** Was the most recent launch against a `.ts` file? Tool layer
150
+ * uses this to bake `sourceOrigin` into frames without
151
+ * re-inspecting `launchArgs`. */
152
+ isTsLaunch() { return this.programIsTs; }
153
+ /** Snapshot of buffered Output entries with id > cursor.
154
+ * AI passes `nextCursor` from the previous read to get the tail.
155
+ * Lock-free; never mutates state. */
156
+ readOutput(cursor, limit = 1000) {
157
+ const out = [];
158
+ let max = cursor;
159
+ for (const e of this.outputBuffer) {
160
+ if (e.id > cursor) {
161
+ out.push(e);
162
+ if (e.id > max)
163
+ max = e.id;
164
+ if (out.length >= limit)
165
+ break;
166
+ }
167
+ }
168
+ return { entries: out, nextCursor: max };
169
+ }
170
+ // ---- Lifecycle -------------------------------------------------------
171
+ async launch(args) {
172
+ return this.mutex.runExclusive(async () => {
173
+ if (this.state !== 'idle' && this.state !== 'terminated') {
174
+ throw new Error(`cannot launch in state '${this.state}'; call debug_disconnect first`);
175
+ }
176
+ if (!(0, node_fs_1.existsSync)(this.debugServerJs)) {
177
+ throw new Error(`dap-adapter server.js not found at ${this.debugServerJs}\n` +
178
+ `Did the @i-scope/dap-adapter install complete? Try: npm install @i-scope/dap-adapter`);
179
+ }
180
+ this.resetSessionState();
181
+ this.launchArgs = args;
182
+ this.programIsTs = /\.ts$/i.test(args.program);
183
+ // Wire a fresh source-map manager for this session and
184
+ // preload the relevant `.ajs.map`s. Failures here are
185
+ // warnings, not hard errors — debug still works without
186
+ // mapping, the AI just sees `sourceOrigin: 'generated'`
187
+ // or `'unmapped-fallback'` for every frame.
188
+ await this.initSourceMaps(args).catch((e) => {
189
+ this.log(`[mcp] source-map preload failed: ${this.errMsg(e)}`);
190
+ });
191
+ const timeoutMs = args.timeoutMs ?? this.defaultTimeoutMs;
192
+ // The adapter package is a pure Node module — no
193
+ // `require('vscode')` lives in its runtime path any more,
194
+ // so we spawn its `server.js` directly. `ISCOPE_EXTENSION_PATH`
195
+ // is forwarded only when explicitly provided (it acts as a
196
+ // `bundledRoot` override for the bridge-client helper
197
+ // resolver). When unset, the spawned adapter falls back to
198
+ // the npm-installed `@i-scope/iscope-bridge` package.
199
+ const childEnv = { ...process.env };
200
+ if (this.extensionPath) {
201
+ childEnv['ISCOPE_EXTENSION_PATH'] = this.extensionPath;
202
+ }
203
+ const dc = new dap_client_js_1.DapClient({
204
+ runtime: 'node',
205
+ executable: this.debugServerJs,
206
+ env: childEnv,
207
+ inheritStderr: true,
208
+ defaultTimeoutMs: timeoutMs,
209
+ log: this.log,
210
+ });
211
+ this.client = dc;
212
+ this.wireClientEvents(dc);
213
+ this.state = 'launching';
214
+ await dc.start();
215
+ await dc.request('initialize', {
216
+ adapterID: 'iscope-ajs',
217
+ linesStartAt1: true,
218
+ columnsStartAt1: true,
219
+ pathFormat: 'path',
220
+ });
221
+ // Phase D-1 finding #11: launchRequest is buffered by the
222
+ // adapter until configurationDone; arrange the wait BEFORE
223
+ // sending launchRequest so we don't lose the `initialized`
224
+ // event.
225
+ const initialized = dc.waitForEvent('initialized', timeoutMs);
226
+ const dapLaunchArgs = {
227
+ type: 'iscope-ajs',
228
+ request: 'launch',
229
+ name: 'mcp-launch',
230
+ program: args.program,
231
+ };
232
+ if (args.mwfFile !== undefined)
233
+ dapLaunchArgs['mwfFile'] = args.mwfFile;
234
+ if (args.autoLaunch !== undefined)
235
+ dapLaunchArgs['autoLaunch'] = args.autoLaunch;
236
+ if (args.openFileBeforeRun !== undefined)
237
+ dapLaunchArgs['openFileBeforeRun'] = args.openFileBeforeRun;
238
+ if (args.stopOnEntry !== undefined)
239
+ dapLaunchArgs['stopOnEntry'] = args.stopOnEntry;
240
+ if (args.oscilloscopePath !== undefined)
241
+ dapLaunchArgs['oscilloscopePath'] = args.oscilloscopePath;
242
+ if (args.helperPath !== undefined)
243
+ dapLaunchArgs['helperPath'] = args.helperPath;
244
+ if (args.outFiles !== undefined)
245
+ dapLaunchArgs['outFiles'] = args.outFiles;
246
+ const launchP = dc.request('launch', dapLaunchArgs);
247
+ await initialized;
248
+ await dc.request('configurationDone', {});
249
+ await launchP;
250
+ // Post-launch state: either we hit stopOnEntry / startup
251
+ // halt right away, or we are running. Wait briefly for a
252
+ // stopped/terminated; otherwise consider ourselves running.
253
+ if (this.state === 'launching') {
254
+ // Race with a small timeout so a long-running script
255
+ // doesn't block this tool indefinitely. Tools then use
256
+ // wait_for_paused / step_* to advance.
257
+ const settle = await this.waitForTransition(/*timeoutMs*/ 1500);
258
+ return settle;
259
+ }
260
+ return this.snapshotTransition();
261
+ });
262
+ }
263
+ async disconnect() {
264
+ return this.mutex.runExclusive(async () => {
265
+ if (!this.client) {
266
+ this.state = 'idle';
267
+ return;
268
+ }
269
+ const dc = this.client;
270
+ try {
271
+ // terminateDebuggee semantics: the adapter (D-1 finding
272
+ // #14) coerces this into closePolicy='ifWeLaunched'
273
+ // unconditionally, so we just send true.
274
+ await dc.request('disconnect', { terminateDebuggee: true });
275
+ }
276
+ catch (e) {
277
+ this.log(`[mcp] disconnect request failed: ${this.errMsg(e)}`);
278
+ }
279
+ try {
280
+ await dc.stop();
281
+ }
282
+ catch (e) {
283
+ this.log(`[mcp] dc.stop() failed: ${this.errMsg(e)}`);
284
+ }
285
+ // Fail any awaiters that were waiting for the next stop.
286
+ this.failPendingWaits('disconnected');
287
+ this.client = null;
288
+ this.state = 'idle';
289
+ });
290
+ }
291
+ // ---- Breakpoints / execution / inspection (exclusive) --------------
292
+ async setBreakpoints(source, lines) {
293
+ return this.mutex.runExclusive(async () => {
294
+ const dc = this.requireClient();
295
+ return dc.request('setBreakpoints', {
296
+ source: { path: source, name: this.basename(source) },
297
+ breakpoints: lines.map((l) => ({ line: l })),
298
+ lines,
299
+ sourceModified: false,
300
+ });
301
+ });
302
+ }
303
+ /** continue / step_* / wait_for_paused all funnel through this
304
+ * helper. `dapCall` returns the DAP response Promise; we await
305
+ * the resulting state transition (stopped / terminated / timeout)
306
+ * and return it.
307
+ *
308
+ * `dapCall` may be null for `wait_for_paused` (no request to
309
+ * send — just observe). */
310
+ async resumeAndWait(dapCall, timeoutMs) {
311
+ return this.mutex.runExclusive(async () => {
312
+ const dc = this.requireClient();
313
+ if (dapCall) {
314
+ if (this.state !== 'paused') {
315
+ throw new Error(`cannot resume in state '${this.state}' (must be 'paused')`);
316
+ }
317
+ this.state = 'running';
318
+ // Eagerly invalidate frame state — DAP responses for
319
+ // stackTrace etc. would be stale until the next stop.
320
+ this.lastStopped = null;
321
+ await dapCall(dc);
322
+ }
323
+ else {
324
+ if (this.state !== 'running' && this.state !== 'launching') {
325
+ // Already paused / terminated — return current
326
+ // snapshot without arming a wait.
327
+ return this.snapshotTransition();
328
+ }
329
+ }
330
+ return this.waitForTransition(timeoutMs ?? this.defaultTimeoutMs);
331
+ });
332
+ }
333
+ async stackTrace() {
334
+ return this.mutex.runExclusive(async () => {
335
+ const dc = this.requireClient();
336
+ if (this.state !== 'paused') {
337
+ throw new Error(`stack_trace requires 'paused' state, got '${this.state}'`);
338
+ }
339
+ return dc.request('stackTrace', { threadId: 1 });
340
+ });
341
+ }
342
+ async scopes(frameId) {
343
+ return this.mutex.runExclusive(async () => {
344
+ const dc = this.requireClient();
345
+ if (this.state !== 'paused') {
346
+ throw new Error(`scopes requires 'paused' state, got '${this.state}'`);
347
+ }
348
+ return dc.request('scopes', { frameId });
349
+ });
350
+ }
351
+ async variables(variablesReference) {
352
+ return this.mutex.runExclusive(async () => {
353
+ const dc = this.requireClient();
354
+ if (this.state !== 'paused') {
355
+ throw new Error(`variables requires 'paused' state, got '${this.state}'`);
356
+ }
357
+ return dc.request('variables', { variablesReference });
358
+ });
359
+ }
360
+ async evaluate(expression, frameId, context) {
361
+ return this.mutex.runExclusive(async () => {
362
+ const dc = this.requireClient();
363
+ if (this.state !== 'paused') {
364
+ throw new Error(`evaluate requires 'paused' state, got '${this.state}'`);
365
+ }
366
+ const args = { expression, context };
367
+ if (frameId !== undefined)
368
+ args.frameId = frameId;
369
+ return dc.request('evaluate', args);
370
+ });
371
+ }
372
+ // ---- Frame mapping (pure, no DAP traffic) ----------------------------
373
+ /** Convert a DAP StackFrame into our richer FrameInfo with
374
+ * sourceOrigin annotation AND fully-populated generatedSource/
375
+ * generatedLine/generatedColumn fields.
376
+ *
377
+ * Algorithm:
378
+ * - launch with `.ajs` → frame is always `generated`.
379
+ * generatedSource = frame.source.
380
+ * - launch with `.ts` and frame.source ends in `.ts` →
381
+ * `mapped`. Reverse-look the TS coords up in our SourceMap
382
+ * Manager to get the corresponding `.ajs` coords (so the AI
383
+ * can cross-reference engine offsets even when reading TS).
384
+ * If the reverse lookup fails we still classify as mapped
385
+ * but leave generatedSource/Line undefined.
386
+ * - launch with `.ts` and frame.source ends in `.ajs` →
387
+ * `unmapped-fallback` (adapter could not translate this
388
+ * specific line; AI sees raw engine position). We also
389
+ * buffer ONE console-category OutputEntry per unique
390
+ * <source:line:col> tuple so `debug_read_output` surfaces
391
+ * the gap as a visible warning. */
392
+ async classifyFrame(f) {
393
+ const sourcePath = f.source?.path ?? f.source?.name ?? '<unknown>';
394
+ const isTsFrame = /\.ts$/i.test(sourcePath);
395
+ const isAjsFrame = /\.ajs$/i.test(sourcePath)
396
+ || /\.apn$/i.test(sourcePath)
397
+ || /\.aps$/i.test(sourcePath);
398
+ let sourceOrigin;
399
+ let generatedSource;
400
+ let generatedLine;
401
+ let generatedColumn;
402
+ if (!this.programIsTs) {
403
+ sourceOrigin = 'generated';
404
+ generatedSource = sourcePath;
405
+ generatedLine = f.line;
406
+ generatedColumn = f.column ?? 1;
407
+ }
408
+ else if (isTsFrame) {
409
+ sourceOrigin = 'mapped';
410
+ // Reverse-map TS → AJS via SourceMapManager so the AI
411
+ // gets engine coordinates alongside the TS view.
412
+ if (this.sourceMaps) {
413
+ try {
414
+ const pos = await this.sourceMaps.toGenerated(sourcePath, f.line, f.column ?? 1);
415
+ if (pos) {
416
+ generatedSource = pos.generatedPath;
417
+ generatedLine = pos.line;
418
+ generatedColumn = pos.column;
419
+ }
420
+ }
421
+ catch (e) {
422
+ // Reverse-lookup is best-effort. Log and move on.
423
+ this.log(`[mcp] reverse source-map lookup failed for ${sourcePath}:${f.line}: ${this.errMsg(e)}`);
424
+ }
425
+ }
426
+ }
427
+ else if (isAjsFrame) {
428
+ sourceOrigin = 'unmapped-fallback';
429
+ generatedSource = sourcePath;
430
+ generatedLine = f.line;
431
+ generatedColumn = f.column ?? 1;
432
+ this.warnUnmappedFallback(sourcePath, f.line, f.column ?? 1);
433
+ }
434
+ else {
435
+ // Shouldn't happen — but be defensive.
436
+ sourceOrigin = 'generated';
437
+ generatedSource = sourcePath;
438
+ generatedLine = f.line;
439
+ generatedColumn = f.column ?? 1;
440
+ }
441
+ const out = {
442
+ id: f.id,
443
+ name: f.name,
444
+ source: sourcePath,
445
+ sourceOrigin,
446
+ line: f.line,
447
+ column: f.column ?? 1,
448
+ };
449
+ if (f.endLine !== undefined)
450
+ out.endLine = f.endLine;
451
+ if (f.endColumn !== undefined)
452
+ out.endColumn = f.endColumn;
453
+ if (generatedSource !== undefined)
454
+ out.generatedSource = generatedSource;
455
+ if (generatedLine !== undefined)
456
+ out.generatedLine = generatedLine;
457
+ if (generatedColumn !== undefined)
458
+ out.generatedColumn = generatedColumn;
459
+ return out;
460
+ }
461
+ /** Buffer a single console-category warning per unique
462
+ * <source:line:col>. AI sees it via `debug_read_output` and can
463
+ * decide whether to ignore or to ask the user to rebuild with
464
+ * better source-map coverage. */
465
+ warnUnmappedFallback(source, line, column) {
466
+ const key = `${source}:${line}:${column}`;
467
+ if (this.unmappedWarned.has(key))
468
+ return;
469
+ this.unmappedWarned.add(key);
470
+ const text = `[source-map] unmapped-fallback at ${(0, node_path_1.basename)(source)}:${line}:${column} ` +
471
+ `— the .ajs.map has no mapping for this line; AI sees raw .ajs coordinates.\n`;
472
+ this.outputBuffer.push({
473
+ id: this.outputNextId++,
474
+ category: 'console',
475
+ text,
476
+ timestamp: Date.now(),
477
+ });
478
+ while (this.outputBuffer.length > this.outputBufferSize) {
479
+ this.outputBuffer.shift();
480
+ }
481
+ }
482
+ // ---- Event wiring ----------------------------------------------------
483
+ wireClientEvents(dc) {
484
+ dc.on('output', (ev) => {
485
+ const body = ev.body || {};
486
+ if (typeof body.output !== 'string')
487
+ return;
488
+ // De-duplication: the Phase 3 adapter emits every
489
+ // `Host.ReportOut(level, text)` TWICE on the DAP wire — once
490
+ // as a normal `stdout`/`stderr` OutputEvent (so the Debug
491
+ // Console keeps showing it for human users) and once with
492
+ // the custom `iScopeTrace` category carrying the structured
493
+ // `{ level, text }` payload (for the extension's Trace
494
+ // OutputChannel, see `adapter.ts` around line 1299-1327).
495
+ // The MCP `debug_read_output` consumer only wants the
496
+ // user-visible form, so we drop the structured duplicate
497
+ // here. The trace category is still observable by other
498
+ // consumers that listen on the DAP wire directly.
499
+ if (body.category === dap_adapter_1.ISCOPE_TRACE_CATEGORY)
500
+ return;
501
+ const entry = {
502
+ id: this.outputNextId++,
503
+ category: body.category ?? 'console',
504
+ text: body.output,
505
+ timestamp: Date.now(),
506
+ };
507
+ this.outputBuffer.push(entry);
508
+ while (this.outputBuffer.length > this.outputBufferSize) {
509
+ this.outputBuffer.shift();
510
+ }
511
+ });
512
+ dc.on('stopped', async (ev) => {
513
+ const body = ev.body || {};
514
+ const reason = typeof body.reason === 'string' ? body.reason : 'breakpoint';
515
+ const description = typeof body.description === 'string'
516
+ ? body.description
517
+ : (typeof body.text === 'string' ? body.text : undefined);
518
+ // Fetch stack trace eagerly so `current_state` / step
519
+ // responses can return frames without an extra round-trip.
520
+ // This is on the wire even when the AI does not need it —
521
+ // acceptable because (a) JScript engine is fast,
522
+ // (b) keeps the public API ergonomic.
523
+ let frames = [];
524
+ try {
525
+ const st = await dc.request('stackTrace', { threadId: 1 });
526
+ frames = await Promise.all((st.stackFrames ?? []).map((f) => this.classifyFrame(f)));
527
+ }
528
+ catch (e) {
529
+ this.log(`[mcp] post-stopped stackTrace failed: ${this.errMsg(e)}`);
530
+ }
531
+ const stopped = description !== undefined
532
+ ? { reason, description, frames }
533
+ : { reason, frames };
534
+ this.lastStopped = stopped;
535
+ this.state = 'paused';
536
+ this.resolvePendingWaits({ state: 'paused', stopped });
537
+ });
538
+ dc.on('continued', () => {
539
+ // Some adapters emit `continued` to signal "no longer
540
+ // paused"; the iScope adapter does on every resume. We
541
+ // shift to running so step tools can re-arm.
542
+ if (this.state === 'paused')
543
+ this.state = 'running';
544
+ });
545
+ dc.on('terminated', (ev) => {
546
+ const body = (ev?.body ?? {});
547
+ const reason = typeof body['reason'] === 'string'
548
+ ? body['reason']
549
+ : undefined;
550
+ this.terminationInfo = reason !== undefined ? { reason } : {};
551
+ this.state = 'terminated';
552
+ this.resolvePendingWaits({ state: 'terminated', exitInfo: this.terminationInfo });
553
+ });
554
+ dc.on('exited', () => {
555
+ if (this.state !== 'terminated') {
556
+ this.terminationInfo = this.terminationInfo ?? { reason: 'process exited' };
557
+ this.state = 'terminated';
558
+ this.resolvePendingWaits({ state: 'terminated', exitInfo: this.terminationInfo });
559
+ }
560
+ });
561
+ }
562
+ // ---- Wait management -------------------------------------------------
563
+ waitForTransition(timeoutMs) {
564
+ // If state already advanced past `running` while we were
565
+ // arming the wait, short-circuit.
566
+ if (this.state === 'paused' && this.lastStopped) {
567
+ return Promise.resolve({ state: 'paused', stopped: this.lastStopped });
568
+ }
569
+ if (this.state === 'terminated') {
570
+ const out = { state: 'terminated' };
571
+ if (this.terminationInfo)
572
+ out.exitInfo = this.terminationInfo;
573
+ return Promise.resolve(out);
574
+ }
575
+ return new Promise((resolve) => {
576
+ const timer = setTimeout(() => {
577
+ this.removePending(entry);
578
+ resolve({ state: 'timeout', waitedMs: timeoutMs });
579
+ }, timeoutMs);
580
+ const entry = { resolve, timer };
581
+ this.pendingWaits.push(entry);
582
+ });
583
+ }
584
+ resolvePendingWaits(t) {
585
+ const list = this.pendingWaits.splice(0);
586
+ for (const w of list) {
587
+ clearTimeout(w.timer);
588
+ w.resolve(t);
589
+ }
590
+ }
591
+ failPendingWaits(reason) {
592
+ const list = this.pendingWaits.splice(0);
593
+ for (const w of list) {
594
+ clearTimeout(w.timer);
595
+ // We can't reject a tool promise from here (it would
596
+ // surface as an MCP error before the disconnect response).
597
+ // Returning a synthetic timeout-with-marker keeps the
598
+ // contract simple: tool sees `state:'timeout'` and the AI
599
+ // can decide.
600
+ w.resolve({ state: 'timeout', waitedMs: 0 });
601
+ }
602
+ // log for our own benefit
603
+ this.log(`[mcp] flushed ${list.length} pending wait(s): ${reason}`);
604
+ }
605
+ removePending(target) {
606
+ const i = this.pendingWaits.indexOf(target);
607
+ if (i >= 0)
608
+ this.pendingWaits.splice(i, 1);
609
+ }
610
+ snapshotTransition() {
611
+ if (this.state === 'paused' && this.lastStopped) {
612
+ return { state: 'paused', stopped: this.lastStopped };
613
+ }
614
+ if (this.state === 'terminated') {
615
+ const out = { state: 'terminated' };
616
+ if (this.terminationInfo)
617
+ out.exitInfo = this.terminationInfo;
618
+ return out;
619
+ }
620
+ // running / launching / idle — we have nothing to report yet.
621
+ return { state: 'timeout', waitedMs: 0 };
622
+ }
623
+ // ---- Helpers --------------------------------------------------------
624
+ requireClient() {
625
+ if (!this.client) {
626
+ throw new Error('no active debug session — call debug_launch first');
627
+ }
628
+ return this.client;
629
+ }
630
+ resetSessionState() {
631
+ this.outputBuffer.length = 0;
632
+ this.outputNextId = 1;
633
+ this.lastStopped = null;
634
+ this.terminationInfo = null;
635
+ this.pendingWaits = [];
636
+ this.launchArgs = null;
637
+ this.programIsTs = false;
638
+ this.generatedAjs = null;
639
+ this.unmappedWarned.clear();
640
+ // Dispose the prior manager to free its WASM-backed consumers.
641
+ // Best-effort — if it fails we just leak a few MB until process
642
+ // exit.
643
+ if (this.sourceMaps) {
644
+ const prior = this.sourceMaps;
645
+ this.sourceMaps = null;
646
+ prior.dispose().catch((e) => this.log(`[mcp] source-map dispose error: ${this.errMsg(e)}`));
647
+ }
648
+ }
649
+ /** Resolve and preload `.ajs.map` candidates for this launch.
650
+ * Stores the resolved generated `.ajs` path in `generatedAjs` so
651
+ * `classifyFrame` can run reverse-mapping lookups without re-
652
+ * guessing the sibling each time. */
653
+ async initSourceMaps(args) {
654
+ const mgr = new source_map_bridge_1.SourceMapManager({
655
+ log: (line) => this.log(`[mcp/sourcemap] ${line}`),
656
+ });
657
+ this.sourceMaps = mgr;
658
+ // Resolve the generated `.ajs` path. If user launched a `.ts`,
659
+ // we look for a sibling with the same basename. If neither
660
+ // file actually exists we still try the load — `loadFor`
661
+ // returns false and we move on (no maps, no harm).
662
+ let ajsPath;
663
+ if (this.programIsTs) {
664
+ const dir = (0, node_path_1.dirname)(args.program);
665
+ const base = (0, node_path_1.basename)(args.program, (0, node_path_1.extname)(args.program));
666
+ ajsPath = (0, node_path_1.join)(dir, base + '.ajs');
667
+ }
668
+ else {
669
+ ajsPath = args.program;
670
+ }
671
+ ajsPath = (0, node_path_1.resolve)(ajsPath);
672
+ if ((0, node_fs_1.existsSync)(ajsPath)) {
673
+ this.generatedAjs = ajsPath;
674
+ await mgr.loadFor(ajsPath);
675
+ }
676
+ // Honour the launch.json `outFiles` array exactly like the
677
+ // adapter does — preload maps for every bundle the user
678
+ // points us at. Useful for multi-bundle workspaces where
679
+ // breakpoints sit in `.ts` whose `.ajs` we haven't met yet.
680
+ if (args.outFiles && args.outFiles.length > 0) {
681
+ const workspaceRoot = (0, node_path_1.dirname)(args.program);
682
+ await mgr.preload(args.outFiles, workspaceRoot);
683
+ }
684
+ }
685
+ basename(p) {
686
+ const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
687
+ return i >= 0 ? p.slice(i + 1) : p;
688
+ }
689
+ errMsg(e) {
690
+ if (e instanceof Error)
691
+ return e.message;
692
+ if (typeof e === 'string')
693
+ return e;
694
+ try {
695
+ return JSON.stringify(e);
696
+ }
697
+ catch {
698
+ return String(e);
699
+ }
700
+ }
701
+ }
702
+ exports.DapDriver = DapDriver;
703
+ //# sourceMappingURL=dap-driver.js.map