gdharness 0.4.2 → 0.5.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/build/index.js CHANGED
@@ -9802,11 +9802,332 @@ var require_websocket_server = __commonJS(function(exports, module) {
9802
9802
  }
9803
9803
  });
9804
9804
 
9805
+ // src/issues.ts
9806
+ import { createHash } from "node:crypto";
9807
+ import process2 from "node:process";
9808
+
9809
+ // src/errors.ts
9810
+ class Refusal extends Error {
9811
+ }
9812
+ function errorMessage(error, fallback = "Unknown error") {
9813
+ if (error instanceof Error) {
9814
+ return error.message || fallback;
9815
+ }
9816
+ if (typeof error === "string") {
9817
+ return error || fallback;
9818
+ }
9819
+ if (typeof error === "number" || typeof error === "boolean") {
9820
+ return String(error);
9821
+ }
9822
+ return fallback;
9823
+ }
9824
+ function toError(error) {
9825
+ return error instanceof Error ? error : new Error(errorMessage(error), { cause: error });
9826
+ }
9827
+
9828
+ // src/server-version.ts
9829
+ import { readFileSync as readFileSync2 } from "node:fs";
9830
+
9831
+ // src/update-check.ts
9832
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9833
+ import { homedir, tmpdir } from "node:os";
9834
+ import { join } from "node:path";
9835
+
9836
+ // src/runner.ts
9837
+ function currentRunner() {
9838
+ const versions = process.versions;
9839
+ return versions["bun"] === undefined ? "npx" : "bunx";
9840
+ }
9841
+ function runLine(runner, version, rest = "") {
9842
+ const flag = runner === "npx" ? "-y " : "";
9843
+ return `${runner} ${flag}gdharness@${version}${rest === "" ? "" : ` ${rest}`}`;
9844
+ }
9845
+
9846
+ // src/update-check.ts
9847
+ var REGISTRY = "https://registry.npmjs.org/gdharness/latest";
9848
+ var RELEASES = "https://github.com/Aureliolo/gdharness/releases/tag";
9849
+ var CACHE_MS = 4 * 60 * 60 * 1000;
9850
+ var REQUEST_TIMEOUT_MS = 1e4;
9851
+ var MAX_BODY_BYTES = 1 << 20;
9852
+ var FIRST_RETRY_MS = 30000;
9853
+ var MAX_RETRY_MS = 60 * 60 * 1000;
9854
+ var VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
9855
+ function cacheDirectory(environment) {
9856
+ const set = (name) => {
9857
+ const value = environment[name];
9858
+ return value !== undefined && value !== "" ? value : null;
9859
+ };
9860
+ const home = set("HOME") ?? homedir();
9861
+ if (process.platform === "win32") {
9862
+ return join(set("LOCALAPPDATA") ?? home, "gdharness");
9863
+ }
9864
+ if (process.platform === "darwin") {
9865
+ return join(home, "Library", "Caches", "gdharness");
9866
+ }
9867
+ return join(set("XDG_CACHE_HOME") ?? join(home, ".cache"), "gdharness");
9868
+ }
9869
+ function cacheFile(environment = process.env) {
9870
+ try {
9871
+ const directory = cacheDirectory(environment);
9872
+ mkdirSync(directory, { recursive: true, mode: 448 });
9873
+ return join(directory, "update-check.json");
9874
+ } catch {
9875
+ return join(tmpdir(), "gdharness-update-check.json");
9876
+ }
9877
+ }
9878
+ function readCache(path) {
9879
+ try {
9880
+ if (!existsSync(path)) {
9881
+ return null;
9882
+ }
9883
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
9884
+ if (typeof parsed !== "object" || parsed === null) {
9885
+ return null;
9886
+ }
9887
+ const record = parsed;
9888
+ const checkedAt = record["checkedAt"];
9889
+ const latest = record["latest"];
9890
+ if (typeof checkedAt !== "number" || typeof latest !== "string" || !VERSION.test(latest)) {
9891
+ return null;
9892
+ }
9893
+ return { checkedAt, latest };
9894
+ } catch {
9895
+ return null;
9896
+ }
9897
+ }
9898
+ function writeCache(path, entry) {
9899
+ try {
9900
+ writeFileSync(path, JSON.stringify(entry), { encoding: "utf8", mode: 384 });
9901
+ } catch {}
9902
+ }
9903
+ function parts(version) {
9904
+ const withoutBuild = version.split("+")[0] ?? version;
9905
+ const dash = withoutBuild.indexOf("-");
9906
+ const numeric = dash === -1 ? withoutBuild : withoutBuild.slice(0, dash);
9907
+ return {
9908
+ numbers: numeric.split(".").map((piece) => Number.parseInt(piece, 10) || 0),
9909
+ prerelease: dash !== -1
9910
+ };
9911
+ }
9912
+ function isNewer(candidate, current) {
9913
+ const left = parts(candidate);
9914
+ const right = parts(current);
9915
+ for (let index = 0;index < 3; index += 1) {
9916
+ const a = left.numbers[index] ?? 0;
9917
+ const b = right.numbers[index] ?? 0;
9918
+ if (a !== b) {
9919
+ return a > b;
9920
+ }
9921
+ }
9922
+ return !left.prerelease && right.prerelease;
9923
+ }
9924
+ async function fetchLatest() {
9925
+ const response = await fetch(REGISTRY, {
9926
+ headers: { accept: "application/vnd.npm.install-v1+json, application/json" },
9927
+ redirect: "error",
9928
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
9929
+ });
9930
+ if (!response.ok || response.body === null) {
9931
+ return null;
9932
+ }
9933
+ const declared = Number.parseInt(response.headers.get("content-length") ?? "", 10);
9934
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
9935
+ return null;
9936
+ }
9937
+ const chunks = [];
9938
+ let size = 0;
9939
+ for await (const chunk of response.body) {
9940
+ size += chunk.byteLength;
9941
+ if (size > MAX_BODY_BYTES) {
9942
+ return null;
9943
+ }
9944
+ chunks.push(chunk);
9945
+ }
9946
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
9947
+ if (typeof parsed !== "object" || parsed === null) {
9948
+ return null;
9949
+ }
9950
+ const version = parsed["version"];
9951
+ return typeof version === "string" && VERSION.test(version) ? version : null;
9952
+ }
9953
+
9954
+ class UpdateCheck {
9955
+ latest = null;
9956
+ checking = false;
9957
+ checkedAt = 0;
9958
+ retryAt = 0;
9959
+ backoffMs = FIRST_RETRY_MS;
9960
+ enabled;
9961
+ current;
9962
+ cachePath;
9963
+ constructor(current, environment = process.env) {
9964
+ this.current = current;
9965
+ this.enabled = (environment["GDHARNESS_NO_UPDATE_CHECK"] ?? "") === "";
9966
+ this.cachePath = cacheFile(environment);
9967
+ const cached = this.enabled ? readCache(this.cachePath) : null;
9968
+ if (cached !== null) {
9969
+ this.latest = cached.latest;
9970
+ this.checkedAt = cached.checkedAt;
9971
+ }
9972
+ }
9973
+ refresh(now = Date.now()) {
9974
+ if (!this.enabled || this.checking || now < this.retryAt || now - this.checkedAt < CACHE_MS) {
9975
+ return;
9976
+ }
9977
+ this.checking = true;
9978
+ fetchLatest().then((version) => {
9979
+ if (version === null) {
9980
+ this.scheduleRetry(now);
9981
+ return;
9982
+ }
9983
+ this.latest = version;
9984
+ this.checkedAt = Date.now();
9985
+ this.backoffMs = FIRST_RETRY_MS;
9986
+ this.retryAt = 0;
9987
+ writeCache(this.cachePath, { checkedAt: this.checkedAt, latest: version });
9988
+ }).catch(() => {
9989
+ this.scheduleRetry(now);
9990
+ }).finally(() => {
9991
+ this.checking = false;
9992
+ });
9993
+ }
9994
+ scheduleRetry(now) {
9995
+ this.retryAt = now + this.backoffMs;
9996
+ this.backoffMs = Math.min(this.backoffMs * 2, MAX_RETRY_MS);
9997
+ }
9998
+ notice() {
9999
+ const latest = this.latest;
10000
+ if (!this.enabled || latest === null || !isNewer(latest, this.current)) {
10001
+ return null;
10002
+ }
10003
+ return {
10004
+ current: this.current,
10005
+ latest,
10006
+ releaseNotes: `${RELEASES}/v${latest}`,
10007
+ upgrade: runLine(currentRunner(), latest, "upgrade")
10008
+ };
10009
+ }
10010
+ }
10011
+
10012
+ // src/server-version.ts
10013
+ var DEBUG_MODE = process.env["DEBUG"] === "true";
10014
+ var GODOT_DEBUG_MODE_DEFAULT = process.env["GODOT_DEBUG"] === "true" || DEBUG_MODE;
10015
+ var SERVER_VERSION = (() => {
10016
+ try {
10017
+ const pkg = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
10018
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
10019
+ } catch {
10020
+ return "0.0.0";
10021
+ }
10022
+ })();
10023
+ var UNVERSIONED = "addon from before versions were reported";
10024
+ function addonMismatch(addonVersion, serverVersion) {
10025
+ if (addonVersion === serverVersion) {
10026
+ return;
10027
+ }
10028
+ const reported = addonVersion ?? "";
10029
+ const editor = reported === "" ? UNVERSIONED : reported;
10030
+ const both = `The editor is running the ${editor} addon while this server ships ${serverVersion}.`;
10031
+ if (reported !== "" && isNewer(reported, serverVersion)) {
10032
+ return `${both} This server is the older half: reconnect it in your harness so it spawns ${reported}.`;
10033
+ }
10034
+ return `${both} Restart it with editor_launch restart to pick the new one up.`;
10035
+ }
10036
+
10037
+ // src/issues.ts
10038
+ var NEW_ISSUE = "https://github.com/Aureliolo/gdharness/issues/new";
10039
+ var ENHANCEMENT_URL = `${NEW_ISSUE}?template=feature_request.md`;
10040
+ function runtime() {
10041
+ const versions = process2.versions;
10042
+ const bun = versions["bun"];
10043
+ return bun === undefined ? `node ${process2.versions.node}` : `bun ${bun}`;
10044
+ }
10045
+ function defectSignature(where, message) {
10046
+ const normalised = `${where}
10047
+ ${message}`.toLowerCase().replaceAll("\\", "/").replaceAll(/(?:[a-z]:)?(?:\/+[\w.-]+){2,}/gu, "<path>").replaceAll(/\d+/gu, "0");
10048
+ return createHash("sha256").update(normalised).digest("hex").slice(0, 8);
10049
+ }
10050
+ function facts(where, message, godotVersion) {
10051
+ return [
10052
+ ["Where", where],
10053
+ ["Error", message],
10054
+ ["Version", `gdharness ${SERVER_VERSION}`],
10055
+ ["Runtime", `${runtime()}, ${process2.platform} ${process2.arch}`],
10056
+ ["Godot", godotVersion ?? "not known at the point this failed"],
10057
+ ["Signature", defectSignature(where, message)]
10058
+ ];
10059
+ }
10060
+ function filledTemplate(where, message, godotVersion) {
10061
+ return [
10062
+ `**gdharness version**: ${SERVER_VERSION}`,
10063
+ `**Godot version**: ${godotVersion ?? ""}`,
10064
+ `**Bun version**: ${runtime()}`,
10065
+ `**OS**: ${process2.platform} ${process2.arch}`,
10066
+ "**MCP client**:",
10067
+ "",
10068
+ "## What you did",
10069
+ "",
10070
+ where,
10071
+ "",
10072
+ "## What you expected",
10073
+ "",
10074
+ "The call to answer, or to refuse with a reason.",
10075
+ "",
10076
+ "## What happened",
10077
+ "",
10078
+ "gdharness failed in a way it does not model.",
10079
+ "",
10080
+ "```text",
10081
+ message,
10082
+ "```",
10083
+ "",
10084
+ `Signature: \`${defectSignature(where, message)}\``,
10085
+ "",
10086
+ "If the real cause turned out to be the project, the environment or the call, say so here:",
10087
+ "reaching you as a defect rather than as a refusal naming what would have worked is then the",
10088
+ "thing to fix.",
10089
+ "",
10090
+ "## Anything that makes it reproducible",
10091
+ ""
10092
+ ].join(`
10093
+ `);
10094
+ }
10095
+ function defectReport(where, error, godotVersion) {
10096
+ const message = errorMessage(error);
10097
+ const title = `Defect ${defectSignature(where, message)}: ${where}`;
10098
+ const url = `${NEW_ISSUE}?template=bug_report.md&title=${encodeURIComponent(title)}` + `&body=${encodeURIComponent(filledTemplate(where, message, godotVersion))}`;
10099
+ const width = Math.max(...facts(where, message, godotVersion).map(([label]) => (label ?? "").length));
10100
+ return [
10101
+ "gdharness failed in a way it does not model. This is a defect in gdharness rather than",
10102
+ "anything about the call: the arguments did not cause it, and sending it again will not",
10103
+ "change it.",
10104
+ "",
10105
+ ...facts(where, message, godotVersion).map(([label, value]) => ` ${(label ?? "").padEnd(width)} ${value ?? ""}`),
10106
+ "",
10107
+ "Do not open an issue on your own initiative. Tell the person whose machine this is what",
10108
+ "broke, and ask whether you may report it to gdharness on their behalf. What the report",
10109
+ "carries is the lines above and nothing else, and whether that goes into a public tracker",
10110
+ "is theirs to decide.",
10111
+ "",
10112
+ "If they say yes, this link carries the report already filled in:",
10113
+ url,
10114
+ "",
10115
+ "If they say yes and you have no way to open an issue yourself, give them that link and",
10116
+ "those lines, and stay with them while they file it.",
10117
+ "",
10118
+ "Worth reporting either way. If you can see that what actually went wrong was the project,",
10119
+ "the environment or the call, then this message is the defect: a failure gdharness knows",
10120
+ "about is meant to arrive as a refusal naming what would have worked, not as this. Say that",
10121
+ "in the report and it is the more useful of the two."
10122
+ ].join(`
10123
+ `);
10124
+ }
10125
+
9805
10126
  // src/server.ts
9806
10127
  import { execFile as execFile3, spawn } from "node:child_process";
9807
- import { existsSync as existsSync4, mkdtempSync as mkdtempSync2, readdirSync as readdirSync4, readFileSync as readFileSync5, realpathSync as realpathSync2, rmSync as rmSync2 } from "node:fs";
9808
- import { tmpdir as tmpdir3 } from "node:os";
9809
- import { basename, dirname as dirname2, join as join5, normalize as normalize2 } from "node:path";
10128
+ import { existsSync as existsSync6, mkdtempSync as mkdtempSync2, readdirSync as readdirSync5, readFileSync as readFileSync7, realpathSync as realpathSync2, rmSync as rmSync2 } from "node:fs";
10129
+ import { tmpdir as tmpdir4 } from "node:os";
10130
+ import { basename, dirname as dirname2, join as join7, normalize as normalize2 } from "node:path";
9810
10131
  import { setTimeout as delay2 } from "node:timers/promises";
9811
10132
  import { fileURLToPath as fileURLToPath2 } from "node:url";
9812
10133
  import { promisify as promisify3 } from "node:util";
@@ -24269,7 +24590,7 @@ var EMPTY_COMPLETION_RESULT = {
24269
24590
  };
24270
24591
 
24271
24592
  // node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js
24272
- import process2 from "node:process";
24593
+ import process3 from "node:process";
24273
24594
 
24274
24595
  // node_modules/@modelcontextprotocol/sdk/dist/esm/shared/stdio.js
24275
24596
  var STDIO_DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024;
@@ -24313,7 +24634,7 @@ function serializeMessage(message) {
24313
24634
 
24314
24635
  // node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js
24315
24636
  class StdioServerTransport {
24316
- constructor(_stdin = process2.stdin, _stdout = process2.stdout, options) {
24637
+ constructor(_stdin = process3.stdin, _stdout = process3.stdout, options) {
24317
24638
  this._stdin = _stdin;
24318
24639
  this._stdout = _stdout;
24319
24640
  this._started = false;
@@ -24374,6 +24695,50 @@ class StdioServerTransport {
24374
24695
  }
24375
24696
  }
24376
24697
 
24698
+ // src/class-cache.ts
24699
+ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync3 } from "node:fs";
24700
+ import { join as join2 } from "node:path";
24701
+ function declaredClasses(projectPath) {
24702
+ const declared = new Map;
24703
+ const visit = (directory, prefix) => {
24704
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
24705
+ if (entry.name.startsWith(".")) {
24706
+ continue;
24707
+ }
24708
+ const path = join2(directory, entry.name);
24709
+ if (entry.isDirectory()) {
24710
+ visit(path, `${prefix}${entry.name}/`);
24711
+ } else if (entry.isFile() && entry.name.endsWith(".gd")) {
24712
+ const found = /^class_name\s+([A-Za-z_][A-Za-z0-9_]*)/m.exec(readFileSync3(path, "utf8"));
24713
+ if (found?.[1]) {
24714
+ declared.set(found[1], `res://${prefix}${entry.name}`);
24715
+ }
24716
+ }
24717
+ }
24718
+ };
24719
+ visit(projectPath, "");
24720
+ return declared;
24721
+ }
24722
+ function cachedClasses(projectPath) {
24723
+ const cache = join2(projectPath, ".godot", "global_script_class_cache.cfg");
24724
+ if (!existsSync2(cache)) {
24725
+ return null;
24726
+ }
24727
+ const listed = new Map;
24728
+ const text = readFileSync3(cache, "utf8");
24729
+ for (const entry of text.matchAll(/"class":\s*&"([^"]+)"[\s\S]*?"path":\s*"([^"]+)"/g)) {
24730
+ listed.set(entry[1] ?? "", entry[2] ?? "");
24731
+ }
24732
+ return listed;
24733
+ }
24734
+ function staleAgainst(cached, projectPath) {
24735
+ return [...declaredClasses(projectPath)].filter(([name, path]) => cached.get(name) !== path).map(([name]) => name);
24736
+ }
24737
+ function staleClassNames(projectPath) {
24738
+ const cached = cachedClasses(projectPath);
24739
+ return cached === null ? [...declaredClasses(projectPath).keys()] : staleAgainst(cached, projectPath);
24740
+ }
24741
+
24377
24742
  // src/dap_client.ts
24378
24743
  import { createConnection } from "node:net";
24379
24744
  import { setTimeout as delay } from "node:timers/promises";
@@ -24464,7 +24829,7 @@ function portFromEnv(variable, fallback) {
24464
24829
  }
24465
24830
  const parsed = Number.parseInt(raw, 10);
24466
24831
  if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535 || String(parsed) !== raw) {
24467
- throw new Error(`${variable} is "${raw}", not a port between 1 and 65535.`);
24832
+ throw new Refusal(`${variable} is "${raw}", not a port between 1 and 65535.`);
24468
24833
  }
24469
24834
  return parsed;
24470
24835
  }
@@ -24488,6 +24853,7 @@ class GodotDAPClient {
24488
24853
  initialized = false;
24489
24854
  attached = false;
24490
24855
  lastThreadId = 1;
24856
+ stopped = false;
24491
24857
  breakpoints = new Map;
24492
24858
  constructor(port = portFromEnv("GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT), host = "127.0.0.1") {
24493
24859
  this.port = port;
@@ -24536,6 +24902,7 @@ class GodotDAPClient {
24536
24902
  this.connected = false;
24537
24903
  this.initialized = false;
24538
24904
  this.attached = false;
24905
+ this.stopped = false;
24539
24906
  this.socket = null;
24540
24907
  this.failPendingRequests(new Error("DAP connection closed"));
24541
24908
  });
@@ -24555,6 +24922,7 @@ class GodotDAPClient {
24555
24922
  this.connected = false;
24556
24923
  this.initialized = false;
24557
24924
  this.attached = false;
24925
+ this.stopped = false;
24558
24926
  return;
24559
24927
  }
24560
24928
  if (this.connected) {
@@ -24583,6 +24951,7 @@ class GodotDAPClient {
24583
24951
  this.connected = false;
24584
24952
  this.initialized = false;
24585
24953
  this.attached = false;
24954
+ this.stopped = false;
24586
24955
  }
24587
24956
  async ensureConnected() {
24588
24957
  if (!this.connected) {
@@ -24592,7 +24961,7 @@ class GodotDAPClient {
24592
24961
  async sendRequest(command, args, timeoutMs = DAP_REQUEST_TIMEOUT_MS) {
24593
24962
  await this.ensureConnected();
24594
24963
  if (!this.socket) {
24595
- throw new Error("DAP socket is not available");
24964
+ throw new Refusal("DAP socket is not available");
24596
24965
  }
24597
24966
  const requestSeq = this.seq++;
24598
24967
  const request = {
@@ -24659,10 +25028,12 @@ class GodotDAPClient {
24659
25028
  if (typeof threadId === "number") {
24660
25029
  this.lastThreadId = threadId;
24661
25030
  }
25031
+ this.stopped = true;
24662
25032
  return;
24663
25033
  }
24664
25034
  if (eventName === "terminated" || eventName === "exited") {
24665
25035
  this.attached = false;
25036
+ this.stopped = false;
24666
25037
  }
24667
25038
  }
24668
25039
  async initialize() {
@@ -24736,6 +25107,7 @@ class GodotDAPClient {
24736
25107
  await this.attach();
24737
25108
  const resolvedThreadId = await this.resolveThreadId(threadId);
24738
25109
  await this.sendRequest("continue", { threadId: resolvedThreadId });
25110
+ this.stopped = false;
24739
25111
  }
24740
25112
  async stepOver(threadId) {
24741
25113
  await this.attach();
@@ -24805,6 +25177,9 @@ class GodotDAPClient {
24805
25177
  isConnected() {
24806
25178
  return this.connected;
24807
25179
  }
25180
+ isStopped() {
25181
+ return this.stopped;
25182
+ }
24808
25183
  async resolveThreadId(threadId) {
24809
25184
  if (typeof threadId === "number" && threadId > 0) {
24810
25185
  this.lastThreadId = threadId;
@@ -24832,6 +25207,7 @@ class GodotDAPClient {
24832
25207
  this.connected = false;
24833
25208
  this.initialized = false;
24834
25209
  this.attached = false;
25210
+ this.stopped = false;
24835
25211
  this.reader = new FrameReader;
24836
25212
  socket?.destroy();
24837
25213
  this.failPendingRequests(new Error(`Godot DAP ${detail}. The connection was dropped.`));
@@ -24856,7 +25232,7 @@ async function handleDAPTool(client, toolName, args) {
24856
25232
  }
24857
25233
  case "dap_set_breakpoint": {
24858
25234
  if (typeof safeArgs.scriptPath !== "string" || typeof safeArgs.line !== "number") {
24859
- throw new Error("dap_set_breakpoint requires { scriptPath: string, line: number }");
25235
+ throw new Refusal("dap_set_breakpoint requires { scriptPath: string, line: number }");
24860
25236
  }
24861
25237
  const result = await client.setBreakpoint(safeArgs.scriptPath, safeArgs.line);
24862
25238
  return {
@@ -24865,7 +25241,7 @@ async function handleDAPTool(client, toolName, args) {
24865
25241
  }
24866
25242
  case "dap_remove_breakpoint": {
24867
25243
  if (typeof safeArgs.scriptPath !== "string" || typeof safeArgs.line !== "number") {
24868
- throw new Error("dap_remove_breakpoint requires { scriptPath: string, line: number }");
25244
+ throw new Refusal("dap_remove_breakpoint requires { scriptPath: string, line: number }");
24869
25245
  }
24870
25246
  const result = await client.removeBreakpoint(safeArgs.scriptPath, safeArgs.line);
24871
25247
  return {
@@ -24930,23 +25306,6 @@ function dictionary(entries) {
24930
25306
  return Object.assign(emptyRecord(), entries);
24931
25307
  }
24932
25308
 
24933
- // src/errors.ts
24934
- function errorMessage(error, fallback = "Unknown error") {
24935
- if (error instanceof Error) {
24936
- return error.message || fallback;
24937
- }
24938
- if (typeof error === "string") {
24939
- return error || fallback;
24940
- }
24941
- if (typeof error === "number" || typeof error === "boolean") {
24942
- return String(error);
24943
- }
24944
- return fallback;
24945
- }
24946
- function toError(error) {
24947
- return error instanceof Error ? error : new Error(errorMessage(error), { cause: error });
24948
- }
24949
-
24950
25309
  // src/game-log.ts
24951
25310
  import { StringDecoder } from "node:string_decoder";
24952
25311
  var HEADLINE = /^(USER )?(SCRIPT ERROR|ERROR|WARNING):\s?(.*)$/;
@@ -25350,7 +25709,7 @@ class GodotBridge extends EventEmitter {
25350
25709
  }
25351
25710
  sendMessage(message) {
25352
25711
  if (this.socket?.readyState !== import_websocket.default.OPEN) {
25353
- throw new Error("Godot is not connected");
25712
+ throw new Refusal("Godot is not connected");
25354
25713
  }
25355
25714
  this.socket.send(JSON.stringify(message));
25356
25715
  }
@@ -25458,28 +25817,28 @@ function getDefaultBridge() {
25458
25817
 
25459
25818
  // src/godot-path.ts
25460
25819
  import { execFile } from "node:child_process";
25461
- import { existsSync as existsSync2 } from "node:fs";
25820
+ import { existsSync as existsSync4 } from "node:fs";
25462
25821
  import { normalize } from "node:path";
25463
25822
  import { promisify } from "node:util";
25464
25823
 
25465
25824
  // src/detection.ts
25466
- import { existsSync, readdirSync, statSync } from "node:fs";
25467
- import { homedir } from "node:os";
25468
- import { join } from "node:path";
25825
+ import { existsSync as existsSync3, readdirSync as readdirSync2, statSync } from "node:fs";
25826
+ import { homedir as homedir2 } from "node:os";
25827
+ import { join as join3 } from "node:path";
25469
25828
  function resolveHomeDirectory() {
25470
25829
  try {
25471
- return homedir();
25830
+ return homedir2();
25472
25831
  } catch {
25473
25832
  return "";
25474
25833
  }
25475
25834
  }
25476
25835
  function scanDirectoryForGodotBinaries(directory, platform) {
25477
- if (!directory || !existsSync(directory)) {
25836
+ if (!directory || !existsSync3(directory)) {
25478
25837
  return [];
25479
25838
  }
25480
25839
  let entries;
25481
25840
  try {
25482
- entries = readdirSync(directory);
25841
+ entries = readdirSync2(directory);
25483
25842
  } catch {
25484
25843
  return [];
25485
25844
  }
@@ -25489,7 +25848,7 @@ function scanDirectoryForGodotBinaries(directory, platform) {
25489
25848
  if (!pattern.test(name)) {
25490
25849
  continue;
25491
25850
  }
25492
- const fullPath = join(directory, name);
25851
+ const fullPath = join3(directory, name);
25493
25852
  try {
25494
25853
  const stat = statSync(fullPath);
25495
25854
  if (stat.isFile()) {
@@ -25498,7 +25857,7 @@ function scanDirectoryForGodotBinaries(directory, platform) {
25498
25857
  } catch {}
25499
25858
  }
25500
25859
  matches.sort((a, b) => b.mtime - a.mtime);
25501
- return matches.map((m) => join(directory, m.name));
25860
+ return matches.map((m) => join3(directory, m.name));
25502
25861
  }
25503
25862
  function conventionalPaths(platform, home) {
25504
25863
  const paths = ["godot"];
@@ -25613,7 +25972,7 @@ class GodotLocator {
25613
25972
  return known;
25614
25973
  }
25615
25974
  let ok = false;
25616
- if (path === "godot" || existsSync2(path)) {
25975
+ if (path === "godot" || existsSync4(path)) {
25617
25976
  try {
25618
25977
  await run(path, ["--version"]);
25619
25978
  ok = true;
@@ -25628,9 +25987,9 @@ class GodotLocator {
25628
25987
 
25629
25988
  // src/headless.ts
25630
25989
  import { execFile as execFile2 } from "node:child_process";
25631
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
25632
- import { tmpdir } from "node:os";
25633
- import { join as join2 } from "node:path";
25990
+ import { mkdtempSync, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
25991
+ import { tmpdir as tmpdir2 } from "node:os";
25992
+ import { join as join4 } from "node:path";
25634
25993
  import { promisify as promisify2 } from "node:util";
25635
25994
  var run2 = promisify2(execFile2);
25636
25995
  function snakeCased(params) {
@@ -25671,9 +26030,9 @@ function reason(stdout, stderr) {
25671
26030
  return stdout.trim().split(/\r?\n/).at(-1) ?? "no output at all";
25672
26031
  }
25673
26032
  async function runOperation(engine, operation, params, projectPath) {
25674
- const paramsDir = mkdtempSync(join2(tmpdir(), "gdharness-params-"));
25675
- const paramsFile = join2(paramsDir, `${operation}.json`);
25676
- writeFileSync(paramsFile, JSON.stringify(snakeCased(params)), "utf8");
26033
+ const paramsDir = mkdtempSync(join4(tmpdir2(), "gdharness-params-"));
26034
+ const paramsFile = join4(paramsDir, `${operation}.json`);
26035
+ writeFileSync2(paramsFile, JSON.stringify(snakeCased(params)), "utf8");
25677
26036
  const args = [
25678
26037
  "--headless",
25679
26038
  "--path",
@@ -25716,6 +26075,105 @@ async function runOperation(engine, operation, params, projectPath) {
25716
26075
  return { ok: true, payload, messages: problems(stderr) };
25717
26076
  }
25718
26077
 
26078
+ // src/issues.ts
26079
+ import { createHash as createHash2 } from "node:crypto";
26080
+ import process4 from "node:process";
26081
+ var NEW_ISSUE2 = "https://github.com/Aureliolo/gdharness/issues/new";
26082
+ var ENHANCEMENT_URL2 = `${NEW_ISSUE2}?template=feature_request.md`;
26083
+ function feedbackNotice() {
26084
+ return JSON.stringify({
26085
+ gdharness_feedback: {
26086
+ what_to_do: "If this session has run into something gdharness cannot do, or a tool that should " + "exist and does not, tell the user what is missing and offer to open an enhancement " + "issue for them. Only if they say yes, and only for a real gap: a call you got wrong " + "is not one. If you cannot open an issue yourself, give them the link and what to " + "write in it.",
26087
+ enhancement_url: ENHANCEMENT_URL2
26088
+ }
26089
+ }, null, 2);
26090
+ }
26091
+ function runtime2() {
26092
+ const versions = process4.versions;
26093
+ const bun = versions["bun"];
26094
+ return bun === undefined ? `node ${process4.versions.node}` : `bun ${bun}`;
26095
+ }
26096
+ function defectSignature2(where, message) {
26097
+ const normalised = `${where}
26098
+ ${message}`.toLowerCase().replaceAll("\\", "/").replaceAll(/(?:[a-z]:)?(?:\/+[\w.-]+){2,}/gu, "<path>").replaceAll(/\d+/gu, "0");
26099
+ return createHash2("sha256").update(normalised).digest("hex").slice(0, 8);
26100
+ }
26101
+ function facts2(where, message, godotVersion) {
26102
+ return [
26103
+ ["Where", where],
26104
+ ["Error", message],
26105
+ ["Version", `gdharness ${SERVER_VERSION}`],
26106
+ ["Runtime", `${runtime2()}, ${process4.platform} ${process4.arch}`],
26107
+ ["Godot", godotVersion ?? "not known at the point this failed"],
26108
+ ["Signature", defectSignature2(where, message)]
26109
+ ];
26110
+ }
26111
+ function filledTemplate2(where, message, godotVersion) {
26112
+ return [
26113
+ `**gdharness version**: ${SERVER_VERSION}`,
26114
+ `**Godot version**: ${godotVersion ?? ""}`,
26115
+ `**Bun version**: ${runtime2()}`,
26116
+ `**OS**: ${process4.platform} ${process4.arch}`,
26117
+ "**MCP client**:",
26118
+ "",
26119
+ "## What you did",
26120
+ "",
26121
+ where,
26122
+ "",
26123
+ "## What you expected",
26124
+ "",
26125
+ "The call to answer, or to refuse with a reason.",
26126
+ "",
26127
+ "## What happened",
26128
+ "",
26129
+ "gdharness failed in a way it does not model.",
26130
+ "",
26131
+ "```text",
26132
+ message,
26133
+ "```",
26134
+ "",
26135
+ `Signature: \`${defectSignature2(where, message)}\``,
26136
+ "",
26137
+ "If the real cause turned out to be the project, the environment or the call, say so here:",
26138
+ "reaching you as a defect rather than as a refusal naming what would have worked is then the",
26139
+ "thing to fix.",
26140
+ "",
26141
+ "## Anything that makes it reproducible",
26142
+ ""
26143
+ ].join(`
26144
+ `);
26145
+ }
26146
+ function defectReport2(where, error, godotVersion) {
26147
+ const message = errorMessage(error);
26148
+ const title = `Defect ${defectSignature2(where, message)}: ${where}`;
26149
+ const url = `${NEW_ISSUE2}?template=bug_report.md&title=${encodeURIComponent(title)}` + `&body=${encodeURIComponent(filledTemplate2(where, message, godotVersion))}`;
26150
+ const width = Math.max(...facts2(where, message, godotVersion).map(([label]) => (label ?? "").length));
26151
+ return [
26152
+ "gdharness failed in a way it does not model. This is a defect in gdharness rather than",
26153
+ "anything about the call: the arguments did not cause it, and sending it again will not",
26154
+ "change it.",
26155
+ "",
26156
+ ...facts2(where, message, godotVersion).map(([label, value]) => ` ${(label ?? "").padEnd(width)} ${value ?? ""}`),
26157
+ "",
26158
+ "Do not open an issue on your own initiative. Tell the person whose machine this is what",
26159
+ "broke, and ask whether you may report it to gdharness on their behalf. What the report",
26160
+ "carries is the lines above and nothing else, and whether that goes into a public tracker",
26161
+ "is theirs to decide.",
26162
+ "",
26163
+ "If they say yes, this link carries the report already filled in:",
26164
+ url,
26165
+ "",
26166
+ "If they say yes and you have no way to open an issue yourself, give them that link and",
26167
+ "those lines, and stay with them while they file it.",
26168
+ "",
26169
+ "Worth reporting either way. If you can see that what actually went wrong was the project,",
26170
+ "the environment or the call, then this message is the defect: a failure gdharness knows",
26171
+ "about is meant to arrive as a refusal naming what would have worked, not as this. Say that",
26172
+ "in the report and it is the more useful of the two."
26173
+ ].join(`
26174
+ `);
26175
+ }
26176
+
25719
26177
  // src/junit.ts
25720
26178
  class MalformedReportError extends Error {
25721
26179
  constructor(message) {
@@ -26083,7 +26541,7 @@ class GodotLSPClient {
26083
26541
  async sendRequest(method, params) {
26084
26542
  await this.ensureConnected();
26085
26543
  if (!this.socket) {
26086
- throw new Error("Not connected to Godot LSP");
26544
+ throw new Refusal("Not connected to Godot LSP");
26087
26545
  }
26088
26546
  this.requestId += 1;
26089
26547
  const id = this.requestId;
@@ -26122,7 +26580,7 @@ class GodotLSPClient {
26122
26580
  }
26123
26581
  sendNotification(method, params) {
26124
26582
  if (!this.connected || !this.socket) {
26125
- throw new Error("Not connected to Godot LSP");
26583
+ throw new Refusal("Not connected to Godot LSP");
26126
26584
  }
26127
26585
  const payload = {
26128
26586
  jsonrpc: "2.0",
@@ -26372,36 +26830,36 @@ async function resolveLSPPaths(projectPathValue, scriptPathValue) {
26372
26830
  try {
26373
26831
  projectPath = await realpath(requestedProjectPath);
26374
26832
  } catch {
26375
- throw new Error(`Project path does not exist: ${requestedProjectPath}`);
26833
+ throw new Refusal(`Project path does not exist: ${requestedProjectPath}`);
26376
26834
  }
26377
26835
  const contained = resolveWithinProject(projectPath, scriptPathValue);
26378
26836
  if (!contained.ok) {
26379
- throw new Error(contained.reason);
26837
+ throw new Refusal(contained.reason);
26380
26838
  }
26381
26839
  let scriptPath;
26382
26840
  try {
26383
26841
  scriptPath = await realpath(contained.absolutePath);
26384
26842
  } catch {
26385
- throw new Error(`Script file does not exist: ${contained.absolutePath}`);
26843
+ throw new Refusal(`Script file does not exist: ${contained.absolutePath}`);
26386
26844
  }
26387
26845
  if (!isWithinRoot(projectPath, scriptPath)) {
26388
- throw new Error("scriptPath resolves outside the project root boundary.");
26846
+ throw new Refusal("scriptPath resolves outside the project root boundary.");
26389
26847
  }
26390
26848
  return { projectPath, scriptPath };
26391
26849
  }
26392
26850
  async function handleLSPTool(client, toolName, args) {
26393
26851
  try {
26394
26852
  if (!args || typeof args !== "object") {
26395
- throw new Error("Tool arguments must be an object.");
26853
+ throw new Refusal("Tool arguments must be an object.");
26396
26854
  }
26397
26855
  const parsedArgs = args;
26398
26856
  const projectPathValue = parsedArgs["projectPath"];
26399
26857
  const scriptPathValue = parsedArgs["scriptPath"];
26400
26858
  if (typeof projectPathValue !== "string" || projectPathValue.length === 0) {
26401
- throw new Error("Missing required argument: projectPath");
26859
+ throw new Refusal("Missing required argument: projectPath");
26402
26860
  }
26403
26861
  if (typeof scriptPathValue !== "string" || scriptPathValue.length === 0) {
26404
- throw new Error("Missing required argument: scriptPath");
26862
+ throw new Refusal("Missing required argument: scriptPath");
26405
26863
  }
26406
26864
  const { projectPath, scriptPath } = await resolveLSPPaths(projectPathValue, scriptPathValue);
26407
26865
  const content = await readFile(scriptPath, "utf8");
@@ -26415,7 +26873,7 @@ async function handleLSPTool(client, toolName, args) {
26415
26873
  const line = Number(parsedArgs["line"]);
26416
26874
  const character = Number(parsedArgs["character"]);
26417
26875
  if (!Number.isFinite(line) || !Number.isFinite(character)) {
26418
- throw new Error("Arguments line and character must be numbers.");
26876
+ throw new Refusal("Arguments line and character must be numbers.");
26419
26877
  }
26420
26878
  const completions = await client.getCompletions(scriptPath, content, line, character);
26421
26879
  return asToolResponse({ completions });
@@ -26424,7 +26882,7 @@ async function handleLSPTool(client, toolName, args) {
26424
26882
  const line = Number(parsedArgs["line"]);
26425
26883
  const character = Number(parsedArgs["character"]);
26426
26884
  if (!Number.isFinite(line) || !Number.isFinite(character)) {
26427
- throw new Error("Arguments line and character must be numbers.");
26885
+ throw new Refusal("Arguments line and character must be numbers.");
26428
26886
  }
26429
26887
  const hover = await client.getHover(scriptPath, content, line, character);
26430
26888
  return asToolResponse({ hover });
@@ -26444,19 +26902,19 @@ async function handleLSPTool(client, toolName, args) {
26444
26902
  }
26445
26903
 
26446
26904
  // src/project-scan.ts
26447
- import { readdirSync as readdirSync2, readFileSync } from "node:fs";
26448
- import { join as join3 } from "node:path";
26905
+ import { readdirSync as readdirSync3, readFileSync as readFileSync4 } from "node:fs";
26906
+ import { join as join5 } from "node:path";
26449
26907
  var SKIPPED = new Set([".git", ".godot", ".import", "node_modules"]);
26450
26908
  var ASSET_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "svg", "ttf", "otf", "wav", "mp3", "ogg"]);
26451
26909
  function projectStructure(projectPath) {
26452
26910
  const structure = { scenes: 0, scripts: 0, assets: 0, other: 0 };
26453
26911
  const visit = (directory) => {
26454
- for (const entry of readdirSync2(directory, { withFileTypes: true })) {
26912
+ for (const entry of readdirSync3(directory, { withFileTypes: true })) {
26455
26913
  if (entry.name.startsWith(".")) {
26456
26914
  continue;
26457
26915
  }
26458
26916
  if (entry.isDirectory()) {
26459
- visit(join3(directory, entry.name));
26917
+ visit(join5(directory, entry.name));
26460
26918
  } else if (entry.isFile()) {
26461
26919
  const extension = entry.name.split(".").pop()?.toLowerCase() ?? "";
26462
26920
  if (extension === "tscn") {
@@ -26491,14 +26949,14 @@ function searchProject(projectPath, options) {
26491
26949
  return false;
26492
26950
  };
26493
26951
  const visit = (directory) => {
26494
- for (const entry of readdirSync2(directory, { withFileTypes: true })) {
26952
+ for (const entry of readdirSync3(directory, { withFileTypes: true })) {
26495
26953
  if (full()) {
26496
26954
  return;
26497
26955
  }
26498
26956
  if (SKIPPED.has(entry.name)) {
26499
26957
  continue;
26500
26958
  }
26501
- const entryPath = join3(directory, entry.name);
26959
+ const entryPath = join5(directory, entry.name);
26502
26960
  if (entry.isDirectory()) {
26503
26961
  visit(entryPath);
26504
26962
  continue;
@@ -26509,7 +26967,7 @@ function searchProject(projectPath, options) {
26509
26967
  }
26510
26968
  result.summary.files_searched += 1;
26511
26969
  const matches = [];
26512
- for (const [index, line] of readFileSync(entryPath, "utf8").split(`
26970
+ for (const [index, line] of readFileSync4(entryPath, "utf8").split(`
26513
26971
  `).entries()) {
26514
26972
  if (full()) {
26515
26973
  break;
@@ -26532,7 +26990,7 @@ function searchProject(projectPath, options) {
26532
26990
  }
26533
26991
 
26534
26992
  // src/resources.ts
26535
- import { readFileSync as readFileSync2 } from "node:fs";
26993
+ import { readFileSync as readFileSync5 } from "node:fs";
26536
26994
  import { extname, resolve as resolve3 } from "node:path";
26537
26995
  var STATIC_RESOURCES = [
26538
26996
  {
@@ -26562,24 +27020,25 @@ var RESOURCE_TEMPLATES = [
26562
27020
  mimeType: "text/plain"
26563
27021
  }
26564
27022
  ];
27023
+ var RESOURCE_COUNT = STATIC_RESOURCES.length + RESOURCE_TEMPLATES.length;
26565
27024
  function ensureProjectPath(getProjectPath) {
26566
27025
  const projectPath = getProjectPath();
26567
27026
  if (!projectPath) {
26568
- throw new Error("Project path is not set. Set a Godot project path first.");
27027
+ throw new Refusal("Project path is not set. Set a Godot project path first.");
26569
27028
  }
26570
27029
  return resolve3(projectPath);
26571
27030
  }
26572
27031
  function uriPathToProjectPath(inputPath) {
26573
27032
  const normalized = inputPath.replace(/\\/g, "/").trim();
26574
27033
  if (normalized.replace(/\//g, "") === "") {
26575
- throw new Error("Resource path is empty.");
27034
+ throw new Refusal("Resource path is empty.");
26576
27035
  }
26577
27036
  return normalized.replace(/^\/+/, "");
26578
27037
  }
26579
27038
  function resolveProjectFile(projectPath, resourcePath) {
26580
27039
  const contained = resolveWithinProject(projectPath, resourcePath);
26581
27040
  if (!contained.ok) {
26582
- throw new Error(contained.reason);
27041
+ throw new Refusal(contained.reason);
26583
27042
  }
26584
27043
  return contained.absolutePath;
26585
27044
  }
@@ -26591,14 +27050,14 @@ function parseGodotUri(uri) {
26591
27050
  try {
26592
27051
  parsed = new URL(uri);
26593
27052
  } catch {
26594
- throw new Error(`Invalid URI: ${uri}`);
27053
+ throw new Refusal(`Invalid URI: ${uri}`);
26595
27054
  }
26596
27055
  if (parsed.protocol !== "godot:") {
26597
- throw new Error(`Unsupported URI scheme: ${parsed.protocol}`);
27056
+ throw new Refusal(`Unsupported URI scheme: ${parsed.protocol}`);
26598
27057
  }
26599
27058
  const host = parsed.hostname;
26600
27059
  if (host !== "scene" && host !== "script" && host !== "resource") {
26601
- throw new Error(`Unsupported Godot resource type: ${host}`);
27060
+ throw new Refusal(`Unsupported Godot resource type: ${host}`);
26602
27061
  }
26603
27062
  const resourcePath = uriPathToProjectPath(decodeURIComponent(parsed.pathname));
26604
27063
  return { kind: host, resourcePath };
@@ -26606,13 +27065,13 @@ function parseGodotUri(uri) {
26606
27065
  function ensureAllowedExtension(kind, filePath) {
26607
27066
  const extension = extname(filePath).toLowerCase();
26608
27067
  if (kind === "scene" && extension !== ".tscn") {
26609
- throw new Error("Scene resources must use .tscn extension.");
27068
+ throw new Refusal("Scene resources must use .tscn extension.");
26610
27069
  }
26611
27070
  if (kind === "script" && extension !== ".gd") {
26612
- throw new Error("Script resources must use .gd extension.");
27071
+ throw new Refusal("Script resources must use .gd extension.");
26613
27072
  }
26614
27073
  if (kind === "resource" && ![".tres", ".tscn", ".gd"].includes(extension)) {
26615
- throw new Error("Resource URIs support only .tres, .tscn, and .gd files.");
27074
+ throw new Refusal("Resource URIs support only .tres, .tscn, and .gd files.");
26616
27075
  }
26617
27076
  }
26618
27077
  function isValueComplete(value) {
@@ -26702,7 +27161,7 @@ function readResourceText(uri, getProjectPath) {
26702
27161
  const parsedUri = parseGodotUri(uri);
26703
27162
  if (parsedUri.kind === "project-info") {
26704
27163
  const projectFilePath = resolveProjectFile(projectPath, "project.godot");
26705
- const rawProject = readFileSync2(projectFilePath, "utf-8");
27164
+ const rawProject = readFileSync5(projectFilePath, "utf-8");
26706
27165
  const parsedProject = parseProjectGodot(rawProject);
26707
27166
  return {
26708
27167
  mimeType: "application/json",
@@ -26711,7 +27170,7 @@ function readResourceText(uri, getProjectPath) {
26711
27170
  }
26712
27171
  const filePath = resolveProjectFile(projectPath, parsedUri.resourcePath);
26713
27172
  ensureAllowedExtension(parsedUri.kind, filePath);
26714
- const text = readFileSync2(filePath, "utf-8");
27173
+ const text = readFileSync5(filePath, "utf-8");
26715
27174
  return {
26716
27175
  mimeType: parsedUri.kind === "script" ? "text/x-gdscript" : "text/plain",
26717
27176
  text
@@ -26740,18 +27199,18 @@ function setupResourceHandlers(mcp, getProjectPath) {
26740
27199
  };
26741
27200
  } catch (error) {
26742
27201
  if (error instanceof Error) {
26743
- throw new Error(`Failed to read resource '${uri}': ${error.message}`, { cause: error });
27202
+ throw new Refusal(`Failed to read resource '${uri}': ${error.message}`, { cause: error });
26744
27203
  }
26745
- throw new Error(`Failed to read resource '${uri}'.`, { cause: error });
27204
+ throw new Refusal(`Failed to read resource '${uri}'.`, { cause: error });
26746
27205
  }
26747
27206
  });
26748
27207
  }
26749
27208
 
26750
27209
  // src/runtime-client.ts
26751
- import { existsSync as existsSync3, readdirSync as readdirSync3, readFileSync as readFileSync3, unlinkSync } from "node:fs";
27210
+ import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync6, unlinkSync } from "node:fs";
26752
27211
  import { createConnection as createConnection3 } from "node:net";
26753
- import { tmpdir as tmpdir2 } from "node:os";
26754
- import { join as join4, resolve as resolve4 } from "node:path";
27212
+ import { tmpdir as tmpdir3 } from "node:os";
27213
+ import { join as join6, resolve as resolve4 } from "node:path";
26755
27214
 
26756
27215
  // src/tool-args.ts
26757
27216
  function asParams(value) {
@@ -26813,7 +27272,15 @@ function runtimeDirectory(variables = process.env) {
26813
27272
  return explicit;
26814
27273
  }
26815
27274
  const perUser = envValue("XDG_RUNTIME_DIR", variables);
26816
- return join4(perUser ?? tmpdir2(), "gdharness");
27275
+ return join6(perUser ?? tmpdir3(), "gdharness");
27276
+ }
27277
+ function runtimeDirectories(variables = process.env) {
27278
+ const candidates = [runtimeDirectory(variables)];
27279
+ const fallbacks = process.platform === "win32" ? [envValue("SystemRoot", variables) ?? envValue("windir", variables) ?? "C:\\Windows"] : ["/tmp", "/var/tmp"];
27280
+ for (const base of fallbacks) {
27281
+ candidates.push(join6(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
27282
+ }
27283
+ return [...new Set(candidates.map((path) => resolve4(path)))];
26817
27284
  }
26818
27285
  function processAlive(pid) {
26819
27286
  try {
@@ -26826,7 +27293,7 @@ function processAlive(pid) {
26826
27293
  function parseAnnouncement(file, pid) {
26827
27294
  let fields;
26828
27295
  try {
26829
- fields = asParams(JSON.parse(readFileSync3(file, "utf8")));
27296
+ fields = asParams(JSON.parse(readFileSync6(file, "utf8")));
26830
27297
  } catch {
26831
27298
  return null;
26832
27299
  }
@@ -26844,17 +27311,28 @@ function parseAnnouncement(file, pid) {
26844
27311
  file
26845
27312
  };
26846
27313
  }
26847
- function discoverRuntimes(directory = runtimeDirectory()) {
26848
- if (!existsSync3(directory)) {
27314
+ function discoverRuntimes(directories = runtimeDirectories()) {
27315
+ const found = new Map;
27316
+ for (const directory of directories) {
27317
+ for (const endpoint of announcedIn(directory)) {
27318
+ if (!found.has(endpoint.pid)) {
27319
+ found.set(endpoint.pid, endpoint);
27320
+ }
27321
+ }
27322
+ }
27323
+ return [...found.values()].sort((a, b) => b.pid - a.pid);
27324
+ }
27325
+ function announcedIn(directory) {
27326
+ if (!existsSync5(directory)) {
26849
27327
  return [];
26850
27328
  }
26851
27329
  const found = [];
26852
- for (const entry of readdirSync3(directory)) {
27330
+ for (const entry of readdirSync4(directory)) {
26853
27331
  const match = ANNOUNCEMENT_PATTERN.exec(entry);
26854
27332
  if (!match) {
26855
27333
  continue;
26856
27334
  }
26857
- const file = join4(directory, entry);
27335
+ const file = join6(directory, entry);
26858
27336
  const pid = Number.parseInt(match[1] ?? "", 10);
26859
27337
  const endpoint = processAlive(pid) ? parseAnnouncement(file, pid) : null;
26860
27338
  if (endpoint) {
@@ -26865,7 +27343,7 @@ function discoverRuntimes(directory = runtimeDirectory()) {
26865
27343
  } catch {}
26866
27344
  }
26867
27345
  }
26868
- return found.sort((a, b) => b.pid - a.pid);
27346
+ return found;
26869
27347
  }
26870
27348
  function describe2(endpoint) {
26871
27349
  return `pid ${endpoint.pid} on ${endpoint.address}:${endpoint.port} (${endpoint.project.name || "unnamed"} at ${endpoint.project.path})`;
@@ -26987,19 +27465,6 @@ function runtimeRequest(endpoint, command, params, timeoutMs) {
26987
27465
  });
26988
27466
  }
26989
27467
 
26990
- // src/server-version.ts
26991
- import { readFileSync as readFileSync4 } from "node:fs";
26992
- var DEBUG_MODE = process.env["DEBUG"] === "true";
26993
- var GODOT_DEBUG_MODE_DEFAULT = process.env["GODOT_DEBUG"] === "true" || DEBUG_MODE;
26994
- var SERVER_VERSION = (() => {
26995
- try {
26996
- const pkg = JSON.parse(readFileSync4(new URL("../package.json", import.meta.url), "utf8"));
26997
- return typeof pkg.version === "string" ? pkg.version : "0.0.0";
26998
- } catch {
26999
- return "0.0.0";
27000
- }
27001
- })();
27002
-
27003
27468
  // src/tool-definitions.ts
27004
27469
  var PROJECT_PATH = {
27005
27470
  type: "string",
@@ -27904,6 +28369,8 @@ function buildToolDefinitions() {
27904
28369
  }
27905
28370
 
27906
28371
  // src/server.ts
28372
+ var UPDATE_NOTICE_EVERY = 500;
28373
+ var FEEDBACK_NOTICE_EVERY = 250;
27907
28374
  var run3 = promisify3(execFile3);
27908
28375
  var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
27909
28376
  var EDITOR_RESTART_TIMEOUT_MS = 90000;
@@ -27982,11 +28449,11 @@ function realPathOr(path) {
27982
28449
  }
27983
28450
  }
27984
28451
  function hasMainScene(projectFile) {
27985
- const scene = parseProjectGodot(readFileSync5(projectFile, "utf8"))["application"]?.["run/main_scene"];
28452
+ const scene = parseProjectGodot(readFileSync7(projectFile, "utf8"))["application"]?.["run/main_scene"];
27986
28453
  return typeof scene === "string" && scene !== "";
27987
28454
  }
27988
28455
  function editorPlaysHeadless(projectFile) {
27989
- const runArgs = parseProjectGodot(readFileSync5(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
28456
+ const runArgs = parseProjectGodot(readFileSync7(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
27990
28457
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
27991
28458
  }
27992
28459
  function camelCased(params) {
@@ -28002,10 +28469,14 @@ function camelCased(params) {
28002
28469
  class GodotServer {
28003
28470
  mcp;
28004
28471
  locator = new GodotLocator;
28005
- operationsScript = join5(__dirname2, "godot", "operations", "godot_operations.gd");
28472
+ operationsScript = join7(__dirname2, "godot", "operations", "godot_operations.gd");
28006
28473
  godotBridge;
28007
28474
  tools = buildToolDefinitions();
28008
28475
  activeProcess = null;
28476
+ updates = new UpdateCheck(SERVER_VERSION);
28477
+ noticedUpdate = false;
28478
+ callsSinceNotice = 0;
28479
+ callsSinceFeedback = 0;
28009
28480
  lspClient = null;
28010
28481
  dapClient = null;
28011
28482
  bridgeStartupError = null;
@@ -28163,9 +28634,61 @@ class GodotServer {
28163
28634
  if (typeof args["projectPath"] === "string") {
28164
28635
  this.lastProjectPath = args["projectPath"];
28165
28636
  }
28166
- return await this.dispatch(spec.name, checked.op ?? "", args);
28637
+ this.updates.refresh();
28638
+ const answer = await this.answered(spec.name, checked.op ?? "", args);
28639
+ return this.withFeedbackNotice(this.withUpdateNotice(answer));
28167
28640
  });
28168
28641
  }
28642
+ async answered(tool, op, args) {
28643
+ try {
28644
+ return await this.dispatch(tool, op, args);
28645
+ } catch (error) {
28646
+ if (error instanceof McpError || error instanceof Refusal) {
28647
+ throw error;
28648
+ }
28649
+ const where = op === "" ? tool : `${tool} op=${op}`;
28650
+ console.error(`[SERVER] Unmodelled failure in ${where}:`, error);
28651
+ return { content: [{ type: "text", text: defectReport2(where, error) }], isError: true };
28652
+ }
28653
+ }
28654
+ withUpdateNotice(answer) {
28655
+ this.callsSinceNotice += 1;
28656
+ if (this.callsSinceNotice < UPDATE_NOTICE_EVERY && this.noticedUpdate) {
28657
+ return answer;
28658
+ }
28659
+ const notice = this.updates.notice();
28660
+ if (notice === null) {
28661
+ return answer;
28662
+ }
28663
+ this.noticedUpdate = true;
28664
+ this.callsSinceNotice = 0;
28665
+ return {
28666
+ ...answer,
28667
+ content: [
28668
+ ...answer.content,
28669
+ {
28670
+ type: "text",
28671
+ text: JSON.stringify({
28672
+ update_available: {
28673
+ ...notice,
28674
+ what_to_do: "Tell the user a newer gdharness is out, with what changed, and offer to take it. " + "Only run the upgrade command if they say yes: it restarts their editor and the " + "MCP server has to be reconnected afterwards."
28675
+ }
28676
+ }, null, 2)
28677
+ }
28678
+ ]
28679
+ };
28680
+ }
28681
+ withFeedbackNotice(answer) {
28682
+ this.callsSinceFeedback += 1;
28683
+ if (this.callsSinceFeedback < FEEDBACK_NOTICE_EVERY) {
28684
+ return answer;
28685
+ }
28686
+ this.callsSinceFeedback = 0;
28687
+ return {
28688
+ ...answer,
28689
+ content: [...answer.content, { type: "text", text: feedbackNotice() }]
28690
+ };
28691
+ }
28169
28692
  validateArguments(spec, args) {
28170
28693
  const known = new Set([...Object.keys(spec.parameters), ...spec.operations ? ["op"] : []]);
28171
28694
  const unknown = Object.keys(args).filter((key) => !known.has(key));
@@ -28371,9 +28894,16 @@ class GodotServer {
28371
28894
  });
28372
28895
  }
28373
28896
  case "debug_control":
28374
- return await this.handleDAP(`dap_${op}`, args);
28375
- case "debug_state":
28376
- return await this.handleDAP(DEBUG_STATE_CALLS[op] ?? "dap_get_stack_trace", args);
28897
+ case "debug_state": {
28898
+ if (tool === "debug_state" && op === "output") {
28899
+ return await this.handleDAP("dap_get_output", args);
28900
+ }
28901
+ const held = await this.debuggedGame();
28902
+ if (!held.ok) {
28903
+ return held.response;
28904
+ }
28905
+ return tool === "debug_control" ? await this.handleDAP(`dap_${op}`, args) : await this.handleDAP(DEBUG_STATE_CALLS[op] ?? "dap_get_stack_trace", args);
28906
+ }
28377
28907
  default:
28378
28908
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${tool}`);
28379
28909
  }
@@ -28393,8 +28923,8 @@ class GodotServer {
28393
28923
  if (path === undefined) {
28394
28924
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
28395
28925
  }
28396
- const file = join5(path, "project.godot");
28397
- if (!existsSync4(file)) {
28926
+ const file = join7(path, "project.godot");
28927
+ if (!existsSync6(file)) {
28398
28928
  return {
28399
28929
  ok: false,
28400
28930
  response: this.createErrorResponse(`Not a Godot project: ${path}`, [
@@ -28404,6 +28934,56 @@ class GodotServer {
28404
28934
  }
28405
28935
  return { ok: true, value: { path, file } };
28406
28936
  }
28937
+ async debuggedGame() {
28938
+ const game = this.activeProcess;
28939
+ if (game === null) {
28940
+ return {
28941
+ ok: false,
28942
+ response: this.createErrorResponse("No game is running, so there is no debug session to answer for.", ["Start one with editor_run start, which has the editor play it"])
28943
+ };
28944
+ }
28945
+ if (!game.throughEditor) {
28946
+ return {
28947
+ ok: false,
28948
+ response: this.createErrorResponse("The running game is its own process, so no debugger is holding it: breakpoints never hit and there is no stack to read.", [
28949
+ "editor_run start plays it through the open editor, whose debugger the debug_* tools speak to",
28950
+ "editor_status says whether an editor has reached this server"
28951
+ ])
28952
+ };
28953
+ }
28954
+ try {
28955
+ await this.dap().attach();
28956
+ } catch (error) {
28957
+ return {
28958
+ ok: false,
28959
+ response: this.createErrorResponse(`The editor is playing the game but its debug adapter did not answer: ${errorMessage(error)}`, [
28960
+ "Godot serves the debug adapter on 6006 unless --dap-port says otherwise",
28961
+ "GDHARNESS_DAP_PORT points this server at another one"
28962
+ ])
28963
+ };
28964
+ }
28965
+ if (!this.dap().isStopped()) {
28966
+ return {
28967
+ ok: false,
28968
+ response: this.createErrorResponse("The game is running, not stopped, so it has no stack and no scope to read.", [
28969
+ "debug_breakpoint set puts a breakpoint on before the run, and it is waiting when the game starts",
28970
+ "editor_output reads what the running game is printing"
28971
+ ])
28972
+ };
28973
+ }
28974
+ return { ok: true, value: game };
28975
+ }
28976
+ async refreshStaleClasses(projectPath) {
28977
+ const stale = staleClassNames(projectPath);
28978
+ if (stale.length === 0) {
28979
+ return { ok: true, value: [] };
28980
+ }
28981
+ const refreshed = await this.operation("refresh_class_cache", {}, projectPath);
28982
+ if (!refreshed.ok) {
28983
+ return { ok: false, response: this.answer(refreshed) };
28984
+ }
28985
+ return { ok: true, value: stale };
28986
+ }
28407
28987
  containProjectFiles(args) {
28408
28988
  const projectPath = readString(args, "projectPath");
28409
28989
  if (!projectPath) {
@@ -28489,7 +29069,7 @@ class GodotServer {
28489
29069
  if (!engine.ok) {
28490
29070
  return engine.response;
28491
29071
  }
28492
- const application = parseProjectGodot(readFileSync5(project.value.file, "utf8"))["application"] ?? {};
29072
+ const application = parseProjectGodot(readFileSync7(project.value.file, "utf8"))["application"] ?? {};
28493
29073
  const name = application["config/name"];
28494
29074
  const mainScene = application["run/main_scene"];
28495
29075
  const info = {
@@ -28578,7 +29158,7 @@ class GodotServer {
28578
29158
  log.finish();
28579
29159
  const problems = log.select({ severity: "warning", sinceLastCall: false, limit: 200 });
28580
29160
  const verdict = {
28581
- exported: exitCode === 0 && log.count("error") === 0 && existsSync4(output.absolutePath),
29161
+ exported: exitCode === 0 && log.count("error") === 0 && existsSync6(output.absolutePath),
28582
29162
  preset,
28583
29163
  outputPath: output.relativePath,
28584
29164
  debug,
@@ -28608,7 +29188,7 @@ class GodotServer {
28608
29188
  return contained.response;
28609
29189
  }
28610
29190
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
28611
- if (!existsSync4(join5(project.value.path, runner))) {
29191
+ if (!existsSync6(join7(project.value.path, runner))) {
28612
29192
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
28613
29193
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
28614
29194
  ]);
@@ -28656,14 +29236,14 @@ class GodotServer {
28656
29236
  resolve(false);
28657
29237
  });
28658
29238
  });
28659
- const reportsDir = join5(project.value.path, ".godot", "gdharness-reports");
29239
+ const reportsDir = join7(project.value.path, ".godot", "gdharness-reports");
28660
29240
  let report = null;
28661
29241
  let reportProblem = null;
28662
29242
  try {
28663
- const written = existsSync4(reportsDir) ? readdirSync4(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
29243
+ const written = existsSync6(reportsDir) ? readdirSync5(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
28664
29244
  const newest = written.at(-1);
28665
29245
  if (newest !== undefined) {
28666
- report = parseJUnit(readFileSync5(join5(reportsDir, newest, "results.xml"), "utf8"));
29246
+ report = parseJUnit(readFileSync7(join7(reportsDir, newest, "results.xml"), "utf8"));
28667
29247
  }
28668
29248
  } catch (error) {
28669
29249
  reportProblem = errorMessage(error);
@@ -28779,7 +29359,7 @@ class GodotServer {
28779
29359
  addonIsStale: status.connected ? stale : undefined,
28780
29360
  bridgeAvailable: this.bridgeStartupError === null,
28781
29361
  startupError: this.bridgeStartupError,
28782
- staleNote: stale ? `The editor is running the ${status.addonVersion === "" ? "addon from before versions were reported" : status.addonVersion} addon while this server ships ${SERVER_VERSION}. Restart it with editor_launch restart to pick the new one up.` : undefined,
29362
+ staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION) : undefined,
28783
29363
  note: isPortConflict ? "Bridge port is already in use. Another gdharness instance may own the editor bridge, so this server cannot report that editor connection." : undefined,
28784
29364
  suggestion: isPortConflict ? "Stop duplicate gdharness/MCP server instances or re-run the command from the same server process that owns the bridge port." : undefined
28785
29365
  };
@@ -28857,6 +29437,7 @@ class GodotServer {
28857
29437
  addonVersion: now.addonVersion,
28858
29438
  serverVersion: SERVER_VERSION,
28859
29439
  addonIsStale: now.addonVersion !== SERVER_VERSION,
29440
+ staleNote: addonMismatch(now.addonVersion, SERVER_VERSION),
28860
29441
  tookMs: Date.now() - began
28861
29442
  });
28862
29443
  }
@@ -28876,6 +29457,13 @@ class GodotServer {
28876
29457
  if (!project.ok) {
28877
29458
  return project.response;
28878
29459
  }
29460
+ if (this.godotBridge.isConnected()) {
29461
+ return this.createErrorResponse("An editor is already connected to this server, and a second one would take the language server and debug adapter ports from it.", [
29462
+ "editor_status says which editor is answering, and for which project",
29463
+ "editor_launch restart replaces the connected editor rather than joining it",
29464
+ "Close the open editor first if the new project is the one you want"
29465
+ ]);
29466
+ }
28879
29467
  const engine = await this.engine();
28880
29468
  if (!engine.ok) {
28881
29469
  return engine.response;
@@ -28883,7 +29471,8 @@ class GodotServer {
28883
29471
  this.logDebug(`Launching Godot editor for project: ${project.value.path}`);
28884
29472
  const editor = spawn(engine.value, editorArguments(project.value.path), {
28885
29473
  stdio: "ignore",
28886
- detached: true
29474
+ detached: true,
29475
+ env: { ...process.env, GDHARNESS_RUNTIME_DIR: runtimeDirectory() }
28887
29476
  });
28888
29477
  const started = await new Promise((resolve) => {
28889
29478
  editor.once("spawn", () => {
@@ -28923,6 +29512,10 @@ class GodotServer {
28923
29512
  if (!engine.ok) {
28924
29513
  return engine.response;
28925
29514
  }
29515
+ const refreshed = await this.refreshStaleClasses(project.value.path);
29516
+ if (!refreshed.ok) {
29517
+ return refreshed.response;
29518
+ }
28926
29519
  const sceneArgument = sceneToRun?.ok ? sceneToRun.relativePath : null;
28927
29520
  if (op === "check") {
28928
29521
  return await this.checkBoot(engine.value, project.value.path, sceneArgument, args);
@@ -28936,7 +29529,7 @@ class GodotServer {
28936
29529
  variables: process.env
28937
29530
  });
28938
29531
  if (this.godotBridge.isConnected() && (!headless || editorPlaysHeadless(project.value.file))) {
28939
- return await this.playThroughEditor(sceneArgument);
29532
+ return await this.playThroughEditor(sceneArgument, refreshed.value);
28940
29533
  }
28941
29534
  const cmdArgs = runArguments({
28942
29535
  projectPath: project.value.path,
@@ -28956,10 +29549,11 @@ class GodotServer {
28956
29549
  through: "gdharness",
28957
29550
  pid: started.process.pid ?? null,
28958
29551
  arguments: cmdArgs,
29552
+ refreshedClasses: refreshed.value,
28959
29553
  message: "Use editor_output for what it prints and editor_run stop to end it."
28960
29554
  });
28961
29555
  }
28962
- async playThroughEditor(scene) {
29556
+ async playThroughEditor(scene, refreshedClasses) {
28963
29557
  const log = new GameLog;
28964
29558
  try {
28965
29559
  await this.dap().connect();
@@ -28985,6 +29579,7 @@ class GodotServer {
28985
29579
  started: true,
28986
29580
  through: "editor",
28987
29581
  scene: scene === null ? "the main scene" : `res://${scene}`,
29582
+ refreshedClasses,
28988
29583
  message: "The editor is playing it, so its debugger holds it: the debug_* tools can reach it, " + "editor_output reads its console through the debug adapter, and editor_run stop ends it."
28989
29584
  });
28990
29585
  }
@@ -29159,8 +29754,8 @@ class GodotServer {
29159
29754
  return this.createErrorResponse(choice.problem);
29160
29755
  }
29161
29756
  const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
29162
- const screenshotDir = expectsScreenshot ? mkdtempSync2(join5(tmpdir3(), "gdharness-runtime-screenshot-")) : null;
29163
- const screenshotPath = screenshotDir ? join5(screenshotDir, "capture.png") : null;
29757
+ const screenshotDir = expectsScreenshot ? mkdtempSync2(join7(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
29758
+ const screenshotPath = screenshotDir ? join7(screenshotDir, "capture.png") : null;
29164
29759
  try {
29165
29760
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
29166
29761
  if (!reply.ok) {
@@ -29181,7 +29776,7 @@ class GodotServer {
29181
29776
  return {
29182
29777
  content: [
29183
29778
  { type: "text", text: `Screenshot captured: ${dimensions}` },
29184
- { type: "image", data: readFileSync5(screenshotPath).toString("base64"), mimeType: "image/png" }
29779
+ { type: "image", data: readFileSync7(screenshotPath).toString("base64"), mimeType: "image/png" }
29185
29780
  ]
29186
29781
  };
29187
29782
  } finally {
@@ -29246,7 +29841,6 @@ async function runGodotServer() {
29246
29841
 
29247
29842
  // src/server-entry.ts
29248
29843
  await runGodotServer().catch((error) => {
29249
- const errorMessage = error instanceof Error ? error.message : "Unknown error";
29250
- console.error("Failed to run server:", errorMessage);
29844
+ console.error(defectReport("gdharness server startup", error));
29251
29845
  process.exit(1);
29252
29846
  });