gdharness 0.13.36 → 0.13.38

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.
Files changed (3) hide show
  1. package/build/cli.js +499 -225
  2. package/build/index.js +342 -124
  3. package/package.json +1 -1
package/build/cli.js CHANGED
@@ -133,6 +133,28 @@ function godotCandidates(platform, home) {
133
133
  }
134
134
  var init_detection = () => {};
135
135
 
136
+ // src/errors.ts
137
+ function errorMessage(error, fallback = "Unknown error") {
138
+ if (error instanceof Error) {
139
+ return error.message || fallback;
140
+ }
141
+ if (typeof error === "string") {
142
+ return error || fallback;
143
+ }
144
+ if (typeof error === "number" || typeof error === "boolean") {
145
+ return String(error);
146
+ }
147
+ return fallback;
148
+ }
149
+ function toError(error) {
150
+ return error instanceof Error ? error : new Error(errorMessage(error), { cause: error });
151
+ }
152
+ var Refusal2;
153
+ var init_errors = __esm(() => {
154
+ Refusal2 = class Refusal2 extends Error {
155
+ };
156
+ });
157
+
136
158
  // src/launch.ts
137
159
  function envValue(name, variables = process.env) {
138
160
  const value = variables[name];
@@ -176,7 +198,10 @@ function userDataIn(home, variables = process.env) {
176
198
  }
177
199
  return carried;
178
200
  }
179
- var OPENED_BY_A_SERVER = "GDHARNESS_OPENED_BY_A_SERVER";
201
+ function savesStayPut(platform = process.platform) {
202
+ return platform === "darwin";
203
+ }
204
+ var OPENED_BY_A_SERVER = "GDHARNESS_OPENED_BY_A_SERVER", SAVES_NOT_MOVED_NOTE = "On macOS the engine reads user:// off HOME and ignores the variables that move it elsewhere, measured on Godot 4.7.2, so this run wrote its saves where the player keeps theirs. A suite that saves a game has written into the same folder as the copy being played, and one that tidies up after itself may have removed real saves.";
180
205
 
181
206
  // node_modules/yaml/dist/nodes/identity.js
182
207
  var require_identity = __commonJS(function(exports) {
@@ -7120,28 +7145,6 @@ var require_public_api = __commonJS(function(exports) {
7120
7145
  exports.stringify = stringify;
7121
7146
  });
7122
7147
 
7123
- // src/errors.ts
7124
- function errorMessage(error, fallback = "Unknown error") {
7125
- if (error instanceof Error) {
7126
- return error.message || fallback;
7127
- }
7128
- if (typeof error === "string") {
7129
- return error || fallback;
7130
- }
7131
- if (typeof error === "number" || typeof error === "boolean") {
7132
- return String(error);
7133
- }
7134
- return fallback;
7135
- }
7136
- function toError(error) {
7137
- return error instanceof Error ? error : new Error(errorMessage(error), { cause: error });
7138
- }
7139
- var Refusal2;
7140
- var init_errors = __esm(() => {
7141
- Refusal2 = class Refusal2 extends Error {
7142
- };
7143
- });
7144
-
7145
7148
  // src/runner.ts
7146
7149
  import { existsSync as existsSync3 } from "node:fs";
7147
7150
  import { dirname, join as join2 } from "node:path";
@@ -7200,7 +7203,7 @@ class GameLog {
7200
7203
  };
7201
7204
  partial = { stdout: "", stderr: "", transcript: "" };
7202
7205
  lastHeadline = null;
7203
- cursor = 0;
7206
+ seen = { error: 0, warning: 0, info: 0 };
7204
7207
  append(source, chunk) {
7205
7208
  const decoded = typeof chunk === "string" ? chunk : this.decoders[source].write(chunk);
7206
7209
  const lines = (this.partial[source] + decoded).split(`
@@ -7257,10 +7260,14 @@ class GameLog {
7257
7260
  return this.entries.filter((entry) => entry.severity === severity).length;
7258
7261
  }
7259
7262
  select(options) {
7260
- const floor = options.sinceLastCall ? this.cursor : 0;
7261
- this.cursor = this.entries.length;
7262
- const wanted = new Set(options.severity === "error" ? ["error"] : options.severity === "warning" ? ["error", "warning"] : ["error", "warning", "info"]);
7263
+ const floor = options.sinceLastCall ? this.seen[options.severity] : 0;
7264
+ const wanted = ADMITTED[options.severity];
7263
7265
  const needle = options.contains?.toLowerCase();
7266
+ if (needle === undefined) {
7267
+ for (const severity of wanted) {
7268
+ this.seen[severity] = this.entries.length;
7269
+ }
7270
+ }
7264
7271
  const matching = this.entries.slice(floor).filter((entry) => {
7265
7272
  if (!wanted.has(entry.severity)) {
7266
7273
  return false;
@@ -7274,11 +7281,16 @@ class GameLog {
7274
7281
  return { entries: matching.slice(omitted), omitted };
7275
7282
  }
7276
7283
  }
7277
- var HEADLINE, COLOUR_CODE, STREAMS;
7284
+ var HEADLINE, COLOUR_CODE, STREAMS, ADMITTED;
7278
7285
  var init_game_log = __esm(() => {
7279
7286
  HEADLINE = /^(USER )?(SCRIPT ERROR|ERROR|WARNING):\s?(.*)$/;
7280
7287
  COLOUR_CODE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
7281
7288
  STREAMS = ["stdout", "stderr", "transcript"];
7289
+ ADMITTED = {
7290
+ error: new Set(["error"]),
7291
+ warning: new Set(["error", "warning"]),
7292
+ info: new Set(["error", "warning", "info"])
7293
+ };
7282
7294
  });
7283
7295
 
7284
7296
  // src/scratch.ts
@@ -7311,6 +7323,20 @@ var init_scratch = __esm(() => {
7311
7323
  import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "node:fs";
7312
7324
  import { homedir as homedir3, tmpdir as tmpdir2 } from "node:os";
7313
7325
  import { join as join5 } from "node:path";
7326
+ function packageUrl(root) {
7327
+ return `${root.replace(/\/+$/, "")}/gdharness/latest`;
7328
+ }
7329
+ function registryFor(environment) {
7330
+ const named = environment["GDHARNESS_REGISTRY"] ?? "";
7331
+ if (named === "") {
7332
+ return packageUrl(REGISTRY_ROOT);
7333
+ }
7334
+ try {
7335
+ return new URL(named).protocol === "https:" ? packageUrl(named) : packageUrl(REGISTRY_ROOT);
7336
+ } catch {
7337
+ return packageUrl(REGISTRY_ROOT);
7338
+ }
7339
+ }
7314
7340
  function cacheDirectory(environment) {
7315
7341
  const set = (name) => {
7316
7342
  const value = environment[name];
@@ -7380,8 +7406,8 @@ function isNewer(candidate, current) {
7380
7406
  }
7381
7407
  return !left.prerelease && right.prerelease;
7382
7408
  }
7383
- async function fetchLatest() {
7384
- const response = await fetch(REGISTRY, {
7409
+ async function fetchLatest(registry) {
7410
+ const response = await fetch(registry, {
7385
7411
  headers: { accept: "application/vnd.npm.install-v1+json, application/json" },
7386
7412
  redirect: "error",
7387
7413
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
@@ -7413,16 +7439,19 @@ async function fetchLatest() {
7413
7439
  class UpdateCheck {
7414
7440
  latest = null;
7415
7441
  checking = false;
7442
+ asked = false;
7416
7443
  checkedAt = 0;
7417
7444
  retryAt = 0;
7418
7445
  backoffMs = FIRST_RETRY_MS;
7419
7446
  enabled;
7420
7447
  current;
7421
7448
  cachePath;
7422
- constructor(current, environment = process.env) {
7449
+ ask;
7450
+ constructor(current, environment = process.env, ask = () => fetchLatest(registryFor(environment))) {
7423
7451
  this.current = current;
7424
7452
  this.enabled = (environment["GDHARNESS_NO_UPDATE_CHECK"] ?? "") === "";
7425
7453
  this.cachePath = cacheFile(environment);
7454
+ this.ask = ask;
7426
7455
  const cached = this.enabled ? readCache(this.cachePath) : null;
7427
7456
  if (cached !== null) {
7428
7457
  this.latest = cached.latest;
@@ -7430,11 +7459,12 @@ class UpdateCheck {
7430
7459
  }
7431
7460
  }
7432
7461
  refresh(now = Date.now()) {
7433
- if (!this.enabled || this.checking || now < this.retryAt || !this.stale(now)) {
7462
+ if (!this.enabled || this.checking || now < this.retryAt || this.asked && !this.stale(now)) {
7434
7463
  return;
7435
7464
  }
7465
+ this.asked = true;
7436
7466
  this.checking = true;
7437
- fetchLatest().then((version) => {
7467
+ this.ask().then((version) => {
7438
7468
  if (version === null) {
7439
7469
  this.scheduleRetry(now);
7440
7470
  return;
@@ -7473,10 +7503,10 @@ class UpdateCheck {
7473
7503
  };
7474
7504
  }
7475
7505
  }
7476
- var REGISTRY = "https://registry.npmjs.org/gdharness/latest", RELEASES = "https://github.com/Aureliolo/gdharness/releases/tag", CACHE_MS, REQUEST_TIMEOUT_MS = 1e4, MAX_BODY_BYTES, FIRST_RETRY_MS = 30000, MAX_RETRY_MS, VERSION;
7506
+ var REGISTRY_ROOT = "https://registry.npmjs.org", RELEASES = "https://github.com/Aureliolo/gdharness/releases/tag", CACHE_MS, REQUEST_TIMEOUT_MS = 1e4, MAX_BODY_BYTES, FIRST_RETRY_MS = 30000, MAX_RETRY_MS, VERSION;
7477
7507
  var init_update_check = __esm(() => {
7478
7508
  init_runner();
7479
- CACHE_MS = 4 * 60 * 60 * 1000;
7509
+ CACHE_MS = 10 * 60 * 1000;
7480
7510
  MAX_BODY_BYTES = 1 << 20;
7481
7511
  MAX_RETRY_MS = 60 * 60 * 1000;
7482
7512
  VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
@@ -7625,7 +7655,7 @@ function staleAnalysisNote(contradicted, dependsOn) {
7625
7655
  }
7626
7656
  const types = new Set(contradicted.map((one) => one.type));
7627
7657
  const declaring = [...new Set(contradicted.map((one) => one.declaredIn))].sort();
7628
- const lever = dependsOn.length === 0 ? ", and the named types depend on no other global class, so that lever is not available here. " + "That is the common case rather than the awkward one: an edit near a type that depends on " + "something refreshes it before the fault is noticed, which is why a type with no " + "dependencies is where this turns up" : `, so changing ${[...dependsOn].sort().join(", ")} and rescanning is worth a try`;
7658
+ const lever = dependsOn.length === 0 ? ", and the named types depend on no other global class, so that lever is not available here. " + "That is the common case rather than the awkward one: an edit near a type that depends on " + "something refreshes it before the fault is noticed, which is why a type with no " + "dependencies is where this turns up" : `, so changing ${[...dependsOn].sort().join(" or ")} and then rescanning is worth a try`;
7629
7659
  return `The editor is reporting against an older copy of ${types.size === 1 ? "a type" : "some types"} ` + "named under contradictedByTheFile. Each member listed is declared in the file the class cache " + "points at, so those diagnostics are wrong however the code is written. Run editor_rescan first: " + "it costs about half a second, and it has cleared this in every reproduction measured since the " + "scan timing was fixed: both in one project, the scan returning in 243ms and 275ms, and one in " + "another where the same call also rebuilt the copy. " + "The fault behind it is that a script the editor has loaded keeps the copy it built, and nothing " + `rebuilds that copy when the file changes; ${declaring.length === 1 ? `editor_rescan with reloadScript set to ${declaring[0]} recompiles that copy` : `reloadScript takes one script, so a call each for ${declaring.join(" and ")} recompiles those copies`} from the file into the same ` + "object and answers with the members it has afterwards under reloadedMethods. Read that and the " + "next diagnostics as two readings, because they are of two things: measured in one window on " + "one editor, the analyser resolved a call to a newly added method while the built copy of that " + "same script did not have it. So the reload is worth doing and is not what clears these; the " + "scan is. The other lever measured is that a held type is refreshed when something it " + `depends on changes rather than when it changes itself${lever}. editor_launch restart has always ` + "worked and costs a window. project_import refresh_classes does not, and answers added: [] while " + "this is happening.";
7630
7660
  }
7631
7661
  function uncachedClassNote(types) {
@@ -15048,6 +15078,11 @@ function settingKeys(content) {
15048
15078
  return named;
15049
15079
  }
15050
15080
  function settingsDroppedReport(before, after) {
15081
+ if (before === null || after === null) {
15082
+ return {
15083
+ settingsNote: `project.godot could not be read ${before === null ? "before" : "after"} this, so nothing can` + " be said about what the editor saved away. Opening a project rewrites the file and drops any" + " key sitting at its own default, and this is the only thing that would have named them." + " Read the file yourself if a setting was pinned at a default on purpose."
15084
+ };
15085
+ }
15051
15086
  const dropped = [...before].filter(([key]) => !after.has(key));
15052
15087
  if (dropped.length === 0) {
15053
15088
  return {};
@@ -15665,7 +15700,7 @@ var init_tool_definitions = __esm(() => {
15665
15700
  },
15666
15701
  {
15667
15702
  name: "project_test",
15668
- description: "Runs the project's gdUnit4 tests headless and answers with every case that did not pass: where it is, and what the assertion said. Suites where everything passed are counted rather than listed, and an engine message keeps the frames above gdUnit4 rather than the twenty inside it, so a clean tier answers in a few lines. The class list is rebuilt first, so a suite written a moment ago is found. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
15703
+ description: "Runs the project's gdUnit4 tests headless and answers with every case that did not pass: where it is, and what the assertion said. Suites where everything passed are counted rather than listed, and an engine message keeps the frames above gdUnit4 rather than the twenty inside it, so a clean tier answers in a few lines. The class list is rebuilt first, so a suite written a moment ago is found. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
15669
15704
  parameters: {
15670
15705
  projectPath: PROJECT_PATH,
15671
15706
  path: {
@@ -15882,7 +15917,7 @@ var init_tool_definitions = __esm(() => {
15882
15917
  },
15883
15918
  {
15884
15919
  name: "script_diagnostics",
15885
- description: "Errors and warnings for a script from the editor's language server, and whether the script is clean. Needs the editor running. Diagnostics are checked against the project's own files, and the two that the files disprove are named rather than passed on: a member the class cache's file declares comes back under contradictedByTheFile, and a class this project declares that the diagnostic could not resolve comes back under typesTheEditorHasNotLoaded, with inTheClassCache saying whether a launched game would resolve it. staleAnalysis then says which remedy applies, because they differ: a stale analysed type needs editor_launch restart, while a class missing from the cache needs project_import refresh_classes.",
15920
+ description: "Errors and warnings for a script from the editor's language server, and whether the script is clean. Needs the editor running. Diagnostics are checked against the project's own files, and the two that the files disprove are named rather than passed on: a member the class cache's file declares comes back under contradictedByTheFile, and a class this project declares that the diagnostic could not resolve comes back under typesTheEditorHasNotLoaded, with inTheClassCache saying whether a launched game would resolve it. staleAnalysis then says what to do, and for both states it is editor_rescan first: it asks the editor already running, starts nothing, and has cleared each in every reproduction measured. What follows differs: for a stale analysed type, editor_rescan with reloadScript recompiles the held copy and editor_launch restart is the last resort that costs a window; for a class missing from the cache, project_import refresh_classes rewrites the cache from disk at the cost of a short headless engine, which matters beside a running bench.",
15886
15921
  parameters: {
15887
15922
  projectPath: PROJECT_PATH,
15888
15923
  scriptPath: SCRIPT_PATH
@@ -15932,7 +15967,7 @@ var init_tool_definitions = __esm(() => {
15932
15967
  },
15933
15968
  {
15934
15969
  name: "editor_launch",
15935
- description: "Opens the Godot editor on a project, in a window on this machine, or restarts the one already connected. An editor goes on serving the addon it read at startup, so restart is what puts a gdharness upgrade into effect; it saves open scenes on the way out and answers with the version that came back. Only an editor with a window can be restarted, because the engine hands back none of the arguments it was started with. editor_status says which editor is connected and whether it is holding an old addon. Opening a project is a save and so is closing one, which is the part that is not obvious from outside: the editor imports the project on open and writes project.godot back, and Godot writes only what differs from its own defaults, so a key named deliberately at its default value is dropped either way. A restart says which under settingsDropped in its own answer because it waits for the editor to return. An open cannot, since it answers as soon as the process exists and the save happens during the import minutes later, so editor_status carries that reading once the editor has connected. Nothing else will say a key went until something depends on one.",
15970
+ description: "Opens the Godot editor on a project, in a window on this machine, or restarts the one already connected. An editor goes on serving the addon it read at startup, so restart is what puts a gdharness upgrade into effect; it saves open scenes on the way out and answers with the version that came back. Only an editor with a window can be restarted, because the engine hands back none of the arguments it was started with. editor_status says which editor is connected and whether it is holding an old addon. Opening a project is a save and so is closing one, which is the part that is not obvious from outside: the editor imports the project on open and writes project.godot back, and Godot writes only what differs from its own defaults, so a key named deliberately at its default value is dropped either way. A restart says which under settingsDropped in its own answer because it waits for the editor to return. An open cannot, since it answers as soon as the process exists and the save happens during the import minutes later, so editor_status carries that reading once the editor has connected. Nothing else will say a key went until something depends on one. A restart of an editor this server opened is a quit and then a launch, and this server can be ended between them, usually by the reconnect the restart was being taken for: the editor is gone and nothing replaces it. That is written down before the quit, so the next server says so under restartInterrupted in editor_status rather than waiting for an editor that is not coming, and open finishes it, saying so under finishesRestart.",
15936
15971
  parameters: { projectPath: { ...PROJECT_PATH, ops: ["open"] } },
15937
15972
  requires: [],
15938
15973
  operations: {
@@ -16003,7 +16038,7 @@ var init_tool_definitions = __esm(() => {
16003
16038
  },
16004
16039
  sinceLastCall: {
16005
16040
  type: "boolean",
16006
- description: "Only entries printed since the previous editor_output. Default false."
16041
+ description: "Only entries this has not already answered with at this severity. Default false. Measured per severity floor and not across them, so polling for errors leaves the rest of the output for a later read rather than consuming it, and a read with contains is a search over the whole run and consumes nothing."
16007
16042
  },
16008
16043
  cpu: {
16009
16044
  type: "boolean",
@@ -16022,7 +16057,7 @@ var init_tool_definitions = __esm(() => {
16022
16057
  },
16023
16058
  {
16024
16059
  name: "editor_status",
16025
- description: 'Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means "not reached yet" as often as it means "no editor". True is worth waiting out; false is an editor that is not there. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it.',
16060
+ description: 'Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means "not reached yet" as often as it means "no editor". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it.',
16026
16061
  parameters: {},
16027
16062
  requires: []
16028
16063
  },
@@ -35225,57 +35260,77 @@ import { execFile as execFile4 } from "node:child_process";
35225
35260
  import { existsSync as existsSync11 } from "node:fs";
35226
35261
  import { normalize as normalize2 } from "node:path";
35227
35262
  import { promisify as promisify4 } from "node:util";
35228
- var run4, GodotLocator2;
35229
- var init_godot_path = __esm(() => {
35230
- init_detection();
35231
- run4 = promisify4(execFile4);
35232
- GodotLocator2 = class GodotLocator2 {
35233
- answered = new Map;
35234
- found = null;
35235
- async find() {
35236
- if (this.found !== null && await this.answers(this.found)) {
35237
- return this.found;
35238
- }
35239
- this.found = null;
35240
- const named = envValue("GODOT_PATH");
35241
- if (named !== undefined) {
35242
- const path = normalize2(named);
35243
- if (await this.answers(path)) {
35244
- this.found = path;
35245
- }
35246
- return this.found;
35247
- }
35248
- for (const candidate of godotCandidates(process.platform, resolveHomeDirectory())) {
35249
- const path = normalize2(candidate);
35250
- if (await this.answers(path)) {
35251
- this.found = path;
35252
- return path;
35253
- }
35263
+
35264
+ class GodotLocator2 {
35265
+ answered = new Map;
35266
+ found = null;
35267
+ async find() {
35268
+ if (this.found !== null && await this.whyNot(this.found) === null) {
35269
+ return { ok: true, path: this.found };
35270
+ }
35271
+ this.found = null;
35272
+ const named = envValue("GODOT_PATH");
35273
+ if (named !== undefined) {
35274
+ const path = normalize2(named);
35275
+ const reason = await this.whyNot(path);
35276
+ if (reason === null) {
35277
+ this.found = path;
35278
+ return { ok: true, path };
35254
35279
  }
35255
- return null;
35280
+ return { ok: false, named: path, reason };
35256
35281
  }
35257
- static ADVICE = [
35258
- "Set GODOT_PATH to the Godot 4 executable",
35259
- "Or install Godot where it is usually found, such as /usr/bin/godot or C:\\Program Files\\Godot"
35260
- ];
35261
- async answers(path) {
35262
- const known = this.answered.get(path);
35263
- if (known !== undefined) {
35264
- return known;
35282
+ for (const candidate of godotCandidates(process.platform, resolveHomeDirectory())) {
35283
+ const path = normalize2(candidate);
35284
+ if (await this.whyNot(path) === null) {
35285
+ this.found = path;
35286
+ return { ok: true, path };
35265
35287
  }
35266
- let ok = false;
35267
- if (path === "godot" || existsSync11(path)) {
35268
- try {
35269
- await run4(path, ["--version"]);
35270
- ok = true;
35271
- } catch {
35272
- ok = false;
35273
- }
35288
+ }
35289
+ return { ok: false, named: null };
35290
+ }
35291
+ static refusal(located) {
35292
+ if (located.named === null) {
35293
+ return {
35294
+ message: "No Godot executable found.",
35295
+ advice: [
35296
+ "Set GODOT_PATH to the Godot 4 executable",
35297
+ "Or install Godot where it is usually found, such as /usr/bin/godot or C:\\Program Files\\Godot"
35298
+ ]
35299
+ };
35300
+ }
35301
+ return {
35302
+ message: `GODOT_PATH is set to ${located.named}, which ${located.reason ?? "does not answer"}.`,
35303
+ advice: [
35304
+ "Point GODOT_PATH at a Godot 4 executable that answers --version",
35305
+ "Or unset it, and the usual install locations are searched instead"
35306
+ ]
35307
+ };
35308
+ }
35309
+ async whyNot(path) {
35310
+ const known = this.answered.get(path);
35311
+ if (known !== undefined) {
35312
+ return known;
35313
+ }
35314
+ let reason;
35315
+ if (path !== "godot" && !existsSync11(path)) {
35316
+ reason = "does not exist";
35317
+ } else {
35318
+ try {
35319
+ await run4(path, ["--version"]);
35320
+ reason = null;
35321
+ } catch (error) {
35322
+ reason = `does not answer --version: ${errorMessage(error)}`;
35274
35323
  }
35275
- this.answered.set(path, ok);
35276
- return ok;
35277
35324
  }
35278
- };
35325
+ this.answered.set(path, reason);
35326
+ return reason;
35327
+ }
35328
+ }
35329
+ var run4;
35330
+ var init_godot_path = __esm(() => {
35331
+ init_detection();
35332
+ init_errors();
35333
+ run4 = promisify4(execFile4);
35279
35334
  });
35280
35335
 
35281
35336
  // src/issues.ts
@@ -35344,15 +35399,36 @@ function filledTemplate2(where, message, godotVersion) {
35344
35399
  ].join(`
35345
35400
  `);
35346
35401
  }
35347
- function defectReport2(where, error, godotVersion) {
35402
+
35403
+ class DefectsSeen {
35404
+ counts = new Map;
35405
+ before(where, message) {
35406
+ const signature = defectSignature2(where, message);
35407
+ const seen = this.counts.get(signature) ?? 0;
35408
+ this.counts.set(signature, seen + 1);
35409
+ return seen;
35410
+ }
35411
+ }
35412
+ function defectReport2(where, error, context = {}) {
35413
+ const { godotVersion, seenBefore = 0 } = context;
35348
35414
  const message = errorMessage(error);
35349
35415
  const title = `Defect ${defectSignature2(where, message)}: ${where}`;
35350
35416
  const url = `${NEW_ISSUE2}?template=bug_report.md&title=${encodeURIComponent(title)}` + `&body=${encodeURIComponent(filledTemplate2(where, message, godotVersion))}`;
35351
35417
  const width = Math.max(...facts2(where, message, godotVersion).map(([label]) => (label ?? "").length));
35352
35418
  return [
35353
- "gdharness failed in a way it does not model. This is a defect in gdharness rather than",
35354
- "anything about the call: the arguments did not cause it, and sending it again will not",
35355
- "change it.",
35419
+ "gdharness failed in a way it does not model. Whatever went wrong underneath, it reached you",
35420
+ "as a crash rather than as a refusal naming what would have worked, and that much is a defect",
35421
+ "in gdharness however the rest of it turns out.",
35422
+ "",
35423
+ ...seenBefore === 0 ? [
35424
+ "Send the call once more before anything else. Some of what reaches this line is a file",
35425
+ "another process had open, or a connection that dropped, and those are gone by the next",
35426
+ "attempt. If the same Signature comes back, it is not one of those, and repeating it",
35427
+ "further only spends turns."
35428
+ ] : [
35429
+ `You have had this exact failure ${seenBefore === 1 ? "once" : `${seenBefore} times`} in this session already, so it is a`,
35430
+ "state rather than a passing one, and sending the call again will not change it."
35431
+ ],
35356
35432
  "",
35357
35433
  ...facts2(where, message, godotVersion).map(([label, value]) => ` ${(label ?? "").padEnd(width)} ${value ?? ""}`),
35358
35434
  "",
@@ -36315,11 +36391,53 @@ var init_project_scan = __esm(() => {
36315
36391
  CARRIES_A_UID = new Set(["gd", "shader", "gdshader"]);
36316
36392
  });
36317
36393
 
36394
+ // src/restart-note.ts
36395
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync13, rmSync as rmSync7, writeFileSync as writeFileSync9 } from "node:fs";
36396
+ import { dirname as dirname9, join as join15 } from "node:path";
36397
+ function restartNotePath(projectPath) {
36398
+ return join15(projectPath, ".godot", "gdharness-restart.json");
36399
+ }
36400
+ function noteRestartBegun(note) {
36401
+ const path = restartNotePath(note.projectPath);
36402
+ try {
36403
+ mkdirSync7(dirname9(path), { recursive: true });
36404
+ writeFileSync9(path, `${JSON.stringify(note, null, 2)}
36405
+ `, "utf8");
36406
+ } catch (error) {
36407
+ console.error(`[SERVER] Could not record the restart at ${path}: ${errorMessage(error)}`);
36408
+ }
36409
+ }
36410
+ function restartOwed(projectPath) {
36411
+ try {
36412
+ const parsed = JSON.parse(readFileSync13(restartNotePath(projectPath), "utf8"));
36413
+ if (typeof parsed !== "object" || parsed === null) {
36414
+ return null;
36415
+ }
36416
+ const said = parsed;
36417
+ if (typeof said.quitAt !== "string" || typeof said.byPid !== "number" || typeof said.ports !== "object") {
36418
+ return null;
36419
+ }
36420
+ return said;
36421
+ } catch {
36422
+ return null;
36423
+ }
36424
+ }
36425
+ function restartSettled(projectPath) {
36426
+ try {
36427
+ rmSync7(restartNotePath(projectPath), { force: true });
36428
+ } catch (error) {
36429
+ console.error(`[SERVER] Could not clear the restart note: ${errorMessage(error)}`);
36430
+ }
36431
+ }
36432
+ var init_restart_note = __esm(() => {
36433
+ init_errors();
36434
+ });
36435
+
36318
36436
  // src/runtime-client.ts
36319
- import { existsSync as existsSync13, readdirSync as readdirSync5, readFileSync as readFileSync13, unlinkSync } from "node:fs";
36437
+ import { existsSync as existsSync13, readdirSync as readdirSync5, readFileSync as readFileSync14, unlinkSync } from "node:fs";
36320
36438
  import { createConnection as createConnection3 } from "node:net";
36321
36439
  import { tmpdir as tmpdir4 } from "node:os";
36322
- import { join as join15, resolve as resolve4 } from "node:path";
36440
+ import { join as join16, resolve as resolve4 } from "node:path";
36323
36441
  import { setTimeout as delay2 } from "node:timers/promises";
36324
36442
  function runtimeDirectory(variables = process.env) {
36325
36443
  const explicit = envValue("GDHARNESS_RUNTIME_DIR", variables);
@@ -36327,13 +36445,13 @@ function runtimeDirectory(variables = process.env) {
36327
36445
  return explicit;
36328
36446
  }
36329
36447
  const perUser = envValue("XDG_RUNTIME_DIR", variables);
36330
- return join15(perUser ?? tmpdir4(), "gdharness");
36448
+ return join16(perUser ?? tmpdir4(), "gdharness");
36331
36449
  }
36332
36450
  function runtimeDirectories(variables = process.env) {
36333
36451
  const candidates = [runtimeDirectory(variables)];
36334
36452
  const fallbacks = process.platform === "win32" ? [envValue("SystemRoot", variables) ?? envValue("windir", variables) ?? "C:\\Windows"] : ["/tmp", "/var/tmp"];
36335
36453
  for (const base of fallbacks) {
36336
- candidates.push(join15(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
36454
+ candidates.push(join16(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
36337
36455
  }
36338
36456
  return [...new Set(candidates.map((path) => resolve4(path)))];
36339
36457
  }
@@ -36348,7 +36466,7 @@ function processAlive(pid) {
36348
36466
  function parseAnnouncement(file, pid) {
36349
36467
  let fields;
36350
36468
  try {
36351
- fields = asParams(JSON.parse(readFileSync13(file, "utf8")));
36469
+ fields = asParams(JSON.parse(readFileSync14(file, "utf8")));
36352
36470
  } catch {
36353
36471
  return { kind: "rubbish" };
36354
36472
  }
@@ -36386,6 +36504,28 @@ function runtimesAnnounced(directories = runtimeDirectories()) {
36386
36504
  function discoverRuntimes(directories = runtimeDirectories()) {
36387
36505
  return runtimesAnnounced(directories).running;
36388
36506
  }
36507
+ function wentAway(file, pid) {
36508
+ let project = "";
36509
+ try {
36510
+ const fields = asParams(JSON.parse(readFileSync14(file, "utf8")));
36511
+ project = readString(readParams(fields, "project") ?? {}, "path") ?? "";
36512
+ } catch {
36513
+ return;
36514
+ }
36515
+ if (project === "") {
36516
+ return;
36517
+ }
36518
+ gone.unshift({ pid, project, noticedAt: Date.now() });
36519
+ gone.length = Math.min(gone.length, GONE_KEPT);
36520
+ }
36521
+ function announcedAndGone(projectPath) {
36522
+ if (projectPath === undefined) {
36523
+ return [];
36524
+ }
36525
+ const since = Date.now() - GONE_WINDOW_MS;
36526
+ const wanted = resolve4(projectPath);
36527
+ return gone.filter((one) => one.noticedAt >= since && resolve4(one.project) === wanted);
36528
+ }
36389
36529
  function announcedIn(directory) {
36390
36530
  if (!existsSync13(directory)) {
36391
36531
  return [];
@@ -36396,10 +36536,14 @@ function announcedIn(directory) {
36396
36536
  if (!match) {
36397
36537
  continue;
36398
36538
  }
36399
- const file = join15(directory, entry);
36539
+ const file = join16(directory, entry);
36400
36540
  const pid = Number.parseInt(match[1] ?? "", 10);
36401
- const announced = processAlive(pid) ? parseAnnouncement(file, pid) : { kind: "rubbish" };
36541
+ const alive = processAlive(pid);
36542
+ const announced = alive ? parseAnnouncement(file, pid) : { kind: "rubbish" };
36402
36543
  if (announced.kind === "rubbish") {
36544
+ if (!alive) {
36545
+ wentAway(file, pid);
36546
+ }
36403
36547
  try {
36404
36548
  unlinkSync(file);
36405
36549
  } catch {}
@@ -36438,6 +36582,16 @@ function chooseRuntime(endpoints, projectPath, unspoken = []) {
36438
36582
  if (unspoken.length > 0) {
36439
36583
  return { problem: tooNew(unspoken) };
36440
36584
  }
36585
+ const went = announcedAndGone(projectPath);
36586
+ const first = went[0];
36587
+ if (first !== undefined) {
36588
+ const ago = Math.max(1, Math.round((Date.now() - first.noticedAt) / 1000));
36589
+ const others = went.length - 1;
36590
+ const earlier = others === 0 ? "" : ` ${others} earlier ${others === 1 ? "one" : "ones"} went the same way.`;
36591
+ return {
36592
+ problem: `A game with the runtime addon announced itself and its process is gone: pid ${first.pid}` + `${first.project === "" ? "" : ` for ${first.project}`}, noticed ${ago}s ago.${earlier}` + " It quit or was ended rather than never starting, so editor_output has what it printed" + " on the way, including whatever it broke on. Start another with editor_run once you have read it."
36593
+ };
36594
+ }
36441
36595
  return {
36442
36596
  problem: "No game with the runtime addon is running. Start one with editor_run, or play the project from the editor with the addon enabled."
36443
36597
  };
@@ -36551,43 +36705,44 @@ function runtimeRequest(endpoint, command, params, timeoutMs) {
36551
36705
  });
36552
36706
  });
36553
36707
  }
36554
- var RUNTIME_PROTOCOL = 2, ANNOUNCEMENT_PATTERN, LOOK_EVERY_MS = 50, ANNOUNCE_BUDGET_MS = 5000, nextRequestId = 1;
36708
+ var RUNTIME_PROTOCOL = 2, ANNOUNCEMENT_PATTERN, GONE_WINDOW_MS = 120000, GONE_KEPT = 32, gone, LOOK_EVERY_MS = 50, ANNOUNCE_BUDGET_MS = 5000, nextRequestId = 1;
36555
36709
  var init_runtime_client = __esm(() => {
36556
36710
  ANNOUNCEMENT_PATTERN = /^runtime-(\d+)\.json$/;
36711
+ gone = [];
36557
36712
  });
36558
36713
 
36559
36714
  // src/run-record.ts
36560
36715
  import { execFileSync } from "node:child_process";
36561
- import { mkdirSync as mkdirSync7, openSync, readdirSync as readdirSync6, readFileSync as readFileSync14, rmSync as rmSync7, statSync as statSync3, writeFileSync as writeFileSync9 } from "node:fs";
36562
- import { basename as basename2, join as join16 } from "node:path";
36716
+ import { mkdirSync as mkdirSync8, openSync, readdirSync as readdirSync6, readFileSync as readFileSync15, rmSync as rmSync8, statSync as statSync3, writeFileSync as writeFileSync10 } from "node:fs";
36717
+ import { basename as basename2, join as join17 } from "node:path";
36563
36718
  function runsDirectory() {
36564
- return join16(runtimeDirectory(), "runs");
36719
+ return join17(runtimeDirectory(), "runs");
36565
36720
  }
36566
36721
  function recordPath() {
36567
- return join16(runsDirectory(), "run.json");
36722
+ return join17(runsDirectory(), "run.json");
36568
36723
  }
36569
36724
  function recordPaths() {
36570
- return runtimeDirectories().map((directory) => join16(directory, "runs", "run.json"));
36725
+ return runtimeDirectories().map((directory) => join17(directory, "runs", "run.json"));
36571
36726
  }
36572
36727
  function openTranscript(startedAt) {
36573
- mkdirSync7(runsDirectory(), { recursive: true });
36574
- const path = join16(runsDirectory(), `run-${startedAt}.log`);
36728
+ mkdirSync8(runsDirectory(), { recursive: true });
36729
+ const path = join17(runsDirectory(), `run-${startedAt}.log`);
36575
36730
  return { path, fd: openSync(path, "a") };
36576
36731
  }
36577
36732
  function writeRunRecord(record) {
36578
- mkdirSync7(runsDirectory(), { recursive: true });
36579
- writeFileSync9(recordPath(), JSON.stringify(record, null, 2), "utf8");
36733
+ mkdirSync8(runsDirectory(), { recursive: true });
36734
+ writeFileSync10(recordPath(), JSON.stringify(record, null, 2), "utf8");
36580
36735
  }
36581
36736
  function editorNotePath() {
36582
- return join16(runsDirectory(), "editor-run.json");
36737
+ return join17(runsDirectory(), "editor-run.json");
36583
36738
  }
36584
36739
  function writeEditorRunNote(note) {
36585
- mkdirSync7(runsDirectory(), { recursive: true });
36586
- writeFileSync9(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
36740
+ mkdirSync8(runsDirectory(), { recursive: true });
36741
+ writeFileSync10(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
36587
36742
  }
36588
36743
  function readEditorRunNote() {
36589
36744
  for (const directory of runtimeDirectories()) {
36590
- const note = editorNoteAt(join16(directory, "runs", "editor-run.json"));
36745
+ const note = editorNoteAt(join17(directory, "runs", "editor-run.json"));
36591
36746
  if (note !== null) {
36592
36747
  return note;
36593
36748
  }
@@ -36597,7 +36752,7 @@ function readEditorRunNote() {
36597
36752
  function editorNoteAt(path) {
36598
36753
  let parsed;
36599
36754
  try {
36600
- parsed = JSON.parse(readFileSync14(path, "utf8"));
36755
+ parsed = JSON.parse(readFileSync15(path, "utf8"));
36601
36756
  } catch {
36602
36757
  return null;
36603
36758
  }
@@ -36620,7 +36775,7 @@ function recordRunEnded(pid, exitCode) {
36620
36775
  return;
36621
36776
  }
36622
36777
  try {
36623
- writeFileSync9(path, JSON.stringify({ ...record, exitCode }, null, 2), "utf8");
36778
+ writeFileSync10(path, JSON.stringify({ ...record, exitCode }, null, 2), "utf8");
36624
36779
  } catch {}
36625
36780
  }
36626
36781
  function readRunRecord() {
@@ -36635,7 +36790,7 @@ function readRunRecord() {
36635
36790
  function recordAt(path) {
36636
36791
  let parsed;
36637
36792
  try {
36638
- parsed = JSON.parse(readFileSync14(path, "utf8"));
36793
+ parsed = JSON.parse(readFileSync15(path, "utf8"));
36639
36794
  } catch {
36640
36795
  return null;
36641
36796
  }
@@ -36680,7 +36835,7 @@ function runningAs(pid) {
36680
36835
  const began = startedAt(pid, null);
36681
36836
  const when = began === null ? {} : { startedAt: began };
36682
36837
  if (process.platform === "linux") {
36683
- const raw = readFileSync14(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
36838
+ const raw = readFileSync15(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
36684
36839
  return raw === "" ? null : { kind: "commandLine", text: raw, ...when };
36685
36840
  }
36686
36841
  const args = execFileSync("ps", ["-p", String(pid), "-o", "args="], {
@@ -36800,7 +36955,7 @@ function clearRunRecord(ours = () => true) {
36800
36955
  continue;
36801
36956
  }
36802
36957
  try {
36803
- rmSync7(path);
36958
+ rmSync8(path);
36804
36959
  } catch {}
36805
36960
  }
36806
36961
  }
@@ -36824,12 +36979,12 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
36824
36979
  if (!entry.startsWith("run-") || !entry.endsWith(".log")) {
36825
36980
  continue;
36826
36981
  }
36827
- const path = join16(directory, entry);
36982
+ const path = join17(directory, entry);
36828
36983
  if (path === keep || ageMs(path, now) < keepMs) {
36829
36984
  continue;
36830
36985
  }
36831
36986
  try {
36832
- rmSync7(path);
36987
+ rmSync8(path);
36833
36988
  } catch {}
36834
36989
  }
36835
36990
  }
@@ -36847,15 +37002,15 @@ import {
36847
37002
  closeSync,
36848
37003
  existsSync as existsSync14,
36849
37004
  fstatSync,
36850
- mkdirSync as mkdirSync8,
37005
+ mkdirSync as mkdirSync9,
36851
37006
  mkdtempSync as mkdtempSync3,
36852
37007
  openSync as openSync2,
36853
37008
  readdirSync as readdirSync7,
36854
- readFileSync as readFileSync15,
37009
+ readFileSync as readFileSync16,
36855
37010
  readSync
36856
37011
  } from "node:fs";
36857
37012
  import { tmpdir as tmpdir5 } from "node:os";
36858
- import { basename as basename3, dirname as dirname9, join as join17, normalize as normalize3 } from "node:path";
37013
+ import { basename as basename3, dirname as dirname10, join as join18, normalize as normalize3 } from "node:path";
36859
37014
  import { setTimeout as delay3 } from "node:timers/promises";
36860
37015
  import { fileURLToPath as fileURLToPath4 } from "node:url";
36861
37016
  import { promisify as promisify6 } from "node:util";
@@ -37010,7 +37165,7 @@ function aboveTheRunner(entry) {
37010
37165
  }
37011
37166
  function elsewhereIn(projectPath, asked) {
37012
37167
  const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
37013
- if (wanted === "" || existsSync14(join17(projectPath, wanted))) {
37168
+ if (wanted === "" || existsSync14(join18(projectPath, wanted))) {
37014
37169
  return "";
37015
37170
  }
37016
37171
  const root = (wanted.split("/")[0] ?? "").toLowerCase();
@@ -37024,11 +37179,11 @@ function elsewhereIn(projectPath, asked) {
37024
37179
  return near.length === 0 ? "" : ` The project does have ${there}.`;
37025
37180
  }
37026
37181
  function hasMainScene(projectFile) {
37027
- const scene = parseProjectGodot(readFileSync15(projectFile, "utf8"))["application"]?.["run/main_scene"];
37182
+ const scene = parseProjectGodot(readFileSync16(projectFile, "utf8"))["application"]?.["run/main_scene"];
37028
37183
  return typeof scene === "string" && scene !== "";
37029
37184
  }
37030
37185
  function editorPlaysHeadless(projectFile) {
37031
- const runArgs = parseProjectGodot(readFileSync15(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
37186
+ const runArgs = parseProjectGodot(readFileSync16(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
37032
37187
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
37033
37188
  }
37034
37189
  function camelCased(params) {
@@ -37044,7 +37199,7 @@ function camelCased(params) {
37044
37199
  class GodotServer {
37045
37200
  mcp;
37046
37201
  locator = new GodotLocator2;
37047
- operationsScript = join17(__dirname2, "godot", "operations", "godot_operations.gd");
37202
+ operationsScript = join18(__dirname2, "godot", "operations", "godot_operations.gd");
37048
37203
  godotBridge;
37049
37204
  tools = buildToolDefinitions();
37050
37205
  activeProcess = null;
@@ -37052,6 +37207,8 @@ class GodotServer {
37052
37207
  noticedUpdate = false;
37053
37208
  callsSinceNotice = 0;
37054
37209
  callsSinceFeedback = 0;
37210
+ defectsSeen = new DefectsSeen;
37211
+ lastGodotVersion = null;
37055
37212
  lspClient = null;
37056
37213
  dapClient = null;
37057
37214
  bridgeStartupError = null;
@@ -37096,11 +37253,11 @@ class GodotServer {
37096
37253
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
37097
37254
  }
37098
37255
  async run() {
37099
- const godotPath = await this.locator.find();
37100
- if (godotPath === null) {
37101
- console.error("[SERVER] No Godot found. Set GODOT_PATH; until then every tool that runs the engine will say so.");
37256
+ const located = await this.locator.find();
37257
+ if (!located.ok) {
37258
+ console.error(`[SERVER] ${GodotLocator2.refusal(located).message} Until that is fixed every tool that runs the engine will say so.`);
37102
37259
  } else {
37103
- console.error(`[SERVER] Using Godot at: ${godotPath}`);
37260
+ console.error(`[SERVER] Using Godot at: ${located.path}`);
37104
37261
  }
37105
37262
  const transport = new StdioServerTransport;
37106
37263
  await this.mcp.server.connect(transport);
@@ -37300,7 +37457,19 @@ class GodotServer {
37300
37457
  }
37301
37458
  const where = op === "" ? tool : `${tool} op=${op}`;
37302
37459
  console.error(`[SERVER] Unmodelled failure in ${where}:`, error);
37303
- return { content: [{ type: "text", text: defectReport2(where, error) }], isError: true };
37460
+ const seenBefore = this.defectsSeen.before(where, errorMessage(error));
37461
+ return {
37462
+ content: [
37463
+ {
37464
+ type: "text",
37465
+ text: defectReport2(where, error, {
37466
+ seenBefore,
37467
+ godotVersion: this.lastGodotVersion ?? undefined
37468
+ })
37469
+ }
37470
+ ],
37471
+ isError: true
37472
+ };
37304
37473
  }
37305
37474
  }
37306
37475
  withUpdateNotice(answer) {
@@ -37661,21 +37830,19 @@ class GodotServer {
37661
37830
  }
37662
37831
  }
37663
37832
  async engine() {
37664
- const godotPath = await this.locator.find();
37665
- if (godotPath === null) {
37666
- return {
37667
- ok: false,
37668
- response: this.createErrorResponse("No Godot executable found.", GodotLocator2.ADVICE)
37669
- };
37833
+ const located = await this.locator.find();
37834
+ if (!located.ok) {
37835
+ const refusal = GodotLocator2.refusal(located);
37836
+ return { ok: false, response: this.createErrorResponse(refusal.message, [...refusal.advice]) };
37670
37837
  }
37671
- return { ok: true, value: godotPath };
37838
+ return { ok: true, value: located.path };
37672
37839
  }
37673
37840
  project(args) {
37674
37841
  const path = readNonEmptyString(args, "projectPath");
37675
37842
  if (path === undefined) {
37676
37843
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
37677
37844
  }
37678
- const file = join17(path, "project.godot");
37845
+ const file = join18(path, "project.godot");
37679
37846
  if (!existsSync14(file)) {
37680
37847
  return {
37681
37848
  ok: false,
@@ -37817,7 +37984,7 @@ class GodotServer {
37817
37984
  }
37818
37985
  const engine = await this.engine();
37819
37986
  if (!engine.ok) {
37820
- return this.createErrorResponse("No Godot executable found.");
37987
+ return engine.response;
37821
37988
  }
37822
37989
  const projectPath = project.value.path;
37823
37990
  const before = scriptsWithoutUid(projectPath);
@@ -37840,7 +38007,11 @@ class GodotServer {
37840
38007
  async operation(operation, params, projectPath) {
37841
38008
  const engine = await this.engine();
37842
38009
  if (!engine.ok) {
37843
- return { ok: false, message: "No Godot executable found.", messages: [] };
38010
+ return {
38011
+ ok: false,
38012
+ message: engine.response.content.map((block) => block.text).join(" "),
38013
+ messages: []
38014
+ };
37844
38015
  }
37845
38016
  this.logDebug(`Running ${operation} in ${projectPath}: ${JSON.stringify(params)}`);
37846
38017
  return await runOperation2({ godotPath: engine.value, script: this.operationsScript, debug: GODOT_DEBUG_MODE_DEFAULT }, operation, params, projectPath);
@@ -37872,7 +38043,7 @@ class GodotServer {
37872
38043
  if (!engine.ok) {
37873
38044
  return engine.response;
37874
38045
  }
37875
- const application = parseProjectGodot(readFileSync15(project.value.file, "utf8"))["application"] ?? {};
38046
+ const application = parseProjectGodot(readFileSync16(project.value.file, "utf8"))["application"] ?? {};
37876
38047
  const name = application["config/name"];
37877
38048
  const mainScene = application["run/main_scene"];
37878
38049
  const info = {
@@ -37933,18 +38104,18 @@ class GodotServer {
37933
38104
  if (!engine.ok) {
37934
38105
  return engine.response;
37935
38106
  }
37936
- const folder = dirname9(output.absolutePath);
38107
+ const folder = dirname10(output.absolutePath);
37937
38108
  try {
37938
- mkdirSync8(folder, { recursive: true });
38109
+ mkdirSync9(folder, { recursive: true });
37939
38110
  } catch (error) {
37940
- return this.createErrorResponse(`Cannot create ${dirname9(output.relativePath)} to export into: ${errorMessage(error)}`);
38111
+ return this.createErrorResponse(`Cannot create ${dirname10(output.relativePath)} to export into: ${errorMessage(error)}`);
37941
38112
  }
37942
38113
  const debug = readBoolean(args, "debug") ?? false;
37943
- const exportLogs = mkdtempSync3(join17(tmpdir5(), "gdharness-export-"));
38114
+ const exportLogs = mkdtempSync3(join18(tmpdir5(), "gdharness-export-"));
37944
38115
  const exportArgs = [
37945
38116
  "--headless",
37946
38117
  "--log-file",
37947
- join17(exportLogs, "engine.log"),
38118
+ join18(exportLogs, "engine.log"),
37948
38119
  "--path",
37949
38120
  project.value.path,
37950
38121
  debug ? "--export-debug" : "--export-release",
@@ -38002,7 +38173,7 @@ class GodotServer {
38002
38173
  return contained.response;
38003
38174
  }
38004
38175
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
38005
- if (!existsSync14(join17(project.value.path, runner))) {
38176
+ if (!existsSync14(join18(project.value.path, runner))) {
38006
38177
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
38007
38178
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
38008
38179
  ]);
@@ -38037,7 +38208,7 @@ class GodotServer {
38037
38208
  ];
38038
38209
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
38039
38210
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
38040
- const userData = mkdtempSync3(join17(tmpdir5(), "gdharness-tests-"));
38211
+ const userData = mkdtempSync3(join18(tmpdir5(), "gdharness-tests-"));
38041
38212
  const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
38042
38213
  const hung = await new Promise((resolve) => {
38043
38214
  const timer = setTimeout(() => {
@@ -38053,14 +38224,14 @@ class GodotServer {
38053
38224
  resolve(false);
38054
38225
  });
38055
38226
  });
38056
- const reportsDir = join17(project.value.path, ".godot", ...ours.split("/"));
38227
+ const reportsDir = join18(project.value.path, ".godot", ...ours.split("/"));
38057
38228
  let report = null;
38058
38229
  let reportProblem = null;
38059
38230
  try {
38060
38231
  const written = existsSync14(reportsDir) ? readdirSync7(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
38061
38232
  const newest = written.at(-1);
38062
38233
  if (newest !== undefined) {
38063
- report = parseJUnit(readFileSync15(join17(reportsDir, newest, "results.xml"), "utf8"));
38234
+ report = parseJUnit(readFileSync16(join18(reportsDir, newest, "results.xml"), "utf8"));
38064
38235
  }
38065
38236
  } catch (error) {
38066
38237
  reportProblem = errorMessage(error);
@@ -38081,6 +38252,7 @@ class GodotServer {
38081
38252
  const said = printed.map((entry) => entry.text);
38082
38253
  const nothingRan = hung ? null : whyNoReport(said, asked);
38083
38254
  const verdict = hung ? `hung: killed after ${timeoutMs} ms` : nothingRan ?? verdicts[exitCode ?? -1] ?? `exit ${exitCode ?? "unknown"}`;
38255
+ const savesNote = savesStayPut() && nothingRan === null ? SAVES_NOT_MOVED_NOTE : undefined;
38084
38256
  if (report === null) {
38085
38257
  const note = nothingRan === null ? `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).` : `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}`;
38086
38258
  return {
@@ -38095,7 +38267,8 @@ class GodotServer {
38095
38267
  exitCode,
38096
38268
  hung,
38097
38269
  arguments: cmdArgs,
38098
- entries: forAnswer(printed.slice(0, 60))
38270
+ entries: forAnswer(printed.slice(0, 60)),
38271
+ savesNote
38099
38272
  }, null, 2)
38100
38273
  }
38101
38274
  ],
@@ -38128,6 +38301,7 @@ class GodotServer {
38128
38301
  warnings: warnings.length > 0 ? warnings : undefined,
38129
38302
  orphans: orphans.total > 0 ? orphans.total : undefined,
38130
38303
  notRun: notRun > 0 ? notRun : undefined,
38304
+ savesNote,
38131
38305
  note: verdict.startsWith("warnings") && warnings.length === 0 ? "gdUnit4 exits 101 for orphan nodes when nothing failed, and this run printed no count of them: orphan reporting may be off in the project settings." : notRun > 0 ? `This run stopped at the first failure in each suite it failed in, so ${notRun} case${notRun === 1 ? "" : "s"} never ran and count as neither passed nor failed. Leave failFast out to run every case.` : undefined,
38132
38306
  suites: unclean.map((suite) => ({
38133
38307
  name: suite.name,
@@ -38150,7 +38324,8 @@ class GodotServer {
38150
38324
  async godotVersion(godotPath) {
38151
38325
  try {
38152
38326
  const { stdout } = await run6(godotPath, ["--version"], { timeout: 1e4 });
38153
- return stdout.trim();
38327
+ this.lastGodotVersion = stdout.trim();
38328
+ return this.lastGodotVersion;
38154
38329
  } catch (error) {
38155
38330
  this.logDebug(`Godot did not answer --version: ${errorMessage(error)}`);
38156
38331
  return null;
@@ -38196,7 +38371,7 @@ class GodotServer {
38196
38371
  return null;
38197
38372
  }
38198
38373
  try {
38199
- return readFileSync15(contained.absolutePath, "utf8");
38374
+ return readFileSync16(contained.absolutePath, "utf8");
38200
38375
  } catch {
38201
38376
  return null;
38202
38377
  }
@@ -38238,7 +38413,7 @@ class GodotServer {
38238
38413
  return null;
38239
38414
  }
38240
38415
  try {
38241
- return readFileSync15(contained.absolutePath, "utf8");
38416
+ return readFileSync16(contained.absolutePath, "utf8");
38242
38417
  } catch {
38243
38418
  return null;
38244
38419
  }
@@ -38308,16 +38483,37 @@ class GodotServer {
38308
38483
  const status = this.godotBridge.getStatus();
38309
38484
  return (status.connected ? status[field] : undefined) ?? fallback;
38310
38485
  }
38486
+ restartLeftUnfinished(connected) {
38487
+ if (this.ownProject === null) {
38488
+ return null;
38489
+ }
38490
+ const owed = restartOwed(this.ownProject);
38491
+ if (owed === null) {
38492
+ return null;
38493
+ }
38494
+ if (connected) {
38495
+ restartSettled(this.ownProject);
38496
+ return null;
38497
+ }
38498
+ return owed;
38499
+ }
38311
38500
  getEditorStatusPayload() {
38312
38501
  const status = this.godotBridge.getStatus();
38313
38502
  const isPortConflict = this.bridgeStartupError?.includes("EADDRINUSE") ?? false;
38314
38503
  const stale = status.connected && status.addonVersion !== SERVER_VERSION;
38504
+ const unfinished = this.restartLeftUnfinished(status.connected);
38315
38505
  return {
38316
38506
  ...status,
38317
38507
  serverVersion: SERVER_VERSION,
38318
38508
  addonIsStale: status.connected ? stale : undefined,
38319
38509
  bridgeAvailable: this.bridgeStartupError === null,
38320
- mayYetConnect: status.connected ? undefined : this.anEditorIsStillComing(status.listeningSince),
38510
+ mayYetConnect: status.connected ? undefined : unfinished !== null ? false : this.anEditorIsStillComing(status.listeningSince),
38511
+ restartInterrupted: unfinished === null ? undefined : {
38512
+ quitEditorPid: unfinished.editorPid,
38513
+ quitAt: unfinished.quitAt,
38514
+ byServerPid: unfinished.byPid,
38515
+ note: "A restart begun by a previous gdharness server asked this project's editor to quit, and that server was ended before it could start the editor again, so nothing is coming. editor_launch open starts one, and clears this."
38516
+ },
38321
38517
  awaitingLaunchedEditor: status.connected || !this.launchedEditorIsUp() ? undefined : this.launchedEditor?.pid ?? undefined,
38322
38518
  ...status.connected ? this.whatTheLaunchedEditorDropped() : {},
38323
38519
  startupError: this.bridgeStartupError,
@@ -38353,7 +38549,7 @@ class GodotServer {
38353
38549
  }
38354
38550
  }
38355
38551
  async handleEditorStatus() {
38356
- const godotPath = await this.locator.find();
38552
+ const located = await this.locator.find();
38357
38553
  const announced = runtimesAnnounced();
38358
38554
  const games = await Promise.all(announced.running.map(async (endpoint) => {
38359
38555
  const reply = await runtimeRequest(endpoint, "ping", {}, this.runtimeTimeoutMs());
@@ -38373,8 +38569,9 @@ class GodotServer {
38373
38569
  debugPort: playing?.debugPort ?? this.godotBridge.getStatus().debugPort
38374
38570
  },
38375
38571
  godot: {
38376
- path: godotPath,
38377
- version: godotPath === null ? null : await this.godotVersion(godotPath)
38572
+ path: located.ok ? located.path : null,
38573
+ version: located.ok ? await this.godotVersion(located.path) : null,
38574
+ problem: located.ok ? undefined : GodotLocator2.refusal(located).message
38378
38575
  },
38379
38576
  game: {
38380
38577
  processActive: runIsUp(this.currentRun(), playing === null ? null : playing.playing),
@@ -38393,12 +38590,12 @@ class GodotServer {
38393
38590
  }
38394
38591
  settingKeysOf(projectPath) {
38395
38592
  if (projectPath === undefined) {
38396
- return new Map;
38593
+ return null;
38397
38594
  }
38398
38595
  try {
38399
- return settingKeys(readFileSync15(join17(projectPath, "project.godot"), "utf8"));
38596
+ return settingKeys(readFileSync16(join18(projectPath, "project.godot"), "utf8"));
38400
38597
  } catch {
38401
- return new Map;
38598
+ return null;
38402
38599
  }
38403
38600
  }
38404
38601
  async handleRestartEditor() {
@@ -38458,12 +38655,21 @@ class GodotServer {
38458
38655
  lsp: this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT),
38459
38656
  dap: this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT)
38460
38657
  };
38658
+ noteRestartBegun({
38659
+ projectPath,
38660
+ editorPid: editorPid ?? null,
38661
+ ports,
38662
+ quitAt: new Date().toISOString(),
38663
+ byPid: process.pid
38664
+ });
38461
38665
  const asked = await this.handleViaBridge("quit_editor", {});
38462
38666
  if (asked.isError === true) {
38667
+ restartSettled(projectPath);
38463
38668
  return asked;
38464
38669
  }
38465
38670
  await this.waitForBridge(() => !alive(editorPid), Date.now() + EDITOR_RESTART_TIMEOUT_MS);
38466
38671
  const opened = await this.openAnEditor(engine.value, projectPath, ports);
38672
+ restartSettled(projectPath);
38467
38673
  if (opened.error !== null) {
38468
38674
  return this.createErrorResponse(`The editor was asked to go and could not be started again: ${opened.error}`, [`editor_launch opens one on ${projectPath}`]);
38469
38675
  }
@@ -38499,10 +38705,12 @@ class GodotServer {
38499
38705
  this.logDebug(`Launching Godot editor for project: ${project.value.path}`);
38500
38706
  const ports = await this.portsForAnEditor();
38501
38707
  const before = this.settingKeysOf(project.value.path);
38708
+ const unfinished = restartOwed(project.value.path);
38502
38709
  const opened = await this.openAnEditor(engine.value, project.value.path, ports);
38503
38710
  if (opened.error !== null) {
38504
38711
  return this.createErrorResponse(`Could not start the editor: ${opened.error}`);
38505
38712
  }
38713
+ restartSettled(project.value.path);
38506
38714
  this.launchedFrom = { projectPath: project.value.path, before };
38507
38715
  return this.jsonTextResponse({
38508
38716
  launched: true,
@@ -38510,7 +38718,8 @@ class GodotServer {
38510
38718
  projectPath: project.value.path,
38511
38719
  lspPort: ports.lsp,
38512
38720
  dapPort: ports.dap,
38513
- settingsNote: before.size === 0 ? undefined : "Opening a project imports it and saves project.godot, and Godot drops any key sitting at its own default. editor_status names anything lost under settingsDropped once this editor has connected."
38721
+ finishesRestart: unfinished === null ? undefined : `This is the launch half of a restart begun at ${unfinished.quitAt} by server pid ${unfinished.byPid}, which quit editor pid ${unfinished.editorPid ?? "unknown"} and was ended before it could start one.`,
38722
+ settingsNote: before === null ? "project.godot could not be read before this editor was opened, so nothing here will be able to say what the import saved away. Opening a project rewrites the file and drops any key sitting at its own default." : before.size === 0 ? undefined : "Opening a project imports it and saves project.godot, and Godot drops any key sitting at its own default. editor_status names anything lost under settingsDropped once this editor has connected."
38514
38723
  });
38515
38724
  }
38516
38725
  async openAnEditor(engine, projectPath, ports) {
@@ -38627,7 +38836,7 @@ class GodotServer {
38627
38836
  return runIsUp(run, editorSays);
38628
38837
  }
38629
38838
  async runtimeUp(projectPath, before, budgetMs, withArgs) {
38630
- if (!existsSync14(join17(projectPath, RUNTIME_AUTOLOAD2.path))) {
38839
+ if (!existsSync14(join18(projectPath, RUNTIME_AUTOLOAD2.path))) {
38631
38840
  return runtimeVerdict(null, { addon: false, budgetMs, heldAt: null, running: false, withArgs });
38632
38841
  }
38633
38842
  const endpoint = await announcedSince(projectPath, before, {
@@ -39295,8 +39504,8 @@ class GodotServer {
39295
39504
  return this.createErrorResponse(choice.problem);
39296
39505
  }
39297
39506
  const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
39298
- const screenshotDir = expectsScreenshot ? mkdtempSync3(join17(tmpdir5(), "gdharness-runtime-screenshot-")) : null;
39299
- const screenshotPath = screenshotDir ? join17(screenshotDir, "capture.png") : null;
39507
+ const screenshotDir = expectsScreenshot ? mkdtempSync3(join18(tmpdir5(), "gdharness-runtime-screenshot-")) : null;
39508
+ const screenshotPath = screenshotDir ? join18(screenshotDir, "capture.png") : null;
39300
39509
  try {
39301
39510
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
39302
39511
  if (!reply.ok) {
@@ -39317,7 +39526,7 @@ class GodotServer {
39317
39526
  return {
39318
39527
  content: [
39319
39528
  { type: "text", text: `Screenshot captured: ${dimensions}` },
39320
- { type: "image", data: readFileSync15(screenshotPath).toString("base64"), mimeType: "image/png" }
39529
+ { type: "image", data: readFileSync16(screenshotPath).toString("base64"), mimeType: "image/png" }
39321
39530
  ]
39322
39531
  };
39323
39532
  } finally {
@@ -39406,6 +39615,7 @@ var init_server3 = __esm(() => {
39406
39615
  init_process_time();
39407
39616
  init_project_scan();
39408
39617
  init_resources();
39618
+ init_restart_note();
39409
39619
  init_run_record();
39410
39620
  init_runtime_client();
39411
39621
  init_scratch();
@@ -39414,7 +39624,7 @@ var init_server3 = __esm(() => {
39414
39624
  init_tool_definitions();
39415
39625
  init_update_check();
39416
39626
  run6 = promisify6(execFile6);
39417
- __dirname2 = dirname9(fileURLToPath4(import.meta.url));
39627
+ __dirname2 = dirname10(fileURLToPath4(import.meta.url));
39418
39628
  VALUE_NAMES = {
39419
39629
  string: "a string",
39420
39630
  number: "a number",
@@ -39453,7 +39663,7 @@ var init_server3 = __esm(() => {
39453
39663
 
39454
39664
  // src/cli.ts
39455
39665
  import { existsSync as existsSync15 } from "fs";
39456
- import { join as join18, resolve as resolve5 } from "path";
39666
+ import { join as join19, resolve as resolve5 } from "path";
39457
39667
 
39458
39668
  // src/errors.ts
39459
39669
  class Refusal extends Error {
@@ -39461,6 +39671,7 @@ class Refusal extends Error {
39461
39671
 
39462
39672
  // src/godot-path.ts
39463
39673
  init_detection();
39674
+ init_errors();
39464
39675
  import { execFile } from "node:child_process";
39465
39676
  import { existsSync as existsSync2 } from "node:fs";
39466
39677
  import { normalize } from "node:path";
@@ -39471,47 +39682,65 @@ class GodotLocator {
39471
39682
  answered = new Map;
39472
39683
  found = null;
39473
39684
  async find() {
39474
- if (this.found !== null && await this.answers(this.found)) {
39475
- return this.found;
39685
+ if (this.found !== null && await this.whyNot(this.found) === null) {
39686
+ return { ok: true, path: this.found };
39476
39687
  }
39477
39688
  this.found = null;
39478
39689
  const named = envValue("GODOT_PATH");
39479
39690
  if (named !== undefined) {
39480
39691
  const path = normalize(named);
39481
- if (await this.answers(path)) {
39692
+ const reason = await this.whyNot(path);
39693
+ if (reason === null) {
39482
39694
  this.found = path;
39695
+ return { ok: true, path };
39483
39696
  }
39484
- return this.found;
39697
+ return { ok: false, named: path, reason };
39485
39698
  }
39486
39699
  for (const candidate of godotCandidates(process.platform, resolveHomeDirectory())) {
39487
39700
  const path = normalize(candidate);
39488
- if (await this.answers(path)) {
39701
+ if (await this.whyNot(path) === null) {
39489
39702
  this.found = path;
39490
- return path;
39703
+ return { ok: true, path };
39491
39704
  }
39492
39705
  }
39493
- return null;
39706
+ return { ok: false, named: null };
39494
39707
  }
39495
- static ADVICE = [
39496
- "Set GODOT_PATH to the Godot 4 executable",
39497
- "Or install Godot where it is usually found, such as /usr/bin/godot or C:\\Program Files\\Godot"
39498
- ];
39499
- async answers(path) {
39708
+ static refusal(located) {
39709
+ if (located.named === null) {
39710
+ return {
39711
+ message: "No Godot executable found.",
39712
+ advice: [
39713
+ "Set GODOT_PATH to the Godot 4 executable",
39714
+ "Or install Godot where it is usually found, such as /usr/bin/godot or C:\\Program Files\\Godot"
39715
+ ]
39716
+ };
39717
+ }
39718
+ return {
39719
+ message: `GODOT_PATH is set to ${located.named}, which ${located.reason ?? "does not answer"}.`,
39720
+ advice: [
39721
+ "Point GODOT_PATH at a Godot 4 executable that answers --version",
39722
+ "Or unset it, and the usual install locations are searched instead"
39723
+ ]
39724
+ };
39725
+ }
39726
+ async whyNot(path) {
39500
39727
  const known = this.answered.get(path);
39501
39728
  if (known !== undefined) {
39502
39729
  return known;
39503
39730
  }
39504
- let ok = false;
39505
- if (path === "godot" || existsSync2(path)) {
39731
+ let reason;
39732
+ if (path !== "godot" && !existsSync2(path)) {
39733
+ reason = "does not exist";
39734
+ } else {
39506
39735
  try {
39507
39736
  await run(path, ["--version"]);
39508
- ok = true;
39509
- } catch {
39510
- ok = false;
39737
+ reason = null;
39738
+ } catch (error) {
39739
+ reason = `does not answer --version: ${errorMessage(error)}`;
39511
39740
  }
39512
39741
  }
39513
- this.answered.set(path, ok);
39514
- return ok;
39742
+ this.answered.set(path, reason);
39743
+ return reason;
39515
39744
  }
39516
39745
  }
39517
39746
 
@@ -40291,15 +40520,27 @@ function disconnect(harness, projectPath) {
40291
40520
  `, "utf8");
40292
40521
  return { harness, path, action: "removed" };
40293
40522
  }
40294
- function harnessNote(moved, now) {
40295
- if (moved.length === 0) {
40523
+ function harnessNote(repinned, now) {
40524
+ const alsoByHand = repinned.byHand === 0 ? "" : `
40525
+ ${repinned.byHand === 1 ? "One harness above was" : `${repinned.byHand} harnesses above were`} handed a command to run instead; run it too.`;
40526
+ if (repinned.moved.length > 0) {
40527
+ return ` 2. Your harness is still running the server it spawned from ${repinned.moved.join(", ")}.
40528
+ ` + ` Its config names ${now} now.
40529
+ ` + ` Reconnect the MCP server, or restart the harness.${alsoByHand}`;
40530
+ }
40531
+ if (repinned.written > 0) {
40296
40532
  return ` 2. Your harness config already named this version, but the server it is running is
40297
40533
  ` + ` whichever one it spawned when the session started, which this cannot see from here.
40298
- ` + " Reconnect the MCP server, or restart the harness.";
40534
+ ` + ` Reconnect the MCP server, or restart the harness.${alsoByHand}`;
40299
40535
  }
40300
- return ` 2. Your harness is still running the server it spawned from ${moved.join(", ")}.
40301
- ` + ` Its config names ${now} now.
40302
- ` + " Reconnect the MCP server, or restart the harness.";
40536
+ if (repinned.byHand > 0) {
40537
+ return ` 2. None of the harness configs found here can be written for you, so nothing was
40538
+ ` + ` re-pinned and a reconnect on its own would come back on the same server.
40539
+ ` + " Run what is printed above, then reconnect the MCP server or restart the harness.";
40540
+ }
40541
+ return ` 2. No harness config here names gdharness, so nothing was re-pinned and there is no
40542
+ ` + ` config for a reconnect to read. Run gdharness setup to write one, or point your
40543
+ ` + ` harness at ${now} yourself, then restart it.`;
40303
40544
  }
40304
40545
  function launchedBy(entry) {
40305
40546
  if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
@@ -40515,15 +40756,26 @@ function filledTemplate(where, message, godotVersion) {
40515
40756
  ].join(`
40516
40757
  `);
40517
40758
  }
40518
- function defectReport(where, error, godotVersion) {
40759
+ function defectReport(where, error, context = {}) {
40760
+ const { godotVersion, seenBefore = 0 } = context;
40519
40761
  const message = errorMessage(error);
40520
40762
  const title = `Defect ${defectSignature(where, message)}: ${where}`;
40521
40763
  const url = `${NEW_ISSUE}?template=bug_report.md&title=${encodeURIComponent(title)}` + `&body=${encodeURIComponent(filledTemplate(where, message, godotVersion))}`;
40522
40764
  const width = Math.max(...facts(where, message, godotVersion).map(([label]) => (label ?? "").length));
40523
40765
  return [
40524
- "gdharness failed in a way it does not model. This is a defect in gdharness rather than",
40525
- "anything about the call: the arguments did not cause it, and sending it again will not",
40526
- "change it.",
40766
+ "gdharness failed in a way it does not model. Whatever went wrong underneath, it reached you",
40767
+ "as a crash rather than as a refusal naming what would have worked, and that much is a defect",
40768
+ "in gdharness however the rest of it turns out.",
40769
+ "",
40770
+ ...seenBefore === 0 ? [
40771
+ "Send the call once more before anything else. Some of what reaches this line is a file",
40772
+ "another process had open, or a connection that dropped, and those are gone by the next",
40773
+ "attempt. If the same Signature comes back, it is not one of those, and repeating it",
40774
+ "further only spends turns."
40775
+ ] : [
40776
+ `You have had this exact failure ${seenBefore === 1 ? "once" : `${seenBefore} times`} in this session already, so it is a`,
40777
+ "state rather than a passing one, and sending the call again will not change it."
40778
+ ],
40527
40779
  "",
40528
40780
  ...facts(where, message, godotVersion).map(([label, value]) => ` ${(label ?? "").padEnd(width)} ${value ?? ""}`),
40529
40781
  "",
@@ -40660,14 +40912,14 @@ function loaderAmong(projectPath, named) {
40660
40912
  if (entry === RUNTIME_AUTOLOAD.name || path === RUNTIME_AUTOLOAD.path) {
40661
40913
  continue;
40662
40914
  }
40663
- let source = "";
40915
+ let source = null;
40664
40916
  try {
40665
40917
  source = readFileSync7(join8(projectPath, path), "utf8");
40666
40918
  } catch {}
40667
- if (source.includes(RUNTIME_AUTOLOAD.path)) {
40668
- return { name: entry, path };
40919
+ if (source?.includes(RUNTIME_AUTOLOAD.path)) {
40920
+ return { name: entry, path, exists: true };
40669
40921
  }
40670
- byName ??= path.toLowerCase().includes("gdharness") ? { name: entry, path } : null;
40922
+ byName ??= path.toLowerCase().includes("gdharness") ? { name: entry, path, exists: source !== null } : null;
40671
40923
  }
40672
40924
  return byName;
40673
40925
  }
@@ -40730,8 +40982,15 @@ function inspectProject(projectPath) {
40730
40982
  const runtimeLoaderAutoload = loaderAmong(projectPath, named);
40731
40983
  const runtimeAutoload = runtimeAutoloadPath !== null || runtimeLoaderAutoload !== null;
40732
40984
  const tracked = trackedByGit(projectPath, [...named.values()]);
40985
+ const addonMissing = (path) => addons.some((addon) => !addon.installed && path.startsWith(`addons/${addon.name}/`));
40733
40986
  for (const [name, path] of named) {
40734
- if (tracked !== null && !tracked.has(path) && existsSync7(join8(projectPath, path))) {
40987
+ if (!existsSync7(join8(projectPath, path))) {
40988
+ if (!addonMissing(path)) {
40989
+ problems.push(`the ${name} autoload names res://${path}, which is not in this project, so it boots with a missing script; restore that file, or point the autoload at one that is here`);
40990
+ }
40991
+ continue;
40992
+ }
40993
+ if (tracked !== null && !tracked.has(path)) {
40735
40994
  problems.push(`the ${name} autoload names res://${path}, which git does not carry, so a clone boots with a missing script; commit that file, or point the autoload at one the project tracks`);
40736
40995
  }
40737
40996
  }
@@ -40906,10 +41165,12 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
40906
41165
  | --- | --- |
40907
41166
  | What a screen says | \`runtime_inspect text\`, which reads what is drawn and leaves out what is hidden. Read \`omitted\`: a long list above a dialog pushes it past the limit, and what comes back then is the top of the screen rather than the screen |
40908
41167
  | Find a control by the word on it | \`runtime_inspect find\` with \`says\`, rather than listing a screen and reading each one |
41168
+ | What a control is showing | \`runtime_inspect find\` with \`property\` as well: the value comes back on each match in the same answer, so the game cannot tick between finding the node and reading it. A \`find\` followed by a \`property\` read of the path it answered is two calls with a frame between them, and a panel that rebuilds itself answers the second with "Node not found" |
40909
41169
  | Where a control is, and whether it is visible | \`runtime_inspect\` \`find\`, \`rect\` |
40910
- | What a property reads right now | \`runtime_inspect\` \`property\`, \`runtime_invoke\` |
41170
+ | What a property reads right now, on a node whose path you already hold | \`runtime_inspect\` \`property\`, \`runtime_invoke\` |
40911
41171
  | What a property on an object a node holds reads | the same two, with colons: \`_game:clock:speed\` |
40912
41172
  | Press a button | \`runtime_input click\`, which says what was under the pointer |
41173
+ | Press the next thing after a click that changed screen | \`runtime_wait until\` with \`says\` and a word the new screen shows, then the click. A click sent straight after a screen change lands on a control the new screen is about to free, answers \`landed: true\`, and does nothing; a frame count is a number nobody can pick correctly across a cold start and a warm one, and waiting for the words answered in about 130ms where twenty frames had not been enough |
40913
41174
  | Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\` with \`replace\`; a submitted field has to be clicked again |
40914
41175
  | Click somebody standing in a 3D room | \`runtime_input click\` on the Node3D, which aims at what it draws |
40915
41176
  | Pick something out of a dropdown | \`runtime_input choose\` on it, by what the item says |
@@ -40965,12 +41226,15 @@ its own copy. So a long run is not a reason to stop editing, and the new code ar
40965
41226
  A wall of "Could not find type" on the project's own classes means the editor is not holding
40966
41227
  them. \`editor_rescan\` asks it to scan and names any class it still cannot resolve under
40967
41228
  \`unseenByEditor\`: its walk skips a file a headless engine has already imported, so the
40968
- declaration and the cache can both be right while the editor stays blind to it. Change the
40969
- declaring script, or restart the editor.
41229
+ declaration and the cache can both be right while the editor stays blind to it. Rescan again on
41230
+ its own, which is the measured cure and needs no change to the declaring script; \`editor_launch
41231
+ restart\` also does it and costs a window.
40970
41232
 
40971
41233
  The scan writes \`.godot/global_script_class_cache.cfg\` from the list the editor is holding, so
40972
- a class it cannot resolve is dropped out of that file too, and the next fresh engine, CI run or
40973
- clone starts from the narrower one. That is the same class \`unseenByEditor\` names.
41234
+ a class it cannot resolve would go out of that file with it. The answer names that loss under
41235
+ \`cacheLost\` and rebuilds the cache from the declarations on disk, naming what came back under
41236
+ \`cacheRestored\`, so no fresh engine, CI run or clone inherits the short file. That is the same
41237
+ class \`unseenByEditor\` names, and the loss is not a reason to refuse the scan.
40974
41238
 
40975
41239
  A diagnostic about a member rather than a type, \`Static function "x()" not found in base "Y"\`
40976
41240
  about something the engine compiles, is a different thing and is no longer stale here: each ask
@@ -41089,17 +41353,22 @@ class UsageError extends Refusal {
41089
41353
  function projectArgument(at) {
41090
41354
  const named = args.slice(at).find((value) => !value.startsWith("--"));
41091
41355
  const projectPath = resolve5(named ?? ".");
41092
- if (!existsSync15(join18(projectPath, "project.godot"))) {
41356
+ if (!existsSync15(join19(projectPath, "project.godot"))) {
41093
41357
  throw new UsageError(`Not a Godot project: ${projectPath} holds no project.godot.`);
41094
41358
  }
41095
41359
  return projectPath;
41096
41360
  }
41097
41361
  async function engine(projectPath) {
41098
- const godotPath = await new GodotLocator().find() ?? (projectPath === undefined ? null : recordedEnginePath(projectPath));
41099
- if (godotPath === null) {
41100
- throw new UsageError(`No Godot executable found. ${GodotLocator.ADVICE.join(". ")}.`);
41362
+ const located = await new GodotLocator().find();
41363
+ if (located.ok) {
41364
+ return { godotPath: located.path, script: shippedOperationsScript(), debug: GODOT_DEBUG_MODE_DEFAULT2 };
41101
41365
  }
41102
- return { godotPath, script: shippedOperationsScript(), debug: GODOT_DEBUG_MODE_DEFAULT2 };
41366
+ const recorded = located.named === null && projectPath !== undefined ? recordedEnginePath(projectPath) : null;
41367
+ if (recorded === null) {
41368
+ const refusal = GodotLocator.refusal(located);
41369
+ throw new UsageError(`${refusal.message} ${refusal.advice.join(". ")}.`);
41370
+ }
41371
+ return { godotPath: recorded, script: shippedOperationsScript(), debug: GODOT_DEBUG_MODE_DEFAULT2 };
41103
41372
  }
41104
41373
  function said2(outcome, what) {
41105
41374
  if (!outcome.ok) {
@@ -41123,7 +41392,7 @@ async function registerRuntime(godot, projectPath) {
41123
41392
  return;
41124
41393
  }
41125
41394
  console.log(`${RUNTIME_AUTOLOAD.name} autoload left at ${named}, which this project registers itself`);
41126
- if (!existsSync15(join18(projectPath, (named ?? "").replace(/^res:\/\//, "")))) {
41395
+ if (!existsSync15(join19(projectPath, (named ?? "").replace(/^res:\/\//, "")))) {
41127
41396
  console.log(` that file is not in this project, so it boots with a missing script: restore it, or point the entry at res://${RUNTIME_AUTOLOAD.path}`);
41128
41397
  }
41129
41398
  }
@@ -41181,7 +41450,7 @@ function reportConnection(group, launch, projectPath) {
41181
41450
  const written = connect(group.writer, projectPath, launch);
41182
41451
  if (written.action === "command") {
41183
41452
  console.log(`${who}: run ${(written.command ?? []).join(" ")}`);
41184
- return;
41453
+ return { kind: "byHand" };
41185
41454
  }
41186
41455
  if (written.action === "snippet") {
41187
41456
  console.log(`${who}: put this in ${written.path}`);
@@ -41189,7 +41458,7 @@ function reportConnection(group, launch, projectPath) {
41189
41458
  `)) {
41190
41459
  console.log(` ${line}`);
41191
41460
  }
41192
- return;
41461
+ return { kind: "byHand" };
41193
41462
  }
41194
41463
  console.log(`${who}: ${written.action} ${written.path}`);
41195
41464
  if (written.wasLaunchedBy !== undefined) {
@@ -41201,7 +41470,7 @@ function reportConnection(group, launch, projectPath) {
41201
41470
  console.log(` ${harness.name}: ${harness.manual}`);
41202
41471
  }
41203
41472
  }
41204
- return written.wasLaunchedBy;
41473
+ return { kind: "written", from: written.wasLaunchedBy ?? null };
41205
41474
  }
41206
41475
  function nextSteps(projectPath, runtime) {
41207
41476
  console.log(`
@@ -41335,14 +41604,18 @@ async function upgrade() {
41335
41604
  const launch = launchFor(version, godot.godotPath, projectPath);
41336
41605
  const already = HARNESSES.filter((harness) => registered(harness, projectPath));
41337
41606
  const moved = [];
41607
+ let written = 0;
41608
+ let byHand = 0;
41338
41609
  for (const group of groupByFile(already, projectPath)) {
41339
- const was = reportConnection(group, launch, projectPath);
41340
- if (was !== undefined && !moved.includes(was)) {
41341
- moved.push(was);
41610
+ const took = reportConnection(group, launch, projectPath);
41611
+ if (took.kind === "byHand") {
41612
+ byHand += 1;
41613
+ continue;
41614
+ }
41615
+ written += 1;
41616
+ if (took.from !== null && !moved.includes(took.from)) {
41617
+ moved.push(took.from);
41342
41618
  }
41343
- }
41344
- if (already.length === 0) {
41345
- console.log("no harness config names gdharness, so none was re-pinned");
41346
41619
  }
41347
41620
  for (const written of writeSkill(skillDirectories(already, projectPath, existsSync15), version)) {
41348
41621
  console.log(`skill: ${written.replaced ? "replaced" : "written"} ${written.path}`);
@@ -41351,7 +41624,7 @@ async function upgrade() {
41351
41624
  On ${version}. Two things this could not do for you:`);
41352
41625
  console.log(` 1. The open editor is still running the addons it loaded at startup. Restart it with the
41353
41626
  ` + " editor_launch restart tool, which closes and reopens the window.");
41354
- console.log(harnessNote(moved, [launch.command, ...launch.args].join(" ")));
41627
+ console.log(harnessNote({ moved, written, byHand }, [launch.command, ...launch.args].join(" ")));
41355
41628
  }
41356
41629
  function doctorReport(projectPath) {
41357
41630
  const report = inspectProject(projectPath);
@@ -41362,7 +41635,8 @@ function doctorReport(projectPath) {
41362
41635
  console.log(`addons/${addon.name}: ${addon.installed ? `installed, ${addon.version ?? "unknown version"}${addon.current ? "" : " (not this version)"}` : "missing"}`);
41363
41636
  }
41364
41637
  console.log(`editor plugins enabled: ${report.pluginsEnabled.join(", ") || "none"}`);
41365
- console.log(`runtime autoload: ${report.runtimeLoaderAutoload === null ? report.runtimeAutoload ? "registered" : "not registered" : `registered through res://${report.runtimeLoaderAutoload.path}, as ${report.runtimeLoaderAutoload.name}`}`);
41638
+ const loader = report.runtimeLoaderAutoload;
41639
+ console.log(`runtime autoload: ${loader === null ? report.runtimeAutoload ? "registered" : "not registered" : `registered through res://${loader.path}, as ${loader.name}${loader.exists ? "" : ", and that file is not in this project"}`}`);
41366
41640
  console.log(report.classCacheExists ? `class cache: ${report.staleClasses.length === 0 ? "current" : `stale for ${report.staleClasses.join(", ")}`}` : "class cache: missing");
41367
41641
  for (const problem of report.problems) {
41368
41642
  console.log(`problem: ${problem}`);