gdharness 1.0.14 → 1.0.16

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 +109 -25
  2. package/build/index.js +99 -21
  3. package/package.json +1 -1
package/build/cli.js CHANGED
@@ -7187,6 +7187,19 @@ function alongside(execPath, name) {
7187
7187
  }
7188
7188
  var init_runner = () => {};
7189
7189
 
7190
+ // src/call-signal.ts
7191
+ import { AsyncLocalStorage } from "node:async_hooks";
7192
+ function withCallSignal(signal, answer) {
7193
+ return signal === undefined ? answer() : current.run(signal, answer);
7194
+ }
7195
+ function callSignal() {
7196
+ return current.getStore();
7197
+ }
7198
+ var current;
7199
+ var init_call_signal = __esm(() => {
7200
+ current = new AsyncLocalStorage;
7201
+ });
7202
+
7190
7203
  // src/dictionary.ts
7191
7204
  function emptyRecord() {
7192
7205
  return Object.create(null);
@@ -7781,14 +7794,7 @@ function reason2(stdout, stderr) {
7781
7794
  async function runImport(godotPath, projectPath) {
7782
7795
  const logDir = mkdtempSync2(join7(tmpdir3(), "gdharness-import-"));
7783
7796
  try {
7784
- const { stderr } = await run3(godotPath, [
7785
- "--headless",
7786
- "--log-file",
7787
- join7(logDir, "engine.log"),
7788
- "--path",
7789
- projectPath,
7790
- "--import"
7791
- ]);
7797
+ const { stderr } = await run3(godotPath, ["--headless", "--log-file", join7(logDir, "engine.log"), "--path", projectPath, "--import"], { signal: callSignal() });
7792
7798
  return { ok: true, messages: problems2(stderr) };
7793
7799
  } catch (error) {
7794
7800
  if (error instanceof Error && "stdout" in error && "stderr" in error) {
@@ -7827,7 +7833,7 @@ async function runOperation2(engine, operation, params, projectPath) {
7827
7833
  let stdout;
7828
7834
  let stderr;
7829
7835
  try {
7830
- ({ stdout, stderr } = await run3(engine.godotPath, args));
7836
+ ({ stdout, stderr } = await run3(engine.godotPath, args, { signal: callSignal() }));
7831
7837
  } catch (error) {
7832
7838
  if (error instanceof Error && "stdout" in error && "stderr" in error) {
7833
7839
  const failed = error;
@@ -7857,6 +7863,7 @@ async function runOperation2(engine, operation, params, projectPath) {
7857
7863
  }
7858
7864
  var run3;
7859
7865
  var init_headless = __esm(() => {
7866
+ init_call_signal();
7860
7867
  init_game_log();
7861
7868
  init_scratch();
7862
7869
  run3 = promisify3(execFile3);
@@ -16398,7 +16405,11 @@ var init_tool_definitions = __esm(() => {
16398
16405
  description: "viewport: the Viewport node. Default the root viewport."
16399
16406
  },
16400
16407
  width: { type: "number", description: "Scale the image to this width." },
16401
- height: { type: "number", description: "Scale the image to this height." }
16408
+ height: { type: "number", description: "Scale the image to this height." },
16409
+ outputPath: {
16410
+ type: "string",
16411
+ description: "Also save the picture as a PNG at this absolute path, for somebody to open later; the answer says where. Refused unless it ends in .png, its directory exists, it is outside the project, and no file is there already."
16412
+ }
16402
16413
  },
16403
16414
  requires: [],
16404
16415
  operations: {
@@ -16421,7 +16432,7 @@ var init_tool_definitions = __esm(() => {
16421
16432
  action: {
16422
16433
  type: "string",
16423
16434
  ops: ["action"],
16424
- description: "action: the InputMap action name. An engine dialog is not answered this way: AcceptDialog reads the Escape key itself and never asks the InputMap, so ui_cancel goes in and the question stays up. Dismiss one with key Escape, or click its button."
16435
+ description: "action: the InputMap action name. An engine dialog is not answered this way: AcceptDialog reads the Escape key itself and never asks the InputMap, so ui_cancel goes in and the question stays up. Dismiss one with the key op and keycode Escape, or click its button."
16425
16436
  },
16426
16437
  pressed: {
16427
16438
  type: "boolean",
@@ -16524,7 +16535,11 @@ var init_tool_definitions = __esm(() => {
16524
16535
  ops: ["frames"],
16525
16536
  description: "frames: how many to let pass, 1 to 600. More than that is refused."
16526
16537
  },
16527
- nodePath: { type: "string", ops: ["signal", "until"], description: "signal, until: the node." },
16538
+ nodePath: {
16539
+ type: "string",
16540
+ ops: ["signal", "until"],
16541
+ description: "signal, until: the node. Needed for a signal and for a property; for says it defaults to /root, the whole screen."
16542
+ },
16528
16543
  signal: { type: "string", ops: ["signal"], description: "signal: the signal name." },
16529
16544
  property: {
16530
16545
  type: "string",
@@ -16535,7 +16550,7 @@ var init_tool_definitions = __esm(() => {
16535
16550
  says: {
16536
16551
  type: "string",
16537
16552
  ops: ["until"],
16538
- description: "until: wait for these words to appear anywhere under nodePath instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property."
16553
+ description: "until: wait for these words to appear anywhere under nodePath, or anywhere in the game when no nodePath is given, instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property."
16539
16554
  },
16540
16555
  includeHidden: {
16541
16556
  type: "boolean",
@@ -16557,7 +16572,7 @@ var init_tool_definitions = __esm(() => {
16557
16572
  },
16558
16573
  until: {
16559
16574
  summary: "wait for a property to read as a value, or for words to appear under a node, and answer with what it found",
16560
- requires: ["nodePath"]
16575
+ requires: []
16561
16576
  }
16562
16577
  }
16563
16578
  },
@@ -38025,6 +38040,8 @@ import { randomUUID as randomUUID2 } from "node:crypto";
38025
38040
  import {
38026
38041
  appendFileSync,
38027
38042
  closeSync as closeSync2,
38043
+ constants,
38044
+ copyFileSync,
38028
38045
  existsSync as existsSync15,
38029
38046
  fstatSync,
38030
38047
  mkdirSync as mkdirSync13,
@@ -38037,10 +38054,34 @@ import {
38037
38054
  statSync as statSync8
38038
38055
  } from "node:fs";
38039
38056
  import { tmpdir as tmpdir5 } from "node:os";
38040
- import { basename as basename4, dirname as dirname15, isAbsolute as isAbsolute3, join as join22, normalize as normalize3, relative as relative3 } from "node:path";
38057
+ import { basename as basename4, dirname as dirname15, extname as extname2, isAbsolute as isAbsolute3, join as join22, normalize as normalize3, relative as relative3 } from "node:path";
38041
38058
  import { setTimeout as delay3 } from "node:timers/promises";
38042
38059
  import { fileURLToPath as fileURLToPath5 } from "node:url";
38043
38060
  import { promisify as promisify7 } from "node:util";
38061
+ function captureDestinationRefusal(outputPath, projects) {
38062
+ if (/^(res|user):\/\//.test(outputPath)) {
38063
+ return `outputPath ${outputPath} is a Godot path; a capture is saved outside the project, at an absolute path on this machine.`;
38064
+ }
38065
+ if (!isAbsolute3(outputPath)) {
38066
+ return `outputPath ${outputPath} is not absolute; name the whole path, so where it is written does not depend on this server's directory.`;
38067
+ }
38068
+ if (extname2(outputPath).toLowerCase() !== ".png") {
38069
+ return `outputPath ${outputPath} does not end in .png, which is what a capture is.`;
38070
+ }
38071
+ const directory = realPathOr(dirname15(outputPath));
38072
+ if (!existsSync15(directory)) {
38073
+ return `outputPath's directory ${dirname15(outputPath)} does not exist; a capture creates no directories.`;
38074
+ }
38075
+ const where = join22(directory, basename4(outputPath));
38076
+ const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
38077
+ if (inside !== undefined) {
38078
+ return `outputPath ${outputPath} is inside the project at ${inside}; a capture is saved outside it, so it never becomes part of the game.`;
38079
+ }
38080
+ if (existsSync15(where)) {
38081
+ return `outputPath ${outputPath} already exists; a capture is not written over a file. Name a new one.`;
38082
+ }
38083
+ return null;
38084
+ }
38044
38085
  function patienceForFrames(frames, atLeast) {
38045
38086
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
38046
38087
  }
@@ -38557,7 +38598,7 @@ class GodotServer {
38557
38598
  }
38558
38599
  setupToolHandlers() {
38559
38600
  this.mcp.server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: this.tools }));
38560
- this.mcp.server.setRequestHandler(CallToolRequestSchema, async (request) => {
38601
+ this.mcp.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
38561
38602
  this.logDebug(`Handling tool request: ${request.params.name}`);
38562
38603
  const spec = toolSpec(request.params.name);
38563
38604
  if (!spec) {
@@ -38572,7 +38613,7 @@ class GodotServer {
38572
38613
  this.lastProjectPath = args["projectPath"];
38573
38614
  }
38574
38615
  this.updates.refresh();
38575
- const answer = await this.answered(spec.name, checked.op ?? "", args);
38616
+ const answer = await withCallSignal(extra.signal, () => this.answered(spec.name, checked.op ?? "", args));
38576
38617
  return this.withFeedbackNotice(this.withUpdateNotice(answer));
38577
38618
  });
38578
38619
  }
@@ -39343,7 +39384,10 @@ class GodotServer {
39343
39384
  const log = new GameLog;
39344
39385
  let exitCode = 0;
39345
39386
  try {
39346
- const { stdout, stderr } = await run7(engine.value, exportArgs, { timeout: 300000 });
39387
+ const { stdout, stderr } = await run7(engine.value, exportArgs, {
39388
+ timeout: 300000,
39389
+ signal: callSignal()
39390
+ });
39347
39391
  log.append("stdout", stdout);
39348
39392
  log.append("stderr", stderr);
39349
39393
  } catch (error) {
@@ -40359,6 +40403,23 @@ class GodotServer {
40359
40403
  }
40360
40404
  spawnGame(godotPath, cmdArgs, env) {
40361
40405
  const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
40406
+ const cancelled = callSignal();
40407
+ if (cancelled !== undefined) {
40408
+ const end = () => {
40409
+ if (child.exitCode === null && child.signalCode === null) {
40410
+ this.logDebug(`The call waiting on pid ${child.pid ?? "unknown"} was cancelled; ending it.`);
40411
+ child.kill();
40412
+ }
40413
+ };
40414
+ if (cancelled.aborted) {
40415
+ end();
40416
+ } else {
40417
+ cancelled.addEventListener("abort", end, { once: true });
40418
+ child.once("exit", () => {
40419
+ cancelled.removeEventListener("abort", end);
40420
+ });
40421
+ }
40422
+ }
40362
40423
  const log = new GameLog;
40363
40424
  const started = {
40364
40425
  process: child,
@@ -41333,7 +41394,7 @@ class GodotServer {
41333
41394
  return `The game this server started (${which}) is still coming up and has not announced its runtime after a wait of ${Math.min(budgetMs, remaining)}ms, so there is nothing to talk to yet. It is still running: ask again in a moment rather than starting another.`;
41334
41395
  }
41335
41396
  async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
41336
- const { op: _op, projectPath, pid, ...params } = asParams(args);
41397
+ const { op: _op, projectPath, pid, outputPath, ...params } = asParams(args);
41337
41398
  const waited = typeof pid === "number" ? null : await this.ownRunOnItsWay(typeof projectPath === "string" ? projectPath : this.ownProject, timeoutMs);
41338
41399
  if (waited !== null) {
41339
41400
  return this.createErrorResponse(waited, [
@@ -41360,6 +41421,16 @@ class GodotServer {
41360
41421
  ]);
41361
41422
  }
41362
41423
  const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
41424
+ const saveTo = expectsScreenshot && typeof outputPath === "string" ? outputPath : null;
41425
+ if (saveTo !== null) {
41426
+ const refusal = captureDestinationRefusal(saveTo, [
41427
+ choice.endpoint.project.path,
41428
+ ...this.ownProject === null ? [] : [this.ownProject]
41429
+ ]);
41430
+ if (refusal !== null) {
41431
+ return this.createErrorResponse(refusal);
41432
+ }
41433
+ }
41363
41434
  const screenshotDir = expectsScreenshot ? mkdtempSync3(join22(tmpdir5(), "gdharness-runtime-screenshot-")) : null;
41364
41435
  const screenshotPath = screenshotDir ? join22(screenshotDir, "capture.png") : null;
41365
41436
  try {
@@ -41379,11 +41450,18 @@ class GodotServer {
41379
41450
  return this.createErrorResponse(`Rejected screenshot file path outside the managed capture path: '${returnedPath}'`);
41380
41451
  }
41381
41452
  const dimensions = `${readNumber(payload, "width") ?? 0}x${readNumber(payload, "height") ?? 0} ${readString(payload, "format") ?? "unknown"}`;
41453
+ if (saveTo !== null) {
41454
+ try {
41455
+ copyFileSync(screenshotPath, saveTo, constants.COPYFILE_EXCL);
41456
+ } catch (error) {
41457
+ return this.createErrorResponse(`The capture was taken and could not be saved at ${saveTo}: ${errorMessage(error)}`);
41458
+ }
41459
+ }
41382
41460
  return {
41383
41461
  content: [
41384
41462
  {
41385
41463
  type: "text",
41386
- text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}`
41464
+ text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${saveTo === null ? "" : `. Saved to ${saveTo}`}`
41387
41465
  },
41388
41466
  { type: "image", data: readFileSync20(screenshotPath).toString("base64"), mimeType: "image/png" }
41389
41467
  ]
@@ -41430,7 +41508,11 @@ class GodotServer {
41430
41508
  return await this.handleRuntimeCommand("wait_frames", { ...whichGame(args), frames }, waited);
41431
41509
  }
41432
41510
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 5000;
41433
- const nodePath = readNonEmptyString(args, "nodePath") ?? "";
41511
+ const asked = readNonEmptyString(args, "nodePath");
41512
+ if (asked === undefined && op === "until" && readNonEmptyString(args, "says") === undefined) {
41513
+ return this.createErrorResponse("runtime_wait until needs nodePath to wait for a property: the property belongs to that node.", ["With says instead of property, nodePath can be left out and the whole game is searched"]);
41514
+ }
41515
+ const nodePath = asked ?? "/root";
41434
41516
  const patience = Math.max(this.runtimeTimeoutMs(), timeoutMs + 5000);
41435
41517
  return op === "signal" ? await this.handleRuntimeCommand("wait_signal", {
41436
41518
  ...whichGame(args),
@@ -41460,6 +41542,7 @@ var init_server3 = __esm(() => {
41460
41542
  init_boot_note();
41461
41543
  init_breakpoint_note();
41462
41544
  init_bridge_announce();
41545
+ init_call_signal();
41463
41546
  init_class_cache();
41464
41547
  init_class_note();
41465
41548
  init_config_pin();
@@ -42468,6 +42551,7 @@ function taken(harness, path, held, stripped) {
42468
42551
  }
42469
42552
 
42470
42553
  // src/headless.ts
42554
+ init_call_signal();
42471
42555
  init_game_log();
42472
42556
  init_scratch();
42473
42557
  import { execFile as execFile2 } from "node:child_process";
@@ -42532,7 +42616,7 @@ async function runOperation(engine, operation, params, projectPath) {
42532
42616
  let stdout;
42533
42617
  let stderr;
42534
42618
  try {
42535
- ({ stdout, stderr } = await run2(engine.godotPath, args));
42619
+ ({ stdout, stderr } = await run2(engine.godotPath, args, { signal: callSignal() }));
42536
42620
  } catch (error) {
42537
42621
  if (error instanceof Error && "stdout" in error && "stderr" in error) {
42538
42622
  const failed = error;
@@ -43098,12 +43182,12 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
43098
43182
  | What a property on an object a node holds reads | the same two, with colons: \`_game:clock:speed\` |
43099
43183
  | Which control holds the focus, or anything only a method answers | the same path with a step written as a call, \`get_viewport():gui_get_focus_owner\`, which calls a method taking no arguments and walks into what it returned |
43100
43184
  | Press a button | \`runtime_input click\`, which says what was under the pointer |
43101
- | Press the next thing after a click that changed screen | \`runtime_wait until\` with \`says\` and a word the new screen shows, then the click. A click sent straight after a screen change lands on a control the new screen is about to free, answers \`landed: true\`, and does nothing; a frame count is a number nobody can pick correctly across a cold start and a warm one, and waiting for the words answered in about 130ms where twenty frames had not been enough |
43185
+ | Press the next thing after a click that changed screen | \`runtime_wait until\` with \`says\` and a word the new screen shows (no \`nodePath\` needed: without one the whole game is searched), then the click. A click sent straight after a screen change lands on a control the new screen is about to free, answers \`landed: true\`, and does nothing; a frame count is a number nobody can pick correctly across a cold start and a warm one, and waiting for the words answered in about 130ms where twenty frames had not been enough |
43102
43186
  | Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\` with \`replace\`; a submitted field has to be clicked again |
43103
43187
  | Click somebody standing in a 3D room | \`runtime_input click\` on the Node3D, which aims at what it draws |
43104
43188
  | Pick something out of a dropdown | \`runtime_input choose\` on it, by what the item says, rather than a click and keys: the menu opens as a window of its own that an injected key never reaches, so Enter closes it with nothing chosen |
43105
- | Answer a dialog | \`runtime_input click\` on its button, or \`key\` Escape to dismiss it: an [AcceptDialog] reads that key itself and never asks the InputMap, so \`ui_cancel\` leaves it standing |
43106
- | Press a bound key | \`runtime_input action\` or \`key\`, a whole press unless you hold it |
43189
+ | Answer a dialog | \`runtime_input click\` on its button, or \`runtime_input key\` with \`keycode\` \`Escape\` to dismiss it: an [AcceptDialog] reads that key itself and never asks the InputMap, so \`ui_cancel\` leaves it standing |
43190
+ | Press a bound key | \`runtime_input action\` with the action's name, or \`runtime_input key\` with \`keycode\`, a whole press unless you hold it |
43107
43191
  | Where a 3D node is on screen | \`runtime_inspect\` \`rect\`, rather than unprojecting by hand |
43108
43192
  | Wait for something in a running game | \`runtime_wait\`, never a sleep; \`until\` with \`says\` for a panel that rebuilds its own labels. The words have to be shown: a button that exists hidden through an animation does not satisfy it until it appears, unless \`includeHidden\` says otherwise |
43109
43193
  | What every label on a screen says | \`runtime_inspect tree\` with \`properties\` naming \`text\`, which reads it off every node that has one; \`includeProperties\` is every stored property of every node and ran to seventy-six thousand characters for one row |
package/build/index.js CHANGED
@@ -10182,6 +10182,8 @@ import { randomUUID as randomUUID2 } from "node:crypto";
10182
10182
  import {
10183
10183
  appendFileSync,
10184
10184
  closeSync as closeSync2,
10185
+ constants,
10186
+ copyFileSync,
10185
10187
  existsSync as existsSync10,
10186
10188
  fstatSync,
10187
10189
  mkdirSync as mkdirSync10,
@@ -10194,7 +10196,7 @@ import {
10194
10196
  statSync as statSync7
10195
10197
  } from "node:fs";
10196
10198
  import { tmpdir as tmpdir4 } from "node:os";
10197
- import { basename as basename3, dirname as dirname10, isAbsolute as isAbsolute3, join as join16, normalize as normalize2, relative as relative3 } from "node:path";
10199
+ import { basename as basename3, dirname as dirname10, extname as extname2, isAbsolute as isAbsolute3, join as join16, normalize as normalize2, relative as relative3 } from "node:path";
10198
10200
  import { setTimeout as delay3 } from "node:timers/promises";
10199
10201
  import { fileURLToPath as fileURLToPath3 } from "node:url";
10200
10202
  import { promisify as promisify5 } from "node:util";
@@ -25728,6 +25730,16 @@ function withdrawBridge(path) {
25728
25730
  }
25729
25731
  }
25730
25732
 
25733
+ // src/call-signal.ts
25734
+ import { AsyncLocalStorage } from "node:async_hooks";
25735
+ var current = new AsyncLocalStorage;
25736
+ function withCallSignal(signal, answer) {
25737
+ return signal === undefined ? answer() : current.run(signal, answer);
25738
+ }
25739
+ function callSignal() {
25740
+ return current.getStore();
25741
+ }
25742
+
25731
25743
  // src/class-cache.ts
25732
25744
  import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync7, statSync as statSync2 } from "node:fs";
25733
25745
  import { join as join6 } from "node:path";
@@ -27752,14 +27764,7 @@ function reason(stdout, stderr) {
27752
27764
  async function runImport(godotPath, projectPath) {
27753
27765
  const logDir = mkdtempSync(join11(tmpdir3(), "gdharness-import-"));
27754
27766
  try {
27755
- const { stderr } = await run3(godotPath, [
27756
- "--headless",
27757
- "--log-file",
27758
- join11(logDir, "engine.log"),
27759
- "--path",
27760
- projectPath,
27761
- "--import"
27762
- ]);
27767
+ const { stderr } = await run3(godotPath, ["--headless", "--log-file", join11(logDir, "engine.log"), "--path", projectPath, "--import"], { signal: callSignal() });
27763
27768
  return { ok: true, messages: problems(stderr) };
27764
27769
  } catch (error) {
27765
27770
  if (error instanceof Error && "stdout" in error && "stderr" in error) {
@@ -27798,7 +27803,7 @@ async function runOperation(engine, operation, params, projectPath) {
27798
27803
  let stdout;
27799
27804
  let stderr;
27800
27805
  try {
27801
- ({ stdout, stderr } = await run3(engine.godotPath, args));
27806
+ ({ stdout, stderr } = await run3(engine.godotPath, args, { signal: callSignal() }));
27802
27807
  } catch (error) {
27803
27808
  if (error instanceof Error && "stdout" in error && "stderr" in error) {
27804
27809
  const failed = error;
@@ -30472,7 +30477,11 @@ var TOOL_SPECS = [
30472
30477
  description: "viewport: the Viewport node. Default the root viewport."
30473
30478
  },
30474
30479
  width: { type: "number", description: "Scale the image to this width." },
30475
- height: { type: "number", description: "Scale the image to this height." }
30480
+ height: { type: "number", description: "Scale the image to this height." },
30481
+ outputPath: {
30482
+ type: "string",
30483
+ description: "Also save the picture as a PNG at this absolute path, for somebody to open later; the answer says where. Refused unless it ends in .png, its directory exists, it is outside the project, and no file is there already."
30484
+ }
30476
30485
  },
30477
30486
  requires: [],
30478
30487
  operations: {
@@ -30495,7 +30504,7 @@ var TOOL_SPECS = [
30495
30504
  action: {
30496
30505
  type: "string",
30497
30506
  ops: ["action"],
30498
- description: "action: the InputMap action name. An engine dialog is not answered this way: AcceptDialog reads the Escape key itself and never asks the InputMap, so ui_cancel goes in and the question stays up. Dismiss one with key Escape, or click its button."
30507
+ description: "action: the InputMap action name. An engine dialog is not answered this way: AcceptDialog reads the Escape key itself and never asks the InputMap, so ui_cancel goes in and the question stays up. Dismiss one with the key op and keycode Escape, or click its button."
30499
30508
  },
30500
30509
  pressed: {
30501
30510
  type: "boolean",
@@ -30598,7 +30607,11 @@ var TOOL_SPECS = [
30598
30607
  ops: ["frames"],
30599
30608
  description: "frames: how many to let pass, 1 to 600. More than that is refused."
30600
30609
  },
30601
- nodePath: { type: "string", ops: ["signal", "until"], description: "signal, until: the node." },
30610
+ nodePath: {
30611
+ type: "string",
30612
+ ops: ["signal", "until"],
30613
+ description: "signal, until: the node. Needed for a signal and for a property; for says it defaults to /root, the whole screen."
30614
+ },
30602
30615
  signal: { type: "string", ops: ["signal"], description: "signal: the signal name." },
30603
30616
  property: {
30604
30617
  type: "string",
@@ -30609,7 +30622,7 @@ var TOOL_SPECS = [
30609
30622
  says: {
30610
30623
  type: "string",
30611
30624
  ops: ["until"],
30612
- description: "until: wait for these words to appear anywhere under nodePath instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property."
30625
+ description: "until: wait for these words to appear anywhere under nodePath, or anywhere in the game when no nodePath is given, instead of for a property, which is how a panel that rebuilds its labels is waited on at all: the labels are named afresh each redraw and the panel is what stays put. Case-insensitive, part of a line, on a node the player can see unless includeHidden says otherwise; a label with a line break is matched with the break in the words, and a backslash followed by n counts as one. Instead of, not as well as: a call carrying this and a property is refused, because they ask about different things and answering one of them silently is how a caller watches a screen believing they are watching a property."
30613
30626
  },
30614
30627
  includeHidden: {
30615
30628
  type: "boolean",
@@ -30631,7 +30644,7 @@ var TOOL_SPECS = [
30631
30644
  },
30632
30645
  until: {
30633
30646
  summary: "wait for a property to read as a value, or for words to appear under a node, and answer with what it found",
30634
- requires: ["nodePath"]
30647
+ requires: []
30635
30648
  }
30636
30649
  }
30637
30650
  },
@@ -30733,6 +30746,30 @@ var __dirname2 = dirname10(fileURLToPath3(import.meta.url));
30733
30746
  var EDITOR_RESTART_TIMEOUT_MS = 90000;
30734
30747
  var HOLD_PING_MS = 2000;
30735
30748
  var SLOWEST_FRAME_RATE = 20;
30749
+ function captureDestinationRefusal(outputPath, projects) {
30750
+ if (/^(res|user):\/\//.test(outputPath)) {
30751
+ return `outputPath ${outputPath} is a Godot path; a capture is saved outside the project, at an absolute path on this machine.`;
30752
+ }
30753
+ if (!isAbsolute3(outputPath)) {
30754
+ return `outputPath ${outputPath} is not absolute; name the whole path, so where it is written does not depend on this server's directory.`;
30755
+ }
30756
+ if (extname2(outputPath).toLowerCase() !== ".png") {
30757
+ return `outputPath ${outputPath} does not end in .png, which is what a capture is.`;
30758
+ }
30759
+ const directory = realPathOr(dirname10(outputPath));
30760
+ if (!existsSync10(directory)) {
30761
+ return `outputPath's directory ${dirname10(outputPath)} does not exist; a capture creates no directories.`;
30762
+ }
30763
+ const where = join16(directory, basename3(outputPath));
30764
+ const inside = projects.find((project) => project !== "" && isWithinRoot(realPathOr(project), where));
30765
+ if (inside !== undefined) {
30766
+ return `outputPath ${outputPath} is inside the project at ${inside}; a capture is saved outside it, so it never becomes part of the game.`;
30767
+ }
30768
+ if (existsSync10(where)) {
30769
+ return `outputPath ${outputPath} already exists; a capture is not written over a file. Name a new one.`;
30770
+ }
30771
+ return null;
30772
+ }
30736
30773
  function patienceForFrames(frames, atLeast) {
30737
30774
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
30738
30775
  }
@@ -31295,7 +31332,7 @@ class GodotServer {
31295
31332
  }
31296
31333
  setupToolHandlers() {
31297
31334
  this.mcp.server.setRequestHandler(ListToolsRequestSchema, () => ({ tools: this.tools }));
31298
- this.mcp.server.setRequestHandler(CallToolRequestSchema, async (request) => {
31335
+ this.mcp.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
31299
31336
  this.logDebug(`Handling tool request: ${request.params.name}`);
31300
31337
  const spec = toolSpec(request.params.name);
31301
31338
  if (!spec) {
@@ -31310,7 +31347,7 @@ class GodotServer {
31310
31347
  this.lastProjectPath = args["projectPath"];
31311
31348
  }
31312
31349
  this.updates.refresh();
31313
- const answer = await this.answered(spec.name, checked.op ?? "", args);
31350
+ const answer = await withCallSignal(extra.signal, () => this.answered(spec.name, checked.op ?? "", args));
31314
31351
  return this.withFeedbackNotice(this.withUpdateNotice(answer));
31315
31352
  });
31316
31353
  }
@@ -32081,7 +32118,10 @@ class GodotServer {
32081
32118
  const log = new GameLog;
32082
32119
  let exitCode = 0;
32083
32120
  try {
32084
- const { stdout, stderr } = await run5(engine.value, exportArgs, { timeout: 300000 });
32121
+ const { stdout, stderr } = await run5(engine.value, exportArgs, {
32122
+ timeout: 300000,
32123
+ signal: callSignal()
32124
+ });
32085
32125
  log.append("stdout", stdout);
32086
32126
  log.append("stderr", stderr);
32087
32127
  } catch (error) {
@@ -33097,6 +33137,23 @@ class GodotServer {
33097
33137
  }
33098
33138
  spawnGame(godotPath, cmdArgs, env) {
33099
33139
  const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
33140
+ const cancelled = callSignal();
33141
+ if (cancelled !== undefined) {
33142
+ const end = () => {
33143
+ if (child.exitCode === null && child.signalCode === null) {
33144
+ this.logDebug(`The call waiting on pid ${child.pid ?? "unknown"} was cancelled; ending it.`);
33145
+ child.kill();
33146
+ }
33147
+ };
33148
+ if (cancelled.aborted) {
33149
+ end();
33150
+ } else {
33151
+ cancelled.addEventListener("abort", end, { once: true });
33152
+ child.once("exit", () => {
33153
+ cancelled.removeEventListener("abort", end);
33154
+ });
33155
+ }
33156
+ }
33100
33157
  const log = new GameLog;
33101
33158
  const started = {
33102
33159
  process: child,
@@ -34071,7 +34128,7 @@ class GodotServer {
34071
34128
  return `The game this server started (${which}) is still coming up and has not announced its runtime after a wait of ${Math.min(budgetMs, remaining)}ms, so there is nothing to talk to yet. It is still running: ask again in a moment rather than starting another.`;
34072
34129
  }
34073
34130
  async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
34074
- const { op: _op, projectPath, pid, ...params } = asParams(args);
34131
+ const { op: _op, projectPath, pid, outputPath, ...params } = asParams(args);
34075
34132
  const waited = typeof pid === "number" ? null : await this.ownRunOnItsWay(typeof projectPath === "string" ? projectPath : this.ownProject, timeoutMs);
34076
34133
  if (waited !== null) {
34077
34134
  return this.createErrorResponse(waited, [
@@ -34098,6 +34155,16 @@ class GodotServer {
34098
34155
  ]);
34099
34156
  }
34100
34157
  const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
34158
+ const saveTo = expectsScreenshot && typeof outputPath === "string" ? outputPath : null;
34159
+ if (saveTo !== null) {
34160
+ const refusal = captureDestinationRefusal(saveTo, [
34161
+ choice.endpoint.project.path,
34162
+ ...this.ownProject === null ? [] : [this.ownProject]
34163
+ ]);
34164
+ if (refusal !== null) {
34165
+ return this.createErrorResponse(refusal);
34166
+ }
34167
+ }
34101
34168
  const screenshotDir = expectsScreenshot ? mkdtempSync2(join16(tmpdir4(), "gdharness-runtime-screenshot-")) : null;
34102
34169
  const screenshotPath = screenshotDir ? join16(screenshotDir, "capture.png") : null;
34103
34170
  try {
@@ -34117,11 +34184,18 @@ class GodotServer {
34117
34184
  return this.createErrorResponse(`Rejected screenshot file path outside the managed capture path: '${returnedPath}'`);
34118
34185
  }
34119
34186
  const dimensions = `${readNumber(payload, "width") ?? 0}x${readNumber(payload, "height") ?? 0} ${readString(payload, "format") ?? "unknown"}`;
34187
+ if (saveTo !== null) {
34188
+ try {
34189
+ copyFileSync(screenshotPath, saveTo, constants.COPYFILE_EXCL);
34190
+ } catch (error) {
34191
+ return this.createErrorResponse(`The capture was taken and could not be saved at ${saveTo}: ${errorMessage(error)}`);
34192
+ }
34193
+ }
34120
34194
  return {
34121
34195
  content: [
34122
34196
  {
34123
34197
  type: "text",
34124
- text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}`
34198
+ text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}${saveTo === null ? "" : `. Saved to ${saveTo}`}`
34125
34199
  },
34126
34200
  { type: "image", data: readFileSync16(screenshotPath).toString("base64"), mimeType: "image/png" }
34127
34201
  ]
@@ -34168,7 +34242,11 @@ class GodotServer {
34168
34242
  return await this.handleRuntimeCommand("wait_frames", { ...whichGame(args), frames }, waited);
34169
34243
  }
34170
34244
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 5000;
34171
- const nodePath = readNonEmptyString(args, "nodePath") ?? "";
34245
+ const asked = readNonEmptyString(args, "nodePath");
34246
+ if (asked === undefined && op === "until" && readNonEmptyString(args, "says") === undefined) {
34247
+ return this.createErrorResponse("runtime_wait until needs nodePath to wait for a property: the property belongs to that node.", ["With says instead of property, nodePath can be left out and the whole game is searched"]);
34248
+ }
34249
+ const nodePath = asked ?? "/root";
34172
34250
  const patience = Math.max(this.runtimeTimeoutMs(), timeoutMs + 5000);
34173
34251
  return op === "signal" ? await this.handleRuntimeCommand("wait_signal", {
34174
34252
  ...whichGame(args),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
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",