@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,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
|
+
}
|