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.
- package/README.md +21 -8
- package/dist/cli.mjs +1078 -63
- 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
|
|
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
|
|
145
|
-
| ---------------------- |
|
|
146
|
-
| `--agent <agent>` | Agent to use (`claude` or `
|
|
147
|
-
| `--max-iterations <n>` | Abort after `n` total iterations
|
|
148
|
-
| `--max-tokens <n>` | Abort after `n` total input+output tokens
|
|
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
|
-
|
|
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
|
-
|
|
532
|
-
this.
|
|
533
|
-
|
|
534
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
this.
|
|
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
|
|
2027
|
+
function spacedLabel(text) {
|
|
2028
|
+
return text.split("").join(" ");
|
|
2029
|
+
}
|
|
2030
|
+
function renderTitleCells(agentName) {
|
|
1034
2031
|
return [
|
|
1035
|
-
textToCells("
|
|
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)
|
|
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
|
|
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 "
|
|
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 "
|
|
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().
|
|
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");
|