gnhf 0.1.5 → 0.1.6

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 -7
  2. package/dist/cli.mjs +82 -12
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -42,7 +42,7 @@
42
42
  gnhf is a [ralph](https://ghuntley.com/ralph/), [autoresearch](https://github.com/karpathy/autoresearch)-style orchestrator that keeps your agents running while you sleep — each iteration makes one small, committed, documented change towards an objective.
43
43
  You wake up to a branch full of clean work and a log of everything that happened.
44
44
 
45
- - **Dead simple** — one command starts an autonomous loop that runs until you Ctrl+C
45
+ - **Dead simple** — one command starts an autonomous loop that runs until you Ctrl+C or a configured runtime cap is reached
46
46
  - **Long running** — each iteration is committed on success, rolled back on failure, with sensible retries and exponential backoff
47
47
  - **Agent-agnostic** — works with Claude Code or Codex out of the box
48
48
 
@@ -50,7 +50,14 @@ You wake up to a branch full of clean work and a log of everything that happened
50
50
 
51
51
  ```sh
52
52
  $ gnhf "reduce complexity of the codebase without changing functionality"
53
- # go to sleep
53
+ # have a good sleep
54
+ ```
55
+
56
+ ```sh
57
+ $ gnhf "reduce complexity of the codebase without changing functionality" \
58
+ --max-iterations 10 \
59
+ --max-tokens 5000000
60
+ # have a good nap
54
61
  ```
55
62
 
56
63
  Run `gnhf` from inside a Git repository with a clean working tree. If you are starting from a plain directory, run `git init` first.
@@ -118,6 +125,7 @@ npm link
118
125
  ```
119
126
 
120
127
  - **Incremental commits** — each successful iteration is a separate git commit, so you can cherry-pick or revert individual changes
128
+ - **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
121
129
  - **Shared memory** — the agent reads `notes.md` (built up from prior iterations) to communicate across iterations
122
130
  - **Local run metadata** — gnhf stores prompt, notes, and resume metadata under `.gnhf/runs/` and ignores it locally, so your branch only contains intentional work
123
131
  - **Resume support** — run `gnhf` while on an existing `gnhf/` branch to pick up where a previous run left off
@@ -133,10 +141,12 @@ npm link
133
141
 
134
142
  ### Flags
135
143
 
136
- | Flag | Description | Default |
137
- | ----------------- | ---------------------------------- | ---------------------- |
138
- | `--agent <agent>` | Agent to use (`claude` or `codex`) | config file (`claude`) |
139
- | `--version` | Show version | |
144
+ | Flag | Description | Default |
145
+ | ---------------------- | ----------------------------------------- | ---------------------- |
146
+ | `--agent <agent>` | Agent to use (`claude` or `codex`) | config file (`claude`) |
147
+ | `--max-iterations <n>` | Abort after `n` total iterations | unlimited |
148
+ | `--max-tokens <n>` | Abort after `n` total input+output tokens | unlimited |
149
+ | `--version` | Show version | |
140
150
 
141
151
  ## Configuration
142
152
 
@@ -152,7 +162,7 @@ maxConsecutiveFailures: 3
152
162
 
153
163
  If the file does not exist yet, `gnhf` creates it on first run using the resolved defaults.
154
164
 
155
- CLI flags override config file values.
165
+ CLI flags override config file values. The iteration and token caps are runtime-only flags and are not persisted in `config.yml`.
156
166
 
157
167
  ## Development
158
168
 
package/dist/cli.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  import { appendFileSync, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
3
  import process$1 from "node:process";
4
4
  import { createInterface } from "node:readline";
5
- import { Command } from "commander";
5
+ import { Command, InvalidArgumentError } from "commander";
6
6
  import { dirname, isAbsolute, join } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import yaml from "js-yaml";
@@ -493,8 +493,10 @@ var Orchestrator = class extends EventEmitter {
493
493
  runInfo;
494
494
  cwd;
495
495
  prompt;
496
+ limits;
496
497
  stopRequested = false;
497
498
  activeAbortController = null;
499
+ pendingAbortReason = null;
498
500
  state = {
499
501
  status: "running",
500
502
  currentIteration: 0,
@@ -509,13 +511,14 @@ var Orchestrator = class extends EventEmitter {
509
511
  waitingUntil: null,
510
512
  lastMessage: null
511
513
  };
512
- constructor(config, agent, runInfo, prompt, cwd, startIteration = 0) {
514
+ constructor(config, agent, runInfo, prompt, cwd, startIteration = 0, limits = {}) {
513
515
  super();
514
516
  this.config = config;
515
517
  this.agent = agent;
516
518
  this.runInfo = runInfo;
517
519
  this.prompt = prompt;
518
520
  this.cwd = cwd;
521
+ this.limits = limits;
519
522
  this.state.currentIteration = startIteration;
520
523
  this.state.commitCount = getBranchCommitCount(this.runInfo.baseCommit, this.cwd);
521
524
  }
@@ -535,6 +538,11 @@ var Orchestrator = class extends EventEmitter {
535
538
  this.state.status = "running";
536
539
  this.emit("state", this.getState());
537
540
  while (!this.stopRequested) {
541
+ const preIterationAbortReason = this.getPreIterationAbortReason();
542
+ if (preIterationAbortReason) {
543
+ this.abort(preIterationAbortReason);
544
+ break;
545
+ }
538
546
  this.state.currentIteration++;
539
547
  this.state.status = "running";
540
548
  this.emit("iteration:start", this.state.currentIteration);
@@ -544,15 +552,22 @@ var Orchestrator = class extends EventEmitter {
544
552
  runId: this.runInfo.runId,
545
553
  prompt: this.prompt
546
554
  });
547
- const record = await this.runIteration(iterationPrompt);
555
+ const result = await this.runIteration(iterationPrompt);
556
+ if (result.type === "aborted") {
557
+ this.abort(result.reason);
558
+ break;
559
+ }
560
+ const { record } = result;
548
561
  this.state.iterations.push(record);
549
562
  this.emit("iteration:end", record);
550
563
  this.emit("state", this.getState());
564
+ const postIterationAbortReason = this.getPostIterationAbortReason();
565
+ if (postIterationAbortReason) {
566
+ this.abort(postIterationAbortReason);
567
+ break;
568
+ }
551
569
  if (this.state.consecutiveFailures >= this.config.maxConsecutiveFailures) {
552
- this.state.status = "aborted";
553
- const reason = `${this.config.maxConsecutiveFailures} consecutive failures`;
554
- this.emit("abort", reason);
555
- this.emit("state", this.getState());
570
+ this.abort(`${this.config.maxConsecutiveFailures} consecutive failures`);
556
571
  break;
557
572
  }
558
573
  if (this.state.consecutiveFailures > 0 && !this.stopRequested) {
@@ -573,10 +588,16 @@ var Orchestrator = class extends EventEmitter {
573
588
  const baseInputTokens = this.state.totalInputTokens;
574
589
  const baseOutputTokens = this.state.totalOutputTokens;
575
590
  this.activeAbortController = new AbortController();
591
+ this.pendingAbortReason = null;
576
592
  const onUsage = (usage) => {
577
593
  this.state.totalInputTokens = baseInputTokens + usage.inputTokens;
578
594
  this.state.totalOutputTokens = baseOutputTokens + usage.outputTokens;
579
595
  this.emit("state", this.getState());
596
+ const reason = this.getTokenAbortReason();
597
+ if (reason && this.activeAbortController && !this.activeAbortController.signal.aborted) {
598
+ this.pendingAbortReason = reason;
599
+ this.activeAbortController.abort();
600
+ }
580
601
  };
581
602
  const onMessage = (text) => {
582
603
  this.state.lastMessage = text;
@@ -590,11 +611,30 @@ var Orchestrator = class extends EventEmitter {
590
611
  signal: this.activeAbortController.signal,
591
612
  logPath
592
613
  });
593
- if (result.output.success) return this.recordSuccess(result.output);
594
- return this.recordFailure(`[FAIL] ${result.output.summary}`, result.output.summary, result.output.key_learnings);
614
+ if (result.output.success) return {
615
+ type: "completed",
616
+ record: this.recordSuccess(result.output)
617
+ };
618
+ return {
619
+ type: "completed",
620
+ record: this.recordFailure(`[FAIL] ${result.output.summary}`, result.output.summary, result.output.key_learnings)
621
+ };
595
622
  } catch (err) {
623
+ if (this.pendingAbortReason && err instanceof Error && err.message === "Agent was aborted") {
624
+ resetHard(this.cwd);
625
+ return {
626
+ type: "aborted",
627
+ reason: this.pendingAbortReason
628
+ };
629
+ }
596
630
  const summary = err instanceof Error ? err.message : String(err);
597
- return this.recordFailure(`[ERROR] ${summary}`, summary, []);
631
+ return {
632
+ type: "completed",
633
+ record: this.recordFailure(`[ERROR] ${summary}`, summary, [])
634
+ };
635
+ } finally {
636
+ this.activeAbortController = null;
637
+ this.pendingAbortReason = null;
598
638
  }
599
639
  }
600
640
  recordSuccess(output) {
@@ -640,6 +680,27 @@ var Orchestrator = class extends EventEmitter {
640
680
  });
641
681
  });
642
682
  }
683
+ getPreIterationAbortReason() {
684
+ if (this.limits.maxIterations !== void 0 && this.state.currentIteration >= this.limits.maxIterations) return `max iterations reached (${this.limits.maxIterations})`;
685
+ return this.getTokenAbortReason();
686
+ }
687
+ getPostIterationAbortReason() {
688
+ if (this.limits.maxIterations !== void 0 && this.state.currentIteration >= this.limits.maxIterations) return `max iterations reached (${this.limits.maxIterations})`;
689
+ return this.getTokenAbortReason();
690
+ }
691
+ getTokenAbortReason() {
692
+ if (this.limits.maxTokens === void 0) return null;
693
+ const totalTokens = this.state.totalInputTokens + this.state.totalOutputTokens;
694
+ if (totalTokens < this.limits.maxTokens) return null;
695
+ return `max tokens reached (${totalTokens}/${this.limits.maxTokens})`;
696
+ }
697
+ abort(reason) {
698
+ this.state.status = "aborted";
699
+ this.state.lastMessage = reason;
700
+ this.state.waitingUntil = null;
701
+ this.emit("abort", reason);
702
+ this.emit("state", this.getState());
703
+ }
643
704
  };
644
705
  //#endregion
645
706
  //#region src/mock-orchestrator.ts
@@ -1231,6 +1292,12 @@ function slugifyPrompt(prompt) {
1231
1292
  //#endregion
1232
1293
  //#region src/cli.ts
1233
1294
  const packageVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
1295
+ function parseNonNegativeInteger(value) {
1296
+ if (!/^\d+$/.test(value)) throw new InvalidArgumentError("must be a non-negative integer");
1297
+ const parsed = Number.parseInt(value, 10);
1298
+ if (!Number.isSafeInteger(parsed)) throw new InvalidArgumentError("must be a safe integer");
1299
+ return parsed;
1300
+ }
1234
1301
  function humanizeErrorMessage(message) {
1235
1302
  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.";
1236
1303
  return message;
@@ -1256,7 +1323,7 @@ function ask(question) {
1256
1323
  });
1257
1324
  }
1258
1325
  const program = new Command();
1259
- 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 or codex)").option("--mock", "", false).action(async (promptArg, options) => {
1326
+ 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 or codex)").option("--max-iterations <n>", "Abort after N total iterations", parseNonNegativeInteger).option("--max-tokens <n>", "Abort after N total input+output tokens", parseNonNegativeInteger).option("--mock", "", false).action(async (promptArg, options) => {
1260
1327
  if (options.mock) {
1261
1328
  const mock = new MockOrchestrator();
1262
1329
  enterAltScreen();
@@ -1307,7 +1374,10 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
1307
1374
  }
1308
1375
  runInfo = initializeNewBranch(prompt, cwd);
1309
1376
  }
1310
- const orchestrator = new Orchestrator(config, createAgent(config.agent, runInfo), runInfo, prompt, cwd, startIteration);
1377
+ const orchestrator = new Orchestrator(config, createAgent(config.agent, runInfo), runInfo, prompt, cwd, startIteration, {
1378
+ maxIterations: options.maxIterations,
1379
+ maxTokens: options.maxTokens
1380
+ });
1311
1381
  enterAltScreen();
1312
1382
  const renderer = new Renderer(orchestrator, prompt);
1313
1383
  renderer.start();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gnhf",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Before I go to bed, I tell my agents: good night, have fun",
5
5
  "type": "module",
6
6
  "bin": {