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.
- package/build/cli.js +109 -25
- package/build/index.js +99 -21
- 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: {
|
|
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: [
|
|
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, {
|
|
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
|
|
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: {
|
|
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: [
|
|
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, {
|
|
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
|
|
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.
|
|
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",
|