gdharness 1.1.22 → 1.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/cli.js CHANGED
@@ -662,7 +662,7 @@ function environmentFor(options, variables = process.env) {
662
662
  const base = options.savesIn === undefined ? { ...variables } : userDataIn(options.savesIn, variables);
663
663
  return { ...base, ...options.env, GDHARNESS_RUNTIME_DIR: options.runtimeDirectory };
664
664
  }
665
- var OPENED_BY_A_SERVER = "GDHARNESS_OPENED_BY_A_SERVER", RESERVED_VARIABLE_PREFIX = "GDHARNESS_", 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.";
665
+ var OPENED_BY_A_SERVER = "GDHARNESS_OPENED_BY_A_SERVER", EDITOR_PID_VARIABLE = "GDHARNESS_EDITOR_PID", RESERVED_VARIABLE_PREFIX = "GDHARNESS_", 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.";
666
666
 
667
667
  // src/godot-path.ts
668
668
  import { execFile as execFile2 } from "node:child_process";
@@ -7684,6 +7684,73 @@ var require_public_api = __commonJS(function(exports) {
7684
7684
  exports.stringify = stringify;
7685
7685
  });
7686
7686
 
7687
+ // src/paths.ts
7688
+ import { realpathSync } from "node:fs";
7689
+ import { isAbsolute, relative, resolve, sep } from "node:path";
7690
+ function realPathOr(path) {
7691
+ try {
7692
+ return realpathSync.native(path);
7693
+ } catch {
7694
+ return path;
7695
+ }
7696
+ }
7697
+ function isSameDirectory(onePath, otherPath) {
7698
+ return relative(realPathOr(resolve(onePath)), realPathOr(resolve(otherPath))) === "";
7699
+ }
7700
+ function isWithinRoot(rootPath, candidatePath) {
7701
+ const step = relative(resolve(rootPath), resolve(candidatePath));
7702
+ return step !== ".." && !step.startsWith(`..${sep}`) && !isAbsolute(step);
7703
+ }
7704
+ function resolveWithinProject(projectPath, candidatePath) {
7705
+ const trimmed = candidatePath.trim();
7706
+ if (trimmed === "") {
7707
+ return { ok: false, reason: "Path is empty." };
7708
+ }
7709
+ if (trimmed.includes("\x00")) {
7710
+ return { ok: false, reason: "Path contains a null byte." };
7711
+ }
7712
+ const withoutScheme = trimmed.startsWith(RESOURCE_SCHEME) ? trimmed.slice(RESOURCE_SCHEME.length) : trimmed;
7713
+ if (withoutScheme.startsWith("uid://")) {
7714
+ return {
7715
+ ok: false,
7716
+ reason: `Path '${trimmed}' is a UID, and a file is judged here by where it is on disk, which a UID does not say. Give the file's res:// path.`
7717
+ };
7718
+ }
7719
+ const scheme = OTHER_SCHEME.exec(withoutScheme);
7720
+ if (scheme) {
7721
+ return {
7722
+ ok: false,
7723
+ reason: `Path '${trimmed}' uses the '${scheme[0]}' scheme, which names somewhere other than this project.`
7724
+ };
7725
+ }
7726
+ if (withoutScheme.startsWith("/") || withoutScheme.startsWith("\\") || isAbsolute(withoutScheme)) {
7727
+ return {
7728
+ ok: false,
7729
+ reason: `Path '${trimmed}' is absolute; give a path inside the project, such as 'scenes/main.tscn'.`
7730
+ };
7731
+ }
7732
+ if (withoutScheme.includes(":")) {
7733
+ return {
7734
+ ok: false,
7735
+ reason: `Path '${trimmed}' contains ':', which no file inside a project is named with.`
7736
+ };
7737
+ }
7738
+ const root = resolve(projectPath);
7739
+ const absolutePath = resolve(root, withoutScheme);
7740
+ if (!isWithinRoot(root, absolutePath)) {
7741
+ return { ok: false, reason: `Path '${trimmed}' resolves outside the project directory.` };
7742
+ }
7743
+ const relativePath = relative(root, absolutePath).split(sep).join("/");
7744
+ if (relativePath === "") {
7745
+ return { ok: false, reason: `Path '${trimmed}' is the project directory itself, not a file in it.` };
7746
+ }
7747
+ return { ok: true, absolutePath, relativePath };
7748
+ }
7749
+ var RESOURCE_SCHEME = "res://", OTHER_SCHEME;
7750
+ var init_paths = __esm(() => {
7751
+ OTHER_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]+:\/\//;
7752
+ });
7753
+
7687
7754
  // src/runner.ts
7688
7755
  import { existsSync as existsSync3 } from "node:fs";
7689
7756
  import { dirname as dirname2, join as join3 } from "node:path";
@@ -7897,22 +7964,22 @@ var init_engine_run = __esm(() => {
7897
7964
 
7898
7965
  // src/reimport.ts
7899
7966
  import { createHash } from "node:crypto";
7900
- import { existsSync as existsSync5, readFileSync as readFileSync3, rmSync as rmSync3, statSync as statSync2, utimesSync, writeFileSync as writeFileSync3 } from "node:fs";
7901
- import { basename as basename2, join as join5 } from "node:path";
7967
+ import { existsSync as existsSync5, readFileSync as readFileSync3, rmSync as rmSync3, statSync as statSync3, utimesSync, writeFileSync as writeFileSync3 } from "node:fs";
7968
+ import { basename as basename3, join as join5 } from "node:path";
7902
7969
  function forceNextImport(projectPath, resourcePath) {
7903
7970
  const relative = resourcePath.replace(/^res:\/\//, "");
7904
7971
  const sidecar = join5(projectPath, `${relative}.import`);
7905
7972
  if (!existsSync5(sidecar)) {
7906
7973
  return;
7907
7974
  }
7908
- const { atime, mtimeMs } = statSync2(sidecar);
7975
+ const { atime, mtimeMs } = statSync3(sidecar);
7909
7976
  writeFileSync3(sidecar, readFileSync3(sidecar, "utf8").replace(/^valid=false\r?\n/m, ""));
7910
7977
  utimesSync(sidecar, atime, new Date(mtimeMs + 2000));
7911
7978
  rmSync3(importRecord(projectPath, resourcePath), { force: true });
7912
7979
  }
7913
7980
  function importRecord(projectPath, resourcePath) {
7914
7981
  const digest = createHash("md5").update(resourcePath).digest("hex");
7915
- return join5(projectPath, ".godot", "imported", `${basename2(resourcePath)}-${digest}.md5`);
7982
+ return join5(projectPath, ".godot", "imported", `${basename3(resourcePath)}-${digest}.md5`);
7916
7983
  }
7917
7984
  var init_reimport = () => {};
7918
7985
 
@@ -8038,7 +8105,7 @@ var init_scratch = __esm(() => {
8038
8105
  });
8039
8106
 
8040
8107
  // src/headless.ts
8041
- import { existsSync as existsSync7, readFileSync as readFileSync4, realpathSync, writeFileSync as writeFileSync4 } from "node:fs";
8108
+ import { existsSync as existsSync7, readFileSync as readFileSync4, realpathSync as realpathSync2, writeFileSync as writeFileSync4 } from "node:fs";
8042
8109
  import { dirname as dirname4, join as join7 } from "node:path";
8043
8110
  function snakeCased(params) {
8044
8111
  const result = emptyRecord();
@@ -8093,7 +8160,7 @@ function scriptFault(problems, script) {
8093
8160
  }
8094
8161
  function resolvedOr(path) {
8095
8162
  try {
8096
- return realpathSync.native(path);
8163
+ return realpathSync2.native(path);
8097
8164
  } catch {
8098
8165
  return path;
8099
8166
  }
@@ -8446,23 +8513,51 @@ function sameCodeNote(addonVersion, serverVersion, addonDigest, shippedDigest) {
8446
8513
  }
8447
8514
  return `The editor loaded the ${addonVersion} addons, which are the same code this ${serverVersion} server ships, so nothing needs restarting; addonVersion changes at the next editor start.`;
8448
8515
  }
8449
- function addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest) {
8516
+ function addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest, onDisk) {
8450
8517
  if (!editorIsStale(addonVersion, serverVersion, addonDigest, shippedDigest)) {
8451
8518
  return;
8452
8519
  }
8453
8520
  const reported = addonVersion ?? "";
8521
+ const restart = onDisk !== undefined && onDisk !== shippedDigest ? `The project's own addons are not this server's either, so a restart would load the same code again: gdharness upgrade in the project installs the ${serverVersion} addons, and editor_launch restart then picks them up.` : "Restart it with editor_launch restart to pick this one up.";
8454
8522
  if (reported === serverVersion) {
8455
- return `The editor is running a different build of the ${reported} addon from the one this server ships. Restart it with editor_launch restart to pick this one up.`;
8523
+ return `The editor is running a different build of the ${reported} addon from the one this server ships. ${restart}`;
8456
8524
  }
8457
8525
  const editor = reported === "" ? UNVERSIONED : `the ${reported} addon`;
8458
8526
  const both = `The editor is running ${editor} while this server ships ${serverVersion}.`;
8459
8527
  if (reported !== "" && isNewer(reported, serverVersion)) {
8460
8528
  return `${both} This server is the older half: reconnect it in your harness so it spawns ${reported}.`;
8461
8529
  }
8462
- return `${both} Restart it with editor_launch restart to pick the new one up.`;
8530
+ return `${both} ${restart}`;
8531
+ }
8532
+ function runtimeAddonUnknown(addon) {
8533
+ return addon?.version === "" && addon.digest === "";
8534
+ }
8535
+ function runtimeMismatch(pid, addon, serverVersion, shippedDigest, onDisk, projectPath) {
8536
+ if (runtimeAddonUnknown(addon)) {
8537
+ return;
8538
+ }
8539
+ if (addon !== undefined && !editorIsStale(addon.version, serverVersion, addon.digest, shippedDigest)) {
8540
+ return;
8541
+ }
8542
+ const game = `The game (pid ${pid})`;
8543
+ if (addon !== undefined && addon.version !== "" && isNewer(addon.version, serverVersion)) {
8544
+ return `${game} is running the ${addon.version} runtime addon while this server ships ${serverVersion}. This server is the older half: reconnect it in your harness so it spawns ${addon.version}.`;
8545
+ }
8546
+ let loaded;
8547
+ if (addon === undefined) {
8548
+ loaded = `a runtime addon from before games reported theirs, while this server ships ${serverVersion}`;
8549
+ } else if (addon.version === serverVersion) {
8550
+ loaded = `a different build of the ${serverVersion} runtime addon from the one this server ships`;
8551
+ } else if (addon.version === "") {
8552
+ loaded = `a runtime addon with no version marker, while this server ships ${serverVersion}`;
8553
+ } else {
8554
+ loaded = `the ${addon.version} runtime addon, while this server ships ${serverVersion}`;
8555
+ }
8556
+ const remedy = onDisk !== undefined && onDisk !== shippedDigest ? `The project's own runtime addon is not this server's either, so starting the game again loads the same code: gdharness upgrade in ${projectPath} installs the ${serverVersion} addons, and the game picks them up when it next starts.` : `The project holds this server's runtime addon, so the game picks it up when it next starts.`;
8557
+ return `${game} is running ${loaded}, and its answers come from that code. ${remedy}`;
8463
8558
  }
8464
- function markIfStale(answer, addonVersion, serverVersion, addonDigest, shippedDigest) {
8465
- const staleNote = addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest);
8559
+ function markIfStale(answer, addonVersion, serverVersion, addonDigest, shippedDigest, onDisk) {
8560
+ const staleNote = addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest, onDisk);
8466
8561
  if (staleNote === undefined || typeof answer !== "object" || answer === null || Array.isArray(answer)) {
8467
8562
  return answer;
8468
8563
  }
@@ -8609,7 +8704,7 @@ var init_issues = __esm(() => {
8609
8704
  });
8610
8705
 
8611
8706
  // src/class-cache.ts
8612
- import { existsSync as existsSync9, readdirSync as readdirSync3, readFileSync as readFileSync7, statSync as statSync3 } from "node:fs";
8707
+ import { existsSync as existsSync9, readdirSync as readdirSync3, readFileSync as readFileSync7, statSync as statSync4 } from "node:fs";
8613
8708
  import { join as join9 } from "node:path";
8614
8709
  function isToolScript(source) {
8615
8710
  return TOOL.test(codeOf(source));
@@ -8905,7 +9000,7 @@ function uncachedClassNote(types) {
8905
9000
  function cacheWrittenAt(projectPath) {
8906
9001
  const cache = join9(projectPath, ".godot", "global_script_class_cache.cfg");
8907
9002
  try {
8908
- return statSync3(cache).mtimeMs;
9003
+ return statSync4(cache).mtimeMs;
8909
9004
  } catch {
8910
9005
  return null;
8911
9006
  }
@@ -8917,7 +9012,7 @@ function declaredSince(projectPath, paths, names, since) {
8917
9012
  return false;
8918
9013
  }
8919
9014
  try {
8920
- return statSync3(join9(projectPath, path.slice("res://".length))).mtimeMs > since;
9015
+ return statSync4(join9(projectPath, path.slice("res://".length))).mtimeMs > since;
8921
9016
  } catch {
8922
9017
  return false;
8923
9018
  }
@@ -16050,82 +16145,15 @@ var init_types = __esm(() => {
16050
16145
  };
16051
16146
  });
16052
16147
 
16053
- // src/paths.ts
16054
- import { realpathSync as realpathSync2 } from "node:fs";
16055
- import { isAbsolute, relative, resolve, sep } from "node:path";
16056
- function realPathOr(path) {
16057
- try {
16058
- return realpathSync2.native(path);
16059
- } catch {
16060
- return path;
16061
- }
16062
- }
16063
- function isSameDirectory(onePath, otherPath) {
16064
- return relative(realPathOr(resolve(onePath)), realPathOr(resolve(otherPath))) === "";
16065
- }
16066
- function isWithinRoot(rootPath, candidatePath) {
16067
- const step = relative(resolve(rootPath), resolve(candidatePath));
16068
- return step !== ".." && !step.startsWith(`..${sep}`) && !isAbsolute(step);
16069
- }
16070
- function resolveWithinProject(projectPath, candidatePath) {
16071
- const trimmed = candidatePath.trim();
16072
- if (trimmed === "") {
16073
- return { ok: false, reason: "Path is empty." };
16074
- }
16075
- if (trimmed.includes("\x00")) {
16076
- return { ok: false, reason: "Path contains a null byte." };
16077
- }
16078
- const withoutScheme = trimmed.startsWith(RESOURCE_SCHEME) ? trimmed.slice(RESOURCE_SCHEME.length) : trimmed;
16079
- if (withoutScheme.startsWith("uid://")) {
16080
- return {
16081
- ok: false,
16082
- reason: `Path '${trimmed}' is a UID, and a file is judged here by where it is on disk, which a UID does not say. Give the file's res:// path.`
16083
- };
16084
- }
16085
- const scheme = OTHER_SCHEME.exec(withoutScheme);
16086
- if (scheme) {
16087
- return {
16088
- ok: false,
16089
- reason: `Path '${trimmed}' uses the '${scheme[0]}' scheme, which names somewhere other than this project.`
16090
- };
16091
- }
16092
- if (withoutScheme.startsWith("/") || withoutScheme.startsWith("\\") || isAbsolute(withoutScheme)) {
16093
- return {
16094
- ok: false,
16095
- reason: `Path '${trimmed}' is absolute; give a path inside the project, such as 'scenes/main.tscn'.`
16096
- };
16097
- }
16098
- if (withoutScheme.includes(":")) {
16099
- return {
16100
- ok: false,
16101
- reason: `Path '${trimmed}' contains ':', which no file inside a project is named with.`
16102
- };
16103
- }
16104
- const root = resolve(projectPath);
16105
- const absolutePath = resolve(root, withoutScheme);
16106
- if (!isWithinRoot(root, absolutePath)) {
16107
- return { ok: false, reason: `Path '${trimmed}' resolves outside the project directory.` };
16108
- }
16109
- const relativePath = relative(root, absolutePath).split(sep).join("/");
16110
- if (relativePath === "") {
16111
- return { ok: false, reason: `Path '${trimmed}' is the project directory itself, not a file in it.` };
16112
- }
16113
- return { ok: true, absolutePath, relativePath };
16114
- }
16115
- var RESOURCE_SCHEME = "res://", OTHER_SCHEME;
16116
- var init_paths = __esm(() => {
16117
- OTHER_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]+:\/\//;
16118
- });
16119
-
16120
16148
  // src/resources.ts
16121
- import { readFileSync as readFileSync8 } from "node:fs";
16122
- import { extname, resolve as resolve2 } from "node:path";
16149
+ import { readFileSync as readFileSync8, realpathSync as realpathSync3 } from "node:fs";
16150
+ import { extname, resolve as resolve3 } from "node:path";
16123
16151
  function ensureProjectPath(getProjectPath) {
16124
16152
  const projectPath = getProjectPath();
16125
16153
  if (!projectPath) {
16126
- throw new Refusal("Project path is not set. Set a Godot project path first.");
16154
+ throw new Refusal("This server knows no project to read from: start it with GDHARNESS_PROJECT, connect an editor, or make any tool call naming a projectPath that holds project.godot.");
16127
16155
  }
16128
- return resolve2(projectPath);
16156
+ return resolve3(projectPath);
16129
16157
  }
16130
16158
  function uriPathToProjectPath(inputPath) {
16131
16159
  const normalized = inputPath.replace(/\\/g, "/").trim();
@@ -16139,7 +16167,22 @@ function resolveProjectFile(projectPath, resourcePath) {
16139
16167
  if (!contained.ok) {
16140
16168
  throw new Refusal(contained.reason);
16141
16169
  }
16142
- return contained.absolutePath;
16170
+ let root;
16171
+ try {
16172
+ root = realpathSync3.native(projectPath);
16173
+ } catch {
16174
+ throw new Refusal(`The project directory ${projectPath} is not there.`);
16175
+ }
16176
+ let real;
16177
+ try {
16178
+ real = realpathSync3.native(contained.absolutePath);
16179
+ } catch {
16180
+ throw new Refusal(`No file at ${resourcePath} in the project.`);
16181
+ }
16182
+ if (!isWithinRoot(root, real)) {
16183
+ throw new Refusal(`${resourcePath} leads outside the project through a link, so it is not read.`);
16184
+ }
16185
+ return real;
16143
16186
  }
16144
16187
  function parseGodotUri(uri) {
16145
16188
  if (uri === "godot://project/info") {
@@ -16431,12 +16474,18 @@ import {
16431
16474
  readFileSync as readFileSync9,
16432
16475
  rmdirSync,
16433
16476
  rmSync as rmSync5,
16434
- statSync as statSync4,
16477
+ statSync as statSync5,
16435
16478
  writeFileSync as writeFileSync6
16436
16479
  } from "node:fs";
16437
16480
  import { dirname as dirname5, join as join10 } from "node:path";
16438
16481
  import { fileURLToPath } from "node:url";
16439
16482
  function editorAddonDigest(from = shippedAddonsDirectory()) {
16483
+ return addonsDigest(EDITOR_PLUGINS, from);
16484
+ }
16485
+ function runtimeAddonDigest(from = shippedAddonsDirectory()) {
16486
+ return addonsDigest([RUNTIME_ADDON], from);
16487
+ }
16488
+ function addonsDigest(names, from) {
16440
16489
  const hash = createHash3("sha256");
16441
16490
  const walk = (directory, relative) => {
16442
16491
  for (const name of readdirSync4(directory).sort()) {
@@ -16445,7 +16494,7 @@ function editorAddonDigest(from = shippedAddonsDirectory()) {
16445
16494
  }
16446
16495
  const path = join10(directory, name);
16447
16496
  const inside = `${relative}/${name}`;
16448
- if (statSync4(path).isDirectory()) {
16497
+ if (statSync5(path).isDirectory()) {
16449
16498
  walk(path, inside);
16450
16499
  } else {
16451
16500
  hash.update(`${inside}
@@ -16456,7 +16505,7 @@ function editorAddonDigest(from = shippedAddonsDirectory()) {
16456
16505
  }
16457
16506
  }
16458
16507
  };
16459
- for (const name of EDITOR_PLUGINS) {
16508
+ for (const name of names) {
16460
16509
  walk(join10(from, name), name);
16461
16510
  }
16462
16511
  return hash.digest("hex");
@@ -16471,8 +16520,24 @@ function shippedEditorDigest() {
16471
16520
  }
16472
16521
  return shippedDigest ?? undefined;
16473
16522
  }
16523
+ function shippedRuntimeDigest() {
16524
+ if (shippedRuntime === undefined) {
16525
+ try {
16526
+ shippedRuntime = runtimeAddonDigest();
16527
+ } catch {
16528
+ shippedRuntime = null;
16529
+ }
16530
+ }
16531
+ return shippedRuntime ?? undefined;
16532
+ }
16474
16533
  function installedEditorDigest(projectPath) {
16475
- const marker = join10(projectPath, "addons", ADDONS[0], DIGEST_MARKER);
16534
+ return digestMarker(projectPath, ADDONS[0]);
16535
+ }
16536
+ function installedRuntimeDigest(projectPath) {
16537
+ return digestMarker(projectPath, RUNTIME_ADDON);
16538
+ }
16539
+ function digestMarker(projectPath, addon) {
16540
+ const marker = join10(projectPath, "addons", addon, DIGEST_MARKER);
16476
16541
  if (!existsSync10(marker)) {
16477
16542
  return null;
16478
16543
  }
@@ -16489,7 +16554,7 @@ function installAddons(projectPath, from = shippedAddonsDirectory()) {
16489
16554
  const installed = [];
16490
16555
  for (const name of ADDONS) {
16491
16556
  const source = join10(from, name);
16492
- if (!existsSync10(join10(source, name === "gdharness_runtime" ? "runtime_autoload.gd" : "plugin.cfg"))) {
16557
+ if (!existsSync10(join10(source, name === RUNTIME_ADDON ? "runtime_autoload.gd" : "plugin.cfg"))) {
16493
16558
  throw new Error(`The package holds no ${name} addon at ${source}.`);
16494
16559
  }
16495
16560
  const target = join10(projectPath, "addons", name);
@@ -16502,6 +16567,8 @@ function installAddons(projectPath, from = shippedAddonsDirectory()) {
16502
16567
  installed.push({ name, path: target, replaced });
16503
16568
  }
16504
16569
  writeFileSync6(join10(projectPath, "addons", ADDONS[0], DIGEST_MARKER), `${editorAddonDigest(from)}
16570
+ `);
16571
+ writeFileSync6(join10(projectPath, "addons", RUNTIME_ADDON, DIGEST_MARKER), `${runtimeAddonDigest(from)}
16505
16572
  `);
16506
16573
  return installed;
16507
16574
  }
@@ -16656,7 +16723,7 @@ function inspectProject(projectPath) {
16656
16723
  problems
16657
16724
  };
16658
16725
  }
16659
- var ADDONS, EDITOR_PLUGINS, RUNTIME_AUTOLOAD, SCRIPT_RUNS_SETTING = "gdharness/runtime/serve_script_runs", BIND_ADDRESS_SETTING = "gdharness/runtime/bind_address", PORT_SETTING = "gdharness/runtime/port", VERSION_MARKER = ".gdharness-version", DIGEST_MARKER = ".gdharness-digest", shippedDigest;
16726
+ var ADDONS, EDITOR_PLUGINS, RUNTIME_AUTOLOAD, SCRIPT_RUNS_SETTING = "gdharness/runtime/serve_script_runs", BIND_ADDRESS_SETTING = "gdharness/runtime/bind_address", PORT_SETTING = "gdharness/runtime/port", VERSION_MARKER = ".gdharness-version", DIGEST_MARKER = ".gdharness-digest", RUNTIME_ADDON = "gdharness_runtime", shippedDigest, shippedRuntime;
16660
16727
  var init_setup = __esm(() => {
16661
16728
  init_class_cache();
16662
16729
  init_headless();
@@ -17395,13 +17462,13 @@ var init_tool_definitions = __esm(() => {
17395
17462
  },
17396
17463
  {
17397
17464
  name: "editor_launch",
17398
- description: "Opens the Godot editor on a project, or restarts the one already connected. The editor is an ordinary visible one on the desktop in use, and on Windows it is shown without being activated, so opening or reopening it does not take the keyboard from whatever the user is typing in. hidden: true opens it headless instead and, on Windows, on a desktop of its own, where the games it plays go too; a hidden editor serves the bridge, the language server and the debug adapter as a visible one does. A game a visible editor plays opens as a window on the desktop in use. An editor goes on serving the addon it read at startup, so restart is what puts a gdharness upgrade into effect, and nothing else here restarts one; it saves open scenes on the way out and answers with the version that came back. An editor a server opened is started again by the server, visible unless hidden: true is passed again. One opened by hand restarts itself, which only one with a window can do, 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.",
17465
+ description: "Opens the Godot editor on a project, or restarts the one already connected. The editor is an ordinary visible one on the desktop in use, and on Windows it is shown without being activated, so opening or reopening it does not take the keyboard from whatever the user is typing in. hidden: true opens it headless instead and, on Windows, on a desktop of its own, where the games it plays go too; a hidden editor serves the bridge, the language server and the debug adapter as a visible one does. A game a visible editor plays opens as a window on the desktop in use. An editor goes on serving the addon it read at startup, so restart is what puts a gdharness upgrade into effect, and nothing else here restarts one; it saves open scenes on the way out and answers with the version that came back. An editor a server opened is started again by the server, visible unless hidden: true is passed again, once the old one has gone: it waits 90 seconds for that, or GDHARNESS_EDITOR_RESTART_TIMEOUT_MS, and an old editor still running after it is not replaced, since the new one would find its ports held. One opened by hand restarts itself, which only one with a window can do, 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. An open is refused when the editor it would start could never reach this server: a project other than the one this server serves, whose editor dials that project's own server; a project without the gdharness editor addon installed and enabled; and a project whose editor a restart is already bringing back.",
17399
17466
  parameters: {
17400
17467
  projectPath: { ...PROJECT_PATH, ops: ["open"] },
17401
17468
  hidden: {
17402
17469
  type: "boolean",
17403
17470
  ops: ["open", "restart"],
17404
- description: "Open the editor headless and, on Windows, on a desktop of its own, so no window of it or of the games it plays appears. Default false: one visible editor, shown without taking the focus."
17471
+ description: "Open the editor headless and, on Windows, on a desktop of its own, so no window of it or of the games it plays appears. Default false: one visible editor, shown without taking the focus. On restart only for an editor a server opened; one opened by hand comes back as it was, so hidden is refused there."
17405
17472
  }
17406
17473
  },
17407
17474
  requires: [],
@@ -17489,7 +17556,7 @@ var init_tool_definitions = __esm(() => {
17489
17556
  requires: ["projectPath"]
17490
17557
  },
17491
17558
  stop: {
17492
- summary: "end the run and answer with what it printed last, naming the process ended under endedPid: for a run the editor plays, the number its game announced under, read at the stop rather than remembered, and the operating system hands a freed number to the next process, so it can equal the game before it. A game that started processes of its own keeps them unless andChildren says to end those too. stopped is false, with the number under notSignalled, when that number no longer answers as the process the run was started as and nothing was signalled",
17559
+ summary: "end the run and answer with what it printed last, naming the process ended under endedPid: for a run the editor plays, the number its game announced under, read at the stop rather than remembered, and the operating system hands a freed number to the next process, so it can equal the game before it. A game that started processes of its own keeps them unless andChildren says to end those too. stopped is false, with the number under notSignalled, when that number no longer answers as the process the run was started as and nothing was signalled, and false with the number under stillGoing when what was told to end was still running ten seconds later",
17493
17560
  requires: []
17494
17561
  },
17495
17562
  check: {
@@ -17550,7 +17617,7 @@ var init_tool_definitions = __esm(() => {
17550
17617
  },
17551
17618
  {
17552
17619
  name: "editor_status",
17553
- 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: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. 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. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. 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. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait.",
17620
+ 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: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. 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. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. Each game also says which runtime addon it loaded, under addonVersion (null when it cannot say); addonIsStale is true when that code is not what this server ships, and staleNote says whether the project needs gdharness upgrade, the game a restart, or this server a reconnect. A second checkout of a project, such as a git worktree, carries addons of its own and is where this shows. Every runtime_* answer from such a game carries addonIsStale and staleNote too. 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. True beside restartUnderway is a restart still in progress, its server alive and within the wait for the old editor to go: the editor is coming, and opening another would put two on one project. 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. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait. An editor that has connected and not yet greeted, which lasts a frame and longer while it imports, reports nothing about itself: greeting says so, addonIsStale is absent rather than guessed, and a restart asked then waits for the greeting to learn who opened it. startedByAnEditor is an editor another editor started, by Godot's own restart or the project manager, which has none of the arguments a server gave the one before it; startedByAnEditorNote says what that costs.",
17554
17621
  parameters: {},
17555
17622
  requires: []
17556
17623
  },
@@ -17589,6 +17656,7 @@ var init_tool_definitions = __esm(() => {
17589
17656
  "physics2d",
17590
17657
  "physics3d",
17591
17658
  "audio",
17659
+ "visual",
17592
17660
  "animation",
17593
17661
  "ui"
17594
17662
  ],
@@ -17628,7 +17696,7 @@ var init_tool_definitions = __esm(() => {
17628
17696
  property: {
17629
17697
  type: "string",
17630
17698
  ops: ["property", "find"],
17631
- description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through what a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about. A number or a key steps into a list or a map, "_game:run:roster:0:traits", which is how the lists a game keeps its state in are walked: a roster, a board, an in-tray. A packed list, such as a PackedStringArray, steps the same way. A negative number counts from the end. A step written as a call, "get_viewport():gui_get_focus_owner()", calls a method that takes no arguments and walks into what it returned, which is how a question only a method answers is read in one call: which control holds the focus in the viewport this one is in. A list or a map answers the calls that read it and take no arguments, "_game:run:board:size()" or "_game:inbox:keys()", and not those that change it, such as clear() or sort(). A step that is not there is named, and says what was there instead.'
17699
+ description: 'property: which one to read. find: read this one off every node matched, so a panel of labels is one call rather than one per label. Colons read through what a node holds, "_game:clock:speed", which is where a game keeps what is worth asking about. A number or a key steps into a list or a map, "_game:run:roster:0:traits", which is how the lists a game keeps its state in are walked: a roster, a board, an in-tray. A packed list, such as a PackedStringArray, steps the same way. A negative number counts from the end. A step written as a call, "get_viewport():gui_get_focus_owner()", calls a method that takes no arguments and only reads (an engine method the engine marks as reading, or a game method not declared -> void, so queue_free() is refused) and walks into what it returned, which is how a question only a method answers is read in one call: which control holds the focus in the viewport this one is in. A list or a map answers the calls that read it and take no arguments, "_game:run:board:size()" or "_game:inbox:keys()", and not those that change it, such as clear() or sort(). A step that is not there is named, and says what was there instead.'
17632
17700
  },
17633
17701
  depth: {
17634
17702
  type: "integer",
@@ -17667,13 +17735,14 @@ var init_tool_definitions = __esm(() => {
17667
17735
  limit: {
17668
17736
  type: "integer",
17669
17737
  minimum: 1,
17738
+ maximum: 5000,
17670
17739
  ops: ["find", "text"],
17671
- description: "find: the most nodes to answer with, default 100. text: the most lines, default 500, with omitted saying how many lines that left behind. A screen whose dialog sits under a long list is a screen read with omitted greater than zero, so raise this or point root at the dialog rather than reading the answer as what is on screen."
17740
+ description: "find: the most nodes to answer with, default 100. text: the most lines, default 500, with omitted saying how many lines that left behind. At most 5000 for either. A screen whose dialog sits under a long list is a screen read with omitted greater than zero, so raise this or point root at the dialog rather than reading the answer as what is on screen."
17672
17741
  },
17673
17742
  includeHidden: {
17674
17743
  type: "boolean",
17675
17744
  ops: ["text", "find"],
17676
- description: "text: read hidden nodes as well, for checking that something is not showing. Default false. find: default true, since a find means the node whether or not it is drawn; false answers with what the player can actually see, which is how a panel that keeps a label for every line and hides the ones that do not is read. A node counts as hidden when anything above it is, and how many matches were left out comes back under hidden."
17745
+ description: "text: read hidden nodes as well, for checking that something is not showing. Default false. find: default true, since a find means the node whether or not it is drawn; false answers with what the player can actually see, which is how a panel that keeps a label for every line and hides the ones that do not is read. A node counts as hidden when the engine would not draw it: a hidden Control, Node2D or Node3D above it hides it, a plain Node in between breaks that chain as it does for the engine, and a hidden canvas layer or window hides everything in it. How many matches were left out comes back under hidden."
17677
17746
  },
17678
17747
  metrics: {
17679
17748
  type: "array",
@@ -17772,7 +17841,7 @@ var init_tool_definitions = __esm(() => {
17772
17841
  },
17773
17842
  {
17774
17843
  name: "runtime_capture",
17775
- description: "A picture of the running game: the whole screen or one viewport, as an image. Needs the game running with a window. A capture asked for before the game's first frame is drawn waits for that frame, since the game announces itself before it and the viewport holds nothing it drew until then.",
17844
+ description: "A picture of the running game: the whole screen or one viewport, as an image. The whole screen includes the game's own dialogs and popups where they sit, also when a project opens them as windows of their own (subwindows not embedded). Needs the game running with a window. A capture asked for before the game's first frame is drawn waits for that frame, since the game announces itself before it and the viewport holds nothing it drew until then.",
17776
17845
  parameters: {
17777
17846
  projectPath: RUNNING_PROJECT_PATH,
17778
17847
  pid: RUNNING_PID,
@@ -17946,7 +18015,7 @@ var init_tool_definitions = __esm(() => {
17946
18015
  },
17947
18016
  value: {
17948
18017
  ops: ["until"],
17949
- description: "until: the value to wait for, fitted to the property's type. On a property holding an object, whether declared to or untyped and holding one or null, the text null means null. One declared to hold an object refuses any value other than null, since it can never hold it."
18018
+ description: "until: the value to wait for, fitted to the property's type and compared at the precision the property keeps, so -1.1 meets a 32-bit float holding -1.10000002. A value the property would have to cut is refused: a fraction on a whole number, a number other than 0 or 1 on a bool. On a property holding an object, whether declared to or untyped and holding one or null, the text null means null. One declared to hold an object refuses any value other than null, since it can never hold it."
17950
18019
  },
17951
18020
  says: {
17952
18021
  type: "string",
@@ -32778,10 +32847,10 @@ var init_stdio2 = __esm(() => {
32778
32847
  });
32779
32848
 
32780
32849
  // src/runtime-client.ts
32781
- import { existsSync as existsSync12, readdirSync as readdirSync6, readFileSync as readFileSync11, statSync as statSync5, unlinkSync } from "node:fs";
32850
+ import { existsSync as existsSync12, readdirSync as readdirSync6, readFileSync as readFileSync11, statSync as statSync6, unlinkSync } from "node:fs";
32782
32851
  import { createConnection } from "node:net";
32783
32852
  import { tmpdir as tmpdir3 } from "node:os";
32784
- import { dirname as dirname8, join as join13, resolve as resolve3 } from "node:path";
32853
+ import { dirname as dirname8, join as join13, resolve as resolve4 } from "node:path";
32785
32854
  import { setTimeout as delay } from "node:timers/promises";
32786
32855
  function runtimeDirectory(variables = process.env) {
32787
32856
  const explicit = envValue("GDHARNESS_RUNTIME_DIR", variables);
@@ -32797,7 +32866,7 @@ function runtimeDirectories(variables = process.env) {
32797
32866
  for (const base of fallbacks) {
32798
32867
  candidates.push(join13(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
32799
32868
  }
32800
- return [...new Set(candidates.map((path) => resolve3(path)))];
32869
+ return [...new Set(candidates.map((path) => resolve4(path)))];
32801
32870
  }
32802
32871
  function errorReportOf(pid, directories = runtimeDirectories()) {
32803
32872
  for (const directory of directories) {
@@ -32850,13 +32919,14 @@ function parseAnnouncement(file, pid) {
32850
32919
  };
32851
32920
  let announcedAt;
32852
32921
  try {
32853
- announcedAt = statSync5(file).mtimeMs;
32922
+ announcedAt = statSync6(file).mtimeMs;
32854
32923
  } catch {}
32855
32924
  const when = announcedAt === undefined ? {} : { announcedAt };
32856
32925
  if (protocol !== RUNTIME_PROTOCOL) {
32857
32926
  return { kind: "unspoken", unspoken: { pid, protocol: protocol ?? 0, project: named, ...when } };
32858
32927
  }
32859
32928
  const editorPid = readNumber(fields, "editor_pid");
32929
+ const addonVersion = readString(fields, "addon_version");
32860
32930
  return {
32861
32931
  kind: "runtime",
32862
32932
  endpoint: {
@@ -32866,10 +32936,17 @@ function parseAnnouncement(file, pid) {
32866
32936
  project: named,
32867
32937
  file,
32868
32938
  ...editorPid !== undefined && Number.isInteger(editorPid) && editorPid > 0 ? { editorPid } : {},
32869
- ...when
32939
+ ...when,
32940
+ ...addonVersion === undefined ? {} : { addon: { version: addonVersion, digest: readString(fields, "addon_digest") ?? "" } }
32870
32941
  }
32871
32942
  };
32872
32943
  }
32944
+ function dialAddress(address) {
32945
+ if (address === "*" || address === "0.0.0.0") {
32946
+ return "127.0.0.1";
32947
+ }
32948
+ return address === "::" ? "::1" : address;
32949
+ }
32873
32950
  function runtimesAnnounced(directories = runtimeDirectories()) {
32874
32951
  const running = new Map;
32875
32952
  const unspoken = new Map;
@@ -32956,7 +33033,7 @@ function announcedIn(directory) {
32956
33033
  entries.push({ file, pid, running });
32957
33034
  if (running) {
32958
33035
  try {
32959
- candidates.push({ file, pid, writtenAt: statSync5(file).mtimeMs });
33036
+ candidates.push({ file, pid, writtenAt: statSync6(file).mtimeMs });
32960
33037
  } catch {}
32961
33038
  }
32962
33039
  }
@@ -33013,7 +33090,7 @@ function sweepErrorReport(path, pid, now = Date.now()) {
33013
33090
  return;
33014
33091
  }
33015
33092
  try {
33016
- if (now - statSync5(path).mtimeMs >= ERROR_REPORT_KEEP_MS) {
33093
+ if (now - statSync6(path).mtimeMs >= ERROR_REPORT_KEEP_MS) {
33017
33094
  unlinkSync(path);
33018
33095
  }
33019
33096
  } catch {}
@@ -33043,7 +33120,7 @@ function theOneProject(endpoints) {
33043
33120
  if (endpoints.some((endpoint) => endpoint.project.path === "")) {
33044
33121
  return;
33045
33122
  }
33046
- const paths = new Set(endpoints.map((endpoint) => realPathOr(resolve3(endpoint.project.path))));
33123
+ const paths = new Set(endpoints.map((endpoint) => realPathOr(resolve4(endpoint.project.path))));
33047
33124
  const only = [...paths][0];
33048
33125
  return paths.size === 1 ? only : undefined;
33049
33126
  }
@@ -33074,7 +33151,7 @@ function chooseRuntime(endpoints, projectPath, unspoken = [], pid) {
33074
33151
  if (named !== undefined) {
33075
33152
  if (projectPath !== undefined && !isSameDirectory(named.project.path, projectPath)) {
33076
33153
  return {
33077
- problem: `The game with pid ${pid} is running ${named.project.path}, not ${resolve3(projectPath)}. Running: ${endpoints.map(describe2).join("; ")}.`
33154
+ problem: `The game with pid ${pid} is running ${named.project.path}, not ${resolve4(projectPath)}. Running: ${endpoints.map(describe2).join("; ")}.`
33078
33155
  };
33079
33156
  }
33080
33157
  return { endpoint: named };
@@ -33114,7 +33191,7 @@ function chooseRuntime(endpoints, projectPath, unspoken = [], pid) {
33114
33191
  };
33115
33192
  }
33116
33193
  if (projectPath !== undefined) {
33117
- const wanted = resolve3(projectPath);
33194
+ const wanted = resolve4(projectPath);
33118
33195
  const matching = endpoints.filter((endpoint) => isSameDirectory(endpoint.project.path, wanted));
33119
33196
  if (matching.length === 0) {
33120
33197
  return {
@@ -33159,7 +33236,7 @@ function runtimeRequest(endpoint, command, params, timeoutMs, keepListening = fa
33159
33236
  let late = false;
33160
33237
  let buffered = "";
33161
33238
  let welcomed = false;
33162
- const socket = createConnection({ port: endpoint.port, host: endpoint.address });
33239
+ const socket = createConnection({ port: endpoint.port, host: dialAddress(endpoint.address) });
33163
33240
  let lateTimer = null;
33164
33241
  const finish = (reply) => {
33165
33242
  if (late) {
@@ -33323,7 +33400,7 @@ var init_boot_note = __esm(() => {
33323
33400
 
33324
33401
  // src/breakpoint-note.ts
33325
33402
  import { mkdirSync as mkdirSync7, readFileSync as readFileSync13, rmSync as rmSync7, writeFileSync as writeFileSync9 } from "node:fs";
33326
- import { dirname as dirname10, isAbsolute as isAbsolute2, join as join15, relative as relative2 } from "node:path";
33403
+ import { dirname as dirname10, isAbsolute as isAbsolute2, join as join15, relative as relative3 } from "node:path";
33327
33404
  function breakpointNotePath(projectPath) {
33328
33405
  return join15(projectPath, ".godot", "gdharness-breakpoints.json");
33329
33406
  }
@@ -33338,7 +33415,7 @@ function writeBreakpointNote(projectPath, held) {
33338
33415
  }
33339
33416
  const note = {
33340
33417
  breakpoints: inside.map((one) => ({
33341
- script: relative2(root, one.scriptPath).replaceAll("\\", "/"),
33418
+ script: relative3(root, one.scriptPath).replaceAll("\\", "/"),
33342
33419
  lines: [...one.lines]
33343
33420
  }))
33344
33421
  };
@@ -33375,7 +33452,7 @@ function readBreakpointNote(projectPath) {
33375
33452
  }
33376
33453
  }
33377
33454
  function withinProject(projectPath, scriptPath) {
33378
- const rel = relative2(projectPath, scriptPath);
33455
+ const rel = relative3(projectPath, scriptPath);
33379
33456
  return rel !== "" && !rel.startsWith("..") && !isAbsolute2(rel);
33380
33457
  }
33381
33458
  var init_breakpoint_note = __esm(() => {
@@ -34238,7 +34315,7 @@ import {
34238
34315
  readFileSync as readFileSync16,
34239
34316
  readSync,
34240
34317
  rmSync as rmSync8,
34241
- statSync as statSync6,
34318
+ statSync as statSync7,
34242
34319
  writeFileSync as writeFileSync11
34243
34320
  } from "node:fs";
34244
34321
  import { dirname as dirname12, join as join18 } from "node:path";
@@ -34301,7 +34378,7 @@ function editorConsole(projectPath, editorPid, openedByAServer) {
34301
34378
  return { log, path, ...of === undefined ? {} : { readBytes: text.length, of } };
34302
34379
  }
34303
34380
  function theFirstOf(path) {
34304
- const whole = statSync6(path).size;
34381
+ const whole = statSync7(path).size;
34305
34382
  if (whole <= MOST_CONSOLE_BYTES) {
34306
34383
  return { text: readFileSync16(path, "utf8") };
34307
34384
  }
@@ -37372,7 +37449,10 @@ function mayYetConnect(listeningSince, now = Date.now()) {
37372
37449
  return listeningSince === undefined || now - listeningSince.getTime() < CONNECT_WINDOW_MS;
37373
37450
  }
37374
37451
  function theEditorHasComeBack(status, startedAt) {
37375
- return status.connected && (status.connectedAt?.getTime() ?? 0) > startedAt && status.addonVersion !== undefined;
37452
+ return hasSaidWhoItIs(status) && (status.connectedAt?.getTime() ?? 0) > startedAt;
37453
+ }
37454
+ function hasSaidWhoItIs(status) {
37455
+ return status.connected && status.addonVersion !== undefined;
37376
37456
  }
37377
37457
  function anEditorIsStillComing(listeningSince, launchedEditorIsAlive, now = Date.now()) {
37378
37458
  return mayYetConnect(listeningSince, now) || launchedEditorIsAlive;
@@ -37531,6 +37611,7 @@ var init_godot_bridge = __esm(() => {
37531
37611
  dapPort: this.connectionInfo?.dapPort,
37532
37612
  debugPort: this.connectionInfo?.debugPort,
37533
37613
  openedByAServer: this.connectionInfo?.openedByAServer,
37614
+ startedByAnEditor: this.connectionInfo?.startedByAnEditor,
37534
37615
  syncsBreakpoints: this.connectionInfo?.syncsBreakpoints,
37535
37616
  pendingRequests: this.pendingRequests.size,
37536
37617
  queuedResources: this.resourceQueues.size,
@@ -37676,6 +37757,7 @@ var init_godot_bridge = __esm(() => {
37676
37757
  this.connectionInfo.dapPort = servedPort(message.dap_port);
37677
37758
  this.connectionInfo.debugPort = servedPort(message.debug_port);
37678
37759
  this.connectionInfo.openedByAServer = message.opened_by_a_server === true;
37760
+ this.connectionInfo.startedByAnEditor = typeof message.started_by_an_editor === "boolean" ? message.started_by_an_editor : undefined;
37679
37761
  this.connectionInfo.syncsBreakpoints = typeof message.syncs_breakpoints === "boolean" ? message.syncs_breakpoints : undefined;
37680
37762
  this.log("info", `Godot ready: ${message.project_path}`);
37681
37763
  this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
@@ -37999,6 +38081,7 @@ function parseJUnit(xml) {
37999
38081
  const cases = suite.children.filter((child) => child.name === "testcase").map((element) => caseOf(suite, element));
38000
38082
  const failures = cases.filter((entry) => entry.status === "failed").length;
38001
38083
  const errors = cases.filter((entry) => entry.status === "error").length;
38084
+ const written = (kind) => suite.children.filter((child) => child.name === "testcase").reduce((sum, element) => sum + element.children.filter((child) => child.name === kind).length, 0);
38002
38085
  return {
38003
38086
  name: suite.attributes["name"] ?? "",
38004
38087
  path: pathOf(suite),
@@ -38006,8 +38089,8 @@ function parseJUnit(xml) {
38006
38089
  discovered: count(suite.attributes, "tests"),
38007
38090
  failures,
38008
38091
  errors,
38009
- hookFailures: Math.max(0, count(suite.attributes, "failures") - failures),
38010
- hookErrors: Math.max(0, count(suite.attributes, "errors") - errors),
38092
+ hookFailures: Math.max(0, count(suite.attributes, "failures") - written("failure")),
38093
+ hookErrors: Math.max(0, count(suite.attributes, "errors") - written("error")),
38011
38094
  skipped: cases.filter((entry) => entry.status === "skipped").length,
38012
38095
  time: count(suite.attributes, "time"),
38013
38096
  cases
@@ -38200,11 +38283,53 @@ var init_junit = __esm(() => {
38200
38283
  ORPHANS = /(\d+)\s+orphans/;
38201
38284
  });
38202
38285
 
38286
+ // src/keeper-channel.ts
38287
+ import { connect as connect2, createServer as createServer2 } from "node:net";
38288
+ function askTheKeeperToStop(address, withinMs) {
38289
+ return new Promise((resolve) => {
38290
+ let said = "";
38291
+ let settled = false;
38292
+ const settle = (answer) => {
38293
+ if (!settled) {
38294
+ settled = true;
38295
+ clearTimeout(timer);
38296
+ socket.destroy();
38297
+ resolve(answer);
38298
+ }
38299
+ };
38300
+ const socket = connect2(address);
38301
+ const timer = setTimeout(() => {
38302
+ settle({ unreached: `it did not answer within ${withinMs} ms` });
38303
+ }, withinMs);
38304
+ socket.setEncoding("utf8");
38305
+ socket.on("connect", () => {
38306
+ socket.write(`stop
38307
+ `);
38308
+ });
38309
+ socket.on("data", (chunk) => {
38310
+ said += chunk;
38311
+ const line = said.split(`
38312
+ `)[0] ?? "";
38313
+ if (said.includes(`
38314
+ `)) {
38315
+ settle(line === "signalled" || line === "gone" ? line : { unreached: `it answered ${JSON.stringify(line)}` });
38316
+ }
38317
+ });
38318
+ socket.on("error", (error) => {
38319
+ settle({ unreached: error.code ?? error.message });
38320
+ });
38321
+ socket.on("close", () => {
38322
+ settle({ unreached: "it closed the connection without answering" });
38323
+ });
38324
+ });
38325
+ }
38326
+ var init_keeper_channel = () => {};
38327
+
38203
38328
  // src/lsp_client.ts
38204
- import { existsSync as existsSync15, realpathSync as realpathSync3 } from "node:fs";
38329
+ import { existsSync as existsSync15, realpathSync as realpathSync4 } from "node:fs";
38205
38330
  import { readFile, realpath } from "node:fs/promises";
38206
38331
  import { createConnection as createConnection3 } from "node:net";
38207
- import { dirname as dirname13, join as join19, resolve as resolve4 } from "node:path";
38332
+ import { dirname as dirname13, join as join19, resolve as resolve5 } from "node:path";
38208
38333
  import { fileURLToPath as fileURLToPath3, pathToFileURL } from "node:url";
38209
38334
  function diagnosticsKey(uri) {
38210
38335
  let decoded;
@@ -38217,7 +38342,7 @@ function diagnosticsKey(uri) {
38217
38342
  return decoded;
38218
38343
  }
38219
38344
  try {
38220
- return realpathSync3.native(fileURLToPath3(decoded)).toLowerCase();
38345
+ return realpathSync4.native(fileURLToPath3(decoded)).toLowerCase();
38221
38346
  } catch {
38222
38347
  return decoded.toLowerCase();
38223
38348
  }
@@ -38334,7 +38459,7 @@ class GodotLSPClient {
38334
38459
  if (this.initialized) {
38335
38460
  return;
38336
38461
  }
38337
- const rootPath = this.rootPath ?? dirname13(resolve4(filePath));
38462
+ const rootPath = this.rootPath ?? dirname13(resolve5(filePath));
38338
38463
  await this.initialize(rootPath);
38339
38464
  }
38340
38465
  async sendRequest(method, params) {
@@ -38476,7 +38601,7 @@ class GodotLSPClient {
38476
38601
  });
38477
38602
  }
38478
38603
  toFileUri(filePath) {
38479
- return pathToFileURL(resolve4(filePath)).href;
38604
+ return pathToFileURL(resolve5(filePath)).href;
38480
38605
  }
38481
38606
  syncDocument(filePath, content) {
38482
38607
  const uri = this.toFileUri(filePath);
@@ -38520,7 +38645,7 @@ class GodotLSPClient {
38520
38645
  }
38521
38646
  async initialize(rootPath) {
38522
38647
  await this.ensureConnected();
38523
- const resolvedRootPath = resolve4(rootPath);
38648
+ const resolvedRootPath = resolve5(rootPath);
38524
38649
  const rootUri = pathToFileURL(resolvedRootPath).href;
38525
38650
  this.forgetServedWorkspace();
38526
38651
  const result = await this.sendRequest("initialize", {
@@ -38689,7 +38814,7 @@ function normalizeLSPError(error, port) {
38689
38814
  return String(error);
38690
38815
  }
38691
38816
  async function resolveLSPPaths(projectPathValue, scriptPathValue) {
38692
- const requestedProjectPath = resolve4(projectPathValue);
38817
+ const requestedProjectPath = resolve5(projectPathValue);
38693
38818
  let projectPath;
38694
38819
  try {
38695
38820
  projectPath = await realpath(requestedProjectPath);
@@ -38917,7 +39042,7 @@ function projectStructure(projectPath) {
38917
39042
  visit(join20(directory, entry.name));
38918
39043
  } else if (entry.isFile()) {
38919
39044
  const extension = entry.name.split(".").pop()?.toLowerCase() ?? "";
38920
- if (extension === "tscn") {
39045
+ if (SCENE_EXTENSIONS.has(extension)) {
38921
39046
  structure.scenes += 1;
38922
39047
  } else if (extension === "gd" || extension === "cs") {
38923
39048
  structure.scripts += 1;
@@ -38959,8 +39084,11 @@ function scriptsWithoutUid(projectPath) {
38959
39084
  visit(projectPath, "");
38960
39085
  return found.sort();
38961
39086
  }
39087
+ function isBinary(bytes) {
39088
+ return bytes.subarray(0, 8000).includes(0);
39089
+ }
38962
39090
  function searchProject(projectPath, options) {
38963
- const extensions = new Set(options.fileTypes.map((ext) => ext.replace(/^\./, "").toLowerCase()).filter(Boolean));
39091
+ const extensions = options.fileTypes === undefined ? null : new Set(options.fileTypes.map((ext) => ext.replace(/^\./, "").toLowerCase()).filter(Boolean));
38964
39092
  const result = {
38965
39093
  query: options.query,
38966
39094
  results: [],
@@ -38968,13 +39096,7 @@ function searchProject(projectPath, options) {
38968
39096
  };
38969
39097
  const regex = options.regex ? new RegExp(options.query, options.caseSensitive ? "" : "i") : null;
38970
39098
  const needle = options.caseSensitive ? options.query : options.query.toLowerCase();
38971
- const full = () => {
38972
- if (result.summary.total_matches >= options.maxResults) {
38973
- result.summary.truncated = true;
38974
- return true;
38975
- }
38976
- return false;
38977
- };
39099
+ const full = () => result.summary.truncated;
38978
39100
  const visit = (directory) => {
38979
39101
  if (steppedOver(directory)) {
38980
39102
  return;
@@ -38992,21 +39114,26 @@ function searchProject(projectPath, options) {
38992
39114
  continue;
38993
39115
  }
38994
39116
  const extension = entry.name.includes(".") ? entry.name.split(".").pop()?.toLowerCase() ?? "" : "";
38995
- if (!entry.isFile() || !extensions.has(extension)) {
39117
+ if (!entry.isFile() || extensions !== null && !extensions.has(extension)) {
39118
+ continue;
39119
+ }
39120
+ const bytes = readFileSync17(entryPath);
39121
+ if (extensions === null && isBinary(bytes)) {
38996
39122
  continue;
38997
39123
  }
38998
39124
  result.summary.files_searched += 1;
38999
39125
  const matches = [];
39000
- for (const [index, line] of readFileSync17(entryPath, "utf8").split(`
39001
- `).entries()) {
39002
- if (full()) {
39003
- break;
39126
+ for (const [index, line] of bytes.toString("utf8").split(/\r?\n/).entries()) {
39127
+ const found = regex ? regex.exec(line) : (options.caseSensitive ? line : line.toLowerCase()).includes(needle) ? [options.query] : null;
39128
+ if (found === null) {
39129
+ continue;
39004
39130
  }
39005
- const match = regex ? regex.exec(line)?.[0] : (options.caseSensitive ? line : line.toLowerCase()).includes(needle) ? options.query : "";
39006
- if (match) {
39007
- matches.push({ line: index + 1, content: line.trim(), match });
39008
- result.summary.total_matches += 1;
39131
+ if (result.summary.total_matches >= options.maxResults) {
39132
+ result.summary.truncated = true;
39133
+ break;
39009
39134
  }
39135
+ matches.push({ line: index + 1, content: line.trim(), match: found[0] ?? "" });
39136
+ result.summary.total_matches += 1;
39010
39137
  }
39011
39138
  if (matches.length > 0) {
39012
39139
  const relativePath = entryPath.slice(projectPath.length + 1).replace(/\\/g, "/");
@@ -39018,10 +39145,40 @@ function searchProject(projectPath, options) {
39018
39145
  visit(projectPath);
39019
39146
  return result;
39020
39147
  }
39021
- var SKIPPED, ASSET_EXTENSIONS, CARRIES_A_UID;
39148
+ var SKIPPED, SCENE_EXTENSIONS, ASSET_EXTENSIONS, CARRIES_A_UID;
39022
39149
  var init_project_scan = __esm(() => {
39023
39150
  SKIPPED = new Set(["node_modules"]);
39024
- ASSET_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "svg", "ttf", "otf", "wav", "mp3", "ogg"]);
39151
+ SCENE_EXTENSIONS = new Set(["tscn", "scn"]);
39152
+ ASSET_EXTENSIONS = new Set([
39153
+ "png",
39154
+ "jpg",
39155
+ "jpeg",
39156
+ "webp",
39157
+ "svg",
39158
+ "bmp",
39159
+ "tga",
39160
+ "exr",
39161
+ "hdr",
39162
+ "ktx",
39163
+ "dds",
39164
+ "ttf",
39165
+ "otf",
39166
+ "woff",
39167
+ "woff2",
39168
+ "fnt",
39169
+ "wav",
39170
+ "mp3",
39171
+ "ogg",
39172
+ "glb",
39173
+ "gltf",
39174
+ "obj",
39175
+ "fbx",
39176
+ "blend",
39177
+ "dae",
39178
+ "ogv",
39179
+ "gdshader",
39180
+ "shader"
39181
+ ]);
39025
39182
  CARRIES_A_UID = new Set(["gd", "shader", "gdshader"]);
39026
39183
  });
39027
39184
 
@@ -39075,12 +39232,12 @@ import {
39075
39232
  openSync as openSync2,
39076
39233
  readdirSync as readdirSync8,
39077
39234
  readFileSync as readFileSync19,
39078
- realpathSync as realpathSync4,
39235
+ realpathSync as realpathSync5,
39079
39236
  rmSync as rmSync10,
39080
- statSync as statSync7,
39237
+ statSync as statSync8,
39081
39238
  writeFileSync as writeFileSync13
39082
39239
  } from "node:fs";
39083
- import { basename as basename4, dirname as dirname15, join as join22, resolve as resolve5 } from "node:path";
39240
+ import { basename as basename5, dirname as dirname15, join as join22, resolve as resolve6 } from "node:path";
39084
39241
  import { promisify as promisify4 } from "node:util";
39085
39242
  function runsDirectory() {
39086
39243
  return join22(runtimeDirectory(), "runs");
@@ -39090,19 +39247,19 @@ function realAsFarAsItExists(path) {
39090
39247
  let at = path;
39091
39248
  for (;; ) {
39092
39249
  try {
39093
- return join22(realpathSync4.native(at), ...missing);
39250
+ return join22(realpathSync5.native(at), ...missing);
39094
39251
  } catch {
39095
39252
  const parent = dirname15(at);
39096
39253
  if (parent === at) {
39097
39254
  return path;
39098
39255
  }
39099
- missing.unshift(basename4(at));
39256
+ missing.unshift(basename5(at));
39100
39257
  at = parent;
39101
39258
  }
39102
39259
  }
39103
39260
  }
39104
39261
  function projectKey(projectPath) {
39105
- const real = realAsFarAsItExists(resolve5(projectPath)).replace(/[\\/]+$/, "");
39262
+ const real = realAsFarAsItExists(resolve6(projectPath)).replace(/[\\/]+$/, "");
39106
39263
  const folded = process.platform === "win32" || process.platform === "darwin" ? real.toLowerCase() : real;
39107
39264
  return createHash4("sha256").update(folded.replaceAll("\\", "/")).digest("hex").slice(0, 16);
39108
39265
  }
@@ -39209,7 +39366,10 @@ function recordAt(path) {
39209
39366
  pid,
39210
39367
  transcript,
39211
39368
  startedAt,
39369
+ ...typeof fields["startedBy"] === "number" ? { startedBy: fields["startedBy"] } : {},
39212
39370
  projectPath: typeof fields["projectPath"] === "string" ? fields["projectPath"] : "",
39371
+ ...typeof fields["servedBy"] === "string" && fields["servedBy"] !== "" ? { servedBy: fields["servedBy"] } : {},
39372
+ ...typeof fields["keeper"] === "string" && fields["keeper"] !== "" ? { keeper: fields["keeper"] } : {},
39213
39373
  arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : [],
39214
39374
  ...typeof fields["command"] === "string" ? { command: fields["command"] } : {},
39215
39375
  ...typeof fields["exitCode"] === "number" ? { exitCode: fields["exitCode"] } : {},
@@ -39218,7 +39378,8 @@ function recordAt(path) {
39218
39378
  }
39219
39379
  async function runningAs(pid) {
39220
39380
  if (process.platform === "win32") {
39221
- const answer = await windowsCommandLine(pid);
39381
+ const read = await readWindowsCommandLine(pid);
39382
+ const answer = "answer" in read ? read.answer : null;
39222
39383
  if (answer !== null) {
39223
39384
  const [, ...rest] = answer.split(`
39224
39385
  `);
@@ -39230,7 +39391,8 @@ async function runningAs(pid) {
39230
39391
  }
39231
39392
  }
39232
39393
  const image = await windowsImage(pid);
39233
- return image === null ? null : { kind: "image", text: image };
39394
+ const unread = "unread" in read ? read.unread : "Windows gave an empty command line";
39395
+ return "image" in image ? { kind: "image", text: image.image, unread } : { kind: "none", text: "", unread: `${unread}; tasklist ${image.why}` };
39234
39396
  }
39235
39397
  try {
39236
39398
  const began = await startedAt(pid, null);
@@ -39281,13 +39443,18 @@ function listeningPidInNetstat(printed, port) {
39281
39443
  }
39282
39444
  return null;
39283
39445
  }
39284
- async function windowsCommandLine(pid) {
39285
- try {
39286
- const answer = (await askWindows(`$p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}"; if ($p) { $p.CreationDate.ToUniversalTime().ToString("o"); $p.CommandLine }`, 15000)).trim();
39287
- return answer === "" ? null : answer;
39288
- } catch {
39289
- return null;
39446
+ async function readWindowsCommandLine(pid, ask = askWindows) {
39447
+ const query = `$p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}"; if ($p) { $p.CreationDate.ToUniversalTime().ToString("o"); $p.CommandLine }`;
39448
+ let unread = "";
39449
+ for (const withinMs of [15000, 45000]) {
39450
+ try {
39451
+ const answer = (await ask(query, withinMs)).trim();
39452
+ return answer === "" ? { unread: "Windows gave no command line for it" } : { answer };
39453
+ } catch (error) {
39454
+ unread = errorMessage(error);
39455
+ }
39290
39456
  }
39457
+ return { unread };
39291
39458
  }
39292
39459
  async function startedAt(pid, windowsAnswer) {
39293
39460
  if (process.platform === "win32") {
@@ -39310,41 +39477,53 @@ async function windowsImage(pid) {
39310
39477
  timeout: 15000,
39311
39478
  windowsHide: true
39312
39479
  });
39313
- return /^"([^"]+)"/.exec(csv.trim())?.[1] ?? null;
39314
- } catch {
39315
- return null;
39480
+ const image = /^"([^"]+)"/.exec(csv.trim())?.[1];
39481
+ return image === undefined ? { why: "named no process under that number" } : { image };
39482
+ } catch (error) {
39483
+ const failed = error;
39484
+ return { why: failed.killed === true ? "did not answer within 15000 ms" : errorMessage(error) };
39316
39485
  }
39317
39486
  }
39318
- async function stillTheRecordedRun(record) {
39319
- return judgeRun(record, await runningAs(record.pid), "confirmed");
39487
+ async function whyNotStillTheRecordedRun(record) {
39488
+ return whyNotTheRun(record, await runningAs(record.pid), "confirmed");
39320
39489
  }
39321
39490
  async function couldStillBeTheRecordedRun(record) {
39322
39491
  return judgeRun(record, await runningAs(record.pid), "possible");
39323
39492
  }
39324
39493
  function judgeRun(record, running, needed) {
39494
+ return whyNotTheRun(record, running, needed) === null;
39495
+ }
39496
+ function whyNotTheRun(record, running, needed) {
39325
39497
  if (running === null) {
39326
- return false;
39498
+ return "the operating system would not describe that process";
39327
39499
  }
39328
- if (running.startedAt !== undefined && running.startedAt - record.startedAt > SAME_RUN_WINDOW_MS) {
39329
- return false;
39500
+ if (running.kind === "none") {
39501
+ return `the operating system would not describe that process (${running.unread ?? "no reason given"})`;
39502
+ }
39503
+ const after = running.startedAt === undefined ? 0 : running.startedAt - (record.startedBy ?? record.startedAt);
39504
+ if (after > SAME_RUN_WINDOW_MS) {
39505
+ return `that process started ${Math.round(after / 1000)} seconds after the run had`;
39330
39506
  }
39331
- const engine = record.command === undefined ? null : basename4(record.command);
39507
+ const engine = record.command === undefined ? null : basename5(record.command);
39332
39508
  const said = process.platform === "win32" ? running.text.toLowerCase() : running.text;
39333
39509
  const wanted = engine === null || process.platform !== "win32" ? engine : engine.toLowerCase();
39334
39510
  if (wanted !== null && !said.includes(wanted)) {
39335
- return false;
39511
+ return `that process is not ${engine ?? "the engine"}, which the run was started with`;
39336
39512
  }
39337
39513
  if (running.kind === "commandLine" && readCommandLine(running.text).editor) {
39338
- return false;
39514
+ return "that process is an editor";
39339
39515
  }
39340
39516
  if (record.projectPath === "") {
39341
- return wanted !== null;
39517
+ return wanted !== null ? null : "the record names neither the project nor the engine";
39342
39518
  }
39343
39519
  if (running.kind === "image") {
39344
- return needed === "possible" && wanted !== null;
39520
+ if (wanted === null) {
39521
+ return "the operating system named only its executable, and the record names no engine";
39522
+ }
39523
+ return needed === "possible" ? null : `the operating system named only its executable and not its command line (${running.unread ?? "no reason given"}), which is not enough to signal it on`;
39345
39524
  }
39346
39525
  const project = process.platform === "win32" ? record.projectPath.toLowerCase() : record.projectPath;
39347
- return said.includes(project);
39526
+ return said.includes(project) ? null : `that process's command line does not name ${record.projectPath}`;
39348
39527
  }
39349
39528
  async function stillTheAnnouncedGame(pid, projectPath, announcedAt) {
39350
39529
  return judgeAnnouncedGame(await runningAs(pid), projectPath, announcedAt);
@@ -39376,7 +39555,7 @@ function clearRunRecord(projectPath) {
39376
39555
  }
39377
39556
  function ageMs(path, now) {
39378
39557
  try {
39379
- return now - statSync7(path).mtimeMs;
39558
+ return now - statSync8(path).mtimeMs;
39380
39559
  } catch {
39381
39560
  return 0;
39382
39561
  }
@@ -39405,6 +39584,7 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
39405
39584
  }
39406
39585
  var execFileAsync, SHARED_NOTE = "run.json", SAME_RUN_WINDOW_MS = 1e4;
39407
39586
  var init_run_record = __esm(() => {
39587
+ init_errors();
39408
39588
  init_paths();
39409
39589
  init_process_children();
39410
39590
  init_runtime_client();
@@ -39428,12 +39608,19 @@ import {
39428
39608
  readFileSync as readFileSync20,
39429
39609
  readSync as readSync2,
39430
39610
  rmdirSync as rmdirSync2,
39431
- statSync as statSync8
39611
+ statSync as statSync9
39432
39612
  } from "node:fs";
39433
- import { basename as basename5, dirname as dirname16, extname as extname2, isAbsolute as isAbsolute3, join as join23, normalize as normalize2, relative as relative3 } from "node:path";
39613
+ import { basename as basename6, dirname as dirname16, extname as extname2, isAbsolute as isAbsolute3, join as join23, normalize as normalize2, relative as relative4 } from "node:path";
39434
39614
  import { setTimeout as delay3 } from "node:timers/promises";
39435
39615
  import { fileURLToPath as fileURLToPath5 } from "node:url";
39436
39616
  import { promisify as promisify5 } from "node:util";
39617
+ function editorRestartTimeoutMs() {
39618
+ const override = Number.parseInt(envValue("GDHARNESS_EDITOR_RESTART_TIMEOUT_MS") ?? "", 10);
39619
+ return Number.isInteger(override) && override > 0 ? override : 90000;
39620
+ }
39621
+ function restartUnderway(note, now = Date.now()) {
39622
+ return alive(note.byPid) && now - Date.parse(note.quitAt) < editorRestartTimeoutMs() + 30000;
39623
+ }
39437
39624
  function captureDestinationRefusal(outputPath, projects) {
39438
39625
  if (/^(res|user):\/\//.test(outputPath)) {
39439
39626
  return `outputPath ${outputPath} is a Godot path; a capture is saved outside the project, at an absolute path on this machine.`;
@@ -39448,7 +39635,7 @@ function captureDestinationRefusal(outputPath, projects) {
39448
39635
  if (!existsSync17(directory)) {
39449
39636
  return `outputPath's directory ${dirname16(outputPath)} does not exist; a capture creates no directories.`;
39450
39637
  }
39451
- const where = join23(directory, basename5(outputPath));
39638
+ const where = join23(directory, basename6(outputPath));
39452
39639
  const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
39453
39640
  if (inside !== undefined) {
39454
39641
  return `outputPath ${outputPath} is inside the project at ${inside}; a capture is saved outside it, so it never becomes part of the game.`;
@@ -39462,7 +39649,7 @@ function patienceForFrames(frames, atLeast) {
39462
39649
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
39463
39650
  }
39464
39651
  function projectSpelling(projectPath, absolutePath) {
39465
- return `res://${relative3(realPathOr(projectPath), realPathOr(absolutePath)).replaceAll("\\", "/")}`;
39652
+ return `res://${relative4(realPathOr(projectPath), realPathOr(absolutePath)).replaceAll("\\", "/")}`;
39466
39653
  }
39467
39654
  function whichGame(args) {
39468
39655
  return { projectPath: args["projectPath"], pid: args["pid"] };
@@ -39584,22 +39771,31 @@ function stopVerdict(stop) {
39584
39771
  withChildren: false
39585
39772
  };
39586
39773
  }
39774
+ if (stop.ending === "lingering") {
39775
+ const told = stop.throughEditor ? stop.endedPid === null ? "The editor was asked to stop the scene it is playing and was still playing it" : `The editor was asked to stop the scene it is playing, and its game, pid ${stop.endedPid}, was still running` : `pid ${stop.endedPid ?? "unknown"} was told to end and was still running`;
39776
+ return {
39777
+ stopped: false,
39778
+ stillGoing: stop.endedPid,
39779
+ note: `${told} ${STOP_WAIT_MS / 1000} seconds later, so it has not stopped. editor_status says when it has gone, and editor_run stop asks again.`,
39780
+ withChildren: true
39781
+ };
39782
+ }
39587
39783
  return {
39588
39784
  stopped: true,
39589
39785
  endedPid: stop.endedPid,
39590
39786
  clean: stop.clean,
39591
- note: `${stop.throughEditor ? stop.endedPid === null ? "The editor was asked to stop the scene it is playing. Its game had not announced a runtime, so no process was named under endedPid, and its exit code stays with the editor." : "The editor was asked to stop the scene it is playing." : "The process named under endedPid was ended."}${stop.exitSignal === null ? "" : ` It was ended by ${stop.exitSignal} and so has no exit code.`}${stop.ending === "gone" ? "" : ` It had not exited ${STOP_WAIT_MS / 1000} seconds after being told to, so it may still be going: editor_status says whether it is.`}`,
39787
+ note: `${stop.throughEditor ? stop.endedPid === null ? "The editor was asked to stop the scene it is playing. Its game had not announced a runtime, so no process was named under endedPid, and its exit code stays with the editor." : "The editor was asked to stop the scene it is playing." : "The process named under endedPid was ended."}${stop.exitSignal === null ? "" : ` It was ended by ${stop.exitSignal} and so has no exit code.`}`,
39592
39788
  withChildren: true
39593
39789
  };
39594
39790
  }
39595
39791
  function endedPreviousRun(ended) {
39596
- if (ended === null || ended.ending === "refused" || ended.ending === "unreached") {
39792
+ if (ended?.ending !== "gone") {
39597
39793
  return;
39598
39794
  }
39599
39795
  return ended.pid ?? true;
39600
39796
  }
39601
39797
  function previousRunLeft(ended) {
39602
- return ended?.ending === "refused" && ended.pid !== null ? ended.pid : undefined;
39798
+ return (ended?.ending === "refused" || ended?.ending === "lingering") && ended.pid !== null ? ended.pid : undefined;
39603
39799
  }
39604
39800
  function engineNamesOf(executable) {
39605
39801
  return CONSOLE_WRAPPER.test(executable) ? [executable, executable.replace(CONSOLE_WRAPPER, "$1")] : [executable];
@@ -39678,8 +39874,10 @@ function endedToStartThis(ended) {
39678
39874
  if (ended.ending === "unreached") {
39679
39875
  return ` ${which} was not ended here: the editor could not be asked to stop it. The editor stops a play before starting the next, so it is not running beside this one if this start reached the editor.`;
39680
39876
  }
39681
- const after = ended.ending === "lingering" ? ` It had not exited ${STOP_WAIT_MS / 1000} seconds after being told to, so it may still be running beside this one: editor_status says whether it is.` : "";
39682
- return ` ${which} was ended to start this one; its output is no longer what editor_output answers about.${after}`;
39877
+ if (ended.ending === "lingering") {
39878
+ return ` ${which} was told to end to start this one and was still running ${STOP_WAIT_MS / 1000} seconds later, so it is running beside this one: editor_status says when it has gone.`;
39879
+ }
39880
+ return ` ${which} was ended to start this one; its output is no longer what editor_output answers about.`;
39683
39881
  }
39684
39882
  function runtimeVerdict(endpoint, after) {
39685
39883
  if (endpoint !== null) {
@@ -40009,6 +40207,7 @@ class GodotServer {
40009
40207
  shutdownInitiated = false;
40010
40208
  launchedEditor = null;
40011
40209
  launchedFrom = null;
40210
+ launchedDropped = null;
40012
40211
  ownProject;
40013
40212
  announcedAt = null;
40014
40213
  handingOverFrom = null;
@@ -40016,9 +40215,12 @@ class GodotServer {
40016
40215
  constructor() {
40017
40216
  this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
40018
40217
  this.godotBridge = getDefaultBridge(this.ownProject);
40218
+ this.godotBridge.on("godot_connected", () => {
40219
+ this.settleTheLaunchReading();
40220
+ });
40019
40221
  this.mcp = new McpServer({ name: "gdharness", version: SERVER_VERSION }, { capabilities: { tools: {}, resources: {} } });
40020
40222
  this.setupToolHandlers();
40021
- setupResourceHandlers(this.mcp, () => this.lastProjectPath);
40223
+ setupResourceHandlers(this.mcp, () => this.ourProject() ?? this.lastProjectPath);
40022
40224
  this.mcp.server.onerror = (error) => {
40023
40225
  console.error("[MCP Error]", error);
40024
40226
  };
@@ -40284,8 +40486,9 @@ class GodotServer {
40284
40486
  if (!checked.ok) {
40285
40487
  return checked.response;
40286
40488
  }
40287
- if (typeof args["projectPath"] === "string") {
40288
- this.lastProjectPath = args["projectPath"];
40489
+ const named = args["projectPath"];
40490
+ if (typeof named === "string" && existsSync17(join23(named, "project.godot"))) {
40491
+ this.lastProjectPath = named;
40289
40492
  }
40290
40493
  this.updates.refresh();
40291
40494
  const answer = await withCallSignal(extra.signal, () => this.answered(spec.name, checked.op ?? "", args));
@@ -40325,6 +40528,17 @@ class GodotServer {
40325
40528
  return answer;
40326
40529
  }
40327
40530
  const moved = this.projectHasMovedOn();
40531
+ if (moved !== null && !isNewer(moved, SERVER_VERSION)) {
40532
+ this.noticedUpdate = true;
40533
+ this.callsSinceNotice = 0;
40534
+ return this.saying(answer, {
40535
+ project_addons_behind_this_server: {
40536
+ server_is: SERVER_VERSION,
40537
+ project_is: moved,
40538
+ what_to_do: `The project's gdharness addons are ${moved}, older than this ${SERVER_VERSION} server, so an ` + "editor restart loads the old code and reconnecting spawns this same server again. Tell the " + "user, and offer to run gdharness upgrade in the project, which replaces the addons with " + "this server's; ask first, since it changes files in their project. editor_launch restart " + "then loads them."
40539
+ }
40540
+ });
40541
+ }
40328
40542
  if (moved !== null) {
40329
40543
  this.noticedUpdate = true;
40330
40544
  this.callsSinceNotice = 0;
@@ -40357,7 +40571,7 @@ class GodotServer {
40357
40571
  return this.saying(answer, {
40358
40572
  update_available: {
40359
40573
  ...notice,
40360
- what_to_do: "Tell the user a newer gdharness is out, with what changed, and offer to take it. " + "Only run the upgrade command if they say yes: it restarts their editor and the " + "MCP server has to be reconnected afterwards."
40574
+ what_to_do: "Tell the user a newer gdharness is out, with what changed, and offer to take it. " + "Only run the upgrade command if they say yes: it replaces the addons in their project, " + "and afterwards the MCP server has to be reconnected and the editor restarted with " + "editor_launch restart."
40361
40575
  }
40362
40576
  });
40363
40577
  }
@@ -40374,6 +40588,15 @@ class GodotServer {
40374
40588
  const installed = installedAddonVersion(this.ownProject);
40375
40589
  return installed === null || installed === SERVER_VERSION ? null : installed;
40376
40590
  }
40591
+ editorCodeOnDisk() {
40592
+ const status = this.godotBridge.getStatus();
40593
+ const project = (status.connected ? status.projectPath : undefined) ?? this.ownProject;
40594
+ return project === null || project === "" ? undefined : installedEditorDigest(project);
40595
+ }
40596
+ runtimeStaleNote(endpoint) {
40597
+ const project = endpoint.project.path;
40598
+ return runtimeMismatch(endpoint.pid, endpoint.addon, SERVER_VERSION, shippedRuntimeDigest(), project === "" ? undefined : installedRuntimeDigest(project), project);
40599
+ }
40377
40600
  configDisagreement() {
40378
40601
  return this.ownProject === null ? null : configDisagrees(this.ownProject, SERVER_VERSION);
40379
40602
  }
@@ -41308,7 +41531,7 @@ class GodotServer {
41308
41531
  const name = application["config/name"];
41309
41532
  const mainScene = application["run/main_scene"];
41310
41533
  const info = {
41311
- name: typeof name === "string" && name !== "" ? name : basename5(project.value.path),
41534
+ name: typeof name === "string" && name !== "" ? name : basename6(project.value.path),
41312
41535
  path: project.value.path,
41313
41536
  mainScene: typeof mainScene === "string" && mainScene !== "" ? mainScene : null,
41314
41537
  godotVersion: await this.godotVersion(engine.value),
@@ -41325,6 +41548,10 @@ class GodotServer {
41325
41548
  }
41326
41549
  return this.jsonTextResponse(info);
41327
41550
  }
41551
+ fileTypes(args) {
41552
+ const named = readStringArray(args, "fileTypes");
41553
+ return named === undefined ? {} : { fileTypes: named };
41554
+ }
41328
41555
  handleSearchProject(args) {
41329
41556
  const project = this.project(args);
41330
41557
  if (!project.ok) {
@@ -41333,16 +41560,7 @@ class GodotServer {
41333
41560
  try {
41334
41561
  return this.jsonTextResponse(searchProject(project.value.path, {
41335
41562
  query: readString(args, "query") ?? "",
41336
- fileTypes: readStringArray(args, "fileTypes") ?? [
41337
- "gd",
41338
- "tscn",
41339
- "tres",
41340
- "gdshader",
41341
- "cfg",
41342
- "md",
41343
- "txt",
41344
- "json"
41345
- ],
41563
+ ...this.fileTypes(args),
41346
41564
  regex: readBoolean(args, "regex") ?? false,
41347
41565
  caseSensitive: readBoolean(args, "caseSensitive") ?? false,
41348
41566
  maxResults: readPositiveNumber(args, "maxResults") ?? 100
@@ -41384,7 +41602,7 @@ class GodotServer {
41384
41602
  output.absolutePath
41385
41603
  ];
41386
41604
  this.logDebug(`Exporting: ${engine.value} ${exportArgs.join(" ")}`);
41387
- const writtenAt = () => existsSync17(output.absolutePath) ? statSync8(output.absolutePath).mtimeMs : null;
41605
+ const writtenAt = () => existsSync17(output.absolutePath) ? statSync9(output.absolutePath).mtimeMs : null;
41388
41606
  const before = writtenAt();
41389
41607
  let ending;
41390
41608
  try {
@@ -41653,7 +41871,7 @@ class GodotServer {
41653
41871
  warnings: diagnostics.length - errors,
41654
41872
  diagnostics,
41655
41873
  ...disagrees
41656
- }, this.godotBridge.getStatus().addonVersion, SERVER_VERSION, this.godotBridge.getStatus().addonDigest, shippedEditorDigest()));
41874
+ }, this.godotBridge.getStatus().addonVersion, SERVER_VERSION, this.godotBridge.getStatus().addonDigest, shippedEditorDigest(), this.editorCodeOnDisk()));
41657
41875
  }
41658
41876
  whereTheFileDisagrees(diagnostics, args) {
41659
41877
  const messages = diagnostics.map((entry) => asParams(entry)["message"]).filter((message) => typeof message === "string");
@@ -41804,7 +42022,7 @@ class GodotServer {
41804
42022
  }
41805
42023
  breakpointsAtRisk() {
41806
42024
  const status = this.godotBridge.getStatus();
41807
- if (!status.connected || status.syncsBreakpoints === true) {
42025
+ if (!hasSaidWhoItIs(status) || status.syncsBreakpoints === true) {
41808
42026
  return {};
41809
42027
  }
41810
42028
  return {
@@ -41859,26 +42077,34 @@ class GodotServer {
41859
42077
  restartSettled(this.ownProject);
41860
42078
  return null;
41861
42079
  }
41862
- return owed;
42080
+ return { note: owed, underway: restartUnderway(owed) };
41863
42081
  }
41864
42082
  async getEditorStatusPayload() {
41865
42083
  const status = this.godotBridge.getStatus();
41866
42084
  const launchedIsUp = !status.connected && await this.launchedEditorIsUp();
41867
42085
  const isPortConflict = this.bridgeStartupError?.includes("EADDRINUSE") ?? false;
41868
- const stale = status.connected && editorIsStale(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest());
42086
+ const greeted = hasSaidWhoItIs(status);
42087
+ const stale = greeted && editorIsStale(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest());
41869
42088
  const unfinished = this.restartLeftUnfinished(status.connected);
41870
42089
  return {
41871
42090
  ...status,
41872
42091
  serverVersion: SERVER_VERSION,
41873
- addonIsStale: status.connected ? stale : undefined,
42092
+ addonIsStale: greeted ? stale : undefined,
42093
+ greeting: status.connected && !greeted ? "The editor has connected and has not yet said who it is: its project, pid, addon version and ports arrive with its greeting a moment after the socket opens, later while it imports. Ask again for them." : undefined,
41874
42094
  bridgeAvailable: this.bridgeStartupError === null,
41875
- mayYetConnect: status.connected ? undefined : unfinished !== null ? false : anEditorIsStillComing(status.listeningSince, launchedIsUp),
41876
- restartInterrupted: unfinished === null ? undefined : {
41877
- quitEditorPid: unfinished.editorPid,
41878
- quitAt: unfinished.quitAt,
41879
- byServerPid: unfinished.byPid,
42095
+ mayYetConnect: status.connected ? undefined : unfinished !== null ? unfinished.underway : anEditorIsStillComing(status.listeningSince, launchedIsUp),
42096
+ restartInterrupted: unfinished === null || unfinished.underway ? undefined : {
42097
+ quitEditorPid: unfinished.note.editorPid,
42098
+ quitAt: unfinished.note.quitAt,
42099
+ byServerPid: unfinished.note.byPid,
41880
42100
  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."
41881
42101
  },
42102
+ restartUnderway: unfinished === null || !unfinished.underway ? undefined : {
42103
+ quitEditorPid: unfinished.note.editorPid,
42104
+ quitAt: unfinished.note.quitAt,
42105
+ byServerPid: unfinished.note.byPid,
42106
+ note: `A restart of this project's editor is under way: gdharness server pid ${unfinished.note.byPid} asked it to quit at ${unfinished.note.quitAt} and is starting it again. Wait for it rather than opening another.`
42107
+ },
41882
42108
  awaitingLaunchedEditor: status.connected || !launchedIsUp ? undefined : this.launchedEditor?.pid ?? undefined,
41883
42109
  ...status.connected ? this.whatTheLaunchedEditorDropped() : {},
41884
42110
  startupError: this.bridgeStartupError,
@@ -41888,8 +42114,9 @@ class GodotServer {
41888
42114
  port: this.godotBridge.configuredPort,
41889
42115
  note: `The gdharness server this one replaced, pid ${this.handingOverFrom}, still holds port ${this.godotBridge.configuredPort} and is standing down; this server takes the port when it lets go, and takes another after ${HANDOVER_MS / 1000} seconds if it does not.`
41890
42116
  },
41891
- staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
41892
- addonNote: status.connected ? sameCodeNote(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
42117
+ staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest(), this.editorCodeOnDisk()) : undefined,
42118
+ addonNote: greeted ? sameCodeNote(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
42119
+ startedByAnEditorNote: status.startedByAnEditor === true ? "Another editor started this one: Godot's own restart, or the project manager opening a project. None of the arguments a server gave the editor before it reached this one, so it serves the ports its editor settings name (and none at all while another editor holds those, when the script and debug tools reach that editor instead), its console is not captured, and it restarts itself. Close it and use editor_launch open to have an editor this server started." : undefined,
41893
42120
  ...this.breakpointsAtRisk(),
41894
42121
  retryingBridge: this.bridgeRetry === null ? undefined : true,
41895
42122
  announcedAt: this.announcedAt ?? undefined,
@@ -41956,12 +42183,17 @@ class GodotServer {
41956
42183
  ok: false,
41957
42184
  message: `The game is held by the editor's debugger, ${describeHalt(held)}, and answers no runtime call while it is. debug_control continue lets it go.`
41958
42185
  };
42186
+ const staleNote = this.runtimeStaleNote(endpoint);
42187
+ const unknown = runtimeAddonUnknown(endpoint.addon);
41959
42188
  return {
41960
42189
  pid: endpoint.pid,
41961
42190
  port: endpoint.port,
41962
42191
  project: endpoint.project,
41963
42192
  ...this.ownProject === null ? {} : { ofThisProject: isSameDirectory(endpoint.project.path, this.ownProject) },
41964
42193
  editorPid: endpoint.editorPid,
42194
+ addonVersion: endpoint.addon === undefined || endpoint.addon.version === "" ? null : endpoint.addon.version,
42195
+ ...unknown ? {} : { addonIsStale: staleNote !== undefined },
42196
+ ...staleNote === undefined ? {} : { staleNote },
41965
42197
  reachable: reply.ok,
41966
42198
  problem: reply.ok ? null : reply.message
41967
42199
  };
@@ -42028,17 +42260,33 @@ class GodotServer {
42028
42260
  "editor_status says whether the bridge is up and what has reached it"
42029
42261
  ]);
42030
42262
  }
42031
- const settingsBefore = this.settingKeysOf(before.projectPath);
42263
+ if (!hasSaidWhoItIs(before)) {
42264
+ const settled = await this.waitForBridge(() => hasSaidWhoItIs(this.godotBridge.getStatus()) || !this.godotBridge.isConnected(), Date.now() + GREETING_WAIT_MS);
42265
+ if (!settled) {
42266
+ return this.createErrorResponse(`The editor has connected and has not said who it is within ${GREETING_WAIT_MS / 1000}s, so whether this server or the editor itself restarts it cannot be told.`, [
42267
+ "editor_status says when it has, under greeting while it has not",
42268
+ "Then ask for the restart again"
42269
+ ]);
42270
+ }
42271
+ return this.handleRestartEditor(args);
42272
+ }
42032
42273
  const mine = before.openedByAServer === true && before.projectPath !== undefined;
42274
+ if (!mine && args["hidden"] === true) {
42275
+ return this.createErrorResponse("This editor was opened by hand, and restarts itself as it was, so it cannot come back hidden.", [
42276
+ "Close it, then editor_launch open with hidden: true starts a hidden one this server can restart",
42277
+ "editor_launch restart without hidden restarts it as it is"
42278
+ ]);
42279
+ }
42280
+ const settingsBefore = this.settingKeysOf(before.projectPath);
42033
42281
  const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid, args["hidden"] === true) : await this.handleViaBridge("restart_editor", {});
42034
42282
  if (asked.isError === true) {
42035
42283
  return asked;
42036
42284
  }
42037
42285
  const startedAt = before.connectedAt?.getTime() ?? 0;
42038
42286
  const began = Date.now();
42039
- const back = await this.waitForBridge(() => theEditorHasComeBack(this.godotBridge.getStatus(), startedAt), began + EDITOR_RESTART_TIMEOUT_MS);
42287
+ const back = await this.waitForBridge(() => theEditorHasComeBack(this.godotBridge.getStatus(), startedAt), began + editorRestartTimeoutMs());
42040
42288
  if (!back) {
42041
- return this.createErrorResponse(`The editor was asked to restart and has not come back within ${EDITOR_RESTART_TIMEOUT_MS / 1000}s.`, [
42289
+ return this.createErrorResponse(`The editor was asked to restart and has not come back within ${editorRestartTimeoutMs() / 1000}s.`, [
42042
42290
  "It may be asking what to do about an unsaved scene: look at the editor window",
42043
42291
  "An addon that no longer parses stops the editor reaching this server",
42044
42292
  "editor_status says whether anything has reached the bridge since"
@@ -42052,18 +42300,27 @@ class GodotServer {
42052
42300
  addonVersion: now.addonVersion,
42053
42301
  serverVersion: SERVER_VERSION,
42054
42302
  addonIsStale: editorIsStale(now.addonVersion, SERVER_VERSION, now.addonDigest, shippedEditorDigest()),
42055
- staleNote: addonMismatch(now.addonVersion, SERVER_VERSION, now.addonDigest, shippedEditorDigest()),
42303
+ staleNote: addonMismatch(now.addonVersion, SERVER_VERSION, now.addonDigest, shippedEditorDigest(), this.editorCodeOnDisk()),
42056
42304
  ...this.whatTheEditorDropped(settingsBefore, after),
42057
42305
  tookMs: Date.now() - began
42058
42306
  });
42059
42307
  }
42060
- whatTheLaunchedEditorDropped() {
42308
+ settleTheLaunchReading() {
42061
42309
  const watched = this.launchedFrom;
42062
- if (watched === null) {
42063
- return {};
42310
+ const status = this.godotBridge.getStatus();
42311
+ if (watched === null || !hasSaidWhoItIs(status)) {
42312
+ return;
42064
42313
  }
42065
42314
  this.launchedFrom = null;
42066
- return this.whatTheEditorDropped(watched.before, this.settingKeysOf(watched.projectPath));
42315
+ if (status.editorPid === undefined || status.editorPid !== this.launchedEditor?.pid) {
42316
+ return;
42317
+ }
42318
+ this.launchedDropped = this.whatTheEditorDropped(watched.before, this.settingKeysOf(watched.projectPath));
42319
+ }
42320
+ whatTheLaunchedEditorDropped() {
42321
+ const dropped = this.launchedDropped ?? {};
42322
+ this.launchedDropped = null;
42323
+ return dropped;
42067
42324
  }
42068
42325
  whatTheEditorDropped(before, after) {
42069
42326
  return settingsDroppedReport(before, after);
@@ -42089,7 +42346,14 @@ class GodotServer {
42089
42346
  restartSettled(projectPath);
42090
42347
  return asked;
42091
42348
  }
42092
- await this.waitForBridge(() => !alive(editorPid), Date.now() + EDITOR_RESTART_TIMEOUT_MS);
42349
+ const gone = await this.waitForBridge(() => !alive(editorPid), Date.now() + editorRestartTimeoutMs());
42350
+ if (!gone) {
42351
+ restartSettled(projectPath);
42352
+ return this.createErrorResponse(`The editor was asked to go and pid ${editorPid ?? "unknown"} is still running after ${editorRestartTimeoutMs() / 1000}s, so no replacement was started: it would find the language server and debug adapter ports still held.`, [
42353
+ "It may be asking what to do about unsaved changes, or a script may be holding up its exit",
42354
+ `editor_launch open starts one on ${projectPath} once it has gone`
42355
+ ]);
42356
+ }
42093
42357
  const opened = await this.openAnEditor(engine.value, projectPath, ports, hidden);
42094
42358
  restartSettled(projectPath);
42095
42359
  if (opened.error !== null) {
@@ -42120,6 +42384,10 @@ class GodotServer {
42120
42384
  "Another project wants its own gdharness server, which is its own harness session"
42121
42385
  ]);
42122
42386
  }
42387
+ const refused = this.anEditorThatCannotConnect(project.value.path);
42388
+ if (refused !== null) {
42389
+ return refused;
42390
+ }
42123
42391
  const engine = await this.engine();
42124
42392
  if (!engine.ok) {
42125
42393
  return engine.response;
@@ -42144,6 +42412,27 @@ class GodotServer {
42144
42412
  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."
42145
42413
  });
42146
42414
  }
42415
+ anEditorThatCannotConnect(projectPath) {
42416
+ if (this.ownProject !== null && !isSameDirectory(this.ownProject, projectPath)) {
42417
+ return this.createErrorResponse(`This server serves ${this.ownProject}, and an editor of ${projectPath} would dial that project's own server rather than this one.`, [
42418
+ "Open it from that project's own gdharness session",
42419
+ `editor_launch with projectPath ${this.ownProject} opens the editor this server serves`
42420
+ ]);
42421
+ }
42422
+ const report = inspectProject(projectPath);
42423
+ const installed = report.addons.some((addon) => addon.name === "gdharness_editor" && addon.installed);
42424
+ if (!installed || !report.pluginsEnabled.includes("gdharness_editor")) {
42425
+ return this.createErrorResponse(`The gdharness editor addon is not ${installed ? "enabled" : "installed"} in ${projectPath}, so an editor opened on it would never reach this server.`, installed ? [
42426
+ "gdharness setup in the project enables it",
42427
+ "Or enable it under Project Settings, Plugins, in an editor opened by hand"
42428
+ ] : ["gdharness setup in the project installs and enables it"]);
42429
+ }
42430
+ const owed = restartOwed(projectPath);
42431
+ if (owed !== null && restartUnderway(owed)) {
42432
+ return this.createErrorResponse(`A restart of this project's editor is under way: gdharness server pid ${owed.byPid} asked it to quit at ${owed.quitAt} and is starting it again, so opening another would put two editors on one project.`, ["editor_status says when it has connected"]);
42433
+ }
42434
+ return null;
42435
+ }
42147
42436
  async openAnEditor(engine, projectPath, ports, hidden) {
42148
42437
  clearEditorLog(projectPath);
42149
42438
  const launched = await launchOutsideTheTree({
@@ -42350,7 +42639,7 @@ class GodotServer {
42350
42639
  }
42351
42640
  let announcedAt;
42352
42641
  try {
42353
- announcedAt = statSync8(endpoint.file).mtimeMs;
42642
+ announcedAt = statSync9(endpoint.file).mtimeMs;
42354
42643
  } catch {
42355
42644
  return;
42356
42645
  }
@@ -42587,27 +42876,41 @@ class GodotServer {
42587
42876
  if (running.pid === null) {
42588
42877
  return "gone";
42589
42878
  }
42879
+ if (running.keeper !== undefined) {
42880
+ const ended = await this.endThroughTheKeeper(running, running.pid, running.keeper);
42881
+ if (ended !== null) {
42882
+ clearTheRecord();
42883
+ return ended;
42884
+ }
42885
+ }
42590
42886
  const startedAs = {
42591
42887
  pid: running.pid,
42592
42888
  transcript: running.transcript ?? "",
42593
42889
  startedAt: running.startedAt,
42890
+ ...running.startedBy === undefined ? {} : { startedBy: running.startedBy },
42594
42891
  projectPath: running.projectPath ?? "",
42595
42892
  arguments: [],
42596
42893
  ...running.command === undefined ? {} : { command: running.command }
42597
42894
  };
42598
- if (!await stillTheRecordedRun(startedAs)) {
42895
+ const why = await whyNotStillTheRecordedRun(startedAs);
42896
+ if (why !== null) {
42599
42897
  clearTheRecord();
42600
42898
  running.endedHere = null;
42601
- running.log.record("warning", `This run was not ended here: pid ${running.pid} no longer answers as the process the run was started as, so nothing was signalled. If that process is still the game, end it yourself; if it is not, it belongs to something else.`);
42899
+ running.log.record("warning", `This run was not ended here: pid ${running.pid} no longer answers as the process the run was started as (${why}), so nothing was signalled. If that process is still the game, end it yourself; if it is not, it belongs to something else.`);
42602
42900
  return "refused";
42603
42901
  }
42604
42902
  const signalled = await runningAs(running.pid);
42605
- const described = signalled === null ? "nothing it would name" : `${signalled.kind}: ${signalled.text}`;
42903
+ const described = signalled === null || signalled.kind === "none" ? "nothing it would name" : `${signalled.kind}: ${signalled.text}`;
42606
42904
  let landed = true;
42905
+ let declined = null;
42607
42906
  try {
42608
42907
  process.kill(running.pid);
42609
- } catch {
42908
+ } catch (error) {
42610
42909
  landed = false;
42910
+ const code = error.code;
42911
+ if (code !== "ESRCH") {
42912
+ declined = code ?? errorMessage(error);
42913
+ }
42611
42914
  }
42612
42915
  const gone = await untilGone([running.pid]);
42613
42916
  if (gone) {
@@ -42618,7 +42921,24 @@ class GodotServer {
42618
42921
  running.exitSignal = "SIGTERM";
42619
42922
  }
42620
42923
  clearTheRecord();
42621
- running.log.record("info", landed ? `gdharness ended pid ${running.pid}, which the operating system described as ${described}.` : `gdharness signalled pid ${running.pid} and it was already gone; the operating system had described it as ${described}.`);
42924
+ running.log.record(gone ? "info" : "warning", declined !== null ? `gdharness could not signal pid ${running.pid}: the operating system refused it (${declined}). It had described that process as ${described}.` : !gone ? `gdharness signalled pid ${running.pid}, which the operating system described as ${described}, and it was still running ${STOP_WAIT_MS / 1000} seconds later.` : landed ? `gdharness ended pid ${running.pid}, which the operating system described as ${described}.` : `gdharness signalled pid ${running.pid} and it was already gone; the operating system had described it as ${described}.`);
42925
+ return gone ? "gone" : "lingering";
42926
+ }
42927
+ async endThroughTheKeeper(run, pid, keeper) {
42928
+ const asked = await askTheKeeperToStop(keeper, KEEPER_ANSWER_MS);
42929
+ if (typeof asked !== "string") {
42930
+ this.logDebug(`The keeper of pid ${pid} could not be asked to end it (${asked.unreached})`);
42931
+ return null;
42932
+ }
42933
+ const gone = await untilGone([pid]);
42934
+ if (gone) {
42935
+ await this.untilTheExitIsRecorded(run);
42936
+ }
42937
+ if (asked === "signalled" && gone) {
42938
+ run.exitCode = null;
42939
+ run.exitSignal = "SIGTERM";
42940
+ }
42941
+ run.log.record(gone ? "info" : "warning", !gone ? `gdharness had the keeper holding pid ${pid} end it, and it was still running ${STOP_WAIT_MS / 1000} seconds later.` : asked === "signalled" ? `gdharness ended pid ${pid} through the keeper holding it.` : `gdharness asked the keeper holding pid ${pid} to end it, and it had already gone.`);
42622
42942
  return gone ? "gone" : "lingering";
42623
42943
  }
42624
42944
  spawnGame(godotPath, cmdArgs, env) {
@@ -42702,13 +43022,16 @@ class GodotServer {
42702
43022
  command: godotPath,
42703
43023
  args: cmdArgs,
42704
43024
  ...env === undefined ? {} : { env },
42705
- run: { transcript: transcript.path, startedAt, projectPath },
43025
+ run: { transcript: transcript.path, startedAt, projectPath, ...this.servedBy() },
42706
43026
  ...desktop === undefined ? {} : { desktop }
42707
43027
  });
42708
43028
  if ("error" in launched) {
42709
43029
  return launched;
42710
43030
  }
43031
+ const startedBy = Date.now();
42711
43032
  sweepTranscripts();
43033
+ const noted = readRunRecord(projectPath);
43034
+ const keeper = noted?.pid === launched.pid ? noted.keeper : undefined;
42712
43035
  return {
42713
43036
  pid: launched.pid,
42714
43037
  log: new GameLog,
@@ -42716,6 +43039,8 @@ class GodotServer {
42716
43039
  readOffset: 0,
42717
43040
  projectPath,
42718
43041
  startedAt,
43042
+ startedBy,
43043
+ ...keeper === undefined ? {} : { keeper },
42719
43044
  command: godotPath,
42720
43045
  exitCode: null,
42721
43046
  exitSignal: null,
@@ -42935,8 +43260,9 @@ class GodotServer {
42935
43260
  }
42936
43261
  async adoptRecordedRun() {
42937
43262
  const project = this.ourProject();
42938
- const record = project === null ? null : readRunRecord(project);
42939
- if (record === null || !this.couldBeOurs(record.projectPath)) {
43263
+ const found = project === null ? [] : [readRunRecord(project), this.startedForUs(project)];
43264
+ const record = found.reduce((newest, one) => one !== null && (newest === null || one.startedAt > newest.startedAt) ? one : newest, null);
43265
+ if (record === null || !(this.couldBeOurs(record.projectPath) || this.couldBeOurs(record.servedBy ?? ""))) {
42940
43266
  return null;
42941
43267
  }
42942
43268
  const adopted = {
@@ -42946,6 +43272,8 @@ class GodotServer {
42946
43272
  readOffset: 0,
42947
43273
  projectPath: record.projectPath === "" ? null : record.projectPath,
42948
43274
  startedAt: record.startedAt,
43275
+ ...record.startedBy === undefined ? {} : { startedBy: record.startedBy },
43276
+ ...record.keeper === undefined ? {} : { keeper: record.keeper },
42949
43277
  ...record.command === undefined ? {} : { command: record.command },
42950
43278
  exitCode: null,
42951
43279
  exitSignal: null,
@@ -42966,6 +43294,13 @@ class GodotServer {
42966
43294
  this.activeProcess = adopted;
42967
43295
  return adopted;
42968
43296
  }
43297
+ servedBy() {
43298
+ const mine = this.ourProject();
43299
+ return mine === null ? {} : { servedBy: mine };
43300
+ }
43301
+ startedForUs(project) {
43302
+ return everyRunRecord().find((one) => one.servedBy !== undefined && isSameDirectory(one.servedBy, project) && !(one.projectPath !== "" && isSameDirectory(one.projectPath, project))) ?? null;
43303
+ }
42969
43304
  nothingOfOursIsRunning() {
42970
43305
  const status = this.godotBridge.getStatus();
42971
43306
  const noEditor = !status.connected ? " No editor is connected, so a game the editor is playing cannot be seen from here at all:" + " editor_status says whether one is on its way, and a run of yours that is still up is" + " reachable again once it is." : "";
@@ -43132,22 +43467,20 @@ class GodotServer {
43132
43467
  }
43133
43468
  async handleEditorConsole(args) {
43134
43469
  const status = this.godotBridge.getStatus();
43135
- const projectPath = status.projectPath ?? this.ownProject ?? undefined;
43136
- if (projectPath === undefined) {
43137
- return this.createErrorResponse("No editor is connected, so there is no console to read.", [
43138
- "editor_status says whether one is connected and whether it may yet be",
43139
- "editor_launch opens one, and an editor opened here has its console captured"
43140
- ]);
43470
+ const greeted = hasSaidWhoItIs(status);
43471
+ const launched = greeted ? null : this.launchedEditor?.pid ?? null;
43472
+ if (!greeted && launched === null && status.connected) {
43473
+ return this.createErrorResponse("The editor has connected and has not yet said who it is, so whose console to read cannot be told yet.", ["Ask again in a moment; editor_status shows greeting until it has"]);
43141
43474
  }
43142
- const launched = status.connected ? null : this.launchedEditor?.pid ?? null;
43143
- if (!status.connected && launched === null) {
43475
+ const projectPath = status.projectPath ?? this.ownProject ?? undefined;
43476
+ if (projectPath === undefined || !greeted && launched === null) {
43144
43477
  return this.createErrorResponse("No editor is connected, so there is no console to read.", [
43145
43478
  "editor_status says whether one is connected and whether it may yet be",
43146
43479
  "editor_launch opens one, and an editor opened here has its console captured"
43147
43480
  ]);
43148
43481
  }
43149
- const editorPid = status.connected ? status.editorPid ?? null : launched;
43150
- const console2 = editorConsole(projectPath, editorPid, status.connected ? status.openedByAServer === true : true);
43482
+ const editorPid = greeted ? status.editorPid ?? null : launched;
43483
+ const console2 = editorConsole(projectPath, editorPid, greeted ? status.openedByAServer === true : true);
43151
43484
  if ("kind" in console2) {
43152
43485
  return this.createErrorResponse(theConsoleWasNotCaptured(console2, projectPath), [
43153
43486
  "editor_launch restart replaces this editor with one whose console is captured",
@@ -43329,6 +43662,7 @@ class GodotServer {
43329
43662
  through: stopped.throughEditor ? "editor" : "gdharness",
43330
43663
  endedPid: verdict.endedPid,
43331
43664
  notSignalled: verdict.notSignalled,
43665
+ stillGoing: verdict.stillGoing,
43332
43666
  ...childrenFields(ended),
43333
43667
  exitedBeforeStop: !wasRunning,
43334
43668
  exitCode: stopped.exitCode,
@@ -43386,12 +43720,13 @@ class GodotServer {
43386
43720
  await announcementEnded(pid);
43387
43721
  }
43388
43722
  return this.jsonTextResponse({
43389
- stopped: true,
43723
+ stopped: gone,
43390
43724
  through: "gdharness",
43391
- endedPid: pid,
43725
+ endedPid: gone ? pid : undefined,
43726
+ stillGoing: gone ? undefined : pid,
43392
43727
  ...childrenFields(ended),
43393
43728
  exitedBeforeStop: !landed,
43394
- note: `${landed ? `pid ${pid} was ended: a game of this project that no server here was holding,` : `pid ${pid} had already gone when it was signalled: a game of this project that no server here was holding,`} identified by its announcement and by its command line, ${read.executable} run with --path ${read.projectPath ?? ""}. Nothing here read what it printed or holds its exit code, so neither is in this answer.${gone ? "" : ` It had not exited ${STOP_WAIT_MS / 1000} seconds after being told to, so it may still be going: editor_status says whether it is.`} ${aboutTheChildren(ended, false)}`
43729
+ note: `${!gone ? `pid ${pid} was told to end and was still running ${STOP_WAIT_MS / 1000} seconds later, so it has not stopped: a game of this project that no server here was holding,` : landed ? `pid ${pid} was ended: a game of this project that no server here was holding,` : `pid ${pid} had already gone when it was signalled: a game of this project that no server here was holding,`} identified by its announcement and by its command line, ${read.executable} run with --path ${read.projectPath ?? ""}. Nothing here read what it printed or holds its exit code, so neither is in this answer.${gone ? "" : " editor_status says when it has gone."} ${aboutTheChildren(ended, false)}`
43395
43730
  });
43396
43731
  }
43397
43732
  async whatTheRunStarted(run) {
@@ -43538,7 +43873,8 @@ class GodotServer {
43538
43873
  }
43539
43874
  }
43540
43875
  const after = projectPath === "" || busy ? null : cachedClasses(projectPath);
43541
- const lost = before === null || after === null ? [] : [...before.keys()].filter((name) => !after.has(name));
43876
+ const declaredNow = before === null || after === null ? null : declaredClasses(projectPath);
43877
+ const lost = before === null || after === null || declaredNow === null ? [] : [...before.keys()].filter((name) => !after.has(name) && declaredNow.has(name));
43542
43878
  if (lost.length > 0) {
43543
43879
  this.noteAShortListEditor(projectPath);
43544
43880
  }
@@ -43599,7 +43935,7 @@ class GodotServer {
43599
43935
  }
43600
43936
  const checked = busy ? { unseen: [] } : await this.classesTheEditorCannotSee(args);
43601
43937
  const unseen = checked.unseen;
43602
- const broughtIn = blind.unseen.map((one) => one.className).filter((name) => !unseen.some((still) => still.className === name));
43938
+ const broughtIn = busy ? [] : blind.unseen.map((one) => one.className).filter((name) => !unseen.some((still) => still.className === name));
43603
43939
  const dependents = busy || projectPath === "" || broughtIn.length === 0 ? null : await this.reloadDependents(projectPath, broughtIn);
43604
43940
  const stillGone = lost.filter((name) => !restored.includes(name));
43605
43941
  const notes = [];
@@ -43840,7 +44176,7 @@ class GodotServer {
43840
44176
  ]);
43841
44177
  }
43842
44178
  try {
43843
- return this.jsonTextResponse(markIfStale(await this.godotBridge.invokeTool(toolName, args), this.godotBridge.getStatus().addonVersion, SERVER_VERSION, this.godotBridge.getStatus().addonDigest, shippedEditorDigest()));
44179
+ return this.jsonTextResponse(markIfStale(await this.godotBridge.invokeTool(toolName, args), this.godotBridge.getStatus().addonVersion, SERVER_VERSION, this.godotBridge.getStatus().addonDigest, shippedEditorDigest(), this.editorCodeOnDisk()));
43844
44180
  } catch (error) {
43845
44181
  return this.createErrorResponse(`The editor answered ${toolName} with an error: ${errorMessage(error)}`);
43846
44182
  }
@@ -43905,7 +44241,7 @@ class GodotServer {
43905
44241
  return !run.announcedBefore.has(one.pid);
43906
44242
  }
43907
44243
  try {
43908
- return statSync8(one.file).mtimeMs >= run.startedAt;
44244
+ return statSync9(one.file).mtimeMs >= run.startedAt;
43909
44245
  } catch {
43910
44246
  return false;
43911
44247
  }
@@ -43981,6 +44317,11 @@ class GodotServer {
43981
44317
  }
43982
44318
  const screenshotDir = expectsScreenshot ? scratchDirectory("runtime-screenshot") : null;
43983
44319
  const screenshotPath = screenshotDir ? join23(screenshotDir, "capture.png") : null;
44320
+ const staleNote = this.runtimeStaleNote(choice.endpoint);
44321
+ const stale = staleNote === undefined ? {} : { addonIsStale: true, staleNote };
44322
+ const refused = (message) => this.createErrorResponse(staleNote === undefined ? message : `${message}
44323
+
44324
+ ${staleNote}`);
43984
44325
  try {
43985
44326
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs, !expectsScreenshot);
43986
44327
  if (!reply.ok && reply.requestId !== undefined) {
@@ -43990,22 +44331,23 @@ class GodotServer {
43990
44331
  command,
43991
44332
  waitedMs: timeoutMs,
43992
44333
  note: reply.message,
43993
- ...answeredBy
44334
+ ...answeredBy,
44335
+ ...stale
43994
44336
  });
43995
44337
  }
43996
44338
  if (!reply.ok) {
43997
- return this.createErrorResponse(reply.message);
44339
+ return refused(reply.message);
43998
44340
  }
43999
44341
  const { id: _id, ...payload } = reply.payload;
44000
44342
  if (!expectsScreenshot) {
44001
- return this.jsonTextResponse({ ...payload, ...answeredBy });
44343
+ return this.jsonTextResponse({ ...payload, ...answeredBy, ...stale });
44002
44344
  }
44003
44345
  const returnedPath = readString(payload, "path");
44004
44346
  if (readString(payload, "type") !== "screenshot_file" || !returnedPath || !screenshotPath) {
44005
- return this.createErrorResponse(`The game answered a capture with ${JSON.stringify(payload)}`);
44347
+ return refused(`The game answered a capture with ${JSON.stringify(payload)}`);
44006
44348
  }
44007
44349
  if (normalize2(returnedPath) !== normalize2(screenshotPath)) {
44008
- return this.createErrorResponse(`Rejected screenshot file path outside the managed capture path: '${returnedPath}'`);
44350
+ return refused(`Rejected screenshot file path outside the managed capture path: '${returnedPath}'`);
44009
44351
  }
44010
44352
  const dimensions = `${readNumber(payload, "width") ?? 0}x${readNumber(payload, "height") ?? 0} ${readString(payload, "format") ?? "unknown"}`;
44011
44353
  if (saveTo !== null) {
@@ -44019,7 +44361,9 @@ class GodotServer {
44019
44361
  content: [
44020
44362
  {
44021
44363
  type: "text",
44022
- text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${saveTo === null ? "" : `. Saved to ${saveTo}`}`
44364
+ text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${saveTo === null ? "" : `. Saved to ${saveTo}`}${staleNote === undefined ? "" : `
44365
+
44366
+ ${staleNote}`}`
44023
44367
  },
44024
44368
  { type: "image", data: readFileSync20(screenshotPath).toString("base64"), mimeType: "image/png" }
44025
44369
  ]
@@ -44118,10 +44462,11 @@ class GodotServer {
44118
44462
  }
44119
44463
  }
44120
44464
  async function runGodotServer() {
44465
+ delete process.env[EDITOR_PID_VARIABLE];
44121
44466
  const server = new GodotServer;
44122
44467
  await server.run();
44123
44468
  }
44124
- var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run4, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, STOP_WAIT_MS = 1e4, EXIT_REPORTED_WITHIN_MS = 2000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, MOST_GROUPS = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CONSOLE_WATCH_MS = 250, BOUNDARY_WAIT_MS = 2000, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, SCAN_WAIT_MS = 30000, STREAMS_GRACE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, SCAN_SETTLED_MS = 500, REIMPORT_TIMEOUT_MS = 300000, AUDIO_BUS_OPS, STALLED_AFTER_MS = 30000, DEPENDED_FAILED = "Failed to compile depended scripts", WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
44469
+ var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run4, __dirname2, GREETING_WAIT_MS = 1e4, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, PLAY_STARTS_WITHIN_MS = 30000, STOP_WAIT_MS = 1e4, KEEPER_ANSWER_MS = 15000, EXIT_REPORTED_WITHIN_MS = 2000, CONSOLE_WRAPPER, LONGEST_BOOT_NOTED_MS, MOST_GROUPS = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CONSOLE_WATCH_MS = 250, BOUNDARY_WAIT_MS = 2000, HANDOVER_MS, HANDOVER_POLL_MS = 250, CACHE_WRITE_MS = 2000, SCAN_WAIT_MS = 30000, STREAMS_GRACE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, SCAN_SETTLED_MS = 500, REIMPORT_TIMEOUT_MS = 300000, AUDIO_BUS_OPS, STALLED_AFTER_MS = 30000, DEPENDED_FAILED = "Failed to compile depended scripts", WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
44125
44470
  var init_server3 = __esm(() => {
44126
44471
  init_mcp();
44127
44472
  init_stdio2();
@@ -44146,6 +44491,7 @@ var init_server3 = __esm(() => {
44146
44491
  init_headless_operations();
44147
44492
  init_issues();
44148
44493
  init_junit();
44494
+ init_keeper_channel();
44149
44495
  init_lsp_client();
44150
44496
  init_outside();
44151
44497
  init_paths();
@@ -44213,12 +44559,12 @@ init_bridge_announce();
44213
44559
  init_errors();
44214
44560
  init_godot_path();
44215
44561
  import { existsSync as existsSync18 } from "node:fs";
44216
- import { join as join24, resolve as resolve6 } from "node:path";
44562
+ import { join as join24, resolve as resolve7 } from "node:path";
44217
44563
 
44218
44564
  // src/harnesses.ts
44219
- import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
44565
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync as rmSync2, statSync as statSync2, writeFileSync as writeFileSync2 } from "node:fs";
44220
44566
  import { homedir as homedir2 } from "node:os";
44221
- import { dirname as dirname3, join as join4 } from "node:path";
44567
+ import { basename as basename2, dirname as dirname3, join as join4, relative as relative2, resolve as resolve2 } from "node:path";
44222
44568
  import process4 from "node:process";
44223
44569
 
44224
44570
  // node_modules/yaml/dist/index.js
@@ -44361,6 +44707,7 @@ function isEmptyCollection(node) {
44361
44707
 
44362
44708
  // src/harnesses.ts
44363
44709
  init_errors();
44710
+ init_paths();
44364
44711
  init_runner();
44365
44712
  var SERVER_KEY = "gdharness";
44366
44713
  function launchFor(version, godotPath, projectPath, runner = currentRunner()) {
@@ -44923,27 +45270,66 @@ function registered(harness, projectPath) {
44923
45270
  }
44924
45271
  function recordedEnginePath(projectPath) {
44925
45272
  for (const harness of HARNESSES) {
44926
- if (harness.scope !== "project" || harness.toml || harness.yaml || harness.snippet) {
45273
+ if (harness.scope !== "project") {
44927
45274
  continue;
44928
45275
  }
44929
- const path = configPath(harness, projectPath);
44930
- if (!existsSync4(path)) {
44931
- continue;
44932
- }
44933
- let parsed;
44934
- try {
44935
- parsed = JSON.parse(readFileSync2(path, "utf8"));
44936
- } catch {
44937
- continue;
44938
- }
44939
- const entry = reach(reach(parsed, harness.container), SERVER_KEY);
44940
- const named = reach(reach(entry, harness.shape === "opencode" ? "environment" : "env"), "GODOT_PATH");
45276
+ const named = reach(entryEnvironment(harness, projectPath), "GODOT_PATH");
44941
45277
  if (typeof named === "string" && named !== "" && existsSync4(named)) {
44942
45278
  return named;
44943
45279
  }
44944
45280
  }
44945
45281
  return null;
44946
45282
  }
45283
+ function mainCheckoutOf(projectPath) {
45284
+ let top = resolve2(projectPath);
45285
+ while (!existsSync4(join4(top, ".git"))) {
45286
+ const up = dirname3(top);
45287
+ if (up === top) {
45288
+ return null;
45289
+ }
45290
+ top = up;
45291
+ }
45292
+ const marker = join4(top, ".git");
45293
+ if (!statSync2(marker).isFile()) {
45294
+ return null;
45295
+ }
45296
+ const pointer = /^gitdir:\s*(.+)$/m.exec(readFileSync2(marker, "utf8"))?.[1]?.trim();
45297
+ if (pointer === undefined) {
45298
+ return null;
45299
+ }
45300
+ const entry = resolve2(top, pointer);
45301
+ const commonFile = join4(entry, "commondir");
45302
+ if (!existsSync4(commonFile)) {
45303
+ return null;
45304
+ }
45305
+ const common = resolve2(entry, readFileSync2(commonFile, "utf8").trim());
45306
+ if (basename2(common) !== ".git") {
45307
+ return null;
45308
+ }
45309
+ const main = join4(dirname3(common), relative2(top, resolve2(projectPath)));
45310
+ return isSameDirectory(main, projectPath) || !existsSync4(join4(main, "project.godot")) ? null : main;
45311
+ }
45312
+ function entryEnvironment(harness, projectPath) {
45313
+ if (harness.toml || harness.yaml || harness.snippet) {
45314
+ return;
45315
+ }
45316
+ const path = configPath(harness, projectPath);
45317
+ if (!existsSync4(path)) {
45318
+ return;
45319
+ }
45320
+ let parsed;
45321
+ try {
45322
+ parsed = JSON.parse(readFileSync2(path, "utf8"));
45323
+ } catch {
45324
+ return;
45325
+ }
45326
+ const entry = reach(reach(parsed, harness.container), SERVER_KEY);
45327
+ return reach(entry, harness.shape === "opencode" ? "environment" : "env");
45328
+ }
45329
+ function servedProject(harness, projectPath) {
45330
+ const named = reach(entryEnvironment(harness, projectPath), "GDHARNESS_PROJECT");
45331
+ return typeof named === "string" && named !== "" ? named : null;
45332
+ }
44947
45333
  function reach(held, key) {
44948
45334
  return typeof held === "object" && held !== null && !Array.isArray(held) ? held[key] : undefined;
44949
45335
  }
@@ -45081,6 +45467,7 @@ function taken(harness, path, held, stripped) {
45081
45467
  // src/cli.ts
45082
45468
  init_headless();
45083
45469
  init_issues();
45470
+ init_paths();
45084
45471
 
45085
45472
  // src/prompt.ts
45086
45473
  import process8 from "node:process";
@@ -45113,7 +45500,7 @@ init_setup();
45113
45500
  init_setup();
45114
45501
  init_tool_definitions();
45115
45502
  import { existsSync as existsSync11, mkdirSync as mkdirSync5, readdirSync as readdirSync5, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "node:fs";
45116
- import { basename as basename3, dirname as dirname6, join as join11 } from "node:path";
45503
+ import { basename as basename4, dirname as dirname6, join as join11 } from "node:path";
45117
45504
 
45118
45505
  // src/tool-reference.ts
45119
45506
  init_headless_operations();
@@ -45238,7 +45625,8 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
45238
45625
  (an older \`addonVersion\` on the same code is not stale), and \`staleNote\` says which half is behind: an editor that
45239
45626
  needs restarting, or a server that needs reconnecting in your harness. Every answer that came
45240
45627
  from the editor carries the same two when they differ, so a stale addon cannot answer quietly:
45241
- believe a restart over a diagnostic that arrived with them.
45628
+ believe a restart over a diagnostic that arrived with them. A game's runtime addon is judged
45629
+ the same way, under each entry in \`runtimes\` and on every \`runtime_*\` answer from it.
45242
45630
  - Start the game with \`editor_run start\`, never by spawning an engine. The editor plays it, so
45243
45631
  its debugger holds it, which is what gives the \`debug_*\` tools something to talk to. Its answer
45244
45632
  says under \`runtime\` whether the game can be talked to yet; when it cannot, \`mayYetAnnounce\`
@@ -45279,7 +45667,7 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
45279
45667
  | Where a control is, and whether it is visible | \`runtime_inspect\` \`find\`, \`rect\` |
45280
45668
  | What a property reads right now, on a node whose path you already hold | \`runtime_inspect\` \`property\`, \`runtime_invoke\` |
45281
45669
  | What a property on an object a node holds reads | the same two, with colons: \`_game:clock:speed\` |
45282
- | Which control holds the focus, or anything only a method answers | the same path with a step written as a call, \`get_viewport():gui_get_focus_owner\`, which calls a method taking no arguments and walks into what it returned |
45670
+ | Which control holds the focus, or anything only a method answers | the same path with a step written as a call, \`get_viewport():gui_get_focus_owner\`, which calls a method taking no arguments that only reads and walks into what it returned |
45283
45671
  | Press a button | \`runtime_input click\` with \`says\` and the words on it, which finds it and presses it in the same frame and says what was under the pointer. A \`find\` and then a click by the path it answered is two calls over a generated path, and a panel that rebuilds between them frees the button first; \`nodePath\` narrows where \`says\` looks, and several matches are refused with an \`index\` for each |
45284
45672
  | Press the next thing after a click that changed screen | \`runtime_wait until\` with \`says\` and a word the new screen shows (no \`nodePath\` needed: without one the whole game is searched), 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 |
45285
45673
  | Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\` with \`replace\`; a submitted field has to be clicked again |
@@ -45440,7 +45828,7 @@ function removeSkill(directories, projectPath) {
45440
45828
  function pruneEmpty(directory, projectPath) {
45441
45829
  const shared = join11(projectPath, ".agents");
45442
45830
  let path = directory;
45443
- while (path !== projectPath && (basename3(path) === "skills" || path === shared)) {
45831
+ while (path !== projectPath && (basename4(path) === "skills" || path === shared)) {
45444
45832
  if (readdirSync5(path).length > 0) {
45445
45833
  return;
45446
45834
  }
@@ -45491,7 +45879,7 @@ class UsageError extends Refusal {
45491
45879
  }
45492
45880
  function projectArgument(at) {
45493
45881
  const named = args.slice(at).find((value) => !value.startsWith("--"));
45494
- const projectPath = resolve6(named ?? ".");
45882
+ const projectPath = resolve7(named ?? ".");
45495
45883
  if (!existsSync18(join24(projectPath, "project.godot"))) {
45496
45884
  throw new UsageError(`Not a Godot project: ${projectPath} holds no project.godot.`);
45497
45885
  }
@@ -45503,11 +45891,17 @@ async function engine(projectPath) {
45503
45891
  return { godotPath: located.path, script: shippedOperationsScript(), debug: GODOT_DEBUG_MODE_DEFAULT };
45504
45892
  }
45505
45893
  const recorded = located.named === null && projectPath !== undefined ? recordedEnginePath(projectPath) : null;
45506
- if (recorded === null) {
45894
+ if (recorded !== null) {
45895
+ return { godotPath: recorded, script: shippedOperationsScript(), debug: GODOT_DEBUG_MODE_DEFAULT };
45896
+ }
45897
+ const main = located.named === null && projectPath !== undefined ? mainCheckoutOf(projectPath) : null;
45898
+ const theirs = main === null ? null : recordedEnginePath(main);
45899
+ if (main === null || theirs === null) {
45507
45900
  const refusal = GodotLocator.refusal(located);
45508
45901
  throw new UsageError(`${refusal.message} ${refusal.advice.join(". ")}.`);
45509
45902
  }
45510
- return { godotPath: recorded, script: shippedOperationsScript(), debug: GODOT_DEBUG_MODE_DEFAULT };
45903
+ console.error(`engine: ${theirs}, which the harness config of ${main} names; this project is a git worktree of it`);
45904
+ return { godotPath: theirs, script: shippedOperationsScript(), debug: GODOT_DEBUG_MODE_DEFAULT };
45511
45905
  }
45512
45906
  function said2(outcome, what) {
45513
45907
  if (!outcome.ok) {
@@ -45680,12 +46074,14 @@ async function uninstall() {
45680
46074
  const named = namedHarnesses();
45681
46075
  const from = named.length > 0 ? named : HARNESSES;
45682
46076
  for (const harness of from) {
45683
- const removal = disconnect(harness, projectPath);
45684
- if (removal.action === "absent") {
46077
+ if (harness.scope === "home" && named.length === 0) {
46078
+ if (registered(harness, projectPath)) {
46079
+ console.log(`${harness.name}: left alone. Its config is machine-wide and may serve another project; pass --${harness.id} to remove it.`);
46080
+ }
45685
46081
  continue;
45686
46082
  }
45687
- if (harness.scope === "home" && named.length === 0) {
45688
- console.log(`${harness.name}: left alone. Its config is machine-wide and may serve another project; pass --${harness.id} to remove it.`);
46083
+ const removal = disconnect(harness, projectPath);
46084
+ if (removal.action === "absent") {
45689
46085
  continue;
45690
46086
  }
45691
46087
  if (removal.action === "manual") {
@@ -45705,15 +46101,19 @@ async function uninstall() {
45705
46101
  said2(outcome, `disabling ${name}`);
45706
46102
  console.log(`${name}: ${outcome.ok ? String(outcome.payload["action"]) : "failed"}`);
45707
46103
  }
45708
- if (before.runtimeAutoload) {
46104
+ if (before.runtimeAutoloadPath !== null) {
45709
46105
  said2(await setRuntime(godot, projectPath, false), "removing the runtime autoload");
45710
46106
  console.log(`${RUNTIME_AUTOLOAD.name} autoload removed`);
45711
46107
  }
46108
+ const loader = before.runtimeLoaderAutoload;
46109
+ if (loader !== null && loader.name !== RUNTIME_AUTOLOAD.name) {
46110
+ console.log(`${loader.name} names res://${loader.path}, which brings the runtime up its own way. That line is this project's, so it is left in place; the addon it brings up is gone now, so take the entry out unless that file does nothing without it.`);
46111
+ }
45712
46112
  const addons = removeAddons(projectPath);
45713
46113
  for (const path of addons) {
45714
46114
  console.log(`removed ${path}`);
45715
46115
  }
45716
- const anything = addons.length > 0 || before.runtimeAutoload || enabled.length > 0;
46116
+ const anything = addons.length > 0 || before.runtimeAutoloadPath !== null || enabled.length > 0;
45717
46117
  console.log(anything ? `
45718
46118
  Reconnect your harness so it stops spawning a server that is no longer installed.` : `
45719
46119
  Nothing of gdharness was in this project.`);
@@ -45722,13 +46122,13 @@ async function upgrade() {
45722
46122
  const projectPath = projectArgument(1);
45723
46123
  const version = getLocalVersion();
45724
46124
  const before = inspectProject(projectPath);
45725
- const installed = before.addons.find((addon) => addon.installed)?.version ?? null;
45726
- if (installed === null) {
46125
+ const present = before.addons.find((addon) => addon.installed);
46126
+ if (present === undefined) {
45727
46127
  const where = projectPath === process.cwd() ? "" : ` ${projectPath}`;
45728
46128
  throw new UsageError(`gdharness is not installed in ${projectPath}. Run gdharness setup${where} instead.`);
45729
46129
  }
45730
46130
  const godot = await engine(projectPath);
45731
- console.log(installed === version ? `already ${version}; reinstalling` : `${installed} -> ${version}`);
46131
+ console.log(present.version === version ? `already ${version}; reinstalling` : `${present.version ?? "an unmarked copy"} -> ${version}`);
45732
46132
  const digestBefore = installedEditorDigest(projectPath);
45733
46133
  for (const addon of installAddons(projectPath)) {
45734
46134
  console.log(`${addon.replaced ? "replaced" : "installed"} ${addon.path}`);
@@ -45742,7 +46142,21 @@ async function upgrade() {
45742
46142
  }
45743
46143
  said2(await runOperation(godot, "refresh_class_cache", {}, projectPath), "rebuilding the class list");
45744
46144
  const launch = launchFor(version, godot.godotPath, projectPath);
45745
- const already = HARNESSES.filter((harness) => registered(harness, projectPath));
46145
+ const named = namedHarnesses();
46146
+ const already = HARNESSES.filter((harness) => {
46147
+ if (!registered(harness, projectPath)) {
46148
+ return false;
46149
+ }
46150
+ if (harness.scope !== "home" || named.includes(harness)) {
46151
+ return true;
46152
+ }
46153
+ const serves = servedProject(harness, projectPath);
46154
+ if (serves !== null && isSameDirectory(serves, projectPath)) {
46155
+ return true;
46156
+ }
46157
+ console.log(`${harness.name}: left alone. Its config is machine-wide and ${serves === null ? "names no project" : `serves ${serves}`}; pass --${harness.id} to point it at this one.`);
46158
+ return false;
46159
+ });
45746
46160
  const moved = [];
45747
46161
  let written = 0;
45748
46162
  let byHand = 0;