gnhf 0.1.22 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +15 -10
  2. package/dist/cli.mjs +157 -55
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -136,10 +136,10 @@ npm link
136
136
  ```
137
137
 
138
138
  - **Incremental commits** — each successful iteration is a separate git commit, so you can cherry-pick or revert individual changes
139
- - **Runtime caps** `--max-iterations` stops before the next iteration begins, while `--max-tokens` can abort mid-iteration once reported usage reaches the cap; uncommitted work is rolled back in either case, and in the interactive TUI the final state remains visible until you press Ctrl+C to exit
139
+ - **Runtime caps** - `--max-iterations` stops before the next iteration begins, `--max-tokens` can abort mid-iteration once reported usage reaches the cap, and `--stop-when` ends the loop after an iteration whose agent output reports the natural-language condition is met; uncommitted work is rolled back in either case, and in the interactive TUI the final state remains visible until you press Ctrl+C to exit
140
140
  - **Shared memory** — the agent reads `notes.md` (built up from prior iterations) to communicate across iterations
141
141
  - **Local run metadata** — gnhf stores prompt, notes, and resume metadata under `.gnhf/runs/` and ignores it locally, so your branch only contains intentional work
142
- - **Resume support** — run `gnhf` while on an existing `gnhf/` branch to pick up where a previous run left off
142
+ - **Resume support** — run `gnhf` while on an existing `gnhf/` branch to pick up where a previous run left off; if you provide a different prompt, gnhf asks whether to overwrite the saved prompt, start a new branch, or quit
143
143
 
144
144
  ### Worktree Mode
145
145
 
@@ -165,16 +165,19 @@ Pass `--worktree` to run each agent in an isolated [git worktree](https://git-sc
165
165
  | `echo "<prompt>" \| gnhf` | Pipe prompt via stdin |
166
166
  | `cat prd.md \| gnhf` | Pipe a large spec or PRD via stdin |
167
167
 
168
+ If you run `gnhf` on an existing `gnhf/` branch with a different prompt, gnhf asks whether to overwrite the saved prompt, start a new branch, or quit. When the prompt came from stdin, that confirmation is read from the controlling terminal, so it must be available.
169
+
168
170
  ### Flags
169
171
 
170
- | Flag | Description | Default |
171
- | ------------------------ | --------------------------------------------------------------------- | ---------------------- |
172
- | `--agent <agent>` | Agent to use (`claude`, `codex`, `rovodev`, or `opencode`) | config file (`claude`) |
173
- | `--max-iterations <n>` | Abort after `n` total iterations | unlimited |
174
- | `--max-tokens <n>` | Abort after `n` total input+output tokens | unlimited |
175
- | `--prevent-sleep <mode>` | Prevent system sleep during the run (`on`/`off` or `true`/`false`) | config file (`on`) |
176
- | `--worktree` | Run in a separate git worktree (enables multiple agents concurrently) | `false` |
177
- | `--version` | Show version | |
172
+ | Flag | Description | Default |
173
+ | ------------------------ | -------------------------------------------------------------------------- | ---------------------- |
174
+ | `--agent <agent>` | Agent to use (`claude`, `codex`, `rovodev`, or `opencode`) | config file (`claude`) |
175
+ | `--max-iterations <n>` | Abort after `n` total iterations | unlimited |
176
+ | `--max-tokens <n>` | Abort after `n` total input+output tokens | unlimited |
177
+ | `--stop-when <cond>` | End the loop when the agent reports this natural-language condition is met | unlimited |
178
+ | `--prevent-sleep <mode>` | Prevent system sleep during the run (`on`/`off` or `true`/`false`) | config file (`on`) |
179
+ | `--worktree` | Run in a separate git worktree (enables multiple agents concurrently) | `false` |
180
+ | `--version` | Show version | |
178
181
 
179
182
  ## Configuration
180
183
 
@@ -248,6 +251,8 @@ Including a snippet of `gnhf.log` is the single most useful thing you can attach
248
251
 
249
252
  ## Development
250
253
 
254
+ If you want to contribute changes back to this repo, see [`CONTRIBUTING.md`](./CONTRIBUTING.md). Human-authored PRs targeting `main` must be opened via `git push no-mistakes` so the required `Require no-mistakes` check passes.
255
+
251
256
  ```sh
252
257
  npm run build # Build with tsdown
253
258
  npm run dev # Watch mode
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { appendFileSync, createWriteStream, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, rmdirSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, closeSync, createReadStream, createWriteStream, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, rmSync, rmdirSync, writeFileSync } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
5
  import process$1 from "node:process";
@@ -427,10 +427,8 @@ function removeWorktree(baseCwd, worktreePath) {
427
427
  }
428
428
  //#endregion
429
429
  //#region src/core/agents/types.ts
430
- const AGENT_OUTPUT_SCHEMA = {
431
- type: "object",
432
- additionalProperties: false,
433
- properties: {
430
+ function buildAgentOutputSchema(opts) {
431
+ const properties = {
434
432
  success: { type: "boolean" },
435
433
  summary: { type: "string" },
436
434
  key_changes_made: {
@@ -441,19 +439,29 @@ const AGENT_OUTPUT_SCHEMA = {
441
439
  type: "array",
442
440
  items: { type: "string" }
443
441
  }
444
- },
445
- required: [
442
+ };
443
+ const required = [
446
444
  "success",
447
445
  "summary",
448
446
  "key_changes_made",
449
447
  "key_learnings"
450
- ]
451
- };
448
+ ];
449
+ if (opts.includeStopField) {
450
+ properties.should_fully_stop = { type: "boolean" };
451
+ required.push("should_fully_stop");
452
+ }
453
+ return {
454
+ type: "object",
455
+ additionalProperties: false,
456
+ properties,
457
+ required
458
+ };
459
+ }
452
460
  //#endregion
453
461
  //#region src/core/run.ts
454
462
  const LOG_FILENAME = "gnhf.log";
455
- function writeSchemaFile(schemaPath) {
456
- writeFileSync(schemaPath, JSON.stringify(AGENT_OUTPUT_SCHEMA, null, 2), "utf-8");
463
+ function writeSchemaFile(schemaPath, includeStopField) {
464
+ writeFileSync(schemaPath, JSON.stringify(buildAgentOutputSchema({ includeStopField }), null, 2), "utf-8");
457
465
  }
458
466
  function ensureRunMetadataIgnored(cwd) {
459
467
  const excludePath = execFileSync("git", [
@@ -473,7 +481,7 @@ function ensureRunMetadataIgnored(cwd) {
473
481
  appendFileSync(resolved, `${content.length > 0 && !content.endsWith("\n") ? "\n" : ""}${entry}\n`, "utf-8");
474
482
  } else writeFileSync(resolved, `${entry}\n`, "utf-8");
475
483
  }
476
- function setupRun(runId, prompt, baseCommit, cwd) {
484
+ function setupRun(runId, prompt, baseCommit, cwd, schemaOptions) {
477
485
  ensureRunMetadataIgnored(cwd);
478
486
  const runDir = join(cwd, ".gnhf", "runs", runId);
479
487
  mkdirSync(runDir, { recursive: true });
@@ -482,7 +490,7 @@ function setupRun(runId, prompt, baseCommit, cwd) {
482
490
  const notesPath = join(runDir, "notes.md");
483
491
  writeFileSync(notesPath, `# gnhf run: ${runId}\n\nObjective: ${prompt}\n\n## Iteration Log\n`, "utf-8");
484
492
  const schemaPath = join(runDir, "output-schema.json");
485
- writeSchemaFile(schemaPath);
493
+ writeSchemaFile(schemaPath, schemaOptions.includeStopField);
486
494
  const logPath = join(runDir, LOG_FILENAME);
487
495
  const baseCommitPath = join(runDir, "base-commit");
488
496
  const hasStoredBaseCommit = existsSync(baseCommitPath);
@@ -499,13 +507,13 @@ function setupRun(runId, prompt, baseCommit, cwd) {
499
507
  baseCommitPath
500
508
  };
501
509
  }
502
- function resumeRun(runId, cwd) {
510
+ function resumeRun(runId, cwd, schemaOptions) {
503
511
  const runDir = join(cwd, ".gnhf", "runs", runId);
504
512
  if (!existsSync(runDir)) throw new Error(`Run directory not found: ${runDir}`);
505
513
  const promptPath = join(runDir, "prompt.md");
506
514
  const notesPath = join(runDir, "notes.md");
507
515
  const schemaPath = join(runDir, "output-schema.json");
508
- writeSchemaFile(schemaPath);
516
+ writeSchemaFile(schemaPath, schemaOptions.includeStopField);
509
517
  const logPath = join(runDir, LOG_FILENAME);
510
518
  const baseCommitPath = join(runDir, "base-commit");
511
519
  return {
@@ -1067,7 +1075,7 @@ function terminateClaudeProcess(child, platform) {
1067
1075
  }
1068
1076
  child.kill("SIGTERM");
1069
1077
  }
1070
- function buildClaudeArgs(prompt, extraArgs) {
1078
+ function buildClaudeArgs(prompt, schema, extraArgs) {
1071
1079
  const userArgs = extraArgs ?? [];
1072
1080
  const userSpecifiedPermissionMode = userArgs.some((arg) => arg === "--dangerously-skip-permissions" || arg === "--permission-mode" || arg.startsWith("--permission-mode=") || arg === "--permission-prompt-tool" || arg.startsWith("--permission-prompt-tool="));
1073
1081
  return [
@@ -1078,7 +1086,7 @@ function buildClaudeArgs(prompt, extraArgs) {
1078
1086
  "--output-format",
1079
1087
  "stream-json",
1080
1088
  "--json-schema",
1081
- JSON.stringify(AGENT_OUTPUT_SCHEMA),
1089
+ JSON.stringify(schema),
1082
1090
  ...userSpecifiedPermissionMode ? [] : ["--dangerously-skip-permissions"]
1083
1091
  ];
1084
1092
  }
@@ -1101,17 +1109,19 @@ var ClaudeAgent = class {
1101
1109
  bin;
1102
1110
  extraArgs;
1103
1111
  platform;
1112
+ schema;
1104
1113
  constructor(binOrDeps = {}) {
1105
1114
  const deps = typeof binOrDeps === "string" ? { bin: binOrDeps } : binOrDeps;
1106
1115
  this.bin = deps.bin ?? "claude";
1107
1116
  this.extraArgs = deps.extraArgs;
1108
1117
  this.platform = deps.platform ?? process.platform;
1118
+ this.schema = deps.schema ?? buildAgentOutputSchema({ includeStopField: false });
1109
1119
  }
1110
1120
  run(prompt, cwd, options) {
1111
1121
  const { onUsage, onMessage, signal, logPath } = options ?? {};
1112
1122
  return new Promise((resolve, reject) => {
1113
1123
  const logStream = logPath ? createWriteStream(logPath) : null;
1114
- const child = spawn(this.bin, buildClaudeArgs(prompt, this.extraArgs), {
1124
+ const child = spawn(this.bin, buildClaudeArgs(prompt, this.schema, this.extraArgs), {
1115
1125
  cwd,
1116
1126
  shell: shouldUseWindowsShell$2(this.bin, this.platform),
1117
1127
  stdio: [
@@ -1338,25 +1348,27 @@ const BLANKET_PERMISSION_RULESET = [{
1338
1348
  pattern: "*",
1339
1349
  action: "allow"
1340
1350
  }];
1341
- const STRUCTURED_OUTPUT_FORMAT = {
1342
- type: "json_schema",
1343
- schema: AGENT_OUTPUT_SCHEMA,
1344
- retryCount: 1
1345
- };
1351
+ function buildStructuredOutputFormat(schema) {
1352
+ return {
1353
+ type: "json_schema",
1354
+ schema,
1355
+ retryCount: 1
1356
+ };
1357
+ }
1346
1358
  function buildOpencodeChildEnv() {
1347
1359
  const env = { ...process.env };
1348
1360
  delete env.OPENCODE_SERVER_USERNAME;
1349
1361
  delete env.OPENCODE_SERVER_PASSWORD;
1350
1362
  return env;
1351
1363
  }
1352
- function buildPrompt(prompt) {
1364
+ function buildPrompt(prompt, schema) {
1353
1365
  return [
1354
1366
  prompt,
1355
1367
  "",
1356
1368
  "When you finish, reply with only valid JSON.",
1357
1369
  "Do not wrap the JSON in markdown fences.",
1358
1370
  "Do not include any prose before or after the JSON.",
1359
- `The JSON must match this schema exactly: ${JSON.stringify(AGENT_OUTPUT_SCHEMA)}`
1371
+ `The JSON must match this schema exactly: ${JSON.stringify(schema)}`
1360
1372
  ].join("\n");
1361
1373
  }
1362
1374
  /**
@@ -1451,6 +1463,7 @@ var OpenCodeAgent = class {
1451
1463
  getPortFn;
1452
1464
  killProcessFn;
1453
1465
  platform;
1466
+ schema;
1454
1467
  spawnFn;
1455
1468
  server = null;
1456
1469
  closingPromise = null;
@@ -1461,6 +1474,7 @@ var OpenCodeAgent = class {
1461
1474
  this.getPortFn = deps.getPort ?? getAvailablePort$1;
1462
1475
  this.killProcessFn = deps.killProcess ?? process.kill.bind(process);
1463
1476
  this.platform = deps.platform ?? process.platform;
1477
+ this.schema = deps.schema ?? buildAgentOutputSchema({ includeStopField: false });
1464
1478
  this.spawnFn = deps.spawn ?? spawn;
1465
1479
  }
1466
1480
  async run(prompt, cwd, options) {
@@ -1486,7 +1500,7 @@ var OpenCodeAgent = class {
1486
1500
  try {
1487
1501
  const server = await this.ensureServer(cwd, runController.signal);
1488
1502
  sessionId = await this.createSession(server, cwd, runController.signal);
1489
- const result = await this.streamMessage(server, sessionId, buildPrompt(prompt), runController.signal, logStream, onUsage, onMessage);
1503
+ const result = await this.streamMessage(server, sessionId, buildPrompt(prompt, this.schema), runController.signal, logStream, onUsage, onMessage);
1490
1504
  appendDebugLog("opencode:run:end", {
1491
1505
  sessionId,
1492
1506
  elapsedMs: Date.now() - runStartedAt,
@@ -1763,7 +1777,7 @@ var OpenCodeAgent = class {
1763
1777
  type: "text",
1764
1778
  text: prompt
1765
1779
  }],
1766
- format: STRUCTURED_OUTPUT_FORMAT
1780
+ format: buildStructuredOutputFormat(this.schema)
1767
1781
  },
1768
1782
  signal
1769
1783
  });
@@ -2756,11 +2770,13 @@ function withTimeoutSignal(signal, timeoutMs) {
2756
2770
  }
2757
2771
  //#endregion
2758
2772
  //#region src/core/agents/factory.ts
2759
- function createAgent(name, runInfo, pathOverride, agentArgsOverride) {
2773
+ function createAgent(name, runInfo, pathOverride, agentArgsOverride, options) {
2774
+ const schema = buildAgentOutputSchema({ includeStopField: options.includeStopField });
2760
2775
  switch (name) {
2761
2776
  case "claude": return new ClaudeAgent({
2762
2777
  bin: pathOverride,
2763
- extraArgs: agentArgsOverride
2778
+ extraArgs: agentArgsOverride,
2779
+ schema
2764
2780
  });
2765
2781
  case "codex": return new CodexAgent(runInfo.schemaPath, {
2766
2782
  bin: pathOverride,
@@ -2768,7 +2784,8 @@ function createAgent(name, runInfo, pathOverride, agentArgsOverride) {
2768
2784
  });
2769
2785
  case "opencode": return new OpenCodeAgent({
2770
2786
  bin: pathOverride,
2771
- extraArgs: agentArgsOverride
2787
+ extraArgs: agentArgsOverride,
2788
+ schema
2772
2789
  });
2773
2790
  case "rovodev": return new RovoDevAgent(runInfo.schemaPath, {
2774
2791
  bin: pathOverride,
@@ -2779,6 +2796,14 @@ function createAgent(name, runInfo, pathOverride, agentArgsOverride) {
2779
2796
  //#endregion
2780
2797
  //#region src/templates/iteration-prompt.ts
2781
2798
  function buildIterationPrompt(params) {
2799
+ const outputFields = [
2800
+ "- success: whether you were able to make a meaningful contribution that got us closer towards the objective. setting this to false means any code change you made should be discarded",
2801
+ "- summary: a concise one-sentence summary of the accomplishment in this iteration",
2802
+ "- key_changes_made: an array of descriptions for key changes you made. don't group this by file - group by logical units of work. don't describe activities - describe material outcomes",
2803
+ "- key_learnings: an array of new learnings that were surprising, weren't captured by previous notes and would be informative for future iterations"
2804
+ ];
2805
+ if (params.stopWhen !== void 0) outputFields.push("- should_fully_stop: set to true ONLY when the stop condition below is fully met and the entire loop should end. default to false");
2806
+ const stopConditionSection = params.stopWhen !== void 0 ? `\n\n## Stop Condition\n\nThe user has configured a condition to end the loop: ${params.stopWhen}\nIf this condition is fully met after this iteration's work, set should_fully_stop=true in your output. Otherwise set it to false.` : "";
2782
2807
  return `You are working autonomously towards an objective given below.
2783
2808
  This is iteration ${params.n}. Each iteration aims to make an incremental step forward, not to complete the entire objective.
2784
2809
 
@@ -2792,10 +2817,7 @@ This is iteration ${params.n}. Each iteration aims to make an incremental step f
2792
2817
 
2793
2818
  ## Output
2794
2819
 
2795
- - success: whether you were able to make a meaningful contribution that got us closer towards the objective. setting this to false means any code change you made should be discarded
2796
- - summary: a concise one-sentence summary of the accomplishment in this iteration
2797
- - key_changes_made: an array of descriptions for key changes you made. don't group this by file - group by logical units of work. don't describe activities - describe material outcomes
2798
- - key_learnings: an array of new learnings that were surprising, weren't captured by previous notes and would be informative for future iterations
2820
+ ${outputFields.join("\n")}${stopConditionSection}
2799
2821
 
2800
2822
  ## Objective
2801
2823
 
@@ -2910,7 +2932,8 @@ var Orchestrator = class extends EventEmitter {
2910
2932
  const iterationPrompt = buildIterationPrompt({
2911
2933
  n: this.state.currentIteration,
2912
2934
  runId: this.runInfo.runId,
2913
- prompt: this.prompt
2935
+ prompt: this.prompt,
2936
+ stopWhen: this.limits.stopWhen
2914
2937
  });
2915
2938
  appendDebugLog("iteration:start", {
2916
2939
  iteration: this.state.currentIteration,
@@ -2957,6 +2980,10 @@ var Orchestrator = class extends EventEmitter {
2957
2980
  totalOutputTokens: this.state.totalOutputTokens,
2958
2981
  commitCount: this.state.commitCount
2959
2982
  });
2983
+ if (this.limits.stopWhen !== void 0 && result.shouldFullyStop) {
2984
+ this.abort("stop condition met");
2985
+ break;
2986
+ }
2960
2987
  const postIterationAbortReason = this.getPostIterationAbortReason();
2961
2988
  if (postIterationAbortReason) {
2962
2989
  this.abort(postIterationAbortReason);
@@ -3053,13 +3080,16 @@ var Orchestrator = class extends EventEmitter {
3053
3080
  cacheCreationTokens: result.usage.cacheCreationTokens
3054
3081
  });
3055
3082
  if (this.stopRequested) return { type: "stopped" };
3083
+ const shouldFullyStop = result.output.should_fully_stop === true;
3056
3084
  if (result.output.success) return {
3057
3085
  type: "completed",
3058
- record: this.recordSuccess(result.output)
3086
+ record: this.recordSuccess(result.output),
3087
+ shouldFullyStop
3059
3088
  };
3060
3089
  return {
3061
3090
  type: "completed",
3062
- record: this.recordFailure(`[FAIL] ${result.output.summary}`, result.output.summary, toStringArray(result.output.key_learnings))
3091
+ record: this.recordFailure(`[FAIL] ${result.output.summary}`, result.output.summary, toStringArray(result.output.key_learnings)),
3092
+ shouldFullyStop
3063
3093
  };
3064
3094
  } catch (err) {
3065
3095
  const elapsedMs = Date.now() - agentStartedAt;
@@ -3090,7 +3120,8 @@ var Orchestrator = class extends EventEmitter {
3090
3120
  const summary = err instanceof Error ? err.message : String(err);
3091
3121
  return {
3092
3122
  type: "completed",
3093
- record: this.recordFailure(`[ERROR] ${summary}`, summary, [])
3123
+ record: this.recordFailure(`[ERROR] ${summary}`, summary, []),
3124
+ shouldFullyStop: false
3094
3125
  };
3095
3126
  } finally {
3096
3127
  this.activeAbortController = null;
@@ -3983,6 +4014,12 @@ const GNHF_REEXEC_STDIN_PROMPT = "GNHF_REEXEC_STDIN_PROMPT";
3983
4014
  const GNHF_REEXEC_STDIN_PROMPT_FILE = "GNHF_REEXEC_STDIN_PROMPT_FILE";
3984
4015
  const GNHF_REEXEC_STDIN_PROMPT_DIR_PREFIX = "gnhf-stdin-";
3985
4016
  const GNHF_REEXEC_STDIN_PROMPT_FILENAME = "prompt.txt";
4017
+ var PromptSignalError = class extends Error {
4018
+ constructor(signal) {
4019
+ super(signal);
4020
+ this.signal = signal;
4021
+ }
4022
+ };
3986
4023
  function parseNonNegativeInteger(value) {
3987
4024
  if (!/^\d+$/.test(value)) throw new InvalidArgumentError("must be a non-negative integer");
3988
4025
  const parsed = Number.parseInt(value, 10);
@@ -3998,15 +4035,15 @@ function humanizeErrorMessage(message) {
3998
4035
  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.";
3999
4036
  return message;
4000
4037
  }
4001
- function initializeNewBranch(prompt, cwd) {
4038
+ function initializeNewBranch(prompt, cwd, schemaOptions) {
4002
4039
  ensureCleanWorkingTree(cwd);
4003
4040
  const baseCommit = getHeadCommit(cwd);
4004
4041
  const branchName = slugifyPrompt(prompt);
4005
4042
  createBranch(branchName, cwd);
4006
4043
  const runId = branchName.split("/")[1];
4007
- return setupRun(runId, prompt, baseCommit, cwd);
4044
+ return setupRun(runId, prompt, baseCommit, cwd, schemaOptions);
4008
4045
  }
4009
- function initializeWorktreeRun(prompt, cwd) {
4046
+ function initializeWorktreeRun(prompt, cwd, schemaOptions) {
4010
4047
  const repoRoot = getRepoRootDir(cwd);
4011
4048
  const baseCommit = getHeadCommit(cwd);
4012
4049
  const branchName = slugifyPrompt(prompt);
@@ -4014,19 +4051,80 @@ function initializeWorktreeRun(prompt, cwd) {
4014
4051
  const worktreePath = join(dirname(repoRoot), `${basename(repoRoot)}-gnhf-worktrees`, runId);
4015
4052
  createWorktree(repoRoot, worktreePath, branchName);
4016
4053
  return {
4017
- runInfo: setupRun(runId, prompt, baseCommit, worktreePath),
4054
+ runInfo: setupRun(runId, prompt, baseCommit, worktreePath, schemaOptions),
4018
4055
  worktreePath,
4019
4056
  effectiveCwd: worktreePath
4020
4057
  };
4021
4058
  }
4022
- function ask(question) {
4023
- const rl = createInterface({
4059
+ function openPromptTerminal() {
4060
+ if (process$1.stdin.isTTY) return {
4024
4061
  input: process$1.stdin,
4025
- output: process$1.stderr
4062
+ output: process$1.stderr,
4063
+ cleanup: () => {}
4064
+ };
4065
+ const inputPath = process$1.platform === "win32" ? "CONIN$" : "/dev/tty";
4066
+ const outputPath = process$1.platform === "win32" ? "CONOUT$" : "/dev/tty";
4067
+ const inputFd = openSync(inputPath, "r");
4068
+ try {
4069
+ const outputFd = openSync(outputPath, "w");
4070
+ try {
4071
+ const input = createReadStream("", {
4072
+ autoClose: true,
4073
+ fd: inputFd
4074
+ });
4075
+ const output = createWriteStream("", {
4076
+ autoClose: true,
4077
+ fd: outputFd
4078
+ });
4079
+ return {
4080
+ input,
4081
+ output,
4082
+ cleanup: () => {
4083
+ input.destroy();
4084
+ output.destroy();
4085
+ }
4086
+ };
4087
+ } catch (error) {
4088
+ closeSync(outputFd);
4089
+ throw error;
4090
+ }
4091
+ } catch (error) {
4092
+ closeSync(inputFd);
4093
+ throw error;
4094
+ }
4095
+ }
4096
+ function ask(question, closeMessage, unavailableMessage) {
4097
+ let terminal;
4098
+ try {
4099
+ terminal = openPromptTerminal();
4100
+ } catch {
4101
+ throw new Error(unavailableMessage);
4102
+ }
4103
+ const rl = createInterface({
4104
+ input: terminal.input,
4105
+ output: terminal.output
4026
4106
  });
4027
- return new Promise((resolve) => {
4107
+ return new Promise((resolve, reject) => {
4108
+ const handleClose = () => {
4109
+ terminal.cleanup();
4110
+ rl.off("close", handleClose);
4111
+ rl.off("SIGINT", handleSigInt);
4112
+ reject(new Error(closeMessage));
4113
+ };
4114
+ const handleSigInt = () => {
4115
+ rl.off("close", handleClose);
4116
+ rl.off("SIGINT", handleSigInt);
4117
+ rl.close();
4118
+ terminal.cleanup();
4119
+ reject(new PromptSignalError("SIGINT"));
4120
+ };
4121
+ rl.once("close", handleClose);
4122
+ rl.once("SIGINT", handleSigInt);
4028
4123
  rl.question(question, (answer) => {
4124
+ rl.off("close", handleClose);
4125
+ rl.off("SIGINT", handleSigInt);
4029
4126
  rl.close();
4127
+ terminal.cleanup();
4030
4128
  resolve(answer.trim().toLowerCase());
4031
4129
  });
4032
4130
  });
@@ -4081,7 +4179,7 @@ function readReexecStdinPrompt(env) {
4081
4179
  }
4082
4180
  }
4083
4181
  const program = new Command();
4084
- 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("--worktree", "Run in a separate git worktree (enables multiple agents on the same repo)", false).option("--mock", "", false).action(async (promptArg, options) => {
4182
+ 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("--stop-when <condition>", "End the loop when the agent reports this natural-language condition is met").option("--prevent-sleep <mode>", "Prevent system sleep during the run (\"on\" or \"off\")", parseOnOffBoolean).option("--worktree", "Run in a separate git worktree (enables multiple agents on the same repo)", false).option("--mock", "", false).action(async (promptArg, options) => {
4085
4183
  if (options.mock) {
4086
4184
  const mock = new MockOrchestrator();
4087
4185
  enterAltScreen();
@@ -4120,6 +4218,7 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
4120
4218
  let worktreeCleanup = null;
4121
4219
  const currentBranch = getCurrentBranch(cwd);
4122
4220
  const onGnhfBranch = currentBranch.startsWith("gnhf/");
4221
+ const schemaOptions = { includeStopField: options.stopWhen !== void 0 };
4123
4222
  let runInfo;
4124
4223
  let startIteration = 0;
4125
4224
  if (options.worktree) {
@@ -4131,7 +4230,7 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
4131
4230
  console.error("Cannot use --worktree from a gnhf branch. Switch to the base branch first.");
4132
4231
  process$1.exit(1);
4133
4232
  }
4134
- const wt = initializeWorktreeRun(prompt, cwd);
4233
+ const wt = initializeWorktreeRun(prompt, cwd, schemaOptions);
4135
4234
  runInfo = wt.runInfo;
4136
4235
  effectiveCwd = wt.effectiveCwd;
4137
4236
  worktreePath = wt.worktreePath;
@@ -4146,18 +4245,18 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
4146
4245
  });
4147
4246
  } else if (onGnhfBranch) {
4148
4247
  const existingRunId = currentBranch.slice(5);
4149
- const existing = resumeRun(existingRunId, cwd);
4248
+ const existing = resumeRun(existingRunId, cwd, schemaOptions);
4150
4249
  const existingPrompt = readFileSync(existing.promptPath, "utf-8");
4151
4250
  if (!prompt || prompt === existingPrompt) {
4152
4251
  prompt = existingPrompt;
4153
4252
  runInfo = existing;
4154
4253
  startIteration = getLastIterationNumber(existing);
4155
4254
  } else {
4156
- const answer = await ask(`You are on gnhf branch "${currentBranch}".\n (o) Overwrite current run with new prompt\n (n) Start a new branch on top of this one\n (q) Quit\nChoose [o/n/q]: `);
4255
+ const answer = await ask(`You are on gnhf branch "${currentBranch}".\n (o) Overwrite current run with new prompt\n (n) Start a new branch on top of this one\n (q) Quit\nChoose [o/n/q]: `, "The overwrite prompt closed before a choice was entered. Re-run gnhf from an interactive terminal and choose o, n, or q.", "Cannot show the overwrite prompt because stdin is not interactive. Re-run gnhf from an interactive terminal and choose o, n, or q.");
4157
4256
  if (answer === "o") {
4158
4257
  ensureCleanWorkingTree(cwd);
4159
- runInfo = setupRun(existingRunId, prompt, existing.baseCommit, cwd);
4160
- } else if (answer === "n") runInfo = initializeNewBranch(prompt, cwd);
4258
+ runInfo = setupRun(existingRunId, prompt, existing.baseCommit, cwd, schemaOptions);
4259
+ } else if (answer === "n") runInfo = initializeNewBranch(prompt, cwd, schemaOptions);
4161
4260
  else process$1.exit(0);
4162
4261
  }
4163
4262
  } else {
@@ -4165,7 +4264,7 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
4165
4264
  program.help();
4166
4265
  return;
4167
4266
  }
4168
- runInfo = initializeNewBranch(prompt, cwd);
4267
+ runInfo = initializeNewBranch(prompt, cwd, schemaOptions);
4169
4268
  }
4170
4269
  let sleepPreventionCleanup = null;
4171
4270
  if (config.preventSleep) {
@@ -4193,6 +4292,7 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
4193
4292
  startIteration,
4194
4293
  maxIterations: options.maxIterations,
4195
4294
  maxTokens: options.maxTokens,
4295
+ stopWhen: options.stopWhen,
4196
4296
  preventSleep: config.preventSleep,
4197
4297
  agentArgsOverride: config.agentArgsOverride?.[config.agent],
4198
4298
  worktree: options.worktree,
@@ -4201,9 +4301,10 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
4201
4301
  nodeVersion: process$1.version,
4202
4302
  gnhfVersion: packageVersion
4203
4303
  });
4204
- const orchestrator = new Orchestrator(config, createAgent(config.agent, runInfo, config.agentPathOverride[config.agent], config.agentArgsOverride?.[config.agent]), runInfo, prompt, effectiveCwd, startIteration, {
4304
+ const orchestrator = new Orchestrator(config, createAgent(config.agent, runInfo, config.agentPathOverride[config.agent], config.agentArgsOverride?.[config.agent], schemaOptions), runInfo, prompt, effectiveCwd, startIteration, {
4205
4305
  maxIterations: options.maxIterations,
4206
- maxTokens: options.maxTokens
4306
+ maxTokens: options.maxTokens,
4307
+ stopWhen: options.stopWhen
4207
4308
  });
4208
4309
  let shutdownSignal = null;
4209
4310
  enterAltScreen();
@@ -4284,6 +4385,7 @@ function die(message) {
4284
4385
  try {
4285
4386
  await program.parseAsync();
4286
4387
  } catch (err) {
4388
+ if (err instanceof PromptSignalError) process$1.exit(getSignalExitCode(err.signal));
4287
4389
  die(err instanceof Error ? err.message : String(err));
4288
4390
  }
4289
4391
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gnhf",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Before I go to bed, I tell my agents: good night, have fun",
5
5
  "type": "module",
6
6
  "bin": {