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.
- package/README.md +12 -10
- package/dist/cli.mjs +71 -17
- 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
|
|
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 │ │
|
|
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
|
|
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
|
|
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:
|
|
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")
|
|
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
|
-
|
|
1207
|
-
if (
|
|
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 (
|
|
1212
|
-
reject(/* @__PURE__ */ new Error(`claude reported error: ${JSON.stringify(
|
|
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 (!
|
|
1262
|
+
if (!terminalResultEvent.structured_output) {
|
|
1216
1263
|
reject(/* @__PURE__ */ new Error("claude returned no structured_output"));
|
|
1217
1264
|
return;
|
|
1218
1265
|
}
|
|
1219
|
-
const output =
|
|
1220
|
-
const usage = toTokenUsage(
|
|
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
|
-
|
|
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.
|
|
2997
|
-
const backoffMs = 6e4 * Math.pow(2, this.state.
|
|
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
|
-
|
|
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)
|
|
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
|
}
|