gdharness 0.5.9 → 0.5.11

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
@@ -7229,6 +7229,16 @@ class GameLog {
7229
7229
  this.entries.push(entry);
7230
7230
  this.lastHeadline = headline ? { index: entry.index, detail } : null;
7231
7231
  }
7232
+ record(severity, text) {
7233
+ this.entries.push({
7234
+ index: this.entries.length,
7235
+ severity,
7236
+ source: "debugger",
7237
+ text,
7238
+ detail: []
7239
+ });
7240
+ this.lastHeadline = null;
7241
+ }
7232
7242
  get all() {
7233
7243
  return this.entries;
7234
7244
  }
@@ -15741,7 +15751,10 @@ var init_tool_definitions = __esm(() => {
15741
15751
  type: "string",
15742
15752
  description: "tree, find: where to start, default /root. rect: the node to place. property: the node to read."
15743
15753
  },
15744
- property: { type: "string", description: "property: which one to read." },
15754
+ property: {
15755
+ type: "string",
15756
+ 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."
15757
+ },
15745
15758
  depth: { type: "number", description: "tree: levels to descend. Default 3." },
15746
15759
  includeProperties: {
15747
15760
  type: "boolean",
@@ -15768,7 +15781,7 @@ var init_tool_definitions = __esm(() => {
15768
15781
  operations: {
15769
15782
  tree: { summary: "the live scene tree", requires: [] },
15770
15783
  find: {
15771
- summary: "the paths of every node matching className, script, namePattern or group",
15784
+ summary: "the paths of every node matching className, script, namePattern or group, with property read off each",
15772
15785
  requires: []
15773
15786
  },
15774
15787
  rect: {
@@ -30785,6 +30798,10 @@ var init_ports = __esm(() => {
30785
30798
  // src/dap_client.ts
30786
30799
  import { createConnection } from "node:net";
30787
30800
  import { setTimeout as delay } from "node:timers/promises";
30801
+ function said(body, field) {
30802
+ const value = body?.[field];
30803
+ return typeof value === "string" ? value : "";
30804
+ }
30788
30805
 
30789
30806
  class GodotDAPClient {
30790
30807
  socket = null;
@@ -30799,7 +30816,7 @@ class GodotDAPClient {
30799
30816
  initialized = false;
30800
30817
  attached = false;
30801
30818
  lastThreadId = 1;
30802
- stopped = false;
30819
+ halt = null;
30803
30820
  breakpoints = new Map;
30804
30821
  constructor(port = portFromEnv("GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT), host = "127.0.0.1") {
30805
30822
  this.port = port;
@@ -30848,7 +30865,7 @@ class GodotDAPClient {
30848
30865
  this.connected = false;
30849
30866
  this.initialized = false;
30850
30867
  this.attached = false;
30851
- this.stopped = false;
30868
+ this.halt = null;
30852
30869
  this.socket = null;
30853
30870
  this.failPendingRequests(new Error("DAP connection closed"));
30854
30871
  });
@@ -30868,7 +30885,7 @@ class GodotDAPClient {
30868
30885
  this.connected = false;
30869
30886
  this.initialized = false;
30870
30887
  this.attached = false;
30871
- this.stopped = false;
30888
+ this.halt = null;
30872
30889
  return;
30873
30890
  }
30874
30891
  if (this.connected) {
@@ -30897,7 +30914,7 @@ class GodotDAPClient {
30897
30914
  this.connected = false;
30898
30915
  this.initialized = false;
30899
30916
  this.attached = false;
30900
- this.stopped = false;
30917
+ this.halt = null;
30901
30918
  }
30902
30919
  async ensureConnected() {
30903
30920
  if (!this.connected) {
@@ -30974,12 +30991,16 @@ class GodotDAPClient {
30974
30991
  if (typeof threadId === "number") {
30975
30992
  this.lastThreadId = threadId;
30976
30993
  }
30977
- this.stopped = true;
30994
+ this.halt = {
30995
+ reason: said(body, "reason"),
30996
+ description: said(body, "description"),
30997
+ text: said(body, "text")
30998
+ };
30978
30999
  return;
30979
31000
  }
30980
31001
  if (eventName === "terminated" || eventName === "exited") {
30981
31002
  this.attached = false;
30982
- this.stopped = false;
31003
+ this.halt = null;
30983
31004
  }
30984
31005
  }
30985
31006
  async initialize() {
@@ -31053,7 +31074,7 @@ class GodotDAPClient {
31053
31074
  await this.attach();
31054
31075
  const resolvedThreadId = await this.resolveThreadId(threadId);
31055
31076
  await this.sendRequest("continue", { threadId: resolvedThreadId });
31056
- this.stopped = false;
31077
+ this.halt = null;
31057
31078
  }
31058
31079
  async stepOver(threadId) {
31059
31080
  await this.attach();
@@ -31124,7 +31145,10 @@ class GodotDAPClient {
31124
31145
  return this.connected;
31125
31146
  }
31126
31147
  isStopped() {
31127
- return this.stopped;
31148
+ return this.halt !== null;
31149
+ }
31150
+ whereItStopped() {
31151
+ return this.halt;
31128
31152
  }
31129
31153
  async resolveThreadId(threadId) {
31130
31154
  if (typeof threadId === "number" && threadId > 0) {
@@ -31153,7 +31177,7 @@ class GodotDAPClient {
31153
31177
  this.connected = false;
31154
31178
  this.initialized = false;
31155
31179
  this.attached = false;
31156
- this.stopped = false;
31180
+ this.halt = null;
31157
31181
  this.reader = new FrameReader;
31158
31182
  socket?.destroy();
31159
31183
  this.failPendingRequests(new Error(`Godot DAP ${detail}. The connection was dropped.`));
@@ -35741,11 +35765,31 @@ var init_runtime_client = __esm(() => {
35741
35765
  ANNOUNCEMENT_PATTERN = /^runtime-(\d+)\.json$/;
35742
35766
  });
35743
35767
 
35768
+ // src/setup.ts
35769
+ import { cpSync as cpSync2, existsSync as existsSync11, mkdirSync as mkdirSync6, readFileSync as readFileSync11, rmSync as rmSync7, writeFileSync as writeFileSync8 } from "node:fs";
35770
+ import { dirname as dirname8, join as join14 } from "node:path";
35771
+ function installedAddonVersion(projectPath) {
35772
+ const marker = join14(projectPath, "addons", ADDONS2[0], VERSION_MARKER2);
35773
+ if (!existsSync11(marker)) {
35774
+ return null;
35775
+ }
35776
+ const said = readFileSync11(marker, "utf8").trim();
35777
+ return said === "" ? null : said;
35778
+ }
35779
+ var ADDONS2, VERSION_MARKER2 = ".gdharness-version";
35780
+ var init_setup = __esm(() => {
35781
+ init_class_cache();
35782
+ init_headless();
35783
+ init_resources();
35784
+ init_server_version();
35785
+ ADDONS2 = ["gdharness_editor", "gdharness_runtime", "auto_reload"];
35786
+ });
35787
+
35744
35788
  // src/server.ts
35745
35789
  import { execFile as execFile5, spawn } from "node:child_process";
35746
- import { existsSync as existsSync11, mkdtempSync as mkdtempSync3, readdirSync as readdirSync6, readFileSync as readFileSync11, realpathSync as realpathSync2, rmSync as rmSync7 } from "node:fs";
35790
+ import { existsSync as existsSync12, mkdtempSync as mkdtempSync3, readdirSync as readdirSync6, readFileSync as readFileSync12, realpathSync as realpathSync2, rmSync as rmSync8 } from "node:fs";
35747
35791
  import { tmpdir as tmpdir5 } from "node:os";
35748
- import { basename as basename2, dirname as dirname8, join as join14, normalize as normalize3 } from "node:path";
35792
+ import { basename as basename2, dirname as dirname9, join as join15, normalize as normalize3 } from "node:path";
35749
35793
  import { setTimeout as delay2 } from "node:timers/promises";
35750
35794
  import { fileURLToPath as fileURLToPath4 } from "node:url";
35751
35795
  import { promisify as promisify5 } from "node:util";
@@ -35771,11 +35815,11 @@ function realPathOr(path) {
35771
35815
  }
35772
35816
  }
35773
35817
  function hasMainScene(projectFile) {
35774
- const scene = parseProjectGodot(readFileSync11(projectFile, "utf8"))["application"]?.["run/main_scene"];
35818
+ const scene = parseProjectGodot(readFileSync12(projectFile, "utf8"))["application"]?.["run/main_scene"];
35775
35819
  return typeof scene === "string" && scene !== "";
35776
35820
  }
35777
35821
  function editorPlaysHeadless(projectFile) {
35778
- const runArgs = parseProjectGodot(readFileSync11(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
35822
+ const runArgs = parseProjectGodot(readFileSync12(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
35779
35823
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
35780
35824
  }
35781
35825
  function camelCased(params) {
@@ -35791,7 +35835,7 @@ function camelCased(params) {
35791
35835
  class GodotServer {
35792
35836
  mcp;
35793
35837
  locator = new GodotLocator2;
35794
- operationsScript = join14(__dirname2, "godot", "operations", "godot_operations.gd");
35838
+ operationsScript = join15(__dirname2, "godot", "operations", "godot_operations.gd");
35795
35839
  godotBridge;
35796
35840
  tools = buildToolDefinitions();
35797
35841
  activeProcess = null;
@@ -36029,28 +36073,44 @@ class GodotServer {
36029
36073
  if (this.callsSinceNotice < UPDATE_NOTICE_EVERY && this.noticedUpdate) {
36030
36074
  return answer;
36031
36075
  }
36076
+ const moved = this.projectHasMovedOn();
36077
+ if (moved !== null) {
36078
+ this.noticedUpdate = true;
36079
+ this.callsSinceNotice = 0;
36080
+ return this.saying(answer, {
36081
+ project_upgraded_under_this_server: {
36082
+ server_is: SERVER_VERSION,
36083
+ project_is: moved,
36084
+ what_to_do: "The project was upgraded while this server has been running, so it is still answering " + "as the version it was started as. Tell the user to reconnect the MCP server, which is " + "the only thing that replaces it: a server cannot restart itself."
36085
+ }
36086
+ });
36087
+ }
36032
36088
  const notice = this.updates.notice();
36033
36089
  if (notice === null) {
36034
36090
  return answer;
36035
36091
  }
36036
36092
  this.noticedUpdate = true;
36037
36093
  this.callsSinceNotice = 0;
36094
+ return this.saying(answer, {
36095
+ update_available: {
36096
+ ...notice,
36097
+ 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."
36098
+ }
36099
+ });
36100
+ }
36101
+ saying(answer, block) {
36038
36102
  return {
36039
36103
  ...answer,
36040
- content: [
36041
- ...answer.content,
36042
- {
36043
- type: "text",
36044
- text: JSON.stringify({
36045
- update_available: {
36046
- ...notice,
36047
- 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."
36048
- }
36049
- }, null, 2)
36050
- }
36051
- ]
36104
+ content: [...answer.content, { type: "text", text: JSON.stringify(block, null, 2) }]
36052
36105
  };
36053
36106
  }
36107
+ projectHasMovedOn() {
36108
+ if (this.ownProject === null) {
36109
+ return null;
36110
+ }
36111
+ const installed = installedAddonVersion(this.ownProject);
36112
+ return installed === null || installed === SERVER_VERSION ? null : installed;
36113
+ }
36054
36114
  withFeedbackNotice(answer) {
36055
36115
  this.callsSinceFeedback += 1;
36056
36116
  if (this.callsSinceFeedback < FEEDBACK_NOTICE_EVERY) {
@@ -36296,8 +36356,8 @@ class GodotServer {
36296
36356
  if (path === undefined) {
36297
36357
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
36298
36358
  }
36299
- const file = join14(path, "project.godot");
36300
- if (!existsSync11(file)) {
36359
+ const file = join15(path, "project.godot");
36360
+ if (!existsSync12(file)) {
36301
36361
  return {
36302
36362
  ok: false,
36303
36363
  response: this.createErrorResponse(`Not a Godot project: ${path}`, [
@@ -36442,7 +36502,7 @@ class GodotServer {
36442
36502
  if (!engine.ok) {
36443
36503
  return engine.response;
36444
36504
  }
36445
- const application = parseProjectGodot(readFileSync11(project.value.file, "utf8"))["application"] ?? {};
36505
+ const application = parseProjectGodot(readFileSync12(project.value.file, "utf8"))["application"] ?? {};
36446
36506
  const name = application["config/name"];
36447
36507
  const mainScene = application["run/main_scene"];
36448
36508
  const info = {
@@ -36531,7 +36591,7 @@ class GodotServer {
36531
36591
  log.finish();
36532
36592
  const problems = log.select({ severity: "warning", sinceLastCall: false, limit: 200 });
36533
36593
  const verdict = {
36534
- exported: exitCode === 0 && log.count("error") === 0 && existsSync11(output.absolutePath),
36594
+ exported: exitCode === 0 && log.count("error") === 0 && existsSync12(output.absolutePath),
36535
36595
  preset,
36536
36596
  outputPath: output.relativePath,
36537
36597
  debug,
@@ -36561,7 +36621,7 @@ class GodotServer {
36561
36621
  return contained.response;
36562
36622
  }
36563
36623
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
36564
- if (!existsSync11(join14(project.value.path, runner))) {
36624
+ if (!existsSync12(join15(project.value.path, runner))) {
36565
36625
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
36566
36626
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
36567
36627
  ]);
@@ -36594,7 +36654,7 @@ class GodotServer {
36594
36654
  ];
36595
36655
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
36596
36656
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
36597
- const userData = mkdtempSync3(join14(tmpdir5(), "gdharness-tests-"));
36657
+ const userData = mkdtempSync3(join15(tmpdir5(), "gdharness-tests-"));
36598
36658
  const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
36599
36659
  const hung = await new Promise((resolve) => {
36600
36660
  const timer = setTimeout(() => {
@@ -36610,20 +36670,20 @@ class GodotServer {
36610
36670
  resolve(false);
36611
36671
  });
36612
36672
  });
36613
- const reportsDir = join14(project.value.path, ".godot", "gdharness-reports");
36673
+ const reportsDir = join15(project.value.path, ".godot", "gdharness-reports");
36614
36674
  let report = null;
36615
36675
  let reportProblem = null;
36616
36676
  try {
36617
- const written = existsSync11(reportsDir) ? readdirSync6(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
36677
+ const written = existsSync12(reportsDir) ? readdirSync6(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
36618
36678
  const newest = written.at(-1);
36619
36679
  if (newest !== undefined) {
36620
- report = parseJUnit(readFileSync11(join14(reportsDir, newest, "results.xml"), "utf8"));
36680
+ report = parseJUnit(readFileSync12(join15(reportsDir, newest, "results.xml"), "utf8"));
36621
36681
  }
36622
36682
  } catch (error) {
36623
36683
  reportProblem = errorMessage(error);
36624
36684
  } finally {
36625
- rmSync7(reportsDir, { recursive: true, force: true });
36626
- rmSync7(userData, { recursive: true, force: true });
36685
+ rmSync8(reportsDir, { recursive: true, force: true });
36686
+ rmSync8(userData, { recursive: true, force: true });
36627
36687
  }
36628
36688
  const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries;
36629
36689
  const verdicts = {
@@ -36737,6 +36797,7 @@ class GodotServer {
36737
36797
  staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION) : undefined,
36738
36798
  retryingBridge: this.bridgeRetry === null ? undefined : true,
36739
36799
  announcedAt: this.announcedAt ?? undefined,
36800
+ projectIs: this.projectHasMovedOn() ?? undefined,
36740
36801
  note: isPortConflict ? "Bridge port is already in use. Another gdharness instance owns the editor bridge, so this server cannot reach the editor. Usually the server this one replaced, still on its way out." : undefined,
36741
36802
  suggestion: isPortConflict ? `This server is asking for the port again every ${BRIDGE_RETRY_MS / 1000}s and takes it the moment the other one lets go, so editor tools come back on their own. Ask again, or end the older gdharness process to have it now.` : undefined
36742
36803
  };
@@ -36949,7 +37010,8 @@ class GodotServer {
36949
37010
  log,
36950
37011
  startedAt: Date.now(),
36951
37012
  exitCode: null,
36952
- throughEditor: true
37013
+ throughEditor: true,
37014
+ brokeOn: null
36953
37015
  };
36954
37016
  this.activeProcess = played;
36955
37017
  return this.jsonTextResponse({
@@ -36969,6 +37031,15 @@ class GodotServer {
36969
37031
  `) ? line : `${line}
36970
37032
  `);
36971
37033
  }
37034
+ this.recordWhatItBrokeOn(game);
37035
+ }
37036
+ recordWhatItBrokeOn(game) {
37037
+ const halt = this.dapClient?.whereItStopped() ?? null;
37038
+ if (halt?.reason !== "exception" || halt.text === "" || game.brokeOn === halt.text) {
37039
+ return;
37040
+ }
37041
+ game.brokeOn = halt.text;
37042
+ game.log.record("error", halt.text);
36972
37043
  }
36973
37044
  async endActiveGame() {
36974
37045
  const running = this.activeProcess;
@@ -36990,7 +37061,8 @@ class GodotServer {
36990
37061
  log,
36991
37062
  startedAt: Date.now(),
36992
37063
  exitCode: null,
36993
- throughEditor: false
37064
+ throughEditor: false,
37065
+ brokeOn: null
36994
37066
  };
36995
37067
  child.stdout.on("data", (data) => {
36996
37068
  log.append("stdout", data);
@@ -37055,6 +37127,7 @@ class GodotServer {
37055
37127
  contains: readNonEmptyString(args, "contains"),
37056
37128
  limit: readPositiveNumber(args, "limit") ?? 200
37057
37129
  });
37130
+ const halt = this.activeProcess.throughEditor ? this.dapClient?.whereItStopped() ?? null : null;
37058
37131
  return this.jsonTextResponse({
37059
37132
  running: this.activeProcess.exitCode === null,
37060
37133
  exitCode: this.activeProcess.exitCode,
@@ -37063,6 +37136,7 @@ class GodotServer {
37063
37136
  errors: this.activeProcess.log.count("error"),
37064
37137
  warnings: this.activeProcess.log.count("warning"),
37065
37138
  clean: this.activeProcess.log.count("error") === 0,
37139
+ heldAt: halt,
37066
37140
  omitted: selected.omitted,
37067
37141
  entries: selected.entries
37068
37142
  });
@@ -37132,8 +37206,8 @@ class GodotServer {
37132
37206
  return this.createErrorResponse(choice.problem);
37133
37207
  }
37134
37208
  const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
37135
- const screenshotDir = expectsScreenshot ? mkdtempSync3(join14(tmpdir5(), "gdharness-runtime-screenshot-")) : null;
37136
- const screenshotPath = screenshotDir ? join14(screenshotDir, "capture.png") : null;
37209
+ const screenshotDir = expectsScreenshot ? mkdtempSync3(join15(tmpdir5(), "gdharness-runtime-screenshot-")) : null;
37210
+ const screenshotPath = screenshotDir ? join15(screenshotDir, "capture.png") : null;
37137
37211
  try {
37138
37212
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
37139
37213
  if (!reply.ok) {
@@ -37154,12 +37228,12 @@ class GodotServer {
37154
37228
  return {
37155
37229
  content: [
37156
37230
  { type: "text", text: `Screenshot captured: ${dimensions}` },
37157
- { type: "image", data: readFileSync11(screenshotPath).toString("base64"), mimeType: "image/png" }
37231
+ { type: "image", data: readFileSync12(screenshotPath).toString("base64"), mimeType: "image/png" }
37158
37232
  ]
37159
37233
  };
37160
37234
  } finally {
37161
37235
  if (screenshotDir) {
37162
- rmSync7(screenshotDir, { recursive: true, force: true });
37236
+ rmSync8(screenshotDir, { recursive: true, force: true });
37163
37237
  }
37164
37238
  }
37165
37239
  }
@@ -37180,11 +37254,13 @@ class GodotServer {
37180
37254
  if (Object.keys(filters).length === 0) {
37181
37255
  return this.createErrorResponse("runtime_inspect find needs at least one of className, script, namePattern, group.");
37182
37256
  }
37257
+ const property = readNonEmptyString(args, "property");
37183
37258
  return await this.handleRuntimeCommand("find_nodes", {
37184
37259
  ...filters,
37185
37260
  projectPath: args["projectPath"],
37186
37261
  root: readNonEmptyString(args, "nodePath") ?? "/root",
37187
- limit: readPositiveNumber(args, "limit") ?? 100
37262
+ limit: readPositiveNumber(args, "limit") ?? 100,
37263
+ ...property === undefined ? {} : { property }
37188
37264
  });
37189
37265
  }
37190
37266
  async handleRuntimeWait(op, args) {
@@ -37237,10 +37313,11 @@ var init_server3 = __esm(() => {
37237
37313
  init_resources();
37238
37314
  init_runtime_client();
37239
37315
  init_server_version();
37316
+ init_setup();
37240
37317
  init_tool_definitions();
37241
37318
  init_update_check();
37242
37319
  run5 = promisify5(execFile5);
37243
- __dirname2 = dirname8(fileURLToPath4(import.meta.url));
37320
+ __dirname2 = dirname9(fileURLToPath4(import.meta.url));
37244
37321
  PATH_SOLUTIONS = [
37245
37322
  'Give the path relative to the project, such as "scenes/main.tscn" or "res://scenes/main.tscn"',
37246
37323
  "Point projectPath at the project the file belongs to"
@@ -37297,8 +37374,8 @@ var init_server3 = __esm(() => {
37297
37374
  });
37298
37375
 
37299
37376
  // src/cli.ts
37300
- import { existsSync as existsSync12 } from "fs";
37301
- import { join as join15, resolve as resolve5 } from "path";
37377
+ import { existsSync as existsSync13 } from "fs";
37378
+ import { join as join16, resolve as resolve5 } from "path";
37302
37379
 
37303
37380
  // src/errors.ts
37304
37381
  class Refusal extends Error {
@@ -38700,7 +38777,7 @@ class UsageError extends Refusal {
38700
38777
  function projectArgument(at) {
38701
38778
  const named = args.slice(at).find((value) => !value.startsWith("--"));
38702
38779
  const projectPath = resolve5(named ?? ".");
38703
- if (!existsSync12(join15(projectPath, "project.godot"))) {
38780
+ if (!existsSync13(join16(projectPath, "project.godot"))) {
38704
38781
  throw new UsageError(`Not a Godot project: ${projectPath} holds no project.godot.`);
38705
38782
  }
38706
38783
  return projectPath;
@@ -38712,7 +38789,7 @@ async function engine() {
38712
38789
  }
38713
38790
  return { godotPath, script: shippedOperationsScript(), debug: GODOT_DEBUG_MODE_DEFAULT2 };
38714
38791
  }
38715
- function said(outcome, what) {
38792
+ function said2(outcome, what) {
38716
38793
  if (!outcome.ok) {
38717
38794
  throw new Refusal(`${what}: ${outcome.message}`);
38718
38795
  }
@@ -38813,15 +38890,15 @@ async function setup() {
38813
38890
  const enabled = await enablePlugins(godot, projectPath, EDITOR_PLUGINS);
38814
38891
  for (const [index, outcome] of enabled.entries()) {
38815
38892
  const name = EDITOR_PLUGINS[index] ?? "";
38816
- said(outcome, `enabling ${name}`);
38893
+ said2(outcome, `enabling ${name}`);
38817
38894
  console.log(`${name}: ${outcome.ok ? String(outcome.payload["action"]) : "failed"}`);
38818
38895
  }
38819
38896
  if (runtime) {
38820
- said(await setRuntime(godot, projectPath, true), "registering the runtime autoload");
38897
+ said2(await setRuntime(godot, projectPath, true), "registering the runtime autoload");
38821
38898
  console.log(`${RUNTIME_AUTOLOAD.name} autoload registered`);
38822
38899
  }
38823
38900
  const classes = await runOperation(godot, "refresh_class_cache", {}, projectPath);
38824
- said(classes, "rebuilding the class list");
38901
+ said2(classes, "rebuilding the class list");
38825
38902
  if (classes.ok) {
38826
38903
  console.log(`class list rebuilt: ${String(classes.payload["classes"])} classes`);
38827
38904
  }
@@ -38844,7 +38921,7 @@ async function setup() {
38844
38921
  console.log("no harness configured; name one yourself, and gdharness harnesses lists them all");
38845
38922
  }
38846
38923
  if (!args.includes("--no-skill")) {
38847
- const directories = skillDirectories(chosen.harnesses, projectPath, existsSync12);
38924
+ const directories = skillDirectories(chosen.harnesses, projectPath, existsSync13);
38848
38925
  for (const written of writeSkill(directories, getLocalVersion())) {
38849
38926
  console.log(`skill: ${written.replaced ? "replaced" : "written"} ${written.path}`);
38850
38927
  }
@@ -38883,11 +38960,11 @@ async function uninstall() {
38883
38960
  const disabled = await disablePlugins(godot, projectPath, enabled);
38884
38961
  for (const [index, outcome] of disabled.entries()) {
38885
38962
  const name = enabled[index] ?? "";
38886
- said(outcome, `disabling ${name}`);
38963
+ said2(outcome, `disabling ${name}`);
38887
38964
  console.log(`${name}: ${outcome.ok ? String(outcome.payload["action"]) : "failed"}`);
38888
38965
  }
38889
38966
  if (before.runtimeAutoload) {
38890
- said(await setRuntime(godot, projectPath, false), "removing the runtime autoload");
38967
+ said2(await setRuntime(godot, projectPath, false), "removing the runtime autoload");
38891
38968
  console.log(`${RUNTIME_AUTOLOAD.name} autoload removed`);
38892
38969
  }
38893
38970
  const addons = removeAddons(projectPath);
@@ -38915,12 +38992,12 @@ async function upgrade() {
38915
38992
  }
38916
38993
  const enabled = await enablePlugins(godot, projectPath, EDITOR_PLUGINS);
38917
38994
  for (const [index, outcome] of enabled.entries()) {
38918
- said(outcome, `enabling ${EDITOR_PLUGINS[index] ?? ""}`);
38995
+ said2(outcome, `enabling ${EDITOR_PLUGINS[index] ?? ""}`);
38919
38996
  }
38920
38997
  if (before.runtimeAutoload) {
38921
- said(await setRuntime(godot, projectPath, true), "registering the runtime autoload");
38998
+ said2(await setRuntime(godot, projectPath, true), "registering the runtime autoload");
38922
38999
  }
38923
- said(await runOperation(godot, "refresh_class_cache", {}, projectPath), "rebuilding the class list");
39000
+ said2(await runOperation(godot, "refresh_class_cache", {}, projectPath), "rebuilding the class list");
38924
39001
  const launch = launchFor(version, godot.godotPath, projectPath);
38925
39002
  const already = HARNESSES.filter((harness) => registered(harness, projectPath));
38926
39003
  for (const group of groupByFile(already, projectPath)) {
@@ -38929,7 +39006,7 @@ async function upgrade() {
38929
39006
  if (already.length === 0) {
38930
39007
  console.log("no harness config names gdharness, so none was re-pinned");
38931
39008
  }
38932
- for (const written of writeSkill(skillDirectories(already, projectPath, existsSync12), version)) {
39009
+ for (const written of writeSkill(skillDirectories(already, projectPath, existsSync13), version)) {
38933
39010
  console.log(`skill: ${written.replaced ? "replaced" : "written"} ${written.path}`);
38934
39011
  }
38935
39012
  console.log(`
@@ -38965,14 +39042,14 @@ async function runtime3() {
38965
39042
  }
38966
39043
  const projectPath = projectArgument(2);
38967
39044
  const godot = await engine();
38968
- said(await setRuntime(godot, projectPath, state === "on"), `turning the runtime ${state}`);
39045
+ said2(await setRuntime(godot, projectPath, state === "on"), `turning the runtime ${state}`);
38969
39046
  console.log(`${RUNTIME_AUTOLOAD.name} autoload ${state === "on" ? "registered" : "removed"}`);
38970
39047
  }
38971
39048
  async function classes() {
38972
39049
  const projectPath = projectArgument(1);
38973
39050
  const godot = await engine();
38974
39051
  const outcome = await runOperation(godot, "refresh_class_cache", {}, projectPath);
38975
- said(outcome, "rebuilding the class list");
39052
+ said2(outcome, "rebuilding the class list");
38976
39053
  if (outcome.ok) {
38977
39054
  console.log(JSON.stringify(outcome.payload, null, 2));
38978
39055
  }
@@ -36,12 +36,19 @@ func get_tree(params: Dictionary) -> Dictionary:
36
36
  ## Nodes matching every filter given, as paths, so a caller can name what it wants without
37
37
  ## reading the whole tree to find it. `class` matches native classes and their subclasses, and
38
38
  ## the global name of a script class; `name` is a case-insensitive glob; `script` is a path.
39
+ ##
40
+ ## `property` names one to read off each of them, which is the difference between one question and
41
+ ## one call per answer. A panel of a dozen labels took thirteen calls to read, and a tree that
42
+ ## rebuilds between them, which any HUD following a clock does, hands back paths that are gone by
43
+ ## the time they are asked about. A node without that property says so rather than answering null,
44
+ ## because null is what a node holding null answers.
39
45
  func find_nodes(params: Dictionary) -> Dictionary:
40
46
  var root_path: String = str(params.get("root", "/root"))
41
47
  var wanted_class: String = str(params.get("class", ""))
42
48
  var wanted_script: String = str(params.get("script", ""))
43
49
  var wanted_name: String = str(params.get("name", ""))
44
50
  var wanted_group: String = str(params.get("group", ""))
51
+ var wanted_property: String = str(params.get("property", ""))
45
52
  var limit: int = clampi(int(params.get("limit", FIND_LIMIT)), 1, FIND_LIMIT_CEILING)
46
53
 
47
54
  if (
@@ -67,7 +74,7 @@ func find_nodes(params: Dictionary) -> Dictionary:
67
74
  if found.size() >= limit:
68
75
  truncated = true
69
76
  break
70
- found.append(_serialize_node(node, false))
77
+ found.append(_found(node, wanted_property))
71
78
  var children: Array[Node] = node.get_children()
72
79
  for index: int in range(children.size() - 1, -1, -1):
73
80
  pending.push_front(children[index])
@@ -75,6 +82,29 @@ func find_nodes(params: Dictionary) -> Dictionary:
75
82
  return {"type": "nodes", "count": found.size(), "truncated": truncated, "nodes": found}
76
83
 
77
84
 
85
+ ## One match, with the named property on it when one was named.
86
+ ##
87
+ ## `has_property` alongside the value, because a node that has not got it and a node holding null
88
+ ## are different answers and a bare null reads as the second. The same distinction the `property`
89
+ ## op makes by refusing, which it cannot do here: a find over a panel matches nodes of several
90
+ ## classes on purpose, and refusing the whole answer because one of them has no `text` would make
91
+ ## the question unaskable.
92
+ func _found(node: Node, wanted_property: String) -> Dictionary:
93
+ var entry: Dictionary = _serialize_node(node, false)
94
+ if wanted_property.is_empty():
95
+ return entry
96
+ var has: bool = false
97
+ for prop: Dictionary in node.get_property_list():
98
+ if str(prop["name"]) == wanted_property:
99
+ has = true
100
+ break
101
+ entry["property"] = wanted_property
102
+ entry["has_property"] = has
103
+ if has:
104
+ entry["value"] = _values.serialize(node.get(wanted_property))
105
+ return entry
106
+
107
+
78
108
  func _matches(
79
109
  node: Node, wanted_class: String, wanted_script: String, wanted_name: String, wanted_group: String
80
110
  ) -> bool:
package/build/index.js CHANGED
@@ -10126,9 +10126,9 @@ function defectReport(where, error, godotVersion) {
10126
10126
 
10127
10127
  // src/server.ts
10128
10128
  import { execFile as execFile3, spawn } from "node:child_process";
10129
- import { existsSync as existsSync6, mkdtempSync as mkdtempSync2, readdirSync as readdirSync5, readFileSync as readFileSync7, realpathSync as realpathSync2, rmSync as rmSync3 } from "node:fs";
10129
+ import { existsSync as existsSync7, mkdtempSync as mkdtempSync2, readdirSync as readdirSync5, readFileSync as readFileSync8, realpathSync as realpathSync2, rmSync as rmSync4 } from "node:fs";
10130
10130
  import { tmpdir as tmpdir4 } from "node:os";
10131
- import { basename, dirname as dirname3, join as join8, normalize as normalize2 } from "node:path";
10131
+ import { basename, dirname as dirname4, join as join9, normalize as normalize2 } from "node:path";
10132
10132
  import { setTimeout as delay2 } from "node:timers/promises";
10133
10133
  import { fileURLToPath as fileURLToPath2 } from "node:url";
10134
10134
  import { promisify as promisify3 } from "node:util";
@@ -24878,6 +24878,10 @@ var DEFAULT_DAP_PORT = 6006;
24878
24878
  var DAP_REQUEST_TIMEOUT_MS = 1e4;
24879
24879
  var SCOPES_TIMEOUT_MS = 20000;
24880
24880
  var VARIABLES_POLL_TIMEOUT_MS = 2000;
24881
+ function said(body, field) {
24882
+ const value = body?.[field];
24883
+ return typeof value === "string" ? value : "";
24884
+ }
24881
24885
 
24882
24886
  class GodotDAPClient {
24883
24887
  socket = null;
@@ -24892,7 +24896,7 @@ class GodotDAPClient {
24892
24896
  initialized = false;
24893
24897
  attached = false;
24894
24898
  lastThreadId = 1;
24895
- stopped = false;
24899
+ halt = null;
24896
24900
  breakpoints = new Map;
24897
24901
  constructor(port = portFromEnv("GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT), host = "127.0.0.1") {
24898
24902
  this.port = port;
@@ -24941,7 +24945,7 @@ class GodotDAPClient {
24941
24945
  this.connected = false;
24942
24946
  this.initialized = false;
24943
24947
  this.attached = false;
24944
- this.stopped = false;
24948
+ this.halt = null;
24945
24949
  this.socket = null;
24946
24950
  this.failPendingRequests(new Error("DAP connection closed"));
24947
24951
  });
@@ -24961,7 +24965,7 @@ class GodotDAPClient {
24961
24965
  this.connected = false;
24962
24966
  this.initialized = false;
24963
24967
  this.attached = false;
24964
- this.stopped = false;
24968
+ this.halt = null;
24965
24969
  return;
24966
24970
  }
24967
24971
  if (this.connected) {
@@ -24990,7 +24994,7 @@ class GodotDAPClient {
24990
24994
  this.connected = false;
24991
24995
  this.initialized = false;
24992
24996
  this.attached = false;
24993
- this.stopped = false;
24997
+ this.halt = null;
24994
24998
  }
24995
24999
  async ensureConnected() {
24996
25000
  if (!this.connected) {
@@ -25067,12 +25071,16 @@ class GodotDAPClient {
25067
25071
  if (typeof threadId === "number") {
25068
25072
  this.lastThreadId = threadId;
25069
25073
  }
25070
- this.stopped = true;
25074
+ this.halt = {
25075
+ reason: said(body, "reason"),
25076
+ description: said(body, "description"),
25077
+ text: said(body, "text")
25078
+ };
25071
25079
  return;
25072
25080
  }
25073
25081
  if (eventName === "terminated" || eventName === "exited") {
25074
25082
  this.attached = false;
25075
- this.stopped = false;
25083
+ this.halt = null;
25076
25084
  }
25077
25085
  }
25078
25086
  async initialize() {
@@ -25146,7 +25154,7 @@ class GodotDAPClient {
25146
25154
  await this.attach();
25147
25155
  const resolvedThreadId = await this.resolveThreadId(threadId);
25148
25156
  await this.sendRequest("continue", { threadId: resolvedThreadId });
25149
- this.stopped = false;
25157
+ this.halt = null;
25150
25158
  }
25151
25159
  async stepOver(threadId) {
25152
25160
  await this.attach();
@@ -25217,7 +25225,10 @@ class GodotDAPClient {
25217
25225
  return this.connected;
25218
25226
  }
25219
25227
  isStopped() {
25220
- return this.stopped;
25228
+ return this.halt !== null;
25229
+ }
25230
+ whereItStopped() {
25231
+ return this.halt;
25221
25232
  }
25222
25233
  async resolveThreadId(threadId) {
25223
25234
  if (typeof threadId === "number" && threadId > 0) {
@@ -25246,7 +25257,7 @@ class GodotDAPClient {
25246
25257
  this.connected = false;
25247
25258
  this.initialized = false;
25248
25259
  this.attached = false;
25249
- this.stopped = false;
25260
+ this.halt = null;
25250
25261
  this.reader = new FrameReader;
25251
25262
  socket?.destroy();
25252
25263
  this.failPendingRequests(new Error(`Godot DAP ${detail}. The connection was dropped.`));
@@ -25402,6 +25413,16 @@ class GameLog {
25402
25413
  this.entries.push(entry);
25403
25414
  this.lastHeadline = headline ? { index: entry.index, detail } : null;
25404
25415
  }
25416
+ record(severity, text) {
25417
+ this.entries.push({
25418
+ index: this.entries.length,
25419
+ severity,
25420
+ source: "debugger",
25421
+ text,
25422
+ detail: []
25423
+ });
25424
+ this.lastHeadline = null;
25425
+ }
25405
25426
  get all() {
25406
25427
  return this.entries;
25407
25428
  }
@@ -27557,6 +27578,20 @@ function runtimeRequest(endpoint, command, params, timeoutMs) {
27557
27578
  });
27558
27579
  }
27559
27580
 
27581
+ // src/setup.ts
27582
+ import { cpSync, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync7, rmSync as rmSync3, writeFileSync as writeFileSync4 } from "node:fs";
27583
+ import { dirname as dirname3, join as join8 } from "node:path";
27584
+ var ADDONS = ["gdharness_editor", "gdharness_runtime", "auto_reload"];
27585
+ var VERSION_MARKER = ".gdharness-version";
27586
+ function installedAddonVersion(projectPath) {
27587
+ const marker = join8(projectPath, "addons", ADDONS[0], VERSION_MARKER);
27588
+ if (!existsSync6(marker)) {
27589
+ return null;
27590
+ }
27591
+ const said = readFileSync7(marker, "utf8").trim();
27592
+ return said === "" ? null : said;
27593
+ }
27594
+
27560
27595
  // src/tool-definitions.ts
27561
27596
  var PROJECT_PATH = {
27562
27597
  type: "string",
@@ -28230,7 +28265,10 @@ var TOOL_SPECS = [
28230
28265
  type: "string",
28231
28266
  description: "tree, find: where to start, default /root. rect: the node to place. property: the node to read."
28232
28267
  },
28233
- property: { type: "string", description: "property: which one to read." },
28268
+ property: {
28269
+ type: "string",
28270
+ 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."
28271
+ },
28234
28272
  depth: { type: "number", description: "tree: levels to descend. Default 3." },
28235
28273
  includeProperties: {
28236
28274
  type: "boolean",
@@ -28257,7 +28295,7 @@ var TOOL_SPECS = [
28257
28295
  operations: {
28258
28296
  tree: { summary: "the live scene tree", requires: [] },
28259
28297
  find: {
28260
- summary: "the paths of every node matching className, script, namePattern or group",
28298
+ summary: "the paths of every node matching className, script, namePattern or group, with property read off each",
28261
28299
  requires: []
28262
28300
  },
28263
28301
  rect: {
@@ -28472,7 +28510,7 @@ function buildToolDefinitions() {
28472
28510
  var UPDATE_NOTICE_EVERY = 500;
28473
28511
  var FEEDBACK_NOTICE_EVERY = 250;
28474
28512
  var run3 = promisify3(execFile3);
28475
- var __dirname2 = dirname3(fileURLToPath2(import.meta.url));
28513
+ var __dirname2 = dirname4(fileURLToPath2(import.meta.url));
28476
28514
  var EDITOR_RESTART_TIMEOUT_MS = 90000;
28477
28515
  var BRIDGE_RETRY_MS = 2000;
28478
28516
  var PATH_SOLUTIONS = [
@@ -28550,11 +28588,11 @@ function realPathOr(path) {
28550
28588
  }
28551
28589
  }
28552
28590
  function hasMainScene(projectFile) {
28553
- const scene = parseProjectGodot(readFileSync7(projectFile, "utf8"))["application"]?.["run/main_scene"];
28591
+ const scene = parseProjectGodot(readFileSync8(projectFile, "utf8"))["application"]?.["run/main_scene"];
28554
28592
  return typeof scene === "string" && scene !== "";
28555
28593
  }
28556
28594
  function editorPlaysHeadless(projectFile) {
28557
- const runArgs = parseProjectGodot(readFileSync7(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
28595
+ const runArgs = parseProjectGodot(readFileSync8(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
28558
28596
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
28559
28597
  }
28560
28598
  function camelCased(params) {
@@ -28570,7 +28608,7 @@ function camelCased(params) {
28570
28608
  class GodotServer {
28571
28609
  mcp;
28572
28610
  locator = new GodotLocator;
28573
- operationsScript = join8(__dirname2, "godot", "operations", "godot_operations.gd");
28611
+ operationsScript = join9(__dirname2, "godot", "operations", "godot_operations.gd");
28574
28612
  godotBridge;
28575
28613
  tools = buildToolDefinitions();
28576
28614
  activeProcess = null;
@@ -28808,28 +28846,44 @@ class GodotServer {
28808
28846
  if (this.callsSinceNotice < UPDATE_NOTICE_EVERY && this.noticedUpdate) {
28809
28847
  return answer;
28810
28848
  }
28849
+ const moved = this.projectHasMovedOn();
28850
+ if (moved !== null) {
28851
+ this.noticedUpdate = true;
28852
+ this.callsSinceNotice = 0;
28853
+ return this.saying(answer, {
28854
+ project_upgraded_under_this_server: {
28855
+ server_is: SERVER_VERSION,
28856
+ project_is: moved,
28857
+ what_to_do: "The project was upgraded while this server has been running, so it is still answering " + "as the version it was started as. Tell the user to reconnect the MCP server, which is " + "the only thing that replaces it: a server cannot restart itself."
28858
+ }
28859
+ });
28860
+ }
28811
28861
  const notice = this.updates.notice();
28812
28862
  if (notice === null) {
28813
28863
  return answer;
28814
28864
  }
28815
28865
  this.noticedUpdate = true;
28816
28866
  this.callsSinceNotice = 0;
28867
+ return this.saying(answer, {
28868
+ update_available: {
28869
+ ...notice,
28870
+ 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."
28871
+ }
28872
+ });
28873
+ }
28874
+ saying(answer, block) {
28817
28875
  return {
28818
28876
  ...answer,
28819
- content: [
28820
- ...answer.content,
28821
- {
28822
- type: "text",
28823
- text: JSON.stringify({
28824
- update_available: {
28825
- ...notice,
28826
- 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."
28827
- }
28828
- }, null, 2)
28829
- }
28830
- ]
28877
+ content: [...answer.content, { type: "text", text: JSON.stringify(block, null, 2) }]
28831
28878
  };
28832
28879
  }
28880
+ projectHasMovedOn() {
28881
+ if (this.ownProject === null) {
28882
+ return null;
28883
+ }
28884
+ const installed = installedAddonVersion(this.ownProject);
28885
+ return installed === null || installed === SERVER_VERSION ? null : installed;
28886
+ }
28833
28887
  withFeedbackNotice(answer) {
28834
28888
  this.callsSinceFeedback += 1;
28835
28889
  if (this.callsSinceFeedback < FEEDBACK_NOTICE_EVERY) {
@@ -29075,8 +29129,8 @@ class GodotServer {
29075
29129
  if (path === undefined) {
29076
29130
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
29077
29131
  }
29078
- const file = join8(path, "project.godot");
29079
- if (!existsSync6(file)) {
29132
+ const file = join9(path, "project.godot");
29133
+ if (!existsSync7(file)) {
29080
29134
  return {
29081
29135
  ok: false,
29082
29136
  response: this.createErrorResponse(`Not a Godot project: ${path}`, [
@@ -29221,7 +29275,7 @@ class GodotServer {
29221
29275
  if (!engine.ok) {
29222
29276
  return engine.response;
29223
29277
  }
29224
- const application = parseProjectGodot(readFileSync7(project.value.file, "utf8"))["application"] ?? {};
29278
+ const application = parseProjectGodot(readFileSync8(project.value.file, "utf8"))["application"] ?? {};
29225
29279
  const name = application["config/name"];
29226
29280
  const mainScene = application["run/main_scene"];
29227
29281
  const info = {
@@ -29310,7 +29364,7 @@ class GodotServer {
29310
29364
  log.finish();
29311
29365
  const problems = log.select({ severity: "warning", sinceLastCall: false, limit: 200 });
29312
29366
  const verdict = {
29313
- exported: exitCode === 0 && log.count("error") === 0 && existsSync6(output.absolutePath),
29367
+ exported: exitCode === 0 && log.count("error") === 0 && existsSync7(output.absolutePath),
29314
29368
  preset,
29315
29369
  outputPath: output.relativePath,
29316
29370
  debug,
@@ -29340,7 +29394,7 @@ class GodotServer {
29340
29394
  return contained.response;
29341
29395
  }
29342
29396
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
29343
- if (!existsSync6(join8(project.value.path, runner))) {
29397
+ if (!existsSync7(join9(project.value.path, runner))) {
29344
29398
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
29345
29399
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
29346
29400
  ]);
@@ -29373,7 +29427,7 @@ class GodotServer {
29373
29427
  ];
29374
29428
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
29375
29429
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
29376
- const userData = mkdtempSync2(join8(tmpdir4(), "gdharness-tests-"));
29430
+ const userData = mkdtempSync2(join9(tmpdir4(), "gdharness-tests-"));
29377
29431
  const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
29378
29432
  const hung = await new Promise((resolve) => {
29379
29433
  const timer = setTimeout(() => {
@@ -29389,20 +29443,20 @@ class GodotServer {
29389
29443
  resolve(false);
29390
29444
  });
29391
29445
  });
29392
- const reportsDir = join8(project.value.path, ".godot", "gdharness-reports");
29446
+ const reportsDir = join9(project.value.path, ".godot", "gdharness-reports");
29393
29447
  let report = null;
29394
29448
  let reportProblem = null;
29395
29449
  try {
29396
- const written = existsSync6(reportsDir) ? readdirSync5(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
29450
+ const written = existsSync7(reportsDir) ? readdirSync5(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
29397
29451
  const newest = written.at(-1);
29398
29452
  if (newest !== undefined) {
29399
- report = parseJUnit(readFileSync7(join8(reportsDir, newest, "results.xml"), "utf8"));
29453
+ report = parseJUnit(readFileSync8(join9(reportsDir, newest, "results.xml"), "utf8"));
29400
29454
  }
29401
29455
  } catch (error) {
29402
29456
  reportProblem = errorMessage(error);
29403
29457
  } finally {
29404
- rmSync3(reportsDir, { recursive: true, force: true });
29405
- rmSync3(userData, { recursive: true, force: true });
29458
+ rmSync4(reportsDir, { recursive: true, force: true });
29459
+ rmSync4(userData, { recursive: true, force: true });
29406
29460
  }
29407
29461
  const engineEntries = run.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries;
29408
29462
  const verdicts = {
@@ -29516,6 +29570,7 @@ class GodotServer {
29516
29570
  staleNote: stale ? addonMismatch(status.addonVersion, SERVER_VERSION) : undefined,
29517
29571
  retryingBridge: this.bridgeRetry === null ? undefined : true,
29518
29572
  announcedAt: this.announcedAt ?? undefined,
29573
+ projectIs: this.projectHasMovedOn() ?? undefined,
29519
29574
  note: isPortConflict ? "Bridge port is already in use. Another gdharness instance owns the editor bridge, so this server cannot reach the editor. Usually the server this one replaced, still on its way out." : undefined,
29520
29575
  suggestion: isPortConflict ? `This server is asking for the port again every ${BRIDGE_RETRY_MS / 1000}s and takes it the moment the other one lets go, so editor tools come back on their own. Ask again, or end the older gdharness process to have it now.` : undefined
29521
29576
  };
@@ -29728,7 +29783,8 @@ class GodotServer {
29728
29783
  log,
29729
29784
  startedAt: Date.now(),
29730
29785
  exitCode: null,
29731
- throughEditor: true
29786
+ throughEditor: true,
29787
+ brokeOn: null
29732
29788
  };
29733
29789
  this.activeProcess = played;
29734
29790
  return this.jsonTextResponse({
@@ -29748,6 +29804,15 @@ class GodotServer {
29748
29804
  `) ? line : `${line}
29749
29805
  `);
29750
29806
  }
29807
+ this.recordWhatItBrokeOn(game);
29808
+ }
29809
+ recordWhatItBrokeOn(game) {
29810
+ const halt = this.dapClient?.whereItStopped() ?? null;
29811
+ if (halt?.reason !== "exception" || halt.text === "" || game.brokeOn === halt.text) {
29812
+ return;
29813
+ }
29814
+ game.brokeOn = halt.text;
29815
+ game.log.record("error", halt.text);
29751
29816
  }
29752
29817
  async endActiveGame() {
29753
29818
  const running = this.activeProcess;
@@ -29769,7 +29834,8 @@ class GodotServer {
29769
29834
  log,
29770
29835
  startedAt: Date.now(),
29771
29836
  exitCode: null,
29772
- throughEditor: false
29837
+ throughEditor: false,
29838
+ brokeOn: null
29773
29839
  };
29774
29840
  child.stdout.on("data", (data) => {
29775
29841
  log.append("stdout", data);
@@ -29834,6 +29900,7 @@ class GodotServer {
29834
29900
  contains: readNonEmptyString(args, "contains"),
29835
29901
  limit: readPositiveNumber(args, "limit") ?? 200
29836
29902
  });
29903
+ const halt = this.activeProcess.throughEditor ? this.dapClient?.whereItStopped() ?? null : null;
29837
29904
  return this.jsonTextResponse({
29838
29905
  running: this.activeProcess.exitCode === null,
29839
29906
  exitCode: this.activeProcess.exitCode,
@@ -29842,6 +29909,7 @@ class GodotServer {
29842
29909
  errors: this.activeProcess.log.count("error"),
29843
29910
  warnings: this.activeProcess.log.count("warning"),
29844
29911
  clean: this.activeProcess.log.count("error") === 0,
29912
+ heldAt: halt,
29845
29913
  omitted: selected.omitted,
29846
29914
  entries: selected.entries
29847
29915
  });
@@ -29911,8 +29979,8 @@ class GodotServer {
29911
29979
  return this.createErrorResponse(choice.problem);
29912
29980
  }
29913
29981
  const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
29914
- const screenshotDir = expectsScreenshot ? mkdtempSync2(join8(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
29915
- const screenshotPath = screenshotDir ? join8(screenshotDir, "capture.png") : null;
29982
+ const screenshotDir = expectsScreenshot ? mkdtempSync2(join9(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
29983
+ const screenshotPath = screenshotDir ? join9(screenshotDir, "capture.png") : null;
29916
29984
  try {
29917
29985
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
29918
29986
  if (!reply.ok) {
@@ -29933,12 +30001,12 @@ class GodotServer {
29933
30001
  return {
29934
30002
  content: [
29935
30003
  { type: "text", text: `Screenshot captured: ${dimensions}` },
29936
- { type: "image", data: readFileSync7(screenshotPath).toString("base64"), mimeType: "image/png" }
30004
+ { type: "image", data: readFileSync8(screenshotPath).toString("base64"), mimeType: "image/png" }
29937
30005
  ]
29938
30006
  };
29939
30007
  } finally {
29940
30008
  if (screenshotDir) {
29941
- rmSync3(screenshotDir, { recursive: true, force: true });
30009
+ rmSync4(screenshotDir, { recursive: true, force: true });
29942
30010
  }
29943
30011
  }
29944
30012
  }
@@ -29959,11 +30027,13 @@ class GodotServer {
29959
30027
  if (Object.keys(filters).length === 0) {
29960
30028
  return this.createErrorResponse("runtime_inspect find needs at least one of className, script, namePattern, group.");
29961
30029
  }
30030
+ const property = readNonEmptyString(args, "property");
29962
30031
  return await this.handleRuntimeCommand("find_nodes", {
29963
30032
  ...filters,
29964
30033
  projectPath: args["projectPath"],
29965
30034
  root: readNonEmptyString(args, "nodePath") ?? "/root",
29966
- limit: readPositiveNumber(args, "limit") ?? 100
30035
+ limit: readPositiveNumber(args, "limit") ?? 100,
30036
+ ...property === undefined ? {} : { property }
29967
30037
  });
29968
30038
  }
29969
30039
  async handleRuntimeWait(op, args) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.5.9",
3
+ "version": "0.5.11",
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",