gnhf 0.1.8 → 0.1.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/README.md +26 -10
  2. package/dist/cli.mjs +652 -76
  3. package/package.json +5 -4
package/README.md CHANGED
@@ -18,10 +18,10 @@
18
18
  src="https://img.shields.io/github/actions/workflow/status/kunchenguid/gnhf/release-please.yml?style=flat-square&label=release"
19
19
  /></a>
20
20
  <a
21
- href="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-blue?style=flat-square"
21
+ href="https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-blue?style=flat-square"
22
22
  ><img
23
23
  alt="Platform"
24
- src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-blue?style=flat-square"
24
+ src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-blue?style=flat-square"
25
25
  /></a>
26
26
  <a href="https://x.com/kunchenguid"
27
27
  ><img
@@ -63,6 +63,7 @@ $ gnhf "reduce complexity of the codebase without changing functionality" \
63
63
  ```
64
64
 
65
65
  Run `gnhf` from inside a Git repository with a clean working tree. If you are starting from a plain directory, run `git init` first.
66
+ `gnhf` supports macOS, Linux, and Windows.
66
67
 
67
68
  ## Install
68
69
 
@@ -143,12 +144,13 @@ npm link
143
144
 
144
145
  ### Flags
145
146
 
146
- | Flag | Description | Default |
147
- | ---------------------- | ---------------------------------------------------------- | ---------------------- |
148
- | `--agent <agent>` | Agent to use (`claude`, `codex`, `rovodev`, or `opencode`) | config file (`claude`) |
149
- | `--max-iterations <n>` | Abort after `n` total iterations | unlimited |
150
- | `--max-tokens <n>` | Abort after `n` total input+output tokens | unlimited |
151
- | `--version` | Show version | |
147
+ | Flag | Description | Default |
148
+ | ------------------------ | ------------------------------------------------------------------ | ---------------------- |
149
+ | `--agent <agent>` | Agent to use (`claude`, `codex`, `rovodev`, or `opencode`) | config file (`claude`) |
150
+ | `--max-iterations <n>` | Abort after `n` total iterations | unlimited |
151
+ | `--max-tokens <n>` | Abort after `n` total input+output tokens | unlimited |
152
+ | `--prevent-sleep <mode>` | Prevent system sleep during the run (`on`/`off` or `true`/`false`) | config file (`on`) |
153
+ | `--version` | Show version | |
152
154
 
153
155
  ## Configuration
154
156
 
@@ -160,11 +162,24 @@ agent: claude
160
162
 
161
163
  # Abort after this many consecutive failures
162
164
  maxConsecutiveFailures: 3
165
+
166
+ # Prevent the machine from sleeping during a run
167
+ preventSleep: true
163
168
  ```
164
169
 
165
170
  If the file does not exist yet, `gnhf` creates it on first run using the resolved defaults.
166
171
 
167
- CLI flags override config file values. The iteration and token caps are runtime-only flags and are not persisted in `config.yml`.
172
+ CLI flags override config file values. `--prevent-sleep` accepts `on`/`off` as well as `true`/`false`; the config file always uses a boolean.
173
+ The iteration and token caps are runtime-only flags and are not persisted in `config.yml`.
174
+ When sleep prevention is enabled, `gnhf` uses the native mechanism for your OS: `caffeinate` on macOS, `systemd-inhibit` on Linux, and a small PowerShell helper backed by `SetThreadExecutionState` on Windows.
175
+
176
+ ## Debug Logs
177
+
178
+ Set `GNHF_DEBUG_LOG_PATH` to capture lifecycle events as JSONL while debugging a run:
179
+
180
+ ```sh
181
+ GNHF_DEBUG_LOG_PATH=/tmp/gnhf-debug.jsonl gnhf "ship it"
182
+ ```
168
183
 
169
184
  ## Agents
170
185
 
@@ -182,7 +197,8 @@ CLI flags override config file values. The iteration and token caps are runtime-
182
197
  ```sh
183
198
  npm run build # Build with tsdown
184
199
  npm run dev # Watch mode
185
- npm test # Run tests (vitest)
200
+ npm test # Build, then run unit tests (vitest)
201
+ npm run test:e2e # Build, then run end-to-end tests against the mock opencode executable
186
202
  npm run lint # ESLint
187
203
  npm run format # Prettier
188
204
  ```
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());
1836
+ appendDebugLog("rovodev:shutdown", {
1837
+ cwd: server.cwd,
1838
+ port: server.port
1381
1839
  });
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?.();
1392
- });
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",
@@ -2217,14 +2685,14 @@ var Renderer = class {
2217
2685
  };
2218
2686
  });
2219
2687
  this.orchestrator.on("stopped", () => {
2220
- this.stop();
2688
+ this.stop("stopped");
2221
2689
  });
2222
2690
  if (process$1.stdin.isTTY) {
2223
2691
  process$1.stdin.setRawMode(true);
2224
2692
  process$1.stdin.resume();
2225
2693
  process$1.stdin.on("data", (data) => {
2226
2694
  if (data[0] === 3) {
2227
- this.stop();
2695
+ this.stop("interrupted");
2228
2696
  this.orchestrator.stop();
2229
2697
  }
2230
2698
  });
@@ -2232,7 +2700,7 @@ var Renderer = class {
2232
2700
  this.interval = setInterval(() => this.render(), TICK_MS);
2233
2701
  this.render();
2234
2702
  }
2235
- stop() {
2703
+ stop(reason = "stopped") {
2236
2704
  if (this.interval) {
2237
2705
  clearInterval(this.interval);
2238
2706
  this.interval = null;
@@ -2242,7 +2710,7 @@ var Renderer = class {
2242
2710
  process$1.stdin.pause();
2243
2711
  process$1.stdin.removeAllListeners("data");
2244
2712
  }
2245
- this.exitResolve();
2713
+ this.exitResolve(reason);
2246
2714
  }
2247
2715
  waitUntilExit() {
2248
2716
  return this.exitPromise;
@@ -2300,12 +2768,21 @@ function slugifyPrompt(prompt) {
2300
2768
  //#region src/cli.ts
2301
2769
  const packageVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
2302
2770
  const FORCE_EXIT_TIMEOUT_MS = 5e3;
2771
+ const GNHF_REEXEC_STDIN_PROMPT = "GNHF_REEXEC_STDIN_PROMPT";
2772
+ const GNHF_REEXEC_STDIN_PROMPT_FILE = "GNHF_REEXEC_STDIN_PROMPT_FILE";
2773
+ const GNHF_REEXEC_STDIN_PROMPT_DIR_PREFIX = "gnhf-stdin-";
2774
+ const GNHF_REEXEC_STDIN_PROMPT_FILENAME = "prompt.txt";
2303
2775
  function parseNonNegativeInteger(value) {
2304
2776
  if (!/^\d+$/.test(value)) throw new InvalidArgumentError("must be a non-negative integer");
2305
2777
  const parsed = Number.parseInt(value, 10);
2306
2778
  if (!Number.isSafeInteger(parsed)) throw new InvalidArgumentError("must be a safe integer");
2307
2779
  return parsed;
2308
2780
  }
2781
+ function parseOnOffBoolean(value) {
2782
+ if (value === "on" || value === "true") return true;
2783
+ if (value === "off" || value === "false") return false;
2784
+ throw new InvalidArgumentError("must be one of: \"on\", \"off\", \"true\", \"false\"");
2785
+ }
2309
2786
  function humanizeErrorMessage(message) {
2310
2787
  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
2788
  return message;
@@ -2330,8 +2807,57 @@ function ask(question) {
2330
2807
  });
2331
2808
  });
2332
2809
  }
2810
+ function getSignalExitCode(signal) {
2811
+ return signal === "SIGINT" ? 130 : 143;
2812
+ }
2813
+ function persistStdinPromptForReexec(prompt) {
2814
+ const promptDir = mkdtempSync(join(tmpdir(), GNHF_REEXEC_STDIN_PROMPT_DIR_PREFIX));
2815
+ const promptPath = join(promptDir, GNHF_REEXEC_STDIN_PROMPT_FILENAME);
2816
+ writeFileSync(promptPath, prompt, {
2817
+ encoding: "utf-8",
2818
+ mode: 384
2819
+ });
2820
+ return {
2821
+ path: promptPath,
2822
+ cleanup: () => {
2823
+ rmSync(promptDir, {
2824
+ recursive: true,
2825
+ force: true
2826
+ });
2827
+ }
2828
+ };
2829
+ }
2830
+ function isTrustedReexecPromptPath(promptPath) {
2831
+ const resolvedPromptPath = resolve(promptPath);
2832
+ const promptDir = dirname(resolvedPromptPath);
2833
+ return basename(resolvedPromptPath) === GNHF_REEXEC_STDIN_PROMPT_FILENAME && dirname(promptDir) === resolve(tmpdir()) && basename(promptDir).startsWith(GNHF_REEXEC_STDIN_PROMPT_DIR_PREFIX);
2834
+ }
2835
+ function cleanupTrustedReexecPromptPath(promptPath) {
2836
+ if (!isTrustedReexecPromptPath(promptPath)) return;
2837
+ const resolvedPromptPath = resolve(promptPath);
2838
+ rmSync(resolvedPromptPath, { force: true });
2839
+ try {
2840
+ rmdirSync(dirname(resolvedPromptPath));
2841
+ } catch {}
2842
+ }
2843
+ function readReexecStdinPrompt(env) {
2844
+ const promptPath = env[GNHF_REEXEC_STDIN_PROMPT_FILE];
2845
+ if (promptPath !== void 0) {
2846
+ delete env[GNHF_REEXEC_STDIN_PROMPT_FILE];
2847
+ try {
2848
+ return readFileSync(promptPath, "utf-8");
2849
+ } finally {
2850
+ cleanupTrustedReexecPromptPath(promptPath);
2851
+ }
2852
+ }
2853
+ const prompt = env[GNHF_REEXEC_STDIN_PROMPT];
2854
+ if (prompt !== void 0) {
2855
+ delete env[GNHF_REEXEC_STDIN_PROMPT];
2856
+ return prompt;
2857
+ }
2858
+ }
2333
2859
  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) => {
2860
+ 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
2861
  if (options.mock) {
2336
2862
  const mock = new MockOrchestrator();
2337
2863
  enterAltScreen();
@@ -2342,18 +2868,28 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
2342
2868
  exitAltScreen();
2343
2869
  return;
2344
2870
  }
2871
+ let initialSleepPrevention = null;
2872
+ if (process$1.env.GNHF_SLEEP_INHIBITED === "1") initialSleepPrevention = await startSleepPrevention(process$1.argv.slice(2));
2345
2873
  let prompt = promptArg;
2346
- if (!prompt && !process$1.stdin.isTTY) prompt = readFileSync("/dev/stdin", "utf-8").trim();
2874
+ let promptFromStdin = false;
2347
2875
  const agentName = options.agent;
2348
2876
  if (agentName !== void 0 && agentName !== "claude" && agentName !== "codex" && agentName !== "rovodev" && agentName !== "opencode") {
2349
2877
  console.error(`Unknown agent: ${options.agent}. Use "claude", "codex", "rovodev", or "opencode".`);
2350
2878
  process$1.exit(1);
2351
2879
  }
2352
- const config = loadConfig(agentName ? { agent: agentName } : void 0);
2880
+ const config = {
2881
+ ...loadConfig(agentName ? { agent: agentName } : {}),
2882
+ ...options.preventSleep === void 0 ? {} : { preventSleep: options.preventSleep }
2883
+ };
2353
2884
  if (config.agent !== "claude" && config.agent !== "codex" && config.agent !== "rovodev" && config.agent !== "opencode") {
2354
2885
  console.error(`Unknown agent: ${config.agent}. Use "claude", "codex", "rovodev", or "opencode".`);
2355
2886
  process$1.exit(1);
2356
2887
  }
2888
+ if (!prompt && process$1.env.GNHF_SLEEP_INHIBITED === "1") prompt = readReexecStdinPrompt(process$1.env);
2889
+ if (!prompt && !process$1.stdin.isTTY) {
2890
+ prompt = await readStdinText(process$1.stdin);
2891
+ promptFromStdin = true;
2892
+ }
2357
2893
  const cwd = process$1.cwd();
2358
2894
  const currentBranch = getCurrentBranch(cwd);
2359
2895
  const onGnhfBranch = currentBranch.startsWith("gnhf/");
@@ -2382,27 +2918,67 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
2382
2918
  }
2383
2919
  runInfo = initializeNewBranch(prompt, cwd);
2384
2920
  }
2921
+ let sleepPreventionCleanup = null;
2922
+ if (config.preventSleep) {
2923
+ const persistedPrompt = promptFromStdin && prompt !== void 0 ? persistStdinPromptForReexec(prompt) : null;
2924
+ let reexeced = false;
2925
+ try {
2926
+ const sleepPrevention = initialSleepPrevention ?? await startSleepPrevention(process$1.argv.slice(2), { reexecEnv: persistedPrompt ? { [GNHF_REEXEC_STDIN_PROMPT_FILE]: persistedPrompt.path } : void 0 });
2927
+ if (sleepPrevention.type === "reexeced") {
2928
+ reexeced = true;
2929
+ process$1.exit(sleepPrevention.exitCode);
2930
+ }
2931
+ if (sleepPrevention.type === "active") sleepPreventionCleanup = sleepPrevention.cleanup;
2932
+ } finally {
2933
+ if (!reexeced) persistedPrompt?.cleanup();
2934
+ }
2935
+ }
2936
+ appendDebugLog("run:start", { args: process$1.argv.slice(2) });
2385
2937
  const orchestrator = new Orchestrator(config, createAgent(config.agent, runInfo), runInfo, prompt, cwd, startIteration, {
2386
2938
  maxIterations: options.maxIterations,
2387
2939
  maxTokens: options.maxTokens
2388
2940
  });
2941
+ let shutdownSignal = null;
2389
2942
  enterAltScreen();
2390
2943
  const renderer = new Renderer(orchestrator, prompt, config.agent);
2391
2944
  renderer.start();
2945
+ const requestShutdown = (signal) => {
2946
+ if (shutdownSignal) return;
2947
+ shutdownSignal = signal;
2948
+ appendDebugLog(`signal:${signal}`);
2949
+ renderer.stop();
2950
+ orchestrator.stop();
2951
+ };
2952
+ const handleSigInt = () => requestShutdown("SIGINT");
2953
+ const handleSigTerm = () => requestShutdown("SIGTERM");
2954
+ process$1.on("SIGINT", handleSigInt);
2955
+ process$1.on("SIGTERM", handleSigTerm);
2392
2956
  const orchestratorPromise = orchestrator.start().finally(() => {
2393
2957
  renderer.stop();
2394
2958
  }).catch((err) => {
2395
2959
  exitAltScreen();
2396
2960
  die(err instanceof Error ? err.message : String(err));
2397
2961
  });
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);
2962
+ try {
2963
+ if (await renderer.waitUntilExit() === "interrupted" && !shutdownSignal) {
2964
+ shutdownSignal = "SIGINT";
2965
+ appendDebugLog("signal:SIGINT");
2966
+ }
2967
+ exitAltScreen();
2968
+ if (await Promise.race([orchestratorPromise.then(() => "done"), new Promise((resolve) => {
2969
+ setTimeout(() => resolve("timeout"), FORCE_EXIT_TIMEOUT_MS).unref();
2970
+ })]) === "timeout") {
2971
+ appendDebugLog("run:shutdown-timeout", { timeoutMs: FORCE_EXIT_TIMEOUT_MS });
2972
+ console.error(`\n gnhf: shutdown timed out after ${FORCE_EXIT_TIMEOUT_MS / 1e3}s, forcing exit\n`);
2973
+ process$1.exit(getSignalExitCode(shutdownSignal ?? "SIGINT"));
2974
+ }
2975
+ } finally {
2976
+ process$1.off("SIGINT", handleSigInt);
2977
+ process$1.off("SIGTERM", handleSigTerm);
2978
+ await sleepPreventionCleanup?.();
2405
2979
  }
2980
+ appendDebugLog("run:complete", { signal: shutdownSignal });
2981
+ if (shutdownSignal) process$1.exit(getSignalExitCode(shutdownSignal));
2406
2982
  });
2407
2983
  function enterAltScreen() {
2408
2984
  process$1.stdout.write("\x1B[?1049h");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gnhf",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Before I go to bed, I tell my agents: good night, have fun",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,12 +9,13 @@
9
9
  "scripts": {
10
10
  "build": "tsdown",
11
11
  "dev": "tsdown --watch",
12
- "start": "node dist/cli.js",
12
+ "start": "node dist/cli.mjs",
13
13
  "lint": "eslint src",
14
14
  "format": "prettier --write src",
15
15
  "format:check": "prettier --check src",
16
- "test": "vitest run",
17
- "test:coverage": "vitest run --coverage"
16
+ "test": "npm run build && vitest run",
17
+ "test:e2e": "npm run build && vitest run test/e2e.test.ts",
18
+ "test:coverage": "vitest run --coverage --exclude test/e2e.test.ts"
18
19
  },
19
20
  "dependencies": {
20
21
  "commander": "^14.0.3",