gnhf 0.1.23 → 0.1.25

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 +17 -12
  2. package/dist/cli.mjs +153 -57
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -45,7 +45,7 @@ gnhf is a [ralph](https://ghuntley.com/ralph/), [autoresearch](https://github.co
45
45
  You wake up to a branch full of clean work and a log of everything that happened.
46
46
 
47
47
  - **Dead simple** — one command starts an autonomous loop that runs until you Ctrl+C or a configured runtime cap is reached
48
- - **Long running** — each iteration is committed on success, rolled back on failure, with sensible retries and exponential backoff
48
+ - **Long running** — each iteration is committed on success, rolled back on failure, with sensible retries; hard agent errors back off exponentially while agent-reported failures continue immediately
49
49
  - **Live terminal title** — interactive runs keep your terminal title updated with live status, token totals, and commit count, then restore the previous title on exit
50
50
  - **Agent-agnostic** — works with Claude Code, Codex, Rovo Dev, or OpenCode out of the box
51
51
 
@@ -122,7 +122,7 @@ npm link
122
122
  ┌──────────┐ ┌───────────┐ │
123
123
  │ commit │ │ git reset │ │
124
124
  │ append │ │ --hard │ │
125
- │ notes.md │ │ backoff │ │
125
+ │ notes.md │ │ maybe wait│ │
126
126
  └────┬─────┘ └─────┬─────┘ │
127
127
  │ │ │
128
128
  │ ┌──────────┘ │
@@ -136,10 +136,11 @@ 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
+ - **Failure handling** - all failed iterations are rolled back with `git reset --hard`; agent-reported failures proceed to the next iteration immediately, while hard agent errors use exponential backoff
139
140
  - **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
141
  - **Shared memory** — the agent reads `notes.md` (built up from prior iterations) to communicate across iterations
141
142
  - **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
143
+ - **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 update the saved prompt and continue with the existing history, start a new branch, or quit
143
144
 
144
145
  ### Worktree Mode
145
146
 
@@ -165,17 +166,19 @@ Pass `--worktree` to run each agent in an isolated [git worktree](https://git-sc
165
166
  | `echo "<prompt>" \| gnhf` | Pipe prompt via stdin |
166
167
  | `cat prd.md \| gnhf` | Pipe a large spec or PRD via stdin |
167
168
 
169
+ If you run `gnhf` on an existing `gnhf/` branch with a different prompt, gnhf asks whether to update `prompt.md` and continue the existing run history, 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.
170
+
168
171
  ### Flags
169
172
 
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
- | `--stop-when <cond>` | End the loop when the agent reports this natural-language condition is met | unlimited |
176
- | `--prevent-sleep <mode>` | Prevent system sleep during the run (`on`/`off` or `true`/`false`) | config file (`on`) |
177
- | `--worktree` | Run in a separate git worktree (enables multiple agents concurrently) | `false` |
178
- | `--version` | Show version | |
173
+ | Flag | Description | Default |
174
+ | ------------------------ | -------------------------------------------------------------------------- | ---------------------- |
175
+ | `--agent <agent>` | Agent to use (`claude`, `codex`, `rovodev`, or `opencode`) | config file (`claude`) |
176
+ | `--max-iterations <n>` | Abort after `n` total iterations | unlimited |
177
+ | `--max-tokens <n>` | Abort after `n` total input+output tokens | unlimited |
178
+ | `--stop-when <cond>` | End the loop when the agent reports this natural-language condition is met | unlimited |
179
+ | `--prevent-sleep <mode>` | Prevent system sleep during the run (`on`/`off` or `true`/`false`) | config file (`on`) |
180
+ | `--worktree` | Run in a separate git worktree (enables multiple agents concurrently) | `false` |
181
+ | `--version` | Show version | |
179
182
 
180
183
  ## Configuration
181
184
 
@@ -249,6 +252,8 @@ Including a snippet of `gnhf.log` is the single most useful thing you can attach
249
252
 
250
253
  ## Development
251
254
 
255
+ 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.
256
+
252
257
  ```sh
253
258
  npm run build # Build with tsdown
254
259
  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: {
@@ -440,21 +438,30 @@ const AGENT_OUTPUT_SCHEMA = {
440
438
  key_learnings: {
441
439
  type: "array",
442
440
  items: { type: "string" }
443
- },
444
- should_fully_stop: { type: "boolean" }
445
- },
446
- required: [
441
+ }
442
+ };
443
+ const required = [
447
444
  "success",
448
445
  "summary",
449
446
  "key_changes_made",
450
447
  "key_learnings"
451
- ]
452
- };
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
+ }
453
460
  //#endregion
454
461
  //#region src/core/run.ts
455
462
  const LOG_FILENAME = "gnhf.log";
456
- function writeSchemaFile(schemaPath) {
457
- 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");
458
465
  }
459
466
  function ensureRunMetadataIgnored(cwd) {
460
467
  const excludePath = execFileSync("git", [
@@ -474,16 +481,16 @@ function ensureRunMetadataIgnored(cwd) {
474
481
  appendFileSync(resolved, `${content.length > 0 && !content.endsWith("\n") ? "\n" : ""}${entry}\n`, "utf-8");
475
482
  } else writeFileSync(resolved, `${entry}\n`, "utf-8");
476
483
  }
477
- function setupRun(runId, prompt, baseCommit, cwd) {
484
+ function setupRun(runId, prompt, baseCommit, cwd, schemaOptions) {
478
485
  ensureRunMetadataIgnored(cwd);
479
486
  const runDir = join(cwd, ".gnhf", "runs", runId);
480
487
  mkdirSync(runDir, { recursive: true });
481
488
  const promptPath = join(runDir, "prompt.md");
482
489
  writeFileSync(promptPath, prompt, "utf-8");
483
490
  const notesPath = join(runDir, "notes.md");
484
- writeFileSync(notesPath, `# gnhf run: ${runId}\n\nObjective: ${prompt}\n\n## Iteration Log\n`, "utf-8");
491
+ if (!existsSync(notesPath)) writeFileSync(notesPath, `# gnhf run: ${runId}\n\nObjective: see .gnhf/runs/${runId}/prompt.md\n\n## Iteration Log\n`, "utf-8");
485
492
  const schemaPath = join(runDir, "output-schema.json");
486
- writeSchemaFile(schemaPath);
493
+ writeSchemaFile(schemaPath, schemaOptions.includeStopField);
487
494
  const logPath = join(runDir, LOG_FILENAME);
488
495
  const baseCommitPath = join(runDir, "base-commit");
489
496
  const hasStoredBaseCommit = existsSync(baseCommitPath);
@@ -500,13 +507,13 @@ function setupRun(runId, prompt, baseCommit, cwd) {
500
507
  baseCommitPath
501
508
  };
502
509
  }
503
- function resumeRun(runId, cwd) {
510
+ function resumeRun(runId, cwd, schemaOptions) {
504
511
  const runDir = join(cwd, ".gnhf", "runs", runId);
505
512
  if (!existsSync(runDir)) throw new Error(`Run directory not found: ${runDir}`);
506
513
  const promptPath = join(runDir, "prompt.md");
507
514
  const notesPath = join(runDir, "notes.md");
508
515
  const schemaPath = join(runDir, "output-schema.json");
509
- writeSchemaFile(schemaPath);
516
+ writeSchemaFile(schemaPath, schemaOptions.includeStopField);
510
517
  const logPath = join(runDir, LOG_FILENAME);
511
518
  const baseCommitPath = join(runDir, "base-commit");
512
519
  return {
@@ -1068,7 +1075,7 @@ function terminateClaudeProcess(child, platform) {
1068
1075
  }
1069
1076
  child.kill("SIGTERM");
1070
1077
  }
1071
- function buildClaudeArgs(prompt, extraArgs) {
1078
+ function buildClaudeArgs(prompt, schema, extraArgs) {
1072
1079
  const userArgs = extraArgs ?? [];
1073
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="));
1074
1081
  return [
@@ -1079,7 +1086,7 @@ function buildClaudeArgs(prompt, extraArgs) {
1079
1086
  "--output-format",
1080
1087
  "stream-json",
1081
1088
  "--json-schema",
1082
- JSON.stringify(AGENT_OUTPUT_SCHEMA),
1089
+ JSON.stringify(schema),
1083
1090
  ...userSpecifiedPermissionMode ? [] : ["--dangerously-skip-permissions"]
1084
1091
  ];
1085
1092
  }
@@ -1102,17 +1109,19 @@ var ClaudeAgent = class {
1102
1109
  bin;
1103
1110
  extraArgs;
1104
1111
  platform;
1112
+ schema;
1105
1113
  constructor(binOrDeps = {}) {
1106
1114
  const deps = typeof binOrDeps === "string" ? { bin: binOrDeps } : binOrDeps;
1107
1115
  this.bin = deps.bin ?? "claude";
1108
1116
  this.extraArgs = deps.extraArgs;
1109
1117
  this.platform = deps.platform ?? process.platform;
1118
+ this.schema = deps.schema ?? buildAgentOutputSchema({ includeStopField: false });
1110
1119
  }
1111
1120
  run(prompt, cwd, options) {
1112
1121
  const { onUsage, onMessage, signal, logPath } = options ?? {};
1113
1122
  return new Promise((resolve, reject) => {
1114
1123
  const logStream = logPath ? createWriteStream(logPath) : null;
1115
- const child = spawn(this.bin, buildClaudeArgs(prompt, this.extraArgs), {
1124
+ const child = spawn(this.bin, buildClaudeArgs(prompt, this.schema, this.extraArgs), {
1116
1125
  cwd,
1117
1126
  shell: shouldUseWindowsShell$2(this.bin, this.platform),
1118
1127
  stdio: [
@@ -1124,6 +1133,7 @@ var ClaudeAgent = class {
1124
1133
  });
1125
1134
  if (setupAbortHandler(signal, child, reject, () => terminateClaudeProcess(child, this.platform))) return;
1126
1135
  let resultEvent = null;
1136
+ let latestResultUsage = null;
1127
1137
  const cumulative = {
1128
1138
  inputTokens: 0,
1129
1139
  outputTokens: 0,
@@ -1192,7 +1202,11 @@ var ClaudeAgent = class {
1192
1202
  }
1193
1203
  }
1194
1204
  }
1195
- if (event.type === "result") resultEvent = event;
1205
+ if (event.type === "result") {
1206
+ const next = event;
1207
+ latestResultUsage = next.usage;
1208
+ if (next.is_error || next.subtype !== "success" || next.structured_output || !resultEvent) resultEvent = next;
1209
+ }
1196
1210
  });
1197
1211
  setupChildProcessHandlers(child, "claude", logStream, reject, () => {
1198
1212
  if (!resultEvent) {
@@ -1208,7 +1222,7 @@ var ClaudeAgent = class {
1208
1222
  return;
1209
1223
  }
1210
1224
  const output = resultEvent.structured_output;
1211
- const usage = toTokenUsage(resultEvent.usage);
1225
+ const usage = toTokenUsage(latestResultUsage ?? resultEvent.usage);
1212
1226
  onUsage?.(usage);
1213
1227
  resolve({
1214
1228
  output,
@@ -1339,25 +1353,27 @@ const BLANKET_PERMISSION_RULESET = [{
1339
1353
  pattern: "*",
1340
1354
  action: "allow"
1341
1355
  }];
1342
- const STRUCTURED_OUTPUT_FORMAT = {
1343
- type: "json_schema",
1344
- schema: AGENT_OUTPUT_SCHEMA,
1345
- retryCount: 1
1346
- };
1356
+ function buildStructuredOutputFormat(schema) {
1357
+ return {
1358
+ type: "json_schema",
1359
+ schema,
1360
+ retryCount: 1
1361
+ };
1362
+ }
1347
1363
  function buildOpencodeChildEnv() {
1348
1364
  const env = { ...process.env };
1349
1365
  delete env.OPENCODE_SERVER_USERNAME;
1350
1366
  delete env.OPENCODE_SERVER_PASSWORD;
1351
1367
  return env;
1352
1368
  }
1353
- function buildPrompt(prompt) {
1369
+ function buildPrompt(prompt, schema) {
1354
1370
  return [
1355
1371
  prompt,
1356
1372
  "",
1357
1373
  "When you finish, reply with only valid JSON.",
1358
1374
  "Do not wrap the JSON in markdown fences.",
1359
1375
  "Do not include any prose before or after the JSON.",
1360
- `The JSON must match this schema exactly: ${JSON.stringify(AGENT_OUTPUT_SCHEMA)}`
1376
+ `The JSON must match this schema exactly: ${JSON.stringify(schema)}`
1361
1377
  ].join("\n");
1362
1378
  }
1363
1379
  /**
@@ -1452,6 +1468,7 @@ var OpenCodeAgent = class {
1452
1468
  getPortFn;
1453
1469
  killProcessFn;
1454
1470
  platform;
1471
+ schema;
1455
1472
  spawnFn;
1456
1473
  server = null;
1457
1474
  closingPromise = null;
@@ -1462,6 +1479,7 @@ var OpenCodeAgent = class {
1462
1479
  this.getPortFn = deps.getPort ?? getAvailablePort$1;
1463
1480
  this.killProcessFn = deps.killProcess ?? process.kill.bind(process);
1464
1481
  this.platform = deps.platform ?? process.platform;
1482
+ this.schema = deps.schema ?? buildAgentOutputSchema({ includeStopField: false });
1465
1483
  this.spawnFn = deps.spawn ?? spawn;
1466
1484
  }
1467
1485
  async run(prompt, cwd, options) {
@@ -1487,7 +1505,7 @@ var OpenCodeAgent = class {
1487
1505
  try {
1488
1506
  const server = await this.ensureServer(cwd, runController.signal);
1489
1507
  sessionId = await this.createSession(server, cwd, runController.signal);
1490
- const result = await this.streamMessage(server, sessionId, buildPrompt(prompt), runController.signal, logStream, onUsage, onMessage);
1508
+ const result = await this.streamMessage(server, sessionId, buildPrompt(prompt, this.schema), runController.signal, logStream, onUsage, onMessage);
1491
1509
  appendDebugLog("opencode:run:end", {
1492
1510
  sessionId,
1493
1511
  elapsedMs: Date.now() - runStartedAt,
@@ -1764,7 +1782,7 @@ var OpenCodeAgent = class {
1764
1782
  type: "text",
1765
1783
  text: prompt
1766
1784
  }],
1767
- format: STRUCTURED_OUTPUT_FORMAT
1785
+ format: buildStructuredOutputFormat(this.schema)
1768
1786
  },
1769
1787
  signal
1770
1788
  });
@@ -2757,11 +2775,13 @@ function withTimeoutSignal(signal, timeoutMs) {
2757
2775
  }
2758
2776
  //#endregion
2759
2777
  //#region src/core/agents/factory.ts
2760
- function createAgent(name, runInfo, pathOverride, agentArgsOverride) {
2778
+ function createAgent(name, runInfo, pathOverride, agentArgsOverride, options) {
2779
+ const schema = buildAgentOutputSchema({ includeStopField: options.includeStopField });
2761
2780
  switch (name) {
2762
2781
  case "claude": return new ClaudeAgent({
2763
2782
  bin: pathOverride,
2764
- extraArgs: agentArgsOverride
2783
+ extraArgs: agentArgsOverride,
2784
+ schema
2765
2785
  });
2766
2786
  case "codex": return new CodexAgent(runInfo.schemaPath, {
2767
2787
  bin: pathOverride,
@@ -2769,7 +2789,8 @@ function createAgent(name, runInfo, pathOverride, agentArgsOverride) {
2769
2789
  });
2770
2790
  case "opencode": return new OpenCodeAgent({
2771
2791
  bin: pathOverride,
2772
- extraArgs: agentArgsOverride
2792
+ extraArgs: agentArgsOverride,
2793
+ schema
2773
2794
  });
2774
2795
  case "rovodev": return new RovoDevAgent(runInfo.schemaPath, {
2775
2796
  bin: pathOverride,
@@ -2787,7 +2808,7 @@ function buildIterationPrompt(params) {
2787
2808
  "- key_learnings: an array of new learnings that were surprising, weren't captured by previous notes and would be informative for future iterations"
2788
2809
  ];
2789
2810
  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");
2790
- 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 (or omit it).` : "";
2811
+ 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.` : "";
2791
2812
  return `You are working autonomously towards an objective given below.
2792
2813
  This is iteration ${params.n}. Each iteration aims to make an incremental step forward, not to complete the entire objective.
2793
2814
 
@@ -2833,6 +2854,7 @@ var Orchestrator = class extends EventEmitter {
2833
2854
  successCount: 0,
2834
2855
  failCount: 0,
2835
2856
  consecutiveFailures: 0,
2857
+ consecutiveErrors: 0,
2836
2858
  startTime: /* @__PURE__ */ new Date(),
2837
2859
  waitingUntil: null,
2838
2860
  lastMessage: null
@@ -2977,14 +2999,14 @@ var Orchestrator = class extends EventEmitter {
2977
2999
  this.abort(`${this.config.maxConsecutiveFailures} consecutive failures`);
2978
3000
  break;
2979
3001
  }
2980
- if (this.state.consecutiveFailures > 0 && !this.stopRequested) {
2981
- const backoffMs = 6e4 * Math.pow(2, this.state.consecutiveFailures - 1);
3002
+ if (this.state.consecutiveErrors > 0 && !this.stopRequested) {
3003
+ const backoffMs = 6e4 * Math.pow(2, this.state.consecutiveErrors - 1);
2982
3004
  this.state.status = "waiting";
2983
3005
  this.state.waitingUntil = new Date(Date.now() + backoffMs);
2984
3006
  this.emit("state", this.getState());
2985
3007
  appendDebugLog("backoff:start", {
2986
3008
  iteration: this.state.currentIteration,
2987
- consecutiveFailures: this.state.consecutiveFailures,
3009
+ consecutiveErrors: this.state.consecutiveErrors,
2988
3010
  backoffMs
2989
3011
  });
2990
3012
  await this.interruptibleSleep(backoffMs);
@@ -3072,7 +3094,7 @@ var Orchestrator = class extends EventEmitter {
3072
3094
  };
3073
3095
  return {
3074
3096
  type: "completed",
3075
- record: this.recordFailure(`[FAIL] ${result.output.summary}`, result.output.summary, toStringArray(result.output.key_learnings)),
3097
+ record: this.recordFailure(`[FAIL] ${result.output.summary}`, result.output.summary, toStringArray(result.output.key_learnings), "reported"),
3076
3098
  shouldFullyStop
3077
3099
  };
3078
3100
  } catch (err) {
@@ -3104,7 +3126,7 @@ var Orchestrator = class extends EventEmitter {
3104
3126
  const summary = err instanceof Error ? err.message : String(err);
3105
3127
  return {
3106
3128
  type: "completed",
3107
- record: this.recordFailure(`[ERROR] ${summary}`, summary, []),
3129
+ record: this.recordFailure(`[ERROR] ${summary}`, summary, [], "error"),
3108
3130
  shouldFullyStop: false
3109
3131
  };
3110
3132
  } finally {
@@ -3118,6 +3140,7 @@ var Orchestrator = class extends EventEmitter {
3118
3140
  this.state.commitCount = getBranchCommitCount(this.runInfo.baseCommit, this.cwd);
3119
3141
  this.state.successCount++;
3120
3142
  this.state.consecutiveFailures = 0;
3143
+ this.state.consecutiveErrors = 0;
3121
3144
  return {
3122
3145
  number: this.state.currentIteration,
3123
3146
  success: true,
@@ -3127,11 +3150,13 @@ var Orchestrator = class extends EventEmitter {
3127
3150
  timestamp: /* @__PURE__ */ new Date()
3128
3151
  };
3129
3152
  }
3130
- recordFailure(notesSummary, recordSummary, learnings) {
3153
+ recordFailure(notesSummary, recordSummary, learnings, kind) {
3131
3154
  appendNotes(this.runInfo.notesPath, this.state.currentIteration, notesSummary, [], toStringArray(learnings));
3132
3155
  resetHard(this.cwd);
3133
3156
  this.state.failCount++;
3134
3157
  this.state.consecutiveFailures++;
3158
+ if (kind === "error") this.state.consecutiveErrors++;
3159
+ else this.state.consecutiveErrors = 0;
3135
3160
  return {
3136
3161
  number: this.state.currentIteration,
3137
3162
  success: false,
@@ -3264,6 +3289,7 @@ var MockOrchestrator = class extends EventEmitter {
3264
3289
  successCount: 11,
3265
3290
  failCount: 2,
3266
3291
  consecutiveFailures: 0,
3292
+ consecutiveErrors: 0,
3267
3293
  startTime: new Date(Date.now() - INITIAL_ELAPSED_MS),
3268
3294
  waitingUntil: null,
3269
3295
  lastMessage: AGENT_MESSAGES[0]
@@ -3998,6 +4024,12 @@ const GNHF_REEXEC_STDIN_PROMPT = "GNHF_REEXEC_STDIN_PROMPT";
3998
4024
  const GNHF_REEXEC_STDIN_PROMPT_FILE = "GNHF_REEXEC_STDIN_PROMPT_FILE";
3999
4025
  const GNHF_REEXEC_STDIN_PROMPT_DIR_PREFIX = "gnhf-stdin-";
4000
4026
  const GNHF_REEXEC_STDIN_PROMPT_FILENAME = "prompt.txt";
4027
+ var PromptSignalError = class extends Error {
4028
+ constructor(signal) {
4029
+ super(signal);
4030
+ this.signal = signal;
4031
+ }
4032
+ };
4001
4033
  function parseNonNegativeInteger(value) {
4002
4034
  if (!/^\d+$/.test(value)) throw new InvalidArgumentError("must be a non-negative integer");
4003
4035
  const parsed = Number.parseInt(value, 10);
@@ -4013,15 +4045,15 @@ function humanizeErrorMessage(message) {
4013
4045
  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.";
4014
4046
  return message;
4015
4047
  }
4016
- function initializeNewBranch(prompt, cwd) {
4048
+ function initializeNewBranch(prompt, cwd, schemaOptions) {
4017
4049
  ensureCleanWorkingTree(cwd);
4018
4050
  const baseCommit = getHeadCommit(cwd);
4019
4051
  const branchName = slugifyPrompt(prompt);
4020
4052
  createBranch(branchName, cwd);
4021
4053
  const runId = branchName.split("/")[1];
4022
- return setupRun(runId, prompt, baseCommit, cwd);
4054
+ return setupRun(runId, prompt, baseCommit, cwd, schemaOptions);
4023
4055
  }
4024
- function initializeWorktreeRun(prompt, cwd) {
4056
+ function initializeWorktreeRun(prompt, cwd, schemaOptions) {
4025
4057
  const repoRoot = getRepoRootDir(cwd);
4026
4058
  const baseCommit = getHeadCommit(cwd);
4027
4059
  const branchName = slugifyPrompt(prompt);
@@ -4029,19 +4061,80 @@ function initializeWorktreeRun(prompt, cwd) {
4029
4061
  const worktreePath = join(dirname(repoRoot), `${basename(repoRoot)}-gnhf-worktrees`, runId);
4030
4062
  createWorktree(repoRoot, worktreePath, branchName);
4031
4063
  return {
4032
- runInfo: setupRun(runId, prompt, baseCommit, worktreePath),
4064
+ runInfo: setupRun(runId, prompt, baseCommit, worktreePath, schemaOptions),
4033
4065
  worktreePath,
4034
4066
  effectiveCwd: worktreePath
4035
4067
  };
4036
4068
  }
4037
- function ask(question) {
4038
- const rl = createInterface({
4069
+ function openPromptTerminal() {
4070
+ if (process$1.stdin.isTTY) return {
4039
4071
  input: process$1.stdin,
4040
- output: process$1.stderr
4072
+ output: process$1.stderr,
4073
+ cleanup: () => {}
4074
+ };
4075
+ const inputPath = process$1.platform === "win32" ? "CONIN$" : "/dev/tty";
4076
+ const outputPath = process$1.platform === "win32" ? "CONOUT$" : "/dev/tty";
4077
+ const inputFd = openSync(inputPath, "r");
4078
+ try {
4079
+ const outputFd = openSync(outputPath, "w");
4080
+ try {
4081
+ const input = createReadStream("", {
4082
+ autoClose: true,
4083
+ fd: inputFd
4084
+ });
4085
+ const output = createWriteStream("", {
4086
+ autoClose: true,
4087
+ fd: outputFd
4088
+ });
4089
+ return {
4090
+ input,
4091
+ output,
4092
+ cleanup: () => {
4093
+ input.destroy();
4094
+ output.destroy();
4095
+ }
4096
+ };
4097
+ } catch (error) {
4098
+ closeSync(outputFd);
4099
+ throw error;
4100
+ }
4101
+ } catch (error) {
4102
+ closeSync(inputFd);
4103
+ throw error;
4104
+ }
4105
+ }
4106
+ function ask(question, closeMessage, unavailableMessage) {
4107
+ let terminal;
4108
+ try {
4109
+ terminal = openPromptTerminal();
4110
+ } catch {
4111
+ throw new Error(unavailableMessage);
4112
+ }
4113
+ const rl = createInterface({
4114
+ input: terminal.input,
4115
+ output: terminal.output
4041
4116
  });
4042
- return new Promise((resolve) => {
4117
+ return new Promise((resolve, reject) => {
4118
+ const handleClose = () => {
4119
+ terminal.cleanup();
4120
+ rl.off("close", handleClose);
4121
+ rl.off("SIGINT", handleSigInt);
4122
+ reject(new Error(closeMessage));
4123
+ };
4124
+ const handleSigInt = () => {
4125
+ rl.off("close", handleClose);
4126
+ rl.off("SIGINT", handleSigInt);
4127
+ rl.close();
4128
+ terminal.cleanup();
4129
+ reject(new PromptSignalError("SIGINT"));
4130
+ };
4131
+ rl.once("close", handleClose);
4132
+ rl.once("SIGINT", handleSigInt);
4043
4133
  rl.question(question, (answer) => {
4134
+ rl.off("close", handleClose);
4135
+ rl.off("SIGINT", handleSigInt);
4044
4136
  rl.close();
4137
+ terminal.cleanup();
4045
4138
  resolve(answer.trim().toLowerCase());
4046
4139
  });
4047
4140
  });
@@ -4135,6 +4228,7 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
4135
4228
  let worktreeCleanup = null;
4136
4229
  const currentBranch = getCurrentBranch(cwd);
4137
4230
  const onGnhfBranch = currentBranch.startsWith("gnhf/");
4231
+ const schemaOptions = { includeStopField: options.stopWhen !== void 0 };
4138
4232
  let runInfo;
4139
4233
  let startIteration = 0;
4140
4234
  if (options.worktree) {
@@ -4146,7 +4240,7 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
4146
4240
  console.error("Cannot use --worktree from a gnhf branch. Switch to the base branch first.");
4147
4241
  process$1.exit(1);
4148
4242
  }
4149
- const wt = initializeWorktreeRun(prompt, cwd);
4243
+ const wt = initializeWorktreeRun(prompt, cwd, schemaOptions);
4150
4244
  runInfo = wt.runInfo;
4151
4245
  effectiveCwd = wt.effectiveCwd;
4152
4246
  worktreePath = wt.worktreePath;
@@ -4161,18 +4255,19 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
4161
4255
  });
4162
4256
  } else if (onGnhfBranch) {
4163
4257
  const existingRunId = currentBranch.slice(5);
4164
- const existing = resumeRun(existingRunId, cwd);
4258
+ const existing = resumeRun(existingRunId, cwd, schemaOptions);
4165
4259
  const existingPrompt = readFileSync(existing.promptPath, "utf-8");
4166
4260
  if (!prompt || prompt === existingPrompt) {
4167
4261
  prompt = existingPrompt;
4168
4262
  runInfo = existing;
4169
4263
  startIteration = getLastIterationNumber(existing);
4170
4264
  } else {
4171
- 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]: `);
4265
+ const answer = await ask(`You are on gnhf branch "${currentBranch}".\n (o) Update prompt and continue current run\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.");
4172
4266
  if (answer === "o") {
4173
4267
  ensureCleanWorkingTree(cwd);
4174
- runInfo = setupRun(existingRunId, prompt, existing.baseCommit, cwd);
4175
- } else if (answer === "n") runInfo = initializeNewBranch(prompt, cwd);
4268
+ runInfo = setupRun(existingRunId, prompt, existing.baseCommit, cwd, schemaOptions);
4269
+ startIteration = getLastIterationNumber(existing);
4270
+ } else if (answer === "n") runInfo = initializeNewBranch(prompt, cwd, schemaOptions);
4176
4271
  else process$1.exit(0);
4177
4272
  }
4178
4273
  } else {
@@ -4180,7 +4275,7 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
4180
4275
  program.help();
4181
4276
  return;
4182
4277
  }
4183
- runInfo = initializeNewBranch(prompt, cwd);
4278
+ runInfo = initializeNewBranch(prompt, cwd, schemaOptions);
4184
4279
  }
4185
4280
  let sleepPreventionCleanup = null;
4186
4281
  if (config.preventSleep) {
@@ -4217,7 +4312,7 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
4217
4312
  nodeVersion: process$1.version,
4218
4313
  gnhfVersion: packageVersion
4219
4314
  });
4220
- const orchestrator = new Orchestrator(config, createAgent(config.agent, runInfo, config.agentPathOverride[config.agent], config.agentArgsOverride?.[config.agent]), runInfo, prompt, effectiveCwd, startIteration, {
4315
+ const orchestrator = new Orchestrator(config, createAgent(config.agent, runInfo, config.agentPathOverride[config.agent], config.agentArgsOverride?.[config.agent], schemaOptions), runInfo, prompt, effectiveCwd, startIteration, {
4221
4316
  maxIterations: options.maxIterations,
4222
4317
  maxTokens: options.maxTokens,
4223
4318
  stopWhen: options.stopWhen
@@ -4301,6 +4396,7 @@ function die(message) {
4301
4396
  try {
4302
4397
  await program.parseAsync();
4303
4398
  } catch (err) {
4399
+ if (err instanceof PromptSignalError) process$1.exit(getSignalExitCode(err.signal));
4304
4400
  die(err instanceof Error ? err.message : String(err));
4305
4401
  }
4306
4402
  //#endregion
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gnhf",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "Before I go to bed, I tell my agents: good night, have fun",
5
5
  "type": "module",
6
6
  "bin": {