gnhf 0.1.7 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -16
- package/dist/cli.mjs +658 -77
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -18,10 +18,10 @@
|
|
|
18
18
|
src="https://img.shields.io/github/actions/workflow/status/kunchenguid/gnhf/release-please.yml?style=flat-square&label=release"
|
|
19
19
|
/></a>
|
|
20
20
|
<a
|
|
21
|
-
href="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-blue?style=flat-square"
|
|
21
|
+
href="https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-blue?style=flat-square"
|
|
22
22
|
><img
|
|
23
23
|
alt="Platform"
|
|
24
|
-
src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux-blue?style=flat-square"
|
|
24
|
+
src="https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-blue?style=flat-square"
|
|
25
25
|
/></a>
|
|
26
26
|
<a href="https://x.com/kunchenguid"
|
|
27
27
|
><img
|
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
<img src="docs/splash.png" alt="gnhf — Good Night, Have Fun" width="800">
|
|
40
40
|
</p>
|
|
41
41
|
|
|
42
|
+
Never wake up empty-handed.
|
|
43
|
+
|
|
42
44
|
gnhf is a [ralph](https://ghuntley.com/ralph/), [autoresearch](https://github.com/karpathy/autoresearch)-style orchestrator that keeps your agents running while you sleep — each iteration makes one small, committed, documented change towards an objective.
|
|
43
45
|
You wake up to a branch full of clean work and a log of everything that happened.
|
|
44
46
|
|
|
@@ -61,6 +63,7 @@ $ gnhf "reduce complexity of the codebase without changing functionality" \
|
|
|
61
63
|
```
|
|
62
64
|
|
|
63
65
|
Run `gnhf` from inside a Git repository with a clean working tree. If you are starting from a plain directory, run `git init` first.
|
|
66
|
+
`gnhf` supports macOS, Linux, and Windows.
|
|
64
67
|
|
|
65
68
|
## Install
|
|
66
69
|
|
|
@@ -80,10 +83,6 @@ npm run build
|
|
|
80
83
|
npm link
|
|
81
84
|
```
|
|
82
85
|
|
|
83
|
-
If you want to run `gnhf --agent rovodev`, install Atlassian's `acli` and authenticate it with Rovo Dev first.
|
|
84
|
-
|
|
85
|
-
If you want to run `gnhf --agent opencode`, install `opencode` and authenticate at least one provider first.
|
|
86
|
-
|
|
87
86
|
## How It Works
|
|
88
87
|
|
|
89
88
|
```
|
|
@@ -145,12 +144,13 @@ If you want to run `gnhf --agent opencode`, install `opencode` and authenticate
|
|
|
145
144
|
|
|
146
145
|
### Flags
|
|
147
146
|
|
|
148
|
-
| Flag
|
|
149
|
-
|
|
|
150
|
-
| `--agent <agent>`
|
|
151
|
-
| `--max-iterations <n>`
|
|
152
|
-
| `--max-tokens <n>`
|
|
153
|
-
| `--
|
|
147
|
+
| Flag | Description | Default |
|
|
148
|
+
| ------------------------ | ------------------------------------------------------------------ | ---------------------- |
|
|
149
|
+
| `--agent <agent>` | Agent to use (`claude`, `codex`, `rovodev`, or `opencode`) | config file (`claude`) |
|
|
150
|
+
| `--max-iterations <n>` | Abort after `n` total iterations | unlimited |
|
|
151
|
+
| `--max-tokens <n>` | Abort after `n` total input+output tokens | unlimited |
|
|
152
|
+
| `--prevent-sleep <mode>` | Prevent system sleep during the run (`on`/`off` or `true`/`false`) | config file (`on`) |
|
|
153
|
+
| `--version` | Show version | |
|
|
154
154
|
|
|
155
155
|
## Configuration
|
|
156
156
|
|
|
@@ -162,22 +162,43 @@ agent: claude
|
|
|
162
162
|
|
|
163
163
|
# Abort after this many consecutive failures
|
|
164
164
|
maxConsecutiveFailures: 3
|
|
165
|
+
|
|
166
|
+
# Prevent the machine from sleeping during a run
|
|
167
|
+
preventSleep: true
|
|
165
168
|
```
|
|
166
169
|
|
|
167
170
|
If the file does not exist yet, `gnhf` creates it on first run using the resolved defaults.
|
|
168
171
|
|
|
169
|
-
CLI flags override config file values.
|
|
172
|
+
CLI flags override config file values. `--prevent-sleep` accepts `on`/`off` as well as `true`/`false`; the config file always uses a boolean.
|
|
173
|
+
The iteration and token caps are runtime-only flags and are not persisted in `config.yml`.
|
|
174
|
+
When sleep prevention is enabled, `gnhf` uses the native mechanism for your OS: `caffeinate` on macOS, `systemd-inhibit` on Linux, and a small PowerShell helper backed by `SetThreadExecutionState` on Windows.
|
|
175
|
+
|
|
176
|
+
## Debug Logs
|
|
177
|
+
|
|
178
|
+
Set `GNHF_DEBUG_LOG_PATH` to capture lifecycle events as JSONL while debugging a run:
|
|
179
|
+
|
|
180
|
+
```sh
|
|
181
|
+
GNHF_DEBUG_LOG_PATH=/tmp/gnhf-debug.jsonl gnhf "ship it"
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Agents
|
|
170
185
|
|
|
171
|
-
|
|
186
|
+
`gnhf` supports four agents:
|
|
172
187
|
|
|
173
|
-
|
|
188
|
+
| Agent | Flag | Requirements | Notes |
|
|
189
|
+
| ----------- | ------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
190
|
+
| Claude Code | `--agent claude` | Install Anthropic's `claude` CLI and sign in first. | `gnhf` invokes `claude` directly in non-interactive mode. |
|
|
191
|
+
| Codex | `--agent codex` | Install OpenAI's `codex` CLI and sign in first. | `gnhf` invokes `codex exec` directly in non-interactive mode. |
|
|
192
|
+
| Rovo Dev | `--agent rovodev` | Install Atlassian's `acli` and authenticate it with Rovo Dev first. | `gnhf` starts a local `acli rovodev serve --disable-session-token <port>` process automatically in the repo workspace. |
|
|
193
|
+
| OpenCode | `--agent opencode` | Install `opencode` and configure at least one usable model provider first. | `gnhf` starts a local `opencode serve --hostname 127.0.0.1 --port <port> --print-logs` process automatically, creates a per-run session, and applies a blanket allow rule so tool calls do not block on prompts. |
|
|
174
194
|
|
|
175
195
|
## Development
|
|
176
196
|
|
|
177
197
|
```sh
|
|
178
198
|
npm run build # Build with tsdown
|
|
179
199
|
npm run dev # Watch mode
|
|
180
|
-
npm test #
|
|
200
|
+
npm test # Build, then run unit tests (vitest)
|
|
201
|
+
npm run test:e2e # Build, then run end-to-end tests against the mock opencode executable
|
|
181
202
|
npm run lint # ESLint
|
|
182
203
|
npm run format # Prettier
|
|
183
204
|
```
|
package/dist/cli.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { appendFileSync, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { appendFileSync, createWriteStream, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, rmdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
5
|
import process$1 from "node:process";
|
|
4
6
|
import { createInterface } from "node:readline";
|
|
5
7
|
import { Command, InvalidArgumentError } from "commander";
|
|
6
|
-
import { dirname, isAbsolute, join } from "node:path";
|
|
7
|
-
import { homedir } from "node:os";
|
|
8
8
|
import yaml from "js-yaml";
|
|
9
9
|
import { execFileSync, execSync, spawn } from "node:child_process";
|
|
10
10
|
import { createServer } from "node:net";
|
|
@@ -13,8 +13,28 @@ import { createHash } from "node:crypto";
|
|
|
13
13
|
//#region src/core/config.ts
|
|
14
14
|
const DEFAULT_CONFIG = {
|
|
15
15
|
agent: "claude",
|
|
16
|
-
maxConsecutiveFailures: 3
|
|
16
|
+
maxConsecutiveFailures: 3,
|
|
17
|
+
preventSleep: true
|
|
17
18
|
};
|
|
19
|
+
var InvalidConfigError = class extends Error {};
|
|
20
|
+
function normalizePreventSleep(value) {
|
|
21
|
+
if (typeof value === "boolean") return value;
|
|
22
|
+
if (typeof value !== "string") return void 0;
|
|
23
|
+
if (value === "true") return true;
|
|
24
|
+
if (value === "false") return false;
|
|
25
|
+
if (value === "on") return true;
|
|
26
|
+
if (value === "off") return false;
|
|
27
|
+
}
|
|
28
|
+
function normalizeConfig(config) {
|
|
29
|
+
const normalized = { ...config };
|
|
30
|
+
const hasPreventSleep = Object.prototype.hasOwnProperty.call(config, "preventSleep");
|
|
31
|
+
const preventSleep = normalizePreventSleep(config.preventSleep);
|
|
32
|
+
if (preventSleep === void 0) {
|
|
33
|
+
if (hasPreventSleep && config.preventSleep !== void 0) throw new InvalidConfigError(`Invalid config value for preventSleep: ${String(config.preventSleep)}`);
|
|
34
|
+
delete normalized.preventSleep;
|
|
35
|
+
} else normalized.preventSleep = preventSleep;
|
|
36
|
+
return normalized;
|
|
37
|
+
}
|
|
18
38
|
function isMissingConfigError(error) {
|
|
19
39
|
if (!(error instanceof Error)) return false;
|
|
20
40
|
return "code" in error ? error.code === "ENOENT" : error.message.includes("ENOENT");
|
|
@@ -25,6 +45,9 @@ agent: ${config.agent}
|
|
|
25
45
|
|
|
26
46
|
# Abort after this many consecutive failures
|
|
27
47
|
maxConsecutiveFailures: ${config.maxConsecutiveFailures}
|
|
48
|
+
|
|
49
|
+
# Prevent the machine from sleeping during a run
|
|
50
|
+
preventSleep: ${config.preventSleep}
|
|
28
51
|
`;
|
|
29
52
|
}
|
|
30
53
|
function loadConfig(overrides) {
|
|
@@ -34,14 +57,15 @@ function loadConfig(overrides) {
|
|
|
34
57
|
let shouldBootstrapConfig = false;
|
|
35
58
|
try {
|
|
36
59
|
const raw = readFileSync(configPath, "utf-8");
|
|
37
|
-
fileConfig = yaml.load(raw) ?? {};
|
|
60
|
+
fileConfig = normalizeConfig(yaml.load(raw) ?? {});
|
|
38
61
|
} catch (error) {
|
|
62
|
+
if (error instanceof InvalidConfigError) throw error;
|
|
39
63
|
if (isMissingConfigError(error)) shouldBootstrapConfig = true;
|
|
40
64
|
}
|
|
41
65
|
const resolvedConfig = {
|
|
42
66
|
...DEFAULT_CONFIG,
|
|
43
67
|
...fileConfig,
|
|
44
|
-
...overrides
|
|
68
|
+
...normalizeConfig(overrides ?? {})
|
|
45
69
|
};
|
|
46
70
|
if (shouldBootstrapConfig) try {
|
|
47
71
|
mkdirSync(configDir, { recursive: true });
|
|
@@ -50,6 +74,20 @@ function loadConfig(overrides) {
|
|
|
50
74
|
return resolvedConfig;
|
|
51
75
|
}
|
|
52
76
|
//#endregion
|
|
77
|
+
//#region src/core/debug-log.ts
|
|
78
|
+
function appendDebugLog(event, details = {}) {
|
|
79
|
+
const logPath = process.env.GNHF_DEBUG_LOG_PATH;
|
|
80
|
+
if (!logPath) return;
|
|
81
|
+
try {
|
|
82
|
+
appendFileSync(logPath, `${JSON.stringify({
|
|
83
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
84
|
+
pid: process.pid,
|
|
85
|
+
event,
|
|
86
|
+
...details
|
|
87
|
+
})}\n`, "utf-8");
|
|
88
|
+
} catch {}
|
|
89
|
+
}
|
|
90
|
+
//#endregion
|
|
53
91
|
//#region src/core/git.ts
|
|
54
92
|
const NOT_GIT_REPOSITORY_MESSAGE = "This command must be run inside a Git repository. Change into a repo or run \"git init\" first.";
|
|
55
93
|
function translateGitError(error) {
|
|
@@ -135,6 +173,7 @@ function resetHard(cwd) {
|
|
|
135
173
|
//#region src/core/agents/types.ts
|
|
136
174
|
const AGENT_OUTPUT_SCHEMA = {
|
|
137
175
|
type: "object",
|
|
176
|
+
additionalProperties: false,
|
|
138
177
|
properties: {
|
|
139
178
|
success: { type: "boolean" },
|
|
140
179
|
summary: { type: "string" },
|
|
@@ -156,6 +195,9 @@ const AGENT_OUTPUT_SCHEMA = {
|
|
|
156
195
|
};
|
|
157
196
|
//#endregion
|
|
158
197
|
//#region src/core/run.ts
|
|
198
|
+
function writeSchemaFile(schemaPath) {
|
|
199
|
+
writeFileSync(schemaPath, JSON.stringify(AGENT_OUTPUT_SCHEMA, null, 2), "utf-8");
|
|
200
|
+
}
|
|
159
201
|
function ensureRunMetadataIgnored(cwd) {
|
|
160
202
|
const excludePath = execFileSync("git", [
|
|
161
203
|
"rev-parse",
|
|
@@ -183,7 +225,7 @@ function setupRun(runId, prompt, baseCommit, cwd) {
|
|
|
183
225
|
const notesPath = join(runDir, "notes.md");
|
|
184
226
|
writeFileSync(notesPath, `# gnhf run: ${runId}\n\nObjective: ${prompt}\n\n## Iteration Log\n`, "utf-8");
|
|
185
227
|
const schemaPath = join(runDir, "output-schema.json");
|
|
186
|
-
|
|
228
|
+
writeSchemaFile(schemaPath);
|
|
187
229
|
const baseCommitPath = join(runDir, "base-commit");
|
|
188
230
|
const hasStoredBaseCommit = existsSync(baseCommitPath);
|
|
189
231
|
const resolvedBaseCommit = hasStoredBaseCommit ? readFileSync(baseCommitPath, "utf-8").trim() : baseCommit;
|
|
@@ -204,6 +246,7 @@ function resumeRun(runId, cwd) {
|
|
|
204
246
|
const promptPath = join(runDir, "prompt.md");
|
|
205
247
|
const notesPath = join(runDir, "notes.md");
|
|
206
248
|
const schemaPath = join(runDir, "output-schema.json");
|
|
249
|
+
writeSchemaFile(schemaPath);
|
|
207
250
|
const baseCommitPath = join(runDir, "base-commit");
|
|
208
251
|
return {
|
|
209
252
|
runId,
|
|
@@ -245,6 +288,417 @@ function appendNotes(notesPath, iteration, summary, changes, learnings) {
|
|
|
245
288
|
].join("\n"), "utf-8");
|
|
246
289
|
}
|
|
247
290
|
//#endregion
|
|
291
|
+
//#region src/core/stdin.ts
|
|
292
|
+
async function readStdinText(input) {
|
|
293
|
+
const chunks = [];
|
|
294
|
+
for await (const chunk of input) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
295
|
+
return Buffer.concat(chunks).toString("utf-8").trim();
|
|
296
|
+
}
|
|
297
|
+
//#endregion
|
|
298
|
+
//#region src/core/agents/managed-process.ts
|
|
299
|
+
const POST_SIGKILL_GRACE_MS = 100;
|
|
300
|
+
function signalChildProcess(child, options) {
|
|
301
|
+
const killProcess = options.killProcess ?? process.kill.bind(process);
|
|
302
|
+
if (options.detached && child.pid) try {
|
|
303
|
+
killProcess(-child.pid, options.signal);
|
|
304
|
+
return;
|
|
305
|
+
} catch {}
|
|
306
|
+
child.kill(options.signal);
|
|
307
|
+
}
|
|
308
|
+
async function shutdownChildProcess(child, options) {
|
|
309
|
+
if (child.exitCode != null || child.signalCode != null) return;
|
|
310
|
+
const timeoutMs = options.timeoutMs ?? 3e3;
|
|
311
|
+
await new Promise((resolve) => {
|
|
312
|
+
let forceKillTimer = null;
|
|
313
|
+
let hardDeadlineTimer = null;
|
|
314
|
+
let settled = false;
|
|
315
|
+
const settle = () => {
|
|
316
|
+
if (settled) return;
|
|
317
|
+
settled = true;
|
|
318
|
+
if (forceKillTimer) {
|
|
319
|
+
clearTimeout(forceKillTimer);
|
|
320
|
+
forceKillTimer = null;
|
|
321
|
+
}
|
|
322
|
+
if (hardDeadlineTimer) {
|
|
323
|
+
clearTimeout(hardDeadlineTimer);
|
|
324
|
+
hardDeadlineTimer = null;
|
|
325
|
+
}
|
|
326
|
+
child.off("close", handleClose);
|
|
327
|
+
resolve();
|
|
328
|
+
};
|
|
329
|
+
const handleClose = () => {
|
|
330
|
+
settle();
|
|
331
|
+
};
|
|
332
|
+
child.on("close", handleClose);
|
|
333
|
+
try {
|
|
334
|
+
signalChildProcess(child, {
|
|
335
|
+
...options,
|
|
336
|
+
signal: "SIGTERM"
|
|
337
|
+
});
|
|
338
|
+
} catch {}
|
|
339
|
+
forceKillTimer = setTimeout(() => {
|
|
340
|
+
try {
|
|
341
|
+
signalChildProcess(child, {
|
|
342
|
+
...options,
|
|
343
|
+
signal: "SIGKILL"
|
|
344
|
+
});
|
|
345
|
+
} catch {}
|
|
346
|
+
hardDeadlineTimer = setTimeout(() => {
|
|
347
|
+
settle();
|
|
348
|
+
}, POST_SIGKILL_GRACE_MS);
|
|
349
|
+
hardDeadlineTimer.unref?.();
|
|
350
|
+
}, timeoutMs);
|
|
351
|
+
forceKillTimer.unref?.();
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
//#endregion
|
|
355
|
+
//#region src/core/sleep.ts
|
|
356
|
+
const SYSTEMD_INHIBIT_READY_TIMEOUT_MS = 5e3;
|
|
357
|
+
const SYSTEMD_INHIBIT_READY_POLL_MS = 25;
|
|
358
|
+
const GNHF_SLEEP_REEXEC_READY_PATH = "GNHF_SLEEP_REEXEC_READY_PATH";
|
|
359
|
+
const GNHF_SLEEP_REEXEC_READY_DIR_PREFIX = "gnhf-sleep-";
|
|
360
|
+
const GNHF_SLEEP_REEXEC_READY_FILENAME = "reexec-ready";
|
|
361
|
+
const HELPER_STARTUP_GRACE_MS = 100;
|
|
362
|
+
function getSignalExitCode$1(signal) {
|
|
363
|
+
if (signal === "SIGINT") return 130;
|
|
364
|
+
if (signal === "SIGTERM") return 143;
|
|
365
|
+
return 1;
|
|
366
|
+
}
|
|
367
|
+
async function waitForSpawn(child) {
|
|
368
|
+
return await new Promise((resolve) => {
|
|
369
|
+
child.once("spawn", () => resolve(true));
|
|
370
|
+
child.once("error", () => resolve(false));
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
async function waitForHelperStability(child, timeoutMs) {
|
|
374
|
+
return await new Promise((resolve) => {
|
|
375
|
+
let settled = false;
|
|
376
|
+
let timer = null;
|
|
377
|
+
const settle = (value) => {
|
|
378
|
+
if (settled) return;
|
|
379
|
+
settled = true;
|
|
380
|
+
if (timer) {
|
|
381
|
+
clearTimeout(timer);
|
|
382
|
+
timer = null;
|
|
383
|
+
}
|
|
384
|
+
resolve(value);
|
|
385
|
+
};
|
|
386
|
+
child.once("exit", () => {
|
|
387
|
+
settle(false);
|
|
388
|
+
});
|
|
389
|
+
child.once("error", () => {
|
|
390
|
+
settle(false);
|
|
391
|
+
});
|
|
392
|
+
if (child.exitCode != null || child.signalCode != null) {
|
|
393
|
+
settle(false);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
timer = setTimeout(() => {
|
|
397
|
+
settle(true);
|
|
398
|
+
}, timeoutMs);
|
|
399
|
+
timer.unref?.();
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
function isTrustedLinuxReexecReadyPath(readyPath) {
|
|
403
|
+
const resolvedReadyPath = resolve(readyPath);
|
|
404
|
+
const readyDir = dirname(resolvedReadyPath);
|
|
405
|
+
return basename(resolvedReadyPath) === GNHF_SLEEP_REEXEC_READY_FILENAME && dirname(readyDir) === resolve(tmpdir()) && basename(readyDir).startsWith(GNHF_SLEEP_REEXEC_READY_DIR_PREFIX);
|
|
406
|
+
}
|
|
407
|
+
function signalLinuxReexecReady(env) {
|
|
408
|
+
const readyPath = env[GNHF_SLEEP_REEXEC_READY_PATH];
|
|
409
|
+
if (!readyPath) return;
|
|
410
|
+
if (!isTrustedLinuxReexecReadyPath(readyPath)) {
|
|
411
|
+
appendDebugLog("sleep:ready-signal-failed", {
|
|
412
|
+
command: "systemd-inhibit",
|
|
413
|
+
error: "untrusted ready path"
|
|
414
|
+
});
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
try {
|
|
418
|
+
writeFileSync(readyPath, "ready\n", {
|
|
419
|
+
encoding: "utf-8",
|
|
420
|
+
flag: "wx"
|
|
421
|
+
});
|
|
422
|
+
} catch (error) {
|
|
423
|
+
appendDebugLog("sleep:ready-signal-failed", {
|
|
424
|
+
command: "systemd-inhibit",
|
|
425
|
+
error: error instanceof Error ? error.message : String(error)
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
async function waitForLinuxReexecReady(readyPath, exitStatePromise, timeoutMs) {
|
|
430
|
+
if (existsSync(readyPath)) return { type: "ready" };
|
|
431
|
+
return await new Promise((resolve) => {
|
|
432
|
+
let settled = false;
|
|
433
|
+
const settle = (result) => {
|
|
434
|
+
if (settled) return;
|
|
435
|
+
settled = true;
|
|
436
|
+
clearInterval(poller);
|
|
437
|
+
clearTimeout(timeout);
|
|
438
|
+
resolve(result);
|
|
439
|
+
};
|
|
440
|
+
const poller = setInterval(() => {
|
|
441
|
+
if (existsSync(readyPath)) settle({ type: "ready" });
|
|
442
|
+
}, SYSTEMD_INHIBIT_READY_POLL_MS);
|
|
443
|
+
poller.unref?.();
|
|
444
|
+
const timeout = setTimeout(() => {
|
|
445
|
+
settle({ type: "timeout" });
|
|
446
|
+
}, timeoutMs);
|
|
447
|
+
timeout.unref?.();
|
|
448
|
+
exitStatePromise.then(({ exitCode, signal }) => {
|
|
449
|
+
settle({
|
|
450
|
+
type: "exit",
|
|
451
|
+
exitCode,
|
|
452
|
+
signal
|
|
453
|
+
});
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
function forwardTerminationSignalsToChild(child, detached, killProcess, processOn, processOff) {
|
|
458
|
+
const listeners = [];
|
|
459
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
460
|
+
const listener = () => {
|
|
461
|
+
try {
|
|
462
|
+
signalChildProcess(child, {
|
|
463
|
+
detached,
|
|
464
|
+
killProcess,
|
|
465
|
+
signal
|
|
466
|
+
});
|
|
467
|
+
} catch {}
|
|
468
|
+
};
|
|
469
|
+
processOn(signal, listener);
|
|
470
|
+
listeners.push([signal, listener]);
|
|
471
|
+
}
|
|
472
|
+
return () => {
|
|
473
|
+
for (const [signal, listener] of listeners) processOff(signal, listener);
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
function buildPowerShellCommand(parentPid) {
|
|
477
|
+
return [
|
|
478
|
+
"Add-Type @'",
|
|
479
|
+
"using System;",
|
|
480
|
+
"using System.Runtime.InteropServices;",
|
|
481
|
+
"public static class SleepBlock {",
|
|
482
|
+
" [DllImport(\"kernel32.dll\")]",
|
|
483
|
+
" public static extern uint SetThreadExecutionState(uint flags);",
|
|
484
|
+
"}",
|
|
485
|
+
"'@;",
|
|
486
|
+
"$ES_CONTINUOUS = 0x80000000;",
|
|
487
|
+
"$ES_SYSTEM_REQUIRED = 0x00000001;",
|
|
488
|
+
"[SleepBlock]::SetThreadExecutionState($ES_CONTINUOUS -bor $ES_SYSTEM_REQUIRED) | Out-Null;",
|
|
489
|
+
`try { Wait-Process -Id ${parentPid} } catch { } finally { [SleepBlock]::SetThreadExecutionState($ES_CONTINUOUS) | Out-Null }`
|
|
490
|
+
].join("\n");
|
|
491
|
+
}
|
|
492
|
+
async function startHelperProcess(command, args, spawnFn, env) {
|
|
493
|
+
const child = spawnFn(command, args, {
|
|
494
|
+
env,
|
|
495
|
+
stdio: "ignore"
|
|
496
|
+
});
|
|
497
|
+
if (!await waitForSpawn(child)) {
|
|
498
|
+
appendDebugLog("sleep:unavailable", { command });
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
if (!await waitForHelperStability(child, HELPER_STARTUP_GRACE_MS)) {
|
|
502
|
+
appendDebugLog("sleep:unavailable", {
|
|
503
|
+
command,
|
|
504
|
+
reason: "early-exit"
|
|
505
|
+
});
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
return child;
|
|
509
|
+
}
|
|
510
|
+
async function startSleepPrevention(argv, deps = {}) {
|
|
511
|
+
const env = deps.env ?? process.env;
|
|
512
|
+
const killProcess = deps.killProcess ?? process.kill.bind(process);
|
|
513
|
+
const pid = deps.pid ?? process.pid;
|
|
514
|
+
const platform = deps.platform ?? process.platform;
|
|
515
|
+
const processExecArgv = deps.processExecArgv ?? process.execArgv;
|
|
516
|
+
const processArgv1 = deps.processArgv1 ?? process.argv[1];
|
|
517
|
+
const processExecPath = deps.processExecPath ?? process.execPath;
|
|
518
|
+
const processOn = deps.processOn ?? process.on.bind(process);
|
|
519
|
+
const processOff = deps.processOff ?? process.off.bind(process);
|
|
520
|
+
const reexecEnv = deps.reexecEnv ?? {};
|
|
521
|
+
const spawnFn = deps.spawn ?? spawn;
|
|
522
|
+
if (platform === "linux") {
|
|
523
|
+
if (env.GNHF_SLEEP_INHIBITED === "1") {
|
|
524
|
+
signalLinuxReexecReady(env);
|
|
525
|
+
return {
|
|
526
|
+
type: "skipped",
|
|
527
|
+
reason: "already-inhibited"
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
const readyDir = mkdtempSync(join(tmpdir(), GNHF_SLEEP_REEXEC_READY_DIR_PREFIX));
|
|
531
|
+
const readyPath = join(readyDir, GNHF_SLEEP_REEXEC_READY_FILENAME);
|
|
532
|
+
const child = spawnFn("systemd-inhibit", [
|
|
533
|
+
"--what=idle:sleep",
|
|
534
|
+
"--mode=block",
|
|
535
|
+
"--who=gnhf",
|
|
536
|
+
"--why=Prevent sleep while gnhf is running",
|
|
537
|
+
processExecPath,
|
|
538
|
+
...processExecArgv,
|
|
539
|
+
processArgv1,
|
|
540
|
+
...argv
|
|
541
|
+
], {
|
|
542
|
+
detached: true,
|
|
543
|
+
env: {
|
|
544
|
+
...env,
|
|
545
|
+
...reexecEnv,
|
|
546
|
+
GNHF_SLEEP_INHIBITED: "1",
|
|
547
|
+
[GNHF_SLEEP_REEXEC_READY_PATH]: readyPath
|
|
548
|
+
},
|
|
549
|
+
stdio: "inherit"
|
|
550
|
+
});
|
|
551
|
+
const exitStatePromise = new Promise((resolve) => {
|
|
552
|
+
child.once("exit", (code, signal) => {
|
|
553
|
+
resolve({
|
|
554
|
+
exitCode: signal ? getSignalExitCode$1(signal) : code ?? 1,
|
|
555
|
+
signal
|
|
556
|
+
});
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
const stopForwardingSignals = forwardTerminationSignalsToChild(child, true, killProcess, processOn, processOff);
|
|
560
|
+
if (!await waitForSpawn(child)) {
|
|
561
|
+
stopForwardingSignals();
|
|
562
|
+
rmSync(readyDir, {
|
|
563
|
+
recursive: true,
|
|
564
|
+
force: true
|
|
565
|
+
});
|
|
566
|
+
appendDebugLog("sleep:unavailable", { command: "systemd-inhibit" });
|
|
567
|
+
return {
|
|
568
|
+
type: "skipped",
|
|
569
|
+
reason: "unavailable"
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
try {
|
|
573
|
+
const readyState = await waitForLinuxReexecReady(readyPath, exitStatePromise, SYSTEMD_INHIBIT_READY_TIMEOUT_MS);
|
|
574
|
+
try {
|
|
575
|
+
if (readyState.type === "ready") {
|
|
576
|
+
appendDebugLog("sleep:reexec", { command: "systemd-inhibit" });
|
|
577
|
+
const { exitCode } = await exitStatePromise;
|
|
578
|
+
return {
|
|
579
|
+
type: "reexeced",
|
|
580
|
+
exitCode
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
if (readyState.type === "exit") {
|
|
584
|
+
if (readyState.signal === "SIGINT" || readyState.signal === "SIGTERM") {
|
|
585
|
+
appendDebugLog("sleep:reexec", {
|
|
586
|
+
command: "systemd-inhibit",
|
|
587
|
+
signal: readyState.signal
|
|
588
|
+
});
|
|
589
|
+
return {
|
|
590
|
+
type: "reexeced",
|
|
591
|
+
exitCode: readyState.exitCode
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
if (readyState.exitCode !== 0) {
|
|
595
|
+
if (existsSync(readyPath)) {
|
|
596
|
+
appendDebugLog("sleep:reexec", {
|
|
597
|
+
command: "systemd-inhibit",
|
|
598
|
+
exitCode: readyState.exitCode,
|
|
599
|
+
readySignal: "late"
|
|
600
|
+
});
|
|
601
|
+
return {
|
|
602
|
+
type: "reexeced",
|
|
603
|
+
exitCode: readyState.exitCode
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
appendDebugLog("sleep:unavailable", {
|
|
607
|
+
command: "systemd-inhibit",
|
|
608
|
+
exitCode: readyState.exitCode
|
|
609
|
+
});
|
|
610
|
+
return {
|
|
611
|
+
type: "skipped",
|
|
612
|
+
reason: "unavailable"
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
appendDebugLog("sleep:reexec", {
|
|
616
|
+
command: "systemd-inhibit",
|
|
617
|
+
readySignal: false
|
|
618
|
+
});
|
|
619
|
+
return {
|
|
620
|
+
type: "reexeced",
|
|
621
|
+
exitCode: readyState.exitCode
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
appendDebugLog("sleep:unavailable", {
|
|
625
|
+
command: "systemd-inhibit",
|
|
626
|
+
reason: "timeout",
|
|
627
|
+
timeoutMs: SYSTEMD_INHIBIT_READY_TIMEOUT_MS
|
|
628
|
+
});
|
|
629
|
+
await shutdownChildProcess(child, {
|
|
630
|
+
detached: true,
|
|
631
|
+
killProcess,
|
|
632
|
+
timeoutMs: 1e3
|
|
633
|
+
});
|
|
634
|
+
return {
|
|
635
|
+
type: "skipped",
|
|
636
|
+
reason: "unavailable"
|
|
637
|
+
};
|
|
638
|
+
} finally {
|
|
639
|
+
stopForwardingSignals();
|
|
640
|
+
}
|
|
641
|
+
} finally {
|
|
642
|
+
rmSync(readyDir, {
|
|
643
|
+
recursive: true,
|
|
644
|
+
force: true
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (platform === "darwin") {
|
|
649
|
+
const child = await startHelperProcess("caffeinate", [
|
|
650
|
+
"-i",
|
|
651
|
+
"-w",
|
|
652
|
+
String(pid)
|
|
653
|
+
], spawnFn, env);
|
|
654
|
+
if (!child) return {
|
|
655
|
+
type: "skipped",
|
|
656
|
+
reason: "unavailable"
|
|
657
|
+
};
|
|
658
|
+
appendDebugLog("sleep:active", { command: "caffeinate" });
|
|
659
|
+
return {
|
|
660
|
+
type: "active",
|
|
661
|
+
cleanup: async () => {
|
|
662
|
+
appendDebugLog("sleep:cleanup", { command: "caffeinate" });
|
|
663
|
+
await shutdownChildProcess(child, {
|
|
664
|
+
detached: false,
|
|
665
|
+
timeoutMs: 1e3
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
if (platform === "win32") {
|
|
671
|
+
const child = await startHelperProcess("powershell.exe", [
|
|
672
|
+
"-NoLogo",
|
|
673
|
+
"-NoProfile",
|
|
674
|
+
"-NonInteractive",
|
|
675
|
+
"-ExecutionPolicy",
|
|
676
|
+
"Bypass",
|
|
677
|
+
"-Command",
|
|
678
|
+
buildPowerShellCommand(pid)
|
|
679
|
+
], spawnFn, env);
|
|
680
|
+
if (!child) return {
|
|
681
|
+
type: "skipped",
|
|
682
|
+
reason: "unavailable"
|
|
683
|
+
};
|
|
684
|
+
appendDebugLog("sleep:active", { command: "powershell.exe" });
|
|
685
|
+
return {
|
|
686
|
+
type: "active",
|
|
687
|
+
cleanup: async () => {
|
|
688
|
+
appendDebugLog("sleep:cleanup", { command: "powershell.exe" });
|
|
689
|
+
await shutdownChildProcess(child, {
|
|
690
|
+
detached: false,
|
|
691
|
+
timeoutMs: 1e3
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
type: "skipped",
|
|
698
|
+
reason: "unsupported"
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
//#endregion
|
|
248
702
|
//#region src/core/agents/stream-utils.ts
|
|
249
703
|
/**
|
|
250
704
|
* Wire stderr collection, spawn-error handling, and the common close-handler
|
|
@@ -481,6 +935,21 @@ function buildPrompt(prompt) {
|
|
|
481
935
|
`The JSON must match this schema exactly: ${JSON.stringify(AGENT_OUTPUT_SCHEMA)}`
|
|
482
936
|
].join("\n");
|
|
483
937
|
}
|
|
938
|
+
/**
|
|
939
|
+
* On Windows with `shell: true`, `child.pid` is the `cmd.exe` wrapper, not
|
|
940
|
+
* the actual server process. `taskkill /T` terminates the entire process
|
|
941
|
+
* tree rooted at that PID so the real server doesn't survive shutdown.
|
|
942
|
+
*/
|
|
943
|
+
async function killWindowsProcessTree(pid) {
|
|
944
|
+
try {
|
|
945
|
+
execFileSync("taskkill", [
|
|
946
|
+
"/T",
|
|
947
|
+
"/F",
|
|
948
|
+
"/PID",
|
|
949
|
+
String(pid)
|
|
950
|
+
], { stdio: "ignore" });
|
|
951
|
+
} catch {}
|
|
952
|
+
}
|
|
484
953
|
function createAbortError$1() {
|
|
485
954
|
return /* @__PURE__ */ new Error("Agent was aborted");
|
|
486
955
|
}
|
|
@@ -552,6 +1021,7 @@ var OpenCodeAgent = class {
|
|
|
552
1021
|
fetchFn;
|
|
553
1022
|
getPortFn;
|
|
554
1023
|
killProcessFn;
|
|
1024
|
+
platform;
|
|
555
1025
|
spawnFn;
|
|
556
1026
|
server = null;
|
|
557
1027
|
closingPromise = null;
|
|
@@ -559,6 +1029,7 @@ var OpenCodeAgent = class {
|
|
|
559
1029
|
this.fetchFn = deps.fetch ?? fetch;
|
|
560
1030
|
this.getPortFn = deps.getPort ?? getAvailablePort$1;
|
|
561
1031
|
this.killProcessFn = deps.killProcess ?? process.kill.bind(process);
|
|
1032
|
+
this.platform = deps.platform ?? process.platform;
|
|
562
1033
|
this.spawnFn = deps.spawn ?? spawn;
|
|
563
1034
|
}
|
|
564
1035
|
async run(prompt, cwd, options) {
|
|
@@ -604,7 +1075,8 @@ var OpenCodeAgent = class {
|
|
|
604
1075
|
return this.server;
|
|
605
1076
|
}
|
|
606
1077
|
const port = await this.getPortFn();
|
|
607
|
-
const
|
|
1078
|
+
const isWindows = this.platform === "win32";
|
|
1079
|
+
const detached = !isWindows;
|
|
608
1080
|
const child = this.spawnFn("opencode", [
|
|
609
1081
|
"serve",
|
|
610
1082
|
"--hostname",
|
|
@@ -615,6 +1087,7 @@ var OpenCodeAgent = class {
|
|
|
615
1087
|
], {
|
|
616
1088
|
cwd,
|
|
617
1089
|
detached,
|
|
1090
|
+
shell: isWindows,
|
|
618
1091
|
stdio: [
|
|
619
1092
|
"ignore",
|
|
620
1093
|
"pipe",
|
|
@@ -647,6 +1120,11 @@ var OpenCodeAgent = class {
|
|
|
647
1120
|
if (this.server === server) this.server = null;
|
|
648
1121
|
});
|
|
649
1122
|
this.server = server;
|
|
1123
|
+
appendDebugLog("opencode:spawn", {
|
|
1124
|
+
cwd,
|
|
1125
|
+
port,
|
|
1126
|
+
detached
|
|
1127
|
+
});
|
|
650
1128
|
server.readyPromise = this.waitForHealthy(server, signal).catch(async (error) => {
|
|
651
1129
|
await this.shutdownServer();
|
|
652
1130
|
throw error;
|
|
@@ -929,37 +1407,20 @@ var OpenCodeAgent = class {
|
|
|
929
1407
|
return;
|
|
930
1408
|
}
|
|
931
1409
|
const server = this.server;
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
return;
|
|
936
|
-
}
|
|
937
|
-
server.child.once("close", () => resolve());
|
|
938
|
-
});
|
|
939
|
-
try {
|
|
940
|
-
this.signalServer(server, "SIGTERM");
|
|
941
|
-
} catch {}
|
|
942
|
-
const forceKill = new Promise((resolve) => {
|
|
943
|
-
setTimeout(() => {
|
|
944
|
-
if (!server.closed) try {
|
|
945
|
-
this.signalServer(server, "SIGKILL");
|
|
946
|
-
} catch {}
|
|
947
|
-
resolve();
|
|
948
|
-
}, 3e3).unref?.();
|
|
1410
|
+
appendDebugLog("opencode:shutdown", {
|
|
1411
|
+
cwd: server.cwd,
|
|
1412
|
+
port: server.port
|
|
949
1413
|
});
|
|
950
|
-
this.closingPromise =
|
|
1414
|
+
this.closingPromise = (this.platform === "win32" && server.child.pid ? killWindowsProcessTree(server.child.pid) : shutdownChildProcess(server.child, {
|
|
1415
|
+
detached: server.detached,
|
|
1416
|
+
killProcess: this.killProcessFn,
|
|
1417
|
+
timeoutMs: 3e3
|
|
1418
|
+
})).finally(() => {
|
|
951
1419
|
if (this.server === server) this.server = null;
|
|
952
1420
|
this.closingPromise = null;
|
|
953
1421
|
});
|
|
954
1422
|
await this.closingPromise;
|
|
955
1423
|
}
|
|
956
|
-
signalServer(server, signal) {
|
|
957
|
-
if (server.detached && server.child.pid) try {
|
|
958
|
-
this.killProcessFn(-server.child.pid, signal);
|
|
959
|
-
return;
|
|
960
|
-
} catch {}
|
|
961
|
-
server.child.kill(signal);
|
|
962
|
-
}
|
|
963
1424
|
async requestJSON(server, path, options) {
|
|
964
1425
|
const body = await this.requestText(server, path, options);
|
|
965
1426
|
return JSON.parse(body);
|
|
@@ -1144,6 +1605,11 @@ var RovoDevAgent = class {
|
|
|
1144
1605
|
if (this.server === server) this.server = null;
|
|
1145
1606
|
});
|
|
1146
1607
|
this.server = server;
|
|
1608
|
+
appendDebugLog("rovodev:spawn", {
|
|
1609
|
+
cwd,
|
|
1610
|
+
port,
|
|
1611
|
+
detached
|
|
1612
|
+
});
|
|
1147
1613
|
server.readyPromise = this.waitForHealthy(server, signal).catch(async (error) => {
|
|
1148
1614
|
await this.shutdownServer();
|
|
1149
1615
|
throw error;
|
|
@@ -1153,13 +1619,13 @@ var RovoDevAgent = class {
|
|
|
1153
1619
|
}
|
|
1154
1620
|
async waitForHealthy(server, signal) {
|
|
1155
1621
|
const deadline = Date.now() + 3e4;
|
|
1156
|
-
let
|
|
1622
|
+
let spawnErrorMessage = null;
|
|
1157
1623
|
server.child.once("error", (error) => {
|
|
1158
|
-
|
|
1624
|
+
spawnErrorMessage = error.message;
|
|
1159
1625
|
});
|
|
1160
1626
|
while (Date.now() < deadline) {
|
|
1161
1627
|
if (signal?.aborted) throw createAbortError();
|
|
1162
|
-
if (
|
|
1628
|
+
if (spawnErrorMessage) throw new Error(`Failed to spawn rovodev: ${spawnErrorMessage}`);
|
|
1163
1629
|
if (server.closed) {
|
|
1164
1630
|
const output = server.stderr.trim() || server.stdout.trim();
|
|
1165
1631
|
throw new Error(output ? `rovodev exited before becoming ready: ${output}` : "rovodev exited before becoming ready");
|
|
@@ -1367,37 +1833,20 @@ var RovoDevAgent = class {
|
|
|
1367
1833
|
return;
|
|
1368
1834
|
}
|
|
1369
1835
|
const server = this.server;
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
return;
|
|
1374
|
-
}
|
|
1375
|
-
server.child.once("close", () => resolve());
|
|
1376
|
-
});
|
|
1377
|
-
try {
|
|
1378
|
-
this.signalServer(server, "SIGTERM");
|
|
1379
|
-
} catch {}
|
|
1380
|
-
const forceKill = new Promise((resolve) => {
|
|
1381
|
-
setTimeout(() => {
|
|
1382
|
-
if (!server.closed) try {
|
|
1383
|
-
this.signalServer(server, "SIGKILL");
|
|
1384
|
-
} catch {}
|
|
1385
|
-
resolve();
|
|
1386
|
-
}, 3e3).unref?.();
|
|
1836
|
+
appendDebugLog("rovodev:shutdown", {
|
|
1837
|
+
cwd: server.cwd,
|
|
1838
|
+
port: server.port
|
|
1387
1839
|
});
|
|
1388
|
-
this.closingPromise =
|
|
1840
|
+
this.closingPromise = shutdownChildProcess(server.child, {
|
|
1841
|
+
detached: server.detached,
|
|
1842
|
+
killProcess: this.killProcessFn,
|
|
1843
|
+
timeoutMs: 3e3
|
|
1844
|
+
}).finally(() => {
|
|
1389
1845
|
if (this.server === server) this.server = null;
|
|
1390
1846
|
this.closingPromise = null;
|
|
1391
1847
|
});
|
|
1392
1848
|
await this.closingPromise;
|
|
1393
1849
|
}
|
|
1394
|
-
signalServer(server, signal) {
|
|
1395
|
-
if (server.detached && server.child.pid) try {
|
|
1396
|
-
this.killProcessFn(-server.child.pid, signal);
|
|
1397
|
-
return;
|
|
1398
|
-
} catch {}
|
|
1399
|
-
server.child.kill(signal);
|
|
1400
|
-
}
|
|
1401
1850
|
async requestJSON(server, path, options) {
|
|
1402
1851
|
return await (await this.request(server, path, options)).json();
|
|
1403
1852
|
}
|
|
@@ -1461,6 +1910,7 @@ ${params.prompt}`;
|
|
|
1461
1910
|
}
|
|
1462
1911
|
//#endregion
|
|
1463
1912
|
//#region src/core/orchestrator.ts
|
|
1913
|
+
const STOP_CLOSE_AGENT_GRACE_MS = 250;
|
|
1464
1914
|
var Orchestrator = class extends EventEmitter {
|
|
1465
1915
|
config;
|
|
1466
1916
|
agent;
|
|
@@ -1470,6 +1920,7 @@ var Orchestrator = class extends EventEmitter {
|
|
|
1470
1920
|
limits;
|
|
1471
1921
|
stopRequested = false;
|
|
1472
1922
|
stopPromise = null;
|
|
1923
|
+
activeIterationPromise = null;
|
|
1473
1924
|
activeAbortController = null;
|
|
1474
1925
|
pendingAbortReason = null;
|
|
1475
1926
|
state = {
|
|
@@ -1505,7 +1956,23 @@ var Orchestrator = class extends EventEmitter {
|
|
|
1505
1956
|
this.activeAbortController?.abort();
|
|
1506
1957
|
if (this.stopPromise) return;
|
|
1507
1958
|
this.stopPromise = (async () => {
|
|
1508
|
-
|
|
1959
|
+
if (this.activeIterationPromise) {
|
|
1960
|
+
const iterationPromise = this.activeIterationPromise.catch(() => void 0);
|
|
1961
|
+
await new Promise((resolve) => {
|
|
1962
|
+
let settled = false;
|
|
1963
|
+
const settle = () => {
|
|
1964
|
+
if (settled) return;
|
|
1965
|
+
settled = true;
|
|
1966
|
+
clearTimeout(timer);
|
|
1967
|
+
resolve();
|
|
1968
|
+
};
|
|
1969
|
+
const timer = setTimeout(settle, STOP_CLOSE_AGENT_GRACE_MS);
|
|
1970
|
+
timer.unref?.();
|
|
1971
|
+
iterationPromise.finally(settle);
|
|
1972
|
+
});
|
|
1973
|
+
await this.closeAgent();
|
|
1974
|
+
await iterationPromise;
|
|
1975
|
+
} else await this.closeAgent();
|
|
1509
1976
|
resetHard(this.cwd);
|
|
1510
1977
|
this.state.status = "stopped";
|
|
1511
1978
|
this.emit("state", this.getState());
|
|
@@ -1532,7 +1999,10 @@ var Orchestrator = class extends EventEmitter {
|
|
|
1532
1999
|
runId: this.runInfo.runId,
|
|
1533
2000
|
prompt: this.prompt
|
|
1534
2001
|
});
|
|
1535
|
-
|
|
2002
|
+
this.activeIterationPromise = this.runIteration(iterationPrompt);
|
|
2003
|
+
const result = await this.activeIterationPromise;
|
|
2004
|
+
this.activeIterationPromise = null;
|
|
2005
|
+
if (result.type === "stopped") break;
|
|
1536
2006
|
if (result.type === "aborted") {
|
|
1537
2007
|
this.abort(result.reason);
|
|
1538
2008
|
break;
|
|
@@ -1564,6 +2034,7 @@ var Orchestrator = class extends EventEmitter {
|
|
|
1564
2034
|
}
|
|
1565
2035
|
}
|
|
1566
2036
|
} finally {
|
|
2037
|
+
this.activeIterationPromise = null;
|
|
1567
2038
|
if (this.stopPromise) await this.stopPromise;
|
|
1568
2039
|
else await this.closeAgent();
|
|
1569
2040
|
}
|
|
@@ -1595,6 +2066,7 @@ var Orchestrator = class extends EventEmitter {
|
|
|
1595
2066
|
signal: this.activeAbortController.signal,
|
|
1596
2067
|
logPath
|
|
1597
2068
|
});
|
|
2069
|
+
if (this.stopRequested) return { type: "stopped" };
|
|
1598
2070
|
if (result.output.success) return {
|
|
1599
2071
|
type: "completed",
|
|
1600
2072
|
record: this.recordSuccess(result.output)
|
|
@@ -1611,6 +2083,7 @@ var Orchestrator = class extends EventEmitter {
|
|
|
1611
2083
|
reason: this.pendingAbortReason
|
|
1612
2084
|
};
|
|
1613
2085
|
}
|
|
2086
|
+
if (this.stopRequested) return { type: "stopped" };
|
|
1614
2087
|
const summary = err instanceof Error ? err.message : String(err);
|
|
1615
2088
|
return {
|
|
1616
2089
|
type: "completed",
|
|
@@ -2212,14 +2685,14 @@ var Renderer = class {
|
|
|
2212
2685
|
};
|
|
2213
2686
|
});
|
|
2214
2687
|
this.orchestrator.on("stopped", () => {
|
|
2215
|
-
this.stop();
|
|
2688
|
+
this.stop("stopped");
|
|
2216
2689
|
});
|
|
2217
2690
|
if (process$1.stdin.isTTY) {
|
|
2218
2691
|
process$1.stdin.setRawMode(true);
|
|
2219
2692
|
process$1.stdin.resume();
|
|
2220
2693
|
process$1.stdin.on("data", (data) => {
|
|
2221
2694
|
if (data[0] === 3) {
|
|
2222
|
-
this.stop();
|
|
2695
|
+
this.stop("interrupted");
|
|
2223
2696
|
this.orchestrator.stop();
|
|
2224
2697
|
}
|
|
2225
2698
|
});
|
|
@@ -2227,7 +2700,7 @@ var Renderer = class {
|
|
|
2227
2700
|
this.interval = setInterval(() => this.render(), TICK_MS);
|
|
2228
2701
|
this.render();
|
|
2229
2702
|
}
|
|
2230
|
-
stop() {
|
|
2703
|
+
stop(reason = "stopped") {
|
|
2231
2704
|
if (this.interval) {
|
|
2232
2705
|
clearInterval(this.interval);
|
|
2233
2706
|
this.interval = null;
|
|
@@ -2237,7 +2710,7 @@ var Renderer = class {
|
|
|
2237
2710
|
process$1.stdin.pause();
|
|
2238
2711
|
process$1.stdin.removeAllListeners("data");
|
|
2239
2712
|
}
|
|
2240
|
-
this.exitResolve();
|
|
2713
|
+
this.exitResolve(reason);
|
|
2241
2714
|
}
|
|
2242
2715
|
waitUntilExit() {
|
|
2243
2716
|
return this.exitPromise;
|
|
@@ -2295,12 +2768,21 @@ function slugifyPrompt(prompt) {
|
|
|
2295
2768
|
//#region src/cli.ts
|
|
2296
2769
|
const packageVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
|
|
2297
2770
|
const FORCE_EXIT_TIMEOUT_MS = 5e3;
|
|
2771
|
+
const GNHF_REEXEC_STDIN_PROMPT = "GNHF_REEXEC_STDIN_PROMPT";
|
|
2772
|
+
const GNHF_REEXEC_STDIN_PROMPT_FILE = "GNHF_REEXEC_STDIN_PROMPT_FILE";
|
|
2773
|
+
const GNHF_REEXEC_STDIN_PROMPT_DIR_PREFIX = "gnhf-stdin-";
|
|
2774
|
+
const GNHF_REEXEC_STDIN_PROMPT_FILENAME = "prompt.txt";
|
|
2298
2775
|
function parseNonNegativeInteger(value) {
|
|
2299
2776
|
if (!/^\d+$/.test(value)) throw new InvalidArgumentError("must be a non-negative integer");
|
|
2300
2777
|
const parsed = Number.parseInt(value, 10);
|
|
2301
2778
|
if (!Number.isSafeInteger(parsed)) throw new InvalidArgumentError("must be a safe integer");
|
|
2302
2779
|
return parsed;
|
|
2303
2780
|
}
|
|
2781
|
+
function parseOnOffBoolean(value) {
|
|
2782
|
+
if (value === "on" || value === "true") return true;
|
|
2783
|
+
if (value === "off" || value === "false") return false;
|
|
2784
|
+
throw new InvalidArgumentError("must be one of: \"on\", \"off\", \"true\", \"false\"");
|
|
2785
|
+
}
|
|
2304
2786
|
function humanizeErrorMessage(message) {
|
|
2305
2787
|
if (message.includes("not a git repository")) return "This command must be run inside a Git repository. Change into a repo or run \"git init\" first.";
|
|
2306
2788
|
return message;
|
|
@@ -2325,8 +2807,57 @@ function ask(question) {
|
|
|
2325
2807
|
});
|
|
2326
2808
|
});
|
|
2327
2809
|
}
|
|
2810
|
+
function getSignalExitCode(signal) {
|
|
2811
|
+
return signal === "SIGINT" ? 130 : 143;
|
|
2812
|
+
}
|
|
2813
|
+
function persistStdinPromptForReexec(prompt) {
|
|
2814
|
+
const promptDir = mkdtempSync(join(tmpdir(), GNHF_REEXEC_STDIN_PROMPT_DIR_PREFIX));
|
|
2815
|
+
const promptPath = join(promptDir, GNHF_REEXEC_STDIN_PROMPT_FILENAME);
|
|
2816
|
+
writeFileSync(promptPath, prompt, {
|
|
2817
|
+
encoding: "utf-8",
|
|
2818
|
+
mode: 384
|
|
2819
|
+
});
|
|
2820
|
+
return {
|
|
2821
|
+
path: promptPath,
|
|
2822
|
+
cleanup: () => {
|
|
2823
|
+
rmSync(promptDir, {
|
|
2824
|
+
recursive: true,
|
|
2825
|
+
force: true
|
|
2826
|
+
});
|
|
2827
|
+
}
|
|
2828
|
+
};
|
|
2829
|
+
}
|
|
2830
|
+
function isTrustedReexecPromptPath(promptPath) {
|
|
2831
|
+
const resolvedPromptPath = resolve(promptPath);
|
|
2832
|
+
const promptDir = dirname(resolvedPromptPath);
|
|
2833
|
+
return basename(resolvedPromptPath) === GNHF_REEXEC_STDIN_PROMPT_FILENAME && dirname(promptDir) === resolve(tmpdir()) && basename(promptDir).startsWith(GNHF_REEXEC_STDIN_PROMPT_DIR_PREFIX);
|
|
2834
|
+
}
|
|
2835
|
+
function cleanupTrustedReexecPromptPath(promptPath) {
|
|
2836
|
+
if (!isTrustedReexecPromptPath(promptPath)) return;
|
|
2837
|
+
const resolvedPromptPath = resolve(promptPath);
|
|
2838
|
+
rmSync(resolvedPromptPath, { force: true });
|
|
2839
|
+
try {
|
|
2840
|
+
rmdirSync(dirname(resolvedPromptPath));
|
|
2841
|
+
} catch {}
|
|
2842
|
+
}
|
|
2843
|
+
function readReexecStdinPrompt(env) {
|
|
2844
|
+
const promptPath = env[GNHF_REEXEC_STDIN_PROMPT_FILE];
|
|
2845
|
+
if (promptPath !== void 0) {
|
|
2846
|
+
delete env[GNHF_REEXEC_STDIN_PROMPT_FILE];
|
|
2847
|
+
try {
|
|
2848
|
+
return readFileSync(promptPath, "utf-8");
|
|
2849
|
+
} finally {
|
|
2850
|
+
cleanupTrustedReexecPromptPath(promptPath);
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
const prompt = env[GNHF_REEXEC_STDIN_PROMPT];
|
|
2854
|
+
if (prompt !== void 0) {
|
|
2855
|
+
delete env[GNHF_REEXEC_STDIN_PROMPT];
|
|
2856
|
+
return prompt;
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2328
2859
|
const program = new Command();
|
|
2329
|
-
program.name("gnhf").description("Before I go to bed, I tell my agents: good night, have fun").version(packageVersion).argument("[prompt]", "The objective for the coding agent").option("--agent <agent>", "Agent to use (claude, codex, rovodev, or opencode)").option("--max-iterations <n>", "Abort after N total iterations", parseNonNegativeInteger).option("--max-tokens <n>", "Abort after N total input+output tokens", parseNonNegativeInteger).option("--mock", "", false).action(async (promptArg, options) => {
|
|
2860
|
+
program.name("gnhf").description("Before I go to bed, I tell my agents: good night, have fun").version(packageVersion).argument("[prompt]", "The objective for the coding agent").option("--agent <agent>", "Agent to use (claude, codex, rovodev, or opencode)").option("--max-iterations <n>", "Abort after N total iterations", parseNonNegativeInteger).option("--max-tokens <n>", "Abort after N total input+output tokens", parseNonNegativeInteger).option("--prevent-sleep <mode>", "Prevent system sleep during the run (\"on\" or \"off\")", parseOnOffBoolean).option("--mock", "", false).action(async (promptArg, options) => {
|
|
2330
2861
|
if (options.mock) {
|
|
2331
2862
|
const mock = new MockOrchestrator();
|
|
2332
2863
|
enterAltScreen();
|
|
@@ -2337,18 +2868,28 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
|
|
|
2337
2868
|
exitAltScreen();
|
|
2338
2869
|
return;
|
|
2339
2870
|
}
|
|
2871
|
+
let initialSleepPrevention = null;
|
|
2872
|
+
if (process$1.env.GNHF_SLEEP_INHIBITED === "1") initialSleepPrevention = await startSleepPrevention(process$1.argv.slice(2));
|
|
2340
2873
|
let prompt = promptArg;
|
|
2341
|
-
|
|
2874
|
+
let promptFromStdin = false;
|
|
2342
2875
|
const agentName = options.agent;
|
|
2343
2876
|
if (agentName !== void 0 && agentName !== "claude" && agentName !== "codex" && agentName !== "rovodev" && agentName !== "opencode") {
|
|
2344
2877
|
console.error(`Unknown agent: ${options.agent}. Use "claude", "codex", "rovodev", or "opencode".`);
|
|
2345
2878
|
process$1.exit(1);
|
|
2346
2879
|
}
|
|
2347
|
-
const config =
|
|
2880
|
+
const config = {
|
|
2881
|
+
...loadConfig(agentName ? { agent: agentName } : {}),
|
|
2882
|
+
...options.preventSleep === void 0 ? {} : { preventSleep: options.preventSleep }
|
|
2883
|
+
};
|
|
2348
2884
|
if (config.agent !== "claude" && config.agent !== "codex" && config.agent !== "rovodev" && config.agent !== "opencode") {
|
|
2349
2885
|
console.error(`Unknown agent: ${config.agent}. Use "claude", "codex", "rovodev", or "opencode".`);
|
|
2350
2886
|
process$1.exit(1);
|
|
2351
2887
|
}
|
|
2888
|
+
if (!prompt && process$1.env.GNHF_SLEEP_INHIBITED === "1") prompt = readReexecStdinPrompt(process$1.env);
|
|
2889
|
+
if (!prompt && !process$1.stdin.isTTY) {
|
|
2890
|
+
prompt = await readStdinText(process$1.stdin);
|
|
2891
|
+
promptFromStdin = true;
|
|
2892
|
+
}
|
|
2352
2893
|
const cwd = process$1.cwd();
|
|
2353
2894
|
const currentBranch = getCurrentBranch(cwd);
|
|
2354
2895
|
const onGnhfBranch = currentBranch.startsWith("gnhf/");
|
|
@@ -2377,27 +2918,67 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
|
|
|
2377
2918
|
}
|
|
2378
2919
|
runInfo = initializeNewBranch(prompt, cwd);
|
|
2379
2920
|
}
|
|
2921
|
+
let sleepPreventionCleanup = null;
|
|
2922
|
+
if (config.preventSleep) {
|
|
2923
|
+
const persistedPrompt = promptFromStdin && prompt !== void 0 ? persistStdinPromptForReexec(prompt) : null;
|
|
2924
|
+
let reexeced = false;
|
|
2925
|
+
try {
|
|
2926
|
+
const sleepPrevention = initialSleepPrevention ?? await startSleepPrevention(process$1.argv.slice(2), { reexecEnv: persistedPrompt ? { [GNHF_REEXEC_STDIN_PROMPT_FILE]: persistedPrompt.path } : void 0 });
|
|
2927
|
+
if (sleepPrevention.type === "reexeced") {
|
|
2928
|
+
reexeced = true;
|
|
2929
|
+
process$1.exit(sleepPrevention.exitCode);
|
|
2930
|
+
}
|
|
2931
|
+
if (sleepPrevention.type === "active") sleepPreventionCleanup = sleepPrevention.cleanup;
|
|
2932
|
+
} finally {
|
|
2933
|
+
if (!reexeced) persistedPrompt?.cleanup();
|
|
2934
|
+
}
|
|
2935
|
+
}
|
|
2936
|
+
appendDebugLog("run:start", { args: process$1.argv.slice(2) });
|
|
2380
2937
|
const orchestrator = new Orchestrator(config, createAgent(config.agent, runInfo), runInfo, prompt, cwd, startIteration, {
|
|
2381
2938
|
maxIterations: options.maxIterations,
|
|
2382
2939
|
maxTokens: options.maxTokens
|
|
2383
2940
|
});
|
|
2941
|
+
let shutdownSignal = null;
|
|
2384
2942
|
enterAltScreen();
|
|
2385
2943
|
const renderer = new Renderer(orchestrator, prompt, config.agent);
|
|
2386
2944
|
renderer.start();
|
|
2945
|
+
const requestShutdown = (signal) => {
|
|
2946
|
+
if (shutdownSignal) return;
|
|
2947
|
+
shutdownSignal = signal;
|
|
2948
|
+
appendDebugLog(`signal:${signal}`);
|
|
2949
|
+
renderer.stop();
|
|
2950
|
+
orchestrator.stop();
|
|
2951
|
+
};
|
|
2952
|
+
const handleSigInt = () => requestShutdown("SIGINT");
|
|
2953
|
+
const handleSigTerm = () => requestShutdown("SIGTERM");
|
|
2954
|
+
process$1.on("SIGINT", handleSigInt);
|
|
2955
|
+
process$1.on("SIGTERM", handleSigTerm);
|
|
2387
2956
|
const orchestratorPromise = orchestrator.start().finally(() => {
|
|
2388
2957
|
renderer.stop();
|
|
2389
2958
|
}).catch((err) => {
|
|
2390
2959
|
exitAltScreen();
|
|
2391
2960
|
die(err instanceof Error ? err.message : String(err));
|
|
2392
2961
|
});
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2962
|
+
try {
|
|
2963
|
+
if (await renderer.waitUntilExit() === "interrupted" && !shutdownSignal) {
|
|
2964
|
+
shutdownSignal = "SIGINT";
|
|
2965
|
+
appendDebugLog("signal:SIGINT");
|
|
2966
|
+
}
|
|
2967
|
+
exitAltScreen();
|
|
2968
|
+
if (await Promise.race([orchestratorPromise.then(() => "done"), new Promise((resolve) => {
|
|
2969
|
+
setTimeout(() => resolve("timeout"), FORCE_EXIT_TIMEOUT_MS).unref();
|
|
2970
|
+
})]) === "timeout") {
|
|
2971
|
+
appendDebugLog("run:shutdown-timeout", { timeoutMs: FORCE_EXIT_TIMEOUT_MS });
|
|
2972
|
+
console.error(`\n gnhf: shutdown timed out after ${FORCE_EXIT_TIMEOUT_MS / 1e3}s, forcing exit\n`);
|
|
2973
|
+
process$1.exit(getSignalExitCode(shutdownSignal ?? "SIGINT"));
|
|
2974
|
+
}
|
|
2975
|
+
} finally {
|
|
2976
|
+
process$1.off("SIGINT", handleSigInt);
|
|
2977
|
+
process$1.off("SIGTERM", handleSigTerm);
|
|
2978
|
+
await sleepPreventionCleanup?.();
|
|
2400
2979
|
}
|
|
2980
|
+
appendDebugLog("run:complete", { signal: shutdownSignal });
|
|
2981
|
+
if (shutdownSignal) process$1.exit(getSignalExitCode(shutdownSignal));
|
|
2401
2982
|
});
|
|
2402
2983
|
function enterAltScreen() {
|
|
2403
2984
|
process$1.stdout.write("\x1B[?1049h");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gnhf",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Before I go to bed, I tell my agents: good night, have fun",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"build": "tsdown",
|
|
11
11
|
"dev": "tsdown --watch",
|
|
12
|
-
"start": "node dist/cli.
|
|
12
|
+
"start": "node dist/cli.mjs",
|
|
13
13
|
"lint": "eslint src",
|
|
14
14
|
"format": "prettier --write src",
|
|
15
15
|
"format:check": "prettier --check src",
|
|
16
|
-
"test": "vitest run",
|
|
17
|
-
"test:
|
|
16
|
+
"test": "npm run build && vitest run",
|
|
17
|
+
"test:e2e": "npm run build && vitest run test/e2e.test.ts",
|
|
18
|
+
"test:coverage": "vitest run --coverage --exclude test/e2e.test.ts"
|
|
18
19
|
},
|
|
19
20
|
"dependencies": {
|
|
20
21
|
"commander": "^14.0.3",
|