breakpoint-mcp 1.78.0 → 1.78.1

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/bridge.js CHANGED
@@ -4,13 +4,23 @@ import { log } from "./logger.js";
4
4
  import { OverdueLedger } from "./late-reply.js";
5
5
  import { findNonFinite, describeNonFinite, tolerate, TOLERANT_METHODS } from "./finiteness.js";
6
6
  import { remedyForWireError } from "./remedies.js";
7
+ import { closeDetail, closeRemedy } from "./close-cause.js";
7
8
  export class BridgeError extends Error {
8
9
  code;
9
10
  /**
10
11
  * The next action the addon attached to this code (254). Optional by
11
- * construction: the addon omits it for codes it has nothing to say about, and
12
- * every failure the host raises itself — timeout, closed socket, non-finite —
13
- * arrives without one, because the remedy for those is not the addon's to give.
12
+ * construction: the addon omits it for codes it has nothing to say about.
13
+ *
14
+ * 🔴 THE REST OF THIS SENTENCE WAS WRONG FOR TWO SESSIONS, AND 264 MEASURED IT. It
15
+ * read: *every failure the host raises itself — timeout, closed socket, non-finite —
16
+ * arrives without one, because the remedy for those is not the addon's to give.* The
17
+ * because-clause is true and the claim does not follow from it. 262 answered `timeout`;
18
+ * 264 answered `bridge_closed` off an errno that had been in hand at the close site the
19
+ * whole time. The census counted the rest: of 25 host-raised failures about the world,
20
+ * ONE carried a remedy in this field, SEVEN named a next action in their message where
21
+ * no reader counts it, and SEVENTEEN said nothing while holding an errno, a state
22
+ * machine or a registry the host owns. **Absent here means nobody has answered it yet,
23
+ * not that it is unanswerable.**
14
24
  */
15
25
  remedy;
16
26
  constructor(code, message, remedy) {
@@ -119,14 +129,22 @@ export class BridgeClient {
119
129
  this.hint = hint;
120
130
  this.secretProvider = secretProvider;
121
131
  this.ledger = new OverdueLedger("bridge", peerNoun, deadlineKnob);
132
+ // 🔴 KEPT, not just handed to the ledger (264). A dropped connection names the same
133
+ // peer a late reply does, and the two sentences drifting apart would be a bug nobody
134
+ // could see from either side.
135
+ this.peerNoun = peerNoun;
122
136
  }
137
+ /** Who answers this client — read by the close path and the late-reply ledger alike. */
138
+ peerNoun;
123
139
  /**
124
140
  * A cause this client cannot see, supplied by whoever CAN, as the remedy on a timeout.
125
141
  *
126
142
  * 🔴 WHY THIS EXISTS AT ALL — the eighth session of "the host's own failures carry no
127
143
  * remedy" (254 §4.6 → 261 §4.4), answered for the one case a walk actually produced.
128
144
  * `BridgeError.remedy` was documented as "the addon's to give, and a host-raised
129
- * timeout arrives without one". True for a closed socket. NOT true here, and USER_GUIDE
145
+ * timeout arrives without one". 🔴 264 DISPROVED THE "TRUE FOR A CLOSED SOCKET" HALF —
146
+ * `onClose` had the errno in hand and read only its `message`, so a killed peer and an
147
+ * orderly shutdown arrived as one code and one sentence. NOT true here either, and USER_GUIDE
130
148
  * §10 B's own step 5 walks the reader straight into it: the addon services `runtime_*`
131
149
  * from `_process`, so a breakpoint inside the method being called halts the very frame
132
150
  * that owes the reply. Measured — the stop's stack trace is `compute` on top of
@@ -328,12 +346,18 @@ export class BridgeClient {
328
346
  this.socket = null;
329
347
  // Name the transport error when there was one. The CODE stays `bridge_closed`
330
348
  // — callers and tests branch on it — but the message carries the errno.
331
- const cause = this.lastSocketError;
349
+ const cause = this.lastSocketError ?? undefined;
332
350
  this.lastSocketError = null;
333
- const detail = cause ? ` (${cause.message})` : "";
351
+ const detail = closeDetail(cause);
352
+ // 🔴 THE ERRNO WAS ALREADY HERE AND ONLY ITS `message` WAS READ (264 §3). A peer that
353
+ // was killed and a peer that shut down cleanly produced the SAME code and the SAME
354
+ // sentence, differing by one parenthetical nothing instructed the caller to act on.
355
+ // `cause.code` separates them, and this is the one close site whose class has a field
356
+ // to put the answer in — see close-cause.ts for the four that do not.
357
+ const remedy = closeRemedy(cause, this.peerNoun);
334
358
  for (const [, p] of this.pending) {
335
359
  clearTimeout(p.timer);
336
- p.reject(new BridgeError("bridge_closed", `Bridge connection closed before a response arrived${detail}`));
360
+ p.reject(new BridgeError("bridge_closed", `Bridge connection closed before a response arrived${detail}`, remedy));
337
361
  }
338
362
  this.pending.clear();
339
363
  // Keep the push channel alive across editor restarts while subscriptions want it.
@@ -353,10 +377,14 @@ export class BridgeClient {
353
377
  // exact `timed out after <n>ms` phrasing — tools/dap.ts:29 and
354
378
  // tools/csdap.ts:31 branch on that substring.
355
379
  this.ledger.note(id, method, timeoutMs);
356
- // 🔴 THE ONE HOST-RAISED FAILURE THAT HAS A KNOWABLE CAUSE (262 §2). Everything
357
- // else this class raises is genuinely opaque from here — a closed socket, a peer
358
- // that never answered — but a timeout on the RUNTIME bridge has one cause the host
359
- // can check for nothing: its own debugger holding the game. See `setHoldProbe`.
380
+ // 🔴 262 §2 CALLED THIS "THE ONE HOST-RAISED FAILURE THAT HAS A KNOWABLE CAUSE",
381
+ // and said everything else this class raises — "a closed socket, a peer that never
382
+ // answered" — is genuinely opaque from here. 264 MEASURED THAT AND IT IS FALSE FOR
383
+ // THE CLOSED SOCKET: `onClose` holds the socket `Error` and its errno separates a
384
+ // peer that was killed from one that shut down cleanly. It stays TRUE for this
385
+ // line — a peer that never answered really does leave nothing behind — which is
386
+ // why the one cause the host can supply comes from outside the socket: its own
387
+ // debugger holding the game. See `setHoldProbe`, and 264's census for the rest.
360
388
  reject(new BridgeError("timeout", `Bridge request '${method}' timed out after ${timeoutMs}ms`, this.holdProbe?.() ?? undefined));
361
389
  }, timeoutMs);
362
390
  this.pending.set(id, { resolve: resolve, reject, timer, method });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * What a dropped connection's errno discriminates, and the next action for each.
3
+ *
4
+ * 🔴 WHY THIS EXISTS — 264's census, which is the answer to a row nine sessions old.
5
+ * `BridgeError`'s own doc said every failure the host raises "arrives without one,
6
+ * because the remedy for those is not the addon's to give", and `setHoldProbe`'s said
7
+ * that was "true for a closed socket". **It is not true for a closed socket.** Measured
8
+ * against real sockets, three ways a peer can go:
9
+ *
10
+ * ```
11
+ * peer resetAndDestroy code=ECONNRESET syscall=read message="read ECONNRESET"
12
+ * peer end() cleanly NULL — no socket error at all
13
+ * peer destroy() NULL — no socket error at all
14
+ * ```
15
+ *
16
+ * and what a caller received for the last two was the SAME code and the SAME sentence,
17
+ * separated by a parenthesis:
18
+ *
19
+ * ```
20
+ * B peer reset mid-request bridge_closed Bridge connection closed before a response arrived (read ECONNRESET)
21
+ * C peer closed cleanly bridge_closed Bridge connection closed before a response arrived
22
+ * ```
23
+ *
24
+ * An editor that was killed and an addon that shut its socket on purpose are different
25
+ * next actions. The `Error` that tells them apart was already in hand at all five close
26
+ * sites — `framing.ts` hands the whole object to every one of them — and every one read
27
+ * `cause.message` and threw the rest away.
28
+ *
29
+ * 🔴 ONE MODULE, FIVE CALLERS, BECAUSE THE ALTERNATIVE IS FIVE COPIES OF A JUDGEMENT.
30
+ * `bridge.ts`, `dap.ts`, `csdap.ts`, `lsp.ts` and `cslsp.ts` each drop a connection the
31
+ * same way for the same reasons. A sentence pasted into five close handlers is five
32
+ * places for the errno list to drift.
33
+ *
34
+ * 🔴 AND WHAT IT DELIBERATELY DOES NOT DO. An errno outside the abrupt family gets NO
35
+ * remedy — not a hedge, not a generic one. 264 measured two families and can speak for
36
+ * two; `classifyClose` returns `"unclassified"` for the rest so a future session can find
37
+ * the population it did not cover instead of inheriting a sentence nobody measured.
38
+ * 260's rule: a diagnostic that names a state is not a diagnosis of its cause.
39
+ */
40
+ /**
41
+ * Errnos that mean the peer went away WITHOUT closing the connection.
42
+ *
43
+ * ECONNRESET: the peer's stack sent RST — the process died or was killed mid-stream.
44
+ * EPIPE: we wrote to a connection the peer had already torn down.
45
+ * ECONNABORTED: the local stack aborted an established connection.
46
+ *
47
+ * All three say the same thing about the world — nobody hung up, somebody vanished —
48
+ * and none of them can be produced by an orderly shutdown.
49
+ */
50
+ export const ABRUPT_CLOSE_CODES = new Set(["ECONNRESET", "EPIPE", "ECONNABORTED"]);
51
+ /**
52
+ * Which of the three a close was. `undefined` means the socket ended with no error at
53
+ * all, which is what an orderly `end()` or `destroy()` looks like from this side.
54
+ */
55
+ export function classifyClose(cause) {
56
+ if (!cause)
57
+ return "deliberate";
58
+ const code = cause.code;
59
+ if (typeof code === "string" && ABRUPT_CLOSE_CODES.has(code))
60
+ return "abrupt";
61
+ return "unclassified";
62
+ }
63
+ /**
64
+ * The next action, given who the peer was — or `undefined` when the errno does not
65
+ * determine one.
66
+ *
67
+ * Written to the grammar check 28 enforces on `error_remedies.gd`: one imperative at the
68
+ * head, a full stop at the end, and nothing named that does not resolve. `peer` is the
69
+ * noun each client already uses for whoever answers it — "the editor", "the running
70
+ * game", "the debug adapter", "the language server" — so the sentence names the thing the
71
+ * caller was actually talking to rather than a socket.
72
+ */
73
+ export function closeRemedy(cause, peer) {
74
+ switch (classifyClose(cause)) {
75
+ case "abrupt":
76
+ // The errno is NOT repeated here: `closeDetail` has already put it in the sentence
77
+ // immediately to the left, and a caller reading `(read ECONNRESET) — … (ECONNRESET)`
78
+ // learns nothing from the second one.
79
+ return (`Restart ${peer} and retry — it went away without closing the connection, ` +
80
+ `so nothing here says whether this request ran.`);
81
+ case "deliberate":
82
+ return (`Check whether ${peer} is still running — it closed the connection in an orderly way ` +
83
+ `rather than dropping it, which is what a shutdown looks like from here.`);
84
+ default:
85
+ return undefined;
86
+ }
87
+ }
88
+ /**
89
+ * The parenthetical the five close handlers already built by hand, spelled once.
90
+ *
91
+ * Kept byte-identical to what shipped — ` (read ECONNRESET)` — because tests and users
92
+ * read it, and this change is about adding the next action, not about rewording the
93
+ * detail that was already right.
94
+ */
95
+ export function closeDetail(cause) {
96
+ return cause ? ` (${cause.message})` : "";
97
+ }
98
+ //# sourceMappingURL=close-cause.js.map
package/dist/csdap.js CHANGED
@@ -1,6 +1,16 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { DapError } from "./dap.js";
3
3
  import { OverdueLedger } from "./late-reply.js";
4
+ import { closeDetail, closeRemedy } from "./close-cause.js";
5
+ /**
6
+ * Who answers this plane, in the words its late-reply ledger already uses.
7
+ *
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.
12
+ */
13
+ const CSDAP_PEER = "the debug adapter";
4
14
  /**
5
15
  * How long `start()` waits for the entry `stopped` event when `stopAtEntry` was
6
16
  * requested. Bounded so an adapter that ignores stop-on-entry reports `running`
@@ -41,7 +51,7 @@ export class CsDapClient extends EventEmitter {
41
51
  * dropped. `seq` is monotonic and is never reset, so a seq is never reused and
42
52
  * a late response can only be its own request's.
43
53
  */
44
- ledger = new OverdueLedger("C# DAP", "the debug adapter", "GODOT_CSDAP_TIMEOUT_MS");
54
+ ledger = new OverdueLedger("C# DAP", CSDAP_PEER, "GODOT_CSDAP_TIMEOUT_MS");
45
55
  configured = false;
46
56
  /** Persistent watch expressions, re-evaluated at each stop (see evaluateWatches). */
47
57
  watches = [];
@@ -117,10 +127,16 @@ export class CsDapClient extends EventEmitter {
117
127
  this.state = "terminated";
118
128
  this.configured = false;
119
129
  this.sessionStarted = false;
120
- const detail = cause ? ` (${cause.message})` : "";
130
+ 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.
136
+ const remedy = closeRemedy(cause, CSDAP_PEER);
121
137
  for (const [, p] of this.pending) {
122
138
  clearTimeout(p.timer);
123
- p.reject(new DapError(p.command, `C# DAP connection closed${detail}`));
139
+ p.reject(new DapError(p.command, `C# DAP connection closed${detail}${remedy ? ` — ${remedy}` : ""}`));
124
140
  }
125
141
  this.pending.clear();
126
142
  this.emit("closed");
package/dist/cslsp.js CHANGED
@@ -1,7 +1,17 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import { LspError } from "./lsp.js";
3
3
  import { OverdueLedger } from "./late-reply.js";
4
+ import { closeDetail, closeRemedy } from "./close-cause.js";
4
5
  import { packageVersion } from "./version.js";
6
+ /**
7
+ * Who answers this plane, in the words its late-reply ledger already uses.
8
+ *
9
+ * 🔴 ONE CONSTANT, TWO READERS (264). The ledger names this peer when a reply arrives
10
+ * late and `closeRemedy` names it when the connection drops. Two literals would let the
11
+ * same peer acquire two names on the same run, which is the class of drift 257's
12
+ * per-instance deadline noun was written to stop.
13
+ */
14
+ const CSLSP_PEER = "the language server";
5
15
  /**
6
16
  * Minimal LSP client for the C#/.NET semantic plane (D4 C2). Transport-agnostic:
7
17
  * it drives any `JsonRpcChannel`, so production spawns OmniSharp over stdio
@@ -31,7 +41,7 @@ export class CsLspClient {
31
41
  * dropped. `nextId` is monotonic and is deliberately NOT reset by onClose(),
32
42
  * so an id is never reused and a late reply can only be its own request's.
33
43
  */
34
- ledger = new OverdueLedger("C# LSP", "the language server", "GODOT_CSLSP_TIMEOUT_MS");
44
+ ledger = new OverdueLedger("C# LSP", CSLSP_PEER, "GODOT_CSLSP_TIMEOUT_MS");
35
45
  /** Absolute project root path (no trailing slash), used to canonicalize URIs. */
36
46
  rootFsPath;
37
47
  constructor(channel, rootUri, timeoutMs) {
@@ -128,9 +138,16 @@ export class CsLspClient {
128
138
  }
129
139
  }
130
140
  onClose(cause) {
141
+ 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.
147
+ const remedy = closeRemedy(cause, CSLSP_PEER);
131
148
  for (const [, p] of this.pending) {
132
149
  clearTimeout(p.timer);
133
- p.reject(new LspError("closed", `C# LSP connection closed${cause ? ` (${cause.message})` : ""}`));
150
+ p.reject(new LspError("closed", `C# LSP connection closed${detail}${remedy ? ` — ${remedy}` : ""}`));
134
151
  }
135
152
  this.pending.clear();
136
153
  this.initialized = null;
package/dist/dap.js CHANGED
@@ -1,6 +1,16 @@
1
1
  import { EventEmitter } from "node:events";
2
2
  import { FramedConnection } from "./framing.js";
3
3
  import { OverdueLedger } from "./late-reply.js";
4
+ import { closeDetail, closeRemedy } from "./close-cause.js";
5
+ /**
6
+ * Who answers this plane, in the words its late-reply ledger already uses.
7
+ *
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.
12
+ */
13
+ const DAP_PEER = "the debug adapter";
4
14
  export class DapError extends Error {
5
15
  command;
6
16
  constructor(command, message) {
@@ -73,7 +83,7 @@ export class DapClient extends EventEmitter {
73
83
  * dropped. `seq` is monotonic and is never reset, so a seq is never reused and
74
84
  * a late response can only be its own request's.
75
85
  */
76
- ledger = new OverdueLedger("DAP", "the debug adapter", "GODOT_DAP_TIMEOUT_MS");
86
+ ledger = new OverdueLedger("DAP", DAP_PEER, "GODOT_DAP_TIMEOUT_MS");
77
87
  configured = false;
78
88
  /** Persistent watch expressions, re-evaluated at each stop (see evaluateWatches). */
79
89
  watches = [];
@@ -141,10 +151,16 @@ export class DapClient extends EventEmitter {
141
151
  // `cause` is the socket-level error when the drop had one (ECONNRESET, EPIPE).
142
152
  // Without it every pending request reported a generic close and the operator
143
153
  // could not tell a crashed server from something else holding the port.
144
- const detail = cause ? ` (${cause.message})` : "";
154
+ 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.
160
+ const remedy = closeRemedy(cause, DAP_PEER);
145
161
  for (const [, p] of this.pending) {
146
162
  clearTimeout(p.timer);
147
- p.reject(new DapError(p.command, `DAP connection closed${detail}`));
163
+ p.reject(new DapError(p.command, `DAP connection closed${detail}${remedy ? ` — ${remedy}` : ""}`));
148
164
  }
149
165
  this.pending.clear();
150
166
  this.emit("closed");
package/dist/index.js CHANGED
@@ -50,7 +50,7 @@ async function main() {
50
50
  // so long jobs (export/import/headless script) support poll/await/cancel.
51
51
  // D3: also advertise resources.subscribe so clients can subscribe to
52
52
  // godot://… resources and receive notifications/resources/updated.
53
- const server = new McpServer({ name: "breakpoint-mcp", version: "1.78.0" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
53
+ const server = new McpServer({ name: "breakpoint-mcp", version: "1.78.1" }, { capabilities: { ...TASK_CAPABILITIES, ...RESOURCE_CAPABILITIES }, taskStore });
54
54
  // 208 §7.1 — delete from the wire what the protocol already says: the SDK's
55
55
  // draft-07 `$schema` inside every input/output schema (MCP's default dialect is
56
56
  // 2020-12, and declaring another is an explicit switch a peer may reject), and
package/dist/lsp.js CHANGED
@@ -2,6 +2,16 @@ import { fileURLToPath } from "node:url";
2
2
  import { FramedConnection } from "./framing.js";
3
3
  import { packageVersion } from "./version.js";
4
4
  import { OverdueLedger } from "./late-reply.js";
5
+ import { closeDetail, closeRemedy } from "./close-cause.js";
6
+ /**
7
+ * Who answers this plane, in the words its late-reply ledger already uses.
8
+ *
9
+ * 🔴 ONE CONSTANT, TWO READERS (264). The ledger names this peer when a reply arrives
10
+ * late and `closeRemedy` names it when the connection drops. Two literals would let the
11
+ * same peer acquire two names on the same run, which is the class of drift 257's
12
+ * per-instance deadline noun was written to stop.
13
+ */
14
+ const LSP_PEER = "the language server";
5
15
  export class LspError extends Error {
6
16
  code;
7
17
  constructor(code, message) {
@@ -31,7 +41,7 @@ export class LspClient {
31
41
  * dropped. `nextId` is monotonic and is deliberately NOT reset by onClose(),
32
42
  * so an id is never reused and a late reply can only be its own request's.
33
43
  */
34
- ledger = new OverdueLedger("LSP", "the language server", "GODOT_LSP_TIMEOUT_MS");
44
+ ledger = new OverdueLedger("LSP", LSP_PEER, "GODOT_LSP_TIMEOUT_MS");
35
45
  /** Absolute project root path (no trailing slash), used to canonicalize URIs. */
36
46
  rootFsPath;
37
47
  constructor(host, port, rootUri, timeoutMs) {
@@ -132,10 +142,16 @@ export class LspClient {
132
142
  // `cause` is the socket-level error when the drop had one (ECONNRESET, EPIPE).
133
143
  // Without it every pending request reported a generic close and the operator
134
144
  // could not tell a crashed server from something else holding the port.
135
- const detail = cause ? ` (${cause.message})` : "";
145
+ 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.
151
+ const remedy = closeRemedy(cause, LSP_PEER);
136
152
  for (const [, p] of this.pending) {
137
153
  clearTimeout(p.timer);
138
- p.reject(new LspError("closed", `LSP connection closed${detail}`));
154
+ p.reject(new LspError("closed", `LSP connection closed${detail}${remedy ? ` — ${remedy}` : ""}`));
139
155
  }
140
156
  this.pending.clear();
141
157
  this.initialized = null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakpoint-mcp",
3
- "version": "1.78.0",
3
+ "version": "1.78.1",
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",