gdharness 0.13.7 → 0.13.9

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 +151 -19
  2. package/build/index.js +151 -19
  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,8 +15739,8 @@ 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.",
15734
- parameters: { projectPath: PROJECT_PATH },
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.",
15743
+ parameters: { projectPath: { ...PROJECT_PATH, ops: ["open"] } },
15735
15744
  requires: [],
15736
15745
  operations: {
15737
15746
  open: { summary: "open the editor on a project", requires: ["projectPath"] },
@@ -15743,7 +15752,7 @@ var init_tool_definitions = __esm(() => {
15743
15752
  name: "editor_run",
15744
15753
  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. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. 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. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice. When it is not listening, mayYetAnnounce says whether that is final: false is a runtime that is not coming, true is a game still on its way up, which editor_status will see and runtimeWaitMs waits longer for. A start this server made also answers with transcript, the file both the run's streams are written to, so a watch on it can be armed off the start rather than off a second call.",
15745
15754
  parameters: {
15746
- projectPath: PROJECT_PATH,
15755
+ projectPath: { ...PROJECT_PATH, ops: ["start", "check"] },
15747
15756
  scene: {
15748
15757
  type: "string",
15749
15758
  ops: ["start", "check"],
@@ -15772,11 +15781,17 @@ var init_tool_definitions = __esm(() => {
15772
15781
  description: "check: how long to give the boot before it is called hung. Default 60000. wait: how long to wait for the run to end before answering anyway. Default 600000."
15773
15782
  }
15774
15783
  },
15775
- requires: ["projectPath"],
15784
+ requires: [],
15776
15785
  operations: {
15777
- start: { summary: "run the project until it quits or is stopped", requires: [] },
15786
+ start: {
15787
+ summary: "run the project until it quits or is stopped",
15788
+ requires: ["projectPath"]
15789
+ },
15778
15790
  stop: { summary: "end the run and answer with what it printed last", requires: [] },
15779
- check: { summary: "boot headless, quit after a few frames, and report the verdict", requires: [] },
15791
+ check: {
15792
+ summary: "boot headless, quit after a few frames, and report the verdict",
15793
+ requires: ["projectPath"]
15794
+ },
15780
15795
  wait: { summary: "wait for the run to end, then answer as editor_output does", requires: [] }
15781
15796
  },
15782
15797
  defaultOperation: "start"
@@ -31159,6 +31174,7 @@ class GodotDAPClient {
31159
31174
  pendingRequests;
31160
31175
  reader = new FrameReader;
31161
31176
  outputBuffer = [];
31177
+ onOutput = null;
31162
31178
  maxOutputLines = 1000;
31163
31179
  initialized = false;
31164
31180
  attached = false;
@@ -31326,6 +31342,13 @@ class GodotDAPClient {
31326
31342
  if (outputText.length > 0) {
31327
31343
  const lines = outputText.split(/\r?\n/).filter((line) => line.length > 0);
31328
31344
  this.outputBuffer.push(...lines);
31345
+ if (this.onOutput !== null) {
31346
+ for (const line of lines) {
31347
+ try {
31348
+ this.onOutput(line);
31349
+ } catch {}
31350
+ }
31351
+ }
31329
31352
  }
31330
31353
  if (this.outputBuffer.length > this.maxOutputLines) {
31331
31354
  this.outputBuffer = this.outputBuffer.slice(this.outputBuffer.length - this.maxOutputLines);
@@ -31382,6 +31405,9 @@ class GodotDAPClient {
31382
31405
  await this.sendRequest("configurationDone", {});
31383
31406
  this.attached = true;
31384
31407
  }
31408
+ setOutputSink(sink) {
31409
+ this.onOutput = sink;
31410
+ }
31385
31411
  getOutput(clear = false) {
31386
31412
  const lines = [...this.outputBuffer];
31387
31413
  if (clear) {
@@ -36321,6 +36347,41 @@ function writeRunRecord(record) {
36321
36347
  mkdirSync6(runsDirectory(), { recursive: true });
36322
36348
  writeFileSync8(recordPath(), JSON.stringify(record, null, 2), "utf8");
36323
36349
  }
36350
+ function editorNotePath() {
36351
+ return join15(runsDirectory(), "editor-run.json");
36352
+ }
36353
+ function writeEditorRunNote(note) {
36354
+ mkdirSync6(runsDirectory(), { recursive: true });
36355
+ writeFileSync8(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
36356
+ }
36357
+ function readEditorRunNote() {
36358
+ for (const directory of runtimeDirectories()) {
36359
+ const note = editorNoteAt(join15(directory, "runs", "editor-run.json"));
36360
+ if (note !== null) {
36361
+ return note;
36362
+ }
36363
+ }
36364
+ return null;
36365
+ }
36366
+ function editorNoteAt(path) {
36367
+ let parsed;
36368
+ try {
36369
+ parsed = JSON.parse(readFileSync13(path, "utf8"));
36370
+ } catch {
36371
+ return null;
36372
+ }
36373
+ if (!parsed || typeof parsed !== "object") {
36374
+ return null;
36375
+ }
36376
+ const fields = parsed;
36377
+ const projectPath = fields["projectPath"];
36378
+ const transcript = fields["transcript"];
36379
+ const startedAt = fields["startedAt"];
36380
+ if (typeof projectPath !== "string" || typeof transcript !== "string" || typeof startedAt !== "number") {
36381
+ return null;
36382
+ }
36383
+ return { projectPath, transcript, startedAt };
36384
+ }
36324
36385
  function recordRunEnded(pid, exitCode) {
36325
36386
  const path = recordPath();
36326
36387
  const record = recordAt(path);
@@ -36513,6 +36574,7 @@ var init_setup = __esm(() => {
36513
36574
  import { execFile as execFile6, spawn } from "node:child_process";
36514
36575
  import { randomUUID as randomUUID2 } from "node:crypto";
36515
36576
  import {
36577
+ appendFileSync,
36516
36578
  closeSync,
36517
36579
  existsSync as existsSync14,
36518
36580
  fstatSync,
@@ -37076,9 +37138,10 @@ class GodotServer {
37076
37138
  if (op !== null) {
37077
37139
  const elsewhere = Object.keys(args).filter((key) => key !== "op" && !opTakes(spec, op, key));
37078
37140
  if (elsewhere.length > 0) {
37141
+ const takes = argumentsOf(spec, op);
37079
37142
  return {
37080
37143
  ok: false,
37081
- response: this.createErrorResponse(`${spec.name} ${op} does not take ${elsewhere.join(", ")}. ${op} takes: ${argumentsOf(spec, op).join(", ")}.`)
37144
+ response: this.createErrorResponse(`${spec.name} ${op} does not take ${elsewhere.join(", ")}. ${takes.length === 0 ? `${op} takes no arguments.` : `${op} takes: ${takes.join(", ")}.`}`)
37082
37145
  };
37083
37146
  }
37084
37147
  }
@@ -37828,9 +37891,27 @@ class GodotServer {
37828
37891
  if (this.dapClient !== null && this.dapClient.port !== port) {
37829
37892
  this.dapClient = null;
37830
37893
  }
37831
- this.dapClient ??= new GodotDAPClient(port);
37894
+ if (this.dapClient === null) {
37895
+ this.dapClient = new GodotDAPClient(port);
37896
+ this.dapClient.setOutputSink((line) => {
37897
+ this.writeToTranscript(line);
37898
+ });
37899
+ }
37832
37900
  return this.dapClient;
37833
37901
  }
37902
+ writeToTranscript(line) {
37903
+ const run = this.activeProcess;
37904
+ if (run?.throughEditor !== true || run.transcript === null) {
37905
+ return;
37906
+ }
37907
+ try {
37908
+ appendFileSync(run.transcript, line.endsWith(`
37909
+ `) ? line : `${line}
37910
+ `);
37911
+ } catch (error) {
37912
+ this.logDebug(`Could not write an editor-played run's transcript: ${errorMessage(error)}`);
37913
+ }
37914
+ }
37834
37915
  editorServes(field, variable, fallback) {
37835
37916
  const named = portFromEnvOrNull(variable);
37836
37917
  if (named !== null) {
@@ -37906,6 +37987,16 @@ class GodotServer {
37906
37987
  }
37907
37988
  });
37908
37989
  }
37990
+ settingKeysOf(projectPath) {
37991
+ if (projectPath === undefined) {
37992
+ return new Set;
37993
+ }
37994
+ try {
37995
+ return settingKeys(readFileSync15(join17(projectPath, "project.godot"), "utf8"));
37996
+ } catch {
37997
+ return new Set;
37998
+ }
37999
+ }
37909
38000
  async handleRestartEditor() {
37910
38001
  const before = this.godotBridge.getStatus();
37911
38002
  if (!before.connected) {
@@ -37914,6 +38005,7 @@ class GodotServer {
37914
38005
  "editor_status says whether the bridge is up and what has reached it"
37915
38006
  ]);
37916
38007
  }
38008
+ const settingsBefore = this.settingKeysOf(before.projectPath);
37917
38009
  const mine = before.openedByAServer === true && before.projectPath !== undefined;
37918
38010
  const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid) : await this.handleViaBridge("restart_editor", {});
37919
38011
  if (asked.isError === true) {
@@ -37933,6 +38025,8 @@ class GodotServer {
37933
38025
  ]);
37934
38026
  }
37935
38027
  const now = this.godotBridge.getStatus();
38028
+ const after = this.settingKeysOf(before.projectPath);
38029
+ const dropped = [...settingsBefore].filter((key) => !after.has(key));
37936
38030
  return this.jsonTextResponse({
37937
38031
  restarted: true,
37938
38032
  editorPid: now.editorPid,
@@ -37940,6 +38034,8 @@ class GodotServer {
37940
38034
  serverVersion: SERVER_VERSION,
37941
38035
  addonIsStale: now.addonVersion !== SERVER_VERSION,
37942
38036
  staleNote: addonMismatch(now.addonVersion, SERVER_VERSION),
38037
+ settingsDropped: dropped.length > 0 ? dropped : undefined,
38038
+ 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,
37943
38039
  tookMs: Date.now() - began
37944
38040
  });
37945
38041
  }
@@ -38130,6 +38226,7 @@ class GodotServer {
38130
38226
  const log = new GameLog;
38131
38227
  try {
38132
38228
  await this.dap().connect();
38229
+ this.dapClient?.getOutput(true);
38133
38230
  } catch (error) {
38134
38231
  return this.createErrorResponse(`The editor is connected but its debug adapter is not: ${errorMessage(error)}`, [
38135
38232
  `This asked on port ${this.dap().port}, which is where the connected editor says it serves`,
@@ -38140,19 +38237,24 @@ class GodotServer {
38140
38237
  if (answer.isError === true) {
38141
38238
  return answer;
38142
38239
  }
38240
+ const startedAt = Date.now();
38241
+ const transcript = openTranscript(startedAt);
38242
+ closeSync(transcript.fd);
38143
38243
  const played = {
38144
38244
  process: null,
38145
38245
  pid: null,
38146
38246
  log,
38147
- transcript: null,
38247
+ transcript: transcript.path,
38148
38248
  readOffset: 0,
38149
38249
  projectPath,
38150
- startedAt: Date.now(),
38250
+ startedAt,
38151
38251
  exitCode: null,
38152
38252
  throughEditor: true,
38153
38253
  brokeOn: null
38154
38254
  };
38155
38255
  this.activeProcess = played;
38256
+ writeEditorRunNote({ projectPath, transcript: transcript.path, startedAt });
38257
+ sweepTranscripts();
38156
38258
  return this.jsonTextResponse({
38157
38259
  started: true,
38158
38260
  through: "editor",
@@ -38168,10 +38270,15 @@ class GodotServer {
38168
38270
  if (!game.throughEditor || !this.dapClient) {
38169
38271
  return;
38170
38272
  }
38171
- for (const line of this.dapClient.getOutput(true)) {
38172
- game.log.append("stdout", line.endsWith(`
38273
+ if (game.transcript === null) {
38274
+ for (const line of this.dapClient.getOutput(true)) {
38275
+ game.log.append("stdout", line.endsWith(`
38173
38276
  `) ? line : `${line}
38174
38277
  `);
38278
+ }
38279
+ }
38280
+ if (!this.dapClient.isConnected()) {
38281
+ game.consoleLost = true;
38175
38282
  }
38176
38283
  this.recordWhatItBrokeOn(game);
38177
38284
  }
@@ -38325,7 +38432,20 @@ class GodotServer {
38325
38432
  }
38326
38433
  }
38327
38434
  async pickUpWhatTheEditorIsPlaying() {
38328
- if (this.activeProcess !== null || !this.godotBridge.isConnected()) {
38435
+ if (!this.godotBridge.isConnected()) {
38436
+ return;
38437
+ }
38438
+ const going = this.activeProcess;
38439
+ if (going !== null) {
38440
+ if (going.throughEditor && going.consoleLost === true && (await this.editorPlayingState())?.playing) {
38441
+ try {
38442
+ await this.dap().connect();
38443
+ going.log.append("stdout", `[gdharness] the debug adapter was reconnected here
38444
+ `);
38445
+ } catch (error) {
38446
+ this.logDebug(`Could not reconnect the adapter for a playing run: ${errorMessage(error)}`);
38447
+ }
38448
+ }
38329
38449
  return;
38330
38450
  }
38331
38451
  const playing = await this.editorPlayingState();
@@ -38337,19 +38457,23 @@ class GodotServer {
38337
38457
  } catch (error) {
38338
38458
  this.logDebug(`Picked up an editor-played run without its adapter: ${errorMessage(error)}`);
38339
38459
  }
38340
- this.activeProcess = {
38460
+ const project = this.godotBridge.getStatus().projectPath ?? null;
38461
+ const left = readEditorRunNote();
38462
+ const note = left !== null && project !== null && isSameDirectory(left.projectPath, project) ? left : null;
38463
+ const picked = {
38341
38464
  process: null,
38342
38465
  pid: null,
38343
38466
  log: new GameLog,
38344
- transcript: null,
38467
+ transcript: note?.transcript ?? null,
38345
38468
  readOffset: 0,
38346
- projectPath: this.godotBridge.getStatus().projectPath ?? null,
38347
- startedAt: Date.now(),
38469
+ projectPath: project,
38470
+ startedAt: note?.startedAt ?? Date.now(),
38348
38471
  exitCode: null,
38349
38472
  throughEditor: true,
38350
38473
  brokeOn: null,
38351
38474
  pickedUpPlaying: true
38352
38475
  };
38476
+ this.activeProcess = picked;
38353
38477
  }
38354
38478
  adoptRecordedRun() {
38355
38479
  const record = readRunRecord();
@@ -38469,6 +38593,9 @@ class GodotServer {
38469
38593
  const wanted = readBoolean(args, "cpu") ?? false;
38470
38594
  const cpuSeconds = wanted && run.pid !== null && stillRunning(run) ? await cpuSecondsOf(run.pid) : undefined;
38471
38595
  const notes = [];
38596
+ if (wanted && cpuSeconds === undefined && stillRunning(run)) {
38597
+ 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.");
38598
+ }
38472
38599
  if (waitedMs !== undefined) {
38473
38600
  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.`);
38474
38601
  }
@@ -38476,7 +38603,11 @@ class GodotServer {
38476
38603
  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.");
38477
38604
  }
38478
38605
  if (run.pickedUpPlaying === true) {
38479
- notes.push("The editor was already playing this when this server reached it, so this is not the run from its start: what it printed before that went with the server that was listening then. It is the run the editor is holding now, which is the one the debug_* tools answer for.");
38606
+ 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.");
38607
+ }
38608
+ if (run.consoleLost === true) {
38609
+ const back = this.dapClient?.isConnected() === true;
38610
+ 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.`);
38480
38611
  }
38481
38612
  if (typeof run.endedHere === "string") {
38482
38613
  notes.push(`This run was ended here, by ${run.endedHere}.`);
@@ -38493,7 +38624,8 @@ class GodotServer {
38493
38624
  pid: run.pid,
38494
38625
  errors: run.log.count("error"),
38495
38626
  warnings: run.log.count("warning"),
38496
- clean: run.log.count("error") === 0,
38627
+ clean: run.consoleLost === true ? undefined : run.log.count("error") === 0,
38628
+ consoleLost: run.consoleLost === true ? true : undefined,
38497
38629
  heldAt: halt,
38498
38630
  endedUnwatched: run.endedUnwatched === true ? true : undefined,
38499
38631
  endedBy: stillRunning(run) ? undefined : run.endedHere ?? null,
package/build/index.js CHANGED
@@ -10142,6 +10142,7 @@ function defectReport(where, error, godotVersion) {
10142
10142
  import { execFile as execFile4, spawn } from "node:child_process";
10143
10143
  import { randomUUID as randomUUID2 } from "node:crypto";
10144
10144
  import {
10145
+ appendFileSync,
10145
10146
  closeSync,
10146
10147
  existsSync as existsSync9,
10147
10148
  fstatSync,
@@ -25009,6 +25010,7 @@ class GodotDAPClient {
25009
25010
  pendingRequests;
25010
25011
  reader = new FrameReader;
25011
25012
  outputBuffer = [];
25013
+ onOutput = null;
25012
25014
  maxOutputLines = 1000;
25013
25015
  initialized = false;
25014
25016
  attached = false;
@@ -25176,6 +25178,13 @@ class GodotDAPClient {
25176
25178
  if (outputText.length > 0) {
25177
25179
  const lines = outputText.split(/\r?\n/).filter((line) => line.length > 0);
25178
25180
  this.outputBuffer.push(...lines);
25181
+ if (this.onOutput !== null) {
25182
+ for (const line of lines) {
25183
+ try {
25184
+ this.onOutput(line);
25185
+ } catch {}
25186
+ }
25187
+ }
25179
25188
  }
25180
25189
  if (this.outputBuffer.length > this.maxOutputLines) {
25181
25190
  this.outputBuffer = this.outputBuffer.slice(this.outputBuffer.length - this.maxOutputLines);
@@ -25232,6 +25241,9 @@ class GodotDAPClient {
25232
25241
  await this.sendRequest("configurationDone", {});
25233
25242
  this.attached = true;
25234
25243
  }
25244
+ setOutputSink(sink) {
25245
+ this.onOutput = sink;
25246
+ }
25235
25247
  getOutput(clear = false) {
25236
25248
  const lines = [...this.outputBuffer];
25237
25249
  if (clear) {
@@ -27545,6 +27557,15 @@ function isValueComplete(value) {
27545
27557
  }
27546
27558
  return depth <= 0 && !inString;
27547
27559
  }
27560
+ function settingKeys(content) {
27561
+ const named = new Set;
27562
+ for (const [section, entries] of Object.entries(parseProjectGodot(content))) {
27563
+ for (const key of Object.keys(entries)) {
27564
+ named.add(`${section}/${key}`);
27565
+ }
27566
+ }
27567
+ return named;
27568
+ }
27548
27569
  function parseProjectGodot(content) {
27549
27570
  const result = emptyRecord();
27550
27571
  let section = emptyRecord();
@@ -27973,6 +27994,41 @@ function writeRunRecord(record) {
27973
27994
  mkdirSync3(runsDirectory(), { recursive: true });
27974
27995
  writeFileSync4(recordPath(), JSON.stringify(record, null, 2), "utf8");
27975
27996
  }
27997
+ function editorNotePath() {
27998
+ return join9(runsDirectory(), "editor-run.json");
27999
+ }
28000
+ function writeEditorRunNote(note) {
28001
+ mkdirSync3(runsDirectory(), { recursive: true });
28002
+ writeFileSync4(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
28003
+ }
28004
+ function readEditorRunNote() {
28005
+ for (const directory of runtimeDirectories()) {
28006
+ const note = editorNoteAt(join9(directory, "runs", "editor-run.json"));
28007
+ if (note !== null) {
28008
+ return note;
28009
+ }
28010
+ }
28011
+ return null;
28012
+ }
28013
+ function editorNoteAt(path) {
28014
+ let parsed;
28015
+ try {
28016
+ parsed = JSON.parse(readFileSync9(path, "utf8"));
28017
+ } catch {
28018
+ return null;
28019
+ }
28020
+ if (!parsed || typeof parsed !== "object") {
28021
+ return null;
28022
+ }
28023
+ const fields = parsed;
28024
+ const projectPath = fields["projectPath"];
28025
+ const transcript = fields["transcript"];
28026
+ const startedAt = fields["startedAt"];
28027
+ if (typeof projectPath !== "string" || typeof transcript !== "string" || typeof startedAt !== "number") {
28028
+ return null;
28029
+ }
28030
+ return { projectPath, transcript, startedAt };
28031
+ }
27976
28032
  function recordRunEnded(pid, exitCode) {
27977
28033
  const path = recordPath();
27978
28034
  const record = recordAt(path);
@@ -28745,8 +28801,8 @@ var TOOL_SPECS = [
28745
28801
  },
28746
28802
  {
28747
28803
  name: "editor_launch",
28748
- 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.",
28749
- parameters: { projectPath: PROJECT_PATH },
28804
+ 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.",
28805
+ parameters: { projectPath: { ...PROJECT_PATH, ops: ["open"] } },
28750
28806
  requires: [],
28751
28807
  operations: {
28752
28808
  open: { summary: "open the editor on a project", requires: ["projectPath"] },
@@ -28758,7 +28814,7 @@ var TOOL_SPECS = [
28758
28814
  name: "editor_run",
28759
28815
  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. What runs is a scene: a SceneTree script is not an entry point here, so put the script on the root of a scene of its own and name that in scene. args hands the game its own flags, the ones it reads with OS.get_cmdline_user_args(), and a run carrying any is started by this server rather than by the editor. 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. A start waits for the game to become something the runtime_* tools can talk to and says which it is under runtime: listening with the port it took, or why not, so the first call after a start does not have to be made twice. When it is not listening, mayYetAnnounce says whether that is final: false is a runtime that is not coming, true is a game still on its way up, which editor_status will see and runtimeWaitMs waits longer for. A start this server made also answers with transcript, the file both the run's streams are written to, so a watch on it can be armed off the start rather than off a second call.",
28760
28816
  parameters: {
28761
- projectPath: PROJECT_PATH,
28817
+ projectPath: { ...PROJECT_PATH, ops: ["start", "check"] },
28762
28818
  scene: {
28763
28819
  type: "string",
28764
28820
  ops: ["start", "check"],
@@ -28787,11 +28843,17 @@ var TOOL_SPECS = [
28787
28843
  description: "check: how long to give the boot before it is called hung. Default 60000. wait: how long to wait for the run to end before answering anyway. Default 600000."
28788
28844
  }
28789
28845
  },
28790
- requires: ["projectPath"],
28846
+ requires: [],
28791
28847
  operations: {
28792
- start: { summary: "run the project until it quits or is stopped", requires: [] },
28848
+ start: {
28849
+ summary: "run the project until it quits or is stopped",
28850
+ requires: ["projectPath"]
28851
+ },
28793
28852
  stop: { summary: "end the run and answer with what it printed last", requires: [] },
28794
- check: { summary: "boot headless, quit after a few frames, and report the verdict", requires: [] },
28853
+ check: {
28854
+ summary: "boot headless, quit after a few frames, and report the verdict",
28855
+ requires: ["projectPath"]
28856
+ },
28795
28857
  wait: { summary: "wait for the run to end, then answer as editor_output does", requires: [] }
28796
28858
  },
28797
28859
  defaultOperation: "start"
@@ -29830,9 +29892,10 @@ class GodotServer {
29830
29892
  if (op !== null) {
29831
29893
  const elsewhere = Object.keys(args).filter((key) => key !== "op" && !opTakes(spec, op, key));
29832
29894
  if (elsewhere.length > 0) {
29895
+ const takes = argumentsOf(spec, op);
29833
29896
  return {
29834
29897
  ok: false,
29835
- response: this.createErrorResponse(`${spec.name} ${op} does not take ${elsewhere.join(", ")}. ${op} takes: ${argumentsOf(spec, op).join(", ")}.`)
29898
+ response: this.createErrorResponse(`${spec.name} ${op} does not take ${elsewhere.join(", ")}. ${takes.length === 0 ? `${op} takes no arguments.` : `${op} takes: ${takes.join(", ")}.`}`)
29836
29899
  };
29837
29900
  }
29838
29901
  }
@@ -30582,9 +30645,27 @@ class GodotServer {
30582
30645
  if (this.dapClient !== null && this.dapClient.port !== port) {
30583
30646
  this.dapClient = null;
30584
30647
  }
30585
- this.dapClient ??= new GodotDAPClient(port);
30648
+ if (this.dapClient === null) {
30649
+ this.dapClient = new GodotDAPClient(port);
30650
+ this.dapClient.setOutputSink((line) => {
30651
+ this.writeToTranscript(line);
30652
+ });
30653
+ }
30586
30654
  return this.dapClient;
30587
30655
  }
30656
+ writeToTranscript(line) {
30657
+ const run = this.activeProcess;
30658
+ if (run?.throughEditor !== true || run.transcript === null) {
30659
+ return;
30660
+ }
30661
+ try {
30662
+ appendFileSync(run.transcript, line.endsWith(`
30663
+ `) ? line : `${line}
30664
+ `);
30665
+ } catch (error) {
30666
+ this.logDebug(`Could not write an editor-played run's transcript: ${errorMessage(error)}`);
30667
+ }
30668
+ }
30588
30669
  editorServes(field, variable, fallback) {
30589
30670
  const named = portFromEnvOrNull(variable);
30590
30671
  if (named !== null) {
@@ -30660,6 +30741,16 @@ class GodotServer {
30660
30741
  }
30661
30742
  });
30662
30743
  }
30744
+ settingKeysOf(projectPath) {
30745
+ if (projectPath === undefined) {
30746
+ return new Set;
30747
+ }
30748
+ try {
30749
+ return settingKeys(readFileSync11(join11(projectPath, "project.godot"), "utf8"));
30750
+ } catch {
30751
+ return new Set;
30752
+ }
30753
+ }
30663
30754
  async handleRestartEditor() {
30664
30755
  const before = this.godotBridge.getStatus();
30665
30756
  if (!before.connected) {
@@ -30668,6 +30759,7 @@ class GodotServer {
30668
30759
  "editor_status says whether the bridge is up and what has reached it"
30669
30760
  ]);
30670
30761
  }
30762
+ const settingsBefore = this.settingKeysOf(before.projectPath);
30671
30763
  const mine = before.openedByAServer === true && before.projectPath !== undefined;
30672
30764
  const asked = mine ? await this.startItAgain(before.projectPath ?? "", before.editorPid) : await this.handleViaBridge("restart_editor", {});
30673
30765
  if (asked.isError === true) {
@@ -30687,6 +30779,8 @@ class GodotServer {
30687
30779
  ]);
30688
30780
  }
30689
30781
  const now = this.godotBridge.getStatus();
30782
+ const after = this.settingKeysOf(before.projectPath);
30783
+ const dropped = [...settingsBefore].filter((key) => !after.has(key));
30690
30784
  return this.jsonTextResponse({
30691
30785
  restarted: true,
30692
30786
  editorPid: now.editorPid,
@@ -30694,6 +30788,8 @@ class GodotServer {
30694
30788
  serverVersion: SERVER_VERSION,
30695
30789
  addonIsStale: now.addonVersion !== SERVER_VERSION,
30696
30790
  staleNote: addonMismatch(now.addonVersion, SERVER_VERSION),
30791
+ settingsDropped: dropped.length > 0 ? dropped : undefined,
30792
+ 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,
30697
30793
  tookMs: Date.now() - began
30698
30794
  });
30699
30795
  }
@@ -30884,6 +30980,7 @@ class GodotServer {
30884
30980
  const log = new GameLog;
30885
30981
  try {
30886
30982
  await this.dap().connect();
30983
+ this.dapClient?.getOutput(true);
30887
30984
  } catch (error) {
30888
30985
  return this.createErrorResponse(`The editor is connected but its debug adapter is not: ${errorMessage(error)}`, [
30889
30986
  `This asked on port ${this.dap().port}, which is where the connected editor says it serves`,
@@ -30894,19 +30991,24 @@ class GodotServer {
30894
30991
  if (answer.isError === true) {
30895
30992
  return answer;
30896
30993
  }
30994
+ const startedAt = Date.now();
30995
+ const transcript = openTranscript(startedAt);
30996
+ closeSync(transcript.fd);
30897
30997
  const played = {
30898
30998
  process: null,
30899
30999
  pid: null,
30900
31000
  log,
30901
- transcript: null,
31001
+ transcript: transcript.path,
30902
31002
  readOffset: 0,
30903
31003
  projectPath,
30904
- startedAt: Date.now(),
31004
+ startedAt,
30905
31005
  exitCode: null,
30906
31006
  throughEditor: true,
30907
31007
  brokeOn: null
30908
31008
  };
30909
31009
  this.activeProcess = played;
31010
+ writeEditorRunNote({ projectPath, transcript: transcript.path, startedAt });
31011
+ sweepTranscripts();
30910
31012
  return this.jsonTextResponse({
30911
31013
  started: true,
30912
31014
  through: "editor",
@@ -30922,10 +31024,15 @@ class GodotServer {
30922
31024
  if (!game.throughEditor || !this.dapClient) {
30923
31025
  return;
30924
31026
  }
30925
- for (const line of this.dapClient.getOutput(true)) {
30926
- game.log.append("stdout", line.endsWith(`
31027
+ if (game.transcript === null) {
31028
+ for (const line of this.dapClient.getOutput(true)) {
31029
+ game.log.append("stdout", line.endsWith(`
30927
31030
  `) ? line : `${line}
30928
31031
  `);
31032
+ }
31033
+ }
31034
+ if (!this.dapClient.isConnected()) {
31035
+ game.consoleLost = true;
30929
31036
  }
30930
31037
  this.recordWhatItBrokeOn(game);
30931
31038
  }
@@ -31079,7 +31186,20 @@ class GodotServer {
31079
31186
  }
31080
31187
  }
31081
31188
  async pickUpWhatTheEditorIsPlaying() {
31082
- if (this.activeProcess !== null || !this.godotBridge.isConnected()) {
31189
+ if (!this.godotBridge.isConnected()) {
31190
+ return;
31191
+ }
31192
+ const going = this.activeProcess;
31193
+ if (going !== null) {
31194
+ if (going.throughEditor && going.consoleLost === true && (await this.editorPlayingState())?.playing) {
31195
+ try {
31196
+ await this.dap().connect();
31197
+ going.log.append("stdout", `[gdharness] the debug adapter was reconnected here
31198
+ `);
31199
+ } catch (error) {
31200
+ this.logDebug(`Could not reconnect the adapter for a playing run: ${errorMessage(error)}`);
31201
+ }
31202
+ }
31083
31203
  return;
31084
31204
  }
31085
31205
  const playing = await this.editorPlayingState();
@@ -31091,19 +31211,23 @@ class GodotServer {
31091
31211
  } catch (error) {
31092
31212
  this.logDebug(`Picked up an editor-played run without its adapter: ${errorMessage(error)}`);
31093
31213
  }
31094
- this.activeProcess = {
31214
+ const project = this.godotBridge.getStatus().projectPath ?? null;
31215
+ const left = readEditorRunNote();
31216
+ const note = left !== null && project !== null && isSameDirectory(left.projectPath, project) ? left : null;
31217
+ const picked = {
31095
31218
  process: null,
31096
31219
  pid: null,
31097
31220
  log: new GameLog,
31098
- transcript: null,
31221
+ transcript: note?.transcript ?? null,
31099
31222
  readOffset: 0,
31100
- projectPath: this.godotBridge.getStatus().projectPath ?? null,
31101
- startedAt: Date.now(),
31223
+ projectPath: project,
31224
+ startedAt: note?.startedAt ?? Date.now(),
31102
31225
  exitCode: null,
31103
31226
  throughEditor: true,
31104
31227
  brokeOn: null,
31105
31228
  pickedUpPlaying: true
31106
31229
  };
31230
+ this.activeProcess = picked;
31107
31231
  }
31108
31232
  adoptRecordedRun() {
31109
31233
  const record = readRunRecord();
@@ -31223,6 +31347,9 @@ class GodotServer {
31223
31347
  const wanted = readBoolean(args, "cpu") ?? false;
31224
31348
  const cpuSeconds = wanted && run.pid !== null && stillRunning(run) ? await cpuSecondsOf(run.pid) : undefined;
31225
31349
  const notes = [];
31350
+ if (wanted && cpuSeconds === undefined && stillRunning(run)) {
31351
+ 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.");
31352
+ }
31226
31353
  if (waitedMs !== undefined) {
31227
31354
  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.`);
31228
31355
  }
@@ -31230,7 +31357,11 @@ class GodotServer {
31230
31357
  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.");
31231
31358
  }
31232
31359
  if (run.pickedUpPlaying === true) {
31233
- notes.push("The editor was already playing this when this server reached it, so this is not the run from its start: what it printed before that went with the server that was listening then. It is the run the editor is holding now, which is the one the debug_* tools answer for.");
31360
+ 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.");
31361
+ }
31362
+ if (run.consoleLost === true) {
31363
+ const back = this.dapClient?.isConnected() === true;
31364
+ 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.`);
31234
31365
  }
31235
31366
  if (typeof run.endedHere === "string") {
31236
31367
  notes.push(`This run was ended here, by ${run.endedHere}.`);
@@ -31247,7 +31378,8 @@ class GodotServer {
31247
31378
  pid: run.pid,
31248
31379
  errors: run.log.count("error"),
31249
31380
  warnings: run.log.count("warning"),
31250
- clean: run.log.count("error") === 0,
31381
+ clean: run.consoleLost === true ? undefined : run.log.count("error") === 0,
31382
+ consoleLost: run.consoleLost === true ? true : undefined,
31251
31383
  heldAt: halt,
31252
31384
  endedUnwatched: run.endedUnwatched === true ? true : undefined,
31253
31385
  endedBy: stillRunning(run) ? undefined : run.endedHere ?? null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.13.7",
3
+ "version": "0.13.9",
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",