gnhf 0.1.6 → 0.1.7

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