breakpoint-mcp 1.78.3 โ†’ 1.79.0

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/dist/csdap.js CHANGED
@@ -1,16 +1,32 @@
1
1
  import { EventEmitter } from "node:events";
2
- import { DapError } from "./dap.js";
2
+ import { DapError, INITIALIZED_WAIT_CEILING_MS } from "./dap.js";
3
3
  import { OverdueLedger } from "./late-reply.js";
4
4
  import { closeDetail, closeRemedy } from "./close-cause.js";
5
+ import { timeoutRemedy } from "./timeout-cause.js";
5
6
  /**
6
7
  * Who answers this plane, in the words its late-reply ledger already uses.
7
8
  *
8
- * ๐Ÿ”ด ONE CONSTANT, TWO READERS (264). The ledger names this peer when a reply arrives
9
- * late and `closeRemedy` names it when the connection drops. Two literals would let the
10
- * same peer acquire two names on the same run, which is the class of drift 257's
11
- * per-instance deadline noun was written to stop.
9
+ * ๐Ÿ”ด ONE CONSTANT, THREE READERS (264, 267). The ledger names this peer when a reply
10
+ * arrives late, `closeRemedy` names it when the connection drops, and `timeoutRemedy`
11
+ * names it when a deadline fires. Two literals would let the same peer acquire two names
12
+ * on the same run, which is the class of drift 257's per-instance deadline noun was
13
+ * written to stop.
12
14
  */
13
15
  const CSDAP_PEER = "the debug adapter";
16
+ /** The environment variable this plane's deadline comes from โ€” see `dap.ts`'s twin. */
17
+ const CSDAP_TIMEOUT_KNOB = "GODOT_CSDAP_TIMEOUT_MS";
18
+ /**
19
+ * The next action when the adapter refuses to start a session.
20
+ *
21
+ * ๐Ÿ”ด THE LAST OF 264's SEVENTEEN, and the one that most looked like it already had an
22
+ * answer. The message names the mode and the arguments, which is a description of what
23
+ * was asked, not of what to do next โ€” measured against a real netcoredbg, the two live
24
+ * causes are a `program` that is not a built assembly and a project that has not been
25
+ * compiled, and neither is visible in the argument dump the message already prints.
26
+ */
27
+ export const CS_START_REMEDY = "Check that the program names a built .NET assembly and that the project compiles โ€” the C# debug adapter refused the session, so nothing is under the debugger and no process was left running.";
28
+ /** The next action when `cs_dbg_restart` is called with no launch behind it. */
29
+ export const CS_RESTART_REMEDY = "Call `cs_dbg_launch` or `cs_dbg_attach` first โ€” a restart reuses the parameters of a launch that already happened, and none has.";
14
30
  /**
15
31
  * How long `start()` waits for the entry `stopped` event when `stopAtEntry` was
16
32
  * requested. Bounded so an adapter that ignores stop-on-entry reports `running`
@@ -51,7 +67,7 @@ export class CsDapClient extends EventEmitter {
51
67
  * dropped. `seq` is monotonic and is never reset, so a seq is never reused and
52
68
  * a late response can only be its own request's.
53
69
  */
54
- ledger = new OverdueLedger("C# DAP", CSDAP_PEER, "GODOT_CSDAP_TIMEOUT_MS");
70
+ ledger = new OverdueLedger("C# DAP", CSDAP_PEER, CSDAP_TIMEOUT_KNOB);
55
71
  configured = false;
56
72
  /** Persistent watch expressions, re-evaluated at each stop (see evaluateWatches). */
57
73
  watches = [];
@@ -128,15 +144,12 @@ export class CsDapClient extends EventEmitter {
128
144
  this.configured = false;
129
145
  this.sessionStarted = false;
130
146
  const detail = closeDetail(cause);
131
- // ๐Ÿ”ด THE SAME ERRNO SPLIT `bridge.ts` GOT (264 ยง3), IN THE MESSAGE BECAUSE THIS CLASS
132
- // HAS NOWHERE ELSE TO PUT IT. `DapError` carries no `remedy` field, and the plane's
133
- // `fail()` renders no `remedyClause`, so the next action goes where the caller will
134
- // actually read it. 264's census records the asymmetry rather than hiding it: of 25
135
- // host-raised failures about the world, 13 are on classes that cannot carry an answer.
147
+ // ๐Ÿ”ด ON THE FIELD, NOT IN THE MESSAGE (267) โ€” see `dap.ts`'s twin for the whole
148
+ // argument. What a caller receives is byte-identical; the channel is what changed.
136
149
  const remedy = closeRemedy(cause, CSDAP_PEER);
137
150
  for (const [, p] of this.pending) {
138
151
  clearTimeout(p.timer);
139
- p.reject(new DapError(p.command, `C# DAP connection closed${detail}${remedy ? ` โ€” ${remedy}` : ""}`));
152
+ p.reject(new DapError(p.command, `C# DAP connection closed${detail}`, remedy));
140
153
  }
141
154
  this.pending.clear();
142
155
  this.emit("closed");
@@ -217,7 +230,9 @@ export class CsDapClient extends EventEmitter {
217
230
  // Remember the seq BEFORE rejecting, so a response already in flight is
218
231
  // reconciled rather than dropped as anonymous.
219
232
  this.ledger.note(seq, command, timeoutMs);
220
- reject(new DapError(command, `C# DAP '${command}' timed out after ${timeoutMs}ms`));
233
+ // The `timed out after <n>ms` wording is load-bearing โ€” `tools/csdap.ts`'s
234
+ // `isDapTimeout` branches on it. The next action rides beside it, not inside it.
235
+ reject(new DapError(command, `C# DAP '${command}' timed out after ${timeoutMs}ms`, timeoutRemedy(CSDAP_PEER, CSDAP_TIMEOUT_KNOB)));
221
236
  }, timeoutMs);
222
237
  this.pending.set(seq, { command, resolve: resolve, reject, timer });
223
238
  this.channel.send({ seq, type: "request", command, arguments: args }).catch((err) => {
@@ -227,15 +242,23 @@ export class CsDapClient extends EventEmitter {
227
242
  });
228
243
  });
229
244
  }
245
+ /**
246
+ * Wait for `name`, bounded โ€” resolving **`true` when the event arrived and `false` when
247
+ * the timer fired**. The twin of `dap.ts`'s, changed for the same measured reason (267):
248
+ * a `Promise<void>` made a five-second silence and a prompt event the same observable,
249
+ * so `start()` sent `setBreakpoints` ahead of the event that licenses it and said
250
+ * nothing. Driven against a stub adapter that never emits `initialized`, `start()`
251
+ * returned after 5,003 ms having sent the handshake in the conformant order.
252
+ */
230
253
  waitEvent(name, timeoutMs) {
231
254
  return new Promise((resolve) => {
232
255
  const timer = setTimeout(() => {
233
256
  this.removeListener(name, onEvent);
234
- resolve();
257
+ resolve(false);
235
258
  }, timeoutMs);
236
259
  const onEvent = () => {
237
260
  clearTimeout(timer);
238
- resolve();
261
+ resolve(true);
239
262
  };
240
263
  this.once(name, onEvent);
241
264
  });
@@ -332,7 +355,8 @@ export class CsDapClient extends EventEmitter {
332
355
  this.lastStartArgs = args;
333
356
  this.sessionStarted = false;
334
357
  // Listen for `initialized` before we ask, so we cannot miss it.
335
- const onInit = this.waitEvent("initialized", Math.min(this.timeoutMs, 5000));
358
+ const initializedWaitMs = Math.min(this.timeoutMs, INITIALIZED_WAIT_CEILING_MS);
359
+ const onInit = this.waitEvent("initialized", initializedWaitMs);
336
360
  this.capabilities = await this.request("initialize", {
337
361
  clientID: "breakpoint-mcp",
338
362
  clientName: "Godot Breakpoint MCP",
@@ -364,7 +388,8 @@ export class CsDapClient extends EventEmitter {
364
388
  // EventEmitter and throws when unlistened. This one is safe to ignore.
365
389
  this.emit("start_failed", err);
366
390
  });
367
- await onInit;
391
+ // ๐Ÿ”ด CAPTURED, NOT DISCARDED (267) โ€” see `dap.ts`'s twin.
392
+ const initializedSeen = await onInit;
368
393
  await this.applyAllBreakpoints();
369
394
  // Arm the entry-stop wait BEFORE configurationDone is sent: netcoredbg can emit
370
395
  // `stopped` before its configurationDone response lands, and a listener attached
@@ -389,9 +414,13 @@ export class CsDapClient extends EventEmitter {
389
414
  this.state = "terminated";
390
415
  void startReq;
391
416
  const detail = fatal?.message ?? String(fatal);
417
+ // ๐Ÿ”ด THE NEXT ACTION MOVED OUT OF THE MESSAGE (267). It read *check that the program
418
+ // exists and is a .NET assembly* mid-sentence, between a state report and an
419
+ // argument dump โ€” 264's census counted exactly this shape among the SEVEN that
420
+ // named an action nothing structural could read. The message now reports state; the
421
+ // field carries the instruction, and check 28's grammar reader can see it.
392
422
  throw new DapError(mode, `the C# debug adapter did not start the session: ${detail}. No debug session is ` +
393
- `running โ€” check that the program exists and is a .NET assembly (${mode} args: ` +
394
- `${JSON.stringify(args).slice(0, 200)}).`);
423
+ `running (${mode} args: ${JSON.stringify(args).slice(0, 200)}).`, CS_START_REMEDY);
395
424
  }
396
425
  this.configured = true;
397
426
  this.sessionStarted = true;
@@ -411,6 +440,7 @@ export class CsDapClient extends EventEmitter {
411
440
  // simply reports `running`, as before.
412
441
  if (entryStop)
413
442
  await entryStop;
443
+ return { initializedSeen, initializedWaitMs };
414
444
  }
415
445
  threadId() {
416
446
  return this.lastStoppedThreadId ?? 1;
@@ -482,7 +512,7 @@ export class CsDapClient extends EventEmitter {
482
512
  */
483
513
  async restart(overrideArgs = {}, waitMs = 15000) {
484
514
  if (!this.lastStartMode || !this.lastStartArgs) {
485
- throw new DapError("restart", "no C# debug session to restart โ€” call cs_dbg_launch or cs_dbg_attach first");
515
+ throw new DapError("restart", "no C# debug session to restart", CS_RESTART_REMEDY);
486
516
  }
487
517
  const args = { ...this.lastStartArgs, ...overrideArgs };
488
518
  if (this.capabilities?.["supportsRestartRequest"] === true) {
package/dist/cslsp.js CHANGED
@@ -2,6 +2,7 @@ import { fileURLToPath } from "node:url";
2
2
  import { LspError } from "./lsp.js";
3
3
  import { OverdueLedger } from "./late-reply.js";
4
4
  import { closeDetail, closeRemedy } from "./close-cause.js";
5
+ import { timeoutRemedy } from "./timeout-cause.js";
5
6
  import { packageVersion } from "./version.js";
6
7
  /**
7
8
  * Who answers this plane, in the words its late-reply ledger already uses.
@@ -12,6 +13,8 @@ import { packageVersion } from "./version.js";
12
13
  * per-instance deadline noun was written to stop.
13
14
  */
14
15
  const CSLSP_PEER = "the language server";
16
+ /** The environment variable this plane's deadline comes from โ€” see `dap.ts`'s twin. */
17
+ const CSLSP_TIMEOUT_KNOB = "GODOT_CSLSP_TIMEOUT_MS";
15
18
  /**
16
19
  * Minimal LSP client for the C#/.NET semantic plane (D4 C2). Transport-agnostic:
17
20
  * it drives any `JsonRpcChannel`, so production spawns OmniSharp over stdio
@@ -41,7 +44,7 @@ export class CsLspClient {
41
44
  * dropped. `nextId` is monotonic and is deliberately NOT reset by onClose(),
42
45
  * so an id is never reused and a late reply can only be its own request's.
43
46
  */
44
- ledger = new OverdueLedger("C# LSP", CSLSP_PEER, "GODOT_CSLSP_TIMEOUT_MS");
47
+ ledger = new OverdueLedger("C# LSP", CSLSP_PEER, CSLSP_TIMEOUT_KNOB);
45
48
  /** Absolute project root path (no trailing slash), used to canonicalize URIs. */
46
49
  rootFsPath;
47
50
  constructor(channel, rootUri, timeoutMs) {
@@ -139,15 +142,17 @@ export class CsLspClient {
139
142
  }
140
143
  onClose(cause) {
141
144
  const detail = closeDetail(cause);
142
- // ๐Ÿ”ด THE SAME ERRNO SPLIT `bridge.ts` GOT (264 ยง3), IN THE MESSAGE BECAUSE THIS CLASS
143
- // HAS NOWHERE ELSE TO PUT IT. `LspError` carries no `remedy` field, and the plane's
144
- // `fail()` renders no `remedyClause`, so the next action goes where the caller will
145
- // actually read it. 264's census records the asymmetry rather than hiding it: of 25
146
- // host-raised failures about the world, 13 are on classes that cannot carry an answer.
145
+ // ๐Ÿ”ด THE SAME ERRNO SPLIT `bridge.ts` GOT (264 ยง3), ON THE FIELD RATHER THAN IN THE
146
+ // MESSAGE (267). The comment that stood here said `LspError` "carries no `remedy`
147
+ // field, and the plane's `fail()` renders no `remedyClause`" โ€” true when written, and
148
+ // the reason 264's census counted this site among the SEVEN that named an action
149
+ // nothing structural could read. Both halves are gone. **What a caller receives is
150
+ // byte-identical** and is asserted so: the message ended in ` โ€” ${remedy}` and the
151
+ // renderer appends exactly that. A change of channel, not of wording.
147
152
  const remedy = closeRemedy(cause, CSLSP_PEER);
148
153
  for (const [, p] of this.pending) {
149
154
  clearTimeout(p.timer);
150
- p.reject(new LspError("closed", `C# LSP connection closed${detail}${remedy ? ` โ€” ${remedy}` : ""}`));
155
+ p.reject(new LspError("closed", `C# LSP connection closed${detail}`, remedy));
151
156
  }
152
157
  this.pending.clear();
153
158
  this.initialized = null;
@@ -169,7 +174,7 @@ export class CsLspClient {
169
174
  // Remember the id BEFORE rejecting, so a reply already in flight is
170
175
  // reconciled rather than dropped as anonymous.
171
176
  this.ledger.note(id, method, timeoutMs);
172
- reject(new LspError("timeout", `C# LSP '${method}' timed out after ${timeoutMs}ms`));
177
+ reject(new LspError("timeout", `C# LSP '${method}' timed out after ${timeoutMs}ms`, timeoutRemedy(CSLSP_PEER, CSLSP_TIMEOUT_KNOB)));
173
178
  }, timeoutMs);
174
179
  this.pending.set(id, { resolve: resolve, reject, timer });
175
180
  this.channel.send({ jsonrpc: "2.0", id, method, params }).catch((err) => {
package/dist/dap.js CHANGED
@@ -2,23 +2,82 @@ import { EventEmitter } from "node:events";
2
2
  import { FramedConnection } from "./framing.js";
3
3
  import { OverdueLedger } from "./late-reply.js";
4
4
  import { closeDetail, closeRemedy } from "./close-cause.js";
5
+ import { timeoutRemedy } from "./timeout-cause.js";
5
6
  /**
6
7
  * Who answers this plane, in the words its late-reply ledger already uses.
7
8
  *
8
- * ๐Ÿ”ด ONE CONSTANT, TWO READERS (264). The ledger names this peer when a reply arrives
9
- * late and `closeRemedy` names it when the connection drops. Two literals would let the
10
- * same peer acquire two names on the same run, which is the class of drift 257's
11
- * per-instance deadline noun was written to stop.
9
+ * ๐Ÿ”ด ONE CONSTANT, THREE READERS (264, 267). The ledger names this peer when a reply
10
+ * arrives late, `closeRemedy` names it when the connection drops, and `timeoutRemedy`
11
+ * names it when a deadline fires. Two literals would let the same peer acquire two names
12
+ * on the same run, which is the class of drift 257's per-instance deadline noun was
13
+ * written to stop.
12
14
  */
13
15
  const DAP_PEER = "the debug adapter";
16
+ /**
17
+ * The environment variable this plane's deadline comes from.
18
+ *
19
+ * ๐Ÿ”ด ONE CONSTANT, TWO READERS, FOR THE REASON DIRECTLY ABOVE. The ledger prints it on a
20
+ * late reply and `timeoutRemedy` tells the caller to raise it; a knob renamed in
21
+ * `config.ts` and updated in one of the two would send an operator to export a variable
22
+ * nothing reads, which is worse than saying nothing.
23
+ */
24
+ const DAP_TIMEOUT_KNOB = "GODOT_DAP_TIMEOUT_MS";
14
25
  export class DapError extends Error {
15
26
  command;
16
- constructor(command, message) {
27
+ /**
28
+ * The next action โ€” the same field `BridgeError` has carried since 254, for the same
29
+ * reason, and ๐Ÿ”ด **ITS ABSENCE WAS THE WHOLE OF THE ROW THIS CLOSES.** 264's census
30
+ * found 13 of 25 host-raised failures sitting on classes with nowhere to put an answer;
31
+ * this class was two of the four. Worse than nowhere: three of the raise sites below
32
+ * DID know the next action and pasted it into the MESSAGE, where nothing structural can
33
+ * read it โ€” no gate can join it, `fail()` cannot render it under one clause with the
34
+ * rest, and a reword drops it with no test able to tell.
35
+ *
36
+ * Optional by construction, exactly as on `BridgeError`: a rejection RELAYING the
37
+ * adapter's own words has no business inventing a next action on its behalf. Absent
38
+ * here means nobody has answered that site yet, not that it is unanswerable.
39
+ */
40
+ remedy;
41
+ constructor(command, message, remedy) {
17
42
  super(message);
18
43
  this.command = command;
19
44
  this.name = "DapError";
45
+ if (remedy)
46
+ this.remedy = remedy;
20
47
  }
21
48
  }
49
+ /**
50
+ * The ceiling on the wait for `initialized`, shared by both DAP planes.
51
+ *
52
+ * Not a new number โ€” both clients have used `Math.min(timeoutMs, 5000)` since their
53
+ * handshakes were written. It is named here because 267 made the OUTCOME of that wait
54
+ * something a caller reads, and a window reported in a warning has to be the window that
55
+ * was actually waited.
56
+ */
57
+ export const INITIALIZED_WAIT_CEILING_MS = 5000;
58
+ /**
59
+ * What a launch/attach result says when the adapter never announced itself.
60
+ *
61
+ * ๐Ÿ”ด REPORTED, NOT REFUSED, AND THE CHOICE IS THE WHOLE OF THE ROW. The wait has resolved
62
+ * on its own timer since the handshake was written, so every adapter in the field has
63
+ * been launching through this path; refusing outright would break working setups to fix a
64
+ * silence. The shape is the one this tool already ships for `stop_on_entry_honored`: name
65
+ * what did not happen, name the symptom to expect, leave the session alone. A stricter
66
+ * refusal remains available and is a separate decision.
67
+ */
68
+ export function unorderedHandshakeWarning(waitedMs) {
69
+ return (`The debug adapter did not announce itself with an initialized event within ${waitedMs}ms. ` +
70
+ `Breakpoints were applied outside the order the DAP specification requires, so a breakpoint ` +
71
+ `that silently fails to bind is the symptom to expect; the session is otherwise live.`);
72
+ }
73
+ /**
74
+ * The next action when `restart` is called with no launch behind it.
75
+ *
76
+ * A `*_REMEDY` const rather than a literal at the throw site because check 28's grammar
77
+ * reader finds host-side remedies by that naming convention โ€” a sentence spelled inline
78
+ * is a sentence no gate reads, which is the defect this whole change is about.
79
+ */
80
+ export const DAP_RESTART_REMEDY = "Call `dbg_launch` or `dbg_attach` first โ€” a restart reuses the parameters of a launch that already happened, and none has.";
22
81
  /**
23
82
  * Per-line breakpoint modifier fields, each gated by an adapter capability. Godot
24
83
  * advertises all three false AND ignores them โ€” a "conditional" breakpoint halts
@@ -83,7 +142,7 @@ export class DapClient extends EventEmitter {
83
142
  * dropped. `seq` is monotonic and is never reset, so a seq is never reused and
84
143
  * a late response can only be its own request's.
85
144
  */
86
- ledger = new OverdueLedger("DAP", DAP_PEER, "GODOT_DAP_TIMEOUT_MS");
145
+ ledger = new OverdueLedger("DAP", DAP_PEER, DAP_TIMEOUT_KNOB);
87
146
  configured = false;
88
147
  /** Persistent watch expressions, re-evaluated at each stop (see evaluateWatches). */
89
148
  watches = [];
@@ -152,15 +211,19 @@ export class DapClient extends EventEmitter {
152
211
  // Without it every pending request reported a generic close and the operator
153
212
  // could not tell a crashed server from something else holding the port.
154
213
  const detail = closeDetail(cause);
155
- // ๐Ÿ”ด THE SAME ERRNO SPLIT `bridge.ts` GOT (264 ยง3), IN THE MESSAGE BECAUSE THIS CLASS
156
- // HAS NOWHERE ELSE TO PUT IT. `DapError` carries no `remedy` field, and the plane's
157
- // `fail()` renders no `remedyClause`, so the next action goes where the caller will
158
- // actually read it. 264's census records the asymmetry rather than hiding it: of 25
159
- // host-raised failures about the world, 13 are on classes that cannot carry an answer.
214
+ // ๐Ÿ”ด THE SAME ERRNO SPLIT `bridge.ts` GOT (264 ยง3), NOW ON THE FIELD RATHER THAN IN
215
+ // THE MESSAGE (267). The comment that stood here said `DapError` "carries no
216
+ // `remedy` field, and the plane's `fail()` renders no `remedyClause`, so the next
217
+ // action goes where the caller will actually read it" โ€” true when it was written,
218
+ // and the reason 264's census counted this site among the SEVEN that named an
219
+ // action nothing structural could read. Both halves are gone: the field exists and
220
+ // `fail()` appends the clause. **What a caller receives is byte-identical**, which
221
+ // is asserted rather than assumed โ€” the message ended in ` โ€” ${remedy}` and the
222
+ // renderer appends exactly that, so this is a change of channel, not of wording.
160
223
  const remedy = closeRemedy(cause, DAP_PEER);
161
224
  for (const [, p] of this.pending) {
162
225
  clearTimeout(p.timer);
163
- p.reject(new DapError(p.command, `DAP connection closed${detail}${remedy ? ` โ€” ${remedy}` : ""}`));
226
+ p.reject(new DapError(p.command, `DAP connection closed${detail}`, remedy));
164
227
  }
165
228
  this.pending.clear();
166
229
  this.emit("closed");
@@ -234,7 +297,10 @@ export class DapClient extends EventEmitter {
234
297
  // Remember the seq BEFORE rejecting, so a response already in flight is
235
298
  // reconciled rather than dropped as anonymous.
236
299
  this.ledger.note(seq, command, timeoutMs);
237
- reject(new DapError(command, `DAP '${command}' timed out after ${timeoutMs}ms`));
300
+ // The message keeps its exact `timed out after <n>ms` wording โ€” `tools/dap.ts`'s
301
+ // `isDapTimeout` branches on it and `timeout-caveat.ts` says out loud that this
302
+ // predicate must not be disturbed. The next action rides beside it, not inside it.
303
+ reject(new DapError(command, `DAP '${command}' timed out after ${timeoutMs}ms`, timeoutRemedy(DAP_PEER, DAP_TIMEOUT_KNOB)));
238
304
  }, timeoutMs);
239
305
  this.pending.set(seq, { command, resolve: resolve, reject, timer });
240
306
  this.conn.send({ seq, type: "request", command, arguments: args }).catch((err) => {
@@ -251,15 +317,32 @@ export class DapClient extends EventEmitter {
251
317
  recentLateReplies() {
252
318
  return this.ledger.recent();
253
319
  }
320
+ /**
321
+ * Wait for `name`, bounded โ€” resolving **`true` when the event arrived and `false` when
322
+ * the timer fired**.
323
+ *
324
+ * ๐Ÿ”ด THE RETURN VALUE IS THE FIX (267), AND THE OLD SIGNATURE IS WHY THE DEFECT COULD
325
+ * NOT BE SEEN. `Promise<void>` gave both outcomes the same shape, so `await onInit` in
326
+ * `start()` below could not distinguish *the adapter said it was ready* from *five
327
+ * seconds passed*, and the handshake carried on either way โ€” sending `setBreakpoints`
328
+ * ahead of the event the DAP specification says must precede it, and telling nobody.
329
+ * Driven against a stub adapter that answers `initialize` and never emits
330
+ * `initialized`, `start()` returned after 5,003 ms having sent
331
+ * `initialize -> launch -> setBreakpoints -> configurationDone` in exactly the order a
332
+ * conformant run sends them, with no complaint anywhere.
333
+ *
334
+ * 262's rule, one instance further in: a guard that asks whether the container exists
335
+ * has not asked whether the content is there. A resolve is not an answer.
336
+ */
254
337
  waitEvent(name, timeoutMs) {
255
338
  return new Promise((resolve) => {
256
339
  const timer = setTimeout(() => {
257
340
  this.removeListener(name, onEvent);
258
- resolve();
341
+ resolve(false);
259
342
  }, timeoutMs);
260
343
  const onEvent = () => {
261
344
  clearTimeout(timer);
262
- resolve();
345
+ resolve(true);
263
346
  };
264
347
  this.once(name, onEvent);
265
348
  });
@@ -398,6 +481,13 @@ export class DapClient extends EventEmitter {
398
481
  * before returning โ€” see `dbg_launch`'s `stop_on_entry`. Godot 4.7 does not honour
399
482
  * `stopOnEntry` at all, so this wait times out there and the caller is told so
400
483
  * rather than being handed a bare `running` that reads like it worked.
484
+ *
485
+ * ๐Ÿ†• `initializedSeen` (267) is the SAME DISCIPLINE applied to the wait one line up.
486
+ * The `initialized` wait had been resolving on its own timer since this handshake was
487
+ * written, so a five-second silence and a prompt event were the same observable and the
488
+ * caller was handed a session that read exactly like a conformant one. It is reported,
489
+ * not refused: every adapter that works today keeps working, and the four launch tools
490
+ * say which of the two happened โ€” the shape `stop_on_entry_honored` already ships in.
401
491
  */
402
492
  async start(mode, args, entryStopWaitMs = 0) {
403
493
  // Remember how we started so restart() can reuse (or override) these params.
@@ -408,7 +498,12 @@ export class DapClient extends EventEmitter {
408
498
  // restart may reach a different build than the one that dropped last time.
409
499
  this.droppedModifiers.clear();
410
500
  // Listen for `initialized` before we ask, so we cannot miss it.
411
- const onInit = this.waitEvent("initialized", Math.min(this.timeoutMs, 5000));
501
+ // The window is captured rather than recomputed at the report site: it is
502
+ // `min(timeoutMs, 5000)`, so a caller who raised the deadline gets 5000 and one who
503
+ // lowered it gets their own number, and a warning naming the wrong one is a warning
504
+ // that sends an operator to the wrong knob.
505
+ const initializedWaitMs = Math.min(this.timeoutMs, INITIALIZED_WAIT_CEILING_MS);
506
+ const onInit = this.waitEvent("initialized", initializedWaitMs);
412
507
  this.capabilities = await this.request("initialize", {
413
508
  clientID: "breakpoint-mcp",
414
509
  clientName: "Godot Breakpoint MCP",
@@ -436,7 +531,11 @@ export class DapClient extends EventEmitter {
436
531
  });
437
532
  // An entry stop can arrive before configurationDone answers โ€” arm first.
438
533
  const entryStop = entryStopWaitMs > 0 ? this.waitEvent("stopped", entryStopWaitMs) : null;
439
- await onInit;
534
+ // ๐Ÿ”ด CAPTURED, NOT DISCARDED (267). `await onInit` threw this away for as long as the
535
+ // handshake has existed, which is why `setBreakpoints` going out ahead of the event
536
+ // that licenses it was invisible: the failing case and the succeeding case returned
537
+ // the same `undefined`.
538
+ const initializedSeen = await onInit;
440
539
  await this.applyAllBreakpoints();
441
540
  await this.request("configurationDone", {}).catch(() => undefined);
442
541
  // ๐Ÿ”ด By the time we get here an already-arrived rejection has ALREADY run its
@@ -460,7 +559,7 @@ export class DapClient extends EventEmitter {
460
559
  if (startFailure)
461
560
  throw startFailure;
462
561
  }
463
- return { entryStopSeen: this.stoppedNow() };
562
+ return { entryStopSeen: this.stoppedNow(), initializedSeen, initializedWaitMs };
464
563
  }
465
564
  threadId() {
466
565
  return this.lastStoppedThreadId ?? 1;
@@ -532,7 +631,7 @@ export class DapClient extends EventEmitter {
532
631
  */
533
632
  async restart(overrideArgs = {}, waitMs = 15000) {
534
633
  if (!this.lastStartMode || !this.lastStartArgs) {
535
- throw new DapError("restart", "no debug session to restart โ€” call dbg_launch or dbg_attach first");
634
+ throw new DapError("restart", "no debug session to restart", DAP_RESTART_REMEDY);
536
635
  }
537
636
  const args = { ...this.lastStartArgs, ...overrideArgs };
538
637
  const scene = typeof args["scene"] === "string" ? args["scene"] : null;
@@ -42,10 +42,12 @@
42
42
  * question", spelled here as a parameter nobody declared. `exitSignal` is captured now
43
43
  * and `describeExit` says `killed by SIGKILL` where the sentence used to print a null.
44
44
  *
45
- * ๐Ÿ”ต WHAT THIS DELIBERATELY DOES NOT DO. `godot_process_output` still answers
46
- * `exit_code: null` for a signal-killed child and carries no `signal` key: that is a wire
47
- * addition to a shipped tool, it would make this cut a MINOR, and it is enqueued rather
48
- * than folded in. The capture is internal; only the two peer sentences read it today.
45
+ * ๐ŸŸข AND THE WIRE CAUGHT UP AT 267. 266 left this capture deliberately internal โ€”
46
+ * `godot_output` answered `exit_code: null` for a signal-killed child with no `signal`
47
+ * key, because that is a wire addition to a shipped tool and would make a PATCH a MINOR.
48
+ * It was enqueued as `process-output-omits-signal` and paid in the next cut: the tool now
49
+ * answers `signal`, nullable beside `exit_code`, so a caller can tell a child that chose
50
+ * to exit from one the OS killed without parsing a sentence.
49
51
  */
50
52
  /** How a managed child ended, in the words a person would use. */
51
53
  export function describeExit(code, signal) {
@@ -84,9 +86,13 @@ export function readinessRemedy(causes) {
84
86
  const exited = causes.some((c) => c === "exited");
85
87
  const silent = causes.some((c) => c === "silent");
86
88
  if (exited && silent) {
87
- return (`Read the output quoted above for the peer(s) that exited and fix what stopped them, ` +
88
- `then check the Breakpoint MCP plugin is enabled for the one(s) that stayed up and never ` +
89
- `answered โ€” two different things failed here and only the second is about the addon.`);
89
+ return (
90
+ // Trimmed at 267 to the ceiling check 28 has always enforced on the addon's own
91
+ // remedies and now enforces here: it was 255 characters, and the two populations it
92
+ // addresses are still both named.
93
+ `Read the output quoted above and fix what stopped the peer(s) that exited, then check ` +
94
+ `the "Breakpoint MCP" plugin for the one(s) that never answered โ€” two things failed and ` +
95
+ `only the second is the addon.`);
90
96
  }
91
97
  if (exited) {
92
98
  return (`Read the output quoted above and fix what stopped the child โ€” it exited on its own, ` +
package/dist/index.js CHANGED
@@ -52,7 +52,7 @@ async function main() {
52
52
  // so long jobs (export/import/headless script) support poll/await/cancel.
53
53
  // D3: also advertise resources.subscribe so clients can subscribe to
54
54
  // godot://โ€ฆ resources and receive notifications/resources/updated.
55
- const server = new McpServer({ name: "breakpoint-mcp", version: "1.78.3" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
55
+ const server = new McpServer({ name: "breakpoint-mcp", version: "1.79.0" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
56
56
  // 208 ยง7.1 โ€” delete from the wire what the protocol already says: the SDK's
57
57
  // draft-07 `$schema` inside every input/output schema (MCP's default dialect is
58
58
  // 2020-12, and declaring another is an explicit switch a peer may reject), and
package/dist/lsp.js CHANGED
@@ -3,6 +3,7 @@ import { FramedConnection } from "./framing.js";
3
3
  import { packageVersion } from "./version.js";
4
4
  import { OverdueLedger } from "./late-reply.js";
5
5
  import { closeDetail, closeRemedy } from "./close-cause.js";
6
+ import { timeoutRemedy } from "./timeout-cause.js";
6
7
  /**
7
8
  * Who answers this plane, in the words its late-reply ledger already uses.
8
9
  *
@@ -12,12 +13,28 @@ import { closeDetail, closeRemedy } from "./close-cause.js";
12
13
  * per-instance deadline noun was written to stop.
13
14
  */
14
15
  const LSP_PEER = "the language server";
16
+ /** The environment variable this plane's deadline comes from โ€” see `dap.ts`'s twin. */
17
+ const LSP_TIMEOUT_KNOB = "GODOT_LSP_TIMEOUT_MS";
15
18
  export class LspError extends Error {
16
19
  code;
17
- constructor(code, message) {
20
+ /**
21
+ * The next action โ€” the same field `BridgeError` has carried since 254, added here at
22
+ * 267 for the same reason it was added there. ๐Ÿ”ด **ITS ABSENCE WAS THE ROW.** 264's
23
+ * census found 13 of 25 host-raised failures sitting on classes with nowhere to put an
24
+ * answer, and this class was two of the four. The close handler below already KNEW the
25
+ * next action and pasted it into the message, where no gate can join it and `fail()`
26
+ * cannot render it under one clause.
27
+ *
28
+ * Optional by construction, as on `BridgeError`: a rejection relaying the language
29
+ * server's own words has no business inventing an action on its behalf.
30
+ */
31
+ remedy;
32
+ constructor(code, message, remedy) {
18
33
  super(message);
19
34
  this.name = "LspError";
20
35
  this.code = code;
36
+ if (remedy)
37
+ this.remedy = remedy;
21
38
  }
22
39
  }
23
40
  /**
@@ -41,7 +58,7 @@ export class LspClient {
41
58
  * dropped. `nextId` is monotonic and is deliberately NOT reset by onClose(),
42
59
  * so an id is never reused and a late reply can only be its own request's.
43
60
  */
44
- ledger = new OverdueLedger("LSP", LSP_PEER, "GODOT_LSP_TIMEOUT_MS");
61
+ ledger = new OverdueLedger("LSP", LSP_PEER, LSP_TIMEOUT_KNOB);
45
62
  /** Absolute project root path (no trailing slash), used to canonicalize URIs. */
46
63
  rootFsPath;
47
64
  constructor(host, port, rootUri, timeoutMs) {
@@ -143,15 +160,17 @@ export class LspClient {
143
160
  // Without it every pending request reported a generic close and the operator
144
161
  // could not tell a crashed server from something else holding the port.
145
162
  const detail = closeDetail(cause);
146
- // ๐Ÿ”ด THE SAME ERRNO SPLIT `bridge.ts` GOT (264 ยง3), IN THE MESSAGE BECAUSE THIS CLASS
147
- // HAS NOWHERE ELSE TO PUT IT. `LspError` carries no `remedy` field, and the plane's
148
- // `fail()` renders no `remedyClause`, so the next action goes where the caller will
149
- // actually read it. 264's census records the asymmetry rather than hiding it: of 25
150
- // host-raised failures about the world, 13 are on classes that cannot carry an answer.
163
+ // ๐Ÿ”ด THE SAME ERRNO SPLIT `bridge.ts` GOT (264 ยง3), ON THE FIELD RATHER THAN IN THE
164
+ // MESSAGE (267). The comment that stood here said `LspError` "carries no `remedy`
165
+ // field, and the plane's `fail()` renders no `remedyClause`" โ€” true when written, and
166
+ // the reason 264's census counted this site among the SEVEN that named an action
167
+ // nothing structural could read. Both halves are gone. **What a caller receives is
168
+ // byte-identical** and is asserted so: the message ended in ` โ€” ${remedy}` and the
169
+ // renderer appends exactly that. A change of channel, not of wording.
151
170
  const remedy = closeRemedy(cause, LSP_PEER);
152
171
  for (const [, p] of this.pending) {
153
172
  clearTimeout(p.timer);
154
- p.reject(new LspError("closed", `LSP connection closed${detail}${remedy ? ` โ€” ${remedy}` : ""}`));
173
+ p.reject(new LspError("closed", `LSP connection closed${detail}`, remedy));
155
174
  }
156
175
  this.pending.clear();
157
176
  this.initialized = null;
@@ -166,7 +185,7 @@ export class LspClient {
166
185
  // Remember the id BEFORE rejecting, so a reply already in flight is
167
186
  // reconciled rather than dropped as anonymous.
168
187
  this.ledger.note(id, method, timeoutMs);
169
- reject(new LspError("timeout", `LSP '${method}' timed out after ${timeoutMs}ms`));
188
+ reject(new LspError("timeout", `LSP '${method}' timed out after ${timeoutMs}ms`, timeoutRemedy(LSP_PEER, LSP_TIMEOUT_KNOB)));
170
189
  }, timeoutMs);
171
190
  this.pending.set(id, { resolve: resolve, reject, timer });
172
191
  this.conn.send({ jsonrpc: "2.0", id, method, params }).catch((err) => {
package/dist/readiness.js CHANGED
@@ -93,16 +93,35 @@ export async function waitForRuntimeBridge(cfg, timeoutMs) {
93
93
  * message that says what broke has done half the job โ€” applied to a field rather
94
94
  * than to a message: `bridge_ready: false` says what happened, and this says what
95
95
  * to do about it.
96
+ *
97
+ * ๐Ÿ”ด BOTH SENTENCES WERE REWRITTEN AT 267, AND A GATE FOUND THEM RATHER THAN A READER.
98
+ * Widening check 28's grammar to the host's OWN remedies โ€” until then it read only
99
+ * `error_remedies.gd` โ€” refused both of these at once, and the two refusals were the same
100
+ * two defects this project has now found three times:
101
+ *
102
+ * 1. **They opened by describing the failure.** *The process launched but the runtime
103
+ * bridge โ€ฆ did not answer ping* is a second copy of what `bridge_ready: false` already
104
+ * said. The next action arrived in the last clause, after 200 characters, which is the
105
+ * exact shape 254 moved the addon's remedies away from.
106
+ * 2. ๐Ÿ”ด **AND THE SECOND ASKED A QUESTION OVER TWO CAUSES.** *Is the "Breakpoint MCP"
107
+ * plugin enabled in this project?* โ€” with *if the project is simply slow to boot* three
108
+ * clauses later, so the sentence named both causes and instructed on neither. That is
109
+ * 266 ยง1's finding, in a different file, surviving the session that fixed it: the
110
+ * question was removed from `peers.ts` and left standing here, because nothing joined
111
+ * the two sites. A gate over the whole population is what joined them.
112
+ *
113
+ * Neither cause can be discriminated from here โ€” a ping that went unanswered inside the
114
+ * wait says nothing about which โ€” so the repair is to name both and give one action each,
115
+ * not to guess.
96
116
  */
97
117
  export function notReadyRemedy(cfg, waitedMs) {
98
118
  if (waitedMs <= 0) {
99
- return (`The process launched and no wait was requested (wait_timeout_ms 0), so nothing here knows whether the ` +
100
- `runtime bridge at ${cfg.runtimeHost}:${cfg.runtimePort} is up yet. Call runtime_get_tree to find out, or ` +
101
- `re-run without wait_timeout_ms and this tool will wait and answer.`);
119
+ return (`Call \`runtime_get_tree\` to find out whether the runtime bridge at ` +
120
+ `${cfg.runtimeHost}:${cfg.runtimePort} is up, or re-run with a wait above 0 โ€” the process launched ` +
121
+ `and wait_timeout_ms 0 asked for no wait, so this is unknown, not failed.`);
102
122
  }
103
- return (`The process launched but the runtime bridge at ${cfg.runtimeHost}:${cfg.runtimePort} did not answer ping ` +
104
- `within ${waitedMs} ms, so a runtime_* call will fail until it does. Is the "Breakpoint MCP" plugin enabled in ` +
105
- `this project? The runtime autoload it registers is what binds the port. If the project is simply slow to boot, ` +
106
- `raise the wait with wait_timeout_ms.`);
123
+ return (`Raise wait_timeout_ms and retry if the project is slow to boot, and check the "Breakpoint MCP" ` +
124
+ `plugin is enabled if it is not โ€” the bridge at ${cfg.runtimeHost}:${cfg.runtimePort} answered no ping ` +
125
+ `in ${waitedMs} ms and this host cannot tell those apart.`);
107
126
  }
108
127
  //# sourceMappingURL=readiness.js.map
package/dist/schemas.js CHANGED
@@ -113,6 +113,10 @@ export const outputSchemas = {
113
113
  id: z.string(),
114
114
  exited: z.boolean(),
115
115
  exit_code: z.number().nullable(),
116
+ // The signal that ended the child, when one did โ€” null otherwise, and null for a
117
+ // process still running. Nullable rather than optional, matching exit_code beside it:
118
+ // a key that vanishes cannot be told apart from a host too old to send it.
119
+ signal: z.string().nullable(),
116
120
  latest_seq: z.number(),
117
121
  lines: z.array(z.object({ seq: z.number(), stream: z.enum(["stdout", "stderr"]), text: z.string() })),
118
122
  },
@@ -449,6 +453,9 @@ export const outputSchemas = {
449
453
  // one inside a shape makes contract_check.py swallow every later entry into
450
454
  // this one, and the drift error then names every field in the file.
451
455
  stop_on_entry_honored: z.boolean().optional(),
456
+ // Whether the adapter emitted its initialized event before breakpoints were applied.
457
+ // Always present: false means the handshake ran out of the order DAP specifies.
458
+ initialized_seen: z.boolean(),
452
459
  // Modifiers dropped when the handshake applied the BUFFERED breakpoints. Buffered
453
460
  // modifiers cannot be feature-detected at set time, so this is where their fate is
454
461
  // reported; dbg_set_breakpoints said only that detection was deferred.
@@ -457,6 +464,7 @@ export const outputSchemas = {
457
464
  },
458
465
  dbg_attach: {
459
466
  session_id: z.string(), state: z.string(),
467
+ initialized_seen: z.boolean(),
460
468
  unsupported_modifiers: z.array(z.string()).optional(),
461
469
  warning: z.string().optional(),
462
470
  },
@@ -496,8 +504,8 @@ export const outputSchemas = {
496
504
  unresolved: z.array(z.object({ name: z.string(), reason: z.string() })),
497
505
  },
498
506
  // ---- Plane D: C# debugging / netcoredbg DAP (tools/csdap.ts) ----
499
- cs_dbg_launch: { session_id: z.string(), state: z.string() },
500
- cs_dbg_attach: { session_id: z.string(), state: z.string() },
507
+ cs_dbg_launch: { session_id: z.string(), state: z.string(), initialized_seen: z.boolean(), warning: z.string().optional() },
508
+ cs_dbg_attach: { session_id: z.string(), state: z.string(), initialized_seen: z.boolean(), warning: z.string().optional() },
501
509
  cs_dbg_set_breakpoints: {
502
510
  path: z.string(), buffered: z.boolean(),
503
511
  breakpoints: z.array(z.object({ line: z.number(), verified: z.boolean() })),
@@ -0,0 +1,48 @@
1
+ /**
2
+ * The next action when a request hit its own deadline โ€” the last class of host-raised
3
+ * failure on the DAP and LSP planes that had nothing to say.
4
+ *
5
+ * ๐Ÿ”ด WHY THIS EXISTS, AND WHY IT IS THE END OF A ROW RATHER THAN THE MIDDLE OF ONE.
6
+ * 264's census counted 25 host-raised failures about the world: ONE carried a remedy in
7
+ * a field, SEVEN named a next action inside their message where no reader counts it, and
8
+ * SEVENTEEN said nothing at all. 266 answered the last three that had a field to put an
9
+ * answer in, and left five that did not โ€” four request deadlines and the C# session
10
+ * start โ€” because `DapError` and `LspError` had no `remedy` at all. **The gap was never
11
+ * that nobody knew the answer; it was that there was nowhere to put it.** This module is
12
+ * the answer for four of the five, and the field it rides on is the other half.
13
+ *
14
+ * ๐Ÿ”ด WHAT THE HOST ACTUALLY KNOWS AT THE MOMENT A DEADLINE FIRES, which is the whole
15
+ * argument for the sentence below. A dropped connection does NOT reach here: every one of
16
+ * the four clients rejects its pending map from the close handler, with `closeDetail` and
17
+ * `closeRemedy` (264), on a different code path. An unreachable peer does not reach here
18
+ * either โ€” `connect()` fails earlier and differently. By the time a deadline can fire the
19
+ * connection was established and the frame was written, so the honest reading is *the
20
+ * peer has this request and has not answered yet*, not *the request did not go through*.
21
+ * That is `timeout-caveat.ts`'s measured argument for the editor bridge, made one
22
+ * transport over, where it holds for the same structural reason.
23
+ *
24
+ * ๐Ÿ”ด AND WHAT IT DELIBERATELY DOES NOT SAY. It does not tell the caller to retry. Three
25
+ * of the four planes carry mutating requests (`setVariable`, `textDocument/rename`,
26
+ * `evaluate` with a side effect), and the ledger in `late-reply.ts` exists because a
27
+ * reply arriving after its deadline is ordinary here, not exotic โ€” so an unqualified
28
+ * *try again* is the instruction that duplicates the write. The sentence names the knob
29
+ * and says plainly that a retry is a second request. 264's rule: measure N families,
30
+ * speak for N.
31
+ */
32
+ /**
33
+ * The next action for a request that outlived its deadline.
34
+ *
35
+ * `peer` is the noun the plane's own late-reply ledger already uses โ€” "the debug
36
+ * adapter", "the language server", "the C# debug adapter", "the C# language server" โ€”
37
+ * so the sentence names what the caller was talking to rather than a socket, exactly as
38
+ * `closeRemedy` does. `knob` is the environment variable `config.ts` reads that
39
+ * deadline from, so the instruction is executable rather than a suggestion to be patient.
40
+ *
41
+ * Written to the grammar check 28 enforces on `error_remedies.gd`: one imperative at the
42
+ * head, a full stop at the end, and nothing named that does not resolve.
43
+ */
44
+ export function timeoutRemedy(peer, knob) {
45
+ return (`Raise \`${knob}\` if ${peer} is merely slow โ€” the request reached it and this ` +
46
+ `host's deadline fired first, so it may still answer and a retry sends a second request.`);
47
+ }
48
+ //# sourceMappingURL=timeout-cause.js.map
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
- import { DapError } from "../dap.js";
2
+ import { DapError, unorderedHandshakeWarning } from "../dap.js";
3
+ import { remedyClause } from "../bridge.js";
3
4
  import { resolveSourceFile } from "../paths.js";
4
5
  import { gate } from "../confirm.js";
5
6
  import { ok } from "./lsp-common.js";
@@ -32,15 +33,28 @@ function fail(err) {
32
33
  content: [{
33
34
  type: "text",
34
35
  text: `C# DAP error [${command}]: the debug adapter reported a failure with no message. ` +
35
- `The request was rejected; nothing was changed by it.`,
36
+ `The request was rejected; nothing was changed by it.${remedyClause(err)}`,
36
37
  }],
37
38
  };
38
39
  }
39
40
  return {
40
41
  isError: true,
41
- content: [{ type: "text", text: `C# DAP error [${command}]: ${message}` }],
42
+ content: [{ type: "text", text: `C# DAP error [${command}]: ${message}${remedyClause(err)}` }],
42
43
  };
43
44
  }
45
+ /**
46
+ * Record whether the adapter announced itself before its breakpoints were applied โ€” the
47
+ * twin of `tools/dap.ts`'s, for the same measured reason (267). Kept per-plane rather than
48
+ * shared because the two planes' result objects are built by different code and a helper
49
+ * imported across them would be the only thing they share.
50
+ */
51
+ function reportHandshakeOrder(result, seen, waitedMs) {
52
+ result.initialized_seen = seen;
53
+ if (seen)
54
+ return;
55
+ const note = unorderedHandshakeWarning(waitedMs);
56
+ result.warning = typeof result.warning === "string" && result.warning.length > 0 ? `${result.warning} ${note}` : note;
57
+ }
44
58
  /** Throw a host refusal โ€” rendered verbatim by `fail()`, never as an adapter error. */
45
59
  function refuse(message, code) {
46
60
  throw Object.assign(new Error(message), { refusal: true, code });
@@ -239,14 +253,16 @@ export function registerCsDapTools(server, dap, cfg) {
239
253
  };
240
254
  }
241
255
  try {
242
- await dap.start("launch", {
256
+ const { initializedSeen, initializedWaitMs } = await dap.start("launch", {
243
257
  program: resolvedProgram,
244
258
  args: resolvedArgs,
245
259
  cwd: cfg.csDapProjectPath,
246
260
  stopAtEntry: stop_on_entry ?? false,
247
261
  justMyCode: just_my_code ?? true,
248
262
  });
249
- return ok({ session_id: "csharp", state: dap.state });
263
+ const result = { session_id: "csharp", state: dap.state };
264
+ reportHandshakeOrder(result, initializedSeen, initializedWaitMs);
265
+ return ok(result);
250
266
  }
251
267
  catch (err) {
252
268
  return fail(err);
@@ -280,8 +296,10 @@ export function registerCsDapTools(server, dap, cfg) {
280
296
  `was previously reported as "running" against a process that does not exist.`, "no_such_process");
281
297
  }
282
298
  }
283
- await dap.start("attach", { processId: process_id });
284
- return ok({ session_id: "csharp", state: dap.state });
299
+ const { initializedSeen, initializedWaitMs } = await dap.start("attach", { processId: process_id });
300
+ const result = { session_id: "csharp", state: dap.state };
301
+ reportHandshakeOrder(result, initializedSeen, initializedWaitMs);
302
+ return ok(result);
285
303
  }
286
304
  catch (err) {
287
305
  return fail(err);
package/dist/tools/dap.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
- import { DapError } from "../dap.js";
2
+ import { DapError, unorderedHandshakeWarning } from "../dap.js";
3
+ import { remedyClause } from "../bridge.js";
3
4
  import { resolveSourceFile } from "../paths.js";
4
5
  import { gate } from "../confirm.js";
5
6
  import { ok } from "./lsp-common.js";
@@ -116,15 +117,20 @@ function fail(err) {
116
117
  // asked. Same distinction lsp-common.fail() already draws for the LSP planes.
117
118
  const e = err;
118
119
  if (err instanceof DapRefusal || e?.refusal) {
119
- return { isError: true, content: [{ type: "text", text: e.message ?? String(err) }] };
120
+ return { isError: true, content: [{ type: "text", text: `${e.message ?? String(err)}${remedyClause(err)}` }] };
120
121
  }
121
122
  // An adapter can answer a failure with an EMPTY message (netcoredbg did, on
122
123
  // setVariable). `?? String(err)` only covers an ABSENT one, so the whole answer
123
124
  // became a label and a colon.
124
125
  const detail = e.message && e.message.trim() !== "" ? e.message : String(err);
126
+ // ๐Ÿ†• 267 โ€” `remedyClause(err)` reads the `remedy` FIELD, which `DapError` gained this
127
+ // release. Before it, this plane's next actions were pasted inside the message at the
128
+ // raise site and this renderer had nothing to append; check 28 could not see them and a
129
+ // reword dropped one silently. Empty string when the site has no answer, so a rejection
130
+ // relaying the adapter's own words is byte-identical to what it was.
125
131
  return {
126
132
  isError: true,
127
- content: [{ type: "text", text: `DAP error [${e.command ?? "error"}]: ${detail}` }],
133
+ content: [{ type: "text", text: `DAP error [${e.command ?? "error"}]: ${detail}${remedyClause(err)}` }],
128
134
  };
129
135
  }
130
136
  /**
@@ -144,6 +150,23 @@ function isDapTimeout(err) {
144
150
  * it, the breakpoint halted every frame, and the result carried no warning at all.
145
151
  * Detection now happens where the modifiers go on the wire, so every path gets it.
146
152
  */
153
+ /**
154
+ * Record whether the adapter announced itself before its breakpoints were applied.
155
+ *
156
+ * ๐Ÿ”ด THE KEY IS ALWAYS PRESENT, THE WARNING ONLY WHEN IT IS FALSE (267). A field that
157
+ * appears only on the bad path is a field a caller cannot branch on โ€” it cannot tell
158
+ * *this adapter was conformant* from *this host is too old to say* โ€” which is the shape
159
+ * `required-any-reachability` is open about one layer up. The warning is the human half
160
+ * and merges rather than clobbers, for the same reason `reportDroppedModifiers` does:
161
+ * an out-of-order handshake and an unhonoured stop-on-entry can both be true at once.
162
+ */
163
+ function reportHandshakeOrder(result, seen, waitedMs) {
164
+ result.initialized_seen = seen;
165
+ if (seen)
166
+ return;
167
+ const note = unorderedHandshakeWarning(waitedMs);
168
+ result.warning = typeof result.warning === "string" && result.warning.length > 0 ? `${result.warning} ${note}` : note;
169
+ }
147
170
  const warnDropped = (dropped) => `The connected Godot debug adapter does not support ${dropped.join(", ")} on breakpoints (it advertises ` +
148
171
  `${dropped.length > 1 ? "them" : "it"} unsupported), so ${dropped.length > 1 ? "they were" : "it was"} dropped โ€” ` +
149
172
  `the affected breakpoint(s) will halt unconditionally.`;
@@ -312,8 +335,9 @@ export function registerDapTools(server, dap, cfg) {
312
335
  };
313
336
  }
314
337
  try {
315
- const { entryStopSeen } = await dap.start("launch", { project: cfg.projectPath, scene: scene ?? "main", stopOnEntry: stop_on_entry ?? false }, stop_on_entry ? ENTRY_STOP_WAIT_MS : 0);
338
+ const { entryStopSeen, initializedSeen, initializedWaitMs } = await dap.start("launch", { project: cfg.projectPath, scene: scene ?? "main", stopOnEntry: stop_on_entry ?? false }, stop_on_entry ? ENTRY_STOP_WAIT_MS : 0);
316
339
  const result = { session_id: "godot", state: dap.state, scene: scene ?? "main" };
340
+ reportHandshakeOrder(result, initializedSeen, initializedWaitMs);
317
341
  if (stop_on_entry) {
318
342
  // ๐Ÿ”ด Measured on Godot 4.7: stopOnEntry is ignored outright โ€” the game ran to
319
343
  // completion and printed its _ready() line while the tool answered a bare
@@ -348,8 +372,9 @@ export function registerDapTools(server, dap, cfg) {
348
372
  },
349
373
  }, async ({ address, port }) => {
350
374
  try {
351
- await dap.start("attach", { address: address ?? "127.0.0.1", port });
375
+ const { initializedSeen, initializedWaitMs } = await dap.start("attach", { address: address ?? "127.0.0.1", port });
352
376
  const result = { session_id: "godot", state: dap.state };
377
+ reportHandshakeOrder(result, initializedSeen, initializedWaitMs);
353
378
  reportDroppedModifiers(result);
354
379
  return ok(result);
355
380
  }
@@ -4,6 +4,7 @@
4
4
  // MarkupContent), so these live once here rather than being duplicated per plane.
5
5
  // The generic MCP success-envelope helper `ok()` is the single shared envelope
6
6
  // shape and is imported by the other tool modules too, not only the LSP planes.
7
+ import { remedyClause } from "../bridge.js";
7
8
  // LSP CompletionItemKind numeric -> readable name.
8
9
  export const COMPLETION_KIND = {
9
10
  1: "text", 2: "method", 3: "function", 4: "constructor", 5: "field", 6: "variable",
@@ -57,12 +58,14 @@ export function fail(err) {
57
58
  if (e?.refusal) {
58
59
  return {
59
60
  isError: true,
60
- content: [{ type: "text", text: e.message ?? String(err) }],
61
+ content: [{ type: "text", text: `${e.message ?? String(err)}${remedyClause(err)}` }],
61
62
  };
62
63
  }
64
+ // ๐Ÿ†• 267 โ€” see `tools/dap.ts`. `LspError` gained a `remedy` field this release; this is
65
+ // the one place both LSP planes render, so both get the clause from one edit.
63
66
  return {
64
67
  isError: true,
65
- content: [{ type: "text", text: `LSP error [${e.code ?? "error"}]: ${e.message ?? String(err)}` }],
68
+ content: [{ type: "text", text: `LSP error [${e.code ?? "error"}]: ${e.message ?? String(err)}${remedyClause(err)}` }],
66
69
  };
67
70
  }
68
71
  /**
@@ -82,7 +85,7 @@ export function failPath(err) {
82
85
  const be = err;
83
86
  return {
84
87
  isError: true,
85
- content: [{ type: "text", text: `Path error [${be?.code ?? "error"}]: ${be?.message ?? String(err)}` }],
88
+ content: [{ type: "text", text: `Path error [${be?.code ?? "error"}]: ${be?.message ?? String(err)}${remedyClause(err)}` }],
86
89
  };
87
90
  }
88
91
  /**
@@ -157,7 +157,12 @@ export function registerProcessTools(server, cfg) {
157
157
  const since = since_seq ?? 0;
158
158
  const want = stream ?? "both";
159
159
  const lines = m.lines.filter((l) => l.seq > since && (want === "both" || l.stream === want));
160
- return ok({ id, exited: m.exited, exit_code: m.exitCode, latest_seq: m.seq, lines });
160
+ // ๐Ÿ†• 267 โ€” `signal` on the wire. `exit_code` alone could not distinguish a child
161
+ // that chose to exit 0 from one the OS killed: both answered `exited: true` with a
162
+ // null-or-number code, and a SIGKILLed child answered `exit_code: null`, which reads
163
+ // exactly like a process that has not finished. The registry has captured the signal
164
+ // since 266; this is the key that lets a caller read it.
165
+ return ok({ id, exited: m.exited, exit_code: m.exitCode, signal: m.exitSignal, latest_seq: m.seq, lines });
161
166
  });
162
167
  server.registerTool("godot_stop", {
163
168
  title: "Stop managed process",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakpoint-mcp",
3
- "version": "1.78.3",
3
+ "version": "1.79.0",
4
4
  "description": "MCP server exposing Godot to AI coding assistants over the Model Context Protocol โ€” developed and tested with Claude โ€” across four planes (CLI, live editor, LSP+DAP, runtime) with elicitation-gated destructive tools, long jobs on the formal MCP task model, captured console output, and MCP resources.",
5
5
  "type": "module",
6
6
  "license": "MIT",