gdharness 0.13.6 → 0.13.8

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 +154 -13
  2. package/build/index.js +154 -13
  3. package/package.json +1 -1
package/build/cli.js CHANGED
@@ -14896,6 +14896,15 @@ function isValueComplete(value) {
14896
14896
  }
14897
14897
  return depth <= 0 && !inString;
14898
14898
  }
14899
+ function settingKeys(content) {
14900
+ const named = new Set;
14901
+ for (const [section, entries] of Object.entries(parseProjectGodot(content))) {
14902
+ for (const key of Object.keys(entries)) {
14903
+ named.add(`${section}/${key}`);
14904
+ }
14905
+ }
14906
+ return named;
14907
+ }
14899
14908
  function parseProjectGodot(content) {
14900
14909
  const result = emptyRecord();
14901
14910
  let section = emptyRecord();
@@ -15730,7 +15739,7 @@ var init_tool_definitions = __esm(() => {
15730
15739
  },
15731
15740
  {
15732
15741
  name: "editor_launch",
15733
- description: "Opens the Godot editor on a project, in a window on this machine, or restarts the one already connected. An editor goes on serving the addon it read at startup, so restart is what puts a gdharness upgrade into effect; it saves open scenes on the way out and answers with the version that came back. Only an editor with a window can be restarted, because the engine hands back none of the arguments it was started with. editor_status says which editor is connected and whether it is holding an old addon.",
15742
+ description: "Opens the Godot editor on a project, in a window on this machine, or restarts the one already connected. An editor goes on serving the addon it read at startup, so restart is what puts a gdharness upgrade into effect; it saves open scenes on the way out and answers with the version that came back. Only an editor with a window can be restarted, because the engine hands back none of the arguments it was started with. editor_status says which editor is connected and whether it is holding an old addon. A restart saves project.godot on the way out, and Godot writes only what differs from its own defaults, so a key named deliberately at its default value is dropped: settingsDropped names any that went, because nothing else will say so until something depends on one.",
15734
15743
  parameters: { projectPath: PROJECT_PATH },
15735
15744
  requires: [],
15736
15745
  operations: {
@@ -26630,13 +26639,14 @@ var require_fast_uri = __commonJS(function(exports, module) {
26630
26639
  if (!malformedIPLiteral) {
26631
26640
  malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP);
26632
26641
  }
26633
- if (!schemeHandler || schemeHandler && !schemeHandler.skipNormalize) {
26634
- if (uri.indexOf("%") !== -1) {
26635
- if (parsed.host !== undefined && !malformedIPLiteral) {
26636
- const host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true);
26637
- parsed.host = reescapeHostDelimiters(host, isIP);
26638
- }
26642
+ if (uri.indexOf("%") !== -1 && parsed.host !== undefined && !malformedIPLiteral) {
26643
+ let host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true);
26644
+ if (!isIP) {
26645
+ host = normalizePercentEncoding(host.toLowerCase());
26639
26646
  }
26647
+ parsed.host = reescapeHostDelimiters(host, isIP);
26648
+ }
26649
+ if (!schemeHandler || schemeHandler && !schemeHandler.skipNormalize) {
26640
26650
  if (parsed.path) {
26641
26651
  parsed.path = normalizePathEncoding(parsed.path);
26642
26652
  }
@@ -36320,6 +36330,41 @@ function writeRunRecord(record) {
36320
36330
  mkdirSync6(runsDirectory(), { recursive: true });
36321
36331
  writeFileSync8(recordPath(), JSON.stringify(record, null, 2), "utf8");
36322
36332
  }
36333
+ function editorNotePath() {
36334
+ return join15(runsDirectory(), "editor-run.json");
36335
+ }
36336
+ function writeEditorRunNote(note) {
36337
+ mkdirSync6(runsDirectory(), { recursive: true });
36338
+ writeFileSync8(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
36339
+ }
36340
+ function readEditorRunNote() {
36341
+ for (const directory of runtimeDirectories()) {
36342
+ const note = editorNoteAt(join15(directory, "runs", "editor-run.json"));
36343
+ if (note !== null) {
36344
+ return note;
36345
+ }
36346
+ }
36347
+ return null;
36348
+ }
36349
+ function editorNoteAt(path) {
36350
+ let parsed;
36351
+ try {
36352
+ parsed = JSON.parse(readFileSync13(path, "utf8"));
36353
+ } catch {
36354
+ return null;
36355
+ }
36356
+ if (!parsed || typeof parsed !== "object") {
36357
+ return null;
36358
+ }
36359
+ const fields = parsed;
36360
+ const projectPath = fields["projectPath"];
36361
+ const transcript = fields["transcript"];
36362
+ const startedAt = fields["startedAt"];
36363
+ if (typeof projectPath !== "string" || typeof transcript !== "string" || typeof startedAt !== "number") {
36364
+ return null;
36365
+ }
36366
+ return { projectPath, transcript, startedAt };
36367
+ }
36323
36368
  function recordRunEnded(pid, exitCode) {
36324
36369
  const path = recordPath();
36325
36370
  const record = recordAt(path);
@@ -36512,6 +36557,7 @@ var init_setup = __esm(() => {
36512
36557
  import { execFile as execFile6, spawn } from "node:child_process";
36513
36558
  import { randomUUID as randomUUID2 } from "node:crypto";
36514
36559
  import {
36560
+ appendFileSync,
36515
36561
  closeSync,
36516
36562
  existsSync as existsSync14,
36517
36563
  fstatSync,
@@ -36521,7 +36567,8 @@ import {
36521
36567
  readdirSync as readdirSync7,
36522
36568
  readFileSync as readFileSync15,
36523
36569
  readSync,
36524
- rmSync as rmSync9
36570
+ rmSync as rmSync9,
36571
+ statSync as statSync4
36525
36572
  } from "node:fs";
36526
36573
  import { tmpdir as tmpdir5 } from "node:os";
36527
36574
  import { basename as basename3, dirname as dirname9, join as join17, normalize as normalize3 } from "node:path";
@@ -37887,6 +37934,7 @@ class GodotServer {
37887
37934
  };
37888
37935
  }));
37889
37936
  const playing = await this.editorPlayingState();
37937
+ await this.pickUpWhatTheEditorIsPlaying();
37890
37938
  return this.jsonTextResponse({
37891
37939
  editor: {
37892
37940
  ...this.getEditorStatusPayload(),
@@ -37904,6 +37952,16 @@ class GodotServer {
37904
37952
  }
37905
37953
  });
37906
37954
  }
37955
+ settingKeysOf(projectPath) {
37956
+ if (projectPath === undefined) {
37957
+ return new Set;
37958
+ }
37959
+ try {
37960
+ return settingKeys(readFileSync15(join17(projectPath, "project.godot"), "utf8"));
37961
+ } catch {
37962
+ return new Set;
37963
+ }
37964
+ }
37907
37965
  async handleRestartEditor() {
37908
37966
  const before = this.godotBridge.getStatus();
37909
37967
  if (!before.connected) {
@@ -37912,6 +37970,7 @@ class GodotServer {
37912
37970
  "editor_status says whether the bridge is up and what has reached it"
37913
37971
  ]);
37914
37972
  }
37973
+ const settingsBefore = this.settingKeysOf(before.projectPath);
37915
37974
  const mine = before.openedByAServer === true && before.projectPath !== undefined;
37916
37975
  const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid) : await this.handleViaBridge("restart_editor", {});
37917
37976
  if (asked.isError === true) {
@@ -37931,6 +37990,8 @@ class GodotServer {
37931
37990
  ]);
37932
37991
  }
37933
37992
  const now = this.godotBridge.getStatus();
37993
+ const after = this.settingKeysOf(before.projectPath);
37994
+ const dropped = [...settingsBefore].filter((key) => !after.has(key));
37934
37995
  return this.jsonTextResponse({
37935
37996
  restarted: true,
37936
37997
  editorPid: now.editorPid,
@@ -37938,6 +37999,8 @@ class GodotServer {
37938
37999
  serverVersion: SERVER_VERSION,
37939
38000
  addonIsStale: now.addonVersion !== SERVER_VERSION,
37940
38001
  staleNote: addonMismatch(now.addonVersion, SERVER_VERSION),
38002
+ settingsDropped: dropped.length > 0 ? dropped : undefined,
38003
+ settingsNote: dropped.length > 0 ? 'The editor saved project.godot on its way out and these keys are no longer in it. Godot writes only what differs from its own defaults, so a key named deliberately at its default value is redundant to the editor and is dropped on save. If any of them were pinned on purpose, to keep "set to this on purpose" and "not set" apart, put them back: nothing else will say they have gone until something depends on one.' : undefined,
37941
38004
  tookMs: Date.now() - began
37942
38005
  });
37943
38006
  }
@@ -38128,6 +38191,7 @@ class GodotServer {
38128
38191
  const log = new GameLog;
38129
38192
  try {
38130
38193
  await this.dap().connect();
38194
+ this.dapClient?.getOutput(true);
38131
38195
  } catch (error) {
38132
38196
  return this.createErrorResponse(`The editor is connected but its debug adapter is not: ${errorMessage(error)}`, [
38133
38197
  `This asked on port ${this.dap().port}, which is where the connected editor says it serves`,
@@ -38138,19 +38202,24 @@ class GodotServer {
38138
38202
  if (answer.isError === true) {
38139
38203
  return answer;
38140
38204
  }
38205
+ const startedAt = Date.now();
38206
+ const transcript = openTranscript(startedAt);
38207
+ closeSync(transcript.fd);
38141
38208
  const played = {
38142
38209
  process: null,
38143
38210
  pid: null,
38144
38211
  log,
38145
- transcript: null,
38212
+ transcript: transcript.path,
38146
38213
  readOffset: 0,
38147
38214
  projectPath,
38148
- startedAt: Date.now(),
38215
+ startedAt,
38149
38216
  exitCode: null,
38150
38217
  throughEditor: true,
38151
38218
  brokeOn: null
38152
38219
  };
38153
38220
  this.activeProcess = played;
38221
+ writeEditorRunNote({ projectPath, transcript: transcript.path, startedAt });
38222
+ sweepTranscripts();
38154
38223
  return this.jsonTextResponse({
38155
38224
  started: true,
38156
38225
  through: "editor",
@@ -38166,10 +38235,24 @@ class GodotServer {
38166
38235
  if (!game.throughEditor || !this.dapClient) {
38167
38236
  return;
38168
38237
  }
38238
+ const heard = [];
38169
38239
  for (const line of this.dapClient.getOutput(true)) {
38170
- game.log.append("stdout", line.endsWith(`
38240
+ const ended = line.endsWith(`
38171
38241
  `) ? line : `${line}
38172
- `);
38242
+ `;
38243
+ game.log.append("stdout", ended);
38244
+ heard.push(ended);
38245
+ }
38246
+ if (heard.length > 0 && game.transcript !== null) {
38247
+ try {
38248
+ appendFileSync(game.transcript, heard.join(""));
38249
+ game.readOffset = statSync4(game.transcript).size;
38250
+ } catch (error) {
38251
+ this.logDebug(`Could not write an editor-played run's transcript: ${errorMessage(error)}`);
38252
+ }
38253
+ }
38254
+ if (!this.dapClient.isConnected()) {
38255
+ game.consoleLost = true;
38173
38256
  }
38174
38257
  this.recordWhatItBrokeOn(game);
38175
38258
  }
@@ -38322,6 +38405,50 @@ class GodotServer {
38322
38405
  closeSync(handle);
38323
38406
  }
38324
38407
  }
38408
+ async pickUpWhatTheEditorIsPlaying() {
38409
+ if (!this.godotBridge.isConnected()) {
38410
+ return;
38411
+ }
38412
+ const going = this.activeProcess;
38413
+ if (going !== null) {
38414
+ if (going.throughEditor && going.consoleLost === true && (await this.editorPlayingState())?.playing) {
38415
+ try {
38416
+ await this.dap().connect();
38417
+ going.log.append("stdout", `[gdharness] the debug adapter was reconnected here
38418
+ `);
38419
+ } catch (error) {
38420
+ this.logDebug(`Could not reconnect the adapter for a playing run: ${errorMessage(error)}`);
38421
+ }
38422
+ }
38423
+ return;
38424
+ }
38425
+ const playing = await this.editorPlayingState();
38426
+ if (playing?.playing !== true) {
38427
+ return;
38428
+ }
38429
+ try {
38430
+ await this.dap().connect();
38431
+ } catch (error) {
38432
+ this.logDebug(`Picked up an editor-played run without its adapter: ${errorMessage(error)}`);
38433
+ }
38434
+ const project = this.godotBridge.getStatus().projectPath ?? null;
38435
+ const left = readEditorRunNote();
38436
+ const note = left !== null && project !== null && isSameDirectory(left.projectPath, project) ? left : null;
38437
+ const picked = {
38438
+ process: null,
38439
+ pid: null,
38440
+ log: new GameLog,
38441
+ transcript: note?.transcript ?? null,
38442
+ readOffset: 0,
38443
+ projectPath: project,
38444
+ startedAt: note?.startedAt ?? Date.now(),
38445
+ exitCode: null,
38446
+ throughEditor: true,
38447
+ brokeOn: null,
38448
+ pickedUpPlaying: true
38449
+ };
38450
+ this.activeProcess = picked;
38451
+ }
38325
38452
  adoptRecordedRun() {
38326
38453
  const record = readRunRecord();
38327
38454
  if (record === null || !this.couldBeOurs(record.projectPath)) {
@@ -38403,6 +38530,7 @@ class GodotServer {
38403
38530
  });
38404
38531
  }
38405
38532
  async handleWaitForRun(args) {
38533
+ await this.pickUpWhatTheEditorIsPlaying();
38406
38534
  const run = this.currentRun();
38407
38535
  if (!run) {
38408
38536
  return this.createErrorResponse(this.nothingOfOursIsRunning());
@@ -38417,6 +38545,7 @@ class GodotServer {
38417
38545
  return await this.handleGetDebugOutput(args, stillRunning(run) ? budgetMs : undefined);
38418
38546
  }
38419
38547
  async handleGetDebugOutput(args, waitedMs) {
38548
+ await this.pickUpWhatTheEditorIsPlaying();
38420
38549
  const run = this.currentRun();
38421
38550
  if (!run) {
38422
38551
  return this.createErrorResponse(this.nothingOfOursIsRunning());
@@ -38438,12 +38567,22 @@ class GodotServer {
38438
38567
  const wanted = readBoolean(args, "cpu") ?? false;
38439
38568
  const cpuSeconds = wanted && run.pid !== null && stillRunning(run) ? await cpuSecondsOf(run.pid) : undefined;
38440
38569
  const notes = [];
38570
+ if (wanted && cpuSeconds === undefined && stillRunning(run)) {
38571
+ notes.push(run.pid === null ? "cpu was asked for and there is no process to ask: the editor is playing this run and holds it, so nothing here has its process id. editor_run start without a connected editor, or with args, starts the game here instead and that run answers cpuSeconds." : "cpu was asked for and this platform would not say what the run has used.");
38572
+ }
38441
38573
  if (waitedMs !== undefined) {
38442
38574
  notes.push(`The wait of ${waitedMs}ms ran out and the run is still going, so this is what it had printed by then rather than everything it will print. editor_run wait again to keep waiting.`);
38443
38575
  }
38444
38576
  if (run.endedUnwatched === true) {
38445
38577
  notes.push("This run outlived the server that started it and is over now, so its exit code was never collected. Everything it printed is below, read back from its transcript.");
38446
38578
  }
38579
+ if (run.pickedUpPlaying === true) {
38580
+ notes.push(run.transcript === null ? "The editor was already playing this when this server reached it, and the server that started it left no transcript, so this is not the run from its start: what it printed before that is only in the editor. It is the run the editor is holding now, which is the one the debug_* tools answer for." : "The editor was already playing this when this server reached it, and what it printed before that has been read back from its transcript. It is the run the editor is holding now, which is the one the debug_* tools answer for.");
38581
+ }
38582
+ if (run.consoleLost === true) {
38583
+ const back = this.dapClient?.isConnected() === true;
38584
+ notes.push(`The debug adapter this run's console arrives over went away while the run was going, so what is below has a hole in it and the counts are of what was heard rather than of what was printed. ${back ? "It is connected again, and the line marking where is in the log." : "It is still gone, so nothing further will arrive here."} The project's own user://logs/godot.log has the rest, and "no entries" here means "not heard" rather than a quiet run.`);
38585
+ }
38447
38586
  if (typeof run.endedHere === "string") {
38448
38587
  notes.push(`This run was ended here, by ${run.endedHere}.`);
38449
38588
  } else if (!stillRunning(run) && !run.throughEditor && run.exitCode !== null) {
@@ -38459,7 +38598,8 @@ class GodotServer {
38459
38598
  pid: run.pid,
38460
38599
  errors: run.log.count("error"),
38461
38600
  warnings: run.log.count("warning"),
38462
- clean: run.log.count("error") === 0,
38601
+ clean: run.consoleLost === true ? undefined : run.log.count("error") === 0,
38602
+ consoleLost: run.consoleLost === true ? true : undefined,
38463
38603
  heldAt: halt,
38464
38604
  endedUnwatched: run.endedUnwatched === true ? true : undefined,
38465
38605
  endedBy: stillRunning(run) ? undefined : run.endedHere ?? null,
@@ -38474,6 +38614,7 @@ class GodotServer {
38474
38614
  });
38475
38615
  }
38476
38616
  async handleStopProject() {
38617
+ await this.pickUpWhatTheEditorIsPlaying();
38477
38618
  const stopped = this.currentRun();
38478
38619
  if (!stopped) {
38479
38620
  return this.createErrorResponse(this.nothingOfOursIsRunning());
package/build/index.js CHANGED
@@ -4060,13 +4060,14 @@ var require_fast_uri = __commonJS(function(exports, module) {
4060
4060
  if (!malformedIPLiteral) {
4061
4061
  malformedHost = canonicalizeHost(parsed, options, schemeHandler, isIP);
4062
4062
  }
4063
- if (!schemeHandler || schemeHandler && !schemeHandler.skipNormalize) {
4064
- if (uri.indexOf("%") !== -1) {
4065
- if (parsed.host !== undefined && !malformedIPLiteral) {
4066
- const host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true);
4067
- parsed.host = reescapeHostDelimiters(host, isIP);
4068
- }
4063
+ if (uri.indexOf("%") !== -1 && parsed.host !== undefined && !malformedIPLiteral) {
4064
+ let host = isIP ? parsed.host : normalizePercentEncoding(parsed.host, true);
4065
+ if (!isIP) {
4066
+ host = normalizePercentEncoding(host.toLowerCase());
4069
4067
  }
4068
+ parsed.host = reescapeHostDelimiters(host, isIP);
4069
+ }
4070
+ if (!schemeHandler || schemeHandler && !schemeHandler.skipNormalize) {
4070
4071
  if (parsed.path) {
4071
4072
  parsed.path = normalizePathEncoding(parsed.path);
4072
4073
  }
@@ -10141,6 +10142,7 @@ function defectReport(where, error, godotVersion) {
10141
10142
  import { execFile as execFile4, spawn } from "node:child_process";
10142
10143
  import { randomUUID as randomUUID2 } from "node:crypto";
10143
10144
  import {
10145
+ appendFileSync,
10144
10146
  closeSync,
10145
10147
  existsSync as existsSync9,
10146
10148
  fstatSync,
@@ -10150,7 +10152,8 @@ import {
10150
10152
  readdirSync as readdirSync6,
10151
10153
  readFileSync as readFileSync11,
10152
10154
  readSync,
10153
- rmSync as rmSync5
10155
+ rmSync as rmSync5,
10156
+ statSync as statSync4
10154
10157
  } from "node:fs";
10155
10158
  import { tmpdir as tmpdir4 } from "node:os";
10156
10159
  import { basename as basename2, dirname as dirname4, join as join11, normalize as normalize2 } from "node:path";
@@ -27544,6 +27547,15 @@ function isValueComplete(value) {
27544
27547
  }
27545
27548
  return depth <= 0 && !inString;
27546
27549
  }
27550
+ function settingKeys(content) {
27551
+ const named = new Set;
27552
+ for (const [section, entries] of Object.entries(parseProjectGodot(content))) {
27553
+ for (const key of Object.keys(entries)) {
27554
+ named.add(`${section}/${key}`);
27555
+ }
27556
+ }
27557
+ return named;
27558
+ }
27547
27559
  function parseProjectGodot(content) {
27548
27560
  const result = emptyRecord();
27549
27561
  let section = emptyRecord();
@@ -27972,6 +27984,41 @@ function writeRunRecord(record) {
27972
27984
  mkdirSync3(runsDirectory(), { recursive: true });
27973
27985
  writeFileSync4(recordPath(), JSON.stringify(record, null, 2), "utf8");
27974
27986
  }
27987
+ function editorNotePath() {
27988
+ return join9(runsDirectory(), "editor-run.json");
27989
+ }
27990
+ function writeEditorRunNote(note) {
27991
+ mkdirSync3(runsDirectory(), { recursive: true });
27992
+ writeFileSync4(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
27993
+ }
27994
+ function readEditorRunNote() {
27995
+ for (const directory of runtimeDirectories()) {
27996
+ const note = editorNoteAt(join9(directory, "runs", "editor-run.json"));
27997
+ if (note !== null) {
27998
+ return note;
27999
+ }
28000
+ }
28001
+ return null;
28002
+ }
28003
+ function editorNoteAt(path) {
28004
+ let parsed;
28005
+ try {
28006
+ parsed = JSON.parse(readFileSync9(path, "utf8"));
28007
+ } catch {
28008
+ return null;
28009
+ }
28010
+ if (!parsed || typeof parsed !== "object") {
28011
+ return null;
28012
+ }
28013
+ const fields = parsed;
28014
+ const projectPath = fields["projectPath"];
28015
+ const transcript = fields["transcript"];
28016
+ const startedAt = fields["startedAt"];
28017
+ if (typeof projectPath !== "string" || typeof transcript !== "string" || typeof startedAt !== "number") {
28018
+ return null;
28019
+ }
28020
+ return { projectPath, transcript, startedAt };
28021
+ }
27975
28022
  function recordRunEnded(pid, exitCode) {
27976
28023
  const path = recordPath();
27977
28024
  const record = recordAt(path);
@@ -28744,7 +28791,7 @@ var TOOL_SPECS = [
28744
28791
  },
28745
28792
  {
28746
28793
  name: "editor_launch",
28747
- description: "Opens the Godot editor on a project, in a window on this machine, or restarts the one already connected. An editor goes on serving the addon it read at startup, so restart is what puts a gdharness upgrade into effect; it saves open scenes on the way out and answers with the version that came back. Only an editor with a window can be restarted, because the engine hands back none of the arguments it was started with. editor_status says which editor is connected and whether it is holding an old addon.",
28794
+ description: "Opens the Godot editor on a project, in a window on this machine, or restarts the one already connected. An editor goes on serving the addon it read at startup, so restart is what puts a gdharness upgrade into effect; it saves open scenes on the way out and answers with the version that came back. Only an editor with a window can be restarted, because the engine hands back none of the arguments it was started with. editor_status says which editor is connected and whether it is holding an old addon. A restart saves project.godot on the way out, and Godot writes only what differs from its own defaults, so a key named deliberately at its default value is dropped: settingsDropped names any that went, because nothing else will say so until something depends on one.",
28748
28795
  parameters: { projectPath: PROJECT_PATH },
28749
28796
  requires: [],
28750
28797
  operations: {
@@ -30641,6 +30688,7 @@ class GodotServer {
30641
30688
  };
30642
30689
  }));
30643
30690
  const playing = await this.editorPlayingState();
30691
+ await this.pickUpWhatTheEditorIsPlaying();
30644
30692
  return this.jsonTextResponse({
30645
30693
  editor: {
30646
30694
  ...this.getEditorStatusPayload(),
@@ -30658,6 +30706,16 @@ class GodotServer {
30658
30706
  }
30659
30707
  });
30660
30708
  }
30709
+ settingKeysOf(projectPath) {
30710
+ if (projectPath === undefined) {
30711
+ return new Set;
30712
+ }
30713
+ try {
30714
+ return settingKeys(readFileSync11(join11(projectPath, "project.godot"), "utf8"));
30715
+ } catch {
30716
+ return new Set;
30717
+ }
30718
+ }
30661
30719
  async handleRestartEditor() {
30662
30720
  const before = this.godotBridge.getStatus();
30663
30721
  if (!before.connected) {
@@ -30666,6 +30724,7 @@ class GodotServer {
30666
30724
  "editor_status says whether the bridge is up and what has reached it"
30667
30725
  ]);
30668
30726
  }
30727
+ const settingsBefore = this.settingKeysOf(before.projectPath);
30669
30728
  const mine = before.openedByAServer === true && before.projectPath !== undefined;
30670
30729
  const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid) : await this.handleViaBridge("restart_editor", {});
30671
30730
  if (asked.isError === true) {
@@ -30685,6 +30744,8 @@ class GodotServer {
30685
30744
  ]);
30686
30745
  }
30687
30746
  const now = this.godotBridge.getStatus();
30747
+ const after = this.settingKeysOf(before.projectPath);
30748
+ const dropped = [...settingsBefore].filter((key) => !after.has(key));
30688
30749
  return this.jsonTextResponse({
30689
30750
  restarted: true,
30690
30751
  editorPid: now.editorPid,
@@ -30692,6 +30753,8 @@ class GodotServer {
30692
30753
  serverVersion: SERVER_VERSION,
30693
30754
  addonIsStale: now.addonVersion !== SERVER_VERSION,
30694
30755
  staleNote: addonMismatch(now.addonVersion, SERVER_VERSION),
30756
+ settingsDropped: dropped.length > 0 ? dropped : undefined,
30757
+ settingsNote: dropped.length > 0 ? 'The editor saved project.godot on its way out and these keys are no longer in it. Godot writes only what differs from its own defaults, so a key named deliberately at its default value is redundant to the editor and is dropped on save. If any of them were pinned on purpose, to keep "set to this on purpose" and "not set" apart, put them back: nothing else will say they have gone until something depends on one.' : undefined,
30695
30758
  tookMs: Date.now() - began
30696
30759
  });
30697
30760
  }
@@ -30882,6 +30945,7 @@ class GodotServer {
30882
30945
  const log = new GameLog;
30883
30946
  try {
30884
30947
  await this.dap().connect();
30948
+ this.dapClient?.getOutput(true);
30885
30949
  } catch (error) {
30886
30950
  return this.createErrorResponse(`The editor is connected but its debug adapter is not: ${errorMessage(error)}`, [
30887
30951
  `This asked on port ${this.dap().port}, which is where the connected editor says it serves`,
@@ -30892,19 +30956,24 @@ class GodotServer {
30892
30956
  if (answer.isError === true) {
30893
30957
  return answer;
30894
30958
  }
30959
+ const startedAt = Date.now();
30960
+ const transcript = openTranscript(startedAt);
30961
+ closeSync(transcript.fd);
30895
30962
  const played = {
30896
30963
  process: null,
30897
30964
  pid: null,
30898
30965
  log,
30899
- transcript: null,
30966
+ transcript: transcript.path,
30900
30967
  readOffset: 0,
30901
30968
  projectPath,
30902
- startedAt: Date.now(),
30969
+ startedAt,
30903
30970
  exitCode: null,
30904
30971
  throughEditor: true,
30905
30972
  brokeOn: null
30906
30973
  };
30907
30974
  this.activeProcess = played;
30975
+ writeEditorRunNote({ projectPath, transcript: transcript.path, startedAt });
30976
+ sweepTranscripts();
30908
30977
  return this.jsonTextResponse({
30909
30978
  started: true,
30910
30979
  through: "editor",
@@ -30920,10 +30989,24 @@ class GodotServer {
30920
30989
  if (!game.throughEditor || !this.dapClient) {
30921
30990
  return;
30922
30991
  }
30992
+ const heard = [];
30923
30993
  for (const line of this.dapClient.getOutput(true)) {
30924
- game.log.append("stdout", line.endsWith(`
30994
+ const ended = line.endsWith(`
30925
30995
  `) ? line : `${line}
30926
- `);
30996
+ `;
30997
+ game.log.append("stdout", ended);
30998
+ heard.push(ended);
30999
+ }
31000
+ if (heard.length > 0 && game.transcript !== null) {
31001
+ try {
31002
+ appendFileSync(game.transcript, heard.join(""));
31003
+ game.readOffset = statSync4(game.transcript).size;
31004
+ } catch (error) {
31005
+ this.logDebug(`Could not write an editor-played run's transcript: ${errorMessage(error)}`);
31006
+ }
31007
+ }
31008
+ if (!this.dapClient.isConnected()) {
31009
+ game.consoleLost = true;
30927
31010
  }
30928
31011
  this.recordWhatItBrokeOn(game);
30929
31012
  }
@@ -31076,6 +31159,50 @@ class GodotServer {
31076
31159
  closeSync(handle);
31077
31160
  }
31078
31161
  }
31162
+ async pickUpWhatTheEditorIsPlaying() {
31163
+ if (!this.godotBridge.isConnected()) {
31164
+ return;
31165
+ }
31166
+ const going = this.activeProcess;
31167
+ if (going !== null) {
31168
+ if (going.throughEditor && going.consoleLost === true && (await this.editorPlayingState())?.playing) {
31169
+ try {
31170
+ await this.dap().connect();
31171
+ going.log.append("stdout", `[gdharness] the debug adapter was reconnected here
31172
+ `);
31173
+ } catch (error) {
31174
+ this.logDebug(`Could not reconnect the adapter for a playing run: ${errorMessage(error)}`);
31175
+ }
31176
+ }
31177
+ return;
31178
+ }
31179
+ const playing = await this.editorPlayingState();
31180
+ if (playing?.playing !== true) {
31181
+ return;
31182
+ }
31183
+ try {
31184
+ await this.dap().connect();
31185
+ } catch (error) {
31186
+ this.logDebug(`Picked up an editor-played run without its adapter: ${errorMessage(error)}`);
31187
+ }
31188
+ const project = this.godotBridge.getStatus().projectPath ?? null;
31189
+ const left = readEditorRunNote();
31190
+ const note = left !== null && project !== null && isSameDirectory(left.projectPath, project) ? left : null;
31191
+ const picked = {
31192
+ process: null,
31193
+ pid: null,
31194
+ log: new GameLog,
31195
+ transcript: note?.transcript ?? null,
31196
+ readOffset: 0,
31197
+ projectPath: project,
31198
+ startedAt: note?.startedAt ?? Date.now(),
31199
+ exitCode: null,
31200
+ throughEditor: true,
31201
+ brokeOn: null,
31202
+ pickedUpPlaying: true
31203
+ };
31204
+ this.activeProcess = picked;
31205
+ }
31079
31206
  adoptRecordedRun() {
31080
31207
  const record = readRunRecord();
31081
31208
  if (record === null || !this.couldBeOurs(record.projectPath)) {
@@ -31157,6 +31284,7 @@ class GodotServer {
31157
31284
  });
31158
31285
  }
31159
31286
  async handleWaitForRun(args) {
31287
+ await this.pickUpWhatTheEditorIsPlaying();
31160
31288
  const run = this.currentRun();
31161
31289
  if (!run) {
31162
31290
  return this.createErrorResponse(this.nothingOfOursIsRunning());
@@ -31171,6 +31299,7 @@ class GodotServer {
31171
31299
  return await this.handleGetDebugOutput(args, stillRunning(run) ? budgetMs : undefined);
31172
31300
  }
31173
31301
  async handleGetDebugOutput(args, waitedMs) {
31302
+ await this.pickUpWhatTheEditorIsPlaying();
31174
31303
  const run = this.currentRun();
31175
31304
  if (!run) {
31176
31305
  return this.createErrorResponse(this.nothingOfOursIsRunning());
@@ -31192,12 +31321,22 @@ class GodotServer {
31192
31321
  const wanted = readBoolean(args, "cpu") ?? false;
31193
31322
  const cpuSeconds = wanted && run.pid !== null && stillRunning(run) ? await cpuSecondsOf(run.pid) : undefined;
31194
31323
  const notes = [];
31324
+ if (wanted && cpuSeconds === undefined && stillRunning(run)) {
31325
+ notes.push(run.pid === null ? "cpu was asked for and there is no process to ask: the editor is playing this run and holds it, so nothing here has its process id. editor_run start without a connected editor, or with args, starts the game here instead and that run answers cpuSeconds." : "cpu was asked for and this platform would not say what the run has used.");
31326
+ }
31195
31327
  if (waitedMs !== undefined) {
31196
31328
  notes.push(`The wait of ${waitedMs}ms ran out and the run is still going, so this is what it had printed by then rather than everything it will print. editor_run wait again to keep waiting.`);
31197
31329
  }
31198
31330
  if (run.endedUnwatched === true) {
31199
31331
  notes.push("This run outlived the server that started it and is over now, so its exit code was never collected. Everything it printed is below, read back from its transcript.");
31200
31332
  }
31333
+ if (run.pickedUpPlaying === true) {
31334
+ notes.push(run.transcript === null ? "The editor was already playing this when this server reached it, and the server that started it left no transcript, so this is not the run from its start: what it printed before that is only in the editor. It is the run the editor is holding now, which is the one the debug_* tools answer for." : "The editor was already playing this when this server reached it, and what it printed before that has been read back from its transcript. It is the run the editor is holding now, which is the one the debug_* tools answer for.");
31335
+ }
31336
+ if (run.consoleLost === true) {
31337
+ const back = this.dapClient?.isConnected() === true;
31338
+ notes.push(`The debug adapter this run's console arrives over went away while the run was going, so what is below has a hole in it and the counts are of what was heard rather than of what was printed. ${back ? "It is connected again, and the line marking where is in the log." : "It is still gone, so nothing further will arrive here."} The project's own user://logs/godot.log has the rest, and "no entries" here means "not heard" rather than a quiet run.`);
31339
+ }
31201
31340
  if (typeof run.endedHere === "string") {
31202
31341
  notes.push(`This run was ended here, by ${run.endedHere}.`);
31203
31342
  } else if (!stillRunning(run) && !run.throughEditor && run.exitCode !== null) {
@@ -31213,7 +31352,8 @@ class GodotServer {
31213
31352
  pid: run.pid,
31214
31353
  errors: run.log.count("error"),
31215
31354
  warnings: run.log.count("warning"),
31216
- clean: run.log.count("error") === 0,
31355
+ clean: run.consoleLost === true ? undefined : run.log.count("error") === 0,
31356
+ consoleLost: run.consoleLost === true ? true : undefined,
31217
31357
  heldAt: halt,
31218
31358
  endedUnwatched: run.endedUnwatched === true ? true : undefined,
31219
31359
  endedBy: stillRunning(run) ? undefined : run.endedHere ?? null,
@@ -31228,6 +31368,7 @@ class GodotServer {
31228
31368
  });
31229
31369
  }
31230
31370
  async handleStopProject() {
31371
+ await this.pickUpWhatTheEditorIsPlaying();
31231
31372
  const stopped = this.currentRun();
31232
31373
  if (!stopped) {
31233
31374
  return this.createErrorResponse(this.nothingOfOursIsRunning());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.13.6",
3
+ "version": "0.13.8",
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",