gdharness 1.1.22 → 1.1.23

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";
@@ -8446,23 +8446,24 @@ function sameCodeNote(addonVersion, serverVersion, addonDigest, shippedDigest) {
8446
8446
  }
8447
8447
  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
8448
  }
8449
- function addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest) {
8449
+ function addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest, onDisk) {
8450
8450
  if (!editorIsStale(addonVersion, serverVersion, addonDigest, shippedDigest)) {
8451
8451
  return;
8452
8452
  }
8453
8453
  const reported = addonVersion ?? "";
8454
+ 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
8455
  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.`;
8456
+ return `The editor is running a different build of the ${reported} addon from the one this server ships. ${restart}`;
8456
8457
  }
8457
8458
  const editor = reported === "" ? UNVERSIONED : `the ${reported} addon`;
8458
8459
  const both = `The editor is running ${editor} while this server ships ${serverVersion}.`;
8459
8460
  if (reported !== "" && isNewer(reported, serverVersion)) {
8460
8461
  return `${both} This server is the older half: reconnect it in your harness so it spawns ${reported}.`;
8461
8462
  }
8462
- return `${both} Restart it with editor_launch restart to pick the new one up.`;
8463
+ return `${both} ${restart}`;
8463
8464
  }
8464
- function markIfStale(answer, addonVersion, serverVersion, addonDigest, shippedDigest) {
8465
- const staleNote = addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest);
8465
+ function markIfStale(answer, addonVersion, serverVersion, addonDigest, shippedDigest, onDisk) {
8466
+ const staleNote = addonMismatch(addonVersion, serverVersion, addonDigest, shippedDigest, onDisk);
8466
8467
  if (staleNote === undefined || typeof answer !== "object" || answer === null || Array.isArray(answer)) {
8467
8468
  return answer;
8468
8469
  }
@@ -8608,6 +8609,73 @@ var init_issues = __esm(() => {
8608
8609
  ENHANCEMENT_URL = `${NEW_ISSUE}?template=feature_request.md`;
8609
8610
  });
8610
8611
 
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
+
8611
8679
  // src/class-cache.ts
8612
8680
  import { existsSync as existsSync9, readdirSync as readdirSync3, readFileSync as readFileSync7, statSync as statSync3 } from "node:fs";
8613
8681
  import { join as join9 } from "node:path";
@@ -16050,80 +16118,13 @@ var init_types = __esm(() => {
16050
16118
  };
16051
16119
  });
16052
16120
 
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
16121
  // src/resources.ts
16121
- import { readFileSync as readFileSync8 } from "node:fs";
16122
+ import { readFileSync as readFileSync8, realpathSync as realpathSync3 } from "node:fs";
16122
16123
  import { extname, resolve as resolve2 } from "node:path";
16123
16124
  function ensureProjectPath(getProjectPath) {
16124
16125
  const projectPath = getProjectPath();
16125
16126
  if (!projectPath) {
16126
- throw new Refusal("Project path is not set. Set a Godot project path first.");
16127
+ 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
16128
  }
16128
16129
  return resolve2(projectPath);
16129
16130
  }
@@ -16139,7 +16140,22 @@ function resolveProjectFile(projectPath, resourcePath) {
16139
16140
  if (!contained.ok) {
16140
16141
  throw new Refusal(contained.reason);
16141
16142
  }
16142
- return contained.absolutePath;
16143
+ let root;
16144
+ try {
16145
+ root = realpathSync3.native(projectPath);
16146
+ } catch {
16147
+ throw new Refusal(`The project directory ${projectPath} is not there.`);
16148
+ }
16149
+ let real;
16150
+ try {
16151
+ real = realpathSync3.native(contained.absolutePath);
16152
+ } catch {
16153
+ throw new Refusal(`No file at ${resourcePath} in the project.`);
16154
+ }
16155
+ if (!isWithinRoot(root, real)) {
16156
+ throw new Refusal(`${resourcePath} leads outside the project through a link, so it is not read.`);
16157
+ }
16158
+ return real;
16143
16159
  }
16144
16160
  function parseGodotUri(uri) {
16145
16161
  if (uri === "godot://project/info") {
@@ -17395,13 +17411,13 @@ var init_tool_definitions = __esm(() => {
17395
17411
  },
17396
17412
  {
17397
17413
  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.",
17414
+ 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
17415
  parameters: {
17400
17416
  projectPath: { ...PROJECT_PATH, ops: ["open"] },
17401
17417
  hidden: {
17402
17418
  type: "boolean",
17403
17419
  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."
17420
+ 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
17421
  }
17406
17422
  },
17407
17423
  requires: [],
@@ -17489,7 +17505,7 @@ var init_tool_definitions = __esm(() => {
17489
17505
  requires: ["projectPath"]
17490
17506
  },
17491
17507
  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",
17508
+ 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
17509
  requires: []
17494
17510
  },
17495
17511
  check: {
@@ -17550,7 +17566,7 @@ var init_tool_definitions = __esm(() => {
17550
17566
  },
17551
17567
  {
17552
17568
  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.",
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.",
17554
17570
  parameters: {},
17555
17571
  requires: []
17556
17572
  },
@@ -17589,6 +17605,7 @@ var init_tool_definitions = __esm(() => {
17589
17605
  "physics2d",
17590
17606
  "physics3d",
17591
17607
  "audio",
17608
+ "visual",
17592
17609
  "animation",
17593
17610
  "ui"
17594
17611
  ],
@@ -17628,7 +17645,7 @@ var init_tool_definitions = __esm(() => {
17628
17645
  property: {
17629
17646
  type: "string",
17630
17647
  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.'
17648
+ 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
17649
  },
17633
17650
  depth: {
17634
17651
  type: "integer",
@@ -17667,13 +17684,14 @@ var init_tool_definitions = __esm(() => {
17667
17684
  limit: {
17668
17685
  type: "integer",
17669
17686
  minimum: 1,
17687
+ maximum: 5000,
17670
17688
  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."
17689
+ 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
17690
  },
17673
17691
  includeHidden: {
17674
17692
  type: "boolean",
17675
17693
  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."
17694
+ 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
17695
  },
17678
17696
  metrics: {
17679
17697
  type: "array",
@@ -17772,7 +17790,7 @@ var init_tool_definitions = __esm(() => {
17772
17790
  },
17773
17791
  {
17774
17792
  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.",
17793
+ 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
17794
  parameters: {
17777
17795
  projectPath: RUNNING_PROJECT_PATH,
17778
17796
  pid: RUNNING_PID,
@@ -17946,7 +17964,7 @@ var init_tool_definitions = __esm(() => {
17946
17964
  },
17947
17965
  value: {
17948
17966
  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."
17967
+ 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
17968
  },
17951
17969
  says: {
17952
17970
  type: "string",
@@ -32870,6 +32888,12 @@ function parseAnnouncement(file, pid) {
32870
32888
  }
32871
32889
  };
32872
32890
  }
32891
+ function dialAddress(address) {
32892
+ if (address === "*" || address === "0.0.0.0") {
32893
+ return "127.0.0.1";
32894
+ }
32895
+ return address === "::" ? "::1" : address;
32896
+ }
32873
32897
  function runtimesAnnounced(directories = runtimeDirectories()) {
32874
32898
  const running = new Map;
32875
32899
  const unspoken = new Map;
@@ -33159,7 +33183,7 @@ function runtimeRequest(endpoint, command, params, timeoutMs, keepListening = fa
33159
33183
  let late = false;
33160
33184
  let buffered = "";
33161
33185
  let welcomed = false;
33162
- const socket = createConnection({ port: endpoint.port, host: endpoint.address });
33186
+ const socket = createConnection({ port: endpoint.port, host: dialAddress(endpoint.address) });
33163
33187
  let lateTimer = null;
33164
33188
  const finish = (reply) => {
33165
33189
  if (late) {
@@ -37372,7 +37396,10 @@ function mayYetConnect(listeningSince, now = Date.now()) {
37372
37396
  return listeningSince === undefined || now - listeningSince.getTime() < CONNECT_WINDOW_MS;
37373
37397
  }
37374
37398
  function theEditorHasComeBack(status, startedAt) {
37375
- return status.connected && (status.connectedAt?.getTime() ?? 0) > startedAt && status.addonVersion !== undefined;
37399
+ return hasSaidWhoItIs(status) && (status.connectedAt?.getTime() ?? 0) > startedAt;
37400
+ }
37401
+ function hasSaidWhoItIs(status) {
37402
+ return status.connected && status.addonVersion !== undefined;
37376
37403
  }
37377
37404
  function anEditorIsStillComing(listeningSince, launchedEditorIsAlive, now = Date.now()) {
37378
37405
  return mayYetConnect(listeningSince, now) || launchedEditorIsAlive;
@@ -37531,6 +37558,7 @@ var init_godot_bridge = __esm(() => {
37531
37558
  dapPort: this.connectionInfo?.dapPort,
37532
37559
  debugPort: this.connectionInfo?.debugPort,
37533
37560
  openedByAServer: this.connectionInfo?.openedByAServer,
37561
+ startedByAnEditor: this.connectionInfo?.startedByAnEditor,
37534
37562
  syncsBreakpoints: this.connectionInfo?.syncsBreakpoints,
37535
37563
  pendingRequests: this.pendingRequests.size,
37536
37564
  queuedResources: this.resourceQueues.size,
@@ -37676,6 +37704,7 @@ var init_godot_bridge = __esm(() => {
37676
37704
  this.connectionInfo.dapPort = servedPort(message.dap_port);
37677
37705
  this.connectionInfo.debugPort = servedPort(message.debug_port);
37678
37706
  this.connectionInfo.openedByAServer = message.opened_by_a_server === true;
37707
+ this.connectionInfo.startedByAnEditor = typeof message.started_by_an_editor === "boolean" ? message.started_by_an_editor : undefined;
37679
37708
  this.connectionInfo.syncsBreakpoints = typeof message.syncs_breakpoints === "boolean" ? message.syncs_breakpoints : undefined;
37680
37709
  this.log("info", `Godot ready: ${message.project_path}`);
37681
37710
  this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
@@ -37999,6 +38028,7 @@ function parseJUnit(xml) {
37999
38028
  const cases = suite.children.filter((child) => child.name === "testcase").map((element) => caseOf(suite, element));
38000
38029
  const failures = cases.filter((entry) => entry.status === "failed").length;
38001
38030
  const errors = cases.filter((entry) => entry.status === "error").length;
38031
+ const written = (kind) => suite.children.filter((child) => child.name === "testcase").reduce((sum, element) => sum + element.children.filter((child) => child.name === kind).length, 0);
38002
38032
  return {
38003
38033
  name: suite.attributes["name"] ?? "",
38004
38034
  path: pathOf(suite),
@@ -38006,8 +38036,8 @@ function parseJUnit(xml) {
38006
38036
  discovered: count(suite.attributes, "tests"),
38007
38037
  failures,
38008
38038
  errors,
38009
- hookFailures: Math.max(0, count(suite.attributes, "failures") - failures),
38010
- hookErrors: Math.max(0, count(suite.attributes, "errors") - errors),
38039
+ hookFailures: Math.max(0, count(suite.attributes, "failures") - written("failure")),
38040
+ hookErrors: Math.max(0, count(suite.attributes, "errors") - written("error")),
38011
38041
  skipped: cases.filter((entry) => entry.status === "skipped").length,
38012
38042
  time: count(suite.attributes, "time"),
38013
38043
  cases
@@ -38201,7 +38231,7 @@ var init_junit = __esm(() => {
38201
38231
  });
38202
38232
 
38203
38233
  // src/lsp_client.ts
38204
- import { existsSync as existsSync15, realpathSync as realpathSync3 } from "node:fs";
38234
+ import { existsSync as existsSync15, realpathSync as realpathSync4 } from "node:fs";
38205
38235
  import { readFile, realpath } from "node:fs/promises";
38206
38236
  import { createConnection as createConnection3 } from "node:net";
38207
38237
  import { dirname as dirname13, join as join19, resolve as resolve4 } from "node:path";
@@ -38217,7 +38247,7 @@ function diagnosticsKey(uri) {
38217
38247
  return decoded;
38218
38248
  }
38219
38249
  try {
38220
- return realpathSync3.native(fileURLToPath3(decoded)).toLowerCase();
38250
+ return realpathSync4.native(fileURLToPath3(decoded)).toLowerCase();
38221
38251
  } catch {
38222
38252
  return decoded.toLowerCase();
38223
38253
  }
@@ -38917,7 +38947,7 @@ function projectStructure(projectPath) {
38917
38947
  visit(join20(directory, entry.name));
38918
38948
  } else if (entry.isFile()) {
38919
38949
  const extension = entry.name.split(".").pop()?.toLowerCase() ?? "";
38920
- if (extension === "tscn") {
38950
+ if (SCENE_EXTENSIONS.has(extension)) {
38921
38951
  structure.scenes += 1;
38922
38952
  } else if (extension === "gd" || extension === "cs") {
38923
38953
  structure.scripts += 1;
@@ -38959,8 +38989,11 @@ function scriptsWithoutUid(projectPath) {
38959
38989
  visit(projectPath, "");
38960
38990
  return found.sort();
38961
38991
  }
38992
+ function isBinary(bytes) {
38993
+ return bytes.subarray(0, 8000).includes(0);
38994
+ }
38962
38995
  function searchProject(projectPath, options) {
38963
- const extensions = new Set(options.fileTypes.map((ext) => ext.replace(/^\./, "").toLowerCase()).filter(Boolean));
38996
+ const extensions = options.fileTypes === undefined ? null : new Set(options.fileTypes.map((ext) => ext.replace(/^\./, "").toLowerCase()).filter(Boolean));
38964
38997
  const result = {
38965
38998
  query: options.query,
38966
38999
  results: [],
@@ -38968,13 +39001,7 @@ function searchProject(projectPath, options) {
38968
39001
  };
38969
39002
  const regex = options.regex ? new RegExp(options.query, options.caseSensitive ? "" : "i") : null;
38970
39003
  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
- };
39004
+ const full = () => result.summary.truncated;
38978
39005
  const visit = (directory) => {
38979
39006
  if (steppedOver(directory)) {
38980
39007
  return;
@@ -38992,21 +39019,26 @@ function searchProject(projectPath, options) {
38992
39019
  continue;
38993
39020
  }
38994
39021
  const extension = entry.name.includes(".") ? entry.name.split(".").pop()?.toLowerCase() ?? "" : "";
38995
- if (!entry.isFile() || !extensions.has(extension)) {
39022
+ if (!entry.isFile() || extensions !== null && !extensions.has(extension)) {
39023
+ continue;
39024
+ }
39025
+ const bytes = readFileSync17(entryPath);
39026
+ if (extensions === null && isBinary(bytes)) {
38996
39027
  continue;
38997
39028
  }
38998
39029
  result.summary.files_searched += 1;
38999
39030
  const matches = [];
39000
- for (const [index, line] of readFileSync17(entryPath, "utf8").split(`
39001
- `).entries()) {
39002
- if (full()) {
39003
- break;
39031
+ for (const [index, line] of bytes.toString("utf8").split(/\r?\n/).entries()) {
39032
+ const found = regex ? regex.exec(line) : (options.caseSensitive ? line : line.toLowerCase()).includes(needle) ? [options.query] : null;
39033
+ if (found === null) {
39034
+ continue;
39004
39035
  }
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;
39036
+ if (result.summary.total_matches >= options.maxResults) {
39037
+ result.summary.truncated = true;
39038
+ break;
39009
39039
  }
39040
+ matches.push({ line: index + 1, content: line.trim(), match: found[0] ?? "" });
39041
+ result.summary.total_matches += 1;
39010
39042
  }
39011
39043
  if (matches.length > 0) {
39012
39044
  const relativePath = entryPath.slice(projectPath.length + 1).replace(/\\/g, "/");
@@ -39018,10 +39050,40 @@ function searchProject(projectPath, options) {
39018
39050
  visit(projectPath);
39019
39051
  return result;
39020
39052
  }
39021
- var SKIPPED, ASSET_EXTENSIONS, CARRIES_A_UID;
39053
+ var SKIPPED, SCENE_EXTENSIONS, ASSET_EXTENSIONS, CARRIES_A_UID;
39022
39054
  var init_project_scan = __esm(() => {
39023
39055
  SKIPPED = new Set(["node_modules"]);
39024
- ASSET_EXTENSIONS = new Set(["png", "jpg", "jpeg", "webp", "svg", "ttf", "otf", "wav", "mp3", "ogg"]);
39056
+ SCENE_EXTENSIONS = new Set(["tscn", "scn"]);
39057
+ ASSET_EXTENSIONS = new Set([
39058
+ "png",
39059
+ "jpg",
39060
+ "jpeg",
39061
+ "webp",
39062
+ "svg",
39063
+ "bmp",
39064
+ "tga",
39065
+ "exr",
39066
+ "hdr",
39067
+ "ktx",
39068
+ "dds",
39069
+ "ttf",
39070
+ "otf",
39071
+ "woff",
39072
+ "woff2",
39073
+ "fnt",
39074
+ "wav",
39075
+ "mp3",
39076
+ "ogg",
39077
+ "glb",
39078
+ "gltf",
39079
+ "obj",
39080
+ "fbx",
39081
+ "blend",
39082
+ "dae",
39083
+ "ogv",
39084
+ "gdshader",
39085
+ "shader"
39086
+ ]);
39025
39087
  CARRIES_A_UID = new Set(["gd", "shader", "gdshader"]);
39026
39088
  });
39027
39089
 
@@ -39075,7 +39137,7 @@ import {
39075
39137
  openSync as openSync2,
39076
39138
  readdirSync as readdirSync8,
39077
39139
  readFileSync as readFileSync19,
39078
- realpathSync as realpathSync4,
39140
+ realpathSync as realpathSync5,
39079
39141
  rmSync as rmSync10,
39080
39142
  statSync as statSync7,
39081
39143
  writeFileSync as writeFileSync13
@@ -39090,7 +39152,7 @@ function realAsFarAsItExists(path) {
39090
39152
  let at = path;
39091
39153
  for (;; ) {
39092
39154
  try {
39093
- return join22(realpathSync4.native(at), ...missing);
39155
+ return join22(realpathSync5.native(at), ...missing);
39094
39156
  } catch {
39095
39157
  const parent = dirname15(at);
39096
39158
  if (parent === at) {
@@ -39209,7 +39271,9 @@ function recordAt(path) {
39209
39271
  pid,
39210
39272
  transcript,
39211
39273
  startedAt,
39274
+ ...typeof fields["startedBy"] === "number" ? { startedBy: fields["startedBy"] } : {},
39212
39275
  projectPath: typeof fields["projectPath"] === "string" ? fields["projectPath"] : "",
39276
+ ...typeof fields["servedBy"] === "string" && fields["servedBy"] !== "" ? { servedBy: fields["servedBy"] } : {},
39213
39277
  arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : [],
39214
39278
  ...typeof fields["command"] === "string" ? { command: fields["command"] } : {},
39215
39279
  ...typeof fields["exitCode"] === "number" ? { exitCode: fields["exitCode"] } : {},
@@ -39218,7 +39282,8 @@ function recordAt(path) {
39218
39282
  }
39219
39283
  async function runningAs(pid) {
39220
39284
  if (process.platform === "win32") {
39221
- const answer = await windowsCommandLine(pid);
39285
+ const read = await readWindowsCommandLine(pid);
39286
+ const answer = "answer" in read ? read.answer : null;
39222
39287
  if (answer !== null) {
39223
39288
  const [, ...rest] = answer.split(`
39224
39289
  `);
@@ -39230,7 +39295,11 @@ async function runningAs(pid) {
39230
39295
  }
39231
39296
  }
39232
39297
  const image = await windowsImage(pid);
39233
- return image === null ? null : { kind: "image", text: image };
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
+ };
39234
39303
  }
39235
39304
  try {
39236
39305
  const began = await startedAt(pid, null);
@@ -39281,13 +39350,18 @@ function listeningPidInNetstat(printed, port) {
39281
39350
  }
39282
39351
  return null;
39283
39352
  }
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;
39353
+ async function readWindowsCommandLine(pid, ask = askWindows) {
39354
+ const query = `$p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}"; if ($p) { $p.CreationDate.ToUniversalTime().ToString("o"); $p.CommandLine }`;
39355
+ let unread = "";
39356
+ for (const withinMs of [15000, 45000]) {
39357
+ try {
39358
+ const answer = (await ask(query, withinMs)).trim();
39359
+ return answer === "" ? { unread: "Windows gave no command line for it" } : { answer };
39360
+ } catch (error) {
39361
+ unread = errorMessage(error);
39362
+ }
39290
39363
  }
39364
+ return { unread };
39291
39365
  }
39292
39366
  async function startedAt(pid, windowsAnswer) {
39293
39367
  if (process.platform === "win32") {
@@ -39315,36 +39389,43 @@ async function windowsImage(pid) {
39315
39389
  return null;
39316
39390
  }
39317
39391
  }
39318
- async function stillTheRecordedRun(record) {
39319
- return judgeRun(record, await runningAs(record.pid), "confirmed");
39392
+ async function whyNotStillTheRecordedRun(record) {
39393
+ return whyNotTheRun(record, await runningAs(record.pid), "confirmed");
39320
39394
  }
39321
39395
  async function couldStillBeTheRecordedRun(record) {
39322
39396
  return judgeRun(record, await runningAs(record.pid), "possible");
39323
39397
  }
39324
39398
  function judgeRun(record, running, needed) {
39399
+ return whyNotTheRun(record, running, needed) === null;
39400
+ }
39401
+ function whyNotTheRun(record, running, needed) {
39325
39402
  if (running === null) {
39326
- return false;
39403
+ return "the operating system would not describe that process";
39327
39404
  }
39328
- if (running.startedAt !== undefined && running.startedAt - record.startedAt > SAME_RUN_WINDOW_MS) {
39329
- return false;
39405
+ const after = running.startedAt === undefined ? 0 : running.startedAt - (record.startedBy ?? record.startedAt);
39406
+ if (after > SAME_RUN_WINDOW_MS) {
39407
+ return `that process started ${Math.round(after / 1000)} seconds after the run had`;
39330
39408
  }
39331
39409
  const engine = record.command === undefined ? null : basename4(record.command);
39332
39410
  const said = process.platform === "win32" ? running.text.toLowerCase() : running.text;
39333
39411
  const wanted = engine === null || process.platform !== "win32" ? engine : engine.toLowerCase();
39334
39412
  if (wanted !== null && !said.includes(wanted)) {
39335
- return false;
39413
+ return `that process is not ${engine ?? "the engine"}, which the run was started with`;
39336
39414
  }
39337
39415
  if (running.kind === "commandLine" && readCommandLine(running.text).editor) {
39338
- return false;
39416
+ return "that process is an editor";
39339
39417
  }
39340
39418
  if (record.projectPath === "") {
39341
- return wanted !== null;
39419
+ return wanted !== null ? null : "the record names neither the project nor the engine";
39342
39420
  }
39343
39421
  if (running.kind === "image") {
39344
- return needed === "possible" && wanted !== null;
39422
+ if (wanted === null) {
39423
+ return "the operating system named only its executable, and the record names no engine";
39424
+ }
39425
+ 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
39426
  }
39346
39427
  const project = process.platform === "win32" ? record.projectPath.toLowerCase() : record.projectPath;
39347
- return said.includes(project);
39428
+ return said.includes(project) ? null : `that process's command line does not name ${record.projectPath}`;
39348
39429
  }
39349
39430
  async function stillTheAnnouncedGame(pid, projectPath, announcedAt) {
39350
39431
  return judgeAnnouncedGame(await runningAs(pid), projectPath, announcedAt);
@@ -39405,6 +39486,7 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
39405
39486
  }
39406
39487
  var execFileAsync, SHARED_NOTE = "run.json", SAME_RUN_WINDOW_MS = 1e4;
39407
39488
  var init_run_record = __esm(() => {
39489
+ init_errors();
39408
39490
  init_paths();
39409
39491
  init_process_children();
39410
39492
  init_runtime_client();
@@ -39434,6 +39516,13 @@ import { basename as basename5, dirname as dirname16, extname as extname2, isAbs
39434
39516
  import { setTimeout as delay3 } from "node:timers/promises";
39435
39517
  import { fileURLToPath as fileURLToPath5 } from "node:url";
39436
39518
  import { promisify as promisify5 } from "node:util";
39519
+ function editorRestartTimeoutMs() {
39520
+ const override = Number.parseInt(envValue("GDHARNESS_EDITOR_RESTART_TIMEOUT_MS") ?? "", 10);
39521
+ return Number.isInteger(override) && override > 0 ? override : 90000;
39522
+ }
39523
+ function restartUnderway(note, now = Date.now()) {
39524
+ return alive(note.byPid) && now - Date.parse(note.quitAt) < editorRestartTimeoutMs() + 30000;
39525
+ }
39437
39526
  function captureDestinationRefusal(outputPath, projects) {
39438
39527
  if (/^(res|user):\/\//.test(outputPath)) {
39439
39528
  return `outputPath ${outputPath} is a Godot path; a capture is saved outside the project, at an absolute path on this machine.`;
@@ -39584,22 +39673,31 @@ function stopVerdict(stop) {
39584
39673
  withChildren: false
39585
39674
  };
39586
39675
  }
39676
+ if (stop.ending === "lingering") {
39677
+ 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`;
39678
+ return {
39679
+ stopped: false,
39680
+ stillGoing: stop.endedPid,
39681
+ 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.`,
39682
+ withChildren: true
39683
+ };
39684
+ }
39587
39685
  return {
39588
39686
  stopped: true,
39589
39687
  endedPid: stop.endedPid,
39590
39688
  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.`}`,
39689
+ 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
39690
  withChildren: true
39593
39691
  };
39594
39692
  }
39595
39693
  function endedPreviousRun(ended) {
39596
- if (ended === null || ended.ending === "refused" || ended.ending === "unreached") {
39694
+ if (ended?.ending !== "gone") {
39597
39695
  return;
39598
39696
  }
39599
39697
  return ended.pid ?? true;
39600
39698
  }
39601
39699
  function previousRunLeft(ended) {
39602
- return ended?.ending === "refused" && ended.pid !== null ? ended.pid : undefined;
39700
+ return (ended?.ending === "refused" || ended?.ending === "lingering") && ended.pid !== null ? ended.pid : undefined;
39603
39701
  }
39604
39702
  function engineNamesOf(executable) {
39605
39703
  return CONSOLE_WRAPPER.test(executable) ? [executable, executable.replace(CONSOLE_WRAPPER, "$1")] : [executable];
@@ -39678,8 +39776,10 @@ function endedToStartThis(ended) {
39678
39776
  if (ended.ending === "unreached") {
39679
39777
  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
39778
  }
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}`;
39779
+ if (ended.ending === "lingering") {
39780
+ 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.`;
39781
+ }
39782
+ return ` ${which} was ended to start this one; its output is no longer what editor_output answers about.`;
39683
39783
  }
39684
39784
  function runtimeVerdict(endpoint, after) {
39685
39785
  if (endpoint !== null) {
@@ -40009,6 +40109,7 @@ class GodotServer {
40009
40109
  shutdownInitiated = false;
40010
40110
  launchedEditor = null;
40011
40111
  launchedFrom = null;
40112
+ launchedDropped = null;
40012
40113
  ownProject;
40013
40114
  announcedAt = null;
40014
40115
  handingOverFrom = null;
@@ -40016,9 +40117,12 @@ class GodotServer {
40016
40117
  constructor() {
40017
40118
  this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
40018
40119
  this.godotBridge = getDefaultBridge(this.ownProject);
40120
+ this.godotBridge.on("godot_connected", () => {
40121
+ this.settleTheLaunchReading();
40122
+ });
40019
40123
  this.mcp = new McpServer({ name: "gdharness", version: SERVER_VERSION }, { capabilities: { tools: {}, resources: {} } });
40020
40124
  this.setupToolHandlers();
40021
- setupResourceHandlers(this.mcp, () => this.lastProjectPath);
40125
+ setupResourceHandlers(this.mcp, () => this.ourProject() ?? this.lastProjectPath);
40022
40126
  this.mcp.server.onerror = (error) => {
40023
40127
  console.error("[MCP Error]", error);
40024
40128
  };
@@ -40284,8 +40388,9 @@ class GodotServer {
40284
40388
  if (!checked.ok) {
40285
40389
  return checked.response;
40286
40390
  }
40287
- if (typeof args["projectPath"] === "string") {
40288
- this.lastProjectPath = args["projectPath"];
40391
+ const named = args["projectPath"];
40392
+ if (typeof named === "string" && existsSync17(join23(named, "project.godot"))) {
40393
+ this.lastProjectPath = named;
40289
40394
  }
40290
40395
  this.updates.refresh();
40291
40396
  const answer = await withCallSignal(extra.signal, () => this.answered(spec.name, checked.op ?? "", args));
@@ -40325,6 +40430,17 @@ class GodotServer {
40325
40430
  return answer;
40326
40431
  }
40327
40432
  const moved = this.projectHasMovedOn();
40433
+ if (moved !== null && !isNewer(moved, SERVER_VERSION)) {
40434
+ this.noticedUpdate = true;
40435
+ this.callsSinceNotice = 0;
40436
+ return this.saying(answer, {
40437
+ project_addons_behind_this_server: {
40438
+ server_is: SERVER_VERSION,
40439
+ project_is: moved,
40440
+ 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."
40441
+ }
40442
+ });
40443
+ }
40328
40444
  if (moved !== null) {
40329
40445
  this.noticedUpdate = true;
40330
40446
  this.callsSinceNotice = 0;
@@ -40357,7 +40473,7 @@ class GodotServer {
40357
40473
  return this.saying(answer, {
40358
40474
  update_available: {
40359
40475
  ...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."
40476
+ 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
40477
  }
40362
40478
  });
40363
40479
  }
@@ -40374,6 +40490,11 @@ class GodotServer {
40374
40490
  const installed = installedAddonVersion(this.ownProject);
40375
40491
  return installed === null || installed === SERVER_VERSION ? null : installed;
40376
40492
  }
40493
+ editorCodeOnDisk() {
40494
+ const status = this.godotBridge.getStatus();
40495
+ const project = (status.connected ? status.projectPath : undefined) ?? this.ownProject;
40496
+ return project === null || project === "" ? undefined : installedEditorDigest(project);
40497
+ }
40377
40498
  configDisagreement() {
40378
40499
  return this.ownProject === null ? null : configDisagrees(this.ownProject, SERVER_VERSION);
40379
40500
  }
@@ -41325,6 +41446,10 @@ class GodotServer {
41325
41446
  }
41326
41447
  return this.jsonTextResponse(info);
41327
41448
  }
41449
+ fileTypes(args) {
41450
+ const named = readStringArray(args, "fileTypes");
41451
+ return named === undefined ? {} : { fileTypes: named };
41452
+ }
41328
41453
  handleSearchProject(args) {
41329
41454
  const project = this.project(args);
41330
41455
  if (!project.ok) {
@@ -41333,16 +41458,7 @@ class GodotServer {
41333
41458
  try {
41334
41459
  return this.jsonTextResponse(searchProject(project.value.path, {
41335
41460
  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
- ],
41461
+ ...this.fileTypes(args),
41346
41462
  regex: readBoolean(args, "regex") ?? false,
41347
41463
  caseSensitive: readBoolean(args, "caseSensitive") ?? false,
41348
41464
  maxResults: readPositiveNumber(args, "maxResults") ?? 100
@@ -41653,7 +41769,7 @@ class GodotServer {
41653
41769
  warnings: diagnostics.length - errors,
41654
41770
  diagnostics,
41655
41771
  ...disagrees
41656
- }, this.godotBridge.getStatus().addonVersion, SERVER_VERSION, this.godotBridge.getStatus().addonDigest, shippedEditorDigest()));
41772
+ }, this.godotBridge.getStatus().addonVersion, SERVER_VERSION, this.godotBridge.getStatus().addonDigest, shippedEditorDigest(), this.editorCodeOnDisk()));
41657
41773
  }
41658
41774
  whereTheFileDisagrees(diagnostics, args) {
41659
41775
  const messages = diagnostics.map((entry) => asParams(entry)["message"]).filter((message) => typeof message === "string");
@@ -41804,7 +41920,7 @@ class GodotServer {
41804
41920
  }
41805
41921
  breakpointsAtRisk() {
41806
41922
  const status = this.godotBridge.getStatus();
41807
- if (!status.connected || status.syncsBreakpoints === true) {
41923
+ if (!hasSaidWhoItIs(status) || status.syncsBreakpoints === true) {
41808
41924
  return {};
41809
41925
  }
41810
41926
  return {
@@ -41859,26 +41975,34 @@ class GodotServer {
41859
41975
  restartSettled(this.ownProject);
41860
41976
  return null;
41861
41977
  }
41862
- return owed;
41978
+ return { note: owed, underway: restartUnderway(owed) };
41863
41979
  }
41864
41980
  async getEditorStatusPayload() {
41865
41981
  const status = this.godotBridge.getStatus();
41866
41982
  const launchedIsUp = !status.connected && await this.launchedEditorIsUp();
41867
41983
  const isPortConflict = this.bridgeStartupError?.includes("EADDRINUSE") ?? false;
41868
- const stale = status.connected && editorIsStale(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest());
41984
+ const greeted = hasSaidWhoItIs(status);
41985
+ const stale = greeted && editorIsStale(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest());
41869
41986
  const unfinished = this.restartLeftUnfinished(status.connected);
41870
41987
  return {
41871
41988
  ...status,
41872
41989
  serverVersion: SERVER_VERSION,
41873
- addonIsStale: status.connected ? stale : undefined,
41990
+ addonIsStale: greeted ? stale : undefined,
41991
+ 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
41992
  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,
41993
+ mayYetConnect: status.connected ? undefined : unfinished !== null ? unfinished.underway : anEditorIsStillComing(status.listeningSince, launchedIsUp),
41994
+ restartInterrupted: unfinished === null || unfinished.underway ? undefined : {
41995
+ quitEditorPid: unfinished.note.editorPid,
41996
+ quitAt: unfinished.note.quitAt,
41997
+ byServerPid: unfinished.note.byPid,
41880
41998
  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
41999
  },
42000
+ restartUnderway: unfinished === null || !unfinished.underway ? undefined : {
42001
+ quitEditorPid: unfinished.note.editorPid,
42002
+ quitAt: unfinished.note.quitAt,
42003
+ byServerPid: unfinished.note.byPid,
42004
+ 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.`
42005
+ },
41882
42006
  awaitingLaunchedEditor: status.connected || !launchedIsUp ? undefined : this.launchedEditor?.pid ?? undefined,
41883
42007
  ...status.connected ? this.whatTheLaunchedEditorDropped() : {},
41884
42008
  startupError: this.bridgeStartupError,
@@ -41888,8 +42012,9 @@ class GodotServer {
41888
42012
  port: this.godotBridge.configuredPort,
41889
42013
  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
42014
  },
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,
42015
+ staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest(), this.editorCodeOnDisk()) : undefined,
42016
+ addonNote: greeted ? sameCodeNote(status.addonVersion, SERVER_VERSION, status.addonDigest, shippedEditorDigest()) : undefined,
42017
+ 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
42018
  ...this.breakpointsAtRisk(),
41894
42019
  retryingBridge: this.bridgeRetry === null ? undefined : true,
41895
42020
  announcedAt: this.announcedAt ?? undefined,
@@ -42028,17 +42153,33 @@ class GodotServer {
42028
42153
  "editor_status says whether the bridge is up and what has reached it"
42029
42154
  ]);
42030
42155
  }
42031
- const settingsBefore = this.settingKeysOf(before.projectPath);
42156
+ if (!hasSaidWhoItIs(before)) {
42157
+ const settled = await this.waitForBridge(() => hasSaidWhoItIs(this.godotBridge.getStatus()) || !this.godotBridge.isConnected(), Date.now() + GREETING_WAIT_MS);
42158
+ if (!settled) {
42159
+ 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.`, [
42160
+ "editor_status says when it has, under greeting while it has not",
42161
+ "Then ask for the restart again"
42162
+ ]);
42163
+ }
42164
+ return this.handleRestartEditor(args);
42165
+ }
42032
42166
  const mine = before.openedByAServer === true && before.projectPath !== undefined;
42167
+ if (!mine && args["hidden"] === true) {
42168
+ return this.createErrorResponse("This editor was opened by hand, and restarts itself as it was, so it cannot come back hidden.", [
42169
+ "Close it, then editor_launch open with hidden: true starts a hidden one this server can restart",
42170
+ "editor_launch restart without hidden restarts it as it is"
42171
+ ]);
42172
+ }
42173
+ const settingsBefore = this.settingKeysOf(before.projectPath);
42033
42174
  const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid, args["hidden"] === true) : await this.handleViaBridge("restart_editor", {});
42034
42175
  if (asked.isError === true) {
42035
42176
  return asked;
42036
42177
  }
42037
42178
  const startedAt = before.connectedAt?.getTime() ?? 0;
42038
42179
  const began = Date.now();
42039
- const back = await this.waitForBridge(() => theEditorHasComeBack(this.godotBridge.getStatus(), startedAt), began + EDITOR_RESTART_TIMEOUT_MS);
42180
+ const back = await this.waitForBridge(() => theEditorHasComeBack(this.godotBridge.getStatus(), startedAt), began + editorRestartTimeoutMs());
42040
42181
  if (!back) {
42041
- return this.createErrorResponse(`The editor was asked to restart and has not come back within ${EDITOR_RESTART_TIMEOUT_MS / 1000}s.`, [
42182
+ return this.createErrorResponse(`The editor was asked to restart and has not come back within ${editorRestartTimeoutMs() / 1000}s.`, [
42042
42183
  "It may be asking what to do about an unsaved scene: look at the editor window",
42043
42184
  "An addon that no longer parses stops the editor reaching this server",
42044
42185
  "editor_status says whether anything has reached the bridge since"
@@ -42052,18 +42193,27 @@ class GodotServer {
42052
42193
  addonVersion: now.addonVersion,
42053
42194
  serverVersion: SERVER_VERSION,
42054
42195
  addonIsStale: editorIsStale(now.addonVersion, SERVER_VERSION, now.addonDigest, shippedEditorDigest()),
42055
- staleNote: addonMismatch(now.addonVersion, SERVER_VERSION, now.addonDigest, shippedEditorDigest()),
42196
+ staleNote: addonMismatch(now.addonVersion, SERVER_VERSION, now.addonDigest, shippedEditorDigest(), this.editorCodeOnDisk()),
42056
42197
  ...this.whatTheEditorDropped(settingsBefore, after),
42057
42198
  tookMs: Date.now() - began
42058
42199
  });
42059
42200
  }
42060
- whatTheLaunchedEditorDropped() {
42201
+ settleTheLaunchReading() {
42061
42202
  const watched = this.launchedFrom;
42062
- if (watched === null) {
42063
- return {};
42203
+ const status = this.godotBridge.getStatus();
42204
+ if (watched === null || !hasSaidWhoItIs(status)) {
42205
+ return;
42064
42206
  }
42065
42207
  this.launchedFrom = null;
42066
- return this.whatTheEditorDropped(watched.before, this.settingKeysOf(watched.projectPath));
42208
+ if (status.editorPid === undefined || status.editorPid !== this.launchedEditor?.pid) {
42209
+ return;
42210
+ }
42211
+ this.launchedDropped = this.whatTheEditorDropped(watched.before, this.settingKeysOf(watched.projectPath));
42212
+ }
42213
+ whatTheLaunchedEditorDropped() {
42214
+ const dropped = this.launchedDropped ?? {};
42215
+ this.launchedDropped = null;
42216
+ return dropped;
42067
42217
  }
42068
42218
  whatTheEditorDropped(before, after) {
42069
42219
  return settingsDroppedReport(before, after);
@@ -42089,7 +42239,14 @@ class GodotServer {
42089
42239
  restartSettled(projectPath);
42090
42240
  return asked;
42091
42241
  }
42092
- await this.waitForBridge(() => !alive(editorPid), Date.now() + EDITOR_RESTART_TIMEOUT_MS);
42242
+ const gone = await this.waitForBridge(() => !alive(editorPid), Date.now() + editorRestartTimeoutMs());
42243
+ if (!gone) {
42244
+ restartSettled(projectPath);
42245
+ 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.`, [
42246
+ "It may be asking what to do about unsaved changes, or a script may be holding up its exit",
42247
+ `editor_launch open starts one on ${projectPath} once it has gone`
42248
+ ]);
42249
+ }
42093
42250
  const opened = await this.openAnEditor(engine.value, projectPath, ports, hidden);
42094
42251
  restartSettled(projectPath);
42095
42252
  if (opened.error !== null) {
@@ -42120,6 +42277,10 @@ class GodotServer {
42120
42277
  "Another project wants its own gdharness server, which is its own harness session"
42121
42278
  ]);
42122
42279
  }
42280
+ const refused = this.anEditorThatCannotConnect(project.value.path);
42281
+ if (refused !== null) {
42282
+ return refused;
42283
+ }
42123
42284
  const engine = await this.engine();
42124
42285
  if (!engine.ok) {
42125
42286
  return engine.response;
@@ -42144,6 +42305,27 @@ class GodotServer {
42144
42305
  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
42306
  });
42146
42307
  }
42308
+ anEditorThatCannotConnect(projectPath) {
42309
+ if (this.ownProject !== null && !isSameDirectory(this.ownProject, projectPath)) {
42310
+ return this.createErrorResponse(`This server serves ${this.ownProject}, and an editor of ${projectPath} would dial that project's own server rather than this one.`, [
42311
+ "Open it from that project's own gdharness session",
42312
+ `editor_launch with projectPath ${this.ownProject} opens the editor this server serves`
42313
+ ]);
42314
+ }
42315
+ const report = inspectProject(projectPath);
42316
+ const installed = report.addons.some((addon) => addon.name === "gdharness_editor" && addon.installed);
42317
+ if (!installed || !report.pluginsEnabled.includes("gdharness_editor")) {
42318
+ 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 ? [
42319
+ "gdharness setup in the project enables it",
42320
+ "Or enable it under Project Settings, Plugins, in an editor opened by hand"
42321
+ ] : ["gdharness setup in the project installs and enables it"]);
42322
+ }
42323
+ const owed = restartOwed(projectPath);
42324
+ if (owed !== null && restartUnderway(owed)) {
42325
+ 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"]);
42326
+ }
42327
+ return null;
42328
+ }
42147
42329
  async openAnEditor(engine, projectPath, ports, hidden) {
42148
42330
  clearEditorLog(projectPath);
42149
42331
  const launched = await launchOutsideTheTree({
@@ -42591,23 +42773,30 @@ class GodotServer {
42591
42773
  pid: running.pid,
42592
42774
  transcript: running.transcript ?? "",
42593
42775
  startedAt: running.startedAt,
42776
+ ...running.startedBy === undefined ? {} : { startedBy: running.startedBy },
42594
42777
  projectPath: running.projectPath ?? "",
42595
42778
  arguments: [],
42596
42779
  ...running.command === undefined ? {} : { command: running.command }
42597
42780
  };
42598
- if (!await stillTheRecordedRun(startedAs)) {
42781
+ const why = await whyNotStillTheRecordedRun(startedAs);
42782
+ if (why !== null) {
42599
42783
  clearTheRecord();
42600
42784
  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.`);
42785
+ 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
42786
  return "refused";
42603
42787
  }
42604
42788
  const signalled = await runningAs(running.pid);
42605
42789
  const described = signalled === null ? "nothing it would name" : `${signalled.kind}: ${signalled.text}`;
42606
42790
  let landed = true;
42791
+ let declined = null;
42607
42792
  try {
42608
42793
  process.kill(running.pid);
42609
- } catch {
42794
+ } catch (error) {
42610
42795
  landed = false;
42796
+ const code = error.code;
42797
+ if (code !== "ESRCH") {
42798
+ declined = code ?? errorMessage(error);
42799
+ }
42611
42800
  }
42612
42801
  const gone = await untilGone([running.pid]);
42613
42802
  if (gone) {
@@ -42618,7 +42807,7 @@ class GodotServer {
42618
42807
  running.exitSignal = "SIGTERM";
42619
42808
  }
42620
42809
  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}.`);
42810
+ 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}.`);
42622
42811
  return gone ? "gone" : "lingering";
42623
42812
  }
42624
42813
  spawnGame(godotPath, cmdArgs, env) {
@@ -42702,12 +42891,13 @@ class GodotServer {
42702
42891
  command: godotPath,
42703
42892
  args: cmdArgs,
42704
42893
  ...env === undefined ? {} : { env },
42705
- run: { transcript: transcript.path, startedAt, projectPath },
42894
+ run: { transcript: transcript.path, startedAt, projectPath, ...this.servedBy() },
42706
42895
  ...desktop === undefined ? {} : { desktop }
42707
42896
  });
42708
42897
  if ("error" in launched) {
42709
42898
  return launched;
42710
42899
  }
42900
+ const startedBy = Date.now();
42711
42901
  sweepTranscripts();
42712
42902
  return {
42713
42903
  pid: launched.pid,
@@ -42716,6 +42906,7 @@ class GodotServer {
42716
42906
  readOffset: 0,
42717
42907
  projectPath,
42718
42908
  startedAt,
42909
+ startedBy,
42719
42910
  command: godotPath,
42720
42911
  exitCode: null,
42721
42912
  exitSignal: null,
@@ -42935,8 +43126,9 @@ class GodotServer {
42935
43126
  }
42936
43127
  async adoptRecordedRun() {
42937
43128
  const project = this.ourProject();
42938
- const record = project === null ? null : readRunRecord(project);
42939
- if (record === null || !this.couldBeOurs(record.projectPath)) {
43129
+ const found = project === null ? [] : [readRunRecord(project), this.startedForUs(project)];
43130
+ const record = found.reduce((newest, one) => one !== null && (newest === null || one.startedAt > newest.startedAt) ? one : newest, null);
43131
+ if (record === null || !(this.couldBeOurs(record.projectPath) || this.couldBeOurs(record.servedBy ?? ""))) {
42940
43132
  return null;
42941
43133
  }
42942
43134
  const adopted = {
@@ -42946,6 +43138,7 @@ class GodotServer {
42946
43138
  readOffset: 0,
42947
43139
  projectPath: record.projectPath === "" ? null : record.projectPath,
42948
43140
  startedAt: record.startedAt,
43141
+ ...record.startedBy === undefined ? {} : { startedBy: record.startedBy },
42949
43142
  ...record.command === undefined ? {} : { command: record.command },
42950
43143
  exitCode: null,
42951
43144
  exitSignal: null,
@@ -42966,6 +43159,13 @@ class GodotServer {
42966
43159
  this.activeProcess = adopted;
42967
43160
  return adopted;
42968
43161
  }
43162
+ servedBy() {
43163
+ const mine = this.ourProject();
43164
+ return mine === null ? {} : { servedBy: mine };
43165
+ }
43166
+ startedForUs(project) {
43167
+ return everyRunRecord().find((one) => one.servedBy !== undefined && isSameDirectory(one.servedBy, project) && !(one.projectPath !== "" && isSameDirectory(one.projectPath, project))) ?? null;
43168
+ }
42969
43169
  nothingOfOursIsRunning() {
42970
43170
  const status = this.godotBridge.getStatus();
42971
43171
  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 +43332,20 @@ class GodotServer {
43132
43332
  }
43133
43333
  async handleEditorConsole(args) {
43134
43334
  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
- ]);
43335
+ const greeted = hasSaidWhoItIs(status);
43336
+ const launched = greeted ? null : this.launchedEditor?.pid ?? null;
43337
+ if (!greeted && launched === null && status.connected) {
43338
+ 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
43339
  }
43142
- const launched = status.connected ? null : this.launchedEditor?.pid ?? null;
43143
- if (!status.connected && launched === null) {
43340
+ const projectPath = status.projectPath ?? this.ownProject ?? undefined;
43341
+ if (projectPath === undefined || !greeted && launched === null) {
43144
43342
  return this.createErrorResponse("No editor is connected, so there is no console to read.", [
43145
43343
  "editor_status says whether one is connected and whether it may yet be",
43146
43344
  "editor_launch opens one, and an editor opened here has its console captured"
43147
43345
  ]);
43148
43346
  }
43149
- const editorPid = status.connected ? status.editorPid ?? null : launched;
43150
- const console2 = editorConsole(projectPath, editorPid, status.connected ? status.openedByAServer === true : true);
43347
+ const editorPid = greeted ? status.editorPid ?? null : launched;
43348
+ const console2 = editorConsole(projectPath, editorPid, greeted ? status.openedByAServer === true : true);
43151
43349
  if ("kind" in console2) {
43152
43350
  return this.createErrorResponse(theConsoleWasNotCaptured(console2, projectPath), [
43153
43351
  "editor_launch restart replaces this editor with one whose console is captured",
@@ -43329,6 +43527,7 @@ class GodotServer {
43329
43527
  through: stopped.throughEditor ? "editor" : "gdharness",
43330
43528
  endedPid: verdict.endedPid,
43331
43529
  notSignalled: verdict.notSignalled,
43530
+ stillGoing: verdict.stillGoing,
43332
43531
  ...childrenFields(ended),
43333
43532
  exitedBeforeStop: !wasRunning,
43334
43533
  exitCode: stopped.exitCode,
@@ -43386,12 +43585,13 @@ class GodotServer {
43386
43585
  await announcementEnded(pid);
43387
43586
  }
43388
43587
  return this.jsonTextResponse({
43389
- stopped: true,
43588
+ stopped: gone,
43390
43589
  through: "gdharness",
43391
- endedPid: pid,
43590
+ endedPid: gone ? pid : undefined,
43591
+ stillGoing: gone ? undefined : pid,
43392
43592
  ...childrenFields(ended),
43393
43593
  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)}`
43594
+ 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
43595
  });
43396
43596
  }
43397
43597
  async whatTheRunStarted(run) {
@@ -43538,7 +43738,8 @@ class GodotServer {
43538
43738
  }
43539
43739
  }
43540
43740
  const after = projectPath === "" || busy ? null : cachedClasses(projectPath);
43541
- const lost = before === null || after === null ? [] : [...before.keys()].filter((name) => !after.has(name));
43741
+ const declaredNow = before === null || after === null ? null : declaredClasses(projectPath);
43742
+ const lost = before === null || after === null || declaredNow === null ? [] : [...before.keys()].filter((name) => !after.has(name) && declaredNow.has(name));
43542
43743
  if (lost.length > 0) {
43543
43744
  this.noteAShortListEditor(projectPath);
43544
43745
  }
@@ -43599,7 +43800,7 @@ class GodotServer {
43599
43800
  }
43600
43801
  const checked = busy ? { unseen: [] } : await this.classesTheEditorCannotSee(args);
43601
43802
  const unseen = checked.unseen;
43602
- const broughtIn = blind.unseen.map((one) => one.className).filter((name) => !unseen.some((still) => still.className === name));
43803
+ const broughtIn = busy ? [] : blind.unseen.map((one) => one.className).filter((name) => !unseen.some((still) => still.className === name));
43603
43804
  const dependents = busy || projectPath === "" || broughtIn.length === 0 ? null : await this.reloadDependents(projectPath, broughtIn);
43604
43805
  const stillGone = lost.filter((name) => !restored.includes(name));
43605
43806
  const notes = [];
@@ -43840,7 +44041,7 @@ class GodotServer {
43840
44041
  ]);
43841
44042
  }
43842
44043
  try {
43843
- return this.jsonTextResponse(markIfStale(await this.godotBridge.invokeTool(toolName, args), this.godotBridge.getStatus().addonVersion, SERVER_VERSION, this.godotBridge.getStatus().addonDigest, shippedEditorDigest()));
44044
+ return this.jsonTextResponse(markIfStale(await this.godotBridge.invokeTool(toolName, args), this.godotBridge.getStatus().addonVersion, SERVER_VERSION, this.godotBridge.getStatus().addonDigest, shippedEditorDigest(), this.editorCodeOnDisk()));
43844
44045
  } catch (error) {
43845
44046
  return this.createErrorResponse(`The editor answered ${toolName} with an error: ${errorMessage(error)}`);
43846
44047
  }
@@ -44118,10 +44319,11 @@ class GodotServer {
44118
44319
  }
44119
44320
  }
44120
44321
  async function runGodotServer() {
44322
+ delete process.env[EDITOR_PID_VARIABLE];
44121
44323
  const server = new GodotServer;
44122
44324
  await server.run();
44123
44325
  }
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/";
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/";
44125
44327
  var init_server3 = __esm(() => {
44126
44328
  init_mcp();
44127
44329
  init_stdio2();
@@ -44923,27 +45125,37 @@ function registered(harness, projectPath) {
44923
45125
  }
44924
45126
  function recordedEnginePath(projectPath) {
44925
45127
  for (const harness of HARNESSES) {
44926
- if (harness.scope !== "project" || harness.toml || harness.yaml || harness.snippet) {
44927
- continue;
44928
- }
44929
- const path = configPath(harness, projectPath);
44930
- if (!existsSync4(path)) {
45128
+ if (harness.scope !== "project") {
44931
45129
  continue;
44932
45130
  }
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");
45131
+ const named = reach(entryEnvironment(harness, projectPath), "GODOT_PATH");
44941
45132
  if (typeof named === "string" && named !== "" && existsSync4(named)) {
44942
45133
  return named;
44943
45134
  }
44944
45135
  }
44945
45136
  return null;
44946
45137
  }
45138
+ function entryEnvironment(harness, projectPath) {
45139
+ if (harness.toml || harness.yaml || harness.snippet) {
45140
+ return;
45141
+ }
45142
+ const path = configPath(harness, projectPath);
45143
+ if (!existsSync4(path)) {
45144
+ return;
45145
+ }
45146
+ let parsed;
45147
+ try {
45148
+ parsed = JSON.parse(readFileSync2(path, "utf8"));
45149
+ } catch {
45150
+ return;
45151
+ }
45152
+ const entry = reach(reach(parsed, harness.container), SERVER_KEY);
45153
+ return reach(entry, harness.shape === "opencode" ? "environment" : "env");
45154
+ }
45155
+ function servedProject(harness, projectPath) {
45156
+ const named = reach(entryEnvironment(harness, projectPath), "GDHARNESS_PROJECT");
45157
+ return typeof named === "string" && named !== "" ? named : null;
45158
+ }
44947
45159
  function reach(held, key) {
44948
45160
  return typeof held === "object" && held !== null && !Array.isArray(held) ? held[key] : undefined;
44949
45161
  }
@@ -45081,6 +45293,7 @@ function taken(harness, path, held, stripped) {
45081
45293
  // src/cli.ts
45082
45294
  init_headless();
45083
45295
  init_issues();
45296
+ init_paths();
45084
45297
 
45085
45298
  // src/prompt.ts
45086
45299
  import process8 from "node:process";
@@ -45279,7 +45492,7 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
45279
45492
  | Where a control is, and whether it is visible | \`runtime_inspect\` \`find\`, \`rect\` |
45280
45493
  | What a property reads right now, on a node whose path you already hold | \`runtime_inspect\` \`property\`, \`runtime_invoke\` |
45281
45494
  | 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 |
45495
+ | 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
45496
  | 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
45497
  | 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
45498
  | Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\` with \`replace\`; a submitted field has to be clicked again |
@@ -45680,12 +45893,14 @@ async function uninstall() {
45680
45893
  const named = namedHarnesses();
45681
45894
  const from = named.length > 0 ? named : HARNESSES;
45682
45895
  for (const harness of from) {
45683
- const removal = disconnect(harness, projectPath);
45684
- if (removal.action === "absent") {
45896
+ if (harness.scope === "home" && named.length === 0) {
45897
+ if (registered(harness, projectPath)) {
45898
+ console.log(`${harness.name}: left alone. Its config is machine-wide and may serve another project; pass --${harness.id} to remove it.`);
45899
+ }
45685
45900
  continue;
45686
45901
  }
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.`);
45902
+ const removal = disconnect(harness, projectPath);
45903
+ if (removal.action === "absent") {
45689
45904
  continue;
45690
45905
  }
45691
45906
  if (removal.action === "manual") {
@@ -45705,15 +45920,19 @@ async function uninstall() {
45705
45920
  said2(outcome, `disabling ${name}`);
45706
45921
  console.log(`${name}: ${outcome.ok ? String(outcome.payload["action"]) : "failed"}`);
45707
45922
  }
45708
- if (before.runtimeAutoload) {
45923
+ if (before.runtimeAutoloadPath !== null) {
45709
45924
  said2(await setRuntime(godot, projectPath, false), "removing the runtime autoload");
45710
45925
  console.log(`${RUNTIME_AUTOLOAD.name} autoload removed`);
45711
45926
  }
45927
+ const loader = before.runtimeLoaderAutoload;
45928
+ if (loader !== null && loader.name !== RUNTIME_AUTOLOAD.name) {
45929
+ 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.`);
45930
+ }
45712
45931
  const addons = removeAddons(projectPath);
45713
45932
  for (const path of addons) {
45714
45933
  console.log(`removed ${path}`);
45715
45934
  }
45716
- const anything = addons.length > 0 || before.runtimeAutoload || enabled.length > 0;
45935
+ const anything = addons.length > 0 || before.runtimeAutoloadPath !== null || enabled.length > 0;
45717
45936
  console.log(anything ? `
45718
45937
  Reconnect your harness so it stops spawning a server that is no longer installed.` : `
45719
45938
  Nothing of gdharness was in this project.`);
@@ -45722,13 +45941,13 @@ async function upgrade() {
45722
45941
  const projectPath = projectArgument(1);
45723
45942
  const version = getLocalVersion();
45724
45943
  const before = inspectProject(projectPath);
45725
- const installed = before.addons.find((addon) => addon.installed)?.version ?? null;
45726
- if (installed === null) {
45944
+ const present = before.addons.find((addon) => addon.installed);
45945
+ if (present === undefined) {
45727
45946
  const where = projectPath === process.cwd() ? "" : ` ${projectPath}`;
45728
45947
  throw new UsageError(`gdharness is not installed in ${projectPath}. Run gdharness setup${where} instead.`);
45729
45948
  }
45730
45949
  const godot = await engine(projectPath);
45731
- console.log(installed === version ? `already ${version}; reinstalling` : `${installed} -> ${version}`);
45950
+ console.log(present.version === version ? `already ${version}; reinstalling` : `${present.version ?? "an unmarked copy"} -> ${version}`);
45732
45951
  const digestBefore = installedEditorDigest(projectPath);
45733
45952
  for (const addon of installAddons(projectPath)) {
45734
45953
  console.log(`${addon.replaced ? "replaced" : "installed"} ${addon.path}`);
@@ -45742,7 +45961,21 @@ async function upgrade() {
45742
45961
  }
45743
45962
  said2(await runOperation(godot, "refresh_class_cache", {}, projectPath), "rebuilding the class list");
45744
45963
  const launch = launchFor(version, godot.godotPath, projectPath);
45745
- const already = HARNESSES.filter((harness) => registered(harness, projectPath));
45964
+ const named = namedHarnesses();
45965
+ const already = HARNESSES.filter((harness) => {
45966
+ if (!registered(harness, projectPath)) {
45967
+ return false;
45968
+ }
45969
+ if (harness.scope !== "home" || named.includes(harness)) {
45970
+ return true;
45971
+ }
45972
+ const serves = servedProject(harness, projectPath);
45973
+ if (serves !== null && isSameDirectory(serves, projectPath)) {
45974
+ return true;
45975
+ }
45976
+ 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.`);
45977
+ return false;
45978
+ });
45746
45979
  const moved = [];
45747
45980
  let written = 0;
45748
45981
  let byHand = 0;