gdharness 0.12.2 → 0.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/build/cli.js +225 -49
  2. package/build/index.js +190 -44
  3. package/package.json +1 -1
package/build/cli.js CHANGED
@@ -15688,19 +15688,34 @@ var init_tool_definitions = __esm(() => {
15688
15688
  },
15689
15689
  {
15690
15690
  name: "editor_run",
15691
- description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice.",
15691
+ description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice. When it is not listening, mayYetAnnounce says whether that is final: false is a runtime that is not coming, true is a game still on its way up, which editor_status will see and runtimeWaitMs waits longer for.",
15692
15692
  parameters: {
15693
15693
  projectPath: PROJECT_PATH,
15694
- scene: { type: "string", description: "A scene to run instead of the main scene." },
15694
+ scene: {
15695
+ type: "string",
15696
+ ops: ["start", "check"],
15697
+ description: "A scene to run instead of the main scene."
15698
+ },
15695
15699
  args: {
15696
15700
  type: "array",
15697
15701
  items: { type: "string" },
15702
+ ops: ["start", "check"],
15698
15703
  description: `The game's own arguments, what OS.get_cmdline_user_args() answers, such as ["--level=2"]. The separator is added here. A run with any is started by this server rather than by the editor, which fixes the game's command line when it opens the project, so the debug_* tools do not answer for it.`
15699
15704
  },
15700
- headless: { type: "boolean", description: "start: force a window or no window." },
15701
- frames: { type: "number", description: "check: frames to run before quitting. Default 3." },
15705
+ headless: { type: "boolean", ops: ["start"], description: "start: force a window or no window." },
15706
+ runtimeWaitMs: {
15707
+ type: "number",
15708
+ ops: ["start"],
15709
+ description: "start: how long to wait for the game to announce its runtime before answering. Default 5000. Worth raising for a project that takes longer than that to reach its first frame, which is what leaves runtime listening false on a game whose runtime arrives a moment later."
15710
+ },
15711
+ frames: {
15712
+ type: "number",
15713
+ ops: ["check"],
15714
+ description: "check: frames to run before quitting. Default 3."
15715
+ },
15702
15716
  timeoutMs: {
15703
15717
  type: "number",
15718
+ ops: ["check"],
15704
15719
  description: "check: how long to give the boot before it is called hung. Default 60000."
15705
15720
  }
15706
15721
  },
@@ -36095,8 +36110,9 @@ var init_runtime_client = __esm(() => {
36095
36110
  });
36096
36111
 
36097
36112
  // src/run-record.ts
36113
+ import { execFileSync } from "node:child_process";
36098
36114
  import { mkdirSync as mkdirSync6, openSync, readdirSync as readdirSync6, readFileSync as readFileSync12, rmSync as rmSync7, statSync as statSync2, writeFileSync as writeFileSync8 } from "node:fs";
36099
- import { join as join14 } from "node:path";
36115
+ import { basename as basename2, join as join14 } from "node:path";
36100
36116
  function runsDirectory() {
36101
36117
  return join14(runtimeDirectory(), "runs");
36102
36118
  }
@@ -36147,9 +36163,75 @@ function recordAt(path) {
36147
36163
  transcript,
36148
36164
  startedAt,
36149
36165
  projectPath: typeof fields["projectPath"] === "string" ? fields["projectPath"] : "",
36150
- arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : []
36166
+ arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : [],
36167
+ ...typeof fields["command"] === "string" ? { command: fields["command"] } : {}
36151
36168
  };
36152
36169
  }
36170
+ function runningAs(pid) {
36171
+ if (process.platform === "win32") {
36172
+ const line = windowsCommandLine(pid);
36173
+ if (line !== null) {
36174
+ return { kind: "commandLine", text: line };
36175
+ }
36176
+ const image = windowsImage(pid);
36177
+ return image === null ? null : { kind: "image", text: image };
36178
+ }
36179
+ try {
36180
+ if (process.platform === "linux") {
36181
+ const raw = readFileSync12(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
36182
+ return raw === "" ? null : { kind: "commandLine", text: raw };
36183
+ }
36184
+ const args = execFileSync("ps", ["-p", String(pid), "-o", "args="], {
36185
+ encoding: "utf8",
36186
+ timeout: 15000
36187
+ }).trim();
36188
+ return args === "" ? null : { kind: "commandLine", text: args };
36189
+ } catch {
36190
+ return null;
36191
+ }
36192
+ }
36193
+ function windowsCommandLine(pid) {
36194
+ try {
36195
+ const answer = execFileSync("powershell", [
36196
+ "-NoProfile",
36197
+ "-NonInteractive",
36198
+ "-Command",
36199
+ `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`
36200
+ ], { encoding: "utf8", timeout: 15000, windowsHide: true }).trim();
36201
+ return answer === "" ? null : answer;
36202
+ } catch {
36203
+ return null;
36204
+ }
36205
+ }
36206
+ function windowsImage(pid) {
36207
+ try {
36208
+ const csv = execFileSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], {
36209
+ encoding: "utf8",
36210
+ timeout: 15000,
36211
+ windowsHide: true
36212
+ });
36213
+ return /^"([^"]+)"/.exec(csv.trim())?.[1] ?? null;
36214
+ } catch {
36215
+ return null;
36216
+ }
36217
+ }
36218
+ function stillTheRecordedRun(record) {
36219
+ const running = runningAs(record.pid);
36220
+ if (running === null) {
36221
+ return false;
36222
+ }
36223
+ const engine = record.command === undefined ? null : basename2(record.command);
36224
+ const said = process.platform === "win32" ? running.text.toLowerCase() : running.text;
36225
+ const wanted = engine === null || process.platform !== "win32" ? engine : engine.toLowerCase();
36226
+ if (wanted !== null && !said.includes(wanted)) {
36227
+ return false;
36228
+ }
36229
+ if (running.kind === "image" || record.projectPath === "") {
36230
+ return wanted !== null;
36231
+ }
36232
+ const project = process.platform === "win32" ? record.projectPath.toLowerCase() : record.projectPath;
36233
+ return said.includes(project);
36234
+ }
36153
36235
  function clearRunRecord(ours = () => true) {
36154
36236
  for (const path of recordPaths()) {
36155
36237
  const record = recordAt(path);
@@ -36234,7 +36316,7 @@ import {
36234
36316
  rmSync as rmSync9
36235
36317
  } from "node:fs";
36236
36318
  import { tmpdir as tmpdir5 } from "node:os";
36237
- import { basename as basename2, dirname as dirname9, join as join16, normalize as normalize3 } from "node:path";
36319
+ import { basename as basename3, dirname as dirname9, join as join16, normalize as normalize3 } from "node:path";
36238
36320
  import { setTimeout as delay3 } from "node:timers/promises";
36239
36321
  import { fileURLToPath as fileURLToPath4 } from "node:url";
36240
36322
  import { promisify as promisify5 } from "node:util";
@@ -36250,6 +36332,38 @@ function stillRunning(run) {
36250
36332
  }
36251
36333
  return true;
36252
36334
  }
36335
+ function runIsUp(run, editorSays) {
36336
+ if (run?.throughEditor !== true) {
36337
+ return stillRunning(run);
36338
+ }
36339
+ return editorSays ?? stillRunning(run);
36340
+ }
36341
+ function runtimeVerdict(endpoint, after) {
36342
+ if (endpoint !== null) {
36343
+ return { listening: true, pid: endpoint.pid, port: endpoint.port };
36344
+ }
36345
+ if (!after.addon) {
36346
+ return {
36347
+ listening: false,
36348
+ mayYetAnnounce: false,
36349
+ note: "this project has no gdharness_runtime addon, so the runtime_* tools have nothing to talk to"
36350
+ };
36351
+ }
36352
+ if (after.heldAt !== null) {
36353
+ return {
36354
+ listening: false,
36355
+ mayYetAnnounce: true,
36356
+ note: "the game stopped before its runtime came up, so the runtime_* tools have nothing to talk to yet: debug_control continue lets it carry on",
36357
+ heldAt: after.heldAt
36358
+ };
36359
+ }
36360
+ return {
36361
+ listening: false,
36362
+ mayYetAnnounce: after.running,
36363
+ note: after.running ? `nothing announced itself within ${after.budgetMs}ms and the game is still running, so it may announce a moment from now: editor_status says whether it has, and editor_run start takes runtimeWaitMs to wait longer than this` : `nothing announced itself within ${after.budgetMs}ms and the game is no longer running, so nothing is going to: editor_output has what it printed on the way down`,
36364
+ heldAt: null
36365
+ };
36366
+ }
36253
36367
  function alive(pid) {
36254
36368
  if (pid === undefined || pid === null) {
36255
36369
  return false;
@@ -37128,7 +37242,7 @@ class GodotServer {
37128
37242
  const name = application["config/name"];
37129
37243
  const mainScene = application["run/main_scene"];
37130
37244
  const info = {
37131
- name: typeof name === "string" && name !== "" ? name : basename2(project.value.path),
37245
+ name: typeof name === "string" && name !== "" ? name : basename3(project.value.path),
37132
37246
  path: project.value.path,
37133
37247
  mainScene: typeof mainScene === "string" && mainScene !== "" ? mainScene : null,
37134
37248
  godotVersion: await this.godotVersion(engine.value),
@@ -37676,18 +37790,21 @@ class GodotServer {
37676
37790
  if (op === "check") {
37677
37791
  return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value);
37678
37792
  }
37793
+ let ended = null;
37679
37794
  if (stillRunning(this.currentRun())) {
37680
37795
  this.logDebug("Ending the running game before starting another");
37681
- await this.endActiveGame();
37796
+ ended = this.currentRun()?.pid ?? null;
37797
+ await this.endActiveGame("editor_run start, which ends the run that was going before it starts another");
37682
37798
  }
37683
37799
  const alreadyPlaying = new Set(discoverRuntimes().map((endpoint) => endpoint.pid));
37684
37800
  const headless = resolveHeadless(args["headless"], {
37685
37801
  platform: process.platform,
37686
37802
  variables: process.env
37687
37803
  });
37804
+ const runtimeWaitMs = readPositiveNumber(args, "runtimeWaitMs") ?? ANNOUNCE_BUDGET_MS;
37688
37805
  const editorWouldPlay = !headless || editorPlaysHeadless(project.value.file);
37689
37806
  if (this.godotBridge.isConnected() && editorWouldPlay && given.value.length === 0) {
37690
- return await this.playThroughEditor(sceneArgument, refreshed.value, project.value.path, alreadyPlaying);
37807
+ return await this.playThroughEditor(sceneArgument, refreshed.value, project.value.path, alreadyPlaying, runtimeWaitMs, ended);
37691
37808
  }
37692
37809
  const cmdArgs = runArguments({
37693
37810
  projectPath: project.value.path,
@@ -37699,37 +37816,39 @@ class GodotServer {
37699
37816
  const started = this.spawnKeptGame(engine.value, cmdArgs, project.value.path);
37700
37817
  this.activeProcess = started;
37701
37818
  const spawnedInstead = this.godotBridge.isConnected() && editorWouldPlay && given.value.length > 0 ? " The editor cannot be handed arguments for a game it plays, so this one was started " + "here: the debug_* tools answer only for a game the editor is playing." : "";
37819
+ const endedForThis = ended === null ? "" : ` The run that was going, pid ${ended}, was ended to start this one; its output is no longer what editor_output answers about.`;
37702
37820
  return this.jsonTextResponse({
37703
37821
  started: true,
37704
37822
  through: "gdharness",
37705
37823
  pid: started.process.pid ?? null,
37706
37824
  arguments: cmdArgs,
37707
37825
  refreshedClasses: refreshed.value,
37708
- runtime: await this.runtimeUp(project.value.path, alreadyPlaying),
37709
- message: `Use editor_output for what it prints and editor_run stop to end it.${spawnedInstead}`
37826
+ endedPreviousRun: ended ?? undefined,
37827
+ runtime: await this.runtimeUp(project.value.path, alreadyPlaying, runtimeWaitMs),
37828
+ message: `Use editor_output for what it prints and editor_run stop to end it.${spawnedInstead}${endedForThis}`
37710
37829
  });
37711
37830
  }
37712
- async runtimeUp(projectPath, before) {
37831
+ async gameIsUp() {
37832
+ const run = this.currentRun();
37833
+ const editorSays = run?.throughEditor === true ? (await this.editorPlayingState())?.playing ?? null : null;
37834
+ return runIsUp(run, editorSays);
37835
+ }
37836
+ async runtimeUp(projectPath, before, budgetMs) {
37713
37837
  if (!existsSync12(join16(projectPath, RUNTIME_AUTOLOAD2.path))) {
37714
- return {
37715
- listening: false,
37716
- note: "this project has no gdharness_runtime addon, so the runtime_* tools have nothing to talk to"
37717
- };
37838
+ return runtimeVerdict(null, { addon: false, budgetMs, heldAt: null, running: false });
37718
37839
  }
37719
37840
  const endpoint = await announcedSince(projectPath, before, {
37720
- giveUp: () => this.dapClient?.isStopped() === true
37841
+ budgetMs,
37842
+ giveUp: () => this.dapClient?.isStopped() === true || !stillRunning(this.currentRun())
37843
+ });
37844
+ return runtimeVerdict(endpoint, {
37845
+ addon: true,
37846
+ budgetMs,
37847
+ heldAt: this.dapClient?.whereItStopped() ?? null,
37848
+ running: await this.gameIsUp()
37721
37849
  });
37722
- if (endpoint === null) {
37723
- const held = this.dapClient?.whereItStopped() ?? null;
37724
- return {
37725
- listening: false,
37726
- note: held === null ? `nothing announced itself within ${ANNOUNCE_BUDGET_MS}ms: the game may be slow to boot, or its runtime is not coming up` : "the game stopped before its runtime came up, so the runtime_* tools have nothing to talk to yet: debug_control continue lets it carry on",
37727
- heldAt: held
37728
- };
37729
- }
37730
- return { listening: true, pid: endpoint.pid, port: endpoint.port };
37731
37850
  }
37732
- async playThroughEditor(scene, refreshedClasses, projectPath, alreadyPlaying) {
37851
+ async playThroughEditor(scene, refreshedClasses, projectPath, alreadyPlaying, runtimeWaitMs, ended) {
37733
37852
  const log = new GameLog;
37734
37853
  try {
37735
37854
  await this.dap().connect();
@@ -37762,8 +37881,9 @@ class GodotServer {
37762
37881
  scene: scene === null ? "the main scene" : `res://${scene}`,
37763
37882
  refreshedClasses,
37764
37883
  debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
37765
- runtime: await this.runtimeUp(projectPath, alreadyPlaying),
37766
- message: "The editor is playing it, so its debugger holds it: the debug_* tools can reach it, " + "editor_output reads its console through the debug adapter, and editor_run stop ends it."
37884
+ endedPreviousRun: ended ?? undefined,
37885
+ runtime: await this.runtimeUp(projectPath, alreadyPlaying, runtimeWaitMs),
37886
+ message: "The editor is playing it, so its debugger holds it: the debug_* tools can reach it, " + "editor_output reads its console through the debug adapter, and editor_run stop ends it." + (ended === null ? "" : ` The run that was going, pid ${ended}, was ended to start this one; its output is no longer what editor_output answers about.`)
37767
37887
  });
37768
37888
  }
37769
37889
  drainEditorOutput(game) {
@@ -37785,13 +37905,15 @@ class GodotServer {
37785
37905
  game.brokeOn = halt.text;
37786
37906
  game.log.record("error", halt.text);
37787
37907
  }
37788
- async endActiveGame() {
37908
+ async endActiveGame(reason) {
37789
37909
  const running = this.activeProcess;
37790
37910
  this.activeProcess = null;
37911
+ const recorded = running?.process === null ? readRunRecord() : null;
37791
37912
  clearRunRecord((record) => this.couldBeOurs(record.projectPath));
37792
37913
  if (!running) {
37793
37914
  return;
37794
37915
  }
37916
+ running.endedHere = reason;
37795
37917
  if (running.throughEditor) {
37796
37918
  await this.handleViaBridge("stop_playing", {});
37797
37919
  return;
@@ -37800,11 +37922,17 @@ class GodotServer {
37800
37922
  running.process.kill();
37801
37923
  return;
37802
37924
  }
37803
- if (running.pid !== null) {
37804
- try {
37805
- process.kill(running.pid);
37806
- } catch {}
37925
+ if (running.pid === null) {
37926
+ return;
37927
+ }
37928
+ if (recorded === null || recorded.pid !== running.pid || !stillTheRecordedRun(recorded)) {
37929
+ running.endedHere = null;
37930
+ running.log.record("warning", `This run was not ended here: pid ${running.pid} no longer answers as the run that was recorded, so nothing was signalled. If that process is still the game, end it yourself; if it is not, it belongs to something else.`);
37931
+ return;
37807
37932
  }
37933
+ try {
37934
+ process.kill(running.pid);
37935
+ } catch {}
37808
37936
  }
37809
37937
  spawnGame(godotPath, cmdArgs, env) {
37810
37938
  const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
@@ -37885,7 +38013,8 @@ class GodotServer {
37885
38013
  transcript: transcript.path,
37886
38014
  startedAt,
37887
38015
  projectPath,
37888
- arguments: cmdArgs
38016
+ arguments: cmdArgs,
38017
+ command: godotPath
37889
38018
  });
37890
38019
  }
37891
38020
  sweepTranscripts();
@@ -37932,7 +38061,7 @@ class GodotServer {
37932
38061
  brokeOn: null
37933
38062
  };
37934
38063
  this.drainTranscript(adopted);
37935
- if (!alive(record.pid)) {
38064
+ if (!alive(record.pid) || !stillTheRecordedRun(record)) {
37936
38065
  adopted.log.finish();
37937
38066
  adopted.exitCode = null;
37938
38067
  adopted.endedUnwatched = true;
@@ -37940,13 +38069,24 @@ class GodotServer {
37940
38069
  this.activeProcess = adopted;
37941
38070
  return adopted;
37942
38071
  }
37943
- couldBeOurs(project) {
37944
- if (project === "") {
37945
- return true;
38072
+ nothingOfOursIsRunning() {
38073
+ const record = readRunRecord();
38074
+ if (record === null) {
38075
+ return "No game is running. Start one with editor_run.";
37946
38076
  }
37947
38077
  const status = this.godotBridge.getStatus();
37948
38078
  const mine = this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
37949
- return mine === null || samePath(mine, project);
38079
+ const whose = record.projectPath === "" ? "a project it does not name" : record.projectPath;
38080
+ const ours = mine === null ? "this server was started without GDHARNESS_PROJECT and has no editor connected, so it cannot say whose that run is" : `this server serves ${mine}`;
38081
+ return `No game of this server's is running. A run started from ${whose} is recorded on this machine, and ${ours}, so it is not this server's to answer for or to end. Start one with editor_run.`;
38082
+ }
38083
+ couldBeOurs(project) {
38084
+ const status = this.godotBridge.getStatus();
38085
+ const mine = this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
38086
+ if (mine === null) {
38087
+ return false;
38088
+ }
38089
+ return project !== "" && samePath(mine, project);
37950
38090
  }
37951
38091
  currentRun() {
37952
38092
  return this.activeProcess ?? this.adoptRecordedRun();
@@ -37986,7 +38126,7 @@ class GodotServer {
37986
38126
  handleGetDebugOutput(args) {
37987
38127
  const run = this.currentRun();
37988
38128
  if (!run) {
37989
- return this.createErrorResponse("No game is running. Start one with editor_run.");
38129
+ return this.createErrorResponse(this.nothingOfOursIsRunning());
37990
38130
  }
37991
38131
  this.drainEditorOutput(run);
37992
38132
  this.drainTranscript(run);
@@ -38006,6 +38146,11 @@ class GodotServer {
38006
38146
  if (run.endedUnwatched === true) {
38007
38147
  notes.push("This run outlived the server that started it and is over now, so its exit code was never collected. Everything it printed is below, read back from its transcript.");
38008
38148
  }
38149
+ if (typeof run.endedHere === "string") {
38150
+ notes.push(`This run was ended here, by ${run.endedHere}.`);
38151
+ } else if (!stillRunning(run) && !run.throughEditor && run.exitCode !== null) {
38152
+ notes.push("Nothing here ended this run: it stopped on its own or something outside this server stopped it.");
38153
+ }
38009
38154
  if (run.transcript !== null && selected.omitted > 0) {
38010
38155
  notes.push(`Everything this run has printed is in ${run.transcript}, uncapped and still being written.`);
38011
38156
  }
@@ -38019,6 +38164,7 @@ class GodotServer {
38019
38164
  clean: run.log.count("error") === 0,
38020
38165
  heldAt: halt,
38021
38166
  endedUnwatched: run.endedUnwatched === true ? true : undefined,
38167
+ endedBy: stillRunning(run) ? undefined : run.endedHere ?? null,
38022
38168
  project: !run.throughEditor && run.process === null ? run.projectPath ?? undefined : undefined,
38023
38169
  transcript: run.transcript ?? undefined,
38024
38170
  note: notes.length > 0 ? notes.join(" ") : undefined,
@@ -38029,12 +38175,12 @@ class GodotServer {
38029
38175
  async handleStopProject() {
38030
38176
  const stopped = this.currentRun();
38031
38177
  if (!stopped) {
38032
- return this.createErrorResponse("No game is running. Start one with editor_run.");
38178
+ return this.createErrorResponse(this.nothingOfOursIsRunning());
38033
38179
  }
38034
38180
  this.drainEditorOutput(stopped);
38035
38181
  this.drainTranscript(stopped);
38036
38182
  this.logDebug("Stopping the running game");
38037
- await this.endActiveGame();
38183
+ await this.endActiveGame("editor_run stop");
38038
38184
  return this.jsonTextResponse({
38039
38185
  stopped: true,
38040
38186
  through: stopped.throughEditor ? "editor" : "gdharness",
@@ -39491,6 +39637,23 @@ function autoloadIsOurs(named) {
39491
39637
  async function setRuntime(engine, projectPath, on) {
39492
39638
  return on ? await runOperation2(engine, "add_autoload", { name: RUNTIME_AUTOLOAD.name, path: `res://${RUNTIME_AUTOLOAD.path}`, enabled: true }, projectPath) : await runOperation2(engine, "remove_autoload", { name: RUNTIME_AUTOLOAD.name }, projectPath);
39493
39639
  }
39640
+ function loaderAmong(projectPath, named) {
39641
+ let byName = null;
39642
+ for (const [entry, path] of named) {
39643
+ if (entry === RUNTIME_AUTOLOAD.name || path === RUNTIME_AUTOLOAD.path) {
39644
+ continue;
39645
+ }
39646
+ let source = "";
39647
+ try {
39648
+ source = readFileSync7(join8(projectPath, path), "utf8");
39649
+ } catch {}
39650
+ if (source.includes(RUNTIME_AUTOLOAD.path)) {
39651
+ return { name: entry, path };
39652
+ }
39653
+ byName ??= path.toLowerCase().includes("gdharness") ? { name: entry, path } : null;
39654
+ }
39655
+ return byName;
39656
+ }
39494
39657
  function autoloadPaths(settings) {
39495
39658
  const named = new Map;
39496
39659
  for (const [name, value] of Object.entries(settings["autoload"] ?? {})) {
@@ -39545,11 +39708,10 @@ function inspectProject(projectPath) {
39545
39708
  }
39546
39709
  const autoload = settings["autoload"];
39547
39710
  const registered = autoload === undefined ? undefined : readString(autoload, RUNTIME_AUTOLOAD.name);
39548
- const runtimeAutoload = typeof registered === "string";
39549
39711
  const runtimeAutoloadPath = typeof registered === "string" ? registered.replace(/^\*/, "") : null;
39550
39712
  const named = autoloadPaths(settings);
39551
- const loaderEntry = [...named].find(([entry, path]) => entry !== RUNTIME_AUTOLOAD.name && path !== RUNTIME_AUTOLOAD.path && path.toLowerCase().includes("gdharness")) ?? null;
39552
- const runtimeLoaderAutoload = loaderEntry === null ? null : { name: loaderEntry[0], path: loaderEntry[1] };
39713
+ const runtimeLoaderAutoload = loaderAmong(projectPath, named);
39714
+ const runtimeAutoload = runtimeAutoloadPath !== null || runtimeLoaderAutoload !== null;
39553
39715
  const tracked = trackedByGit(projectPath, [...named.values()]);
39554
39716
  for (const [name, path] of named) {
39555
39717
  if (tracked !== null && !tracked.has(path) && existsSync7(join8(projectPath, path))) {
@@ -39662,7 +39824,9 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
39662
39824
  \`addonIsStale\` means they differ, and \`staleNote\` says which half is behind: an editor that
39663
39825
  needs restarting, or a server that needs reconnecting in your harness.
39664
39826
  - Start the game with \`editor_run start\`, never by spawning an engine. The editor plays it, so
39665
- its debugger holds it, which is what gives the \`debug_*\` tools something to talk to.
39827
+ its debugger holds it, which is what gives the \`debug_*\` tools something to talk to. Its answer
39828
+ says under \`runtime\` whether the game can be talked to yet; when it cannot, \`mayYetAnnounce\`
39829
+ false is a runtime that is not coming and true is a game still on its way up.
39666
39830
  - Read \`editor_output\` after every run. It returns the engine's errors and warnings as entries
39667
39831
  with their backtraces, and a \`clean\` verdict, so a run that printed an error is one call away
39668
39832
  from being known.
@@ -40109,7 +40273,7 @@ function doctorReport(projectPath) {
40109
40273
  console.log(`addons/${addon.name}: ${addon.installed ? `installed, ${addon.version ?? "unknown version"}${addon.current ? "" : " (not this version)"}` : "missing"}`);
40110
40274
  }
40111
40275
  console.log(`editor plugins enabled: ${report.pluginsEnabled.join(", ") || "none"}`);
40112
- console.log(`runtime autoload: ${report.runtimeAutoload ? "registered" : "not registered"}`);
40276
+ console.log(`runtime autoload: ${report.runtimeLoaderAutoload === null ? report.runtimeAutoload ? "registered" : "not registered" : `registered through res://${report.runtimeLoaderAutoload.path}, as ${report.runtimeLoaderAutoload.name}`}`);
40113
40277
  console.log(report.classCacheExists ? `class cache: ${report.staleClasses.length === 0 ? "current" : `stale for ${report.staleClasses.join(", ")}`}` : "class cache: missing");
40114
40278
  for (const problem of report.problems) {
40115
40279
  console.log(`problem: ${problem}`);
@@ -40130,8 +40294,20 @@ async function runtime3() {
40130
40294
  await registerRuntime(godot, projectPath);
40131
40295
  return;
40132
40296
  }
40297
+ const before = inspectProject(projectPath);
40298
+ const loader = before.runtimeLoaderAutoload;
40299
+ if (before.runtimeAutoloadPath === null) {
40300
+ console.log(`${RUNTIME_AUTOLOAD.name} autoload is not registered, so there was nothing to remove`);
40301
+ if (loader !== null) {
40302
+ console.log(` ${loader.name} names res://${loader.path}, which brings the runtime up its own way. That line is this project's, not this tool's, so nothing here has changed it: whatever that file decides about a release build still decides it.`);
40303
+ }
40304
+ return;
40305
+ }
40133
40306
  said2(await setRuntime(godot, projectPath, false), "turning the runtime off");
40134
40307
  console.log(`${RUNTIME_AUTOLOAD.name} autoload removed`);
40308
+ if (loader !== null) {
40309
+ console.log(` ${loader.name} still names res://${loader.path}, which brings the runtime up its own way: this command has not touched it, so whatever that file decides about a release build still decides it`);
40310
+ }
40135
40311
  }
40136
40312
  async function classes() {
40137
40313
  const projectPath = projectArgument(1);
package/build/index.js CHANGED
@@ -10140,7 +10140,7 @@ import {
10140
10140
  rmSync as rmSync5
10141
10141
  } from "node:fs";
10142
10142
  import { tmpdir as tmpdir4 } from "node:os";
10143
- import { basename, dirname as dirname4, join as join10, normalize as normalize2 } from "node:path";
10143
+ import { basename as basename2, dirname as dirname4, join as join10, normalize as normalize2 } from "node:path";
10144
10144
  import { setTimeout as delay3 } from "node:timers/promises";
10145
10145
  import { fileURLToPath as fileURLToPath2 } from "node:url";
10146
10146
  import { promisify as promisify3 } from "node:util";
@@ -27493,8 +27493,9 @@ function setupResourceHandlers(mcp, getProjectPath) {
27493
27493
  }
27494
27494
 
27495
27495
  // src/run-record.ts
27496
+ import { execFileSync } from "node:child_process";
27496
27497
  import { mkdirSync as mkdirSync3, openSync, readdirSync as readdirSync5, readFileSync as readFileSync8, rmSync as rmSync3, statSync as statSync2, writeFileSync as writeFileSync4 } from "node:fs";
27497
- import { join as join8 } from "node:path";
27498
+ import { basename, join as join8 } from "node:path";
27498
27499
 
27499
27500
  // src/runtime-client.ts
27500
27501
  import { existsSync as existsSync5, readdirSync as readdirSync4, readFileSync as readFileSync7, unlinkSync } from "node:fs";
@@ -27842,9 +27843,75 @@ function recordAt(path) {
27842
27843
  transcript,
27843
27844
  startedAt,
27844
27845
  projectPath: typeof fields["projectPath"] === "string" ? fields["projectPath"] : "",
27845
- arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : []
27846
+ arguments: Array.isArray(args) ? args.filter((value) => typeof value === "string") : [],
27847
+ ...typeof fields["command"] === "string" ? { command: fields["command"] } : {}
27846
27848
  };
27847
27849
  }
27850
+ function runningAs(pid) {
27851
+ if (process.platform === "win32") {
27852
+ const line = windowsCommandLine(pid);
27853
+ if (line !== null) {
27854
+ return { kind: "commandLine", text: line };
27855
+ }
27856
+ const image = windowsImage(pid);
27857
+ return image === null ? null : { kind: "image", text: image };
27858
+ }
27859
+ try {
27860
+ if (process.platform === "linux") {
27861
+ const raw = readFileSync8(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
27862
+ return raw === "" ? null : { kind: "commandLine", text: raw };
27863
+ }
27864
+ const args = execFileSync("ps", ["-p", String(pid), "-o", "args="], {
27865
+ encoding: "utf8",
27866
+ timeout: 15000
27867
+ }).trim();
27868
+ return args === "" ? null : { kind: "commandLine", text: args };
27869
+ } catch {
27870
+ return null;
27871
+ }
27872
+ }
27873
+ function windowsCommandLine(pid) {
27874
+ try {
27875
+ const answer = execFileSync("powershell", [
27876
+ "-NoProfile",
27877
+ "-NonInteractive",
27878
+ "-Command",
27879
+ `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`
27880
+ ], { encoding: "utf8", timeout: 15000, windowsHide: true }).trim();
27881
+ return answer === "" ? null : answer;
27882
+ } catch {
27883
+ return null;
27884
+ }
27885
+ }
27886
+ function windowsImage(pid) {
27887
+ try {
27888
+ const csv = execFileSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], {
27889
+ encoding: "utf8",
27890
+ timeout: 15000,
27891
+ windowsHide: true
27892
+ });
27893
+ return /^"([^"]+)"/.exec(csv.trim())?.[1] ?? null;
27894
+ } catch {
27895
+ return null;
27896
+ }
27897
+ }
27898
+ function stillTheRecordedRun(record) {
27899
+ const running = runningAs(record.pid);
27900
+ if (running === null) {
27901
+ return false;
27902
+ }
27903
+ const engine = record.command === undefined ? null : basename(record.command);
27904
+ const said = process.platform === "win32" ? running.text.toLowerCase() : running.text;
27905
+ const wanted = engine === null || process.platform !== "win32" ? engine : engine.toLowerCase();
27906
+ if (wanted !== null && !said.includes(wanted)) {
27907
+ return false;
27908
+ }
27909
+ if (running.kind === "image" || record.projectPath === "") {
27910
+ return wanted !== null;
27911
+ }
27912
+ const project = process.platform === "win32" ? record.projectPath.toLowerCase() : record.projectPath;
27913
+ return said.includes(project);
27914
+ }
27848
27915
  function clearRunRecord(ours = () => true) {
27849
27916
  for (const path of recordPaths()) {
27850
27917
  const record = recordAt(path);
@@ -28492,19 +28559,34 @@ var TOOL_SPECS = [
28492
28559
  },
28493
28560
  {
28494
28561
  name: "editor_run",
28495
- description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice.",
28562
+ description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice. When it is not listening, mayYetAnnounce says whether that is final: false is a runtime that is not coming, true is a game still on its way up, which editor_status will see and runtimeWaitMs waits longer for.",
28496
28563
  parameters: {
28497
28564
  projectPath: PROJECT_PATH,
28498
- scene: { type: "string", description: "A scene to run instead of the main scene." },
28565
+ scene: {
28566
+ type: "string",
28567
+ ops: ["start", "check"],
28568
+ description: "A scene to run instead of the main scene."
28569
+ },
28499
28570
  args: {
28500
28571
  type: "array",
28501
28572
  items: { type: "string" },
28573
+ ops: ["start", "check"],
28502
28574
  description: `The game's own arguments, what OS.get_cmdline_user_args() answers, such as ["--level=2"]. The separator is added here. A run with any is started by this server rather than by the editor, which fixes the game's command line when it opens the project, so the debug_* tools do not answer for it.`
28503
28575
  },
28504
- headless: { type: "boolean", description: "start: force a window or no window." },
28505
- frames: { type: "number", description: "check: frames to run before quitting. Default 3." },
28576
+ headless: { type: "boolean", ops: ["start"], description: "start: force a window or no window." },
28577
+ runtimeWaitMs: {
28578
+ type: "number",
28579
+ ops: ["start"],
28580
+ description: "start: how long to wait for the game to announce its runtime before answering. Default 5000. Worth raising for a project that takes longer than that to reach its first frame, which is what leaves runtime listening false on a game whose runtime arrives a moment later."
28581
+ },
28582
+ frames: {
28583
+ type: "number",
28584
+ ops: ["check"],
28585
+ description: "check: frames to run before quitting. Default 3."
28586
+ },
28506
28587
  timeoutMs: {
28507
28588
  type: "number",
28589
+ ops: ["check"],
28508
28590
  description: "check: how long to give the boot before it is called hung. Default 60000."
28509
28591
  }
28510
28592
  },
@@ -28970,6 +29052,38 @@ function stillRunning(run) {
28970
29052
  }
28971
29053
  return true;
28972
29054
  }
29055
+ function runIsUp(run, editorSays) {
29056
+ if (run?.throughEditor !== true) {
29057
+ return stillRunning(run);
29058
+ }
29059
+ return editorSays ?? stillRunning(run);
29060
+ }
29061
+ function runtimeVerdict(endpoint, after) {
29062
+ if (endpoint !== null) {
29063
+ return { listening: true, pid: endpoint.pid, port: endpoint.port };
29064
+ }
29065
+ if (!after.addon) {
29066
+ return {
29067
+ listening: false,
29068
+ mayYetAnnounce: false,
29069
+ note: "this project has no gdharness_runtime addon, so the runtime_* tools have nothing to talk to"
29070
+ };
29071
+ }
29072
+ if (after.heldAt !== null) {
29073
+ return {
29074
+ listening: false,
29075
+ mayYetAnnounce: true,
29076
+ note: "the game stopped before its runtime came up, so the runtime_* tools have nothing to talk to yet: debug_control continue lets it carry on",
29077
+ heldAt: after.heldAt
29078
+ };
29079
+ }
29080
+ return {
29081
+ listening: false,
29082
+ mayYetAnnounce: after.running,
29083
+ note: after.running ? `nothing announced itself within ${after.budgetMs}ms and the game is still running, so it may announce a moment from now: editor_status says whether it has, and editor_run start takes runtimeWaitMs to wait longer than this` : `nothing announced itself within ${after.budgetMs}ms and the game is no longer running, so nothing is going to: editor_output has what it printed on the way down`,
29084
+ heldAt: null
29085
+ };
29086
+ }
28973
29087
  function alive(pid) {
28974
29088
  if (pid === undefined || pid === null) {
28975
29089
  return false;
@@ -29910,7 +30024,7 @@ class GodotServer {
29910
30024
  const name = application["config/name"];
29911
30025
  const mainScene = application["run/main_scene"];
29912
30026
  const info = {
29913
- name: typeof name === "string" && name !== "" ? name : basename(project.value.path),
30027
+ name: typeof name === "string" && name !== "" ? name : basename2(project.value.path),
29914
30028
  path: project.value.path,
29915
30029
  mainScene: typeof mainScene === "string" && mainScene !== "" ? mainScene : null,
29916
30030
  godotVersion: await this.godotVersion(engine.value),
@@ -30458,18 +30572,21 @@ class GodotServer {
30458
30572
  if (op === "check") {
30459
30573
  return await this.checkBoot(engine.value, project.value.path, sceneArgument, args, given.value);
30460
30574
  }
30575
+ let ended = null;
30461
30576
  if (stillRunning(this.currentRun())) {
30462
30577
  this.logDebug("Ending the running game before starting another");
30463
- await this.endActiveGame();
30578
+ ended = this.currentRun()?.pid ?? null;
30579
+ await this.endActiveGame("editor_run start, which ends the run that was going before it starts another");
30464
30580
  }
30465
30581
  const alreadyPlaying = new Set(discoverRuntimes().map((endpoint) => endpoint.pid));
30466
30582
  const headless = resolveHeadless(args["headless"], {
30467
30583
  platform: process.platform,
30468
30584
  variables: process.env
30469
30585
  });
30586
+ const runtimeWaitMs = readPositiveNumber(args, "runtimeWaitMs") ?? ANNOUNCE_BUDGET_MS;
30470
30587
  const editorWouldPlay = !headless || editorPlaysHeadless(project.value.file);
30471
30588
  if (this.godotBridge.isConnected() && editorWouldPlay && given.value.length === 0) {
30472
- return await this.playThroughEditor(sceneArgument, refreshed.value, project.value.path, alreadyPlaying);
30589
+ return await this.playThroughEditor(sceneArgument, refreshed.value, project.value.path, alreadyPlaying, runtimeWaitMs, ended);
30473
30590
  }
30474
30591
  const cmdArgs = runArguments({
30475
30592
  projectPath: project.value.path,
@@ -30481,37 +30598,39 @@ class GodotServer {
30481
30598
  const started = this.spawnKeptGame(engine.value, cmdArgs, project.value.path);
30482
30599
  this.activeProcess = started;
30483
30600
  const spawnedInstead = this.godotBridge.isConnected() && editorWouldPlay && given.value.length > 0 ? " The editor cannot be handed arguments for a game it plays, so this one was started " + "here: the debug_* tools answer only for a game the editor is playing." : "";
30601
+ const endedForThis = ended === null ? "" : ` The run that was going, pid ${ended}, was ended to start this one; its output is no longer what editor_output answers about.`;
30484
30602
  return this.jsonTextResponse({
30485
30603
  started: true,
30486
30604
  through: "gdharness",
30487
30605
  pid: started.process.pid ?? null,
30488
30606
  arguments: cmdArgs,
30489
30607
  refreshedClasses: refreshed.value,
30490
- runtime: await this.runtimeUp(project.value.path, alreadyPlaying),
30491
- message: `Use editor_output for what it prints and editor_run stop to end it.${spawnedInstead}`
30608
+ endedPreviousRun: ended ?? undefined,
30609
+ runtime: await this.runtimeUp(project.value.path, alreadyPlaying, runtimeWaitMs),
30610
+ message: `Use editor_output for what it prints and editor_run stop to end it.${spawnedInstead}${endedForThis}`
30492
30611
  });
30493
30612
  }
30494
- async runtimeUp(projectPath, before) {
30613
+ async gameIsUp() {
30614
+ const run = this.currentRun();
30615
+ const editorSays = run?.throughEditor === true ? (await this.editorPlayingState())?.playing ?? null : null;
30616
+ return runIsUp(run, editorSays);
30617
+ }
30618
+ async runtimeUp(projectPath, before, budgetMs) {
30495
30619
  if (!existsSync7(join10(projectPath, RUNTIME_AUTOLOAD.path))) {
30496
- return {
30497
- listening: false,
30498
- note: "this project has no gdharness_runtime addon, so the runtime_* tools have nothing to talk to"
30499
- };
30620
+ return runtimeVerdict(null, { addon: false, budgetMs, heldAt: null, running: false });
30500
30621
  }
30501
30622
  const endpoint = await announcedSince(projectPath, before, {
30502
- giveUp: () => this.dapClient?.isStopped() === true
30623
+ budgetMs,
30624
+ giveUp: () => this.dapClient?.isStopped() === true || !stillRunning(this.currentRun())
30625
+ });
30626
+ return runtimeVerdict(endpoint, {
30627
+ addon: true,
30628
+ budgetMs,
30629
+ heldAt: this.dapClient?.whereItStopped() ?? null,
30630
+ running: await this.gameIsUp()
30503
30631
  });
30504
- if (endpoint === null) {
30505
- const held = this.dapClient?.whereItStopped() ?? null;
30506
- return {
30507
- listening: false,
30508
- note: held === null ? `nothing announced itself within ${ANNOUNCE_BUDGET_MS}ms: the game may be slow to boot, or its runtime is not coming up` : "the game stopped before its runtime came up, so the runtime_* tools have nothing to talk to yet: debug_control continue lets it carry on",
30509
- heldAt: held
30510
- };
30511
- }
30512
- return { listening: true, pid: endpoint.pid, port: endpoint.port };
30513
30632
  }
30514
- async playThroughEditor(scene, refreshedClasses, projectPath, alreadyPlaying) {
30633
+ async playThroughEditor(scene, refreshedClasses, projectPath, alreadyPlaying, runtimeWaitMs, ended) {
30515
30634
  const log = new GameLog;
30516
30635
  try {
30517
30636
  await this.dap().connect();
@@ -30544,8 +30663,9 @@ class GodotServer {
30544
30663
  scene: scene === null ? "the main scene" : `res://${scene}`,
30545
30664
  refreshedClasses,
30546
30665
  debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
30547
- runtime: await this.runtimeUp(projectPath, alreadyPlaying),
30548
- message: "The editor is playing it, so its debugger holds it: the debug_* tools can reach it, " + "editor_output reads its console through the debug adapter, and editor_run stop ends it."
30666
+ endedPreviousRun: ended ?? undefined,
30667
+ runtime: await this.runtimeUp(projectPath, alreadyPlaying, runtimeWaitMs),
30668
+ message: "The editor is playing it, so its debugger holds it: the debug_* tools can reach it, " + "editor_output reads its console through the debug adapter, and editor_run stop ends it." + (ended === null ? "" : ` The run that was going, pid ${ended}, was ended to start this one; its output is no longer what editor_output answers about.`)
30549
30669
  });
30550
30670
  }
30551
30671
  drainEditorOutput(game) {
@@ -30567,13 +30687,15 @@ class GodotServer {
30567
30687
  game.brokeOn = halt.text;
30568
30688
  game.log.record("error", halt.text);
30569
30689
  }
30570
- async endActiveGame() {
30690
+ async endActiveGame(reason) {
30571
30691
  const running = this.activeProcess;
30572
30692
  this.activeProcess = null;
30693
+ const recorded = running?.process === null ? readRunRecord() : null;
30573
30694
  clearRunRecord((record) => this.couldBeOurs(record.projectPath));
30574
30695
  if (!running) {
30575
30696
  return;
30576
30697
  }
30698
+ running.endedHere = reason;
30577
30699
  if (running.throughEditor) {
30578
30700
  await this.handleViaBridge("stop_playing", {});
30579
30701
  return;
@@ -30582,11 +30704,17 @@ class GodotServer {
30582
30704
  running.process.kill();
30583
30705
  return;
30584
30706
  }
30585
- if (running.pid !== null) {
30586
- try {
30587
- process.kill(running.pid);
30588
- } catch {}
30707
+ if (running.pid === null) {
30708
+ return;
30709
+ }
30710
+ if (recorded === null || recorded.pid !== running.pid || !stillTheRecordedRun(recorded)) {
30711
+ running.endedHere = null;
30712
+ running.log.record("warning", `This run was not ended here: pid ${running.pid} no longer answers as the run that was recorded, so nothing was signalled. If that process is still the game, end it yourself; if it is not, it belongs to something else.`);
30713
+ return;
30589
30714
  }
30715
+ try {
30716
+ process.kill(running.pid);
30717
+ } catch {}
30590
30718
  }
30591
30719
  spawnGame(godotPath, cmdArgs, env) {
30592
30720
  const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
@@ -30667,7 +30795,8 @@ class GodotServer {
30667
30795
  transcript: transcript.path,
30668
30796
  startedAt,
30669
30797
  projectPath,
30670
- arguments: cmdArgs
30798
+ arguments: cmdArgs,
30799
+ command: godotPath
30671
30800
  });
30672
30801
  }
30673
30802
  sweepTranscripts();
@@ -30714,7 +30843,7 @@ class GodotServer {
30714
30843
  brokeOn: null
30715
30844
  };
30716
30845
  this.drainTranscript(adopted);
30717
- if (!alive(record.pid)) {
30846
+ if (!alive(record.pid) || !stillTheRecordedRun(record)) {
30718
30847
  adopted.log.finish();
30719
30848
  adopted.exitCode = null;
30720
30849
  adopted.endedUnwatched = true;
@@ -30722,13 +30851,24 @@ class GodotServer {
30722
30851
  this.activeProcess = adopted;
30723
30852
  return adopted;
30724
30853
  }
30725
- couldBeOurs(project) {
30726
- if (project === "") {
30727
- return true;
30854
+ nothingOfOursIsRunning() {
30855
+ const record = readRunRecord();
30856
+ if (record === null) {
30857
+ return "No game is running. Start one with editor_run.";
30728
30858
  }
30729
30859
  const status = this.godotBridge.getStatus();
30730
30860
  const mine = this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
30731
- return mine === null || samePath(mine, project);
30861
+ const whose = record.projectPath === "" ? "a project it does not name" : record.projectPath;
30862
+ const ours = mine === null ? "this server was started without GDHARNESS_PROJECT and has no editor connected, so it cannot say whose that run is" : `this server serves ${mine}`;
30863
+ return `No game of this server's is running. A run started from ${whose} is recorded on this machine, and ${ours}, so it is not this server's to answer for or to end. Start one with editor_run.`;
30864
+ }
30865
+ couldBeOurs(project) {
30866
+ const status = this.godotBridge.getStatus();
30867
+ const mine = this.ownProject ?? (status.connected ? status.projectPath ?? null : null);
30868
+ if (mine === null) {
30869
+ return false;
30870
+ }
30871
+ return project !== "" && samePath(mine, project);
30732
30872
  }
30733
30873
  currentRun() {
30734
30874
  return this.activeProcess ?? this.adoptRecordedRun();
@@ -30768,7 +30908,7 @@ class GodotServer {
30768
30908
  handleGetDebugOutput(args) {
30769
30909
  const run = this.currentRun();
30770
30910
  if (!run) {
30771
- return this.createErrorResponse("No game is running. Start one with editor_run.");
30911
+ return this.createErrorResponse(this.nothingOfOursIsRunning());
30772
30912
  }
30773
30913
  this.drainEditorOutput(run);
30774
30914
  this.drainTranscript(run);
@@ -30788,6 +30928,11 @@ class GodotServer {
30788
30928
  if (run.endedUnwatched === true) {
30789
30929
  notes.push("This run outlived the server that started it and is over now, so its exit code was never collected. Everything it printed is below, read back from its transcript.");
30790
30930
  }
30931
+ if (typeof run.endedHere === "string") {
30932
+ notes.push(`This run was ended here, by ${run.endedHere}.`);
30933
+ } else if (!stillRunning(run) && !run.throughEditor && run.exitCode !== null) {
30934
+ notes.push("Nothing here ended this run: it stopped on its own or something outside this server stopped it.");
30935
+ }
30791
30936
  if (run.transcript !== null && selected.omitted > 0) {
30792
30937
  notes.push(`Everything this run has printed is in ${run.transcript}, uncapped and still being written.`);
30793
30938
  }
@@ -30801,6 +30946,7 @@ class GodotServer {
30801
30946
  clean: run.log.count("error") === 0,
30802
30947
  heldAt: halt,
30803
30948
  endedUnwatched: run.endedUnwatched === true ? true : undefined,
30949
+ endedBy: stillRunning(run) ? undefined : run.endedHere ?? null,
30804
30950
  project: !run.throughEditor && run.process === null ? run.projectPath ?? undefined : undefined,
30805
30951
  transcript: run.transcript ?? undefined,
30806
30952
  note: notes.length > 0 ? notes.join(" ") : undefined,
@@ -30811,12 +30957,12 @@ class GodotServer {
30811
30957
  async handleStopProject() {
30812
30958
  const stopped = this.currentRun();
30813
30959
  if (!stopped) {
30814
- return this.createErrorResponse("No game is running. Start one with editor_run.");
30960
+ return this.createErrorResponse(this.nothingOfOursIsRunning());
30815
30961
  }
30816
30962
  this.drainEditorOutput(stopped);
30817
30963
  this.drainTranscript(stopped);
30818
30964
  this.logDebug("Stopping the running game");
30819
- await this.endActiveGame();
30965
+ await this.endActiveGame("editor_run stop");
30820
30966
  return this.jsonTextResponse({
30821
30967
  stopped: true,
30822
30968
  through: stopped.throughEditor ? "editor" : "gdharness",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.12.2",
3
+ "version": "0.12.4",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",