gdharness 0.13.41 → 0.13.42

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 +124 -25
  2. package/build/index.js +114 -21
  3. package/package.json +1 -1
package/build/cli.js CHANGED
@@ -15393,7 +15393,7 @@ var init_tool_definitions = __esm(() => {
15393
15393
  };
15394
15394
  RUNNING_PID = {
15395
15395
  type: "number",
15396
- description: "Which game, when several are running from one project, such as a bench and its workers: its process id, as editor_status lists under runtimes. Not needed with one game per project."
15396
+ description: "Which game, when several are running from one project, such as a bench and its workers: its process id, as editor_status lists under runtimes. Without it the game this server started or plays is the one asked when it is among them, and the answer says so under answeredBy; with one game per project it is not needed at all."
15397
15397
  };
15398
15398
  SCENE_PATH = {
15399
15399
  type: "string",
@@ -16011,6 +16011,11 @@ var init_tool_definitions = __esm(() => {
16011
16011
  type: "number",
16012
16012
  ops: ["check", "wait"],
16013
16013
  description: "check: how long to give the boot before it is called hung. Default 60000. wait: how long to wait for the run to end before answering anyway. Default 600000."
16014
+ },
16015
+ andChildren: {
16016
+ type: "boolean",
16017
+ ops: ["stop"],
16018
+ description: "stop: also end what the game started for itself, with OS.create_process or otherwise, such as a bench's workers. Only the children of the run's process that are announced as games of this project are ended, named under endedChildren; any other child is left and named under childrenLeft. Default false, and the run's process is ended either way."
16014
16019
  }
16015
16020
  },
16016
16021
  requires: [],
@@ -16020,7 +16025,7 @@ var init_tool_definitions = __esm(() => {
16020
16025
  requires: ["projectPath"]
16021
16026
  },
16022
16027
  stop: {
16023
- summary: "end the run and answer with what it printed last, naming the process ended under endedPid: a game that started processes of its own keeps them",
16028
+ summary: "end the run and answer with what it printed last, naming the process ended under endedPid: a game that started processes of its own keeps them unless andChildren says to end those too",
16024
16029
  requires: []
16025
16030
  },
16026
16031
  check: {
@@ -36413,23 +36418,55 @@ var init_lsp_client = __esm(() => {
36413
36418
  init_ports();
36414
36419
  });
36415
36420
 
36416
- // src/process-time.ts
36421
+ // src/process-children.ts
36417
36422
  import { execFile as execFile5 } from "node:child_process";
36418
36423
  import { promisify as promisify5 } from "node:util";
36424
+ async function childrenOf(pid) {
36425
+ try {
36426
+ const printed = process.platform === "win32" ? await askWindows(pid) : await askPosix(pid);
36427
+ return printed.split(/\r?\n/).map((line) => Number(line.trim())).filter((child) => Number.isInteger(child) && child > 0);
36428
+ } catch (error) {
36429
+ if (process.platform !== "win32" && error.code === 1) {
36430
+ return [];
36431
+ }
36432
+ return;
36433
+ }
36434
+ }
36435
+ async function askWindows(pid) {
36436
+ const { stdout } = await run5("powershell.exe", [
36437
+ "-NoProfile",
36438
+ "-NonInteractive",
36439
+ "-Command",
36440
+ `(Get-CimInstance Win32_Process -Filter "ParentProcessId=${pid}").ProcessId`
36441
+ ], { timeout: ASK_TIMEOUT_MS, windowsHide: true });
36442
+ return stdout;
36443
+ }
36444
+ async function askPosix(pid) {
36445
+ const { stdout } = await run5("pgrep", ["-P", String(pid)], { timeout: ASK_TIMEOUT_MS });
36446
+ return stdout;
36447
+ }
36448
+ var run5, ASK_TIMEOUT_MS = 15000;
36449
+ var init_process_children = __esm(() => {
36450
+ run5 = promisify5(execFile5);
36451
+ });
36452
+
36453
+ // src/process-time.ts
36454
+ import { execFile as execFile6 } from "node:child_process";
36455
+ import { promisify as promisify6 } from "node:util";
36419
36456
  async function cpuSecondsOf(pid) {
36420
36457
  try {
36421
- return process.platform === "win32" ? await askWindows(pid) : await askPosix(pid);
36458
+ return process.platform === "win32" ? await askWindows2(pid) : await askPosix2(pid);
36422
36459
  } catch {
36423
36460
  return;
36424
36461
  }
36425
36462
  }
36426
- async function askWindows(pid) {
36427
- const { stdout } = await run5("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${pid}).CPU`], { timeout: ASK_TIMEOUT_MS, windowsHide: true });
36463
+ async function askWindows2(pid) {
36464
+ const { stdout } = await run6("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${pid}).CPU`], { timeout: ASK_TIMEOUT_MS2, windowsHide: true });
36428
36465
  const seconds = Number(stdout.trim().replace(",", "."));
36429
36466
  return Number.isFinite(seconds) ? seconds : undefined;
36430
36467
  }
36431
- async function askPosix(pid) {
36432
- const { stdout } = await run5("ps", ["-o", "time=", "-p", String(pid)], { timeout: ASK_TIMEOUT_MS });
36468
+ async function askPosix2(pid) {
36469
+ const { stdout } = await run6("ps", ["-o", "time=", "-p", String(pid)], { timeout: ASK_TIMEOUT_MS2 });
36433
36470
  return secondsFromClock(stdout.trim());
36434
36471
  }
36435
36472
  function secondsFromClock(printed) {
@@ -36447,9 +36484,9 @@ function secondsFromClock(printed) {
36447
36484
  const extra = days === null ? 0 : Number(days) * 86400;
36448
36485
  return Number.isFinite(extra) ? seconds + extra : undefined;
36449
36486
  }
36450
- var run5, ASK_TIMEOUT_MS = 6000;
36487
+ var run6, ASK_TIMEOUT_MS2 = 6000;
36451
36488
  var init_process_time = __esm(() => {
36452
- run5 = promisify5(execFile5);
36489
+ run6 = promisify6(execFile6);
36453
36490
  });
36454
36491
 
36455
36492
  // src/project-scan.ts
@@ -37216,7 +37253,7 @@ var init_run_record = __esm(() => {
37216
37253
  });
37217
37254
 
37218
37255
  // src/server.ts
37219
- import { execFile as execFile6, spawn } from "node:child_process";
37256
+ import { execFile as execFile7, spawn } from "node:child_process";
37220
37257
  import { randomUUID as randomUUID2 } from "node:crypto";
37221
37258
  import {
37222
37259
  appendFileSync,
@@ -37234,7 +37271,7 @@ import { tmpdir as tmpdir5 } from "node:os";
37234
37271
  import { basename as basename3, dirname as dirname11, join as join19, normalize as normalize3, relative as relative3 } from "node:path";
37235
37272
  import { setTimeout as delay3 } from "node:timers/promises";
37236
37273
  import { fileURLToPath as fileURLToPath4 } from "node:url";
37237
- import { promisify as promisify6 } from "node:util";
37274
+ import { promisify as promisify7 } from "node:util";
37238
37275
  function patienceForFrames(frames, atLeast) {
37239
37276
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
37240
37277
  }
@@ -37285,6 +37322,18 @@ function endedPreviousRun(ended) {
37285
37322
  }
37286
37323
  return ended.pid ?? true;
37287
37324
  }
37325
+ function aboutTheChildren(ended, throughEditor) {
37326
+ if (ended === null) {
37327
+ return throughEditor ? "Anything that game started for itself, with OS.create_process or otherwise, is not the editor's to stop and is still running; andChildren ends what it started that is announced as a game of this project." : "Anything that game started for itself, with OS.create_process or otherwise, is a separate process and is still running; andChildren ends what it started that is announced as a game of this project.";
37328
+ }
37329
+ if (ended.unknown) {
37330
+ return "andChildren was asked for and this platform would not list what the game had started, so nothing else was ended.";
37331
+ }
37332
+ const counted = (n) => `${n} ${n === 1 ? "process" : "processes"}`;
37333
+ const endedToo = ended.ended.length === 0 ? "The game had started nothing announced as a game of this project, so nothing else was ended." : `${counted(ended.ended.length)} the game had started for itself, announced as games of this project, ${ended.ended.length === 1 ? "was" : "were"} ended with it, named under endedChildren.`;
37334
+ const leftToo = ended.left.length === 0 ? "" : ` ${counted(ended.left.length)} it had started ${ended.left.length === 1 ? "was" : "were"} left, named under childrenLeft: not announced as a game of this project, so what ${ended.left.length === 1 ? "it is" : "they are"} cannot be told from here.`;
37335
+ return `${endedToo}${leftToo}`;
37336
+ }
37288
37337
  function endedToStartThis(ended) {
37289
37338
  if (ended === null) {
37290
37339
  return "";
@@ -37984,7 +38033,7 @@ class GodotServer {
37984
38033
  return op === "restart" ? await this.handleRestartEditor() : await this.handleLaunchEditor(args);
37985
38034
  case "editor_run":
37986
38035
  if (op === "stop") {
37987
- return await this.handleStopProject();
38036
+ return await this.handleStopProject(args);
37988
38037
  }
37989
38038
  if (op === "wait") {
37990
38039
  return await this.handleWaitForRun(args);
@@ -38427,7 +38476,7 @@ class GodotServer {
38427
38476
  const log = new GameLog;
38428
38477
  let exitCode = 0;
38429
38478
  try {
38430
- const { stdout, stderr } = await run6(engine.value, exportArgs, { timeout: 300000 });
38479
+ const { stdout, stderr } = await run7(engine.value, exportArgs, { timeout: 300000 });
38431
38480
  log.append("stdout", stdout);
38432
38481
  log.append("stderr", stderr);
38433
38482
  } catch (error) {
@@ -38624,7 +38673,7 @@ class GodotServer {
38624
38673
  }
38625
38674
  async godotVersion(godotPath) {
38626
38675
  try {
38627
- const { stdout } = await run6(godotPath, ["--version"], { timeout: 1e4 });
38676
+ const { stdout } = await run7(godotPath, ["--version"], { timeout: 1e4 });
38628
38677
  this.lastGodotVersion = stdout.trim();
38629
38678
  return this.lastGodotVersion;
38630
38679
  } catch (error) {
@@ -39507,6 +39556,17 @@ class GodotServer {
39507
39556
  }
39508
39557
  return run.announcedPid;
39509
39558
  }
39559
+ ownGameAmong(running, projectPath) {
39560
+ const run = this.currentRun();
39561
+ if (run === null || !stillRunning(run)) {
39562
+ return;
39563
+ }
39564
+ if (projectPath !== undefined && run.projectPath !== null && !isSameDirectory(run.projectPath, projectPath)) {
39565
+ return;
39566
+ }
39567
+ const its = run.throughEditor ? this.announcedPidOf(run, running) : run.pid;
39568
+ return its !== null && its !== undefined && running.some((one) => one.pid === its) ? its : undefined;
39569
+ }
39510
39570
  playedByOurEditor(endpoint, projectPath) {
39511
39571
  const editor = this.godotBridge.getStatus().editorPid;
39512
39572
  if (editor === undefined) {
@@ -39746,7 +39806,7 @@ class GodotServer {
39746
39806
  entries: forAnswer(selected.entries)
39747
39807
  });
39748
39808
  }
39749
- async handleStopProject() {
39809
+ async handleStopProject(args) {
39750
39810
  await this.pickUpWhatTheEditorIsPlaying();
39751
39811
  const stopped = this.currentRun();
39752
39812
  if (!stopped) {
@@ -39755,21 +39815,48 @@ class GodotServer {
39755
39815
  this.drainEditorOutput(stopped);
39756
39816
  this.drainTranscript(stopped);
39757
39817
  const wasRunning = await this.runStillGoing(stopped);
39818
+ const endedPid = stopped.pid ?? this.announcedPidOf(stopped) ?? null;
39819
+ const children = readBoolean(args, "andChildren") === true && wasRunning && endedPid !== null ? await childrenOf(endedPid) : null;
39758
39820
  this.logDebug("Stopping the running game");
39759
39821
  await this.endActiveGame("editor_run stop");
39822
+ const ended = children === null ? null : this.endChildrenAmong(children, stopped.projectPath);
39760
39823
  return this.jsonTextResponse({
39761
39824
  stopped: true,
39762
39825
  through: stopped.throughEditor ? "editor" : "gdharness",
39763
- endedPid: stopped.pid ?? this.announcedPidOf(stopped) ?? null,
39826
+ endedPid,
39827
+ ...ended === null ? {} : {
39828
+ endedChildren: ended.ended,
39829
+ childrenLeft: ended.left.length === 0 ? undefined : ended.left,
39830
+ childrenUnknown: ended.unknown ? true : undefined
39831
+ },
39764
39832
  exitedBeforeStop: stopped.exitCode !== null || !wasRunning,
39765
39833
  exitCode: stopped.exitCode,
39766
39834
  errors: stopped.log.count("error"),
39767
39835
  warnings: stopped.log.count("warning"),
39768
39836
  clean: stopped.log.count("error") === 0,
39769
- note: !wasRunning ? "This run was over before the stop, so nothing was ended here: editor_output has what it printed and how it ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and may still be running." : stopped.throughEditor ? "The editor was asked to stop the scene it is playing. Anything that game started for itself, with OS.create_process or otherwise, is not the editor's to stop and is still running." : "The process named under endedPid was ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and is still running.",
39837
+ note: !wasRunning ? "This run was over before the stop, so nothing was ended here: editor_output has what it printed and how it ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and may still be running." : `${stopped.throughEditor ? "The editor was asked to stop the scene it is playing." : "The process named under endedPid was ended."} ${aboutTheChildren(ended, stopped.throughEditor)}`,
39770
39838
  entries: forAnswer(stopped.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
39771
39839
  });
39772
39840
  }
39841
+ endChildrenAmong(children, projectPath) {
39842
+ if (children === undefined) {
39843
+ return { ended: [], left: [], unknown: true };
39844
+ }
39845
+ const games = new Set(runtimesAnnounced().running.filter((one) => projectPath === null || isSameDirectory(one.project.path, projectPath)).map((one) => one.pid));
39846
+ const ended = [];
39847
+ const left = [];
39848
+ for (const child of children) {
39849
+ if (!games.has(child)) {
39850
+ left.push(child);
39851
+ continue;
39852
+ }
39853
+ try {
39854
+ process.kill(child);
39855
+ ended.push(child);
39856
+ } catch {}
39857
+ }
39858
+ return { ended, left, unknown: false };
39859
+ }
39773
39860
  async handleRescanFilesystem(args) {
39774
39861
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 30000;
39775
39862
  const started = Date.now();
@@ -39964,10 +40051,15 @@ class GodotServer {
39964
40051
  async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
39965
40052
  const { op: _op, projectPath, pid, ...params } = asParams(args);
39966
40053
  const announced = runtimesAnnounced();
39967
- const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined, announced.unspoken, typeof pid === "number" ? pid : undefined);
40054
+ const wanted = typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined;
40055
+ const own = typeof pid === "number" ? undefined : this.ownGameAmong(announced.running, wanted);
40056
+ const choice = chooseRuntime(announced.running, wanted, announced.unspoken, typeof pid === "number" ? pid : own);
39968
40057
  if ("problem" in choice) {
39969
40058
  return this.createErrorResponse(choice.problem);
39970
40059
  }
40060
+ const ofProject = announced.running.filter((one) => wanted === undefined || isSameDirectory(one.project.path, wanted));
40061
+ const chosenAmongSeveral = own !== undefined && ofProject.length > 1;
40062
+ const answeredBy = chosenAmongSeveral ? { answeredBy: own } : {};
39971
40063
  const held = this.knownHoldOn(choice.endpoint, announced.running);
39972
40064
  if (held !== null) {
39973
40065
  return this.createErrorResponse(`The game (pid ${choice.endpoint.pid}) is held by the editor's debugger, ${describeHalt(held)}, and answers no runtime call while it is.`, [
@@ -39986,7 +40078,7 @@ class GodotServer {
39986
40078
  }
39987
40079
  const { id: _id, ...payload } = reply.payload;
39988
40080
  if (!expectsScreenshot) {
39989
- return this.jsonTextResponse(payload);
40081
+ return this.jsonTextResponse({ ...payload, ...answeredBy });
39990
40082
  }
39991
40083
  const returnedPath = readString(payload, "path");
39992
40084
  if (readString(payload, "type") !== "screenshot_file" || !returnedPath || !screenshotPath) {
@@ -39998,7 +40090,10 @@ class GodotServer {
39998
40090
  const dimensions = `${readNumber(payload, "width") ?? 0}x${readNumber(payload, "height") ?? 0} ${readString(payload, "format") ?? "unknown"}`;
39999
40091
  return {
40000
40092
  content: [
40001
- { type: "text", text: `Screenshot captured: ${dimensions}` },
40093
+ {
40094
+ type: "text",
40095
+ text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}`
40096
+ },
40002
40097
  { type: "image", data: readFileSync17(screenshotPath).toString("base64"), mimeType: "image/png" }
40003
40098
  ]
40004
40099
  };
@@ -40065,7 +40160,7 @@ async function runGodotServer() {
40065
40160
  const server = new GodotServer;
40066
40161
  await server.run();
40067
40162
  }
40068
- var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run6, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CACHE_WRITE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
40163
+ var UPDATE_NOTICE_EVERY = 500, FEEDBACK_NOTICE_EVERY = 250, run7, __dirname2, EDITOR_RESTART_TIMEOUT_MS = 90000, HOLD_PING_MS = 2000, SLOWEST_FRAME_RATE = 20, VALUE_NAMES, BRIDGE_RETRY_MS = 2000, SUCCESSOR_CHECK_MS = 1e4, CACHE_WRITE_MS = 2000, PLAYING_STATUS_MS = 1000, SCAN_START_MS = 2000, WAIT_FOR_RUN_MS = 600000, RUN_POLL_MS = 250, PATH_SOLUTIONS, PROJECT_FILE_ARGUMENTS, DEBUG_STATE_CALLS, PROJECT_INFO_SECTIONS2, RUNNER_DIRECTORY = "addons/gdUnit4/";
40069
40164
  var init_server3 = __esm(() => {
40070
40165
  init_mcp();
40071
40166
  init_stdio2();
@@ -40086,6 +40181,7 @@ var init_server3 = __esm(() => {
40086
40181
  init_lsp_client();
40087
40182
  init_paths();
40088
40183
  init_ports();
40184
+ init_process_children();
40089
40185
  init_process_time();
40090
40186
  init_project_scan();
40091
40187
  init_resources();
@@ -40097,7 +40193,7 @@ var init_server3 = __esm(() => {
40097
40193
  init_setup();
40098
40194
  init_tool_definitions();
40099
40195
  init_update_check();
40100
- run6 = promisify6(execFile6);
40196
+ run7 = promisify7(execFile7);
40101
40197
  __dirname2 = dirname11(fileURLToPath4(import.meta.url));
40102
40198
  VALUE_NAMES = {
40103
40199
  string: "a string",
@@ -41608,8 +41704,10 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
41608
41704
 
41609
41705
  - ${projectPathSentence()}. The \`runtime_*\` and \`debug_*\` ones pick between running
41610
41706
  games instead, by \`projectPath\`, or by \`pid\` when several run from one project, as a bench
41611
- and its workers do; the others answer about the editor this server is connected to. There is no
41612
- ambient project, and an argument a tool does not declare is refused rather than ignored.
41707
+ and its workers do: without \`pid\` the game this server started or plays is the one asked, and
41708
+ the answer says so under \`answeredBy\`; the others answer about the editor this server is
41709
+ connected to. There is no ambient project, and an argument a tool does not declare is refused
41710
+ rather than ignored.
41613
41711
  - \`editor_status\` says whether an editor is connected and whether its addon matches the server.
41614
41712
  \`addonIsStale\` means they differ, and \`staleNote\` says which half is behind: an editor that
41615
41713
  needs restarting, or a server that needs reconnecting in your harness. Every answer that came
@@ -41654,6 +41752,7 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
41654
41752
  | Where a 3D node is on screen | \`runtime_inspect\` \`rect\`, rather than unprojecting by hand |
41655
41753
  | Wait for something in a running game | \`runtime_wait\`, never a sleep; \`until\` with \`says\` for a panel that rebuilds its own labels |
41656
41754
  | Wait for a run to finish | \`editor_run wait\`, never a sleep or a shell loop on the pid: it answers as \`editor_output\` does, once the run is over |
41755
+ | End a bench and the workers it opened | \`editor_run stop\` with \`andChildren\`, never a process listing by scene name: it ends the children of the run's process that are announced as games of the project, names them under \`endedChildren\`, and leaves and names any other child |
41657
41756
  | A picture, for a person who asked to see one | \`runtime_capture\` |
41658
41757
 
41659
41758
  These need the runtime autoload, which \`gdharness setup\` registers. \`runtime_capture\` needs a
package/build/index.js CHANGED
@@ -10168,7 +10168,7 @@ function defectReport(where, error, context = {}) {
10168
10168
  }
10169
10169
 
10170
10170
  // src/server.ts
10171
- import { execFile as execFile4, spawn } from "node:child_process";
10171
+ import { execFile as execFile5, spawn } from "node:child_process";
10172
10172
  import { randomUUID as randomUUID2 } from "node:crypto";
10173
10173
  import {
10174
10174
  appendFileSync,
@@ -10186,7 +10186,7 @@ import { tmpdir as tmpdir4 } from "node:os";
10186
10186
  import { basename as basename2, dirname as dirname6, join as join13, normalize as normalize2, relative as relative3 } from "node:path";
10187
10187
  import { setTimeout as delay3 } from "node:timers/promises";
10188
10188
  import { fileURLToPath as fileURLToPath2 } from "node:url";
10189
- import { promisify as promisify4 } from "node:util";
10189
+ import { promisify as promisify5 } from "node:util";
10190
10190
 
10191
10191
  // node_modules/zod/v3/helpers/util.js
10192
10192
  var util;
@@ -27709,25 +27709,55 @@ async function handleLSPTool(client, toolName, args) {
27709
27709
  }
27710
27710
  }
27711
27711
 
27712
- // src/process-time.ts
27712
+ // src/process-children.ts
27713
27713
  import { execFile as execFile3 } from "node:child_process";
27714
27714
  import { promisify as promisify3 } from "node:util";
27715
27715
  var run3 = promisify3(execFile3);
27716
- var ASK_TIMEOUT_MS = 6000;
27716
+ var ASK_TIMEOUT_MS = 15000;
27717
+ async function childrenOf(pid) {
27718
+ try {
27719
+ const printed = process.platform === "win32" ? await askWindows(pid) : await askPosix(pid);
27720
+ return printed.split(/\r?\n/).map((line) => Number(line.trim())).filter((child) => Number.isInteger(child) && child > 0);
27721
+ } catch (error) {
27722
+ if (process.platform !== "win32" && error.code === 1) {
27723
+ return [];
27724
+ }
27725
+ return;
27726
+ }
27727
+ }
27728
+ async function askWindows(pid) {
27729
+ const { stdout } = await run3("powershell.exe", [
27730
+ "-NoProfile",
27731
+ "-NonInteractive",
27732
+ "-Command",
27733
+ `(Get-CimInstance Win32_Process -Filter "ParentProcessId=${pid}").ProcessId`
27734
+ ], { timeout: ASK_TIMEOUT_MS, windowsHide: true });
27735
+ return stdout;
27736
+ }
27737
+ async function askPosix(pid) {
27738
+ const { stdout } = await run3("pgrep", ["-P", String(pid)], { timeout: ASK_TIMEOUT_MS });
27739
+ return stdout;
27740
+ }
27741
+
27742
+ // src/process-time.ts
27743
+ import { execFile as execFile4 } from "node:child_process";
27744
+ import { promisify as promisify4 } from "node:util";
27745
+ var run4 = promisify4(execFile4);
27746
+ var ASK_TIMEOUT_MS2 = 6000;
27717
27747
  async function cpuSecondsOf(pid) {
27718
27748
  try {
27719
- return process.platform === "win32" ? await askWindows(pid) : await askPosix(pid);
27749
+ return process.platform === "win32" ? await askWindows2(pid) : await askPosix2(pid);
27720
27750
  } catch {
27721
27751
  return;
27722
27752
  }
27723
27753
  }
27724
- async function askWindows(pid) {
27725
- const { stdout } = await run3("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${pid}).CPU`], { timeout: ASK_TIMEOUT_MS, windowsHide: true });
27754
+ async function askWindows2(pid) {
27755
+ const { stdout } = await run4("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `(Get-Process -Id ${pid}).CPU`], { timeout: ASK_TIMEOUT_MS2, windowsHide: true });
27726
27756
  const seconds = Number(stdout.trim().replace(",", "."));
27727
27757
  return Number.isFinite(seconds) ? seconds : undefined;
27728
27758
  }
27729
- async function askPosix(pid) {
27730
- const { stdout } = await run3("ps", ["-o", "time=", "-p", String(pid)], { timeout: ASK_TIMEOUT_MS });
27759
+ async function askPosix2(pid) {
27760
+ const { stdout } = await run4("ps", ["-o", "time=", "-p", String(pid)], { timeout: ASK_TIMEOUT_MS2 });
27731
27761
  return secondsFromClock(stdout.trim());
27732
27762
  }
27733
27763
  function secondsFromClock(printed) {
@@ -28848,7 +28878,7 @@ var RUNNING_PROJECT_PATH = {
28848
28878
  };
28849
28879
  var RUNNING_PID = {
28850
28880
  type: "number",
28851
- description: "Which game, when several are running from one project, such as a bench and its workers: its process id, as editor_status lists under runtimes. Not needed with one game per project."
28881
+ description: "Which game, when several are running from one project, such as a bench and its workers: its process id, as editor_status lists under runtimes. Without it the game this server started or plays is the one asked when it is among them, and the answer says so under answeredBy; with one game per project it is not needed at all."
28852
28882
  };
28853
28883
  var SCENE_PATH = {
28854
28884
  type: "string",
@@ -29466,6 +29496,11 @@ var TOOL_SPECS = [
29466
29496
  type: "number",
29467
29497
  ops: ["check", "wait"],
29468
29498
  description: "check: how long to give the boot before it is called hung. Default 60000. wait: how long to wait for the run to end before answering anyway. Default 600000."
29499
+ },
29500
+ andChildren: {
29501
+ type: "boolean",
29502
+ ops: ["stop"],
29503
+ description: "stop: also end what the game started for itself, with OS.create_process or otherwise, such as a bench's workers. Only the children of the run's process that are announced as games of this project are ended, named under endedChildren; any other child is left and named under childrenLeft. Default false, and the run's process is ended either way."
29469
29504
  }
29470
29505
  },
29471
29506
  requires: [],
@@ -29475,7 +29510,7 @@ var TOOL_SPECS = [
29475
29510
  requires: ["projectPath"]
29476
29511
  },
29477
29512
  stop: {
29478
- summary: "end the run and answer with what it printed last, naming the process ended under endedPid: a game that started processes of its own keeps them",
29513
+ summary: "end the run and answer with what it printed last, naming the process ended under endedPid: a game that started processes of its own keeps them unless andChildren says to end those too",
29479
29514
  requires: []
29480
29515
  },
29481
29516
  check: {
@@ -29937,7 +29972,7 @@ function buildToolDefinitions() {
29937
29972
  // src/server.ts
29938
29973
  var UPDATE_NOTICE_EVERY = 500;
29939
29974
  var FEEDBACK_NOTICE_EVERY = 250;
29940
- var run4 = promisify4(execFile4);
29975
+ var run5 = promisify5(execFile5);
29941
29976
  var __dirname2 = dirname6(fileURLToPath2(import.meta.url));
29942
29977
  var EDITOR_RESTART_TIMEOUT_MS = 90000;
29943
29978
  var HOLD_PING_MS = 2000;
@@ -29992,6 +30027,18 @@ function endedPreviousRun(ended) {
29992
30027
  }
29993
30028
  return ended.pid ?? true;
29994
30029
  }
30030
+ function aboutTheChildren(ended, throughEditor) {
30031
+ if (ended === null) {
30032
+ return throughEditor ? "Anything that game started for itself, with OS.create_process or otherwise, is not the editor's to stop and is still running; andChildren ends what it started that is announced as a game of this project." : "Anything that game started for itself, with OS.create_process or otherwise, is a separate process and is still running; andChildren ends what it started that is announced as a game of this project.";
30033
+ }
30034
+ if (ended.unknown) {
30035
+ return "andChildren was asked for and this platform would not list what the game had started, so nothing else was ended.";
30036
+ }
30037
+ const counted = (n) => `${n} ${n === 1 ? "process" : "processes"}`;
30038
+ const endedToo = ended.ended.length === 0 ? "The game had started nothing announced as a game of this project, so nothing else was ended." : `${counted(ended.ended.length)} the game had started for itself, announced as games of this project, ${ended.ended.length === 1 ? "was" : "were"} ended with it, named under endedChildren.`;
30039
+ const leftToo = ended.left.length === 0 ? "" : ` ${counted(ended.left.length)} it had started ${ended.left.length === 1 ? "was" : "were"} left, named under childrenLeft: not announced as a game of this project, so what ${ended.left.length === 1 ? "it is" : "they are"} cannot be told from here.`;
30040
+ return `${endedToo}${leftToo}`;
30041
+ }
29995
30042
  function endedToStartThis(ended) {
29996
30043
  if (ended === null) {
29997
30044
  return "";
@@ -30733,7 +30780,7 @@ class GodotServer {
30733
30780
  return op === "restart" ? await this.handleRestartEditor() : await this.handleLaunchEditor(args);
30734
30781
  case "editor_run":
30735
30782
  if (op === "stop") {
30736
- return await this.handleStopProject();
30783
+ return await this.handleStopProject(args);
30737
30784
  }
30738
30785
  if (op === "wait") {
30739
30786
  return await this.handleWaitForRun(args);
@@ -31176,7 +31223,7 @@ class GodotServer {
31176
31223
  const log = new GameLog;
31177
31224
  let exitCode = 0;
31178
31225
  try {
31179
- const { stdout, stderr } = await run4(engine.value, exportArgs, { timeout: 300000 });
31226
+ const { stdout, stderr } = await run5(engine.value, exportArgs, { timeout: 300000 });
31180
31227
  log.append("stdout", stdout);
31181
31228
  log.append("stderr", stderr);
31182
31229
  } catch (error) {
@@ -31373,7 +31420,7 @@ class GodotServer {
31373
31420
  }
31374
31421
  async godotVersion(godotPath) {
31375
31422
  try {
31376
- const { stdout } = await run4(godotPath, ["--version"], { timeout: 1e4 });
31423
+ const { stdout } = await run5(godotPath, ["--version"], { timeout: 1e4 });
31377
31424
  this.lastGodotVersion = stdout.trim();
31378
31425
  return this.lastGodotVersion;
31379
31426
  } catch (error) {
@@ -32256,6 +32303,17 @@ class GodotServer {
32256
32303
  }
32257
32304
  return run.announcedPid;
32258
32305
  }
32306
+ ownGameAmong(running, projectPath) {
32307
+ const run = this.currentRun();
32308
+ if (run === null || !stillRunning(run)) {
32309
+ return;
32310
+ }
32311
+ if (projectPath !== undefined && run.projectPath !== null && !isSameDirectory(run.projectPath, projectPath)) {
32312
+ return;
32313
+ }
32314
+ const its = run.throughEditor ? this.announcedPidOf(run, running) : run.pid;
32315
+ return its !== null && its !== undefined && running.some((one) => one.pid === its) ? its : undefined;
32316
+ }
32259
32317
  playedByOurEditor(endpoint, projectPath) {
32260
32318
  const editor = this.godotBridge.getStatus().editorPid;
32261
32319
  if (editor === undefined) {
@@ -32495,7 +32553,7 @@ class GodotServer {
32495
32553
  entries: forAnswer(selected.entries)
32496
32554
  });
32497
32555
  }
32498
- async handleStopProject() {
32556
+ async handleStopProject(args) {
32499
32557
  await this.pickUpWhatTheEditorIsPlaying();
32500
32558
  const stopped = this.currentRun();
32501
32559
  if (!stopped) {
@@ -32504,21 +32562,48 @@ class GodotServer {
32504
32562
  this.drainEditorOutput(stopped);
32505
32563
  this.drainTranscript(stopped);
32506
32564
  const wasRunning = await this.runStillGoing(stopped);
32565
+ const endedPid = stopped.pid ?? this.announcedPidOf(stopped) ?? null;
32566
+ const children = readBoolean(args, "andChildren") === true && wasRunning && endedPid !== null ? await childrenOf(endedPid) : null;
32507
32567
  this.logDebug("Stopping the running game");
32508
32568
  await this.endActiveGame("editor_run stop");
32569
+ const ended = children === null ? null : this.endChildrenAmong(children, stopped.projectPath);
32509
32570
  return this.jsonTextResponse({
32510
32571
  stopped: true,
32511
32572
  through: stopped.throughEditor ? "editor" : "gdharness",
32512
- endedPid: stopped.pid ?? this.announcedPidOf(stopped) ?? null,
32573
+ endedPid,
32574
+ ...ended === null ? {} : {
32575
+ endedChildren: ended.ended,
32576
+ childrenLeft: ended.left.length === 0 ? undefined : ended.left,
32577
+ childrenUnknown: ended.unknown ? true : undefined
32578
+ },
32513
32579
  exitedBeforeStop: stopped.exitCode !== null || !wasRunning,
32514
32580
  exitCode: stopped.exitCode,
32515
32581
  errors: stopped.log.count("error"),
32516
32582
  warnings: stopped.log.count("warning"),
32517
32583
  clean: stopped.log.count("error") === 0,
32518
- note: !wasRunning ? "This run was over before the stop, so nothing was ended here: editor_output has what it printed and how it ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and may still be running." : stopped.throughEditor ? "The editor was asked to stop the scene it is playing. Anything that game started for itself, with OS.create_process or otherwise, is not the editor's to stop and is still running." : "The process named under endedPid was ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and is still running.",
32584
+ note: !wasRunning ? "This run was over before the stop, so nothing was ended here: editor_output has what it printed and how it ended. Anything that game started for itself, with OS.create_process or otherwise, is a separate process and may still be running." : `${stopped.throughEditor ? "The editor was asked to stop the scene it is playing." : "The process named under endedPid was ended."} ${aboutTheChildren(ended, stopped.throughEditor)}`,
32519
32585
  entries: forAnswer(stopped.log.select({ severity: "warning", sinceLastCall: false, limit: 200 }).entries)
32520
32586
  });
32521
32587
  }
32588
+ endChildrenAmong(children, projectPath) {
32589
+ if (children === undefined) {
32590
+ return { ended: [], left: [], unknown: true };
32591
+ }
32592
+ const games = new Set(runtimesAnnounced().running.filter((one) => projectPath === null || isSameDirectory(one.project.path, projectPath)).map((one) => one.pid));
32593
+ const ended = [];
32594
+ const left = [];
32595
+ for (const child of children) {
32596
+ if (!games.has(child)) {
32597
+ left.push(child);
32598
+ continue;
32599
+ }
32600
+ try {
32601
+ process.kill(child);
32602
+ ended.push(child);
32603
+ } catch {}
32604
+ }
32605
+ return { ended, left, unknown: false };
32606
+ }
32522
32607
  async handleRescanFilesystem(args) {
32523
32608
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 30000;
32524
32609
  const started = Date.now();
@@ -32713,10 +32798,15 @@ class GodotServer {
32713
32798
  async handleRuntimeCommand(command, args, timeoutMs = this.runtimeTimeoutMs()) {
32714
32799
  const { op: _op, projectPath, pid, ...params } = asParams(args);
32715
32800
  const announced = runtimesAnnounced();
32716
- const choice = chooseRuntime(announced.running, typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined, announced.unspoken, typeof pid === "number" ? pid : undefined);
32801
+ const wanted = typeof projectPath === "string" ? projectPath : this.ownProject ?? undefined;
32802
+ const own = typeof pid === "number" ? undefined : this.ownGameAmong(announced.running, wanted);
32803
+ const choice = chooseRuntime(announced.running, wanted, announced.unspoken, typeof pid === "number" ? pid : own);
32717
32804
  if ("problem" in choice) {
32718
32805
  return this.createErrorResponse(choice.problem);
32719
32806
  }
32807
+ const ofProject = announced.running.filter((one) => wanted === undefined || isSameDirectory(one.project.path, wanted));
32808
+ const chosenAmongSeveral = own !== undefined && ofProject.length > 1;
32809
+ const answeredBy = chosenAmongSeveral ? { answeredBy: own } : {};
32720
32810
  const held = this.knownHoldOn(choice.endpoint, announced.running);
32721
32811
  if (held !== null) {
32722
32812
  return this.createErrorResponse(`The game (pid ${choice.endpoint.pid}) is held by the editor's debugger, ${describeHalt(held)}, and answers no runtime call while it is.`, [
@@ -32735,7 +32825,7 @@ class GodotServer {
32735
32825
  }
32736
32826
  const { id: _id, ...payload } = reply.payload;
32737
32827
  if (!expectsScreenshot) {
32738
- return this.jsonTextResponse(payload);
32828
+ return this.jsonTextResponse({ ...payload, ...answeredBy });
32739
32829
  }
32740
32830
  const returnedPath = readString(payload, "path");
32741
32831
  if (readString(payload, "type") !== "screenshot_file" || !returnedPath || !screenshotPath) {
@@ -32747,7 +32837,10 @@ class GodotServer {
32747
32837
  const dimensions = `${readNumber(payload, "width") ?? 0}x${readNumber(payload, "height") ?? 0} ${readString(payload, "format") ?? "unknown"}`;
32748
32838
  return {
32749
32839
  content: [
32750
- { type: "text", text: `Screenshot captured: ${dimensions}` },
32840
+ {
32841
+ type: "text",
32842
+ text: `Screenshot captured: ${dimensions}${chosenAmongSeveral ? ` from pid ${own}, the game this server holds` : ""}`
32843
+ },
32751
32844
  { type: "image", data: readFileSync13(screenshotPath).toString("base64"), mimeType: "image/png" }
32752
32845
  ]
32753
32846
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.13.41",
3
+ "version": "0.13.42",
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",