gnhf 0.1.19 → 0.1.21

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 +9 -8
  2. package/dist/cli.mjs +120 -45
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -46,6 +46,7 @@ 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
48
  - **Long running** — each iteration is committed on success, rolled back on failure, with sensible retries and exponential backoff
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
49
50
  - **Agent-agnostic** — works with Claude Code, Codex, Rovo Dev, or OpenCode out of the box
50
51
 
51
52
  ## Quick Start
@@ -166,14 +167,14 @@ Pass `--worktree` to run each agent in an isolated [git worktree](https://git-sc
166
167
 
167
168
  ### Flags
168
169
 
169
- | Flag | Description | Default |
170
- | ------------------------ | ------------------------------------------------------------------ | ---------------------- |
171
- | `--agent <agent>` | Agent to use (`claude`, `codex`, `rovodev`, or `opencode`) | config file (`claude`) |
172
- | `--max-iterations <n>` | Abort after `n` total iterations | unlimited |
173
- | `--max-tokens <n>` | Abort after `n` total input+output tokens | unlimited |
174
- | `--prevent-sleep <mode>` | Prevent system sleep during the run (`on`/`off` or `true`/`false`) | config file (`on`) |
175
- | `--worktree` | Run in a separate git worktree (enables multiple agents concurrently) | `false` |
176
- | `--version` | Show version | |
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 | |
177
178
 
178
179
  ## Configuration
179
180
 
package/dist/cli.mjs CHANGED
@@ -6,7 +6,7 @@ import process$1 from "node:process";
6
6
  import { createInterface } from "node:readline";
7
7
  import { Command, InvalidArgumentError } from "commander";
8
8
  import yaml from "js-yaml";
9
- import { execFileSync, execSync, spawn } from "node:child_process";
9
+ import { execFileSync, spawn } from "node:child_process";
10
10
  import { createServer } from "node:net";
11
11
  import { EventEmitter } from "node:events";
12
12
  import { createHash } from "node:crypto";
@@ -302,32 +302,24 @@ const NOT_GIT_REPOSITORY_MESSAGE = "This command must be run inside a Git reposi
302
302
  function translateGitError(error) {
303
303
  return error instanceof Error ? error : new Error(String(error));
304
304
  }
305
- function git(args, cwd) {
305
+ function git(args, cwd, options = {}) {
306
306
  try {
307
- return execSync(`git ${args}`, {
307
+ return execFileSync("git", args, {
308
308
  cwd,
309
309
  encoding: "utf-8",
310
- stdio: "pipe"
310
+ stdio: "pipe",
311
+ ...options.env ? { env: options.env } : {}
311
312
  }).trim();
312
313
  } catch (error) {
313
314
  throw translateGitError(error);
314
315
  }
315
316
  }
316
- /** Wrap a value in single quotes, escaping embedded single quotes for POSIX shells. */
317
- function shellEscape(value) {
318
- return `'${value.replace(/'/g, "'\\''")}'`;
319
- }
320
317
  function isGitRepository(cwd) {
321
318
  try {
322
- execSync("git rev-parse --git-dir", {
323
- cwd,
324
- encoding: "utf-8",
325
- stdio: "pipe",
326
- env: {
327
- ...process.env,
328
- LC_ALL: "C"
329
- }
330
- });
319
+ git(["rev-parse", "--git-dir"], cwd, { env: {
320
+ ...process.env,
321
+ LC_ALL: "C"
322
+ } });
331
323
  return true;
332
324
  } catch {
333
325
  return false;
@@ -339,23 +331,41 @@ function ensureGitRepository(cwd) {
339
331
  function getCurrentBranch(cwd) {
340
332
  ensureGitRepository(cwd);
341
333
  try {
342
- return git("symbolic-ref --short HEAD", cwd);
334
+ return git([
335
+ "symbolic-ref",
336
+ "--short",
337
+ "HEAD"
338
+ ], cwd);
343
339
  } catch {
344
- return git("rev-parse --abbrev-ref HEAD", cwd);
340
+ return git([
341
+ "rev-parse",
342
+ "--abbrev-ref",
343
+ "HEAD"
344
+ ], cwd);
345
345
  }
346
346
  }
347
347
  function ensureCleanWorkingTree(cwd) {
348
- if (git("status --porcelain", cwd)) throw new Error("Working tree is not clean. Commit or stash changes first.");
348
+ if (git(["status", "--porcelain"], cwd)) throw new Error("Working tree is not clean. Commit or stash changes first.");
349
349
  }
350
350
  function createBranch(branchName, cwd) {
351
- git(`checkout -b ${branchName}`, cwd);
351
+ git([
352
+ "checkout",
353
+ "-b",
354
+ branchName
355
+ ], cwd);
352
356
  }
353
357
  function getHeadCommit(cwd) {
354
- return git("rev-parse HEAD", cwd);
358
+ return git(["rev-parse", "HEAD"], cwd);
355
359
  }
356
360
  function findLegacyRunBaseCommit(runId, cwd) {
357
361
  try {
358
- const marker = git("log --first-parent --reverse --format=%H%x09%s HEAD", cwd).split("\n").map((line) => {
362
+ const marker = git([
363
+ "log",
364
+ "--first-parent",
365
+ "--reverse",
366
+ "--format=%H%x09%s",
367
+ "HEAD"
368
+ ], cwd).split("\n").map((line) => {
359
369
  const [sha, ...subjectParts] = line.split(" ");
360
370
  return {
361
371
  sha,
@@ -363,33 +373,57 @@ function findLegacyRunBaseCommit(runId, cwd) {
363
373
  };
364
374
  }).find(({ subject }) => subject === `gnhf: initialize run ${runId}` || subject === `gnhf: overwrite run ${runId}`);
365
375
  if (!marker?.sha) return null;
366
- return git(`rev-parse ${marker.sha}^`, cwd);
376
+ return git(["rev-parse", `${marker.sha}^`], cwd);
367
377
  } catch {
368
378
  return null;
369
379
  }
370
380
  }
371
381
  function getBranchCommitCount(baseCommit, cwd) {
372
382
  if (!baseCommit) return 0;
373
- return Number.parseInt(git(`rev-list --count --first-parent ${baseCommit}..HEAD`, cwd), 10);
383
+ return Number.parseInt(git([
384
+ "rev-list",
385
+ "--count",
386
+ "--first-parent",
387
+ `${baseCommit}..HEAD`
388
+ ], cwd), 10);
374
389
  }
375
390
  function commitAll(message, cwd) {
376
- git("add -A", cwd);
391
+ git(["add", "-A"], cwd);
377
392
  try {
378
- git(`commit -m "${message.replace(/"/g, "\\\"")}"`, cwd);
393
+ git([
394
+ "commit",
395
+ "-m",
396
+ message
397
+ ], cwd);
379
398
  } catch {}
380
399
  }
381
400
  function resetHard(cwd) {
382
- git("reset --hard HEAD", cwd);
383
- git("clean -fd", cwd);
401
+ git([
402
+ "reset",
403
+ "--hard",
404
+ "HEAD"
405
+ ], cwd);
406
+ git(["clean", "-fd"], cwd);
384
407
  }
385
408
  function getRepoRootDir(cwd) {
386
- return git("rev-parse --show-toplevel", cwd);
409
+ return git(["rev-parse", "--show-toplevel"], cwd);
387
410
  }
388
411
  function createWorktree(baseCwd, worktreePath, branchName) {
389
- git(`worktree add -b ${shellEscape(branchName)} ${shellEscape(worktreePath)}`, baseCwd);
412
+ git([
413
+ "worktree",
414
+ "add",
415
+ "-b",
416
+ branchName,
417
+ worktreePath
418
+ ], baseCwd);
390
419
  }
391
420
  function removeWorktree(baseCwd, worktreePath) {
392
- git(`worktree remove --force ${shellEscape(worktreePath)}`, baseCwd);
421
+ git([
422
+ "worktree",
423
+ "remove",
424
+ "--force",
425
+ worktreePath
426
+ ], baseCwd);
393
427
  }
394
428
  //#endregion
395
429
  //#region src/core/agents/types.ts
@@ -3496,6 +3530,24 @@ const DONE_HINT = "[ctrl+c to exit]";
3496
3530
  function spacedLabel(text) {
3497
3531
  return text.split("").join(" ");
3498
3532
  }
3533
+ function formatTokenCount(tokens, direction) {
3534
+ return `${formatTokens(tokens)} ${direction}`;
3535
+ }
3536
+ function formatCommitCount(commitCount) {
3537
+ return `${commitCount} ${commitCount === 1 ? "commit" : "commits"}`;
3538
+ }
3539
+ function buildTerminalTitle(state, now) {
3540
+ return `gnhf ${state.status === "running" || state.status === "waiting" ? getMoonPhase("active", now, MOON_PHASE_PERIOD) : state.status} · ${formatTokenCount(state.totalInputTokens, "in")} · ${formatTokenCount(state.totalOutputTokens, "out")} · ${formatCommitCount(state.commitCount)}`;
3541
+ }
3542
+ function emitTerminalTitle(title) {
3543
+ return `\x1b]2;${title}\x07`;
3544
+ }
3545
+ function saveTerminalTitle() {
3546
+ return "\x1B[22;0t";
3547
+ }
3548
+ function restoreTerminalTitle() {
3549
+ return "\x1B[23;0t";
3550
+ }
3499
3551
  function renderTitleCells(agentName) {
3500
3552
  return [
3501
3553
  [...textToCells(spacedLabel("gnhf"), "dim"), ...agentName ? [
@@ -3511,21 +3563,20 @@ function renderTitleCells(agentName) {
3511
3563
  ];
3512
3564
  }
3513
3565
  function renderStatsCells(elapsed, inputTokens, outputTokens, commitCount) {
3514
- const commitLabel = commitCount === 1 ? "commit" : "commits";
3515
3566
  return [
3516
3567
  ...textToCells(elapsed, "bold"),
3517
3568
  ...textToCells(" ", "normal"),
3518
3569
  ...textToCells("·", "dim"),
3519
3570
  ...textToCells(" ", "normal"),
3520
- ...textToCells(`${formatTokens(inputTokens)} in`, "normal"),
3571
+ ...textToCells(formatTokenCount(inputTokens, "in"), "normal"),
3521
3572
  ...textToCells(" ", "normal"),
3522
3573
  ...textToCells("·", "dim"),
3523
3574
  ...textToCells(" ", "normal"),
3524
- ...textToCells(`${formatTokens(outputTokens)} out`, "normal"),
3575
+ ...textToCells(formatTokenCount(outputTokens, "out"), "normal"),
3525
3576
  ...textToCells(" ", "normal"),
3526
3577
  ...textToCells("·", "dim"),
3527
3578
  ...textToCells(" ", "normal"),
3528
- ...textToCells(`${commitCount} ${commitLabel}`, "normal")
3579
+ ...textToCells(formatCommitCount(commitCount), "normal")
3529
3580
  ];
3530
3581
  }
3531
3582
  function renderAgentMessageCells(message, status) {
@@ -3741,10 +3792,22 @@ var Renderer = class {
3741
3792
  cachedWidth = 0;
3742
3793
  cachedHeight = 0;
3743
3794
  prevCells = [];
3795
+ prevTitle = null;
3796
+ titleSaved = false;
3744
3797
  isFirstFrame = true;
3745
3798
  seedTop;
3746
3799
  seedBottom;
3747
3800
  seedSide;
3801
+ handleState = (newState) => {
3802
+ this.state = {
3803
+ ...newState,
3804
+ iterations: [...newState.iterations]
3805
+ };
3806
+ this.updateTerminalTitle();
3807
+ };
3808
+ handleStopped = () => {
3809
+ this.stop("stopped");
3810
+ };
3748
3811
  constructor(orchestrator, prompt, agentName) {
3749
3812
  this.orchestrator = orchestrator;
3750
3813
  this.prompt = prompt;
@@ -3758,15 +3821,8 @@ var Renderer = class {
3758
3821
  });
3759
3822
  }
3760
3823
  start() {
3761
- this.orchestrator.on("state", (newState) => {
3762
- this.state = {
3763
- ...newState,
3764
- iterations: [...newState.iterations]
3765
- };
3766
- });
3767
- this.orchestrator.on("stopped", () => {
3768
- this.stop("stopped");
3769
- });
3824
+ this.orchestrator.on("state", this.handleState);
3825
+ this.orchestrator.on("stopped", this.handleStopped);
3770
3826
  if (process$1.stdin.isTTY) {
3771
3827
  process$1.stdin.setRawMode(true);
3772
3828
  process$1.stdin.resume();
@@ -3785,11 +3841,18 @@ var Renderer = class {
3785
3841
  clearInterval(this.interval);
3786
3842
  this.interval = null;
3787
3843
  }
3844
+ this.orchestrator.off("state", this.handleState);
3845
+ this.orchestrator.off("stopped", this.handleStopped);
3788
3846
  if (process$1.stdin.isTTY) {
3789
3847
  process$1.stdin.setRawMode(false);
3790
3848
  process$1.stdin.pause();
3791
3849
  process$1.stdin.removeAllListeners("data");
3792
3850
  }
3851
+ if (this.titleSaved) {
3852
+ process$1.stdout.write(restoreTerminalTitle());
3853
+ this.titleSaved = false;
3854
+ this.prevTitle = null;
3855
+ }
3793
3856
  this.exitResolve(reason);
3794
3857
  }
3795
3858
  waitUntilExit() {
@@ -3828,6 +3891,7 @@ var Renderer = class {
3828
3891
  const w = process$1.stdout.columns || 80;
3829
3892
  const h = process$1.stdout.rows || 24;
3830
3893
  const resized = this.ensureStarFields(w, h);
3894
+ this.updateTerminalTitle(now);
3831
3895
  const nextCells = buildFrameCells(this.prompt, this.agentName, this.state, this.topStars, this.bottomStars, this.sideStars, now, w, h);
3832
3896
  if (this.isFirstFrame || resized) {
3833
3897
  process$1.stdout.write("\x1B[H" + nextCells.map(rowToString).join("\n"));
@@ -3838,6 +3902,17 @@ var Renderer = class {
3838
3902
  }
3839
3903
  this.prevCells = nextCells;
3840
3904
  }
3905
+ updateTerminalTitle(now = Date.now()) {
3906
+ if (!process$1.stdout.isTTY) return;
3907
+ const nextTitle = buildTerminalTitle(this.state, now);
3908
+ if (!this.titleSaved) {
3909
+ process$1.stdout.write(saveTerminalTitle());
3910
+ this.titleSaved = true;
3911
+ }
3912
+ if (nextTitle === this.prevTitle) return;
3913
+ process$1.stdout.write(emitTerminalTitle(nextTitle));
3914
+ this.prevTitle = nextTitle;
3915
+ }
3841
3916
  };
3842
3917
  //#endregion
3843
3918
  //#region src/utils/slugify.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gnhf",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "Before I go to bed, I tell my agents: good night, have fun",
5
5
  "type": "module",
6
6
  "bin": {