gdharness 0.4.2 → 0.5.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/build/index.js CHANGED
@@ -9802,11 +9802,136 @@ 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 } from "node:fs";
9830
+ var DEBUG_MODE = process.env["DEBUG"] === "true";
9831
+ var GODOT_DEBUG_MODE_DEFAULT = process.env["GODOT_DEBUG"] === "true" || DEBUG_MODE;
9832
+ var SERVER_VERSION = (() => {
9833
+ try {
9834
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
9835
+ return typeof pkg.version === "string" ? pkg.version : "0.0.0";
9836
+ } catch {
9837
+ return "0.0.0";
9838
+ }
9839
+ })();
9840
+
9841
+ // src/issues.ts
9842
+ var NEW_ISSUE = "https://github.com/Aureliolo/gdharness/issues/new";
9843
+ var ENHANCEMENT_URL = `${NEW_ISSUE}?template=feature_request.md`;
9844
+ function runtime() {
9845
+ const versions = process2.versions;
9846
+ const bun = versions["bun"];
9847
+ return bun === undefined ? `node ${process2.versions.node}` : `bun ${bun}`;
9848
+ }
9849
+ function defectSignature(where, message) {
9850
+ const normalised = `${where}
9851
+ ${message}`.toLowerCase().replaceAll("\\", "/").replaceAll(/(?:[a-z]:)?(?:\/+[\w.-]+){2,}/gu, "<path>").replaceAll(/\d+/gu, "0");
9852
+ return createHash("sha256").update(normalised).digest("hex").slice(0, 8);
9853
+ }
9854
+ function facts(where, message, godotVersion) {
9855
+ return [
9856
+ ["Where", where],
9857
+ ["Error", message],
9858
+ ["Version", `gdharness ${SERVER_VERSION}`],
9859
+ ["Runtime", `${runtime()}, ${process2.platform} ${process2.arch}`],
9860
+ ["Godot", godotVersion ?? "not known at the point this failed"],
9861
+ ["Signature", defectSignature(where, message)]
9862
+ ];
9863
+ }
9864
+ function filledTemplate(where, message, godotVersion) {
9865
+ return [
9866
+ `**gdharness version**: ${SERVER_VERSION}`,
9867
+ `**Godot version**: ${godotVersion ?? ""}`,
9868
+ `**Bun version**: ${runtime()}`,
9869
+ `**OS**: ${process2.platform} ${process2.arch}`,
9870
+ "**MCP client**:",
9871
+ "",
9872
+ "## What you did",
9873
+ "",
9874
+ where,
9875
+ "",
9876
+ "## What you expected",
9877
+ "",
9878
+ "The call to answer, or to refuse with a reason.",
9879
+ "",
9880
+ "## What happened",
9881
+ "",
9882
+ "gdharness failed in a way it does not model.",
9883
+ "",
9884
+ "```text",
9885
+ message,
9886
+ "```",
9887
+ "",
9888
+ `Signature: \`${defectSignature(where, message)}\``,
9889
+ "",
9890
+ "If the real cause turned out to be the project, the environment or the call, say so here:",
9891
+ "reaching you as a defect rather than as a refusal naming what would have worked is then the",
9892
+ "thing to fix.",
9893
+ "",
9894
+ "## Anything that makes it reproducible",
9895
+ ""
9896
+ ].join(`
9897
+ `);
9898
+ }
9899
+ function defectReport(where, error, godotVersion) {
9900
+ const message = errorMessage(error);
9901
+ const title = `Defect ${defectSignature(where, message)}: ${where}`;
9902
+ const url = `${NEW_ISSUE}?template=bug_report.md&title=${encodeURIComponent(title)}` + `&body=${encodeURIComponent(filledTemplate(where, message, godotVersion))}`;
9903
+ const width = Math.max(...facts(where, message, godotVersion).map(([label]) => (label ?? "").length));
9904
+ return [
9905
+ "gdharness failed in a way it does not model. This is a defect in gdharness rather than",
9906
+ "anything about the call: the arguments did not cause it, and sending it again will not",
9907
+ "change it.",
9908
+ "",
9909
+ ...facts(where, message, godotVersion).map(([label, value]) => ` ${(label ?? "").padEnd(width)} ${value ?? ""}`),
9910
+ "",
9911
+ "Do not open an issue on your own initiative. Tell the person whose machine this is what",
9912
+ "broke, and ask whether you may report it to gdharness on their behalf. What the report",
9913
+ "carries is the lines above and nothing else, and whether that goes into a public tracker",
9914
+ "is theirs to decide.",
9915
+ "",
9916
+ "If they say yes, this link carries the report already filled in:",
9917
+ url,
9918
+ "",
9919
+ "If they say yes and you have no way to open an issue yourself, give them that link and",
9920
+ "those lines, and stay with them while they file it.",
9921
+ "",
9922
+ "Worth reporting either way. If you can see that what actually went wrong was the project,",
9923
+ "the environment or the call, then this message is the defect: a failure gdharness knows",
9924
+ "about is meant to arrive as a refusal naming what would have worked, not as this. Say that",
9925
+ "in the report and it is the more useful of the two."
9926
+ ].join(`
9927
+ `);
9928
+ }
9929
+
9805
9930
  // src/server.ts
9806
9931
  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";
9932
+ import { existsSync as existsSync6, mkdtempSync as mkdtempSync2, readdirSync as readdirSync5, readFileSync as readFileSync7, realpathSync as realpathSync2, rmSync as rmSync2 } from "node:fs";
9933
+ import { tmpdir as tmpdir4 } from "node:os";
9934
+ import { basename, dirname as dirname2, join as join7, normalize as normalize2 } from "node:path";
9810
9935
  import { setTimeout as delay2 } from "node:timers/promises";
9811
9936
  import { fileURLToPath as fileURLToPath2 } from "node:url";
9812
9937
  import { promisify as promisify3 } from "node:util";
@@ -24269,7 +24394,7 @@ var EMPTY_COMPLETION_RESULT = {
24269
24394
  };
24270
24395
 
24271
24396
  // node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js
24272
- import process2 from "node:process";
24397
+ import process3 from "node:process";
24273
24398
 
24274
24399
  // node_modules/@modelcontextprotocol/sdk/dist/esm/shared/stdio.js
24275
24400
  var STDIO_DEFAULT_MAX_BUFFER_SIZE = 10 * 1024 * 1024;
@@ -24313,7 +24438,7 @@ function serializeMessage(message) {
24313
24438
 
24314
24439
  // node_modules/@modelcontextprotocol/sdk/dist/esm/server/stdio.js
24315
24440
  class StdioServerTransport {
24316
- constructor(_stdin = process2.stdin, _stdout = process2.stdout, options) {
24441
+ constructor(_stdin = process3.stdin, _stdout = process3.stdout, options) {
24317
24442
  this._stdin = _stdin;
24318
24443
  this._stdout = _stdout;
24319
24444
  this._started = false;
@@ -24374,6 +24499,50 @@ class StdioServerTransport {
24374
24499
  }
24375
24500
  }
24376
24501
 
24502
+ // src/class-cache.ts
24503
+ import { existsSync, readdirSync, readFileSync as readFileSync2 } from "node:fs";
24504
+ import { join } from "node:path";
24505
+ function declaredClasses(projectPath) {
24506
+ const declared = new Map;
24507
+ const visit = (directory, prefix) => {
24508
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
24509
+ if (entry.name.startsWith(".")) {
24510
+ continue;
24511
+ }
24512
+ const path = join(directory, entry.name);
24513
+ if (entry.isDirectory()) {
24514
+ visit(path, `${prefix}${entry.name}/`);
24515
+ } else if (entry.isFile() && entry.name.endsWith(".gd")) {
24516
+ const found = /^class_name\s+([A-Za-z_][A-Za-z0-9_]*)/m.exec(readFileSync2(path, "utf8"));
24517
+ if (found?.[1]) {
24518
+ declared.set(found[1], `res://${prefix}${entry.name}`);
24519
+ }
24520
+ }
24521
+ }
24522
+ };
24523
+ visit(projectPath, "");
24524
+ return declared;
24525
+ }
24526
+ function cachedClasses(projectPath) {
24527
+ const cache = join(projectPath, ".godot", "global_script_class_cache.cfg");
24528
+ if (!existsSync(cache)) {
24529
+ return null;
24530
+ }
24531
+ const listed = new Map;
24532
+ const text = readFileSync2(cache, "utf8");
24533
+ for (const entry of text.matchAll(/"class":\s*&"([^"]+)"[\s\S]*?"path":\s*"([^"]+)"/g)) {
24534
+ listed.set(entry[1] ?? "", entry[2] ?? "");
24535
+ }
24536
+ return listed;
24537
+ }
24538
+ function staleAgainst(cached, projectPath) {
24539
+ return [...declaredClasses(projectPath)].filter(([name, path]) => cached.get(name) !== path).map(([name]) => name);
24540
+ }
24541
+ function staleClassNames(projectPath) {
24542
+ const cached = cachedClasses(projectPath);
24543
+ return cached === null ? [...declaredClasses(projectPath).keys()] : staleAgainst(cached, projectPath);
24544
+ }
24545
+
24377
24546
  // src/dap_client.ts
24378
24547
  import { createConnection } from "node:net";
24379
24548
  import { setTimeout as delay } from "node:timers/promises";
@@ -24464,7 +24633,7 @@ function portFromEnv(variable, fallback) {
24464
24633
  }
24465
24634
  const parsed = Number.parseInt(raw, 10);
24466
24635
  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.`);
24636
+ throw new Refusal(`${variable} is "${raw}", not a port between 1 and 65535.`);
24468
24637
  }
24469
24638
  return parsed;
24470
24639
  }
@@ -24488,6 +24657,7 @@ class GodotDAPClient {
24488
24657
  initialized = false;
24489
24658
  attached = false;
24490
24659
  lastThreadId = 1;
24660
+ stopped = false;
24491
24661
  breakpoints = new Map;
24492
24662
  constructor(port = portFromEnv("GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT), host = "127.0.0.1") {
24493
24663
  this.port = port;
@@ -24536,6 +24706,7 @@ class GodotDAPClient {
24536
24706
  this.connected = false;
24537
24707
  this.initialized = false;
24538
24708
  this.attached = false;
24709
+ this.stopped = false;
24539
24710
  this.socket = null;
24540
24711
  this.failPendingRequests(new Error("DAP connection closed"));
24541
24712
  });
@@ -24555,6 +24726,7 @@ class GodotDAPClient {
24555
24726
  this.connected = false;
24556
24727
  this.initialized = false;
24557
24728
  this.attached = false;
24729
+ this.stopped = false;
24558
24730
  return;
24559
24731
  }
24560
24732
  if (this.connected) {
@@ -24583,6 +24755,7 @@ class GodotDAPClient {
24583
24755
  this.connected = false;
24584
24756
  this.initialized = false;
24585
24757
  this.attached = false;
24758
+ this.stopped = false;
24586
24759
  }
24587
24760
  async ensureConnected() {
24588
24761
  if (!this.connected) {
@@ -24592,7 +24765,7 @@ class GodotDAPClient {
24592
24765
  async sendRequest(command, args, timeoutMs = DAP_REQUEST_TIMEOUT_MS) {
24593
24766
  await this.ensureConnected();
24594
24767
  if (!this.socket) {
24595
- throw new Error("DAP socket is not available");
24768
+ throw new Refusal("DAP socket is not available");
24596
24769
  }
24597
24770
  const requestSeq = this.seq++;
24598
24771
  const request = {
@@ -24659,10 +24832,12 @@ class GodotDAPClient {
24659
24832
  if (typeof threadId === "number") {
24660
24833
  this.lastThreadId = threadId;
24661
24834
  }
24835
+ this.stopped = true;
24662
24836
  return;
24663
24837
  }
24664
24838
  if (eventName === "terminated" || eventName === "exited") {
24665
24839
  this.attached = false;
24840
+ this.stopped = false;
24666
24841
  }
24667
24842
  }
24668
24843
  async initialize() {
@@ -24736,6 +24911,7 @@ class GodotDAPClient {
24736
24911
  await this.attach();
24737
24912
  const resolvedThreadId = await this.resolveThreadId(threadId);
24738
24913
  await this.sendRequest("continue", { threadId: resolvedThreadId });
24914
+ this.stopped = false;
24739
24915
  }
24740
24916
  async stepOver(threadId) {
24741
24917
  await this.attach();
@@ -24805,6 +24981,9 @@ class GodotDAPClient {
24805
24981
  isConnected() {
24806
24982
  return this.connected;
24807
24983
  }
24984
+ isStopped() {
24985
+ return this.stopped;
24986
+ }
24808
24987
  async resolveThreadId(threadId) {
24809
24988
  if (typeof threadId === "number" && threadId > 0) {
24810
24989
  this.lastThreadId = threadId;
@@ -24832,6 +25011,7 @@ class GodotDAPClient {
24832
25011
  this.connected = false;
24833
25012
  this.initialized = false;
24834
25013
  this.attached = false;
25014
+ this.stopped = false;
24835
25015
  this.reader = new FrameReader;
24836
25016
  socket?.destroy();
24837
25017
  this.failPendingRequests(new Error(`Godot DAP ${detail}. The connection was dropped.`));
@@ -24856,7 +25036,7 @@ async function handleDAPTool(client, toolName, args) {
24856
25036
  }
24857
25037
  case "dap_set_breakpoint": {
24858
25038
  if (typeof safeArgs.scriptPath !== "string" || typeof safeArgs.line !== "number") {
24859
- throw new Error("dap_set_breakpoint requires { scriptPath: string, line: number }");
25039
+ throw new Refusal("dap_set_breakpoint requires { scriptPath: string, line: number }");
24860
25040
  }
24861
25041
  const result = await client.setBreakpoint(safeArgs.scriptPath, safeArgs.line);
24862
25042
  return {
@@ -24865,7 +25045,7 @@ async function handleDAPTool(client, toolName, args) {
24865
25045
  }
24866
25046
  case "dap_remove_breakpoint": {
24867
25047
  if (typeof safeArgs.scriptPath !== "string" || typeof safeArgs.line !== "number") {
24868
- throw new Error("dap_remove_breakpoint requires { scriptPath: string, line: number }");
25048
+ throw new Refusal("dap_remove_breakpoint requires { scriptPath: string, line: number }");
24869
25049
  }
24870
25050
  const result = await client.removeBreakpoint(safeArgs.scriptPath, safeArgs.line);
24871
25051
  return {
@@ -24930,23 +25110,6 @@ function dictionary(entries) {
24930
25110
  return Object.assign(emptyRecord(), entries);
24931
25111
  }
24932
25112
 
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
25113
  // src/game-log.ts
24951
25114
  import { StringDecoder } from "node:string_decoder";
24952
25115
  var HEADLINE = /^(USER )?(SCRIPT ERROR|ERROR|WARNING):\s?(.*)$/;
@@ -25350,7 +25513,7 @@ class GodotBridge extends EventEmitter {
25350
25513
  }
25351
25514
  sendMessage(message) {
25352
25515
  if (this.socket?.readyState !== import_websocket.default.OPEN) {
25353
- throw new Error("Godot is not connected");
25516
+ throw new Refusal("Godot is not connected");
25354
25517
  }
25355
25518
  this.socket.send(JSON.stringify(message));
25356
25519
  }
@@ -25458,14 +25621,14 @@ function getDefaultBridge() {
25458
25621
 
25459
25622
  // src/godot-path.ts
25460
25623
  import { execFile } from "node:child_process";
25461
- import { existsSync as existsSync2 } from "node:fs";
25624
+ import { existsSync as existsSync3 } from "node:fs";
25462
25625
  import { normalize } from "node:path";
25463
25626
  import { promisify } from "node:util";
25464
25627
 
25465
25628
  // src/detection.ts
25466
- import { existsSync, readdirSync, statSync } from "node:fs";
25629
+ import { existsSync as existsSync2, readdirSync as readdirSync2, statSync } from "node:fs";
25467
25630
  import { homedir } from "node:os";
25468
- import { join } from "node:path";
25631
+ import { join as join2 } from "node:path";
25469
25632
  function resolveHomeDirectory() {
25470
25633
  try {
25471
25634
  return homedir();
@@ -25474,12 +25637,12 @@ function resolveHomeDirectory() {
25474
25637
  }
25475
25638
  }
25476
25639
  function scanDirectoryForGodotBinaries(directory, platform) {
25477
- if (!directory || !existsSync(directory)) {
25640
+ if (!directory || !existsSync2(directory)) {
25478
25641
  return [];
25479
25642
  }
25480
25643
  let entries;
25481
25644
  try {
25482
- entries = readdirSync(directory);
25645
+ entries = readdirSync2(directory);
25483
25646
  } catch {
25484
25647
  return [];
25485
25648
  }
@@ -25489,7 +25652,7 @@ function scanDirectoryForGodotBinaries(directory, platform) {
25489
25652
  if (!pattern.test(name)) {
25490
25653
  continue;
25491
25654
  }
25492
- const fullPath = join(directory, name);
25655
+ const fullPath = join2(directory, name);
25493
25656
  try {
25494
25657
  const stat = statSync(fullPath);
25495
25658
  if (stat.isFile()) {
@@ -25498,7 +25661,7 @@ function scanDirectoryForGodotBinaries(directory, platform) {
25498
25661
  } catch {}
25499
25662
  }
25500
25663
  matches.sort((a, b) => b.mtime - a.mtime);
25501
- return matches.map((m) => join(directory, m.name));
25664
+ return matches.map((m) => join2(directory, m.name));
25502
25665
  }
25503
25666
  function conventionalPaths(platform, home) {
25504
25667
  const paths = ["godot"];
@@ -25613,7 +25776,7 @@ class GodotLocator {
25613
25776
  return known;
25614
25777
  }
25615
25778
  let ok = false;
25616
- if (path === "godot" || existsSync2(path)) {
25779
+ if (path === "godot" || existsSync3(path)) {
25617
25780
  try {
25618
25781
  await run(path, ["--version"]);
25619
25782
  ok = true;
@@ -25630,7 +25793,7 @@ class GodotLocator {
25630
25793
  import { execFile as execFile2 } from "node:child_process";
25631
25794
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
25632
25795
  import { tmpdir } from "node:os";
25633
- import { join as join2 } from "node:path";
25796
+ import { join as join3 } from "node:path";
25634
25797
  import { promisify as promisify2 } from "node:util";
25635
25798
  var run2 = promisify2(execFile2);
25636
25799
  function snakeCased(params) {
@@ -25671,8 +25834,8 @@ function reason(stdout, stderr) {
25671
25834
  return stdout.trim().split(/\r?\n/).at(-1) ?? "no output at all";
25672
25835
  }
25673
25836
  async function runOperation(engine, operation, params, projectPath) {
25674
- const paramsDir = mkdtempSync(join2(tmpdir(), "gdharness-params-"));
25675
- const paramsFile = join2(paramsDir, `${operation}.json`);
25837
+ const paramsDir = mkdtempSync(join3(tmpdir(), "gdharness-params-"));
25838
+ const paramsFile = join3(paramsDir, `${operation}.json`);
25676
25839
  writeFileSync(paramsFile, JSON.stringify(snakeCased(params)), "utf8");
25677
25840
  const args = [
25678
25841
  "--headless",
@@ -25716,6 +25879,105 @@ async function runOperation(engine, operation, params, projectPath) {
25716
25879
  return { ok: true, payload, messages: problems(stderr) };
25717
25880
  }
25718
25881
 
25882
+ // src/issues.ts
25883
+ import { createHash as createHash2 } from "node:crypto";
25884
+ import process4 from "node:process";
25885
+ var NEW_ISSUE2 = "https://github.com/Aureliolo/gdharness/issues/new";
25886
+ var ENHANCEMENT_URL2 = `${NEW_ISSUE2}?template=feature_request.md`;
25887
+ function feedbackNotice() {
25888
+ return JSON.stringify({
25889
+ gdharness_feedback: {
25890
+ 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.",
25891
+ enhancement_url: ENHANCEMENT_URL2
25892
+ }
25893
+ }, null, 2);
25894
+ }
25895
+ function runtime2() {
25896
+ const versions = process4.versions;
25897
+ const bun = versions["bun"];
25898
+ return bun === undefined ? `node ${process4.versions.node}` : `bun ${bun}`;
25899
+ }
25900
+ function defectSignature2(where, message) {
25901
+ const normalised = `${where}
25902
+ ${message}`.toLowerCase().replaceAll("\\", "/").replaceAll(/(?:[a-z]:)?(?:\/+[\w.-]+){2,}/gu, "<path>").replaceAll(/\d+/gu, "0");
25903
+ return createHash2("sha256").update(normalised).digest("hex").slice(0, 8);
25904
+ }
25905
+ function facts2(where, message, godotVersion) {
25906
+ return [
25907
+ ["Where", where],
25908
+ ["Error", message],
25909
+ ["Version", `gdharness ${SERVER_VERSION}`],
25910
+ ["Runtime", `${runtime2()}, ${process4.platform} ${process4.arch}`],
25911
+ ["Godot", godotVersion ?? "not known at the point this failed"],
25912
+ ["Signature", defectSignature2(where, message)]
25913
+ ];
25914
+ }
25915
+ function filledTemplate2(where, message, godotVersion) {
25916
+ return [
25917
+ `**gdharness version**: ${SERVER_VERSION}`,
25918
+ `**Godot version**: ${godotVersion ?? ""}`,
25919
+ `**Bun version**: ${runtime2()}`,
25920
+ `**OS**: ${process4.platform} ${process4.arch}`,
25921
+ "**MCP client**:",
25922
+ "",
25923
+ "## What you did",
25924
+ "",
25925
+ where,
25926
+ "",
25927
+ "## What you expected",
25928
+ "",
25929
+ "The call to answer, or to refuse with a reason.",
25930
+ "",
25931
+ "## What happened",
25932
+ "",
25933
+ "gdharness failed in a way it does not model.",
25934
+ "",
25935
+ "```text",
25936
+ message,
25937
+ "```",
25938
+ "",
25939
+ `Signature: \`${defectSignature2(where, message)}\``,
25940
+ "",
25941
+ "If the real cause turned out to be the project, the environment or the call, say so here:",
25942
+ "reaching you as a defect rather than as a refusal naming what would have worked is then the",
25943
+ "thing to fix.",
25944
+ "",
25945
+ "## Anything that makes it reproducible",
25946
+ ""
25947
+ ].join(`
25948
+ `);
25949
+ }
25950
+ function defectReport2(where, error, godotVersion) {
25951
+ const message = errorMessage(error);
25952
+ const title = `Defect ${defectSignature2(where, message)}: ${where}`;
25953
+ const url = `${NEW_ISSUE2}?template=bug_report.md&title=${encodeURIComponent(title)}` + `&body=${encodeURIComponent(filledTemplate2(where, message, godotVersion))}`;
25954
+ const width = Math.max(...facts2(where, message, godotVersion).map(([label]) => (label ?? "").length));
25955
+ return [
25956
+ "gdharness failed in a way it does not model. This is a defect in gdharness rather than",
25957
+ "anything about the call: the arguments did not cause it, and sending it again will not",
25958
+ "change it.",
25959
+ "",
25960
+ ...facts2(where, message, godotVersion).map(([label, value]) => ` ${(label ?? "").padEnd(width)} ${value ?? ""}`),
25961
+ "",
25962
+ "Do not open an issue on your own initiative. Tell the person whose machine this is what",
25963
+ "broke, and ask whether you may report it to gdharness on their behalf. What the report",
25964
+ "carries is the lines above and nothing else, and whether that goes into a public tracker",
25965
+ "is theirs to decide.",
25966
+ "",
25967
+ "If they say yes, this link carries the report already filled in:",
25968
+ url,
25969
+ "",
25970
+ "If they say yes and you have no way to open an issue yourself, give them that link and",
25971
+ "those lines, and stay with them while they file it.",
25972
+ "",
25973
+ "Worth reporting either way. If you can see that what actually went wrong was the project,",
25974
+ "the environment or the call, then this message is the defect: a failure gdharness knows",
25975
+ "about is meant to arrive as a refusal naming what would have worked, not as this. Say that",
25976
+ "in the report and it is the more useful of the two."
25977
+ ].join(`
25978
+ `);
25979
+ }
25980
+
25719
25981
  // src/junit.ts
25720
25982
  class MalformedReportError extends Error {
25721
25983
  constructor(message) {
@@ -26083,7 +26345,7 @@ class GodotLSPClient {
26083
26345
  async sendRequest(method, params) {
26084
26346
  await this.ensureConnected();
26085
26347
  if (!this.socket) {
26086
- throw new Error("Not connected to Godot LSP");
26348
+ throw new Refusal("Not connected to Godot LSP");
26087
26349
  }
26088
26350
  this.requestId += 1;
26089
26351
  const id = this.requestId;
@@ -26122,7 +26384,7 @@ class GodotLSPClient {
26122
26384
  }
26123
26385
  sendNotification(method, params) {
26124
26386
  if (!this.connected || !this.socket) {
26125
- throw new Error("Not connected to Godot LSP");
26387
+ throw new Refusal("Not connected to Godot LSP");
26126
26388
  }
26127
26389
  const payload = {
26128
26390
  jsonrpc: "2.0",
@@ -26372,36 +26634,36 @@ async function resolveLSPPaths(projectPathValue, scriptPathValue) {
26372
26634
  try {
26373
26635
  projectPath = await realpath(requestedProjectPath);
26374
26636
  } catch {
26375
- throw new Error(`Project path does not exist: ${requestedProjectPath}`);
26637
+ throw new Refusal(`Project path does not exist: ${requestedProjectPath}`);
26376
26638
  }
26377
26639
  const contained = resolveWithinProject(projectPath, scriptPathValue);
26378
26640
  if (!contained.ok) {
26379
- throw new Error(contained.reason);
26641
+ throw new Refusal(contained.reason);
26380
26642
  }
26381
26643
  let scriptPath;
26382
26644
  try {
26383
26645
  scriptPath = await realpath(contained.absolutePath);
26384
26646
  } catch {
26385
- throw new Error(`Script file does not exist: ${contained.absolutePath}`);
26647
+ throw new Refusal(`Script file does not exist: ${contained.absolutePath}`);
26386
26648
  }
26387
26649
  if (!isWithinRoot(projectPath, scriptPath)) {
26388
- throw new Error("scriptPath resolves outside the project root boundary.");
26650
+ throw new Refusal("scriptPath resolves outside the project root boundary.");
26389
26651
  }
26390
26652
  return { projectPath, scriptPath };
26391
26653
  }
26392
26654
  async function handleLSPTool(client, toolName, args) {
26393
26655
  try {
26394
26656
  if (!args || typeof args !== "object") {
26395
- throw new Error("Tool arguments must be an object.");
26657
+ throw new Refusal("Tool arguments must be an object.");
26396
26658
  }
26397
26659
  const parsedArgs = args;
26398
26660
  const projectPathValue = parsedArgs["projectPath"];
26399
26661
  const scriptPathValue = parsedArgs["scriptPath"];
26400
26662
  if (typeof projectPathValue !== "string" || projectPathValue.length === 0) {
26401
- throw new Error("Missing required argument: projectPath");
26663
+ throw new Refusal("Missing required argument: projectPath");
26402
26664
  }
26403
26665
  if (typeof scriptPathValue !== "string" || scriptPathValue.length === 0) {
26404
- throw new Error("Missing required argument: scriptPath");
26666
+ throw new Refusal("Missing required argument: scriptPath");
26405
26667
  }
26406
26668
  const { projectPath, scriptPath } = await resolveLSPPaths(projectPathValue, scriptPathValue);
26407
26669
  const content = await readFile(scriptPath, "utf8");
@@ -26415,7 +26677,7 @@ async function handleLSPTool(client, toolName, args) {
26415
26677
  const line = Number(parsedArgs["line"]);
26416
26678
  const character = Number(parsedArgs["character"]);
26417
26679
  if (!Number.isFinite(line) || !Number.isFinite(character)) {
26418
- throw new Error("Arguments line and character must be numbers.");
26680
+ throw new Refusal("Arguments line and character must be numbers.");
26419
26681
  }
26420
26682
  const completions = await client.getCompletions(scriptPath, content, line, character);
26421
26683
  return asToolResponse({ completions });
@@ -26424,7 +26686,7 @@ async function handleLSPTool(client, toolName, args) {
26424
26686
  const line = Number(parsedArgs["line"]);
26425
26687
  const character = Number(parsedArgs["character"]);
26426
26688
  if (!Number.isFinite(line) || !Number.isFinite(character)) {
26427
- throw new Error("Arguments line and character must be numbers.");
26689
+ throw new Refusal("Arguments line and character must be numbers.");
26428
26690
  }
26429
26691
  const hover = await client.getHover(scriptPath, content, line, character);
26430
26692
  return asToolResponse({ hover });
@@ -26444,19 +26706,19 @@ async function handleLSPTool(client, toolName, args) {
26444
26706
  }
26445
26707
 
26446
26708
  // src/project-scan.ts
26447
- import { readdirSync as readdirSync2, readFileSync } from "node:fs";
26448
- import { join as join3 } from "node:path";
26709
+ import { readdirSync as readdirSync3, readFileSync as readFileSync3 } from "node:fs";
26710
+ import { join as join4 } from "node:path";
26449
26711
  var SKIPPED = new Set([".git", ".godot", ".import", "node_modules"]);
26450
26712
  var ASSET_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "svg", "ttf", "otf", "wav", "mp3", "ogg"]);
26451
26713
  function projectStructure(projectPath) {
26452
26714
  const structure = { scenes: 0, scripts: 0, assets: 0, other: 0 };
26453
26715
  const visit = (directory) => {
26454
- for (const entry of readdirSync2(directory, { withFileTypes: true })) {
26716
+ for (const entry of readdirSync3(directory, { withFileTypes: true })) {
26455
26717
  if (entry.name.startsWith(".")) {
26456
26718
  continue;
26457
26719
  }
26458
26720
  if (entry.isDirectory()) {
26459
- visit(join3(directory, entry.name));
26721
+ visit(join4(directory, entry.name));
26460
26722
  } else if (entry.isFile()) {
26461
26723
  const extension = entry.name.split(".").pop()?.toLowerCase() ?? "";
26462
26724
  if (extension === "tscn") {
@@ -26491,14 +26753,14 @@ function searchProject(projectPath, options) {
26491
26753
  return false;
26492
26754
  };
26493
26755
  const visit = (directory) => {
26494
- for (const entry of readdirSync2(directory, { withFileTypes: true })) {
26756
+ for (const entry of readdirSync3(directory, { withFileTypes: true })) {
26495
26757
  if (full()) {
26496
26758
  return;
26497
26759
  }
26498
26760
  if (SKIPPED.has(entry.name)) {
26499
26761
  continue;
26500
26762
  }
26501
- const entryPath = join3(directory, entry.name);
26763
+ const entryPath = join4(directory, entry.name);
26502
26764
  if (entry.isDirectory()) {
26503
26765
  visit(entryPath);
26504
26766
  continue;
@@ -26509,7 +26771,7 @@ function searchProject(projectPath, options) {
26509
26771
  }
26510
26772
  result.summary.files_searched += 1;
26511
26773
  const matches = [];
26512
- for (const [index, line] of readFileSync(entryPath, "utf8").split(`
26774
+ for (const [index, line] of readFileSync3(entryPath, "utf8").split(`
26513
26775
  `).entries()) {
26514
26776
  if (full()) {
26515
26777
  break;
@@ -26532,7 +26794,7 @@ function searchProject(projectPath, options) {
26532
26794
  }
26533
26795
 
26534
26796
  // src/resources.ts
26535
- import { readFileSync as readFileSync2 } from "node:fs";
26797
+ import { readFileSync as readFileSync4 } from "node:fs";
26536
26798
  import { extname, resolve as resolve3 } from "node:path";
26537
26799
  var STATIC_RESOURCES = [
26538
26800
  {
@@ -26562,24 +26824,25 @@ var RESOURCE_TEMPLATES = [
26562
26824
  mimeType: "text/plain"
26563
26825
  }
26564
26826
  ];
26827
+ var RESOURCE_COUNT = STATIC_RESOURCES.length + RESOURCE_TEMPLATES.length;
26565
26828
  function ensureProjectPath(getProjectPath) {
26566
26829
  const projectPath = getProjectPath();
26567
26830
  if (!projectPath) {
26568
- throw new Error("Project path is not set. Set a Godot project path first.");
26831
+ throw new Refusal("Project path is not set. Set a Godot project path first.");
26569
26832
  }
26570
26833
  return resolve3(projectPath);
26571
26834
  }
26572
26835
  function uriPathToProjectPath(inputPath) {
26573
26836
  const normalized = inputPath.replace(/\\/g, "/").trim();
26574
26837
  if (normalized.replace(/\//g, "") === "") {
26575
- throw new Error("Resource path is empty.");
26838
+ throw new Refusal("Resource path is empty.");
26576
26839
  }
26577
26840
  return normalized.replace(/^\/+/, "");
26578
26841
  }
26579
26842
  function resolveProjectFile(projectPath, resourcePath) {
26580
26843
  const contained = resolveWithinProject(projectPath, resourcePath);
26581
26844
  if (!contained.ok) {
26582
- throw new Error(contained.reason);
26845
+ throw new Refusal(contained.reason);
26583
26846
  }
26584
26847
  return contained.absolutePath;
26585
26848
  }
@@ -26591,14 +26854,14 @@ function parseGodotUri(uri) {
26591
26854
  try {
26592
26855
  parsed = new URL(uri);
26593
26856
  } catch {
26594
- throw new Error(`Invalid URI: ${uri}`);
26857
+ throw new Refusal(`Invalid URI: ${uri}`);
26595
26858
  }
26596
26859
  if (parsed.protocol !== "godot:") {
26597
- throw new Error(`Unsupported URI scheme: ${parsed.protocol}`);
26860
+ throw new Refusal(`Unsupported URI scheme: ${parsed.protocol}`);
26598
26861
  }
26599
26862
  const host = parsed.hostname;
26600
26863
  if (host !== "scene" && host !== "script" && host !== "resource") {
26601
- throw new Error(`Unsupported Godot resource type: ${host}`);
26864
+ throw new Refusal(`Unsupported Godot resource type: ${host}`);
26602
26865
  }
26603
26866
  const resourcePath = uriPathToProjectPath(decodeURIComponent(parsed.pathname));
26604
26867
  return { kind: host, resourcePath };
@@ -26606,13 +26869,13 @@ function parseGodotUri(uri) {
26606
26869
  function ensureAllowedExtension(kind, filePath) {
26607
26870
  const extension = extname(filePath).toLowerCase();
26608
26871
  if (kind === "scene" && extension !== ".tscn") {
26609
- throw new Error("Scene resources must use .tscn extension.");
26872
+ throw new Refusal("Scene resources must use .tscn extension.");
26610
26873
  }
26611
26874
  if (kind === "script" && extension !== ".gd") {
26612
- throw new Error("Script resources must use .gd extension.");
26875
+ throw new Refusal("Script resources must use .gd extension.");
26613
26876
  }
26614
26877
  if (kind === "resource" && ![".tres", ".tscn", ".gd"].includes(extension)) {
26615
- throw new Error("Resource URIs support only .tres, .tscn, and .gd files.");
26878
+ throw new Refusal("Resource URIs support only .tres, .tscn, and .gd files.");
26616
26879
  }
26617
26880
  }
26618
26881
  function isValueComplete(value) {
@@ -26702,7 +26965,7 @@ function readResourceText(uri, getProjectPath) {
26702
26965
  const parsedUri = parseGodotUri(uri);
26703
26966
  if (parsedUri.kind === "project-info") {
26704
26967
  const projectFilePath = resolveProjectFile(projectPath, "project.godot");
26705
- const rawProject = readFileSync2(projectFilePath, "utf-8");
26968
+ const rawProject = readFileSync4(projectFilePath, "utf-8");
26706
26969
  const parsedProject = parseProjectGodot(rawProject);
26707
26970
  return {
26708
26971
  mimeType: "application/json",
@@ -26711,7 +26974,7 @@ function readResourceText(uri, getProjectPath) {
26711
26974
  }
26712
26975
  const filePath = resolveProjectFile(projectPath, parsedUri.resourcePath);
26713
26976
  ensureAllowedExtension(parsedUri.kind, filePath);
26714
- const text = readFileSync2(filePath, "utf-8");
26977
+ const text = readFileSync4(filePath, "utf-8");
26715
26978
  return {
26716
26979
  mimeType: parsedUri.kind === "script" ? "text/x-gdscript" : "text/plain",
26717
26980
  text
@@ -26740,18 +27003,18 @@ function setupResourceHandlers(mcp, getProjectPath) {
26740
27003
  };
26741
27004
  } catch (error) {
26742
27005
  if (error instanceof Error) {
26743
- throw new Error(`Failed to read resource '${uri}': ${error.message}`, { cause: error });
27006
+ throw new Refusal(`Failed to read resource '${uri}': ${error.message}`, { cause: error });
26744
27007
  }
26745
- throw new Error(`Failed to read resource '${uri}'.`, { cause: error });
27008
+ throw new Refusal(`Failed to read resource '${uri}'.`, { cause: error });
26746
27009
  }
26747
27010
  });
26748
27011
  }
26749
27012
 
26750
27013
  // src/runtime-client.ts
26751
- import { existsSync as existsSync3, readdirSync as readdirSync3, readFileSync as readFileSync3, unlinkSync } from "node:fs";
27014
+ import { existsSync as existsSync4, readdirSync as readdirSync4, readFileSync as readFileSync5, unlinkSync } from "node:fs";
26752
27015
  import { createConnection as createConnection3 } from "node:net";
26753
27016
  import { tmpdir as tmpdir2 } from "node:os";
26754
- import { join as join4, resolve as resolve4 } from "node:path";
27017
+ import { join as join5, resolve as resolve4 } from "node:path";
26755
27018
 
26756
27019
  // src/tool-args.ts
26757
27020
  function asParams(value) {
@@ -26813,7 +27076,7 @@ function runtimeDirectory(variables = process.env) {
26813
27076
  return explicit;
26814
27077
  }
26815
27078
  const perUser = envValue("XDG_RUNTIME_DIR", variables);
26816
- return join4(perUser ?? tmpdir2(), "gdharness");
27079
+ return join5(perUser ?? tmpdir2(), "gdharness");
26817
27080
  }
26818
27081
  function processAlive(pid) {
26819
27082
  try {
@@ -26826,7 +27089,7 @@ function processAlive(pid) {
26826
27089
  function parseAnnouncement(file, pid) {
26827
27090
  let fields;
26828
27091
  try {
26829
- fields = asParams(JSON.parse(readFileSync3(file, "utf8")));
27092
+ fields = asParams(JSON.parse(readFileSync5(file, "utf8")));
26830
27093
  } catch {
26831
27094
  return null;
26832
27095
  }
@@ -26845,16 +27108,16 @@ function parseAnnouncement(file, pid) {
26845
27108
  };
26846
27109
  }
26847
27110
  function discoverRuntimes(directory = runtimeDirectory()) {
26848
- if (!existsSync3(directory)) {
27111
+ if (!existsSync4(directory)) {
26849
27112
  return [];
26850
27113
  }
26851
27114
  const found = [];
26852
- for (const entry of readdirSync3(directory)) {
27115
+ for (const entry of readdirSync4(directory)) {
26853
27116
  const match = ANNOUNCEMENT_PATTERN.exec(entry);
26854
27117
  if (!match) {
26855
27118
  continue;
26856
27119
  }
26857
- const file = join4(directory, entry);
27120
+ const file = join5(directory, entry);
26858
27121
  const pid = Number.parseInt(match[1] ?? "", 10);
26859
27122
  const endpoint = processAlive(pid) ? parseAnnouncement(file, pid) : null;
26860
27123
  if (endpoint) {
@@ -26987,19 +27250,6 @@ function runtimeRequest(endpoint, command, params, timeoutMs) {
26987
27250
  });
26988
27251
  }
26989
27252
 
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
27253
  // src/tool-definitions.ts
27004
27254
  var PROJECT_PATH = {
27005
27255
  type: "string",
@@ -27903,7 +28153,190 @@ function buildToolDefinitions() {
27903
28153
  });
27904
28154
  }
27905
28155
 
28156
+ // src/update-check.ts
28157
+ import { existsSync as existsSync5, mkdirSync, readFileSync as readFileSync6, writeFileSync as writeFileSync2 } from "node:fs";
28158
+ import { homedir as homedir2, tmpdir as tmpdir3 } from "node:os";
28159
+ import { join as join6 } from "node:path";
28160
+
28161
+ // src/runner.ts
28162
+ function currentRunner() {
28163
+ const versions = process.versions;
28164
+ return versions["bun"] === undefined ? "npx" : "bunx";
28165
+ }
28166
+ function runLine(runner, version, rest = "") {
28167
+ const flag = runner === "npx" ? "-y " : "";
28168
+ return `${runner} ${flag}gdharness@${version}${rest === "" ? "" : ` ${rest}`}`;
28169
+ }
28170
+
28171
+ // src/update-check.ts
28172
+ var REGISTRY = "https://registry.npmjs.org/gdharness/latest";
28173
+ var RELEASES = "https://github.com/Aureliolo/gdharness/releases/tag";
28174
+ var CACHE_MS = 4 * 60 * 60 * 1000;
28175
+ var REQUEST_TIMEOUT_MS = 1e4;
28176
+ var MAX_BODY_BYTES = 1 << 20;
28177
+ var FIRST_RETRY_MS = 30000;
28178
+ var MAX_RETRY_MS = 60 * 60 * 1000;
28179
+ var VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
28180
+ function cacheDirectory(environment) {
28181
+ const set = (name) => {
28182
+ const value = environment[name];
28183
+ return value !== undefined && value !== "" ? value : null;
28184
+ };
28185
+ const home = set("HOME") ?? homedir2();
28186
+ if (process.platform === "win32") {
28187
+ return join6(set("LOCALAPPDATA") ?? home, "gdharness");
28188
+ }
28189
+ if (process.platform === "darwin") {
28190
+ return join6(home, "Library", "Caches", "gdharness");
28191
+ }
28192
+ return join6(set("XDG_CACHE_HOME") ?? join6(home, ".cache"), "gdharness");
28193
+ }
28194
+ function cacheFile(environment = process.env) {
28195
+ try {
28196
+ const directory = cacheDirectory(environment);
28197
+ mkdirSync(directory, { recursive: true, mode: 448 });
28198
+ return join6(directory, "update-check.json");
28199
+ } catch {
28200
+ return join6(tmpdir3(), "gdharness-update-check.json");
28201
+ }
28202
+ }
28203
+ function readCache(path) {
28204
+ try {
28205
+ if (!existsSync5(path)) {
28206
+ return null;
28207
+ }
28208
+ const parsed = JSON.parse(readFileSync6(path, "utf8"));
28209
+ if (typeof parsed !== "object" || parsed === null) {
28210
+ return null;
28211
+ }
28212
+ const record = parsed;
28213
+ const checkedAt = record["checkedAt"];
28214
+ const latest = record["latest"];
28215
+ if (typeof checkedAt !== "number" || typeof latest !== "string" || !VERSION.test(latest)) {
28216
+ return null;
28217
+ }
28218
+ return { checkedAt, latest };
28219
+ } catch {
28220
+ return null;
28221
+ }
28222
+ }
28223
+ function writeCache(path, entry) {
28224
+ try {
28225
+ writeFileSync2(path, JSON.stringify(entry), { encoding: "utf8", mode: 384 });
28226
+ } catch {}
28227
+ }
28228
+ function parts(version) {
28229
+ const withoutBuild = version.split("+")[0] ?? version;
28230
+ const dash = withoutBuild.indexOf("-");
28231
+ const numeric = dash === -1 ? withoutBuild : withoutBuild.slice(0, dash);
28232
+ return {
28233
+ numbers: numeric.split(".").map((piece) => Number.parseInt(piece, 10) || 0),
28234
+ prerelease: dash !== -1
28235
+ };
28236
+ }
28237
+ function isNewer(candidate, current) {
28238
+ const left = parts(candidate);
28239
+ const right = parts(current);
28240
+ for (let index = 0;index < 3; index += 1) {
28241
+ const a = left.numbers[index] ?? 0;
28242
+ const b = right.numbers[index] ?? 0;
28243
+ if (a !== b) {
28244
+ return a > b;
28245
+ }
28246
+ }
28247
+ return !left.prerelease && right.prerelease;
28248
+ }
28249
+ async function fetchLatest() {
28250
+ const response = await fetch(REGISTRY, {
28251
+ headers: { accept: "application/vnd.npm.install-v1+json, application/json" },
28252
+ redirect: "error",
28253
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
28254
+ });
28255
+ if (!response.ok || response.body === null) {
28256
+ return null;
28257
+ }
28258
+ const declared = Number.parseInt(response.headers.get("content-length") ?? "", 10);
28259
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
28260
+ return null;
28261
+ }
28262
+ const chunks = [];
28263
+ let size = 0;
28264
+ for await (const chunk of response.body) {
28265
+ size += chunk.byteLength;
28266
+ if (size > MAX_BODY_BYTES) {
28267
+ return null;
28268
+ }
28269
+ chunks.push(chunk);
28270
+ }
28271
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
28272
+ if (typeof parsed !== "object" || parsed === null) {
28273
+ return null;
28274
+ }
28275
+ const version = parsed["version"];
28276
+ return typeof version === "string" && VERSION.test(version) ? version : null;
28277
+ }
28278
+
28279
+ class UpdateCheck {
28280
+ latest = null;
28281
+ checking = false;
28282
+ checkedAt = 0;
28283
+ retryAt = 0;
28284
+ backoffMs = FIRST_RETRY_MS;
28285
+ enabled;
28286
+ current;
28287
+ cachePath;
28288
+ constructor(current, environment = process.env) {
28289
+ this.current = current;
28290
+ this.enabled = (environment["GDHARNESS_NO_UPDATE_CHECK"] ?? "") === "";
28291
+ this.cachePath = cacheFile(environment);
28292
+ const cached = this.enabled ? readCache(this.cachePath) : null;
28293
+ if (cached !== null) {
28294
+ this.latest = cached.latest;
28295
+ this.checkedAt = cached.checkedAt;
28296
+ }
28297
+ }
28298
+ refresh(now = Date.now()) {
28299
+ if (!this.enabled || this.checking || now < this.retryAt || now - this.checkedAt < CACHE_MS) {
28300
+ return;
28301
+ }
28302
+ this.checking = true;
28303
+ fetchLatest().then((version) => {
28304
+ if (version === null) {
28305
+ this.scheduleRetry(now);
28306
+ return;
28307
+ }
28308
+ this.latest = version;
28309
+ this.checkedAt = Date.now();
28310
+ this.backoffMs = FIRST_RETRY_MS;
28311
+ this.retryAt = 0;
28312
+ writeCache(this.cachePath, { checkedAt: this.checkedAt, latest: version });
28313
+ }).catch(() => {
28314
+ this.scheduleRetry(now);
28315
+ }).finally(() => {
28316
+ this.checking = false;
28317
+ });
28318
+ }
28319
+ scheduleRetry(now) {
28320
+ this.retryAt = now + this.backoffMs;
28321
+ this.backoffMs = Math.min(this.backoffMs * 2, MAX_RETRY_MS);
28322
+ }
28323
+ notice() {
28324
+ const latest = this.latest;
28325
+ if (!this.enabled || latest === null || !isNewer(latest, this.current)) {
28326
+ return null;
28327
+ }
28328
+ return {
28329
+ current: this.current,
28330
+ latest,
28331
+ releaseNotes: `${RELEASES}/v${latest}`,
28332
+ upgrade: runLine(currentRunner(), latest, "upgrade")
28333
+ };
28334
+ }
28335
+ }
28336
+
27906
28337
  // src/server.ts
28338
+ var UPDATE_NOTICE_EVERY = 500;
28339
+ var FEEDBACK_NOTICE_EVERY = 250;
27907
28340
  var run3 = promisify3(execFile3);
27908
28341
  var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
27909
28342
  var EDITOR_RESTART_TIMEOUT_MS = 90000;
@@ -27982,11 +28415,11 @@ function realPathOr(path) {
27982
28415
  }
27983
28416
  }
27984
28417
  function hasMainScene(projectFile) {
27985
- const scene = parseProjectGodot(readFileSync5(projectFile, "utf8"))["application"]?.["run/main_scene"];
28418
+ const scene = parseProjectGodot(readFileSync7(projectFile, "utf8"))["application"]?.["run/main_scene"];
27986
28419
  return typeof scene === "string" && scene !== "";
27987
28420
  }
27988
28421
  function editorPlaysHeadless(projectFile) {
27989
- const runArgs = parseProjectGodot(readFileSync5(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
28422
+ const runArgs = parseProjectGodot(readFileSync7(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
27990
28423
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
27991
28424
  }
27992
28425
  function camelCased(params) {
@@ -28002,10 +28435,14 @@ function camelCased(params) {
28002
28435
  class GodotServer {
28003
28436
  mcp;
28004
28437
  locator = new GodotLocator;
28005
- operationsScript = join5(__dirname2, "godot", "operations", "godot_operations.gd");
28438
+ operationsScript = join7(__dirname2, "godot", "operations", "godot_operations.gd");
28006
28439
  godotBridge;
28007
28440
  tools = buildToolDefinitions();
28008
28441
  activeProcess = null;
28442
+ updates = new UpdateCheck(SERVER_VERSION);
28443
+ noticedUpdate = false;
28444
+ callsSinceNotice = 0;
28445
+ callsSinceFeedback = 0;
28009
28446
  lspClient = null;
28010
28447
  dapClient = null;
28011
28448
  bridgeStartupError = null;
@@ -28163,9 +28600,61 @@ class GodotServer {
28163
28600
  if (typeof args["projectPath"] === "string") {
28164
28601
  this.lastProjectPath = args["projectPath"];
28165
28602
  }
28166
- return await this.dispatch(spec.name, checked.op ?? "", args);
28603
+ this.updates.refresh();
28604
+ const answer = await this.answered(spec.name, checked.op ?? "", args);
28605
+ return this.withFeedbackNotice(this.withUpdateNotice(answer));
28167
28606
  });
28168
28607
  }
28608
+ async answered(tool, op, args) {
28609
+ try {
28610
+ return await this.dispatch(tool, op, args);
28611
+ } catch (error) {
28612
+ if (error instanceof McpError || error instanceof Refusal) {
28613
+ throw error;
28614
+ }
28615
+ const where = op === "" ? tool : `${tool} op=${op}`;
28616
+ console.error(`[SERVER] Unmodelled failure in ${where}:`, error);
28617
+ return { content: [{ type: "text", text: defectReport2(where, error) }], isError: true };
28618
+ }
28619
+ }
28620
+ withUpdateNotice(answer) {
28621
+ this.callsSinceNotice += 1;
28622
+ if (this.callsSinceNotice < UPDATE_NOTICE_EVERY && this.noticedUpdate) {
28623
+ return answer;
28624
+ }
28625
+ const notice = this.updates.notice();
28626
+ if (notice === null) {
28627
+ return answer;
28628
+ }
28629
+ this.noticedUpdate = true;
28630
+ this.callsSinceNotice = 0;
28631
+ return {
28632
+ ...answer,
28633
+ content: [
28634
+ ...answer.content,
28635
+ {
28636
+ type: "text",
28637
+ text: JSON.stringify({
28638
+ update_available: {
28639
+ ...notice,
28640
+ 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."
28641
+ }
28642
+ }, null, 2)
28643
+ }
28644
+ ]
28645
+ };
28646
+ }
28647
+ withFeedbackNotice(answer) {
28648
+ this.callsSinceFeedback += 1;
28649
+ if (this.callsSinceFeedback < FEEDBACK_NOTICE_EVERY) {
28650
+ return answer;
28651
+ }
28652
+ this.callsSinceFeedback = 0;
28653
+ return {
28654
+ ...answer,
28655
+ content: [...answer.content, { type: "text", text: feedbackNotice() }]
28656
+ };
28657
+ }
28169
28658
  validateArguments(spec, args) {
28170
28659
  const known = new Set([...Object.keys(spec.parameters), ...spec.operations ? ["op"] : []]);
28171
28660
  const unknown = Object.keys(args).filter((key) => !known.has(key));
@@ -28371,9 +28860,16 @@ class GodotServer {
28371
28860
  });
28372
28861
  }
28373
28862
  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);
28863
+ case "debug_state": {
28864
+ if (tool === "debug_state" && op === "output") {
28865
+ return await this.handleDAP("dap_get_output", args);
28866
+ }
28867
+ const held = await this.debuggedGame();
28868
+ if (!held.ok) {
28869
+ return held.response;
28870
+ }
28871
+ return tool === "debug_control" ? await this.handleDAP(`dap_${op}`, args) : await this.handleDAP(DEBUG_STATE_CALLS[op] ?? "dap_get_stack_trace", args);
28872
+ }
28377
28873
  default:
28378
28874
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${tool}`);
28379
28875
  }
@@ -28393,8 +28889,8 @@ class GodotServer {
28393
28889
  if (path === undefined) {
28394
28890
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
28395
28891
  }
28396
- const file = join5(path, "project.godot");
28397
- if (!existsSync4(file)) {
28892
+ const file = join7(path, "project.godot");
28893
+ if (!existsSync6(file)) {
28398
28894
  return {
28399
28895
  ok: false,
28400
28896
  response: this.createErrorResponse(`Not a Godot project: ${path}`, [
@@ -28404,6 +28900,56 @@ class GodotServer {
28404
28900
  }
28405
28901
  return { ok: true, value: { path, file } };
28406
28902
  }
28903
+ async debuggedGame() {
28904
+ const game = this.activeProcess;
28905
+ if (game === null) {
28906
+ return {
28907
+ ok: false,
28908
+ 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"])
28909
+ };
28910
+ }
28911
+ if (!game.throughEditor) {
28912
+ return {
28913
+ ok: false,
28914
+ 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.", [
28915
+ "editor_run start plays it through the open editor, whose debugger the debug_* tools speak to",
28916
+ "editor_status says whether an editor has reached this server"
28917
+ ])
28918
+ };
28919
+ }
28920
+ try {
28921
+ await this.dap().attach();
28922
+ } catch (error) {
28923
+ return {
28924
+ ok: false,
28925
+ response: this.createErrorResponse(`The editor is playing the game but its debug adapter did not answer: ${errorMessage(error)}`, [
28926
+ "Godot serves the debug adapter on 6006 unless --dap-port says otherwise",
28927
+ "GDHARNESS_DAP_PORT points this server at another one"
28928
+ ])
28929
+ };
28930
+ }
28931
+ if (!this.dap().isStopped()) {
28932
+ return {
28933
+ ok: false,
28934
+ response: this.createErrorResponse("The game is running, not stopped, so it has no stack and no scope to read.", [
28935
+ "debug_breakpoint set puts a breakpoint on before the run, and it is waiting when the game starts",
28936
+ "editor_output reads what the running game is printing"
28937
+ ])
28938
+ };
28939
+ }
28940
+ return { ok: true, value: game };
28941
+ }
28942
+ async refreshStaleClasses(projectPath) {
28943
+ const stale = staleClassNames(projectPath);
28944
+ if (stale.length === 0) {
28945
+ return { ok: true, value: [] };
28946
+ }
28947
+ const refreshed = await this.operation("refresh_class_cache", {}, projectPath);
28948
+ if (!refreshed.ok) {
28949
+ return { ok: false, response: this.answer(refreshed) };
28950
+ }
28951
+ return { ok: true, value: stale };
28952
+ }
28407
28953
  containProjectFiles(args) {
28408
28954
  const projectPath = readString(args, "projectPath");
28409
28955
  if (!projectPath) {
@@ -28489,7 +29035,7 @@ class GodotServer {
28489
29035
  if (!engine.ok) {
28490
29036
  return engine.response;
28491
29037
  }
28492
- const application = parseProjectGodot(readFileSync5(project.value.file, "utf8"))["application"] ?? {};
29038
+ const application = parseProjectGodot(readFileSync7(project.value.file, "utf8"))["application"] ?? {};
28493
29039
  const name = application["config/name"];
28494
29040
  const mainScene = application["run/main_scene"];
28495
29041
  const info = {
@@ -28578,7 +29124,7 @@ class GodotServer {
28578
29124
  log.finish();
28579
29125
  const problems = log.select({ severity: "warning", sinceLastCall: false, limit: 200 });
28580
29126
  const verdict = {
28581
- exported: exitCode === 0 && log.count("error") === 0 && existsSync4(output.absolutePath),
29127
+ exported: exitCode === 0 && log.count("error") === 0 && existsSync6(output.absolutePath),
28582
29128
  preset,
28583
29129
  outputPath: output.relativePath,
28584
29130
  debug,
@@ -28608,7 +29154,7 @@ class GodotServer {
28608
29154
  return contained.response;
28609
29155
  }
28610
29156
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
28611
- if (!existsSync4(join5(project.value.path, runner))) {
29157
+ if (!existsSync6(join7(project.value.path, runner))) {
28612
29158
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
28613
29159
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
28614
29160
  ]);
@@ -28656,14 +29202,14 @@ class GodotServer {
28656
29202
  resolve(false);
28657
29203
  });
28658
29204
  });
28659
- const reportsDir = join5(project.value.path, ".godot", "gdharness-reports");
29205
+ const reportsDir = join7(project.value.path, ".godot", "gdharness-reports");
28660
29206
  let report = null;
28661
29207
  let reportProblem = null;
28662
29208
  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))) : [];
29209
+ 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
29210
  const newest = written.at(-1);
28665
29211
  if (newest !== undefined) {
28666
- report = parseJUnit(readFileSync5(join5(reportsDir, newest, "results.xml"), "utf8"));
29212
+ report = parseJUnit(readFileSync7(join7(reportsDir, newest, "results.xml"), "utf8"));
28667
29213
  }
28668
29214
  } catch (error) {
28669
29215
  reportProblem = errorMessage(error);
@@ -28876,6 +29422,13 @@ class GodotServer {
28876
29422
  if (!project.ok) {
28877
29423
  return project.response;
28878
29424
  }
29425
+ if (this.godotBridge.isConnected()) {
29426
+ 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.", [
29427
+ "editor_status says which editor is answering, and for which project",
29428
+ "editor_launch restart replaces the connected editor rather than joining it",
29429
+ "Close the open editor first if the new project is the one you want"
29430
+ ]);
29431
+ }
28879
29432
  const engine = await this.engine();
28880
29433
  if (!engine.ok) {
28881
29434
  return engine.response;
@@ -28923,6 +29476,10 @@ class GodotServer {
28923
29476
  if (!engine.ok) {
28924
29477
  return engine.response;
28925
29478
  }
29479
+ const refreshed = await this.refreshStaleClasses(project.value.path);
29480
+ if (!refreshed.ok) {
29481
+ return refreshed.response;
29482
+ }
28926
29483
  const sceneArgument = sceneToRun?.ok ? sceneToRun.relativePath : null;
28927
29484
  if (op === "check") {
28928
29485
  return await this.checkBoot(engine.value, project.value.path, sceneArgument, args);
@@ -28936,7 +29493,7 @@ class GodotServer {
28936
29493
  variables: process.env
28937
29494
  });
28938
29495
  if (this.godotBridge.isConnected() && (!headless || editorPlaysHeadless(project.value.file))) {
28939
- return await this.playThroughEditor(sceneArgument);
29496
+ return await this.playThroughEditor(sceneArgument, refreshed.value);
28940
29497
  }
28941
29498
  const cmdArgs = runArguments({
28942
29499
  projectPath: project.value.path,
@@ -28956,10 +29513,11 @@ class GodotServer {
28956
29513
  through: "gdharness",
28957
29514
  pid: started.process.pid ?? null,
28958
29515
  arguments: cmdArgs,
29516
+ refreshedClasses: refreshed.value,
28959
29517
  message: "Use editor_output for what it prints and editor_run stop to end it."
28960
29518
  });
28961
29519
  }
28962
- async playThroughEditor(scene) {
29520
+ async playThroughEditor(scene, refreshedClasses) {
28963
29521
  const log = new GameLog;
28964
29522
  try {
28965
29523
  await this.dap().connect();
@@ -28985,6 +29543,7 @@ class GodotServer {
28985
29543
  started: true,
28986
29544
  through: "editor",
28987
29545
  scene: scene === null ? "the main scene" : `res://${scene}`,
29546
+ refreshedClasses,
28988
29547
  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
29548
  });
28990
29549
  }
@@ -29159,8 +29718,8 @@ class GodotServer {
29159
29718
  return this.createErrorResponse(choice.problem);
29160
29719
  }
29161
29720
  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;
29721
+ const screenshotDir = expectsScreenshot ? mkdtempSync2(join7(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
29722
+ const screenshotPath = screenshotDir ? join7(screenshotDir, "capture.png") : null;
29164
29723
  try {
29165
29724
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
29166
29725
  if (!reply.ok) {
@@ -29181,7 +29740,7 @@ class GodotServer {
29181
29740
  return {
29182
29741
  content: [
29183
29742
  { type: "text", text: `Screenshot captured: ${dimensions}` },
29184
- { type: "image", data: readFileSync5(screenshotPath).toString("base64"), mimeType: "image/png" }
29743
+ { type: "image", data: readFileSync7(screenshotPath).toString("base64"), mimeType: "image/png" }
29185
29744
  ]
29186
29745
  };
29187
29746
  } finally {
@@ -29246,7 +29805,6 @@ async function runGodotServer() {
29246
29805
 
29247
29806
  // src/server-entry.ts
29248
29807
  await runGodotServer().catch((error) => {
29249
- const errorMessage = error instanceof Error ? error.message : "Unknown error";
29250
- console.error("Failed to run server:", errorMessage);
29808
+ console.error(defectReport("gdharness server startup", error));
29251
29809
  process.exit(1);
29252
29810
  });