gnhf 0.1.24 → 0.1.26

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 +12 -10
  2. package/dist/cli.mjs +71 -17
  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,12 @@ 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
141
+ - **Iteration finalization** - agents are expected to finish validation, stop any background processes they started, and only then emit the final JSON result for the iteration
140
142
  - **Shared memory** — the agent reads `notes.md` (built up from prior iterations) to communicate across iterations
141
143
  - **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; if you provide a different prompt, gnhf asks whether to overwrite the saved prompt, start a new branch, or quit
144
+ - **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
145
 
144
146
  ### Worktree Mode
145
147
 
@@ -165,7 +167,7 @@ Pass `--worktree` to run each agent in an isolated [git worktree](https://git-sc
165
167
  | `echo "<prompt>" \| gnhf` | Pipe prompt via stdin |
166
168
  | `cat prd.md \| gnhf` | Pipe a large spec or PRD via stdin |
167
169
 
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.
170
+ 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.
169
171
 
170
172
  ### Flags
171
173
 
@@ -242,12 +244,12 @@ Including a snippet of `gnhf.log` is the single most useful thing you can attach
242
244
 
243
245
  `gnhf` supports four agents:
244
246
 
245
- | Agent | Flag | Requirements | Notes |
246
- | ----------- | ------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
247
- | Claude Code | `--agent claude` | Install Anthropic's `claude` CLI and sign in first. | `gnhf` invokes `claude` directly in non-interactive mode. |
248
- | Codex | `--agent codex` | Install OpenAI's `codex` CLI and sign in first. | `gnhf` invokes `codex exec` directly in non-interactive mode. |
249
- | Rovo Dev | `--agent rovodev` | Install Atlassian's `acli` and authenticate it with Rovo Dev first. | `gnhf` starts a local `acli rovodev serve --disable-session-token <port>` process automatically in the repo workspace. |
250
- | OpenCode | `--agent opencode` | Install `opencode` and configure at least one usable model provider first. | `gnhf` starts a local `opencode serve --hostname 127.0.0.1 --port <port> --print-logs` process automatically, creates a per-run session, and applies a blanket allow rule so tool calls do not block on prompts. |
247
+ | Agent | Flag | Requirements | Notes |
248
+ | ----------- | ------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
249
+ | Claude Code | `--agent claude` | Install Anthropic's `claude` CLI and sign in first. | `gnhf` invokes `claude` directly in non-interactive mode. After Claude emits a successful structured result, `gnhf` treats that result as final and shuts down any lingering Claude process tree after a short grace period. |
250
+ | Codex | `--agent codex` | Install OpenAI's `codex` CLI and sign in first. | `gnhf` invokes `codex exec` directly in non-interactive mode. |
251
+ | Rovo Dev | `--agent rovodev` | Install Atlassian's `acli` and authenticate it with Rovo Dev first. | `gnhf` starts a local `acli rovodev serve --disable-session-token <port>` process automatically in the repo workspace. |
252
+ | OpenCode | `--agent opencode` | Install `opencode` and configure at least one usable model provider first. | `gnhf` starts a local `opencode serve --hostname 127.0.0.1 --port <port> --print-logs` process automatically, creates a per-run session, and applies a blanket allow rule so tool calls do not block on prompts. |
251
253
 
252
254
  ## Development
253
255
 
package/dist/cli.mjs CHANGED
@@ -488,7 +488,7 @@ function setupRun(runId, prompt, baseCommit, cwd, schemaOptions) {
488
488
  const promptPath = join(runDir, "prompt.md");
489
489
  writeFileSync(promptPath, prompt, "utf-8");
490
490
  const notesPath = join(runDir, "notes.md");
491
- 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");
492
492
  const schemaPath = join(runDir, "output-schema.json");
493
493
  writeSchemaFile(schemaPath, schemaOptions.includeStopField);
494
494
  const logPath = join(runDir, LOG_FILENAME);
@@ -1043,6 +1043,7 @@ function setupAbortHandler(signal, child, reject, abortChild = () => {
1043
1043
  }
1044
1044
  //#endregion
1045
1045
  //#region src/core/agents/claude.ts
1046
+ const DEFAULT_FINAL_RESULT_EXIT_GRACE_MS = 15e3;
1046
1047
  function shouldUseWindowsShell$2(bin, platform) {
1047
1048
  if (platform !== "win32") return false;
1048
1049
  if (/\.(cmd|bat)$/i.test(bin)) return true;
@@ -1073,8 +1074,22 @@ function terminateClaudeProcess(child, platform) {
1073
1074
  } catch {}
1074
1075
  return;
1075
1076
  }
1077
+ if (child.pid) try {
1078
+ process.kill(-child.pid, "SIGTERM");
1079
+ return;
1080
+ } catch {}
1076
1081
  child.kill("SIGTERM");
1077
1082
  }
1083
+ async function shutdownClaudeProcess(child, platform) {
1084
+ if (platform === "win32") {
1085
+ terminateClaudeProcess(child, platform);
1086
+ return;
1087
+ }
1088
+ await shutdownChildProcess(child, { detached: true });
1089
+ }
1090
+ function isFinalStructuredResult(event) {
1091
+ return !event.is_error && event.subtype === "success" && !!event.structured_output;
1092
+ }
1078
1093
  function buildClaudeArgs(prompt, schema, extraArgs) {
1079
1094
  const userArgs = extraArgs ?? [];
1080
1095
  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="));
@@ -1108,12 +1123,14 @@ var ClaudeAgent = class {
1108
1123
  name = "claude";
1109
1124
  bin;
1110
1125
  extraArgs;
1126
+ finalResultGraceMs;
1111
1127
  platform;
1112
1128
  schema;
1113
1129
  constructor(binOrDeps = {}) {
1114
1130
  const deps = typeof binOrDeps === "string" ? { bin: binOrDeps } : binOrDeps;
1115
1131
  this.bin = deps.bin ?? "claude";
1116
1132
  this.extraArgs = deps.extraArgs;
1133
+ this.finalResultGraceMs = deps.finalResultGraceMs ?? DEFAULT_FINAL_RESULT_EXIT_GRACE_MS;
1117
1134
  this.platform = deps.platform ?? process.platform;
1118
1135
  this.schema = deps.schema ?? buildAgentOutputSchema({ includeStopField: false });
1119
1136
  }
@@ -1123,6 +1140,7 @@ var ClaudeAgent = class {
1123
1140
  const logStream = logPath ? createWriteStream(logPath) : null;
1124
1141
  const child = spawn(this.bin, buildClaudeArgs(prompt, this.schema, this.extraArgs), {
1125
1142
  cwd,
1143
+ detached: this.platform !== "win32",
1126
1144
  shell: shouldUseWindowsShell$2(this.bin, this.platform),
1127
1145
  stdio: [
1128
1146
  "ignore",
@@ -1133,6 +1151,11 @@ var ClaudeAgent = class {
1133
1151
  });
1134
1152
  if (setupAbortHandler(signal, child, reject, () => terminateClaudeProcess(child, this.platform))) return;
1135
1153
  let resultEvent = null;
1154
+ let finalStructuredResultEvent = null;
1155
+ let latestResultUsage = null;
1156
+ let finalResultCleanupTimer = null;
1157
+ let closedAfterFinalCleanup = false;
1158
+ let stderr = "";
1136
1159
  const cumulative = {
1137
1160
  inputTokens: 0,
1138
1161
  outputTokens: 0,
@@ -1144,6 +1167,12 @@ var ClaudeAgent = class {
1144
1167
  let lastAnonymousAssistantId = null;
1145
1168
  let lastAnonymousAssistantUsage = null;
1146
1169
  let pendingAnonymousAssistantUsage = null;
1170
+ child.stderr.on("data", (data) => {
1171
+ stderr += data.toString();
1172
+ });
1173
+ child.on("error", (err) => {
1174
+ reject(/* @__PURE__ */ new Error(`Failed to spawn claude: ${err.message}`));
1175
+ });
1147
1176
  parseJSONLStream(child.stdout, logStream, (event) => {
1148
1177
  if (event.type === "assistant") {
1149
1178
  const msg = event.message;
@@ -1201,23 +1230,41 @@ var ClaudeAgent = class {
1201
1230
  }
1202
1231
  }
1203
1232
  }
1204
- if (event.type === "result") resultEvent = event;
1233
+ if (event.type === "result") {
1234
+ const next = event;
1235
+ latestResultUsage = next.usage;
1236
+ if (isFinalStructuredResult(next)) {
1237
+ finalStructuredResultEvent = next;
1238
+ if (finalResultCleanupTimer) clearTimeout(finalResultCleanupTimer);
1239
+ finalResultCleanupTimer = setTimeout(() => {
1240
+ closedAfterFinalCleanup = true;
1241
+ shutdownClaudeProcess(child, this.platform);
1242
+ }, this.finalResultGraceMs);
1243
+ } else if (!finalStructuredResultEvent && (next.is_error || next.subtype !== "success" || next.structured_output || !resultEvent)) resultEvent = next;
1244
+ }
1205
1245
  });
1206
- setupChildProcessHandlers(child, "claude", logStream, reject, () => {
1207
- if (!resultEvent) {
1246
+ child.on("close", (code) => {
1247
+ if (finalResultCleanupTimer) clearTimeout(finalResultCleanupTimer);
1248
+ logStream?.end();
1249
+ if (code !== 0 && !closedAfterFinalCleanup) {
1250
+ reject(/* @__PURE__ */ new Error(`claude exited with code ${code}: ${stderr}`));
1251
+ return;
1252
+ }
1253
+ const terminalResultEvent = finalStructuredResultEvent ?? resultEvent;
1254
+ if (!terminalResultEvent) {
1208
1255
  reject(/* @__PURE__ */ new Error("claude returned no result event"));
1209
1256
  return;
1210
1257
  }
1211
- if (resultEvent.is_error || resultEvent.subtype !== "success") {
1212
- reject(/* @__PURE__ */ new Error(`claude reported error: ${JSON.stringify(resultEvent)}`));
1258
+ if (terminalResultEvent.is_error || terminalResultEvent.subtype !== "success") {
1259
+ reject(/* @__PURE__ */ new Error(`claude reported error: ${JSON.stringify(terminalResultEvent)}`));
1213
1260
  return;
1214
1261
  }
1215
- if (!resultEvent.structured_output) {
1262
+ if (!terminalResultEvent.structured_output) {
1216
1263
  reject(/* @__PURE__ */ new Error("claude returned no structured_output"));
1217
1264
  return;
1218
1265
  }
1219
- const output = resultEvent.structured_output;
1220
- const usage = toTokenUsage(resultEvent.usage);
1266
+ const output = terminalResultEvent.structured_output;
1267
+ const usage = toTokenUsage(latestResultUsage ?? terminalResultEvent.usage);
1221
1268
  onUsage?.(usage);
1222
1269
  resolve({
1223
1270
  output,
@@ -2813,7 +2860,8 @@ This is iteration ${params.n}. Each iteration aims to make an incremental step f
2813
2860
  2. Identify the next smallest logical unit of work that's individually verifiable and would make incremental progress towards the objective, and treat that as the scope of this iteration
2814
2861
  3. If you attempted a solution and it didn't end up moving the needle on the objective, document learnings and record success=false, then conclude the iteration rather than continuously pivoting
2815
2862
  4. If you made code changes, run build/tests/linters/formatters if available to validate your work. Do NOT make any git commits - that will be handled automatically by the gnhf orchestrator
2816
- 6. Finally, respond with a JSON object according to the provided schema
2863
+ 5. If you started any long-running background processes (dev servers, browsers, watchers, Electron, etc.), stop them before finishing the iteration
2864
+ 6. Only submit the final JSON object after the result is final: your work is complete, validation is done, and you have stopped any background processes you started
2817
2865
 
2818
2866
  ## Output
2819
2867
 
@@ -2849,6 +2897,7 @@ var Orchestrator = class extends EventEmitter {
2849
2897
  successCount: 0,
2850
2898
  failCount: 0,
2851
2899
  consecutiveFailures: 0,
2900
+ consecutiveErrors: 0,
2852
2901
  startTime: /* @__PURE__ */ new Date(),
2853
2902
  waitingUntil: null,
2854
2903
  lastMessage: null
@@ -2993,14 +3042,14 @@ var Orchestrator = class extends EventEmitter {
2993
3042
  this.abort(`${this.config.maxConsecutiveFailures} consecutive failures`);
2994
3043
  break;
2995
3044
  }
2996
- if (this.state.consecutiveFailures > 0 && !this.stopRequested) {
2997
- const backoffMs = 6e4 * Math.pow(2, this.state.consecutiveFailures - 1);
3045
+ if (this.state.consecutiveErrors > 0 && !this.stopRequested) {
3046
+ const backoffMs = 6e4 * Math.pow(2, this.state.consecutiveErrors - 1);
2998
3047
  this.state.status = "waiting";
2999
3048
  this.state.waitingUntil = new Date(Date.now() + backoffMs);
3000
3049
  this.emit("state", this.getState());
3001
3050
  appendDebugLog("backoff:start", {
3002
3051
  iteration: this.state.currentIteration,
3003
- consecutiveFailures: this.state.consecutiveFailures,
3052
+ consecutiveErrors: this.state.consecutiveErrors,
3004
3053
  backoffMs
3005
3054
  });
3006
3055
  await this.interruptibleSleep(backoffMs);
@@ -3088,7 +3137,7 @@ var Orchestrator = class extends EventEmitter {
3088
3137
  };
3089
3138
  return {
3090
3139
  type: "completed",
3091
- record: this.recordFailure(`[FAIL] ${result.output.summary}`, result.output.summary, toStringArray(result.output.key_learnings)),
3140
+ record: this.recordFailure(`[FAIL] ${result.output.summary}`, result.output.summary, toStringArray(result.output.key_learnings), "reported"),
3092
3141
  shouldFullyStop
3093
3142
  };
3094
3143
  } catch (err) {
@@ -3120,7 +3169,7 @@ var Orchestrator = class extends EventEmitter {
3120
3169
  const summary = err instanceof Error ? err.message : String(err);
3121
3170
  return {
3122
3171
  type: "completed",
3123
- record: this.recordFailure(`[ERROR] ${summary}`, summary, []),
3172
+ record: this.recordFailure(`[ERROR] ${summary}`, summary, [], "error"),
3124
3173
  shouldFullyStop: false
3125
3174
  };
3126
3175
  } finally {
@@ -3134,6 +3183,7 @@ var Orchestrator = class extends EventEmitter {
3134
3183
  this.state.commitCount = getBranchCommitCount(this.runInfo.baseCommit, this.cwd);
3135
3184
  this.state.successCount++;
3136
3185
  this.state.consecutiveFailures = 0;
3186
+ this.state.consecutiveErrors = 0;
3137
3187
  return {
3138
3188
  number: this.state.currentIteration,
3139
3189
  success: true,
@@ -3143,11 +3193,13 @@ var Orchestrator = class extends EventEmitter {
3143
3193
  timestamp: /* @__PURE__ */ new Date()
3144
3194
  };
3145
3195
  }
3146
- recordFailure(notesSummary, recordSummary, learnings) {
3196
+ recordFailure(notesSummary, recordSummary, learnings, kind) {
3147
3197
  appendNotes(this.runInfo.notesPath, this.state.currentIteration, notesSummary, [], toStringArray(learnings));
3148
3198
  resetHard(this.cwd);
3149
3199
  this.state.failCount++;
3150
3200
  this.state.consecutiveFailures++;
3201
+ if (kind === "error") this.state.consecutiveErrors++;
3202
+ else this.state.consecutiveErrors = 0;
3151
3203
  return {
3152
3204
  number: this.state.currentIteration,
3153
3205
  success: false,
@@ -3280,6 +3332,7 @@ var MockOrchestrator = class extends EventEmitter {
3280
3332
  successCount: 11,
3281
3333
  failCount: 2,
3282
3334
  consecutiveFailures: 0,
3335
+ consecutiveErrors: 0,
3283
3336
  startTime: new Date(Date.now() - INITIAL_ELAPSED_MS),
3284
3337
  waitingUntil: null,
3285
3338
  lastMessage: AGENT_MESSAGES[0]
@@ -4252,10 +4305,11 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
4252
4305
  runInfo = existing;
4253
4306
  startIteration = getLastIterationNumber(existing);
4254
4307
  } else {
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.");
4308
+ 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.");
4256
4309
  if (answer === "o") {
4257
4310
  ensureCleanWorkingTree(cwd);
4258
4311
  runInfo = setupRun(existingRunId, prompt, existing.baseCommit, cwd, schemaOptions);
4312
+ startIteration = getLastIterationNumber(existing);
4259
4313
  } else if (answer === "n") runInfo = initializeNewBranch(prompt, cwd, schemaOptions);
4260
4314
  else process$1.exit(0);
4261
4315
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gnhf",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "description": "Before I go to bed, I tell my agents: good night, have fun",
5
5
  "type": "module",
6
6
  "bin": {