gnhf 0.1.8 → 0.1.10

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/README.md +27 -10
  2. package/dist/cli.mjs +837 -114
  3. package/package.json +5 -4
package/dist/cli.mjs CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { appendFileSync, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, createWriteStream, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, rmdirSync, writeFileSync } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { basename, dirname, isAbsolute, join, resolve } from "node:path";
3
5
  import process$1 from "node:process";
4
6
  import { createInterface } from "node:readline";
5
7
  import { Command, InvalidArgumentError } from "commander";
6
- import { dirname, isAbsolute, join } from "node:path";
7
- import { homedir } from "node:os";
8
8
  import yaml from "js-yaml";
9
9
  import { execFileSync, execSync, spawn } from "node:child_process";
10
10
  import { createServer } from "node:net";
@@ -13,8 +13,28 @@ import { createHash } from "node:crypto";
13
13
  //#region src/core/config.ts
14
14
  const DEFAULT_CONFIG = {
15
15
  agent: "claude",
16
- maxConsecutiveFailures: 3
16
+ maxConsecutiveFailures: 3,
17
+ preventSleep: true
17
18
  };
19
+ var InvalidConfigError = class extends Error {};
20
+ function normalizePreventSleep(value) {
21
+ if (typeof value === "boolean") return value;
22
+ if (typeof value !== "string") return void 0;
23
+ if (value === "true") return true;
24
+ if (value === "false") return false;
25
+ if (value === "on") return true;
26
+ if (value === "off") return false;
27
+ }
28
+ function normalizeConfig(config) {
29
+ const normalized = { ...config };
30
+ const hasPreventSleep = Object.prototype.hasOwnProperty.call(config, "preventSleep");
31
+ const preventSleep = normalizePreventSleep(config.preventSleep);
32
+ if (preventSleep === void 0) {
33
+ if (hasPreventSleep && config.preventSleep !== void 0) throw new InvalidConfigError(`Invalid config value for preventSleep: ${String(config.preventSleep)}`);
34
+ delete normalized.preventSleep;
35
+ } else normalized.preventSleep = preventSleep;
36
+ return normalized;
37
+ }
18
38
  function isMissingConfigError(error) {
19
39
  if (!(error instanceof Error)) return false;
20
40
  return "code" in error ? error.code === "ENOENT" : error.message.includes("ENOENT");
@@ -25,6 +45,9 @@ agent: ${config.agent}
25
45
 
26
46
  # Abort after this many consecutive failures
27
47
  maxConsecutiveFailures: ${config.maxConsecutiveFailures}
48
+
49
+ # Prevent the machine from sleeping during a run
50
+ preventSleep: ${config.preventSleep}
28
51
  `;
29
52
  }
30
53
  function loadConfig(overrides) {
@@ -34,14 +57,15 @@ function loadConfig(overrides) {
34
57
  let shouldBootstrapConfig = false;
35
58
  try {
36
59
  const raw = readFileSync(configPath, "utf-8");
37
- fileConfig = yaml.load(raw) ?? {};
60
+ fileConfig = normalizeConfig(yaml.load(raw) ?? {});
38
61
  } catch (error) {
62
+ if (error instanceof InvalidConfigError) throw error;
39
63
  if (isMissingConfigError(error)) shouldBootstrapConfig = true;
40
64
  }
41
65
  const resolvedConfig = {
42
66
  ...DEFAULT_CONFIG,
43
67
  ...fileConfig,
44
- ...overrides
68
+ ...normalizeConfig(overrides ?? {})
45
69
  };
46
70
  if (shouldBootstrapConfig) try {
47
71
  mkdirSync(configDir, { recursive: true });
@@ -50,6 +74,20 @@ function loadConfig(overrides) {
50
74
  return resolvedConfig;
51
75
  }
52
76
  //#endregion
77
+ //#region src/core/debug-log.ts
78
+ function appendDebugLog(event, details = {}) {
79
+ const logPath = process.env.GNHF_DEBUG_LOG_PATH;
80
+ if (!logPath) return;
81
+ try {
82
+ appendFileSync(logPath, `${JSON.stringify({
83
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
84
+ pid: process.pid,
85
+ event,
86
+ ...details
87
+ })}\n`, "utf-8");
88
+ } catch {}
89
+ }
90
+ //#endregion
53
91
  //#region src/core/git.ts
54
92
  const NOT_GIT_REPOSITORY_MESSAGE = "This command must be run inside a Git repository. Change into a repo or run \"git init\" first.";
55
93
  function translateGitError(error) {
@@ -250,6 +288,417 @@ function appendNotes(notesPath, iteration, summary, changes, learnings) {
250
288
  ].join("\n"), "utf-8");
251
289
  }
252
290
  //#endregion
291
+ //#region src/core/stdin.ts
292
+ async function readStdinText(input) {
293
+ const chunks = [];
294
+ for await (const chunk of input) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
295
+ return Buffer.concat(chunks).toString("utf-8").trim();
296
+ }
297
+ //#endregion
298
+ //#region src/core/agents/managed-process.ts
299
+ const POST_SIGKILL_GRACE_MS = 100;
300
+ function signalChildProcess(child, options) {
301
+ const killProcess = options.killProcess ?? process.kill.bind(process);
302
+ if (options.detached && child.pid) try {
303
+ killProcess(-child.pid, options.signal);
304
+ return;
305
+ } catch {}
306
+ child.kill(options.signal);
307
+ }
308
+ async function shutdownChildProcess(child, options) {
309
+ if (child.exitCode != null || child.signalCode != null) return;
310
+ const timeoutMs = options.timeoutMs ?? 3e3;
311
+ await new Promise((resolve) => {
312
+ let forceKillTimer = null;
313
+ let hardDeadlineTimer = null;
314
+ let settled = false;
315
+ const settle = () => {
316
+ if (settled) return;
317
+ settled = true;
318
+ if (forceKillTimer) {
319
+ clearTimeout(forceKillTimer);
320
+ forceKillTimer = null;
321
+ }
322
+ if (hardDeadlineTimer) {
323
+ clearTimeout(hardDeadlineTimer);
324
+ hardDeadlineTimer = null;
325
+ }
326
+ child.off("close", handleClose);
327
+ resolve();
328
+ };
329
+ const handleClose = () => {
330
+ settle();
331
+ };
332
+ child.on("close", handleClose);
333
+ try {
334
+ signalChildProcess(child, {
335
+ ...options,
336
+ signal: "SIGTERM"
337
+ });
338
+ } catch {}
339
+ forceKillTimer = setTimeout(() => {
340
+ try {
341
+ signalChildProcess(child, {
342
+ ...options,
343
+ signal: "SIGKILL"
344
+ });
345
+ } catch {}
346
+ hardDeadlineTimer = setTimeout(() => {
347
+ settle();
348
+ }, POST_SIGKILL_GRACE_MS);
349
+ hardDeadlineTimer.unref?.();
350
+ }, timeoutMs);
351
+ forceKillTimer.unref?.();
352
+ });
353
+ }
354
+ //#endregion
355
+ //#region src/core/sleep.ts
356
+ const SYSTEMD_INHIBIT_READY_TIMEOUT_MS = 5e3;
357
+ const SYSTEMD_INHIBIT_READY_POLL_MS = 25;
358
+ const GNHF_SLEEP_REEXEC_READY_PATH = "GNHF_SLEEP_REEXEC_READY_PATH";
359
+ const GNHF_SLEEP_REEXEC_READY_DIR_PREFIX = "gnhf-sleep-";
360
+ const GNHF_SLEEP_REEXEC_READY_FILENAME = "reexec-ready";
361
+ const HELPER_STARTUP_GRACE_MS = 100;
362
+ function getSignalExitCode$1(signal) {
363
+ if (signal === "SIGINT") return 130;
364
+ if (signal === "SIGTERM") return 143;
365
+ return 1;
366
+ }
367
+ async function waitForSpawn(child) {
368
+ return await new Promise((resolve) => {
369
+ child.once("spawn", () => resolve(true));
370
+ child.once("error", () => resolve(false));
371
+ });
372
+ }
373
+ async function waitForHelperStability(child, timeoutMs) {
374
+ return await new Promise((resolve) => {
375
+ let settled = false;
376
+ let timer = null;
377
+ const settle = (value) => {
378
+ if (settled) return;
379
+ settled = true;
380
+ if (timer) {
381
+ clearTimeout(timer);
382
+ timer = null;
383
+ }
384
+ resolve(value);
385
+ };
386
+ child.once("exit", () => {
387
+ settle(false);
388
+ });
389
+ child.once("error", () => {
390
+ settle(false);
391
+ });
392
+ if (child.exitCode != null || child.signalCode != null) {
393
+ settle(false);
394
+ return;
395
+ }
396
+ timer = setTimeout(() => {
397
+ settle(true);
398
+ }, timeoutMs);
399
+ timer.unref?.();
400
+ });
401
+ }
402
+ function isTrustedLinuxReexecReadyPath(readyPath) {
403
+ const resolvedReadyPath = resolve(readyPath);
404
+ const readyDir = dirname(resolvedReadyPath);
405
+ return basename(resolvedReadyPath) === GNHF_SLEEP_REEXEC_READY_FILENAME && dirname(readyDir) === resolve(tmpdir()) && basename(readyDir).startsWith(GNHF_SLEEP_REEXEC_READY_DIR_PREFIX);
406
+ }
407
+ function signalLinuxReexecReady(env) {
408
+ const readyPath = env[GNHF_SLEEP_REEXEC_READY_PATH];
409
+ if (!readyPath) return;
410
+ if (!isTrustedLinuxReexecReadyPath(readyPath)) {
411
+ appendDebugLog("sleep:ready-signal-failed", {
412
+ command: "systemd-inhibit",
413
+ error: "untrusted ready path"
414
+ });
415
+ return;
416
+ }
417
+ try {
418
+ writeFileSync(readyPath, "ready\n", {
419
+ encoding: "utf-8",
420
+ flag: "wx"
421
+ });
422
+ } catch (error) {
423
+ appendDebugLog("sleep:ready-signal-failed", {
424
+ command: "systemd-inhibit",
425
+ error: error instanceof Error ? error.message : String(error)
426
+ });
427
+ }
428
+ }
429
+ async function waitForLinuxReexecReady(readyPath, exitStatePromise, timeoutMs) {
430
+ if (existsSync(readyPath)) return { type: "ready" };
431
+ return await new Promise((resolve) => {
432
+ let settled = false;
433
+ const settle = (result) => {
434
+ if (settled) return;
435
+ settled = true;
436
+ clearInterval(poller);
437
+ clearTimeout(timeout);
438
+ resolve(result);
439
+ };
440
+ const poller = setInterval(() => {
441
+ if (existsSync(readyPath)) settle({ type: "ready" });
442
+ }, SYSTEMD_INHIBIT_READY_POLL_MS);
443
+ poller.unref?.();
444
+ const timeout = setTimeout(() => {
445
+ settle({ type: "timeout" });
446
+ }, timeoutMs);
447
+ timeout.unref?.();
448
+ exitStatePromise.then(({ exitCode, signal }) => {
449
+ settle({
450
+ type: "exit",
451
+ exitCode,
452
+ signal
453
+ });
454
+ });
455
+ });
456
+ }
457
+ function forwardTerminationSignalsToChild(child, detached, killProcess, processOn, processOff) {
458
+ const listeners = [];
459
+ for (const signal of ["SIGINT", "SIGTERM"]) {
460
+ const listener = () => {
461
+ try {
462
+ signalChildProcess(child, {
463
+ detached,
464
+ killProcess,
465
+ signal
466
+ });
467
+ } catch {}
468
+ };
469
+ processOn(signal, listener);
470
+ listeners.push([signal, listener]);
471
+ }
472
+ return () => {
473
+ for (const [signal, listener] of listeners) processOff(signal, listener);
474
+ };
475
+ }
476
+ function buildPowerShellCommand(parentPid) {
477
+ return [
478
+ "Add-Type @'",
479
+ "using System;",
480
+ "using System.Runtime.InteropServices;",
481
+ "public static class SleepBlock {",
482
+ " [DllImport(\"kernel32.dll\")]",
483
+ " public static extern uint SetThreadExecutionState(uint flags);",
484
+ "}",
485
+ "'@;",
486
+ "$ES_CONTINUOUS = 0x80000000;",
487
+ "$ES_SYSTEM_REQUIRED = 0x00000001;",
488
+ "[SleepBlock]::SetThreadExecutionState($ES_CONTINUOUS -bor $ES_SYSTEM_REQUIRED) | Out-Null;",
489
+ `try { Wait-Process -Id ${parentPid} } catch { } finally { [SleepBlock]::SetThreadExecutionState($ES_CONTINUOUS) | Out-Null }`
490
+ ].join("\n");
491
+ }
492
+ async function startHelperProcess(command, args, spawnFn, env) {
493
+ const child = spawnFn(command, args, {
494
+ env,
495
+ stdio: "ignore"
496
+ });
497
+ if (!await waitForSpawn(child)) {
498
+ appendDebugLog("sleep:unavailable", { command });
499
+ return null;
500
+ }
501
+ if (!await waitForHelperStability(child, HELPER_STARTUP_GRACE_MS)) {
502
+ appendDebugLog("sleep:unavailable", {
503
+ command,
504
+ reason: "early-exit"
505
+ });
506
+ return null;
507
+ }
508
+ return child;
509
+ }
510
+ async function startSleepPrevention(argv, deps = {}) {
511
+ const env = deps.env ?? process.env;
512
+ const killProcess = deps.killProcess ?? process.kill.bind(process);
513
+ const pid = deps.pid ?? process.pid;
514
+ const platform = deps.platform ?? process.platform;
515
+ const processExecArgv = deps.processExecArgv ?? process.execArgv;
516
+ const processArgv1 = deps.processArgv1 ?? process.argv[1];
517
+ const processExecPath = deps.processExecPath ?? process.execPath;
518
+ const processOn = deps.processOn ?? process.on.bind(process);
519
+ const processOff = deps.processOff ?? process.off.bind(process);
520
+ const reexecEnv = deps.reexecEnv ?? {};
521
+ const spawnFn = deps.spawn ?? spawn;
522
+ if (platform === "linux") {
523
+ if (env.GNHF_SLEEP_INHIBITED === "1") {
524
+ signalLinuxReexecReady(env);
525
+ return {
526
+ type: "skipped",
527
+ reason: "already-inhibited"
528
+ };
529
+ }
530
+ const readyDir = mkdtempSync(join(tmpdir(), GNHF_SLEEP_REEXEC_READY_DIR_PREFIX));
531
+ const readyPath = join(readyDir, GNHF_SLEEP_REEXEC_READY_FILENAME);
532
+ const child = spawnFn("systemd-inhibit", [
533
+ "--what=idle:sleep",
534
+ "--mode=block",
535
+ "--who=gnhf",
536
+ "--why=Prevent sleep while gnhf is running",
537
+ processExecPath,
538
+ ...processExecArgv,
539
+ processArgv1,
540
+ ...argv
541
+ ], {
542
+ detached: true,
543
+ env: {
544
+ ...env,
545
+ ...reexecEnv,
546
+ GNHF_SLEEP_INHIBITED: "1",
547
+ [GNHF_SLEEP_REEXEC_READY_PATH]: readyPath
548
+ },
549
+ stdio: "inherit"
550
+ });
551
+ const exitStatePromise = new Promise((resolve) => {
552
+ child.once("exit", (code, signal) => {
553
+ resolve({
554
+ exitCode: signal ? getSignalExitCode$1(signal) : code ?? 1,
555
+ signal
556
+ });
557
+ });
558
+ });
559
+ const stopForwardingSignals = forwardTerminationSignalsToChild(child, true, killProcess, processOn, processOff);
560
+ if (!await waitForSpawn(child)) {
561
+ stopForwardingSignals();
562
+ rmSync(readyDir, {
563
+ recursive: true,
564
+ force: true
565
+ });
566
+ appendDebugLog("sleep:unavailable", { command: "systemd-inhibit" });
567
+ return {
568
+ type: "skipped",
569
+ reason: "unavailable"
570
+ };
571
+ }
572
+ try {
573
+ const readyState = await waitForLinuxReexecReady(readyPath, exitStatePromise, SYSTEMD_INHIBIT_READY_TIMEOUT_MS);
574
+ try {
575
+ if (readyState.type === "ready") {
576
+ appendDebugLog("sleep:reexec", { command: "systemd-inhibit" });
577
+ const { exitCode } = await exitStatePromise;
578
+ return {
579
+ type: "reexeced",
580
+ exitCode
581
+ };
582
+ }
583
+ if (readyState.type === "exit") {
584
+ if (readyState.signal === "SIGINT" || readyState.signal === "SIGTERM") {
585
+ appendDebugLog("sleep:reexec", {
586
+ command: "systemd-inhibit",
587
+ signal: readyState.signal
588
+ });
589
+ return {
590
+ type: "reexeced",
591
+ exitCode: readyState.exitCode
592
+ };
593
+ }
594
+ if (readyState.exitCode !== 0) {
595
+ if (existsSync(readyPath)) {
596
+ appendDebugLog("sleep:reexec", {
597
+ command: "systemd-inhibit",
598
+ exitCode: readyState.exitCode,
599
+ readySignal: "late"
600
+ });
601
+ return {
602
+ type: "reexeced",
603
+ exitCode: readyState.exitCode
604
+ };
605
+ }
606
+ appendDebugLog("sleep:unavailable", {
607
+ command: "systemd-inhibit",
608
+ exitCode: readyState.exitCode
609
+ });
610
+ return {
611
+ type: "skipped",
612
+ reason: "unavailable"
613
+ };
614
+ }
615
+ appendDebugLog("sleep:reexec", {
616
+ command: "systemd-inhibit",
617
+ readySignal: false
618
+ });
619
+ return {
620
+ type: "reexeced",
621
+ exitCode: readyState.exitCode
622
+ };
623
+ }
624
+ appendDebugLog("sleep:unavailable", {
625
+ command: "systemd-inhibit",
626
+ reason: "timeout",
627
+ timeoutMs: SYSTEMD_INHIBIT_READY_TIMEOUT_MS
628
+ });
629
+ await shutdownChildProcess(child, {
630
+ detached: true,
631
+ killProcess,
632
+ timeoutMs: 1e3
633
+ });
634
+ return {
635
+ type: "skipped",
636
+ reason: "unavailable"
637
+ };
638
+ } finally {
639
+ stopForwardingSignals();
640
+ }
641
+ } finally {
642
+ rmSync(readyDir, {
643
+ recursive: true,
644
+ force: true
645
+ });
646
+ }
647
+ }
648
+ if (platform === "darwin") {
649
+ const child = await startHelperProcess("caffeinate", [
650
+ "-i",
651
+ "-w",
652
+ String(pid)
653
+ ], spawnFn, env);
654
+ if (!child) return {
655
+ type: "skipped",
656
+ reason: "unavailable"
657
+ };
658
+ appendDebugLog("sleep:active", { command: "caffeinate" });
659
+ return {
660
+ type: "active",
661
+ cleanup: async () => {
662
+ appendDebugLog("sleep:cleanup", { command: "caffeinate" });
663
+ await shutdownChildProcess(child, {
664
+ detached: false,
665
+ timeoutMs: 1e3
666
+ });
667
+ }
668
+ };
669
+ }
670
+ if (platform === "win32") {
671
+ const child = await startHelperProcess("powershell.exe", [
672
+ "-NoLogo",
673
+ "-NoProfile",
674
+ "-NonInteractive",
675
+ "-ExecutionPolicy",
676
+ "Bypass",
677
+ "-Command",
678
+ buildPowerShellCommand(pid)
679
+ ], spawnFn, env);
680
+ if (!child) return {
681
+ type: "skipped",
682
+ reason: "unavailable"
683
+ };
684
+ appendDebugLog("sleep:active", { command: "powershell.exe" });
685
+ return {
686
+ type: "active",
687
+ cleanup: async () => {
688
+ appendDebugLog("sleep:cleanup", { command: "powershell.exe" });
689
+ await shutdownChildProcess(child, {
690
+ detached: false,
691
+ timeoutMs: 1e3
692
+ });
693
+ }
694
+ };
695
+ }
696
+ return {
697
+ type: "skipped",
698
+ reason: "unsupported"
699
+ };
700
+ }
701
+ //#endregion
253
702
  //#region src/core/agents/stream-utils.ts
254
703
  /**
255
704
  * Wire stderr collection, spawn-error handling, and the common close-handler
@@ -486,6 +935,21 @@ function buildPrompt(prompt) {
486
935
  `The JSON must match this schema exactly: ${JSON.stringify(AGENT_OUTPUT_SCHEMA)}`
487
936
  ].join("\n");
488
937
  }
938
+ /**
939
+ * On Windows with `shell: true`, `child.pid` is the `cmd.exe` wrapper, not
940
+ * the actual server process. `taskkill /T` terminates the entire process
941
+ * tree rooted at that PID so the real server doesn't survive shutdown.
942
+ */
943
+ async function killWindowsProcessTree(pid) {
944
+ try {
945
+ execFileSync("taskkill", [
946
+ "/T",
947
+ "/F",
948
+ "/PID",
949
+ String(pid)
950
+ ], { stdio: "ignore" });
951
+ } catch {}
952
+ }
489
953
  function createAbortError$1() {
490
954
  return /* @__PURE__ */ new Error("Agent was aborted");
491
955
  }
@@ -557,6 +1021,7 @@ var OpenCodeAgent = class {
557
1021
  fetchFn;
558
1022
  getPortFn;
559
1023
  killProcessFn;
1024
+ platform;
560
1025
  spawnFn;
561
1026
  server = null;
562
1027
  closingPromise = null;
@@ -564,6 +1029,7 @@ var OpenCodeAgent = class {
564
1029
  this.fetchFn = deps.fetch ?? fetch;
565
1030
  this.getPortFn = deps.getPort ?? getAvailablePort$1;
566
1031
  this.killProcessFn = deps.killProcess ?? process.kill.bind(process);
1032
+ this.platform = deps.platform ?? process.platform;
567
1033
  this.spawnFn = deps.spawn ?? spawn;
568
1034
  }
569
1035
  async run(prompt, cwd, options) {
@@ -609,7 +1075,8 @@ var OpenCodeAgent = class {
609
1075
  return this.server;
610
1076
  }
611
1077
  const port = await this.getPortFn();
612
- const detached = process.platform !== "win32";
1078
+ const isWindows = this.platform === "win32";
1079
+ const detached = !isWindows;
613
1080
  const child = this.spawnFn("opencode", [
614
1081
  "serve",
615
1082
  "--hostname",
@@ -620,6 +1087,7 @@ var OpenCodeAgent = class {
620
1087
  ], {
621
1088
  cwd,
622
1089
  detached,
1090
+ shell: isWindows,
623
1091
  stdio: [
624
1092
  "ignore",
625
1093
  "pipe",
@@ -652,6 +1120,11 @@ var OpenCodeAgent = class {
652
1120
  if (this.server === server) this.server = null;
653
1121
  });
654
1122
  this.server = server;
1123
+ appendDebugLog("opencode:spawn", {
1124
+ cwd,
1125
+ port,
1126
+ detached
1127
+ });
655
1128
  server.readyPromise = this.waitForHealthy(server, signal).catch(async (error) => {
656
1129
  await this.shutdownServer();
657
1130
  throw error;
@@ -934,37 +1407,20 @@ var OpenCodeAgent = class {
934
1407
  return;
935
1408
  }
936
1409
  const server = this.server;
937
- const waitForClose = new Promise((resolve) => {
938
- if (server.closed) {
939
- resolve();
940
- return;
941
- }
942
- server.child.once("close", () => resolve());
943
- });
944
- try {
945
- this.signalServer(server, "SIGTERM");
946
- } catch {}
947
- const forceKill = new Promise((resolve) => {
948
- setTimeout(() => {
949
- if (!server.closed) try {
950
- this.signalServer(server, "SIGKILL");
951
- } catch {}
952
- resolve();
953
- }, 3e3).unref?.();
1410
+ appendDebugLog("opencode:shutdown", {
1411
+ cwd: server.cwd,
1412
+ port: server.port
954
1413
  });
955
- this.closingPromise = Promise.race([waitForClose, forceKill]).finally(() => {
1414
+ this.closingPromise = (this.platform === "win32" && server.child.pid ? killWindowsProcessTree(server.child.pid) : shutdownChildProcess(server.child, {
1415
+ detached: server.detached,
1416
+ killProcess: this.killProcessFn,
1417
+ timeoutMs: 3e3
1418
+ })).finally(() => {
956
1419
  if (this.server === server) this.server = null;
957
1420
  this.closingPromise = null;
958
1421
  });
959
1422
  await this.closingPromise;
960
1423
  }
961
- signalServer(server, signal) {
962
- if (server.detached && server.child.pid) try {
963
- this.killProcessFn(-server.child.pid, signal);
964
- return;
965
- } catch {}
966
- server.child.kill(signal);
967
- }
968
1424
  async requestJSON(server, path, options) {
969
1425
  const body = await this.requestText(server, path, options);
970
1426
  return JSON.parse(body);
@@ -1149,6 +1605,11 @@ var RovoDevAgent = class {
1149
1605
  if (this.server === server) this.server = null;
1150
1606
  });
1151
1607
  this.server = server;
1608
+ appendDebugLog("rovodev:spawn", {
1609
+ cwd,
1610
+ port,
1611
+ detached
1612
+ });
1152
1613
  server.readyPromise = this.waitForHealthy(server, signal).catch(async (error) => {
1153
1614
  await this.shutdownServer();
1154
1615
  throw error;
@@ -1158,13 +1619,13 @@ var RovoDevAgent = class {
1158
1619
  }
1159
1620
  async waitForHealthy(server, signal) {
1160
1621
  const deadline = Date.now() + 3e4;
1161
- let spawnError = null;
1622
+ let spawnErrorMessage = null;
1162
1623
  server.child.once("error", (error) => {
1163
- spawnError = error;
1624
+ spawnErrorMessage = error.message;
1164
1625
  });
1165
1626
  while (Date.now() < deadline) {
1166
1627
  if (signal?.aborted) throw createAbortError();
1167
- if (spawnError) throw new Error(`Failed to spawn rovodev: ${spawnError.message}`);
1628
+ if (spawnErrorMessage) throw new Error(`Failed to spawn rovodev: ${spawnErrorMessage}`);
1168
1629
  if (server.closed) {
1169
1630
  const output = server.stderr.trim() || server.stdout.trim();
1170
1631
  throw new Error(output ? `rovodev exited before becoming ready: ${output}` : "rovodev exited before becoming ready");
@@ -1372,37 +1833,20 @@ var RovoDevAgent = class {
1372
1833
  return;
1373
1834
  }
1374
1835
  const server = this.server;
1375
- const waitForClose = new Promise((resolve) => {
1376
- if (server.closed) {
1377
- resolve();
1378
- return;
1379
- }
1380
- server.child.once("close", () => resolve());
1381
- });
1382
- try {
1383
- this.signalServer(server, "SIGTERM");
1384
- } catch {}
1385
- const forceKill = new Promise((resolve) => {
1386
- setTimeout(() => {
1387
- if (!server.closed) try {
1388
- this.signalServer(server, "SIGKILL");
1389
- } catch {}
1390
- resolve();
1391
- }, 3e3).unref?.();
1836
+ appendDebugLog("rovodev:shutdown", {
1837
+ cwd: server.cwd,
1838
+ port: server.port
1392
1839
  });
1393
- this.closingPromise = Promise.race([waitForClose, forceKill]).finally(() => {
1840
+ this.closingPromise = shutdownChildProcess(server.child, {
1841
+ detached: server.detached,
1842
+ killProcess: this.killProcessFn,
1843
+ timeoutMs: 3e3
1844
+ }).finally(() => {
1394
1845
  if (this.server === server) this.server = null;
1395
1846
  this.closingPromise = null;
1396
1847
  });
1397
1848
  await this.closingPromise;
1398
1849
  }
1399
- signalServer(server, signal) {
1400
- if (server.detached && server.child.pid) try {
1401
- this.killProcessFn(-server.child.pid, signal);
1402
- return;
1403
- } catch {}
1404
- server.child.kill(signal);
1405
- }
1406
1850
  async requestJSON(server, path, options) {
1407
1851
  return await (await this.request(server, path, options)).json();
1408
1852
  }
@@ -1466,6 +1910,7 @@ ${params.prompt}`;
1466
1910
  }
1467
1911
  //#endregion
1468
1912
  //#region src/core/orchestrator.ts
1913
+ const STOP_CLOSE_AGENT_GRACE_MS = 250;
1469
1914
  var Orchestrator = class extends EventEmitter {
1470
1915
  config;
1471
1916
  agent;
@@ -1475,6 +1920,7 @@ var Orchestrator = class extends EventEmitter {
1475
1920
  limits;
1476
1921
  stopRequested = false;
1477
1922
  stopPromise = null;
1923
+ activeIterationPromise = null;
1478
1924
  activeAbortController = null;
1479
1925
  pendingAbortReason = null;
1480
1926
  state = {
@@ -1510,7 +1956,23 @@ var Orchestrator = class extends EventEmitter {
1510
1956
  this.activeAbortController?.abort();
1511
1957
  if (this.stopPromise) return;
1512
1958
  this.stopPromise = (async () => {
1513
- await this.closeAgent();
1959
+ if (this.activeIterationPromise) {
1960
+ const iterationPromise = this.activeIterationPromise.catch(() => void 0);
1961
+ await new Promise((resolve) => {
1962
+ let settled = false;
1963
+ const settle = () => {
1964
+ if (settled) return;
1965
+ settled = true;
1966
+ clearTimeout(timer);
1967
+ resolve();
1968
+ };
1969
+ const timer = setTimeout(settle, STOP_CLOSE_AGENT_GRACE_MS);
1970
+ timer.unref?.();
1971
+ iterationPromise.finally(settle);
1972
+ });
1973
+ await this.closeAgent();
1974
+ await iterationPromise;
1975
+ } else await this.closeAgent();
1514
1976
  resetHard(this.cwd);
1515
1977
  this.state.status = "stopped";
1516
1978
  this.emit("state", this.getState());
@@ -1537,7 +1999,10 @@ var Orchestrator = class extends EventEmitter {
1537
1999
  runId: this.runInfo.runId,
1538
2000
  prompt: this.prompt
1539
2001
  });
1540
- const result = await this.runIteration(iterationPrompt);
2002
+ this.activeIterationPromise = this.runIteration(iterationPrompt);
2003
+ const result = await this.activeIterationPromise;
2004
+ this.activeIterationPromise = null;
2005
+ if (result.type === "stopped") break;
1541
2006
  if (result.type === "aborted") {
1542
2007
  this.abort(result.reason);
1543
2008
  break;
@@ -1569,6 +2034,7 @@ var Orchestrator = class extends EventEmitter {
1569
2034
  }
1570
2035
  }
1571
2036
  } finally {
2037
+ this.activeIterationPromise = null;
1572
2038
  if (this.stopPromise) await this.stopPromise;
1573
2039
  else await this.closeAgent();
1574
2040
  }
@@ -1600,6 +2066,7 @@ var Orchestrator = class extends EventEmitter {
1600
2066
  signal: this.activeAbortController.signal,
1601
2067
  logPath
1602
2068
  });
2069
+ if (this.stopRequested) return { type: "stopped" };
1603
2070
  if (result.output.success) return {
1604
2071
  type: "completed",
1605
2072
  record: this.recordSuccess(result.output)
@@ -1616,6 +2083,7 @@ var Orchestrator = class extends EventEmitter {
1616
2083
  reason: this.pendingAbortReason
1617
2084
  };
1618
2085
  }
2086
+ if (this.stopRequested) return { type: "stopped" };
1619
2087
  const summary = err instanceof Error ? err.message : String(err);
1620
2088
  return {
1621
2089
  type: "completed",
@@ -1888,7 +2356,68 @@ function formatTokens(count) {
1888
2356
  return String(count);
1889
2357
  }
1890
2358
  //#endregion
2359
+ //#region src/utils/terminal-width.ts
2360
+ const graphemeSegmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
2361
+ const MARK_REGEX = /\p{Mark}/u;
2362
+ const REGIONAL_INDICATOR_REGEX = /\p{Regional_Indicator}/u;
2363
+ const EXTENDED_PICTOGRAPHIC_REGEX = /\p{Extended_Pictographic}/u;
2364
+ function isFullWidthCodePoint(codePoint) {
2365
+ return codePoint >= 4352 && (codePoint <= 4447 || codePoint === 9001 || codePoint === 9002 || codePoint >= 11904 && codePoint <= 12871 && codePoint !== 12351 || codePoint >= 12880 && codePoint <= 19903 || codePoint >= 19968 && codePoint <= 42182 || codePoint >= 43360 && codePoint <= 43388 || codePoint >= 44032 && codePoint <= 55203 || codePoint >= 63744 && codePoint <= 64255 || codePoint >= 65040 && codePoint <= 65049 || codePoint >= 65072 && codePoint <= 65131 || codePoint >= 65281 && codePoint <= 65376 || codePoint >= 65504 && codePoint <= 65510 || codePoint >= 110592 && codePoint <= 110593 || codePoint >= 127488 && codePoint <= 127569 || codePoint >= 131072 && codePoint <= 262141);
2366
+ }
2367
+ function codePointWidth(codePoint) {
2368
+ if (codePoint === 0 || codePoint === 8204 || codePoint === 8205 || codePoint === 65038 || codePoint === 65039) return 0;
2369
+ if (MARK_REGEX.test(String.fromCodePoint(codePoint))) return 0;
2370
+ return isFullWidthCodePoint(codePoint) ? 2 : 1;
2371
+ }
2372
+ function isWideEmojiGrapheme(grapheme) {
2373
+ return grapheme.includes("‍") || grapheme.includes("️") || grapheme.includes("⃣") || REGIONAL_INDICATOR_REGEX.test(grapheme) || Array.from(grapheme).some((char) => EXTENDED_PICTOGRAPHIC_REGEX.test(char));
2374
+ }
2375
+ function splitGraphemes(text) {
2376
+ return Array.from(graphemeSegmenter.segment(text), ({ segment }) => segment);
2377
+ }
2378
+ function graphemeWidth(grapheme) {
2379
+ if (!grapheme) return 0;
2380
+ if (isWideEmojiGrapheme(grapheme)) return 2;
2381
+ let width = 0;
2382
+ for (const char of grapheme) width += codePointWidth(char.codePointAt(0) ?? 0);
2383
+ return width;
2384
+ }
2385
+ function stringWidth(text) {
2386
+ let width = 0;
2387
+ for (const grapheme of splitGraphemes(text)) width += graphemeWidth(grapheme);
2388
+ return width;
2389
+ }
2390
+ //#endregion
1891
2391
  //#region src/utils/wordwrap.ts
2392
+ function sliceToWidth(text, width) {
2393
+ let result = "";
2394
+ let currentWidth = 0;
2395
+ for (const grapheme of splitGraphemes(text)) {
2396
+ const nextWidth = currentWidth + graphemeWidth(grapheme);
2397
+ if (nextWidth > width) break;
2398
+ result += grapheme;
2399
+ currentWidth = nextWidth;
2400
+ }
2401
+ return result;
2402
+ }
2403
+ function splitByWidth(text, width) {
2404
+ const lines = [];
2405
+ let current = "";
2406
+ let currentWidth = 0;
2407
+ for (const grapheme of splitGraphemes(text)) {
2408
+ const glyphWidth = graphemeWidth(grapheme);
2409
+ if (current && currentWidth + glyphWidth > width) {
2410
+ lines.push(current);
2411
+ current = grapheme;
2412
+ currentWidth = glyphWidth;
2413
+ continue;
2414
+ }
2415
+ current += grapheme;
2416
+ currentWidth += glyphWidth;
2417
+ }
2418
+ if (current) lines.push(current);
2419
+ return lines;
2420
+ }
1892
2421
  function wordWrap(text, width, maxLines) {
1893
2422
  if (!text) return [];
1894
2423
  const lines = [];
@@ -1899,26 +2428,34 @@ function wordWrap(text, width, maxLines) {
1899
2428
  continue;
1900
2429
  }
1901
2430
  let current = "";
2431
+ let currentWidth = 0;
1902
2432
  for (const word of words) {
1903
- if (word.length > width) {
2433
+ const wordWidth = stringWidth(word);
2434
+ if (wordWidth > width) {
1904
2435
  if (current) {
1905
2436
  lines.push(current);
1906
2437
  current = "";
2438
+ currentWidth = 0;
1907
2439
  }
1908
- for (let i = 0; i < word.length; i += width) lines.push(word.slice(i, i + width));
2440
+ for (const slice of splitByWidth(word, width)) lines.push(slice);
1909
2441
  continue;
1910
2442
  }
1911
- if (current && current.length + 1 + word.length > width) {
2443
+ const nextWidth = current ? currentWidth + 1 + wordWidth : wordWidth;
2444
+ if (current && nextWidth > width) {
1912
2445
  lines.push(current);
1913
2446
  current = word;
1914
- } else current = current ? current + " " + word : word;
2447
+ currentWidth = wordWidth;
2448
+ } else {
2449
+ current = current ? current + " " + word : word;
2450
+ currentWidth = nextWidth;
2451
+ }
1915
2452
  }
1916
2453
  if (current) lines.push(current);
1917
2454
  }
1918
2455
  if (maxLines && lines.length > maxLines) {
1919
2456
  const capped = lines.slice(0, maxLines);
1920
2457
  const last = capped[maxLines - 1];
1921
- capped[maxLines - 1] = last.length >= width ? last.slice(0, width - 1) + "…" : last + "…";
2458
+ capped[maxLines - 1] = stringWidth(last) >= width ? sliceToWidth(last, width - 1) + "…" : last + "…";
1922
2459
  return capped;
1923
2460
  }
1924
2461
  return lines;
@@ -1934,13 +2471,13 @@ function makeCell(char, style) {
1934
2471
  return {
1935
2472
  char,
1936
2473
  style,
1937
- width: (char.codePointAt(0) ?? 0) > 65535 ? 2 : 1
2474
+ width: graphemeWidth(char)
1938
2475
  };
1939
2476
  }
1940
2477
  function textToCells(text, style) {
1941
2478
  const cells = [];
1942
- for (const char of text) {
1943
- const cell = makeCell(char, style);
2479
+ for (const grapheme of splitGraphemes(text)) {
2480
+ const cell = makeCell(grapheme, style);
1944
2481
  cells.push(cell);
1945
2482
  if (cell.width === 2) cells.push({
1946
2483
  char: "",
@@ -2022,7 +2559,7 @@ const TICK_MS = 200;
2022
2559
  const MOONS_PER_ROW = 30;
2023
2560
  const MOON_PHASE_PERIOD = 1600;
2024
2561
  const MAX_MSG_LINES = 3;
2025
- const MAX_MSG_LINE_LEN = 64;
2562
+ const MAX_MSG_LINE_LEN = CONTENT_WIDTH;
2026
2563
  const RESUME_HINT = "[ctrl+c to stop, gnhf again to resume]";
2027
2564
  function spacedLabel(text) {
2028
2565
  return text.split("").join(" ");
@@ -2116,52 +2653,124 @@ function renderSideStarsCells(stars, rowIndex, xOffset, sideWidth, now) {
2116
2653
  placeStarsInCells(cells, stars, rowIndex, xOffset, xOffset + sideWidth, xOffset, now);
2117
2654
  return cells;
2118
2655
  }
2656
+ function clampCellsToWidth(content, width) {
2657
+ if (content.length <= width) return content;
2658
+ const clamped = [];
2659
+ let remaining = width;
2660
+ for (let i = 0; i < content.length && remaining > 0; i++) {
2661
+ const cell = content[i];
2662
+ if (cell.width === 0) continue;
2663
+ if (cell.width > remaining) break;
2664
+ clamped.push(cell);
2665
+ remaining -= cell.width;
2666
+ if (cell.width === 2 && content[i + 1]?.width === 0) {
2667
+ clamped.push(content[i + 1]);
2668
+ i += 1;
2669
+ }
2670
+ }
2671
+ return clamped;
2672
+ }
2119
2673
  function centerLineCells(content, width) {
2120
- const w = content.length;
2674
+ const clamped = clampCellsToWidth(content, width);
2675
+ const w = clamped.length;
2121
2676
  const pad = Math.max(0, Math.floor((width - w) / 2));
2122
2677
  const rightPad = Math.max(0, width - w - pad);
2123
2678
  return [
2124
2679
  ...emptyCells(pad),
2125
- ...content,
2680
+ ...clamped,
2126
2681
  ...emptyCells(rightPad)
2127
2682
  ];
2128
2683
  }
2129
2684
  function renderResumeHintCells(width) {
2130
2685
  return centerLineCells(textToCells(RESUME_HINT, "dim"), width);
2131
2686
  }
2132
- function fitContentRows(contentRows, maxRows) {
2133
- if (contentRows.length <= maxRows) return contentRows;
2134
- const fitted = [...contentRows];
2135
- while (fitted.length > maxRows) {
2136
- const emptyRowIndex = fitted.findIndex((row) => row.length === 0);
2137
- if (emptyRowIndex === -1) break;
2138
- fitted.splice(emptyRowIndex, 1);
2139
- }
2140
- return fitted.length > maxRows ? fitted.slice(fitted.length - maxRows) : fitted;
2141
- }
2142
- function buildContentCells(prompt, agentName, state, elapsed, now) {
2143
- const rows = [];
2687
+ /**
2688
+ * Builds the centered content viewport for the renderer.
2689
+ *
2690
+ * When `availableHeight` is constrained, the layout drops optional sections in
2691
+ * priority order (ASCII art, eyebrow, agent message, then prompt) so the stats
2692
+ * row remains visible and any remaining space is used for the newest moon rows.
2693
+ */
2694
+ function buildContentCells(prompt, agentName, state, elapsed, now, availableHeight) {
2144
2695
  const isRunning = state.status === "running" || state.status === "waiting";
2145
- rows.push([]);
2146
- rows.push(...renderTitleCells(agentName));
2147
- rows.push([], []);
2696
+ const moonRows = renderMoonStripCells(state.iterations, isRunning, now);
2697
+ const maxRows = availableHeight ?? Infinity;
2698
+ if (maxRows <= 0) return [];
2699
+ const titleCells = renderTitleCells(agentName);
2700
+ const titleSpacer = titleCells[1] ?? [];
2148
2701
  const promptLines = wordWrap(prompt, CONTENT_WIDTH, MAX_PROMPT_LINES);
2702
+ const promptRows = [];
2149
2703
  for (let i = 0; i < MAX_PROMPT_LINES; i++) {
2150
2704
  const pl = promptLines[i] ?? "";
2151
- rows.push(pl ? textToCells(pl, "dim") : []);
2152
- }
2153
- rows.push([], []);
2154
- rows.push(renderStatsCells(elapsed, state.totalInputTokens, state.totalOutputTokens, state.commitCount));
2155
- rows.push([], []);
2156
- rows.push(...renderAgentMessageCells(state.lastMessage, state.status));
2157
- rows.push([], []);
2158
- rows.push(...renderMoonStripCells(state.iterations, isRunning, now));
2705
+ promptRows.push(pl ? textToCells(pl, "dim") : []);
2706
+ }
2707
+ const sections = {
2708
+ top: [[]],
2709
+ eyebrow: [
2710
+ titleCells[0],
2711
+ [],
2712
+ []
2713
+ ],
2714
+ art: titleCells.slice(2),
2715
+ prompt: [
2716
+ titleSpacer,
2717
+ ...promptRows,
2718
+ [],
2719
+ []
2720
+ ],
2721
+ stats: [renderStatsCells(elapsed, state.totalInputTokens, state.totalOutputTokens, state.commitCount)],
2722
+ agent: [
2723
+ [],
2724
+ [],
2725
+ ...renderAgentMessageCells(state.lastMessage, state.status)
2726
+ ],
2727
+ moon: [
2728
+ [],
2729
+ [],
2730
+ ...moonRows
2731
+ ]
2732
+ };
2733
+ const flattenSections = () => [
2734
+ ...sections.top,
2735
+ ...sections.eyebrow,
2736
+ ...sections.art,
2737
+ ...sections.prompt,
2738
+ ...sections.stats,
2739
+ ...sections.agent,
2740
+ ...sections.moon
2741
+ ];
2742
+ const optionalSections = [
2743
+ "art",
2744
+ "eyebrow",
2745
+ "agent",
2746
+ "prompt"
2747
+ ];
2748
+ let rows = flattenSections();
2749
+ for (const section of optionalSections) {
2750
+ if (rows.length <= maxRows) break;
2751
+ sections[section] = [];
2752
+ rows = flattenSections();
2753
+ }
2754
+ if (rows.length > maxRows) rows = rows.filter((row) => row.length > 0);
2755
+ if (rows.length > maxRows) {
2756
+ const nonMoonRows = [
2757
+ ...sections.top,
2758
+ ...sections.eyebrow,
2759
+ ...sections.art,
2760
+ ...sections.prompt,
2761
+ ...sections.stats,
2762
+ ...sections.agent
2763
+ ].filter((row) => row.length > 0);
2764
+ const allowedMoonRows = Math.max(0, maxRows - nonMoonRows.length);
2765
+ const visibleMoonRows = allowedMoonRows === 0 ? [] : moonRows.filter((row) => row.length > 0).slice(-allowedMoonRows);
2766
+ rows = [...nonMoonRows, ...visibleMoonRows];
2767
+ }
2159
2768
  return rows;
2160
2769
  }
2161
2770
  function buildFrameCells(prompt, agentName, state, topStars, bottomStars, sideStars, now, terminalWidth, terminalHeight) {
2162
2771
  const elapsed = formatElapsed(now - state.startTime.getTime());
2163
2772
  const availableHeight = Math.max(0, terminalHeight - 2);
2164
- const contentRows = fitContentRows(buildContentCells(prompt, agentName, state, elapsed, now), availableHeight);
2773
+ const contentRows = buildContentCells(prompt, agentName, state, elapsed, now, availableHeight);
2165
2774
  while (contentRows.length < Math.min(BASE_CONTENT_ROWS, availableHeight)) contentRows.push([]);
2166
2775
  const contentCount = contentRows.length;
2167
2776
  const remaining = Math.max(0, availableHeight - contentCount);
@@ -2200,11 +2809,17 @@ var Renderer = class {
2200
2809
  cachedHeight = 0;
2201
2810
  prevCells = [];
2202
2811
  isFirstFrame = true;
2812
+ seedTop;
2813
+ seedBottom;
2814
+ seedSide;
2203
2815
  constructor(orchestrator, prompt, agentName) {
2204
2816
  this.orchestrator = orchestrator;
2205
2817
  this.prompt = prompt;
2206
2818
  this.agentName = agentName;
2207
2819
  this.state = orchestrator.getState();
2820
+ this.seedTop = Math.floor(Math.random() * 2147483646) + 1;
2821
+ this.seedBottom = Math.floor(Math.random() * 2147483646) + 1;
2822
+ this.seedSide = Math.floor(Math.random() * 2147483646) + 1;
2208
2823
  this.exitPromise = new Promise((resolve) => {
2209
2824
  this.exitResolve = resolve;
2210
2825
  });
@@ -2217,14 +2832,14 @@ var Renderer = class {
2217
2832
  };
2218
2833
  });
2219
2834
  this.orchestrator.on("stopped", () => {
2220
- this.stop();
2835
+ this.stop("stopped");
2221
2836
  });
2222
2837
  if (process$1.stdin.isTTY) {
2223
2838
  process$1.stdin.setRawMode(true);
2224
2839
  process$1.stdin.resume();
2225
2840
  process$1.stdin.on("data", (data) => {
2226
2841
  if (data[0] === 3) {
2227
- this.stop();
2842
+ this.stop("interrupted");
2228
2843
  this.orchestrator.stop();
2229
2844
  }
2230
2845
  });
@@ -2232,7 +2847,7 @@ var Renderer = class {
2232
2847
  this.interval = setInterval(() => this.render(), TICK_MS);
2233
2848
  this.render();
2234
2849
  }
2235
- stop() {
2850
+ stop(reason = "stopped") {
2236
2851
  if (this.interval) {
2237
2852
  clearInterval(this.interval);
2238
2853
  this.interval = null;
@@ -2242,7 +2857,7 @@ var Renderer = class {
2242
2857
  process$1.stdin.pause();
2243
2858
  process$1.stdin.removeAllListeners("data");
2244
2859
  }
2245
- this.exitResolve();
2860
+ this.exitResolve(reason);
2246
2861
  }
2247
2862
  waitUntilExit() {
2248
2863
  return this.exitPromise;
@@ -2268,9 +2883,9 @@ var Renderer = class {
2268
2883
  rest: "dim"
2269
2884
  } : star;
2270
2885
  };
2271
- this.topStars = generateStarField(w, h, STAR_DENSITY, 42).map((s) => shrinkBig(s, s.y >= topHeight - proximityRows));
2272
- this.bottomStars = generateStarField(w, h, STAR_DENSITY, 137).map((s) => shrinkBig(s, s.y < proximityRows));
2273
- this.sideStars = generateStarField(w, Math.max(BASE_CONTENT_ROWS, availableHeight), STAR_DENSITY, 99);
2886
+ this.topStars = generateStarField(w, h, STAR_DENSITY, this.seedTop).map((s) => shrinkBig(s, s.y >= topHeight - proximityRows));
2887
+ this.bottomStars = generateStarField(w, h, STAR_DENSITY, this.seedBottom).map((s) => shrinkBig(s, s.y < proximityRows));
2888
+ this.sideStars = generateStarField(w, Math.max(BASE_CONTENT_ROWS, availableHeight), STAR_DENSITY, this.seedSide);
2274
2889
  return true;
2275
2890
  }
2276
2891
  return false;
@@ -2300,12 +2915,21 @@ function slugifyPrompt(prompt) {
2300
2915
  //#region src/cli.ts
2301
2916
  const packageVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
2302
2917
  const FORCE_EXIT_TIMEOUT_MS = 5e3;
2918
+ const GNHF_REEXEC_STDIN_PROMPT = "GNHF_REEXEC_STDIN_PROMPT";
2919
+ const GNHF_REEXEC_STDIN_PROMPT_FILE = "GNHF_REEXEC_STDIN_PROMPT_FILE";
2920
+ const GNHF_REEXEC_STDIN_PROMPT_DIR_PREFIX = "gnhf-stdin-";
2921
+ const GNHF_REEXEC_STDIN_PROMPT_FILENAME = "prompt.txt";
2303
2922
  function parseNonNegativeInteger(value) {
2304
2923
  if (!/^\d+$/.test(value)) throw new InvalidArgumentError("must be a non-negative integer");
2305
2924
  const parsed = Number.parseInt(value, 10);
2306
2925
  if (!Number.isSafeInteger(parsed)) throw new InvalidArgumentError("must be a safe integer");
2307
2926
  return parsed;
2308
2927
  }
2928
+ function parseOnOffBoolean(value) {
2929
+ if (value === "on" || value === "true") return true;
2930
+ if (value === "off" || value === "false") return false;
2931
+ throw new InvalidArgumentError("must be one of: \"on\", \"off\", \"true\", \"false\"");
2932
+ }
2309
2933
  function humanizeErrorMessage(message) {
2310
2934
  if (message.includes("not a git repository")) return "This command must be run inside a Git repository. Change into a repo or run \"git init\" first.";
2311
2935
  return message;
@@ -2330,8 +2954,57 @@ function ask(question) {
2330
2954
  });
2331
2955
  });
2332
2956
  }
2957
+ function getSignalExitCode(signal) {
2958
+ return signal === "SIGINT" ? 130 : 143;
2959
+ }
2960
+ function persistStdinPromptForReexec(prompt) {
2961
+ const promptDir = mkdtempSync(join(tmpdir(), GNHF_REEXEC_STDIN_PROMPT_DIR_PREFIX));
2962
+ const promptPath = join(promptDir, GNHF_REEXEC_STDIN_PROMPT_FILENAME);
2963
+ writeFileSync(promptPath, prompt, {
2964
+ encoding: "utf-8",
2965
+ mode: 384
2966
+ });
2967
+ return {
2968
+ path: promptPath,
2969
+ cleanup: () => {
2970
+ rmSync(promptDir, {
2971
+ recursive: true,
2972
+ force: true
2973
+ });
2974
+ }
2975
+ };
2976
+ }
2977
+ function isTrustedReexecPromptPath(promptPath) {
2978
+ const resolvedPromptPath = resolve(promptPath);
2979
+ const promptDir = dirname(resolvedPromptPath);
2980
+ return basename(resolvedPromptPath) === GNHF_REEXEC_STDIN_PROMPT_FILENAME && dirname(promptDir) === resolve(tmpdir()) && basename(promptDir).startsWith(GNHF_REEXEC_STDIN_PROMPT_DIR_PREFIX);
2981
+ }
2982
+ function cleanupTrustedReexecPromptPath(promptPath) {
2983
+ if (!isTrustedReexecPromptPath(promptPath)) return;
2984
+ const resolvedPromptPath = resolve(promptPath);
2985
+ rmSync(resolvedPromptPath, { force: true });
2986
+ try {
2987
+ rmdirSync(dirname(resolvedPromptPath));
2988
+ } catch {}
2989
+ }
2990
+ function readReexecStdinPrompt(env) {
2991
+ const promptPath = env[GNHF_REEXEC_STDIN_PROMPT_FILE];
2992
+ if (promptPath !== void 0) {
2993
+ delete env[GNHF_REEXEC_STDIN_PROMPT_FILE];
2994
+ try {
2995
+ return readFileSync(promptPath, "utf-8");
2996
+ } finally {
2997
+ cleanupTrustedReexecPromptPath(promptPath);
2998
+ }
2999
+ }
3000
+ const prompt = env[GNHF_REEXEC_STDIN_PROMPT];
3001
+ if (prompt !== void 0) {
3002
+ delete env[GNHF_REEXEC_STDIN_PROMPT];
3003
+ return prompt;
3004
+ }
3005
+ }
2333
3006
  const program = new Command();
2334
- program.name("gnhf").description("Before I go to bed, I tell my agents: good night, have fun").version(packageVersion).argument("[prompt]", "The objective for the coding agent").option("--agent <agent>", "Agent to use (claude, codex, rovodev, or opencode)").option("--max-iterations <n>", "Abort after N total iterations", parseNonNegativeInteger).option("--max-tokens <n>", "Abort after N total input+output tokens", parseNonNegativeInteger).option("--mock", "", false).action(async (promptArg, options) => {
3007
+ program.name("gnhf").description("Before I go to bed, I tell my agents: good night, have fun").version(packageVersion).argument("[prompt]", "The objective for the coding agent").option("--agent <agent>", "Agent to use (claude, codex, rovodev, or opencode)").option("--max-iterations <n>", "Abort after N total iterations", parseNonNegativeInteger).option("--max-tokens <n>", "Abort after N total input+output tokens", parseNonNegativeInteger).option("--prevent-sleep <mode>", "Prevent system sleep during the run (\"on\" or \"off\")", parseOnOffBoolean).option("--mock", "", false).action(async (promptArg, options) => {
2335
3008
  if (options.mock) {
2336
3009
  const mock = new MockOrchestrator();
2337
3010
  enterAltScreen();
@@ -2342,18 +3015,28 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
2342
3015
  exitAltScreen();
2343
3016
  return;
2344
3017
  }
3018
+ let initialSleepPrevention = null;
3019
+ if (process$1.env.GNHF_SLEEP_INHIBITED === "1") initialSleepPrevention = await startSleepPrevention(process$1.argv.slice(2));
2345
3020
  let prompt = promptArg;
2346
- if (!prompt && !process$1.stdin.isTTY) prompt = readFileSync("/dev/stdin", "utf-8").trim();
3021
+ let promptFromStdin = false;
2347
3022
  const agentName = options.agent;
2348
3023
  if (agentName !== void 0 && agentName !== "claude" && agentName !== "codex" && agentName !== "rovodev" && agentName !== "opencode") {
2349
3024
  console.error(`Unknown agent: ${options.agent}. Use "claude", "codex", "rovodev", or "opencode".`);
2350
3025
  process$1.exit(1);
2351
3026
  }
2352
- const config = loadConfig(agentName ? { agent: agentName } : void 0);
3027
+ const config = {
3028
+ ...loadConfig(agentName ? { agent: agentName } : {}),
3029
+ ...options.preventSleep === void 0 ? {} : { preventSleep: options.preventSleep }
3030
+ };
2353
3031
  if (config.agent !== "claude" && config.agent !== "codex" && config.agent !== "rovodev" && config.agent !== "opencode") {
2354
3032
  console.error(`Unknown agent: ${config.agent}. Use "claude", "codex", "rovodev", or "opencode".`);
2355
3033
  process$1.exit(1);
2356
3034
  }
3035
+ if (!prompt && process$1.env.GNHF_SLEEP_INHIBITED === "1") prompt = readReexecStdinPrompt(process$1.env);
3036
+ if (!prompt && !process$1.stdin.isTTY) {
3037
+ prompt = await readStdinText(process$1.stdin);
3038
+ promptFromStdin = true;
3039
+ }
2357
3040
  const cwd = process$1.cwd();
2358
3041
  const currentBranch = getCurrentBranch(cwd);
2359
3042
  const onGnhfBranch = currentBranch.startsWith("gnhf/");
@@ -2382,27 +3065,67 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
2382
3065
  }
2383
3066
  runInfo = initializeNewBranch(prompt, cwd);
2384
3067
  }
3068
+ let sleepPreventionCleanup = null;
3069
+ if (config.preventSleep) {
3070
+ const persistedPrompt = promptFromStdin && prompt !== void 0 ? persistStdinPromptForReexec(prompt) : null;
3071
+ let reexeced = false;
3072
+ try {
3073
+ const sleepPrevention = initialSleepPrevention ?? await startSleepPrevention(process$1.argv.slice(2), { reexecEnv: persistedPrompt ? { [GNHF_REEXEC_STDIN_PROMPT_FILE]: persistedPrompt.path } : void 0 });
3074
+ if (sleepPrevention.type === "reexeced") {
3075
+ reexeced = true;
3076
+ process$1.exit(sleepPrevention.exitCode);
3077
+ }
3078
+ if (sleepPrevention.type === "active") sleepPreventionCleanup = sleepPrevention.cleanup;
3079
+ } finally {
3080
+ if (!reexeced) persistedPrompt?.cleanup();
3081
+ }
3082
+ }
3083
+ appendDebugLog("run:start", { args: process$1.argv.slice(2) });
2385
3084
  const orchestrator = new Orchestrator(config, createAgent(config.agent, runInfo), runInfo, prompt, cwd, startIteration, {
2386
3085
  maxIterations: options.maxIterations,
2387
3086
  maxTokens: options.maxTokens
2388
3087
  });
3088
+ let shutdownSignal = null;
2389
3089
  enterAltScreen();
2390
3090
  const renderer = new Renderer(orchestrator, prompt, config.agent);
2391
3091
  renderer.start();
3092
+ const requestShutdown = (signal) => {
3093
+ if (shutdownSignal) return;
3094
+ shutdownSignal = signal;
3095
+ appendDebugLog(`signal:${signal}`);
3096
+ renderer.stop();
3097
+ orchestrator.stop();
3098
+ };
3099
+ const handleSigInt = () => requestShutdown("SIGINT");
3100
+ const handleSigTerm = () => requestShutdown("SIGTERM");
3101
+ process$1.on("SIGINT", handleSigInt);
3102
+ process$1.on("SIGTERM", handleSigTerm);
2392
3103
  const orchestratorPromise = orchestrator.start().finally(() => {
2393
3104
  renderer.stop();
2394
3105
  }).catch((err) => {
2395
3106
  exitAltScreen();
2396
3107
  die(err instanceof Error ? err.message : String(err));
2397
3108
  });
2398
- await renderer.waitUntilExit();
2399
- exitAltScreen();
2400
- if (await Promise.race([orchestratorPromise.then(() => "done"), new Promise((resolve) => {
2401
- setTimeout(() => resolve("timeout"), FORCE_EXIT_TIMEOUT_MS).unref();
2402
- })]) === "timeout") {
2403
- console.error(`\n gnhf: shutdown timed out after ${FORCE_EXIT_TIMEOUT_MS / 1e3}s, forcing exit\n`);
2404
- process$1.exit(130);
3109
+ try {
3110
+ if (await renderer.waitUntilExit() === "interrupted" && !shutdownSignal) {
3111
+ shutdownSignal = "SIGINT";
3112
+ appendDebugLog("signal:SIGINT");
3113
+ }
3114
+ exitAltScreen();
3115
+ if (await Promise.race([orchestratorPromise.then(() => "done"), new Promise((resolve) => {
3116
+ setTimeout(() => resolve("timeout"), FORCE_EXIT_TIMEOUT_MS).unref();
3117
+ })]) === "timeout") {
3118
+ appendDebugLog("run:shutdown-timeout", { timeoutMs: FORCE_EXIT_TIMEOUT_MS });
3119
+ console.error(`\n gnhf: shutdown timed out after ${FORCE_EXIT_TIMEOUT_MS / 1e3}s, forcing exit\n`);
3120
+ process$1.exit(getSignalExitCode(shutdownSignal ?? "SIGINT"));
3121
+ }
3122
+ } finally {
3123
+ process$1.off("SIGINT", handleSigInt);
3124
+ process$1.off("SIGTERM", handleSigTerm);
3125
+ await sleepPreventionCleanup?.();
2405
3126
  }
3127
+ appendDebugLog("run:complete", { signal: shutdownSignal });
3128
+ if (shutdownSignal) process$1.exit(getSignalExitCode(shutdownSignal));
2406
3129
  });
2407
3130
  function enterAltScreen() {
2408
3131
  process$1.stdout.write("\x1B[?1049h");