gnhf 0.1.2 → 0.1.4

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 +15 -11
  2. package/dist/cli.mjs +326 -282
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -39,11 +39,11 @@
39
39
  <img src="docs/splash.png" alt="gnhf — Good Night, Have Fun" width="800">
40
40
  </p>
41
41
 
42
- gnhf is a [ralph loop](https://ghuntley.com/ralph/) orchestrator that keeps your agents running while you sleep — each iteration makes one small, committed, documented change towards an objective.
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
45
  - **Dead simple** — one command starts an autonomous loop that runs until you Ctrl+C
46
- - **Autonomous by design** — each iteration is committed on success, rolled back on failure, with exponential backoff and auto-abort after consecutive failures
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
 
49
49
  ## Quick Start
@@ -117,22 +117,24 @@ npm link
117
117
 
118
118
  - **Incremental commits** — each successful iteration is a separate git commit, so you can cherry-pick or revert individual changes
119
119
  - **Shared memory** — the agent reads `notes.md` (built up from prior iterations) to communicate across iterations
120
+ - **Local run metadata** — gnhf stores prompt, notes, and resume metadata under `.gnhf/runs/` and ignores it locally, so your branch only contains intentional work
120
121
  - **Resume support** — run `gnhf` while on an existing `gnhf/` branch to pick up where a previous run left off
121
122
 
122
123
  ## CLI Reference
123
124
 
124
- | Command | Description |
125
- | ----------------------- | ----------------------------------------------- |
126
- | `gnhf "<prompt>"` | Start a new run with the given objective |
127
- | `gnhf` | Resume a run (when on an existing gnhf/ branch) |
128
- | `echo "prompt" \| gnhf` | Pipe prompt via stdin |
125
+ | Command | Description |
126
+ | ------------------------- | ----------------------------------------------- |
127
+ | `gnhf "<prompt>"` | Start a new run with the given objective |
128
+ | `gnhf` | Resume a run (when on an existing gnhf/ branch) |
129
+ | `echo "<prompt>" \| gnhf` | Pipe prompt via stdin |
130
+ | `cat prd.md \| gnhf` | Pipe a large spec or PRD via stdin |
129
131
 
130
132
  ### Flags
131
133
 
132
- | Flag | Description | Default |
133
- | ----------------- | ---------------------------------- | -------- |
134
- | `--agent <agent>` | Agent to use (`claude` or `codex`) | `claude` |
135
- | `--version` | Show version | |
134
+ | Flag | Description | Default |
135
+ | ----------------- | ---------------------------------- | ---------------------- |
136
+ | `--agent <agent>` | Agent to use (`claude` or `codex`) | config file (`claude`) |
137
+ | `--version` | Show version | |
136
138
 
137
139
  ## Configuration
138
140
 
@@ -146,6 +148,8 @@ agent: claude
146
148
  maxConsecutiveFailures: 3
147
149
  ```
148
150
 
151
+ If the file does not exist yet, `gnhf` creates it on first run using the resolved defaults.
152
+
149
153
  CLI flags override config file values.
150
154
 
151
155
  ## Development
package/dist/cli.mjs CHANGED
@@ -3,10 +3,10 @@ import { appendFileSync, createWriteStream, existsSync, mkdirSync, readFileSync,
3
3
  import process$1 from "node:process";
4
4
  import { createInterface } from "node:readline";
5
5
  import { Command } from "commander";
6
- import { join } from "node:path";
6
+ import { dirname, isAbsolute, join } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import yaml from "js-yaml";
9
- import { execSync, spawn } from "node:child_process";
9
+ import { execFileSync, execSync, spawn } from "node:child_process";
10
10
  import { EventEmitter } from "node:events";
11
11
  import { createHash } from "node:crypto";
12
12
  //#region src/core/config.ts
@@ -14,18 +14,39 @@ const DEFAULT_CONFIG = {
14
14
  agent: "claude",
15
15
  maxConsecutiveFailures: 3
16
16
  };
17
+ function isMissingConfigError(error) {
18
+ if (!(error instanceof Error)) return false;
19
+ return "code" in error ? error.code === "ENOENT" : error.message.includes("ENOENT");
20
+ }
21
+ function serializeConfig(config) {
22
+ return `# Agent to use by default
23
+ agent: ${config.agent}
24
+
25
+ # Abort after this many consecutive failures
26
+ maxConsecutiveFailures: ${config.maxConsecutiveFailures}
27
+ `;
28
+ }
17
29
  function loadConfig(overrides) {
18
- const configPath = join(homedir(), ".gnhf", "config.yml");
30
+ const configDir = join(homedir(), ".gnhf");
31
+ const configPath = join(configDir, "config.yml");
19
32
  let fileConfig = {};
33
+ let shouldBootstrapConfig = false;
20
34
  try {
21
35
  const raw = readFileSync(configPath, "utf-8");
22
36
  fileConfig = yaml.load(raw) ?? {};
23
- } catch {}
24
- return {
37
+ } catch (error) {
38
+ if (isMissingConfigError(error)) shouldBootstrapConfig = true;
39
+ }
40
+ const resolvedConfig = {
25
41
  ...DEFAULT_CONFIG,
26
42
  ...fileConfig,
27
43
  ...overrides
28
44
  };
45
+ if (shouldBootstrapConfig) try {
46
+ mkdirSync(configDir, { recursive: true });
47
+ writeFileSync(configPath, serializeConfig(resolvedConfig), "utf-8");
48
+ } catch {}
49
+ return resolvedConfig;
29
50
  }
30
51
  //#endregion
31
52
  //#region src/core/git.ts
@@ -45,6 +66,28 @@ function ensureCleanWorkingTree(cwd) {
45
66
  function createBranch(branchName, cwd) {
46
67
  git(`checkout -b ${branchName}`, cwd);
47
68
  }
69
+ function getHeadCommit(cwd) {
70
+ return git("rev-parse HEAD", cwd);
71
+ }
72
+ function findLegacyRunBaseCommit(runId, cwd) {
73
+ try {
74
+ const marker = git("log --first-parent --reverse --format=%H%x09%s HEAD", cwd).split("\n").map((line) => {
75
+ const [sha, ...subjectParts] = line.split(" ");
76
+ return {
77
+ sha,
78
+ subject: subjectParts.join(" ")
79
+ };
80
+ }).find(({ subject }) => subject === `gnhf: initialize run ${runId}` || subject === `gnhf: overwrite run ${runId}`);
81
+ if (!marker?.sha) return null;
82
+ return git(`rev-parse ${marker.sha}^`, cwd);
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+ function getBranchCommitCount(baseCommit, cwd) {
88
+ if (!baseCommit) return 0;
89
+ return Number.parseInt(git(`rev-list --count --first-parent ${baseCommit}..HEAD`, cwd), 10);
90
+ }
48
91
  function commitAll(message, cwd) {
49
92
  git("add -A", cwd);
50
93
  try {
@@ -56,8 +99,8 @@ function resetHard(cwd) {
56
99
  git("clean -fd", cwd);
57
100
  }
58
101
  //#endregion
59
- //#region src/core/run.ts
60
- const OUTPUT_SCHEMA$1 = JSON.stringify({
102
+ //#region src/core/agents/types.ts
103
+ const AGENT_OUTPUT_SCHEMA = {
61
104
  type: "object",
62
105
  properties: {
63
106
  success: { type: "boolean" },
@@ -77,17 +120,29 @@ const OUTPUT_SCHEMA$1 = JSON.stringify({
77
120
  "key_changes_made",
78
121
  "key_learnings"
79
122
  ]
80
- }, null, 2);
81
- function ensureGitignore(cwd) {
82
- const gitignorePath = join(cwd, ".gitignore");
123
+ };
124
+ //#endregion
125
+ //#region src/core/run.ts
126
+ function ensureRunMetadataIgnored(cwd) {
127
+ const excludePath = execFileSync("git", [
128
+ "rev-parse",
129
+ "--git-path",
130
+ "info/exclude"
131
+ ], {
132
+ cwd,
133
+ encoding: "utf-8"
134
+ }).trim();
135
+ const resolved = isAbsolute(excludePath) ? excludePath : join(cwd, excludePath);
83
136
  const entry = ".gnhf/runs/";
84
- if (existsSync(gitignorePath)) {
85
- if (readFileSync(gitignorePath, "utf-8").split("\n").some((line) => line.trim() === entry)) return;
86
- appendFileSync(gitignorePath, `\n${entry}\n`, "utf-8");
87
- } else writeFileSync(gitignorePath, `${entry}\n`, "utf-8");
137
+ mkdirSync(dirname(resolved), { recursive: true });
138
+ if (existsSync(resolved)) {
139
+ const content = readFileSync(resolved, "utf-8");
140
+ if (content.split("\n").some((line) => line.trim() === entry)) return;
141
+ appendFileSync(resolved, `${content.length > 0 && !content.endsWith("\n") ? "\n" : ""}${entry}\n`, "utf-8");
142
+ } else writeFileSync(resolved, `${entry}\n`, "utf-8");
88
143
  }
89
- function setupRun(runId, prompt, cwd) {
90
- ensureGitignore(cwd);
144
+ function setupRun(runId, prompt, baseCommit, cwd) {
145
+ ensureRunMetadataIgnored(cwd);
91
146
  const runDir = join(cwd, ".gnhf", "runs", runId);
92
147
  mkdirSync(runDir, { recursive: true });
93
148
  const promptPath = join(runDir, "prompt.md");
@@ -95,26 +150,43 @@ function setupRun(runId, prompt, cwd) {
95
150
  const notesPath = join(runDir, "notes.md");
96
151
  writeFileSync(notesPath, `# gnhf run: ${runId}\n\nObjective: ${prompt}\n\n## Iteration Log\n`, "utf-8");
97
152
  const schemaPath = join(runDir, "output-schema.json");
98
- writeFileSync(schemaPath, OUTPUT_SCHEMA$1, "utf-8");
153
+ writeFileSync(schemaPath, JSON.stringify(AGENT_OUTPUT_SCHEMA, null, 2), "utf-8");
154
+ const baseCommitPath = join(runDir, "base-commit");
155
+ const hasStoredBaseCommit = existsSync(baseCommitPath);
156
+ const resolvedBaseCommit = hasStoredBaseCommit ? readFileSync(baseCommitPath, "utf-8").trim() : baseCommit;
157
+ if (!hasStoredBaseCommit) writeFileSync(baseCommitPath, `${baseCommit}\n`, "utf-8");
99
158
  return {
100
159
  runId,
101
160
  runDir,
102
161
  promptPath,
103
162
  notesPath,
104
- schemaPath
163
+ schemaPath,
164
+ baseCommit: resolvedBaseCommit,
165
+ baseCommitPath
105
166
  };
106
167
  }
107
168
  function resumeRun(runId, cwd) {
108
169
  const runDir = join(cwd, ".gnhf", "runs", runId);
109
170
  if (!existsSync(runDir)) throw new Error(`Run directory not found: ${runDir}`);
171
+ const promptPath = join(runDir, "prompt.md");
172
+ const notesPath = join(runDir, "notes.md");
173
+ const schemaPath = join(runDir, "output-schema.json");
174
+ const baseCommitPath = join(runDir, "base-commit");
110
175
  return {
111
176
  runId,
112
177
  runDir,
113
- promptPath: join(runDir, "prompt.md"),
114
- notesPath: join(runDir, "notes.md"),
115
- schemaPath: join(runDir, "output-schema.json")
178
+ promptPath,
179
+ notesPath,
180
+ schemaPath,
181
+ baseCommit: existsSync(baseCommitPath) ? readFileSync(baseCommitPath, "utf-8").trim() : backfillLegacyBaseCommit(runId, baseCommitPath, cwd),
182
+ baseCommitPath
116
183
  };
117
184
  }
185
+ function backfillLegacyBaseCommit(runId, baseCommitPath, cwd) {
186
+ const baseCommit = findLegacyRunBaseCommit(runId, cwd) ?? getHeadCommit(cwd);
187
+ writeFileSync(baseCommitPath, `${baseCommit}\n`, "utf-8");
188
+ return baseCommit;
189
+ }
118
190
  function getLastIterationNumber(runInfo) {
119
191
  const files = readdirSync(runInfo.runDir);
120
192
  let max = 0;
@@ -127,37 +199,81 @@ function getLastIterationNumber(runInfo) {
127
199
  }
128
200
  return max;
129
201
  }
202
+ function formatListSection(title, items) {
203
+ if (items.length === 0) return "";
204
+ return `**${title}:**\n${items.map((item) => `- ${item}`).join("\n")}\n`;
205
+ }
130
206
  function appendNotes(notesPath, iteration, summary, changes, learnings) {
131
207
  appendFileSync(notesPath, [
132
208
  `\n### Iteration ${iteration}\n`,
133
209
  `**Summary:** ${summary}\n`,
134
- changes.length > 0 ? `**Changes:**\n${changes.map((c) => `- ${c}`).join("\n")}\n` : "",
135
- learnings.length > 0 ? `**Learnings:**\n${learnings.map((l) => `- ${l}`).join("\n")}\n` : ""
210
+ formatListSection("Changes", changes),
211
+ formatListSection("Learnings", learnings)
136
212
  ].join("\n"), "utf-8");
137
213
  }
138
214
  //#endregion
139
- //#region src/core/agents/claude.ts
140
- const OUTPUT_SCHEMA = JSON.stringify({
141
- type: "object",
142
- properties: {
143
- success: { type: "boolean" },
144
- summary: { type: "string" },
145
- key_changes_made: {
146
- type: "array",
147
- items: { type: "string" }
148
- },
149
- key_learnings: {
150
- type: "array",
151
- items: { type: "string" }
215
+ //#region src/core/agents/stream-utils.ts
216
+ /**
217
+ * Wire stderr collection, spawn-error handling, and the common close-handler
218
+ * prefix (logStream.end + non-zero exit code rejection) for a child process.
219
+ * Calls `onSuccess` only when the process exits with code 0.
220
+ */
221
+ function setupChildProcessHandlers(child, agentName, logStream, reject, onSuccess) {
222
+ let stderr = "";
223
+ child.stderr.on("data", (data) => {
224
+ stderr += data.toString();
225
+ });
226
+ child.on("error", (err) => {
227
+ reject(/* @__PURE__ */ new Error(`Failed to spawn ${agentName}: ${err.message}`));
228
+ });
229
+ child.on("close", (code) => {
230
+ logStream?.end();
231
+ if (code !== 0) {
232
+ reject(/* @__PURE__ */ new Error(`${agentName} exited with code ${code}: ${stderr}`));
233
+ return;
152
234
  }
153
- },
154
- required: [
155
- "success",
156
- "summary",
157
- "key_changes_made",
158
- "key_learnings"
159
- ]
160
- });
235
+ onSuccess();
236
+ });
237
+ }
238
+ /**
239
+ * Parse a JSONL stream, calling the callback for each parsed event.
240
+ * Handles buffering of incomplete lines and skips unparseable lines.
241
+ */
242
+ function parseJSONLStream(stream, logStream, callback) {
243
+ let buffer = "";
244
+ stream.on("data", (data) => {
245
+ logStream?.write(data);
246
+ buffer += data.toString();
247
+ const lines = buffer.split("\n");
248
+ buffer = lines.pop() ?? "";
249
+ for (const line of lines) {
250
+ if (!line.trim()) continue;
251
+ try {
252
+ callback(JSON.parse(line));
253
+ } catch {}
254
+ }
255
+ });
256
+ }
257
+ /**
258
+ * Wire an AbortSignal to kill a child process.
259
+ * Returns true if the signal was already aborted (caller should return early).
260
+ */
261
+ function setupAbortHandler(signal, child, reject) {
262
+ if (!signal) return false;
263
+ const onAbort = () => {
264
+ child.kill("SIGTERM");
265
+ reject(/* @__PURE__ */ new Error("Agent was aborted"));
266
+ };
267
+ if (signal.aborted) {
268
+ onAbort();
269
+ return true;
270
+ }
271
+ signal.addEventListener("abort", onAbort, { once: true });
272
+ child.on("close", () => signal.removeEventListener("abort", onAbort));
273
+ return false;
274
+ }
275
+ //#endregion
276
+ //#region src/core/agents/claude.ts
161
277
  var ClaudeAgent = class {
162
278
  name = "claude";
163
279
  run(prompt, cwd, options) {
@@ -171,7 +287,7 @@ var ClaudeAgent = class {
171
287
  "--output-format",
172
288
  "stream-json",
173
289
  "--json-schema",
174
- OUTPUT_SCHEMA,
290
+ JSON.stringify(AGENT_OUTPUT_SCHEMA),
175
291
  "--dangerously-skip-permissions"
176
292
  ], {
177
293
  cwd,
@@ -182,20 +298,7 @@ var ClaudeAgent = class {
182
298
  ],
183
299
  env: process.env
184
300
  });
185
- if (signal) {
186
- const onAbort = () => {
187
- child.kill("SIGTERM");
188
- reject(/* @__PURE__ */ new Error("Agent was aborted"));
189
- };
190
- if (signal.aborted) {
191
- onAbort();
192
- return;
193
- }
194
- signal.addEventListener("abort", onAbort, { once: true });
195
- child.on("close", () => signal.removeEventListener("abort", onAbort));
196
- }
197
- let stderr = "";
198
- let buffer = "";
301
+ if (setupAbortHandler(signal, child, reject)) return;
199
302
  let resultEvent = null;
200
303
  const cumulative = {
201
304
  inputTokens: 0,
@@ -203,45 +306,24 @@ var ClaudeAgent = class {
203
306
  cacheReadTokens: 0,
204
307
  cacheCreationTokens: 0
205
308
  };
206
- child.stdout.on("data", (data) => {
207
- logStream?.write(data);
208
- buffer += data.toString();
209
- const lines = buffer.split("\n");
210
- buffer = lines.pop() ?? "";
211
- for (const line of lines) {
212
- if (!line.trim()) continue;
213
- try {
214
- const event = JSON.parse(line);
215
- if (event.type === "assistant") {
216
- const msg = event.message;
217
- cumulative.inputTokens += (msg.usage.input_tokens ?? 0) + (msg.usage.cache_read_input_tokens ?? 0);
218
- cumulative.outputTokens += msg.usage.output_tokens ?? 0;
219
- cumulative.cacheReadTokens += msg.usage.cache_read_input_tokens ?? 0;
220
- cumulative.cacheCreationTokens += msg.usage.cache_creation_input_tokens ?? 0;
221
- onUsage?.({ ...cumulative });
222
- if (onMessage) {
223
- const content = msg.content;
224
- if (Array.isArray(content)) {
225
- for (const block of content) if (block?.type === "text" && typeof block.text === "string" && block.text.trim()) onMessage(block.text.trim());
226
- }
227
- }
309
+ parseJSONLStream(child.stdout, logStream, (event) => {
310
+ if (event.type === "assistant") {
311
+ const msg = event.message;
312
+ cumulative.inputTokens += (msg.usage.input_tokens ?? 0) + (msg.usage.cache_read_input_tokens ?? 0);
313
+ cumulative.outputTokens += msg.usage.output_tokens ?? 0;
314
+ cumulative.cacheReadTokens += msg.usage.cache_read_input_tokens ?? 0;
315
+ cumulative.cacheCreationTokens += msg.usage.cache_creation_input_tokens ?? 0;
316
+ onUsage?.({ ...cumulative });
317
+ if (onMessage) {
318
+ const content = msg.content;
319
+ if (Array.isArray(content)) {
320
+ for (const block of content) if (block?.type === "text" && typeof block.text === "string" && block.text.trim()) onMessage(block.text.trim());
228
321
  }
229
- if (event.type === "result") resultEvent = event;
230
- } catch {}
322
+ }
231
323
  }
324
+ if (event.type === "result") resultEvent = event;
232
325
  });
233
- child.stderr.on("data", (data) => {
234
- stderr += data.toString();
235
- });
236
- child.on("error", (err) => {
237
- reject(/* @__PURE__ */ new Error(`Failed to spawn claude: ${err.message}`));
238
- });
239
- child.on("close", (code) => {
240
- logStream?.end();
241
- if (code !== 0) {
242
- reject(/* @__PURE__ */ new Error(`claude exited with code ${code}: ${stderr}`));
243
- return;
244
- }
326
+ setupChildProcessHandlers(child, "claude", logStream, reject, () => {
245
327
  if (!resultEvent) {
246
328
  reject(/* @__PURE__ */ new Error("claude returned no result event"));
247
329
  return;
@@ -300,64 +382,28 @@ var CodexAgent = class {
300
382
  ],
301
383
  env: process.env
302
384
  });
303
- if (signal) {
304
- const onAbort = () => {
305
- child.kill("SIGTERM");
306
- reject(/* @__PURE__ */ new Error("Agent was aborted"));
307
- };
308
- if (signal.aborted) {
309
- onAbort();
310
- return;
311
- }
312
- signal.addEventListener("abort", onAbort, { once: true });
313
- child.on("close", () => signal.removeEventListener("abort", onAbort));
314
- }
315
- let stderr = "";
385
+ if (setupAbortHandler(signal, child, reject)) return;
316
386
  let lastAgentMessage = null;
317
- const usages = [];
318
- let buffer = "";
319
- child.stdout.on("data", (data) => {
320
- logStream?.write(data);
321
- buffer += data.toString();
322
- const lines = buffer.split("\n");
323
- buffer = lines.pop() ?? "";
324
- for (const line of lines) {
325
- if (!line.trim()) continue;
326
- try {
327
- const event = JSON.parse(line);
328
- if (event.type === "item.completed" && "item" in event && event.item.type === "agent_message") {
329
- lastAgentMessage = event.item.text;
330
- onMessage?.(lastAgentMessage);
331
- }
332
- if (event.type === "turn.completed" && "usage" in event) {
333
- usages.push(event.usage);
334
- if (onUsage) onUsage(usages.reduce((acc, u) => ({
335
- inputTokens: acc.inputTokens + (u.input_tokens ?? 0),
336
- outputTokens: acc.outputTokens + (u.output_tokens ?? 0),
337
- cacheReadTokens: acc.cacheReadTokens + (u.cached_input_tokens ?? 0),
338
- cacheCreationTokens: 0
339
- }), {
340
- inputTokens: 0,
341
- outputTokens: 0,
342
- cacheReadTokens: 0,
343
- cacheCreationTokens: 0
344
- }));
345
- }
346
- } catch {}
387
+ const cumulative = {
388
+ inputTokens: 0,
389
+ outputTokens: 0,
390
+ cacheReadTokens: 0,
391
+ cacheCreationTokens: 0
392
+ };
393
+ parseJSONLStream(child.stdout, logStream, (event) => {
394
+ if (event.type === "item.completed" && "item" in event && event.item.type === "agent_message") {
395
+ lastAgentMessage = event.item.text;
396
+ onMessage?.(lastAgentMessage);
347
397
  }
348
- });
349
- child.stderr.on("data", (data) => {
350
- stderr += data.toString();
351
- });
352
- child.on("error", (err) => {
353
- reject(/* @__PURE__ */ new Error(`Failed to spawn codex: ${err.message}`));
354
- });
355
- child.on("close", (code) => {
356
- logStream?.end();
357
- if (code !== 0) {
358
- reject(/* @__PURE__ */ new Error(`codex exited with code ${code}: ${stderr}`));
359
- return;
398
+ if (event.type === "turn.completed" && "usage" in event) {
399
+ const u = event.usage;
400
+ cumulative.inputTokens += u.input_tokens ?? 0;
401
+ cumulative.outputTokens += u.output_tokens ?? 0;
402
+ cumulative.cacheReadTokens += u.cached_input_tokens ?? 0;
403
+ onUsage?.({ ...cumulative });
360
404
  }
405
+ });
406
+ setupChildProcessHandlers(child, "codex", logStream, reject, () => {
361
407
  if (!lastAgentMessage) {
362
408
  reject(/* @__PURE__ */ new Error("codex returned no agent message"));
363
409
  return;
@@ -365,17 +411,7 @@ var CodexAgent = class {
365
411
  try {
366
412
  resolve({
367
413
  output: JSON.parse(lastAgentMessage),
368
- usage: usages.reduce((acc, u) => ({
369
- inputTokens: acc.inputTokens + (u.input_tokens ?? 0),
370
- outputTokens: acc.outputTokens + (u.output_tokens ?? 0),
371
- cacheReadTokens: acc.cacheReadTokens + (u.cached_input_tokens ?? 0),
372
- cacheCreationTokens: acc.cacheCreationTokens
373
- }), {
374
- inputTokens: 0,
375
- outputTokens: 0,
376
- cacheReadTokens: 0,
377
- cacheCreationTokens: 0
378
- })
414
+ usage: cumulative
379
415
  });
380
416
  } catch (err) {
381
417
  reject(/* @__PURE__ */ new Error(`Failed to parse codex output: ${err instanceof Error ? err.message : err}`));
@@ -432,6 +468,7 @@ var Orchestrator = class extends EventEmitter {
432
468
  currentIteration: 0,
433
469
  totalInputTokens: 0,
434
470
  totalOutputTokens: 0,
471
+ commitCount: 0,
435
472
  iterations: [],
436
473
  successCount: 0,
437
474
  failCount: 0,
@@ -448,6 +485,7 @@ var Orchestrator = class extends EventEmitter {
448
485
  this.prompt = prompt;
449
486
  this.cwd = cwd;
450
487
  this.state.currentIteration = startIteration;
488
+ this.state.commitCount = getBranchCommitCount(this.runInfo.baseCommit, this.cwd);
451
489
  }
452
490
  getState() {
453
491
  return { ...this.state };
@@ -474,69 +512,7 @@ var Orchestrator = class extends EventEmitter {
474
512
  runId: this.runInfo.runId,
475
513
  prompt: this.prompt
476
514
  });
477
- let record;
478
- const baseInputTokens = this.state.totalInputTokens;
479
- const baseOutputTokens = this.state.totalOutputTokens;
480
- this.activeAbortController = new AbortController();
481
- const onUsage = (usage) => {
482
- this.state.totalInputTokens = baseInputTokens + usage.inputTokens;
483
- this.state.totalOutputTokens = baseOutputTokens + usage.outputTokens;
484
- this.emit("state", this.getState());
485
- };
486
- const onMessage = (text) => {
487
- this.state.lastMessage = text;
488
- this.emit("state", this.getState());
489
- };
490
- const logPath = join(this.runInfo.runDir, `iteration-${this.state.currentIteration}.jsonl`);
491
- try {
492
- const result = await this.agent.run(iterationPrompt, this.cwd, {
493
- onUsage,
494
- onMessage,
495
- signal: this.activeAbortController.signal,
496
- logPath
497
- });
498
- if (result.output.success) {
499
- appendNotes(this.runInfo.notesPath, this.state.currentIteration, result.output.summary, result.output.key_changes_made, result.output.key_learnings);
500
- commitAll(`gnhf #${this.state.currentIteration}: ${result.output.summary}`, this.cwd);
501
- this.state.successCount++;
502
- this.state.consecutiveFailures = 0;
503
- record = {
504
- number: this.state.currentIteration,
505
- success: true,
506
- summary: result.output.summary,
507
- keyChanges: result.output.key_changes_made,
508
- keyLearnings: result.output.key_learnings,
509
- timestamp: /* @__PURE__ */ new Date()
510
- };
511
- } else {
512
- appendNotes(this.runInfo.notesPath, this.state.currentIteration, `[FAIL] ${result.output.summary}`, [], result.output.key_learnings);
513
- resetHard(this.cwd);
514
- this.state.failCount++;
515
- this.state.consecutiveFailures++;
516
- record = {
517
- number: this.state.currentIteration,
518
- success: false,
519
- summary: result.output.summary,
520
- keyChanges: [],
521
- keyLearnings: result.output.key_learnings,
522
- timestamp: /* @__PURE__ */ new Date()
523
- };
524
- }
525
- } catch (err) {
526
- const summary = err instanceof Error ? err.message : String(err);
527
- appendNotes(this.runInfo.notesPath, this.state.currentIteration, `[ERROR] ${summary}`, [], []);
528
- resetHard(this.cwd);
529
- this.state.failCount++;
530
- this.state.consecutiveFailures++;
531
- record = {
532
- number: this.state.currentIteration,
533
- success: false,
534
- summary,
535
- keyChanges: [],
536
- keyLearnings: [],
537
- timestamp: /* @__PURE__ */ new Date()
538
- };
539
- }
515
+ const record = await this.runIteration(iterationPrompt);
540
516
  this.state.iterations.push(record);
541
517
  this.emit("iteration:end", record);
542
518
  this.emit("state", this.getState());
@@ -561,6 +537,63 @@ var Orchestrator = class extends EventEmitter {
561
537
  }
562
538
  }
563
539
  }
540
+ async runIteration(prompt) {
541
+ const baseInputTokens = this.state.totalInputTokens;
542
+ const baseOutputTokens = this.state.totalOutputTokens;
543
+ this.activeAbortController = new AbortController();
544
+ const onUsage = (usage) => {
545
+ this.state.totalInputTokens = baseInputTokens + usage.inputTokens;
546
+ this.state.totalOutputTokens = baseOutputTokens + usage.outputTokens;
547
+ this.emit("state", this.getState());
548
+ };
549
+ const onMessage = (text) => {
550
+ this.state.lastMessage = text;
551
+ this.emit("state", this.getState());
552
+ };
553
+ const logPath = join(this.runInfo.runDir, `iteration-${this.state.currentIteration}.jsonl`);
554
+ try {
555
+ const result = await this.agent.run(prompt, this.cwd, {
556
+ onUsage,
557
+ onMessage,
558
+ signal: this.activeAbortController.signal,
559
+ logPath
560
+ });
561
+ if (result.output.success) return this.recordSuccess(result.output);
562
+ return this.recordFailure(`[FAIL] ${result.output.summary}`, result.output.summary, result.output.key_learnings);
563
+ } catch (err) {
564
+ const summary = err instanceof Error ? err.message : String(err);
565
+ return this.recordFailure(`[ERROR] ${summary}`, summary, []);
566
+ }
567
+ }
568
+ recordSuccess(output) {
569
+ appendNotes(this.runInfo.notesPath, this.state.currentIteration, output.summary, output.key_changes_made, output.key_learnings);
570
+ commitAll(`gnhf #${this.state.currentIteration}: ${output.summary}`, this.cwd);
571
+ this.state.commitCount = getBranchCommitCount(this.runInfo.baseCommit, this.cwd);
572
+ this.state.successCount++;
573
+ this.state.consecutiveFailures = 0;
574
+ return {
575
+ number: this.state.currentIteration,
576
+ success: true,
577
+ summary: output.summary,
578
+ keyChanges: output.key_changes_made,
579
+ keyLearnings: output.key_learnings,
580
+ timestamp: /* @__PURE__ */ new Date()
581
+ };
582
+ }
583
+ recordFailure(notesSummary, recordSummary, learnings) {
584
+ appendNotes(this.runInfo.notesPath, this.state.currentIteration, notesSummary, [], learnings);
585
+ resetHard(this.cwd);
586
+ this.state.failCount++;
587
+ this.state.consecutiveFailures++;
588
+ return {
589
+ number: this.state.currentIteration,
590
+ success: false,
591
+ summary: recordSummary,
592
+ keyChanges: [],
593
+ keyLearnings: learnings,
594
+ timestamp: /* @__PURE__ */ new Date()
595
+ };
596
+ }
564
597
  interruptibleSleep(ms) {
565
598
  return new Promise((resolve) => {
566
599
  this.activeAbortController = new AbortController();
@@ -635,6 +668,7 @@ var MockOrchestrator = class extends EventEmitter {
635
668
  currentIteration: 14,
636
669
  totalInputTokens: 873e5,
637
670
  totalOutputTokens: 86e4,
671
+ commitCount: 11,
638
672
  iterations: [...MOCK_ITERATIONS],
639
673
  successCount: 11,
640
674
  failCount: 2,
@@ -725,14 +759,10 @@ function generateStarField(width, height, density, seed) {
725
759
  function getStarState(star, now) {
726
760
  const t = (now % star.period / star.period + star.phase / (Math.PI * 2)) % 1;
727
761
  if (t > .05) return star.rest;
728
- if (star.rest === "bright") {
762
+ if (star.rest === "bright" || star.rest === "hidden") {
763
+ const opposite = star.rest === "bright" ? "hidden" : "bright";
729
764
  if (t > .0325) return "dim";
730
- if (t > .0175) return "hidden";
731
- return "dim";
732
- }
733
- if (star.rest === "hidden") {
734
- if (t > .0325) return "dim";
735
- if (t > .0175) return "bright";
765
+ if (t > .0175) return opposite;
736
766
  return "dim";
737
767
  }
738
768
  if (t > .025) return "bright";
@@ -906,6 +936,7 @@ const MOONS_PER_ROW = 30;
906
936
  const MOON_PHASE_PERIOD = 1600;
907
937
  const MAX_MSG_LINES = 3;
908
938
  const MAX_MSG_LINE_LEN = 64;
939
+ const RESUME_HINT = "[ctrl+c to stop, gnhf again to resume]";
909
940
  function renderTitleCells() {
910
941
  return [
911
942
  textToCells("g n h f", "dim"),
@@ -915,7 +946,8 @@ function renderTitleCells() {
915
946
  textToCells("┗━┛┗━┛┗━┛╺┻┛ ╹ ╹╹┗━┛╹ ╹ ╹ ╹ ╹╹ ╹┗┛ ┗━╸ ╹ ┗━┛╹ ╹", "bold")
916
947
  ];
917
948
  }
918
- function renderStatsCells(elapsed, inputTokens, outputTokens) {
949
+ function renderStatsCells(elapsed, inputTokens, outputTokens, commitCount) {
950
+ const commitLabel = commitCount === 1 ? "commit" : "commits";
919
951
  return [
920
952
  ...textToCells(elapsed, "bold"),
921
953
  ...textToCells(" ", "normal"),
@@ -925,7 +957,11 @@ function renderStatsCells(elapsed, inputTokens, outputTokens) {
925
957
  ...textToCells(" ", "normal"),
926
958
  ...textToCells("·", "dim"),
927
959
  ...textToCells(" ", "normal"),
928
- ...textToCells(`${formatTokens(outputTokens)} out`, "normal")
960
+ ...textToCells(`${formatTokens(outputTokens)} out`, "normal"),
961
+ ...textToCells(" ", "normal"),
962
+ ...textToCells("·", "dim"),
963
+ ...textToCells(" ", "normal"),
964
+ ...textToCells(`${commitCount} ${commitLabel}`, "normal")
929
965
  ];
930
966
  }
931
967
  function renderAgentMessageCells(message, status) {
@@ -958,42 +994,31 @@ function starStyle(state) {
958
994
  if (state === "dim") return "dim";
959
995
  return "normal";
960
996
  }
961
- function renderStarLineCells(stars, width, y, now) {
962
- const cells = emptyCells(width);
997
+ function placeStarsInCells(cells, stars, row, xMin, xMax, xOffset, now) {
963
998
  for (const star of stars) {
964
- if (star.y !== y) continue;
999
+ if (star.y !== row || star.x < xMin || star.x >= xMax) continue;
965
1000
  const state = getStarState(star, now);
966
- if (state === "hidden") cells[star.x] = {
1001
+ const localX = star.x - xOffset;
1002
+ cells[localX] = state === "hidden" ? {
967
1003
  char: " ",
968
1004
  style: "normal",
969
1005
  width: 1
970
- };
971
- else cells[star.x] = {
1006
+ } : {
972
1007
  char: star.char,
973
1008
  style: starStyle(state),
974
1009
  width: 1
975
1010
  };
976
1011
  }
1012
+ }
1013
+ function renderStarLineCells(stars, width, y, now) {
1014
+ const cells = emptyCells(width);
1015
+ placeStarsInCells(cells, stars, y, 0, width, 0, now);
977
1016
  return cells;
978
1017
  }
979
1018
  function renderSideStarsCells(stars, rowIndex, xOffset, sideWidth, now) {
980
1019
  if (sideWidth <= 0) return [];
981
1020
  const cells = emptyCells(sideWidth);
982
- for (const star of stars) {
983
- if (star.y !== rowIndex || star.x < xOffset || star.x >= xOffset + sideWidth) continue;
984
- const localX = star.x - xOffset;
985
- const state = getStarState(star, now);
986
- if (state === "hidden") cells[localX] = {
987
- char: " ",
988
- style: "normal",
989
- width: 1
990
- };
991
- else cells[localX] = {
992
- char: star.char,
993
- style: starStyle(state),
994
- width: 1
995
- };
996
- }
1021
+ placeStarsInCells(cells, stars, rowIndex, xOffset, xOffset + sideWidth, xOffset, now);
997
1022
  return cells;
998
1023
  }
999
1024
  function centerLineCells(content, width) {
@@ -1006,6 +1031,19 @@ function centerLineCells(content, width) {
1006
1031
  ...emptyCells(rightPad)
1007
1032
  ];
1008
1033
  }
1034
+ function renderResumeHintCells(width) {
1035
+ return centerLineCells(textToCells(RESUME_HINT, "dim"), width);
1036
+ }
1037
+ function fitContentRows(contentRows, maxRows) {
1038
+ if (contentRows.length <= maxRows) return contentRows;
1039
+ const fitted = [...contentRows];
1040
+ while (fitted.length > maxRows) {
1041
+ const emptyRowIndex = fitted.findIndex((row) => row.length === 0);
1042
+ if (emptyRowIndex === -1) break;
1043
+ fitted.splice(emptyRowIndex, 1);
1044
+ }
1045
+ return fitted.length > maxRows ? fitted.slice(fitted.length - maxRows) : fitted;
1046
+ }
1009
1047
  function buildContentCells(prompt, state, elapsed, now) {
1010
1048
  const rows = [];
1011
1049
  const isRunning = state.status === "running" || state.status === "waiting";
@@ -1018,7 +1056,7 @@ function buildContentCells(prompt, state, elapsed, now) {
1018
1056
  rows.push(pl ? textToCells(pl, "dim") : []);
1019
1057
  }
1020
1058
  rows.push([], []);
1021
- rows.push(renderStatsCells(elapsed, state.totalInputTokens, state.totalOutputTokens));
1059
+ rows.push(renderStatsCells(elapsed, state.totalInputTokens, state.totalOutputTokens, state.commitCount));
1022
1060
  rows.push([], []);
1023
1061
  rows.push(...renderAgentMessageCells(state.lastMessage, state.status));
1024
1062
  rows.push([], []);
@@ -1026,11 +1064,13 @@ function buildContentCells(prompt, state, elapsed, now) {
1026
1064
  return rows;
1027
1065
  }
1028
1066
  function buildFrameCells(prompt, state, topStars, bottomStars, sideStars, now, terminalWidth, terminalHeight) {
1029
- const contentRows = buildContentCells(prompt, state, formatElapsed(now - state.startTime.getTime()), now);
1030
- while (contentRows.length < BASE_CONTENT_ROWS) contentRows.push([]);
1067
+ const elapsed = formatElapsed(now - state.startTime.getTime());
1068
+ const availableHeight = Math.max(0, terminalHeight - 2);
1069
+ const contentRows = fitContentRows(buildContentCells(prompt, state, elapsed, now), availableHeight);
1070
+ while (contentRows.length < Math.min(BASE_CONTENT_ROWS, availableHeight)) contentRows.push([]);
1031
1071
  const contentCount = contentRows.length;
1032
- const remaining = Math.max(0, terminalHeight - contentCount);
1033
- const topHeight = Math.ceil(remaining / 2) - 1;
1072
+ const remaining = Math.max(0, availableHeight - contentCount);
1073
+ const topHeight = Math.max(0, Math.ceil(remaining / 2));
1034
1074
  const bottomHeight = remaining - topHeight;
1035
1075
  const sideWidth = Math.max(0, Math.floor((terminalWidth - CONTENT_WIDTH) / 2));
1036
1076
  const frame = [];
@@ -1046,6 +1086,8 @@ function buildFrameCells(prompt, state, topStars, bottomStars, sideStars, now, t
1046
1086
  ]);
1047
1087
  }
1048
1088
  for (let y = 0; y < bottomHeight; y++) frame.push(renderStarLineCells(bottomStars, terminalWidth, y, now));
1089
+ frame.push(renderResumeHintCells(terminalWidth));
1090
+ frame.push(emptyCells(terminalWidth));
1049
1091
  return frame;
1050
1092
  }
1051
1093
  var Renderer = class {
@@ -1111,8 +1153,9 @@ var Renderer = class {
1111
1153
  this.cachedHeight = h;
1112
1154
  const contentStart = Math.max(0, Math.floor((w - CONTENT_WIDTH) / 2) - 8);
1113
1155
  const contentEnd = contentStart + CONTENT_WIDTH + 16;
1114
- const remaining = Math.max(0, h - BASE_CONTENT_ROWS);
1115
- const topHeight = Math.ceil(remaining / 2) - 1;
1156
+ const availableHeight = Math.max(0, h - 2);
1157
+ const remaining = Math.max(0, availableHeight - BASE_CONTENT_ROWS);
1158
+ const topHeight = Math.max(0, Math.ceil(remaining / 2));
1116
1159
  const proximityRows = 8;
1117
1160
  const shrinkBig = (s, nearContentRow) => {
1118
1161
  if (!nearContentRow || s.x < contentStart || s.x >= contentEnd) return s;
@@ -1127,7 +1170,7 @@ var Renderer = class {
1127
1170
  };
1128
1171
  this.topStars = generateStarField(w, h, STAR_DENSITY, 42).map((s) => shrinkBig(s, s.y >= topHeight - proximityRows));
1129
1172
  this.bottomStars = generateStarField(w, h, STAR_DENSITY, 137).map((s) => shrinkBig(s, s.y < proximityRows));
1130
- this.sideStars = generateStarField(w, BASE_CONTENT_ROWS, STAR_DENSITY, 99);
1173
+ this.sideStars = generateStarField(w, Math.max(BASE_CONTENT_ROWS, availableHeight), STAR_DENSITY, 99);
1131
1174
  return true;
1132
1175
  }
1133
1176
  return false;
@@ -1155,6 +1198,15 @@ function slugifyPrompt(prompt) {
1155
1198
  }
1156
1199
  //#endregion
1157
1200
  //#region src/cli.ts
1201
+ const packageVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
1202
+ function initializeNewBranch(prompt, cwd) {
1203
+ ensureCleanWorkingTree(cwd);
1204
+ const baseCommit = getHeadCommit(cwd);
1205
+ const branchName = slugifyPrompt(prompt);
1206
+ createBranch(branchName, cwd);
1207
+ const runId = branchName.split("/")[1];
1208
+ return setupRun(runId, prompt, baseCommit, cwd);
1209
+ }
1158
1210
  function ask(question) {
1159
1211
  const rl = createInterface({
1160
1212
  input: process$1.stdin,
@@ -1168,7 +1220,7 @@ function ask(question) {
1168
1220
  });
1169
1221
  }
1170
1222
  const program = new Command();
1171
- program.name("gnhf").description("Before I go to bed, I tell my agents: good night, have fun").version("0.1.0").argument("[prompt]", "The objective for the coding agent").option("--agent <agent>", "Agent to use (claude or codex)", "claude").option("--mock", "", false).action(async (promptArg, options) => {
1223
+ 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) => {
1172
1224
  if (options.mock) {
1173
1225
  const mock = new MockOrchestrator();
1174
1226
  enterAltScreen();
@@ -1182,11 +1234,15 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
1182
1234
  let prompt = promptArg;
1183
1235
  if (!prompt && !process$1.stdin.isTTY) prompt = readFileSync("/dev/stdin", "utf-8").trim();
1184
1236
  const agentName = options.agent;
1185
- if (agentName !== "claude" && agentName !== "codex") {
1237
+ if (agentName !== void 0 && agentName !== "claude" && agentName !== "codex") {
1186
1238
  console.error(`Unknown agent: ${options.agent}. Use "claude" or "codex".`);
1187
1239
  process$1.exit(1);
1188
1240
  }
1189
- const config = loadConfig({ agent: agentName });
1241
+ const config = loadConfig(agentName ? { agent: agentName } : void 0);
1242
+ if (config.agent !== "claude" && config.agent !== "codex") {
1243
+ console.error(`Unknown agent: ${config.agent}. Use "claude" or "codex".`);
1244
+ process$1.exit(1);
1245
+ }
1190
1246
  const cwd = process$1.cwd();
1191
1247
  const currentBranch = getCurrentBranch(cwd);
1192
1248
  const onGnhfBranch = currentBranch.startsWith("gnhf/");
@@ -1204,30 +1260,18 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
1204
1260
  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]: `);
1205
1261
  if (answer === "o") {
1206
1262
  ensureCleanWorkingTree(cwd);
1207
- runInfo = setupRun(existingRunId, prompt, cwd);
1208
- commitAll(`gnhf: overwrite run ${existingRunId}`, cwd);
1209
- } else if (answer === "n") {
1210
- ensureCleanWorkingTree(cwd);
1211
- const branchName = slugifyPrompt(prompt);
1212
- createBranch(branchName, cwd);
1213
- const runId = branchName.split("/")[1];
1214
- runInfo = setupRun(runId, prompt, cwd);
1215
- commitAll(`gnhf: initialize run ${runId}`, cwd);
1216
- } else process$1.exit(0);
1263
+ runInfo = setupRun(existingRunId, prompt, existing.baseCommit, cwd);
1264
+ } else if (answer === "n") runInfo = initializeNewBranch(prompt, cwd);
1265
+ else process$1.exit(0);
1217
1266
  }
1218
1267
  } else {
1219
1268
  if (!prompt) {
1220
1269
  program.help();
1221
1270
  return;
1222
1271
  }
1223
- ensureCleanWorkingTree(cwd);
1224
- const branchName = slugifyPrompt(prompt);
1225
- createBranch(branchName, cwd);
1226
- const runId = branchName.split("/")[1];
1227
- runInfo = setupRun(runId, prompt, cwd);
1228
- commitAll(`gnhf: initialize run ${runId}`, cwd);
1272
+ runInfo = initializeNewBranch(prompt, cwd);
1229
1273
  }
1230
- const orchestrator = new Orchestrator(config, createAgent(agentName, runInfo), runInfo, prompt, cwd, startIteration);
1274
+ const orchestrator = new Orchestrator(config, createAgent(config.agent, runInfo), runInfo, prompt, cwd, startIteration);
1231
1275
  enterAltScreen();
1232
1276
  const renderer = new Renderer(orchestrator, prompt);
1233
1277
  renderer.start();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gnhf",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Before I go to bed, I tell my agents: good night, have fun",
5
5
  "type": "module",
6
6
  "bin": {