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/index.js CHANGED
@@ -9846,9 +9846,23 @@ function runLine(runner, version, rest = "") {
9846
9846
  }
9847
9847
 
9848
9848
  // src/update-check.ts
9849
- var REGISTRY = "https://registry.npmjs.org/gdharness/latest";
9849
+ var REGISTRY_ROOT = "https://registry.npmjs.org";
9850
+ function packageUrl(root) {
9851
+ return `${root.replace(/\/+$/, "")}/gdharness/latest`;
9852
+ }
9853
+ function registryFor(environment) {
9854
+ const named = environment["GDHARNESS_REGISTRY"] ?? "";
9855
+ if (named === "") {
9856
+ return packageUrl(REGISTRY_ROOT);
9857
+ }
9858
+ try {
9859
+ return new URL(named).protocol === "https:" ? packageUrl(named) : packageUrl(REGISTRY_ROOT);
9860
+ } catch {
9861
+ return packageUrl(REGISTRY_ROOT);
9862
+ }
9863
+ }
9850
9864
  var RELEASES = "https://github.com/Aureliolo/gdharness/releases/tag";
9851
- var CACHE_MS = 4 * 60 * 60 * 1000;
9865
+ var CACHE_MS = 10 * 60 * 1000;
9852
9866
  var REQUEST_TIMEOUT_MS = 1e4;
9853
9867
  var MAX_BODY_BYTES = 1 << 20;
9854
9868
  var FIRST_RETRY_MS = 30000;
@@ -9923,8 +9937,8 @@ function isNewer(candidate, current) {
9923
9937
  }
9924
9938
  return !left.prerelease && right.prerelease;
9925
9939
  }
9926
- async function fetchLatest() {
9927
- const response = await fetch(REGISTRY, {
9940
+ async function fetchLatest(registry) {
9941
+ const response = await fetch(registry, {
9928
9942
  headers: { accept: "application/vnd.npm.install-v1+json, application/json" },
9929
9943
  redirect: "error",
9930
9944
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
@@ -9956,16 +9970,19 @@ async function fetchLatest() {
9956
9970
  class UpdateCheck {
9957
9971
  latest = null;
9958
9972
  checking = false;
9973
+ asked = false;
9959
9974
  checkedAt = 0;
9960
9975
  retryAt = 0;
9961
9976
  backoffMs = FIRST_RETRY_MS;
9962
9977
  enabled;
9963
9978
  current;
9964
9979
  cachePath;
9965
- constructor(current, environment = process.env) {
9980
+ ask;
9981
+ constructor(current, environment = process.env, ask = () => fetchLatest(registryFor(environment))) {
9966
9982
  this.current = current;
9967
9983
  this.enabled = (environment["GDHARNESS_NO_UPDATE_CHECK"] ?? "") === "";
9968
9984
  this.cachePath = cacheFile(environment);
9985
+ this.ask = ask;
9969
9986
  const cached = this.enabled ? readCache(this.cachePath) : null;
9970
9987
  if (cached !== null) {
9971
9988
  this.latest = cached.latest;
@@ -9973,11 +9990,12 @@ class UpdateCheck {
9973
9990
  }
9974
9991
  }
9975
9992
  refresh(now = Date.now()) {
9976
- if (!this.enabled || this.checking || now < this.retryAt || !this.stale(now)) {
9993
+ if (!this.enabled || this.checking || now < this.retryAt || this.asked && !this.stale(now)) {
9977
9994
  return;
9978
9995
  }
9996
+ this.asked = true;
9979
9997
  this.checking = true;
9980
- fetchLatest().then((version) => {
9998
+ this.ask().then((version) => {
9981
9999
  if (version === null) {
9982
10000
  this.scheduleRetry(now);
9983
10001
  return;
@@ -10107,15 +10125,26 @@ function filledTemplate(where, message, godotVersion) {
10107
10125
  ].join(`
10108
10126
  `);
10109
10127
  }
10110
- function defectReport(where, error, godotVersion) {
10128
+ function defectReport(where, error, context = {}) {
10129
+ const { godotVersion, seenBefore = 0 } = context;
10111
10130
  const message = errorMessage(error);
10112
10131
  const title = `Defect ${defectSignature(where, message)}: ${where}`;
10113
10132
  const url = `${NEW_ISSUE}?template=bug_report.md&title=${encodeURIComponent(title)}` + `&body=${encodeURIComponent(filledTemplate(where, message, godotVersion))}`;
10114
10133
  const width = Math.max(...facts(where, message, godotVersion).map(([label]) => (label ?? "").length));
10115
10134
  return [
10116
- "gdharness failed in a way it does not model. This is a defect in gdharness rather than",
10117
- "anything about the call: the arguments did not cause it, and sending it again will not",
10118
- "change it.",
10135
+ "gdharness failed in a way it does not model. Whatever went wrong underneath, it reached you",
10136
+ "as a crash rather than as a refusal naming what would have worked, and that much is a defect",
10137
+ "in gdharness however the rest of it turns out.",
10138
+ "",
10139
+ ...seenBefore === 0 ? [
10140
+ "Send the call once more before anything else. Some of what reaches this line is a file",
10141
+ "another process had open, or a connection that dropped, and those are gone by the next",
10142
+ "attempt. If the same Signature comes back, it is not one of those, and repeating it",
10143
+ "further only spends turns."
10144
+ ] : [
10145
+ `You have had this exact failure ${seenBefore === 1 ? "once" : `${seenBefore} times`} in this session already, so it is a`,
10146
+ "state rather than a passing one, and sending the call again will not change it."
10147
+ ],
10119
10148
  "",
10120
10149
  ...facts(where, message, godotVersion).map(([label, value]) => ` ${(label ?? "").padEnd(width)} ${value ?? ""}`),
10121
10150
  "",
@@ -10146,15 +10175,15 @@ import {
10146
10175
  closeSync,
10147
10176
  existsSync as existsSync9,
10148
10177
  fstatSync,
10149
- mkdirSync as mkdirSync5,
10178
+ mkdirSync as mkdirSync6,
10150
10179
  mkdtempSync as mkdtempSync2,
10151
10180
  openSync as openSync2,
10152
10181
  readdirSync as readdirSync6,
10153
- readFileSync as readFileSync11,
10182
+ readFileSync as readFileSync12,
10154
10183
  readSync
10155
10184
  } from "node:fs";
10156
10185
  import { tmpdir as tmpdir4 } from "node:os";
10157
- import { basename as basename2, dirname as dirname4, join as join11, normalize as normalize2 } from "node:path";
10186
+ import { basename as basename2, dirname as dirname5, join as join12, normalize as normalize2 } from "node:path";
10158
10187
  import { setTimeout as delay3 } from "node:timers/promises";
10159
10188
  import { fileURLToPath as fileURLToPath2 } from "node:url";
10160
10189
  import { promisify as promisify4 } from "node:util";
@@ -24886,7 +24915,7 @@ function staleAnalysisNote(contradicted, dependsOn) {
24886
24915
  }
24887
24916
  const types = new Set(contradicted.map((one) => one.type));
24888
24917
  const declaring = [...new Set(contradicted.map((one) => one.declaredIn))].sort();
24889
- 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`;
24918
+ 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`;
24890
24919
  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.";
24891
24920
  }
24892
24921
  function uncachedClassNote(types) {
@@ -25574,6 +25603,11 @@ function announced(line) {
25574
25603
  return { severity: match[2] === "WARNING" ? "warning" : "error", text: match[3] ?? "" };
25575
25604
  }
25576
25605
  var STREAMS = ["stdout", "stderr", "transcript"];
25606
+ var ADMITTED = {
25607
+ error: new Set(["error"]),
25608
+ warning: new Set(["error", "warning"]),
25609
+ info: new Set(["error", "warning", "info"])
25610
+ };
25577
25611
  function forAnswer(entries) {
25578
25612
  return entries.map((entry) => entry.detail.length === 0 ? { index: entry.index, severity: entry.severity, source: entry.source, text: entry.text } : entry);
25579
25613
  }
@@ -25587,7 +25621,7 @@ class GameLog {
25587
25621
  };
25588
25622
  partial = { stdout: "", stderr: "", transcript: "" };
25589
25623
  lastHeadline = null;
25590
- cursor = 0;
25624
+ seen = { error: 0, warning: 0, info: 0 };
25591
25625
  append(source, chunk) {
25592
25626
  const decoded = typeof chunk === "string" ? chunk : this.decoders[source].write(chunk);
25593
25627
  const lines = (this.partial[source] + decoded).split(`
@@ -25644,10 +25678,14 @@ class GameLog {
25644
25678
  return this.entries.filter((entry) => entry.severity === severity).length;
25645
25679
  }
25646
25680
  select(options) {
25647
- const floor = options.sinceLastCall ? this.cursor : 0;
25648
- this.cursor = this.entries.length;
25649
- const wanted = new Set(options.severity === "error" ? ["error"] : options.severity === "warning" ? ["error", "warning"] : ["error", "warning", "info"]);
25681
+ const floor = options.sinceLastCall ? this.seen[options.severity] : 0;
25682
+ const wanted = ADMITTED[options.severity];
25650
25683
  const needle = options.contains?.toLowerCase();
25684
+ if (needle === undefined) {
25685
+ for (const severity of wanted) {
25686
+ this.seen[severity] = this.entries.length;
25687
+ }
25688
+ }
25651
25689
  const matching = this.entries.slice(floor).filter((entry) => {
25652
25690
  if (!wanted.has(entry.severity)) {
25653
25691
  return false;
@@ -26353,6 +26391,10 @@ function userDataIn(home, variables = process.env) {
26353
26391
  }
26354
26392
  return carried;
26355
26393
  }
26394
+ function savesStayPut(platform = process.platform) {
26395
+ return platform === "darwin";
26396
+ }
26397
+ var 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.";
26356
26398
 
26357
26399
  // src/godot-path.ts
26358
26400
  var run = promisify(execFile);
@@ -26361,47 +26403,65 @@ class GodotLocator {
26361
26403
  answered = new Map;
26362
26404
  found = null;
26363
26405
  async find() {
26364
- if (this.found !== null && await this.answers(this.found)) {
26365
- return this.found;
26406
+ if (this.found !== null && await this.whyNot(this.found) === null) {
26407
+ return { ok: true, path: this.found };
26366
26408
  }
26367
26409
  this.found = null;
26368
26410
  const named = envValue("GODOT_PATH");
26369
26411
  if (named !== undefined) {
26370
26412
  const path = normalize(named);
26371
- if (await this.answers(path)) {
26413
+ const reason = await this.whyNot(path);
26414
+ if (reason === null) {
26372
26415
  this.found = path;
26416
+ return { ok: true, path };
26373
26417
  }
26374
- return this.found;
26418
+ return { ok: false, named: path, reason };
26375
26419
  }
26376
26420
  for (const candidate of godotCandidates(process.platform, resolveHomeDirectory())) {
26377
26421
  const path = normalize(candidate);
26378
- if (await this.answers(path)) {
26422
+ if (await this.whyNot(path) === null) {
26379
26423
  this.found = path;
26380
- return path;
26424
+ return { ok: true, path };
26381
26425
  }
26382
26426
  }
26383
- return null;
26427
+ return { ok: false, named: null };
26384
26428
  }
26385
- static ADVICE = [
26386
- "Set GODOT_PATH to the Godot 4 executable",
26387
- "Or install Godot where it is usually found, such as /usr/bin/godot or C:\\Program Files\\Godot"
26388
- ];
26389
- async answers(path) {
26429
+ static refusal(located) {
26430
+ if (located.named === null) {
26431
+ return {
26432
+ message: "No Godot executable found.",
26433
+ advice: [
26434
+ "Set GODOT_PATH to the Godot 4 executable",
26435
+ "Or install Godot where it is usually found, such as /usr/bin/godot or C:\\Program Files\\Godot"
26436
+ ]
26437
+ };
26438
+ }
26439
+ return {
26440
+ message: `GODOT_PATH is set to ${located.named}, which ${located.reason ?? "does not answer"}.`,
26441
+ advice: [
26442
+ "Point GODOT_PATH at a Godot 4 executable that answers --version",
26443
+ "Or unset it, and the usual install locations are searched instead"
26444
+ ]
26445
+ };
26446
+ }
26447
+ async whyNot(path) {
26390
26448
  const known = this.answered.get(path);
26391
26449
  if (known !== undefined) {
26392
26450
  return known;
26393
26451
  }
26394
- let ok = false;
26395
- if (path === "godot" || existsSync5(path)) {
26452
+ let reason;
26453
+ if (path !== "godot" && !existsSync5(path)) {
26454
+ reason = "does not exist";
26455
+ } else {
26396
26456
  try {
26397
26457
  await run(path, ["--version"]);
26398
- ok = true;
26399
- } catch {
26400
- ok = false;
26458
+ reason = null;
26459
+ } catch (error) {
26460
+ reason = `does not answer --version: ${errorMessage(error)}`;
26401
26461
  }
26402
26462
  }
26403
- this.answered.set(path, ok);
26404
- return ok;
26463
+ this.answered.set(path, reason);
26464
+ return reason;
26405
26465
  }
26406
26466
  }
26407
26467
 
@@ -26659,15 +26719,36 @@ function filledTemplate2(where, message, godotVersion) {
26659
26719
  ].join(`
26660
26720
  `);
26661
26721
  }
26662
- function defectReport2(where, error, godotVersion) {
26722
+
26723
+ class DefectsSeen {
26724
+ counts = new Map;
26725
+ before(where, message) {
26726
+ const signature = defectSignature2(where, message);
26727
+ const seen = this.counts.get(signature) ?? 0;
26728
+ this.counts.set(signature, seen + 1);
26729
+ return seen;
26730
+ }
26731
+ }
26732
+ function defectReport2(where, error, context = {}) {
26733
+ const { godotVersion, seenBefore = 0 } = context;
26663
26734
  const message = errorMessage(error);
26664
26735
  const title = `Defect ${defectSignature2(where, message)}: ${where}`;
26665
26736
  const url = `${NEW_ISSUE2}?template=bug_report.md&title=${encodeURIComponent(title)}` + `&body=${encodeURIComponent(filledTemplate2(where, message, godotVersion))}`;
26666
26737
  const width = Math.max(...facts2(where, message, godotVersion).map(([label]) => (label ?? "").length));
26667
26738
  return [
26668
- "gdharness failed in a way it does not model. This is a defect in gdharness rather than",
26669
- "anything about the call: the arguments did not cause it, and sending it again will not",
26670
- "change it.",
26739
+ "gdharness failed in a way it does not model. Whatever went wrong underneath, it reached you",
26740
+ "as a crash rather than as a refusal naming what would have worked, and that much is a defect",
26741
+ "in gdharness however the rest of it turns out.",
26742
+ "",
26743
+ ...seenBefore === 0 ? [
26744
+ "Send the call once more before anything else. Some of what reaches this line is a file",
26745
+ "another process had open, or a connection that dropped, and those are gone by the next",
26746
+ "attempt. If the same Signature comes back, it is not one of those, and repeating it",
26747
+ "further only spends turns."
26748
+ ] : [
26749
+ `You have had this exact failure ${seenBefore === 1 ? "once" : `${seenBefore} times`} in this session already, so it is a`,
26750
+ "state rather than a passing one, and sending the call again will not change it."
26751
+ ],
26671
26752
  "",
26672
26753
  ...facts2(where, message, godotVersion).map(([label, value]) => ` ${(label ?? "").padEnd(width)} ${value ?? ""}`),
26673
26754
  "",
@@ -27735,6 +27816,11 @@ function settingKeys(content) {
27735
27816
  return named;
27736
27817
  }
27737
27818
  function settingsDroppedReport(before, after) {
27819
+ if (before === null || after === null) {
27820
+ return {
27821
+ 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."
27822
+ };
27823
+ }
27738
27824
  const dropped = [...before].filter(([key]) => !after.has(key));
27739
27825
  if (dropped.length === 0) {
27740
27826
  return {};
@@ -27848,16 +27934,55 @@ function setupResourceHandlers(mcp, getProjectPath) {
27848
27934
  });
27849
27935
  }
27850
27936
 
27937
+ // src/restart-note.ts
27938
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync8, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "node:fs";
27939
+ import { dirname as dirname3, join as join8 } from "node:path";
27940
+ function restartNotePath(projectPath) {
27941
+ return join8(projectPath, ".godot", "gdharness-restart.json");
27942
+ }
27943
+ function noteRestartBegun(note) {
27944
+ const path = restartNotePath(note.projectPath);
27945
+ try {
27946
+ mkdirSync3(dirname3(path), { recursive: true });
27947
+ writeFileSync4(path, `${JSON.stringify(note, null, 2)}
27948
+ `, "utf8");
27949
+ } catch (error) {
27950
+ console.error(`[SERVER] Could not record the restart at ${path}: ${errorMessage(error)}`);
27951
+ }
27952
+ }
27953
+ function restartOwed(projectPath) {
27954
+ try {
27955
+ const parsed = JSON.parse(readFileSync8(restartNotePath(projectPath), "utf8"));
27956
+ if (typeof parsed !== "object" || parsed === null) {
27957
+ return null;
27958
+ }
27959
+ const said = parsed;
27960
+ if (typeof said.quitAt !== "string" || typeof said.byPid !== "number" || typeof said.ports !== "object") {
27961
+ return null;
27962
+ }
27963
+ return said;
27964
+ } catch {
27965
+ return null;
27966
+ }
27967
+ }
27968
+ function restartSettled(projectPath) {
27969
+ try {
27970
+ rmSync3(restartNotePath(projectPath), { force: true });
27971
+ } catch (error) {
27972
+ console.error(`[SERVER] Could not clear the restart note: ${errorMessage(error)}`);
27973
+ }
27974
+ }
27975
+
27851
27976
  // src/run-record.ts
27852
27977
  import { execFileSync } from "node:child_process";
27853
- import { mkdirSync as mkdirSync3, openSync, readdirSync as readdirSync5, readFileSync as readFileSync9, rmSync as rmSync3, statSync as statSync3, writeFileSync as writeFileSync4 } from "node:fs";
27854
- import { basename, join as join9 } from "node:path";
27978
+ import { mkdirSync as mkdirSync4, openSync, readdirSync as readdirSync5, readFileSync as readFileSync10, rmSync as rmSync4, statSync as statSync3, writeFileSync as writeFileSync5 } from "node:fs";
27979
+ import { basename, join as join10 } from "node:path";
27855
27980
 
27856
27981
  // src/runtime-client.ts
27857
- import { existsSync as existsSync7, readdirSync as readdirSync4, readFileSync as readFileSync8, unlinkSync } from "node:fs";
27982
+ import { existsSync as existsSync7, readdirSync as readdirSync4, readFileSync as readFileSync9, unlinkSync } from "node:fs";
27858
27983
  import { createConnection as createConnection3 } from "node:net";
27859
27984
  import { tmpdir as tmpdir3 } from "node:os";
27860
- import { join as join8, resolve as resolve4 } from "node:path";
27985
+ import { join as join9, resolve as resolve4 } from "node:path";
27861
27986
  import { setTimeout as delay2 } from "node:timers/promises";
27862
27987
 
27863
27988
  // src/tool-args.ts
@@ -27925,13 +28050,13 @@ function runtimeDirectory(variables = process.env) {
27925
28050
  return explicit;
27926
28051
  }
27927
28052
  const perUser = envValue("XDG_RUNTIME_DIR", variables);
27928
- return join8(perUser ?? tmpdir3(), "gdharness");
28053
+ return join9(perUser ?? tmpdir3(), "gdharness");
27929
28054
  }
27930
28055
  function runtimeDirectories(variables = process.env) {
27931
28056
  const candidates = [runtimeDirectory(variables)];
27932
28057
  const fallbacks = process.platform === "win32" ? [envValue("SystemRoot", variables) ?? envValue("windir", variables) ?? "C:\\Windows"] : ["/tmp", "/var/tmp"];
27933
28058
  for (const base of fallbacks) {
27934
- candidates.push(join8(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
28059
+ candidates.push(join9(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
27935
28060
  }
27936
28061
  return [...new Set(candidates.map((path) => resolve4(path)))];
27937
28062
  }
@@ -27946,7 +28071,7 @@ function processAlive(pid) {
27946
28071
  function parseAnnouncement(file, pid) {
27947
28072
  let fields;
27948
28073
  try {
27949
- fields = asParams(JSON.parse(readFileSync8(file, "utf8")));
28074
+ fields = asParams(JSON.parse(readFileSync9(file, "utf8")));
27950
28075
  } catch {
27951
28076
  return { kind: "rubbish" };
27952
28077
  }
@@ -27984,6 +28109,31 @@ function runtimesAnnounced(directories = runtimeDirectories()) {
27984
28109
  function discoverRuntimes(directories = runtimeDirectories()) {
27985
28110
  return runtimesAnnounced(directories).running;
27986
28111
  }
28112
+ var GONE_WINDOW_MS = 120000;
28113
+ var GONE_KEPT = 32;
28114
+ var gone = [];
28115
+ function wentAway(file, pid) {
28116
+ let project = "";
28117
+ try {
28118
+ const fields = asParams(JSON.parse(readFileSync9(file, "utf8")));
28119
+ project = readString(readParams(fields, "project") ?? {}, "path") ?? "";
28120
+ } catch {
28121
+ return;
28122
+ }
28123
+ if (project === "") {
28124
+ return;
28125
+ }
28126
+ gone.unshift({ pid, project, noticedAt: Date.now() });
28127
+ gone.length = Math.min(gone.length, GONE_KEPT);
28128
+ }
28129
+ function announcedAndGone(projectPath) {
28130
+ if (projectPath === undefined) {
28131
+ return [];
28132
+ }
28133
+ const since = Date.now() - GONE_WINDOW_MS;
28134
+ const wanted = resolve4(projectPath);
28135
+ return gone.filter((one) => one.noticedAt >= since && resolve4(one.project) === wanted);
28136
+ }
27987
28137
  function announcedIn(directory) {
27988
28138
  if (!existsSync7(directory)) {
27989
28139
  return [];
@@ -27994,10 +28144,14 @@ function announcedIn(directory) {
27994
28144
  if (!match) {
27995
28145
  continue;
27996
28146
  }
27997
- const file = join8(directory, entry);
28147
+ const file = join9(directory, entry);
27998
28148
  const pid = Number.parseInt(match[1] ?? "", 10);
27999
- const announced = processAlive(pid) ? parseAnnouncement(file, pid) : { kind: "rubbish" };
28149
+ const alive = processAlive(pid);
28150
+ const announced = alive ? parseAnnouncement(file, pid) : { kind: "rubbish" };
28000
28151
  if (announced.kind === "rubbish") {
28152
+ if (!alive) {
28153
+ wentAway(file, pid);
28154
+ }
28001
28155
  try {
28002
28156
  unlinkSync(file);
28003
28157
  } catch {}
@@ -28038,6 +28192,16 @@ function chooseRuntime(endpoints, projectPath, unspoken = []) {
28038
28192
  if (unspoken.length > 0) {
28039
28193
  return { problem: tooNew(unspoken) };
28040
28194
  }
28195
+ const went = announcedAndGone(projectPath);
28196
+ const first = went[0];
28197
+ if (first !== undefined) {
28198
+ const ago = Math.max(1, Math.round((Date.now() - first.noticedAt) / 1000));
28199
+ const others = went.length - 1;
28200
+ const earlier = others === 0 ? "" : ` ${others} earlier ${others === 1 ? "one" : "ones"} went the same way.`;
28201
+ return {
28202
+ 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."
28203
+ };
28204
+ }
28041
28205
  return {
28042
28206
  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."
28043
28207
  };
@@ -28155,33 +28319,33 @@ function runtimeRequest(endpoint, command, params, timeoutMs) {
28155
28319
 
28156
28320
  // src/run-record.ts
28157
28321
  function runsDirectory() {
28158
- return join9(runtimeDirectory(), "runs");
28322
+ return join10(runtimeDirectory(), "runs");
28159
28323
  }
28160
28324
  function recordPath() {
28161
- return join9(runsDirectory(), "run.json");
28325
+ return join10(runsDirectory(), "run.json");
28162
28326
  }
28163
28327
  function recordPaths() {
28164
- return runtimeDirectories().map((directory) => join9(directory, "runs", "run.json"));
28328
+ return runtimeDirectories().map((directory) => join10(directory, "runs", "run.json"));
28165
28329
  }
28166
28330
  function openTranscript(startedAt) {
28167
- mkdirSync3(runsDirectory(), { recursive: true });
28168
- const path = join9(runsDirectory(), `run-${startedAt}.log`);
28331
+ mkdirSync4(runsDirectory(), { recursive: true });
28332
+ const path = join10(runsDirectory(), `run-${startedAt}.log`);
28169
28333
  return { path, fd: openSync(path, "a") };
28170
28334
  }
28171
28335
  function writeRunRecord(record) {
28172
- mkdirSync3(runsDirectory(), { recursive: true });
28173
- writeFileSync4(recordPath(), JSON.stringify(record, null, 2), "utf8");
28336
+ mkdirSync4(runsDirectory(), { recursive: true });
28337
+ writeFileSync5(recordPath(), JSON.stringify(record, null, 2), "utf8");
28174
28338
  }
28175
28339
  function editorNotePath() {
28176
- return join9(runsDirectory(), "editor-run.json");
28340
+ return join10(runsDirectory(), "editor-run.json");
28177
28341
  }
28178
28342
  function writeEditorRunNote(note) {
28179
- mkdirSync3(runsDirectory(), { recursive: true });
28180
- writeFileSync4(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
28343
+ mkdirSync4(runsDirectory(), { recursive: true });
28344
+ writeFileSync5(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
28181
28345
  }
28182
28346
  function readEditorRunNote() {
28183
28347
  for (const directory of runtimeDirectories()) {
28184
- const note = editorNoteAt(join9(directory, "runs", "editor-run.json"));
28348
+ const note = editorNoteAt(join10(directory, "runs", "editor-run.json"));
28185
28349
  if (note !== null) {
28186
28350
  return note;
28187
28351
  }
@@ -28191,7 +28355,7 @@ function readEditorRunNote() {
28191
28355
  function editorNoteAt(path) {
28192
28356
  let parsed;
28193
28357
  try {
28194
- parsed = JSON.parse(readFileSync9(path, "utf8"));
28358
+ parsed = JSON.parse(readFileSync10(path, "utf8"));
28195
28359
  } catch {
28196
28360
  return null;
28197
28361
  }
@@ -28214,7 +28378,7 @@ function recordRunEnded(pid, exitCode) {
28214
28378
  return;
28215
28379
  }
28216
28380
  try {
28217
- writeFileSync4(path, JSON.stringify({ ...record, exitCode }, null, 2), "utf8");
28381
+ writeFileSync5(path, JSON.stringify({ ...record, exitCode }, null, 2), "utf8");
28218
28382
  } catch {}
28219
28383
  }
28220
28384
  function readRunRecord() {
@@ -28229,7 +28393,7 @@ function readRunRecord() {
28229
28393
  function recordAt(path) {
28230
28394
  let parsed;
28231
28395
  try {
28232
- parsed = JSON.parse(readFileSync9(path, "utf8"));
28396
+ parsed = JSON.parse(readFileSync10(path, "utf8"));
28233
28397
  } catch {
28234
28398
  return null;
28235
28399
  }
@@ -28274,7 +28438,7 @@ function runningAs(pid) {
28274
28438
  const began = startedAt(pid, null);
28275
28439
  const when = began === null ? {} : { startedAt: began };
28276
28440
  if (process.platform === "linux") {
28277
- const raw = readFileSync9(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
28441
+ const raw = readFileSync10(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
28278
28442
  return raw === "" ? null : { kind: "commandLine", text: raw, ...when };
28279
28443
  }
28280
28444
  const args = execFileSync("ps", ["-p", String(pid), "-o", "args="], {
@@ -28396,7 +28560,7 @@ function clearRunRecord(ours = () => true) {
28396
28560
  continue;
28397
28561
  }
28398
28562
  try {
28399
- rmSync3(path);
28563
+ rmSync4(path);
28400
28564
  } catch {}
28401
28565
  }
28402
28566
  }
@@ -28420,19 +28584,19 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
28420
28584
  if (!entry.startsWith("run-") || !entry.endsWith(".log")) {
28421
28585
  continue;
28422
28586
  }
28423
- const path = join9(directory, entry);
28587
+ const path = join10(directory, entry);
28424
28588
  if (path === keep || ageMs(path, now) < keepMs) {
28425
28589
  continue;
28426
28590
  }
28427
28591
  try {
28428
- rmSync3(path);
28592
+ rmSync4(path);
28429
28593
  } catch {}
28430
28594
  }
28431
28595
  }
28432
28596
 
28433
28597
  // src/setup.ts
28434
- import { cpSync, existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync10, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs";
28435
- import { dirname as dirname3, join as join10 } from "node:path";
28598
+ import { cpSync, existsSync as existsSync8, mkdirSync as mkdirSync5, readFileSync as readFileSync11, rmSync as rmSync5, writeFileSync as writeFileSync6 } from "node:fs";
28599
+ import { dirname as dirname4, join as join11 } from "node:path";
28436
28600
  var ADDONS = ["gdharness_editor", "gdharness_runtime", "auto_reload"];
28437
28601
  var RUNTIME_AUTOLOAD = {
28438
28602
  name: "GdharnessRuntime",
@@ -28440,11 +28604,11 @@ var RUNTIME_AUTOLOAD = {
28440
28604
  };
28441
28605
  var VERSION_MARKER = ".gdharness-version";
28442
28606
  function installedAddonVersion(projectPath) {
28443
- const marker = join10(projectPath, "addons", ADDONS[0], VERSION_MARKER);
28607
+ const marker = join11(projectPath, "addons", ADDONS[0], VERSION_MARKER);
28444
28608
  if (!existsSync8(marker)) {
28445
28609
  return null;
28446
28610
  }
28447
- const said = readFileSync10(marker, "utf8").trim();
28611
+ const said = readFileSync11(marker, "utf8").trim();
28448
28612
  return said === "" ? null : said;
28449
28613
  }
28450
28614
 
@@ -28781,7 +28945,7 @@ var TOOL_SPECS = [
28781
28945
  },
28782
28946
  {
28783
28947
  name: "project_test",
28784
- 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.",
28948
+ 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.",
28785
28949
  parameters: {
28786
28950
  projectPath: PROJECT_PATH,
28787
28951
  path: {
@@ -28998,7 +29162,7 @@ var TOOL_SPECS = [
28998
29162
  },
28999
29163
  {
29000
29164
  name: "script_diagnostics",
29001
- 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.",
29165
+ 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.",
29002
29166
  parameters: {
29003
29167
  projectPath: PROJECT_PATH,
29004
29168
  scriptPath: SCRIPT_PATH
@@ -29048,7 +29212,7 @@ var TOOL_SPECS = [
29048
29212
  },
29049
29213
  {
29050
29214
  name: "editor_launch",
29051
- 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.",
29215
+ 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.",
29052
29216
  parameters: { projectPath: { ...PROJECT_PATH, ops: ["open"] } },
29053
29217
  requires: [],
29054
29218
  operations: {
@@ -29119,7 +29283,7 @@ var TOOL_SPECS = [
29119
29283
  },
29120
29284
  sinceLastCall: {
29121
29285
  type: "boolean",
29122
- description: "Only entries printed since the previous editor_output. Default false."
29286
+ 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."
29123
29287
  },
29124
29288
  cpu: {
29125
29289
  type: "boolean",
@@ -29138,7 +29302,7 @@ var TOOL_SPECS = [
29138
29302
  },
29139
29303
  {
29140
29304
  name: "editor_status",
29141
- 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.',
29305
+ 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.',
29142
29306
  parameters: {},
29143
29307
  requires: []
29144
29308
  },
@@ -29555,7 +29719,7 @@ function buildToolDefinitions() {
29555
29719
  var UPDATE_NOTICE_EVERY = 500;
29556
29720
  var FEEDBACK_NOTICE_EVERY = 250;
29557
29721
  var run4 = promisify4(execFile4);
29558
- var __dirname2 = dirname4(fileURLToPath2(import.meta.url));
29722
+ var __dirname2 = dirname5(fileURLToPath2(import.meta.url));
29559
29723
  var EDITOR_RESTART_TIMEOUT_MS = 90000;
29560
29724
  var SLOWEST_FRAME_RATE = 20;
29561
29725
  function patienceForFrames(frames, atLeast) {
@@ -29751,7 +29915,7 @@ function aboveTheRunner(entry) {
29751
29915
  }
29752
29916
  function elsewhereIn(projectPath, asked) {
29753
29917
  const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
29754
- if (wanted === "" || existsSync9(join11(projectPath, wanted))) {
29918
+ if (wanted === "" || existsSync9(join12(projectPath, wanted))) {
29755
29919
  return "";
29756
29920
  }
29757
29921
  const root = (wanted.split("/")[0] ?? "").toLowerCase();
@@ -29765,11 +29929,11 @@ function elsewhereIn(projectPath, asked) {
29765
29929
  return near.length === 0 ? "" : ` The project does have ${there}.`;
29766
29930
  }
29767
29931
  function hasMainScene(projectFile) {
29768
- const scene = parseProjectGodot(readFileSync11(projectFile, "utf8"))["application"]?.["run/main_scene"];
29932
+ const scene = parseProjectGodot(readFileSync12(projectFile, "utf8"))["application"]?.["run/main_scene"];
29769
29933
  return typeof scene === "string" && scene !== "";
29770
29934
  }
29771
29935
  function editorPlaysHeadless(projectFile) {
29772
- const runArgs = parseProjectGodot(readFileSync11(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
29936
+ const runArgs = parseProjectGodot(readFileSync12(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
29773
29937
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
29774
29938
  }
29775
29939
  function camelCased(params) {
@@ -29785,7 +29949,7 @@ function camelCased(params) {
29785
29949
  class GodotServer {
29786
29950
  mcp;
29787
29951
  locator = new GodotLocator;
29788
- operationsScript = join11(__dirname2, "godot", "operations", "godot_operations.gd");
29952
+ operationsScript = join12(__dirname2, "godot", "operations", "godot_operations.gd");
29789
29953
  godotBridge;
29790
29954
  tools = buildToolDefinitions();
29791
29955
  activeProcess = null;
@@ -29793,6 +29957,8 @@ class GodotServer {
29793
29957
  noticedUpdate = false;
29794
29958
  callsSinceNotice = 0;
29795
29959
  callsSinceFeedback = 0;
29960
+ defectsSeen = new DefectsSeen;
29961
+ lastGodotVersion = null;
29796
29962
  lspClient = null;
29797
29963
  dapClient = null;
29798
29964
  bridgeStartupError = null;
@@ -29837,11 +30003,11 @@ class GodotServer {
29837
30003
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
29838
30004
  }
29839
30005
  async run() {
29840
- const godotPath = await this.locator.find();
29841
- if (godotPath === null) {
29842
- console.error("[SERVER] No Godot found. Set GODOT_PATH; until then every tool that runs the engine will say so.");
30006
+ const located = await this.locator.find();
30007
+ if (!located.ok) {
30008
+ console.error(`[SERVER] ${GodotLocator.refusal(located).message} Until that is fixed every tool that runs the engine will say so.`);
29843
30009
  } else {
29844
- console.error(`[SERVER] Using Godot at: ${godotPath}`);
30010
+ console.error(`[SERVER] Using Godot at: ${located.path}`);
29845
30011
  }
29846
30012
  const transport = new StdioServerTransport;
29847
30013
  await this.mcp.server.connect(transport);
@@ -30041,7 +30207,19 @@ class GodotServer {
30041
30207
  }
30042
30208
  const where = op === "" ? tool : `${tool} op=${op}`;
30043
30209
  console.error(`[SERVER] Unmodelled failure in ${where}:`, error);
30044
- return { content: [{ type: "text", text: defectReport2(where, error) }], isError: true };
30210
+ const seenBefore = this.defectsSeen.before(where, errorMessage(error));
30211
+ return {
30212
+ content: [
30213
+ {
30214
+ type: "text",
30215
+ text: defectReport2(where, error, {
30216
+ seenBefore,
30217
+ godotVersion: this.lastGodotVersion ?? undefined
30218
+ })
30219
+ }
30220
+ ],
30221
+ isError: true
30222
+ };
30045
30223
  }
30046
30224
  }
30047
30225
  withUpdateNotice(answer) {
@@ -30402,21 +30580,19 @@ class GodotServer {
30402
30580
  }
30403
30581
  }
30404
30582
  async engine() {
30405
- const godotPath = await this.locator.find();
30406
- if (godotPath === null) {
30407
- return {
30408
- ok: false,
30409
- response: this.createErrorResponse("No Godot executable found.", GodotLocator.ADVICE)
30410
- };
30583
+ const located = await this.locator.find();
30584
+ if (!located.ok) {
30585
+ const refusal = GodotLocator.refusal(located);
30586
+ return { ok: false, response: this.createErrorResponse(refusal.message, [...refusal.advice]) };
30411
30587
  }
30412
- return { ok: true, value: godotPath };
30588
+ return { ok: true, value: located.path };
30413
30589
  }
30414
30590
  project(args) {
30415
30591
  const path = readNonEmptyString(args, "projectPath");
30416
30592
  if (path === undefined) {
30417
30593
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
30418
30594
  }
30419
- const file = join11(path, "project.godot");
30595
+ const file = join12(path, "project.godot");
30420
30596
  if (!existsSync9(file)) {
30421
30597
  return {
30422
30598
  ok: false,
@@ -30558,7 +30734,7 @@ class GodotServer {
30558
30734
  }
30559
30735
  const engine = await this.engine();
30560
30736
  if (!engine.ok) {
30561
- return this.createErrorResponse("No Godot executable found.");
30737
+ return engine.response;
30562
30738
  }
30563
30739
  const projectPath = project.value.path;
30564
30740
  const before = scriptsWithoutUid(projectPath);
@@ -30581,7 +30757,11 @@ class GodotServer {
30581
30757
  async operation(operation, params, projectPath) {
30582
30758
  const engine = await this.engine();
30583
30759
  if (!engine.ok) {
30584
- return { ok: false, message: "No Godot executable found.", messages: [] };
30760
+ return {
30761
+ ok: false,
30762
+ message: engine.response.content.map((block) => block.text).join(" "),
30763
+ messages: []
30764
+ };
30585
30765
  }
30586
30766
  this.logDebug(`Running ${operation} in ${projectPath}: ${JSON.stringify(params)}`);
30587
30767
  return await runOperation({ godotPath: engine.value, script: this.operationsScript, debug: GODOT_DEBUG_MODE_DEFAULT }, operation, params, projectPath);
@@ -30613,7 +30793,7 @@ class GodotServer {
30613
30793
  if (!engine.ok) {
30614
30794
  return engine.response;
30615
30795
  }
30616
- const application = parseProjectGodot(readFileSync11(project.value.file, "utf8"))["application"] ?? {};
30796
+ const application = parseProjectGodot(readFileSync12(project.value.file, "utf8"))["application"] ?? {};
30617
30797
  const name = application["config/name"];
30618
30798
  const mainScene = application["run/main_scene"];
30619
30799
  const info = {
@@ -30674,18 +30854,18 @@ class GodotServer {
30674
30854
  if (!engine.ok) {
30675
30855
  return engine.response;
30676
30856
  }
30677
- const folder = dirname4(output.absolutePath);
30857
+ const folder = dirname5(output.absolutePath);
30678
30858
  try {
30679
- mkdirSync5(folder, { recursive: true });
30859
+ mkdirSync6(folder, { recursive: true });
30680
30860
  } catch (error) {
30681
- return this.createErrorResponse(`Cannot create ${dirname4(output.relativePath)} to export into: ${errorMessage(error)}`);
30861
+ return this.createErrorResponse(`Cannot create ${dirname5(output.relativePath)} to export into: ${errorMessage(error)}`);
30682
30862
  }
30683
30863
  const debug = readBoolean(args, "debug") ?? false;
30684
- const exportLogs = mkdtempSync2(join11(tmpdir4(), "gdharness-export-"));
30864
+ const exportLogs = mkdtempSync2(join12(tmpdir4(), "gdharness-export-"));
30685
30865
  const exportArgs = [
30686
30866
  "--headless",
30687
30867
  "--log-file",
30688
- join11(exportLogs, "engine.log"),
30868
+ join12(exportLogs, "engine.log"),
30689
30869
  "--path",
30690
30870
  project.value.path,
30691
30871
  debug ? "--export-debug" : "--export-release",
@@ -30743,7 +30923,7 @@ class GodotServer {
30743
30923
  return contained.response;
30744
30924
  }
30745
30925
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
30746
- if (!existsSync9(join11(project.value.path, runner))) {
30926
+ if (!existsSync9(join12(project.value.path, runner))) {
30747
30927
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
30748
30928
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
30749
30929
  ]);
@@ -30778,7 +30958,7 @@ class GodotServer {
30778
30958
  ];
30779
30959
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
30780
30960
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
30781
- const userData = mkdtempSync2(join11(tmpdir4(), "gdharness-tests-"));
30961
+ const userData = mkdtempSync2(join12(tmpdir4(), "gdharness-tests-"));
30782
30962
  const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
30783
30963
  const hung = await new Promise((resolve) => {
30784
30964
  const timer = setTimeout(() => {
@@ -30794,14 +30974,14 @@ class GodotServer {
30794
30974
  resolve(false);
30795
30975
  });
30796
30976
  });
30797
- const reportsDir = join11(project.value.path, ".godot", ...ours.split("/"));
30977
+ const reportsDir = join12(project.value.path, ".godot", ...ours.split("/"));
30798
30978
  let report = null;
30799
30979
  let reportProblem = null;
30800
30980
  try {
30801
30981
  const written = existsSync9(reportsDir) ? readdirSync6(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
30802
30982
  const newest = written.at(-1);
30803
30983
  if (newest !== undefined) {
30804
- report = parseJUnit(readFileSync11(join11(reportsDir, newest, "results.xml"), "utf8"));
30984
+ report = parseJUnit(readFileSync12(join12(reportsDir, newest, "results.xml"), "utf8"));
30805
30985
  }
30806
30986
  } catch (error) {
30807
30987
  reportProblem = errorMessage(error);
@@ -30822,6 +31002,7 @@ class GodotServer {
30822
31002
  const said = printed.map((entry) => entry.text);
30823
31003
  const nothingRan = hung ? null : whyNoReport(said, asked);
30824
31004
  const verdict = hung ? `hung: killed after ${timeoutMs} ms` : nothingRan ?? verdicts[exitCode ?? -1] ?? `exit ${exitCode ?? "unknown"}`;
31005
+ const savesNote = savesStayPut() && nothingRan === null ? SAVES_NOT_MOVED_NOTE : undefined;
30825
31006
  if (report === null) {
30826
31007
  const note = nothingRan === null ? `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).` : `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}`;
30827
31008
  return {
@@ -30836,7 +31017,8 @@ class GodotServer {
30836
31017
  exitCode,
30837
31018
  hung,
30838
31019
  arguments: cmdArgs,
30839
- entries: forAnswer(printed.slice(0, 60))
31020
+ entries: forAnswer(printed.slice(0, 60)),
31021
+ savesNote
30840
31022
  }, null, 2)
30841
31023
  }
30842
31024
  ],
@@ -30869,6 +31051,7 @@ class GodotServer {
30869
31051
  warnings: warnings.length > 0 ? warnings : undefined,
30870
31052
  orphans: orphans.total > 0 ? orphans.total : undefined,
30871
31053
  notRun: notRun > 0 ? notRun : undefined,
31054
+ savesNote,
30872
31055
  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,
30873
31056
  suites: unclean.map((suite) => ({
30874
31057
  name: suite.name,
@@ -30891,7 +31074,8 @@ class GodotServer {
30891
31074
  async godotVersion(godotPath) {
30892
31075
  try {
30893
31076
  const { stdout } = await run4(godotPath, ["--version"], { timeout: 1e4 });
30894
- return stdout.trim();
31077
+ this.lastGodotVersion = stdout.trim();
31078
+ return this.lastGodotVersion;
30895
31079
  } catch (error) {
30896
31080
  this.logDebug(`Godot did not answer --version: ${errorMessage(error)}`);
30897
31081
  return null;
@@ -30937,7 +31121,7 @@ class GodotServer {
30937
31121
  return null;
30938
31122
  }
30939
31123
  try {
30940
- return readFileSync11(contained.absolutePath, "utf8");
31124
+ return readFileSync12(contained.absolutePath, "utf8");
30941
31125
  } catch {
30942
31126
  return null;
30943
31127
  }
@@ -30979,7 +31163,7 @@ class GodotServer {
30979
31163
  return null;
30980
31164
  }
30981
31165
  try {
30982
- return readFileSync11(contained.absolutePath, "utf8");
31166
+ return readFileSync12(contained.absolutePath, "utf8");
30983
31167
  } catch {
30984
31168
  return null;
30985
31169
  }
@@ -31049,16 +31233,37 @@ class GodotServer {
31049
31233
  const status = this.godotBridge.getStatus();
31050
31234
  return (status.connected ? status[field] : undefined) ?? fallback;
31051
31235
  }
31236
+ restartLeftUnfinished(connected) {
31237
+ if (this.ownProject === null) {
31238
+ return null;
31239
+ }
31240
+ const owed = restartOwed(this.ownProject);
31241
+ if (owed === null) {
31242
+ return null;
31243
+ }
31244
+ if (connected) {
31245
+ restartSettled(this.ownProject);
31246
+ return null;
31247
+ }
31248
+ return owed;
31249
+ }
31052
31250
  getEditorStatusPayload() {
31053
31251
  const status = this.godotBridge.getStatus();
31054
31252
  const isPortConflict = this.bridgeStartupError?.includes("EADDRINUSE") ?? false;
31055
31253
  const stale = status.connected && status.addonVersion !== SERVER_VERSION;
31254
+ const unfinished = this.restartLeftUnfinished(status.connected);
31056
31255
  return {
31057
31256
  ...status,
31058
31257
  serverVersion: SERVER_VERSION,
31059
31258
  addonIsStale: status.connected ? stale : undefined,
31060
31259
  bridgeAvailable: this.bridgeStartupError === null,
31061
- mayYetConnect: status.connected ? undefined : this.anEditorIsStillComing(status.listeningSince),
31260
+ mayYetConnect: status.connected ? undefined : unfinished !== null ? false : this.anEditorIsStillComing(status.listeningSince),
31261
+ restartInterrupted: unfinished === null ? undefined : {
31262
+ quitEditorPid: unfinished.editorPid,
31263
+ quitAt: unfinished.quitAt,
31264
+ byServerPid: unfinished.byPid,
31265
+ 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."
31266
+ },
31062
31267
  awaitingLaunchedEditor: status.connected || !this.launchedEditorIsUp() ? undefined : this.launchedEditor?.pid ?? undefined,
31063
31268
  ...status.connected ? this.whatTheLaunchedEditorDropped() : {},
31064
31269
  startupError: this.bridgeStartupError,
@@ -31094,7 +31299,7 @@ class GodotServer {
31094
31299
  }
31095
31300
  }
31096
31301
  async handleEditorStatus() {
31097
- const godotPath = await this.locator.find();
31302
+ const located = await this.locator.find();
31098
31303
  const announced = runtimesAnnounced();
31099
31304
  const games = await Promise.all(announced.running.map(async (endpoint) => {
31100
31305
  const reply = await runtimeRequest(endpoint, "ping", {}, this.runtimeTimeoutMs());
@@ -31114,8 +31319,9 @@ class GodotServer {
31114
31319
  debugPort: playing?.debugPort ?? this.godotBridge.getStatus().debugPort
31115
31320
  },
31116
31321
  godot: {
31117
- path: godotPath,
31118
- version: godotPath === null ? null : await this.godotVersion(godotPath)
31322
+ path: located.ok ? located.path : null,
31323
+ version: located.ok ? await this.godotVersion(located.path) : null,
31324
+ problem: located.ok ? undefined : GodotLocator.refusal(located).message
31119
31325
  },
31120
31326
  game: {
31121
31327
  processActive: runIsUp(this.currentRun(), playing === null ? null : playing.playing),
@@ -31134,12 +31340,12 @@ class GodotServer {
31134
31340
  }
31135
31341
  settingKeysOf(projectPath) {
31136
31342
  if (projectPath === undefined) {
31137
- return new Map;
31343
+ return null;
31138
31344
  }
31139
31345
  try {
31140
- return settingKeys(readFileSync11(join11(projectPath, "project.godot"), "utf8"));
31346
+ return settingKeys(readFileSync12(join12(projectPath, "project.godot"), "utf8"));
31141
31347
  } catch {
31142
- return new Map;
31348
+ return null;
31143
31349
  }
31144
31350
  }
31145
31351
  async handleRestartEditor() {
@@ -31199,12 +31405,21 @@ class GodotServer {
31199
31405
  lsp: this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT),
31200
31406
  dap: this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT)
31201
31407
  };
31408
+ noteRestartBegun({
31409
+ projectPath,
31410
+ editorPid: editorPid ?? null,
31411
+ ports,
31412
+ quitAt: new Date().toISOString(),
31413
+ byPid: process.pid
31414
+ });
31202
31415
  const asked = await this.handleViaBridge("quit_editor", {});
31203
31416
  if (asked.isError === true) {
31417
+ restartSettled(projectPath);
31204
31418
  return asked;
31205
31419
  }
31206
31420
  await this.waitForBridge(() => !alive(editorPid), Date.now() + EDITOR_RESTART_TIMEOUT_MS);
31207
31421
  const opened = await this.openAnEditor(engine.value, projectPath, ports);
31422
+ restartSettled(projectPath);
31208
31423
  if (opened.error !== null) {
31209
31424
  return this.createErrorResponse(`The editor was asked to go and could not be started again: ${opened.error}`, [`editor_launch opens one on ${projectPath}`]);
31210
31425
  }
@@ -31240,10 +31455,12 @@ class GodotServer {
31240
31455
  this.logDebug(`Launching Godot editor for project: ${project.value.path}`);
31241
31456
  const ports = await this.portsForAnEditor();
31242
31457
  const before = this.settingKeysOf(project.value.path);
31458
+ const unfinished = restartOwed(project.value.path);
31243
31459
  const opened = await this.openAnEditor(engine.value, project.value.path, ports);
31244
31460
  if (opened.error !== null) {
31245
31461
  return this.createErrorResponse(`Could not start the editor: ${opened.error}`);
31246
31462
  }
31463
+ restartSettled(project.value.path);
31247
31464
  this.launchedFrom = { projectPath: project.value.path, before };
31248
31465
  return this.jsonTextResponse({
31249
31466
  launched: true,
@@ -31251,7 +31468,8 @@ class GodotServer {
31251
31468
  projectPath: project.value.path,
31252
31469
  lspPort: ports.lsp,
31253
31470
  dapPort: ports.dap,
31254
- 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."
31471
+ 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.`,
31472
+ 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."
31255
31473
  });
31256
31474
  }
31257
31475
  async openAnEditor(engine, projectPath, ports) {
@@ -31368,7 +31586,7 @@ class GodotServer {
31368
31586
  return runIsUp(run, editorSays);
31369
31587
  }
31370
31588
  async runtimeUp(projectPath, before, budgetMs, withArgs) {
31371
- if (!existsSync9(join11(projectPath, RUNTIME_AUTOLOAD.path))) {
31589
+ if (!existsSync9(join12(projectPath, RUNTIME_AUTOLOAD.path))) {
31372
31590
  return runtimeVerdict(null, { addon: false, budgetMs, heldAt: null, running: false, withArgs });
31373
31591
  }
31374
31592
  const endpoint = await announcedSince(projectPath, before, {
@@ -32036,8 +32254,8 @@ class GodotServer {
32036
32254
  return this.createErrorResponse(choice.problem);
32037
32255
  }
32038
32256
  const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
32039
- const screenshotDir = expectsScreenshot ? mkdtempSync2(join11(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
32040
- const screenshotPath = screenshotDir ? join11(screenshotDir, "capture.png") : null;
32257
+ const screenshotDir = expectsScreenshot ? mkdtempSync2(join12(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
32258
+ const screenshotPath = screenshotDir ? join12(screenshotDir, "capture.png") : null;
32041
32259
  try {
32042
32260
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
32043
32261
  if (!reply.ok) {
@@ -32058,7 +32276,7 @@ class GodotServer {
32058
32276
  return {
32059
32277
  content: [
32060
32278
  { type: "text", text: `Screenshot captured: ${dimensions}` },
32061
- { type: "image", data: readFileSync11(screenshotPath).toString("base64"), mimeType: "image/png" }
32279
+ { type: "image", data: readFileSync12(screenshotPath).toString("base64"), mimeType: "image/png" }
32062
32280
  ]
32063
32281
  };
32064
32282
  } finally {