gnhf 0.1.6 → 0.1.8

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 +21 -8
  2. package/dist/cli.mjs +1078 -63
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -39,12 +39,14 @@
39
39
  <img src="docs/splash.png" alt="gnhf — Good Night, Have Fun" width="800">
40
40
  </p>
41
41
 
42
+ Never wake up empty-handed.
43
+
42
44
  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
45
  You wake up to a branch full of clean work and a log of everything that happened.
44
46
 
45
47
  - **Dead simple** — one command starts an autonomous loop that runs until you Ctrl+C or a configured runtime cap is reached
46
48
  - **Long running** — each iteration is committed on success, rolled back on failure, with sensible retries and exponential backoff
47
- - **Agent-agnostic** — works with Claude Code or Codex out of the box
49
+ - **Agent-agnostic** — works with Claude Code, Codex, Rovo Dev, or OpenCode out of the box
48
50
 
49
51
  ## Quick Start
50
52
 
@@ -141,19 +143,19 @@ npm link
141
143
 
142
144
  ### Flags
143
145
 
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 | |
146
+ | Flag | Description | Default |
147
+ | ---------------------- | ---------------------------------------------------------- | ---------------------- |
148
+ | `--agent <agent>` | Agent to use (`claude`, `codex`, `rovodev`, or `opencode`) | config file (`claude`) |
149
+ | `--max-iterations <n>` | Abort after `n` total iterations | unlimited |
150
+ | `--max-tokens <n>` | Abort after `n` total input+output tokens | unlimited |
151
+ | `--version` | Show version | |
150
152
 
151
153
  ## Configuration
152
154
 
153
155
  Config lives at `~/.gnhf/config.yml`:
154
156
 
155
157
  ```yaml
156
- # Agent to use by default
158
+ # Agent to use by default (claude, codex, rovodev, or opencode)
157
159
  agent: claude
158
160
 
159
161
  # Abort after this many consecutive failures
@@ -164,6 +166,17 @@ If the file does not exist yet, `gnhf` creates it on first run using the resolve
164
166
 
165
167
  CLI flags override config file values. The iteration and token caps are runtime-only flags and are not persisted in `config.yml`.
166
168
 
169
+ ## Agents
170
+
171
+ `gnhf` supports four agents:
172
+
173
+ | Agent | Flag | Requirements | Notes |
174
+ | ----------- | ------------------ | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
175
+ | Claude Code | `--agent claude` | Install Anthropic's `claude` CLI and sign in first. | `gnhf` invokes `claude` directly in non-interactive mode. |
176
+ | Codex | `--agent codex` | Install OpenAI's `codex` CLI and sign in first. | `gnhf` invokes `codex exec` directly in non-interactive mode. |
177
+ | Rovo Dev | `--agent rovodev` | Install Atlassian's `acli` and authenticate it with Rovo Dev first. | `gnhf` starts a local `acli rovodev serve --disable-session-token <port>` process automatically in the repo workspace. |
178
+ | OpenCode | `--agent opencode` | Install `opencode` and configure at least one usable model provider first. | `gnhf` starts a local `opencode serve --hostname 127.0.0.1 --port <port> --print-logs` process automatically, creates a per-run session, and applies a blanket allow rule so tool calls do not block on prompts. |
179
+
167
180
  ## Development
168
181
 
169
182
  ```sh
package/dist/cli.mjs CHANGED
@@ -7,6 +7,7 @@ import { dirname, isAbsolute, join } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import yaml from "js-yaml";
9
9
  import { execFileSync, execSync, spawn } from "node:child_process";
10
+ import { createServer } from "node:net";
10
11
  import { EventEmitter } from "node:events";
11
12
  import { createHash } from "node:crypto";
12
13
  //#region src/core/config.ts
@@ -134,6 +135,7 @@ function resetHard(cwd) {
134
135
  //#region src/core/agents/types.ts
135
136
  const AGENT_OUTPUT_SCHEMA = {
136
137
  type: "object",
138
+ additionalProperties: false,
137
139
  properties: {
138
140
  success: { type: "boolean" },
139
141
  summary: { type: "string" },
@@ -155,6 +157,9 @@ const AGENT_OUTPUT_SCHEMA = {
155
157
  };
156
158
  //#endregion
157
159
  //#region src/core/run.ts
160
+ function writeSchemaFile(schemaPath) {
161
+ writeFileSync(schemaPath, JSON.stringify(AGENT_OUTPUT_SCHEMA, null, 2), "utf-8");
162
+ }
158
163
  function ensureRunMetadataIgnored(cwd) {
159
164
  const excludePath = execFileSync("git", [
160
165
  "rev-parse",
@@ -182,7 +187,7 @@ function setupRun(runId, prompt, baseCommit, cwd) {
182
187
  const notesPath = join(runDir, "notes.md");
183
188
  writeFileSync(notesPath, `# gnhf run: ${runId}\n\nObjective: ${prompt}\n\n## Iteration Log\n`, "utf-8");
184
189
  const schemaPath = join(runDir, "output-schema.json");
185
- writeFileSync(schemaPath, JSON.stringify(AGENT_OUTPUT_SCHEMA, null, 2), "utf-8");
190
+ writeSchemaFile(schemaPath);
186
191
  const baseCommitPath = join(runDir, "base-commit");
187
192
  const hasStoredBaseCommit = existsSync(baseCommitPath);
188
193
  const resolvedBaseCommit = hasStoredBaseCommit ? readFileSync(baseCommitPath, "utf-8").trim() : baseCommit;
@@ -203,6 +208,7 @@ function resumeRun(runId, cwd) {
203
208
  const promptPath = join(runDir, "prompt.md");
204
209
  const notesPath = join(runDir, "notes.md");
205
210
  const schemaPath = join(runDir, "output-schema.json");
211
+ writeSchemaFile(schemaPath);
206
212
  const baseCommitPath = join(runDir, "base-commit");
207
213
  return {
208
214
  runId,
@@ -453,11 +459,984 @@ var CodexAgent = class {
453
459
  }
454
460
  };
455
461
  //#endregion
462
+ //#region src/core/agents/opencode.ts
463
+ const BLANKET_PERMISSION_RULESET = [{
464
+ permission: "*",
465
+ pattern: "*",
466
+ action: "allow"
467
+ }];
468
+ const STRUCTURED_OUTPUT_FORMAT = {
469
+ type: "json_schema",
470
+ schema: AGENT_OUTPUT_SCHEMA,
471
+ retryCount: 1
472
+ };
473
+ function buildOpencodeChildEnv() {
474
+ const env = { ...process.env };
475
+ delete env.OPENCODE_SERVER_USERNAME;
476
+ delete env.OPENCODE_SERVER_PASSWORD;
477
+ return env;
478
+ }
479
+ function buildPrompt(prompt) {
480
+ return [
481
+ prompt,
482
+ "",
483
+ "When you finish, reply with only valid JSON.",
484
+ "Do not wrap the JSON in markdown fences.",
485
+ "Do not include any prose before or after the JSON.",
486
+ `The JSON must match this schema exactly: ${JSON.stringify(AGENT_OUTPUT_SCHEMA)}`
487
+ ].join("\n");
488
+ }
489
+ function createAbortError$1() {
490
+ return /* @__PURE__ */ new Error("Agent was aborted");
491
+ }
492
+ function isAgentAbortError(error) {
493
+ return error instanceof Error && error.message === "Agent was aborted";
494
+ }
495
+ function isAbortError$1(error) {
496
+ return error instanceof Error && error.name === "AbortError";
497
+ }
498
+ function getAvailablePort$1() {
499
+ return new Promise((resolve, reject) => {
500
+ const server = createServer();
501
+ server.unref();
502
+ server.on("error", reject);
503
+ server.listen(0, "127.0.0.1", () => {
504
+ const address = server.address();
505
+ if (!address || typeof address === "string") {
506
+ server.close();
507
+ reject(/* @__PURE__ */ new Error("Failed to allocate a port for opencode"));
508
+ return;
509
+ }
510
+ server.close((error) => {
511
+ if (error) {
512
+ reject(error);
513
+ return;
514
+ }
515
+ resolve(address.port);
516
+ });
517
+ });
518
+ });
519
+ }
520
+ async function delay$1(ms, signal) {
521
+ if (!signal) {
522
+ await new Promise((resolve) => setTimeout(resolve, ms));
523
+ return;
524
+ }
525
+ await new Promise((resolve, reject) => {
526
+ const timer = setTimeout(() => {
527
+ signal.removeEventListener("abort", onAbort);
528
+ resolve();
529
+ }, ms);
530
+ const onAbort = () => {
531
+ clearTimeout(timer);
532
+ signal.removeEventListener("abort", onAbort);
533
+ reject(createAbortError$1());
534
+ };
535
+ if (signal.aborted) {
536
+ onAbort();
537
+ return;
538
+ }
539
+ signal.addEventListener("abort", onAbort, { once: true });
540
+ });
541
+ }
542
+ function toUsage(tokens) {
543
+ return {
544
+ inputTokens: tokens?.input ?? 0,
545
+ outputTokens: tokens?.output ?? 0,
546
+ cacheReadTokens: tokens?.cache?.read ?? 0,
547
+ cacheCreationTokens: tokens?.cache?.write ?? 0
548
+ };
549
+ }
550
+ function withTimeoutSignal$1(signal, timeoutMs) {
551
+ if (timeoutMs === void 0) return signal;
552
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
553
+ return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
554
+ }
555
+ var OpenCodeAgent = class {
556
+ name = "opencode";
557
+ fetchFn;
558
+ getPortFn;
559
+ killProcessFn;
560
+ spawnFn;
561
+ server = null;
562
+ closingPromise = null;
563
+ constructor(deps = {}) {
564
+ this.fetchFn = deps.fetch ?? fetch;
565
+ this.getPortFn = deps.getPort ?? getAvailablePort$1;
566
+ this.killProcessFn = deps.killProcess ?? process.kill.bind(process);
567
+ this.spawnFn = deps.spawn ?? spawn;
568
+ }
569
+ async run(prompt, cwd, options) {
570
+ const { onUsage, onMessage, signal, logPath } = options ?? {};
571
+ const logStream = logPath ? createWriteStream(logPath) : null;
572
+ const runController = new AbortController();
573
+ let sessionId = null;
574
+ const onAbort = () => {
575
+ runController.abort();
576
+ };
577
+ if (signal?.aborted) {
578
+ logStream?.end();
579
+ throw createAbortError$1();
580
+ }
581
+ signal?.addEventListener("abort", onAbort, { once: true });
582
+ try {
583
+ const server = await this.ensureServer(cwd, runController.signal);
584
+ sessionId = await this.createSession(server, cwd, runController.signal);
585
+ return await this.streamMessage(server, sessionId, buildPrompt(prompt), runController.signal, logStream, onUsage, onMessage);
586
+ } catch (error) {
587
+ if (runController.signal.aborted || isAbortError$1(error)) throw createAbortError$1();
588
+ throw error;
589
+ } finally {
590
+ signal?.removeEventListener("abort", onAbort);
591
+ logStream?.end();
592
+ if (this.server && sessionId) {
593
+ if (runController.signal.aborted) await this.abortSession(this.server, sessionId);
594
+ await this.deleteSession(this.server, sessionId);
595
+ }
596
+ }
597
+ }
598
+ async close() {
599
+ await this.shutdownServer();
600
+ }
601
+ async ensureServer(cwd, signal) {
602
+ if (this.server && !this.server.closed) if (this.server.cwd !== cwd) await this.shutdownServer();
603
+ else {
604
+ await this.server.readyPromise;
605
+ return this.server;
606
+ }
607
+ if (this.server && !this.server.closed) {
608
+ await this.server.readyPromise;
609
+ return this.server;
610
+ }
611
+ const port = await this.getPortFn();
612
+ const detached = process.platform !== "win32";
613
+ const child = this.spawnFn("opencode", [
614
+ "serve",
615
+ "--hostname",
616
+ "127.0.0.1",
617
+ "--port",
618
+ String(port),
619
+ "--print-logs"
620
+ ], {
621
+ cwd,
622
+ detached,
623
+ stdio: [
624
+ "ignore",
625
+ "pipe",
626
+ "pipe"
627
+ ],
628
+ env: buildOpencodeChildEnv()
629
+ });
630
+ const server = {
631
+ baseUrl: `http://127.0.0.1:${port}`,
632
+ child,
633
+ closed: false,
634
+ cwd,
635
+ detached,
636
+ port,
637
+ readyPromise: Promise.resolve(),
638
+ stderr: "",
639
+ stdout: ""
640
+ };
641
+ const maxOutput = 64 * 1024;
642
+ child.stdout.on("data", (data) => {
643
+ server.stdout += data.toString();
644
+ if (server.stdout.length > maxOutput) server.stdout = server.stdout.slice(-maxOutput);
645
+ });
646
+ child.stderr.on("data", (data) => {
647
+ server.stderr += data.toString();
648
+ if (server.stderr.length > maxOutput) server.stderr = server.stderr.slice(-maxOutput);
649
+ });
650
+ child.on("close", () => {
651
+ server.closed = true;
652
+ if (this.server === server) this.server = null;
653
+ });
654
+ this.server = server;
655
+ server.readyPromise = this.waitForHealthy(server, signal).catch(async (error) => {
656
+ await this.shutdownServer();
657
+ throw error;
658
+ });
659
+ await server.readyPromise;
660
+ return server;
661
+ }
662
+ async waitForHealthy(server, signal) {
663
+ const deadline = Date.now() + 3e4;
664
+ let spawnErrorMessage = null;
665
+ server.child.once("error", (error) => {
666
+ spawnErrorMessage = error.message;
667
+ });
668
+ while (Date.now() < deadline) {
669
+ if (signal?.aborted) throw createAbortError$1();
670
+ if (spawnErrorMessage) throw new Error(`Failed to spawn opencode: ${spawnErrorMessage}`);
671
+ if (server.closed) {
672
+ const output = server.stderr.trim() || server.stdout.trim();
673
+ throw new Error(output ? `opencode exited before becoming ready: ${output}` : "opencode exited before becoming ready");
674
+ }
675
+ try {
676
+ if ((await this.fetchFn(`${server.baseUrl}/global/health`, {
677
+ method: "GET",
678
+ signal
679
+ })).ok) return;
680
+ } catch (error) {
681
+ if (isAbortError$1(error)) throw createAbortError$1();
682
+ }
683
+ await delay$1(250, signal);
684
+ }
685
+ throw new Error(`Timed out waiting for opencode serve to become ready on port ${server.port}`);
686
+ }
687
+ async createSession(server, cwd, signal) {
688
+ return (await this.requestJSON(server, "/session", {
689
+ method: "POST",
690
+ body: {
691
+ directory: cwd,
692
+ permission: BLANKET_PERMISSION_RULESET
693
+ },
694
+ signal
695
+ })).id;
696
+ }
697
+ async streamMessage(server, sessionId, prompt, signal, logStream, onUsage, onMessage) {
698
+ const streamAbortController = new AbortController();
699
+ const streamSignal = AbortSignal.any([signal, streamAbortController.signal]);
700
+ const eventResponse = await this.request(server, "/global/event", {
701
+ method: "GET",
702
+ headers: { accept: "text/event-stream" },
703
+ signal: streamSignal
704
+ });
705
+ if (!eventResponse.body) throw new Error("opencode returned no event stream body");
706
+ let messageRequestError = null;
707
+ const messageRequest = (async () => {
708
+ try {
709
+ return {
710
+ ok: true,
711
+ body: await this.requestText(server, `/session/${sessionId}/message`, {
712
+ method: "POST",
713
+ body: {
714
+ role: "user",
715
+ parts: [{
716
+ type: "text",
717
+ text: prompt
718
+ }],
719
+ format: STRUCTURED_OUTPUT_FORMAT
720
+ },
721
+ signal
722
+ })
723
+ };
724
+ } catch (error) {
725
+ messageRequestError = error;
726
+ streamAbortController.abort();
727
+ return {
728
+ ok: false,
729
+ error
730
+ };
731
+ }
732
+ })();
733
+ const usage = {
734
+ inputTokens: 0,
735
+ outputTokens: 0,
736
+ cacheReadTokens: 0,
737
+ cacheCreationTokens: 0
738
+ };
739
+ const usageByMessageId = /* @__PURE__ */ new Map();
740
+ const textParts = /* @__PURE__ */ new Map();
741
+ let lastText = null;
742
+ let lastFinalAnswerText = null;
743
+ let lastUsageSignature = "0:0:0:0";
744
+ const updateUsage = (messageId, tokens) => {
745
+ if (!messageId || !tokens) return;
746
+ usageByMessageId.set(messageId, toUsage(tokens));
747
+ let nextInputTokens = 0;
748
+ let nextOutputTokens = 0;
749
+ let nextCacheReadTokens = 0;
750
+ let nextCacheCreationTokens = 0;
751
+ for (const messageUsage of usageByMessageId.values()) {
752
+ nextInputTokens += messageUsage.inputTokens;
753
+ nextOutputTokens += messageUsage.outputTokens;
754
+ nextCacheReadTokens += messageUsage.cacheReadTokens;
755
+ nextCacheCreationTokens += messageUsage.cacheCreationTokens;
756
+ }
757
+ const signature = [
758
+ nextInputTokens,
759
+ nextOutputTokens,
760
+ nextCacheReadTokens,
761
+ nextCacheCreationTokens
762
+ ].join(":");
763
+ usage.inputTokens = nextInputTokens;
764
+ usage.outputTokens = nextOutputTokens;
765
+ usage.cacheReadTokens = nextCacheReadTokens;
766
+ usage.cacheCreationTokens = nextCacheCreationTokens;
767
+ if (signature !== lastUsageSignature) {
768
+ lastUsageSignature = signature;
769
+ onUsage?.({ ...usage });
770
+ }
771
+ };
772
+ const emitText = (partId, nextText, phase) => {
773
+ const trimmed = nextText.trim();
774
+ textParts.set(partId, {
775
+ text: nextText,
776
+ phase
777
+ });
778
+ if (!trimmed) return;
779
+ lastText = nextText;
780
+ if (phase === "final_answer") lastFinalAnswerText = nextText;
781
+ onMessage?.(trimmed);
782
+ };
783
+ const handleEvent = (event) => {
784
+ const payload = event.payload;
785
+ const properties = payload?.properties;
786
+ if (!properties || properties.sessionID !== sessionId) return false;
787
+ if (payload?.type === "message.part.delta" && properties.field === "text" && typeof properties.partID === "string" && typeof properties.delta === "string") {
788
+ const current = textParts.get(properties.partID);
789
+ emitText(properties.partID, `${current?.text ?? ""}${properties.delta}`, current?.phase);
790
+ return false;
791
+ }
792
+ if (payload?.type === "message.part.updated") {
793
+ const part = properties.part;
794
+ if (!part) return false;
795
+ if (part.type === "text" && typeof part.id === "string") {
796
+ emitText(part.id, part.text ?? "", part.metadata?.openai?.phase);
797
+ return false;
798
+ }
799
+ if (part.type === "step-finish") {
800
+ updateUsage(part.messageID, part.tokens);
801
+ return false;
802
+ }
803
+ return false;
804
+ }
805
+ if (payload?.type === "message.updated") {
806
+ if (properties.info?.role === "assistant") updateUsage(properties.info.id, properties.info.tokens);
807
+ return false;
808
+ }
809
+ return payload?.type === "session.idle";
810
+ };
811
+ const decoder = new TextDecoder();
812
+ const reader = eventResponse.body.getReader();
813
+ let buffer = "";
814
+ let sawSessionIdle = false;
815
+ const processRawEvent = (rawEvent) => {
816
+ if (!rawEvent.trim()) return;
817
+ const dataLines = rawEvent.split(/\r?\n/).filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart());
818
+ if (dataLines.length === 0) return;
819
+ try {
820
+ if (handleEvent(JSON.parse(dataLines.join("\n")))) sawSessionIdle = true;
821
+ } catch {}
822
+ };
823
+ const processBufferedEvents = (flushRemainder = false) => {
824
+ while (true) {
825
+ const lfBoundary = buffer.indexOf("\n\n");
826
+ const crlfBoundary = buffer.indexOf("\r\n\r\n");
827
+ let boundary;
828
+ let separatorLen;
829
+ if (lfBoundary === -1 && crlfBoundary === -1) break;
830
+ if (crlfBoundary !== -1 && (lfBoundary === -1 || crlfBoundary < lfBoundary)) {
831
+ boundary = crlfBoundary;
832
+ separatorLen = 4;
833
+ } else {
834
+ boundary = lfBoundary;
835
+ separatorLen = 2;
836
+ }
837
+ processRawEvent(buffer.slice(0, boundary));
838
+ buffer = buffer.slice(boundary + separatorLen);
839
+ if (sawSessionIdle) return;
840
+ }
841
+ if (flushRemainder && buffer.trim()) {
842
+ processRawEvent(buffer);
843
+ buffer = "";
844
+ }
845
+ };
846
+ try {
847
+ while (!sawSessionIdle) {
848
+ let readResult;
849
+ try {
850
+ readResult = await reader.read();
851
+ } catch (error) {
852
+ if (messageRequestError) {
853
+ if (isAbortError$1(messageRequestError) || isAgentAbortError(messageRequestError)) throw createAbortError$1();
854
+ throw messageRequestError;
855
+ }
856
+ if (isAbortError$1(error)) throw createAbortError$1();
857
+ throw error;
858
+ }
859
+ if (readResult.done) {
860
+ const tail = decoder.decode();
861
+ if (tail) {
862
+ logStream?.write(tail);
863
+ buffer += tail;
864
+ }
865
+ processBufferedEvents(true);
866
+ break;
867
+ }
868
+ const chunk = decoder.decode(readResult.value, { stream: true });
869
+ logStream?.write(chunk);
870
+ buffer += chunk;
871
+ processBufferedEvents();
872
+ }
873
+ } finally {
874
+ streamAbortController.abort();
875
+ await reader.cancel().catch(() => void 0);
876
+ }
877
+ const messageResult = await messageRequest;
878
+ if (!messageResult.ok) {
879
+ if (isAbortError$1(messageResult.error) || isAgentAbortError(messageResult.error)) throw createAbortError$1();
880
+ throw messageResult.error;
881
+ }
882
+ const body = messageResult.body;
883
+ let response;
884
+ try {
885
+ response = JSON.parse(body);
886
+ } catch (error) {
887
+ throw new Error(`Failed to parse opencode response: ${error instanceof Error ? error.message : String(error)}`);
888
+ }
889
+ if (response.info?.role === "assistant") updateUsage(response.info.id, response.info.tokens);
890
+ for (const part of response.parts ?? []) {
891
+ if (part.type !== "text" || typeof part.text !== "string") continue;
892
+ if (!part.text.trim()) continue;
893
+ lastText = part.text;
894
+ if (part.metadata?.openai?.phase === "final_answer") lastFinalAnswerText = part.text;
895
+ }
896
+ if (response.info?.structured) return {
897
+ output: response.info.structured,
898
+ usage
899
+ };
900
+ const outputText = lastFinalAnswerText ?? lastText;
901
+ if (!outputText) throw new Error("opencode returned no text output");
902
+ try {
903
+ return {
904
+ output: JSON.parse(outputText),
905
+ usage
906
+ };
907
+ } catch (error) {
908
+ throw new Error(`Failed to parse opencode output: ${error instanceof Error ? error.message : String(error)}`);
909
+ }
910
+ }
911
+ async deleteSession(server, sessionId) {
912
+ try {
913
+ await this.request(server, `/session/${sessionId}`, {
914
+ method: "DELETE",
915
+ timeoutMs: 1e3
916
+ });
917
+ } catch {}
918
+ }
919
+ async abortSession(server, sessionId) {
920
+ try {
921
+ await this.request(server, `/session/${sessionId}/abort`, {
922
+ method: "POST",
923
+ timeoutMs: 1e3
924
+ });
925
+ } catch {}
926
+ }
927
+ async shutdownServer() {
928
+ if (!this.server || this.server.closed) {
929
+ this.server = null;
930
+ return;
931
+ }
932
+ if (this.closingPromise) {
933
+ await this.closingPromise;
934
+ return;
935
+ }
936
+ const server = this.server;
937
+ const waitForClose = new Promise((resolve) => {
938
+ if (server.closed) {
939
+ resolve();
940
+ return;
941
+ }
942
+ server.child.once("close", () => resolve());
943
+ });
944
+ try {
945
+ this.signalServer(server, "SIGTERM");
946
+ } catch {}
947
+ const forceKill = new Promise((resolve) => {
948
+ setTimeout(() => {
949
+ if (!server.closed) try {
950
+ this.signalServer(server, "SIGKILL");
951
+ } catch {}
952
+ resolve();
953
+ }, 3e3).unref?.();
954
+ });
955
+ this.closingPromise = Promise.race([waitForClose, forceKill]).finally(() => {
956
+ if (this.server === server) this.server = null;
957
+ this.closingPromise = null;
958
+ });
959
+ await this.closingPromise;
960
+ }
961
+ signalServer(server, signal) {
962
+ if (server.detached && server.child.pid) try {
963
+ this.killProcessFn(-server.child.pid, signal);
964
+ return;
965
+ } catch {}
966
+ server.child.kill(signal);
967
+ }
968
+ async requestJSON(server, path, options) {
969
+ const body = await this.requestText(server, path, options);
970
+ return JSON.parse(body);
971
+ }
972
+ async requestText(server, path, options) {
973
+ return await (await this.request(server, path, options)).text();
974
+ }
975
+ async request(server, path, options) {
976
+ const headers = new Headers(options.headers);
977
+ if (options.body !== void 0) headers.set("content-type", "application/json");
978
+ const signal = withTimeoutSignal$1(options.signal, options.timeoutMs);
979
+ const response = await this.fetchFn(`${server.baseUrl}${path}`, {
980
+ method: options.method,
981
+ headers,
982
+ body: options.body === void 0 ? void 0 : JSON.stringify(options.body),
983
+ signal
984
+ });
985
+ if (!response.ok) {
986
+ const body = await response.text();
987
+ throw new Error(`opencode ${options.method} ${path} failed with ${response.status}: ${body}`);
988
+ }
989
+ return response;
990
+ }
991
+ };
992
+ //#endregion
993
+ //#region src/core/agents/rovodev.ts
994
+ function buildSystemPrompt(schema) {
995
+ return [
996
+ "You are the coding agent used by gnhf.",
997
+ "Work autonomously in the current workspace and use tools when needed.",
998
+ "When you finish, reply with only valid JSON.",
999
+ "Do not wrap the JSON in markdown fences.",
1000
+ "Do not include any prose before or after the JSON.",
1001
+ `The JSON must match this schema exactly: ${schema}`
1002
+ ].join(" ");
1003
+ }
1004
+ function createAbortError() {
1005
+ return /* @__PURE__ */ new Error("Agent was aborted");
1006
+ }
1007
+ function isAbortError(error) {
1008
+ return error instanceof Error && error.name === "AbortError";
1009
+ }
1010
+ function getAvailablePort() {
1011
+ return new Promise((resolve, reject) => {
1012
+ const server = createServer();
1013
+ server.unref();
1014
+ server.on("error", reject);
1015
+ server.listen(0, "127.0.0.1", () => {
1016
+ const address = server.address();
1017
+ if (!address || typeof address === "string") {
1018
+ server.close();
1019
+ reject(/* @__PURE__ */ new Error("Failed to allocate a port for rovodev"));
1020
+ return;
1021
+ }
1022
+ server.close((error) => {
1023
+ if (error) {
1024
+ reject(error);
1025
+ return;
1026
+ }
1027
+ resolve(address.port);
1028
+ });
1029
+ });
1030
+ });
1031
+ }
1032
+ async function delay(ms, signal) {
1033
+ if (!signal) {
1034
+ await new Promise((resolve) => setTimeout(resolve, ms));
1035
+ return;
1036
+ }
1037
+ await new Promise((resolve, reject) => {
1038
+ const timer = setTimeout(() => {
1039
+ signal.removeEventListener("abort", onAbort);
1040
+ resolve();
1041
+ }, ms);
1042
+ const onAbort = () => {
1043
+ clearTimeout(timer);
1044
+ signal.removeEventListener("abort", onAbort);
1045
+ reject(createAbortError());
1046
+ };
1047
+ if (signal.aborted) {
1048
+ onAbort();
1049
+ return;
1050
+ }
1051
+ signal.addEventListener("abort", onAbort, { once: true });
1052
+ });
1053
+ }
1054
+ var RovoDevAgent = class {
1055
+ name = "rovodev";
1056
+ schemaPath;
1057
+ fetchFn;
1058
+ getPortFn;
1059
+ killProcessFn;
1060
+ spawnFn;
1061
+ server = null;
1062
+ closingPromise = null;
1063
+ constructor(schemaPath, deps = {}) {
1064
+ this.schemaPath = schemaPath;
1065
+ this.fetchFn = deps.fetch ?? fetch;
1066
+ this.getPortFn = deps.getPort ?? getAvailablePort;
1067
+ this.killProcessFn = deps.killProcess ?? process.kill.bind(process);
1068
+ this.spawnFn = deps.spawn ?? spawn;
1069
+ }
1070
+ async run(prompt, cwd, options) {
1071
+ const { onUsage, onMessage, signal, logPath } = options ?? {};
1072
+ const logStream = logPath ? createWriteStream(logPath) : null;
1073
+ const runController = new AbortController();
1074
+ let sessionId = null;
1075
+ const onAbort = () => {
1076
+ runController.abort();
1077
+ };
1078
+ if (signal?.aborted) {
1079
+ logStream?.end();
1080
+ throw createAbortError();
1081
+ }
1082
+ signal?.addEventListener("abort", onAbort, { once: true });
1083
+ try {
1084
+ const server = await this.ensureServer(cwd, runController.signal);
1085
+ sessionId = await this.createSession(server, runController.signal);
1086
+ await this.setInlineSystemPrompt(server, sessionId, runController.signal);
1087
+ await this.setChatMessage(server, sessionId, prompt, runController.signal);
1088
+ return await this.streamChat(server, sessionId, runController.signal, logStream, onUsage, onMessage);
1089
+ } catch (error) {
1090
+ if (runController.signal.aborted || isAbortError(error)) throw createAbortError();
1091
+ throw error;
1092
+ } finally {
1093
+ signal?.removeEventListener("abort", onAbort);
1094
+ logStream?.end();
1095
+ if (this.server && sessionId) {
1096
+ if (runController.signal.aborted) await this.cancelSession(this.server, sessionId);
1097
+ await this.deleteSession(this.server, sessionId);
1098
+ }
1099
+ }
1100
+ }
1101
+ async close() {
1102
+ await this.shutdownServer();
1103
+ }
1104
+ async ensureServer(cwd, signal) {
1105
+ if (this.server && !this.server.closed && this.server.cwd === cwd) {
1106
+ await this.server.readyPromise;
1107
+ return this.server;
1108
+ }
1109
+ if (this.server && !this.server.closed) await this.shutdownServer();
1110
+ const port = await this.getPortFn();
1111
+ const detached = process.platform !== "win32";
1112
+ const child = this.spawnFn("acli", [
1113
+ "rovodev",
1114
+ "serve",
1115
+ "--disable-session-token",
1116
+ String(port)
1117
+ ], {
1118
+ cwd,
1119
+ detached,
1120
+ stdio: [
1121
+ "ignore",
1122
+ "pipe",
1123
+ "pipe"
1124
+ ],
1125
+ env: process.env
1126
+ });
1127
+ const server = {
1128
+ baseUrl: `http://127.0.0.1:${port}`,
1129
+ child,
1130
+ cwd,
1131
+ detached,
1132
+ port,
1133
+ readyPromise: Promise.resolve(),
1134
+ closed: false,
1135
+ stdout: "",
1136
+ stderr: ""
1137
+ };
1138
+ const MAX_OUTPUT = 64 * 1024;
1139
+ child.stdout.on("data", (data) => {
1140
+ server.stdout += data.toString();
1141
+ if (server.stdout.length > MAX_OUTPUT) server.stdout = server.stdout.slice(-MAX_OUTPUT);
1142
+ });
1143
+ child.stderr.on("data", (data) => {
1144
+ server.stderr += data.toString();
1145
+ if (server.stderr.length > MAX_OUTPUT) server.stderr = server.stderr.slice(-MAX_OUTPUT);
1146
+ });
1147
+ child.on("close", () => {
1148
+ server.closed = true;
1149
+ if (this.server === server) this.server = null;
1150
+ });
1151
+ this.server = server;
1152
+ server.readyPromise = this.waitForHealthy(server, signal).catch(async (error) => {
1153
+ await this.shutdownServer();
1154
+ throw error;
1155
+ });
1156
+ await server.readyPromise;
1157
+ return server;
1158
+ }
1159
+ async waitForHealthy(server, signal) {
1160
+ const deadline = Date.now() + 3e4;
1161
+ let spawnError = null;
1162
+ server.child.once("error", (error) => {
1163
+ spawnError = error;
1164
+ });
1165
+ while (Date.now() < deadline) {
1166
+ if (signal?.aborted) throw createAbortError();
1167
+ if (spawnError) throw new Error(`Failed to spawn rovodev: ${spawnError.message}`);
1168
+ if (server.closed) {
1169
+ const output = server.stderr.trim() || server.stdout.trim();
1170
+ throw new Error(output ? `rovodev exited before becoming ready: ${output}` : "rovodev exited before becoming ready");
1171
+ }
1172
+ try {
1173
+ if ((await this.fetchFn(`${server.baseUrl}/healthcheck`, {
1174
+ method: "GET",
1175
+ signal
1176
+ })).ok) return;
1177
+ } catch (error) {
1178
+ if (isAbortError(error)) throw createAbortError();
1179
+ }
1180
+ await delay(250, signal);
1181
+ }
1182
+ throw new Error(`Timed out waiting for rovodev serve to become ready on port ${server.port}`);
1183
+ }
1184
+ async createSession(server, signal) {
1185
+ return (await this.requestJSON(server, "/v3/sessions/create", {
1186
+ method: "POST",
1187
+ body: { custom_title: "gnhf" },
1188
+ signal
1189
+ })).session_id;
1190
+ }
1191
+ async setInlineSystemPrompt(server, sessionId, signal) {
1192
+ const schema = readFileSync(this.schemaPath, "utf-8").trim();
1193
+ await this.requestJSON(server, "/v3/inline-system-prompt", {
1194
+ method: "PUT",
1195
+ sessionId,
1196
+ body: { prompt: buildSystemPrompt(schema) },
1197
+ signal
1198
+ });
1199
+ }
1200
+ async setChatMessage(server, sessionId, prompt, signal) {
1201
+ await this.requestJSON(server, "/v3/set_chat_message", {
1202
+ method: "POST",
1203
+ sessionId,
1204
+ body: { message: prompt },
1205
+ signal
1206
+ });
1207
+ }
1208
+ async cancelSession(server, sessionId) {
1209
+ try {
1210
+ await this.request(server, "/v3/cancel", {
1211
+ method: "POST",
1212
+ sessionId,
1213
+ timeoutMs: 1e3
1214
+ });
1215
+ } catch {}
1216
+ }
1217
+ async deleteSession(server, sessionId) {
1218
+ try {
1219
+ await this.request(server, `/v3/sessions/${sessionId}`, {
1220
+ method: "DELETE",
1221
+ sessionId,
1222
+ timeoutMs: 1e3
1223
+ });
1224
+ } catch {}
1225
+ }
1226
+ async streamChat(server, sessionId, signal, logStream, onUsage, onMessage) {
1227
+ const response = await this.request(server, "/v3/stream_chat", {
1228
+ method: "GET",
1229
+ sessionId,
1230
+ headers: { accept: "text/event-stream" },
1231
+ signal
1232
+ });
1233
+ if (!response.body) throw new Error("rovodev returned no response body");
1234
+ const usage = {
1235
+ inputTokens: 0,
1236
+ outputTokens: 0,
1237
+ cacheReadTokens: 0,
1238
+ cacheCreationTokens: 0
1239
+ };
1240
+ let latestTextSegment = "";
1241
+ let currentTextParts = [];
1242
+ let currentTextIndexes = /* @__PURE__ */ new Map();
1243
+ const decoder = new TextDecoder();
1244
+ const reader = response.body.getReader();
1245
+ let buffer = "";
1246
+ const emitMessage = () => {
1247
+ const message = currentTextParts.join("").trim();
1248
+ if (message) {
1249
+ latestTextSegment = message;
1250
+ onMessage?.(message);
1251
+ }
1252
+ };
1253
+ const resetCurrentMessage = () => {
1254
+ currentTextParts = [];
1255
+ currentTextIndexes = /* @__PURE__ */ new Map();
1256
+ };
1257
+ const handleUsage = (event) => {
1258
+ usage.inputTokens += event.input_tokens ?? 0;
1259
+ usage.outputTokens += event.output_tokens ?? 0;
1260
+ usage.cacheReadTokens += event.cache_read_tokens ?? 0;
1261
+ usage.cacheCreationTokens += event.cache_write_tokens ?? 0;
1262
+ onUsage?.({ ...usage });
1263
+ };
1264
+ const handleEvent = (rawEvent) => {
1265
+ const lines = rawEvent.split(/\r?\n/);
1266
+ let eventName = "";
1267
+ const dataLines = [];
1268
+ for (const line of lines) {
1269
+ if (line.startsWith("event:")) {
1270
+ eventName = line.slice(6).trim();
1271
+ continue;
1272
+ }
1273
+ if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart());
1274
+ }
1275
+ const rawData = dataLines.join("\n");
1276
+ if (rawData.length === 0) return;
1277
+ let payload;
1278
+ try {
1279
+ payload = JSON.parse(rawData);
1280
+ } catch {
1281
+ return;
1282
+ }
1283
+ const kind = eventName || (typeof payload.event_kind === "string" ? payload.event_kind : "");
1284
+ if (kind === "request-usage") {
1285
+ handleUsage(payload);
1286
+ return;
1287
+ }
1288
+ if (kind === "tool-return" || kind === "on_call_tools_start") {
1289
+ resetCurrentMessage();
1290
+ return;
1291
+ }
1292
+ if (kind === "text") {
1293
+ const content = payload.content;
1294
+ if (typeof content === "string") {
1295
+ currentTextParts = [content];
1296
+ currentTextIndexes = /* @__PURE__ */ new Map();
1297
+ emitMessage();
1298
+ }
1299
+ return;
1300
+ }
1301
+ if (kind === "part_start") {
1302
+ const partStart = payload;
1303
+ if (typeof partStart.index === "number" && partStart.part?.part_kind === "text" && typeof partStart.part.content === "string") {
1304
+ const nextIndex = currentTextParts.push(partStart.part.content) - 1;
1305
+ currentTextIndexes.set(partStart.index, nextIndex);
1306
+ emitMessage();
1307
+ }
1308
+ return;
1309
+ }
1310
+ if (kind === "part_delta") {
1311
+ const partDelta = payload;
1312
+ if (typeof partDelta.index === "number" && partDelta.delta?.part_delta_kind === "text" && typeof partDelta.delta.content_delta === "string") {
1313
+ const textIndex = currentTextIndexes.get(partDelta.index);
1314
+ if (textIndex === void 0) {
1315
+ const nextIndex = currentTextParts.push(partDelta.delta.content_delta) - 1;
1316
+ currentTextIndexes.set(partDelta.index, nextIndex);
1317
+ } else currentTextParts[textIndex] += partDelta.delta.content_delta;
1318
+ emitMessage();
1319
+ }
1320
+ }
1321
+ };
1322
+ while (true) {
1323
+ let readResult;
1324
+ try {
1325
+ readResult = await reader.read();
1326
+ } catch (error) {
1327
+ if (isAbortError(error)) throw createAbortError();
1328
+ throw error;
1329
+ }
1330
+ if (readResult.done) break;
1331
+ const chunk = decoder.decode(readResult.value, { stream: true });
1332
+ logStream?.write(chunk);
1333
+ buffer += chunk;
1334
+ while (true) {
1335
+ const lfBoundary = buffer.indexOf("\n\n");
1336
+ const crlfBoundary = buffer.indexOf("\r\n\r\n");
1337
+ let boundary;
1338
+ let separatorLen;
1339
+ if (lfBoundary === -1 && crlfBoundary === -1) break;
1340
+ if (crlfBoundary !== -1 && (lfBoundary === -1 || crlfBoundary < lfBoundary)) {
1341
+ boundary = crlfBoundary;
1342
+ separatorLen = 4;
1343
+ } else {
1344
+ boundary = lfBoundary;
1345
+ separatorLen = 2;
1346
+ }
1347
+ const rawEvent = buffer.slice(0, boundary);
1348
+ buffer = buffer.slice(boundary + separatorLen);
1349
+ if (rawEvent.trim()) handleEvent(rawEvent);
1350
+ }
1351
+ }
1352
+ buffer += decoder.decode();
1353
+ if (buffer.trim()) handleEvent(buffer);
1354
+ const finalText = latestTextSegment.trim();
1355
+ if (!finalText) throw new Error("rovodev returned no text output");
1356
+ try {
1357
+ return {
1358
+ output: JSON.parse(finalText),
1359
+ usage
1360
+ };
1361
+ } catch (error) {
1362
+ throw new Error(`Failed to parse rovodev output: ${error instanceof Error ? error.message : String(error)}`);
1363
+ }
1364
+ }
1365
+ async shutdownServer() {
1366
+ if (!this.server || this.server.closed) {
1367
+ this.server = null;
1368
+ return;
1369
+ }
1370
+ if (this.closingPromise) {
1371
+ await this.closingPromise;
1372
+ return;
1373
+ }
1374
+ const server = this.server;
1375
+ const waitForClose = new Promise((resolve) => {
1376
+ if (server.closed) {
1377
+ resolve();
1378
+ return;
1379
+ }
1380
+ server.child.once("close", () => resolve());
1381
+ });
1382
+ try {
1383
+ this.signalServer(server, "SIGTERM");
1384
+ } catch {}
1385
+ const forceKill = new Promise((resolve) => {
1386
+ setTimeout(() => {
1387
+ if (!server.closed) try {
1388
+ this.signalServer(server, "SIGKILL");
1389
+ } catch {}
1390
+ resolve();
1391
+ }, 3e3).unref?.();
1392
+ });
1393
+ this.closingPromise = Promise.race([waitForClose, forceKill]).finally(() => {
1394
+ if (this.server === server) this.server = null;
1395
+ this.closingPromise = null;
1396
+ });
1397
+ await this.closingPromise;
1398
+ }
1399
+ signalServer(server, signal) {
1400
+ if (server.detached && server.child.pid) try {
1401
+ this.killProcessFn(-server.child.pid, signal);
1402
+ return;
1403
+ } catch {}
1404
+ server.child.kill(signal);
1405
+ }
1406
+ async requestJSON(server, path, options) {
1407
+ return await (await this.request(server, path, options)).json();
1408
+ }
1409
+ async request(server, path, options) {
1410
+ const headers = new Headers(options.headers);
1411
+ if (options.sessionId) headers.set("x-session-id", options.sessionId);
1412
+ if (options.body !== void 0 && !headers.has("content-type")) headers.set("content-type", "application/json");
1413
+ const signal = withTimeoutSignal(options.signal, options.timeoutMs);
1414
+ const response = await this.fetchFn(`${server.baseUrl}${path}`, {
1415
+ method: options.method,
1416
+ headers,
1417
+ body: options.body === void 0 ? void 0 : JSON.stringify(options.body),
1418
+ signal
1419
+ });
1420
+ if (!response.ok) {
1421
+ const body = await response.text();
1422
+ throw new Error(`rovodev ${options.method} ${path} failed with ${response.status}: ${body}`);
1423
+ }
1424
+ return response;
1425
+ }
1426
+ };
1427
+ function withTimeoutSignal(signal, timeoutMs) {
1428
+ if (timeoutMs === void 0) return signal;
1429
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
1430
+ return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
1431
+ }
1432
+ //#endregion
456
1433
  //#region src/core/agents/factory.ts
457
1434
  function createAgent(name, runInfo) {
458
1435
  switch (name) {
459
1436
  case "claude": return new ClaudeAgent();
460
1437
  case "codex": return new CodexAgent(runInfo.schemaPath);
1438
+ case "opencode": return new OpenCodeAgent();
1439
+ case "rovodev": return new RovoDevAgent(runInfo.schemaPath);
461
1440
  }
462
1441
  }
463
1442
  //#endregion
@@ -495,6 +1474,7 @@ var Orchestrator = class extends EventEmitter {
495
1474
  prompt;
496
1475
  limits;
497
1476
  stopRequested = false;
1477
+ stopPromise = null;
498
1478
  activeAbortController = null;
499
1479
  pendingAbortReason = null;
500
1480
  state = {
@@ -528,60 +1508,69 @@ var Orchestrator = class extends EventEmitter {
528
1508
  stop() {
529
1509
  this.stopRequested = true;
530
1510
  this.activeAbortController?.abort();
531
- resetHard(this.cwd);
532
- this.state.status = "stopped";
533
- this.emit("state", this.getState());
534
- this.emit("stopped");
1511
+ if (this.stopPromise) return;
1512
+ this.stopPromise = (async () => {
1513
+ await this.closeAgent();
1514
+ resetHard(this.cwd);
1515
+ this.state.status = "stopped";
1516
+ this.emit("state", this.getState());
1517
+ this.emit("stopped");
1518
+ })();
535
1519
  }
536
1520
  async start() {
537
1521
  this.state.startTime = /* @__PURE__ */ new Date();
538
1522
  this.state.status = "running";
539
1523
  this.emit("state", this.getState());
540
- while (!this.stopRequested) {
541
- const preIterationAbortReason = this.getPreIterationAbortReason();
542
- if (preIterationAbortReason) {
543
- this.abort(preIterationAbortReason);
544
- break;
545
- }
546
- this.state.currentIteration++;
547
- this.state.status = "running";
548
- this.emit("iteration:start", this.state.currentIteration);
549
- this.emit("state", this.getState());
550
- const iterationPrompt = buildIterationPrompt({
551
- n: this.state.currentIteration,
552
- runId: this.runInfo.runId,
553
- prompt: this.prompt
554
- });
555
- const result = await this.runIteration(iterationPrompt);
556
- if (result.type === "aborted") {
557
- this.abort(result.reason);
558
- break;
559
- }
560
- const { record } = result;
561
- this.state.iterations.push(record);
562
- this.emit("iteration:end", record);
563
- this.emit("state", this.getState());
564
- const postIterationAbortReason = this.getPostIterationAbortReason();
565
- if (postIterationAbortReason) {
566
- this.abort(postIterationAbortReason);
567
- break;
568
- }
569
- if (this.state.consecutiveFailures >= this.config.maxConsecutiveFailures) {
570
- this.abort(`${this.config.maxConsecutiveFailures} consecutive failures`);
571
- break;
572
- }
573
- if (this.state.consecutiveFailures > 0 && !this.stopRequested) {
574
- const backoffMs = 6e4 * Math.pow(2, this.state.consecutiveFailures - 1);
575
- this.state.status = "waiting";
576
- this.state.waitingUntil = new Date(Date.now() + backoffMs);
1524
+ try {
1525
+ while (!this.stopRequested) {
1526
+ const preIterationAbortReason = this.getPreIterationAbortReason();
1527
+ if (preIterationAbortReason) {
1528
+ this.abort(preIterationAbortReason);
1529
+ break;
1530
+ }
1531
+ this.state.currentIteration++;
1532
+ this.state.status = "running";
1533
+ this.emit("iteration:start", this.state.currentIteration);
577
1534
  this.emit("state", this.getState());
578
- await this.interruptibleSleep(backoffMs);
579
- this.state.waitingUntil = null;
580
- if (!this.stopRequested) {
581
- this.state.status = "running";
1535
+ const iterationPrompt = buildIterationPrompt({
1536
+ n: this.state.currentIteration,
1537
+ runId: this.runInfo.runId,
1538
+ prompt: this.prompt
1539
+ });
1540
+ const result = await this.runIteration(iterationPrompt);
1541
+ if (result.type === "aborted") {
1542
+ this.abort(result.reason);
1543
+ break;
1544
+ }
1545
+ const { record } = result;
1546
+ this.state.iterations.push(record);
1547
+ this.emit("iteration:end", record);
1548
+ this.emit("state", this.getState());
1549
+ const postIterationAbortReason = this.getPostIterationAbortReason();
1550
+ if (postIterationAbortReason) {
1551
+ this.abort(postIterationAbortReason);
1552
+ break;
1553
+ }
1554
+ if (this.state.consecutiveFailures >= this.config.maxConsecutiveFailures) {
1555
+ this.abort(`${this.config.maxConsecutiveFailures} consecutive failures`);
1556
+ break;
1557
+ }
1558
+ if (this.state.consecutiveFailures > 0 && !this.stopRequested) {
1559
+ const backoffMs = 6e4 * Math.pow(2, this.state.consecutiveFailures - 1);
1560
+ this.state.status = "waiting";
1561
+ this.state.waitingUntil = new Date(Date.now() + backoffMs);
582
1562
  this.emit("state", this.getState());
1563
+ await this.interruptibleSleep(backoffMs);
1564
+ this.state.waitingUntil = null;
1565
+ if (!this.stopRequested) {
1566
+ this.state.status = "running";
1567
+ this.emit("state", this.getState());
1568
+ }
583
1569
  }
584
1570
  }
1571
+ } finally {
1572
+ if (this.stopPromise) await this.stopPromise;
1573
+ else await this.closeAgent();
585
1574
  }
586
1575
  }
587
1576
  async runIteration(prompt) {
@@ -701,6 +1690,11 @@ var Orchestrator = class extends EventEmitter {
701
1690
  this.emit("abort", reason);
702
1691
  this.emit("state", this.getState());
703
1692
  }
1693
+ async closeAgent() {
1694
+ try {
1695
+ await this.agent.close?.();
1696
+ } catch {}
1697
+ }
704
1698
  };
705
1699
  //#endregion
706
1700
  //#region src/mock-orchestrator.ts
@@ -1030,9 +2024,17 @@ const MOON_PHASE_PERIOD = 1600;
1030
2024
  const MAX_MSG_LINES = 3;
1031
2025
  const MAX_MSG_LINE_LEN = 64;
1032
2026
  const RESUME_HINT = "[ctrl+c to stop, gnhf again to resume]";
1033
- function renderTitleCells() {
2027
+ function spacedLabel(text) {
2028
+ return text.split("").join(" ");
2029
+ }
2030
+ function renderTitleCells(agentName) {
1034
2031
  return [
1035
- textToCells("g n h f", "dim"),
2032
+ [...textToCells(spacedLabel("gnhf"), "dim"), ...agentName ? [
2033
+ ...textToCells(" ", "normal"),
2034
+ ...textToCells("·", "dim"),
2035
+ ...textToCells(" ", "normal"),
2036
+ ...textToCells(spacedLabel(agentName), "dim")
2037
+ ] : []],
1036
2038
  [],
1037
2039
  textToCells("┏━╸┏━┓┏━┓╺┳┓ ┏┓╻╻┏━╸╻ ╻╺┳╸ ╻ ╻┏━┓╻ ╻┏━╸ ┏━╸╻ ╻┏┓╻", "bold"),
1038
2040
  textToCells("┃╺┓┃ ┃┃ ┃ ┃┃ ┃┗┫┃┃╺┓┣━┫ ┃ ┣━┫┣━┫┃┏┛┣╸ ┣╸ ┃ ┃┃┗┫", "bold"),
@@ -1137,11 +2139,11 @@ function fitContentRows(contentRows, maxRows) {
1137
2139
  }
1138
2140
  return fitted.length > maxRows ? fitted.slice(fitted.length - maxRows) : fitted;
1139
2141
  }
1140
- function buildContentCells(prompt, state, elapsed, now) {
2142
+ function buildContentCells(prompt, agentName, state, elapsed, now) {
1141
2143
  const rows = [];
1142
2144
  const isRunning = state.status === "running" || state.status === "waiting";
1143
2145
  rows.push([]);
1144
- rows.push(...renderTitleCells());
2146
+ rows.push(...renderTitleCells(agentName));
1145
2147
  rows.push([], []);
1146
2148
  const promptLines = wordWrap(prompt, CONTENT_WIDTH, MAX_PROMPT_LINES);
1147
2149
  for (let i = 0; i < MAX_PROMPT_LINES; i++) {
@@ -1156,10 +2158,10 @@ function buildContentCells(prompt, state, elapsed, now) {
1156
2158
  rows.push(...renderMoonStripCells(state.iterations, isRunning, now));
1157
2159
  return rows;
1158
2160
  }
1159
- function buildFrameCells(prompt, state, topStars, bottomStars, sideStars, now, terminalWidth, terminalHeight) {
2161
+ function buildFrameCells(prompt, agentName, state, topStars, bottomStars, sideStars, now, terminalWidth, terminalHeight) {
1160
2162
  const elapsed = formatElapsed(now - state.startTime.getTime());
1161
2163
  const availableHeight = Math.max(0, terminalHeight - 2);
1162
- const contentRows = fitContentRows(buildContentCells(prompt, state, elapsed, now), availableHeight);
2164
+ const contentRows = fitContentRows(buildContentCells(prompt, agentName, state, elapsed, now), availableHeight);
1163
2165
  while (contentRows.length < Math.min(BASE_CONTENT_ROWS, availableHeight)) contentRows.push([]);
1164
2166
  const contentCount = contentRows.length;
1165
2167
  const remaining = Math.max(0, availableHeight - contentCount);
@@ -1186,6 +2188,7 @@ function buildFrameCells(prompt, state, topStars, bottomStars, sideStars, now, t
1186
2188
  var Renderer = class {
1187
2189
  orchestrator;
1188
2190
  prompt;
2191
+ agentName;
1189
2192
  state;
1190
2193
  interval = null;
1191
2194
  exitResolve;
@@ -1197,9 +2200,10 @@ var Renderer = class {
1197
2200
  cachedHeight = 0;
1198
2201
  prevCells = [];
1199
2202
  isFirstFrame = true;
1200
- constructor(orchestrator, prompt) {
2203
+ constructor(orchestrator, prompt, agentName) {
1201
2204
  this.orchestrator = orchestrator;
1202
2205
  this.prompt = prompt;
2206
+ this.agentName = agentName;
1203
2207
  this.state = orchestrator.getState();
1204
2208
  this.exitPromise = new Promise((resolve) => {
1205
2209
  this.exitResolve = resolve;
@@ -1219,7 +2223,10 @@ var Renderer = class {
1219
2223
  process$1.stdin.setRawMode(true);
1220
2224
  process$1.stdin.resume();
1221
2225
  process$1.stdin.on("data", (data) => {
1222
- if (data[0] === 3) this.orchestrator.stop();
2226
+ if (data[0] === 3) {
2227
+ this.stop();
2228
+ this.orchestrator.stop();
2229
+ }
1223
2230
  });
1224
2231
  }
1225
2232
  this.interval = setInterval(() => this.render(), TICK_MS);
@@ -1273,7 +2280,7 @@ var Renderer = class {
1273
2280
  const w = process$1.stdout.columns || 80;
1274
2281
  const h = process$1.stdout.rows || 24;
1275
2282
  const resized = this.ensureStarFields(w, h);
1276
- const nextCells = buildFrameCells(this.prompt, this.state, this.topStars, this.bottomStars, this.sideStars, now, w, h);
2283
+ const nextCells = buildFrameCells(this.prompt, this.agentName, this.state, this.topStars, this.bottomStars, this.sideStars, now, w, h);
1277
2284
  if (this.isFirstFrame || resized) {
1278
2285
  process$1.stdout.write("\x1B[H" + nextCells.map(rowToString).join("\n"));
1279
2286
  this.isFirstFrame = false;
@@ -1292,6 +2299,7 @@ function slugifyPrompt(prompt) {
1292
2299
  //#endregion
1293
2300
  //#region src/cli.ts
1294
2301
  const packageVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
2302
+ const FORCE_EXIT_TIMEOUT_MS = 5e3;
1295
2303
  function parseNonNegativeInteger(value) {
1296
2304
  if (!/^\d+$/.test(value)) throw new InvalidArgumentError("must be a non-negative integer");
1297
2305
  const parsed = Number.parseInt(value, 10);
@@ -1323,11 +2331,11 @@ function ask(question) {
1323
2331
  });
1324
2332
  }
1325
2333
  const program = new Command();
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) => {
2334
+ 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, codex, rovodev, or opencode)").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) => {
1327
2335
  if (options.mock) {
1328
2336
  const mock = new MockOrchestrator();
1329
2337
  enterAltScreen();
1330
- const renderer = new Renderer(mock, "let's minimize app startup latency without sacrificing any functionality");
2338
+ const renderer = new Renderer(mock, "let's minimize app startup latency without sacrificing any functionality", "claude");
1331
2339
  renderer.start();
1332
2340
  mock.start();
1333
2341
  await renderer.waitUntilExit();
@@ -1337,13 +2345,13 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
1337
2345
  let prompt = promptArg;
1338
2346
  if (!prompt && !process$1.stdin.isTTY) prompt = readFileSync("/dev/stdin", "utf-8").trim();
1339
2347
  const agentName = options.agent;
1340
- if (agentName !== void 0 && agentName !== "claude" && agentName !== "codex") {
1341
- console.error(`Unknown agent: ${options.agent}. Use "claude" or "codex".`);
2348
+ if (agentName !== void 0 && agentName !== "claude" && agentName !== "codex" && agentName !== "rovodev" && agentName !== "opencode") {
2349
+ console.error(`Unknown agent: ${options.agent}. Use "claude", "codex", "rovodev", or "opencode".`);
1342
2350
  process$1.exit(1);
1343
2351
  }
1344
2352
  const config = loadConfig(agentName ? { agent: agentName } : void 0);
1345
- if (config.agent !== "claude" && config.agent !== "codex") {
1346
- console.error(`Unknown agent: ${config.agent}. Use "claude" or "codex".`);
2353
+ if (config.agent !== "claude" && config.agent !== "codex" && config.agent !== "rovodev" && config.agent !== "opencode") {
2354
+ console.error(`Unknown agent: ${config.agent}. Use "claude", "codex", "rovodev", or "opencode".`);
1347
2355
  process$1.exit(1);
1348
2356
  }
1349
2357
  const cwd = process$1.cwd();
@@ -1379,15 +2387,22 @@ program.name("gnhf").description("Before I go to bed, I tell my agents: good nig
1379
2387
  maxTokens: options.maxTokens
1380
2388
  });
1381
2389
  enterAltScreen();
1382
- const renderer = new Renderer(orchestrator, prompt);
2390
+ const renderer = new Renderer(orchestrator, prompt, config.agent);
1383
2391
  renderer.start();
1384
- orchestrator.start().catch((err) => {
2392
+ const orchestratorPromise = orchestrator.start().finally(() => {
1385
2393
  renderer.stop();
2394
+ }).catch((err) => {
1386
2395
  exitAltScreen();
1387
2396
  die(err instanceof Error ? err.message : String(err));
1388
2397
  });
1389
2398
  await renderer.waitUntilExit();
1390
2399
  exitAltScreen();
2400
+ if (await Promise.race([orchestratorPromise.then(() => "done"), new Promise((resolve) => {
2401
+ setTimeout(() => resolve("timeout"), FORCE_EXIT_TIMEOUT_MS).unref();
2402
+ })]) === "timeout") {
2403
+ console.error(`\n gnhf: shutdown timed out after ${FORCE_EXIT_TIMEOUT_MS / 1e3}s, forcing exit\n`);
2404
+ process$1.exit(130);
2405
+ }
1391
2406
  });
1392
2407
  function enterAltScreen() {
1393
2408
  process$1.stdout.write("\x1B[?1049h");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gnhf",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Before I go to bed, I tell my agents: good night, have fun",
5
5
  "type": "module",
6
6
  "bin": {