gdharness 1.1.30 → 1.1.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/cli.js CHANGED
@@ -384,9 +384,18 @@ function readCommandLine(command) {
384
384
  return {
385
385
  executable,
386
386
  projectPath,
387
- editor: words.some((word) => word === "-e" || word === "--editor")
387
+ editor: words.some((word) => word === "-e" || word === "--editor"),
388
+ oneShot: words.find((word) => ONE_SHOT_OPTIONS.includes(word)) ?? null
388
389
  };
389
390
  }
391
+ async function oneShotOptionOf(pid) {
392
+ const tree = await processTree();
393
+ const listed = tree?.get(pid);
394
+ if (listed === undefined || listed.command === "") {
395
+ return;
396
+ }
397
+ return readCommandLine(listed.command).oneShot;
398
+ }
390
399
  function commandWords(command) {
391
400
  const words = [];
392
401
  let word = "";
@@ -422,10 +431,31 @@ async function askPosix() {
422
431
  });
423
432
  return stdout;
424
433
  }
425
- var run, ASK_TIMEOUT_MS = 15000, lastListingFailure;
434
+ var run, ASK_TIMEOUT_MS = 15000, lastListingFailure, ONE_SHOT_OPTIONS;
426
435
  var init_process_children = __esm(() => {
427
436
  init_windows_ask();
428
437
  run = promisify(execFile);
438
+ ONE_SHOT_OPTIONS = [
439
+ "--import",
440
+ "--quit",
441
+ "--quit-after",
442
+ "--export-release",
443
+ "--export-debug",
444
+ "--export-pack",
445
+ "--export-patch",
446
+ "--doctool",
447
+ "--gdscript-docs",
448
+ "--script",
449
+ "-s",
450
+ "--check-only",
451
+ "--build-solutions",
452
+ "--dump-gdextension-interface",
453
+ "--dump-extension-api",
454
+ "--dump-extension-api-with-docs",
455
+ "--validate-extension-api",
456
+ "--convert-3to4",
457
+ "--validate-conversion-3to4"
458
+ ];
429
459
  });
430
460
 
431
461
  // src/bridge-announce.ts
@@ -17617,7 +17647,7 @@ var init_tool_definitions = __esm(() => {
17617
17647
  },
17618
17648
  {
17619
17649
  name: "editor_status",
17620
- description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. game.run names the run processActive is about, the one editor_output and editor_run stop answer for: its pid, its project, and adopted true when this server read it back from another server rather than starting it; null when there is none. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. Each game also says which runtime addon it loaded, under addonVersion (null when it cannot say); addonIsStale is true when that code is not what this server ships, and staleNote says whether the project needs gdharness upgrade, the game a restart, or this server a reconnect. A second checkout of a project, such as a git worktree, carries addons of its own and is where this shows. Every runtime_* answer from such a game carries addonIsStale and staleNote too. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. True beside restartUnderway is a restart still in progress, its server alive and within the wait for the old editor to go: the editor is coming, and opening another would put two on one project. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait. An editor that has connected and not yet greeted, which lasts a frame and longer while it imports, reports nothing about itself: greeting says so, addonIsStale is absent rather than guessed, and a restart asked then waits for the greeting to learn who opened it. startedByAnEditor is an editor another editor started, by Godot's own restart or the project manager, which has none of the arguments a server gave the one before it; startedByAnEditorNote says what that costs.",
17650
+ description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. game.run names the run processActive is about, the one editor_output and editor_run stop answer for: its pid, its project, and adopted true when this server read it back from another server rather than starting it; null when there is none. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. Each game also says which runtime addon it loaded, under addonVersion (null when it cannot say); addonIsStale is true when that code is not what this server ships, and staleNote says whether the project needs gdharness upgrade, the game a restart, or this server a reconnect. A second checkout of a project, such as a git worktree, carries addons of its own and is where this shows. Every runtime_* answer from such a game carries addonIsStale and staleNote too. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. True beside restartUnderway is a restart still in progress, its server alive and within the wait for the old editor to go: the editor is coming, and opening another would put two on one project. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait. An editor that has connected and not yet greeted, which lasts a frame and longer while it imports, reports nothing about itself: greeting says so, addonIsStale is absent rather than guessed, and a restart asked then waits for the greeting to learn who opened it. startedByAnEditor is an editor another editor started, by Godot's own restart or the project manager, which has none of the arguments a server gave the one before it; startedByAnEditorNote says what that costs. An engine run that loads the editor addon to do one job and quit, such as a gate's godot --headless --import, an export or a script, is not taken as the editor: a process that greets without a server's mark has its command line read first, and while that is read identifying is true and connected false; one started with such an option is turned away, and turnedAway names the last one, its pid and the option.",
17621
17651
  parameters: {},
17622
17652
  requires: []
17623
17653
  },
@@ -37477,7 +37507,7 @@ function getDefaultBridge(ownProject = null) {
37477
37507
  defaultBridge ??= new GodotBridge(resolveDefaultBridgePort(), resolveDefaultBridgeHost(), DEFAULT_TIMEOUT_MS, ownProject);
37478
37508
  return defaultBridge;
37479
37509
  }
37480
- var DEFAULT_PORT = 6505, DEFAULT_HOST = "127.0.0.1", DEFAULT_TIMEOUT_MS = 30000, KEEPALIVE_INTERVAL_MS = 1e4, SECOND_CONNECTION_CLOSE_CODE = 4000, OTHER_PROJECT_CLOSE_CODE = 4001, CONNECT_WINDOW_MS = 35000, GodotBridge, defaultBridge = null;
37510
+ var DEFAULT_PORT = 6505, DEFAULT_HOST = "127.0.0.1", DEFAULT_TIMEOUT_MS = 30000, KEEPALIVE_INTERVAL_MS = 1e4, SECOND_CONNECTION_CLOSE_CODE = 4000, OTHER_PROJECT_CLOSE_CODE = 4001, ONE_SHOT_CLOSE_CODE = 4002, CONNECT_WINDOW_MS = 35000, GodotBridge, defaultBridge = null;
37481
37511
  var init_godot_bridge = __esm(() => {
37482
37512
  init_wrapper();
37483
37513
  init_errors();
@@ -37490,6 +37520,9 @@ var init_godot_bridge = __esm(() => {
37490
37520
  socket = null;
37491
37521
  pingInterval = null;
37492
37522
  connectionInfo = null;
37523
+ oneShotReader = null;
37524
+ oneShots = new Map;
37525
+ turnedAway = null;
37493
37526
  pendingRequests = new Map;
37494
37527
  resourceQueues = new Map;
37495
37528
  wantedPort;
@@ -37504,6 +37537,9 @@ var init_godot_bridge = __esm(() => {
37504
37537
  this.timeoutMs = timeoutMs;
37505
37538
  this.ownProject = ownProject;
37506
37539
  }
37540
+ identifyEditorsWith(reader) {
37541
+ this.oneShotReader = reader;
37542
+ }
37507
37543
  get mayMove() {
37508
37544
  return this.ownProject !== null;
37509
37545
  }
@@ -37610,25 +37646,29 @@ var init_godot_bridge = __esm(() => {
37610
37646
  this.log("info", "WebSocket bridge stopped");
37611
37647
  }
37612
37648
  isConnected() {
37613
- return this.socket?.readyState === import_websocket.default.OPEN;
37649
+ return this.socket?.readyState === import_websocket.default.OPEN && this.connectionInfo?.identifying !== true;
37614
37650
  }
37615
37651
  getStatus() {
37652
+ const identifying = this.connectionInfo?.identifying === true;
37653
+ const editor = identifying ? null : this.connectionInfo;
37616
37654
  return {
37617
37655
  host: this.host,
37618
37656
  port: this.port,
37619
37657
  connected: this.isConnected(),
37620
- projectPath: this.connectionInfo?.projectPath,
37621
- connectedAt: this.connectionInfo?.connectedAt,
37622
- lastPongAt: this.connectionInfo?.lastPongAt,
37623
- addonVersion: this.connectionInfo?.addonVersion,
37624
- addonDigest: this.connectionInfo?.addonDigest,
37625
- editorPid: this.connectionInfo?.editorPid,
37626
- lspPort: this.connectionInfo?.lspPort,
37627
- dapPort: this.connectionInfo?.dapPort,
37628
- debugPort: this.connectionInfo?.debugPort,
37629
- openedByAServer: this.connectionInfo?.openedByAServer,
37630
- startedByAnEditor: this.connectionInfo?.startedByAnEditor,
37631
- syncsBreakpoints: this.connectionInfo?.syncsBreakpoints,
37658
+ projectPath: editor?.projectPath,
37659
+ connectedAt: editor?.connectedAt,
37660
+ lastPongAt: editor?.lastPongAt,
37661
+ addonVersion: editor?.addonVersion,
37662
+ addonDigest: editor?.addonDigest,
37663
+ editorPid: editor?.editorPid,
37664
+ lspPort: editor?.lspPort,
37665
+ dapPort: editor?.dapPort,
37666
+ debugPort: editor?.debugPort,
37667
+ openedByAServer: editor?.openedByAServer,
37668
+ startedByAnEditor: editor?.startedByAnEditor,
37669
+ syncsBreakpoints: editor?.syncsBreakpoints,
37670
+ turnedAway: this.turnedAway ?? undefined,
37671
+ identifying: identifying ? true : undefined,
37632
37672
  pendingRequests: this.pendingRequests.size,
37633
37673
  queuedResources: this.resourceQueues.size,
37634
37674
  listeningSince: this.listeningSince ?? undefined,
@@ -37776,6 +37816,11 @@ var init_godot_bridge = __esm(() => {
37776
37816
  this.connectionInfo.startedByAnEditor = typeof message.started_by_an_editor === "boolean" ? message.started_by_an_editor : undefined;
37777
37817
  this.connectionInfo.syncsBreakpoints = typeof message.syncs_breakpoints === "boolean" ? message.syncs_breakpoints : undefined;
37778
37818
  this.log("info", `Godot ready: ${message.project_path}`);
37819
+ const pid = this.connectionInfo.editorPid;
37820
+ if (!this.connectionInfo.openedByAServer && pid !== undefined && this.oneShotReader !== null) {
37821
+ this.identify(pid);
37822
+ return;
37823
+ }
37779
37824
  this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
37780
37825
  }
37781
37826
  return;
@@ -37866,6 +37911,45 @@ var init_godot_bridge = __esm(() => {
37866
37911
  this.stopKeepalive();
37867
37912
  stranger?.close(OTHER_PROJECT_CLOSE_CODE, said);
37868
37913
  }
37914
+ identify(pid) {
37915
+ const known = this.oneShots.get(pid);
37916
+ if (known !== undefined) {
37917
+ this.turnAway(pid, known);
37918
+ return;
37919
+ }
37920
+ const info = this.connectionInfo;
37921
+ const socket = this.socket;
37922
+ const reader = this.oneShotReader;
37923
+ if (info === null || reader === null) {
37924
+ return;
37925
+ }
37926
+ info.identifying = true;
37927
+ reader(pid).catch(() => {
37928
+ return;
37929
+ }).then((option) => {
37930
+ if (this.socket !== socket || this.connectionInfo !== info) {
37931
+ return;
37932
+ }
37933
+ if (typeof option === "string") {
37934
+ this.oneShots.set(pid, option);
37935
+ this.turnAway(pid, option);
37936
+ return;
37937
+ }
37938
+ info.identifying = false;
37939
+ this.log("info", `Godot editor identified: pid ${pid}`);
37940
+ this.emitBridgeEvent("godot_connected", { projectPath: info.projectPath });
37941
+ });
37942
+ }
37943
+ turnAway(pid, option) {
37944
+ const said = `pid ${pid} was started with ${option}, which does one job and quits, so it is not an editor`;
37945
+ this.log("info", `Turning away an engine run: ${said}`);
37946
+ this.turnedAway = { pid, option, at: new Date };
37947
+ const run = this.socket;
37948
+ this.socket = null;
37949
+ this.connectionInfo = null;
37950
+ this.stopKeepalive();
37951
+ run?.close(ONE_SHOT_CLOSE_CODE, said);
37952
+ }
37869
37953
  handleDisconnect(disconnectedSocket, reason) {
37870
37954
  if (disconnectedSocket && this.socket && disconnectedSocket !== this.socket) {
37871
37955
  this.log("debug", "Ignoring stale Godot socket disconnect event");
@@ -40259,6 +40343,7 @@ class GodotServer {
40259
40343
  constructor() {
40260
40344
  this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
40261
40345
  this.godotBridge = getDefaultBridge(this.ownProject);
40346
+ this.godotBridge.identifyEditorsWith(oneShotOptionOf);
40262
40347
  this.godotBridge.on("godot_connected", () => {
40263
40348
  this.settleTheLaunchReading();
40264
40349
  });
@@ -42518,6 +42603,10 @@ class GodotServer {
42518
42603
  const dap = portFromEnvOrNull("GDHARNESS_DAP_PORT") ?? await freePort(DEFAULT_DAP_PORT);
42519
42604
  return { lsp, dap };
42520
42605
  }
42606
+ editorOnTheBridge() {
42607
+ const pid = this.godotBridge.getStatus().editorPid;
42608
+ return pid === undefined ? "the editor on this bridge" : `the editor on this bridge (pid ${pid})`;
42609
+ }
42521
42610
  async handleRunProject(args, op) {
42522
42611
  const project = this.project(args);
42523
42612
  if (!project.ok) {
@@ -42525,7 +42614,7 @@ class GodotServer {
42525
42614
  }
42526
42615
  const open = this.godotBridge.isConnected() ? this.godotBridge.getStatus().projectPath : undefined;
42527
42616
  if (op !== "check" && open !== undefined && open !== "" && !isSameDirectory(open, project.value.path)) {
42528
- return this.createErrorResponse(`This start names ${project.value.path}, and the editor on this bridge has ${open} open.`, [
42617
+ return this.createErrorResponse(`This start names ${project.value.path}, and ${this.editorOnTheBridge()} has ${open} open.`, [
42529
42618
  "editor_status names the project this server's editor is showing",
42530
42619
  "One server serves one editor: start a second server for the other project"
42531
42620
  ]);
@@ -44242,7 +44331,7 @@ class GodotServer {
44242
44331
  const open = this.godotBridge.getStatus().projectPath;
44243
44332
  const meant = readNonEmptyString(args, "projectPath");
44244
44333
  if (open !== undefined && open !== "" && meant !== undefined && !isSameDirectory(open, meant)) {
44245
- return this.createErrorResponse(`This call names ${meant}, and the editor on this bridge has ${open} open.`, [
44334
+ return this.createErrorResponse(`This call names ${meant}, and ${this.editorOnTheBridge()} has ${open} open.`, [
44246
44335
  "editor_status names the project this server's editor is showing",
44247
44336
  "One server serves one editor: start a second server for the other project"
44248
44337
  ]);
@@ -25,6 +25,10 @@ const MAX_RECONNECT_DELAY: float = 30.0
25
25
  ## What a server closes with when the editor that said hello belongs to another project. Kept in
26
26
  ## step with `OTHER_PROJECT_CLOSE_CODE` in src/godot-bridge.ts.
27
27
  const ELSEWHERE_CLOSE_CODE: int = 4001
28
+ ## What a server closes with when this process is one job that quits, such as a gate's `--import`,
29
+ ## rather than an editor: dialling in again changes nothing for the rest of the run. Kept in step
30
+ ## with `ONE_SHOT_CLOSE_CODE` in src/godot-bridge.ts.
31
+ const ONE_SHOT_CLOSE_CODE: int = 4002
28
32
 
29
33
  ## Where Godot keeps the three ports an editor serves. None of them is per editor: the settings
30
34
  ## file is one for every editor of this engine version on the machine, so two open at once want the
@@ -447,6 +451,8 @@ func _read_marker(path: String) -> String:
447
451
  func _handle_disconnect() -> void:
448
452
  _is_connected = false
449
453
  _said_elsewhere()
454
+ if socket.get_close_code() == ONE_SHOT_CLOSE_CODE:
455
+ _should_reconnect = false
450
456
  disconnected.emit()
451
457
 
452
458
  if _should_reconnect:
package/build/index.js CHANGED
@@ -25284,6 +25284,27 @@ function ancestorsIn(tree, pid, root) {
25284
25284
  }
25285
25285
  return chain;
25286
25286
  }
25287
+ var ONE_SHOT_OPTIONS = [
25288
+ "--import",
25289
+ "--quit",
25290
+ "--quit-after",
25291
+ "--export-release",
25292
+ "--export-debug",
25293
+ "--export-pack",
25294
+ "--export-patch",
25295
+ "--doctool",
25296
+ "--gdscript-docs",
25297
+ "--script",
25298
+ "-s",
25299
+ "--check-only",
25300
+ "--build-solutions",
25301
+ "--dump-gdextension-interface",
25302
+ "--dump-extension-api",
25303
+ "--dump-extension-api-with-docs",
25304
+ "--validate-extension-api",
25305
+ "--convert-3to4",
25306
+ "--validate-conversion-3to4"
25307
+ ];
25287
25308
  function readCommandLine(command) {
25288
25309
  const all = commandWords(command);
25289
25310
  const separator = all.findIndex((word) => word === "--" || word === "++");
@@ -25304,9 +25325,18 @@ function readCommandLine(command) {
25304
25325
  return {
25305
25326
  executable,
25306
25327
  projectPath,
25307
- editor: words.some((word) => word === "-e" || word === "--editor")
25328
+ editor: words.some((word) => word === "-e" || word === "--editor"),
25329
+ oneShot: words.find((word) => ONE_SHOT_OPTIONS.includes(word)) ?? null
25308
25330
  };
25309
25331
  }
25332
+ async function oneShotOptionOf(pid) {
25333
+ const tree = await processTree();
25334
+ const listed = tree?.get(pid);
25335
+ if (listed === undefined || listed.command === "") {
25336
+ return;
25337
+ }
25338
+ return readCommandLine(listed.command).oneShot;
25339
+ }
25310
25340
  function commandWords(command) {
25311
25341
  const words = [];
25312
25342
  let word = "";
@@ -27664,6 +27694,7 @@ var DEFAULT_TIMEOUT_MS = 30000;
27664
27694
  var KEEPALIVE_INTERVAL_MS = 1e4;
27665
27695
  var SECOND_CONNECTION_CLOSE_CODE = 4000;
27666
27696
  var OTHER_PROJECT_CLOSE_CODE = 4001;
27697
+ var ONE_SHOT_CLOSE_CODE = 4002;
27667
27698
  function resolveDefaultBridgePort() {
27668
27699
  return portFromEnv("GDHARNESS_BRIDGE_PORT", DEFAULT_PORT);
27669
27700
  }
@@ -27695,6 +27726,9 @@ class GodotBridge extends EventEmitter {
27695
27726
  socket = null;
27696
27727
  pingInterval = null;
27697
27728
  connectionInfo = null;
27729
+ oneShotReader = null;
27730
+ oneShots = new Map;
27731
+ turnedAway = null;
27698
27732
  pendingRequests = new Map;
27699
27733
  resourceQueues = new Map;
27700
27734
  wantedPort;
@@ -27709,6 +27743,9 @@ class GodotBridge extends EventEmitter {
27709
27743
  this.timeoutMs = timeoutMs;
27710
27744
  this.ownProject = ownProject;
27711
27745
  }
27746
+ identifyEditorsWith(reader) {
27747
+ this.oneShotReader = reader;
27748
+ }
27712
27749
  get mayMove() {
27713
27750
  return this.ownProject !== null;
27714
27751
  }
@@ -27815,25 +27852,29 @@ class GodotBridge extends EventEmitter {
27815
27852
  this.log("info", "WebSocket bridge stopped");
27816
27853
  }
27817
27854
  isConnected() {
27818
- return this.socket?.readyState === import_websocket.default.OPEN;
27855
+ return this.socket?.readyState === import_websocket.default.OPEN && this.connectionInfo?.identifying !== true;
27819
27856
  }
27820
27857
  getStatus() {
27858
+ const identifying = this.connectionInfo?.identifying === true;
27859
+ const editor = identifying ? null : this.connectionInfo;
27821
27860
  return {
27822
27861
  host: this.host,
27823
27862
  port: this.port,
27824
27863
  connected: this.isConnected(),
27825
- projectPath: this.connectionInfo?.projectPath,
27826
- connectedAt: this.connectionInfo?.connectedAt,
27827
- lastPongAt: this.connectionInfo?.lastPongAt,
27828
- addonVersion: this.connectionInfo?.addonVersion,
27829
- addonDigest: this.connectionInfo?.addonDigest,
27830
- editorPid: this.connectionInfo?.editorPid,
27831
- lspPort: this.connectionInfo?.lspPort,
27832
- dapPort: this.connectionInfo?.dapPort,
27833
- debugPort: this.connectionInfo?.debugPort,
27834
- openedByAServer: this.connectionInfo?.openedByAServer,
27835
- startedByAnEditor: this.connectionInfo?.startedByAnEditor,
27836
- syncsBreakpoints: this.connectionInfo?.syncsBreakpoints,
27864
+ projectPath: editor?.projectPath,
27865
+ connectedAt: editor?.connectedAt,
27866
+ lastPongAt: editor?.lastPongAt,
27867
+ addonVersion: editor?.addonVersion,
27868
+ addonDigest: editor?.addonDigest,
27869
+ editorPid: editor?.editorPid,
27870
+ lspPort: editor?.lspPort,
27871
+ dapPort: editor?.dapPort,
27872
+ debugPort: editor?.debugPort,
27873
+ openedByAServer: editor?.openedByAServer,
27874
+ startedByAnEditor: editor?.startedByAnEditor,
27875
+ syncsBreakpoints: editor?.syncsBreakpoints,
27876
+ turnedAway: this.turnedAway ?? undefined,
27877
+ identifying: identifying ? true : undefined,
27837
27878
  pendingRequests: this.pendingRequests.size,
27838
27879
  queuedResources: this.resourceQueues.size,
27839
27880
  listeningSince: this.listeningSince ?? undefined,
@@ -27981,6 +28022,11 @@ class GodotBridge extends EventEmitter {
27981
28022
  this.connectionInfo.startedByAnEditor = typeof message.started_by_an_editor === "boolean" ? message.started_by_an_editor : undefined;
27982
28023
  this.connectionInfo.syncsBreakpoints = typeof message.syncs_breakpoints === "boolean" ? message.syncs_breakpoints : undefined;
27983
28024
  this.log("info", `Godot ready: ${message.project_path}`);
28025
+ const pid = this.connectionInfo.editorPid;
28026
+ if (!this.connectionInfo.openedByAServer && pid !== undefined && this.oneShotReader !== null) {
28027
+ this.identify(pid);
28028
+ return;
28029
+ }
27984
28030
  this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
27985
28031
  }
27986
28032
  return;
@@ -28071,6 +28117,45 @@ class GodotBridge extends EventEmitter {
28071
28117
  this.stopKeepalive();
28072
28118
  stranger?.close(OTHER_PROJECT_CLOSE_CODE, said);
28073
28119
  }
28120
+ identify(pid) {
28121
+ const known = this.oneShots.get(pid);
28122
+ if (known !== undefined) {
28123
+ this.turnAway(pid, known);
28124
+ return;
28125
+ }
28126
+ const info = this.connectionInfo;
28127
+ const socket = this.socket;
28128
+ const reader = this.oneShotReader;
28129
+ if (info === null || reader === null) {
28130
+ return;
28131
+ }
28132
+ info.identifying = true;
28133
+ reader(pid).catch(() => {
28134
+ return;
28135
+ }).then((option) => {
28136
+ if (this.socket !== socket || this.connectionInfo !== info) {
28137
+ return;
28138
+ }
28139
+ if (typeof option === "string") {
28140
+ this.oneShots.set(pid, option);
28141
+ this.turnAway(pid, option);
28142
+ return;
28143
+ }
28144
+ info.identifying = false;
28145
+ this.log("info", `Godot editor identified: pid ${pid}`);
28146
+ this.emitBridgeEvent("godot_connected", { projectPath: info.projectPath });
28147
+ });
28148
+ }
28149
+ turnAway(pid, option) {
28150
+ const said = `pid ${pid} was started with ${option}, which does one job and quits, so it is not an editor`;
28151
+ this.log("info", `Turning away an engine run: ${said}`);
28152
+ this.turnedAway = { pid, option, at: new Date };
28153
+ const run = this.socket;
28154
+ this.socket = null;
28155
+ this.connectionInfo = null;
28156
+ this.stopKeepalive();
28157
+ run?.close(ONE_SHOT_CLOSE_CODE, said);
28158
+ }
28074
28159
  handleDisconnect(disconnectedSocket, reason) {
28075
28160
  if (disconnectedSocket && this.socket && disconnectedSocket !== this.socket) {
28076
28161
  this.log("debug", "Ignoring stale Godot socket disconnect event");
@@ -31601,7 +31686,7 @@ var TOOL_SPECS = [
31601
31686
  },
31602
31687
  {
31603
31688
  name: "editor_status",
31604
- description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. game.run names the run processActive is about, the one editor_output and editor_run stop answer for: its pid, its project, and adopted true when this server read it back from another server rather than starting it; null when there is none. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. Each game also says which runtime addon it loaded, under addonVersion (null when it cannot say); addonIsStale is true when that code is not what this server ships, and staleNote says whether the project needs gdharness upgrade, the game a restart, or this server a reconnect. A second checkout of a project, such as a git worktree, carries addons of its own and is where this shows. Every runtime_* answer from such a game carries addonIsStale and staleNote too. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. True beside restartUnderway is a restart still in progress, its server alive and within the wait for the old editor to go: the editor is coming, and opening another would put two on one project. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait. An editor that has connected and not yet greeted, which lasts a frame and longer while it imports, reports nothing about itself: greeting says so, addonIsStale is absent rather than guessed, and a restart asked then waits for the greeting to learn who opened it. startedByAnEditor is an editor another editor started, by Godot's own restart or the project manager, which has none of the arguments a server gave the one before it; startedByAnEditorNote says what that costs.",
31689
+ description: "Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable: runtimeConnected counts the games of the project this server serves, and runtimes lists every game on the machine with ofThisProject saying which is which, since a game of another project is reached only by naming its projectPath. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. game.run names the run processActive is about, the one editor_output and editor_run stop answer for: its pid, its project, and adopted true when this server read it back from another server rather than starting it; null when there is none. editor.scan says whether the editor is scanning or importing and how many milliseconds ago its last scan finished (finishedMsAgo), or is null when it did not answer: the editor rewrites the class cache just after a scan, and editor_run waits that out before starting a game, saying so under waitedForEditorScanMs. Each game under runtimes is pinged with a short wait, so one held at a breakpoint is listed as unreachable rather than making this call wait out the runtime timeout; for the run this server is reporting on, heldAt says whether that is what it is, with the same three answers editor_output gives. A game the editor played announces the editor under editorPid, and so does whatever that game started for itself; a game some other server started announces none, which is how the two are told apart when both are running from one project. Each game also says which runtime addon it loaded, under addonVersion (null when it cannot say); addonIsStale is true when that code is not what this server ships, and staleNote says whether the project needs gdharness upgrade, the game a restart, or this server a reconnect. A second checkout of a project, such as a git worktree, carries addons of its own and is where this shows. Every runtime_* answer from such a game carries addonIsStale and staleNote too. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means \"not reached yet\" as often as it means \"no editor\". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. True beside restartUnderway is a restart still in progress, its server alive and within the wait for the old editor to go: the editor is coming, and opening another would put two on one project. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it. addonIsStale is decided by the code of the editor addons rather than their version, so an editor started before an upgrade that left that code alone reports the older addonVersion, is not stale, and addonNote says so: it needs no restart. port is the one the editor bridge is on; when that is not the one this server was configured with, portWanted names the configured one and portNote says why it moved and, when it was the server this one replaced, which pid kept it. A server replacing another of its project that holds the configured port waits for it to stand down and takes the port over, which takes a few seconds; bridgeHandover says so while it waits, and it moves only if that server has not let go within the wait. An editor that has connected and not yet greeted, which lasts a frame and longer while it imports, reports nothing about itself: greeting says so, addonIsStale is absent rather than guessed, and a restart asked then waits for the greeting to learn who opened it. startedByAnEditor is an editor another editor started, by Godot's own restart or the project manager, which has none of the arguments a server gave the one before it; startedByAnEditorNote says what that costs. An engine run that loads the editor addon to do one job and quit, such as a gate's godot --headless --import, an export or a script, is not taken as the editor: a process that greets without a server's mark has its command line read first, and while that is read identifying is true and connected false; one started with such an option is turned away, and turnedAway names the last one, its pid and the option.",
31605
31690
  parameters: {},
31606
31691
  requires: []
31607
31692
  },
@@ -32839,6 +32924,7 @@ class GodotServer {
32839
32924
  constructor() {
32840
32925
  this.ownProject = envValue("GDHARNESS_PROJECT") ?? null;
32841
32926
  this.godotBridge = getDefaultBridge(this.ownProject);
32927
+ this.godotBridge.identifyEditorsWith(oneShotOptionOf);
32842
32928
  this.godotBridge.on("godot_connected", () => {
32843
32929
  this.settleTheLaunchReading();
32844
32930
  });
@@ -35098,6 +35184,10 @@ class GodotServer {
35098
35184
  const dap = portFromEnvOrNull("GDHARNESS_DAP_PORT") ?? await freePort(DEFAULT_DAP_PORT);
35099
35185
  return { lsp, dap };
35100
35186
  }
35187
+ editorOnTheBridge() {
35188
+ const pid = this.godotBridge.getStatus().editorPid;
35189
+ return pid === undefined ? "the editor on this bridge" : `the editor on this bridge (pid ${pid})`;
35190
+ }
35101
35191
  async handleRunProject(args, op) {
35102
35192
  const project = this.project(args);
35103
35193
  if (!project.ok) {
@@ -35105,7 +35195,7 @@ class GodotServer {
35105
35195
  }
35106
35196
  const open = this.godotBridge.isConnected() ? this.godotBridge.getStatus().projectPath : undefined;
35107
35197
  if (op !== "check" && open !== undefined && open !== "" && !isSameDirectory(open, project.value.path)) {
35108
- return this.createErrorResponse(`This start names ${project.value.path}, and the editor on this bridge has ${open} open.`, [
35198
+ return this.createErrorResponse(`This start names ${project.value.path}, and ${this.editorOnTheBridge()} has ${open} open.`, [
35109
35199
  "editor_status names the project this server's editor is showing",
35110
35200
  "One server serves one editor: start a second server for the other project"
35111
35201
  ]);
@@ -36822,7 +36912,7 @@ class GodotServer {
36822
36912
  const open = this.godotBridge.getStatus().projectPath;
36823
36913
  const meant = readNonEmptyString(args, "projectPath");
36824
36914
  if (open !== undefined && open !== "" && meant !== undefined && !isSameDirectory(open, meant)) {
36825
- return this.createErrorResponse(`This call names ${meant}, and the editor on this bridge has ${open} open.`, [
36915
+ return this.createErrorResponse(`This call names ${meant}, and ${this.editorOnTheBridge()} has ${open} open.`, [
36826
36916
  "editor_status names the project this server's editor is showing",
36827
36917
  "One server serves one editor: start a second server for the other project"
36828
36918
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.1.30",
3
+ "version": "1.1.31",
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",