diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..8e486f6
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,3 @@
+* text=auto eol=lf
+*.cmd text eol=crlf
+*.bat text eol=crlf
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 54d6a88..f08e344 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -8,7 +8,11 @@ on:
jobs:
build-and-test:
- runs-on: ubuntu-latest
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v6
@@ -16,7 +20,6 @@ jobs:
node-version: 24
cache: npm
- run: npm ci
- - run: npm run build
- run: npm run lint
- run: npm run format:check
- run: npm test
diff --git a/README.md b/README.md
index d38bf9a..8f3de35 100644
--- a/README.md
+++ b/README.md
@@ -18,10 +18,10 @@
src="https://img.shields.io/github/actions/workflow/status/kunchenguid/gnhf/release-please.yml?style=flat-square&label=release"
/>
` | Agent to use (`claude`, `codex`, `rovodev`, or `opencode`) | config file (`claude`) |
-| `--max-iterations ` | Abort after `n` total iterations | unlimited |
-| `--max-tokens ` | Abort after `n` total input+output tokens | unlimited |
-| `--version` | Show version | |
+| Flag | Description | Default |
+| ------------------------ | ------------------------------------------------------------------ | ---------------------- |
+| `--agent ` | Agent to use (`claude`, `codex`, `rovodev`, or `opencode`) | config file (`claude`) |
+| `--max-iterations ` | Abort after `n` total iterations | unlimited |
+| `--max-tokens ` | Abort after `n` total input+output tokens | unlimited |
+| `--prevent-sleep ` | Prevent system sleep during the run (`on`/`off` or `true`/`false`) | config file (`on`) |
+| `--version` | Show version | |
## Configuration
@@ -160,11 +162,24 @@ agent: claude
# Abort after this many consecutive failures
maxConsecutiveFailures: 3
+
+# Prevent the machine from sleeping during a run
+preventSleep: true
```
If the file does not exist yet, `gnhf` creates it on first run using the resolved defaults.
-CLI flags override config file values. The iteration and token caps are runtime-only flags and are not persisted in `config.yml`.
+CLI flags override config file values. `--prevent-sleep` accepts `on`/`off` as well as `true`/`false`; the config file always uses a boolean.
+The iteration and token caps are runtime-only flags and are not persisted in `config.yml`.
+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.
+
+## Debug Logs
+
+Set `GNHF_DEBUG_LOG_PATH` to capture lifecycle events as JSONL while debugging a run:
+
+```sh
+GNHF_DEBUG_LOG_PATH=/tmp/gnhf-debug.jsonl gnhf "ship it"
+```
## Agents
@@ -182,7 +197,8 @@ CLI flags override config file values. The iteration and token caps are runtime-
```sh
npm run build # Build with tsdown
npm run dev # Watch mode
-npm test # Run tests (vitest)
+npm test # Build, then run unit tests (vitest)
+npm run test:e2e # Build, then run end-to-end tests against the mock opencode executable
npm run lint # ESLint
npm run format # Prettier
```
diff --git a/package.json b/package.json
index 7bbdad3..68d920a 100644
--- a/package.json
+++ b/package.json
@@ -9,12 +9,13 @@
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
- "start": "node dist/cli.js",
+ "start": "node dist/cli.mjs",
"lint": "eslint src",
"format": "prettier --write src",
"format:check": "prettier --check src",
- "test": "vitest run",
- "test:coverage": "vitest run --coverage"
+ "test": "npm run build && vitest run",
+ "test:e2e": "npm run build && vitest run test/e2e.test.ts",
+ "test:coverage": "vitest run --coverage --exclude test/e2e.test.ts"
},
"dependencies": {
"commander": "^14.0.3",
diff --git a/src/cli.test.ts b/src/cli.test.ts
index 3c57ea5..39fc8cf 100644
--- a/src/cli.test.ts
+++ b/src/cli.test.ts
@@ -1,4 +1,12 @@
-import { readFileSync } from "node:fs";
+import {
+ existsSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { Config } from "./core/config.js";
import type { RunInfo } from "./core/run.js";
@@ -18,9 +26,15 @@ const stubRunInfo: RunInfo = {
};
interface CliMockOverrides {
+ appendDebugLog?: ReturnType;
+ createAgent?: ReturnType;
+ env?: Record;
orchestratorStart?: ReturnType;
+ readStdinText?: ReturnType;
rendererWaitUntilExit?: ReturnType;
rendererStop?: ReturnType;
+ startSleepPrevention?: ReturnType;
+ stdinIsTTY?: boolean;
}
async function runCliWithMocks(
@@ -41,7 +55,14 @@ async function runCliWithMocks(
}) as typeof process.exit);
const loadConfig = vi.fn(() => config);
- const createAgent = vi.fn(() => ({ name: config.agent }));
+ const createAgent =
+ overrides.createAgent ?? vi.fn(() => ({ name: config.agent }));
+ const appendDebugLog = overrides.appendDebugLog ?? vi.fn();
+ const readStdinText =
+ overrides.readStdinText ?? vi.fn(() => Promise.resolve(""));
+ const startSleepPrevention =
+ overrides.startSleepPrevention ??
+ vi.fn(() => Promise.resolve({ type: "skipped", reason: "unsupported" }));
const orchestratorStart =
overrides.orchestratorStart ?? vi.fn(() => Promise.resolve());
@@ -70,6 +91,7 @@ async function runCliWithMocks(
vi.resetModules();
vi.doMock("./core/config.js", () => ({ loadConfig }));
+ vi.doMock("./core/debug-log.js", () => ({ appendDebugLog }));
vi.doMock("./core/git.js", () => ({
ensureCleanWorkingTree: vi.fn(),
createBranch: vi.fn(),
@@ -81,7 +103,11 @@ async function runCliWithMocks(
resumeRun: vi.fn(),
getLastIterationNumber: vi.fn(() => 0),
}));
+ vi.doMock("./core/stdin.js", () => ({ readStdinText }));
vi.doMock("./core/agents/factory.js", () => ({ createAgent }));
+ vi.doMock("./core/sleep.js", () => ({
+ startSleepPrevention,
+ }));
vi.doMock("./core/orchestrator.js", () => ({
Orchestrator: class MockOrchestrator {
constructor(...args: unknown[]) {
@@ -102,16 +128,49 @@ async function runCliWithMocks(
}));
process.argv = ["node", "gnhf", ...args];
+ const originalIsTTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
+ Object.defineProperty(process.stdin, "isTTY", {
+ configurable: true,
+ value: overrides.stdinIsTTY ?? true,
+ });
+ const envEntries = Object.entries(overrides.env ?? {});
+ const originalEnv = new Map(
+ envEntries.map(([key]) => [key, process.env[key]]),
+ );
+ for (const [key, value] of envEntries) {
+ if (value === undefined) {
+ delete process.env[key];
+ } else {
+ process.env[key] = value;
+ }
+ }
try {
await import("./cli.js");
} finally {
process.argv = originalArgv;
+ if (originalIsTTY) {
+ Object.defineProperty(process.stdin, "isTTY", originalIsTTY);
+ }
+ for (const [key, value] of originalEnv) {
+ if (value === undefined) {
+ delete process.env[key];
+ } else {
+ process.env[key] = value;
+ }
+ }
stdoutWrite.mockRestore();
exitSpy.mockRestore();
}
- return { loadConfig, createAgent, orchestratorCtor };
+ return {
+ appendDebugLog,
+ loadConfig,
+ createAgent,
+ orchestratorCtor,
+ readStdinText,
+ startSleepPrevention,
+ };
}
describe("cli", () => {
@@ -120,21 +179,32 @@ describe("cli", () => {
const stdoutWrite = vi
.spyOn(process.stdout, "write")
.mockImplementation(() => true);
- const exitSpy = vi
- .spyOn(process, "exit")
- .mockImplementation((() => undefined) as typeof process.exit);
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation(((
+ code?: string | number | null,
+ ) => {
+ throw new Error(
+ `process.exit unexpectedly called with ${JSON.stringify(code)}`,
+ );
+ }) as typeof process.exit);
process.argv = ["node", "gnhf", "-V"];
try {
vi.resetModules();
- await import("./cli.js");
+ await expect(import("./cli.js")).rejects.toThrow(
+ /process\.exit unexpectedly called with 1/,
+ );
expect(stdoutWrite).toHaveBeenCalledWith(`${packageVersion}\n`);
- expect(exitSpy).toHaveBeenCalledWith(0);
+ expect(exitSpy).toHaveBeenNthCalledWith(1, 0);
+ expect(exitSpy).toHaveBeenNthCalledWith(2, 1);
} finally {
process.argv = originalArgv;
stdoutWrite.mockRestore();
+ consoleError.mockRestore();
exitSpy.mockRestore();
}
});
@@ -143,9 +213,10 @@ describe("cli", () => {
const { loadConfig, createAgent } = await runCliWithMocks(["ship it"], {
agent: "codex",
maxConsecutiveFailures: 3,
+ preventSleep: false,
});
- expect(loadConfig).toHaveBeenCalledWith(undefined);
+ expect(loadConfig).toHaveBeenCalledWith({});
expect(createAgent).toHaveBeenCalledWith("codex", stubRunInfo);
});
@@ -155,6 +226,7 @@ describe("cli", () => {
{
agent: "claude",
maxConsecutiveFailures: 3,
+ preventSleep: false,
},
);
@@ -168,6 +240,7 @@ describe("cli", () => {
{
agent: "rovodev",
maxConsecutiveFailures: 3,
+ preventSleep: false,
},
);
@@ -181,6 +254,7 @@ describe("cli", () => {
{
agent: "opencode",
maxConsecutiveFailures: 3,
+ preventSleep: false,
},
);
@@ -194,6 +268,7 @@ describe("cli", () => {
{
agent: "claude",
maxConsecutiveFailures: 3,
+ preventSleep: false,
},
);
@@ -204,6 +279,428 @@ describe("cli", () => {
});
});
+ it("treats --prevent-sleep as a runtime override without passing it to config bootstrap", async () => {
+ const { loadConfig, orchestratorCtor, startSleepPrevention } =
+ await runCliWithMocks(["ship it", "--prevent-sleep", "off"], {
+ agent: "claude",
+ maxConsecutiveFailures: 3,
+ preventSleep: false,
+ });
+
+ expect(loadConfig).toHaveBeenCalledWith({});
+ expect(startSleepPrevention).not.toHaveBeenCalled();
+ expect(orchestratorCtor).toHaveBeenCalledTimes(1);
+ expect(orchestratorCtor.mock.calls[0]?.[0]).toEqual({
+ agent: "claude",
+ maxConsecutiveFailures: 3,
+ preventSleep: false,
+ });
+ });
+
+ it("does not emit run:start from the Linux sleep-prevention wrapper process", async () => {
+ const appendDebugLog = vi.fn();
+ const startSleepPrevention = vi.fn(() =>
+ Promise.resolve({ type: "reexeced" as const, exitCode: 0 }),
+ );
+
+ await expect(
+ runCliWithMocks(
+ ["ship it"],
+ {
+ agent: "claude",
+ maxConsecutiveFailures: 3,
+ preventSleep: true,
+ },
+ { appendDebugLog, startSleepPrevention },
+ ),
+ ).rejects.toThrow(/process\.exit unexpectedly called/);
+
+ expect(startSleepPrevention).toHaveBeenCalledTimes(1);
+ expect(appendDebugLog).not.toHaveBeenCalledWith(
+ "run:start",
+ expect.anything(),
+ );
+ });
+
+ it("passes the stdin prompt to Linux sleep-prevention re-exec via a temp file", async () => {
+ let promptFilePath: string | undefined;
+ const readStdinText = vi.fn(() => Promise.resolve("objective from stdin"));
+ const startSleepPrevention = vi.fn(async (_argv, deps) => {
+ promptFilePath = deps?.reexecEnv?.GNHF_REEXEC_STDIN_PROMPT_FILE;
+ expect(promptFilePath).toEqual(expect.any(String));
+ expect(deps?.reexecEnv?.GNHF_REEXEC_STDIN_PROMPT).toBeUndefined();
+ expect(readFileSync(promptFilePath!, "utf-8")).toBe(
+ "objective from stdin",
+ );
+ return { type: "skipped" as const, reason: "unsupported" };
+ });
+
+ await runCliWithMocks(
+ [],
+ {
+ agent: "claude",
+ maxConsecutiveFailures: 3,
+ preventSleep: true,
+ },
+ {
+ readStdinText,
+ startSleepPrevention,
+ stdinIsTTY: false,
+ },
+ );
+
+ expect(readStdinText).toHaveBeenCalledTimes(1);
+ expect(startSleepPrevention).toHaveBeenCalledTimes(1);
+ expect(promptFilePath).toBeDefined();
+ expect(existsSync(promptFilePath!)).toBe(false);
+ });
+
+ it("uses the serialized stdin prompt file after Linux sleep-prevention re-exec", async () => {
+ const readStdinText = vi.fn(() => Promise.resolve("should not be read"));
+ const startSleepPrevention = vi.fn(() =>
+ Promise.resolve({
+ type: "skipped" as const,
+ reason: "already-inhibited",
+ }),
+ );
+ const promptDir = mkdtempSync(join(tmpdir(), "gnhf-stdin-"));
+ const promptPath = join(promptDir, "prompt.txt");
+ writeFileSync(promptPath, "objective from stdin", "utf-8");
+
+ try {
+ const { orchestratorCtor } = await runCliWithMocks(
+ [],
+ {
+ agent: "claude",
+ maxConsecutiveFailures: 3,
+ preventSleep: true,
+ },
+ {
+ env: {
+ GNHF_REEXEC_STDIN_PROMPT_FILE: promptPath,
+ GNHF_SLEEP_INHIBITED: "1",
+ },
+ readStdinText,
+ startSleepPrevention,
+ stdinIsTTY: false,
+ },
+ );
+
+ expect(readStdinText).not.toHaveBeenCalled();
+ expect(startSleepPrevention).toHaveBeenCalledTimes(1);
+ expect(orchestratorCtor).toHaveBeenCalledTimes(1);
+ expect(orchestratorCtor.mock.calls[0]?.[3]).toBe("objective from stdin");
+ expect(existsSync(promptPath)).toBe(false);
+ expect(existsSync(dirname(promptPath))).toBe(false);
+ } finally {
+ rmSync(promptDir, { recursive: true, force: true });
+ }
+ });
+
+ it("falls back to stdin when Linux sleep inhibition is inherited without a serialized prompt", async () => {
+ const readStdinText = vi.fn(() => Promise.resolve("objective from stdin"));
+ const startSleepPrevention = vi.fn(() =>
+ Promise.resolve({
+ type: "skipped" as const,
+ reason: "already-inhibited",
+ }),
+ );
+
+ const { orchestratorCtor } = await runCliWithMocks(
+ [],
+ {
+ agent: "claude",
+ maxConsecutiveFailures: 3,
+ preventSleep: true,
+ },
+ {
+ env: {
+ GNHF_SLEEP_INHIBITED: "1",
+ },
+ readStdinText,
+ startSleepPrevention,
+ stdinIsTTY: false,
+ },
+ );
+
+ expect(readStdinText).toHaveBeenCalledTimes(1);
+ expect(startSleepPrevention).toHaveBeenCalledTimes(1);
+ expect(orchestratorCtor).toHaveBeenCalledTimes(1);
+ expect(orchestratorCtor.mock.calls[0]?.[3]).toBe("objective from stdin");
+ });
+
+ it("clears the serialized stdin prompt file path from process.env after reading it", async () => {
+ let inheritedPromptPath: string | undefined;
+ const createAgent = vi.fn(() => {
+ inheritedPromptPath = process.env.GNHF_REEXEC_STDIN_PROMPT_FILE;
+ return { name: "claude" };
+ });
+ const startSleepPrevention = vi.fn(() =>
+ Promise.resolve({
+ type: "skipped" as const,
+ reason: "already-inhibited",
+ }),
+ );
+ const promptDir = mkdtempSync(join(tmpdir(), "gnhf-stdin-"));
+ const promptPath = join(promptDir, "prompt.txt");
+ writeFileSync(promptPath, "sensitive prompt", "utf-8");
+
+ try {
+ await runCliWithMocks(
+ [],
+ {
+ agent: "claude",
+ maxConsecutiveFailures: 3,
+ preventSleep: true,
+ },
+ {
+ createAgent,
+ env: {
+ GNHF_REEXEC_STDIN_PROMPT_FILE: promptPath,
+ GNHF_SLEEP_INHIBITED: "1",
+ },
+ startSleepPrevention,
+ },
+ );
+
+ expect(startSleepPrevention).toHaveBeenCalledTimes(1);
+ expect(createAgent).toHaveBeenCalledTimes(1);
+ expect(inheritedPromptPath).toBeUndefined();
+ expect(existsSync(promptPath)).toBe(false);
+ } finally {
+ rmSync(promptDir, { recursive: true, force: true });
+ }
+ });
+
+ it("does not recursively delete an untrusted prompt file parent directory", async () => {
+ const promptDir = mkdtempSync(join(tmpdir(), "gnhf-cli-test-"));
+ const promptPath = join(promptDir, "prompt-from-env.txt");
+ const siblingPath = join(promptDir, "keep.txt");
+ writeFileSync(promptPath, "prompt from env", "utf-8");
+ writeFileSync(siblingPath, "keep me", "utf-8");
+
+ try {
+ const { orchestratorCtor } = await runCliWithMocks(
+ [],
+ {
+ agent: "claude",
+ maxConsecutiveFailures: 3,
+ preventSleep: true,
+ },
+ {
+ env: {
+ GNHF_REEXEC_STDIN_PROMPT_FILE: promptPath,
+ GNHF_SLEEP_INHIBITED: "1",
+ },
+ startSleepPrevention: vi.fn(() =>
+ Promise.resolve({
+ type: "skipped" as const,
+ reason: "already-inhibited",
+ }),
+ ),
+ },
+ );
+
+ expect(orchestratorCtor).toHaveBeenCalledTimes(1);
+ expect(orchestratorCtor.mock.calls[0]?.[3]).toBe("prompt from env");
+ expect(existsSync(promptDir)).toBe(true);
+ expect(existsSync(siblingPath)).toBe(true);
+ } finally {
+ rmSync(promptDir, { recursive: true, force: true });
+ }
+ });
+
+ it("signals Linux sleep-prevention re-exec readiness before loading config", async () => {
+ const loadConfig = vi.fn(() => ({
+ agent: "claude" as const,
+ maxConsecutiveFailures: 3,
+ preventSleep: true,
+ }));
+ const startSleepPrevention = vi.fn(() =>
+ Promise.resolve({
+ type: "skipped" as const,
+ reason: "already-inhibited",
+ }),
+ );
+
+ vi.resetModules();
+ vi.doMock("./core/config.js", () => ({ loadConfig }));
+ vi.doMock("./core/debug-log.js", () => ({ appendDebugLog: vi.fn() }));
+ vi.doMock("./core/git.js", () => ({
+ ensureCleanWorkingTree: vi.fn(),
+ createBranch: vi.fn(),
+ getHeadCommit: vi.fn(() => "abc123"),
+ getCurrentBranch: vi.fn(() => "main"),
+ }));
+ vi.doMock("./core/run.js", () => ({
+ setupRun: vi.fn(() => stubRunInfo),
+ resumeRun: vi.fn(),
+ getLastIterationNumber: vi.fn(() => 0),
+ }));
+ vi.doMock("./core/stdin.js", () => ({
+ readStdinText: vi.fn(() => Promise.resolve("")),
+ }));
+ vi.doMock("./core/agents/factory.js", () => ({
+ createAgent: vi.fn(() => ({ name: "claude" })),
+ }));
+ vi.doMock("./core/sleep.js", () => ({
+ startSleepPrevention,
+ }));
+ vi.doMock("./core/orchestrator.js", () => ({
+ Orchestrator: class MockOrchestrator {
+ start = vi.fn(() => Promise.resolve());
+ stop = vi.fn();
+ on = vi.fn();
+ getState = vi.fn(() => ({
+ status: "running" as const,
+ currentIteration: 0,
+ totalInputTokens: 0,
+ totalOutputTokens: 0,
+ commitCount: 0,
+ iterations: [],
+ successCount: 0,
+ failCount: 0,
+ consecutiveFailures: 0,
+ startTime: new Date("2026-01-01T00:00:00Z"),
+ waitingUntil: null,
+ lastMessage: null,
+ }));
+ },
+ }));
+ vi.doMock("./renderer.js", () => ({
+ Renderer: class MockRenderer {
+ start = vi.fn();
+ stop = vi.fn();
+ waitUntilExit = vi.fn(() => Promise.resolve());
+ },
+ }));
+
+ const originalArgv = [...process.argv];
+ const stdoutWrite = vi
+ .spyOn(process.stdout, "write")
+ .mockImplementation(() => true);
+ const exitSpy = vi
+ .spyOn(process, "exit")
+ .mockImplementation((() => undefined) as typeof process.exit);
+
+ process.argv = ["node", "gnhf", "ship it"];
+ const originalSleepInhibited = process.env.GNHF_SLEEP_INHIBITED;
+ process.env.GNHF_SLEEP_INHIBITED = "1";
+
+ try {
+ await import("./cli.js");
+
+ expect(startSleepPrevention).toHaveBeenCalledTimes(1);
+ expect(loadConfig).toHaveBeenCalledTimes(1);
+ expect(startSleepPrevention.mock.invocationCallOrder[0]).toBeLessThan(
+ loadConfig.mock.invocationCallOrder[0] ?? Infinity,
+ );
+ } finally {
+ process.argv = originalArgv;
+ if (originalSleepInhibited === undefined) {
+ delete process.env.GNHF_SLEEP_INHIBITED;
+ } else {
+ process.env.GNHF_SLEEP_INHIBITED = originalSleepInhibited;
+ }
+ stdoutWrite.mockRestore();
+ exitSpy.mockRestore();
+ }
+ });
+
+ it("does not start sleep prevention when quitting from the overwrite prompt", async () => {
+ const originalArgv = [...process.argv];
+ const stdoutWrite = vi
+ .spyOn(process.stdout, "write")
+ .mockImplementation(() => true);
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+ const exitSpy = vi.spyOn(process, "exit").mockImplementation(((
+ code?: string | number | null,
+ ) => {
+ throw new Error(
+ `process.exit unexpectedly called with ${JSON.stringify(code)}`,
+ );
+ }) as typeof process.exit);
+ const startSleepPrevention = vi.fn(() =>
+ Promise.resolve({ type: "skipped" as const, reason: "unsupported" }),
+ );
+ const tempDir = mkdtempSync(join(tmpdir(), "gnhf-cli-test-"));
+ const promptPath = join(tempDir, "PROMPT.md");
+ writeFileSync(promptPath, "existing prompt", "utf-8");
+
+ vi.resetModules();
+ vi.doMock("node:readline", () => ({
+ createInterface: vi.fn(() => ({
+ question: (_question: string, callback: (answer: string) => void) => {
+ callback("q");
+ },
+ close: vi.fn(),
+ })),
+ }));
+ vi.doMock("./core/config.js", () => ({
+ loadConfig: vi.fn(() => ({
+ agent: "claude",
+ maxConsecutiveFailures: 3,
+ preventSleep: true,
+ })),
+ }));
+ vi.doMock("./core/git.js", () => ({
+ ensureCleanWorkingTree: vi.fn(),
+ createBranch: vi.fn(),
+ getHeadCommit: vi.fn(() => "abc123"),
+ getCurrentBranch: vi.fn(() => "gnhf/existing-run"),
+ }));
+ vi.doMock("./core/run.js", () => ({
+ setupRun: vi.fn(() => stubRunInfo),
+ resumeRun: vi.fn(() => ({
+ ...stubRunInfo,
+ runId: "existing-run",
+ promptPath,
+ })),
+ getLastIterationNumber: vi.fn(() => 3),
+ }));
+ vi.doMock("./core/agents/factory.js", () => ({
+ createAgent: vi.fn(() => ({ name: "claude" })),
+ }));
+ vi.doMock("./core/sleep.js", () => ({
+ startSleepPrevention,
+ }));
+ vi.doMock("./core/orchestrator.js", () => ({
+ Orchestrator: class MockOrchestrator {
+ start = vi.fn(() => Promise.resolve());
+ stop = vi.fn();
+ on = vi.fn();
+ getState = vi.fn();
+ },
+ }));
+ vi.doMock("./renderer.js", () => ({
+ Renderer: class MockRenderer {
+ start = vi.fn();
+ stop = vi.fn();
+ waitUntilExit = vi.fn(() => Promise.resolve());
+ },
+ }));
+
+ process.argv = ["node", "gnhf", "new prompt"];
+
+ try {
+ await expect(import("./cli.js")).rejects.toThrow(
+ /process\.exit unexpectedly called with 1/,
+ );
+
+ expect(startSleepPrevention).not.toHaveBeenCalled();
+ expect(exitSpy).toHaveBeenNthCalledWith(1, 0);
+ expect(exitSpy).toHaveBeenNthCalledWith(2, 1);
+ } finally {
+ process.argv = originalArgv;
+ stdoutWrite.mockRestore();
+ consoleError.mockRestore();
+ exitSpy.mockRestore();
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
it("waits for orchestrator shutdown after the renderer exits", async () => {
let resolveStart!: () => void;
const orchestratorStart = vi.fn(
@@ -219,6 +716,7 @@ describe("cli", () => {
{
agent: "claude",
maxConsecutiveFailures: 3,
+ preventSleep: false,
},
{ orchestratorStart, rendererWaitUntilExit },
);
@@ -253,6 +751,7 @@ describe("cli", () => {
{
agent: "opencode",
maxConsecutiveFailures: 3,
+ preventSleep: false,
},
{
orchestratorStart: vi.fn(() => Promise.resolve()),
@@ -289,6 +788,7 @@ describe("cli", () => {
loadConfig: vi.fn(() => ({
agent: "claude",
maxConsecutiveFailures: 3,
+ preventSleep: false,
})),
}));
vi.doMock("./core/git.js", () => ({
@@ -324,4 +824,194 @@ describe("cli", () => {
exitSpy.mockRestore();
}
});
+
+ it("uses the SIGTERM exit code when shutdown times out after SIGTERM", async () => {
+ vi.useFakeTimers();
+
+ const originalArgv = [...process.argv];
+ const stdoutWrite = vi
+ .spyOn(process.stdout, "write")
+ .mockImplementation(() => true);
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+ const exitSpy = vi
+ .spyOn(process, "exit")
+ .mockImplementation((() => undefined) as typeof process.exit);
+ const processOn = vi.spyOn(process, "on");
+ const processOff = vi.spyOn(process, "off");
+ const signalHandlers = new Map void>();
+ processOn.mockImplementation(((event: string, listener: () => void) => {
+ if (event === "SIGINT" || event === "SIGTERM") {
+ signalHandlers.set(event, listener);
+ }
+ return process;
+ }) as typeof process.on);
+ processOff.mockImplementation((() => process) as typeof process.off);
+
+ let resolveRendererExit!: () => void;
+ const rendererWaitUntilExit = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveRendererExit = resolve;
+ }),
+ );
+
+ vi.resetModules();
+ vi.doMock("./core/config.js", () => ({
+ loadConfig: vi.fn(() => ({
+ agent: "claude",
+ maxConsecutiveFailures: 3,
+ preventSleep: false,
+ })),
+ }));
+ vi.doMock("./core/git.js", () => ({
+ ensureCleanWorkingTree: vi.fn(),
+ createBranch: vi.fn(),
+ getHeadCommit: vi.fn(() => "abc123"),
+ getCurrentBranch: vi.fn(() => "main"),
+ }));
+ vi.doMock("./core/run.js", () => ({
+ setupRun: vi.fn(() => stubRunInfo),
+ resumeRun: vi.fn(),
+ getLastIterationNumber: vi.fn(() => 0),
+ }));
+ vi.doMock("./core/agents/factory.js", () => ({
+ createAgent: vi.fn(() => ({ name: "claude" })),
+ }));
+ vi.doMock("./core/orchestrator.js", () => ({
+ Orchestrator: class MockOrchestrator {
+ start = vi.fn(() => new Promise(() => {}));
+ stop = vi.fn();
+ on = vi.fn();
+ getState = vi.fn(() => ({
+ status: "running" as const,
+ currentIteration: 0,
+ totalInputTokens: 0,
+ totalOutputTokens: 0,
+ commitCount: 0,
+ iterations: [],
+ successCount: 0,
+ failCount: 0,
+ consecutiveFailures: 0,
+ startTime: new Date("2026-01-01T00:00:00Z"),
+ waitingUntil: null,
+ lastMessage: null,
+ }));
+ },
+ }));
+ vi.doMock("./renderer.js", () => ({
+ Renderer: class MockRenderer {
+ start = vi.fn();
+ stop = vi.fn(() => {
+ resolveRendererExit();
+ });
+ waitUntilExit = rendererWaitUntilExit;
+ },
+ }));
+
+ process.argv = ["node", "gnhf", "ship it"];
+
+ try {
+ const cliPromise = import("./cli.js");
+
+ await vi.waitFor(() => {
+ expect(signalHandlers.has("SIGTERM")).toBe(true);
+ });
+
+ signalHandlers.get("SIGTERM")?.();
+ await vi.advanceTimersByTimeAsync(5_000);
+
+ await cliPromise;
+
+ expect(exitSpy).toHaveBeenCalledWith(143);
+ expect(exitSpy).not.toHaveBeenCalledWith(130);
+ } finally {
+ process.argv = originalArgv;
+ stdoutWrite.mockRestore();
+ consoleError.mockRestore();
+ exitSpy.mockRestore();
+ processOn.mockRestore();
+ processOff.mockRestore();
+ vi.useRealTimers();
+ }
+ });
+
+ it("uses the SIGINT exit code when the renderer reports an interactive interrupt", async () => {
+ const originalArgv = [...process.argv];
+ const stdoutWrite = vi
+ .spyOn(process.stdout, "write")
+ .mockImplementation(() => true);
+ const consoleError = vi
+ .spyOn(console, "error")
+ .mockImplementation(() => {});
+ const exitSpy = vi
+ .spyOn(process, "exit")
+ .mockImplementation((() => undefined) as typeof process.exit);
+
+ vi.resetModules();
+ vi.doMock("./core/config.js", () => ({
+ loadConfig: vi.fn(() => ({
+ agent: "claude",
+ maxConsecutiveFailures: 3,
+ preventSleep: false,
+ })),
+ }));
+ vi.doMock("./core/git.js", () => ({
+ ensureCleanWorkingTree: vi.fn(),
+ createBranch: vi.fn(),
+ getHeadCommit: vi.fn(() => "abc123"),
+ getCurrentBranch: vi.fn(() => "main"),
+ }));
+ vi.doMock("./core/run.js", () => ({
+ setupRun: vi.fn(() => stubRunInfo),
+ resumeRun: vi.fn(),
+ getLastIterationNumber: vi.fn(() => 0),
+ }));
+ vi.doMock("./core/agents/factory.js", () => ({
+ createAgent: vi.fn(() => ({ name: "claude" })),
+ }));
+ vi.doMock("./core/orchestrator.js", () => ({
+ Orchestrator: class MockOrchestrator {
+ start = vi.fn(() => Promise.resolve());
+ stop = vi.fn();
+ on = vi.fn();
+ getState = vi.fn(() => ({
+ status: "running" as const,
+ currentIteration: 0,
+ totalInputTokens: 0,
+ totalOutputTokens: 0,
+ commitCount: 0,
+ iterations: [],
+ successCount: 0,
+ failCount: 0,
+ consecutiveFailures: 0,
+ startTime: new Date("2026-01-01T00:00:00Z"),
+ waitingUntil: null,
+ lastMessage: null,
+ }));
+ },
+ }));
+ vi.doMock("./renderer.js", () => ({
+ Renderer: class MockRenderer {
+ start = vi.fn();
+ stop = vi.fn();
+ waitUntilExit = vi.fn(() => Promise.resolve("interrupted"));
+ },
+ }));
+
+ process.argv = ["node", "gnhf", "ship it"];
+
+ try {
+ await import("./cli.js");
+
+ expect(exitSpy).toHaveBeenCalledWith(130);
+ expect(exitSpy).not.toHaveBeenCalledWith(0);
+ } finally {
+ process.argv = originalArgv;
+ stdoutWrite.mockRestore();
+ consoleError.mockRestore();
+ exitSpy.mockRestore();
+ }
+ });
});
diff --git a/src/cli.ts b/src/cli.ts
index f3f2e10..f8cdf16 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -1,8 +1,17 @@
-import { readFileSync } from "node:fs";
+import {
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ rmdirSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { basename, dirname, join, resolve } from "node:path";
import process from "node:process";
import { createInterface } from "node:readline";
import { Command, InvalidArgumentError } from "commander";
import { loadConfig } from "./core/config.js";
+import { appendDebugLog } from "./core/debug-log.js";
import {
ensureCleanWorkingTree,
createBranch,
@@ -15,6 +24,8 @@ import {
resumeRun,
getLastIterationNumber,
} from "./core/run.js";
+import { readStdinText } from "./core/stdin.js";
+import { startSleepPrevention } from "./core/sleep.js";
import { createAgent } from "./core/agents/factory.js";
import { Orchestrator } from "./core/orchestrator.js";
import { MockOrchestrator } from "./mock-orchestrator.js";
@@ -25,6 +36,10 @@ const packageVersion = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf-8"),
).version as string;
const FORCE_EXIT_TIMEOUT_MS = 5_000;
+const GNHF_REEXEC_STDIN_PROMPT = "GNHF_REEXEC_STDIN_PROMPT";
+const GNHF_REEXEC_STDIN_PROMPT_FILE = "GNHF_REEXEC_STDIN_PROMPT_FILE";
+const GNHF_REEXEC_STDIN_PROMPT_DIR_PREFIX = "gnhf-stdin-";
+const GNHF_REEXEC_STDIN_PROMPT_FILENAME = "prompt.txt";
function parseNonNegativeInteger(value: string): number {
if (!/^\d+$/.test(value)) {
@@ -39,6 +54,14 @@ function parseNonNegativeInteger(value: string): number {
return parsed;
}
+function parseOnOffBoolean(value: string): boolean {
+ if (value === "on" || value === "true") return true;
+ if (value === "off" || value === "false") return false;
+ throw new InvalidArgumentError(
+ 'must be one of: "on", "off", "true", "false"',
+ );
+}
+
function humanizeErrorMessage(message: string): string {
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.';
@@ -69,6 +92,71 @@ function ask(question: string): Promise {
});
}
+function getSignalExitCode(signal: NodeJS.Signals): number {
+ return signal === "SIGINT" ? 130 : 143;
+}
+
+function persistStdinPromptForReexec(prompt: string): {
+ path: string;
+ cleanup: () => void;
+} {
+ const promptDir = mkdtempSync(
+ join(tmpdir(), GNHF_REEXEC_STDIN_PROMPT_DIR_PREFIX),
+ );
+ const promptPath = join(promptDir, GNHF_REEXEC_STDIN_PROMPT_FILENAME);
+ writeFileSync(promptPath, prompt, { encoding: "utf-8", mode: 0o600 });
+ return {
+ path: promptPath,
+ cleanup: () => {
+ rmSync(promptDir, { recursive: true, force: true });
+ },
+ };
+}
+
+function isTrustedReexecPromptPath(promptPath: string): boolean {
+ const resolvedPromptPath = resolve(promptPath);
+ const promptDir = dirname(resolvedPromptPath);
+ return (
+ basename(resolvedPromptPath) === GNHF_REEXEC_STDIN_PROMPT_FILENAME &&
+ dirname(promptDir) === resolve(tmpdir()) &&
+ basename(promptDir).startsWith(GNHF_REEXEC_STDIN_PROMPT_DIR_PREFIX)
+ );
+}
+
+function cleanupTrustedReexecPromptPath(promptPath: string): void {
+ if (!isTrustedReexecPromptPath(promptPath)) {
+ return;
+ }
+
+ const resolvedPromptPath = resolve(promptPath);
+ rmSync(resolvedPromptPath, { force: true });
+ try {
+ rmdirSync(dirname(resolvedPromptPath));
+ } catch {
+ // Leave the directory in place if anything unexpected remains.
+ }
+}
+
+function readReexecStdinPrompt(env: NodeJS.ProcessEnv): string | undefined {
+ const promptPath = env[GNHF_REEXEC_STDIN_PROMPT_FILE];
+ if (promptPath !== undefined) {
+ delete env[GNHF_REEXEC_STDIN_PROMPT_FILE];
+ try {
+ return readFileSync(promptPath, "utf-8");
+ } finally {
+ cleanupTrustedReexecPromptPath(promptPath);
+ }
+ }
+
+ const prompt = env[GNHF_REEXEC_STDIN_PROMPT];
+ if (prompt !== undefined) {
+ delete env[GNHF_REEXEC_STDIN_PROMPT];
+ return prompt;
+ }
+
+ return undefined;
+}
+
const program = new Command();
program
@@ -90,6 +178,11 @@ program
"Abort after N total input+output tokens",
parseNonNegativeInteger,
)
+ .option(
+ "--prevent-sleep ",
+ 'Prevent system sleep during the run ("on" or "off")',
+ parseOnOffBoolean,
+ )
.option("--mock", "", false)
.action(
async (
@@ -98,6 +191,7 @@ program
agent?: string;
maxIterations?: number;
maxTokens?: number;
+ preventSleep?: boolean;
mock: boolean;
},
) => {
@@ -115,11 +209,16 @@ program
exitAltScreen();
return;
}
- let prompt = promptArg;
-
- if (!prompt && !process.stdin.isTTY) {
- prompt = readFileSync("/dev/stdin", "utf-8").trim();
+ let initialSleepPrevention: Awaited<
+ ReturnType
+ > | null = null;
+ if (process.env.GNHF_SLEEP_INHIBITED === "1") {
+ initialSleepPrevention = await startSleepPrevention(
+ process.argv.slice(2),
+ );
}
+ let prompt = promptArg;
+ let promptFromStdin = false;
const agentName = options.agent;
if (
@@ -135,13 +234,19 @@ program
process.exit(1);
}
- const config = loadConfig(
+ const loadedConfig = loadConfig(
agentName
? {
agent: agentName as "claude" | "codex" | "rovodev" | "opencode",
}
- : undefined,
+ : {},
);
+ const config = {
+ ...loadedConfig,
+ ...(options.preventSleep === undefined
+ ? {}
+ : { preventSleep: options.preventSleep }),
+ };
if (
config.agent !== "claude" &&
config.agent !== "codex" &&
@@ -153,6 +258,15 @@ program
);
process.exit(1);
}
+
+ if (!prompt && process.env.GNHF_SLEEP_INHIBITED === "1") {
+ prompt = readReexecStdinPrompt(process.env);
+ }
+ if (!prompt && !process.stdin.isTTY) {
+ prompt = await readStdinText(process.stdin);
+ promptFromStdin = true;
+ }
+
const cwd = process.cwd();
const currentBranch = getCurrentBranch(cwd);
@@ -197,6 +311,41 @@ program
runInfo = initializeNewBranch(prompt, cwd);
}
+ let sleepPreventionCleanup: (() => Promise) | null = null;
+ if (config.preventSleep) {
+ const persistedPrompt =
+ promptFromStdin && prompt !== undefined
+ ? persistStdinPromptForReexec(prompt)
+ : null;
+ let reexeced = false;
+ try {
+ const sleepPrevention =
+ initialSleepPrevention ??
+ (await startSleepPrevention(process.argv.slice(2), {
+ reexecEnv: persistedPrompt
+ ? {
+ [GNHF_REEXEC_STDIN_PROMPT_FILE]: persistedPrompt.path,
+ }
+ : undefined,
+ }));
+ if (sleepPrevention.type === "reexeced") {
+ reexeced = true;
+ process.exit(sleepPrevention.exitCode);
+ }
+ if (sleepPrevention.type === "active") {
+ sleepPreventionCleanup = sleepPrevention.cleanup;
+ }
+ } finally {
+ if (!reexeced) {
+ persistedPrompt?.cleanup();
+ }
+ }
+ }
+
+ appendDebugLog("run:start", {
+ args: process.argv.slice(2),
+ });
+
const agent = createAgent(config.agent, runInfo);
const orchestrator = new Orchestrator(
config,
@@ -210,11 +359,24 @@ program
maxTokens: options.maxTokens,
},
);
+ let shutdownSignal: NodeJS.Signals | null = null;
enterAltScreen();
const renderer = new Renderer(orchestrator, prompt, config.agent);
renderer.start();
+ const requestShutdown = (signal: NodeJS.Signals) => {
+ if (shutdownSignal) return;
+ shutdownSignal = signal;
+ appendDebugLog(`signal:${signal}`);
+ renderer.stop();
+ orchestrator.stop();
+ };
+ const handleSigInt = () => requestShutdown("SIGINT");
+ const handleSigTerm = () => requestShutdown("SIGTERM");
+ process.on("SIGINT", handleSigInt);
+ process.on("SIGTERM", handleSigTerm);
+
const orchestratorPromise = orchestrator
.start()
.finally(() => {
@@ -225,20 +387,41 @@ program
die(err instanceof Error ? err.message : String(err));
});
- await renderer.waitUntilExit();
- exitAltScreen();
- const shutdownResult = await Promise.race([
- orchestratorPromise.then(() => "done" as const),
- new Promise<"timeout">((resolve) => {
- setTimeout(() => resolve("timeout"), FORCE_EXIT_TIMEOUT_MS).unref();
- }),
- ]);
+ try {
+ const rendererExitReason = await renderer.waitUntilExit();
+ if (rendererExitReason === "interrupted" && !shutdownSignal) {
+ shutdownSignal = "SIGINT";
+ appendDebugLog("signal:SIGINT");
+ }
+ exitAltScreen();
+ const shutdownResult = await Promise.race([
+ orchestratorPromise.then(() => "done" as const),
+ new Promise<"timeout">((resolve) => {
+ setTimeout(() => resolve("timeout"), FORCE_EXIT_TIMEOUT_MS).unref();
+ }),
+ ]);
- if (shutdownResult === "timeout") {
- console.error(
- `\n gnhf: shutdown timed out after ${FORCE_EXIT_TIMEOUT_MS / 1000}s, forcing exit\n`,
- );
- process.exit(130);
+ if (shutdownResult === "timeout") {
+ appendDebugLog("run:shutdown-timeout", {
+ timeoutMs: FORCE_EXIT_TIMEOUT_MS,
+ });
+ console.error(
+ `\n gnhf: shutdown timed out after ${FORCE_EXIT_TIMEOUT_MS / 1000}s, forcing exit\n`,
+ );
+ process.exit(getSignalExitCode(shutdownSignal ?? "SIGINT"));
+ }
+ } finally {
+ process.off("SIGINT", handleSigInt);
+ process.off("SIGTERM", handleSigTerm);
+ await sleepPreventionCleanup?.();
+ }
+
+ appendDebugLog("run:complete", {
+ signal: shutdownSignal,
+ });
+
+ if (shutdownSignal) {
+ process.exit(getSignalExitCode(shutdownSignal));
}
},
);
diff --git a/src/core/agents/managed-process.test.ts b/src/core/agents/managed-process.test.ts
new file mode 100644
index 0000000..1bc2ac5
--- /dev/null
+++ b/src/core/agents/managed-process.test.ts
@@ -0,0 +1,163 @@
+import { EventEmitter } from "node:events";
+import type { ChildProcess } from "node:child_process";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { shutdownChildProcess, signalChildProcess } from "./managed-process.js";
+
+function createChildProcess(pid = 1234): ChildProcess {
+ return Object.assign(new EventEmitter(), {
+ exitCode: null,
+ pid,
+ kill: vi.fn(() => true),
+ signalCode: null,
+ }) as unknown as ChildProcess;
+}
+
+describe("signalChildProcess", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("signals the process group for detached children", () => {
+ const child = createChildProcess();
+ const killProcess = vi.fn();
+
+ signalChildProcess(child, {
+ detached: true,
+ killProcess,
+ signal: "SIGTERM",
+ });
+
+ expect(killProcess).toHaveBeenCalledWith(-1234, "SIGTERM");
+ expect(child.kill).not.toHaveBeenCalled();
+ });
+
+ it("falls back to killing the direct child when process-group signaling fails", () => {
+ const child = createChildProcess();
+ const killProcess = vi.fn(() => {
+ throw new Error("group kill failed");
+ });
+
+ signalChildProcess(child, {
+ detached: true,
+ killProcess,
+ signal: "SIGTERM",
+ });
+
+ expect(child.kill).toHaveBeenCalledWith("SIGTERM");
+ });
+});
+
+describe("shutdownChildProcess", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ });
+
+ it("force kills the child when graceful shutdown times out", async () => {
+ const child = createChildProcess();
+ vi.mocked(child.kill).mockImplementation((signal?: NodeJS.Signals) => {
+ if (signal === "SIGKILL") {
+ queueMicrotask(() => {
+ child.emit("close", 0, null);
+ });
+ }
+ return true;
+ });
+
+ const closePromise = shutdownChildProcess(child, {
+ detached: false,
+ timeoutMs: 3_000,
+ });
+
+ expect(child.kill).toHaveBeenCalledWith("SIGTERM");
+
+ await vi.advanceTimersByTimeAsync(3_000);
+ expect(child.kill).toHaveBeenCalledWith("SIGKILL");
+
+ await closePromise;
+ vi.useRealTimers();
+ });
+
+ it("waits for close after sending SIGKILL", async () => {
+ const child = createChildProcess();
+ let resolved = false;
+
+ const closePromise = shutdownChildProcess(child, {
+ detached: false,
+ timeoutMs: 3_000,
+ }).then(() => {
+ resolved = true;
+ });
+
+ expect(child.kill).toHaveBeenCalledWith("SIGTERM");
+
+ await vi.advanceTimersByTimeAsync(3_000);
+ expect(child.kill).toHaveBeenCalledWith("SIGKILL");
+ await Promise.resolve();
+ expect(resolved).toBe(false);
+
+ child.emit("close", 0, null);
+ await closePromise;
+ expect(resolved).toBe(true);
+
+ vi.useRealTimers();
+ });
+
+ it("resolves after a hard deadline if the child never closes", async () => {
+ const child = createChildProcess();
+ let resolved = false;
+
+ const closePromise = shutdownChildProcess(child, {
+ detached: false,
+ timeoutMs: 3_000,
+ }).then(() => {
+ resolved = true;
+ });
+
+ expect(child.kill).toHaveBeenCalledWith("SIGTERM");
+
+ await vi.advanceTimersByTimeAsync(3_000);
+ expect(child.kill).toHaveBeenCalledWith("SIGKILL");
+ expect(resolved).toBe(false);
+
+ await vi.advanceTimersByTimeAsync(100);
+ await closePromise;
+ expect(resolved).toBe(true);
+ });
+
+ it("clears the force-kill timer when the child closes first", async () => {
+ const child = createChildProcess();
+
+ const closePromise = shutdownChildProcess(child, {
+ detached: false,
+ timeoutMs: 3_000,
+ });
+
+ expect(child.kill).toHaveBeenCalledWith("SIGTERM");
+
+ child.emit("close", 0, null);
+ await closePromise;
+
+ await vi.advanceTimersByTimeAsync(3_000);
+ expect(child.kill).not.toHaveBeenCalledWith("SIGKILL");
+
+ vi.useRealTimers();
+ });
+
+ it("resolves immediately when the child has already exited", async () => {
+ const child = Object.assign(createChildProcess(), {
+ exitCode: 0,
+ });
+
+ await shutdownChildProcess(child, {
+ detached: false,
+ timeoutMs: 3_000,
+ });
+
+ expect(child.kill).not.toHaveBeenCalled();
+ await vi.advanceTimersByTimeAsync(3_000);
+ expect(child.kill).not.toHaveBeenCalledWith("SIGKILL");
+
+ vi.useRealTimers();
+ });
+});
diff --git a/src/core/agents/managed-process.ts b/src/core/agents/managed-process.ts
new file mode 100644
index 0000000..9380254
--- /dev/null
+++ b/src/core/agents/managed-process.ts
@@ -0,0 +1,90 @@
+import type { ChildProcess } from "node:child_process";
+
+interface SignalChildProcessOptions {
+ detached: boolean;
+ killProcess?: typeof process.kill;
+ signal: NodeJS.Signals;
+}
+
+interface ShutdownChildProcessOptions {
+ detached: boolean;
+ killProcess?: typeof process.kill;
+ timeoutMs?: number;
+}
+
+const POST_SIGKILL_GRACE_MS = 100;
+
+export function signalChildProcess(
+ child: ChildProcess,
+ options: SignalChildProcessOptions,
+): void {
+ const killProcess = options.killProcess ?? process.kill.bind(process);
+
+ if (options.detached && child.pid) {
+ try {
+ killProcess(-child.pid, options.signal);
+ return;
+ } catch {
+ // Fall back to the direct child below.
+ }
+ }
+
+ child.kill(options.signal);
+}
+
+export async function shutdownChildProcess(
+ child: ChildProcess,
+ options: ShutdownChildProcessOptions,
+): Promise {
+ if (child.exitCode != null || child.signalCode != null) {
+ return;
+ }
+
+ const timeoutMs = options.timeoutMs ?? 3_000;
+ await new Promise((resolve) => {
+ let forceKillTimer: ReturnType | null = null;
+ let hardDeadlineTimer: ReturnType | null = null;
+ let settled = false;
+
+ const settle = () => {
+ if (settled) return;
+ settled = true;
+ if (forceKillTimer) {
+ clearTimeout(forceKillTimer);
+ forceKillTimer = null;
+ }
+ if (hardDeadlineTimer) {
+ clearTimeout(hardDeadlineTimer);
+ hardDeadlineTimer = null;
+ }
+ child.off("close", handleClose);
+ resolve();
+ };
+
+ const handleClose = () => {
+ settle();
+ };
+
+ child.on("close", handleClose);
+
+ try {
+ signalChildProcess(child, { ...options, signal: "SIGTERM" });
+ } catch {
+ // Best-effort cleanup only.
+ }
+
+ forceKillTimer = setTimeout(() => {
+ try {
+ signalChildProcess(child, { ...options, signal: "SIGKILL" });
+ } catch {
+ // Best-effort cleanup only.
+ }
+
+ hardDeadlineTimer = setTimeout(() => {
+ settle();
+ }, POST_SIGKILL_GRACE_MS);
+ hardDeadlineTimer.unref?.();
+ }, timeoutMs);
+ forceKillTimer.unref?.();
+ });
+}
diff --git a/src/core/agents/opencode.test.ts b/src/core/agents/opencode.test.ts
index 18f4fad..5b740b7 100644
--- a/src/core/agents/opencode.test.ts
+++ b/src/core/agents/opencode.test.ts
@@ -7,10 +7,11 @@ import type { Mock } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", () => ({
+ execFileSync: vi.fn(),
spawn: vi.fn(),
}));
-import { spawn } from "node:child_process";
+import { execFileSync, spawn } from "node:child_process";
import { OpenCodeAgent } from "./opencode.js";
import { AGENT_OUTPUT_SCHEMA } from "./types.js";
@@ -266,7 +267,9 @@ describe("OpenCodeAgent", () => {
cacheReadTokens: 3,
cacheCreationTokens: 2,
});
- expect(readFileSync(logPath, "utf-8")).toContain("message.part.delta");
+ await vi.waitFor(() => {
+ expect(readFileSync(logPath, "utf-8")).toContain("message.part.delta");
+ });
resolveMessageResponse(
finalMessageResponse("done", { input: 10, output: 4, read: 3, write: 2 }),
@@ -457,6 +460,99 @@ describe("OpenCodeAgent", () => {
);
});
+ it("uses a shell on Windows so PATH-resolved .cmd shims can launch", async () => {
+ const proc = createMockProcess();
+ mockSpawn.mockReturnValue(proc);
+
+ const windowsAgent = new OpenCodeAgent({
+ fetch: fetchMock as typeof fetch,
+ getPort,
+ platform: "win32",
+ });
+
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse({ healthy: true, version: "1.3.13" }))
+ .mockResolvedValueOnce(
+ jsonResponse({
+ id: "session-123",
+ directory: "/repo",
+ permission: [{ permission: "*", pattern: "*", action: "allow" }],
+ }),
+ )
+ .mockResolvedValueOnce(
+ sseResponse(
+ finalAnswerEvents("done", {
+ input: 1,
+ output: 1,
+ read: 0,
+ write: 0,
+ }),
+ ),
+ )
+ .mockResolvedValueOnce(
+ finalMessageResponse("done", {
+ input: 1,
+ output: 1,
+ read: 0,
+ write: 0,
+ }),
+ )
+ .mockResolvedValueOnce(jsonResponse(true));
+
+ await windowsAgent.run("test prompt", "/repo");
+
+ expect(mockSpawn).toHaveBeenCalledWith(
+ "opencode",
+ ["serve", "--hostname", "127.0.0.1", "--port", "8765", "--print-logs"],
+ expect.objectContaining({
+ cwd: "/repo",
+ detached: false,
+ shell: true,
+ stdio: ["ignore", "pipe", "pipe"],
+ }),
+ );
+ });
+
+ it("kills the full process tree on Windows so the opencode server does not survive shutdown", async () => {
+ const proc = createMockProcess();
+ Object.defineProperty(proc, "pid", { value: 5678 });
+ mockSpawn.mockReturnValue(proc);
+
+ const windowsAgent = new OpenCodeAgent({
+ fetch: fetchMock as typeof fetch,
+ getPort,
+ platform: "win32",
+ });
+
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse({ healthy: true, version: "1.3.13" }))
+ .mockResolvedValueOnce(jsonResponse({ id: "session-123" }))
+ .mockResolvedValueOnce(
+ sseResponse(
+ finalAnswerEvents("done", { input: 1, output: 1, read: 0, write: 0 }),
+ ),
+ )
+ .mockResolvedValueOnce(
+ finalMessageResponse("done", {
+ input: 1,
+ output: 1,
+ read: 0,
+ write: 0,
+ }),
+ )
+ .mockResolvedValueOnce(jsonResponse(true));
+
+ await windowsAgent.run("test", "/repo");
+ await windowsAgent.close();
+
+ expect(vi.mocked(execFileSync)).toHaveBeenCalledWith(
+ "taskkill",
+ ["/T", "/F", "/PID", "5678"],
+ { stdio: "ignore" },
+ );
+ expect(proc.kill).not.toHaveBeenCalled();
+ });
+
it("reuses the existing server process across runs in the same cwd", async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
@@ -665,6 +761,14 @@ describe("OpenCodeAgent", () => {
it("force terminates opencode if shutdown exceeds the timeout", async () => {
vi.useFakeTimers();
const proc = createMockProcess();
+ vi.mocked(proc.kill).mockImplementation((signal?: NodeJS.Signals) => {
+ if (signal === "SIGKILL") {
+ queueMicrotask(() => {
+ proc.emit("close", 0, null);
+ });
+ }
+ return true;
+ });
mockSpawn.mockReturnValue(proc);
fetchMock
diff --git a/src/core/agents/opencode.ts b/src/core/agents/opencode.ts
index ef16163..fd1aab3 100644
--- a/src/core/agents/opencode.ts
+++ b/src/core/agents/opencode.ts
@@ -1,4 +1,8 @@
-import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
+import {
+ execFileSync,
+ spawn,
+ type ChildProcessWithoutNullStreams,
+} from "node:child_process";
import { createWriteStream, type WriteStream } from "node:fs";
import { createServer } from "node:net";
import {
@@ -9,6 +13,8 @@ import {
type AgentRunOptions,
type TokenUsage,
} from "./types.js";
+import { appendDebugLog } from "../debug-log.js";
+import { shutdownChildProcess } from "./managed-process.js";
interface OpenCodeMessagePart {
type?: string;
@@ -77,6 +83,7 @@ interface OpenCodeDeps {
fetch?: typeof fetch;
getPort?: () => Promise;
killProcess?: typeof process.kill;
+ platform?: NodeJS.Platform;
spawn?: typeof spawn;
}
@@ -137,6 +144,21 @@ function buildPrompt(prompt: string): string {
].join("\n");
}
+/**
+ * On Windows with `shell: true`, `child.pid` is the `cmd.exe` wrapper, not
+ * the actual server process. `taskkill /T` terminates the entire process
+ * tree rooted at that PID so the real server doesn't survive shutdown.
+ */
+async function killWindowsProcessTree(pid: number): Promise {
+ try {
+ execFileSync("taskkill", ["/T", "/F", "/PID", String(pid)], {
+ stdio: "ignore",
+ });
+ } catch {
+ // Best-effort: the process may have already exited.
+ }
+}
+
function createAbortError(): Error {
return new Error("Agent was aborted");
}
@@ -225,6 +247,7 @@ export class OpenCodeAgent implements Agent {
private fetchFn: typeof fetch;
private getPortFn: () => Promise;
private killProcessFn: typeof process.kill;
+ private platform: NodeJS.Platform;
private spawnFn: typeof spawn;
private server: OpenCodeServer | null = null;
private closingPromise: Promise | null = null;
@@ -233,6 +256,7 @@ export class OpenCodeAgent implements Agent {
this.fetchFn = deps.fetch ?? fetch;
this.getPortFn = deps.getPort ?? getAvailablePort;
this.killProcessFn = deps.killProcess ?? process.kill.bind(process);
+ this.platform = deps.platform ?? process.platform;
this.spawnFn = deps.spawn ?? spawn;
}
@@ -309,7 +333,8 @@ export class OpenCodeAgent implements Agent {
}
const port = await this.getPortFn();
- const detached = process.platform !== "win32";
+ const isWindows = this.platform === "win32";
+ const detached = !isWindows;
const child = this.spawnFn(
"opencode",
[
@@ -323,6 +348,7 @@ export class OpenCodeAgent implements Agent {
{
cwd,
detached,
+ shell: isWindows,
stdio: ["ignore", "pipe", "pipe"],
env: buildOpencodeChildEnv(),
},
@@ -363,6 +389,7 @@ export class OpenCodeAgent implements Agent {
});
this.server = server;
+ appendDebugLog("opencode:spawn", { cwd, port, detached });
server.readyPromise = this.waitForHealthy(server, signal).catch(
async (error) => {
await this.shutdownServer();
@@ -789,59 +816,26 @@ export class OpenCodeAgent implements Agent {
}
const server = this.server;
- const waitForClose = new Promise((resolve) => {
- if (server.closed) {
- resolve();
- return;
+ appendDebugLog("opencode:shutdown", { cwd: server.cwd, port: server.port });
+
+ this.closingPromise = (
+ this.platform === "win32" && server.child.pid
+ ? killWindowsProcessTree(server.child.pid)
+ : shutdownChildProcess(server.child, {
+ detached: server.detached,
+ killProcess: this.killProcessFn,
+ timeoutMs: 3_000,
+ })
+ ).finally(() => {
+ if (this.server === server) {
+ this.server = null;
}
- server.child.once("close", () => resolve());
- });
-
- try {
- this.signalServer(server, "SIGTERM");
- } catch {
- // Best effort only.
- }
-
- const forceKill = new Promise((resolve) => {
- const timer = setTimeout(() => {
- if (!server.closed) {
- try {
- this.signalServer(server, "SIGKILL");
- } catch {
- // Best effort only.
- }
- }
- resolve();
- }, 3_000);
- timer.unref?.();
+ this.closingPromise = null;
});
- this.closingPromise = Promise.race([waitForClose, forceKill]).finally(
- () => {
- if (this.server === server) {
- this.server = null;
- }
- this.closingPromise = null;
- },
- );
-
await this.closingPromise;
}
- private signalServer(server: OpenCodeServer, signal: NodeJS.Signals): void {
- if (server.detached && server.child.pid) {
- try {
- this.killProcessFn(-server.child.pid, signal);
- return;
- } catch {
- // Fall back to killing the direct child below.
- }
- }
-
- server.child.kill(signal);
- }
-
private async requestJSON(
server: OpenCodeServer,
path: string,
diff --git a/src/core/agents/rovodev.test.ts b/src/core/agents/rovodev.test.ts
index 892179b..18980a6 100644
--- a/src/core/agents/rovodev.test.ts
+++ b/src/core/agents/rovodev.test.ts
@@ -333,6 +333,14 @@ describe("RovoDevAgent", () => {
it("force terminates rovodev if shutdown exceeds the timeout", async () => {
vi.useFakeTimers();
const proc = createMockProcess();
+ vi.mocked(proc.kill).mockImplementation((signal?: NodeJS.Signals) => {
+ if (signal === "SIGKILL") {
+ queueMicrotask(() => {
+ proc.emit("close", 0, null);
+ });
+ }
+ return true;
+ });
mockSpawn.mockReturnValue(proc);
fetchMock
diff --git a/src/core/agents/rovodev.ts b/src/core/agents/rovodev.ts
index 1fa3288..a6f7924 100644
--- a/src/core/agents/rovodev.ts
+++ b/src/core/agents/rovodev.ts
@@ -8,6 +8,8 @@ import type {
AgentRunOptions,
TokenUsage,
} from "./types.js";
+import { appendDebugLog } from "../debug-log.js";
+import { shutdownChildProcess } from "./managed-process.js";
interface RovoDevRequestUsageEvent {
input_tokens?: number;
@@ -213,7 +215,7 @@ export class RovoDevAgent implements Agent {
stdio: ["ignore", "pipe", "pipe"],
env: process.env,
},
- );
+ ) as unknown as ChildProcessWithoutNullStreams;
const server: RovoDevServer = {
baseUrl: `http://127.0.0.1:${port}`,
@@ -250,6 +252,7 @@ export class RovoDevAgent implements Agent {
});
this.server = server;
+ appendDebugLog("rovodev:spawn", { cwd, port, detached });
server.readyPromise = this.waitForHealthy(server, signal).catch(
async (error) => {
await this.shutdownServer();
@@ -266,10 +269,10 @@ export class RovoDevAgent implements Agent {
signal?: AbortSignal,
): Promise {
const deadline = Date.now() + 30_000;
- let spawnError: Error | null = null;
+ let spawnErrorMessage: string | null = null;
server.child.once("error", (error) => {
- spawnError = error;
+ spawnErrorMessage = error.message;
});
while (Date.now() < deadline) {
@@ -277,8 +280,8 @@ export class RovoDevAgent implements Agent {
throw createAbortError();
}
- if (spawnError) {
- throw new Error(`Failed to spawn rovodev: ${spawnError.message}`);
+ if (spawnErrorMessage) {
+ throw new Error(`Failed to spawn rovodev: ${spawnErrorMessage}`);
}
if (server.closed) {
@@ -605,59 +608,22 @@ export class RovoDevAgent implements Agent {
}
const server = this.server;
- const waitForClose = new Promise((resolve) => {
- if (server.closed) {
- resolve();
- return;
- }
- server.child.once("close", () => resolve());
- });
-
- try {
- this.signalServer(server, "SIGTERM");
- } catch {
- // Best effort only.
- }
+ appendDebugLog("rovodev:shutdown", { cwd: server.cwd, port: server.port });
- const forceKill = new Promise((resolve) => {
- const timer = setTimeout(() => {
- if (!server.closed) {
- try {
- this.signalServer(server, "SIGKILL");
- } catch {
- // Best effort only.
- }
- }
- resolve();
- }, 3_000);
- timer.unref?.();
+ this.closingPromise = shutdownChildProcess(server.child, {
+ detached: server.detached,
+ killProcess: this.killProcessFn,
+ timeoutMs: 3_000,
+ }).finally(() => {
+ if (this.server === server) {
+ this.server = null;
+ }
+ this.closingPromise = null;
});
- this.closingPromise = Promise.race([waitForClose, forceKill]).finally(
- () => {
- if (this.server === server) {
- this.server = null;
- }
- this.closingPromise = null;
- },
- );
-
await this.closingPromise;
}
- private signalServer(server: RovoDevServer, signal: NodeJS.Signals): void {
- if (server.detached && server.child.pid) {
- try {
- this.killProcessFn(-server.child.pid, signal);
- return;
- } catch {
- // Fall back to killing the direct child below.
- }
- }
-
- server.child.kill(signal);
- }
-
private async requestJSON(
server: RovoDevServer,
path: string,
diff --git a/src/core/config.test.ts b/src/core/config.test.ts
index f67278d..738fd99 100644
--- a/src/core/config.test.ts
+++ b/src/core/config.test.ts
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
+import { join } from "node:path";
vi.mock("node:fs", () => ({
readFileSync: vi.fn(),
@@ -17,6 +18,10 @@ const mockMkdirSync = vi.mocked(mkdirSync);
const mockReadFileSync = vi.mocked(readFileSync);
const mockWriteFileSync = vi.mocked(writeFileSync);
+const HOME = "/mock-home";
+const CONFIG_DIR = join(HOME, ".gnhf");
+const CONFIG_PATH = join(CONFIG_DIR, "config.yml");
+
describe("loadConfig", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -29,17 +34,18 @@ describe("loadConfig", () => {
const config = loadConfig();
- expect(mockMkdirSync).toHaveBeenCalledWith("/mock-home/.gnhf", {
+ expect(mockMkdirSync).toHaveBeenCalledWith(CONFIG_DIR, {
recursive: true,
});
expect(mockWriteFileSync).toHaveBeenCalledWith(
- "/mock-home/.gnhf/config.yml",
- "# Agent to use by default\nagent: claude\n\n# Abort after this many consecutive failures\nmaxConsecutiveFailures: 3\n",
+ CONFIG_PATH,
+ "# Agent to use by default\nagent: claude\n\n# Abort after this many consecutive failures\nmaxConsecutiveFailures: 3\n\n# Prevent the machine from sleeping during a run\npreventSleep: true\n",
"utf-8",
);
expect(config).toEqual({
agent: "claude",
maxConsecutiveFailures: 3,
+ preventSleep: true,
});
});
@@ -58,6 +64,7 @@ describe("loadConfig", () => {
expect(config).toEqual({
agent: "claude",
maxConsecutiveFailures: 3,
+ preventSleep: true,
});
});
@@ -71,13 +78,14 @@ describe("loadConfig", () => {
const config = loadConfig({ agent: "codex" });
expect(mockWriteFileSync).toHaveBeenCalledWith(
- "/mock-home/.gnhf/config.yml",
- "# Agent to use by default\nagent: codex\n\n# Abort after this many consecutive failures\nmaxConsecutiveFailures: 3\n",
+ CONFIG_PATH,
+ "# Agent to use by default\nagent: codex\n\n# Abort after this many consecutive failures\nmaxConsecutiveFailures: 3\n\n# Prevent the machine from sleeping during a run\npreventSleep: true\n",
"utf-8",
);
expect(config).toEqual({
agent: "codex",
maxConsecutiveFailures: 3,
+ preventSleep: true,
});
});
@@ -91,13 +99,14 @@ describe("loadConfig", () => {
const config = loadConfig({ agent: "rovodev" });
expect(mockWriteFileSync).toHaveBeenCalledWith(
- "/mock-home/.gnhf/config.yml",
- "# Agent to use by default\nagent: rovodev\n\n# Abort after this many consecutive failures\nmaxConsecutiveFailures: 3\n",
+ CONFIG_PATH,
+ "# Agent to use by default\nagent: rovodev\n\n# Abort after this many consecutive failures\nmaxConsecutiveFailures: 3\n\n# Prevent the machine from sleeping during a run\npreventSleep: true\n",
"utf-8",
);
expect(config).toEqual({
agent: "rovodev",
maxConsecutiveFailures: 3,
+ preventSleep: true,
});
});
@@ -111,13 +120,14 @@ describe("loadConfig", () => {
const config = loadConfig({ agent: "opencode" });
expect(mockWriteFileSync).toHaveBeenCalledWith(
- "/mock-home/.gnhf/config.yml",
- "# Agent to use by default\nagent: opencode\n\n# Abort after this many consecutive failures\nmaxConsecutiveFailures: 3\n",
+ CONFIG_PATH,
+ "# Agent to use by default\nagent: opencode\n\n# Abort after this many consecutive failures\nmaxConsecutiveFailures: 3\n\n# Prevent the machine from sleeping during a run\npreventSleep: true\n",
"utf-8",
);
expect(config).toEqual({
agent: "opencode",
maxConsecutiveFailures: 3,
+ preventSleep: true,
});
});
@@ -126,10 +136,7 @@ describe("loadConfig", () => {
const config = loadConfig();
- expect(mockReadFileSync).toHaveBeenCalledWith(
- "/mock-home/.gnhf/config.yml",
- "utf-8",
- );
+ expect(mockReadFileSync).toHaveBeenCalledWith(CONFIG_PATH, "utf-8");
expect(config.agent).toBe("codex");
});
@@ -140,18 +147,54 @@ describe("loadConfig", () => {
expect(config).toEqual({
agent: "claude",
maxConsecutiveFailures: 10,
+ preventSleep: true,
+ });
+ });
+
+ it('coerces quoted "false" for preventSleep to a boolean false', () => {
+ mockReadFileSync.mockReturnValue('preventSleep: "false"\n');
+
+ const config = loadConfig();
+
+ expect(config).toEqual({
+ agent: "claude",
+ maxConsecutiveFailures: 3,
+ preventSleep: false,
});
});
+ it('coerces "off" for preventSleep to a boolean false', () => {
+ mockReadFileSync.mockReturnValue("preventSleep: off\n");
+
+ const config = loadConfig();
+
+ expect(config).toEqual({
+ agent: "claude",
+ maxConsecutiveFailures: 3,
+ preventSleep: false,
+ });
+ });
+
+ it("throws when preventSleep has an unrecognized value", () => {
+ mockReadFileSync.mockReturnValue("preventSleep: flase\n");
+
+ expect(() => loadConfig()).toThrow(/Invalid config value for preventSleep/);
+ });
+
it("overrides take precedence over file config and defaults", () => {
mockReadFileSync.mockReturnValue(
- "agent: codex\nmaxConsecutiveFailures: 10\n",
+ "agent: codex\nmaxConsecutiveFailures: 10\npreventSleep: false\n",
);
- const config = loadConfig({ agent: "claude", maxConsecutiveFailures: 3 });
+ const config = loadConfig({
+ agent: "claude",
+ maxConsecutiveFailures: 3,
+ preventSleep: true,
+ });
expect(config).toEqual({
agent: "claude",
maxConsecutiveFailures: 3,
+ preventSleep: true,
});
});
@@ -162,6 +205,7 @@ describe("loadConfig", () => {
expect(config).toEqual({
agent: "claude",
maxConsecutiveFailures: 3,
+ preventSleep: true,
});
});
@@ -174,6 +218,7 @@ describe("loadConfig", () => {
expect(config).toEqual({
agent: "claude",
maxConsecutiveFailures: 3,
+ preventSleep: true,
});
});
});
diff --git a/src/core/config.ts b/src/core/config.ts
index 94a7718..47062db 100644
--- a/src/core/config.ts
+++ b/src/core/config.ts
@@ -6,13 +6,50 @@ import yaml from "js-yaml";
export interface Config {
agent: "claude" | "codex" | "rovodev" | "opencode";
maxConsecutiveFailures: number;
+ preventSleep: boolean;
}
const DEFAULT_CONFIG: Config = {
agent: "claude",
maxConsecutiveFailures: 3,
+ preventSleep: true,
};
+class InvalidConfigError extends Error {}
+
+function normalizePreventSleep(value: unknown): boolean | undefined {
+ if (typeof value === "boolean") return value;
+ if (typeof value !== "string") return undefined;
+
+ if (value === "true") return true;
+ if (value === "false") return false;
+ if (value === "on") return true;
+ if (value === "off") return false;
+ return undefined;
+}
+
+function normalizeConfig(config: Partial): Partial {
+ const normalized: Partial = { ...config };
+ const hasPreventSleep = Object.prototype.hasOwnProperty.call(
+ config,
+ "preventSleep",
+ );
+ const preventSleep = normalizePreventSleep(config.preventSleep);
+
+ if (preventSleep === undefined) {
+ if (hasPreventSleep && config.preventSleep !== undefined) {
+ throw new InvalidConfigError(
+ `Invalid config value for preventSleep: ${String(config.preventSleep)}`,
+ );
+ }
+ delete normalized.preventSleep;
+ } else {
+ normalized.preventSleep = preventSleep;
+ }
+
+ return normalized;
+}
+
function isMissingConfigError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
return "code" in error
@@ -26,6 +63,9 @@ agent: ${config.agent}
# Abort after this many consecutive failures
maxConsecutiveFailures: ${config.maxConsecutiveFailures}
+
+# Prevent the machine from sleeping during a run
+preventSleep: ${config.preventSleep}
`;
}
@@ -37,8 +77,11 @@ export function loadConfig(overrides?: Partial): Config {
try {
const raw = readFileSync(configPath, "utf-8");
- fileConfig = (yaml.load(raw) as Partial) ?? {};
+ fileConfig = normalizeConfig((yaml.load(raw) as Partial) ?? {});
} catch (error) {
+ if (error instanceof InvalidConfigError) {
+ throw error;
+ }
if (isMissingConfigError(error)) {
shouldBootstrapConfig = true;
}
@@ -46,7 +89,11 @@ export function loadConfig(overrides?: Partial): Config {
// Config file doesn't exist or is invalid -- use defaults
}
- const resolvedConfig = { ...DEFAULT_CONFIG, ...fileConfig, ...overrides };
+ const resolvedConfig = {
+ ...DEFAULT_CONFIG,
+ ...fileConfig,
+ ...normalizeConfig(overrides ?? {}),
+ };
if (shouldBootstrapConfig) {
try {
diff --git a/src/core/debug-log.test.ts b/src/core/debug-log.test.ts
new file mode 100644
index 0000000..fb6eeb2
--- /dev/null
+++ b/src/core/debug-log.test.ts
@@ -0,0 +1,42 @@
+import { mkdtempSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { appendDebugLog } from "./debug-log.js";
+
+describe("appendDebugLog", () => {
+ const originalLogPath = process.env.GNHF_DEBUG_LOG_PATH;
+
+ afterEach(() => {
+ if (originalLogPath === undefined) {
+ delete process.env.GNHF_DEBUG_LOG_PATH;
+ } else {
+ process.env.GNHF_DEBUG_LOG_PATH = originalLogPath;
+ }
+ });
+
+ it("writes JSON lines when GNHF_DEBUG_LOG_PATH is set", () => {
+ const tempDir = mkdtempSync(join(tmpdir(), "gnhf-debug-log-test-"));
+ const logPath = join(tempDir, "debug.jsonl");
+ process.env.GNHF_DEBUG_LOG_PATH = logPath;
+
+ appendDebugLog("run:start", { prompt: "ship it" });
+
+ const [line] = readFileSync(logPath, "utf-8").trim().split("\n");
+ expect(JSON.parse(line!)).toMatchObject({
+ event: "run:start",
+ prompt: "ship it",
+ pid: process.pid,
+ });
+
+ rmSync(tempDir, { recursive: true, force: true });
+ });
+
+ it("does nothing when the env var is unset", () => {
+ delete process.env.GNHF_DEBUG_LOG_PATH;
+
+ expect(() =>
+ appendDebugLog("run:start", { prompt: "ship it" }),
+ ).not.toThrow();
+ });
+});
diff --git a/src/core/debug-log.ts b/src/core/debug-log.ts
new file mode 100644
index 0000000..0efe8bf
--- /dev/null
+++ b/src/core/debug-log.ts
@@ -0,0 +1,24 @@
+import { appendFileSync } from "node:fs";
+
+export function appendDebugLog(
+ event: string,
+ details: Record = {},
+): void {
+ const logPath = process.env.GNHF_DEBUG_LOG_PATH;
+ if (!logPath) return;
+
+ try {
+ appendFileSync(
+ logPath,
+ `${JSON.stringify({
+ timestamp: new Date().toISOString(),
+ pid: process.pid,
+ event,
+ ...details,
+ })}\n`,
+ "utf-8",
+ );
+ } catch {
+ // Debug logging is best-effort only.
+ }
+}
diff --git a/src/core/orchestrator.test.ts b/src/core/orchestrator.test.ts
index 028438b..a468884 100644
--- a/src/core/orchestrator.test.ts
+++ b/src/core/orchestrator.test.ts
@@ -59,6 +59,7 @@ function createSuccessResult(summary = "done"): AgentResult {
describe("Orchestrator stop limits", () => {
beforeEach(() => {
vi.clearAllMocks();
+ vi.useRealTimers();
});
it("aborts before starting when the max iteration cap is already reached", async () => {
@@ -117,7 +118,7 @@ describe("Orchestrator stop limits", () => {
name: "claude",
run: vi.fn(
(_prompt, _cwd, options) =>
- new Promise((_resolve, reject) => {
+ new Promise((_resolve, reject) => {
options?.signal?.addEventListener("abort", () => {
reject(new Error("Agent was aborted"));
});
@@ -156,7 +157,7 @@ describe("Orchestrator stop limits", () => {
});
});
- it("closes the agent when stop is requested", () => {
+ it("closes the agent when stop is requested", async () => {
const close = vi.fn();
const agent: Agent = {
name: "claude",
@@ -172,6 +173,7 @@ describe("Orchestrator stop limits", () => {
);
orchestrator.stop();
+ await Promise.resolve();
expect(close).toHaveBeenCalledTimes(1);
});
@@ -201,6 +203,7 @@ describe("Orchestrator stop limits", () => {
orchestrator.on("stopped", stopped);
orchestrator.stop();
+ await Promise.resolve();
expect(close).toHaveBeenCalledTimes(1);
expect(stopped).not.toHaveBeenCalled();
@@ -211,4 +214,139 @@ describe("Orchestrator stop limits", () => {
expect(stopped).toHaveBeenCalledTimes(1);
});
+
+ it("waits for the active iteration to unwind before closing the agent", async () => {
+ let rejectRun!: (error: Error) => void;
+ const close = vi.fn(() => Promise.resolve());
+ const agent: Agent = {
+ name: "claude",
+ run: vi.fn(
+ (_prompt, _cwd, options) =>
+ new Promise((_resolve, reject) => {
+ rejectRun = reject;
+ options?.signal?.addEventListener("abort", () => {
+ queueMicrotask(() => {
+ reject(new Error("Agent was aborted"));
+ });
+ });
+ }),
+ ),
+ close,
+ };
+ const orchestrator = new Orchestrator(
+ config,
+ agent,
+ runInfo,
+ "ship it",
+ "/repo",
+ );
+
+ const startPromise = orchestrator.start();
+
+ await vi.waitFor(() => {
+ expect(agent.run).toHaveBeenCalledTimes(1);
+ });
+
+ orchestrator.stop();
+
+ expect(close).not.toHaveBeenCalled();
+
+ rejectRun(new Error("Agent was aborted"));
+ await startPromise;
+
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it("starts agent cleanup if a stopped iteration remains stuck after a grace period", async () => {
+ vi.useFakeTimers();
+
+ let rejectRun!: (error: Error) => void;
+ const close = vi.fn(() => {
+ rejectRun(new Error("Agent was aborted"));
+ return Promise.resolve();
+ });
+ const agent: Agent = {
+ name: "claude",
+ run: vi.fn(
+ (_prompt, _cwd, options) =>
+ new Promise((_resolve, reject) => {
+ rejectRun = reject;
+ options?.signal?.addEventListener("abort", () => {
+ // Simulate an agent that stays hung until close() tears down
+ // its backing process.
+ });
+ }),
+ ),
+ close,
+ };
+ const orchestrator = new Orchestrator(
+ config,
+ agent,
+ runInfo,
+ "ship it",
+ "/repo",
+ );
+
+ const startPromise = orchestrator.start();
+
+ await vi.waitFor(() => {
+ expect(agent.run).toHaveBeenCalledTimes(1);
+ });
+
+ orchestrator.stop();
+
+ expect(close).not.toHaveBeenCalled();
+
+ await vi.advanceTimersByTimeAsync(250);
+
+ expect(close).toHaveBeenCalledTimes(1);
+
+ await startPromise;
+ });
+
+ it("does not record or commit a late successful result after stop is requested", async () => {
+ vi.useFakeTimers();
+
+ let resolveRun!: (result: AgentResult) => void;
+ const close = vi.fn(() => Promise.resolve());
+ const agent: Agent = {
+ name: "claude",
+ run: vi.fn(
+ (_prompt, _cwd, options) =>
+ new Promise((resolve) => {
+ resolveRun = resolve;
+ options?.signal?.addEventListener("abort", () => {
+ setTimeout(() => {
+ resolve(createSuccessResult("late success"));
+ }, 10);
+ });
+ }),
+ ),
+ close,
+ };
+ const orchestrator = new Orchestrator(
+ { ...config, preventSleep: false },
+ agent,
+ runInfo,
+ "ship it",
+ "/repo",
+ );
+
+ const startPromise = orchestrator.start();
+
+ await vi.waitFor(() => {
+ expect(agent.run).toHaveBeenCalledTimes(1);
+ });
+
+ orchestrator.stop();
+ await vi.advanceTimersByTimeAsync(10);
+ await startPromise;
+
+ expect(resolveRun).toBeTypeOf("function");
+ expect(mockAppendNotes).not.toHaveBeenCalled();
+ expect(mockCommitAll).not.toHaveBeenCalled();
+ expect(orchestrator.getState().iterations).toEqual([]);
+ expect(orchestrator.getState().status).toBe("stopped");
+ expect(close).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/src/core/orchestrator.ts b/src/core/orchestrator.ts
index 5c59bef..7c733c0 100644
--- a/src/core/orchestrator.ts
+++ b/src/core/orchestrator.ts
@@ -44,8 +44,11 @@ export interface RunLimits {
maxTokens?: number;
}
+const STOP_CLOSE_AGENT_GRACE_MS = 250;
+
type RunIterationResult =
| { type: "completed"; record: IterationRecord }
+ | { type: "stopped" }
| { type: "aborted"; reason: string };
export class Orchestrator extends EventEmitter {
@@ -57,6 +60,7 @@ export class Orchestrator extends EventEmitter {
private limits: RunLimits;
private stopRequested = false;
private stopPromise: Promise | null = null;
+ private activeIterationPromise: Promise | null = null;
private activeAbortController: AbortController | null = null;
private pendingAbortReason: string | null = null;
@@ -109,7 +113,27 @@ export class Orchestrator extends EventEmitter {
if (this.stopPromise) return;
this.stopPromise = (async () => {
- await this.closeAgent();
+ if (this.activeIterationPromise) {
+ const iterationPromise = this.activeIterationPromise.catch(
+ () => undefined,
+ );
+ await new Promise((resolve) => {
+ let settled = false;
+ const settle = () => {
+ if (settled) return;
+ settled = true;
+ clearTimeout(timer);
+ resolve();
+ };
+ const timer = setTimeout(settle, STOP_CLOSE_AGENT_GRACE_MS);
+ timer.unref?.();
+ void iterationPromise.finally(settle);
+ });
+ await this.closeAgent();
+ await iterationPromise;
+ } else {
+ await this.closeAgent();
+ }
resetHard(this.cwd);
this.state.status = "stopped";
this.emit("state", this.getState());
@@ -141,7 +165,12 @@ export class Orchestrator extends EventEmitter {
prompt: this.prompt,
});
- const result = await this.runIteration(iterationPrompt);
+ this.activeIterationPromise = this.runIteration(iterationPrompt);
+ const result = await this.activeIterationPromise;
+ this.activeIterationPromise = null;
+ if (result.type === "stopped") {
+ break;
+ }
if (result.type === "aborted") {
this.abort(result.reason);
break;
@@ -184,6 +213,7 @@ export class Orchestrator extends EventEmitter {
}
}
} finally {
+ this.activeIterationPromise = null;
if (this.stopPromise) {
await this.stopPromise;
} else {
@@ -233,6 +263,10 @@ export class Orchestrator extends EventEmitter {
logPath,
});
+ if (this.stopRequested) {
+ return { type: "stopped" };
+ }
+
if (result.output.success) {
return { type: "completed", record: this.recordSuccess(result.output) };
}
@@ -254,6 +288,10 @@ export class Orchestrator extends EventEmitter {
return { type: "aborted", reason: this.pendingAbortReason };
}
+ if (this.stopRequested) {
+ return { type: "stopped" };
+ }
+
const summary = err instanceof Error ? err.message : String(err);
return {
type: "completed",
diff --git a/src/core/run.test.ts b/src/core/run.test.ts
index 961495a..504f2fe 100644
--- a/src/core/run.test.ts
+++ b/src/core/run.test.ts
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
+import { join } from "node:path";
vi.mock("node:fs", () => ({
mkdirSync: vi.fn(),
@@ -29,6 +30,8 @@ import {
import { findLegacyRunBaseCommit, getHeadCommit } from "./git.js";
import { setupRun, appendNotes, resumeRun } from "./run.js";
+const P = "/project";
+
const mockMkdirSync = vi.mocked(mkdirSync);
const mockWriteFileSync = vi.mocked(writeFileSync);
const mockAppendFileSync = vi.mocked(appendFileSync);
@@ -45,37 +48,37 @@ describe("setupRun", () => {
});
it("creates the run directory recursively", () => {
- setupRun("test-run-1", "fix bugs", "abc123", "/project");
- expect(mockMkdirSync).toHaveBeenCalledWith("/project/.git/info", {
+ setupRun("test-run-1", "fix bugs", "abc123", P);
+ expect(mockMkdirSync).toHaveBeenCalledWith(join(P, ".git", "info"), {
recursive: true,
});
expect(mockMkdirSync).toHaveBeenCalledWith(
- "/project/.gnhf/runs/test-run-1",
+ join(P, ".gnhf", "runs", "test-run-1"),
{ recursive: true },
);
});
it("writes the ignore rule to .git/info/exclude", () => {
- setupRun("run-abc", "test", "abc123", "/project");
+ setupRun("run-abc", "test", "abc123", P);
expect(mockWriteFileSync).toHaveBeenCalledWith(
- "/project/.git/info/exclude",
+ join(P, ".git", "info", "exclude"),
".gnhf/runs/\n",
"utf-8",
);
});
it("writes PROMPT.md with the prompt text", () => {
- setupRun("run-abc", "improve coverage", "abc123", "/project");
+ setupRun("run-abc", "improve coverage", "abc123", P);
expect(mockWriteFileSync).toHaveBeenCalledWith(
- "/project/.gnhf/runs/run-abc/prompt.md",
+ join(P, ".gnhf", "runs", "run-abc", "prompt.md"),
"improve coverage",
"utf-8",
);
});
it("writes notes.md with header and objective", () => {
- setupRun("run-abc", "improve coverage", "abc123", "/project");
+ setupRun("run-abc", "improve coverage", "abc123", P);
const notesCall = mockWriteFileSync.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].endsWith("notes.md"),
);
@@ -87,7 +90,7 @@ describe("setupRun", () => {
});
it("writes output-schema.json with valid JSON schema", () => {
- setupRun("run-abc", "test", "abc123", "/project");
+ setupRun("run-abc", "test", "abc123", P);
const schemaCall = mockWriteFileSync.mock.calls.find(
(call) =>
typeof call[0] === "string" && call[0].endsWith("output-schema.json"),
@@ -103,42 +106,42 @@ describe("setupRun", () => {
});
it("writes the branch base commit for new runs", () => {
- setupRun("run-abc", "test", "abc123", "/project");
+ setupRun("run-abc", "test", "abc123", P);
expect(mockWriteFileSync).toHaveBeenCalledWith(
- "/project/.gnhf/runs/run-abc/base-commit",
+ join(P, ".gnhf", "runs", "run-abc", "base-commit"),
"abc123\n",
"utf-8",
);
});
it("preserves the existing branch base commit on overwrite", () => {
- mockExistsSync.mockImplementation(
- (path) => path === "/project/.gnhf/runs/run-abc/base-commit",
- );
+ const baseCommitPath = join(P, ".gnhf", "runs", "run-abc", "base-commit");
+ mockExistsSync.mockImplementation((path) => path === baseCommitPath);
mockReadFileSync.mockImplementation((path) =>
- path === "/project/.gnhf/runs/run-abc/base-commit" ? "old123\n" : "",
+ path === baseCommitPath ? "old123\n" : "",
);
- setupRun("run-abc", "test", "new456", "/project");
+ setupRun("run-abc", "test", "new456", P);
expect(mockWriteFileSync).not.toHaveBeenCalledWith(
- "/project/.gnhf/runs/run-abc/base-commit",
+ baseCommitPath,
"new456\n",
"utf-8",
);
});
it("returns correct RunInfo paths", () => {
- const info = setupRun("my-run", "prompt text", "abc123", "/project");
+ const runDir = join(P, ".gnhf", "runs", "my-run");
+ const info = setupRun("my-run", "prompt text", "abc123", P);
expect(info).toEqual({
runId: "my-run",
- runDir: "/project/.gnhf/runs/my-run",
- promptPath: "/project/.gnhf/runs/my-run/prompt.md",
- notesPath: "/project/.gnhf/runs/my-run/notes.md",
- schemaPath: "/project/.gnhf/runs/my-run/output-schema.json",
+ runDir,
+ promptPath: join(runDir, "prompt.md"),
+ notesPath: join(runDir, "notes.md"),
+ schemaPath: join(runDir, "output-schema.json"),
baseCommit: "abc123",
- baseCommitPath: "/project/.gnhf/runs/my-run/base-commit",
+ baseCommitPath: join(runDir, "base-commit"),
});
});
});
@@ -149,14 +152,13 @@ describe("resumeRun", () => {
});
it("refreshes output-schema.json to the current JSON schema", () => {
- mockExistsSync.mockImplementation(
- (path) => path === "/project/.gnhf/runs/run-abc",
- );
+ const runDir = join(P, ".gnhf", "runs", "run-abc");
+ mockExistsSync.mockImplementation((path) => path === runDir);
- resumeRun("run-abc", "/project");
+ resumeRun("run-abc", P);
expect(mockWriteFileSync).toHaveBeenCalledWith(
- "/project/.gnhf/runs/run-abc/output-schema.json",
+ join(runDir, "output-schema.json"),
expect.any(String),
"utf-8",
);
@@ -169,34 +171,30 @@ describe("resumeRun", () => {
});
it("reads the stored base commit when present", () => {
+ const runDir = join(P, ".gnhf", "runs", "run-abc");
+ const baseCommitPath = join(runDir, "base-commit");
mockExistsSync.mockImplementation(
- (path) =>
- path === "/project/.gnhf/runs/run-abc" ||
- path === "/project/.gnhf/runs/run-abc/base-commit",
+ (path) => path === runDir || path === baseCommitPath,
);
mockReadFileSync.mockImplementation((path) =>
- path === "/project/.gnhf/runs/run-abc/base-commit" ? "abc123\n" : "",
+ path === baseCommitPath ? "abc123\n" : "",
);
- const info = resumeRun("run-abc", "/project");
+ const info = resumeRun("run-abc", P);
expect(info.baseCommit).toBe("abc123");
});
it("backfills missing base-commit for legacy runs", () => {
- mockExistsSync.mockImplementation(
- (path) => path === "/project/.gnhf/runs/run-abc",
- );
+ const runDir = join(P, ".gnhf", "runs", "run-abc");
+ mockExistsSync.mockImplementation((path) => path === runDir);
mockFindLegacyRunBaseCommit.mockReturnValue("legacy123");
- const info = resumeRun("run-abc", "/project");
+ const info = resumeRun("run-abc", P);
- expect(mockFindLegacyRunBaseCommit).toHaveBeenCalledWith(
- "run-abc",
- "/project",
- );
+ expect(mockFindLegacyRunBaseCommit).toHaveBeenCalledWith("run-abc", P);
expect(mockWriteFileSync).toHaveBeenCalledWith(
- "/project/.gnhf/runs/run-abc/base-commit",
+ join(runDir, "base-commit"),
"legacy123\n",
"utf-8",
);
@@ -204,15 +202,14 @@ describe("resumeRun", () => {
});
it("falls back to HEAD when a legacy run has no recoverable base commit", () => {
- mockExistsSync.mockImplementation(
- (path) => path === "/project/.gnhf/runs/run-abc",
- );
+ const runDir = join(P, ".gnhf", "runs", "run-abc");
+ mockExistsSync.mockImplementation((path) => path === runDir);
mockFindLegacyRunBaseCommit.mockReturnValue(null);
mockGetHeadCommit.mockReturnValue("head456");
- const info = resumeRun("run-abc", "/project");
+ const info = resumeRun("run-abc", P);
- expect(mockGetHeadCommit).toHaveBeenCalledWith("/project");
+ expect(mockGetHeadCommit).toHaveBeenCalledWith(P);
expect(info.baseCommit).toBe("head456");
});
});
diff --git a/src/core/sleep.test.ts b/src/core/sleep.test.ts
new file mode 100644
index 0000000..13fc8d1
--- /dev/null
+++ b/src/core/sleep.test.ts
@@ -0,0 +1,568 @@
+import { EventEmitter } from "node:events";
+import type { ChildProcess } from "node:child_process";
+import {
+ existsSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("node:child_process", () => ({
+ spawn: vi.fn(),
+}));
+
+import { spawn } from "node:child_process";
+import { startSleepPrevention } from "./sleep.js";
+
+const mockSpawn = vi.mocked(spawn);
+
+function createChildProcess(pid = 1234): ChildProcess {
+ const child = Object.assign(new EventEmitter(), {
+ exitCode: null,
+ pid,
+ kill: vi.fn((signal?: NodeJS.Signals) => {
+ child.emit("close", signal === "SIGKILL" ? 1 : 0, null);
+ return true;
+ }),
+ signalCode: null,
+ });
+ return child as unknown as ChildProcess;
+}
+
+describe("startSleepPrevention", () => {
+ afterEach(() => {
+ vi.clearAllMocks();
+ vi.useRealTimers();
+ });
+
+ it("starts caffeinate on macOS and returns a cleanup handle", async () => {
+ const child = createChildProcess();
+ mockSpawn.mockImplementation(() => {
+ queueMicrotask(() => child.emit("spawn"));
+ return child as never;
+ });
+
+ const result = await startSleepPrevention(["ship it"], {
+ pid: 42,
+ platform: "darwin",
+ });
+
+ expect(mockSpawn).toHaveBeenCalledWith(
+ "caffeinate",
+ ["-i", "-w", "42"],
+ expect.objectContaining({ stdio: "ignore" }),
+ );
+ expect(result.type).toBe("active");
+ if (result.type === "active") {
+ await result.cleanup();
+ }
+ expect(child.kill).toHaveBeenCalledWith("SIGTERM");
+ });
+
+ it("falls back when a helper exits immediately after spawn", async () => {
+ vi.useFakeTimers();
+
+ const child = createChildProcess();
+ mockSpawn.mockImplementation(() => {
+ queueMicrotask(() => {
+ child.emit("spawn");
+ setTimeout(() => {
+ child.emit("exit", 1, null);
+ }, 50);
+ });
+ return child as never;
+ });
+
+ const resultPromise = startSleepPrevention(["ship it"], {
+ pid: 42,
+ platform: "darwin",
+ });
+
+ await vi.advanceTimersByTimeAsync(50);
+
+ await expect(resultPromise).resolves.toEqual({
+ type: "skipped",
+ reason: "unavailable",
+ });
+ });
+
+ it("falls back when a helper has already exited by the time stability checking starts", async () => {
+ vi.useFakeTimers();
+
+ const child = createChildProcess();
+ mockSpawn.mockImplementation(() => {
+ queueMicrotask(() => {
+ child.emit("spawn");
+ child.exitCode = 1;
+ });
+ return child as never;
+ });
+
+ const resultPromise = startSleepPrevention(["ship it"], {
+ pid: 42,
+ platform: "darwin",
+ });
+
+ await Promise.resolve();
+ await vi.advanceTimersByTimeAsync(100);
+
+ await expect(resultPromise).resolves.toEqual({
+ type: "skipped",
+ reason: "unavailable",
+ });
+ });
+
+ it("re-execs under systemd-inhibit on Linux", async () => {
+ const child = createChildProcess();
+ mockSpawn.mockImplementation(() => {
+ queueMicrotask(() => {
+ child.emit("spawn");
+ child.emit("exit", 0, null);
+ });
+ return child as never;
+ });
+
+ const result = await startSleepPrevention(
+ ["ship it", "--agent", "opencode"],
+ {
+ env: {},
+ platform: "linux",
+ processArgv1: "/dist/cli.mjs",
+ processExecPath: "/node",
+ },
+ );
+
+ expect(mockSpawn).toHaveBeenCalledWith(
+ "systemd-inhibit",
+ expect.arrayContaining([
+ "--what=idle:sleep",
+ "--mode=block",
+ "/node",
+ "/dist/cli.mjs",
+ "ship it",
+ "--agent",
+ "opencode",
+ ]),
+ expect.objectContaining({
+ detached: true,
+ stdio: "inherit",
+ env: expect.objectContaining({ GNHF_SLEEP_INHIBITED: "1" }),
+ }),
+ );
+ expect(result).toEqual({ type: "reexeced", exitCode: 0 });
+ });
+
+ it("preserves process.execArgv when re-execing under systemd-inhibit on Linux", async () => {
+ const child = createChildProcess();
+ mockSpawn.mockImplementation(() => {
+ queueMicrotask(() => {
+ child.emit("spawn");
+ child.emit("exit", 0, null);
+ });
+ return child as never;
+ });
+
+ const result = await startSleepPrevention(["ship it"], {
+ env: {},
+ platform: "linux",
+ processArgv1: "/dist/cli.mjs",
+ processExecPath: "/node",
+ processExecArgv: ["--inspect", "--loader", "tsx"],
+ });
+
+ expect(mockSpawn).toHaveBeenCalledWith(
+ "systemd-inhibit",
+ expect.arrayContaining([
+ "--what=idle:sleep",
+ "--mode=block",
+ "/node",
+ "--inspect",
+ "--loader",
+ "tsx",
+ "/dist/cli.mjs",
+ "ship it",
+ ]),
+ expect.any(Object),
+ );
+ expect(result).toEqual({ type: "reexeced", exitCode: 0 });
+ });
+
+ it("preserves re-exec environment overrides when re-execing under systemd-inhibit on Linux", async () => {
+ const child = createChildProcess();
+ mockSpawn.mockImplementation(() => {
+ queueMicrotask(() => {
+ child.emit("spawn");
+ child.emit("exit", 0, null);
+ });
+ return child as never;
+ });
+
+ const result = await startSleepPrevention(["--agent", "opencode"], {
+ env: {},
+ platform: "linux",
+ processArgv1: "/dist/cli.mjs",
+ processExecPath: "/node",
+ reexecEnv: {
+ GNHF_REEXEC_STDIN_PROMPT: "objective from stdin",
+ },
+ });
+
+ expect(mockSpawn).toHaveBeenCalledWith(
+ "systemd-inhibit",
+ expect.any(Array),
+ expect.objectContaining({
+ env: expect.objectContaining({
+ GNHF_REEXEC_STDIN_PROMPT: "objective from stdin",
+ GNHF_SLEEP_INHIBITED: "1",
+ }),
+ }),
+ );
+ expect(result).toEqual({ type: "reexeced", exitCode: 0 });
+ });
+
+ it("falls back when systemd-inhibit exits immediately with an error", async () => {
+ const child = createChildProcess();
+ mockSpawn.mockImplementation(() => {
+ queueMicrotask(() => {
+ child.emit("spawn");
+ child.emit("exit", 1, null);
+ });
+ return child as never;
+ });
+
+ const result = await startSleepPrevention(["ship it"], {
+ env: {},
+ platform: "linux",
+ processArgv1: "/dist/cli.mjs",
+ processExecPath: "/node",
+ });
+
+ expect(result).toEqual({ type: "skipped", reason: "unavailable" });
+ });
+
+ it("signals readiness when running inside the re-execed Linux process", async () => {
+ const tempDir = mkdtempSync(join(tmpdir(), "gnhf-sleep-"));
+ const readyPath = join(tempDir, "reexec-ready");
+
+ try {
+ const result = await startSleepPrevention(["ship it"], {
+ env: {
+ GNHF_SLEEP_INHIBITED: "1",
+ GNHF_SLEEP_REEXEC_READY_PATH: readyPath,
+ },
+ platform: "linux",
+ });
+
+ expect(result).toEqual({ type: "skipped", reason: "already-inhibited" });
+ expect(existsSync(readyPath)).toBe(true);
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it("does not overwrite an untrusted readiness path from the environment", async () => {
+ const tempDir = mkdtempSync(join(tmpdir(), "gnhf-sleep-test-"));
+ const victimPath = join(tempDir, "victim.txt");
+ writeFileSync(victimPath, "do not touch", "utf-8");
+
+ try {
+ const result = await startSleepPrevention(["ship it"], {
+ env: {
+ GNHF_SLEEP_INHIBITED: "1",
+ GNHF_SLEEP_REEXEC_READY_PATH: victimPath,
+ },
+ platform: "linux",
+ });
+
+ expect(result).toEqual({ type: "skipped", reason: "already-inhibited" });
+ expect(readFileSync(victimPath, "utf-8")).toBe("do not touch");
+ } finally {
+ rmSync(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it("falls back when systemd-inhibit exits before the re-execed child signals readiness", async () => {
+ vi.useFakeTimers();
+
+ const child = createChildProcess();
+ mockSpawn.mockImplementation(() => {
+ queueMicrotask(() => {
+ child.emit("spawn");
+ setTimeout(() => {
+ child.emit("exit", 1, null);
+ }, 500);
+ });
+ return child as never;
+ });
+
+ const resultPromise = startSleepPrevention(["ship it"], {
+ env: {},
+ platform: "linux",
+ processArgv1: "/dist/cli.mjs",
+ processExecPath: "/node",
+ });
+
+ await vi.advanceTimersByTimeAsync(500);
+
+ await expect(resultPromise).resolves.toEqual({
+ type: "skipped",
+ reason: "unavailable",
+ });
+ });
+
+ it("treats the Linux re-exec as authoritative after the child signals readiness", async () => {
+ vi.useFakeTimers();
+
+ const child = createChildProcess();
+ mockSpawn.mockImplementation((_, __, options) => {
+ const readyPath = options?.env?.GNHF_SLEEP_REEXEC_READY_PATH;
+
+ expect(readyPath).toEqual(expect.any(String));
+
+ queueMicrotask(() => {
+ child.emit("spawn");
+ setTimeout(() => {
+ if (typeof readyPath === "string") {
+ writeFileSync(readyPath, "ready\n", "utf-8");
+ }
+ }, 300);
+ setTimeout(() => {
+ child.emit("exit", 1, null);
+ }, 600);
+ });
+ return child as never;
+ });
+
+ const resultPromise = startSleepPrevention(["ship it"], {
+ env: {},
+ platform: "linux",
+ processArgv1: "/dist/cli.mjs",
+ processExecPath: "/node",
+ });
+
+ await vi.advanceTimersByTimeAsync(600);
+
+ await expect(resultPromise).resolves.toEqual({
+ type: "reexeced",
+ exitCode: 1,
+ });
+ });
+
+ it("treats the Linux re-exec as authoritative when the ready file appears just before a nonzero exit", async () => {
+ vi.useFakeTimers();
+
+ const child = createChildProcess();
+ mockSpawn.mockImplementation((_, __, options) => {
+ const readyPath = options?.env?.GNHF_SLEEP_REEXEC_READY_PATH;
+
+ expect(readyPath).toEqual(expect.any(String));
+
+ queueMicrotask(() => {
+ child.emit("spawn");
+ setTimeout(() => {
+ if (typeof readyPath === "string") {
+ writeFileSync(readyPath, "ready\n", "utf-8");
+ }
+ }, 301);
+ setTimeout(() => {
+ child.emit("exit", 1, null);
+ }, 302);
+ });
+ return child as never;
+ });
+
+ const resultPromise = startSleepPrevention(["ship it"], {
+ env: {},
+ platform: "linux",
+ processArgv1: "/dist/cli.mjs",
+ processExecPath: "/node",
+ });
+
+ await vi.advanceTimersByTimeAsync(302);
+
+ await expect(resultPromise).resolves.toEqual({
+ type: "reexeced",
+ exitCode: 1,
+ });
+ });
+
+ it("forwards SIGTERM to systemd-inhibit while waiting for the re-execed child to exit", async () => {
+ vi.useFakeTimers();
+
+ const child = createChildProcess();
+ const killProcess = vi.fn(() => true);
+ let handleSigTerm: (() => void) | undefined;
+ const processOn = vi.fn((event: string, listener: () => void) => {
+ if (event === "SIGTERM") handleSigTerm = listener;
+ return process;
+ });
+ const processOff = vi.fn(() => process);
+ mockSpawn.mockImplementation((_, __, options) => {
+ const readyPath = options?.env?.GNHF_SLEEP_REEXEC_READY_PATH;
+
+ queueMicrotask(() => {
+ child.emit("spawn");
+ setTimeout(() => {
+ if (typeof readyPath === "string") {
+ writeFileSync(readyPath, "ready\n", "utf-8");
+ }
+ }, 100);
+ });
+ return child as never;
+ });
+
+ const resultPromise = startSleepPrevention(["ship it"], {
+ env: {},
+ platform: "linux",
+ processArgv1: "/dist/cli.mjs",
+ processExecPath: "/node",
+ killProcess,
+ processOn,
+ processOff,
+ });
+
+ await vi.advanceTimersByTimeAsync(100);
+ handleSigTerm?.();
+ child.emit("exit", null, "SIGTERM");
+
+ await expect(resultPromise).resolves.toEqual({
+ type: "reexeced",
+ exitCode: 143,
+ });
+ expect(killProcess).toHaveBeenCalledWith(-1234, "SIGTERM");
+ expect(processOff).toHaveBeenCalledWith("SIGTERM", expect.any(Function));
+ });
+
+ it("forwards signals received before waitForSpawn resolves", async () => {
+ const child = createChildProcess();
+ const killProcess = vi.fn(() => true);
+ let handleSigInt: (() => void) | undefined;
+ const processOn = vi.fn((event: string, listener: () => void) => {
+ if (event === "SIGINT") handleSigInt = listener;
+ return process;
+ });
+ const processOff = vi.fn(() => process);
+ mockSpawn.mockImplementation(() => {
+ // Simulate SIGINT arriving *before* the spawn event fires.
+ queueMicrotask(() => {
+ handleSigInt?.();
+ child.emit("spawn");
+ child.emit("exit", null, "SIGINT");
+ });
+ return child as never;
+ });
+
+ const result = await startSleepPrevention(["ship it"], {
+ env: {},
+ platform: "linux",
+ processArgv1: "/dist/cli.mjs",
+ processExecPath: "/node",
+ killProcess,
+ processOn,
+ processOff,
+ });
+
+ expect(result).toEqual({ type: "reexeced", exitCode: 130 });
+ expect(killProcess).toHaveBeenCalledWith(-1234, "SIGINT");
+ expect(processOff).toHaveBeenCalledWith("SIGINT", expect.any(Function));
+ });
+
+ it("tears down signal forwarding when spawn fails", async () => {
+ const child = createChildProcess();
+ const processOn = vi.fn(() => process);
+ const processOff = vi.fn(() => process);
+ mockSpawn.mockImplementation(() => {
+ queueMicrotask(() => {
+ child.emit("error", new Error("spawn failed"));
+ });
+ return child as never;
+ });
+
+ const result = await startSleepPrevention(["ship it"], {
+ env: {},
+ platform: "linux",
+ processArgv1: "/dist/cli.mjs",
+ processExecPath: "/node",
+ processOn,
+ processOff,
+ });
+
+ expect(result).toEqual({ type: "skipped", reason: "unavailable" });
+ expect(processOn).toHaveBeenCalledWith("SIGINT", expect.any(Function));
+ expect(processOn).toHaveBeenCalledWith("SIGTERM", expect.any(Function));
+ expect(processOff).toHaveBeenCalledWith("SIGINT", expect.any(Function));
+ expect(processOff).toHaveBeenCalledWith("SIGTERM", expect.any(Function));
+ });
+
+ it("shuts down the Linux re-exec process group before falling back after a readiness timeout", async () => {
+ vi.useFakeTimers();
+
+ const child = createChildProcess();
+ const killProcess = vi.fn((pid: number, signal: NodeJS.Signals) => {
+ if (pid === -1234 && signal === "SIGTERM") {
+ queueMicrotask(() => {
+ child.emit("close", 0, null);
+ });
+ }
+ return true;
+ });
+ mockSpawn.mockImplementation(() => {
+ queueMicrotask(() => {
+ child.emit("spawn");
+ });
+ return child as never;
+ });
+
+ const resultPromise = startSleepPrevention(["ship it"], {
+ env: {},
+ killProcess,
+ platform: "linux",
+ processArgv1: "/dist/cli.mjs",
+ processExecPath: "/node",
+ });
+
+ await vi.advanceTimersByTimeAsync(5_000);
+
+ await expect(resultPromise).resolves.toEqual({
+ type: "skipped",
+ reason: "unavailable",
+ });
+ expect(killProcess).toHaveBeenCalledWith(-1234, "SIGTERM");
+ });
+
+ it("starts a PowerShell helper on Windows", async () => {
+ const child = createChildProcess();
+ mockSpawn.mockImplementation(() => {
+ queueMicrotask(() => child.emit("spawn"));
+ return child as never;
+ });
+
+ const result = await startSleepPrevention(["ship it"], {
+ pid: 42,
+ platform: "win32",
+ });
+
+ expect(mockSpawn.mock.calls[0]?.[0]).toBe("powershell.exe");
+ expect(mockSpawn.mock.calls[0]?.[1]).toEqual(
+ expect.arrayContaining([
+ "-NoLogo",
+ "-NoProfile",
+ "-NonInteractive",
+ "-Command",
+ ]),
+ );
+ expect(String(mockSpawn.mock.calls[0]?.[1]?.at(-1))).toContain(
+ "SetThreadExecutionState",
+ );
+ expect(String(mockSpawn.mock.calls[0]?.[1]?.at(-1))).toContain(
+ "Add-Type @'\n",
+ );
+ expect(String(mockSpawn.mock.calls[0]?.[1]?.at(-1))).toContain("\n'@;");
+ expect(String(mockSpawn.mock.calls[0]?.[1]?.at(-1))).toContain("42");
+ expect(result.type).toBe("active");
+ });
+});
diff --git a/src/core/sleep.ts b/src/core/sleep.ts
new file mode 100644
index 0000000..fd55c7a
--- /dev/null
+++ b/src/core/sleep.ts
@@ -0,0 +1,456 @@
+import { spawn, type ChildProcess } from "node:child_process";
+import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { basename, dirname, join, resolve } from "node:path";
+import {
+ shutdownChildProcess,
+ signalChildProcess,
+} from "./agents/managed-process.js";
+import { appendDebugLog } from "./debug-log.js";
+
+export type SleepPreventionResult =
+ | {
+ type: "active";
+ cleanup: () => Promise;
+ }
+ | {
+ type: "reexeced";
+ exitCode: number;
+ }
+ | {
+ type: "skipped";
+ reason: "already-inhibited" | "unavailable" | "unsupported";
+ };
+
+interface SleepPreventionDeps {
+ env?: NodeJS.ProcessEnv;
+ killProcess?: typeof process.kill;
+ pid?: number;
+ platform?: NodeJS.Platform;
+ processExecArgv?: string[];
+ processArgv1?: string;
+ processExecPath?: string;
+ processOff?: typeof process.off;
+ processOn?: typeof process.on;
+ reexecEnv?: NodeJS.ProcessEnv;
+ spawn?: typeof spawn;
+}
+
+const SYSTEMD_INHIBIT_READY_TIMEOUT_MS = 5_000;
+const SYSTEMD_INHIBIT_READY_POLL_MS = 25;
+const GNHF_SLEEP_REEXEC_READY_PATH = "GNHF_SLEEP_REEXEC_READY_PATH";
+const GNHF_SLEEP_REEXEC_READY_DIR_PREFIX = "gnhf-sleep-";
+const GNHF_SLEEP_REEXEC_READY_FILENAME = "reexec-ready";
+const HELPER_STARTUP_GRACE_MS = 100;
+
+function getSignalExitCode(signal: NodeJS.Signals | null): number {
+ if (signal === "SIGINT") return 130;
+ if (signal === "SIGTERM") return 143;
+ return 1;
+}
+
+async function waitForSpawn(child: ChildProcess): Promise {
+ return await new Promise((resolve) => {
+ child.once("spawn", () => resolve(true));
+ child.once("error", () => resolve(false));
+ });
+}
+
+async function waitForHelperStability(
+ child: ChildProcess,
+ timeoutMs: number,
+): Promise {
+ return await new Promise((resolve) => {
+ let settled = false;
+ let timer: ReturnType | null = null;
+ const settle = (value: boolean) => {
+ if (settled) return;
+ settled = true;
+ if (timer) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ resolve(value);
+ };
+
+ child.once("exit", () => {
+ settle(false);
+ });
+ child.once("error", () => {
+ settle(false);
+ });
+
+ if (child.exitCode != null || child.signalCode != null) {
+ settle(false);
+ return;
+ }
+
+ timer = setTimeout(() => {
+ settle(true);
+ }, timeoutMs);
+ timer.unref?.();
+ });
+}
+
+function isTrustedLinuxReexecReadyPath(readyPath: string): boolean {
+ const resolvedReadyPath = resolve(readyPath);
+ const readyDir = dirname(resolvedReadyPath);
+ return (
+ basename(resolvedReadyPath) === GNHF_SLEEP_REEXEC_READY_FILENAME &&
+ dirname(readyDir) === resolve(tmpdir()) &&
+ basename(readyDir).startsWith(GNHF_SLEEP_REEXEC_READY_DIR_PREFIX)
+ );
+}
+
+function signalLinuxReexecReady(env: NodeJS.ProcessEnv): void {
+ const readyPath = env[GNHF_SLEEP_REEXEC_READY_PATH];
+ if (!readyPath) return;
+ if (!isTrustedLinuxReexecReadyPath(readyPath)) {
+ appendDebugLog("sleep:ready-signal-failed", {
+ command: "systemd-inhibit",
+ error: "untrusted ready path",
+ });
+ return;
+ }
+
+ try {
+ writeFileSync(readyPath, "ready\n", { encoding: "utf-8", flag: "wx" });
+ } catch (error) {
+ appendDebugLog("sleep:ready-signal-failed", {
+ command: "systemd-inhibit",
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+}
+
+async function waitForLinuxReexecReady(
+ readyPath: string,
+ exitStatePromise: Promise<{
+ exitCode: number;
+ signal: NodeJS.Signals | null;
+ }>,
+ timeoutMs: number,
+): Promise<
+ | { type: "ready" }
+ | { type: "exit"; exitCode: number; signal: NodeJS.Signals | null }
+ | { type: "timeout" }
+> {
+ if (existsSync(readyPath)) {
+ return { type: "ready" };
+ }
+
+ return await new Promise((resolve) => {
+ let settled = false;
+ const settle = (
+ result:
+ | { type: "ready" }
+ | { type: "exit"; exitCode: number }
+ | { type: "timeout" },
+ ) => {
+ if (settled) return;
+ settled = true;
+ clearInterval(poller);
+ clearTimeout(timeout);
+ resolve(result);
+ };
+
+ const poller = setInterval(() => {
+ if (existsSync(readyPath)) {
+ settle({ type: "ready" });
+ }
+ }, SYSTEMD_INHIBIT_READY_POLL_MS);
+ poller.unref?.();
+
+ const timeout = setTimeout(() => {
+ settle({ type: "timeout" });
+ }, timeoutMs);
+ timeout.unref?.();
+
+ void exitStatePromise.then(({ exitCode, signal }) => {
+ settle({ type: "exit", exitCode, signal });
+ });
+ });
+}
+
+function forwardTerminationSignalsToChild(
+ child: ChildProcess,
+ detached: boolean,
+ killProcess: typeof process.kill,
+ processOn: typeof process.on,
+ processOff: typeof process.off,
+): () => void {
+ const listeners: Array<[NodeJS.Signals, () => void]> = [];
+
+ for (const signal of ["SIGINT", "SIGTERM"] as const) {
+ const listener = () => {
+ try {
+ signalChildProcess(child, {
+ detached,
+ killProcess,
+ signal,
+ });
+ } catch {
+ // Best-effort only.
+ }
+ };
+ processOn(signal, listener);
+ listeners.push([signal, listener]);
+ }
+
+ return () => {
+ for (const [signal, listener] of listeners) {
+ processOff(signal, listener);
+ }
+ };
+}
+
+function buildPowerShellCommand(parentPid: number): string {
+ return [
+ "Add-Type @'",
+ "using System;",
+ "using System.Runtime.InteropServices;",
+ "public static class SleepBlock {",
+ ' [DllImport("kernel32.dll")]',
+ " public static extern uint SetThreadExecutionState(uint flags);",
+ "}",
+ "'@;",
+ "$ES_CONTINUOUS = 0x80000000;",
+ "$ES_SYSTEM_REQUIRED = 0x00000001;",
+ "[SleepBlock]::SetThreadExecutionState($ES_CONTINUOUS -bor $ES_SYSTEM_REQUIRED) | Out-Null;",
+ `try { Wait-Process -Id ${parentPid} } catch { } finally { [SleepBlock]::SetThreadExecutionState($ES_CONTINUOUS) | Out-Null }`,
+ ].join("\n");
+}
+
+async function startHelperProcess(
+ command: string,
+ args: string[],
+ spawnFn: typeof spawn,
+ env: NodeJS.ProcessEnv,
+): Promise {
+ const child = spawnFn(command, args, {
+ env,
+ stdio: "ignore",
+ });
+
+ const spawned = await waitForSpawn(child);
+ if (!spawned) {
+ appendDebugLog("sleep:unavailable", { command });
+ return null;
+ }
+
+ const stable = await waitForHelperStability(child, HELPER_STARTUP_GRACE_MS);
+ if (!stable) {
+ appendDebugLog("sleep:unavailable", {
+ command,
+ reason: "early-exit",
+ });
+ return null;
+ }
+
+ return child;
+}
+
+export async function startSleepPrevention(
+ argv: string[],
+ deps: SleepPreventionDeps = {},
+): Promise {
+ const env = deps.env ?? process.env;
+ const killProcess = deps.killProcess ?? process.kill.bind(process);
+ const pid = deps.pid ?? process.pid;
+ const platform = deps.platform ?? process.platform;
+ const processExecArgv = deps.processExecArgv ?? process.execArgv;
+ const processArgv1 = deps.processArgv1 ?? process.argv[1];
+ const processExecPath = deps.processExecPath ?? process.execPath;
+ const processOn = deps.processOn ?? process.on.bind(process);
+ const processOff = deps.processOff ?? process.off.bind(process);
+ const reexecEnv = deps.reexecEnv ?? {};
+ const spawnFn = deps.spawn ?? spawn;
+
+ if (platform === "linux") {
+ if (env.GNHF_SLEEP_INHIBITED === "1") {
+ signalLinuxReexecReady(env);
+ return { type: "skipped", reason: "already-inhibited" };
+ }
+
+ const readyDir = mkdtempSync(
+ join(tmpdir(), GNHF_SLEEP_REEXEC_READY_DIR_PREFIX),
+ );
+ const readyPath = join(readyDir, GNHF_SLEEP_REEXEC_READY_FILENAME);
+ const child = spawnFn(
+ "systemd-inhibit",
+ [
+ "--what=idle:sleep",
+ "--mode=block",
+ "--who=gnhf",
+ "--why=Prevent sleep while gnhf is running",
+ processExecPath,
+ ...processExecArgv,
+ processArgv1,
+ ...argv,
+ ],
+ {
+ detached: true,
+ env: {
+ ...env,
+ ...reexecEnv,
+ GNHF_SLEEP_INHIBITED: "1",
+ [GNHF_SLEEP_REEXEC_READY_PATH]: readyPath,
+ },
+ stdio: "inherit",
+ },
+ );
+ const exitStatePromise = new Promise<{
+ exitCode: number;
+ signal: NodeJS.Signals | null;
+ }>((resolve) => {
+ child.once("exit", (code, signal) => {
+ resolve({
+ exitCode: signal ? getSignalExitCode(signal) : (code ?? 1),
+ signal,
+ });
+ });
+ });
+
+ // Register signal forwarding immediately so SIGINT/SIGTERM received
+ // between spawn and the readiness check are forwarded to the child.
+ const stopForwardingSignals = forwardTerminationSignalsToChild(
+ child,
+ true,
+ killProcess,
+ processOn,
+ processOff,
+ );
+
+ const spawned = await waitForSpawn(child);
+ if (!spawned) {
+ stopForwardingSignals();
+ rmSync(readyDir, { recursive: true, force: true });
+ appendDebugLog("sleep:unavailable", { command: "systemd-inhibit" });
+ return { type: "skipped", reason: "unavailable" };
+ }
+
+ try {
+ const readyState = await waitForLinuxReexecReady(
+ readyPath,
+ exitStatePromise,
+ SYSTEMD_INHIBIT_READY_TIMEOUT_MS,
+ );
+ try {
+ if (readyState.type === "ready") {
+ appendDebugLog("sleep:reexec", { command: "systemd-inhibit" });
+ const { exitCode } = await exitStatePromise;
+ return {
+ type: "reexeced",
+ exitCode,
+ };
+ }
+
+ if (readyState.type === "exit") {
+ if (
+ readyState.signal === "SIGINT" ||
+ readyState.signal === "SIGTERM"
+ ) {
+ appendDebugLog("sleep:reexec", {
+ command: "systemd-inhibit",
+ signal: readyState.signal,
+ });
+ return { type: "reexeced", exitCode: readyState.exitCode };
+ }
+
+ if (readyState.exitCode !== 0) {
+ if (existsSync(readyPath)) {
+ appendDebugLog("sleep:reexec", {
+ command: "systemd-inhibit",
+ exitCode: readyState.exitCode,
+ readySignal: "late",
+ });
+ return { type: "reexeced", exitCode: readyState.exitCode };
+ }
+
+ appendDebugLog("sleep:unavailable", {
+ command: "systemd-inhibit",
+ exitCode: readyState.exitCode,
+ });
+ return { type: "skipped", reason: "unavailable" };
+ }
+
+ appendDebugLog("sleep:reexec", {
+ command: "systemd-inhibit",
+ readySignal: false,
+ });
+ return { type: "reexeced", exitCode: readyState.exitCode };
+ }
+
+ appendDebugLog("sleep:unavailable", {
+ command: "systemd-inhibit",
+ reason: "timeout",
+ timeoutMs: SYSTEMD_INHIBIT_READY_TIMEOUT_MS,
+ });
+ await shutdownChildProcess(child, {
+ detached: true,
+ killProcess,
+ timeoutMs: 1_000,
+ });
+ return { type: "skipped", reason: "unavailable" };
+ } finally {
+ stopForwardingSignals();
+ }
+ } finally {
+ rmSync(readyDir, { recursive: true, force: true });
+ }
+ }
+
+ if (platform === "darwin") {
+ const child = await startHelperProcess(
+ "caffeinate",
+ ["-i", "-w", String(pid)],
+ spawnFn,
+ env,
+ );
+ if (!child) return { type: "skipped", reason: "unavailable" };
+
+ appendDebugLog("sleep:active", { command: "caffeinate" });
+ return {
+ type: "active",
+ cleanup: async () => {
+ appendDebugLog("sleep:cleanup", { command: "caffeinate" });
+ await shutdownChildProcess(child, {
+ detached: false,
+ timeoutMs: 1_000,
+ });
+ },
+ };
+ }
+
+ if (platform === "win32") {
+ const child = await startHelperProcess(
+ "powershell.exe",
+ [
+ "-NoLogo",
+ "-NoProfile",
+ "-NonInteractive",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-Command",
+ buildPowerShellCommand(pid),
+ ],
+ spawnFn,
+ env,
+ );
+ if (!child) return { type: "skipped", reason: "unavailable" };
+
+ appendDebugLog("sleep:active", { command: "powershell.exe" });
+ return {
+ type: "active",
+ cleanup: async () => {
+ appendDebugLog("sleep:cleanup", { command: "powershell.exe" });
+ await shutdownChildProcess(child, {
+ detached: false,
+ timeoutMs: 1_000,
+ });
+ },
+ };
+ }
+
+ return { type: "skipped", reason: "unsupported" };
+}
diff --git a/src/core/stdin.test.ts b/src/core/stdin.test.ts
new file mode 100644
index 0000000..11ba648
--- /dev/null
+++ b/src/core/stdin.test.ts
@@ -0,0 +1,20 @@
+import { Readable } from "node:stream";
+import { describe, expect, it } from "vitest";
+import { readStdinText } from "./stdin.js";
+
+describe("readStdinText", () => {
+ it("reads and trims text from a stream", async () => {
+ const input = Readable.from(["ship it\n"]);
+
+ await expect(readStdinText(input)).resolves.toBe("ship it");
+ });
+
+ it("supports Buffer chunks", async () => {
+ const input = Readable.from([
+ Buffer.from("multi"),
+ Buffer.from(" line\nobjective\n"),
+ ]);
+
+ await expect(readStdinText(input)).resolves.toBe("multi line\nobjective");
+ });
+});
diff --git a/src/core/stdin.ts b/src/core/stdin.ts
new file mode 100644
index 0000000..3664749
--- /dev/null
+++ b/src/core/stdin.ts
@@ -0,0 +1,11 @@
+export async function readStdinText(
+ input: AsyncIterable,
+): Promise {
+ const chunks: Buffer[] = [];
+
+ for await (const chunk of input) {
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
+ }
+
+ return Buffer.concat(chunks).toString("utf-8").trim();
+}
diff --git a/src/renderer.test.ts b/src/renderer.test.ts
index 2c23062..b819f1b 100644
--- a/src/renderer.test.ts
+++ b/src/renderer.test.ts
@@ -283,7 +283,7 @@ describe("Renderer ctrl+c", () => {
expect(dataHandler).not.toBeNull();
dataHandler?.(Buffer.from([3]));
- await renderer.waitUntilExit();
+ await expect(renderer.waitUntilExit()).resolves.toBe("interrupted");
expect(
orchestrator.stop as ReturnType,
diff --git a/src/renderer.ts b/src/renderer.ts
index e55edbe..7aab26b 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -28,6 +28,8 @@ const MAX_MSG_LINES = 3;
const MAX_MSG_LINE_LEN = 64;
const RESUME_HINT = "[ctrl+c to stop, gnhf again to resume]";
+export type RendererExitReason = "interrupted" | "stopped";
+
// ── ANSI helpers ─────────────────────────────────────────────
export function stripAnsi(s: string): string {
@@ -422,8 +424,8 @@ export class Renderer {
private agentName: string;
private state: OrchestratorState;
private interval: ReturnType | null = null;
- private exitResolve!: () => void;
- private exitPromise: Promise;
+ private exitResolve!: (reason: RendererExitReason) => void;
+ private exitPromise: Promise;
private topStars: Star[] = [];
private bottomStars: Star[] = [];
private sideStars: Star[] = [];
@@ -448,7 +450,7 @@ export class Renderer {
});
this.orchestrator.on("stopped", () => {
- this.stop();
+ this.stop("stopped");
});
if (process.stdin.isTTY) {
@@ -456,7 +458,7 @@ export class Renderer {
process.stdin.resume();
process.stdin.on("data", (data) => {
if (data[0] === 3) {
- this.stop();
+ this.stop("interrupted");
this.orchestrator.stop();
}
});
@@ -466,7 +468,7 @@ export class Renderer {
this.render();
}
- stop(): void {
+ stop(reason: RendererExitReason = "stopped"): void {
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
@@ -476,10 +478,10 @@ export class Renderer {
process.stdin.pause();
process.stdin.removeAllListeners("data");
}
- this.exitResolve();
+ this.exitResolve(reason);
}
- waitUntilExit(): Promise {
+ waitUntilExit(): Promise {
return this.exitPromise;
}
diff --git a/test/e2e.test.ts b/test/e2e.test.ts
new file mode 100644
index 0000000..57ca123
--- /dev/null
+++ b/test/e2e.test.ts
@@ -0,0 +1,267 @@
+import { execFileSync, spawn } from "node:child_process";
+import {
+ existsSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { afterEach, describe, expect, it } from "vitest";
+
+const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const distCliPath = join(repoRoot, "dist", "cli.mjs");
+const fixtureBinDir = join(repoRoot, "test", "fixtures");
+
+interface RunResult {
+ code: number | null;
+ signal: NodeJS.Signals | null;
+ stdout: string;
+ stderr: string;
+}
+
+function git(args: string[], cwd: string): string {
+ return execFileSync("git", args, { cwd, encoding: "utf-8" }).trim();
+}
+
+function createRepo(): string {
+ const cwd = mkdtempSync(join(tmpdir(), "gnhf-e2e-"));
+ git(["init", "-b", "main"], cwd);
+ git(["config", "user.name", "gnhf tests"], cwd);
+ git(["config", "user.email", "tests@example.com"], cwd);
+ writeFileSync(join(cwd, "README.md"), "# fixture\n", "utf-8");
+ git(["add", "README.md"], cwd);
+ git(["commit", "-m", "init"], cwd);
+ return cwd;
+}
+
+function readJsonLines(filePath: string): Record[] {
+ if (!existsSync(filePath)) return [];
+ return readFileSync(filePath, "utf-8")
+ .trim()
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => JSON.parse(line) as Record);
+}
+
+async function waitForLogEvent(
+ filePath: string,
+ event: string,
+ timeoutMs = 15_000,
+): Promise> {
+ const deadline = Date.now() + timeoutMs;
+
+ while (Date.now() < deadline) {
+ const match = readJsonLines(filePath).find(
+ (entry) => entry.event === event,
+ );
+ if (match) return match;
+ await new Promise((resolveDelay) => setTimeout(resolveDelay, 50));
+ }
+
+ throw new Error(`Timed out waiting for log event ${event} in ${filePath}`);
+}
+
+function isProcessAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function runCli(
+ cwd: string,
+ args: string[],
+ options: { stdin?: string; env?: NodeJS.ProcessEnv } = {},
+): Promise {
+ return new Promise((resolveResult, reject) => {
+ const child = spawn(process.execPath, [distCliPath, ...args], {
+ cwd,
+ env: options.env,
+ stdio: ["pipe", "pipe", "pipe"],
+ });
+
+ let stdout = "";
+ let stderr = "";
+ child.stdout.on("data", (chunk) => {
+ stdout += chunk.toString();
+ });
+ child.stderr.on("data", (chunk) => {
+ stderr += chunk.toString();
+ });
+ child.on("error", reject);
+ child.on("close", (code, signal) => {
+ resolveResult({ code, signal, stdout, stderr });
+ });
+
+ if (options.stdin !== undefined) {
+ child.stdin.end(options.stdin);
+ } else {
+ child.stdin.end();
+ }
+ });
+}
+
+describe("gnhf e2e", () => {
+ const tempDirs: string[] = [];
+
+ afterEach(() => {
+ for (const dir of tempDirs.splice(0)) {
+ try {
+ rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
+ } catch {
+ // Windows: child processes may still hold file locks briefly after exit
+ }
+ }
+ });
+
+ it("runs one iteration from an argv prompt and cleans up the mock opencode server", async () => {
+ const cwd = createRepo();
+ tempDirs.push(cwd);
+ const logDir = mkdtempSync(join(tmpdir(), "gnhf-e2e-logs-"));
+ tempDirs.push(logDir);
+ const mockLogPath = join(logDir, "mock-opencode.jsonl");
+ const debugLogPath = join(logDir, "gnhf-debug.jsonl");
+
+ const result = await runCli(
+ cwd,
+ ["ship it", "--agent", "opencode", "--max-iterations", "1"],
+ {
+ env: {
+ ...process.env,
+ PATH: `${fixtureBinDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`,
+ GNHF_MOCK_OPENCODE_LOG_PATH: mockLogPath,
+ GNHF_DEBUG_LOG_PATH: debugLogPath,
+ },
+ },
+ );
+
+ expect(result.code).toBe(0);
+ expect(git(["rev-list", "--count", "HEAD"], cwd)).toBe("2");
+ expect(git(["log", "-1", "--format=%s"], cwd)).toContain("gnhf #1:");
+
+ const startEvent = await waitForLogEvent(mockLogPath, "server:start");
+ expect(startEvent.command).toBe("serve");
+ expect(isProcessAlive(Number(startEvent.pid))).toBe(false);
+
+ const debugEvents = readJsonLines(debugLogPath).map((entry) => entry.event);
+ expect(debugEvents).toContain("run:start");
+ expect(debugEvents).toContain("run:complete");
+ }, 30_000);
+
+ it("reads the objective from stdin", async () => {
+ const cwd = createRepo();
+ tempDirs.push(cwd);
+ const logDir = mkdtempSync(join(tmpdir(), "gnhf-e2e-logs-"));
+ tempDirs.push(logDir);
+ const mockLogPath = join(logDir, "mock-opencode.jsonl");
+
+ const result = await runCli(
+ cwd,
+ ["--agent", "opencode", "--max-iterations", "1"],
+ {
+ stdin: "ship it from stdin\n",
+ env: {
+ ...process.env,
+ PATH: `${fixtureBinDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`,
+ GNHF_MOCK_OPENCODE_LOG_PATH: mockLogPath,
+ },
+ },
+ );
+
+ expect(result.code).toBe(0);
+
+ const messageEvent = await waitForLogEvent(mockLogPath, "message:start");
+ expect(String(messageEvent.prompt)).toContain("ship it from stdin");
+ }, 30_000);
+
+ it("resumes an existing gnhf branch without requiring the prompt again", async () => {
+ const cwd = createRepo();
+ tempDirs.push(cwd);
+ const logDir = mkdtempSync(join(tmpdir(), "gnhf-e2e-logs-"));
+ tempDirs.push(logDir);
+ const mockLogPath = join(logDir, "mock-opencode.jsonl");
+
+ const env = {
+ ...process.env,
+ PATH: `${fixtureBinDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`,
+ GNHF_MOCK_OPENCODE_LOG_PATH: mockLogPath,
+ };
+
+ const firstRun = await runCli(
+ cwd,
+ ["first prompt", "--agent", "opencode", "--max-iterations", "1"],
+ { env },
+ );
+ expect(firstRun.code).toBe(0);
+
+ const secondRun = await runCli(
+ cwd,
+ ["--agent", "opencode", "--max-iterations", "2"],
+ { env },
+ );
+ expect(secondRun.code).toBe(0);
+ expect(git(["rev-list", "--count", "HEAD"], cwd)).toBe("3");
+ }, 30_000);
+
+ // Windows has no POSIX signals; child.kill("SIGINT") force-terminates the
+ // process tree without triggering the graceful shutdown path this test covers.
+ it.skipIf(process.platform === "win32")("shuts down the agent server when gnhf receives SIGINT", async () => {
+ const cwd = createRepo();
+ tempDirs.push(cwd);
+ const logDir = mkdtempSync(join(tmpdir(), "gnhf-e2e-logs-"));
+ tempDirs.push(logDir);
+ const mockLogPath = join(logDir, "mock-opencode.jsonl");
+ const debugLogPath = join(logDir, "gnhf-debug.jsonl");
+
+ const child = spawn(
+ process.execPath,
+ [distCliPath, "slow cleanup", "--agent", "opencode"],
+ {
+ cwd,
+ env: {
+ ...process.env,
+ PATH: `${fixtureBinDir}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}`,
+ GNHF_MOCK_OPENCODE_LOG_PATH: mockLogPath,
+ GNHF_DEBUG_LOG_PATH: debugLogPath,
+ },
+ stdio: ["pipe", "pipe", "pipe"],
+ },
+ );
+ child.stdin.end();
+
+ const exitPromise = new Promise((resolveResult, reject) => {
+ let stdout = "";
+ let stderr = "";
+ child.stdout.on("data", (chunk) => {
+ stdout += chunk.toString();
+ });
+ child.stderr.on("data", (chunk) => {
+ stderr += chunk.toString();
+ });
+ child.on("error", reject);
+ child.on("close", (code, signal) => {
+ resolveResult({ code, signal, stdout, stderr });
+ });
+ });
+
+ const startEvent = await waitForLogEvent(mockLogPath, "server:start");
+ await waitForLogEvent(mockLogPath, "message:start");
+ child.kill("SIGINT");
+
+ const result = await exitPromise;
+ expect(result.code).toBe(130);
+ expect(isProcessAlive(Number(startEvent.pid))).toBe(false);
+
+ const mockEvents = readJsonLines(mockLogPath).map((entry) => entry.event);
+ expect(mockEvents).toContain("session:abort");
+ expect(mockEvents).toContain("session:delete");
+
+ const debugEvents = readJsonLines(debugLogPath).map((entry) => entry.event);
+ expect(debugEvents).toContain("signal:SIGINT");
+ }, 30_000);
+});
diff --git a/test/fixtures/mock-opencode-server.mjs b/test/fixtures/mock-opencode-server.mjs
new file mode 100755
index 0000000..86c1b20
--- /dev/null
+++ b/test/fixtures/mock-opencode-server.mjs
@@ -0,0 +1,266 @@
+#!/usr/bin/env node
+
+import console from "node:console";
+import { Buffer } from "node:buffer";
+import { appendFileSync } from "node:fs";
+import { join } from "node:path";
+import { createServer } from "node:http";
+import process from "node:process";
+import { setTimeout } from "node:timers";
+
+function appendLog(event, details = {}) {
+ const logPath = process.env.GNHF_MOCK_OPENCODE_LOG_PATH;
+ if (!logPath) return;
+ appendFileSync(
+ logPath,
+ `${JSON.stringify({ timestamp: new Date().toISOString(), pid: process.pid, event, ...details })}\n`,
+ "utf-8",
+ );
+}
+
+function readJson(req) {
+ return new Promise((resolve, reject) => {
+ let body = "";
+ req.on("data", (chunk) => {
+ body += chunk.toString();
+ });
+ req.on("end", () => {
+ if (!body) {
+ resolve({});
+ return;
+ }
+
+ try {
+ resolve(JSON.parse(body));
+ } catch (error) {
+ reject(error);
+ }
+ });
+ req.on("error", reject);
+ });
+}
+
+function sendJson(res, body, statusCode = 200) {
+ const text = JSON.stringify(body);
+ res.writeHead(statusCode, {
+ "content-type": "application/json",
+ "content-length": Buffer.byteLength(text),
+ });
+ res.end(text);
+}
+
+const args = process.argv.slice(2);
+if (args[0] !== "serve") {
+ console.error("mock-opencode only supports 'serve'");
+ process.exit(1);
+}
+
+const host = args[args.indexOf("--hostname") + 1] ?? "127.0.0.1";
+const port = Number(args[args.indexOf("--port") + 1] ?? "0");
+
+let sessionCounter = 0;
+const sessions = new Map();
+const eventStreams = new Set();
+
+function broadcast(event) {
+ const line = `data: ${JSON.stringify(event)}\n\n`;
+ for (const stream of eventStreams) {
+ stream.write(line);
+ }
+}
+
+function buildStructuredResponse(summary) {
+ return {
+ success: true,
+ summary,
+ key_changes_made: ["README.md"],
+ key_learnings: ["mock opencode completed successfully"],
+ };
+}
+
+function emitCompletedEvents(sessionId, summary) {
+ const output = buildStructuredResponse(summary);
+ broadcast({
+ directory: "/repo",
+ payload: {
+ type: "message.part.updated",
+ properties: {
+ sessionID: sessionId,
+ part: {
+ id: "part-commentary",
+ type: "text",
+ text: "Mock agent is working.",
+ metadata: { openai: { phase: "commentary" } },
+ },
+ },
+ },
+ });
+ broadcast({
+ directory: "/repo",
+ payload: {
+ type: "message.part.updated",
+ properties: {
+ sessionID: sessionId,
+ part: {
+ id: "part-final",
+ type: "text",
+ text: JSON.stringify(output),
+ metadata: { openai: { phase: "final_answer" } },
+ },
+ },
+ },
+ });
+ broadcast({
+ directory: "/repo",
+ payload: {
+ type: "message.part.updated",
+ properties: {
+ sessionID: sessionId,
+ part: {
+ id: "finish-1",
+ messageID: "msg-1",
+ type: "step-finish",
+ tokens: {
+ input: 10,
+ output: 5,
+ cache: { read: 1, write: 0 },
+ },
+ },
+ },
+ },
+ });
+ broadcast({
+ directory: "/repo",
+ payload: {
+ type: "session.idle",
+ properties: { sessionID: sessionId },
+ },
+ });
+ return output;
+}
+
+function applyWorkspaceChange(sessionId) {
+ const session = sessions.get(sessionId);
+ if (!session?.directory) return;
+
+ const marker = `- mock change ${Date.now()}\n`;
+ appendFileSync(join(session.directory, "README.md"), marker, "utf-8");
+ appendLog("workspace:changed", { sessionId, marker: marker.trim() });
+}
+
+const server = createServer(async (req, res) => {
+ appendLog("http:request", { method: req.method, url: req.url });
+
+ if (req.method === "GET" && req.url === "/global/health") {
+ sendJson(res, { healthy: true, version: "mock" });
+ return;
+ }
+
+ if (req.method === "GET" && req.url === "/global/event") {
+ res.writeHead(200, {
+ "content-type": "text/event-stream",
+ "cache-control": "no-cache",
+ connection: "keep-alive",
+ });
+ res.flushHeaders();
+ eventStreams.add(res);
+ req.on("close", () => {
+ eventStreams.delete(res);
+ });
+ return;
+ }
+
+ if (req.method === "POST" && req.url === "/session") {
+ const body = await readJson(req);
+ const sessionId = `session-${++sessionCounter}`;
+ sessions.set(sessionId, { directory: body.directory, aborted: false });
+ appendLog("session:create", { sessionId, directory: body.directory });
+ sendJson(res, { id: sessionId });
+ return;
+ }
+
+ const match = req.url?.match(/^\/session\/([^/]+)(?:\/(message|abort))?$/);
+ if (match?.[2] === "message" && req.method === "POST") {
+ const sessionId = match[1];
+ const body = await readJson(req);
+ const prompt = body.parts?.[0]?.text ?? "";
+ appendLog("message:start", { sessionId, prompt });
+
+ if (String(prompt).includes("slow cleanup")) {
+ req.on("close", () => {
+ appendLog("message:closed", { sessionId });
+ });
+ return;
+ }
+
+ applyWorkspaceChange(sessionId);
+ const output = emitCompletedEvents(sessionId, "mocked objective complete");
+ sendJson(res, {
+ info: {
+ id: "msg-1",
+ role: "assistant",
+ structured: output,
+ tokens: {
+ input: 10,
+ output: 5,
+ cache: { read: 1, write: 0 },
+ },
+ },
+ parts: [
+ {
+ id: "part-final",
+ type: "text",
+ text: JSON.stringify(output),
+ metadata: { openai: { phase: "final_answer" } },
+ },
+ ],
+ });
+ return;
+ }
+
+ if (match?.[2] === "abort" && req.method === "POST") {
+ const sessionId = match[1];
+ const session = sessions.get(sessionId);
+ if (session) session.aborted = true;
+ appendLog("session:abort", { sessionId });
+ sendJson(res, { ok: true });
+ return;
+ }
+
+ if (match && !match[2] && req.method === "DELETE") {
+ const sessionId = match[1];
+ sessions.delete(sessionId);
+ appendLog("session:delete", { sessionId });
+ sendJson(res, { ok: true });
+ return;
+ }
+
+ sendJson(res, { error: "not found" }, 404);
+});
+
+let shuttingDown = false;
+
+function shutdown(reason) {
+ if (shuttingDown) return;
+ shuttingDown = true;
+ appendLog("server:shutdown", { reason });
+ for (const stream of eventStreams) {
+ stream.end();
+ }
+ server.close(() => {
+ process.exit(0);
+ });
+ setTimeout(() => process.exit(0), 1_000).unref();
+}
+
+process.on("SIGINT", () => shutdown("SIGINT"));
+process.on("SIGTERM", () => shutdown("SIGTERM"));
+process.on("SIGBREAK", () => shutdown("SIGBREAK"));
+
+server.listen(port, host, () => {
+ appendLog("server:start", {
+ command: "serve",
+ host,
+ port,
+ });
+});
diff --git a/test/fixtures/opencode b/test/fixtures/opencode
new file mode 100755
index 0000000..28993ed
--- /dev/null
+++ b/test/fixtures/opencode
@@ -0,0 +1,4 @@
+#!/usr/bin/env sh
+
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+exec node "$SCRIPT_DIR/mock-opencode-server.mjs" "$@"
diff --git a/test/fixtures/opencode.cmd b/test/fixtures/opencode.cmd
new file mode 100644
index 0000000..7a97fe2
--- /dev/null
+++ b/test/fixtures/opencode.cmd
@@ -0,0 +1,2 @@
+@echo off
+node "%~dp0\mock-opencode-server.mjs" %*