gdharness 1.1.23 → 1.1.25

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
@@ -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
  }
@@ -8462,6 +8529,33 @@ function addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest,
8462
8529
  }
8463
8530
  return `${both} ${restart}`;
8464
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}`;
8558
+ }
8465
8559
  function markIfStale(answer, addonVersion, serverVersion, addonDigest, shippedDigest, onDisk) {
8466
8560
  const staleNote = addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest, onDisk);
8467
8561
  if (staleNote === undefined || typeof answer !== "object" || answer === null || Array.isArray(answer)) {
@@ -8609,75 +8703,8 @@ var init_issues = __esm(() => {
8609
8703
  ENHANCEMENT_URL = `${NEW_ISSUE}?template=feature_request.md`;
8610
8704
  });
8611
8705
 
8612
- // src/paths.ts
8613
- import { realpathSync as realpathSync2 } from "node:fs";
8614
- import { isAbsolute, relative, resolve, sep } from "node:path";
8615
- function realPathOr(path) {
8616
- try {
8617
- return realpathSync2.native(path);
8618
- } catch {
8619
- return path;
8620
- }
8621
- }
8622
- function isSameDirectory(onePath, otherPath) {
8623
- return relative(realPathOr(resolve(onePath)), realPathOr(resolve(otherPath))) === "";
8624
- }
8625
- function isWithinRoot(rootPath, candidatePath) {
8626
- const step = relative(resolve(rootPath), resolve(candidatePath));
8627
- return step !== ".." && !step.startsWith(`..${sep}`) && !isAbsolute(step);
8628
- }
8629
- function resolveWithinProject(projectPath, candidatePath) {
8630
- const trimmed = candidatePath.trim();
8631
- if (trimmed === "") {
8632
- return { ok: false, reason: "Path is empty." };
8633
- }
8634
- if (trimmed.includes("\x00")) {
8635
- return { ok: false, reason: "Path contains a null byte." };
8636
- }
8637
- const withoutScheme = trimmed.startsWith(RESOURCE_SCHEME) ? trimmed.slice(RESOURCE_SCHEME.length) : trimmed;
8638
- if (withoutScheme.startsWith("uid://")) {
8639
- return {
8640
- ok: false,
8641
- 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.`
8642
- };
8643
- }
8644
- const scheme = OTHER_SCHEME.exec(withoutScheme);
8645
- if (scheme) {
8646
- return {
8647
- ok: false,
8648
- reason: `Path '${trimmed}' uses the '${scheme[0]}' scheme, which names somewhere other than this project.`
8649
- };
8650
- }
8651
- if (withoutScheme.startsWith("/") || withoutScheme.startsWith("\\") || isAbsolute(withoutScheme)) {
8652
- return {
8653
- ok: false,
8654
- reason: `Path '${trimmed}' is absolute; give a path inside the project, such as 'scenes/main.tscn'.`
8655
- };
8656
- }
8657
- if (withoutScheme.includes(":")) {
8658
- return {
8659
- ok: false,
8660
- reason: `Path '${trimmed}' contains ':', which no file inside a project is named with.`
8661
- };
8662
- }
8663
- const root = resolve(projectPath);
8664
- const absolutePath = resolve(root, withoutScheme);
8665
- if (!isWithinRoot(root, absolutePath)) {
8666
- return { ok: false, reason: `Path '${trimmed}' resolves outside the project directory.` };
8667
- }
8668
- const relativePath = relative(root, absolutePath).split(sep).join("/");
8669
- if (relativePath === "") {
8670
- return { ok: false, reason: `Path '${trimmed}' is the project directory itself, not a file in it.` };
8671
- }
8672
- return { ok: true, absolutePath, relativePath };
8673
- }
8674
- var RESOURCE_SCHEME = "res://", OTHER_SCHEME;
8675
- var init_paths = __esm(() => {
8676
- OTHER_SCHEME = /^[a-zA-Z][a-zA-Z\d+.-]+:\/\//;
8677
- });
8678
-
8679
8706
  // src/class-cache.ts
8680
- 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";
8681
8708
  import { join as join9 } from "node:path";
8682
8709
  function isToolScript(source) {
8683
8710
  return TOOL.test(codeOf(source));
@@ -8973,7 +9000,7 @@ function uncachedClassNote(types) {
8973
9000
  function cacheWrittenAt(projectPath) {
8974
9001
  const cache = join9(projectPath, ".godot", "global_script_class_cache.cfg");
8975
9002
  try {
8976
- return statSync3(cache).mtimeMs;
9003
+ return statSync4(cache).mtimeMs;
8977
9004
  } catch {
8978
9005
  return null;
8979
9006
  }
@@ -8985,7 +9012,7 @@ function declaredSince(projectPath, paths, names, since) {
8985
9012
  return false;
8986
9013
  }
8987
9014
  try {
8988
- return statSync3(join9(projectPath, path.slice("res://".length))).mtimeMs > since;
9015
+ return statSync4(join9(projectPath, path.slice("res://".length))).mtimeMs > since;
8989
9016
  } catch {
8990
9017
  return false;
8991
9018
  }
@@ -16120,13 +16147,13 @@ var init_types = __esm(() => {
16120
16147
 
16121
16148
  // src/resources.ts
16122
16149
  import { readFileSync as readFileSync8, realpathSync as realpathSync3 } from "node:fs";
16123
- import { extname, resolve as resolve2 } from "node:path";
16150
+ import { extname, resolve as resolve3 } from "node:path";
16124
16151
  function ensureProjectPath(getProjectPath) {
16125
16152
  const projectPath = getProjectPath();
16126
16153
  if (!projectPath) {
16127
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.");
16128
16155
  }
16129
- return resolve2(projectPath);
16156
+ return resolve3(projectPath);
16130
16157
  }
16131
16158
  function uriPathToProjectPath(inputPath) {
16132
16159
  const normalized = inputPath.replace(/\\/g, "/").trim();
@@ -16447,12 +16474,18 @@ import {
16447
16474
  readFileSync as readFileSync9,
16448
16475
  rmdirSync,
16449
16476
  rmSync as rmSync5,
16450
- statSync as statSync4,
16477
+ statSync as statSync5,
16451
16478
  writeFileSync as writeFileSync6
16452
16479
  } from "node:fs";
16453
16480
  import { dirname as dirname5, join as join10 } from "node:path";
16454
16481
  import { fileURLToPath } from "node:url";
16455
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) {
16456
16489
  const hash = createHash3("sha256");
16457
16490
  const walk = (directory, relative) => {
16458
16491
  for (const name of readdirSync4(directory).sort()) {
@@ -16461,7 +16494,7 @@ function editorAddonDigest(from = shippedAddonsDirectory()) {
16461
16494
  }
16462
16495
  const path = join10(directory, name);
16463
16496
  const inside = `${relative}/${name}`;
16464
- if (statSync4(path).isDirectory()) {
16497
+ if (statSync5(path).isDirectory()) {
16465
16498
  walk(path, inside);
16466
16499
  } else {
16467
16500
  hash.update(`${inside}
@@ -16472,7 +16505,7 @@ function editorAddonDigest(from = shippedAddonsDirectory()) {
16472
16505
  }
16473
16506
  }
16474
16507
  };
16475
- for (const name of EDITOR_PLUGINS) {
16508
+ for (const name of names) {
16476
16509
  walk(join10(from, name), name);
16477
16510
  }
16478
16511
  return hash.digest("hex");
@@ -16487,8 +16520,24 @@ function shippedEditorDigest() {
16487
16520
  }
16488
16521
  return shippedDigest ?? undefined;
16489
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
+ }
16490
16533
  function installedEditorDigest(projectPath) {
16491
- 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);
16492
16541
  if (!existsSync10(marker)) {
16493
16542
  return null;
16494
16543
  }
@@ -16505,7 +16554,7 @@ function installAddons(projectPath, from = shippedAddonsDirectory()) {
16505
16554
  const installed = [];
16506
16555
  for (const name of ADDONS) {
16507
16556
  const source = join10(from, name);
16508
- 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"))) {
16509
16558
  throw new Error(`The package holds no ${name} addon at ${source}.`);
16510
16559
  }
16511
16560
  const target = join10(projectPath, "addons", name);
@@ -16518,6 +16567,8 @@ function installAddons(projectPath, from = shippedAddonsDirectory()) {
16518
16567
  installed.push({ name, path: target, replaced });
16519
16568
  }
16520
16569
  writeFileSync6(join10(projectPath, "addons", ADDONS[0], DIGEST_MARKER), `${editorAddonDigest(from)}
16570
+ `);
16571
+ writeFileSync6(join10(projectPath, "addons", RUNTIME_ADDON, DIGEST_MARKER), `${runtimeAddonDigest(from)}
16521
16572
  `);
16522
16573
  return installed;
16523
16574
  }
@@ -16672,7 +16723,7 @@ function inspectProject(projectPath) {
16672
16723
  problems
16673
16724
  };
16674
16725
  }
16675
- 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;
16676
16727
  var init_setup = __esm(() => {
16677
16728
  init_class_cache();
16678
16729
  init_headless();
@@ -17566,7 +17617,7 @@ var init_tool_definitions = __esm(() => {
17566
17617
  },
17567
17618
  {
17568
17619
  name: "editor_status",
17569
- 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. 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.",
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.",
17570
17621
  parameters: {},
17571
17622
  requires: []
17572
17623
  },
@@ -17823,7 +17874,7 @@ var init_tool_definitions = __esm(() => {
17823
17874
  },
17824
17875
  {
17825
17876
  name: "runtime_input",
17826
- description: "Input to the running game: a whole click on a Control or a 3D node named by path or on a control named by the words on it, an item chosen out of a menu, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it.",
17877
+ description: "Input to the running game: a whole click on a Control or a 3D node named by path or on a control named by the words on it, an item chosen out of a menu, typing into whatever has the focus, or a raw action, key, mouse button or mouse motion. All of it works headless, where the window is 64 by 64 and the GUI only takes what is inside it. On gdharness's own desktop, where a windowed run goes, Windows refuses to say where the pointer is: the pointer ops answer with pointer_note there, because get_mouse_position() on the root window, and the mouse positions derived from it, read a meaningless position, while what reads the event itself (the control under it, hover, tooltips, input handlers) gets the injected position.",
17827
17878
  parameters: {
17828
17879
  projectPath: RUNNING_PROJECT_PATH,
17829
17880
  pid: RUNNING_PID,
@@ -17863,7 +17914,7 @@ var init_tool_definitions = __esm(() => {
17863
17914
  type: "string",
17864
17915
  ops: ["text", "choose"],
17865
17916
  blank: true,
17866
- description: 'text: what to type. A newline is Enter and a tab is Tab, a space is a space, and "" with replace empties the field. choose: the item to take, by what it says as drawn, which in a game with translations is the translation; the key it holds is matched after that.'
17917
+ description: 'text: what to type. A newline is Enter and a tab is Tab, a space is a space, and "" with replace empties the field. choose: the item to take, by what it says as drawn, which in a game with translations is the translation; the key it holds is matched after that. An item whose whole words are these comes first; otherwise the words match as says does anywhere, a plain word as part of the item, a glob, or alternatives split at |, enabled items ahead of disabled ones, and several matching equally are refused with each listed.'
17867
17918
  },
17868
17919
  replace: {
17869
17920
  type: "boolean",
@@ -32796,10 +32847,10 @@ var init_stdio2 = __esm(() => {
32796
32847
  });
32797
32848
 
32798
32849
  // src/runtime-client.ts
32799
- 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";
32800
32851
  import { createConnection } from "node:net";
32801
32852
  import { tmpdir as tmpdir3 } from "node:os";
32802
- 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";
32803
32854
  import { setTimeout as delay } from "node:timers/promises";
32804
32855
  function runtimeDirectory(variables = process.env) {
32805
32856
  const explicit = envValue("GDHARNESS_RUNTIME_DIR", variables);
@@ -32815,7 +32866,7 @@ function runtimeDirectories(variables = process.env) {
32815
32866
  for (const base of fallbacks) {
32816
32867
  candidates.push(join13(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
32817
32868
  }
32818
- return [...new Set(candidates.map((path) => resolve3(path)))];
32869
+ return [...new Set(candidates.map((path) => resolve4(path)))];
32819
32870
  }
32820
32871
  function errorReportOf(pid, directories = runtimeDirectories()) {
32821
32872
  for (const directory of directories) {
@@ -32868,13 +32919,14 @@ function parseAnnouncement(file, pid) {
32868
32919
  };
32869
32920
  let announcedAt;
32870
32921
  try {
32871
- announcedAt = statSync5(file).mtimeMs;
32922
+ announcedAt = statSync6(file).mtimeMs;
32872
32923
  } catch {}
32873
32924
  const when = announcedAt === undefined ? {} : { announcedAt };
32874
32925
  if (protocol !== RUNTIME_PROTOCOL) {
32875
32926
  return { kind: "unspoken", unspoken: { pid, protocol: protocol ?? 0, project: named, ...when } };
32876
32927
  }
32877
32928
  const editorPid = readNumber(fields, "editor_pid");
32929
+ const addonVersion = readString(fields, "addon_version");
32878
32930
  return {
32879
32931
  kind: "runtime",
32880
32932
  endpoint: {
@@ -32884,7 +32936,8 @@ function parseAnnouncement(file, pid) {
32884
32936
  project: named,
32885
32937
  file,
32886
32938
  ...editorPid !== undefined && Number.isInteger(editorPid) && editorPid > 0 ? { editorPid } : {},
32887
- ...when
32939
+ ...when,
32940
+ ...addonVersion === undefined ? {} : { addon: { version: addonVersion, digest: readString(fields, "addon_digest") ?? "" } }
32888
32941
  }
32889
32942
  };
32890
32943
  }
@@ -32980,7 +33033,7 @@ function announcedIn(directory) {
32980
33033
  entries.push({ file, pid, running });
32981
33034
  if (running) {
32982
33035
  try {
32983
- candidates.push({ file, pid, writtenAt: statSync5(file).mtimeMs });
33036
+ candidates.push({ file, pid, writtenAt: statSync6(file).mtimeMs });
32984
33037
  } catch {}
32985
33038
  }
32986
33039
  }
@@ -33037,7 +33090,7 @@ function sweepErrorReport(path, pid, now = Date.now()) {
33037
33090
  return;
33038
33091
  }
33039
33092
  try {
33040
- if (now - statSync5(path).mtimeMs >= ERROR_REPORT_KEEP_MS) {
33093
+ if (now - statSync6(path).mtimeMs >= ERROR_REPORT_KEEP_MS) {
33041
33094
  unlinkSync(path);
33042
33095
  }
33043
33096
  } catch {}
@@ -33067,7 +33120,7 @@ function theOneProject(endpoints) {
33067
33120
  if (endpoints.some((endpoint) => endpoint.project.path === "")) {
33068
33121
  return;
33069
33122
  }
33070
- 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))));
33071
33124
  const only = [...paths][0];
33072
33125
  return paths.size === 1 ? only : undefined;
33073
33126
  }
@@ -33098,7 +33151,7 @@ function chooseRuntime(endpoints, projectPath, unspoken = [], pid) {
33098
33151
  if (named !== undefined) {
33099
33152
  if (projectPath !== undefined && !isSameDirectory(named.project.path, projectPath)) {
33100
33153
  return {
33101
- 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("; ")}.`
33102
33155
  };
33103
33156
  }
33104
33157
  return { endpoint: named };
@@ -33138,7 +33191,7 @@ function chooseRuntime(endpoints, projectPath, unspoken = [], pid) {
33138
33191
  };
33139
33192
  }
33140
33193
  if (projectPath !== undefined) {
33141
- const wanted = resolve3(projectPath);
33194
+ const wanted = resolve4(projectPath);
33142
33195
  const matching = endpoints.filter((endpoint) => isSameDirectory(endpoint.project.path, wanted));
33143
33196
  if (matching.length === 0) {
33144
33197
  return {
@@ -33347,7 +33400,7 @@ var init_boot_note = __esm(() => {
33347
33400
 
33348
33401
  // src/breakpoint-note.ts
33349
33402
  import { mkdirSync as mkdirSync7, readFileSync as readFileSync13, rmSync as rmSync7, writeFileSync as writeFileSync9 } from "node:fs";
33350
- 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";
33351
33404
  function breakpointNotePath(projectPath) {
33352
33405
  return join15(projectPath, ".godot", "gdharness-breakpoints.json");
33353
33406
  }
@@ -33362,7 +33415,7 @@ function writeBreakpointNote(projectPath, held) {
33362
33415
  }
33363
33416
  const note = {
33364
33417
  breakpoints: inside.map((one) => ({
33365
- script: relative2(root, one.scriptPath).replaceAll("\\", "/"),
33418
+ script: relative3(root, one.scriptPath).replaceAll("\\", "/"),
33366
33419
  lines: [...one.lines]
33367
33420
  }))
33368
33421
  };
@@ -33399,7 +33452,7 @@ function readBreakpointNote(projectPath) {
33399
33452
  }
33400
33453
  }
33401
33454
  function withinProject(projectPath, scriptPath) {
33402
- const rel = relative2(projectPath, scriptPath);
33455
+ const rel = relative3(projectPath, scriptPath);
33403
33456
  return rel !== "" && !rel.startsWith("..") && !isAbsolute2(rel);
33404
33457
  }
33405
33458
  var init_breakpoint_note = __esm(() => {
@@ -34262,7 +34315,7 @@ import {
34262
34315
  readFileSync as readFileSync16,
34263
34316
  readSync,
34264
34317
  rmSync as rmSync8,
34265
- statSync as statSync6,
34318
+ statSync as statSync7,
34266
34319
  writeFileSync as writeFileSync11
34267
34320
  } from "node:fs";
34268
34321
  import { dirname as dirname12, join as join18 } from "node:path";
@@ -34325,7 +34378,7 @@ function editorConsole(projectPath, editorPid, openedByAServer) {
34325
34378
  return { log, path, ...of === undefined ? {} : { readBytes: text.length, of } };
34326
34379
  }
34327
34380
  function theFirstOf(path) {
34328
- const whole = statSync6(path).size;
34381
+ const whole = statSync7(path).size;
34329
34382
  if (whole <= MOST_CONSOLE_BYTES) {
34330
34383
  return { text: readFileSync16(path, "utf8") };
34331
34384
  }
@@ -38230,11 +38283,53 @@ var init_junit = __esm(() => {
38230
38283
  ORPHANS = /(\d+)\s+orphans/;
38231
38284
  });
38232
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
+
38233
38328
  // src/lsp_client.ts
38234
38329
  import { existsSync as existsSync15, realpathSync as realpathSync4 } from "node:fs";
38235
38330
  import { readFile, realpath } from "node:fs/promises";
38236
38331
  import { createConnection as createConnection3 } from "node:net";
38237
- 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";
38238
38333
  import { fileURLToPath as fileURLToPath3, pathToFileURL } from "node:url";
38239
38334
  function diagnosticsKey(uri) {
38240
38335
  let decoded;
@@ -38364,7 +38459,7 @@ class GodotLSPClient {
38364
38459
  if (this.initialized) {
38365
38460
  return;
38366
38461
  }
38367
- const rootPath = this.rootPath ?? dirname13(resolve4(filePath));
38462
+ const rootPath = this.rootPath ?? dirname13(resolve5(filePath));
38368
38463
  await this.initialize(rootPath);
38369
38464
  }
38370
38465
  async sendRequest(method, params) {
@@ -38506,7 +38601,7 @@ class GodotLSPClient {
38506
38601
  });
38507
38602
  }
38508
38603
  toFileUri(filePath) {
38509
- return pathToFileURL(resolve4(filePath)).href;
38604
+ return pathToFileURL(resolve5(filePath)).href;
38510
38605
  }
38511
38606
  syncDocument(filePath, content) {
38512
38607
  const uri = this.toFileUri(filePath);
@@ -38550,7 +38645,7 @@ class GodotLSPClient {
38550
38645
  }
38551
38646
  async initialize(rootPath) {
38552
38647
  await this.ensureConnected();
38553
- const resolvedRootPath = resolve4(rootPath);
38648
+ const resolvedRootPath = resolve5(rootPath);
38554
38649
  const rootUri = pathToFileURL(resolvedRootPath).href;
38555
38650
  this.forgetServedWorkspace();
38556
38651
  const result = await this.sendRequest("initialize", {
@@ -38719,7 +38814,7 @@ function normalizeLSPError(error, port) {
38719
38814
  return String(error);
38720
38815
  }
38721
38816
  async function resolveLSPPaths(projectPathValue, scriptPathValue) {
38722
- const requestedProjectPath = resolve4(projectPathValue);
38817
+ const requestedProjectPath = resolve5(projectPathValue);
38723
38818
  let projectPath;
38724
38819
  try {
38725
38820
  projectPath = await realpath(requestedProjectPath);
@@ -39139,10 +39234,10 @@ import {
39139
39234
  readFileSync as readFileSync19,
39140
39235
  realpathSync as realpathSync5,
39141
39236
  rmSync as rmSync10,
39142
- statSync as statSync7,
39237
+ statSync as statSync8,
39143
39238
  writeFileSync as writeFileSync13
39144
39239
  } from "node:fs";
39145
- 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";
39146
39241
  import { promisify as promisify4 } from "node:util";
39147
39242
  function runsDirectory() {
39148
39243
  return join22(runtimeDirectory(), "runs");
@@ -39158,13 +39253,13 @@ function realAsFarAsItExists(path) {
39158
39253
  if (parent === at) {
39159
39254
  return path;
39160
39255
  }
39161
- missing.unshift(basename4(at));
39256
+ missing.unshift(basename5(at));
39162
39257
  at = parent;
39163
39258
  }
39164
39259
  }
39165
39260
  }
39166
39261
  function projectKey(projectPath) {
39167
- const real = realAsFarAsItExists(resolve5(projectPath)).replace(/[\\/]+$/, "");
39262
+ const real = realAsFarAsItExists(resolve6(projectPath)).replace(/[\\/]+$/, "");
39168
39263
  const folded = process.platform === "win32" || process.platform === "darwin" ? real.toLowerCase() : real;
39169
39264
  return createHash4("sha256").update(folded.replaceAll("\\", "/")).digest("hex").slice(0, 16);
39170
39265
  }
@@ -39274,6 +39369,7 @@ function recordAt(path) {
39274
39369
  ...typeof fields["startedBy"] === "number" ? { startedBy: fields["startedBy"] } : {},
39275
39370
  projectPath: typeof fields["projectPath"] === "string" ? fields["projectPath"] : "",
39276
39371
  ...typeof fields["servedBy"] === "string" && fields["servedBy"] !== "" ? { servedBy: fields["servedBy"] } : {},
39372
+ ...typeof fields["keeper"] === "string" && fields["keeper"] !== "" ? { keeper: fields["keeper"] } : {},
39277
39373
  arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : [],
39278
39374
  ...typeof fields["command"] === "string" ? { command: fields["command"] } : {},
39279
39375
  ...typeof fields["exitCode"] === "number" ? { exitCode: fields["exitCode"] } : {},
@@ -39295,11 +39391,8 @@ async function runningAs(pid) {
39295
39391
  }
39296
39392
  }
39297
39393
  const image = await windowsImage(pid);
39298
- return image === null ? null : {
39299
- kind: "image",
39300
- text: image,
39301
- unread: "unread" in read ? read.unread : "Windows gave an empty command line"
39302
- };
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}` };
39303
39396
  }
39304
39397
  try {
39305
39398
  const began = await startedAt(pid, null);
@@ -39384,9 +39477,11 @@ async function windowsImage(pid) {
39384
39477
  timeout: 15000,
39385
39478
  windowsHide: true
39386
39479
  });
39387
- return /^"([^"]+)"/.exec(csv.trim())?.[1] ?? null;
39388
- } catch {
39389
- 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) };
39390
39485
  }
39391
39486
  }
39392
39487
  async function whyNotStillTheRecordedRun(record) {
@@ -39402,11 +39497,14 @@ function whyNotTheRun(record, running, needed) {
39402
39497
  if (running === null) {
39403
39498
  return "the operating system would not describe that process";
39404
39499
  }
39500
+ if (running.kind === "none") {
39501
+ return `the operating system would not describe that process (${running.unread ?? "no reason given"})`;
39502
+ }
39405
39503
  const after = running.startedAt === undefined ? 0 : running.startedAt - (record.startedBy ?? record.startedAt);
39406
39504
  if (after > SAME_RUN_WINDOW_MS) {
39407
39505
  return `that process started ${Math.round(after / 1000)} seconds after the run had`;
39408
39506
  }
39409
- const engine = record.command === undefined ? null : basename4(record.command);
39507
+ const engine = record.command === undefined ? null : basename5(record.command);
39410
39508
  const said = process.platform === "win32" ? running.text.toLowerCase() : running.text;
39411
39509
  const wanted = engine === null || process.platform !== "win32" ? engine : engine.toLowerCase();
39412
39510
  if (wanted !== null && !said.includes(wanted)) {
@@ -39457,7 +39555,7 @@ function clearRunRecord(projectPath) {
39457
39555
  }
39458
39556
  function ageMs(path, now) {
39459
39557
  try {
39460
- return now - statSync7(path).mtimeMs;
39558
+ return now - statSync8(path).mtimeMs;
39461
39559
  } catch {
39462
39560
  return 0;
39463
39561
  }
@@ -39510,9 +39608,9 @@ import {
39510
39608
  readFileSync as readFileSync20,
39511
39609
  readSync as readSync2,
39512
39610
  rmdirSync as rmdirSync2,
39513
- statSync as statSync8
39611
+ statSync as statSync9
39514
39612
  } from "node:fs";
39515
- 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";
39516
39614
  import { setTimeout as delay3 } from "node:timers/promises";
39517
39615
  import { fileURLToPath as fileURLToPath5 } from "node:url";
39518
39616
  import { promisify as promisify5 } from "node:util";
@@ -39537,7 +39635,7 @@ function captureDestinationRefusal(outputPath, projects) {
39537
39635
  if (!existsSync17(directory)) {
39538
39636
  return `outputPath's directory ${dirname16(outputPath)} does not exist; a capture creates no directories.`;
39539
39637
  }
39540
- const where = join23(directory, basename5(outputPath));
39638
+ const where = join23(directory, basename6(outputPath));
39541
39639
  const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
39542
39640
  if (inside !== undefined) {
39543
39641
  return `outputPath ${outputPath} is inside the project at ${inside}; a capture is saved outside it, so it never becomes part of the game.`;
@@ -39551,7 +39649,7 @@ function patienceForFrames(frames, atLeast) {
39551
39649
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
39552
39650
  }
39553
39651
  function projectSpelling(projectPath, absolutePath) {
39554
- return `res://${relative3(realPathOr(projectPath), realPathOr(absolutePath)).replaceAll("\\", "/")}`;
39652
+ return `res://${relative4(realPathOr(projectPath), realPathOr(absolutePath)).replaceAll("\\", "/")}`;
39555
39653
  }
39556
39654
  function whichGame(args) {
39557
39655
  return { projectPath: args["projectPath"], pid: args["pid"] };
@@ -40495,6 +40593,10 @@ class GodotServer {
40495
40593
  const project = (status.connected ? status.projectPath : undefined) ?? this.ownProject;
40496
40594
  return project === null || project === "" ? undefined : installedEditorDigest(project);
40497
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
+ }
40498
40600
  configDisagreement() {
40499
40601
  return this.ownProject === null ? null : configDisagrees(this.ownProject, SERVER_VERSION);
40500
40602
  }
@@ -41429,7 +41531,7 @@ class GodotServer {
41429
41531
  const name = application["config/name"];
41430
41532
  const mainScene = application["run/main_scene"];
41431
41533
  const info = {
41432
- name: typeof name === "string" && name !== "" ? name : basename5(project.value.path),
41534
+ name: typeof name === "string" && name !== "" ? name : basename6(project.value.path),
41433
41535
  path: project.value.path,
41434
41536
  mainScene: typeof mainScene === "string" && mainScene !== "" ? mainScene : null,
41435
41537
  godotVersion: await this.godotVersion(engine.value),
@@ -41500,7 +41602,7 @@ class GodotServer {
41500
41602
  output.absolutePath
41501
41603
  ];
41502
41604
  this.logDebug(`Exporting: ${engine.value} ${exportArgs.join(" ")}`);
41503
- const writtenAt = () => existsSync17(output.absolutePath) ? statSync8(output.absolutePath).mtimeMs : null;
41605
+ const writtenAt = () => existsSync17(output.absolutePath) ? statSync9(output.absolutePath).mtimeMs : null;
41504
41606
  const before = writtenAt();
41505
41607
  let ending;
41506
41608
  try {
@@ -42081,12 +42183,17 @@ class GodotServer {
42081
42183
  ok: false,
42082
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.`
42083
42185
  };
42186
+ const staleNote = this.runtimeStaleNote(endpoint);
42187
+ const unknown = runtimeAddonUnknown(endpoint.addon);
42084
42188
  return {
42085
42189
  pid: endpoint.pid,
42086
42190
  port: endpoint.port,
42087
42191
  project: endpoint.project,
42088
42192
  ...this.ownProject === null ? {} : { ofThisProject: isSameDirectory(endpoint.project.path, this.ownProject) },
42089
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 },
42090
42197
  reachable: reply.ok,
42091
42198
  problem: reply.ok ? null : reply.message
42092
42199
  };
@@ -42532,7 +42639,7 @@ class GodotServer {
42532
42639
  }
42533
42640
  let announcedAt;
42534
42641
  try {
42535
- announcedAt = statSync8(endpoint.file).mtimeMs;
42642
+ announcedAt = statSync9(endpoint.file).mtimeMs;
42536
42643
  } catch {
42537
42644
  return;
42538
42645
  }
@@ -42769,6 +42876,13 @@ class GodotServer {
42769
42876
  if (running.pid === null) {
42770
42877
  return "gone";
42771
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
+ }
42772
42886
  const startedAs = {
42773
42887
  pid: running.pid,
42774
42888
  transcript: running.transcript ?? "",
@@ -42786,7 +42900,7 @@ class GodotServer {
42786
42900
  return "refused";
42787
42901
  }
42788
42902
  const signalled = await runningAs(running.pid);
42789
- 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}`;
42790
42904
  let landed = true;
42791
42905
  let declined = null;
42792
42906
  try {
@@ -42810,6 +42924,23 @@ class GodotServer {
42810
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}.`);
42811
42925
  return gone ? "gone" : "lingering";
42812
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.`);
42942
+ return gone ? "gone" : "lingering";
42943
+ }
42813
42944
  spawnGame(godotPath, cmdArgs, env) {
42814
42945
  const child = spawn4(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
42815
42946
  const cancelled = callSignal();
@@ -42899,6 +43030,8 @@ class GodotServer {
42899
43030
  }
42900
43031
  const startedBy = Date.now();
42901
43032
  sweepTranscripts();
43033
+ const noted = readRunRecord(projectPath);
43034
+ const keeper = noted?.pid === launched.pid ? noted.keeper : undefined;
42902
43035
  return {
42903
43036
  pid: launched.pid,
42904
43037
  log: new GameLog,
@@ -42907,6 +43040,7 @@ class GodotServer {
42907
43040
  projectPath,
42908
43041
  startedAt,
42909
43042
  startedBy,
43043
+ ...keeper === undefined ? {} : { keeper },
42910
43044
  command: godotPath,
42911
43045
  exitCode: null,
42912
43046
  exitSignal: null,
@@ -43139,6 +43273,7 @@ class GodotServer {
43139
43273
  projectPath: record.projectPath === "" ? null : record.projectPath,
43140
43274
  startedAt: record.startedAt,
43141
43275
  ...record.startedBy === undefined ? {} : { startedBy: record.startedBy },
43276
+ ...record.keeper === undefined ? {} : { keeper: record.keeper },
43142
43277
  ...record.command === undefined ? {} : { command: record.command },
43143
43278
  exitCode: null,
43144
43279
  exitSignal: null,
@@ -44106,7 +44241,7 @@ class GodotServer {
44106
44241
  return !run.announcedBefore.has(one.pid);
44107
44242
  }
44108
44243
  try {
44109
- return statSync8(one.file).mtimeMs >= run.startedAt;
44244
+ return statSync9(one.file).mtimeMs >= run.startedAt;
44110
44245
  } catch {
44111
44246
  return false;
44112
44247
  }
@@ -44182,6 +44317,11 @@ class GodotServer {
44182
44317
  }
44183
44318
  const screenshotDir = expectsScreenshot ? scratchDirectory("runtime-screenshot") : null;
44184
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}`);
44185
44325
  try {
44186
44326
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs, !expectsScreenshot);
44187
44327
  if (!reply.ok && reply.requestId !== undefined) {
@@ -44191,22 +44331,23 @@ class GodotServer {
44191
44331
  command,
44192
44332
  waitedMs: timeoutMs,
44193
44333
  note: reply.message,
44194
- ...answeredBy
44334
+ ...answeredBy,
44335
+ ...stale
44195
44336
  });
44196
44337
  }
44197
44338
  if (!reply.ok) {
44198
- return this.createErrorResponse(reply.message);
44339
+ return refused(reply.message);
44199
44340
  }
44200
44341
  const { id: _id, ...payload } = reply.payload;
44201
44342
  if (!expectsScreenshot) {
44202
- return this.jsonTextResponse({ ...payload, ...answeredBy });
44343
+ return this.jsonTextResponse({ ...payload, ...answeredBy, ...stale });
44203
44344
  }
44204
44345
  const returnedPath = readString(payload, "path");
44205
44346
  if (readString(payload, "type") !== "screenshot_file" || !returnedPath || !screenshotPath) {
44206
- return this.createErrorResponse(`The game answered a capture with ${JSON.stringify(payload)}`);
44347
+ return refused(`The game answered a capture with ${JSON.stringify(payload)}`);
44207
44348
  }
44208
44349
  if (normalize2(returnedPath) !== normalize2(screenshotPath)) {
44209
- 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}'`);
44210
44351
  }
44211
44352
  const dimensions = `${readNumber(payload, "width") ?? 0}x${readNumber(payload, "height") ?? 0} ${readString(payload, "format") ?? "unknown"}`;
44212
44353
  if (saveTo !== null) {
@@ -44220,7 +44361,9 @@ class GodotServer {
44220
44361
  content: [
44221
44362
  {
44222
44363
  type: "text",
44223
- 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}`}`
44224
44367
  },
44225
44368
  { type: "image", data: readFileSync20(screenshotPath).toString("base64"), mimeType: "image/png" }
44226
44369
  ]
@@ -44323,7 +44466,7 @@ async function runGodotServer() {
44323
44466
  const server = new GodotServer;
44324
44467
  await server.run();
44325
44468
  }
44326
- 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, 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/";
44327
44470
  var init_server3 = __esm(() => {
44328
44471
  init_mcp();
44329
44472
  init_stdio2();
@@ -44348,6 +44491,7 @@ var init_server3 = __esm(() => {
44348
44491
  init_headless_operations();
44349
44492
  init_issues();
44350
44493
  init_junit();
44494
+ init_keeper_channel();
44351
44495
  init_lsp_client();
44352
44496
  init_outside();
44353
44497
  init_paths();
@@ -44415,12 +44559,12 @@ init_bridge_announce();
44415
44559
  init_errors();
44416
44560
  init_godot_path();
44417
44561
  import { existsSync as existsSync18 } from "node:fs";
44418
- import { join as join24, resolve as resolve6 } from "node:path";
44562
+ import { join as join24, resolve as resolve7 } from "node:path";
44419
44563
 
44420
44564
  // src/harnesses.ts
44421
- 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";
44422
44566
  import { homedir as homedir2 } from "node:os";
44423
- 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";
44424
44568
  import process4 from "node:process";
44425
44569
 
44426
44570
  // node_modules/yaml/dist/index.js
@@ -44563,6 +44707,7 @@ function isEmptyCollection(node) {
44563
44707
 
44564
44708
  // src/harnesses.ts
44565
44709
  init_errors();
44710
+ init_paths();
44566
44711
  init_runner();
44567
44712
  var SERVER_KEY = "gdharness";
44568
44713
  function launchFor(version, godotPath, projectPath, runner = currentRunner()) {
@@ -45135,6 +45280,35 @@ function recordedEnginePath(projectPath) {
45135
45280
  }
45136
45281
  return null;
45137
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
+ }
45138
45312
  function entryEnvironment(harness, projectPath) {
45139
45313
  if (harness.toml || harness.yaml || harness.snippet) {
45140
45314
  return;
@@ -45326,7 +45500,7 @@ init_setup();
45326
45500
  init_setup();
45327
45501
  init_tool_definitions();
45328
45502
  import { existsSync as existsSync11, mkdirSync as mkdirSync5, readdirSync as readdirSync5, rmSync as rmSync6, writeFileSync as writeFileSync7 } from "node:fs";
45329
- 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";
45330
45504
 
45331
45505
  // src/tool-reference.ts
45332
45506
  init_headless_operations();
@@ -45451,7 +45625,8 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
45451
45625
  (an older \`addonVersion\` on the same code is not stale), and \`staleNote\` says which half is behind: an editor that
45452
45626
  needs restarting, or a server that needs reconnecting in your harness. Every answer that came
45453
45627
  from the editor carries the same two when they differ, so a stale addon cannot answer quietly:
45454
- 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.
45455
45630
  - Start the game with \`editor_run start\`, never by spawning an engine. The editor plays it, so
45456
45631
  its debugger holds it, which is what gives the \`debug_*\` tools something to talk to. Its answer
45457
45632
  says under \`runtime\` whether the game can be talked to yet; when it cannot, \`mayYetAnnounce\`
@@ -45653,7 +45828,7 @@ function removeSkill(directories, projectPath) {
45653
45828
  function pruneEmpty(directory, projectPath) {
45654
45829
  const shared = join11(projectPath, ".agents");
45655
45830
  let path = directory;
45656
- while (path !== projectPath && (basename3(path) === "skills" || path === shared)) {
45831
+ while (path !== projectPath && (basename4(path) === "skills" || path === shared)) {
45657
45832
  if (readdirSync5(path).length > 0) {
45658
45833
  return;
45659
45834
  }
@@ -45704,7 +45879,7 @@ class UsageError extends Refusal {
45704
45879
  }
45705
45880
  function projectArgument(at) {
45706
45881
  const named = args.slice(at).find((value) => !value.startsWith("--"));
45707
- const projectPath = resolve6(named ?? ".");
45882
+ const projectPath = resolve7(named ?? ".");
45708
45883
  if (!existsSync18(join24(projectPath, "project.godot"))) {
45709
45884
  throw new UsageError(`Not a Godot project: ${projectPath} holds no project.godot.`);
45710
45885
  }
@@ -45716,11 +45891,17 @@ async function engine(projectPath) {
45716
45891
  return { godotPath: located.path, script: shippedOperationsScript(), debug: GODOT_DEBUG_MODE_DEFAULT };
45717
45892
  }
45718
45893
  const recorded = located.named === null && projectPath !== undefined ? recordedEnginePath(projectPath) : null;
45719
- 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) {
45720
45900
  const refusal = GodotLocator.refusal(located);
45721
45901
  throw new UsageError(`${refusal.message} ${refusal.advice.join(". ")}.`);
45722
45902
  }
45723
- 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 };
45724
45905
  }
45725
45906
  function said2(outcome, what) {
45726
45907
  if (!outcome.ok) {