gnhf 0.1.7 → 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 +37 -16
  2. package/dist/cli.mjs +658 -77
  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
@@ -39,6 +39,8 @@
39
39
  <img src="docs/splash.png" alt="gnhf — Good Night, Have Fun" width="800">
40
40
  </p>
41
41
 
42
+ Never wake up empty-handed.
43
+
42
44
  gnhf is a [ralph](https://ghuntley.com/ralph/), [autoresearch](https://github.com/karpathy/autoresearch)-style orchestrator that keeps your agents running while you sleep — each iteration makes one small, committed, documented change towards an objective.
43
45
  You wake up to a branch full of clean work and a log of everything that happened.
44
46
 
@@ -61,6 +63,7 @@ $ gnhf "reduce complexity of the codebase without changing functionality" \
61
63
  ```
62
64
 
63
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.
64
67
 
65
68
  ## Install
66
69
 
@@ -80,10 +83,6 @@ npm run build
80
83
  npm link
81
84
  ```
82
85
 
83
- If you want to run `gnhf --agent rovodev`, install Atlassian's `acli` and authenticate it with Rovo Dev first.
84
-
85
- If you want to run `gnhf --agent opencode`, install `opencode` and authenticate at least one provider first.
86
-
87
86
  ## How It Works
88
87
 
89
88
  ```
@@ -145,12 +144,13 @@ If you want to run `gnhf --agent opencode`, install `opencode` and authenticate
145
144
 
146
145
  ### Flags
147
146
 
148
- | Flag | Description | Default |
149
- | ---------------------- | ---------------------------------------------------------- | ---------------------- |
150
- | `--agent <agent>` | Agent to use (`claude`, `codex`, `rovodev`, or `opencode`) | config file (`claude`) |
151
- | `--max-iterations <n>` | Abort after `n` total iterations | unlimited |
152
- | `--max-tokens <n>` | Abort after `n` total input+output tokens | unlimited |
153
- | `--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 | |
154
154
 
155
155
  ## Configuration
156
156
 
@@ -162,22 +162,43 @@ agent: claude
162
162
 
163
163
  # Abort after this many consecutive failures
164
164
  maxConsecutiveFailures: 3
165
+
166
+ # Prevent the machine from sleeping during a run
167
+ preventSleep: true
165
168
  ```
166
169
 
167
170
  If the file does not exist yet, `gnhf` creates it on first run using the resolved defaults.
168
171
 
169
- 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
+ ```
183
+
184
+ ## Agents
170
185
 
171
- When using `agent: rovodev`, `gnhf` starts a local `acli rovodev serve --disable-session-token <port>` process automatically in the repo workspace. That requires `acli` to be installed and already authenticated for Rovo Dev.
186
+ `gnhf` supports four agents:
172
187
 
173
- When using `agent: opencode`, `gnhf` starts a local `opencode serve --hostname 127.0.0.1 --port <port> --print-logs` process automatically, creates a per-run session for the target workspace, and applies a blanket `{"permission":"*","pattern":"*","action":"allow"}` rule so tool calls do not block on prompts. That requires the `opencode` CLI to be installed and already configured with a usable model provider.
188
+ | Agent | Flag | Requirements | Notes |
189
+ | ----------- | ------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
190
+ | Claude Code | `--agent claude` | Install Anthropic's `claude` CLI and sign in first. | `gnhf` invokes `claude` directly in non-interactive mode. |
191
+ | Codex | `--agent codex` | Install OpenAI's `codex` CLI and sign in first. | `gnhf` invokes `codex exec` directly in non-interactive mode. |
192
+ | Rovo Dev | `--agent rovodev` | Install Atlassian's `acli` and authenticate it with Rovo Dev first. | `gnhf` starts a local `acli rovodev serve --disable-session-token <port>` process automatically in the repo workspace. |
193
+ | OpenCode | `--agent opencode` | Install `opencode` and configure at least one usable model provider first. | `gnhf` starts a local `opencode serve --hostname 127.0.0.1 --port <port> --print-logs` process automatically, creates a per-run session, and applies a blanket allow rule so tool calls do not block on prompts. |
174
194
 
175
195
  ## Development
176
196
 
177
197
  ```sh
178
198
  npm run build # Build with tsdown
179
199
  npm run dev # Watch mode
180
- 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
181
202
  npm run lint # ESLint
182
203
  npm run format # Prettier
183
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) {
@@ -135,6 +173,7 @@ function resetHard(cwd) {
135
173
  //#region src/core/agents/types.ts
136
174
  const AGENT_OUTPUT_SCHEMA = {
137
175
  type: "object",
176
+ additionalProperties: false,
138
177
  properties: {
139
178
  success: { type: "boolean" },
140
179
  summary: { type: "string" },
@@ -156,6 +195,9 @@ const AGENT_OUTPUT_SCHEMA = {
156
195
  };
157
196
  //#endregion
158
197
  //#region src/core/run.ts
198
+ function writeSchemaFile(schemaPath) {
199
+ writeFileSync(schemaPath, JSON.stringify(AGENT_OUTPUT_SCHEMA, null, 2), "utf-8");
200
+ }
159
201
  function ensureRunMetadataIgnored(cwd) {
160
202
  const excludePath = execFileSync("git", [
161
203
  "rev-parse",
@@ -183,7 +225,7 @@ function setupRun(runId, prompt, baseCommit, cwd) {
183
225
  const notesPath = join(runDir, "notes.md");
184
226
  writeFileSync(notesPath, `# gnhf run: ${runId}\n\nObjective: ${prompt}\n\n## Iteration Log\n`, "utf-8");
185
227
  const schemaPath = join(runDir, "output-schema.json");
186
- writeFileSync(schemaPath, JSON.stringify(AGENT_OUTPUT_SCHEMA, null, 2), "utf-8");
228
+ writeSchemaFile(schemaPath);
187
229
  const baseCommitPath = join(runDir, "base-commit");
188
230
  const hasStoredBaseCommit = existsSync(baseCommitPath);
189
231
  const resolvedBaseCommit = hasStoredBaseCommit ? readFileSync(baseCommitPath, "utf-8").trim() : baseCommit;
@@ -204,6 +246,7 @@ function resumeRun(runId, cwd) {
204
246
  const promptPath = join(runDir, "prompt.md");
205
247
  const notesPath = join(runDir, "notes.md");
206
248
  const schemaPath = join(runDir, "output-schema.json");
249
+ writeSchemaFile(schemaPath);
207
250
  const baseCommitPath = join(runDir, "base-commit");
208
251
  return {
209
252
  runId,
@@ -245,6 +288,417 @@ function appendNotes(notesPath, iteration, summary, changes, learnings) {
245
288
  ].join("\n"), "utf-8");
246
289
  }
247
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
248
702
  //#region src/core/agents/stream-utils.ts
249
703
  /**
250
704
  * Wire stderr collection, spawn-error handling, and the common close-handler
@@ -481,6 +935,21 @@ function buildPrompt(prompt) {
481
935
  `The JSON must match this schema exactly: ${JSON.stringify(AGENT_OUTPUT_SCHEMA)}`
482
936
  ].join("\n");
483
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
+ }
484
953
  function createAbortError$1() {
485
954
  return /* @__PURE__ */ new Error("Agent was aborted");
486
955
  }
@@ -552,6 +1021,7 @@ var OpenCodeAgent = class {
552
1021
  fetchFn;
553
1022
  getPortFn;
554
1023
  killProcessFn;
1024
+ platform;
555
1025
  spawnFn;
556
1026
  server = null;
557
1027
  closingPromise = null;
@@ -559,6 +1029,7 @@ var OpenCodeAgent = class {
559
1029
  this.fetchFn = deps.fetch ?? fetch;
560
1030
  this.getPortFn = deps.getPort ?? getAvailablePort$1;
561
1031
  this.killProcessFn = deps.killProcess ?? process.kill.bind(process);
1032
+ this.platform = deps.platform ?? process.platform;
562
1033
  this.spawnFn = deps.spawn ?? spawn;
563
1034
  }
564
1035
  async run(prompt, cwd, options) {
@@ -604,7 +1075,8 @@ var OpenCodeAgent = class {
604
1075
  return this.server;
605
1076
  }
606
1077
  const port = await this.getPortFn();
607
- const detached = process.platform !== "win32";
1078
+ const isWindows = this.platform === "win32";
1079
+ const detached = !isWindows;
608
1080
  const child = this.spawnFn("opencode", [
609
1081
  "serve",
610
1082
  "--hostname",
@@ -615,6 +1087,7 @@ var OpenCodeAgent = class {
615
1087
  ], {
616
1088
  cwd,
617
1089
  detached,
1090
+ shell: isWindows,
618
1091
  stdio: [
619
1092
  "ignore",
620
1093
  "pipe",
@@ -647,6 +1120,11 @@ var OpenCodeAgent = class {
647
1120
  if (this.server === server) this.server = null;
648
1121
  });
649
1122
  this.server = server;
1123
+ appendDebugLog("opencode:spawn", {
1124
+ cwd,
1125
+ port,
1126
+ detached
1127
+ });
650
1128
  server.readyPromise = this.waitForHealthy(server, signal).catch(async (error) => {
651
1129
  await this.shutdownServer();
652
1130
  throw error;
@@ -929,37 +1407,20 @@ var OpenCodeAgent = class {
929
1407
  return;
930
1408
  }
931
1409
  const server = this.server;
932
- const waitForClose = new Promise((resolve) => {
933
- if (server.closed) {
934
- resolve();
935
- return;
936
- }
937
- server.child.once("close", () => resolve());
938
- });
939
- try {
940
- this.signalServer(server, "SIGTERM");
941
- } catch {}
942
- const forceKill = new Promise((resolve) => {
943
- setTimeout(() => {
944
- if (!server.closed) try {
945
- this.signalServer(server, "SIGKILL");
946
- } catch {}
947
- resolve();
948
- }, 3e3).unref?.();
1410
+ appendDebugLog("opencode:shutdown", {
1411
+ cwd: server.cwd,
1412
+ port: server.port
949
1413
  });
950
- 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(() => {
951
1419
  if (this.server === server) this.server = null;
952
1420
  this.closingPromise = null;
953
1421
  });
954
1422
  await this.closingPromise;
955
1423
  }
956
- signalServer(server, signal) {
957
- if (server.detached && server.child.pid) try {
958
- this.killProcessFn(-server.child.pid, signal);
959
- return;
960
- } catch {}
961
- server.child.kill(signal);
962
- }
963
1424
  async requestJSON(server, path, options) {
964
1425
  const body = await this.requestText(server, path, options);
965
1426
  return JSON.parse(body);
@@ -1144,6 +1605,11 @@ var RovoDevAgent = class {
1144
1605
  if (this.server === server) this.server = null;
1145
1606
  });
1146
1607
  this.server = server;
1608
+ appendDebugLog("rovodev:spawn", {
1609
+ cwd,
1610
+ port,
1611
+ detached
1612
+ });
1147
1613
  server.readyPromise = this.waitForHealthy(server, signal).catch(async (error) => {
1148
1614
  await this.shutdownServer();
1149
1615
  throw error;
@@ -1153,13 +1619,13 @@ var RovoDevAgent = class {
1153
1619
  }
1154
1620
  async waitForHealthy(server, signal) {
1155
1621
  const deadline = Date.now() + 3e4;
1156
- let spawnError = null;
1622
+ let spawnErrorMessage = null;
1157
1623
  server.child.once("error", (error) => {
1158
- spawnError = error;
1624
+ spawnErrorMessage = error.message;
1159
1625
  });
1160
1626
  while (Date.now() < deadline) {
1161
1627
  if (signal?.aborted) throw createAbortError();
1162
- if (spawnError) throw new Error(`Failed to spawn rovodev: ${spawnError.message}`);
1628
+ if (spawnErrorMessage) throw new Error(`Failed to spawn rovodev: ${spawnErrorMessage}`);
1163
1629
  if (server.closed) {
1164
1630
  const output = server.stderr.trim() || server.stdout.trim();
1165
1631
  throw new Error(output ? `rovodev exited before becoming ready: ${output}` : "rovodev exited before becoming ready");
@@ -1367,37 +1833,20 @@ var RovoDevAgent = class {
1367
1833
  return;
1368
1834
  }
1369
1835
  const server = this.server;
1370
- const waitForClose = new Promise((resolve) => {
1371
- if (server.closed) {
1372
- resolve();
1373
- return;
1374
- }
1375
- server.child.once("close", () => resolve());
1376
- });
1377
- try {
1378
- this.signalServer(server, "SIGTERM");
1379
- } catch {}
1380
- const forceKill = new Promise((resolve) => {
1381
- setTimeout(() => {
1382
- if (!server.closed) try {
1383
- this.signalServer(server, "SIGKILL");
1384
- } catch {}
1385
- resolve();
1386
- }, 3e3).unref?.();
1836
+ appendDebugLog("rovodev:shutdown", {
1837
+ cwd: server.cwd,
1838
+ port: server.port
1387
1839
  });
1388
- 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(() => {
1389
1845
  if (this.server === server) this.server = null;
1390
1846
  this.closingPromise = null;
1391
1847
  });
1392
1848
  await this.closingPromise;
1393
1849
  }
1394
- signalServer(server, signal) {
1395
- if (server.detached && server.child.pid) try {
1396
- this.killProcessFn(-server.child.pid, signal);
1397
- return;
1398
- } catch {}
1399
- server.child.kill(signal);
1400
- }
1401
1850
  async requestJSON(server, path, options) {
1402
1851
  return await (await this.request(server, path, options)).json();
1403
1852
  }
@@ -1461,6 +1910,7 @@ ${params.prompt}`;
1461
1910
  }
1462
1911
  //#endregion
1463
1912
  //#region src/core/orchestrator.ts
1913
+ const STOP_CLOSE_AGENT_GRACE_MS = 250;
1464
1914
  var Orchestrator = class extends EventEmitter {
1465
1915
  config;
1466
1916
  agent;
@@ -1470,6 +1920,7 @@ var Orchestrator = class extends EventEmitter {
1470
1920
  limits;
1471
1921
  stopRequested = false;
1472
1922
  stopPromise = null;
1923
+ activeIterationPromise = null;
1473
1924
  activeAbortController = null;
1474
1925
  pendingAbortReason = null;
1475
1926
  state = {
@@ -1505,7 +1956,23 @@ var Orchestrator = class extends EventEmitter {
1505
1956
  this.activeAbortController?.abort();
1506
1957
  if (this.stopPromise) return;
1507
1958
  this.stopPromise = (async () => {
1508
- 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();
1509
1976
  resetHard(this.cwd);
1510
1977
  this.state.status = "stopped";
1511
1978
  this.emit("state", this.getState());
@@ -1532,7 +1999,10 @@ var Orchestrator = class extends EventEmitter {
1532
1999
  runId: this.runInfo.runId,
1533
2000
  prompt: this.prompt
1534
2001
  });
1535
- 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;
1536
2006
  if (result.type === "aborted") {
1537
2007
  this.abort(result.reason);
1538
2008
  break;
@@ -1564,6 +2034,7 @@ var Orchestrator = class extends EventEmitter {
1564
2034
  }
1565
2035
  }
1566
2036
  } finally {
2037
+ this.activeIterationPromise = null;
1567
2038
  if (this.stopPromise) await this.stopPromise;
1568
2039
  else await this.closeAgent();
1569
2040
  }
@@ -1595,6 +2066,7 @@ var Orchestrator = class extends EventEmitter {
1595
2066
  signal: this.activeAbortController.signal,
1596
2067
  logPath
1597
2068
  });
2069
+ if (this.stopRequested) return { type: "stopped" };
1598
2070
  if (result.output.success) return {
1599
2071
  type: "completed",
1600
2072
  record: this.recordSuccess(result.output)
@@ -1611,6 +2083,7 @@ var Orchestrator = class extends EventEmitter {
1611
2083
  reason: this.pendingAbortReason
1612
2084
  };
1613
2085
  }
2086
+ if (this.stopRequested) return { type: "stopped" };
1614
2087
  const summary = err instanceof Error ? err.message : String(err);
1615
2088
  return {
1616
2089
  type: "completed",
@@ -2212,14 +2685,14 @@ var Renderer = class {
2212
2685
  };
2213
2686
  });
2214
2687
  this.orchestrator.on("stopped", () => {
2215
- this.stop();
2688
+ this.stop("stopped");
2216
2689
  });
2217
2690
  if (process$1.stdin.isTTY) {
2218
2691
  process$1.stdin.setRawMode(true);
2219
2692
  process$1.stdin.resume();
2220
2693
  process$1.stdin.on("data", (data) => {
2221
2694
  if (data[0] === 3) {
2222
- this.stop();
2695
+ this.stop("interrupted");
2223
2696
  this.orchestrator.stop();
2224
2697
  }
2225
2698
  });
@@ -2227,7 +2700,7 @@ var Renderer = class {
2227
2700
  this.interval = setInterval(() => this.render(), TICK_MS);
2228
2701
  this.render();
2229
2702
  }
2230
- stop() {
2703
+ stop(reason = "stopped") {
2231
2704
  if (this.interval) {
2232
2705
  clearInterval(this.interval);
2233
2706
  this.interval = null;
@@ -2237,7 +2710,7 @@ var Renderer = class {
2237
2710
  process$1.stdin.pause();
2238
2711
  process$1.stdin.removeAllListeners("data");
2239
2712
  }
2240
- this.exitResolve();
2713
+ this.exitResolve(reason);
2241
2714
  }
2242
2715
  waitUntilExit() {
2243
2716
  return this.exitPromise;
@@ -2295,12 +2768,21 @@ function slugifyPrompt(prompt) {
2295
2768
  //#region src/cli.ts
2296
2769
  const packageVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
2297
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";
2298
2775
  function parseNonNegativeInteger(value) {
2299
2776
  if (!/^\d+$/.test(value)) throw new InvalidArgumentError("must be a non-negative integer");
2300
2777
  const parsed = Number.parseInt(value, 10);
2301
2778
  if (!Number.isSafeInteger(parsed)) throw new InvalidArgumentError("must be a safe integer");
2302
2779
  return parsed;
2303
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
+ }
2304
2786
  function humanizeErrorMessage(message) {
2305
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.";
2306
2788
  return message;
@@ -2325,8 +2807,57 @@ function ask(question) {
2325
2807
  });
2326
2808
  });
2327
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
+ }
2328
2859
  const program = new Command();
2329
- 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) => {
2330
2861
  if (options.mock) {
2331
2862
  const mock = new MockOrchestrator();
2332
2863
  enterAltScreen();
@@ -2337,18 +2868,28 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
2337
2868
  exitAltScreen();
2338
2869
  return;
2339
2870
  }
2871
+ let initialSleepPrevention = null;
2872
+ if (process$1.env.GNHF_SLEEP_INHIBITED === "1") initialSleepPrevention = await startSleepPrevention(process$1.argv.slice(2));
2340
2873
  let prompt = promptArg;
2341
- if (!prompt && !process$1.stdin.isTTY) prompt = readFileSync("/dev/stdin", "utf-8").trim();
2874
+ let promptFromStdin = false;
2342
2875
  const agentName = options.agent;
2343
2876
  if (agentName !== void 0 && agentName !== "claude" && agentName !== "codex" && agentName !== "rovodev" && agentName !== "opencode") {
2344
2877
  console.error(`Unknown agent: ${options.agent}. Use "claude", "codex", "rovodev", or "opencode".`);
2345
2878
  process$1.exit(1);
2346
2879
  }
2347
- 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
+ };
2348
2884
  if (config.agent !== "claude" && config.agent !== "codex" && config.agent !== "rovodev" && config.agent !== "opencode") {
2349
2885
  console.error(`Unknown agent: ${config.agent}. Use "claude", "codex", "rovodev", or "opencode".`);
2350
2886
  process$1.exit(1);
2351
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
+ }
2352
2893
  const cwd = process$1.cwd();
2353
2894
  const currentBranch = getCurrentBranch(cwd);
2354
2895
  const onGnhfBranch = currentBranch.startsWith("gnhf/");
@@ -2377,27 +2918,67 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
2377
2918
  }
2378
2919
  runInfo = initializeNewBranch(prompt, cwd);
2379
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) });
2380
2937
  const orchestrator = new Orchestrator(config, createAgent(config.agent, runInfo), runInfo, prompt, cwd, startIteration, {
2381
2938
  maxIterations: options.maxIterations,
2382
2939
  maxTokens: options.maxTokens
2383
2940
  });
2941
+ let shutdownSignal = null;
2384
2942
  enterAltScreen();
2385
2943
  const renderer = new Renderer(orchestrator, prompt, config.agent);
2386
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);
2387
2956
  const orchestratorPromise = orchestrator.start().finally(() => {
2388
2957
  renderer.stop();
2389
2958
  }).catch((err) => {
2390
2959
  exitAltScreen();
2391
2960
  die(err instanceof Error ? err.message : String(err));
2392
2961
  });
2393
- await renderer.waitUntilExit();
2394
- exitAltScreen();
2395
- if (await Promise.race([orchestratorPromise.then(() => "done"), new Promise((resolve) => {
2396
- setTimeout(() => resolve("timeout"), FORCE_EXIT_TIMEOUT_MS).unref();
2397
- })]) === "timeout") {
2398
- console.error(`\n gnhf: shutdown timed out after ${FORCE_EXIT_TIMEOUT_MS / 1e3}s, forcing exit\n`);
2399
- 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?.();
2400
2979
  }
2980
+ appendDebugLog("run:complete", { signal: shutdownSignal });
2981
+ if (shutdownSignal) process$1.exit(getSignalExitCode(shutdownSignal));
2401
2982
  });
2402
2983
  function enterAltScreen() {
2403
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.7",
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",