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