gdharness 0.9.2 → 0.9.4

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
@@ -173,6 +173,7 @@ function userDataIn(home, variables = process.env) {
173
173
  }
174
174
  return carried;
175
175
  }
176
+ var OPENED_BY_A_SERVER = "GDHARNESS_OPENED_BY_A_SERVER";
176
177
 
177
178
  // node_modules/yaml/dist/nodes/identity.js
178
179
  var require_identity = __commonJS(function(exports) {
@@ -15667,7 +15668,7 @@ var init_tool_definitions = __esm(() => {
15667
15668
  },
15668
15669
  {
15669
15670
  name: "editor_run",
15670
- description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until stop, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way.",
15671
+ description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way.",
15671
15672
  parameters: {
15672
15673
  projectPath: PROJECT_PATH,
15673
15674
  scene: { type: "string", description: "A scene to run instead of the main scene." },
@@ -15680,7 +15681,7 @@ var init_tool_definitions = __esm(() => {
15680
15681
  },
15681
15682
  requires: ["projectPath"],
15682
15683
  operations: {
15683
- start: { summary: "run the project until stop", requires: [] },
15684
+ start: { summary: "run the project until it quits or is stopped", requires: [] },
15684
15685
  stop: { summary: "end the run and answer with what it printed last", requires: [] },
15685
15686
  check: { summary: "boot headless, quit after a few frames, and report the verdict", requires: [] }
15686
15687
  },
@@ -15688,7 +15689,7 @@ var init_tool_definitions = __esm(() => {
15688
15689
  },
15689
15690
  {
15690
15691
  name: "editor_output",
15691
- description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. Answers with the counts and the verdict as well as the entries.",
15692
+ description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. Answers with the counts and the verdict as well as the entries. A run that has quit still answers here, with running false and its exit code, until the next one starts.",
15692
15693
  parameters: {
15693
15694
  severity: {
15694
15695
  type: "string",
@@ -34499,6 +34500,7 @@ var init_godot_bridge = __esm(() => {
34499
34500
  lspPort: this.connectionInfo?.lspPort,
34500
34501
  dapPort: this.connectionInfo?.dapPort,
34501
34502
  debugPort: this.connectionInfo?.debugPort,
34503
+ openedByAServer: this.connectionInfo?.openedByAServer,
34502
34504
  pendingRequests: this.pendingRequests.size,
34503
34505
  queuedResources: this.resourceQueues.size
34504
34506
  };
@@ -34639,6 +34641,7 @@ var init_godot_bridge = __esm(() => {
34639
34641
  this.connectionInfo.lspPort = servedPort(message.lsp_port);
34640
34642
  this.connectionInfo.dapPort = servedPort(message.dap_port);
34641
34643
  this.connectionInfo.debugPort = servedPort(message.debug_port);
34644
+ this.connectionInfo.openedByAServer = message.opened_by_a_server === true;
34642
34645
  this.log("info", `Godot ready: ${message.project_path}`);
34643
34646
  this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
34644
34647
  }
@@ -35999,6 +36002,17 @@ import { promisify as promisify5 } from "node:util";
35999
36002
  function patienceForFrames(frames, atLeast) {
36000
36003
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
36001
36004
  }
36005
+ function alive(pid) {
36006
+ if (pid === undefined) {
36007
+ return false;
36008
+ }
36009
+ try {
36010
+ process.kill(pid, 0);
36011
+ return true;
36012
+ } catch {
36013
+ return false;
36014
+ }
36015
+ }
36002
36016
  function isOfType(value, kind) {
36003
36017
  switch (kind) {
36004
36018
  case "string":
@@ -36234,12 +36248,7 @@ class GodotServer {
36234
36248
  if (announced === null || announced.pid === process.pid) {
36235
36249
  return null;
36236
36250
  }
36237
- try {
36238
- process.kill(announced.pid, 0);
36239
- return announced.pid;
36240
- } catch {
36241
- return null;
36242
- }
36251
+ return alive(announced.pid) ? announced.pid : null;
36243
36252
  }
36244
36253
  stopWatchingForASuccessor() {
36245
36254
  if (this.successorWatch !== null) {
@@ -36699,6 +36708,15 @@ class GodotServer {
36699
36708
  }
36700
36709
  async debuggedGame() {
36701
36710
  const game = this.activeProcess;
36711
+ if (game !== null && game.exitCode !== null) {
36712
+ return {
36713
+ ok: false,
36714
+ response: this.createErrorResponse(`The last run has already finished (exit code ${game.exitCode}), so there is no debug session to answer for.`, [
36715
+ "editor_output still reads what it printed, until the next run starts",
36716
+ "editor_run start plays another, which the debugger can hold"
36717
+ ])
36718
+ };
36719
+ }
36702
36720
  if (game === null) {
36703
36721
  return {
36704
36722
  ok: false,
@@ -37191,7 +37209,7 @@ class GodotServer {
37191
37209
  version: godotPath === null ? null : await this.godotVersion(godotPath)
37192
37210
  },
37193
37211
  game: {
37194
- processActive: this.activeProcess !== null,
37212
+ processActive: this.activeProcess?.exitCode === null,
37195
37213
  playingInEditor: playing,
37196
37214
  runtimeConnected: games.some((game) => game.reachable),
37197
37215
  runtimes: games
@@ -37206,7 +37224,8 @@ class GodotServer {
37206
37224
  "editor_status says whether the bridge is up and what has reached it"
37207
37225
  ]);
37208
37226
  }
37209
- const asked = await this.handleViaBridge("restart_editor", {});
37227
+ const mine = before.openedByAServer === true && before.projectPath !== undefined;
37228
+ const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid) : await this.handleViaBridge("restart_editor", {});
37210
37229
  if (asked.isError === true) {
37211
37230
  return asked;
37212
37231
  }
@@ -37234,6 +37253,26 @@ class GodotServer {
37234
37253
  tookMs: Date.now() - began
37235
37254
  });
37236
37255
  }
37256
+ async startItAgain(projectPath, editorPid) {
37257
+ const engine = await this.engine();
37258
+ if (!engine.ok) {
37259
+ return engine.response;
37260
+ }
37261
+ const ports = {
37262
+ lsp: this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT),
37263
+ dap: this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT)
37264
+ };
37265
+ const asked = await this.handleViaBridge("quit_editor", {});
37266
+ if (asked.isError === true) {
37267
+ return asked;
37268
+ }
37269
+ await this.waitForBridge(() => !alive(editorPid), Date.now() + EDITOR_RESTART_TIMEOUT_MS);
37270
+ const opened = await this.openAnEditor(engine.value, projectPath, ports);
37271
+ if (opened.error !== null) {
37272
+ return this.createErrorResponse(`The editor was asked to go and could not be started again: ${opened.error}`, [`editor_launch opens one on ${projectPath}`]);
37273
+ }
37274
+ return this.jsonTextResponse({ restarting: true, pid: opened.pid });
37275
+ }
37237
37276
  async waitForBridge(reached, deadline) {
37238
37277
  for (;; ) {
37239
37278
  if (reached()) {
@@ -37263,14 +37302,28 @@ class GodotServer {
37263
37302
  }
37264
37303
  this.logDebug(`Launching Godot editor for project: ${project.value.path}`);
37265
37304
  const ports = await this.portsForAnEditor();
37266
- const editor = spawn(engine.value, editorArguments(project.value.path, ports), {
37305
+ const opened = await this.openAnEditor(engine.value, project.value.path, ports);
37306
+ if (opened.error !== null) {
37307
+ return this.createErrorResponse(`Could not start the editor: ${opened.error}`);
37308
+ }
37309
+ return this.jsonTextResponse({
37310
+ launched: true,
37311
+ pid: opened.pid,
37312
+ projectPath: project.value.path,
37313
+ lspPort: ports.lsp,
37314
+ dapPort: ports.dap
37315
+ });
37316
+ }
37317
+ async openAnEditor(engine, projectPath, ports) {
37318
+ const editor = spawn(engine, editorArguments(projectPath, ports), {
37267
37319
  stdio: "ignore",
37268
37320
  detached: true,
37269
37321
  env: {
37270
37322
  ...process.env,
37271
37323
  GDHARNESS_RUNTIME_DIR: runtimeDirectory(),
37272
37324
  GDHARNESS_LSP_PORT: String(ports.lsp),
37273
- GDHARNESS_DAP_PORT: String(ports.dap)
37325
+ GDHARNESS_DAP_PORT: String(ports.dap),
37326
+ [OPENED_BY_A_SERVER]: "1"
37274
37327
  }
37275
37328
  });
37276
37329
  const started = await new Promise((resolve) => {
@@ -37282,16 +37335,10 @@ class GodotServer {
37282
37335
  });
37283
37336
  });
37284
37337
  if (started !== null) {
37285
- return this.createErrorResponse(`Could not start the editor: ${started}`);
37338
+ return { pid: null, error: started };
37286
37339
  }
37287
37340
  editor.unref();
37288
- return this.jsonTextResponse({
37289
- launched: true,
37290
- pid: editor.pid ?? null,
37291
- projectPath: project.value.path,
37292
- lspPort: ports.lsp,
37293
- dapPort: ports.dap
37294
- });
37341
+ return { pid: editor.pid ?? null, error: null };
37295
37342
  }
37296
37343
  async portsForAnEditor() {
37297
37344
  const lsp = portFromEnvOrNull("GDHARNESS_LSP_PORT") ?? await freePort(DEFAULT_LSP_PORT);
@@ -37326,7 +37373,7 @@ class GodotServer {
37326
37373
  if (op === "check") {
37327
37374
  return await this.checkBoot(engine.value, project.value.path, sceneArgument, args);
37328
37375
  }
37329
- if (this.activeProcess) {
37376
+ if (this.activeProcess?.exitCode === null) {
37330
37377
  this.logDebug("Ending the running game before starting another");
37331
37378
  await this.endActiveGame();
37332
37379
  }
@@ -37345,11 +37392,6 @@ class GodotServer {
37345
37392
  this.logDebug(`Running Godot project: ${engine.value} ${cmdArgs.join(" ")}`);
37346
37393
  const started = this.spawnGame(engine.value, cmdArgs);
37347
37394
  this.activeProcess = started;
37348
- started.process.on("exit", () => {
37349
- if (this.activeProcess === started) {
37350
- this.activeProcess = null;
37351
- }
37352
- });
37353
37395
  return this.jsonTextResponse({
37354
37396
  started: true,
37355
37397
  through: "gdharness",
@@ -38412,27 +38454,42 @@ function displayPath(harness, platform = process3.platform) {
38412
38454
  }
38413
38455
  return `${harness.scope === "home" ? "~/" : ""}${file.replaceAll("\\", "/")}`;
38414
38456
  }
38415
- function entryFor(shape, launch) {
38457
+ var OURS = ["GODOT_PATH", "GDHARNESS_PROJECT"];
38458
+ function environmentFor(launch, held) {
38459
+ const kept = {};
38460
+ if (typeof held === "object" && held !== null && !Array.isArray(held)) {
38461
+ for (const [name, value] of Object.entries(held)) {
38462
+ if (typeof value === "string" && !OURS.includes(name)) {
38463
+ kept[name] = value;
38464
+ }
38465
+ }
38466
+ }
38467
+ return { ...kept, ...launch.env };
38468
+ }
38469
+ function entryFor(shape, launch, held) {
38470
+ const before = typeof held === "object" && held !== null && !Array.isArray(held) ? { ...held } : {};
38416
38471
  if (shape === "opencode") {
38417
38472
  return {
38473
+ ...before,
38418
38474
  type: "local",
38419
38475
  command: [launch.command, ...launch.args],
38420
- environment: { ...launch.env },
38476
+ environment: environmentFor(launch, before["environment"]),
38421
38477
  enabled: true
38422
38478
  };
38423
38479
  }
38424
38480
  return {
38481
+ ...before,
38425
38482
  ...shape === "typed" ? { type: "stdio" } : {},
38426
38483
  command: launch.command,
38427
38484
  args: [...launch.args],
38428
- env: { ...launch.env }
38485
+ env: environmentFor(launch, before["env"])
38429
38486
  };
38430
38487
  }
38431
38488
  function merged(existing, harness, launch) {
38432
38489
  const root = typeof existing === "object" && existing !== null && !Array.isArray(existing) ? { ...existing } : {};
38433
38490
  const held = root[harness.container];
38434
38491
  const servers = typeof held === "object" && held !== null && !Array.isArray(held) ? { ...held } : {};
38435
- servers[SERVER_KEY] = entryFor(harness.shape, launch);
38492
+ servers[SERVER_KEY] = entryFor(harness.shape, launch, servers[SERVER_KEY]);
38436
38493
  root[harness.container] = servers;
38437
38494
  return root;
38438
38495
  }
@@ -38550,6 +38607,17 @@ function disconnect(harness, projectPath) {
38550
38607
  `, "utf8");
38551
38608
  return { harness, path, action: "removed" };
38552
38609
  }
38610
+ function launchedBy(entry) {
38611
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
38612
+ return;
38613
+ }
38614
+ const held = entry;
38615
+ const whole = held["command"];
38616
+ const args = Array.isArray(held["args"]) ? held["args"] : [];
38617
+ const parts = Array.isArray(whole) ? whole : [whole, ...args];
38618
+ const said = parts.filter((part) => typeof part === "string");
38619
+ return said.length === 0 ? undefined : said.join(" ");
38620
+ }
38553
38621
  function connect(harness, projectPath, launch) {
38554
38622
  const path = configPath(harness, projectPath);
38555
38623
  if (harness.snippet !== undefined) {
@@ -38574,8 +38642,11 @@ function connect(harness, projectPath, launch) {
38574
38642
  }
38575
38643
  const container = typeof existing === "object" && existing !== null && !Array.isArray(existing) ? existing[harness.container] : undefined;
38576
38644
  const already = typeof container === "object" && container !== null && SERVER_KEY in container;
38577
- return put(harness, path, `${JSON.stringify(merged(existing, harness, launch), null, 2)}
38645
+ const written = put(harness, path, `${JSON.stringify(merged(existing, harness, launch), null, 2)}
38578
38646
  `, already);
38647
+ const was = launchedBy(container?.[SERVER_KEY]);
38648
+ const now = launchedBy(entryFor(harness.shape, launch));
38649
+ return was === undefined || was === now ? written : { ...written, wasLaunchedBy: was };
38579
38650
  }
38580
38651
  function put(harness, path, contents, already) {
38581
38652
  mkdirSync(dirname2(path), { recursive: true });
@@ -39262,6 +39333,10 @@ function reportConnection(group, launch, projectPath) {
39262
39333
  return;
39263
39334
  }
39264
39335
  console.log(`${who}: ${written.action} ${written.path}`);
39336
+ if (written.wasLaunchedBy !== undefined) {
39337
+ console.log(` it was launched by ${written.wasLaunchedBy}`);
39338
+ console.log(` now by ${[launch.command, ...launch.args].join(" ")}`);
39339
+ }
39265
39340
  for (const harness of group.harnesses) {
39266
39341
  if (harness.manual !== undefined) {
39267
39342
  console.log(` ${harness.name}: ${harness.manual}`);
@@ -23,8 +23,9 @@ const MAX_RECONNECT_DELAY: float = 30.0
23
23
  const ELSEWHERE_CLOSE_CODE: int = 4001
24
24
 
25
25
  ## Where Godot keeps the three ports an editor serves. None of them is per editor: the settings
26
- ## file is one for every editor on the machine, so two open at once want the same three and the
27
- ## second one binds nothing.
26
+ ## file is one for every editor of this engine version on the machine, so two open at once want the
27
+ ## same three and the second one binds nothing. Read here and never written, for the reason
28
+ ## [method _serves] gives.
28
29
  const LSP_SETTING: String = "network/language_server/remote_port"
29
30
  const DAP_SETTING: String = "network/debug_adapter/remote_port"
30
31
  const DEBUGGER_SETTING: String = "network/debug/remote_port"
@@ -32,6 +33,9 @@ const DEBUGGER_SETTING: String = "network/debug/remote_port"
32
33
  ## the command line. Kept in step with `editorArguments` in src/launch.ts.
33
34
  const LSP_ASKED: String = "GDHARNESS_LSP_PORT"
34
35
  const DAP_ASKED: String = "GDHARNESS_DAP_PORT"
36
+ ## What a server that opened this editor says about itself, so that being opened by one is stated
37
+ ## rather than guessed at from the ports. Kept in step with `OPENED_BY_A_SERVER` in src/launch.ts.
38
+ const OPENED_BY_A_SERVER: String = "GDHARNESS_OPENED_BY_A_SERVER"
35
39
 
36
40
  ## How long one attempt is given before the address is called a bad one.
37
41
  ##
@@ -81,7 +85,6 @@ var _connecting_for: float = 0.0
81
85
  func _ready() -> void:
82
86
  _project_path = ProjectSettings.globalize_path("res://")
83
87
  version_at_load = _loaded_version()
84
- _keep_the_ports_this_editor_was_given()
85
88
 
86
89
  _reconnect_timer = Timer.new()
87
90
  _reconnect_timer.one_shot = true
@@ -273,39 +276,41 @@ func _handle_connect() -> void:
273
276
  "editor_pid": OS.get_process_id(),
274
277
  "lsp_port": _serves(LSP_ASKED, LSP_SETTING),
275
278
  "dap_port": _serves(DAP_ASKED, DAP_SETTING),
276
- "debug_port": _serving(DEBUGGER_SETTING)
279
+ "debug_port": _serving(DEBUGGER_SETTING),
280
+ # Whether a server started this editor, which is the only kind that can be started again
281
+ # as itself: the engine hands back none of the arguments it was given, so the arguments
282
+ # have to come from whoever wrote them. See `restart_editor` in play_tools.gd.
283
+ "opened_by_a_server": _opened_by_a_server()
277
284
  }
278
285
  )
279
286
 
280
287
  connected.emit()
281
288
 
282
289
 
283
- ## Writes the ports this editor was started on into the settings it reads them from.
290
+ ## Whether a gdharness server started this editor, which it says in the environment.
284
291
  ##
285
- ## The command line moved the language server and the debug adapter for this run and the engine
286
- ## keeps that override to itself: the setting still reads whatever it read before, so an editor
287
- ## that restarts itself comes back on the old number and lands on top of whichever editor holds
288
- ## it. Writing it here is what makes the move survive a restart, and what leaves one place either
289
- ## side has to read.
290
- func _keep_the_ports_this_editor_was_given() -> void:
291
- if not Engine.is_editor_hint():
292
- return
293
- _keep_port(LSP_ASKED, LSP_SETTING)
294
- _keep_port(DAP_ASKED, DAP_SETTING)
295
-
296
-
297
- func _keep_port(variable: String, setting: String) -> void:
298
- var port: int = _asked_for(variable)
299
- if port < 1 or port == _serving(setting):
300
- return
301
- var settings: EditorSettings = EditorInterface.get_editor_settings()
302
- if settings != null:
303
- settings.set_setting(setting, port)
292
+ ## It decides who may restart this editor by starting it again. Only a server that wrote the
293
+ ## arguments can write them a second time, and only an editor started that way needs it: one opened
294
+ ## by hand is on the ports its own settings name and comes back on them by itself.
295
+ ##
296
+ ## Said outright rather than read off the two port variables, which a server also sets and anybody
297
+ ## else may: an editor started from a shell that exports them is one somebody opened themselves,
298
+ ## and taking it for a server's is taking an editor off somebody's desk and putting a new one there.
299
+ static func _opened_by_a_server() -> bool:
300
+ return not OS.get_environment(OPENED_BY_A_SERVER).is_empty()
304
301
 
305
302
 
306
- ## What this editor serves: what it was told to when a server opened it, and what its settings
307
- ## say otherwise. The first is the one that counts, because the settings are the thing the command
308
- ## line was overriding.
303
+ ## What this editor serves: what it was told to when a server opened it, and what its settings say
304
+ ## otherwise. The first is the one that counts, because the settings are what the command line was
305
+ ## overriding.
306
+ ##
307
+ ## The settings are read and never written. They were written for a while, because the engine keeps
308
+ ## a command-line port override to itself and hands none of it back, so an editor restarting itself
309
+ ## came up on whatever the settings said. But there is one settings file for every editor of this
310
+ ## engine version on the machine, so writing a port chosen for one project made it the number every
311
+ ## other project's editor came up on: an editor opened by hand for something else inherited it and
312
+ ## collided with the editor it was moved away from. Restarting is done by starting the editor again
313
+ ## with the same arguments now, which is `editor_launch restart`, so there is nothing to persist.
309
314
  func _serves(variable: String, setting: String) -> int:
310
315
  var asked: int = _asked_for(variable)
311
316
  return asked if asked > 0 else _serving(setting)
@@ -77,6 +77,7 @@ func _init_tools() -> void:
77
77
  # Animation tools
78
78
  "play_scene": [_play_tools, "play_scene"],
79
79
  "restart_editor": [_play_tools, "restart_editor"],
80
+ "quit_editor": [_play_tools, "quit_editor"],
80
81
  "stop_playing": [_play_tools, "stop_playing"],
81
82
  "playing_status": [_play_tools, "playing_status"],
82
83
  "create_animation": [_animation_tools, "create_animation"],
@@ -125,6 +125,33 @@ func restart_editor(_args: Dictionary) -> Dictionary:
125
125
  return {"ok": true, "restarting": true, "saved": true}
126
126
 
127
127
 
128
+ ## Ends this editor, having saved, so that whoever opened it can open it again.
129
+ ##
130
+ ## The editor's half of restarting one a server started. Godot's own restart cannot carry the ports
131
+ ## such an editor was given, for the reason above: the engine consumes the arguments and hands none
132
+ ## of them back. So the server starts it again itself, and this is the part only the editor can do.
133
+ ##
134
+ ## Saved first, exactly as the restart above saves. The editor is going either way, and unsaved
135
+ ## scenes are not this tool's to lose.
136
+ func quit_editor(_args: Dictionary) -> Dictionary:
137
+ if EditorInterface.is_playing_scene():
138
+ EditorInterface.stop_playing_scene()
139
+
140
+ EditorInterface.save_all_scenes()
141
+
142
+ # Deferred so this answer is on its way out before the editor goes.
143
+ _leave.call_deferred()
144
+
145
+ return {"ok": true, "quitting": true, "saved": true}
146
+
147
+
148
+ ## Asked of the tree the editor's own window is in, because this object is not in one.
149
+ static func _leave() -> void:
150
+ var base: Control = EditorInterface.get_base_control()
151
+ if base != null and base.get_tree() != null:
152
+ base.get_tree().quit()
153
+
154
+
128
155
  func playing_status(_args: Dictionary) -> Dictionary:
129
156
  var playing: bool = EditorInterface.is_playing_scene()
130
157
  # The debugger's port with it, read now rather than remembered: it is taken again before every
package/build/index.js CHANGED
@@ -25718,6 +25718,7 @@ class GodotBridge extends EventEmitter {
25718
25718
  lspPort: this.connectionInfo?.lspPort,
25719
25719
  dapPort: this.connectionInfo?.dapPort,
25720
25720
  debugPort: this.connectionInfo?.debugPort,
25721
+ openedByAServer: this.connectionInfo?.openedByAServer,
25721
25722
  pendingRequests: this.pendingRequests.size,
25722
25723
  queuedResources: this.resourceQueues.size
25723
25724
  };
@@ -25858,6 +25859,7 @@ class GodotBridge extends EventEmitter {
25858
25859
  this.connectionInfo.lspPort = servedPort(message.lsp_port);
25859
25860
  this.connectionInfo.dapPort = servedPort(message.dap_port);
25860
25861
  this.connectionInfo.debugPort = servedPort(message.debug_port);
25862
+ this.connectionInfo.openedByAServer = message.opened_by_a_server === true;
25861
25863
  this.log("info", `Godot ready: ${message.project_path}`);
25862
25864
  this.emitBridgeEvent("godot_connected", { projectPath: message.project_path });
25863
25865
  }
@@ -26148,6 +26150,7 @@ function runArguments(options) {
26148
26150
  function editorArguments(projectPath, ports) {
26149
26151
  return ["-e", "--path", projectPath, "--lsp-port", String(ports.lsp), "--dap-port", String(ports.dap)];
26150
26152
  }
26153
+ var OPENED_BY_A_SERVER = "GDHARNESS_OPENED_BY_A_SERVER";
26151
26154
  function userDataIn(home, variables = process.env) {
26152
26155
  const moved = ["APPDATA", "XDG_DATA_HOME"];
26153
26156
  const carried = {};
@@ -28259,7 +28262,7 @@ var TOOL_SPECS = [
28259
28262
  },
28260
28263
  {
28261
28264
  name: "editor_run",
28262
- description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until stop, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way.",
28265
+ description: "The run: starting the project, stopping it, or booting it once to see whether it comes up clean. start keeps it running and collecting output until it quits or is stopped, windowed where there is a display and headless where there is not, unless headless says otherwise; only runtime_capture needs the window. A run that quits on its own is kept, so a scene that prints an answer and quits is start, then editor_output until running is false. check boots it headless for a few frames, waits for it to quit, and answers with the verdict: whether it came up, and every error and warning it printed on the way.",
28263
28266
  parameters: {
28264
28267
  projectPath: PROJECT_PATH,
28265
28268
  scene: { type: "string", description: "A scene to run instead of the main scene." },
@@ -28272,7 +28275,7 @@ var TOOL_SPECS = [
28272
28275
  },
28273
28276
  requires: ["projectPath"],
28274
28277
  operations: {
28275
- start: { summary: "run the project until stop", requires: [] },
28278
+ start: { summary: "run the project until it quits or is stopped", requires: [] },
28276
28279
  stop: { summary: "end the run and answer with what it printed last", requires: [] },
28277
28280
  check: { summary: "boot headless, quit after a few frames, and report the verdict", requires: [] }
28278
28281
  },
@@ -28280,7 +28283,7 @@ var TOOL_SPECS = [
28280
28283
  },
28281
28284
  {
28282
28285
  name: "editor_output",
28283
- description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. Answers with the counts and the verdict as well as the entries.",
28286
+ description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. Answers with the counts and the verdict as well as the entries. A run that has quit still answers here, with running false and its exit code, until the next one starts.",
28284
28287
  parameters: {
28285
28288
  severity: {
28286
28289
  type: "string",
@@ -28717,6 +28720,17 @@ var SLOWEST_FRAME_RATE = 20;
28717
28720
  function patienceForFrames(frames, atLeast) {
28718
28721
  return Math.max(atLeast, frames / SLOWEST_FRAME_RATE * 1000 + atLeast);
28719
28722
  }
28723
+ function alive(pid) {
28724
+ if (pid === undefined) {
28725
+ return false;
28726
+ }
28727
+ try {
28728
+ process.kill(pid, 0);
28729
+ return true;
28730
+ } catch {
28731
+ return false;
28732
+ }
28733
+ }
28720
28734
  function isOfType(value, kind) {
28721
28735
  switch (kind) {
28722
28736
  case "string":
@@ -29014,12 +29028,7 @@ class GodotServer {
29014
29028
  if (announced === null || announced.pid === process.pid) {
29015
29029
  return null;
29016
29030
  }
29017
- try {
29018
- process.kill(announced.pid, 0);
29019
- return announced.pid;
29020
- } catch {
29021
- return null;
29022
- }
29031
+ return alive(announced.pid) ? announced.pid : null;
29023
29032
  }
29024
29033
  stopWatchingForASuccessor() {
29025
29034
  if (this.successorWatch !== null) {
@@ -29479,6 +29488,15 @@ class GodotServer {
29479
29488
  }
29480
29489
  async debuggedGame() {
29481
29490
  const game = this.activeProcess;
29491
+ if (game !== null && game.exitCode !== null) {
29492
+ return {
29493
+ ok: false,
29494
+ response: this.createErrorResponse(`The last run has already finished (exit code ${game.exitCode}), so there is no debug session to answer for.`, [
29495
+ "editor_output still reads what it printed, until the next run starts",
29496
+ "editor_run start plays another, which the debugger can hold"
29497
+ ])
29498
+ };
29499
+ }
29482
29500
  if (game === null) {
29483
29501
  return {
29484
29502
  ok: false,
@@ -29971,7 +29989,7 @@ class GodotServer {
29971
29989
  version: godotPath === null ? null : await this.godotVersion(godotPath)
29972
29990
  },
29973
29991
  game: {
29974
- processActive: this.activeProcess !== null,
29992
+ processActive: this.activeProcess?.exitCode === null,
29975
29993
  playingInEditor: playing,
29976
29994
  runtimeConnected: games.some((game) => game.reachable),
29977
29995
  runtimes: games
@@ -29986,7 +30004,8 @@ class GodotServer {
29986
30004
  "editor_status says whether the bridge is up and what has reached it"
29987
30005
  ]);
29988
30006
  }
29989
- const asked = await this.handleViaBridge("restart_editor", {});
30007
+ const mine = before.openedByAServer === true && before.projectPath !== undefined;
30008
+ const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid) : await this.handleViaBridge("restart_editor", {});
29990
30009
  if (asked.isError === true) {
29991
30010
  return asked;
29992
30011
  }
@@ -30014,6 +30033,26 @@ class GodotServer {
30014
30033
  tookMs: Date.now() - began
30015
30034
  });
30016
30035
  }
30036
+ async startItAgain(projectPath, editorPid) {
30037
+ const engine = await this.engine();
30038
+ if (!engine.ok) {
30039
+ return engine.response;
30040
+ }
30041
+ const ports = {
30042
+ lsp: this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT),
30043
+ dap: this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT)
30044
+ };
30045
+ const asked = await this.handleViaBridge("quit_editor", {});
30046
+ if (asked.isError === true) {
30047
+ return asked;
30048
+ }
30049
+ await this.waitForBridge(() => !alive(editorPid), Date.now() + EDITOR_RESTART_TIMEOUT_MS);
30050
+ const opened = await this.openAnEditor(engine.value, projectPath, ports);
30051
+ if (opened.error !== null) {
30052
+ return this.createErrorResponse(`The editor was asked to go and could not be started again: ${opened.error}`, [`editor_launch opens one on ${projectPath}`]);
30053
+ }
30054
+ return this.jsonTextResponse({ restarting: true, pid: opened.pid });
30055
+ }
30017
30056
  async waitForBridge(reached, deadline) {
30018
30057
  for (;; ) {
30019
30058
  if (reached()) {
@@ -30043,14 +30082,28 @@ class GodotServer {
30043
30082
  }
30044
30083
  this.logDebug(`Launching Godot editor for project: ${project.value.path}`);
30045
30084
  const ports = await this.portsForAnEditor();
30046
- const editor = spawn(engine.value, editorArguments(project.value.path, ports), {
30085
+ const opened = await this.openAnEditor(engine.value, project.value.path, ports);
30086
+ if (opened.error !== null) {
30087
+ return this.createErrorResponse(`Could not start the editor: ${opened.error}`);
30088
+ }
30089
+ return this.jsonTextResponse({
30090
+ launched: true,
30091
+ pid: opened.pid,
30092
+ projectPath: project.value.path,
30093
+ lspPort: ports.lsp,
30094
+ dapPort: ports.dap
30095
+ });
30096
+ }
30097
+ async openAnEditor(engine, projectPath, ports) {
30098
+ const editor = spawn(engine, editorArguments(projectPath, ports), {
30047
30099
  stdio: "ignore",
30048
30100
  detached: true,
30049
30101
  env: {
30050
30102
  ...process.env,
30051
30103
  GDHARNESS_RUNTIME_DIR: runtimeDirectory(),
30052
30104
  GDHARNESS_LSP_PORT: String(ports.lsp),
30053
- GDHARNESS_DAP_PORT: String(ports.dap)
30105
+ GDHARNESS_DAP_PORT: String(ports.dap),
30106
+ [OPENED_BY_A_SERVER]: "1"
30054
30107
  }
30055
30108
  });
30056
30109
  const started = await new Promise((resolve) => {
@@ -30062,16 +30115,10 @@ class GodotServer {
30062
30115
  });
30063
30116
  });
30064
30117
  if (started !== null) {
30065
- return this.createErrorResponse(`Could not start the editor: ${started}`);
30118
+ return { pid: null, error: started };
30066
30119
  }
30067
30120
  editor.unref();
30068
- return this.jsonTextResponse({
30069
- launched: true,
30070
- pid: editor.pid ?? null,
30071
- projectPath: project.value.path,
30072
- lspPort: ports.lsp,
30073
- dapPort: ports.dap
30074
- });
30121
+ return { pid: editor.pid ?? null, error: null };
30075
30122
  }
30076
30123
  async portsForAnEditor() {
30077
30124
  const lsp = portFromEnvOrNull("GDHARNESS_LSP_PORT") ?? await freePort(DEFAULT_LSP_PORT);
@@ -30106,7 +30153,7 @@ class GodotServer {
30106
30153
  if (op === "check") {
30107
30154
  return await this.checkBoot(engine.value, project.value.path, sceneArgument, args);
30108
30155
  }
30109
- if (this.activeProcess) {
30156
+ if (this.activeProcess?.exitCode === null) {
30110
30157
  this.logDebug("Ending the running game before starting another");
30111
30158
  await this.endActiveGame();
30112
30159
  }
@@ -30125,11 +30172,6 @@ class GodotServer {
30125
30172
  this.logDebug(`Running Godot project: ${engine.value} ${cmdArgs.join(" ")}`);
30126
30173
  const started = this.spawnGame(engine.value, cmdArgs);
30127
30174
  this.activeProcess = started;
30128
- started.process.on("exit", () => {
30129
- if (this.activeProcess === started) {
30130
- this.activeProcess = null;
30131
- }
30132
- });
30133
30175
  return this.jsonTextResponse({
30134
30176
  started: true,
30135
30177
  through: "gdharness",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
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",