gnhf 0.1.9 → 0.1.11

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 -0
  2. package/dist/cli.mjs +411 -67
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -160,6 +160,11 @@ Config lives at `~/.gnhf/config.yml`:
160
160
  # Agent to use by default (claude, codex, rovodev, or opencode)
161
161
  agent: claude
162
162
 
163
+ # Custom paths to agent binaries (optional)
164
+ # agentPathOverride:
165
+ # claude: /path/to/custom-claude
166
+ # codex: /path/to/custom-codex
167
+
163
168
  # Abort after this many consecutive failures
164
169
  maxConsecutiveFailures: 3
165
170
 
@@ -171,6 +176,18 @@ If the file does not exist yet, `gnhf` creates it on first run using the resolve
171
176
 
172
177
  CLI flags override config file values. `--prevent-sleep` accepts `on`/`off` as well as `true`/`false`; the config file always uses a boolean.
173
178
  The iteration and token caps are runtime-only flags and are not persisted in `config.yml`.
179
+
180
+ ### Custom Agent Paths
181
+
182
+ Use `agentPathOverride` to point any agent at a custom binary — useful for wrappers like Claude Code Switch or custom Codex builds that accept the same flags and arguments as the original:
183
+
184
+ ```yaml
185
+ agentPathOverride:
186
+ claude: ~/bin/claude-code-switch
187
+ codex: /usr/local/bin/my-codex-wrapper
188
+ ```
189
+
190
+ Paths may be absolute, bare executable names already on your `PATH`, `~`-prefixed, or relative to the config directory (`~/.gnhf/`). The override replaces only the binary name; all standard arguments are preserved, so the replacement must be CLI-compatible with the original agent. On Windows, `.cmd` and `.bat` wrappers are supported, including bare names resolved from `PATH`. For `rovodev`, the override must point to an `acli`-compatible binary since gnhf invokes it as `<bin> rovodev serve ...`.
174
191
  When sleep prevention is enabled, `gnhf` uses the native mechanism for your OS: `caffeinate` on macOS, `systemd-inhibit` on Linux, and a small PowerShell helper backed by `SetThreadExecutionState` on Windows.
175
192
 
176
193
  ## Debug Logs
package/dist/cli.mjs CHANGED
@@ -11,8 +11,15 @@ import { createServer } from "node:net";
11
11
  import { EventEmitter } from "node:events";
12
12
  import { createHash } from "node:crypto";
13
13
  //#region src/core/config.ts
14
+ const AGENT_NAMES = [
15
+ "claude",
16
+ "codex",
17
+ "rovodev",
18
+ "opencode"
19
+ ];
14
20
  const DEFAULT_CONFIG = {
15
21
  agent: "claude",
22
+ agentPathOverride: {},
16
23
  maxConsecutiveFailures: 3,
17
24
  preventSleep: true
18
25
  };
@@ -25,7 +32,35 @@ function normalizePreventSleep(value) {
25
32
  if (value === "on") return true;
26
33
  if (value === "off") return false;
27
34
  }
28
- function normalizeConfig(config) {
35
+ /**
36
+ * Resolve a user-supplied path against the config directory (~/.gnhf).
37
+ * Expands leading `~` or `~/` to the home directory, then resolves relative
38
+ * paths against `baseDir` so that entries like `./bin/codex` work predictably
39
+ * regardless of the repo's cwd. Bare executable names and absolute paths pass
40
+ * through unchanged.
41
+ */
42
+ function resolveConfigPath(raw, baseDir) {
43
+ if (raw !== "~" && !raw.startsWith("~/") && !raw.startsWith("~\\") && !raw.includes("/") && !raw.includes("\\")) return raw;
44
+ const home = homedir();
45
+ let expanded = raw;
46
+ if (expanded === "~") expanded = home;
47
+ else if (expanded.startsWith("~/") || expanded.startsWith("~\\")) expanded = join(home, expanded.slice(2));
48
+ return resolve(baseDir, expanded);
49
+ }
50
+ function normalizeAgentPathOverride(value, configDir) {
51
+ if (value === void 0 || value === null) return void 0;
52
+ if (typeof value !== "object" || Array.isArray(value)) throw new InvalidConfigError(`Invalid config value for agentPathOverride: expected an object mapping agent names to paths`);
53
+ const validNames = new Set(AGENT_NAMES);
54
+ const result = {};
55
+ for (const [key, val] of Object.entries(value)) {
56
+ if (!validNames.has(key)) throw new InvalidConfigError(`Invalid agent name in agentPathOverride: "${key}". Use "claude", "codex", "rovodev", or "opencode".`);
57
+ if (typeof val !== "string") throw new InvalidConfigError(`Invalid path for agentPathOverride.${key}: expected a string`);
58
+ if (val.trim() === "") throw new InvalidConfigError(`Invalid path for agentPathOverride.${key}: expected a non-empty string`);
59
+ result[key] = resolveConfigPath(val, configDir);
60
+ }
61
+ return result;
62
+ }
63
+ function normalizeConfig(config, configDir) {
29
64
  const normalized = { ...config };
30
65
  const hasPreventSleep = Object.prototype.hasOwnProperty.call(config, "preventSleep");
31
66
  const preventSleep = normalizePreventSleep(config.preventSleep);
@@ -33,22 +68,47 @@ function normalizeConfig(config) {
33
68
  if (hasPreventSleep && config.preventSleep !== void 0) throw new InvalidConfigError(`Invalid config value for preventSleep: ${String(config.preventSleep)}`);
34
69
  delete normalized.preventSleep;
35
70
  } else normalized.preventSleep = preventSleep;
71
+ if (Object.prototype.hasOwnProperty.call(config, "agentPathOverride")) {
72
+ const resolveDir = configDir ?? join(homedir(), ".gnhf");
73
+ const agentPathOverride = normalizeAgentPathOverride(config.agentPathOverride, resolveDir);
74
+ if (agentPathOverride === void 0) delete normalized.agentPathOverride;
75
+ else normalized.agentPathOverride = agentPathOverride;
76
+ } else delete normalized.agentPathOverride;
36
77
  return normalized;
37
78
  }
38
79
  function isMissingConfigError(error) {
39
80
  if (!(error instanceof Error)) return false;
40
81
  return "code" in error ? error.code === "ENOENT" : error.message.includes("ENOENT");
41
82
  }
83
+ function serializeAgentPathOverride(agentPathOverride) {
84
+ const serializedOverrides = Object.fromEntries(AGENT_NAMES.flatMap((name) => {
85
+ const value = agentPathOverride[name];
86
+ return value === void 0 ? [] : [[name, value]];
87
+ }));
88
+ if (Object.keys(serializedOverrides).length === 0) return "";
89
+ return yaml.dump({ agentPathOverride: serializedOverrides }, {
90
+ lineWidth: -1,
91
+ noRefs: true,
92
+ sortKeys: false
93
+ }).trimEnd();
94
+ }
42
95
  function serializeConfig(config) {
43
- return `# Agent to use by default
44
- agent: ${config.agent}
45
-
46
- # Abort after this many consecutive failures
47
- maxConsecutiveFailures: ${config.maxConsecutiveFailures}
48
-
49
- # Prevent the machine from sleeping during a run
50
- preventSleep: ${config.preventSleep}
51
- `;
96
+ const agentPathOverrideSection = serializeAgentPathOverride(config.agentPathOverride);
97
+ const lines = [
98
+ "# Agent to use by default",
99
+ `agent: ${config.agent}`,
100
+ "",
101
+ "# Custom paths to agent binaries (optional)",
102
+ "# Paths may be absolute, bare executable names on PATH,",
103
+ "# ~-prefixed, or relative to this config directory.",
104
+ "# Note: rovodev overrides must point to an acli-compatible binary.",
105
+ "# agentPathOverride:",
106
+ "# claude: /path/to/custom-claude",
107
+ "# codex: /path/to/custom-codex"
108
+ ];
109
+ if (agentPathOverrideSection) lines.push(...agentPathOverrideSection.split("\n"));
110
+ lines.push("", "# Abort after this many consecutive failures", `maxConsecutiveFailures: ${config.maxConsecutiveFailures}`, "", "# Prevent the machine from sleeping during a run", `preventSleep: ${config.preventSleep}`, "");
111
+ return lines.join("\n");
52
112
  }
53
113
  function loadConfig(overrides) {
54
114
  const configDir = join(homedir(), ".gnhf");
@@ -57,7 +117,7 @@ function loadConfig(overrides) {
57
117
  let shouldBootstrapConfig = false;
58
118
  try {
59
119
  const raw = readFileSync(configPath, "utf-8");
60
- fileConfig = normalizeConfig(yaml.load(raw) ?? {});
120
+ fileConfig = normalizeConfig(yaml.load(raw) ?? {}, configDir);
61
121
  } catch (error) {
62
122
  if (error instanceof InvalidConfigError) throw error;
63
123
  if (isMissingConfigError(error)) shouldBootstrapConfig = true;
@@ -745,10 +805,12 @@ function parseJSONLStream(stream, logStream, callback) {
745
805
  * Wire an AbortSignal to kill a child process.
746
806
  * Returns true if the signal was already aborted (caller should return early).
747
807
  */
748
- function setupAbortHandler(signal, child, reject) {
808
+ function setupAbortHandler(signal, child, reject, abortChild = () => {
809
+ child.kill("SIGTERM");
810
+ }) {
749
811
  if (!signal) return false;
750
812
  const onAbort = () => {
751
- child.kill("SIGTERM");
813
+ abortChild();
752
814
  reject(/* @__PURE__ */ new Error("Agent was aborted"));
753
815
  };
754
816
  if (signal.aborted) {
@@ -761,13 +823,52 @@ function setupAbortHandler(signal, child, reject) {
761
823
  }
762
824
  //#endregion
763
825
  //#region src/core/agents/claude.ts
826
+ function shouldUseWindowsShell$2(bin, platform) {
827
+ if (platform !== "win32") return false;
828
+ if (/\.(cmd|bat)$/i.test(bin)) return true;
829
+ if (/[\\/]/.test(bin)) return false;
830
+ try {
831
+ const firstMatch = execFileSync("where", [bin], {
832
+ encoding: "utf8",
833
+ stdio: [
834
+ "ignore",
835
+ "pipe",
836
+ "ignore"
837
+ ]
838
+ }).split(/\r?\n/).map((line) => line.trim()).find(Boolean);
839
+ return firstMatch ? /\.(cmd|bat)$/i.test(firstMatch) : false;
840
+ } catch {
841
+ return false;
842
+ }
843
+ }
844
+ function terminateClaudeProcess(child, platform) {
845
+ if (platform === "win32" && child.pid) {
846
+ try {
847
+ execFileSync("taskkill", [
848
+ "/T",
849
+ "/F",
850
+ "/PID",
851
+ String(child.pid)
852
+ ], { stdio: "ignore" });
853
+ } catch {}
854
+ return;
855
+ }
856
+ child.kill("SIGTERM");
857
+ }
764
858
  var ClaudeAgent = class {
765
859
  name = "claude";
860
+ bin;
861
+ platform;
862
+ constructor(binOrDeps = {}) {
863
+ const deps = typeof binOrDeps === "string" ? { bin: binOrDeps } : binOrDeps;
864
+ this.bin = deps.bin ?? "claude";
865
+ this.platform = deps.platform ?? process.platform;
866
+ }
766
867
  run(prompt, cwd, options) {
767
868
  const { onUsage, onMessage, signal, logPath } = options ?? {};
768
869
  return new Promise((resolve, reject) => {
769
870
  const logStream = logPath ? createWriteStream(logPath) : null;
770
- const child = spawn("claude", [
871
+ const child = spawn(this.bin, [
771
872
  "-p",
772
873
  prompt,
773
874
  "--verbose",
@@ -778,6 +879,7 @@ var ClaudeAgent = class {
778
879
  "--dangerously-skip-permissions"
779
880
  ], {
780
881
  cwd,
882
+ shell: shouldUseWindowsShell$2(this.bin, this.platform),
781
883
  stdio: [
782
884
  "ignore",
783
885
  "pipe",
@@ -785,7 +887,7 @@ var ClaudeAgent = class {
785
887
  ],
786
888
  env: process.env
787
889
  });
788
- if (setupAbortHandler(signal, child, reject)) return;
890
+ if (setupAbortHandler(signal, child, reject, () => terminateClaudeProcess(child, this.platform))) return;
789
891
  let resultEvent = null;
790
892
  const cumulative = {
791
893
  inputTokens: 0,
@@ -841,17 +943,54 @@ var ClaudeAgent = class {
841
943
  };
842
944
  //#endregion
843
945
  //#region src/core/agents/codex.ts
946
+ function shouldUseWindowsShell$1(bin, platform) {
947
+ if (platform !== "win32") return false;
948
+ if (/\.(cmd|bat)$/i.test(bin)) return true;
949
+ if (/[\\/]/.test(bin)) return false;
950
+ try {
951
+ const firstMatch = execFileSync("where", [bin], {
952
+ encoding: "utf8",
953
+ stdio: [
954
+ "ignore",
955
+ "pipe",
956
+ "ignore"
957
+ ]
958
+ }).split(/\r?\n/).map((line) => line.trim()).find(Boolean);
959
+ return firstMatch ? /\.(cmd|bat)$/i.test(firstMatch) : false;
960
+ } catch {
961
+ return false;
962
+ }
963
+ }
964
+ function terminateCodexProcess(child, platform) {
965
+ if (platform === "win32" && child.pid) {
966
+ try {
967
+ execFileSync("taskkill", [
968
+ "/T",
969
+ "/F",
970
+ "/PID",
971
+ String(child.pid)
972
+ ], { stdio: "ignore" });
973
+ } catch {}
974
+ return;
975
+ }
976
+ child.kill("SIGTERM");
977
+ }
844
978
  var CodexAgent = class {
845
979
  name = "codex";
980
+ bin;
981
+ platform;
846
982
  schemaPath;
847
- constructor(schemaPath) {
983
+ constructor(schemaPath, binOrDeps = {}) {
984
+ const deps = typeof binOrDeps === "string" ? { bin: binOrDeps } : binOrDeps;
985
+ this.bin = deps.bin ?? "codex";
986
+ this.platform = deps.platform ?? process.platform;
848
987
  this.schemaPath = schemaPath;
849
988
  }
850
989
  run(prompt, cwd, options) {
851
990
  const { onUsage, onMessage, signal, logPath } = options ?? {};
852
991
  return new Promise((resolve, reject) => {
853
992
  const logStream = logPath ? createWriteStream(logPath) : null;
854
- const child = spawn("codex", [
993
+ const child = spawn(this.bin, [
855
994
  "exec",
856
995
  prompt,
857
996
  "--json",
@@ -862,6 +1001,7 @@ var CodexAgent = class {
862
1001
  "never"
863
1002
  ], {
864
1003
  cwd,
1004
+ shell: shouldUseWindowsShell$1(this.bin, this.platform),
865
1005
  stdio: [
866
1006
  "ignore",
867
1007
  "pipe",
@@ -869,7 +1009,7 @@ var CodexAgent = class {
869
1009
  ],
870
1010
  env: process.env
871
1011
  });
872
- if (setupAbortHandler(signal, child, reject)) return;
1012
+ if (setupAbortHandler(signal, child, reject, () => terminateCodexProcess(child, this.platform))) return;
873
1013
  let lastAgentMessage = null;
874
1014
  const cumulative = {
875
1015
  inputTokens: 0,
@@ -1018,6 +1158,7 @@ function withTimeoutSignal$1(signal, timeoutMs) {
1018
1158
  }
1019
1159
  var OpenCodeAgent = class {
1020
1160
  name = "opencode";
1161
+ bin;
1021
1162
  fetchFn;
1022
1163
  getPortFn;
1023
1164
  killProcessFn;
@@ -1026,6 +1167,7 @@ var OpenCodeAgent = class {
1026
1167
  server = null;
1027
1168
  closingPromise = null;
1028
1169
  constructor(deps = {}) {
1170
+ this.bin = deps.bin ?? "opencode";
1029
1171
  this.fetchFn = deps.fetch ?? fetch;
1030
1172
  this.getPortFn = deps.getPort ?? getAvailablePort$1;
1031
1173
  this.killProcessFn = deps.killProcess ?? process.kill.bind(process);
@@ -1077,7 +1219,7 @@ var OpenCodeAgent = class {
1077
1219
  const port = await this.getPortFn();
1078
1220
  const isWindows = this.platform === "win32";
1079
1221
  const detached = !isWindows;
1080
- const child = this.spawnFn("opencode", [
1222
+ const child = this.spawnFn(this.bin, [
1081
1223
  "serve",
1082
1224
  "--hostname",
1083
1225
  "127.0.0.1",
@@ -1463,6 +1605,38 @@ function createAbortError() {
1463
1605
  function isAbortError(error) {
1464
1606
  return error instanceof Error && error.name === "AbortError";
1465
1607
  }
1608
+ function shouldUseWindowsShell(bin, platform) {
1609
+ if (platform !== "win32") return false;
1610
+ if (/\.(cmd|bat)$/i.test(bin)) return true;
1611
+ if (/[\\/]/.test(bin)) return false;
1612
+ try {
1613
+ const firstMatch = execFileSync("where", [bin], {
1614
+ encoding: "utf8",
1615
+ stdio: [
1616
+ "ignore",
1617
+ "pipe",
1618
+ "ignore"
1619
+ ]
1620
+ }).split(/\r?\n/).map((line) => line.trim()).find(Boolean);
1621
+ return firstMatch ? /\.(cmd|bat)$/i.test(firstMatch) : false;
1622
+ } catch {
1623
+ return false;
1624
+ }
1625
+ }
1626
+ function terminateRovoDevProcess(child, platform) {
1627
+ if (platform === "win32" && child.pid) {
1628
+ try {
1629
+ execFileSync("taskkill", [
1630
+ "/T",
1631
+ "/F",
1632
+ "/PID",
1633
+ String(child.pid)
1634
+ ], { stdio: "ignore" });
1635
+ } catch {}
1636
+ return;
1637
+ }
1638
+ child.kill("SIGTERM");
1639
+ }
1466
1640
  function getAvailablePort() {
1467
1641
  return new Promise((resolve, reject) => {
1468
1642
  const server = createServer();
@@ -1509,18 +1683,22 @@ async function delay(ms, signal) {
1509
1683
  }
1510
1684
  var RovoDevAgent = class {
1511
1685
  name = "rovodev";
1686
+ bin;
1512
1687
  schemaPath;
1513
1688
  fetchFn;
1514
1689
  getPortFn;
1515
1690
  killProcessFn;
1691
+ platform;
1516
1692
  spawnFn;
1517
1693
  server = null;
1518
1694
  closingPromise = null;
1519
1695
  constructor(schemaPath, deps = {}) {
1696
+ this.bin = deps.bin ?? "acli";
1520
1697
  this.schemaPath = schemaPath;
1521
1698
  this.fetchFn = deps.fetch ?? fetch;
1522
1699
  this.getPortFn = deps.getPort ?? getAvailablePort;
1523
1700
  this.killProcessFn = deps.killProcess ?? process.kill.bind(process);
1701
+ this.platform = deps.platform ?? process.platform;
1524
1702
  this.spawnFn = deps.spawn ?? spawn;
1525
1703
  }
1526
1704
  async run(prompt, cwd, options) {
@@ -1564,8 +1742,8 @@ var RovoDevAgent = class {
1564
1742
  }
1565
1743
  if (this.server && !this.server.closed) await this.shutdownServer();
1566
1744
  const port = await this.getPortFn();
1567
- const detached = process.platform !== "win32";
1568
- const child = this.spawnFn("acli", [
1745
+ const detached = this.platform !== "win32";
1746
+ const child = this.spawnFn(this.bin, [
1569
1747
  "rovodev",
1570
1748
  "serve",
1571
1749
  "--disable-session-token",
@@ -1573,6 +1751,7 @@ var RovoDevAgent = class {
1573
1751
  ], {
1574
1752
  cwd,
1575
1753
  detached,
1754
+ shell: shouldUseWindowsShell(this.bin, this.platform),
1576
1755
  stdio: [
1577
1756
  "ignore",
1578
1757
  "pipe",
@@ -1837,11 +2016,29 @@ var RovoDevAgent = class {
1837
2016
  cwd: server.cwd,
1838
2017
  port: server.port
1839
2018
  });
1840
- this.closingPromise = shutdownChildProcess(server.child, {
2019
+ this.closingPromise = this.platform === "win32" ? new Promise((resolve) => {
2020
+ const handleClose = () => {
2021
+ server.child.off("close", handleClose);
2022
+ resolve();
2023
+ };
2024
+ server.child.on("close", handleClose);
2025
+ try {
2026
+ terminateRovoDevProcess(server.child, this.platform);
2027
+ } catch {
2028
+ server.child.off("close", handleClose);
2029
+ resolve();
2030
+ return;
2031
+ }
2032
+ setTimeout(() => {
2033
+ server.child.off("close", handleClose);
2034
+ resolve();
2035
+ }, 100).unref?.();
2036
+ }) : shutdownChildProcess(server.child, {
1841
2037
  detached: server.detached,
1842
2038
  killProcess: this.killProcessFn,
1843
2039
  timeoutMs: 3e3
1844
- }).finally(() => {
2040
+ });
2041
+ this.closingPromise = this.closingPromise.finally(() => {
1845
2042
  if (this.server === server) this.server = null;
1846
2043
  this.closingPromise = null;
1847
2044
  });
@@ -1875,12 +2072,12 @@ function withTimeoutSignal(signal, timeoutMs) {
1875
2072
  }
1876
2073
  //#endregion
1877
2074
  //#region src/core/agents/factory.ts
1878
- function createAgent(name, runInfo) {
2075
+ function createAgent(name, runInfo, pathOverride) {
1879
2076
  switch (name) {
1880
- case "claude": return new ClaudeAgent();
1881
- case "codex": return new CodexAgent(runInfo.schemaPath);
1882
- case "opencode": return new OpenCodeAgent();
1883
- case "rovodev": return new RovoDevAgent(runInfo.schemaPath);
2077
+ case "claude": return new ClaudeAgent(pathOverride);
2078
+ case "codex": return new CodexAgent(runInfo.schemaPath, pathOverride);
2079
+ case "opencode": return new OpenCodeAgent({ bin: pathOverride });
2080
+ case "rovodev": return new RovoDevAgent(runInfo.schemaPath, { bin: pathOverride });
1884
2081
  }
1885
2082
  }
1886
2083
  //#endregion
@@ -2356,7 +2553,68 @@ function formatTokens(count) {
2356
2553
  return String(count);
2357
2554
  }
2358
2555
  //#endregion
2556
+ //#region src/utils/terminal-width.ts
2557
+ const graphemeSegmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
2558
+ const MARK_REGEX = /\p{Mark}/u;
2559
+ const REGIONAL_INDICATOR_REGEX = /\p{Regional_Indicator}/u;
2560
+ const EXTENDED_PICTOGRAPHIC_REGEX = /\p{Extended_Pictographic}/u;
2561
+ function isFullWidthCodePoint(codePoint) {
2562
+ return codePoint >= 4352 && (codePoint <= 4447 || codePoint === 9001 || codePoint === 9002 || codePoint >= 11904 && codePoint <= 12871 && codePoint !== 12351 || codePoint >= 12880 && codePoint <= 19903 || codePoint >= 19968 && codePoint <= 42182 || codePoint >= 43360 && codePoint <= 43388 || codePoint >= 44032 && codePoint <= 55203 || codePoint >= 63744 && codePoint <= 64255 || codePoint >= 65040 && codePoint <= 65049 || codePoint >= 65072 && codePoint <= 65131 || codePoint >= 65281 && codePoint <= 65376 || codePoint >= 65504 && codePoint <= 65510 || codePoint >= 110592 && codePoint <= 110593 || codePoint >= 127488 && codePoint <= 127569 || codePoint >= 131072 && codePoint <= 262141);
2563
+ }
2564
+ function codePointWidth(codePoint) {
2565
+ if (codePoint === 0 || codePoint === 8204 || codePoint === 8205 || codePoint === 65038 || codePoint === 65039) return 0;
2566
+ if (MARK_REGEX.test(String.fromCodePoint(codePoint))) return 0;
2567
+ return isFullWidthCodePoint(codePoint) ? 2 : 1;
2568
+ }
2569
+ function isWideEmojiGrapheme(grapheme) {
2570
+ return grapheme.includes("‍") || grapheme.includes("️") || grapheme.includes("⃣") || REGIONAL_INDICATOR_REGEX.test(grapheme) || Array.from(grapheme).some((char) => EXTENDED_PICTOGRAPHIC_REGEX.test(char));
2571
+ }
2572
+ function splitGraphemes(text) {
2573
+ return Array.from(graphemeSegmenter.segment(text), ({ segment }) => segment);
2574
+ }
2575
+ function graphemeWidth(grapheme) {
2576
+ if (!grapheme) return 0;
2577
+ if (isWideEmojiGrapheme(grapheme)) return 2;
2578
+ let width = 0;
2579
+ for (const char of grapheme) width += codePointWidth(char.codePointAt(0) ?? 0);
2580
+ return width;
2581
+ }
2582
+ function stringWidth(text) {
2583
+ let width = 0;
2584
+ for (const grapheme of splitGraphemes(text)) width += graphemeWidth(grapheme);
2585
+ return width;
2586
+ }
2587
+ //#endregion
2359
2588
  //#region src/utils/wordwrap.ts
2589
+ function sliceToWidth(text, width) {
2590
+ let result = "";
2591
+ let currentWidth = 0;
2592
+ for (const grapheme of splitGraphemes(text)) {
2593
+ const nextWidth = currentWidth + graphemeWidth(grapheme);
2594
+ if (nextWidth > width) break;
2595
+ result += grapheme;
2596
+ currentWidth = nextWidth;
2597
+ }
2598
+ return result;
2599
+ }
2600
+ function splitByWidth(text, width) {
2601
+ const lines = [];
2602
+ let current = "";
2603
+ let currentWidth = 0;
2604
+ for (const grapheme of splitGraphemes(text)) {
2605
+ const glyphWidth = graphemeWidth(grapheme);
2606
+ if (current && currentWidth + glyphWidth > width) {
2607
+ lines.push(current);
2608
+ current = grapheme;
2609
+ currentWidth = glyphWidth;
2610
+ continue;
2611
+ }
2612
+ current += grapheme;
2613
+ currentWidth += glyphWidth;
2614
+ }
2615
+ if (current) lines.push(current);
2616
+ return lines;
2617
+ }
2360
2618
  function wordWrap(text, width, maxLines) {
2361
2619
  if (!text) return [];
2362
2620
  const lines = [];
@@ -2367,26 +2625,34 @@ function wordWrap(text, width, maxLines) {
2367
2625
  continue;
2368
2626
  }
2369
2627
  let current = "";
2628
+ let currentWidth = 0;
2370
2629
  for (const word of words) {
2371
- if (word.length > width) {
2630
+ const wordWidth = stringWidth(word);
2631
+ if (wordWidth > width) {
2372
2632
  if (current) {
2373
2633
  lines.push(current);
2374
2634
  current = "";
2635
+ currentWidth = 0;
2375
2636
  }
2376
- for (let i = 0; i < word.length; i += width) lines.push(word.slice(i, i + width));
2637
+ for (const slice of splitByWidth(word, width)) lines.push(slice);
2377
2638
  continue;
2378
2639
  }
2379
- if (current && current.length + 1 + word.length > width) {
2640
+ const nextWidth = current ? currentWidth + 1 + wordWidth : wordWidth;
2641
+ if (current && nextWidth > width) {
2380
2642
  lines.push(current);
2381
2643
  current = word;
2382
- } else current = current ? current + " " + word : word;
2644
+ currentWidth = wordWidth;
2645
+ } else {
2646
+ current = current ? current + " " + word : word;
2647
+ currentWidth = nextWidth;
2648
+ }
2383
2649
  }
2384
2650
  if (current) lines.push(current);
2385
2651
  }
2386
2652
  if (maxLines && lines.length > maxLines) {
2387
2653
  const capped = lines.slice(0, maxLines);
2388
2654
  const last = capped[maxLines - 1];
2389
- capped[maxLines - 1] = last.length >= width ? last.slice(0, width - 1) + "…" : last + "…";
2655
+ capped[maxLines - 1] = stringWidth(last) >= width ? sliceToWidth(last, width - 1) + "…" : last + "…";
2390
2656
  return capped;
2391
2657
  }
2392
2658
  return lines;
@@ -2402,13 +2668,13 @@ function makeCell(char, style) {
2402
2668
  return {
2403
2669
  char,
2404
2670
  style,
2405
- width: (char.codePointAt(0) ?? 0) > 65535 ? 2 : 1
2671
+ width: graphemeWidth(char)
2406
2672
  };
2407
2673
  }
2408
2674
  function textToCells(text, style) {
2409
2675
  const cells = [];
2410
- for (const char of text) {
2411
- const cell = makeCell(char, style);
2676
+ for (const grapheme of splitGraphemes(text)) {
2677
+ const cell = makeCell(grapheme, style);
2412
2678
  cells.push(cell);
2413
2679
  if (cell.width === 2) cells.push({
2414
2680
  char: "",
@@ -2490,7 +2756,7 @@ const TICK_MS = 200;
2490
2756
  const MOONS_PER_ROW = 30;
2491
2757
  const MOON_PHASE_PERIOD = 1600;
2492
2758
  const MAX_MSG_LINES = 3;
2493
- const MAX_MSG_LINE_LEN = 64;
2759
+ const MAX_MSG_LINE_LEN = CONTENT_WIDTH;
2494
2760
  const RESUME_HINT = "[ctrl+c to stop, gnhf again to resume]";
2495
2761
  function spacedLabel(text) {
2496
2762
  return text.split("").join(" ");
@@ -2584,52 +2850,124 @@ function renderSideStarsCells(stars, rowIndex, xOffset, sideWidth, now) {
2584
2850
  placeStarsInCells(cells, stars, rowIndex, xOffset, xOffset + sideWidth, xOffset, now);
2585
2851
  return cells;
2586
2852
  }
2853
+ function clampCellsToWidth(content, width) {
2854
+ if (content.length <= width) return content;
2855
+ const clamped = [];
2856
+ let remaining = width;
2857
+ for (let i = 0; i < content.length && remaining > 0; i++) {
2858
+ const cell = content[i];
2859
+ if (cell.width === 0) continue;
2860
+ if (cell.width > remaining) break;
2861
+ clamped.push(cell);
2862
+ remaining -= cell.width;
2863
+ if (cell.width === 2 && content[i + 1]?.width === 0) {
2864
+ clamped.push(content[i + 1]);
2865
+ i += 1;
2866
+ }
2867
+ }
2868
+ return clamped;
2869
+ }
2587
2870
  function centerLineCells(content, width) {
2588
- const w = content.length;
2871
+ const clamped = clampCellsToWidth(content, width);
2872
+ const w = clamped.length;
2589
2873
  const pad = Math.max(0, Math.floor((width - w) / 2));
2590
2874
  const rightPad = Math.max(0, width - w - pad);
2591
2875
  return [
2592
2876
  ...emptyCells(pad),
2593
- ...content,
2877
+ ...clamped,
2594
2878
  ...emptyCells(rightPad)
2595
2879
  ];
2596
2880
  }
2597
2881
  function renderResumeHintCells(width) {
2598
2882
  return centerLineCells(textToCells(RESUME_HINT, "dim"), width);
2599
2883
  }
2600
- function fitContentRows(contentRows, maxRows) {
2601
- if (contentRows.length <= maxRows) return contentRows;
2602
- const fitted = [...contentRows];
2603
- while (fitted.length > maxRows) {
2604
- const emptyRowIndex = fitted.findIndex((row) => row.length === 0);
2605
- if (emptyRowIndex === -1) break;
2606
- fitted.splice(emptyRowIndex, 1);
2607
- }
2608
- return fitted.length > maxRows ? fitted.slice(fitted.length - maxRows) : fitted;
2609
- }
2610
- function buildContentCells(prompt, agentName, state, elapsed, now) {
2611
- const rows = [];
2884
+ /**
2885
+ * Builds the centered content viewport for the renderer.
2886
+ *
2887
+ * When `availableHeight` is constrained, the layout drops optional sections in
2888
+ * priority order (ASCII art, eyebrow, agent message, then prompt) so the stats
2889
+ * row remains visible and any remaining space is used for the newest moon rows.
2890
+ */
2891
+ function buildContentCells(prompt, agentName, state, elapsed, now, availableHeight) {
2612
2892
  const isRunning = state.status === "running" || state.status === "waiting";
2613
- rows.push([]);
2614
- rows.push(...renderTitleCells(agentName));
2615
- rows.push([], []);
2893
+ const moonRows = renderMoonStripCells(state.iterations, isRunning, now);
2894
+ const maxRows = availableHeight ?? Infinity;
2895
+ if (maxRows <= 0) return [];
2896
+ const titleCells = renderTitleCells(agentName);
2897
+ const titleSpacer = titleCells[1] ?? [];
2616
2898
  const promptLines = wordWrap(prompt, CONTENT_WIDTH, MAX_PROMPT_LINES);
2899
+ const promptRows = [];
2617
2900
  for (let i = 0; i < MAX_PROMPT_LINES; i++) {
2618
2901
  const pl = promptLines[i] ?? "";
2619
- rows.push(pl ? textToCells(pl, "dim") : []);
2620
- }
2621
- rows.push([], []);
2622
- rows.push(renderStatsCells(elapsed, state.totalInputTokens, state.totalOutputTokens, state.commitCount));
2623
- rows.push([], []);
2624
- rows.push(...renderAgentMessageCells(state.lastMessage, state.status));
2625
- rows.push([], []);
2626
- rows.push(...renderMoonStripCells(state.iterations, isRunning, now));
2902
+ promptRows.push(pl ? textToCells(pl, "dim") : []);
2903
+ }
2904
+ const sections = {
2905
+ top: [[]],
2906
+ eyebrow: [
2907
+ titleCells[0],
2908
+ [],
2909
+ []
2910
+ ],
2911
+ art: titleCells.slice(2),
2912
+ prompt: [
2913
+ titleSpacer,
2914
+ ...promptRows,
2915
+ [],
2916
+ []
2917
+ ],
2918
+ stats: [renderStatsCells(elapsed, state.totalInputTokens, state.totalOutputTokens, state.commitCount)],
2919
+ agent: [
2920
+ [],
2921
+ [],
2922
+ ...renderAgentMessageCells(state.lastMessage, state.status)
2923
+ ],
2924
+ moon: [
2925
+ [],
2926
+ [],
2927
+ ...moonRows
2928
+ ]
2929
+ };
2930
+ const flattenSections = () => [
2931
+ ...sections.top,
2932
+ ...sections.eyebrow,
2933
+ ...sections.art,
2934
+ ...sections.prompt,
2935
+ ...sections.stats,
2936
+ ...sections.agent,
2937
+ ...sections.moon
2938
+ ];
2939
+ const optionalSections = [
2940
+ "art",
2941
+ "eyebrow",
2942
+ "agent",
2943
+ "prompt"
2944
+ ];
2945
+ let rows = flattenSections();
2946
+ for (const section of optionalSections) {
2947
+ if (rows.length <= maxRows) break;
2948
+ sections[section] = [];
2949
+ rows = flattenSections();
2950
+ }
2951
+ if (rows.length > maxRows) rows = rows.filter((row) => row.length > 0);
2952
+ if (rows.length > maxRows) {
2953
+ const nonMoonRows = [
2954
+ ...sections.top,
2955
+ ...sections.eyebrow,
2956
+ ...sections.art,
2957
+ ...sections.prompt,
2958
+ ...sections.stats,
2959
+ ...sections.agent
2960
+ ].filter((row) => row.length > 0);
2961
+ const allowedMoonRows = Math.max(0, maxRows - nonMoonRows.length);
2962
+ const visibleMoonRows = allowedMoonRows === 0 ? [] : moonRows.filter((row) => row.length > 0).slice(-allowedMoonRows);
2963
+ rows = [...nonMoonRows, ...visibleMoonRows];
2964
+ }
2627
2965
  return rows;
2628
2966
  }
2629
2967
  function buildFrameCells(prompt, agentName, state, topStars, bottomStars, sideStars, now, terminalWidth, terminalHeight) {
2630
2968
  const elapsed = formatElapsed(now - state.startTime.getTime());
2631
2969
  const availableHeight = Math.max(0, terminalHeight - 2);
2632
- const contentRows = fitContentRows(buildContentCells(prompt, agentName, state, elapsed, now), availableHeight);
2970
+ const contentRows = buildContentCells(prompt, agentName, state, elapsed, now, availableHeight);
2633
2971
  while (contentRows.length < Math.min(BASE_CONTENT_ROWS, availableHeight)) contentRows.push([]);
2634
2972
  const contentCount = contentRows.length;
2635
2973
  const remaining = Math.max(0, availableHeight - contentCount);
@@ -2668,11 +3006,17 @@ var Renderer = class {
2668
3006
  cachedHeight = 0;
2669
3007
  prevCells = [];
2670
3008
  isFirstFrame = true;
3009
+ seedTop;
3010
+ seedBottom;
3011
+ seedSide;
2671
3012
  constructor(orchestrator, prompt, agentName) {
2672
3013
  this.orchestrator = orchestrator;
2673
3014
  this.prompt = prompt;
2674
3015
  this.agentName = agentName;
2675
3016
  this.state = orchestrator.getState();
3017
+ this.seedTop = Math.floor(Math.random() * 2147483646) + 1;
3018
+ this.seedBottom = Math.floor(Math.random() * 2147483646) + 1;
3019
+ this.seedSide = Math.floor(Math.random() * 2147483646) + 1;
2676
3020
  this.exitPromise = new Promise((resolve) => {
2677
3021
  this.exitResolve = resolve;
2678
3022
  });
@@ -2736,9 +3080,9 @@ var Renderer = class {
2736
3080
  rest: "dim"
2737
3081
  } : star;
2738
3082
  };
2739
- this.topStars = generateStarField(w, h, STAR_DENSITY, 42).map((s) => shrinkBig(s, s.y >= topHeight - proximityRows));
2740
- this.bottomStars = generateStarField(w, h, STAR_DENSITY, 137).map((s) => shrinkBig(s, s.y < proximityRows));
2741
- this.sideStars = generateStarField(w, Math.max(BASE_CONTENT_ROWS, availableHeight), STAR_DENSITY, 99);
3083
+ this.topStars = generateStarField(w, h, STAR_DENSITY, this.seedTop).map((s) => shrinkBig(s, s.y >= topHeight - proximityRows));
3084
+ this.bottomStars = generateStarField(w, h, STAR_DENSITY, this.seedBottom).map((s) => shrinkBig(s, s.y < proximityRows));
3085
+ this.sideStars = generateStarField(w, Math.max(BASE_CONTENT_ROWS, availableHeight), STAR_DENSITY, this.seedSide);
2742
3086
  return true;
2743
3087
  }
2744
3088
  return false;
@@ -2934,7 +3278,7 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
2934
3278
  }
2935
3279
  }
2936
3280
  appendDebugLog("run:start", { args: process$1.argv.slice(2) });
2937
- const orchestrator = new Orchestrator(config, createAgent(config.agent, runInfo), runInfo, prompt, cwd, startIteration, {
3281
+ const orchestrator = new Orchestrator(config, createAgent(config.agent, runInfo, config.agentPathOverride[config.agent]), runInfo, prompt, cwd, startIteration, {
2938
3282
  maxIterations: options.maxIterations,
2939
3283
  maxTokens: options.maxTokens
2940
3284
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gnhf",
3
- "version": "0.1.9",
3
+ "version": "0.1.11",
4
4
  "description": "Before I go to bed, I tell my agents: good night, have fun",
5
5
  "type": "module",
6
6
  "bin": {