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