@arvorco/relentless 0.3.0 → 0.4.2

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 (71) hide show
  1. package/.claude/commands/relentless.constitution.md +1 -1
  2. package/.claude/commands/relentless.convert.md +25 -0
  3. package/.claude/commands/relentless.specify.md +1 -1
  4. package/.claude/skills/analyze/SKILL.md +113 -40
  5. package/.claude/skills/analyze/templates/analysis-report.md +138 -0
  6. package/.claude/skills/checklist/SKILL.md +143 -51
  7. package/.claude/skills/checklist/templates/checklist.md +43 -11
  8. package/.claude/skills/clarify/SKILL.md +70 -11
  9. package/.claude/skills/constitution/SKILL.md +61 -3
  10. package/.claude/skills/constitution/templates/constitution.md +241 -160
  11. package/.claude/skills/constitution/templates/prompt.md +150 -20
  12. package/.claude/skills/convert/SKILL.md +248 -0
  13. package/.claude/skills/implement/SKILL.md +82 -34
  14. package/.claude/skills/plan/SKILL.md +136 -27
  15. package/.claude/skills/plan/templates/plan.md +92 -9
  16. package/.claude/skills/specify/SKILL.md +110 -19
  17. package/.claude/skills/specify/scripts/bash/create-new-feature.sh +2 -2
  18. package/.claude/skills/specify/scripts/bash/setup-plan.sh +1 -1
  19. package/.claude/skills/specify/templates/spec.md +40 -5
  20. package/.claude/skills/tasks/SKILL.md +75 -1
  21. package/.claude/skills/tasks/templates/tasks.md +5 -4
  22. package/CHANGELOG.md +63 -1
  23. package/MANUAL.md +40 -0
  24. package/README.md +263 -11
  25. package/bin/relentless.ts +292 -5
  26. package/package.json +2 -2
  27. package/relentless/config.json +46 -2
  28. package/relentless/constitution.md +2 -2
  29. package/relentless/prompt.md +97 -18
  30. package/src/agents/amp.ts +53 -13
  31. package/src/agents/claude.ts +70 -15
  32. package/src/agents/codex.ts +73 -14
  33. package/src/agents/droid.ts +68 -14
  34. package/src/agents/exec.ts +96 -0
  35. package/src/agents/gemini.ts +59 -16
  36. package/src/agents/opencode.ts +188 -9
  37. package/src/cli/fallback-order.ts +210 -0
  38. package/src/cli/index.ts +63 -0
  39. package/src/cli/mode-flag.ts +198 -0
  40. package/src/cli/review-flags.ts +192 -0
  41. package/src/config/loader.ts +16 -1
  42. package/src/config/schema.ts +157 -2
  43. package/src/execution/runner.ts +144 -21
  44. package/src/init/scaffolder.ts +285 -25
  45. package/src/prd/parser.ts +92 -1
  46. package/src/prd/types.ts +136 -0
  47. package/src/review/index.ts +92 -0
  48. package/src/review/prompt.ts +293 -0
  49. package/src/review/runner.ts +337 -0
  50. package/src/review/tasks/docs.ts +529 -0
  51. package/src/review/tasks/index.ts +80 -0
  52. package/src/review/tasks/lint.ts +436 -0
  53. package/src/review/tasks/quality.ts +760 -0
  54. package/src/review/tasks/security.ts +452 -0
  55. package/src/review/tasks/test.ts +456 -0
  56. package/src/review/tasks/typecheck.ts +323 -0
  57. package/src/review/types.ts +139 -0
  58. package/src/routing/cascade.ts +310 -0
  59. package/src/routing/classifier.ts +338 -0
  60. package/src/routing/estimate.ts +270 -0
  61. package/src/routing/fallback.ts +512 -0
  62. package/src/routing/index.ts +124 -0
  63. package/src/routing/registry.ts +501 -0
  64. package/src/routing/report.ts +570 -0
  65. package/src/routing/router.ts +287 -0
  66. package/src/tui/App.tsx +2 -0
  67. package/src/tui/TUIRunner.tsx +103 -8
  68. package/src/tui/components/CurrentStory.tsx +23 -1
  69. package/src/tui/hooks/useTUI.ts +1 -0
  70. package/src/tui/types.ts +9 -0
  71. package/.claude/skills/specify/scripts/bash/update-agent-context.sh +0 -799
package/src/agents/amp.ts CHANGED
@@ -1,11 +1,31 @@
1
1
  /**
2
2
  * Amp Agent Adapter
3
3
  *
4
- * Adapter for Sourcegraph's Amp CLI
4
+ * Adapter for Sourcegraph's Amp CLI with model/mode selection support.
5
5
  * https://ampcode.com
6
+ *
7
+ * ## Supported Modes
8
+ * - `free` - Free tier mode ($10/day grant, may have rate limits)
9
+ * - `smart` - Smart mode (uses Amp's intelligent model selection)
10
+ *
11
+ * ## Model Selection Method
12
+ * Amp uses the `-m`/`--mode` CLI flag for mode selection.
13
+ * Amp uses `-x`/`--execute` to run a single prompt non-interactively.
14
+ *
15
+ * ## Usage Example
16
+ * ```typescript
17
+ * const result = await ampAdapter.invoke("Fix the bug", {
18
+ * model: "free", // Uses -m free
19
+ * workingDirectory: "/path/to/project"
20
+ * });
21
+ * ```
22
+ *
23
+ * @module agents/amp
6
24
  */
7
25
 
8
26
  import type { AgentAdapter, AgentResult, InvokeOptions, RateLimitInfo } from "./types";
27
+ import { getModelById } from "../routing/registry";
28
+ import { runCommand } from "./exec";
9
29
 
10
30
  export const ampAdapter: AgentAdapter = {
11
31
  name: "amp",
@@ -37,31 +57,41 @@ export const ampAdapter: AgentAdapter = {
37
57
  },
38
58
 
39
59
  async invoke(prompt: string, options?: InvokeOptions): Promise<AgentResult> {
40
- const startTime = Date.now();
41
60
  const args: string[] = [];
42
61
 
43
62
  if (options?.dangerouslyAllowAll) {
44
63
  args.push("--dangerously-allow-all");
45
64
  }
46
65
 
47
- const proc = Bun.spawn(["amp", ...args], {
66
+ // Add mode flag if model is provided
67
+ if (options?.model) {
68
+ const modelProfile = getModelById(options.model);
69
+ const modeValue =
70
+ modelProfile?.harness === "amp" ? modelProfile.cliValue : options.model;
71
+ const modeFlag =
72
+ modelProfile?.harness === "amp" ? modelProfile.cliFlag : "-m";
73
+ args.push(modeFlag, modeValue);
74
+ }
75
+
76
+ // Execute single prompt in non-interactive mode
77
+ args.push("-x");
78
+
79
+ const result = await runCommand(["amp", ...args], {
48
80
  cwd: options?.workingDirectory,
49
81
  stdin: new Blob([prompt]),
50
- stdout: "pipe",
51
- stderr: "pipe",
82
+ timeoutMs: options?.timeout,
52
83
  });
53
84
 
54
- // Collect output
55
- const stdout = await new Response(proc.stdout).text();
56
- const stderr = await new Response(proc.stderr).text();
57
- const exitCode = await proc.exited;
58
-
59
- const output = stdout + (stderr ? `\n${stderr}` : "");
60
- const duration = Date.now() - startTime;
85
+ const timeoutNote =
86
+ result.timedOut && options?.timeout
87
+ ? `\n[Relentless] Idle timeout after ${options.timeout}ms.`
88
+ : "";
89
+ const output = result.stdout + (result.stderr ? `\n${result.stderr}` : "") + timeoutNote;
90
+ const duration = result.duration;
61
91
 
62
92
  return {
63
93
  output,
64
- exitCode,
94
+ exitCode: result.exitCode,
65
95
  isComplete: this.detectCompletion(output),
66
96
  duration,
67
97
  };
@@ -72,12 +102,22 @@ export const ampAdapter: AgentAdapter = {
72
102
  },
73
103
 
74
104
  detectRateLimit(output: string): RateLimitInfo {
105
+ if (output.includes("[Relentless] Idle timeout")) {
106
+ return {
107
+ limited: true,
108
+ message: "Amp idle timeout",
109
+ };
110
+ }
111
+
75
112
  // Amp rate limit patterns
76
113
  const patterns = [
77
114
  /quota exceeded/i,
78
115
  /limit reached/i,
79
116
  /rate limit/i,
80
117
  /too many requests/i,
118
+ /execute mode is not permitted/i,
119
+ /unexpected error inside amp cli/i,
120
+ /amp threads share --support/i,
81
121
  ];
82
122
 
83
123
  for (const pattern of patterns) {
@@ -1,11 +1,38 @@
1
1
  /**
2
2
  * Claude Code Agent Adapter
3
3
  *
4
- * Adapter for Anthropic's Claude Code CLI
4
+ * Adapter for Anthropic's Claude Code CLI.
5
5
  * https://docs.anthropic.com/claude-code
6
+ *
7
+ * ## Model Selection
8
+ *
9
+ * Claude Code supports model selection via the `--model` flag.
10
+ * Pass the model name in the `options.model` parameter.
11
+ *
12
+ * **Supported models:**
13
+ * - `opus-4.5` (claude-opus-4-5-20251101) - SOTA, best for code review and architecture
14
+ * - `sonnet-4.5` (claude-sonnet-4-5-20251020) - Balanced, good for daily coding
15
+ * - `haiku-4.5` (claude-haiku-4-5-20251001) - Fast and cheap, good for simple tasks
16
+ *
17
+ * These IDs map to the full Claude model identifiers in the CLI.
18
+ *
19
+ * **CLI command format:**
20
+ * ```
21
+ * claude --model <model> -p <prompt>
22
+ * ```
23
+ *
24
+ * @example
25
+ * ```typescript
26
+ * const result = await claudeAdapter.invoke("Fix the bug", {
27
+ * model: "opus-4.5",
28
+ * workingDirectory: "/path/to/project"
29
+ * });
30
+ * ```
6
31
  */
7
32
 
8
33
  import type { AgentAdapter, AgentResult, InvokeOptions, RateLimitInfo } from "./types.js";
34
+ import { getModelById } from "../routing/registry";
35
+ import { runCommand } from "./exec";
9
36
 
10
37
  export const claudeAdapter: AgentAdapter = {
11
38
  name: "claude",
@@ -37,7 +64,6 @@ export const claudeAdapter: AgentAdapter = {
37
64
  },
38
65
 
39
66
  async invoke(prompt: string, options?: InvokeOptions): Promise<AgentResult> {
40
- const startTime = Date.now();
41
67
  const args = ["-p"];
42
68
 
43
69
  if (options?.dangerouslyAllowAll) {
@@ -45,27 +71,30 @@ export const claudeAdapter: AgentAdapter = {
45
71
  }
46
72
 
47
73
  if (options?.model) {
48
- args.push("--model", options.model);
74
+ const modelProfile = getModelById(options.model);
75
+ if (modelProfile?.harness === "claude") {
76
+ args.push("--model", modelProfile.cliValue);
77
+ } else {
78
+ args.push("--model", options.model);
79
+ }
49
80
  }
50
81
 
51
- const proc = Bun.spawn(["claude", ...args], {
82
+ const result = await runCommand(["claude", ...args], {
52
83
  cwd: options?.workingDirectory,
53
84
  stdin: new Blob([prompt]),
54
- stdout: "pipe",
55
- stderr: "pipe",
85
+ timeoutMs: options?.timeout,
56
86
  });
57
87
 
58
- // Collect output
59
- const stdout = await new Response(proc.stdout).text();
60
- const stderr = await new Response(proc.stderr).text();
61
- const exitCode = await proc.exited;
62
-
63
- const output = stdout + (stderr ? `\n${stderr}` : "");
64
- const duration = Date.now() - startTime;
88
+ const timeoutNote =
89
+ result.timedOut && options?.timeout
90
+ ? `\n[Relentless] Idle timeout after ${options.timeout}ms.`
91
+ : "";
92
+ const output = result.stdout + (result.stderr ? `\n${result.stderr}` : "") + timeoutNote;
93
+ const duration = result.duration;
65
94
 
66
95
  return {
67
96
  output,
68
- exitCode,
97
+ exitCode: result.exitCode,
69
98
  isComplete: this.detectCompletion(output),
70
99
  duration,
71
100
  };
@@ -83,7 +112,12 @@ export const claudeAdapter: AgentAdapter = {
83
112
  }
84
113
 
85
114
  if (options?.model) {
86
- args.push("--model", options.model);
115
+ const modelProfile = getModelById(options.model);
116
+ if (modelProfile?.harness === "claude") {
117
+ args.push("--model", modelProfile.cliValue);
118
+ } else {
119
+ args.push("--model", options.model);
120
+ }
87
121
  }
88
122
 
89
123
  const proc = Bun.spawn(["claude", ...args], {
@@ -130,6 +164,27 @@ export const claudeAdapter: AgentAdapter = {
130
164
  },
131
165
 
132
166
  detectRateLimit(output: string): RateLimitInfo {
167
+ if (output.includes("[Relentless] Idle timeout")) {
168
+ return {
169
+ limited: true,
170
+ message: "Claude idle timeout",
171
+ };
172
+ }
173
+
174
+ if (/(?:operation not permitted|permission denied|\beperm\b).*(?:\/\.claude|\.claude)/i.test(output)) {
175
+ return {
176
+ limited: true,
177
+ message: "Claude unavailable due to permission error",
178
+ };
179
+ }
180
+
181
+ if (/not_found_error/i.test(output) && /model/i.test(output)) {
182
+ return {
183
+ limited: true,
184
+ message: "Claude model not found",
185
+ };
186
+ }
187
+
133
188
  // Pattern: "You've hit your limit · resets 12am (America/Sao_Paulo)"
134
189
  if (output.includes("You've hit your limit") || output.includes("you've hit your limit")) {
135
190
  const resetMatch = output.match(/resets\s+(\d{1,2})(am|pm)/i);
@@ -1,11 +1,39 @@
1
1
  /**
2
2
  * Codex Agent Adapter
3
3
  *
4
- * Adapter for OpenAI's Codex CLI
4
+ * Adapter for OpenAI's Codex CLI.
5
5
  * https://developers.openai.com/codex/cli/
6
+ *
7
+ * ## Model Selection
8
+ *
9
+ * Codex supports model selection via the `--model` flag.
10
+ * Pass the model name in the `options.model` parameter.
11
+ *
12
+ * **Supported models (GPT-5.2 reasoning tiers):**
13
+ * - `gpt-5.2-xhigh` - reasoning-effort xhigh (~$1.75/$14 per MTok)
14
+ * - `gpt-5.2-high` - reasoning-effort high (~$1.75/$14 per MTok)
15
+ * - `gpt-5.2-medium` - reasoning-effort medium (~$1.25/$10 per MTok)
16
+ * - `gpt-5.2-low` - reasoning-effort low (~$0.75/$6 per MTok)
17
+ *
18
+ * These IDs map to `--model gpt-5.2 -c reasoning_effort="<tier>"` in the CLI.
19
+ *
20
+ * **CLI command format:**
21
+ * ```
22
+ * codex exec --model gpt-5.2 -c reasoning_effort="<low|medium|high|xhigh>" -
23
+ * ```
24
+ *
25
+ * @example
26
+ * ```typescript
27
+ * const result = await codexAdapter.invoke("Fix the bug", {
28
+ * model: "gpt-5.2-high",
29
+ * workingDirectory: "/path/to/project"
30
+ * });
31
+ * ```
6
32
  */
7
33
 
8
34
  import type { AgentAdapter, AgentResult, InvokeOptions, RateLimitInfo } from "./types";
35
+ import { getModelById } from "../routing/registry";
36
+ import { runCommand } from "./exec";
9
37
 
10
38
  export const codexAdapter: AgentAdapter = {
11
39
  name: "codex",
@@ -37,27 +65,40 @@ export const codexAdapter: AgentAdapter = {
37
65
  },
38
66
 
39
67
  async invoke(prompt: string, options?: InvokeOptions): Promise<AgentResult> {
40
- const startTime = Date.now();
68
+ const args = ["exec"];
69
+
70
+ // Add model selection if specified
71
+ if (options?.model) {
72
+ const modelProfile = getModelById(options.model);
73
+ if (modelProfile?.harness === "codex") {
74
+ args.push(modelProfile.cliFlag, modelProfile.cliValue);
75
+ if (modelProfile.cliArgs) {
76
+ args.push(...modelProfile.cliArgs);
77
+ }
78
+ } else {
79
+ args.push("--model", options.model);
80
+ }
81
+ }
82
+
83
+ // Codex uses `-` to read from stdin
84
+ args.push("-");
41
85
 
42
- // Codex uses `codex exec -` to read from stdin
43
- const proc = Bun.spawn(["codex", "exec", "-"], {
86
+ const result = await runCommand(["codex", ...args], {
44
87
  cwd: options?.workingDirectory,
45
88
  stdin: new Blob([prompt]),
46
- stdout: "pipe",
47
- stderr: "pipe",
89
+ timeoutMs: options?.timeout,
48
90
  });
49
91
 
50
- // Collect output
51
- const stdout = await new Response(proc.stdout).text();
52
- const stderr = await new Response(proc.stderr).text();
53
- const exitCode = await proc.exited;
54
-
55
- const output = stdout + (stderr ? `\n${stderr}` : "");
56
- const duration = Date.now() - startTime;
92
+ const timeoutNote =
93
+ result.timedOut && options?.timeout
94
+ ? `\n[Relentless] Idle timeout after ${options.timeout}ms.`
95
+ : "";
96
+ const output = result.stdout + (result.stderr ? `\n${result.stderr}` : "") + timeoutNote;
97
+ const duration = result.duration;
57
98
 
58
99
  return {
59
100
  output,
60
- exitCode,
101
+ exitCode: result.exitCode,
61
102
  isComplete: this.detectCompletion(output),
62
103
  duration,
63
104
  };
@@ -68,6 +109,24 @@ export const codexAdapter: AgentAdapter = {
68
109
  },
69
110
 
70
111
  detectRateLimit(output: string): RateLimitInfo {
112
+ if (output.includes("[Relentless] Idle timeout")) {
113
+ return {
114
+ limited: true,
115
+ message: "Codex idle timeout",
116
+ };
117
+ }
118
+
119
+ if (
120
+ /cannot access session files/i.test(output) ||
121
+ /failed to create session/i.test(output) ||
122
+ /(permission denied|operation not permitted).*\/\.codex\/sessions/i.test(output)
123
+ ) {
124
+ return {
125
+ limited: true,
126
+ message: "Codex unavailable due to session permissions",
127
+ };
128
+ }
129
+
71
130
  // Codex/OpenAI rate limit patterns
72
131
  const patterns = [
73
132
  /rate limit exceeded/i,
@@ -1,11 +1,41 @@
1
1
  /**
2
2
  * Droid Agent Adapter
3
3
  *
4
- * Adapter for Factory's Droid CLI
4
+ * Adapter for Factory's Droid CLI.
5
5
  * https://factory.ai
6
+ *
7
+ * ## Model Selection
8
+ *
9
+ * Droid supports model selection via the `-m` flag (short form).
10
+ * Pass the model name in the `options.model` parameter.
11
+ *
12
+ * **Supported models:**
13
+ * - `claude-opus-4-5-20251101` - Claude Opus 4.5
14
+ * - `claude-sonnet-4-5-20250929` - Claude Sonnet 4.5
15
+ * - `claude-haiku-4-5-20251001` - Claude Haiku 4.5
16
+ * - `gpt-5.2` - GPT-5.2
17
+ * - `gpt-5.1` - GPT-5.1
18
+ * - `gpt-5.1-codex` - GPT-5.1 Codex
19
+ * - `gpt-5.1-codex-max` - GPT-5.1 Codex Max
20
+ * - `gemini-3-pro-preview` - Gemini 3 Pro Preview
21
+ *
22
+ * **CLI command format:**
23
+ * ```
24
+ * droid exec -m <model> --reasoning-effort <level> --auto high
25
+ * ```
26
+ *
27
+ * @example
28
+ * ```typescript
29
+ * const result = await droidAdapter.invoke("Fix the bug", {
30
+ * model: "gpt-5.2",
31
+ * workingDirectory: "/path/to/project"
32
+ * });
33
+ * ```
6
34
  */
7
35
 
8
36
  import type { AgentAdapter, AgentResult, InvokeOptions, RateLimitInfo } from "./types";
37
+ import { getModelById } from "../routing/registry";
38
+ import { runCommand } from "./exec";
9
39
 
10
40
  export const droidAdapter: AgentAdapter = {
11
41
  name: "droid",
@@ -37,28 +67,38 @@ export const droidAdapter: AgentAdapter = {
37
67
  },
38
68
 
39
69
  async invoke(prompt: string, options?: InvokeOptions): Promise<AgentResult> {
40
- const startTime = Date.now();
70
+ // Build command args: droid exec [-m <model>] --auto high
71
+ const args = ["exec"];
72
+
73
+ if (options?.model) {
74
+ const modelProfile = getModelById(options.model);
75
+ const modelValue =
76
+ modelProfile?.harness === "droid" ? modelProfile.cliValue : options.model;
77
+ args.push("-m", modelValue);
78
+ if (modelProfile?.harness === "droid" && modelProfile.cliArgs) {
79
+ args.push(...modelProfile.cliArgs);
80
+ }
81
+ }
41
82
 
42
- // Droid reads from stdin when piped or redirected.
43
83
  // Use --auto high for high risk tolerance by default
44
- const proc = Bun.spawn(["droid", "exec", "--auto", "high"], {
84
+ args.push("--auto", "high");
85
+
86
+ const result = await runCommand(["droid", ...args], {
45
87
  cwd: options?.workingDirectory,
46
88
  stdin: new Blob([prompt]),
47
- stdout: "pipe",
48
- stderr: "pipe",
89
+ timeoutMs: options?.timeout,
49
90
  });
50
91
 
51
- // Collect output
52
- const stdout = await new Response(proc.stdout).text();
53
- const stderr = await new Response(proc.stderr).text();
54
- const exitCode = await proc.exited;
55
-
56
- const output = stdout + (stderr ? `\n${stderr}` : "");
57
- const duration = Date.now() - startTime;
92
+ const timeoutNote =
93
+ result.timedOut && options?.timeout
94
+ ? `\n[Relentless] Idle timeout after ${options.timeout}ms.`
95
+ : "";
96
+ const output = result.stdout + (result.stderr ? `\n${result.stderr}` : "") + timeoutNote;
97
+ const duration = result.duration;
58
98
 
59
99
  return {
60
100
  output,
61
- exitCode,
101
+ exitCode: result.exitCode,
62
102
  isComplete: this.detectCompletion(output),
63
103
  duration,
64
104
  };
@@ -69,6 +109,20 @@ export const droidAdapter: AgentAdapter = {
69
109
  },
70
110
 
71
111
  detectRateLimit(output: string): RateLimitInfo {
112
+ if (output.includes("[Relentless] Idle timeout")) {
113
+ return {
114
+ limited: true,
115
+ message: "Droid idle timeout",
116
+ };
117
+ }
118
+
119
+ if (/mcp start failed/i.test(output) || /error reloading mcp servers/i.test(output)) {
120
+ return {
121
+ limited: true,
122
+ message: "Droid unavailable due to MCP initialization failure",
123
+ };
124
+ }
125
+
72
126
  // Droid rate limit patterns
73
127
  const patterns = [
74
128
  /rate limit/i,
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Shared command runner with idle timeout support for agent adapters.
3
+ */
4
+
5
+ export interface RunCommandOptions {
6
+ cwd?: string;
7
+ stdin?: Blob;
8
+ timeoutMs?: number;
9
+ }
10
+
11
+ export interface RunCommandResult {
12
+ stdout: string;
13
+ stderr: string;
14
+ exitCode: number;
15
+ duration: number;
16
+ timedOut: boolean;
17
+ }
18
+
19
+ async function readStream(
20
+ stream: ReadableStream<Uint8Array>,
21
+ onChunk: () => void
22
+ ): Promise<string> {
23
+ const reader = stream.getReader();
24
+ const decoder = new TextDecoder();
25
+ let output = "";
26
+
27
+ try {
28
+ while (true) {
29
+ const { done, value } = await reader.read();
30
+ if (done) break;
31
+ if (value) {
32
+ const chunk = decoder.decode(value, { stream: true });
33
+ if (chunk) {
34
+ output += chunk;
35
+ onChunk();
36
+ }
37
+ }
38
+ }
39
+ } finally {
40
+ reader.releaseLock();
41
+ }
42
+
43
+ return output;
44
+ }
45
+
46
+ export async function runCommand(
47
+ command: string[],
48
+ options: RunCommandOptions = {}
49
+ ): Promise<RunCommandResult> {
50
+ const startTime = Date.now();
51
+ const proc = Bun.spawn(command, {
52
+ cwd: options.cwd,
53
+ stdin: options.stdin,
54
+ stdout: "pipe",
55
+ stderr: "pipe",
56
+ });
57
+
58
+ let lastOutput = Date.now();
59
+ let timedOut = false;
60
+ let idleTimer: ReturnType<typeof setInterval> | undefined;
61
+
62
+ const onChunk = () => {
63
+ lastOutput = Date.now();
64
+ };
65
+
66
+ if (options.timeoutMs && options.timeoutMs > 0) {
67
+ idleTimer = setInterval(() => {
68
+ if (Date.now() - lastOutput > options.timeoutMs!) {
69
+ timedOut = true;
70
+ try {
71
+ proc.kill();
72
+ } catch {
73
+ // Best-effort kill on timeout.
74
+ }
75
+ }
76
+ }, 500);
77
+ }
78
+
79
+ const [stdout, stderr] = await Promise.all([
80
+ readStream(proc.stdout, onChunk),
81
+ readStream(proc.stderr, onChunk),
82
+ ]);
83
+ const exitCode = await proc.exited;
84
+
85
+ if (idleTimer) {
86
+ clearInterval(idleTimer);
87
+ }
88
+
89
+ return {
90
+ stdout,
91
+ stderr,
92
+ exitCode,
93
+ duration: Date.now() - startTime,
94
+ timedOut,
95
+ };
96
+ }
@@ -1,11 +1,33 @@
1
1
  /**
2
2
  * Gemini Agent Adapter
3
3
  *
4
- * Adapter for Google's Gemini CLI
4
+ * Adapter for Google's Gemini CLI with model selection support.
5
5
  * https://github.com/google-gemini/gemini-cli
6
+ *
7
+ * ## Supported Models
8
+ * - `gemini-3-pro` - Gemini 3 Pro (best for complex reasoning and coding tasks)
9
+ * - `gemini-3-flash` - Gemini 3 Flash (faster, more cost-effective for simpler tasks)
10
+ *
11
+ * ## CLI Command Format
12
+ * With model: `gemini --model <model> --prompt "<prompt>"`
13
+ * Without model: `gemini --prompt "<prompt>"`
14
+ * With dangerous mode: `gemini --yolo --model <model> --prompt "<prompt>"`
15
+ *
16
+ * ## Usage Example
17
+ * ```typescript
18
+ * const result = await geminiAdapter.invoke("Fix the bug", {
19
+ * model: "gemini-3-pro",
20
+ * dangerouslyAllowAll: true, // Sets --yolo flag
21
+ * workingDirectory: "/path/to/project"
22
+ * });
23
+ * ```
24
+ *
25
+ * @module agents/gemini
6
26
  */
7
27
 
8
28
  import type { AgentAdapter, AgentResult, InvokeOptions, RateLimitInfo } from "./types";
29
+ import { getModelById } from "../routing/registry";
30
+ import { runCommand } from "./exec";
9
31
 
10
32
  export const geminiAdapter: AgentAdapter = {
11
33
  name: "gemini",
@@ -37,7 +59,6 @@ export const geminiAdapter: AgentAdapter = {
37
59
  },
38
60
 
39
61
  async invoke(prompt: string, options?: InvokeOptions): Promise<AgentResult> {
40
- const startTime = Date.now();
41
62
  const args: string[] = [];
42
63
 
43
64
  if (options?.dangerouslyAllowAll) {
@@ -45,29 +66,35 @@ export const geminiAdapter: AgentAdapter = {
45
66
  }
46
67
 
47
68
  if (options?.model) {
48
- args.push("--model", options.model);
69
+ const modelProfile = getModelById(options.model);
70
+ if (modelProfile?.harness === "gemini") {
71
+ args.push("--model", modelProfile.cliValue);
72
+ if (modelProfile.cliArgs) {
73
+ args.push(...modelProfile.cliArgs);
74
+ }
75
+ } else {
76
+ args.push("--model", options.model);
77
+ }
49
78
  }
50
79
 
51
- // Gemini CLI accepts prompt as positional argument
52
- args.push(prompt);
80
+ // Gemini CLI uses -p/--prompt for non-interactive prompts
81
+ args.push("--prompt", prompt);
53
82
 
54
- const proc = Bun.spawn(["gemini", ...args], {
83
+ const result = await runCommand(["gemini", ...args], {
55
84
  cwd: options?.workingDirectory,
56
- stdout: "pipe",
57
- stderr: "pipe",
85
+ timeoutMs: options?.timeout,
58
86
  });
59
87
 
60
- // Collect output
61
- const stdout = await new Response(proc.stdout).text();
62
- const stderr = await new Response(proc.stderr).text();
63
- const exitCode = await proc.exited;
64
-
65
- const output = stdout + (stderr ? `\n${stderr}` : "");
66
- const duration = Date.now() - startTime;
88
+ const timeoutNote =
89
+ result.timedOut && options?.timeout
90
+ ? `\n[Relentless] Idle timeout after ${options.timeout}ms.`
91
+ : "";
92
+ const output = result.stdout + (result.stderr ? `\n${result.stderr}` : "") + timeoutNote;
93
+ const duration = result.duration;
67
94
 
68
95
  return {
69
96
  output,
70
- exitCode,
97
+ exitCode: result.exitCode,
71
98
  isComplete: this.detectCompletion(output),
72
99
  duration,
73
100
  };
@@ -78,6 +105,13 @@ export const geminiAdapter: AgentAdapter = {
78
105
  },
79
106
 
80
107
  detectRateLimit(output: string): RateLimitInfo {
108
+ if (output.includes("[Relentless] Idle timeout")) {
109
+ return {
110
+ limited: true,
111
+ message: "Gemini idle timeout",
112
+ };
113
+ }
114
+
81
115
  // Gemini rate limit patterns
82
116
  const patterns = [
83
117
  /quota exceeded/i,
@@ -85,6 +119,15 @@ export const geminiAdapter: AgentAdapter = {
85
119
  /rate limit/i,
86
120
  /\b429\b/,
87
121
  /too many requests/i,
122
+ /operation not permitted/i,
123
+ /\beperm\b/i,
124
+ /error initializing chat recording service/i,
125
+ /err_module_not_found/i,
126
+ /cannot find package/i,
127
+ /data collection is disabled/i,
128
+ /gemini_api_key/i,
129
+ /google_genai_use_vertexai/i,
130
+ /google_genai_use_gca/i,
88
131
  ];
89
132
 
90
133
  for (const pattern of patterns) {